diff --git a/openspec/changes/archive/2026-08-03-replace-sync-fs-calls-and-silent-catches/.openspec.yaml b/openspec/changes/archive/2026-08-03-replace-sync-fs-calls-and-silent-catches/.openspec.yaml new file mode 100644 index 00000000..d6589364 --- /dev/null +++ b/openspec/changes/archive/2026-08-03-replace-sync-fs-calls-and-silent-catches/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-02 diff --git a/openspec/changes/archive/2026-08-03-replace-sync-fs-calls-and-silent-catches/design.md b/openspec/changes/archive/2026-08-03-replace-sync-fs-calls-and-silent-catches/design.md new file mode 100644 index 00000000..d8042243 --- /dev/null +++ b/openspec/changes/archive/2026-08-03-replace-sync-fs-calls-and-silent-catches/design.md @@ -0,0 +1,97 @@ +## Context + +The src/ audit (issue #683) identified two categories of violations against AGENTS.md §1.1: +1. Synchronous fs operations in async contexts that block the Node.js event loop +2. Bare silent catch blocks that swallow errors without logging + +These violations exist across 25+ files in the codebase. The codebase uses Node.js 24+ with ESM, and the existing pattern for async fs operations is well-established in many files (e.g., `src/tools/session_search.js`, `src/tools/sampling.js`, `src/scheduler/cron.js`). + +## Goals / Non-Goals + +**Goals:** +- Convert all sync fs calls in async contexts to async equivalents from `node:fs/promises` +- Replace all bare `catch {}` blocks with proper error logging via the existing `logger` singleton +- Preserve existing function signatures, return types, and error handling semantics +- Maintain backward compatibility — no breaking changes to public APIs + +**Non-Goals:** +- Converting module-level sync fs calls in `config/loader.js` and `logger.js` (synchronous initialization) +- Adding new error handling libraries or frameworks +- Changing the behavior of error recovery — only adding logging +- Converting sync fs calls in files that are definitively only called from sync contexts + +## Decisions + +### Decision 1: Use `node:fs/promises` instead of `util.promisify` + +**Choice:** Import `readFile`, `writeFile`, `readdir`, `stat`, `access`, `mkdir`, `unlink` from `node:fs/promises` directly. + +**Rationale:** This is the existing pattern throughout the codebase (e.g., `src/tools/session_search.js`, `src/tools/sampling.js`, `src/scheduler/cron.js`). It's cleaner than `util.promisify` and more readable. + +**Alternatives considered:** +- `util.promisify(fs.readFileSync)` — adds indirection, not used elsewhere in the codebase +- Third-party promise libraries — unnecessary dependency + +### Decision 2: Error logging severity levels + +**Choice:** Use `logger.debug()` for expected/non-critical failures (file not found, directory doesn't exist, YAML parse errors during discovery) and `logger.error()` for unexpected/critical failures (schedule execution failures, context load failures). + +**Rationale:** The existing `logger` singleton from `src/logger.js` provides structured JSON logging. Using `debug` for expected failures avoids log noise while still providing visibility. Using `error` for unexpected failures ensures they surface in monitoring. + +**Alternatives considered:** +- Always use `logger.error()` — creates log noise for expected failures +- Always use `logger.warn()` — doesn't distinguish between expected and unexpected failures +- Re-throw all errors — would break existing error handling patterns in callers + +### Decision 3: Preserve sync fs in module-level initialization + +**Choice:** Leave `readFileSync` in `config/loader.js` and `logger.js` as-is. + +**Rationale:** These files are imported at module level and called synchronously during application startup. Converting them would require restructuring the entire initialization flow, which is out of scope for this fix. + +**Alternatives considered:** +- Convert to async and make initialization async — would require changes to `index.js` and all subsystems +- Leave as-is with a comment explaining why — acceptable for now, could be addressed in a future refactor + +### Decision 4: Handle `existsSync` → `access` + +**Choice:** Replace `existsSync(path)` with `access(path, constants.F_OK)` from `node:fs/promises`, wrapped in try/catch. + +**Rationale:** `access` is the async equivalent of `existsSync`. It throws if the file doesn't exist, so callers need to handle the error. This is consistent with existing patterns in the codebase (e.g., `src/tools/session_search.js:17`). + +**Alternatives considered:** +- `stat` — more information than needed, slightly slower +- `readFile` — overkill for existence check + +## Risks / Trade-offs + +### Risk 1: Performance regression in hot paths + +**Impact:** Async fs operations have slightly higher overhead than sync operations due to the event loop scheduling. + +**Mitigation:** The affected functions are not in hot paths — they are called during session loading, skill discovery, and prompt loading, which are infrequent operations. The performance difference is negligible. + +### Risk 2: Test failures due to async changes + +**Impact:** Tests that mock sync fs calls may need to be updated to mock async fs calls. + +**Mitigation:** Review test files for affected functions and update mocks to return promises. The existing test patterns in the codebase already use async mocks (e.g., `src/tools/session_search.test.js`). + +### Risk 3: Silent catch replacement changes behavior + +**Impact:** Replacing `catch {}` with `catch (err) { logger.debug(...) }` will add log output where there was none before. + +**Mitigation:** This is the intended behavior — silent catches are a bug. The debug-level logging ensures visibility without noise. Tests that verify no log output may need adjustment. + +## Migration Plan + +1. Convert sync fs calls to async in each file (10 files) +2. Replace bare catch blocks with proper error handling (25+ files) +3. Run `npm run test` to verify all tests pass +4. Run `npm run lint` to verify linting passes +5. Run `npm run coverage` to verify coverage is maintained +6. Commit and push to the feature branch + +## Open Questions + +- None — all affected files have been identified and the approach is clear. diff --git a/openspec/changes/archive/2026-08-03-replace-sync-fs-calls-and-silent-catches/proposal.md b/openspec/changes/archive/2026-08-03-replace-sync-fs-calls-and-silent-catches/proposal.md new file mode 100644 index 00000000..b2a99543 --- /dev/null +++ b/openspec/changes/archive/2026-08-03-replace-sync-fs-calls-and-silent-catches/proposal.md @@ -0,0 +1,31 @@ +## Why + +The src/ audit (issue #683) identified two categories of violations against AGENTS.md §1.1: synchronous fs operations in async contexts that block the event loop, and bare silent catch blocks that swallow errors without logging. Both patterns degrade reliability and make debugging difficult. + +## What Changes + +- Convert synchronous fs calls (`readFileSync`, `writeFileSync`, `readdirSync`, `statSync`, `existsSync`, `mkdirSync`, `unlinkSync`) to async counterparts (`readFile`, `writeFile`, `readdir`, `stat`, `access`, `mkdir`, `unlink`) from `node:fs/promises` in all files called from async contexts +- Replace bare `catch {}` blocks with proper error handling using the existing `logger` singleton — `logger.debug()` for expected/non-critical failures, `logger.error()` for unexpected/critical failures +- Preserve module-level sync fs calls in `config/loader.js` and `logger.js` where they are used during synchronous initialization + +## Capabilities + +### New Capabilities +- `async-fs`: Requirement that all fs operations in async contexts use `node:fs/promises` instead of blocking `node:fs` +- `error-logging`: Requirement that all catch blocks log errors via the structured logger or re-throw — no silent catches + +### Modified Capabilities +- None — no existing spec-level behavior changes, only implementation improvements + +## Impact + +- **Affected files**: `src/memory/context.js`, `src/memory/prompts.js`, `src/skills/registry.js`, `src/memory/reader.js`, `src/memory/writer.js`, `src/session/loader.js`, `src/memory/profile.js`, `src/sandbox/runner.js`, `src/skills/discoverer.js`, `src/memory/retention.js`, `src/memory/expireEphemeral.js`, `src/scheduler/scheduler.js`, `src/scheduler/cron.js`, `src/agent/agents/coding.js`, `src/agent/agents/debug.js`, `src/agent/agents/documentation.js`, `src/agent/agents/code-review.js`, `src/agent/agents/search.js`, `src/agent/agents/security-audit.js`, `src/agent/agents/performance.js`, `src/agent/agents/testing.js`, `src/agent/agents/research.js`, `src/tui/contextTokens.js`, `src/tui/statusBar.js`, `src/workspace/loadAgents.js` +- **APIs**: No breaking changes — function signatures and return types remain identical +- **Dependencies**: No new dependencies — uses existing `node:fs/promises` and `src/logger.js` +- **Tests**: Existing tests must continue to pass; some tests may need adjustment if they mock sync fs calls + +## Non-goals + +- Converting module-level sync fs calls in `config/loader.js` and `logger.js` (these are synchronous initialization) +- Adding new error handling frameworks or libraries +- Changing the behavior of error recovery — only adding logging, not changing what gets caught diff --git a/openspec/changes/archive/2026-08-03-replace-sync-fs-calls-and-silent-catches/specs/async-fs/spec.md b/openspec/changes/archive/2026-08-03-replace-sync-fs-calls-and-silent-catches/specs/async-fs/spec.md new file mode 100644 index 00000000..7256e8e2 --- /dev/null +++ b/openspec/changes/archive/2026-08-03-replace-sync-fs-calls-and-silent-catches/specs/async-fs/spec.md @@ -0,0 +1,35 @@ +## ADDED Requirements + +### Requirement: Async fs operations in async contexts +The system SHALL use `node:fs/promises` for all fs operations (`readFile`, `writeFile`, `readdir`, `stat`, `access`, `mkdir`, `unlink`) in functions that are async or called from async contexts. Synchronous fs operations (`readFileSync`, `writeFileSync`, `readdirSync`, `statSync`, `existsSync`, `mkdirSync`, `unlinkSync`) are prohibited in async contexts per AGENTS.md §1.1. + +#### Scenario: loadContext uses async fs +- **WHEN** `loadContext()` in `src/memory/context.js` reads context files +- **THEN** it uses `readFile` and `readdir` from `node:fs/promises` instead of `readFileSync` and `readdirSync` + +#### Scenario: loadSystemPrompt uses async fs +- **WHEN** `loadSystemPrompt()` in `src/memory/prompts.js` reads the system prompt file +- **THEN** it uses `readFile` from `node:fs/promises` instead of `readFileSync` + +#### Scenario: getSkillBody uses async fs +- **WHEN** `getSkillBody()` in `src/skills/registry.js` reads a skill's SKILL.md body +- **THEN** it uses `readFile` from `node:fs/promises` instead of `readFileSync` + +#### Scenario: Module-level sync fs preserved +- **WHEN** `config/loader.js` or `logger.js` perform module-level initialization +- **THEN** synchronous fs operations are preserved (they are not called from async contexts during initialization) + +### Requirement: No blocking fs in async call chains +The system SHALL ensure that no file in the async call chain uses blocking fs operations. If a function is called from an async context, all fs operations within it and its transitive callees must be async. + +#### Scenario: detectShebang uses async fs +- **WHEN** `detectShebang()` in `src/sandbox/runner.js` reads a script's first line +- **THEN** it uses `readFile` and `access` from `node:fs/promises` instead of `readFileSync` and `existsSync` + +#### Scenario: discoverSkills uses async fs +- **WHEN** `discoverSkills()` in `src/skills/discoverer.js` scans skill directories +- **THEN** it uses `readdir`, `stat`, `readFile`, and `access` from `node:fs/promises` instead of sync equivalents + +#### Scenario: loadSession uses async fs +- **WHEN** `loadSession()` in `src/session/loader.js` reads session files +- **THEN** it uses `readFile`, `readdir`, and `stat` from `node:fs/promises` instead of sync equivalents diff --git a/openspec/changes/archive/2026-08-03-replace-sync-fs-calls-and-silent-catches/specs/error-logging/spec.md b/openspec/changes/archive/2026-08-03-replace-sync-fs-calls-and-silent-catches/specs/error-logging/spec.md new file mode 100644 index 00000000..11a1595f --- /dev/null +++ b/openspec/changes/archive/2026-08-03-replace-sync-fs-calls-and-silent-catches/specs/error-logging/spec.md @@ -0,0 +1,27 @@ +## ADDED Requirements + +### Requirement: All catch blocks log errors +The system SHALL log all caught errors using the structured `logger` singleton from `src/logger.js`. No bare `catch {}` blocks are permitted per AGENTS.md §1.1. + +#### Scenario: Expected failures use debug level +- **WHEN** a file not found error occurs during skill discovery +- **THEN** the catch block logs via `logger.debug()` with the error message + +#### Scenario: Unexpected failures use error level +- **WHEN** a schedule execution fails unexpectedly +- **THEN** the catch block logs via `logger.error()` with the error message + +#### Scenario: Silent catches are eliminated +- **WHEN** any file in the codebase has a `catch {}` block +- **THEN** it has been replaced with `catch (err) { logger.debug(...) }` or `catch (err) { logger.error(...) }` + +### Requirement: Error logging preserves existing semantics +The system SHALL preserve existing error handling semantics — errors that were previously swallowed should still be handled gracefully, but now with logging. Errors that were previously re-thrown should continue to be re-thrown. + +#### Scenario: Graceful degradation with logging +- **WHEN** `loadSystemPrompt()` fails to find the system prompt file +- **THEN** it logs the error and returns an empty string (same behavior as before, but now logged) + +#### Scenario: Retention cleanup with logging +- **WHEN** `cleanRetainedMemory()` encounters a directory it cannot read +- **THEN** it logs the error and returns 0 (same behavior as before, but now logged) diff --git a/openspec/changes/archive/2026-08-03-replace-sync-fs-calls-and-silent-catches/tasks.md b/openspec/changes/archive/2026-08-03-replace-sync-fs-calls-and-silent-catches/tasks.md new file mode 100644 index 00000000..5fae7b45 --- /dev/null +++ b/openspec/changes/archive/2026-08-03-replace-sync-fs-calls-and-silent-catches/tasks.md @@ -0,0 +1,86 @@ +## 1. Convert sync fs in memory module + +- [ ] 1.1 Convert `src/memory/context.js` — replace `readdirSync`/`readFileSync` with `readdir`/`readFile` from `node:fs/promises`, make `loadContext()` async +- [ ] 1.2 Convert `src/memory/reader.js` — replace `readFileSync`/`existsSync` with `readFile`/`access` from `node:fs/promises`, make `readMemoryFile()` async +- [ ] 1.3 Convert `src/memory/writer.js` — replace `mkdirSync`/`writeFileSync` with `mkdir`/`writeFile` from `node:fs/promises`, make `writeMemoryFile()` async +- [ ] 1.4 Convert `src/memory/profile.js` — replace `readFileSync`/`writeFileSync`/`mkdirSync`/`existsSync`/`renameSync` with async equivalents, make `loadProfile()` and `saveProfile()` async +- [ ] 1.5 Convert `src/memory/prompts.js` — replace `readFileSync` with `readFile` from `node:fs/promises`, make `loadSystemPrompt()` async +- [ ] 1.6 Convert `src/memory/retention.js` — replace `readdirSync`/`statSync`/`unlinkSync` with async equivalents, make `cleanRetainedMemory()` and `enforceMaxEntries()` async + +## 2. Convert sync fs in skills module + +- [ ] 2.1 Convert `src/skills/discoverer.js` — replace `readdirSync`/`statSync`/`readFileSync`/`existsSync` with async equivalents, make `findSkillFiles()` and `discoverSkills()` async +- [ ] 2.2 Convert `src/skills/registry.js` — replace `readFileSync` with `readFile` from `node:fs/promises`, make `getSkillBody()` async + +## 3. Convert sync fs in session module + +- [ ] 3.1 Convert `src/session/loader.js` — replace `readdirSync`/`readFileSync`/`statSync` with async equivalents, make `loadSession()` and `loadFile()` async + +## 4. Convert sync fs in sandbox module + +- [ ] 4.1 Convert `src/sandbox/runner.js` — replace `existsSync`/`readFileSync` with `access`/`readFile` from `node:fs/promises`, make `detectShebang()` async + +## 5. Convert sync fs in agent module + +- [ ] 5.1 Convert `src/agent/agents/coding.js` — replace `readFileSync` with `readFile` from `node:fs/promises`, make `loadCodingPrompt()` async +- [ ] 5.2 Convert `src/agent/agents/debug.js` — replace `readFileSync` with `readFile` from `node:fs/promises`, make `loadDebugPrompt()` async +- [ ] 5.3 Convert `src/agent/agents/documentation.js` — replace `readFileSync` with `readFile` from `node:fs/promises`, make `loadDocumentationPrompt()` async +- [ ] 5.4 Convert `src/agent/agents/code-review.js` — replace `readFileSync` with `readFile` from `node:fs/promises`, make `loadCodeReviewPrompt()` async +- [ ] 5.5 Convert `src/agent/agents/search.js` — replace `readFileSync` with `readFile` from `node:fs/promises`, make `loadSearchPrompt()` async +- [ ] 5.6 Convert `src/agent/agents/security-audit.js` — replace `readFileSync` with `readFile` from `node:fs/promises`, make `loadSecurityAuditPrompt()` async +- [ ] 5.7 Convert `src/agent/agents/performance.js` — replace `readFileSync` with `readFile` from `node:fs/promises`, make `loadPerformancePrompt()` async +- [ ] 5.8 Convert `src/agent/agents/testing.js` — replace `readFileSync` with `readFile` from `node:fs/promises`, make `loadTestingPrompt()` async +- [ ] 5.9 Convert `src/agent/agents/research.js` — replace `readFileSync` with `readFile` from `node:fs/promises`, make `loadResearchPrompt()` async + +## 6. Replace silent catch blocks + +- [ ] 6.1 Replace bare `catch {}` in `src/memory/retention.js` (lines 29, 63) with `catch (err) { logger.debug(...) }` +- [ ] 6.2 Replace bare `catch {}` in `src/memory/expireEphemeral.js` (lines 23, 52, 64) with `catch (err) { logger.debug(...) }` +- [ ] 6.3 Replace bare `catch {}` in `src/scheduler/scheduler.js` (lines 65, 69, 197, 200) with `catch (err) { logger.error(...) }` +- [ ] 6.4 Replace bare `catch {}` in `src/scheduler/cron.js` (lines 429, 432, 439, 475, 479) with `catch (err) { logger.debug(...) }` +- [ ] 6.5 Replace bare `catch {}` in `src/skills/discoverer.js` (lines 42, 57, 81, 94, 172) with `catch (err) { logger.debug(...) }` +- [ ] 6.6 Replace bare `catch {}` in `src/agent/agents/coding.js` (line 17) with `catch (err) { logger.debug(...) }` +- [ ] 6.7 Replace bare `catch {}` in `src/agent/agents/debug.js` (line 17) with `catch (err) { logger.debug(...) }` +- [ ] 6.8 Replace bare `catch {}` in `src/agent/agents/documentation.js` (line 17) with `catch (err) { logger.debug(...) }` +- [ ] 6.9 Replace bare `catch {}` in `src/agent/agents/code-review.js` (line 17) with `catch (err) { logger.debug(...) }` +- [ ] 6.10 Replace bare `catch {}` in `src/agent/agents/search.js` (line 17) with `catch (err) { logger.debug(...) }` +- [ ] 6.11 Replace bare `catch {}` in `src/agent/agents/security-audit.js` (line 17) with `catch (err) { logger.debug(...) }` +- [ ] 6.12 Replace bare `catch {}` in `src/agent/agents/performance.js` (line 17) with `catch (err) { logger.debug(...) }` +- [ ] 6.13 Replace bare `catch {}` in `src/agent/agents/testing.js` (line 17) with `catch (err) { logger.debug(...) }` +- [ ] 6.14 Replace bare `catch {}` in `src/agent/agents/research.js` (line 17) with `catch (err) { logger.debug(...) }` +- [ ] 6.15 Replace bare `catch {}` in `src/tui/contextTokens.js` (lines 21, 40) with `catch (err) { logger.debug(...) }` +- [ ] 6.16 Replace bare `catch {}` in `src/tui/statusBar.js` (line 35) with `catch (err) { logger.debug(...) }` +- [ ] 6.17 Replace bare `catch {}` in `src/workspace/loadAgents.js` (line 16) with `catch (err) { logger.debug(...) }` + +## 7. Update callers of converted functions + +- [ ] 7.1 Update `src/agent/deepAgents.js` — await `loadSystemPrompt()` and `skillRegistry.discover()` +- [ ] 7.2 Update `src/scheduler/scheduler.js` — await `loadContext()` +- [ ] 7.3 Update `src/tui/app.js` — await `loadSystemPrompt()` calls +- [ ] 7.4 Update `src/tools/skills.js` — await `skillRegistry.getSkillBody()` and `skillRegistry.discover()` +- [ ] 7.5 Update `src/tools/session_search.js` — await `parseFrontmatter` calls (if needed) +- [ ] 7.6 Update `src/session/factory.js` — await `loadSession()` +- [ ] 7.7 Update `src/session/onboarding.js` — await `saveProfile()` +- [ ] 7.8 Update `src/skills/registry.js` — await `discoverSkills()` in `discover()` method + +## 8. Update tests + +- [ ] 8.1 Update `tests/unit/memory/context.test.js` — mock async fs calls +- [ ] 8.2 Update `tests/unit/memory/reader.test.js` — mock async fs calls +- [ ] 8.3 Update `tests/unit/memory/writer.test.js` — mock async fs calls +- [ ] 8.4 Update `tests/unit/memory/profile.test.js` — mock async fs calls +- [ ] 8.5 Update `tests/unit/memory/prompts.test.js` — mock async fs calls +- [ ] 8.6 Update `tests/unit/memory/retention.test.js` — mock async fs calls +- [ ] 8.7 Update `tests/unit/skills/discoverer.test.js` — mock async fs calls +- [ ] 8.8 Update `tests/unit/skills/registry.test.js` — mock async fs calls +- [ ] 8.9 Update `tests/unit/session/loader.test.js` — mock async fs calls +- [ ] 8.10 Update `tests/unit/sandbox/runner.test.js` — mock async fs calls +- [ ] 8.11 Update `tests/unit/agent/agents/*.test.js` — mock async fs calls + +## 9. Verification + +- [ ] 9.1 Run `npm run test` — all tests pass +- [ ] 9.2 Run `npm run lint` — no lint errors +- [ ] 9.3 Run `npm run coverage` — coverage maintained +- [ ] 9.4 Verify no sync fs calls remain in async contexts: `grep -rn 'readFileSync\|writeFileSync\|readdirSync\|statSync\|existsSync\|mkdirSync\|unlinkSync' src/ --include='*.js' | grep -v 'config/loader.js' | grep -v 'logger.js'` +- [ ] 9.5 Verify no bare catch blocks remain: `grep -rn 'catch {' src/ --include='*.js' | grep -v '.test.'` diff --git a/openspec/specs/async-fs/spec.md b/openspec/specs/async-fs/spec.md new file mode 100644 index 00000000..e1d15255 --- /dev/null +++ b/openspec/specs/async-fs/spec.md @@ -0,0 +1,39 @@ +# async-fs Specification + +## Purpose +TBD - created by archiving change replace-sync-fs-calls-and-silent-catches. Update Purpose after archive. +## Requirements +### Requirement: Async fs operations in async contexts +The system SHALL use `node:fs/promises` for all fs operations (`readFile`, `writeFile`, `readdir`, `stat`, `access`, `mkdir`, `unlink`) in functions that are async or called from async contexts. Synchronous fs operations (`readFileSync`, `writeFileSync`, `readdirSync`, `statSync`, `existsSync`, `mkdirSync`, `unlinkSync`) are prohibited in async contexts per AGENTS.md §1.1. + +#### Scenario: loadContext uses async fs +- **WHEN** `loadContext()` in `src/memory/context.js` reads context files +- **THEN** it uses `readFile` and `readdir` from `node:fs/promises` instead of `readFileSync` and `readdirSync` + +#### Scenario: loadSystemPrompt uses async fs +- **WHEN** `loadSystemPrompt()` in `src/memory/prompts.js` reads the system prompt file +- **THEN** it uses `readFile` from `node:fs/promises` instead of `readFileSync` + +#### Scenario: getSkillBody uses async fs +- **WHEN** `getSkillBody()` in `src/skills/registry.js` reads a skill's SKILL.md body +- **THEN** it uses `readFile` from `node:fs/promises` instead of `readFileSync` + +#### Scenario: Module-level sync fs preserved +- **WHEN** `config/loader.js` or `logger.js` perform module-level initialization +- **THEN** synchronous fs operations are preserved (they are not called from async contexts during initialization) + +### Requirement: No blocking fs in async call chains +The system SHALL ensure that no file in the async call chain uses blocking fs operations. If a function is called from an async context, all fs operations within it and its transitive callees must be async. + +#### Scenario: detectShebang uses async fs +- **WHEN** `detectShebang()` in `src/sandbox/runner.js` reads a script's first line +- **THEN** it uses `readFile` and `access` from `node:fs/promises` instead of `readFileSync` and `existsSync` + +#### Scenario: discoverSkills uses async fs +- **WHEN** `discoverSkills()` in `src/skills/discoverer.js` scans skill directories +- **THEN** it uses `readdir`, `stat`, `readFile`, and `access` from `node:fs/promises` instead of sync equivalents + +#### Scenario: loadSession uses async fs +- **WHEN** `loadSession()` in `src/session/loader.js` reads session files +- **THEN** it uses `readFile`, `readdir`, and `stat` from `node:fs/promises` instead of sync equivalents + diff --git a/openspec/specs/error-logging/spec.md b/openspec/specs/error-logging/spec.md new file mode 100644 index 00000000..03b2f41a --- /dev/null +++ b/openspec/specs/error-logging/spec.md @@ -0,0 +1,31 @@ +# error-logging Specification + +## Purpose +TBD - created by archiving change replace-sync-fs-calls-and-silent-catches. Update Purpose after archive. +## Requirements +### Requirement: All catch blocks log errors +The system SHALL log all caught errors using the structured `logger` singleton from `src/logger.js`. No bare `catch {}` blocks are permitted per AGENTS.md §1.1. + +#### Scenario: Expected failures use debug level +- **WHEN** a file not found error occurs during skill discovery +- **THEN** the catch block logs via `logger.debug()` with the error message + +#### Scenario: Unexpected failures use error level +- **WHEN** a schedule execution fails unexpectedly +- **THEN** the catch block logs via `logger.error()` with the error message + +#### Scenario: Silent catches are eliminated +- **WHEN** any file in the codebase has a `catch {}` block +- **THEN** it has been replaced with `catch (err) { logger.debug(...) }` or `catch (err) { logger.error(...) }` + +### Requirement: Error logging preserves existing semantics +The system SHALL preserve existing error handling semantics — errors that were previously swallowed should still be handled gracefully, but now with logging. Errors that were previously re-thrown should continue to be re-thrown. + +#### Scenario: Graceful degradation with logging +- **WHEN** `loadSystemPrompt()` fails to find the system prompt file +- **THEN** it logs the error and returns an empty string (same behavior as before, but now logged) + +#### Scenario: Retention cleanup with logging +- **WHEN** `cleanRetainedMemory()` encounters a directory it cannot read +- **THEN** it logs the error and returns 0 (same behavior as before, but now logged) + diff --git a/src/agent/agents/code-review.js b/src/agent/agents/code-review.js index 3f4c06e8..aa7a5564 100644 --- a/src/agent/agents/code-review.js +++ b/src/agent/agents/code-review.js @@ -2,19 +2,21 @@ * Code review agent definition for structured code reviews. */ -import { readFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; import { join } from "node:path"; +import { logger } from "../../logger.js"; /** * Load the code review agent system prompt from disk. * @param {string} [baseDir] - Base directory (defaults to process.cwd()) - * @returns {string} System prompt text + * @returns {Promise} System prompt text */ -function loadCodeReviewPrompt(baseDir) { +async function loadCodeReviewPrompt(baseDir) { try { const dir = baseDir || process.cwd(); - return readFileSync(join(dir, "prompts", "CODE_REVIEW.md"), "utf-8"); - } catch { + return await readFile(join(dir, "prompts", "CODE_REVIEW.md"), "utf-8"); + } catch (err) { + logger.debug(`[code-review] Failed to load prompt: ${err.message}`); return ""; } } @@ -27,5 +29,9 @@ export const codeReviewAgent = { name: "code-review", description: "Specialized agent for structured code reviews covering bugs, security, style, and performance.", - systemPrompt: loadCodeReviewPrompt(), + systemPrompt: "", }; + +loadCodeReviewPrompt().then((prompt) => { + codeReviewAgent.systemPrompt = prompt; +}); diff --git a/src/agent/agents/coding.js b/src/agent/agents/coding.js index 0ad5c774..9cba0c0e 100644 --- a/src/agent/agents/coding.js +++ b/src/agent/agents/coding.js @@ -2,19 +2,21 @@ * Coding agent definition for code execution and editing. */ -import { readFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; import { join } from "node:path"; +import { logger } from "../../logger.js"; /** * Load the coding agent system prompt from disk. * @param {string} [baseDir] - Base directory (defaults to process.cwd()) - * @returns {string} System prompt text + * @returns {Promise} System prompt text */ -function loadCodingPrompt(baseDir) { +async function loadCodingPrompt(baseDir) { try { const dir = baseDir || process.cwd(); - return readFileSync(join(dir, "prompts", "CODING.md"), "utf-8"); - } catch { + return await readFile(join(dir, "prompts", "CODING.md"), "utf-8"); + } catch (err) { + logger.debug(`[coding] Failed to load prompt: ${err.message}`); return ""; } } @@ -26,5 +28,10 @@ function loadCodingPrompt(baseDir) { export const codingAgent = { name: "coding", description: "Specialized agent for code editing, debugging, testing, and implementation tasks.", - systemPrompt: loadCodingPrompt(), + systemPrompt: "", }; + +// Load prompt asynchronously at module initialization +loadCodingPrompt().then((prompt) => { + codingAgent.systemPrompt = prompt; +}); diff --git a/src/agent/agents/debug.js b/src/agent/agents/debug.js index bafc8897..6499300b 100644 --- a/src/agent/agents/debug.js +++ b/src/agent/agents/debug.js @@ -1,20 +1,22 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { logger } from "../../logger.js"; + /** * Debug agent definition for error tracing and fix proposals. */ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - /** * Load the debug agent system prompt from disk. * @param {string} [baseDir] - Base directory (defaults to process.cwd()) - * @returns {string} System prompt text + * @returns {Promise} System prompt text */ -function loadDebugPrompt(baseDir) { +async function loadDebugPrompt(baseDir) { try { const dir = baseDir || process.cwd(); - return readFileSync(join(dir, "prompts", "DEBUG.md"), "utf-8"); - } catch { + return await readFile(join(dir, "prompts", "DEBUG.md"), "utf-8"); + } catch (err) { + logger.debug(`[debug] Failed to load prompt: ${err.message}`); return ""; } } @@ -27,5 +29,9 @@ export const debugAgent = { name: "debug", description: "Specialized agent for error tracing, reproduction, and fix proposals with dedicated context.", - systemPrompt: loadDebugPrompt(), + systemPrompt: "", }; + +loadDebugPrompt().then((prompt) => { + debugAgent.systemPrompt = prompt; +}); diff --git a/src/agent/agents/documentation.js b/src/agent/agents/documentation.js index 6b7d3487..4047765f 100644 --- a/src/agent/agents/documentation.js +++ b/src/agent/agents/documentation.js @@ -2,19 +2,21 @@ * Documentation agent definition for documentation updates. */ -import { readFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; import { join } from "node:path"; +import { logger } from "../../logger.js"; /** * Load the documentation agent system prompt from disk. * @param {string} [baseDir] - Base directory (defaults to process.cwd()) - * @returns {string} System prompt text + * @returns {Promise} System prompt text */ -function loadDocumentationPrompt(baseDir) { +async function loadDocumentationPrompt(baseDir) { try { const dir = baseDir || process.cwd(); - return readFileSync(join(dir, "prompts", "DOCUMENTATION.md"), "utf-8"); - } catch { + return await readFile(join(dir, "prompts", "DOCUMENTATION.md"), "utf-8"); + } catch (err) { + logger.debug(`[documentation] Failed to load prompt: ${err.message}`); return ""; } } @@ -27,5 +29,9 @@ export const documentationAgent = { name: "documentation", description: "Specialized agent for documentation updates, API docs generation, and changelog maintenance.", - systemPrompt: loadDocumentationPrompt(), + systemPrompt: "", }; + +loadDocumentationPrompt().then((prompt) => { + documentationAgent.systemPrompt = prompt; +}); diff --git a/src/agent/agents/performance.js b/src/agent/agents/performance.js index 7bcaf17e..6472c211 100644 --- a/src/agent/agents/performance.js +++ b/src/agent/agents/performance.js @@ -2,19 +2,21 @@ * Performance agent definition for performance benchmarking. */ -import { readFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; import { join } from "node:path"; +import { logger } from "../../logger.js"; /** * Load the performance agent system prompt from disk. * @param {string} [baseDir] - Base directory (defaults to process.cwd()) - * @returns {string} System prompt text + * @returns {Promise} System prompt text */ -function loadPerformancePrompt(baseDir) { +async function loadPerformancePrompt(baseDir) { try { const dir = baseDir || process.cwd(); - return readFileSync(join(dir, "prompts", "PERFORMANCE.md"), "utf-8"); - } catch { + return await readFile(join(dir, "prompts", "PERFORMANCE.md"), "utf-8"); + } catch (err) { + logger.debug(`[performance] Failed to load prompt: ${err.message}`); return ""; } } @@ -27,5 +29,9 @@ export const performanceAgent = { name: "performance", description: "Specialized agent for performance benchmarking, bottleneck identification, and optimization suggestions.", - systemPrompt: loadPerformancePrompt(), + systemPrompt: "", }; + +loadPerformancePrompt().then((prompt) => { + performanceAgent.systemPrompt = prompt; +}); diff --git a/src/agent/agents/research.js b/src/agent/agents/research.js index 3c773a2f..72991f6e 100644 --- a/src/agent/agents/research.js +++ b/src/agent/agents/research.js @@ -2,19 +2,21 @@ * Research agent definition for multi-step research. */ -import { readFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; import { join } from "node:path"; +import { logger } from "../../logger.js"; /** * Load the research agent system prompt from disk. * @param {string} [baseDir] - Base directory (defaults to process.cwd()) - * @returns {string} System prompt text + * @returns {Promise} System prompt text */ -function loadResearchPrompt(baseDir) { +async function loadResearchPrompt(baseDir) { try { const dir = baseDir || process.cwd(); - return readFileSync(join(dir, "prompts", "RESEARCH.md"), "utf-8"); - } catch { + return await readFile(join(dir, "prompts", "RESEARCH.md"), "utf-8"); + } catch (err) { + logger.debug(`[research] Failed to load prompt: ${err.message}`); return ""; } } @@ -27,5 +29,9 @@ export const researchAgent = { name: "research", description: "Specialized agent for multi-step research with source tracking and comprehensive reports.", - systemPrompt: loadResearchPrompt(), + systemPrompt: "", }; + +loadResearchPrompt().then((prompt) => { + researchAgent.systemPrompt = prompt; +}); diff --git a/src/agent/agents/search.js b/src/agent/agents/search.js index d617eda0..18c5004b 100644 --- a/src/agent/agents/search.js +++ b/src/agent/agents/search.js @@ -2,19 +2,21 @@ * Search agent definition for multi-source search and synthesis. */ -import { readFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; import { join } from "node:path"; +import { logger } from "../../logger.js"; /** * Load the search agent system prompt from disk. * @param {string} [baseDir] - Base directory (defaults to process.cwd()) - * @returns {string} System prompt text + * @returns {Promise} System prompt text */ -function loadSearchPrompt(baseDir) { +async function loadSearchPrompt(baseDir) { try { const dir = baseDir || process.cwd(); - return readFileSync(join(dir, "prompts", "SEARCH.md"), "utf-8"); - } catch { + return await readFile(join(dir, "prompts", "SEARCH.md"), "utf-8"); + } catch (err) { + logger.debug(`[search] Failed to load prompt: ${err.message}`); return ""; } } @@ -27,5 +29,9 @@ export const searchAgent = { name: "search", description: "Specialized agent for multi-source search (web, docs, codebase) with synthesis into structured summaries.", - systemPrompt: loadSearchPrompt(), + systemPrompt: "", }; + +loadSearchPrompt().then((prompt) => { + searchAgent.systemPrompt = prompt; +}); diff --git a/src/agent/agents/security-audit.js b/src/agent/agents/security-audit.js index ca80bd86..ddbfdb9f 100644 --- a/src/agent/agents/security-audit.js +++ b/src/agent/agents/security-audit.js @@ -2,19 +2,21 @@ * Security audit agent definition for security scanning. */ -import { readFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; import { join } from "node:path"; +import { logger } from "../../logger.js"; /** * Load the security audit agent system prompt from disk. * @param {string} [baseDir] - Base directory (defaults to process.cwd()) - * @returns {string} System prompt text + * @returns {Promise} System prompt text */ -function loadSecurityAuditPrompt(baseDir) { +async function loadSecurityAuditPrompt(baseDir) { try { const dir = baseDir || process.cwd(); - return readFileSync(join(dir, "prompts", "SECURITY_AUDIT.md"), "utf-8"); - } catch { + return await readFile(join(dir, "prompts", "SECURITY_AUDIT.md"), "utf-8"); + } catch (err) { + logger.debug(`[security-audit] Failed to load prompt: ${err.message}`); return ""; } } @@ -27,5 +29,9 @@ export const securityAuditAgent = { name: "security-audit", description: "Specialized agent for security scanning, dependency auditing, and vulnerability detection.", - systemPrompt: loadSecurityAuditPrompt(), + systemPrompt: "", }; + +loadSecurityAuditPrompt().then((prompt) => { + securityAuditAgent.systemPrompt = prompt; +}); diff --git a/src/agent/agents/testing.js b/src/agent/agents/testing.js index 36a584ba..605ee067 100644 --- a/src/agent/agents/testing.js +++ b/src/agent/agents/testing.js @@ -2,19 +2,21 @@ * Testing agent definition for test generation and gap analysis. */ -import { readFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; import { join } from "node:path"; +import { logger } from "../../logger.js"; /** * Load the testing agent system prompt from disk. * @param {string} [baseDir] - Base directory (defaults to process.cwd()) - * @returns {string} System prompt text + * @returns {Promise} System prompt text */ -function loadTestingPrompt(baseDir) { +async function loadTestingPrompt(baseDir) { try { const dir = baseDir || process.cwd(); - return readFileSync(join(dir, "prompts", "TESTING.md"), "utf-8"); - } catch { + return await readFile(join(dir, "prompts", "TESTING.md"), "utf-8"); + } catch (err) { + logger.debug(`[testing] Failed to load prompt: ${err.message}`); return ""; } } @@ -26,5 +28,9 @@ function loadTestingPrompt(baseDir) { export const testingAgent = { name: "testing", description: "Specialized agent for test generation, gap analysis, and coverage improvements.", - systemPrompt: loadTestingPrompt(), + systemPrompt: "", }; + +loadTestingPrompt().then((prompt) => { + testingAgent.systemPrompt = prompt; +}); diff --git a/src/agent/deepAgents.js b/src/agent/deepAgents.js index 81c9c4f0..7bd95ade 100644 --- a/src/agent/deepAgents.js +++ b/src/agent/deepAgents.js @@ -123,7 +123,7 @@ function buildSkillsMapping(skillRegistry) { */ export async function createDeepAgentsOrchestrator(checkpointer = null) { const config = loadConfig(); - let systemPrompt = loadSystemPrompt(); + let systemPrompt = await loadSystemPrompt(); const agentsPath = join(config.cwd, "AGENTS.md"); // Discover skills from configured scopes diff --git a/src/memory/context.js b/src/memory/context.js index 006a194a..e88c11e5 100644 --- a/src/memory/context.js +++ b/src/memory/context.js @@ -1,8 +1,9 @@ -import { readdirSync, readFileSync } from "node:fs"; +import { readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; import { loadConfig } from "../config/loader.js"; import { parseFrontmatter } from "./reader.js"; import { loadProfile, formatProfileContext } from "./profile.js"; +import { logger } from "../logger.js"; const cwd = loadConfig().cwd; const PROFILE_FILENAME = "profile.md"; @@ -14,16 +15,16 @@ const PROFILE_FILENAME = "profile.md"; * then ephemeral memories (if any) sorted newest first. * @param {string} contextDir - Path to the context directory * @param {number} limit - Maximum number of recent context files to load (excludes profile and ephemeral) - * @returns {string} Combined context content with profile prefix + * @returns {Promise} Combined context content with profile prefix */ -export function loadContext(contextDir = "memory/context/", limit = 10, cwdParam = cwd) { +export async 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); + const profileBlock = await loadAndFormatProfile(fullPath, contextDir); // Load all .md files (excluding profile.md) - const allFiles = readdirSync(fullPath).filter( + const allFiles = (await readdir(fullPath)).filter( (f) => f.endsWith(".md") && f !== PROFILE_FILENAME, ); @@ -32,10 +33,10 @@ export function loadContext(contextDir = "memory/context/", limit = 10, cwdParam const ephemeralFiles = allFiles.filter((f) => f.startsWith("ephemeral")); // Process persistent files sorted by timestamp (newest first) - const persistentEntries = persistentFiles - .map((filename) => { + const persistentEntries = await Promise.all( + persistentFiles.map(async (filename) => { const filepath = join(fullPath, filename); - const content = readFileSync(filepath, "utf-8"); + const content = await readFile(filepath, "utf-8"); const { frontmatter, content: body } = parseFrontmatter(content); return { filepath, @@ -43,12 +44,13 @@ export function loadContext(contextDir = "memory/context/", limit = 10, cwdParam body, timestamp: frontmatter.timestamp || "", }; - }) - .sort((a, b) => { - const aTs = a.timestamp instanceof Date ? a.timestamp.toISOString() : a.timestamp; - const bTs = b.timestamp instanceof Date ? b.timestamp.toISOString() : b.timestamp; - return (bTs || "").localeCompare(aTs || ""); - }); + }), + ); + persistentEntries.sort((a, b) => { + const aTs = a.timestamp instanceof Date ? a.timestamp.toISOString() : a.timestamp; + const bTs = b.timestamp instanceof Date ? b.timestamp.toISOString() : b.timestamp; + return (bTs || "").localeCompare(aTs || ""); + }); const recent = persistentEntries.slice(0, limit); const contextBlocks = recent @@ -60,10 +62,10 @@ export function loadContext(contextDir = "memory/context/", limit = 10, cwdParam // Load ephemeral memories last (newest first, limited) const ephemeralLimit = loadConfig().memory.ephemeralLimit; - const ephemeralEntries = ephemeralFiles - .map((filename) => { + const ephemeralEntries = await Promise.all( + ephemeralFiles.map(async (filename) => { const filepath = join(fullPath, filename); - const content = readFileSync(filepath, "utf-8"); + const content = await readFile(filepath, "utf-8"); const { frontmatter, content: body } = parseFrontmatter(content); return { filepath, @@ -71,12 +73,13 @@ export function loadContext(contextDir = "memory/context/", limit = 10, cwdParam body, timestamp: frontmatter.timestamp || "", }; - }) - .sort((a, b) => { - const aTs = a.timestamp instanceof Date ? a.timestamp.toISOString() : a.timestamp; - const bTs = b.timestamp instanceof Date ? b.timestamp.toISOString() : b.timestamp; - return (bTs || "").localeCompare(aTs || ""); - }); + }), + ); + ephemeralEntries.sort((a, b) => { + const aTs = a.timestamp instanceof Date ? a.timestamp.toISOString() : a.timestamp; + const bTs = b.timestamp instanceof Date ? b.timestamp.toISOString() : b.timestamp; + return (bTs || "").localeCompare(aTs || ""); + }); const recentEphemeral = ephemeralEntries.slice(0, ephemeralLimit); const ephemeralBlocks = recentEphemeral @@ -89,7 +92,8 @@ export function loadContext(contextDir = "memory/context/", limit = 10, cwdParam if (!profileBlock && !contextBlocks && !ephemeralBlocks) return ""; const result = (profileBlock ? profileBlock + "\n" : "") + contextBlocks + ephemeralBlocks; return result; - } catch { + } catch (err) { + logger.debug(`[context] Failed to load context: ${err.message}`); return ""; } } @@ -98,15 +102,16 @@ export function loadContext(contextDir = "memory/context/", limit = 10, cwdParam * Load the context profile and format it for LLM prompts. * @param {string} fullPath - Full path to the context directory * @param {string} contextDir - Relative context directory path - * @returns {string} Formatted profile context block or empty string + * @returns {Promise} Formatted profile context block or empty string */ -function loadAndFormatProfile(fullPath, contextDir, cwdParam = cwd) { +async function loadAndFormatProfile(fullPath, contextDir, cwdParam = cwd) { try { const profilePath = join(cwdParam, contextDir, PROFILE_FILENAME); - const profile = loadProfile(profilePath); + const profile = await loadProfile(profilePath); if (!profile) return ""; return formatProfileContext(profile.data); - } catch { + } catch (err) { + logger.debug(`[context] Failed to load profile: ${err.message}`); return ""; } } diff --git a/src/memory/expireEphemeral.js b/src/memory/expireEphemeral.js index b7888535..d59e7427 100644 --- a/src/memory/expireEphemeral.js +++ b/src/memory/expireEphemeral.js @@ -2,6 +2,7 @@ import { readdir, unlink, readFile } from "node:fs/promises"; import { join } from "node:path"; import { parseFrontmatter } from "./reader.js"; import { loadConfig } from "../config/loader.js"; +import { logger } from "../logger.js"; const cwd = loadConfig().cwd; @@ -20,7 +21,8 @@ export async function readEphemeralFile(contextDir, filename, cwdParam = cwd) { ephemeral: frontmatter.ephemeral === true, expiresAt: frontmatter.ephemeral_expiresAt || "", }; - } catch { + } catch (err) { + logger.debug(`[expireEphemeral] Failed to read ephemeral file: ${err.message}`); return null; } } @@ -49,7 +51,8 @@ export async function expireEphemeralMemories(contextDir, nowStr, cwdParam = cwd let files; try { files = await readdir(join(cwdParam, contextDir)); - } catch { + } catch (err) { + logger.debug(`[expireEphemeral] Failed to read directory: ${err.message}`); return 0; } let removed = 0; @@ -61,8 +64,8 @@ export async function expireEphemeralMemories(contextDir, nowStr, cwdParam = cwd try { await unlink(filepath); removed++; - } catch { - // Ignore deletion errors + } catch (unlinkErr) { + logger.debug(`[expireEphemeral] Failed to delete file: ${unlinkErr.message}`); } } } diff --git a/src/memory/profile.js b/src/memory/profile.js index f4d7a8f5..81e3a3f6 100644 --- a/src/memory/profile.js +++ b/src/memory/profile.js @@ -1,6 +1,7 @@ -import { readFileSync, writeFileSync, existsSync, renameSync, mkdirSync } from "node:fs"; +import { readFile, writeFile, rename, mkdir, access, constants } from "node:fs/promises"; import { join } from "node:path"; import { loadConfig } from "../config/loader.js"; +import { logger } from "../logger.js"; const config = loadConfig(); const PROFILE_DIR = join(config.cwd, config.memory.contextDir); @@ -59,12 +60,17 @@ function parseProfileBody(body) { /** * Load the user's context profile from the markdown body. * @param {string} [profilePath] - Optional path override (default: memory/context/profile.md) - * @returns {{ data: Object, body: string } | null} The parsed profile or null if not found + * @returns {Promise<{ data: Object, body: string } | null>} The parsed profile or null if not found */ -export function loadProfile(profilePath = PROFILE_PATH) { - if (!existsSync(profilePath)) return null; +export async function loadProfile(profilePath = PROFILE_PATH) { try { - const content = readFileSync(profilePath, "utf-8"); + await access(profilePath, constants.F_OK); + } catch (err) { + logger.debug(`[profile] Failed to access profile: ${err.message}`); + return null; + } + try { + const content = await readFile(profilePath, "utf-8"); const body = content.trim(); if (!body) return null; const data = parseProfileBody(body); @@ -72,7 +78,8 @@ export function loadProfile(profilePath = PROFILE_PATH) { const hasProfile = attrKeys.some((k) => Object.prototype.hasOwnProperty.call(data, k)); if (!hasProfile) return null; return { data, body }; - } catch { + } catch (err) { + logger.debug(`[profile] Failed to read profile: ${err.message}`); return null; } } @@ -81,25 +88,38 @@ export function loadProfile(profilePath = PROFILE_PATH) { * Save the user's context profile atomically to the markdown body. * @param {Object} profileData - Profile object with attribute keys * @param {string} [profilePath] - Optional path override + * @returns {Promise} */ -export function saveProfile(profileData, profilePath = PROFILE_PATH) { +export async function saveProfile(profileData, profilePath = PROFILE_PATH) { const profileDir = join(profilePath, ".."); - if (!existsSync(profileDir)) { - mkdirSync(profileDir, { recursive: true }); + try { + await access(profileDir, constants.F_OK); + } catch (_err) { + try { + await mkdir(profileDir, { recursive: true }); + } catch (mkdirErr) { + logger.debug(`[profile] Failed to create profile dir: ${mkdirErr.message}`); + } } const content = buildProfileContent(profileData); const tmpPath = profilePath + ".tmp"; - writeFileSync(tmpPath, content, "utf-8"); - renameSync(tmpPath, profilePath); + await writeFile(tmpPath, content, "utf-8"); + await rename(tmpPath, profilePath); } /** * Check whether a context profile exists on disk. * @param {string} [profilePath] - Optional path override - * @returns {boolean} + * @returns {Promise} */ -export function hasProfile(profilePath = PROFILE_PATH) { - return existsSync(profilePath); +export async function hasProfile(profilePath = PROFILE_PATH) { + try { + await access(profilePath, constants.F_OK); + return true; + } catch (err) { + logger.debug(`[profile] Profile not found: ${err.message}`); + return false; + } } /** diff --git a/src/memory/prompts.js b/src/memory/prompts.js index 47aa8893..4008d864 100644 --- a/src/memory/prompts.js +++ b/src/memory/prompts.js @@ -1,7 +1,8 @@ -import { readFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { loadContext } from "./context.js"; import { loadConfig } from "../config/loader.js"; +import { logger } from "../logger.js"; const cwd = loadConfig().cwd; @@ -9,12 +10,12 @@ const cwd = loadConfig().cwd; * Load the system prompt from prompts/SYSTEM_PROMPT.md, * appending the current memory context to the end. * @param {string} [baseDir=cwd] - Base directory for loading the prompt file - * @returns {string} System prompt text with appended context, or empty string if file not found + * @returns {Promise} System prompt text with appended context, or empty string if file not found */ -export function loadSystemPrompt(baseDir = cwd) { +export async function loadSystemPrompt(baseDir = cwd) { try { const path = join(baseDir, "prompts", "SYSTEM_PROMPT.md"); - let content = readFileSync(path, "utf-8"); + let content = await readFile(path, "utf-8"); if (content.startsWith("---")) { const closeIdx = content.indexOf("---", 3); if (closeIdx !== -1) { @@ -22,12 +23,13 @@ export function loadSystemPrompt(baseDir = cwd) { } } // Append memory context to the system prompt - const context = loadContext(); + const context = await loadContext(); if (context) { content = content + "\n\n---\n\n" + context; } return content; - } catch { + } catch (err) { + logger.debug(`[prompts] Failed to load system prompt: ${err.message}`); return ""; } } diff --git a/src/memory/reader.js b/src/memory/reader.js index 39e29bd5..23274f73 100644 --- a/src/memory/reader.js +++ b/src/memory/reader.js @@ -1,5 +1,6 @@ -import { readFileSync, existsSync } from "node:fs"; +import { readFile, access, constants } from "node:fs/promises"; import { load } from "js-yaml"; +import { logger } from "../logger.js"; /** * Parse YAML frontmatter from a markdown file. @@ -18,7 +19,8 @@ export function parseFrontmatter(content) { const fmParsed = (() => { try { return load(fmStr); - } catch { + } catch (err) { + logger.debug(`[reader] YAML parse failed: ${err.message}`); return {}; } })(); @@ -45,11 +47,16 @@ export function parseFrontmatter(content) { * Load and parse a memory markdown file. * Returns { frontmatter, content, path }. * @param {string} filepath - Full path to the markdown file - * @returns {{ frontmatter: Object, content: string, path: string } | null} + * @returns {Promise<{ frontmatter: Object, content: string, path: string } | null>} */ -export function readMemoryFile(filepath) { - if (!existsSync(filepath)) return null; - const content = readFileSync(filepath, "utf-8"); +export async function readMemoryFile(filepath) { + try { + await access(filepath, constants.F_OK); + } catch (err) { + logger.debug(`[reader] File not found: ${err.message}`); + return null; + } + const content = await readFile(filepath, "utf-8"); const { frontmatter, content: body } = parseFrontmatter(content); return { frontmatter, content: body, path: filepath }; } diff --git a/src/memory/retention.js b/src/memory/retention.js index 7911e913..7698736f 100644 --- a/src/memory/retention.js +++ b/src/memory/retention.js @@ -1,6 +1,7 @@ -import { readdirSync, statSync, unlinkSync } from "node:fs"; +import { readdir, stat, unlink } from "node:fs/promises"; import { join } from "node:path"; import { loadConfig } from "../config/loader.js"; +import { logger } from "../logger.js"; const cwd = loadConfig().cwd; @@ -8,26 +9,26 @@ const cwd = loadConfig().cwd; * Remove memory files older than the retention policy allows. * @param {string} directory - The memory directory to clean * @param {number} retentionDays - Maximum age in days - * @returns {number} Number of files removed + * @returns {Promise} Number of files removed */ -export function cleanRetainedMemory(directory, retentionDays = 90, cwdParam = cwd) { +export async function cleanRetainedMemory(directory, retentionDays = 90, cwdParam = cwd) { const fullPath = join(cwdParam, directory); const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000; let removed = 0; try { - const files = readdirSync(fullPath); + const files = await readdir(fullPath); for (const filename of files) { if (!filename.endsWith(".md")) continue; const filepath = join(fullPath, filename); - const stat = statSync(filepath); - if (stat.mtimeMs < cutoff) { - unlinkSync(filepath); + const st = await stat(filepath); + if (st.mtimeMs < cutoff) { + await unlink(filepath); removed++; } } - } catch { - // Directory doesn't exist or can't be read — skip silently + } catch (err) { + logger.debug(`[retention] Failed to read directory: ${err.message}`); } return removed; @@ -37,31 +38,33 @@ export function cleanRetainedMemory(directory, retentionDays = 90, cwdParam = cw * Enforce maximum entry count across a memory directory. * @param {string} directory - The memory directory to clean * @param {number} maxEntries - Maximum number of files to keep - * @returns {number} Number of files removed + * @returns {Promise} Number of files removed */ -export function enforceMaxEntries(directory, maxEntries = 1000, cwdParam = cwd) { +export async function enforceMaxEntries(directory, maxEntries = 1000, cwdParam = cwd) { const fullPath = join(cwdParam, directory); let removed = 0; try { - const files = readdirSync(fullPath) + const files = await readdir(fullPath); + const entries = files .filter((f) => f.endsWith(".md")) - .map((filename) => { + .map(async (filename) => { const filepath = join(fullPath, filename); - const mtime = statSync(filepath).mtimeMs; - return { filepath, mtime }; - }) - .sort((a, b) => a.mtime - b.mtime); + const st = await stat(filepath); + return { filepath, mtime: st.mtimeMs }; + }); + const resolved = await Promise.all(entries); + resolved.sort((a, b) => a.mtime - b.mtime); - if (files.length > maxEntries) { - const excess = files.length - maxEntries; + if (resolved.length > maxEntries) { + const excess = resolved.length - maxEntries; for (let i = 0; i < excess; i++) { - unlinkSync(files[i].filepath); + await unlink(resolved[i].filepath); removed++; } } - } catch { - // Directory doesn't exist — skip silently + } catch (err) { + logger.debug(`[retention] Failed to read directory: ${err.message}`); } return removed; diff --git a/src/memory/writer.js b/src/memory/writer.js index 66332044..62c9dedf 100644 --- a/src/memory/writer.js +++ b/src/memory/writer.js @@ -1,4 +1,4 @@ -import { writeFileSync, mkdirSync } from "node:fs"; +import { writeFile, mkdir } from "node:fs/promises"; import { join } from "node:path"; import { loadConfig } from "../config/loader.js"; @@ -22,12 +22,12 @@ function escapeYamlString(str) { * @param {string} title - A short title for the entry * @param {Object} frontmatter - YAML frontmatter metadata * @param {string} body - The markdown body content - * @returns {string} The path of the created file + * @returns {Promise} The path of the created file */ -export function writeMemoryFile(subdirectory, title, frontmatter, body = "") { +export async function writeMemoryFile(subdirectory, title, frontmatter, body = "") { const config = loadConfig(); const directory = join(config.cwd, subdirectory); - mkdirSync(directory, { recursive: true }); + await mkdir(directory, { recursive: true }); const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const slug = title .toLowerCase() @@ -54,6 +54,6 @@ export function writeMemoryFile(subdirectory, title, frontmatter, body = "") { ]; const content = lines.join("\n"); - writeFileSync(filepath, content); + await writeFile(filepath, content); return filepath; } diff --git a/src/sandbox/runner.js b/src/sandbox/runner.js index 2fc0d473..54d0c700 100644 --- a/src/sandbox/runner.js +++ b/src/sandbox/runner.js @@ -2,14 +2,15 @@ import { spawn } from "node:child_process"; import { handleTimeout } from "./timeoutHandler.js"; import { filterEnv } from "./envInjector.js"; import { enforceCapabilities } from "./capability.js"; -import { readFileSync, existsSync } from "node:fs"; +import { readFile, access, constants } from "node:fs/promises"; +import { logger } from "../logger.js"; /** * Map file extension to interpreter command. * @param {string} filePath - Path to the script - * @returns {object | null} { command, args } or null if unsupported + * @returns {Promise} { command, args } or null if unsupported */ -export function detectInterpreter(filePath) { +export async function detectInterpreter(filePath) { if (!filePath || typeof filePath !== "string") return null; const ext = filePath.split(".").pop()?.toLowerCase(); @@ -30,20 +31,26 @@ export function detectInterpreter(filePath) { return { command: "lua", args: [] }; default: // Try to detect via shebang - return detectShebang(filePath); + return await detectShebang(filePath); } } /** * Read the first line of a file to detect interpreter via shebang. * @param {string} filePath - Path to the script - * @returns {object | null} { command, args } or null if unsupported + * @returns {Promise} { command, args } or null if unsupported */ -export function detectShebang(filePath) { - if (!filePath || !existsSync(filePath)) return null; +export async function detectShebang(filePath) { + if (!filePath) return null; + try { + await access(filePath, constants.F_OK); + } catch (_err) { + return null; + } try { - const firstLine = readFileSync(filePath, "utf-8").split("\n")[0]; + const content = await readFile(filePath, "utf-8"); + const firstLine = content.split("\n")[0]; const match = firstLine.match(/^#!\s*(\/\S+?)(?:\s+(.*))?$/); if (match) { const cmd = match[1]; @@ -90,8 +97,8 @@ export function detectShebang(filePath) { return { command: cmd, args }; } } - } catch { - // File unreadable + } catch (err) { + logger.debug(`[runner] Error: ${err.message}`); } return null; @@ -127,11 +134,13 @@ export async function runSandbox(options) { let env = filterEnv(process.env, whitelist); // Detect interpreter - const interp = detectInterpreter(script) || - detectShebang(script) || { - command: "node", - args: [], - }; + let interp = await detectInterpreter(script); + if (!interp) { + interp = await detectShebang(script); + } + if (!interp) { + interp = { command: "node", args: [] }; + } const ext = script.split(".").pop()?.toLowerCase(); diff --git a/src/sandbox/urlFilter.js b/src/sandbox/urlFilter.js index 5e8a949e..3bce9c5f 100644 --- a/src/sandbox/urlFilter.js +++ b/src/sandbox/urlFilter.js @@ -31,7 +31,7 @@ export function filterUrl(url, allowlist = []) { } return { allowed: true, reason: "" }; - } catch { + } catch (_err) { return { allowed: false, reason: "Invalid URL format" }; } } @@ -46,7 +46,7 @@ export function isSchemeAllowed(url) { try { const scheme = new URL(url).protocol.toLowerCase(); return !BLOCKED_SCHEMES.has(scheme); - } catch { + } catch (_err) { return false; } } diff --git a/src/scheduler/cron.js b/src/scheduler/cron.js index 0356cad9..8b37361d 100644 --- a/src/scheduler/cron.js +++ b/src/scheduler/cron.js @@ -1,6 +1,7 @@ import { exec } from "node:child_process"; import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { logger } from "../logger.js"; // Block delimiters for madz-managed crontab entries const BLOCK_START = "# --- BEGIN madz-schedules ---"; @@ -426,18 +427,18 @@ export const Cron = { const filePath = join(schedulesDir, `${REFLECTION_JOB.name}.json`); try { await readdir(schedulesDir); - } catch { + } catch (_err) { try { await mkdir(schedulesDir, { recursive: true }); - } catch { - // Directory creation failed — sync will handle gracefully + } catch (mkdirErr) { + logger.debug(`[cron] Directory creation failed: ${mkdirErr.message}`); return; } } try { await readFile(filePath, "utf-8"); - } catch { - // File doesn't exist — create it + } catch (err) { + logger.debug(`[cron] File not found, creating: ${err.message}`); const jobData = Object.freeze({ name: REFLECTION_JOB.name, cron: REFLECTION_JOB.cron, @@ -472,12 +473,12 @@ export const Cron = { enabled: job.enabled !== false, }); } - } catch { - // Skip unreadable or malformed JSON files + } catch (err) { + logger.debug(`[cron] Skipping unreadable file: ${err.message}`); } } - } catch { - // Directory doesn't exist or can't be read — return empty + } catch (err) { + logger.debug(`[cron] Error: ${err.message}`); } return jobs; }, diff --git a/src/scheduler/scheduler.js b/src/scheduler/scheduler.js index 26f9ea28..60b065a3 100644 --- a/src/scheduler/scheduler.js +++ b/src/scheduler/scheduler.js @@ -1,5 +1,6 @@ import { readFile, readdir } from "node:fs/promises"; import { join } from "node:path"; +import { logger } from "../logger.js"; const DEFAULT_TIMEOUT_MS = 60000; @@ -62,12 +63,12 @@ export class ScheduleManager { } entries.push(entry); - } catch { - // Skip malformed JSON files + } catch (err) { + logger.debug(`[scheduler] Skipping malformed schedule file: ${err.message}`); } } - } catch { - // Directory doesn't exist — return empty manager + } catch (err) { + logger.debug(`[scheduler] Schedules directory not found: ${err.message}`); } return new ScheduleManager(undefined, entries); @@ -194,11 +195,11 @@ export class ScheduleManager { try { await access(entry.contextFile, constants.F_OK); contextPrefix = await readFile(entry.contextFile, "utf-8"); - } catch { - contextPrefix = loadContext(contextDir); + } catch (_err) { + contextPrefix = await loadContext(contextDir); } - } catch { - // Context load failed — continue with empty context + } catch (err) { + logger.debug(`[scheduler] Context load failed: ${err.message}`); } } diff --git a/src/session/loader.js b/src/session/loader.js index cec227b7..a65bd855 100644 --- a/src/session/loader.js +++ b/src/session/loader.js @@ -1,4 +1,4 @@ -import { readdirSync, readFileSync, statSync } from "node:fs"; +import { readdir, readFile, stat } from "node:fs/promises"; import { join } from "node:path"; import { parseFrontmatter } from "../memory/reader.js"; import { loadConfig } from "../config/loader.js"; @@ -10,9 +10,9 @@ const cwd = loadConfig().cwd; * @param {string} sessionsDir - Path to sessions directory * @param {number} [windowSize=20] - Context window limit for loaded messages * @param {string} [sessionId] - Optional session/thread ID to load (fallbacks to latest) - * @returns {{ sessionId: string, conversation: Array, metadata: Object }} + * @returns {Promise<{ sessionId: string, conversation: Array, metadata: Object }>} */ -export function loadSession( +export async function loadSession( sessionsDir = "memory/sessions/", windowSize = 20, sessionId = "", @@ -29,16 +29,16 @@ export function loadSession( let latestFile = null; let latestTime = 0; try { - const files = readdirSync(dir); + const files = await readdir(dir); for (const file of files) { if (!file.endsWith(".md")) continue; - const stat = statSync(join(dir, file)); - if (stat.mtimeMs > latestTime) { - latestTime = stat.mtimeMs; + const st = await stat(join(dir, file)); + if (st.mtimeMs > latestTime) { + latestTime = st.mtimeMs; latestFile = file; } } - } catch { + } catch (_err) { // Directory doesn't exist — return empty return { sessionId: "", conversation: [], metadata: {} }; } @@ -50,8 +50,8 @@ export function loadSession( return loadFile(join(dir, latestFile), windowSize); } -function loadFile(filepath, windowSize) { - const content = readFileSync(filepath, "utf-8"); +async function loadFile(filepath, windowSize) { + const content = await readFile(filepath, "utf-8"); const { frontmatter, content: body } = parseFrontmatter(content); let conversation = []; @@ -60,7 +60,7 @@ function loadFile(filepath, windowSize) { if (Array.isArray(parsed)) { conversation = parsed; } - } catch { + } catch (_err) { conversation = [{ role: "system", content: body }]; } diff --git a/src/session/onboarding.js b/src/session/onboarding.js index ace6f804..f38171fc 100644 --- a/src/session/onboarding.js +++ b/src/session/onboarding.js @@ -182,14 +182,14 @@ export class Onboarding { /** * Execute the SAVE phase: persist profile data to disk. - * @returns {boolean} Whether save succeeded + * @returns {Promise} Whether save succeeded */ - save() { + async save() { if (this.#phase !== PHASES.SAVE) { return false; } const sanitized = sanitizeProfileData(this.#profileData); - saveProfile(sanitized, this.#profilePath); + await saveProfile(sanitized, this.#profilePath); // Invoke the onSave callback if provided (e.g., auto-schedule) if (this.#onSave) { this.#onSave(); diff --git a/src/skills/discoverer.js b/src/skills/discoverer.js index 86667907..7a066204 100644 --- a/src/skills/discoverer.js +++ b/src/skills/discoverer.js @@ -1,7 +1,8 @@ -import { readdirSync, statSync, readFileSync, existsSync } from "node:fs"; +import { readdir, stat, readFile, access, constants } from "node:fs/promises"; import { join, basename, resolve } from "node:path"; import { load } from "js-yaml"; import { loadConfig } from "../config/loader.js"; +import { logger } from "../logger.js"; export const defaultScope = loadConfig().sandbox.skillScanPaths; export let cwd = loadConfig().cwd; @@ -39,7 +40,7 @@ export function extractFrontmatter(content) { let frontmatter; try { frontmatter = load(yamlStr); - } catch { + } catch (_err) { // Fallback to lenient parsing frontmatter = lenientYamlParse(yamlStr); } @@ -54,7 +55,7 @@ export function extractFrontmatter(content) { let metadata; try { metadata = yaml.load(metadataStr); - } catch { + } catch (_err) { metadata = lenientYamlParse(metadataStr); } if (metadata && typeof metadata === "object") { @@ -78,7 +79,7 @@ export function extractFrontmatter(content) { export function lenientYamlParse(yamlStr) { try { return yaml.load(yamlStr); - } catch { + } catch (_err) { // Try quoting line values that contain unquoted colons (e.g., "description: Use when: the user asks") const fixed = yamlStr.replace( /^(\s*[\w-]+:\s*)(?!["'])(.*:.*)(\s*)$/gm, @@ -91,7 +92,7 @@ export function lenientYamlParse(yamlStr) { ); try { return load(fixed); - } catch { + } catch (_err) { return null; } } @@ -113,21 +114,22 @@ function shouldSkip(name) { /** * Recursively scan a directory for SKILL.md files. * @param {string} dir - The directory to scan - * @returns {string[]} Array of paths to SKILL.md files + * @returns {Promise} Array of paths to SKILL.md files */ -function findSkillFiles(dir) { +async function findSkillFiles(dir) { const skills = []; try { - const entries = readdirSync(dir); + const entries = await readdir(dir); for (const entry of entries) { if (shouldSkip(entry)) continue; const fullPath = join(dir, entry); - const st = statSync(fullPath); + const st = await stat(fullPath); if (st.isDirectory()) { const skillMdPath = join(fullPath, SKILL_DIR); - if (existsSync(skillMdPath)) { - const frontmatter = extractFrontmatter(readFileSync(skillMdPath, "utf-8")); + try { + await access(skillMdPath, constants.F_OK); + const frontmatter = extractFrontmatter(await readFile(skillMdPath, "utf-8")); // Skip skills that lack valid frontmatter or required metadata if (!frontmatter.frontmatter) { @@ -157,8 +159,13 @@ function findSkillFiles(dir) { // Check for skill-specific scripts directory const skillScripts = join(fullPath, "scripts"); - if (existsSync(skillScripts) && statSync(skillScripts).isDirectory()) { - metadata.scripts = skillScripts; + try { + const scriptsStat = await stat(skillScripts); + if (scriptsStat.isDirectory()) { + metadata.scripts = skillScripts; + } + } catch (err) { + logger.debug(`[discoverer] Error: ${err.message}`); } skills.push({ @@ -166,11 +173,13 @@ function findSkillFiles(dir) { name: basename(fullPath), metadata, }); + } catch (err) { + logger.debug(`[discoverer] Error: ${err.message}`); } } } - } catch { - // Directory doesn't exist or can't be read + } catch (err) { + logger.debug(`[discoverer] Error: ${err.message}`); } return skills; @@ -181,9 +190,9 @@ function findSkillFiles(dir) { * @param {string[]} [scope] - Array of directories to scan (defaults to sandbox.skillScanPaths from config) * @param {object} [options] - Discovery options * @param {boolean} [options.trustProjectSkills=true] - Whether to trust project-level skills - * @returns {Array<{ path: string, name: string, metadata: Object }>} + * @returns {Promise>} */ -export function discoverSkills(scope = defaultScope, options = {}) { +export async function discoverSkills(scope = defaultScope, options = {}) { const cwdParam = options.cwd || cwd; const { trustProjectSkills: _trustProjectSkills = true } = options; const allSkills = []; @@ -191,11 +200,13 @@ export function discoverSkills(scope = defaultScope, options = {}) { for (const scopePath of scope) { const fullScope = resolve(cwdParam, scopePath); - if (!existsSync(fullScope)) { + try { + await access(fullScope, constants.F_OK); + } catch (_err) { continue; } - const skills = findSkillFiles(fullScope); + const skills = await findSkillFiles(fullScope); for (const skill of skills) { const name = skill.metadata.name || skill.name; diff --git a/src/skills/registry.js b/src/skills/registry.js index 58ee14c6..03f8d28c 100644 --- a/src/skills/registry.js +++ b/src/skills/registry.js @@ -1,5 +1,4 @@ -import { readFileSync } from "node:fs"; -import { mkdir } from "node:fs/promises"; +import { readFile, mkdir } from "node:fs/promises"; import { join } from "node:path"; import { discoverSkills, defaultScope } from "./discoverer.js"; import { validateSkillSchema } from "./validator.js"; @@ -32,10 +31,10 @@ export class SkillRegistry { * @param {string[]} [scope] - Array of directories to scan (defaults to sandbox.skillScanPaths from config) * @param {object} [options] - Discovery options * @param {boolean} [options.trustProjectSkills=true] - Trust project-level skills - * @returns {Array<{ name: string, errors: string[], warnings: string[] }>} Registration results + * @returns {Promise>} Registration results */ - discover(scope = defaultScope, options = {}) { - const discovered = discoverSkills(scope, options); + async discover(scope = defaultScope, options = {}) { + const discovered = await discoverSkills(scope, options); const results = []; for (const skill of discovered) { @@ -149,16 +148,16 @@ export class SkillRegistry { /** * Read and return the full SKILL.md body for a skill (tier 2 progressive disclosure). * @param {string} name - The skill name - * @returns {string | null} The full SKILL.md content, or null if not found + * @returns {Promise} The full SKILL.md content, or null if not found */ - getSkillBody(name) { + async getSkillBody(name) { const bodyPath = this.#bodyPaths.get(name); if (!bodyPath) { return null; } try { - return readFileSync(bodyPath, "utf-8"); - } catch { + return await readFile(bodyPath, "utf-8"); + } catch (_err) { return null; } } diff --git a/src/tools/clarify.js b/src/tools/clarify.js index d2ff8b63..f400aad7 100644 --- a/src/tools/clarify.js +++ b/src/tools/clarify.js @@ -11,7 +11,7 @@ async function pathExists(filePath) { try { await access(filePath); return true; - } catch { + } catch (_err) { return false; } } diff --git a/src/tools/code.js b/src/tools/code.js index ca516bf0..9d4fa425 100644 --- a/src/tools/code.js +++ b/src/tools/code.js @@ -5,6 +5,7 @@ import { mkdtemp, unlink, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadConfig } from "../config/loader.js"; +import { logger } from "../logger.js"; const LANGUAGE_MAP = { python3: { ext: ".py", interpreter: "python3" }, @@ -66,6 +67,7 @@ function getImportHookCode() { import sys import types + class RestrictedImporter: def find_spec(self, fullname, path, target=None): blocked = {'subprocess', 'os', 'socket', 'pty', 'tty', 'popen2', 'popen3', 'popen4'} @@ -125,8 +127,8 @@ export async function executeCodeImpl(input, options = {}) { try { const { setrlimit } = await import("posix"); setrlimit("as", { soft: memLimit, hard: memLimit }); - } catch { - // setrlimit not available + } catch (err) { + logger.debug(`[code] Error: ${err.message}`); } } diff --git a/src/tools/common.js b/src/tools/common.js index a283afa2..550a0ac5 100644 --- a/src/tools/common.js +++ b/src/tools/common.js @@ -71,7 +71,7 @@ export async function fetchWithTimeout(url, timeoutMs = 5000, allowlist = []) { export async function checkFileLimit(filePath, maxReadSize) { try { await access(filePath); - } catch { + } catch (_err) { return { ok: false, error: `File not found: ${filePath}` }; } diff --git a/src/tools/compact_context.js b/src/tools/compact_context.js index 4e1ed712..462dec92 100644 --- a/src/tools/compact_context.js +++ b/src/tools/compact_context.js @@ -347,7 +347,7 @@ export function createCompactContextTool(options = {}) { })); } } - } catch { + } catch (_err) { // Checkpointer access failed — fall back to empty conversation conversation = []; } diff --git a/src/tools/cron.js b/src/tools/cron.js index 535f479b..d7ebbc13 100644 --- a/src/tools/cron.js +++ b/src/tools/cron.js @@ -40,8 +40,8 @@ export async function findSkillScript(skillName, baseDir = ["system-skills", "sk try { await access(fullPath, constants.F_OK); return fullPath; - } catch { - // File doesn't exist, continue + } catch (err) { + logger.debug(`[cron] Error: ${err.message}`); } } @@ -50,8 +50,8 @@ export async function findSkillScript(skillName, baseDir = ["system-skills", "sk try { await access(fullPath, constants.F_OK); return fullPath; - } catch { - // File doesn't exist, continue + } catch (err) { + logger.debug(`[cron] Error: ${err.message}`); } } } @@ -149,7 +149,7 @@ async function loadJob(name, schedulesDir) { try { const content = await readFile(filePath, "utf-8"); return JSON.parse(content); - } catch { + } catch (_err) { return null; } } @@ -192,7 +192,7 @@ async function getScheduleFiles(schedulesDir) { try { const files = await readdir(schedulesDir); return files.filter((f) => f.endsWith(".json")); - } catch { + } catch (_err) { return []; } } diff --git a/src/tools/memory.js b/src/tools/memory.js index 5fc1f907..f3d297cf 100644 --- a/src/tools/memory.js +++ b/src/tools/memory.js @@ -17,7 +17,7 @@ async function pathExists(filePath) { try { await access(filePath); return true; - } catch { + } catch (_err) { return false; } } @@ -94,7 +94,7 @@ function getEntryPath(key, contextDir, cwdParam = cwd) { async function getEntryFiles(contextDir) { try { return (await readdir(contextDir)).filter((f) => f.endsWith(".md")); - } catch { + } catch (_err) { return []; } } @@ -106,7 +106,7 @@ async function getEntryFiles(contextDir) { async function countEntries(contextDir) { try { return (await readdir(contextDir)).filter((f) => f.endsWith(".md")).length; - } catch { + } catch (_err) { return 0; } } @@ -141,7 +141,7 @@ async function loadEntry(key, contextDir, cwdParam = cwd) { createdDate: created, updatedDate: frontmatter.updateddate || created, }; - } catch { + } catch (_err) { return null; } } diff --git a/src/tools/sampling.js b/src/tools/sampling.js index 05646a8e..5443e04d 100644 --- a/src/tools/sampling.js +++ b/src/tools/sampling.js @@ -76,7 +76,7 @@ export async function countEphemeralMemoryFiles(contextDir, nowStr) { let files; try { files = await readdir(join(config.cwd, contextDir)); - } catch { + } catch (_err) { return 0; } let count = 0; diff --git a/src/tools/session_search.js b/src/tools/session_search.js index 1e83aa02..2df3d032 100644 --- a/src/tools/session_search.js +++ b/src/tools/session_search.js @@ -18,7 +18,7 @@ async function exists(path) { try { await access(path, FS.MODE_RDONLY); return true; - } catch { + } catch (_err) { return false; } } @@ -209,7 +209,7 @@ async function browseConversations(sessionsDir) { if (Array.isArray(parsed) && parsed.length > 0) { preview = parsed[0].content?.toString().slice(0, 100) || "Empty"; } - } catch { + } catch (_err) { preview = body.slice(0, 100).replace(/\n/g, " "); } diff --git a/src/tools/shell.js b/src/tools/shell.js index 3c662965..30207356 100644 --- a/src/tools/shell.js +++ b/src/tools/shell.js @@ -2,6 +2,7 @@ import { tool } from "@langchain/core/tools"; import { z } from "zod"; import { spawn } from "node:child_process"; import { loadConfig } from "../config/loader.js"; +import { logger } from "../logger.js"; const MAX_COMMAND_LENGTH = 4096; @@ -193,8 +194,8 @@ export async function manageProcessImpl(input) { if (entry.child.exitCode === null) { try { entry.child.kill("SIGKILL"); - } catch { - // Process may have already exited + } catch (err) { + logger.debug(`[shell] Error: ${err.message}`); } } }, 5000); diff --git a/src/tools/web.js b/src/tools/web.js index 58281e42..a6511131 100644 --- a/src/tools/web.js +++ b/src/tools/web.js @@ -42,7 +42,7 @@ async function searchWithDuckDuckGo(query, limit) { return { ok: false, error: "DuckDuckGo returned no results" }; } return { ok: true, results }; - } catch { + } catch (_err) { clearTimeout(timeoutId); return { ok: false, error: "DuckDuckGo search failed" }; } @@ -85,7 +85,7 @@ async function searchWithBing(apiKey, query, limit) { description: r.snippet || "", })), }; - } catch { + } catch (_err) { clearTimeout(timeoutId); return { ok: false, error: "Bing search failed" }; } @@ -122,7 +122,7 @@ async function searchWithSearXNG(searxngUrl, query, limit) { description: r.content?.slice(0, 500) || "", })), }; - } catch { + } catch (_err) { clearTimeout(timeoutId); return { ok: false, error: "SearXNG search failed" }; } @@ -188,7 +188,7 @@ async function searchWithCustom(cfg, query, limit) { description: r[cfg.descriptionField] || "", })), }; - } catch { + } catch (_err) { clearTimeout(timeoutId); return { ok: false, error: "Custom search failed" }; } @@ -324,7 +324,7 @@ export async function webExtractImpl(input) { } return JSON.stringify({ ok: true, url, contentLength: clean.length, content: clean }); - } catch { + } catch (_err) { clearTimeout(timeoutId); return JSON.stringify({ ok: false, error: "Fetch failed" }); } diff --git a/src/tui/app.js b/src/tui/app.js index 993f2763..400db616 100644 --- a/src/tui/app.js +++ b/src/tui/app.js @@ -76,16 +76,16 @@ export default function App({ let totalTokens = calculateConversationTokens(conversation, modelName, encoding); // Add system prompt tokens - const systemPrompt = loadSystemPrompt(); - if (systemPrompt) { - totalTokens += calculateConversationTokens( - [{ role: "system", content: systemPrompt }], - modelName, - encoding, - ); - } - - setContextSize(totalTokens); + loadSystemPrompt().then((systemPrompt) => { + if (systemPrompt) { + totalTokens += calculateConversationTokens( + [{ role: "system", content: systemPrompt }], + modelName, + encoding, + ); + } + setContextSize(totalTokens); + }); } return () => { process.off("uncaughtException", onUncaught); @@ -159,7 +159,7 @@ export default function App({ }) : null, _skillList: skillList, - _executeSkill: (skillName, _args) => { + _executeSkill: async (skillName, _args) => { const skill = registry.get(skillName); if (!skill) { return { @@ -170,7 +170,7 @@ export default function App({ } // Skills are prompt-based instructions for the agent to interpret and execute. // Load the SKILL.md and pass it to the conversation so the agent can use it. - const body = registry.getSkillBody(skillName); + const body = await registry.getSkillBody(skillName); return { action: "skill", subAction: "load", @@ -365,15 +365,16 @@ export default function App({ // Calculate conversation tokens + system prompt let totalTokens = calculateConversationTokens(conversation, modelName, encoding); - const systemPrompt = loadSystemPrompt(); - if (systemPrompt) { - totalTokens += calculateConversationTokens( - [{ role: "system", content: systemPrompt }], - modelName, - encoding, - ); - } - setContextSize(totalTokens); + loadSystemPrompt().then((systemPrompt) => { + if (systemPrompt) { + totalTokens += calculateConversationTokens( + [{ role: "system", content: systemPrompt }], + modelName, + encoding, + ); + } + setContextSize(totalTokens); + }); } const assistantTime = getTimestamp(); @@ -485,15 +486,16 @@ export default function App({ // Calculate conversation tokens + system prompt let totalTokens = calculateConversationTokens(conversation, modelName, encoding); - const systemPrompt = loadSystemPrompt(); - if (systemPrompt) { - totalTokens += calculateConversationTokens( - [{ role: "system", content: systemPrompt }], - modelName, - encoding, - ); - } - setContextSize(totalTokens); + loadSystemPrompt().then((systemPrompt) => { + if (systemPrompt) { + totalTokens += calculateConversationTokens( + [{ role: "system", content: systemPrompt }], + modelName, + encoding, + ); + } + setContextSize(totalTokens); + }); } if (onSaveSession) { onSaveSession(); @@ -605,7 +607,7 @@ export default function App({ * Process onboarding input: forward to onboarding instance and update state. * @param {string} text - Raw user input */ - function processOnboardingInput(text) { + async function processOnboardingInput(text) { if (!onboarding || !showOnboarding) return false; const trimmed = text.trim(); @@ -626,7 +628,7 @@ export default function App({ } if (result.action === "save") { - const saved = onboarding.save(); + const saved = await onboarding.save(); if (saved) { addMessage({ role: "system", diff --git a/src/tui/contextTokens.js b/src/tui/contextTokens.js index e2477ad7..bf7af710 100644 --- a/src/tui/contextTokens.js +++ b/src/tui/contextTokens.js @@ -18,7 +18,7 @@ export function calculateConversationTokens(conversation, modelName, encoding) { let tiktoken; try { tiktoken = require("tiktoken"); - } catch { + } catch (_err) { // tiktoken not available — estimate based on character count // Rough heuristic: ~4 characters per token for English text return estimateTokensFromCharacters(conversation); @@ -37,7 +37,7 @@ export function calculateConversationTokens(conversation, modelName, encoding) { enc.free(); return totalTokens; - } catch { + } catch (_err) { // encoding_for_model failed — estimate based on character count return estimateTokensFromCharacters(conversation); } diff --git a/src/tui/statusBar.js b/src/tui/statusBar.js index 9a1bec55..b922dd25 100644 --- a/src/tui/statusBar.js +++ b/src/tui/statusBar.js @@ -32,7 +32,7 @@ export function formatNumber(num) { return String(num); } return result; - } catch { + } catch (_err) { return String(num); } } diff --git a/src/workspace/loadAgents.js b/src/workspace/loadAgents.js index d3045f25..e4f81a2f 100644 --- a/src/workspace/loadAgents.js +++ b/src/workspace/loadAgents.js @@ -13,7 +13,7 @@ async function fileExists(filepath) { try { await access(filepath); return true; - } catch { + } catch (_err) { return false; } } diff --git a/tests/unit/agentDefinitions.test.js b/tests/unit/agentDefinitions.test.js index 21364280..e7f07ed9 100644 --- a/tests/unit/agentDefinitions.test.js +++ b/tests/unit/agentDefinitions.test.js @@ -2,11 +2,24 @@ * Agent definition tests - validates structure, output formats, and tool mappings. */ -import { describe, it } from "node:test"; +import { describe, it, before } from "node:test"; import { strictEqual, ok, deepStrictEqual } from "node:assert"; import { getAllAgents } from "../../src/agent/agents/index.js"; import { getToolsForAgentTypes, TOOL_CLASSIFICATIONS } from "../../src/tools/index.js"; +// Wait for async prompt loading at module init +function waitForPrompts() { + return new Promise((resolve) => { + const check = () => { + const agents = getAllAgents(); + const allLoaded = agents.every((a) => a.systemPrompt && a.systemPrompt.length > 50); + if (allLoaded) resolve(); + else setTimeout(check, 10); + }; + check(); + }); +} + const ALL_AGENTS = getAllAgents(); const EXPECTED_AGENT_NAMES = [ "coding", @@ -21,6 +34,9 @@ const EXPECTED_AGENT_NAMES = [ ]; describe("Agent Definitions", () => { + before(async () => { + await waitForPrompts(); + }); describe("getAllAgents", () => { it("should return all 9 agent definitions", () => { strictEqual(ALL_AGENTS.length, 9, "Should have exactly 9 agents"); diff --git a/tests/unit/context.test.js b/tests/unit/context.test.js index 8b00bb12..06f5832f 100644 --- a/tests/unit/context.test.js +++ b/tests/unit/context.test.js @@ -35,7 +35,7 @@ describe("loadContext", () => { }); after(teardown); - it("returns combined context from markdown files sorted by timestamp descending", () => { + it("returns combined context from markdown files sorted by timestamp descending", async () => { writeFileSync( join(fullTestDir, "note1.md"), "---\ntitle: First Note\ntimestamp: 2024-01-01\n---\nContent of first note", @@ -49,7 +49,7 @@ describe("loadContext", () => { "---\ntitle: Third Note\ntimestamp: 2024-01-02\n---\nContent of third note", ); - const result = loadContext(testDir, 3); + const result = await loadContext(testDir, 3); assert.ok(result.includes("[Context: Second Note]")); assert.ok(result.includes("Content of second note")); assert.ok(result.includes("[Context: Third Note]")); @@ -63,57 +63,57 @@ describe("loadContext", () => { assert.ok(thirdIdx < firstIdx, "Third Note should come before First Note"); }); - it("respects the limit parameter", () => { + it("respects the limit parameter", async () => { writeFileSync(join(fullTestDir, "a.md"), "---\ntitle: A\ntimestamp: 2024-01-01\n---\nAAA"); writeFileSync(join(fullTestDir, "b.md"), "---\ntitle: B\ntimestamp: 2024-01-02\n---\nBBB"); writeFileSync(join(fullTestDir, "c.md"), "---\ntitle: C\ntimestamp: 2024-01-03\n---\nCCC"); - const result = loadContext(testDir, 2); + const result = await loadContext(testDir, 2); assert.ok(result.includes("[Context: B]")); assert.ok(result.includes("[Context: C]")); assert.ok(!result.includes("[Context: A]"), "A should be excluded with limit 2"); }); - it("filters out non-.md files", () => { + it("filters out non-.md files", async () => { writeFileSync( join(fullTestDir, "valid.md"), "---\ntitle: Valid\ntimestamp: 2024-01-01\n---\nValid content", ); writeFileSync(join(fullTestDir, "invalid.txt"), "this is not markdown"); - const result = loadContext(testDir, 10); + const result = await loadContext(testDir, 10); assert.ok(result.includes("[Context: Valid]")); assert.ok(!result.includes("[Context: invalid.txt]")); assert.ok(!result.includes("this is not markdown")); }); - it("returns empty string for non-existent directory", () => { - const result = loadContext("__nonexistent_dir_xyz__", 10); + it("returns empty string for non-existent directory", async () => { + const result = await loadContext("__nonexistent_dir_xyz__", 10); assert.strictEqual(result, ""); }); - it("returns empty string for empty directory", () => { + it("returns empty string for empty directory", async () => { const emptyDir = "__empty_ctx_test__"; mkdirSync(join(process.cwd(), emptyDir), { recursive: true }); try { - const result = loadContext(emptyDir, 10); + const result = await loadContext(emptyDir, 10); assert.strictEqual(result, ""); } finally { rmSync(join(process.cwd(), emptyDir), { recursive: true, force: true }); } }); - it("trims body content of each context entry", () => { + it("trims body content of each context entry", async () => { writeFileSync( join(fullTestDir, "trimmed.md"), "---\ntitle: Trimmed\ntimestamp: 2024-01-01\n---\n some text with spaces \n", ); - const result = loadContext(testDir, 10); + const result = await loadContext(testDir, 10); assert.ok(result.includes("some text with spaces")); }); - it("handles files without timestamp (falls back to empty string sort)", () => { + it("handles files without timestamp (falls back to empty string sort)", async () => { writeFileSync( join(fullTestDir, "no-ts.md"), "---\ntitle: No Timestamp\n---\nNo timestamp body", @@ -123,12 +123,12 @@ describe("loadContext", () => { "---\ntitle: With Timestamp\ntimestamp: 2024-01-01\n---\nHas timestamp body", ); - const result = loadContext(testDir, 10); + const result = await loadContext(testDir, 10); assert.ok(result.includes("No Timestamp")); assert.ok(result.includes("With Timestamp")); }); - it("filters out ephemeral files from main processing", () => { + it("filters out ephemeral files from main processing", async () => { writeFileSync( join(fullTestDir, "persistent.md"), "---\ntitle: Persistent\ntimestamp: 2024-01-03\n---\nPersistent content", @@ -138,14 +138,14 @@ describe("loadContext", () => { "---\ntitle: Ephemeral Note\ntimestamp: 2024-01-04\n---\nEphemeral content", ); - const result = loadContext(testDir, 10); + const result = await loadContext(testDir, 10); assert.ok(result.includes("[Context: Persistent]")); assert.ok(result.includes("Persistent content")); // Ephemeral files should not appear as [Context:] entries assert.ok(!result.includes("[Context: Ephemeral Note]")); }); - it("loads ephemeral files last with correct sort order", () => { + it("loads ephemeral files last with correct sort order", async () => { writeFileSync( join(fullTestDir, "persistent.md"), "---\ntitle: Persistent\ntimestamp: 2024-01-01\n---\nPersistent content", @@ -159,7 +159,7 @@ describe("loadContext", () => { "---\ntitle: Ephemeral New\ntimestamp: 2024-01-03\n---\nNew ephemeral", ); - const result = loadContext(testDir, 10); + const result = await loadContext(testDir, 10); // Persistent should appear as [Context:] assert.ok(result.includes("[Context: Persistent]")); // Ephemeral should appear as [Ephemeral:] @@ -174,7 +174,7 @@ describe("loadContext", () => { assert.ok(persistentIdx < newIdx, "Persistent context should come before ephemeral"); }); - it("respects ephemeral limit", () => { + it("respects ephemeral limit", async () => { writeFileSync( join(fullTestDir, "persistent.md"), "---\ntitle: Persistent\ntimestamp: 2024-01-01\n---\nPersistent content", @@ -187,7 +187,7 @@ describe("loadContext", () => { ); } - const result = loadContext(testDir, 10); + const result = await loadContext(testDir, 10); // Should only include up to 5 ephemeral files (default limit) let count = 0; for (let i = 1; i <= 7; i++) { @@ -196,24 +196,24 @@ describe("loadContext", () => { assert.strictEqual(count, 5, "Should only load 5 ephemeral files by default"); }); - it("handles missing profile.md gracefully", () => { + it("handles missing profile.md gracefully", async () => { writeFileSync( join(fullTestDir, "note.md"), "---\ntitle: Note\ntimestamp: 2024-01-01\n---\nNote content", ); - const result = loadContext(testDir, 10); + const result = await loadContext(testDir, 10); assert.ok(result.includes("[Context: Note]")); assert.ok(result.includes("Note content")); }); - it("handles no ephemeral files gracefully", () => { + it("handles no ephemeral files gracefully", async () => { writeFileSync( join(fullTestDir, "note.md"), "---\ntitle: Note\ntimestamp: 2024-01-01\n---\nNote content", ); - const result = loadContext(testDir, 10); + const result = await loadContext(testDir, 10); assert.ok(result.includes("[Context: Note]")); assert.ok(!result.includes("[Ephemeral:")); }); diff --git a/tests/unit/discoverer.test.js b/tests/unit/discoverer.test.js index 98b36a9d..0d9cfd02 100644 --- a/tests/unit/discoverer.test.js +++ b/tests/unit/discoverer.test.js @@ -110,7 +110,7 @@ describe("discoverSkills", () => { beforeEach(setup); afterEach(cleanup); - it("discovers SKILL.md files with valid frontmatter", () => { + it("discovers SKILL.md files with valid frontmatter", async () => { const skillDir = join(testDir, "my-skill"); mkdirSync(skillDir, { recursive: true }); writeFileSync( @@ -118,7 +118,7 @@ describe("discoverSkills", () => { "---\nname: my-skill\ndescription: A test skill\n---\n\nContent", ); - const skills = discoverSkills(["."]); + const skills = await discoverSkills(["."]); assert.strictEqual(skills.length, 1); assert.strictEqual(skills[0].name, "my-skill"); assert.strictEqual(skills[0].metadata.name, "my-skill"); @@ -126,16 +126,16 @@ describe("discoverSkills", () => { assert.ok(skills[0].metadata._path.endsWith("SKILL.md")); }); - it("skips SKILL.md without valid frontmatter", () => { + it("skips SKILL.md without valid frontmatter", async () => { const skillDir = join(testDir, "no-meta-skill"); mkdirSync(skillDir, { recursive: true }); writeFileSync(join(skillDir, "SKILL.md"), "# No frontmatter here"); - const skills = discoverSkills(["."]); + const skills = await discoverSkills(["."]); assert.strictEqual(skills.length, 0); }); - it("skips directories with empty description", () => { + it("skips directories with empty description", async () => { const skillDir = join(testDir, "empty-desc-skill"); mkdirSync(skillDir, { recursive: true }); writeFileSync( @@ -143,11 +143,11 @@ describe("discoverSkills", () => { "---\nname: empty-desc-skill\ndescription: ''\n---\n\nBody", ); - const skills = discoverSkills(["."]); + const skills = await discoverSkills(["."]); assert.strictEqual(skills.length, 0); }); - it("skips directories starting with dot", () => { + it("skips directories starting with dot", async () => { const hiddenDir = join(testDir, ".hidden-skill"); mkdirSync(hiddenDir, { recursive: true }); writeFileSync( @@ -155,11 +155,11 @@ describe("discoverSkills", () => { "---\nname: hidden-skill\ndescription: Hidden\n---\n\nBody", ); - const skills = discoverSkills(["."]); + const skills = await discoverSkills(["."]); assert.strictEqual(skills.length, 0); }); - it("skips node_modules directories", () => { + it("skips node_modules directories", async () => { const nmDir = join(testDir, "node_modules", "some-skill"); mkdirSync(nmDir, { recursive: true }); writeFileSync( @@ -167,11 +167,11 @@ describe("discoverSkills", () => { "---\nname: node-skill\ndescription: In node_modules\n---\n\nBody", ); - const skills = discoverSkills(["."]); + const skills = await discoverSkills(["."]); assert.strictEqual(skills.length, 0); }); - it("discovers skills with scripts directory", () => { + it("discovers skills with scripts directory", async () => { const skillDir = join(testDir, "scripts-skill"); const scriptsDir = join(skillDir, "scripts"); mkdirSync(scriptsDir, { recursive: true }); @@ -180,12 +180,12 @@ describe("discoverSkills", () => { "---\nname: scripts-skill\ndescription: Has scripts\n---\n\nBody", ); - const skills = discoverSkills(["."]); + const skills = await discoverSkills(["."]); assert.strictEqual(skills.length, 1); assert.ok(skills[0].metadata.scripts.endsWith("scripts")); }); - it("discovers multiple skills", () => { + it("discovers multiple skills", async () => { const skill1 = join(testDir, "skill-a"); const skill2 = join(testDir, "skill-b"); mkdirSync(skill1, { recursive: true }); @@ -194,18 +194,18 @@ describe("discoverSkills", () => { writeFileSync(join(skill1, "SKILL.md"), "---\nname: skill-a\ndescription: Skill A\n---\n\nA"); writeFileSync(join(skill2, "SKILL.md"), "---\nname: skill-b\ndescription: Skill B\n---\n\nB"); - const skills = discoverSkills(["."]); + const skills = await discoverSkills(["."]); assert.strictEqual(skills.length, 2); const names = skills.map((s) => s.name).sort(); assert.deepStrictEqual(names, ["skill-a", "skill-b"]); }); - it("returns empty array for non-existent directory", () => { - const skills = discoverSkills(["/nonexistent/path"]); + it("returns empty array for non-existent directory", async () => { + const skills = await discoverSkills(["/nonexistent/path"]); assert.strictEqual(skills.length, 0); }); - it("handles multiple scopes", () => { + it("handles multiple scopes", async () => { const scope1 = join(testDir, "shared"); const dirA = join(scope1, "shared-skill"); mkdirSync(dirA, { recursive: true }); @@ -214,12 +214,12 @@ describe("discoverSkills", () => { "---\nname: shared-skill\ndescription: Shared skill\n---\n\nBody", ); - const skills = discoverSkills([scope1, "skills/"]); + const skills = await discoverSkills([scope1, "skills/"]); assert.strictEqual(skills.length, 1); assert.strictEqual(skills[0].name, "shared-skill"); }); - it("handles name collisions - second occurrence is skipped", () => { + it("handles name collisions - second occurrence is skipped", async () => { const dir1 = join(testDir, "collision-skill"); mkdirSync(dir1, { recursive: true }); writeFileSync( @@ -234,34 +234,34 @@ describe("discoverSkills", () => { "---\nname: collision-skill\ndescription: Second\n---\n\nBody", ); - const skills = discoverSkills(["."]); + const skills = await discoverSkills(["."]); // Should only have one (the first directory alphabetically found) // "collision-alias" sorts before "collision-skill", so that one is found first assert.strictEqual(skills.length, 1); assert.strictEqual(skills[0].metadata.name, "collision-skill"); }); - it("skips skills without a name in frontmatter", () => { + it("skips skills without a name in frontmatter", async () => { const skillDir = join(testDir, "no-name-skill"); mkdirSync(skillDir, { recursive: true }); writeFileSync(join(skillDir, "SKILL.md"), "---\ndescription: No name\n---\n\nBody"); - const skills = discoverSkills(["."]); + const skills = await discoverSkills(["."]); assert.strictEqual(skills.length, 0); }); - it("accepts numeric name cast to string", () => { + it("accepts numeric name cast to string", async () => { const skillDir = join(testDir, "numeric-name"); mkdirSync(skillDir, { recursive: true }); writeFileSync(join(skillDir, "SKILL.md"), "---\nname: 123\ndescription: Numeric\n---\n\nBody"); - const skills = discoverSkills(["."]); + const skills = await discoverSkills(["."]); // YAML parses "name: 123" as a number, but we cast it to string for validation assert.strictEqual(skills.length, 1); assert.strictEqual(skills[0].metadata.name, "123"); }); - it("handles project-level .agents/skills taking precedence", () => { + it("handles project-level .agents/skills taking precedence", async () => { const agentsDir = join(testDir, ".agents", "skills"); const agentSkillDir = join(agentsDir, "shared-skill"); mkdirSync(agentSkillDir, { recursive: true }); @@ -277,14 +277,14 @@ describe("discoverSkills", () => { "---\nname: shared-skill\ndescription: Regular skill\n---\n\nRegular body", ); - const skills = discoverSkills([join(testDir, "skills/"), ".agents/skills/"]); + const skills = await discoverSkills([join(testDir, "skills/"), ".agents/skills/"]); // Both skills found, but the one from .agents/skills is higher priority // and should override the regular one assert.strictEqual(skills.length, 1); assert.strictEqual(skills[0].metadata.description, "Agent skill"); }); - it("discovers skills from system-skills/ directory", () => { + it("discovers skills from system-skills/ directory", async () => { const systemDir = join(testDir, "system-skills"); const systemSkillDir = join(systemDir, "system-skill"); mkdirSync(systemSkillDir, { recursive: true }); @@ -293,13 +293,13 @@ describe("discoverSkills", () => { "---\nname: system-skill\ndescription: A system skill\n---\n\nSystem body", ); - const skills = discoverSkills([systemDir]); + const skills = await discoverSkills([systemDir]); assert.strictEqual(skills.length, 1); assert.strictEqual(skills[0].name, "system-skill"); assert.strictEqual(skills[0].metadata.description, "A system skill"); }); - it("handles system-skills/ shadowing user skills/", () => { + it("handles system-skills/ shadowing user skills/", async () => { const systemDir = join(testDir, "system-skills"); const shadowDir = join(systemDir, "shadow-skill"); mkdirSync(shadowDir, { recursive: true }); @@ -316,14 +316,14 @@ describe("discoverSkills", () => { "---\nname: shadow-skill\ndescription: User version\n---\n\nUser body", ); - const skills = discoverSkills([systemDir, userDir]); + const skills = await discoverSkills([systemDir, userDir]); // System skill should shadow user skill (first scope wins) assert.strictEqual(skills.length, 1); assert.strictEqual(skills[0].metadata.description, "System version"); assert.ok(skills[0].path.includes("system-skills")); }); - it("discovers both system and user skills when no collision", () => { + it("discovers both system and user skills when no collision", async () => { const systemDir = join(testDir, "system-skills"); const systemSkillDir = join(systemDir, "sys-only"); mkdirSync(systemSkillDir, { recursive: true }); @@ -340,7 +340,7 @@ describe("discoverSkills", () => { "---\nname: user-only\ndescription: User only\n---\n\nUser body", ); - const skills = discoverSkills([systemDir, userDir]); + const skills = await discoverSkills([systemDir, userDir]); assert.strictEqual(skills.length, 2); const names = skills.map((s) => s.name).sort(); assert.deepStrictEqual(names, ["sys-only", "user-only"]); @@ -350,43 +350,43 @@ describe("discoverSkills", () => { // --- Detect interpreter tests --- describe("detectInterpreter", () => { - it("detects python from .py extension", () => { - const result = detectInterpreter("skill/scripts/extract.py"); + it("detects python from .py extension", async () => { + const result = await detectInterpreter("skill/scripts/extract.py"); assert.deepStrictEqual(result, { command: "python3", args: [] }); }); - it("detects node from .js extension", () => { - const result = detectInterpreter("skill/main.js"); + it("detects node from .js extension", async () => { + const result = await detectInterpreter("skill/main.js"); assert.deepStrictEqual(result, { command: "node", args: [] }); }); - it("detects node from .mjs extension", () => { - const result = detectInterpreter("skill/module.mjs"); + it("detects node from .mjs extension", async () => { + const result = await detectInterpreter("skill/module.mjs"); assert.deepStrictEqual(result, { command: "node", args: [] }); }); - it("detects bash from .sh extension", () => { - const result = detectInterpreter("skill/run.sh"); + it("detects bash from .sh extension", async () => { + const result = await detectInterpreter("skill/run.sh"); assert.deepStrictEqual(result, { command: "bash", args: [] }); }); - it("detects ruby from .rb extension", () => { - const result = detectInterpreter("skill/script.rb"); + it("detects ruby from .rb extension", async () => { + const result = await detectInterpreter("skill/script.rb"); assert.deepStrictEqual(result, { command: "ruby", args: [] }); }); - it("detects typescript via node+tsx", () => { - const result = detectInterpreter("skill/index.ts"); + it("detects typescript via node+tsx", async () => { + const result = await detectInterpreter("skill/index.ts"); assert.deepStrictEqual(result, { command: "node", args: ["--import", "tsx"] }); }); - it("returns null for unsupported extension", () => { - const result = detectInterpreter("skill/file.xyz"); + it("returns null for unsupported extension", async () => { + const result = await detectInterpreter("skill/file.xyz"); assert.strictEqual(result, null); }); - it("returns null for null input", () => { - const result = detectInterpreter(null); + it("returns null for null input", async () => { + const result = await detectInterpreter(null); assert.strictEqual(result, null); }); }); @@ -394,49 +394,49 @@ describe("detectInterpreter", () => { // --- Detect shebang tests --- describe("detectShebang", () => { - it("detects python shebang", () => { + it("detects python shebang", async () => { const scriptDir = join(testDir, "shebang-test"); mkdirSync(scriptDir, { recursive: true }); const scriptPath = join(scriptDir, "script"); writeFileSync(scriptPath, "#!/usr/bin/env python3\nprint('hello')"); - const result = detectShebang(scriptPath); + const result = await detectShebang(scriptPath); assert.deepStrictEqual(result, { command: "python3", args: [] }); }); - it("detects bash shebang", () => { + it("detects bash shebang", async () => { const scriptDir = join(testDir, "shebang-test2"); mkdirSync(scriptDir, { recursive: true }); const scriptPath = join(scriptDir, "run"); writeFileSync(scriptPath, "#!/bin/bash\necho hello"); - const result = detectShebang(scriptPath); + const result = await detectShebang(scriptPath); assert.deepStrictEqual(result, { command: "bash", args: [] }); }); - it("detects ruby shebang with args", () => { + it("detects ruby shebang with args", async () => { const scriptDir = join(testDir, "shebang-test3"); mkdirSync(scriptDir, { recursive: true }); const scriptPath = join(scriptDir, "script"); writeFileSync(scriptPath, "#!/usr/bin/env ruby -w\nputs 'hello'"); - const result = detectShebang(scriptPath); + const result = await detectShebang(scriptPath); assert.strictEqual(result.command, "ruby"); assert.ok(result.args.includes("-w")); }); - it("returns null for non-existent file", () => { - const result = detectShebang("/nonexistent/file.py"); + it("returns null for non-existent file", async () => { + const result = await detectShebang("/nonexistent/file.py"); assert.strictEqual(result, null); }); - it("returns null for no shebang", () => { + it("returns null for no shebang", async () => { const scriptDir = join(testDir, "shebang-test4"); mkdirSync(scriptDir, { recursive: true }); const scriptPath = join(scriptDir, "plain"); writeFileSync(scriptPath, "print('hello')"); - const result = detectShebang(scriptPath); + const result = await detectShebang(scriptPath); assert.strictEqual(result, null); }); }); diff --git a/tests/unit/onboarding.test.js b/tests/unit/onboarding.test.js index 7f53cb01..80f96979 100644 --- a/tests/unit/onboarding.test.js +++ b/tests/unit/onboarding.test.js @@ -213,7 +213,7 @@ describe("SAVE phase", () => { }); describe("save", () => { - it("persists profile data and transitions to TRANSCEND", () => { + it("persists profile data and transitions to TRANSCEND", async () => { const ob = create(); ob.processResponse("yes"); ob.processResponse("ok"); @@ -225,9 +225,9 @@ describe("save", () => { assert.ok(Object.keys(data).length > 0); }); - it("returns false when not in SAVE phase", () => { + it("returns false when not in SAVE phase", async () => { const ob = create(); - assert.strictEqual(ob.save(), false); + assert.strictEqual(await ob.save(), false); }); }); @@ -237,7 +237,7 @@ describe("isComplete", () => { assert.strictEqual(ob.isComplete(), false); }); - it("is true after save", () => { + it("is true after save", async () => { const ob = create(); // Answer all attributes to reach SAVE ob.processResponse("yes"); @@ -246,20 +246,20 @@ describe("isComplete", () => { ob.processResponse(`x${i}`); } // save() transitions to TRANSCEND - ob.save(); + await ob.save(); assert.strictEqual(ob.isComplete(), true); }); }); describe("getCurrentPrompt", () => { - it("returns null in TRANSCEND phase", () => { + it("returns null in TRANSCEND phase", async () => { const ob = create(); ob.processResponse("yes"); ob.processResponse("ok"); for (let i = 0; i < ATTRIBUTES.length; i++) { ob.processResponse(`x${i}`); } - ob.save(); + await ob.save(); assert.strictEqual(ob.getCurrentPrompt(), null); }); }); diff --git a/tests/unit/profile.test.js b/tests/unit/profile.test.js index 40da003a..529e4734 100644 --- a/tests/unit/profile.test.js +++ b/tests/unit/profile.test.js @@ -1,6 +1,7 @@ import { describe, it, after, beforeEach } from "node:test"; import assert from "node:assert"; -import { writeFileSync, rmSync, existsSync, readFileSync, mkdirSync } from "node:fs"; +import { writeFileSync, rmSync, mkdirSync } from "node:fs"; +import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { ATTRIBUTES, @@ -80,96 +81,95 @@ describe("ATTRIBUTES schema", () => { }); describe("loadProfile", () => { - it("returns null when profile file does not exist", () => { + it("returns null when profile file does not exist", async () => { const missing = join(FULL_TEST_DIR, "missing.md"); - assert.strictEqual(loadProfile(missing), null); + assert.strictEqual(await loadProfile(missing), null); }); - it("returns null when body has no profile attributes", () => { + it("returns null when body has no profile attributes", async () => { const fp = join(FULL_TEST_DIR, "note.md"); writeFileSync(fp, "just a note"); - assert.strictEqual(loadProfile(fp), null); + assert.strictEqual(await loadProfile(fp), null); }); - it("returns null for empty file content", () => { + it("returns null for empty file content", async () => { const fp = join(FULL_TEST_DIR, "empty.md"); writeFileSync(fp, ""); - assert.strictEqual(loadProfile(fp), null); + assert.strictEqual(await loadProfile(fp), null); }); - it("returns null for empty body with frontmatter", () => { + it("returns null for empty body with frontmatter", async () => { const fp = join(FULL_TEST_DIR, "fm.md"); writeFileSync(fp, "---\ntitle: Note\n---\n"); - assert.strictEqual(loadProfile(fp), null); + assert.strictEqual(await loadProfile(fp), null); }); - it("parses profile from body lines", () => { + it("parses profile from body lines", async () => { const fp = join(FULL_TEST_DIR, "body.md"); writeFileSync(fp, "name: Alice\nhobbies: hiking\npets: cat"); - const result = loadProfile(fp); + const result = await loadProfile(fp); assert.ok(result); assert.strictEqual(result.data.name, "Alice"); assert.strictEqual(result.data.hobbies, "hiking"); assert.strictEqual(result.data.pets, "cat"); }); - it("skips comment lines in body", () => { + it("skips comment lines in body", async () => { const fp = join(FULL_TEST_DIR, "comment.md"); writeFileSync(fp, "# comment\nname: Bob\n # another comment\n\nhobbies: reading\n"); - const result = loadProfile(fp); + const result = await loadProfile(fp); assert.strictEqual(result.data.name, "Bob"); assert.strictEqual(result.data.hobbies, "reading"); }); - it("returns null if body values match no known attributes", () => { + it("returns null if body values match no known attributes", async () => { const fp = join(FULL_TEST_DIR, "unknown.md"); writeFileSync(fp, "foo: bar"); - assert.strictEqual(loadProfile(fp), null); + assert.strictEqual(await loadProfile(fp), null); }); }); describe("saveProfile", () => { - it("writes profile file with body data", () => { + it("writes profile file with body data", async () => { const fp = join(FULL_TEST_DIR, "profile.md"); - saveProfile({ name: "Alice", hobbies: "hiking", pets: "cat" }, fp); - assert.ok(existsSync(fp), "profile file should exist"); - assert.ok(!existsSync(fp + ".tmp"), "temp file should be gone"); - const content = readFileSync(fp, "utf-8"); + await saveProfile({ name: "Alice", hobbies: "hiking", pets: "cat" }, fp); + const content = await readFile(fp, "utf-8"); assert.ok(content.includes("name: Alice")); assert.ok(content.includes("hobbies: hiking")); assert.ok(content.includes("pets: cat")); assert.ok(!content.includes("---")); }); - it("overwrites existing profile", () => { + it("overwrites existing profile", async () => { const fp = join(FULL_TEST_DIR, "over.md"); - saveProfile({ name: "Alice" }, fp); - let content = readFileSync(fp, "utf-8"); + await saveProfile({ name: "Alice" }, fp); + let content = await readFile(fp, "utf-8"); assert.ok(content.includes("name: Alice")); - saveProfile({ name: "Bob" }, fp); - content = readFileSync(fp, "utf-8"); + await saveProfile({ name: "Bob" }, fp); + content = await readFile(fp, "utf-8"); assert.ok(content.includes("name: Bob")); assert.ok(!content.includes("Alice")); }); - it("creates parent directory if missing", () => { + it("creates parent directory if missing", async () => { const fp = join(FULL_TEST_DIR, "sub", "dir", "p.md"); - saveProfile({ name: "test" }, fp); - assert.ok(existsSync(fp)); + await saveProfile({ name: "test" }, fp); + const content = await readFile(fp, "utf-8"); + assert.ok(content.includes("name: test")); }); - it("skips null and empty attributes", () => { + it("skips null and empty attributes", async () => { const fp = join(FULL_TEST_DIR, "nulls.md"); - saveProfile({ name: "test", dob: null, pet: "cat", hobbies: "" }, fp); - const content = readFileSync(fp, "utf-8"); + await saveProfile({ name: "test", dob: null, pet: "cat", hobbies: "" }, fp); + const content = await readFile(fp, "utf-8"); assert.ok(content.includes("name: test")); assert.ok(!content.includes("cat")); }); - it("is readable by loadProfile", () => { + it("is readable by loadProfile", async () => { const fp = join(FULL_TEST_DIR, "roundtrip.md"); - saveProfile({ name: "Eve", hobbies: "coding", notes: "likes coffee" }, fp); - const loaded = loadProfile(fp); + await saveProfile({ name: "Eve", hobbies: "coding", notes: "likes coffee" }, fp); + const loaded = await loadProfile(fp); assert.ok(loaded); assert.strictEqual(loaded.data.name, "Eve"); assert.strictEqual(loaded.data.hobbies, "coding"); @@ -178,15 +178,15 @@ describe("saveProfile", () => { }); describe("hasProfile", () => { - it("returns true when profile file exists", () => { + it("returns true when profile file exists", async () => { const fp = join(FULL_TEST_DIR, "exists.md"); - saveProfile({ name: "x" }, fp); - assert.strictEqual(hasProfile(fp), true); + await saveProfile({ name: "x" }, fp); + assert.strictEqual(await hasProfile(fp), true); }); - it("returns false when profile file does not exist", () => { + it("returns false when profile file does not exist", async () => { const fp = join(FULL_TEST_DIR, "nope.md"); - assert.strictEqual(hasProfile(fp), false); + assert.strictEqual(await hasProfile(fp), false); }); }); diff --git a/tests/unit/prompts.test.js b/tests/unit/prompts.test.js index 12cd1442..6b66565b 100644 --- a/tests/unit/prompts.test.js +++ b/tests/unit/prompts.test.js @@ -41,7 +41,7 @@ describe("loadSystemPrompt", () => { ); const { loadSystemPrompt } = await import("../../src/memory/prompts.js"); - const result = loadSystemPrompt(fullTestDir); + const result = await loadSystemPrompt(fullTestDir); assert.ok(result.includes("# System Prompt")); assert.ok(result.includes("You are a helpful assistant.")); }); @@ -53,7 +53,7 @@ describe("loadSystemPrompt", () => { ); const { loadSystemPrompt } = await import("../../src/memory/prompts.js"); - const result = loadSystemPrompt(fullTestDir); + const result = await loadSystemPrompt(fullTestDir); assert.ok(!result.startsWith("---")); assert.ok(result.includes("You are a helpful assistant.")); }); @@ -70,7 +70,7 @@ describe("loadSystemPrompt", () => { ); const { loadSystemPrompt } = await import("../../src/memory/prompts.js"); - const result = loadSystemPrompt(fullTestDir); + const result = await loadSystemPrompt(fullTestDir); assert.ok(result.includes("# System Prompt")); assert.ok(result.includes("You are a helpful assistant.")); // loadContext reads from cwd/memory/context/ by default, not from baseDir @@ -87,7 +87,7 @@ describe("loadSystemPrompt", () => { mkdirSync(join(fullTestDir, "memory", "context"), { recursive: true }); const { loadSystemPrompt } = await import("../../src/memory/prompts.js"); - const result = loadSystemPrompt(fullTestDir); + const result = await loadSystemPrompt(fullTestDir); // Should return prompt content without crashing assert.ok(result.includes("# System Prompt")); assert.ok(result.includes("You are a helpful assistant.")); @@ -95,7 +95,7 @@ describe("loadSystemPrompt", () => { it("returns empty string when SYSTEM_PROMPT.md does not exist", async () => { const { loadSystemPrompt } = await import("../../src/memory/prompts.js"); - const result = loadSystemPrompt("__nonexistent_dir_xyz__"); + const result = await loadSystemPrompt("__nonexistent_dir_xyz__"); assert.strictEqual(result, ""); }); }); diff --git a/tests/unit/reader.test.js b/tests/unit/reader.test.js index b092fea1..8b38c954 100644 --- a/tests/unit/reader.test.js +++ b/tests/unit/reader.test.js @@ -65,20 +65,20 @@ describe("reader", () => { assert.strictEqual(result, null); }); - it("returns parsed data for existing file with frontmatter", () => { + it("returns parsed data for existing file with frontmatter", async () => { const filePath = join(testDir, "test.md"); writeFileSync(filePath, "---\ntitle: Test File\n---\n\nThis is the body content."); - const result = readMemoryFile(filePath); + const result = await readMemoryFile(filePath); assert.ok(result); assert.strictEqual(result.frontmatter.title, "Test File"); assert.strictEqual(result.content, "This is the body content."); assert.strictEqual(result.path, filePath); }); - it("returns parsed data for file without frontmatter", () => { + it("returns parsed data for file without frontmatter", async () => { const filePath = join(testDir, "plain.md"); writeFileSync(filePath, "Just plain content without frontmatter."); - const result = readMemoryFile(filePath); + const result = await readMemoryFile(filePath); assert.ok(result); assert.deepStrictEqual(result.frontmatter, {}); assert.strictEqual(result.content, "Just plain content without frontmatter."); diff --git a/tests/unit/registry.test.js b/tests/unit/registry.test.js index ff8f532d..c733307a 100644 --- a/tests/unit/registry.test.js +++ b/tests/unit/registry.test.js @@ -360,7 +360,7 @@ describe("ensureSkillsDir", () => { fs.mkdirSync(parentDir, { recursive: true }); } - ensureSkillsDir(testDir); + await ensureSkillsDir(testDir); assert.ok(fs.existsSync(parentDir)); }); diff --git a/tests/unit/sandbox.test.js b/tests/unit/sandbox.test.js index 9ffc8b91..d4f27f8a 100644 --- a/tests/unit/sandbox.test.js +++ b/tests/unit/sandbox.test.js @@ -292,33 +292,33 @@ describe("sandbox - capability enforcement", () => { // --- Detect interpreter tests (in sandbox/runner.js too) --- describe("sandbox - detectInterpreter", () => { - it("detects python from .py extension", () => { - const result = detectInterpreter("script.py"); + it("detects python from .py extension", async () => { + const result = await detectInterpreter("script.py"); assert.deepStrictEqual(result, { command: "python3", args: [] }); }); - it("detects node from .js extension", () => { - const result = detectInterpreter("script.js"); + it("detects node from .js extension", async () => { + const result = await detectInterpreter("script.js"); assert.deepStrictEqual(result, { command: "node", args: [] }); }); - it("detects bash from .sh extension", () => { - const result = detectInterpreter("script.sh"); + it("detects bash from .sh extension", async () => { + const result = await detectInterpreter("script.sh"); assert.deepStrictEqual(result, { command: "bash", args: [] }); }); - it("detects ruby from .rb extension", () => { - const result = detectInterpreter("script.rb"); + it("detects ruby from .rb extension", async () => { + const result = await detectInterpreter("script.rb"); assert.deepStrictEqual(result, { command: "ruby", args: [] }); }); - it("detects typescript from .ts extension", () => { - const result = detectInterpreter("script.ts"); + it("detects typescript from .ts extension", async () => { + const result = await detectInterpreter("script.ts"); assert.deepStrictEqual(result, { command: "node", args: ["--import", "tsx"] }); }); - it("returns null for unknown extension", () => { - const result = detectInterpreter("script.xyz"); + it("returns null for unknown extension", async () => { + const result = await detectInterpreter("script.xyz"); assert.strictEqual(result, null); }); });