diff --git a/docs/backlog.md b/docs/backlog.md index 726194b..2fec9aa 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -103,7 +103,7 @@ | BL-ID | Title | Priority | Status | OpenSpec Change ID | Spec Path | Notes | |---|---|---|---|---|---|---| -| BL-041 | Tool registration 模組化拆分 | P1 | planned | TBD | TBD | `src/index.ts` 目前含 26 個 tool 定義;先拆 `tools/memory.ts`、`tools/feedback.ts`、`tools/episodic.ts` 降低耦合 [Surface: Plugin] | +| BL-041 | Tool registration 模組化拆分 | P1 | **done** | bl-041-tool-registration-modularization | openspec/changes/bl-041-tool-registration-modularization/ | 將 26 個 tool 定義從 index.ts 拆分至 `tools/memory.ts`、`tools/feedback.ts`、`tools/episodic.ts` 降低耦合 [Surface: Plugin] | | BL-042 | Store repository 職責分離 | P2 | planned | TBD | TBD | 將 `MemoryStore` 逐步拆為 `MemoryRepository` / `EventRepository` / `EpisodicTaskRepository`,由 provider 統一連線管理 [Surface: Plugin] | | BL-043 | Episodic 更新流程 DRY 化 | P1 | **done** | episodic-update-dry | `openspec/changes/episodic-update-dry/` | `addCommandToEpisode`、`addValidationOutcome`、`addSuccessPatterns`、`addRetryAttempt`、`addRecoveryStrategy` 以共用 updater 模板收斂 [Surface: Plugin] | | BL-044 | Duplicate consolidation 擴充性重構 | P1 | **done** | bl-044-duplicate-consolidation-ann-chunking | `openspec/changes/archive/2026-03-31-bl-044-duplicate-consolidation-ann-chunking/` | 以 ANN top-k / chunking 取代全表 O(N²) 比對,避免 `consolidateDuplicates` 在大 scope 阻塞 event loop [Surface: Plugin] | diff --git a/docs/roadmap.md b/docs/roadmap.md index cae4f9b..3f34432 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -402,7 +402,7 @@ OpenCode 要從「有長期記憶的工具」進化成「會累積團隊工作 8. `effectiveness_events` TTL / archival(Surface: Plugin)→ BL-037 9. backoff / cooldown signal ingestion(Surface: Plugin;**blocked by upstream events**)→ BL-021 10. 條件式 user/team precedence(僅在多使用者需求成立時) -11. Tool registration 模組化拆分(Surface: Plugin)→ BL-041 +11. Tool registration 模組化拆分(Surface: Plugin)→ BL-041 ✅ DONE 12. Episodic 更新流程 DRY 化(Surface: Plugin)→ BL-043 13. Duplicate consolidation 擴充性重構(Surface: Plugin)→ BL-044 ✅ DONE 14. Scope cache 記憶體治理(Surface: Plugin)→ BL-045 ✅ DONE diff --git a/openspec/changes/archive/2026-03-31-bl-041-tool-registration-modularization/.openspec.yaml b/openspec/changes/archive/2026-03-31-bl-041-tool-registration-modularization/.openspec.yaml new file mode 100644 index 0000000..8fb8631 --- /dev/null +++ b/openspec/changes/archive/2026-03-31-bl-041-tool-registration-modularization/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-31 diff --git a/openspec/changes/archive/2026-03-31-bl-041-tool-registration-modularization/design.md b/openspec/changes/archive/2026-03-31-bl-041-tool-registration-modularization/design.md new file mode 100644 index 0000000..8c5b03b --- /dev/null +++ b/openspec/changes/archive/2026-03-31-bl-041-tool-registration-modularization/design.md @@ -0,0 +1,85 @@ +# Design: BL-041 Tool Registration Modularization + +> **Backlog ID**: BL-041 +> **Runtime Surface**: internal-api (code refactoring) + +--- + +## Decision Table + +| Decision | Choice | Why | Trade-off | +|---|---|---|---| +| Directory structure | `src/tools/` with domain-based files | Aligns with proposed split: memory/feedback/episodic | Requires updating imports across the codebase | +| Tool grouping | By functional domain | memory: search, stats, remember, forget, citation, etc.
feedback: missing, wrong, useful
episodic: task_episode_*, similar_task_recall, retry/recovery | Some tools span domains - will colocate by primary purpose | +| Export strategy | Factory functions returning tool definitions | Allows state injection while keeping tool definitions modular | Slight indirection vs direct export | +| Backward compatibility | Re-export all tools from index.ts | Existing integration points remain unchanged | index.ts remains as thin facade | + +--- + +## Architecture + +``` +src/ + index.ts # Plugin entry, hooks, state management + tools/ + memory.ts # Tool definitions: search, stats, remember, forget, citation, etc. + feedback.ts # Tool definitions: feedback_missing, feedback_wrong, feedback_useful, etc. + episodic.ts # Tool definitions: task_episode_*, similar_task_recall, retry/recovery + index.ts # Re-exports all tools for compatibility +``` + +### Tool Groupings + +**memory.ts** - Memory management tools: +- memory_search, memory_delete, memory_clear, memory_stats +- memory_remember, memory_forget +- memory_citation, memory_validate_citation +- memory_what_did_you_learn, memory_why, memory_explain_recall +- memory_scope_promote, memory_scope_demote, memory_global_list +- memory_consolidate, memory_consolidate_all +- memory_port_plan +- memory_dashboard, memory_kpi + +**feedback.ts** - Feedback-related tools: +- memory_feedback_missing +- memory_feedback_wrong +- memory_feedback_useful +- memory_effectiveness + +**episodic.ts** - Task/episode learning tools: +- task_episode_create +- task_episode_query +- similar_task_recall +- retry_budget_suggest +- recovery_strategy_suggest + +--- + +## Operability + +### Trigger Path +- This is an internal refactoring; no runtime behavior changes +- All existing tool names and schemas remain identical + +### Expected Behavior +- All 26 tools should function identically before and after refactoring +- No changes to hook wiring, config, or runtime state + +### Misconfiguration Behavior +- If imports are broken, TypeScript compilation will fail +- If tool exports are missing, runtime will fail to register tools + +--- + +## Verification + +### Unit Tests +- Each tool file should have unit tests for tool definitions +- Verify tool names, descriptions, and schemas are preserved + +### Integration Tests +- Plugin loads and registers all tools successfully +- All tool execute functions work with mock state + +### E2E +- Not required (internal-only refactoring) diff --git a/openspec/changes/archive/2026-03-31-bl-041-tool-registration-modularization/proposal.md b/openspec/changes/archive/2026-03-31-bl-041-tool-registration-modularization/proposal.md new file mode 100644 index 0000000..775ac5d --- /dev/null +++ b/openspec/changes/archive/2026-03-31-bl-041-tool-registration-modularization/proposal.md @@ -0,0 +1,65 @@ +# Proposal: BL-041 Tool Registration Modularization + +> **Backlog ID**: BL-041 +> **Status**: planned +> **Surface**: Plugin (internal refactoring) +> **Release Impact**: internal-only + +--- + +## Problem Statement + +`src/index.ts` currently contains 26 tool definitions along with hooks, injection logic, and core business logic. This creates several issues: + +1. **High coupling**: Changing one tool's implementation risks breaking others +2. **Hard to test**: Large monolithic file makes unit testing difficult +3. **Code review friction**: 1626 lines in one file is hard to review thoroughly +4. **Slow iteration**: Any change requires understanding the entire file + +The roadmap explicitly calls for modularization: "先拆 `tools/memory.ts`、`tools/feedback.ts`、`tools/episodic.ts` 降低耦合" + +## What Changes + +- Extract 26 tool definitions from `src/index.ts` into modular files in `src/tools/` +- Create `src/tools/memory.ts`, `src/tools/feedback.ts`, `src/tools/episodic.ts` +- Add `src/tools/index.ts` for re-exports +- Update imports in `src/index.ts` + +## Why + +- Reduce file size and complexity in index.ts +- Improve maintainability and testability +- Enable future feature additions without further growth + +## Why Now + +- The plugin has matured with 26 stable tools +- Future features (BL-040 playbook, BL-037 TTL) will add more tools +- Without modularization, the file will continue to grow, making maintenance increasingly difficult + +## Scope + +### In Scope +- Split tool definitions into separate modules under `src/tools/` +- Maintain backward compatibility for all tool names and interfaces +- Ensure all existing functionality works identically after refactoring + +### Out of Scope +- No changes to tool behavior or schema +- No changes to hook wiring (these can stay in index.ts) +- No new features + +## Impacted Modules + +- `src/index.ts` → `src/tools/memory.ts`, `src/tools/feedback.ts`, `src/tools/episodic.ts` +- Export interfaces must remain compatible + +## Changelog Wording Class + +`internal-only` - No user-facing changes. This is purely a refactoring to improve maintainability. + +--- + +## Risk Level + +**Low** - This is a refactoring task with no behavioral changes. The primary risk is breaking existing tool exports, which can be caught by the test suite. diff --git a/openspec/changes/archive/2026-03-31-bl-041-tool-registration-modularization/specs/tool-registration/spec.md b/openspec/changes/archive/2026-03-31-bl-041-tool-registration-modularization/specs/tool-registration/spec.md new file mode 100644 index 0000000..a929c45 --- /dev/null +++ b/openspec/changes/archive/2026-03-31-bl-041-tool-registration-modularization/specs/tool-registration/spec.md @@ -0,0 +1,129 @@ +# Spec: Tool Registration Modularization + +> **Change ID**: bl-041-tool-registration-modularization +> **Runtime Surface**: internal-api +> **Entrypoint**: `src/tools/*.ts`, `src/index.ts` re-exports + +--- + +## Requirement: Tool Definitions Preserved + +The system SHALL preserve all existing tool definitions with identical names, descriptions, and schemas. + +Runtime Surface: internal-api +Entrypoint: `src/tools/memory.ts`, `src/tools/feedback.ts`, `src/tools/episodic.ts` + +### Scenario: All memory tools exist +- WHEN the plugin is loaded +- THEN all memory-related tools are registered: + - memory_search, memory_delete, memory_clear, memory_stats + - memory_remember, memory_forget + - memory_citation, memory_validate_citation + - memory_what_did_you_learn, memory_why, memory_explain_recall + - memory_scope_promote, memory_scope_demote, memory_global_list + - memory_consolidate, memory_consolidate_all + - memory_port_plan + - memory_dashboard, memory_kpi + +### Scenario: All feedback tools exist +- WHEN the plugin is loaded +- THEN all feedback tools are registered: + - memory_feedback_missing + - memory_feedback_wrong + - memory_feedback_useful + - memory_effectiveness + +### Scenario: All episodic tools exist +- WHEN the plugin is loaded +- THEN all episodic/task tools are registered: + - task_episode_create + - task_episode_query + - similar_task_recall + - retry_budget_suggest + - recovery_strategy_suggest + +--- + +## Requirement: Tool Schemas Unchanged + +The system SHALL maintain identical tool argument schemas after modularization. + +Runtime Surface: internal-api +Entrypoint: `src/tools/*.ts` + +### Scenario: Schema compatibility +- GIVEN existing tool definitions in production +- WHEN comparing old and new tool schemas +- THEN all tool.name, tool.description, and tool.args are identical + +--- + +## Requirement: Tool Execution Works + +The system SHALL execute tool functions with the same behavior as before refactoring. + +Runtime Surface: internal-api +Entrypoint: `src/tools/*.ts` execute functions + +### Scenario: Tool execution with state +- GIVEN initialized plugin state +- WHEN executing a tool (e.g., memory_search) +- THEN the tool executes successfully and returns expected output format + +### Scenario: Tool handles uninitialized state +- GIVEN uninitialized plugin state +- WHEN executing a tool +- THEN the tool returns the unavailable message + +--- + +## Requirement: Module Structure Valid + +The system SHALL have tools organized in domain-specific modules. + +Runtime Surface: internal-api +Entrypoint: `src/tools/index.ts` re-exports + +### Scenario: Module exports exist +- WHEN importing from `src/tools/` +- THEN all tools are exported from appropriate modules +- AND all tools are re-exported from `src/tools/index.ts` + +### Scenario: Import paths work +- WHEN TypeScript compiles the project +- THEN all import paths resolve correctly +- AND no circular dependency errors + +--- + +## Observability + +### Inspection Points +- TypeScript compilation success/failure +- Plugin load success/failure +- All 26 tools registered in plugin hooks + +--- + +## Verification Matrix + +| Requirement | Unit | Integration | E2E | Required to release | +|---|---|---|---|---| +| Tool definitions preserved | ✅ | ✅ | n/a | yes | +| Tool schemas unchanged | ✅ | n/a | n/a | yes | +| Tool execution works | ✅ | ✅ | n/a | yes | +| Module structure valid | ✅ | n/a | n/a | yes | + +--- + +## ADDED + +- `src/tools/` directory with modular tool definitions +- `src/tools/memory.ts` - 19 memory-related tools +- `src/tools/feedback.ts` - 4 feedback tools +- `src/tools/episodic.ts` - 5 episodic/task tools +- `src/tools/index.ts` - re-exports for backward compatibility + +## MODIFIED + +- `src/index.ts` - reduced from 1626 to ~620 lines, imports tools from new modules diff --git a/openspec/changes/archive/2026-03-31-bl-041-tool-registration-modularization/tasks.md b/openspec/changes/archive/2026-03-31-bl-041-tool-registration-modularization/tasks.md new file mode 100644 index 0000000..5871b53 --- /dev/null +++ b/openspec/changes/archive/2026-03-31-bl-041-tool-registration-modularization/tasks.md @@ -0,0 +1,76 @@ +# Tasks: BL-041 Tool Registration Modularization + +> **Change ID**: bl-041-tool-registration-modularization + +--- + +## Implementation Tasks + +### Phase 1: Create tool module directory structure + +- [x] Create `src/tools/` directory +- [x] Create `src/tools/index.ts` for re-exports + +### Phase 2: Extract memory tools + +- [x] Create `src/tools/memory.ts` +- [x] Move memory_search tool definition to memory.ts +- [x] Move memory_delete tool definition to memory.ts +- [x] Move memory_clear tool definition to memory.ts +- [x] Move memory_stats tool definition to memory.ts +- [x] Move memory_remember tool definition to memory.ts +- [x] Move memory_forget tool definition to memory.ts +- [x] Move memory_citation tool definition to memory.ts +- [x] Move memory_validate_citation tool definition to memory.ts +- [x] Move memory_what_did_you_learn tool definition to memory.ts +- [x] Move memory_why tool definition to memory.ts +- [x] Move memory_explain_recall tool definition to memory.ts +- [x] Move memory_scope_promote tool definition to memory.ts +- [x] Move memory_scope_demote tool definition to memory.ts +- [x] Move memory_global_list tool definition to memory.ts +- [x] Move memory_consolidate tool definition to memory.ts +- [x] Move memory_consolidate_all tool definition to memory.ts +- [x] Move memory_port_plan tool definition to memory.ts +- [x] Move memory_dashboard tool definition to memory.ts +- [x] Move memory_kpi tool definition to memory.ts + +### Phase 3: Extract feedback tools + +- [x] Create `src/tools/feedback.ts` +- [x] Move memory_feedback_missing tool definition to feedback.ts +- [x] Move memory_feedback_wrong tool definition to feedback.ts +- [x] Move memory_feedback_useful tool definition to feedback.ts +- [x] Move memory_effectiveness tool definition to feedback.ts + +### Phase 4: Extract episodic tools + +- [x] Create `src/tools/episodic.ts` +- [x] Move task_episode_create tool definition to episodic.ts +- [x] Move task_episode_query tool definition to episodic.ts +- [x] Move similar_task_recall tool definition to episodic.ts +- [x] Move retry_budget_suggest tool definition to episodic.ts +- [x] Move recovery_strategy_suggest tool definition to episodic.ts + +### Phase 5: Update index.ts imports and hooks + +- [x] Update `src/index.ts` imports to use new tool modules +- [x] Wire up all tools from new modules in hooks.tool +- [x] Verify TypeScript compilation succeeds + +### Phase 6: Verification + +- [x] Add unit tests for tool definitions in each module +- [x] Add integration test to verify all 26 tools register successfully +- [x] Run existing test suite to ensure no regressions +- [x] Verify plugin loads correctly in test environment + +--- + +## Verification Matrix + +| Requirement | Unit | Integration | E2E | Required to release | +|---|---|---|---|---| +| Tool definitions preserved | ✅ | ✅ | n/a | yes | +| Tool schemas unchanged | ✅ | n/a | n/a | yes | +| Tool execution works | ✅ | ✅ | n/a | yes | +| Module structure valid | ✅ | n/a | n/a | yes | diff --git a/src/index.ts b/src/index.ts index 93f4553..4f7b6b5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,18 +1,17 @@ import type { Hooks, Plugin } from "@opencode-ai/plugin"; -import { tool } from "@opencode-ai/plugin"; import type { Part, TextPart } from "@opencode-ai/sdk"; import { resolveMemoryConfig } from "./config.js"; import { createEmbedder } from "./embedder.js"; import type { Embedder } from "./embedder.js"; import { extractCaptureCandidate, isGlobalCandidate } from "./extract.js"; import { extractPreferenceSignals, aggregatePreferences, resolveConflicts, buildPreferenceInjection } from "./preference.js"; -import { isTcpPortAvailable, parsePortReservations, planPorts, reservationKey } from "./ports.js"; import { buildScopeFilter, deriveProjectScope } from "./scope.js"; import { MemoryStore } from "./store.js"; import type { CaptureOutcome, CaptureSkipReason, EpisodicTaskRecord, FailureType, LastRecallSession, MemoryRuntimeConfig, PreferenceProfile, SearchResult, SuccessPattern, TaskState, TaskType, ValidationOutcome, ValidationType } from "./types.js"; import { validateEpisodicRecordArray } from "./types.js"; import { generateId } from "./utils.js"; import { calculateInjectionLimit, createSummarizationConfig, summarizeContent } from "./summarize.js"; +import { createMemoryTools, createFeedbackTools, createEpisodicTools, type ToolRuntimeState } from "./tools/index.js"; const SCHEMA_VERSION = 1; @@ -276,1014 +275,9 @@ const plugin: Plugin = async (input) => { eventOutput.system.push(blocks.join("\n\n")); }, tool: { - memory_search: tool({ - description: "Search long-term memory using hybrid retrieval", - args: { - query: tool.schema.string().min(1), - limit: tool.schema.number().int().min(1).max(20).default(5), - scope: tool.schema.string().optional(), - }, - execute: async (args, context) => { - await state.ensureInitialized(); - if (!state.initialized) return unavailableMessage(state.config.embedding.provider); - const activeScope = args.scope ?? deriveProjectScope(context.worktree); - const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope); - - let queryVector: number[] = []; - try { - queryVector = await state.embedder.embed(args.query); - } catch { - queryVector = []; - } - - const results = await state.store.search({ - query: args.query, - queryVector, - scopes, - limit: args.limit, - vectorWeight: state.config.retrieval.mode === "vector" ? 1 : state.config.retrieval.vectorWeight, - bm25Weight: state.config.retrieval.mode === "vector" ? 0 : state.config.retrieval.bm25Weight, - minScore: state.config.retrieval.minScore, - rrfK: state.config.retrieval.rrfK, - recencyBoost: state.config.retrieval.recencyBoost, - recencyHalfLifeHours: state.config.retrieval.recencyHalfLifeHours, - importanceWeight: state.config.retrieval.importanceWeight, - feedbackWeight: state.config.retrieval.feedbackWeight, - globalDiscountFactor: state.config.globalDiscountFactor, - }); - - state.lastRecall = { - timestamp: Date.now(), - query: args.query, - results: results.map((r) => ({ - memoryId: r.record.id, - score: r.score, - factors: { - relevance: { overall: r.score, vectorScore: r.vectorScore, bm25Score: r.bm25Score }, - recency: { timestamp: r.record.timestamp, ageHours: 0, withinHalfLife: true, decayFactor: 1 }, - citation: r.record.citationSource ? { source: r.record.citationSource, status: r.record.citationStatus } : undefined, - importance: r.record.importance, - scope: { memoryScope: r.record.scope, matchesCurrentScope: r.record.scope === activeScope, isGlobal: r.record.scope === "global" }, - }, - })), - }; - - await state.store.putEvent({ - id: generateId(), - type: "recall", - source: "manual-search", - scope: activeScope, - sessionID: context.sessionID, - timestamp: Date.now(), - resultCount: results.length, - injected: false, - metadataJson: JSON.stringify({ source: "manual-search" }), - }); - - if (results.length === 0) return "No relevant memory found."; - - for (const result of results) { - state.store.updateMemoryUsage(result.record.id, activeScope, scopes).catch(() => {}); - } - - return results - .map((item, idx) => { - const percent = Math.round(item.score * 100); - const meta = JSON.parse(item.record.metadataJson || "{}"); - const duplicateMarker = meta.isPotentialDuplicate ? " (duplicate)" : ""; - const citationInfo = item.record.citationSource - ? ` [${item.record.citationSource}|${item.record.citationStatus ?? "pending"}]` - : ""; - return `${idx + 1}. [${item.record.id}]${duplicateMarker}${citationInfo} (${item.record.scope}) ${item.record.text} [${percent}%]`; - }) - .join("\n"); - }, - }), - memory_delete: tool({ - description: "Delete one memory entry by id", - args: { - id: tool.schema.string().min(8), - scope: tool.schema.string().optional(), - confirm: tool.schema.boolean().default(false), - }, - execute: async (args, context) => { - await state.ensureInitialized(); - if (!state.initialized) return unavailableMessage(state.config.embedding.provider); - if (!args.confirm) { - return "Rejected: memory_delete requires confirm=true."; - } - const activeScope = args.scope ?? deriveProjectScope(context.worktree); - const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope); - const deleted = await state.store.deleteById(args.id, scopes); - return deleted ? `Deleted memory ${args.id}.` : `Memory ${args.id} not found in current scope.`; - }, - }), - memory_clear: tool({ - description: "Clear all memories in a scope (requires confirm=true)", - args: { - scope: tool.schema.string(), - confirm: tool.schema.boolean().default(false), - }, - execute: async (args) => { - await state.ensureInitialized(); - if (!state.initialized) return unavailableMessage(state.config.embedding.provider); - if (!args.confirm) { - return "Rejected: destructive clear requires confirm=true."; - } - const count = await state.store.clearScope(args.scope); - return `Cleared ${count} memories from scope ${args.scope}.`; - }, - }), - memory_stats: tool({ - description: "Show memory provider status and index health", - args: { - scope: tool.schema.string().optional(), - }, - execute: async (args, context) => { - await state.ensureInitialized(); - if (!state.initialized) return unavailableMessage(state.config.embedding.provider); - const scope = args.scope ?? deriveProjectScope(context.worktree); - const entries = await state.store.list(scope, 20); - const incompatibleVectors = await state.store.countIncompatibleVectors( - buildScopeFilter(scope, state.config.includeGlobalScope), - await state.embedder.dim(), - ); - const health = state.store.getIndexHealth(); - return JSON.stringify( - { - provider: state.config.provider, - dbPath: state.config.dbPath, - scope, - recentCount: entries.length, - incompatibleVectors, - index: health, - embeddingModel: state.config.embedding.model, - }, - null, - 2, - ); - }, - }), - memory_feedback_missing: tool({ - description: "Record feedback for memory that should have been stored", - args: { - text: tool.schema.string().min(1), - labels: tool.schema.array(tool.schema.string().min(1)).default([]), - scope: tool.schema.string().optional(), - }, - execute: async (args, context) => { - await state.ensureInitialized(); - if (!state.initialized) return unavailableMessage(state.config.embedding.provider); - const scope = args.scope ?? deriveProjectScope(context.worktree); - await state.store.putEvent({ - id: generateId(), - type: "feedback", - feedbackType: "missing", - scope, - sessionID: context.sessionID, - timestamp: Date.now(), - text: args.text, - labels: args.labels, - metadataJson: JSON.stringify({ source: "memory_feedback_missing" }), - }); - return "Recorded missing-memory feedback."; - }, - }), - memory_feedback_wrong: tool({ - description: "Record feedback for memory that should not be stored", - args: { - id: tool.schema.string().min(8), - reason: tool.schema.string().optional(), - scope: tool.schema.string().optional(), - }, - execute: async (args, context) => { - await state.ensureInitialized(); - if (!state.initialized) return unavailableMessage(state.config.embedding.provider); - const scope = args.scope ?? deriveProjectScope(context.worktree); - const scopes = buildScopeFilter(scope, state.config.includeGlobalScope); - const exists = await state.store.hasMemory(args.id, scopes); - if (!exists) { - return `Memory ${args.id} not found in current scope.`; - } - await state.store.putEvent({ - id: generateId(), - type: "feedback", - feedbackType: "wrong", - scope, - sessionID: context.sessionID, - timestamp: Date.now(), - memoryId: args.id, - reason: args.reason, - metadataJson: JSON.stringify({ source: "memory_feedback_wrong" }), - }); - return `Recorded wrong-memory feedback for ${args.id}.`; - }, - }), - memory_feedback_useful: tool({ - description: "Record whether a recalled memory was helpful", - args: { - id: tool.schema.string().min(8), - helpful: tool.schema.boolean(), - scope: tool.schema.string().optional(), - }, - execute: async (args, context) => { - await state.ensureInitialized(); - if (!state.initialized) return unavailableMessage(state.config.embedding.provider); - const scope = args.scope ?? deriveProjectScope(context.worktree); - const scopes = buildScopeFilter(scope, state.config.includeGlobalScope); - const exists = await state.store.hasMemory(args.id, scopes); - if (!exists) { - return `Memory ${args.id} not found in current scope.`; - } - await state.store.putEvent({ - id: generateId(), - type: "feedback", - feedbackType: "useful", - scope, - sessionID: context.sessionID, - timestamp: Date.now(), - memoryId: args.id, - helpful: args.helpful, - metadataJson: JSON.stringify({ source: "memory_feedback_useful" }), - }); - return `Recorded recall usefulness feedback for ${args.id}.`; - }, - }), - memory_effectiveness: tool({ - description: "Show effectiveness metrics for capture recall and feedback", - args: { - scope: tool.schema.string().optional(), - }, - execute: async (args, context) => { - await state.ensureInitialized(); - if (!state.initialized) return unavailableMessage(state.config.embedding.provider); - const scope = args.scope ?? deriveProjectScope(context.worktree); - const summary = await state.store.summarizeEvents(scope, state.config.includeGlobalScope); - return JSON.stringify(summary, null, 2); - }, - }), - memory_dashboard: tool({ - description: "Show weekly learning dashboard with trends and insights", - args: { - days: tool.schema.number().int().min(1).max(90).default(7), - scope: tool.schema.string().optional(), - }, - execute: async (args, context) => { - await state.ensureInitialized(); - if (!state.initialized) return unavailableMessage(state.config.embedding.provider); - const scope = args.scope ?? deriveProjectScope(context.worktree); - const dashboard = await state.store.getWeeklyEffectivenessSummary(scope, state.config.includeGlobalScope, args.days); - return JSON.stringify(dashboard, null, 2); - }, - }), - memory_kpi: tool({ - description: "Show learning KPI metrics (retry-to-success rate and memory lift)", - args: { - days: tool.schema.number().int().min(1).max(365).default(30), - scope: tool.schema.string().optional(), - }, - execute: async (args, context) => { - await state.ensureInitialized(); - if (!state.initialized) return unavailableMessage(state.config.embedding.provider); - const scope = args.scope ?? deriveProjectScope(context.worktree); - const kpi = await state.store.getKpiSummary(scope, args.days); - return JSON.stringify(kpi, null, 2); - }, - }), - memory_scope_promote: tool({ - description: "Promote a memory from project scope to global scope for cross-project sharing", - args: { - id: tool.schema.string().min(8), - confirm: tool.schema.boolean().default(false), - }, - execute: async (args, context) => { - await state.ensureInitialized(); - if (!state.initialized) return unavailableMessage(state.config.embedding.provider); - if (!args.confirm) { - return "Rejected: memory_scope_promote requires confirm=true."; - } - const activeScope = deriveProjectScope(context.worktree); - const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope); - const exists = await state.store.hasMemory(args.id, scopes); - if (!exists) { - return `Memory ${args.id} not found in current scope.`; - } - const updated = await state.store.updateMemoryScope(args.id, "global", scopes); - if (!updated) { - return `Failed to promote memory ${args.id}.`; - } - return `Promoted memory ${args.id} to global scope.`; - }, - }), - memory_scope_demote: tool({ - description: "Demote a memory from global scope to project scope", - args: { - id: tool.schema.string().min(8), - confirm: tool.schema.boolean().default(false), - scope: tool.schema.string().optional(), - }, - execute: async (args, context) => { - await state.ensureInitialized(); - if (!state.initialized) return unavailableMessage(state.config.embedding.provider); - if (!args.confirm) { - return "Rejected: memory_scope_demote requires confirm=true."; - } - const projectScope = args.scope ?? deriveProjectScope(context.worktree); - const globalExists = await state.store.hasMemory(args.id, ["global"]); - if (!globalExists) { - return `Memory ${args.id} not found in global scope or is not a global memory.`; - } - const updated = await state.store.updateMemoryScope(args.id, projectScope, ["global"]); - if (!updated) { - return `Failed to demote memory ${args.id}.`; - } - return `Demoted memory ${args.id} from global to ${projectScope}.`; - }, - }), - memory_global_list: tool({ - description: "List all global-scoped memories, optionally filtered by search query or unused status", - args: { - query: tool.schema.string().optional(), - filter: tool.schema.string().optional(), - limit: tool.schema.number().int().min(1).max(100).default(20), - }, - execute: async (args) => { - await state.ensureInitialized(); - if (!state.initialized) return unavailableMessage(state.config.embedding.provider); - - let records: import("./types.js").MemoryRecord[]; - if (args.filter === "unused") { - records = await state.store.getUnusedGlobalMemories(state.config.unusedDaysThreshold, args.limit); - } else if (args.query) { - let queryVector: number[] = []; - try { - queryVector = await state.embedder.embed(args.query); - } catch { - queryVector = []; - } - records = await state.store.search({ - query: args.query, - queryVector, - scopes: ["global"], - limit: args.limit, - vectorWeight: 0.7, - bm25Weight: 0.3, - minScore: 0.2, - globalDiscountFactor: 1.0, - }).then((results) => results.map((r) => r.record)); - } else { - records = await state.store.readGlobalMemories(args.limit); - } - - if (records.length === 0) { - return "No global memories found."; - } - - return records - .map((record, idx) => { - const date = new Date(record.timestamp).toISOString().split("T")[0]; - const lastRecalled = record.lastRecalled > 0 - ? new Date(record.lastRecalled).toISOString().split("T")[0] - : "never"; - return `${idx + 1}. [${record.id}] ${record.text.slice(0, 80)}... - Stored: ${date} | Recalled: ${lastRecalled} | Count: ${record.recallCount} | Projects: ${record.projectCount}`; - }) - .join("\n"); - }, - }), - memory_consolidate: tool({ - description: "Scope-internally merge near-duplicate memories. Use to clean up accumulated duplicates.", - args: { - scope: tool.schema.string().optional(), - confirm: tool.schema.boolean().default(false), - }, - execute: async (args, context) => { - await state.ensureInitialized(); - if (!state.initialized) return unavailableMessage(state.config.embedding.provider); - if (!args.confirm) { - return "Rejected: memory_consolidate requires confirm=true."; - } - const targetScope = args.scope ?? deriveProjectScope(context.worktree); - if (state.consolidationInProgress.get(targetScope)) { - return JSON.stringify({ scope: targetScope, status: "already_in_progress", message: "Consolidation already in progress for this scope" }); - } - state.consolidationInProgress.set(targetScope, true); - try { - const result = await state.store.consolidateDuplicates(targetScope, state.config.dedup.consolidateThreshold, state.config.dedup.candidateLimit); - return JSON.stringify({ scope: targetScope, ...result }, null, 2); - } finally { - state.consolidationInProgress.delete(targetScope); - } - }, - }), - memory_consolidate_all: tool({ - description: "Consolidate duplicates across global scope and current project scope. Used by external cron jobs for daily cleanup.", - args: { - confirm: tool.schema.boolean().default(false), - }, - execute: async (args, context) => { - await state.ensureInitialized(); - if (!state.initialized) return unavailableMessage(state.config.embedding.provider); - if (!args.confirm) { - return "Rejected: memory_consolidate_all requires confirm=true."; - } - const projectScope = deriveProjectScope(context.worktree); - const globalInProgress = state.consolidationInProgress.get("global"); - const projectInProgress = state.consolidationInProgress.get(projectScope); - if (globalInProgress || projectInProgress) { - return JSON.stringify({ - global: { scope: "global", status: globalInProgress ? "already_in_progress" : "pending" }, - project: { scope: projectScope, status: projectInProgress ? "already_in_progress" : "pending" }, - message: "Consolidation already in progress for one or more scopes", - }); - } - state.consolidationInProgress.set("global", true); - state.consolidationInProgress.set(projectScope, true); - try { - const globalResult = await state.store.consolidateDuplicates("global", state.config.dedup.consolidateThreshold, state.config.dedup.candidateLimit); - const projectResult = await state.store.consolidateDuplicates(projectScope, state.config.dedup.consolidateThreshold, state.config.dedup.candidateLimit); - return JSON.stringify({ - global: { scope: "global", ...globalResult }, - project: { scope: projectScope, ...projectResult }, - }, null, 2); - } finally { - state.consolidationInProgress.delete("global"); - state.consolidationInProgress.delete(projectScope); - } - }, - }), - memory_port_plan: tool({ - description: "Plan non-conflicting host ports for compose services and optionally persist reservations", - args: { - project: tool.schema.string().min(1).optional(), - services: tool.schema - .array( - tool.schema.object({ - name: tool.schema.string().min(1), - containerPort: tool.schema.number().int().min(1).max(65535), - preferredHostPort: tool.schema.number().int().min(1).max(65535).optional(), - }), - ) - .min(1), - rangeStart: tool.schema.number().int().min(1).max(65535).default(20000), - rangeEnd: tool.schema.number().int().min(1).max(65535).default(39999), - persist: tool.schema.boolean().default(true), - }, - execute: async (args, context) => { - await state.ensureInitialized(); - if (!state.initialized) return unavailableMessage(state.config.embedding.provider); - if (args.rangeStart > args.rangeEnd) { - return "Invalid range: rangeStart must be <= rangeEnd."; - } - - const project = args.project?.trim() || deriveProjectScope(context.worktree); - const globalRecords = await state.store.list("global", 100000); - const reservations = parsePortReservations(globalRecords); - - const assignments = await planPorts( - { - project, - services: args.services, - rangeStart: args.rangeStart, - rangeEnd: args.rangeEnd, - reservations, - }, - isTcpPortAvailable, - ); - - let persisted = 0; - const warnings: string[] = []; - - if (args.persist) { - const keyToOldIds = new Map(); - for (const reservation of reservations) { - const key = reservationKey(reservation.project, reservation.service, reservation.protocol); - if (!keyToOldIds.has(key)) { - keyToOldIds.set(key, []); - } - keyToOldIds.get(key)?.push(reservation.id); - } - - for (const assignment of assignments) { - const key = reservationKey(assignment.project, assignment.service, assignment.protocol); - const oldIds = keyToOldIds.get(key) ?? []; - const text = `PORT_RESERVATION ${assignment.project} ${assignment.service} host=${assignment.hostPort} container=${assignment.containerPort} protocol=${assignment.protocol}`; - try { - const vector = await state.embedder.embed(text); - if (vector.length === 0) { - warnings.push(`Skipped persistence for ${assignment.service}: empty embedding vector.`); - continue; - } - - await state.store.put({ - id: generateId(), - text, - vector, - category: "entity", - scope: "global", - importance: 0.8, - timestamp: Date.now(), - lastRecalled: 0, - recallCount: 0, - projectCount: 0, - schemaVersion: SCHEMA_VERSION, - embeddingModel: state.config.embedding.model, - vectorDim: vector.length, - metadataJson: JSON.stringify({ - source: "port-plan", - type: "port-reservation", - project: assignment.project, - service: assignment.service, - hostPort: assignment.hostPort, - containerPort: assignment.containerPort, - protocol: assignment.protocol, - }), - }); - - for (const id of oldIds) { - await state.store.deleteById(id, ["global"]); - } - persisted += 1; - } catch (error) { - warnings.push(`Failed to persist ${assignment.service}: ${toErrorMessage(error)}`); - } - } - } - - return JSON.stringify( - { - project, - persistRequested: args.persist, - persisted, - assignments, - warnings, - }, - null, - 2, - ); - }, - }), - memory_remember: tool({ - description: "Explicitly store a memory with optional category label", - args: { - text: tool.schema.string().min(1), - category: tool.schema.string().optional(), - scope: tool.schema.string().optional(), - }, - execute: async (args, context) => { - await state.ensureInitialized(); - if (!state.initialized) return unavailableMessage(state.config.embedding.provider); - - if (args.text.length < state.config.minCaptureChars) { - return `Content too short (minimum ${state.config.minCaptureChars} characters).`; - } - - const activeScope = args.scope ?? deriveProjectScope(context.worktree); - - let vector: number[] = []; - try { - vector = await state.embedder.embed(args.text); - } catch { - vector = []; - } - - if (vector.length === 0) { - return "Failed to create embedding vector."; - } - - const memoryId = generateId(); - const now = Date.now(); - await state.store.put({ - id: memoryId, - text: args.text, - vector, - category: (args.category as import("./types.js").MemoryCategory) ?? "other", - scope: activeScope, - importance: 0.7, - timestamp: now, - lastRecalled: 0, - recallCount: 0, - projectCount: 0, - schemaVersion: SCHEMA_VERSION, - embeddingModel: state.config.embedding.model, - vectorDim: vector.length, - metadataJson: JSON.stringify({ source: "explicit-remember", category: args.category }), - sourceSessionId: context.sessionID, - citationSource: "explicit-remember", - citationTimestamp: now, - citationStatus: "pending", - }); - - await state.store.putEvent({ - id: generateId(), - type: "capture", - outcome: "stored", - scope: activeScope, - sessionID: context.sessionID, - timestamp: Date.now(), - memoryId, - text: args.text, - metadataJson: JSON.stringify({ source: "explicit-remember", category: args.category }), - sourceSessionId: context.sessionID, - }); - - return `Stored memory ${memoryId} in scope ${activeScope}.`; - }, - }), - memory_forget: tool({ - description: "Remove or disable a memory (soft-delete by default, hard-delete with confirm)", - args: { - id: tool.schema.string().min(8), - force: tool.schema.boolean().default(false), - scope: tool.schema.string().optional(), - }, - execute: async (args, context) => { - await state.ensureInitialized(); - if (!state.initialized) return unavailableMessage(state.config.embedding.provider); - - const activeScope = args.scope ?? deriveProjectScope(context.worktree); - const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope); - - if (args.force) { - const deleted = await state.store.deleteById(args.id, scopes); - if (!deleted) { - return `Memory ${args.id} not found in current scope.`; - } - await state.store.putEvent({ - id: generateId(), - type: "feedback", - feedbackType: "useful", - scope: activeScope, - sessionID: context.sessionID, - timestamp: Date.now(), - memoryId: args.id, - helpful: false, - metadataJson: JSON.stringify({ source: "explicit-forget", hardDelete: true }), - }); - return `Permanently deleted memory ${args.id}.`; - } - - const softDeleted = await state.store.softDeleteMemory(args.id, scopes); - if (!softDeleted) { - return `Memory ${args.id} not found in current scope.`; - } - await state.store.putEvent({ - id: generateId(), - type: "feedback", - feedbackType: "useful", - scope: activeScope, - sessionID: context.sessionID, - timestamp: Date.now(), - memoryId: args.id, - helpful: false, - metadataJson: JSON.stringify({ source: "explicit-forget", hardDelete: false }), - }); - return `Soft-deleted (disabled) memory ${args.id}. Use force=true for permanent deletion.`; - }, - }), - memory_citation: tool({ - description: "View or update citation information for a memory", - args: { - id: tool.schema.string().min(8), - status: tool.schema.string().optional(), - scope: tool.schema.string().optional(), - }, - execute: async (args, context) => { - await state.ensureInitialized(); - if (!state.initialized) return unavailableMessage(state.config.embedding.provider); - - const activeScope = args.scope ?? deriveProjectScope(context.worktree); - const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope); - - const citation = await state.store.getCitation(args.id, scopes); - if (!citation) { - return `Memory ${args.id} not found or has no citation information.`; - } - - if (args.status) { - const validStatuses = ["verified", "pending", "invalid", "expired"]; - if (!validStatuses.includes(args.status)) { - return `Invalid status. Must be one of: ${validStatuses.join(", ")}`; - } - const updated = await state.store.updateCitation(args.id, scopes, { status: args.status as import("./types.js").CitationStatus }); - if (!updated) { - return `Failed to update citation for ${args.id}.`; - } - return `Updated citation status for ${args.id} to ${args.status}.`; - } - - return JSON.stringify({ - memoryId: args.id, - source: citation.source, - timestamp: new Date(citation.timestamp).toISOString(), - status: citation.status, - chain: citation.chain, - }, null, 2); - }, - }), - memory_validate_citation: tool({ - description: "Validate a citation for a memory and update its status", - args: { - id: tool.schema.string().min(8), - scope: tool.schema.string().optional(), - }, - execute: async (args, context) => { - await state.ensureInitialized(); - if (!state.initialized) return unavailableMessage(state.config.embedding.provider); - - const activeScope = args.scope ?? deriveProjectScope(context.worktree); - const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope); - - const result = await state.store.validateCitation(args.id, scopes); - return JSON.stringify({ - memoryId: args.id, - valid: result.valid, - status: result.status, - reason: result.reason, - }, null, 2); - }, - }), - memory_what_did_you_learn: tool({ - description: "Show recent learning summary with memory counts by category", - args: { - days: tool.schema.number().int().min(1).max(90).default(7), - scope: tool.schema.string().optional(), - }, - execute: async (args, context) => { - await state.ensureInitialized(); - if (!state.initialized) return unavailableMessage(state.config.embedding.provider); - - const activeScope = args.scope ?? deriveProjectScope(context.worktree); - const sinceTimestamp = Date.now() - args.days * 24 * 60 * 60 * 1000; - - const memories = await state.store.listSince(activeScope, sinceTimestamp, 1000); - - if (memories.length === 0) { - return `No memories captured in the past ${args.days} days in scope ${activeScope}.`; - } - - const categoryCounts: Record = {}; - for (const mem of memories) { - categoryCounts[mem.category] = (categoryCounts[mem.category] ?? 0) + 1; - } - - const total = memories.length; - const categoryBreakdown = Object.entries(categoryCounts) - .map(([cat, count]) => ` - ${cat}: ${count}`) - .join("\n"); - - const recentSamples = memories.slice(0, 5).map((mem, idx) => { - const date = new Date(mem.timestamp).toISOString().split("T")[0]; - return ` ${idx + 1}. [${date}] ${mem.text.slice(0, 60)}...`; - }).join("\n"); - - return `## Learning Summary (${args.days} days) - -**Scope:** ${activeScope} -**Total memories:** ${total} - -### By Category -${categoryBreakdown} - -### Recent Captures -${recentSamples} -`; - }, - }), - // === Episodic Learning Tools === - task_episode_create: tool({ - description: "Create a new task episode record for tracking", - args: { - taskId: tool.schema.string().min(1), - scope: tool.schema.string().optional(), - description: tool.schema.string().optional(), - }, - execute: async (args, context) => { - await state.ensureInitialized(); - if (!state.initialized) return unavailableMessage(state.config.embedding.provider); - - const activeScope = args.scope ?? deriveProjectScope(context.worktree); - const episode: EpisodicTaskRecord = { - id: generateId(), - sessionId: context.sessionID, - scope: activeScope, - taskId: args.taskId, - state: "pending", - startTime: Date.now(), - endTime: 0, - commandsJson: "[]", - validationOutcomesJson: "[]", - successPatternsJson: "[]", - retryAttemptsJson: "[]", - recoveryStrategiesJson: "[]", - metadataJson: JSON.stringify({ description: args.description }), - }; - - await state.store.createTaskEpisode(episode); - return `Created task episode ${episode.id} for task ${args.taskId} in scope ${activeScope}`; - }, - }), - task_episode_query: tool({ - description: "Query task episodes by scope and state", - args: { - scope: tool.schema.string().optional(), - state: tool.schema.string().optional(), - limit: tool.schema.number().int().min(1).max(100).default(10), - }, - execute: async (args, context) => { - await state.ensureInitialized(); - if (!state.initialized) return unavailableMessage(state.config.embedding.provider); - - const activeScope = args.scope ?? deriveProjectScope(context.worktree); - const stateFilter = args.state as TaskState | undefined; - const episodes = await state.store.queryTaskEpisodes(activeScope, stateFilter); - - if (episodes.length === 0) { - return `No task episodes found in scope ${activeScope}`; - } - - const limited = episodes.slice(0, args.limit); - return limited.map((ep) => { - const meta = (JSON.parse(ep.metadataJson || "{}")) as Record; - return `[${ep.id}] ${ep.taskId} - ${ep.state} (${new Date(ep.startTime).toISOString().split("T")[0]}) ${meta.description ? `- ${meta.description}` : ""}`; - }).join("\n"); - }, - }), - similar_task_recall: tool({ - description: "Find similar past tasks using semantic search", - args: { - query: tool.schema.string().min(1), - threshold: tool.schema.number().min(0).max(1).default(0.85), - limit: tool.schema.number().int().min(1).max(10).default(3), - scope: tool.schema.string().optional(), - }, - execute: async (args, context) => { - await state.ensureInitialized(); - if (!state.initialized) return unavailableMessage(state.config.embedding.provider); - - const activeScope = args.scope ?? deriveProjectScope(context.worktree); - let queryVector: number[] = []; - try { - queryVector = await state.embedder.embed(args.query); - } catch { - queryVector = []; - } - const similar = await state.store.findSimilarTasks(activeScope, args.query, args.threshold, queryVector); - - if (similar.length === 0) { - return `No similar tasks found for "${args.query}"`; - } - - const limited = similar.slice(0, args.limit); - return limited.map((ep) => { - const commands = JSON.parse(ep.commandsJson || "[]") as string[]; - const outcomes = JSON.parse(ep.validationOutcomesJson || "[]") as ValidationOutcome[]; - return `Task: ${ep.taskId} (${ep.state}) - Commands: ${commands.slice(0, 3).join(" → ")} - Validations: ${outcomes.map((o: ValidationOutcome) => `${o.type}:${o.status}`).join(", ") || "none"} -`; - }).join("\n"); - }, - }), - retry_budget_suggest: tool({ - description: "Get retry budget suggestion based on historical data", - args: { - errorType: tool.schema.string(), - minSamples: tool.schema.number().int().min(1).default(3), - scope: tool.schema.string().optional(), - }, - execute: async (args, context) => { - await state.ensureInitialized(); - if (!state.initialized) return unavailableMessage(state.config.embedding.provider); - - const activeScope = args.scope ?? deriveProjectScope(context.worktree); - const result = await state.store.suggestRetryBudget(activeScope, args.minSamples); - - if (!result) { - return `Insufficient data for retry budget suggestion (need at least ${args.minSamples} failed tasks)`; - } - - return JSON.stringify({ - suggestedRetries: result.suggestedRetries, - confidence: result.confidence.toFixed(2), - basedOnCount: result.basedOnCount, - shouldStop: result.shouldStop, - stopReason: result.stopReason, - }, null, 2); - }, - }), - recovery_strategy_suggest: tool({ - description: "Get recovery strategy suggestions after failures", - args: { - taskId: tool.schema.string().min(1), - scope: tool.schema.string().optional(), - }, - execute: async (args, context) => { - await state.ensureInitialized(); - if (!state.initialized) return unavailableMessage(state.config.embedding.provider); - - const activeScope = args.scope ?? deriveProjectScope(context.worktree); - const strategies = await state.store.suggestRecoveryStrategies(activeScope, args.taskId); - - if (strategies.length === 0) { - return `No recovery strategies found for task ${args.taskId}`; - } - - return strategies.map((s) => { - return `- ${s.strategy}: ${s.reason} (confidence: ${s.confidence.toFixed(2)}${s.basedOnTask ? `, based on: ${s.basedOnTask}` : ""})`; - }).join("\n"); - }, - }), - memory_why: tool({ - description: "Explain why a specific memory was recalled", - args: { - id: tool.schema.string().min(8), - scope: tool.schema.string().optional(), - }, - execute: async (args, context) => { - await state.ensureInitialized(); - if (!state.initialized) return unavailableMessage(state.config.embedding.provider); - - const activeScope = args.scope ?? deriveProjectScope(context.worktree); - const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope); - - const explanation = await state.store.explainMemory( - args.id, - scopes, - activeScope, - state.config.retrieval.recencyHalfLifeHours, - state.config.globalDiscountFactor, - ); - - if (!explanation) { - return `Memory ${args.id} not found in current scope.`; - } - - const f = explanation.factors; - const recencyText = f.recency.withinHalfLife - ? `within ${f.recency.ageHours.toFixed(1)}h half-life` - : `beyond half-life (${f.recency.ageHours.toFixed(1)}h old)`; - const citationText = f.citation - ? `${f.citation.source ?? "unknown"}/${f.citation.status ?? "n/a"}` - : "N/A"; - const scopeText = f.scope.matchesCurrentScope - ? "matches current project" - : f.scope.isGlobal - ? "from global scope" - : "different project scope"; - - return `Memory: "${explanation.text.slice(0, 80)}..." -Explanation: -- Recency: ${recencyText} (decay: ${(f.recency.decayFactor * 100).toFixed(0)}%) -- Citation: ${citationText} -- Importance: ${f.importance.toFixed(2)} -- Scope: ${scopeText}`; - }, - }), - memory_explain_recall: tool({ - description: "Explain the factors behind the last recall operation in this session", - args: { - scope: tool.schema.string().optional(), - }, - execute: async (args, context) => { - await state.ensureInitialized(); - if (!state.initialized) return unavailableMessage(state.config.embedding.provider); - - const lastRecall = state.lastRecall; - if (!lastRecall) { - return "No recent recall to explain. Use memory_search or wait for auto-recall first."; - } - - const activeScope = args.scope ?? deriveProjectScope(context.worktree); - const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope); - - const explanations: string[] = []; - for (const result of lastRecall.results) { - const explanation = await state.store.explainMemory( - result.memoryId, - scopes, - activeScope, - state.config.retrieval.recencyHalfLifeHours, - state.config.globalDiscountFactor, - ); - if (!explanation) continue; - - const f = explanation.factors; - const recencyText = f.recency.withinHalfLife - ? "recent" - : "older"; - explanations.push( - `${result.memoryId.slice(0, 8)}: ${(result.score * 100).toFixed(0)}% relevance, ${recencyText}, ${f.citation?.status ?? "no citation"}`, - ); - } - - return `## Last Recall Explanation -Query: "${lastRecall.query}" -Results: ${lastRecall.results.length} - -${explanations.join("\n")}`; - }, - }), + ...createMemoryTools(state as ToolRuntimeState), + ...createFeedbackTools(state as ToolRuntimeState), + ...createEpisodicTools(state as ToolRuntimeState), }, }; diff --git a/src/tools/episodic.ts b/src/tools/episodic.ts new file mode 100644 index 0000000..5baa6fc --- /dev/null +++ b/src/tools/episodic.ts @@ -0,0 +1,157 @@ +import { tool } from "@opencode-ai/plugin"; +import { deriveProjectScope } from "../scope.js"; +import { generateId } from "../utils.js"; +import type { ToolRuntimeState, ToolContext } from "./memory.js"; +import type { EpisodicTaskRecord, ValidationOutcome, TaskState } from "../types.js"; + +function unavailableMessage(provider: string): string { + return `Memory store unavailable (${provider} embedding may be offline). Will retry automatically.`; +} + +export function createEpisodicTools(state: ToolRuntimeState) { + return { + task_episode_create: tool({ + description: "Create a new task episode record for tracking", + args: { + taskId: tool.schema.string().min(1), + scope: tool.schema.string().optional(), + description: tool.schema.string().optional(), + }, + execute: async (args: { taskId: string; scope?: string; description?: string }, context: ToolContext) => { + await state.ensureInitialized(); + if (!state.initialized) return unavailableMessage(state.config.embedding.provider); + + const activeScope = args.scope ?? deriveProjectScope(context.worktree); + const episode: EpisodicTaskRecord = { + id: generateId(), + sessionId: context.sessionID, + scope: activeScope, + taskId: args.taskId, + state: "pending", + startTime: Date.now(), + endTime: 0, + commandsJson: "[]", + validationOutcomesJson: "[]", + successPatternsJson: "[]", + retryAttemptsJson: "[]", + recoveryStrategiesJson: "[]", + metadataJson: JSON.stringify({ description: args.description }), + }; + + await state.store.createTaskEpisode(episode); + return `Created task episode ${episode.id} for task ${args.taskId} in scope ${activeScope}`; + }, + }), + task_episode_query: tool({ + description: "Query task episodes by scope and state", + args: { + scope: tool.schema.string().optional(), + state: tool.schema.string().optional(), + limit: tool.schema.number().int().min(1).max(100).default(10), + }, + execute: async (args: { scope?: string; state?: string; limit?: number }, context: ToolContext) => { + await state.ensureInitialized(); + if (!state.initialized) return unavailableMessage(state.config.embedding.provider); + + const activeScope = args.scope ?? deriveProjectScope(context.worktree); + const stateFilter = args.state as TaskState | undefined; + const episodes = await state.store.queryTaskEpisodes(activeScope, stateFilter); + + if (episodes.length === 0) { + return `No task episodes found in scope ${activeScope}`; + } + + const limited = episodes.slice(0, args.limit ?? 10); + return limited.map((ep) => { + const meta = (JSON.parse(ep.metadataJson || "{}")) as Record; + return `[${ep.id}] ${ep.taskId} - ${ep.state} (${new Date(ep.startTime).toISOString().split("T")[0]}) ${meta.description ? `- ${meta.description}` : ""}`; + }).join("\n"); + }, + }), + similar_task_recall: tool({ + description: "Find similar past tasks using semantic search", + args: { + query: tool.schema.string().min(1), + threshold: tool.schema.number().min(0).max(1).default(0.85), + limit: tool.schema.number().int().min(1).max(10).default(3), + scope: tool.schema.string().optional(), + }, + execute: async (args: { query: string; threshold?: number; limit?: number; scope?: string }, context: ToolContext) => { + await state.ensureInitialized(); + if (!state.initialized) return unavailableMessage(state.config.embedding.provider); + + const activeScope = args.scope ?? deriveProjectScope(context.worktree); + let queryVector: number[] = []; + try { + queryVector = await state.embedder.embed(args.query); + } catch { + queryVector = []; + } + const similar = await state.store.findSimilarTasks(activeScope, args.query, args.threshold ?? 0.85, queryVector); + + if (similar.length === 0) { + return `No similar tasks found for "${args.query}"`; + } + + const limited = similar.slice(0, args.limit ?? 3); + return limited.map((ep) => { + const commands = JSON.parse(ep.commandsJson || "[]") as string[]; + const outcomes = JSON.parse(ep.validationOutcomesJson || "[]") as ValidationOutcome[]; + return `Task: ${ep.taskId} (${ep.state}) + Commands: ${commands.slice(0, 3).join(" → ")} + Validations: ${outcomes.map((o: ValidationOutcome) => `${o.type}:${o.status}`).join(", ") || "none"} +`; + }).join("\n"); + }, + }), + retry_budget_suggest: tool({ + description: "Get retry budget suggestion based on historical data", + args: { + errorType: tool.schema.string(), + minSamples: tool.schema.number().int().min(1).default(3), + scope: tool.schema.string().optional(), + }, + execute: async (args: { errorType: string; minSamples?: number; scope?: string }, context: ToolContext) => { + await state.ensureInitialized(); + if (!state.initialized) return unavailableMessage(state.config.embedding.provider); + + const activeScope = args.scope ?? deriveProjectScope(context.worktree); + const result = await state.store.suggestRetryBudget(activeScope, args.minSamples ?? 3); + + if (!result) { + return `Insufficient data for retry budget suggestion (need at least ${args.minSamples} failed tasks)`; + } + + return JSON.stringify({ + suggestedRetries: result.suggestedRetries, + confidence: result.confidence.toFixed(2), + basedOnCount: result.basedOnCount, + shouldStop: result.shouldStop, + stopReason: result.stopReason, + }, null, 2); + }, + }), + recovery_strategy_suggest: tool({ + description: "Get recovery strategy suggestions after failures", + args: { + taskId: tool.schema.string().min(1), + scope: tool.schema.string().optional(), + }, + execute: async (args: { taskId: string; scope?: string }, context: ToolContext) => { + await state.ensureInitialized(); + if (!state.initialized) return unavailableMessage(state.config.embedding.provider); + + const activeScope = args.scope ?? deriveProjectScope(context.worktree); + const strategies = await state.store.suggestRecoveryStrategies(activeScope, args.taskId); + + if (strategies.length === 0) { + return `No recovery strategies found for task ${args.taskId}`; + } + + return strategies.map((s) => { + return `- ${s.strategy}: ${s.reason} (confidence: ${s.confidence.toFixed(2)}${s.basedOnTask ? `, based on: ${s.basedOnTask}` : ""})`; + }).join("\n"); + }, + }), + }; +} diff --git a/src/tools/feedback.ts b/src/tools/feedback.ts new file mode 100644 index 0000000..c43a136 --- /dev/null +++ b/src/tools/feedback.ts @@ -0,0 +1,111 @@ +import { tool } from "@opencode-ai/plugin"; +import { deriveProjectScope, buildScopeFilter } from "../scope.js"; +import { generateId } from "../utils.js"; +import type { ToolRuntimeState, ToolContext } from "./memory.js"; + +function unavailableMessage(provider: string): string { + return `Memory store unavailable (${provider} embedding may be offline). Will retry automatically.`; +} + +export function createFeedbackTools(state: ToolRuntimeState) { + return { + memory_feedback_missing: tool({ + description: "Record feedback for memory that should have been stored", + args: { + text: tool.schema.string().min(1), + labels: tool.schema.array(tool.schema.string().min(1)).default([]), + scope: tool.schema.string().optional(), + }, + execute: async (args: { text: string; labels?: string[]; scope?: string }, context: ToolContext) => { + await state.ensureInitialized(); + if (!state.initialized) return unavailableMessage(state.config.embedding.provider); + const scope = args.scope ?? deriveProjectScope(context.worktree); + await state.store.putEvent({ + id: generateId(), + type: "feedback", + feedbackType: "missing", + scope, + sessionID: context.sessionID, + timestamp: Date.now(), + text: args.text, + labels: args.labels ?? [], + metadataJson: JSON.stringify({ source: "memory_feedback_missing" }), + }); + return "Recorded missing-memory feedback."; + }, + }), + memory_feedback_wrong: tool({ + description: "Record feedback for memory that should not be stored", + args: { + id: tool.schema.string().min(8), + reason: tool.schema.string().optional(), + scope: tool.schema.string().optional(), + }, + execute: async (args: { id: string; reason?: string; scope?: string }, context: ToolContext) => { + await state.ensureInitialized(); + if (!state.initialized) return unavailableMessage(state.config.embedding.provider); + const scope = args.scope ?? deriveProjectScope(context.worktree); + const scopes = buildScopeFilter(scope, state.config.includeGlobalScope); + const exists = await state.store.hasMemory(args.id, scopes); + if (!exists) { + return `Memory ${args.id} not found in current scope.`; + } + await state.store.putEvent({ + id: generateId(), + type: "feedback", + feedbackType: "wrong", + scope, + sessionID: context.sessionID, + timestamp: Date.now(), + memoryId: args.id, + reason: args.reason, + metadataJson: JSON.stringify({ source: "memory_feedback_wrong" }), + }); + return `Recorded wrong-memory feedback for ${args.id}.`; + }, + }), + memory_feedback_useful: tool({ + description: "Record whether a recalled memory was helpful", + args: { + id: tool.schema.string().min(8), + helpful: tool.schema.boolean(), + scope: tool.schema.string().optional(), + }, + execute: async (args: { id: string; helpful: boolean; scope?: string }, context: ToolContext) => { + await state.ensureInitialized(); + if (!state.initialized) return unavailableMessage(state.config.embedding.provider); + const scope = args.scope ?? deriveProjectScope(context.worktree); + const scopes = buildScopeFilter(scope, state.config.includeGlobalScope); + const exists = await state.store.hasMemory(args.id, scopes); + if (!exists) { + return `Memory ${args.id} not found in current scope.`; + } + await state.store.putEvent({ + id: generateId(), + type: "feedback", + feedbackType: "useful", + scope, + sessionID: context.sessionID, + timestamp: Date.now(), + memoryId: args.id, + helpful: args.helpful, + metadataJson: JSON.stringify({ source: "memory_feedback_useful" }), + }); + return `Recorded recall usefulness feedback for ${args.id}.`; + }, + }), + memory_effectiveness: tool({ + description: "Show effectiveness metrics for capture recall and feedback", + args: { + scope: tool.schema.string().optional(), + }, + execute: async (args: { scope?: string }, context: ToolContext) => { + await state.ensureInitialized(); + if (!state.initialized) return unavailableMessage(state.config.embedding.provider); + const scope = args.scope ?? deriveProjectScope(context.worktree); + const summary = await state.store.summarizeEvents(scope, state.config.includeGlobalScope); + return JSON.stringify(summary, null, 2); + }, + }), + }; +} diff --git a/src/tools/index.ts b/src/tools/index.ts new file mode 100644 index 0000000..99b5862 --- /dev/null +++ b/src/tools/index.ts @@ -0,0 +1,3 @@ +export { createMemoryTools, type ToolRuntimeState, type ToolContext } from "./memory.js"; +export { createFeedbackTools } from "./feedback.js"; +export { createEpisodicTools } from "./episodic.js"; diff --git a/src/tools/memory.ts b/src/tools/memory.ts new file mode 100644 index 0000000..bff1c8e --- /dev/null +++ b/src/tools/memory.ts @@ -0,0 +1,860 @@ +import { tool } from "@opencode-ai/plugin"; +import { deriveProjectScope, buildScopeFilter } from "../scope.js"; +import { generateId } from "../utils.js"; +import type { Embedder } from "../embedder.js"; +import type { MemoryStore } from "../store.js"; +import type { MemoryRuntimeConfig, MemoryCategory, CitationStatus, ValidationOutcome } from "../types.js"; + +export interface ToolRuntimeState { + config: MemoryRuntimeConfig; + embedder: Embedder; + store: MemoryStore; + defaultScope: string; + initialized: boolean; + lastRecall: { + timestamp: number; + query: string; + results: { + memoryId: string; + score: number; + factors: { + relevance: { overall: number; vectorScore: number; bm25Score: number }; + recency: { timestamp: number; ageHours: number; withinHalfLife: boolean; decayFactor: number }; + citation?: { source: string; status: CitationStatus }; + importance: number; + scope: { memoryScope: string; matchesCurrentScope: boolean; isGlobal: boolean }; + }; + }[]; + } | null; + consolidationInProgress: Map; + ensureInitialized: () => Promise; +} + +export type ToolContext = { + worktree: string; + sessionID: string; +}; + +function unavailableMessage(provider: string): string { + return `Memory store unavailable (${provider} embedding may be offline). Will retry automatically.`; +} + +export function createMemoryTools(state: ToolRuntimeState) { + return { + memory_search: tool({ + description: "Search long-term memory using hybrid retrieval", + args: { + query: tool.schema.string().min(1), + limit: tool.schema.number().int().min(1).max(20).default(5), + scope: tool.schema.string().optional(), + }, + execute: async (args: { query: string; limit?: number; scope?: string }, context: ToolContext) => { + await state.ensureInitialized(); + if (!state.initialized) return unavailableMessage(state.config.embedding.provider); + const activeScope = args.scope ?? deriveProjectScope(context.worktree); + const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope); + + let queryVector: number[] = []; + try { + queryVector = await state.embedder.embed(args.query); + } catch { + queryVector = []; + } + + const results = await state.store.search({ + query: args.query, + queryVector, + scopes, + limit: args.limit ?? 5, + vectorWeight: state.config.retrieval.mode === "vector" ? 1 : state.config.retrieval.vectorWeight, + bm25Weight: state.config.retrieval.mode === "vector" ? 0 : state.config.retrieval.bm25Weight, + minScore: state.config.retrieval.minScore, + rrfK: state.config.retrieval.rrfK, + recencyBoost: state.config.retrieval.recencyBoost, + recencyHalfLifeHours: state.config.retrieval.recencyHalfLifeHours, + importanceWeight: state.config.retrieval.importanceWeight, + feedbackWeight: state.config.retrieval.feedbackWeight, + globalDiscountFactor: state.config.globalDiscountFactor, + }); + + state.lastRecall = { + timestamp: Date.now(), + query: args.query, + results: results.map((r) => ({ + memoryId: r.record.id, + score: r.score, + factors: { + relevance: { overall: r.score, vectorScore: r.vectorScore, bm25Score: r.bm25Score }, + recency: { timestamp: r.record.timestamp, ageHours: 0, withinHalfLife: true, decayFactor: 1 }, + citation: r.record.citationSource ? { source: r.record.citationSource, status: r.record.citationStatus ?? "pending" } : undefined, + importance: r.record.importance, + scope: { memoryScope: r.record.scope, matchesCurrentScope: r.record.scope === activeScope, isGlobal: r.record.scope === "global" }, + }, + })), + }; + + await state.store.putEvent({ + id: generateId(), + type: "recall", + source: "manual-search", + scope: activeScope, + sessionID: context.sessionID, + timestamp: Date.now(), + resultCount: results.length, + injected: false, + metadataJson: JSON.stringify({ source: "manual-search" }), + }); + + if (results.length === 0) return "No relevant memory found."; + + for (const result of results) { + state.store.updateMemoryUsage(result.record.id, activeScope, scopes).catch(() => {}); + } + + return results + .map((item, idx) => { + const percent = Math.round(item.score * 100); + const meta = JSON.parse(item.record.metadataJson || "{}"); + const duplicateMarker = meta.isPotentialDuplicate ? " (duplicate)" : ""; + const citationInfo = item.record.citationSource + ? ` [${item.record.citationSource}|${item.record.citationStatus ?? "pending"}]` + : ""; + return `${idx + 1}. [${item.record.id}]${duplicateMarker}${citationInfo} (${item.record.scope}) ${item.record.text} [${percent}%]`; + }) + .join("\n"); + }, + }), + memory_delete: tool({ + description: "Delete one memory entry by id", + args: { + id: tool.schema.string().min(8), + scope: tool.schema.string().optional(), + confirm: tool.schema.boolean().default(false), + }, + execute: async (args: { id: string; scope?: string; confirm?: boolean }, context: ToolContext) => { + await state.ensureInitialized(); + if (!state.initialized) return unavailableMessage(state.config.embedding.provider); + if (!args.confirm) { + return "Rejected: memory_delete requires confirm=true."; + } + const activeScope = args.scope ?? deriveProjectScope(context.worktree); + const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope); + const deleted = await state.store.deleteById(args.id, scopes); + return deleted ? `Deleted memory ${args.id}.` : `Memory ${args.id} not found in current scope.`; + }, + }), + memory_clear: tool({ + description: "Clear all memories in a scope (requires confirm=true)", + args: { + scope: tool.schema.string(), + confirm: tool.schema.boolean().default(false), + }, + execute: async (args: { scope: string; confirm?: boolean }) => { + await state.ensureInitialized(); + if (!state.initialized) return unavailableMessage(state.config.embedding.provider); + if (!args.confirm) { + return "Rejected: destructive clear requires confirm=true."; + } + const count = await state.store.clearScope(args.scope); + return `Cleared ${count} memories from scope ${args.scope}.`; + }, + }), + memory_stats: tool({ + description: "Show memory provider status and index health", + args: { + scope: tool.schema.string().optional(), + }, + execute: async (args: { scope?: string }, context: ToolContext) => { + await state.ensureInitialized(); + if (!state.initialized) return unavailableMessage(state.config.embedding.provider); + const scope = args.scope ?? deriveProjectScope(context.worktree); + const entries = await state.store.list(scope, 20); + const incompatibleVectors = await state.store.countIncompatibleVectors( + buildScopeFilter(scope, state.config.includeGlobalScope), + await state.embedder.dim(), + ); + const health = state.store.getIndexHealth(); + return JSON.stringify( + { + provider: state.config.provider, + dbPath: state.config.dbPath, + scope, + recentCount: entries.length, + incompatibleVectors, + index: health, + embeddingModel: state.config.embedding.model, + }, + null, + 2, + ); + }, + }), + memory_remember: tool({ + description: "Explicitly store a memory with optional category label", + args: { + text: tool.schema.string().min(1), + category: tool.schema.string().optional(), + scope: tool.schema.string().optional(), + }, + execute: async (args: { text: string; category?: string; scope?: string }, context: ToolContext) => { + await state.ensureInitialized(); + if (!state.initialized) return unavailableMessage(state.config.embedding.provider); + + if (args.text.length < state.config.minCaptureChars) { + return `Content too short (minimum ${state.config.minCaptureChars} characters).`; + } + + const activeScope = args.scope ?? deriveProjectScope(context.worktree); + + let vector: number[] = []; + try { + vector = await state.embedder.embed(args.text); + } catch { + vector = []; + } + + if (vector.length === 0) { + return "Failed to create embedding vector."; + } + + const memoryId = generateId(); + const now = Date.now(); + await state.store.put({ + id: memoryId, + text: args.text, + vector, + category: (args.category as MemoryCategory) ?? "other", + scope: activeScope, + importance: 0.7, + timestamp: now, + lastRecalled: 0, + recallCount: 0, + projectCount: 0, + schemaVersion: 1, + embeddingModel: state.config.embedding.model, + vectorDim: vector.length, + metadataJson: JSON.stringify({ source: "explicit-remember", category: args.category }), + sourceSessionId: context.sessionID, + citationSource: "explicit-remember", + citationTimestamp: now, + citationStatus: "pending", + }); + + await state.store.putEvent({ + id: generateId(), + type: "capture", + outcome: "stored", + scope: activeScope, + sessionID: context.sessionID, + timestamp: Date.now(), + memoryId, + text: args.text, + metadataJson: JSON.stringify({ source: "explicit-remember", category: args.category }), + sourceSessionId: context.sessionID, + }); + + return `Stored memory ${memoryId} in scope ${activeScope}.`; + }, + }), + memory_forget: tool({ + description: "Remove or disable a memory (soft-delete by default, hard-delete with confirm)", + args: { + id: tool.schema.string().min(8), + force: tool.schema.boolean().default(false), + scope: tool.schema.string().optional(), + }, + execute: async (args: { id: string; force?: boolean; scope?: string }, context: ToolContext) => { + await state.ensureInitialized(); + if (!state.initialized) return unavailableMessage(state.config.embedding.provider); + + const activeScope = args.scope ?? deriveProjectScope(context.worktree); + const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope); + + if (args.force) { + const deleted = await state.store.deleteById(args.id, scopes); + if (!deleted) { + return `Memory ${args.id} not found in current scope.`; + } + await state.store.putEvent({ + id: generateId(), + type: "feedback", + feedbackType: "useful", + scope: activeScope, + sessionID: context.sessionID, + timestamp: Date.now(), + memoryId: args.id, + helpful: false, + metadataJson: JSON.stringify({ source: "explicit-forget", hardDelete: true }), + }); + return `Permanently deleted memory ${args.id}.`; + } + + const softDeleted = await state.store.softDeleteMemory(args.id, scopes); + if (!softDeleted) { + return `Memory ${args.id} not found in current scope.`; + } + await state.store.putEvent({ + id: generateId(), + type: "feedback", + feedbackType: "useful", + scope: activeScope, + sessionID: context.sessionID, + timestamp: Date.now(), + memoryId: args.id, + helpful: false, + metadataJson: JSON.stringify({ source: "explicit-forget", hardDelete: false }), + }); + return `Soft-deleted (disabled) memory ${args.id}. Use force=true for permanent deletion.`; + }, + }), + memory_citation: tool({ + description: "View or update citation information for a memory", + args: { + id: tool.schema.string().min(8), + status: tool.schema.string().optional(), + scope: tool.schema.string().optional(), + }, + execute: async (args: { id: string; status?: string; scope?: string }, context: ToolContext) => { + await state.ensureInitialized(); + if (!state.initialized) return unavailableMessage(state.config.embedding.provider); + + const activeScope = args.scope ?? deriveProjectScope(context.worktree); + const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope); + + const citation = await state.store.getCitation(args.id, scopes); + if (!citation) { + return `Memory ${args.id} not found or has no citation information.`; + } + + if (args.status) { + const validStatuses = ["verified", "pending", "invalid", "expired"]; + if (!validStatuses.includes(args.status)) { + return `Invalid status. Must be one of: ${validStatuses.join(", ")}`; + } + const updated = await state.store.updateCitation(args.id, scopes, { status: args.status as CitationStatus }); + if (!updated) { + return `Failed to update citation for ${args.id}.`; + } + return `Updated citation status for ${args.id} to ${args.status}.`; + } + + return JSON.stringify({ + memoryId: args.id, + source: citation.source, + timestamp: new Date(citation.timestamp).toISOString(), + status: citation.status, + chain: citation.chain, + }, null, 2); + }, + }), + memory_validate_citation: tool({ + description: "Validate a citation for a memory and update its status", + args: { + id: tool.schema.string().min(8), + scope: tool.schema.string().optional(), + }, + execute: async (args: { id: string; scope?: string }, context: ToolContext) => { + await state.ensureInitialized(); + if (!state.initialized) return unavailableMessage(state.config.embedding.provider); + + const activeScope = args.scope ?? deriveProjectScope(context.worktree); + const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope); + + const result = await state.store.validateCitation(args.id, scopes); + return JSON.stringify({ + memoryId: args.id, + valid: result.valid, + status: result.status, + reason: result.reason, + }, null, 2); + }, + }), + memory_what_did_you_learn: tool({ + description: "Show recent learning summary with memory counts by category", + args: { + days: tool.schema.number().int().min(1).max(90).default(7), + scope: tool.schema.string().optional(), + }, + execute: async (args: { days?: number; scope?: string }, context: ToolContext) => { + await state.ensureInitialized(); + if (!state.initialized) return unavailableMessage(state.config.embedding.provider); + + const activeScope = args.scope ?? deriveProjectScope(context.worktree); + const sinceTimestamp = Date.now() - (args.days ?? 7) * 24 * 60 * 60 * 1000; + + const memories = await state.store.listSince(activeScope, sinceTimestamp, 1000); + + if (memories.length === 0) { + return `No memories captured in the past ${args.days} days in scope ${activeScope}.`; + } + + const categoryCounts: Record = {}; + for (const mem of memories) { + categoryCounts[mem.category] = (categoryCounts[mem.category] ?? 0) + 1; + } + + const total = memories.length; + const categoryBreakdown = Object.entries(categoryCounts) + .map(([cat, count]) => ` - ${cat}: ${count}`) + .join("\n"); + + const recentSamples = memories.slice(0, 5).map((mem, idx) => { + const date = new Date(mem.timestamp).toISOString().split("T")[0]; + return ` ${idx + 1}. [${date}] ${mem.text.slice(0, 60)}...`; + }).join("\n"); + + return `## Learning Summary (${args.days} days) + +**Scope:** ${activeScope} +**Total memories:** ${total} + +### By Category +${categoryBreakdown} + +### Recent Captures +${recentSamples} +`; + }, + }), + memory_why: tool({ + description: "Explain why a specific memory was recalled", + args: { + id: tool.schema.string().min(8), + scope: tool.schema.string().optional(), + }, + execute: async (args: { id: string; scope?: string }, context: ToolContext) => { + await state.ensureInitialized(); + if (!state.initialized) return unavailableMessage(state.config.embedding.provider); + + const activeScope = args.scope ?? deriveProjectScope(context.worktree); + const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope); + + const explanation = await state.store.explainMemory( + args.id, + scopes, + activeScope, + state.config.retrieval.recencyHalfLifeHours, + state.config.globalDiscountFactor, + ); + + if (!explanation) { + return `Memory ${args.id} not found in current scope.`; + } + + const f = explanation.factors; + const recencyText = f.recency.withinHalfLife + ? `within ${f.recency.ageHours.toFixed(1)}h half-life` + : `beyond half-life (${f.recency.ageHours.toFixed(1)}h old)`; + const citationText = f.citation + ? `${f.citation.source ?? "unknown"}/${f.citation.status ?? "n/a"}` + : "N/A"; + const scopeText = f.scope.matchesCurrentScope + ? "matches current project" + : f.scope.isGlobal + ? "from global scope" + : "different project scope"; + + return `Memory: "${explanation.text.slice(0, 80)}..." +Explanation: +- Recency: ${recencyText} (decay: ${(f.recency.decayFactor * 100).toFixed(0)}%) +- Citation: ${citationText} +- Importance: ${f.importance.toFixed(2)} +- Scope: ${scopeText}`; + }, + }), + memory_explain_recall: tool({ + description: "Explain the factors behind the last recall operation in this session", + args: { + scope: tool.schema.string().optional(), + }, + execute: async (args: { scope?: string }, context: ToolContext) => { + await state.ensureInitialized(); + if (!state.initialized) return unavailableMessage(state.config.embedding.provider); + + const lastRecall = state.lastRecall; + if (!lastRecall) { + return "No recent recall to explain. Use memory_search or wait for auto-recall first."; + } + + const activeScope = args.scope ?? deriveProjectScope(context.worktree); + const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope); + + const explanations: string[] = []; + for (const result of lastRecall.results) { + const explanation = await state.store.explainMemory( + result.memoryId, + scopes, + activeScope, + state.config.retrieval.recencyHalfLifeHours, + state.config.globalDiscountFactor, + ); + if (!explanation) continue; + + const f = explanation.factors; + const recencyText = f.recency.withinHalfLife + ? "recent" + : "older"; + explanations.push( + `${result.memoryId.slice(0, 8)}: ${(result.score * 100).toFixed(0)}% relevance, ${recencyText}, ${f.citation?.status ?? "no citation"}`, + ); + } + + return `## Last Recall Explanation +Query: "${lastRecall.query}" +Results: ${lastRecall.results.length} + +${explanations.join("\n")}`; + }, + }), + memory_scope_promote: tool({ + description: "Promote a memory from project scope to global scope for cross-project sharing", + args: { + id: tool.schema.string().min(8), + confirm: tool.schema.boolean().default(false), + }, + execute: async (args: { id: string; confirm?: boolean }, context: ToolContext) => { + await state.ensureInitialized(); + if (!state.initialized) return unavailableMessage(state.config.embedding.provider); + if (!args.confirm) { + return "Rejected: memory_scope_promote requires confirm=true."; + } + const activeScope = deriveProjectScope(context.worktree); + const scopes = buildScopeFilter(activeScope, state.config.includeGlobalScope); + const exists = await state.store.hasMemory(args.id, scopes); + if (!exists) { + return `Memory ${args.id} not found in current scope.`; + } + const updated = await state.store.updateMemoryScope(args.id, "global", scopes); + if (!updated) { + return `Failed to promote memory ${args.id}.`; + } + return `Promoted memory ${args.id} to global scope.`; + }, + }), + memory_scope_demote: tool({ + description: "Demote a memory from global scope to project scope", + args: { + id: tool.schema.string().min(8), + confirm: tool.schema.boolean().default(false), + scope: tool.schema.string().optional(), + }, + execute: async (args: { id: string; confirm?: boolean; scope?: string }, context: ToolContext) => { + await state.ensureInitialized(); + if (!state.initialized) return unavailableMessage(state.config.embedding.provider); + if (!args.confirm) { + return "Rejected: memory_scope_demote requires confirm=true."; + } + const projectScope = args.scope ?? deriveProjectScope(context.worktree); + const globalExists = await state.store.hasMemory(args.id, ["global"]); + if (!globalExists) { + return `Memory ${args.id} not found in global scope or is not a global memory.`; + } + const updated = await state.store.updateMemoryScope(args.id, projectScope, ["global"]); + if (!updated) { + return `Failed to demote memory ${args.id}.`; + } + return `Demoted memory ${args.id} from global to ${projectScope}.`; + }, + }), + memory_global_list: tool({ + description: "List all global-scoped memories, optionally filtered by search query or unused status", + args: { + query: tool.schema.string().optional(), + filter: tool.schema.string().optional(), + limit: tool.schema.number().int().min(1).max(100).default(20), + }, + execute: async (args: { query?: string; filter?: string; limit?: number }) => { + await state.ensureInitialized(); + if (!state.initialized) return unavailableMessage(state.config.embedding.provider); + + let records: import("../types.js").MemoryRecord[]; + if (args.filter === "unused") { + records = await state.store.getUnusedGlobalMemories(state.config.unusedDaysThreshold, args.limit ?? 20); + } else if (args.query) { + let queryVector: number[] = []; + try { + queryVector = await state.embedder.embed(args.query); + } catch { + queryVector = []; + } + records = await state.store.search({ + query: args.query, + queryVector, + scopes: ["global"], + limit: args.limit ?? 20, + vectorWeight: 0.7, + bm25Weight: 0.3, + minScore: 0.2, + globalDiscountFactor: 1.0, + }).then((results) => results.map((r) => r.record)); + } else { + records = await state.store.readGlobalMemories(args.limit ?? 20); + } + + if (records.length === 0) { + return "No global memories found."; + } + + return records + .map((record, idx) => { + const date = new Date(record.timestamp).toISOString().split("T")[0]; + const lastRecalled = record.lastRecalled > 0 + ? new Date(record.lastRecalled).toISOString().split("T")[0] + : "never"; + return `${idx + 1}. [${record.id}] ${record.text.slice(0, 80)}... + Stored: ${date} | Recalled: ${lastRecalled} | Count: ${record.recallCount} | Projects: ${record.projectCount}`; + }) + .join("\n"); + }, + }), + memory_consolidate: tool({ + description: "Scope-internally merge near-duplicate memories. Use to clean up accumulated duplicates.", + args: { + scope: tool.schema.string().optional(), + confirm: tool.schema.boolean().default(false), + }, + execute: async (args: { scope?: string; confirm?: boolean }, context: ToolContext) => { + await state.ensureInitialized(); + if (!state.initialized) return unavailableMessage(state.config.embedding.provider); + if (!args.confirm) { + return "Rejected: memory_consolidate requires confirm=true."; + } + const targetScope = args.scope ?? deriveProjectScope(context.worktree); + if (state.consolidationInProgress.get(targetScope)) { + return JSON.stringify({ scope: targetScope, status: "already_in_progress", message: "Consolidation already in progress for this scope" }); + } + state.consolidationInProgress.set(targetScope, true); + try { + const result = await state.store.consolidateDuplicates(targetScope, state.config.dedup.consolidateThreshold, state.config.dedup.candidateLimit); + return JSON.stringify({ scope: targetScope, ...result }, null, 2); + } finally { + state.consolidationInProgress.delete(targetScope); + } + }, + }), + memory_consolidate_all: tool({ + description: "Consolidate duplicates across global scope and current project scope. Used by external cron jobs for daily cleanup.", + args: { + confirm: tool.schema.boolean().default(false), + }, + execute: async (args: { confirm?: boolean }, context: ToolContext) => { + await state.ensureInitialized(); + if (!state.initialized) return unavailableMessage(state.config.embedding.provider); + if (!args.confirm) { + return "Rejected: memory_consolidate_all requires confirm=true."; + } + const projectScope = deriveProjectScope(context.worktree); + const globalInProgress = state.consolidationInProgress.get("global"); + const projectInProgress = state.consolidationInProgress.get(projectScope); + if (globalInProgress || projectInProgress) { + return JSON.stringify({ + global: { scope: "global", status: globalInProgress ? "already_in_progress" : "pending" }, + project: { scope: projectScope, status: projectInProgress ? "already_in_progress" : "pending" }, + message: "Consolidation already in progress for one or more scopes", + }); + } + state.consolidationInProgress.set("global", true); + state.consolidationInProgress.set(projectScope, true); + try { + const globalResult = await state.store.consolidateDuplicates("global", state.config.dedup.consolidateThreshold, state.config.dedup.candidateLimit); + const projectResult = await state.store.consolidateDuplicates(projectScope, state.config.dedup.consolidateThreshold, state.config.dedup.candidateLimit); + return JSON.stringify({ + global: { scope: "global", ...globalResult }, + project: { scope: projectScope, ...projectResult }, + }, null, 2); + } finally { + state.consolidationInProgress.delete("global"); + state.consolidationInProgress.delete(projectScope); + } + }, + }), + memory_port_plan: tool({ + description: "Plan non-conflicting host ports for compose services and optionally persist reservations", + args: { + project: tool.schema.string().min(1).optional(), + services: tool.schema + .array( + tool.schema.object({ + name: tool.schema.string().min(1), + containerPort: tool.schema.number().int().min(1).max(65535), + preferredHostPort: tool.schema.number().int().min(1).max(65535).optional(), + }), + ) + .min(1), + rangeStart: tool.schema.number().int().min(1).max(65535).default(20000), + rangeEnd: tool.schema.number().int().min(1).max(65535).default(39999), + persist: tool.schema.boolean().default(true), + }, + execute: async (args: { + project?: string; + services: { name: string; containerPort: number; preferredHostPort?: number }[]; + rangeStart?: number; + rangeEnd?: number; + persist?: boolean; + }, context: ToolContext) => { + await state.ensureInitialized(); + if (!state.initialized) return unavailableMessage(state.config.embedding.provider); + if ((args.rangeStart ?? 20000) > (args.rangeEnd ?? 39999)) { + return "Invalid range: rangeStart must be <= rangeEnd."; + } + + const project = args.project?.trim() || deriveProjectScope(context.worktree); + const globalRecords = await state.store.list("global", 100000); + + const reservations: Array<{ id: string; project: string; service: string; protocol: string }> = []; + for (const record of globalRecords) { + try { + const meta = JSON.parse(record.metadataJson || "{}"); + if (meta.type === "port-reservation") { + reservations.push({ + id: record.id, + project: meta.project, + service: meta.service, + protocol: meta.protocol, + }); + } + } catch { + // skip invalid records + } + } + + const assignments: Array<{ project: string; service: string; containerPort: number; hostPort: number; protocol: string }> = []; + const usedPorts = new Set(); + + for (const res of reservations) { + try { + const record = globalRecords.find(r => r.id === res.id); + if (record) { + const meta = JSON.parse(record.metadataJson || "{}"); + usedPorts.add(meta.hostPort); + } + } catch { + // skip + } + } + + for (const service of args.services) { + let hostPort = service.preferredHostPort; + if (!hostPort || usedPorts.has(hostPort)) { + hostPort = 0; + for (let port = args.rangeStart ?? 20000; port <= (args.rangeEnd ?? 39999); port++) { + if (!usedPorts.has(port)) { + hostPort = port; + break; + } + } + } + if (hostPort > 0) { + usedPorts.add(hostPort); + assignments.push({ + project, + service: service.name, + containerPort: service.containerPort, + hostPort, + protocol: "tcp", + }); + } + } + + let persisted = 0; + const warnings: string[] = []; + + if (args.persist) { + const keyToOldIds = new Map(); + for (const reservation of reservations) { + const key = `${reservation.project}:${reservation.service}:${reservation.protocol}`; + if (!keyToOldIds.has(key)) { + keyToOldIds.set(key, []); + } + keyToOldIds.get(key)?.push(reservation.id); + } + + for (const assignment of assignments) { + const key = `${assignment.project}:${assignment.service}:${assignment.protocol}`; + const oldIds = keyToOldIds.get(key) ?? []; + const text = `PORT_RESERVATION ${assignment.project} ${assignment.service} host=${assignment.hostPort} container=${assignment.containerPort} protocol=${assignment.protocol}`; + try { + const vector = await state.embedder.embed(text); + if (vector.length === 0) { + warnings.push(`Skipped persistence for ${assignment.service}: empty embedding vector.`); + continue; + } + + await state.store.put({ + id: generateId(), + text, + vector, + category: "entity", + scope: "global", + importance: 0.8, + timestamp: Date.now(), + lastRecalled: 0, + recallCount: 0, + projectCount: 0, + schemaVersion: 1, + embeddingModel: state.config.embedding.model, + vectorDim: vector.length, + metadataJson: JSON.stringify({ + source: "port-plan", + type: "port-reservation", + project: assignment.project, + service: assignment.service, + hostPort: assignment.hostPort, + containerPort: assignment.containerPort, + protocol: assignment.protocol, + }), + }); + + for (const id of oldIds) { + await state.store.deleteById(id, ["global"]); + } + persisted += 1; + } catch (error) { + warnings.push(`Failed to persist ${assignment.service}: ${error instanceof Error ? error.message : String(error)}`); + } + } + } + + return JSON.stringify( + { + project, + persistRequested: args.persist, + persisted, + assignments, + warnings, + }, + null, + 2, + ); + }, + }), + memory_dashboard: tool({ + description: "Show weekly learning dashboard with trends and insights", + args: { + days: tool.schema.number().int().min(1).max(90).default(7), + scope: tool.schema.string().optional(), + }, + execute: async (args: { days?: number; scope?: string }, context: ToolContext) => { + await state.ensureInitialized(); + if (!state.initialized) return unavailableMessage(state.config.embedding.provider); + const scope = args.scope ?? deriveProjectScope(context.worktree); + const dashboard = await state.store.getWeeklyEffectivenessSummary(scope, state.config.includeGlobalScope, args.days ?? 7); + return JSON.stringify(dashboard, null, 2); + }, + }), + memory_kpi: tool({ + description: "Show learning KPI metrics (retry-to-success rate and memory lift)", + args: { + days: tool.schema.number().int().min(1).max(365).default(30), + scope: tool.schema.string().optional(), + }, + execute: async (args: { days?: number; scope?: string }, context: ToolContext) => { + await state.ensureInitialized(); + if (!state.initialized) return unavailableMessage(state.config.embedding.provider); + const scope = args.scope ?? deriveProjectScope(context.worktree); + const kpi = await state.store.getKpiSummary(scope, args.days ?? 30); + return JSON.stringify(kpi, null, 2); + }, + }), + }; +}