diff --git a/docs/FLOWS.md b/docs/FLOWS.md index c98e1950..5e287a7d 100644 --- a/docs/FLOWS.md +++ b/docs/FLOWS.md @@ -626,18 +626,24 @@ Multi-engine search backends (webSearch): ``` -## Deep Agents Orchestration Flow ## Deep Agents Orchestration Flow **Entry:** `src/agent/deepAgents.js` → `createDeepAgentsOrchestrator()` ``` Deep Agents orchestrator (native multi-agent architecture): -├── createDeepAgent({ model, systemPrompt, tools, middleware, subagents, checkpointer }) +├── createDeepAgent({ model, systemPrompt, tools, middleware, subagents, checkpointer, backend }) +│ ├── backend: CompositeBackend(coreBackend, { +│ │ │ "/memory/context/": contextBackend, +│ │ │ "/tmp": dmzBackend +│ │ │ }) +│ │ ├── coreBackend: FilesystemBackend({ rootDir: process.cwd(), virtualMode: true }) +│ │ ├── contextBackend: FilesystemBackend({ rootDir: memory/context/, virtualMode: true }) +│ │ └── dmzBackend: FilesystemBackend({ rootDir: /tmp, virtualMode: true }) │ ├── middleware: filesystem, memory, skills, summarization │ ├── subagents: │ │ ├── coding-agent: code editing, debugging, implementation, code review -│ └── orchestrator routes tasks automatically based on task nature +│ │ └── orchestrator routes tasks automatically based on task nature ├── agent.stream(input, { streamMode: "messages", subgraphs: true }) │ ├── for each chunk: │ │ ├── extract text content @@ -649,6 +655,107 @@ No process spawning, no marker-based parsing, no manual fan-out coordination. The deepagents library handles agent lifecycle, state management, and streaming internally. ``` +## Backend Routing Flow + +**Entry:** `src/agent/backends/coreBackend.js`, `contextBackend.js`, `dmzBackend.js` → `CompositeBackend` + +The `CompositeBackend` routes file operations to different backends based on path prefix matching. + +``` +CompositeBackend routing: +├── Constructor: +│ ├── defaultBackend: coreBackend (process.cwd(), virtualMode: true) +│ └── routes: { +│ │ "/memory/context/": contextBackend, +│ │ "/tmp": dmzBackend +│ └── } +│ +├── Route matching (longest prefix first): +│ ├── "/memory/context/profile.md" → contextBackend (stripped: "/profile.md") +│ ├── "/tmp/sessions/abc.md" → dmzBackend (stripped: "/sessions/abc.md") +│ └── "/package.json" → coreBackend (default, no route match) +│ +├── FilesystemBackend virtualMode: +│ ├── Incoming path: "/src/tools/index.js" +│ ├── Strip leading "/": "src/tools/index.js" +│ ├── Resolve relative to rootDir: path.resolve(rootDir, "src/tools/index.js") +│ ├── Validate: resolved.startsWith(rootDir + "/") +│ └── Return virtual path: "/src/tools/index.js" +│ +└── ls("/") special case: + ├── Returns files from default backend at "/" + ├── Route prefixes as directory entries (is_dir: true) + └── Sorted together by path +``` + +**Virtual Path Convention:** + +All file paths in the agent's view are virtual paths starting with `/`. The `/` root maps to the application's working directory. When the agent reads `/package.json`, it resolves to `/package.json`. When it writes `/src/tools/index.js`, it resolves to `/src/tools/index.js`. + +**Security:** + +``` +allPathsScopedToRoutes(permissions, backend): +├── if !CompositeBackend.isInstance(backend) → false +├── prefixes = backend.routePrefixes +├── if prefixes.length === 0 → false +└── permissions.every(rule => + rule.paths.every(path => + prefixes.some(prefix => + path.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`) + ) + ) + ) +``` + +This prevents shell commands from bypassing path-based permission rules when using CompositeBackend. + +--- + +## File Tool Execution Flow + +**Entry:** `src/tools/code.js` (read_file, write_file, patch, search_files) + +``` +read_file: +├── validatePath(input.path, allowedPaths) +│ └── resolvePath(file, dirs) → { allowed: true/false, path } +│ └── In virtualMode: path.resolve(rootDir, key.substring(1)) +│ └── Validates: resolved.startsWith(rootDir + "/") +├── checkFileLimit(resolved.path, maxReadSize) +│ └── access(file) → stat(file) → compare size vs maxReadBytes +│ └── parseSizeString("1mb") → 1048576 +├── readFile(resolved.path, "utf-8") +├── if ENOENT → suggestSimilarFile(path) → Levenshtein distance ≤ 2 +└── lines.map((line, i) => `${i+1}|${line}`).join("\n") + +write_file: +├── validatePath(input.path, allowedPaths) +├── if input.content.length > MAX_CONTENT_SIZE (500KB) → error +├── mkdir(dirname(resolved.path), recursive) +└── writeFile(resolved.path, content, "utf-8") + +patch: +├── validatePath(input.path, allowedPaths) +├── readFile(resolved.path) → content +├── fuzzyMatch(input.oldStr, content) +│ └── 9 strategies: exact, line-exact, trim-trailing/leading, collapse-whitespace, +│ case-insensitive, normalize-newlines, normalize-tabs, loose-substring +├── if no match → suggest Levenshtein line matches +└── content = content.slice(0,match.start) + input.newStr + content.slice(match.end) + → writeFile → return with unified diff + +search_files: +├── validatePath(input.path, allowedPaths) +├── execFile("rg", ["--line-number", "--no-heading", "-n", pattern, resolved.path], timeout: 10s) +└── if ENOENT (no ripgrep) → nativeSearch(pattern, resolved.path, maxResults) + └── walk() → readdir → stat → readFile → regex test line by line +``` + +--- + +## Deep Agents Orchestration Flow + **Entry:** `src/tools/scanAgents.js` → `createScanAgentsTool()` ``` @@ -1094,37 +1201,3 @@ index.js ├── session/onboarding.js → session/stateManager.js — onboarding state machine (INIT → ATTRACTOR → COLLECT → SAVE → TRANSCEND) └── telemetry/provider.js → @opentelemetry/sdk-node ``` -I TTS API -│ ├── tools/moa.js → OPENROUTER_API_KEY — mixture-of-agents (4 parallel OpenRouter calls + aggregation) -│ ├── tools/cron.js → node:fs/promises — cron job CRUD operations -│ ├── tools/compactContext.js → @langchain/core, zod — automatic conversation context compaction on LLM 400 errors (tiered retention, retry loop, error detection) -│ └── tools/... -│ └── tools/... -├── sandbox/pathResolver.js → node:path -├── sandbox/urlFilter.js → node:url -├── sandbox/runner.js → node:child_process, sandbox/timeoutHandler.js, envInjector.js, capability.js -├── registry/registry.js → discoverer.js, validator.js -├── registry/discoverer.js → js-yaml, node:fs, node:path -├── registry/validator.js → types.js (zod schemas) -├── registry/types.js → zod -├── scheduler/scheduler.js → node:fs/promises — ScheduleManager CRUD class (register, list, pause, resume, runNow) -├── scheduler/cron.js → node:child_process, node:fs/promises, node:path — Cron object (isAvailable, add, remove) -├── scheduler/autoSchedule.js → node:fs — setupAutoSchedule() callback for reflection-daily cron -├── scheduler/index.js → re-exports ScheduleManager and Cron -├── memory/writer.js → node:fs, node:path -├── memory/reader.js → js-yaml, node:fs -├── memory/context.js → node:fs, node:path, memory/reader.js -├── memory/retention.js → node:fs, node:path -├── memory/prompts.js → node:fs -├── memory/profile.js → node:fs — user profile management: ATTRIBUTES, loadProfile, saveProfile, formatProfileContext, processOnboardingInput -├── session/factory.js → node:crypto (randomUUID) -├── session/stateManager.js -├── session/checkpointer.js → @langchain/langgraph, @langchain/langgraph-checkpoint-sqlite — createCheckpointer() returns MemorySaver (in-memory) or SQLiteCheckpointer (persistent) -├── session/loader.js → fs, path, memory/reader.js -├── session/saver.js → fs, path, memory/writer.js -├── session/onboarding.js → session/stateManager.js — onboarding state machine (INIT → ATTRACTOR → COLLECT → SAVE → TRANSCEND) -└── telemetry/provider.js → @opentelemetry/sdk-node -``` - → SAVE → TRANSCEND) -└── telemetry/provider.js → @opentelemetry/sdk-node -``` diff --git a/docs/OVERVIEW.md b/docs/OVERVIEW.md index 5320ad23..843891d1 100644 --- a/docs/OVERVIEW.md +++ b/docs/OVERVIEW.md @@ -1,6 +1,6 @@ # Architecture Overview -This document describes how madz is structured, how subsystems interact, and the key data flows through them. It covers the runtime components — not how to configure or contribute code (see [README.md](../README.md) and [CODE_STYLE.md](./CODE_STYLE.md)). +This document describes how madz is structured, how subsystems interact, and the key data flows through them. It covers the runtime components — not how to configure or contribute code. --- @@ -16,8 +16,10 @@ graph TD DA -->|"orchestrator + shared"| OT["Orchestrator Tools"] DA -->|"subagent + shared"| ST["Subagent Tools"] DA -->|"model"| P["Provider"] - DA -->|"core FS"| CB["Core Backend\nFilesystemBackend"] - DA -->|"context FS"| CTB["Context Backend\nFilesystemBackend"] + DA -->|"backend"| CB["CompositeBackend"] + CB -->|"default"| CFB["Core Backend\nFilesystemBackend\n(rootDir: process.cwd())"] + CB -->|"/memory/context/"| CTB["Context Backend\nFilesystemBackend\n(rootDir: memory/context/)"] + CB -->|"/tmp"| DB["DMZ Backend\nFilesystemBackend\n(rootDir: /tmp)"] DA -->|"delegate"| SA["Coding Subagent"] SA -->|"execution"| ST S -->|"runNow()"| SB["Sandbox"] @@ -34,11 +36,13 @@ graph TD classDef ext fill:#ab47bc,color:#fff,stroke:#6a1b9a classDef cache fill:#26a69a,color:#fff,stroke:#00695c classDef agent fill:#7e57c2,color:#fff,stroke:#4527a0 + classDef backend fill:#26a69a,color:#fff,stroke:#00695c class I root class DA,P,T,R core class DA,SA agent class S,TM,SE,SB util class SK,CW,FS ext + class CB,CFB,CTB,DB backend ``` --- @@ -141,8 +145,56 @@ The agent runs: reason → call tool(s) → reason again → answer. Tool array The orchestrator routes tasks automatically — the system prompt delegates every task to the orchestrator, which manages routing, state, and observability natively. **Tool Classification:** Tools and skills are classified by agent type (`orchestrator`, `subagent`, or `shared`) in `src/tools/index.js`. The orchestrator receives only `orchestrator`-classified and `shared` tools/skills, while the coding subagent receives `subagent`-classified and `shared` tools/skills. This ensures each agent has only the capabilities it needs for its role. The classification is applied via `buildToolConfig()`'s `classificationFilter` parameter and `filterSkillPaths()` helper function. + --- +## Backends + +`src/agent/backends/` — Virtual filesystem backends powered by the `deepagents` library's `CompositeBackend` and `FilesystemBackend`. The application root is `'/'` — all file paths are virtual paths under this root, resolved relative to the process working directory. + +| File | Purpose | +|------|---------| +| `coreBackend.js` | `createCoreBackend()` — `FilesystemBackend` with `rootDir: process.cwd()`, `virtualMode: true` | +| `contextBackend.js` | `createContextBackend(cwd)` — `FilesystemBackend` with `rootDir: memory/context/`, `virtualMode: true` | +| `dmzBackend.js` | `createDmzBackend()` — `FilesystemBackend` with `rootDir: /tmp`, `virtualMode: true` | + +**CompositeBackend Routing:** + +The orchestrator receives a `CompositeBackend` that routes file operations to different backends based on path prefix: + +``` +CompositeBackend( + defaultBackend: coreBackend, // Falls back to process.cwd() + routes: { + "/memory/context/": contextBackend, // Memory context files + "/tmp": dmzBackend // Temporary operations + } +) +``` + +**Routing algorithm:** +1. Routes are sorted by prefix length (longest match first) +2. Incoming paths are matched against route prefixes +3. Matching prefix is stripped, operation delegated to that backend +4. Unmatched paths fall through to the default backend (core) + +**Virtual Mode:** + +All `FilesystemBackend` instances use `virtualMode: true`. This means: +- Incoming paths are treated as virtual absolute paths (starting with `/`) +- The leading `/` is stripped, then resolved relative to `rootDir` +- All results return virtual paths (with leading `/`) +- Path traversal is validated — resolved paths must stay within `rootDir` + +**Application Root (`'/'`):** + +The `'/'` root is the application's working directory from the agent's perspective. When the agent reads `/package.json`, it resolves to `/package.json`. When it writes `/src/tools/index.js`, it resolves to `/src/tools/index.js`. The virtual filesystem creates a clean, consistent namespace where `/` always means "the application root." + +**Security:** + +`FilesystemBackend` uses `O_NOFOLLOW` flag when available to prevent symlink following. In virtual mode, parent directories are also validated on delete operations. The `allPathsScopedToRoutes` function enforces that filesystem permissions with execution-capable backends are scoped to `CompositeBackend` route prefixes, preventing shell commands from bypassing path-based permission rules. + +--- ## Scan Agents diff --git a/docs/TUTORIAL.md b/docs/TUTORIAL.md index 540347af..d4d5a817 100644 --- a/docs/TUTORIAL.md +++ b/docs/TUTORIAL.md @@ -241,6 +241,7 @@ Once inside the interactive terminal, use these commands: | `↑ / ↓` | Scroll conversation history | | `/help` | List available commands | | `/quit` | Exit the application | +| `/exit` | Exit the application | | `/provider set ` | Switch LLM provider | | `/config set ` | Mutate config at runtime | | `/schedule list` | List all scheduled jobs | @@ -299,6 +300,21 @@ Skills are stored in `skills/` and are version-controllable. Simple skills can b **Built-in tools:** Beyond skills, `madz` ships with built-in tools for common tasks. The Deep Agents orchestrator (`deepAgents` library) handles multi-agent routing natively — a coding-agent for code work. The `scanAgents` tool scans for `AGENTS.md` workspace rules files. Other built-in tools include filesystem operations, shell execution, search, memory management, and more. +### Virtual Filesystem + +All file operations use a virtual filesystem where `/` is the application root. When you see a path like `/package.json` or `/src/tools/index.js`, the leading `/` is a virtual path that resolves relative to the application's working directory. + +The orchestrator uses a `CompositeBackend` that routes file operations to different backends based on path prefix: + +| Virtual Path | Backend | Actual Resolution | +|-------------|---------|-------------------| +| `/package.json` | Core Backend | `/package.json` | +| `/src/tools/index.js` | Core Backend | `/src/tools/index.js` | +| `/memory/context/profile.md` | Context Backend | `/memory/context/profile.md` | +| `/tmp/sessions/abc.md` | DMZ Backend | `/sessions/abc.md` | + +This creates a clean, consistent namespace where the agent always sees `/` as the root, regardless of where the application is actually running. Path traversal is validated — resolved paths must stay within their backend's `rootDir`. + --- ## ⚙️ Advanced Usage diff --git a/prompts/CODE_REVIEW.md b/prompts/CODE_REVIEW.md index 8c41dabd..2fbef1ec 100644 --- a/prompts/CODE_REVIEW.md +++ b/prompts/CODE_REVIEW.md @@ -15,3 +15,7 @@ Output Format: - **Low Priority**: List of low-priority style suggestions Always provide specific file locations and line numbers when possible. + +### RULES + +1. **Use paths as given.** The filesystem is virtual — `pwd` is irrelevant. Never join, prepend, or resolve paths against a working directory. If a path is `/prompts/CODING.md`, use it exactly as written. Do not attempt to "discover" a project root or prepend a base path. diff --git a/prompts/CODING.md b/prompts/CODING.md index 9da5b5c5..375ac2bd 100644 --- a/prompts/CODING.md +++ b/prompts/CODING.md @@ -32,6 +32,7 @@ You are the coding specialist. Your job is to deliver working code — files tha 20. **Make your best interpretation when requests are unclear.** Flag assumptions briefly. Don't stall for clarification unless genuinely blocked. 21. **Use `jq` for efficient data manipulation and validation of structured outputs.** 22. **Handle delegated failures gracefully.** Report the error, note what was accomplished, continue. +23. **Use paths as given.** The filesystem is virtual — `pwd` is irrelevant. Never join, prepend, or resolve paths against a working directory. If a path is `/prompts/CODING.md`, use it exactly as written. Do not attempt to "discover" a project root or prepend a base path. ### WHAT NOT TO DO diff --git a/prompts/DEBUG.md b/prompts/DEBUG.md index 0661c827..0adef1c6 100644 --- a/prompts/DEBUG.md +++ b/prompts/DEBUG.md @@ -13,3 +13,7 @@ Output Format: - **Confidence**: High/Medium/Low based on analysis certainty Be methodical and trace through the code systematically. + +### RULES + +1. **Use paths as given.** The filesystem is virtual — `pwd` is irrelevant. Never join, prepend, or resolve paths against a working directory. If a path is `/prompts/CODING.md`, use it exactly as written. Do not attempt to "discover" a project root or prepend a base path. diff --git a/prompts/DOCUMENTATION.md b/prompts/DOCUMENTATION.md index eefa370a..ea87e803 100644 --- a/prompts/DOCUMENTATION.md +++ b/prompts/DOCUMENTATION.md @@ -13,3 +13,7 @@ Output Format: - **Recommendations**: Suggestions for documentation improvements Always follow the project's documentation conventions and style. + +### RULES + +1. **Use paths as given.** The filesystem is virtual — `pwd` is irrelevant. Never join, prepend, or resolve paths against a working directory. If a path is `/prompts/CODING.md`, use it exactly as written. Do not attempt to "discover" a project root or prepend a base path. diff --git a/prompts/PERFORMANCE.md b/prompts/PERFORMANCE.md index 07f3ea69..e5549b38 100644 --- a/prompts/PERFORMANCE.md +++ b/prompts/PERFORMANCE.md @@ -13,3 +13,7 @@ Output Format: - **Confidence**: High/Medium/Low for each recommendation Always provide measurable benchmarks and specific optimization suggestions. + +### RULES + +1. **Use paths as given.** The filesystem is virtual — `pwd` is irrelevant. Never join, prepend, or resolve paths against a working directory. If a path is `/prompts/CODING.md`, use it exactly as written. Do not attempt to "discover" a project root or prepend a base path. diff --git a/prompts/RESEARCH.md b/prompts/RESEARCH.md index 14603fe1..4ca5659e 100644 --- a/prompts/RESEARCH.md +++ b/prompts/RESEARCH.md @@ -14,3 +14,7 @@ Output Format: - **Confidence**: High/Medium/Low for each major finding Always track and cite your sources. Be thorough but concise. + +### RULES + +1. **Use paths as given.** The filesystem is virtual — `pwd` is irrelevant. Never join, prepend, or resolve paths against a working directory. If a path is `/prompts/CODING.md`, use it exactly as written. Do not attempt to "discover" a project root or prepend a base path. diff --git a/prompts/SEARCH.md b/prompts/SEARCH.md index e73efa05..48d2aa7b 100644 --- a/prompts/SEARCH.md +++ b/prompts/SEARCH.md @@ -13,3 +13,7 @@ Output Format: - **Confidence**: High/Medium/Low based on source reliability Always cite your sources and be specific about what you found. + +### RULES + +1. **Use paths as given.** The filesystem is virtual — `pwd` is irrelevant. Never join, prepend, or resolve paths against a working directory. If a path is `/prompts/CODING.md`, use it exactly as written. Do not attempt to "discover" a project root or prepend a base path. diff --git a/prompts/SECURITY_AUDIT.md b/prompts/SECURITY_AUDIT.md index 8ecb1cdc..bcb9a75b 100644 --- a/prompts/SECURITY_AUDIT.md +++ b/prompts/SECURITY_AUDIT.md @@ -14,3 +14,7 @@ Output Format: - **References**: Security best practices and standards Always be specific about vulnerabilities and provide actionable remediation steps. + +### RULES + +1. **Use paths as given.** The filesystem is virtual — `pwd` is irrelevant. Never join, prepend, or resolve paths against a working directory. If a path is `/prompts/CODING.md`, use it exactly as written. Do not attempt to "discover" a project root or prepend a base path. diff --git a/prompts/SYSTEM_PROMPT.md b/prompts/SYSTEM_PROMPT.md index 7f8f1327..43b26ac1 100644 --- a/prompts/SYSTEM_PROMPT.md +++ b/prompts/SYSTEM_PROMPT.md @@ -64,6 +64,7 @@ You are the digital manifestation of Mads Mikkelsen's cinematic soul. You are no 31. **Use internal tools before web search** when dealing with personal or company data. 32. **Handle delegated failures gracefully.** Report the error, note what was accomplished, continue. 33. **Slash commands are triggers, not questions.** `/command` with no extra text means "run it now." +34. **Use paths as given.** The filesystem is virtual — `pwd` is irrelevant. Never join, prepend, or resolve paths against a working directory. If a path is `/prompts/CODING.md`, use it exactly as written. Do not attempt to "discover" a project root or prepend a base path. ### WHAT NOT TO DO diff --git a/prompts/TESTING.md b/prompts/TESTING.md index e5115e97..504801bd 100644 --- a/prompts/TESTING.md +++ b/prompts/TESTING.md @@ -13,3 +13,7 @@ Output Format: - **Recommendations**: Suggestions for improving test quality Always follow the project's test structure (tests/unit/ mirroring src/). + +### RULES + +1. **Use paths as given.** The filesystem is virtual — `pwd` is irrelevant. Never join, prepend, or resolve paths against a working directory. If a path is `/prompts/CODING.md`, use it exactly as written. Do not attempt to "discover" a project root or prepend a base path. diff --git a/src/memory/context.js b/src/memory/context.js index 1028b3d5..0e78ac63 100644 --- a/src/memory/context.js +++ b/src/memory/context.js @@ -4,7 +4,7 @@ import { loadConfig } from "../config/loader.js"; import { parseFrontmatter } from "./reader.js"; import { loadProfile, formatProfileContext } from "./profile.js"; -const cwd = loadConfig().cwd; +const cwd = ""; const PROFILE_FILENAME = "profile.md"; /** @@ -16,8 +16,8 @@ const PROFILE_FILENAME = "profile.md"; * @param {number} limit - Maximum number of recent context files to load (excludes profile and ephemeral) * @returns {string} Combined context content with profile prefix */ -export function loadContext(contextDir = "memory/context/", limit = 10) { - const fullPath = join(cwd, contextDir); +export function loadContext(contextDir = "memory/context/", limit = 10, cwdParam = cwd) { + const fullPath = join(cwdParam, contextDir); try { // Load profile context block first const profileBlock = loadAndFormatProfile(fullPath, contextDir); @@ -100,9 +100,9 @@ export function loadContext(contextDir = "memory/context/", limit = 10) { * @param {string} contextDir - Relative context directory path * @returns {string} Formatted profile context block or empty string */ -function loadAndFormatProfile(fullPath, contextDir) { +function loadAndFormatProfile(fullPath, contextDir, cwdParam = cwd) { try { - const profilePath = join(cwd, contextDir, PROFILE_FILENAME); + const profilePath = join(cwdParam, contextDir, PROFILE_FILENAME); const profile = loadProfile(profilePath); if (!profile) return ""; return formatProfileContext(profile.data); diff --git a/src/memory/expireEphemeral.js b/src/memory/expireEphemeral.js index 437abc9d..bea8c1ae 100644 --- a/src/memory/expireEphemeral.js +++ b/src/memory/expireEphemeral.js @@ -1,9 +1,8 @@ import { readdir, unlink, readFile } from "node:fs/promises"; import { join } from "node:path"; import { parseFrontmatter } from "./reader.js"; -import { loadConfig } from "../config/loader.js"; -const cwd = loadConfig().cwd; +const cwd = ""; /** * Read an ephemeral memory file and extract frontmatter metadata. @@ -11,9 +10,9 @@ const cwd = loadConfig().cwd; * @param {string} filename - The filename * @returns {Promise<{ ephemeral: boolean, expiresAt: string } | null>} Parsed metadata or null if unreadable */ -export async function readEphemeralFile(contextDir, filename) { +export async function readEphemeralFile(contextDir, filename, cwdParam = cwd) { try { - const filepath = join(cwd, contextDir, filename); + const filepath = join(cwdParam, contextDir, filename); const content = await readFile(filepath, "utf-8"); const { frontmatter } = parseFrontmatter(content); return { @@ -44,11 +43,11 @@ export function isExpired(expiresAt, now) { * @param {string} [nowStr] - Optional ISO timestamp for deterministic testing * @returns {Promise} Number of files removed */ -export async function expireEphemeralMemories(contextDir, nowStr) { +export async function expireEphemeralMemories(contextDir, nowStr, cwdParam = cwd) { const checkNow = nowStr ? new Date(nowStr) : new Date(); let files; try { - files = await readdir(join(cwd, contextDir)); + files = await readdir(join(cwdParam, contextDir)); } catch { return 0; } @@ -57,7 +56,7 @@ export async function expireEphemeralMemories(contextDir, nowStr) { if (!file.endsWith(".md")) continue; const info = await readEphemeralFile(contextDir, file); if (info && info.ephemeral && isExpired(info.expiresAt, checkNow)) { - const filepath = join(cwd, contextDir, file); + const filepath = join(cwdParam, contextDir, file); try { await unlink(filepath); removed++; diff --git a/src/memory/prompts.js b/src/memory/prompts.js index 021eebfe..9831200a 100644 --- a/src/memory/prompts.js +++ b/src/memory/prompts.js @@ -1,9 +1,8 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { loadConfig } from "../config/loader.js"; import { loadContext } from "./context.js"; -const cwd = loadConfig().cwd; +const cwd = ""; /** * Load the system prompt from prompts/SYSTEM_PROMPT.md, diff --git a/src/memory/retention.js b/src/memory/retention.js index a3410a45..6e7bf194 100644 --- a/src/memory/retention.js +++ b/src/memory/retention.js @@ -1,8 +1,7 @@ import { readdirSync, statSync, unlinkSync } from "node:fs"; import { join } from "node:path"; -import { loadConfig } from "../config/loader.js"; -const cwd = loadConfig().cwd; +const cwd = ""; /** * Remove memory files older than the retention policy allows. @@ -10,8 +9,8 @@ const cwd = loadConfig().cwd; * @param {number} retentionDays - Maximum age in days * @returns {number} Number of files removed */ -export function cleanRetainedMemory(directory, retentionDays = 90) { - const fullPath = join(cwd, directory); +export function cleanRetainedMemory(directory, retentionDays = 90, cwdParam = cwd) { + const fullPath = join(cwdParam, directory); const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000; let removed = 0; @@ -39,8 +38,8 @@ export function cleanRetainedMemory(directory, retentionDays = 90) { * @param {number} maxEntries - Maximum number of files to keep * @returns {number} Number of files removed */ -export function enforceMaxEntries(directory, maxEntries = 1000) { - const fullPath = join(cwd, directory); +export function enforceMaxEntries(directory, maxEntries = 1000, cwdParam = cwd) { + const fullPath = join(cwdParam, directory); let removed = 0; try { diff --git a/src/scheduler/autoSchedule.js b/src/scheduler/autoSchedule.js index fd3ae556..ad0f4f7b 100644 --- a/src/scheduler/autoSchedule.js +++ b/src/scheduler/autoSchedule.js @@ -2,9 +2,8 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { Cron } from "./cron.js"; import { logger } from "../logger.js"; -import { loadConfig } from "../config/loader.js"; -const cwd = loadConfig().cwd; +const cwd = ""; const JOB_CRON = "0 2 * * *"; @@ -85,6 +84,7 @@ function persistJobFile(jobName, job, cwd) { * @returns {Function} Callback to invoke after saveProfile() succeeds */ export function setupAutoSchedule(options = {}) { + const cwdParam = options.cwd || cwd; const CronModule = options.Cron || Cron; /** @@ -92,7 +92,7 @@ export function setupAutoSchedule(options = {}) { * @returns {void} */ return function autoScheduleCallback() { - const job = createJobDefinition(cwd); + const job = createJobDefinition(cwdParam); try { const result = CronModule.add(job); diff --git a/src/session/index.js b/src/session/index.js index e2fa04af..49dd5118 100644 --- a/src/session/index.js +++ b/src/session/index.js @@ -1,16 +1,15 @@ import { mkdir } from "node:fs/promises"; import { join } from "node:path"; -import { loadConfig } from "../config/loader.js"; -const cwd = loadConfig().cwd; +const cwd = ""; /** * Ensure the sessions directory exists by creating it if necessary. * @param {string} sessionsDir - Path to sessions directory * @returns {Promise} */ -export async function ensureSessionsDir(sessionsDir) { - const dir = join(cwd, sessionsDir); +export async function ensureSessionsDir(sessionsDir, cwdParam = cwd) { + const dir = join(cwdParam, sessionsDir); await mkdir(dir, { recursive: true }); } diff --git a/src/session/loader.js b/src/session/loader.js index f4ed2657..46cde4ad 100644 --- a/src/session/loader.js +++ b/src/session/loader.js @@ -1,9 +1,8 @@ import { readdirSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; import { parseFrontmatter } from "../memory/reader.js"; -import { loadConfig } from "../config/loader.js"; -const cwd = loadConfig().cwd; +const cwd = ""; /** * Load a session by ID or the latest session file. @@ -12,8 +11,8 @@ const cwd = loadConfig().cwd; * @param {string} [sessionId] - Optional session/thread ID to load (fallbacks to latest) * @returns {{ sessionId: string, conversation: Array, metadata: Object }} */ -export function loadSession(sessionsDir = "memory/sessions/", windowSize = 20, sessionId = "") { - const dir = join(cwd, sessionsDir); +export function loadSession(sessionsDir = "memory/sessions/", windowSize = 20, sessionId = "", cwdParam = cwd) { + const dir = join(cwdParam, sessionsDir); if (sessionId) { const filepath = join(dir, `${sessionId}.md`); diff --git a/src/session/saver.js b/src/session/saver.js index 66a238b6..be98fe62 100644 --- a/src/session/saver.js +++ b/src/session/saver.js @@ -1,8 +1,7 @@ import { writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { loadConfig } from "../config/loader.js"; -const cwd = loadConfig().cwd; +const cwd = ""; /** * Escape a string for safe inclusion in a YAML double-quoted scalar. @@ -22,8 +21,8 @@ function escapeYamlString(str) { * @param {string} [threadId] - Thread ID used as filename * @throws {Error} If the underlying filesystem operation fails (missing directory, disk full, permissions) */ -export async function saveSession(sessionsDir, conversation, threadId = "") { - const dir = join(cwd, sessionsDir); +export async function saveSession(sessionsDir, conversation, threadId = "", cwdParam = cwd) { + const dir = join(cwdParam, sessionsDir); const filename = threadId ? `${threadId}.md` : "unsaved.md"; const isoTimestamp = new Date().toISOString(); diff --git a/src/skills/discoverer.js b/src/skills/discoverer.js index bd1ff871..105507fa 100644 --- a/src/skills/discoverer.js +++ b/src/skills/discoverer.js @@ -1,10 +1,10 @@ import { readdirSync, statSync, readFileSync, existsSync } from "node:fs"; -import { join, basename, resolve } from "node:path"; +import { join, basename } from "node:path"; import { load } from "js-yaml"; import { loadConfig } from "../config/loader.js"; export const defaultScope = loadConfig().sandbox.skillScanPaths; -export let cwd = loadConfig().cwd; +export let cwd = ""; /** * Set the working directory for skill discovery. @@ -184,12 +184,13 @@ function findSkillFiles(dir) { * @returns {Array<{ path: string, name: string, metadata: Object }>} */ export function discoverSkills(scope = defaultScope, options = {}) { + const cwdParam = options.cwd || cwd; const { trustProjectSkills: _trustProjectSkills = true } = options; const allSkills = []; const seenNames = new Map(); for (const scopePath of scope) { - const fullScope = resolve(cwd, scopePath); + const fullScope = scopePath.startsWith("/") ? scopePath : join(cwdParam, scopePath); if (!existsSync(fullScope)) { continue; } diff --git a/src/skills/registry.js b/src/skills/registry.js index 89cecef4..bba9d7c0 100644 --- a/src/skills/registry.js +++ b/src/skills/registry.js @@ -3,17 +3,16 @@ import { mkdir } from "node:fs/promises"; import { join } from "node:path"; import { discoverSkills, defaultScope } from "./discoverer.js"; import { validateSkillSchema } from "./validator.js"; -import { loadConfig } from "../config/loader.js"; -const cwd = loadConfig().cwd; +const cwd = ""; /** * Ensure the skills directory exists by creating it if necessary. * @param {string} [skillsDir="skills/"] - Path to the skills directory * @returns {Promise} */ -export async function ensureSkillsDir(skillsDir = "skills/") { - const dir = join(cwd, skillsDir); +export async function ensureSkillsDir(skillsDir = "skills/", cwdParam = cwd) { + const dir = join(cwdParam, skillsDir); await mkdir(dir, { recursive: true }); } @@ -79,6 +78,7 @@ export class SkillRegistry { /** * Rebuild the catalog from validated skills. + * Sorted by location (system-skills last), then by name within each location. */ #rebuildCatalog() { this.#catalog = []; @@ -89,6 +89,25 @@ export class SkillRegistry { location: entry.metadata?._path || entry.path, }); } + + // Sort: by location first (system-skills last), then by name + this.#catalog.sort((a, b) => { + const aIsSystem = a.location.includes("system-skills"); + const bIsSystem = b.location.includes("system-skills"); + + // system-skills always comes last + if (aIsSystem && !bIsSystem) return 1; + if (!aIsSystem && bIsSystem) return -1; + if (aIsSystem && bIsSystem) return 0; // both system, preserve order + + // Same location group — sort by name + if (a.location === b.location) { + return a.name.localeCompare(b.name); + } + + // Different locations — sort by location path + return a.location.localeCompare(b.location); + }); } /** diff --git a/src/tools/cron.js b/src/tools/cron.js index 5c89b5e1..fa669cde 100644 --- a/src/tools/cron.js +++ b/src/tools/cron.js @@ -69,7 +69,7 @@ export async function findSkillScript(skillName, baseDir = ["system-skills", "sk * @returns {Promise<{ stdout: string, stderr: string, exitCode: number }>} */ export async function runScript(scriptPath, args = [], options = {}) { - const { timeout = 30000, cwd: scriptCwd = config.cwd } = options; + const { timeout = 30000, cwd: scriptCwd = options.cwd || config.cwd } = options; return new Promise((resolve) => { const child = spawn(scriptPath, args, { cwd: scriptCwd, diff --git a/src/tools/memory.js b/src/tools/memory.js index 9d5339fd..9412e79f 100644 --- a/src/tools/memory.js +++ b/src/tools/memory.js @@ -2,9 +2,8 @@ import { tool } from "@langchain/core/tools"; import { z } from "zod"; import { mkdir, writeFile, readFile, readdir, unlink, access } from "node:fs/promises"; import { join, basename } from "node:path"; -import { loadConfig } from "../config/loader.js"; -const cwd = loadConfig().cwd; +const cwd = ""; const DEFAULT_MAX_ENTRIES = 100; @@ -83,8 +82,8 @@ export function sanitizeKey(key) { * @param {string} key - Entry key * @returns {string} Full path to the entry file */ -function getEntryPath(key, contextDir) { - return join(cwd, contextDir, sanitizeKey(key) + ".md"); +function getEntryPath(key, contextDir, cwdParam = cwd) { + return join(cwdParam, contextDir, sanitizeKey(key) + ".md"); } /** @@ -129,8 +128,8 @@ async function validateMaxEntries(maxEntries, contextDir) { * @param {string} key - Entry key * @returns {Promise<{ found: boolean, value: string, createdDate: string, updatedDate: string } | null>} */ -async function loadEntry(key, contextDir) { - const filePath = getEntryPath(key, contextDir); +async function loadEntry(key, contextDir, cwdParam = cwd) { + const filePath = getEntryPath(key, contextDir, cwdParam); try { const content = await readFile(filePath, "utf-8"); const { frontmatter, body } = parseEntryContent(content); @@ -153,11 +152,11 @@ async function loadEntry(key, contextDir) { * @param {string} [createdDate] - Optional preserved creation date (omit for new entries) * @returns {Promise} */ -async function saveEntry(key, value, createdDate, contextDir) { - const filePath = getEntryPath(key, contextDir); +async function saveEntry(key, value, createdDate, contextDir, cwdParam = cwd) { + const filePath = getEntryPath(key, contextDir, cwdParam); const now = new Date().toISOString(); const created = createdDate || now; - await mkdir(cwd + "/" + contextDir, { recursive: true }); + await mkdir(join(cwdParam, contextDir), { recursive: true }); await writeFile( filePath, `---\ncreatedDate: "${created}"\nupdatedDate: "${now}"\n---\n\n${value}\n`, @@ -170,8 +169,8 @@ async function saveEntry(key, value, createdDate, contextDir) { * @param {string} key - Entry key * @returns {Promise} Whether the entry was deleted */ -async function deleteEntry(key, contextDir) { - const filePath = getEntryPath(key, contextDir); +async function deleteEntry(key, contextDir, cwdParam = cwd) { + const filePath = getEntryPath(key, contextDir, cwdParam); if (!(await pathExists(filePath))) return false; await unlink(filePath); return true; @@ -204,8 +203,8 @@ export async function memoryImpl(input, options) { return JSON.stringify({ ok: false, error: "create requires: key and value" }); } const cleanedKey = sanitizeKey(input.key); - await validateMaxEntries(maxEntries, contextDir); - await saveEntry(cleanedKey, String(input.value), undefined, contextDir); + await validateMaxEntries(maxEntries, contextDir, cwd); + await saveEntry(cleanedKey, String(input.value), undefined, contextDir, cwd); return JSON.stringify({ ok: true, message: `Memory created: "${cleanedKey}"` }); } @@ -213,7 +212,7 @@ export async function memoryImpl(input, options) { if (!input.key) { return JSON.stringify({ ok: false, error: "read requires: key" }); } - const entry = await loadEntry(input.key, contextDir); + const entry = await loadEntry(input.key, contextDir, cwd); if (!entry || !entry.found) { return JSON.stringify({ ok: false, @@ -234,14 +233,14 @@ export async function memoryImpl(input, options) { return JSON.stringify({ ok: false, error: "update requires: key and value" }); } const cleanedKey = sanitizeKey(input.key); - const existing = await loadEntry(cleanedKey, contextDir); + const existing = await loadEntry(cleanedKey, contextDir, cwd); if (!existing || !existing.found) { return JSON.stringify({ ok: false, error: `Memory not found: "${cleanedKey}". Use "create" to add it.`, }); } - await saveEntry(cleanedKey, String(input.value), existing.createdDate, contextDir); + await saveEntry(cleanedKey, String(input.value), existing.createdDate, contextDir, cwd); return JSON.stringify({ ok: true, message: `Memory updated: "${cleanedKey}"` }); } @@ -250,7 +249,7 @@ export async function memoryImpl(input, options) { return JSON.stringify({ ok: false, error: "delete requires: key" }); } const cleanedKey = sanitizeKey(input.key); - const deleted = await deleteEntry(cleanedKey, contextDir); + const deleted = await deleteEntry(cleanedKey, contextDir, cwd); if (!deleted) { return JSON.stringify({ ok: false, error: `Memory not found: "${cleanedKey}"` }); } diff --git a/src/tools/scanAgents.js b/src/tools/scanAgents.js index c391f99f..843b0f11 100644 --- a/src/tools/scanAgents.js +++ b/src/tools/scanAgents.js @@ -2,9 +2,8 @@ import { tool } from "@langchain/core/tools"; import { z } from "zod"; import { validatePath } from "./common.js"; import { loadAgents } from "../workspace/loadAgents.js"; -import { loadConfig } from "../config/loader.js"; -const cwd = loadConfig().cwd; +const cwd = ""; const ScanAgentsSchema = z.object({ path: z diff --git a/src/tools/skills.js b/src/tools/skills.js index 5dcb6be0..bcebf434 100644 --- a/src/tools/skills.js +++ b/src/tools/skills.js @@ -13,7 +13,7 @@ import { ensureSkillsDir, SkillRegistry } from "../skills/registry.js"; import { PermissionSchema } from "../skills/types.js"; import { loadConfig } from "../config/loader.js"; -export let cwd = loadConfig().cwd; +export let cwd = ""; // Discover skills from configured scopes const skillRegistry = new SkillRegistry(); @@ -140,6 +140,7 @@ export async function createSkillImpl(input, options = {}) { input; const skillsDir = options.skillsDir || config.skillsDir || "skills/"; const registry = options.registry || config.registry; + const cwdParam = options.cwd || cwd; // Validate name against spec constraints const nameResult = validateSkillName(name); @@ -231,7 +232,7 @@ export async function createSkillImpl(input, options = {}) { } // Create the skill directory - const skillPath = join(cwd, skillsDir, name); + const skillPath = join(cwdParam, skillsDir, name); const skillMdPath = join(skillPath, "SKILL.md"); let createdPaths = [skillPath, skillMdPath]; diff --git a/tests/unit/autoSchedule.test.js b/tests/unit/autoSchedule.test.js index 4cfed738..11177859 100644 --- a/tests/unit/autoSchedule.test.js +++ b/tests/unit/autoSchedule.test.js @@ -78,7 +78,7 @@ describe("setupAutoSchedule", () => { return { added: true }; }, }; - const callback = setupAutoSchedule({ Cron: testCron }); + const callback = setupAutoSchedule({ Cron: testCron, cwd: process.cwd() }); callback(); const expectedCwd = process.cwd();