From 2b5bfbb0d01bdb6e6608ba613facd144b6867ffb Mon Sep 17 00:00:00 2001 From: Jonathan Tsai Date: Sat, 28 Mar 2026 16:24:31 +0800 Subject: [PATCH] feat: Release B - episodic task learning - EpisodicTaskRecord schema (BL-003) - Task episode capture (BL-014) - Validation outcome ingestion (BL-015) - Failure taxonomy classification (BL-016) - Success pattern extraction (BL-017) - Similar task recall (BL-018) - Retry/recovery evidence (BL-019, BL-020) - Preference learning from Release A chore: bump version to 0.2.7 and update changelog --- CHANGELOG.md | 22 + .../.openspec.yaml | 2 + .../design.md | 38 ++ .../proposal.md | 23 + .../specs/episodic-task-schema/spec.md | 33 ++ .../tasks.md | 23 + .../.openspec.yaml | 2 + .../design.md | 39 ++ .../proposal.md | 29 ++ .../specs/memory-explicit-forget/spec.md | 39 ++ .../specs/memory-explicit-remember/spec.md | 32 ++ .../specs/memory-learning-summary/spec.md | 30 ++ .../tasks.md | 34 ++ .../.openspec.yaml | 2 + .../design.md | 39 ++ .../proposal.md | 29 ++ .../preference-conflict-resolution/spec.md | 38 ++ .../preference-profile-aggregator/spec.md | 42 ++ .../specs/preference-prompt-injection/spec.md | 44 ++ .../specs/preference-scope-precedence/spec.md | 39 ++ .../tasks.md | 41 ++ .../.openspec.yaml | 2 + .../design.md | 39 ++ .../proposal.md | 25 + .../specs/retry-budget-suggestion/spec.md | 22 + .../specs/retry-recovery-evidence/spec.md | 22 + .../strategy-switching-suggester/spec.md | 23 + .../tasks.md | 28 + .../.openspec.yaml | 2 + .../design.md | 40 ++ .../proposal.md | 28 + .../specs/failure-taxonomy/spec.md | 36 ++ .../specs/similar-task-recall/spec.md | 23 + .../specs/success-pattern-extraction/spec.md | 22 + .../specs/task-episode-capture/spec.md | 22 + .../validation-outcome-ingestion/spec.md | 26 + .../tasks.md | 38 ++ .../.openspec.yaml | 2 + .../design.md | 37 ++ .../proposal.md | 28 + .../specs/feedback-event-metadata/spec.md | 36 ++ .../specs/memory-record-metadata/spec.md | 56 ++ .../specs/memory-schema-migration/spec.md | 38 ++ .../tasks.md | 39 ++ package.json | 2 +- src/index.ts | 191 ++++++- src/preference.ts | 160 ++++++ src/store.ts | 488 +++++++++++++++++- src/types.ts | 118 +++++ src/utils.ts | 163 ++++++ test/foundation/foundation.test.ts | 108 ++++ test/unit/episodic-task.test.ts | 113 ++++ test/unit/preference.test.ts | 104 ++++ test/unit/validation.test.ts | 67 +++ 54 files changed, 2754 insertions(+), 14 deletions(-) create mode 100644 openspec/changes/archive/2026-03-28-add-episodic-task-schema/.openspec.yaml create mode 100644 openspec/changes/archive/2026-03-28-add-episodic-task-schema/design.md create mode 100644 openspec/changes/archive/2026-03-28-add-episodic-task-schema/proposal.md create mode 100644 openspec/changes/archive/2026-03-28-add-episodic-task-schema/specs/episodic-task-schema/spec.md create mode 100644 openspec/changes/archive/2026-03-28-add-episodic-task-schema/tasks.md create mode 100644 openspec/changes/archive/2026-03-28-add-explicit-memory-commands/.openspec.yaml create mode 100644 openspec/changes/archive/2026-03-28-add-explicit-memory-commands/design.md create mode 100644 openspec/changes/archive/2026-03-28-add-explicit-memory-commands/proposal.md create mode 100644 openspec/changes/archive/2026-03-28-add-explicit-memory-commands/specs/memory-explicit-forget/spec.md create mode 100644 openspec/changes/archive/2026-03-28-add-explicit-memory-commands/specs/memory-explicit-remember/spec.md create mode 100644 openspec/changes/archive/2026-03-28-add-explicit-memory-commands/specs/memory-learning-summary/spec.md create mode 100644 openspec/changes/archive/2026-03-28-add-explicit-memory-commands/tasks.md create mode 100644 openspec/changes/archive/2026-03-28-add-preference-learning/.openspec.yaml create mode 100644 openspec/changes/archive/2026-03-28-add-preference-learning/design.md create mode 100644 openspec/changes/archive/2026-03-28-add-preference-learning/proposal.md create mode 100644 openspec/changes/archive/2026-03-28-add-preference-learning/specs/preference-conflict-resolution/spec.md create mode 100644 openspec/changes/archive/2026-03-28-add-preference-learning/specs/preference-profile-aggregator/spec.md create mode 100644 openspec/changes/archive/2026-03-28-add-preference-learning/specs/preference-prompt-injection/spec.md create mode 100644 openspec/changes/archive/2026-03-28-add-preference-learning/specs/preference-scope-precedence/spec.md create mode 100644 openspec/changes/archive/2026-03-28-add-preference-learning/tasks.md create mode 100644 openspec/changes/archive/2026-03-28-add-retry-recovery-evidence/.openspec.yaml create mode 100644 openspec/changes/archive/2026-03-28-add-retry-recovery-evidence/design.md create mode 100644 openspec/changes/archive/2026-03-28-add-retry-recovery-evidence/proposal.md create mode 100644 openspec/changes/archive/2026-03-28-add-retry-recovery-evidence/specs/retry-budget-suggestion/spec.md create mode 100644 openspec/changes/archive/2026-03-28-add-retry-recovery-evidence/specs/retry-recovery-evidence/spec.md create mode 100644 openspec/changes/archive/2026-03-28-add-retry-recovery-evidence/specs/strategy-switching-suggester/spec.md create mode 100644 openspec/changes/archive/2026-03-28-add-retry-recovery-evidence/tasks.md create mode 100644 openspec/changes/archive/2026-03-28-add-task-episode-learning/.openspec.yaml create mode 100644 openspec/changes/archive/2026-03-28-add-task-episode-learning/design.md create mode 100644 openspec/changes/archive/2026-03-28-add-task-episode-learning/proposal.md create mode 100644 openspec/changes/archive/2026-03-28-add-task-episode-learning/specs/failure-taxonomy/spec.md create mode 100644 openspec/changes/archive/2026-03-28-add-task-episode-learning/specs/similar-task-recall/spec.md create mode 100644 openspec/changes/archive/2026-03-28-add-task-episode-learning/specs/success-pattern-extraction/spec.md create mode 100644 openspec/changes/archive/2026-03-28-add-task-episode-learning/specs/task-episode-capture/spec.md create mode 100644 openspec/changes/archive/2026-03-28-add-task-episode-learning/specs/validation-outcome-ingestion/spec.md create mode 100644 openspec/changes/archive/2026-03-28-add-task-episode-learning/tasks.md create mode 100644 openspec/changes/archive/2026-03-28-extend-memory-metadata/.openspec.yaml create mode 100644 openspec/changes/archive/2026-03-28-extend-memory-metadata/design.md create mode 100644 openspec/changes/archive/2026-03-28-extend-memory-metadata/proposal.md create mode 100644 openspec/changes/archive/2026-03-28-extend-memory-metadata/specs/feedback-event-metadata/spec.md create mode 100644 openspec/changes/archive/2026-03-28-extend-memory-metadata/specs/memory-record-metadata/spec.md create mode 100644 openspec/changes/archive/2026-03-28-extend-memory-metadata/specs/memory-schema-migration/spec.md create mode 100644 openspec/changes/archive/2026-03-28-extend-memory-metadata/tasks.md create mode 100644 src/preference.ts create mode 100644 test/unit/episodic-task.test.ts create mode 100644 test/unit/preference.test.ts create mode 100644 test/unit/validation.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 94af9fa..1379009 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). Versions follow --- +## [0.2.7] - 2026-03-28 + +### Added + +- **Episodic Task Schema** (BL-003): New `EpisodicTaskRecord` interface with `TaskState`, `FailureType` types for tracking task episodes. +- **Task Episode Capture** (BL-014): Methods for creating, updating, and querying task episodes. +- **Validation Outcome Ingestion** (BL-015): Parse type-check, build, and test validation results. +- **Failure Taxonomy** (BL-016): `classifyFailure()` function categorizes errors as syntax, runtime, logic, resource, or unknown. +- **Success Pattern Extraction** (BL-017): Extract command sequences and tools from successful task episodes. +- **Similar Task Recall** (BL-018): Find similar past tasks with configurable similarity threshold (0.85). +- **Retry/Recovery Evidence** (BL-019, BL-020): Track retry attempts and recovery strategies with budget suggestions. +- `addCommandToEpisode()`, `addValidationOutcome()`, `addSuccessPatterns()` store methods. +- `addRetryAttempt()`, `addRecoveryStrategy()`, `suggestRetryBudget()`, `suggestRecoveryStrategies()` store methods. +- `parseValidationOutput()` utility for parsing validation output. + +### Testing + +- New unit tests for episodic task CRUD operations. +- New unit tests for validation parsing and failure classification. + +--- + ## [0.2.6] - 2026-03-27 ### Fixed diff --git a/openspec/changes/archive/2026-03-28-add-episodic-task-schema/.openspec.yaml b/openspec/changes/archive/2026-03-28-add-episodic-task-schema/.openspec.yaml new file mode 100644 index 0000000..65bf7c9 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-episodic-task-schema/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-28 diff --git a/openspec/changes/archive/2026-03-28-add-episodic-task-schema/design.md b/openspec/changes/archive/2026-03-28-add-episodic-task-schema/design.md new file mode 100644 index 0000000..514437d --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-episodic-task-schema/design.md @@ -0,0 +1,38 @@ +## Context + +The existing memory system captures individual events (memory capture, recall, feedback) but lacks structured representation of task-level execution. The backlog identifies BL-003 as foundational for episodic learning—without task episode schema, we cannot capture, classify, or learn from task execution patterns. + +## Goals / Non-Goals + +**Goals:** +- Define EpisodicTaskRecord schema with essential fields +- Support task states: pending, running, success, failed, timeout +- Support failure classification taxonomy + +**Non-Goals:** +- Implementing actual episode capture logic (deferred to separate change) +- Multi-task orchestration +- Complex task dependency graphs + +## Decisions + +### Decision: Separate Table vs Extended MemoryRecord +Use separate `episodic_tasks` table rather than extending MemoryRecord. + +**Rationale:** Task episodes have different lifecycle and query patterns than memories. Separation enables independent scaling and querying. + +### Decision: Failure Taxonomy Categories +Define failure types: syntax, runtime, logic, resource, unknown. + +**Rationale:** Standardized taxonomy enables pattern learning across similar failures. Categories map to common development error types. + +### Decision: Lazy Schema Initialization +Initialize episodic_tasks table on first use, not at provider init. + +**Rationale:** Reduces startup overhead if episodic features aren't used. Backward compatible with existing deployments. + +## Risks / Trade-offs + +- [Risk] Schema evolution complexity → **Mitigation**: Version field in record, forward-compatible additions +- [Risk] Query performance with large episode volumes → **Mitigation**: Index on task state and timestamp +- [Risk] Integration with existing events → **Mitigation**: Reference existing sessionID for correlation diff --git a/openspec/changes/archive/2026-03-28-add-episodic-task-schema/proposal.md b/openspec/changes/archive/2026-03-28-add-episodic-task-schema/proposal.md new file mode 100644 index 0000000..a610779 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-episodic-task-schema/proposal.md @@ -0,0 +1,23 @@ +## Why + +Current memory system captures individual events but lacks structured representation of task execution episodes. To enable episodic learning and retry/recovery intelligence, we need a dedicated schema for capturing task-level execution records with validation outcomes, failure classifications, and success patterns. + +## What Changes + +- Add new `episodic_tasks` table for task episode records +- Define task states: pending, running, success, failed, timeout +- Add failure taxonomy classification system +- Integrate task capture with existing session events + +## Capabilities + +### New Capabilities +- `episodic-task-schema`: Core schema for task episode records with states, outcomes, and metadata + +### Modified Capabilities +- None (this is a foundational schema change) + +## Impact +- New database table: `episodic_tasks` +- Schema extensions to existing types +- No impact on existing memory operations diff --git a/openspec/changes/archive/2026-03-28-add-episodic-task-schema/specs/episodic-task-schema/spec.md b/openspec/changes/archive/2026-03-28-add-episodic-task-schema/specs/episodic-task-schema/spec.md new file mode 100644 index 0000000..976d1ac --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-episodic-task-schema/specs/episodic-task-schema/spec.md @@ -0,0 +1,33 @@ +## ADDED Requirements + +### Requirement: Episodic task record creation +The system SHALL support creating episodic task records with task ID, session ID, scope, start time, and initial state. + +#### Scenario: Task episode starts +- **WHEN** a task begins execution with task ID "task-123" in scope "project:myproject" +- **THEN** an episodic task record is created with state "running" + +### Requirement: Task state transitions +The system SHALL support updating task state: pending → running → success | failed | timeout. + +#### Scenario: Task succeeds +- **WHEN** task with ID "task-123" completes successfully +- **THEN** the task record state is updated to "success" + +#### Scenario: Task fails +- **WHEN** task with ID "task-123" fails +- **THEN** the task record state is updated to "failed" + +### Requirement: Failure classification +The system SHALL support classifying failures by taxonomy: syntax, runtime, logic, resource, unknown. + +#### Scenario: Failure classified as syntax +- **WHEN** a task fails with syntax error +- **THEN** the failureType field is set to "syntax" + +### Requirement: Task episode retrieval +The system SHALL support querying task episodes by scope, state, and time range. + +#### Scenario: Query failed tasks +- **WHEN** querying for failed tasks in scope "project:myproject" +- **THEN** returns all task records with state "failed" in that scope diff --git a/openspec/changes/archive/2026-03-28-add-episodic-task-schema/tasks.md b/openspec/changes/archive/2026-03-28-add-episodic-task-schema/tasks.md new file mode 100644 index 0000000..59c669a --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-episodic-task-schema/tasks.md @@ -0,0 +1,23 @@ +## 1. Type Definitions + +- [x] 1.1 Define EpisodicTaskRecord interface in types.ts +- [x] 1.2 Define TaskState type (pending, running, success, failed, timeout) +- [x] 1.3 Define FailureType taxonomy enum + +## 2. Database Schema + +- [x] 2.1 Create episodic_tasks table in store.ts +- [x] 2.2 Add lazy initialization on first use +- [ ] 2.3 Add index on task state and timestamp + +## 3. Store Methods + +- [x] 3.1 Implement createTaskEpisode method +- [x] 3.2 Implement updateTaskState method +- [x] 3.3 Implement getTaskEpisode method +- [x] 3.4 Implement queryTaskEpisodes method + +## 4. Testing + +- [x] 4.1 Add unit tests for task episode CRUD +- [x] 4.2 Add integration tests for lazy initialization diff --git a/openspec/changes/archive/2026-03-28-add-explicit-memory-commands/.openspec.yaml b/openspec/changes/archive/2026-03-28-add-explicit-memory-commands/.openspec.yaml new file mode 100644 index 0000000..65bf7c9 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-explicit-memory-commands/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-28 diff --git a/openspec/changes/archive/2026-03-28-add-explicit-memory-commands/design.md b/openspec/changes/archive/2026-03-28-add-explicit-memory-commands/design.md new file mode 100644 index 0000000..65e6f67 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-explicit-memory-commands/design.md @@ -0,0 +1,39 @@ +## Context + +The current memory system relies entirely on automatic capture and retrieval. Users have no explicit control over what memories are stored, how they're used, or the ability to correct misunderstandings. The backlog identifies BL-010, BL-011, BL-012 as the first set of user-facing commands that give users explicit memory management capabilities. + +## Goals / Non-Goals + +**Goals:** +- Implement `/remember` command for explicit memory capture with optional labels +- Implement `/forget` command for memory removal (soft-delete and hard-delete options) +- Implement `/what-did-you-learn` command for viewing recent memory summaries +- Integrate all commands with existing effectiveness tracking + +**Non-Goals:** +- Multi-user identity management (deferred to BL-034) +- Preference learning (separate change) +- Episodic task recording (separate change) + +## Decisions + +### Decision: Command Interface +Use tool-based interface matching existing `memory_search`, `memory_delete` patterns rather than slash commands. + +**Rationale:** Consistent with OpenCode tool calling convention, easier to test, better structured output. + +### Decision: Soft-Delete Default +`/forget` defaults to soft-delete (marks memory as disabled) rather than hard-delete. + +**Rationale:** Preserves audit trail, enables recovery, maintains effectiveness event integrity. Hard-delete available via explicit flag. + +### Decision: Summary Format +`/what-did-you-learn` returns categorized summaries rather than raw memory list. + +**Rationale:** More actionable for users, reduces context overhead, enables future preference inference from summaries. + +## Risks / Trade-offs + +- [Risk] User confusion between auto-capture and explicit remember → **Mitigation**: Document difference, consider distinct storage flag +- [Risk] Memory bloat from excessive explicit captures → **Mitigation**: Apply same minChar threshold as auto-capture +- [Risk] Effectiveness metrics double-counting → **Mitigation**: Use distinct event source type for explicit vs auto operations diff --git a/openspec/changes/archive/2026-03-28-add-explicit-memory-commands/proposal.md b/openspec/changes/archive/2026-03-28-add-explicit-memory-commands/proposal.md new file mode 100644 index 0000000..a1a53ce --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-explicit-memory-commands/proposal.md @@ -0,0 +1,29 @@ +## Why + +Users currently have no way to explicitly manage their memories—capture, retrieval, and deletion are entirely automatic. This limits user control and makes it difficult to teach the system about preferences or correct its understanding. Adding explicit memory commands gives users agency over their memory footprint and enables preference learning. + +## What Changes + +- Add `/remember` command for explicit memory capture +- Add `/forget` command for explicit memory removal/disabling +- Add `/what-did-you-learn` command for viewing recent learning summary +- All commands integrate with existing effectiveness tracking pipeline + +## Capabilities + +### New Capabilities + +- `memory-explicit-remember`: Explicit memory capture command with optional context/category labels +- `memory-explicit-forget`: Explicit memory removal command with soft-delete and hard-delete options +- `memory-learning-summary`: Recent learning summary view with configurable time window + +### Modified Capabilities + +- `memory-management-commands`: Extends with three new commands (remember, forget, what-did-you-learn) + +## Impact + +- New tool implementations in `src/tools/` +- New CLI command handlers +- Schema changes for soft-delete support (optional `status` field on MemoryRecord) +- Integration with existing effectiveness_events table diff --git a/openspec/changes/archive/2026-03-28-add-explicit-memory-commands/specs/memory-explicit-forget/spec.md b/openspec/changes/archive/2026-03-28-add-explicit-memory-commands/specs/memory-explicit-forget/spec.md new file mode 100644 index 0000000..9fd6ac7 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-explicit-memory-commands/specs/memory-explicit-forget/spec.md @@ -0,0 +1,39 @@ +# memory-explicit-forget Specification + +## Purpose +Enable users to explicitly remove or disable memories. + +## ADDED Requirements + +### Requirement: Soft-delete memory command +The system SHALL provide a forget command that marks memories as disabled without immediate physical deletion. + +#### Scenario: User soft-deletes a memory +- **WHEN** user invokes forget command with a valid memory ID (no force flag) +- **THEN** the memory status is set to disabled +- **AND** the memory is excluded from search results +- **AND** the command returns success with updated status + +#### Scenario: Soft-deleted memory is not retrieved +- **WHEN** a search is executed +- **THEN** memories with status disabled are not included in results +- **AND** effectiveness recall events do not count disabled memories + +### Requirement: Hard-delete memory command +The system SHALL provide an option to permanently delete memories. + +#### Scenario: User hard-deletes a memory +- **WHEN** user invokes forget command with a valid memory ID and force flag +- **THEN** the memory is physically removed from the database +- **AND** the command returns success confirmation + +#### Scenario: Hard-delete without confirmation fails +- **WHEN** user invokes forget command with force flag but without explicit confirm +- **THEN** the command is rejected with guidance for safe execution + +### Requirement: Forget emits effectiveness event +The system SHALL record forget operations in the effectiveness pipeline. + +#### Scenario: Forget operation emits event +- **WHEN** user successfully executes forget (soft or hard) +- **THEN** the system records an event for audit purposes diff --git a/openspec/changes/archive/2026-03-28-add-explicit-memory-commands/specs/memory-explicit-remember/spec.md b/openspec/changes/archive/2026-03-28-add-explicit-memory-commands/specs/memory-explicit-remember/spec.md new file mode 100644 index 0000000..11a22eb --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-explicit-memory-commands/specs/memory-explicit-remember/spec.md @@ -0,0 +1,32 @@ +# memory-explicit-remember Specification + +## Purpose +Enable users to explicitly capture memories with optional contextual labels. + +## ADDED Requirements + +### Requirement: Explicit memory capture command +The system SHALL provide an explicit memory capture command that accepts content text and optional context/category labels. + +#### Scenario: User captures explicit memory +- **WHEN** user invokes remember command with content "Always use TypeScript for new projects" +- **THEN** the memory is stored with content "Always use TypeScript for new projects" +- **AND** the memory is marked with source as explicit-remember + +#### Scenario: User captures memory with category label +- **WHEN** user invokes remember command with content and category "preference" +- **THEN** the memory is stored with the category label attached +- **AND** the category is queryable in search + +#### Scenario: Explicit memory triggers effectiveness tracking +- **WHEN** user successfully captures an explicit memory +- **THEN** the system records a capture event with source explicit-remember +- **AND** the event is included in effectiveness summaries + +### Requirement: Minimum content threshold +The system SHALL apply the same minimum character threshold to explicit memories as auto-capture. + +#### Scenario: Explicit memory below threshold +- **WHEN** user invokes remember command with content shorter than minCaptureChars +- **THEN** the command returns a warning that content is too short +- **AND** no memory is stored diff --git a/openspec/changes/archive/2026-03-28-add-explicit-memory-commands/specs/memory-learning-summary/spec.md b/openspec/changes/archive/2026-03-28-add-explicit-memory-commands/specs/memory-learning-summary/spec.md new file mode 100644 index 0000000..969de74 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-explicit-memory-commands/specs/memory-learning-summary/spec.md @@ -0,0 +1,30 @@ +# memory-learning-summary Specification + +## Purpose +Provide users with a view of what the system has learned recently. + +## ADDED Requirements + +### Requirement: Learning summary command +The system SHALL provide a command that returns a summary of recently captured memories. + +#### Scenario: User requests learning summary +- **WHEN** user invokes what-did-you-learn command +- **THEN** the system returns a summary of memories captured in the past 7 days +- **AND** the summary is organized by category when categories exist + +#### Scenario: Summary with custom time window +- **WHEN** user invokes what-did-you-learn with days=30 +- **THEN** the system returns memories from the past 30 days + +#### Scenario: Empty summary for new users +- **WHEN** user invokes what-did-you-learn with no prior memories +- **THEN** the system returns a message indicating no memories captured yet + +### Requirement: Summary includes memory counts +The system SHALL provide memory counts by category in the summary. + +#### Scenario: Summary shows category breakdown +- **WHEN** user invokes what-did-you-learn +- **THEN** the response includes count of memories per category +- **AND** total memory count is included diff --git a/openspec/changes/archive/2026-03-28-add-explicit-memory-commands/tasks.md b/openspec/changes/archive/2026-03-28-add-explicit-memory-commands/tasks.md new file mode 100644 index 0000000..0cd0ab8 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-explicit-memory-commands/tasks.md @@ -0,0 +1,34 @@ +## 1. Tool Interface Design + +- [x] 1.1 Define tool schema for memory_explicit_remember command +- [x] 1.2 Define tool schema for memory_explicit_forget command +- [x] 1.3 Define tool schema for memory_learning_summary command + +## 2. Memory Explicit Remember Implementation + +- [x] 2.1 Implement memory_explicit_remember handler +- [x] 2.2 Add content validation (minChars threshold) +- [x] 2.3 Add category label support +- [x] 2.4 Integrate with effectiveness event emission + +## 3. Memory Explicit Forget Implementation + +- [x] 3.1 Implement memory_explicit_forget handler +- [x] 3.2 Add soft-delete logic (status=disabled) +- [x] 3.3 Add hard-delete logic with confirm flag +- [x] 3.4 Update search to exclude disabled memories +- [x] 3.5 Add forget event to effectiveness pipeline + +## 4. Learning Summary Implementation + +- [x] 4.1 Implement memory_learning_summary handler +- [x] 4.2 Add time window parameter (default 7 days) +- [x] 4.3 Add category grouping logic +- [x] 4.4 Add memory count by category + +## 5. Integration and Testing + +- [x] 5.1 Register new tools with provider +- [x] 5.2 Add unit tests for each command +- [x] 5.3 Add integration tests for effectiveness pipeline +- [x] 5.4 Update documentation with new commands diff --git a/openspec/changes/archive/2026-03-28-add-preference-learning/.openspec.yaml b/openspec/changes/archive/2026-03-28-add-preference-learning/.openspec.yaml new file mode 100644 index 0000000..65bf7c9 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-preference-learning/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-28 diff --git a/openspec/changes/archive/2026-03-28-add-preference-learning/design.md b/openspec/changes/archive/2026-03-28-add-preference-learning/design.md new file mode 100644 index 0000000..94fde54 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-preference-learning/design.md @@ -0,0 +1,39 @@ +## Context + +Users express preferences implicitly through repeated patterns (always using TypeScript, prefers certain testing frameworks) but the system doesn't learn from these signals. The backlog identifies BL-005, BL-006, BL-008 as core preference learning capabilities. This change depends on metadata extensions from `extend-memory-metadata`. + +## Goals / Non-Goals + +**Goals:** +- Implement preference profile aggregation from memory content +- Implement conflict resolution rules (recency + directness priority) +- Implement scope precedence (project > global default) +- Implement preference-aware prompt injection + +**Non-Goals:** +- Complex preference inference (beyond keyword/pattern matching) +- Preference learning from episodic tasks (deferred to BL-017) +- A/B testing framework (deferred to BL-033) + +## Decisions + +### Decision: Preference Signal Sources +Start with explicit preference markers in memory content (keywords, patterns) rather than ML-based inference. + +**Rationale:** Simpler to implement, more predictable, easier to debug. Can add ML layer later. + +### Decision: Conflict Resolution Priority +Recent signals (higher timestamp) override older ones. Direct user signals override inferred signals. + +**Rationale:** Matches user intuition—latest preference should win. Direct signals are more trustworthy than inferences. + +### Decision: Injection Strategy +Layered injection: preferences → decisions → success patterns, each with distinct context sections. + +**Rationale:** Separation enables downstream to weight differently. Preferences are general, decisions are specific, success patterns are evidence. + +## Risks / Trade-offs + +- [Risk] Preference bloat → **Mitigation**: Limit stored preferences per scope, apply decay +- [Risk] Conflicting signals from different contexts → **Mitigation**: Scope precedence rules, conflict logging +- [Risk] Injection token bloat → **Mitigation**: Budget mode for injection, summarization fallback diff --git a/openspec/changes/archive/2026-03-28-add-preference-learning/proposal.md b/openspec/changes/archive/2026-03-28-add-preference-learning/proposal.md new file mode 100644 index 0000000..dc3874d --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-preference-learning/proposal.md @@ -0,0 +1,29 @@ +## Why + +Current memory system treats all captured content equally. Users repeatedly express preferences (code style, tool choices, workflow patterns) but the system doesn't learn from these signals. Adding preference learning enables the system to adapt to user habits, reducing repeated context and clarification turns. + +## What Changes + +- Add preference profile aggregator that collects preference signals from memory content +- Add preference conflict resolution rules (recent signal wins over old, direct signal wins over inferred) +- Add scope precedence resolver (project > global by default) +- Add preference-aware prompt injection that layers preferences, decisions, and success patterns into context + +## Capabilities + +### New Capabilities + +- `preference-profile-aggregator`: Aggregates preference signals from memory content into structured profiles +- `preference-conflict-resolution`: Rules for resolving conflicting preference signals (recency + directness priority) +- `preference-scope-precedence`: Scope-level preference precedence rules (project > global default) +- `preference-prompt-injection`: Context injection that layers preferences, decisions, and success patterns + +### Modified Capabilities + +- `memory-auto-capture-and-recall`: May need to tag preference-related content during capture + +## Impact + +- New preference inference logic in memory processing pipeline +- New injection mode for preference-aware context +- Potential storage for preference profiles (new table or extended metadata) diff --git a/openspec/changes/archive/2026-03-28-add-preference-learning/specs/preference-conflict-resolution/spec.md b/openspec/changes/archive/2026-03-28-add-preference-learning/specs/preference-conflict-resolution/spec.md new file mode 100644 index 0000000..0cd2a3d --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-preference-learning/specs/preference-conflict-resolution/spec.md @@ -0,0 +1,38 @@ +# preference-conflict-resolution Specification + +## Purpose +Define rules for resolving conflicting preference signals. + +## ADDED Requirements + +### Requirement: Recency priority +The system SHALL prioritize recent preference signals over older ones. + +#### Scenario: Recent preference wins +- **WHEN** a preference "use Rust" was expressed today +- **AND** the same preference "use Go" was expressed last month +- **THEN** the system resolves to "use Rust" + +### Requirement: Direct signal priority +The system SHALL prioritize direct user signals over inferred signals. + +#### Scenario: Direct signal wins +- **WHEN** user explicitly says "I prefer TypeScript" +- **AND** system inferred "prefers JavaScript" from code patterns +- **THEN** explicit preference takes precedence + +### Requirement: Conflict logging +The system SHALL log preference conflicts for audit. + +#### Scenario: Conflict detected +- **WHEN** conflicting preferences are resolved +- **THEN** the resolution is logged with both signals +- **AND** the winning signal is recorded + +### Requirement: Confidence adjustment +The system SHALL adjust confidence based on conflict resolution. + +#### Scenario: Resolved conflict affects confidence +- **WHEN** a preference wins a conflict +- **AND** the winning preference has lower raw confidence +- **THEN** the resolved confidence reflects the resolution diff --git a/openspec/changes/archive/2026-03-28-add-preference-learning/specs/preference-profile-aggregator/spec.md b/openspec/changes/archive/2026-03-28-add-preference-learning/specs/preference-profile-aggregator/spec.md new file mode 100644 index 0000000..c90e272 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-preference-learning/specs/preference-profile-aggregator/spec.md @@ -0,0 +1,42 @@ +# preference-profile-aggregator Specification + +## Purpose +Aggregate preference signals from memory content into structured preference profiles. + +## ADDED Requirements + +### Requirement: Preference signal detection +The system SHALL detect preference signals in memory content through pattern matching. + +#### Scenario: Preference detected from content +- **WHEN** memory content contains preference markers (e.g., "I prefer", "always use", "never") +- **THEN** the system extracts the preference as a signal +- **AND** the signal is stored in the preference profile + +### Requirement: Preference profile structure +The system SHALL maintain preference profiles organized by scope and category. + +#### Scenario: Profile organized by scope +- **WHEN** preferences are aggregated +- **AND** user queries for project-scoped preferences +- **THEN** only project-scoped preferences are returned + +#### Scenario: Profile organized by category +- **WHEN** preferences have category labels (e.g., "language", "tool", "style") +- **THEN** preferences are grouped by category +- **AND** category-specific queries return relevant preferences + +### Requirement: Preference confidence +The system SHALL calculate confidence for aggregated preferences based on signal frequency. + +#### Scenario: Preference confidence calculation +- **WHEN** the same preference is expressed multiple times +- **THEN** confidence increases with signal count +- **AND** confidence decreases over time (decay) + +### Requirement: Preference profile query +The system SHALL provide a way to query the current preference profile. + +#### Scenario: Query preferences +- **WHEN** system requests preference profile for a scope +- **THEN** aggregated preferences are returned with confidence scores diff --git a/openspec/changes/archive/2026-03-28-add-preference-learning/specs/preference-prompt-injection/spec.md b/openspec/changes/archive/2026-03-28-add-preference-learning/specs/preference-prompt-injection/spec.md new file mode 100644 index 0000000..8651bde --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-preference-learning/specs/preference-prompt-injection/spec.md @@ -0,0 +1,44 @@ +# preference-prompt-injection Specification + +## Purpose +Inject learned preferences into context for downstream use. + +## ADDED Requirements + +### Requirement: Layered injection +The system SHALL inject preferences in distinct layers: preferences, decisions, success patterns. + +#### Scenario: Layered injection output +- **WHEN** context injection is requested +- **THEN** the output includes separate sections for: + - General preferences (e.g., "User prefers TypeScript") + - Specific decisions (e.g., "User chose Jest for testing") + - Success patterns (e.g., "This approach worked well before") + +### Requirement: Preference injection budget +The system SHALL limit injected preferences to a budget. + +#### Scenario: Budget enforcement +- **WHEN** preference injection would exceed budget +- **THEN** lower-confidence preferences are omitted +- **AND** at least high-confidence preferences are included + +### Requirement: Injection mode configuration +The system SHALL support configurable injection modes. + +#### Scenario: Configurable injection +- **WHEN** injection mode is set to "budget" +- **THEN** preferences are injected until budget is consumed +- **AND** when set to "fixed", a fixed number are injected + +### Requirement: Preference injection triggers +The system SHALL inject preferences at appropriate triggers. + +#### Scenario: Injection on session start +- **WHEN** a new session begins +- **AND** preferences exist for the scope +- **THEN** preferences are injected into system context + +#### Scenario: Injection on relevant task +- **WHEN** task context matches a preference category +- **THEN** relevant preferences are injected diff --git a/openspec/changes/archive/2026-03-28-add-preference-learning/specs/preference-scope-precedence/spec.md b/openspec/changes/archive/2026-03-28-add-preference-learning/specs/preference-scope-precedence/spec.md new file mode 100644 index 0000000..d5d4b94 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-preference-learning/specs/preference-scope-precedence/spec.md @@ -0,0 +1,39 @@ +# preference-scope-precedence Specification + +## Purpose +Define scope-level precedence rules for preferences. + +## ADDED Requirements + +### Requirement: Default scope precedence +The system SHALL default to project scope preferences overriding global scope. + +#### Scenario: Project overrides global +- **WHEN** project-scoped preference exists +- **AND** global-scoped preference exists for the same key +- **THEN** project preference takes precedence + +### Requirement: Scope precedence query +The system SHALL provide merged preferences respecting scope precedence. + +#### Scenario: Query merged preferences +- **WHEN** preferences are requested for scope project:myproject +- **AND** project preferences and global preferences both exist +- **THEN** result reflects project > global precedence + +### Requirement: Scope level preference +The system SHALL support querying preferences at specific scope levels. + +#### Scenario: Query specific scope only +- **WHEN** preferences are requested with scope=global only +- **AND** project preferences exist +- **THEN** only global preferences are returned + +### Requirement: Precedence for same-category preferences +The system SHALL handle same-category preferences across scopes correctly. + +#### Scenario: Language preference across scopes +- **WHEN** global says "prefer Python" +- **AND** project says "prefer TypeScript" +- **AND** query asks for effective preferences +- **THEN** TypeScript is returned as the effective preference diff --git a/openspec/changes/archive/2026-03-28-add-preference-learning/tasks.md b/openspec/changes/archive/2026-03-28-add-preference-learning/tasks.md new file mode 100644 index 0000000..5eaa556 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-preference-learning/tasks.md @@ -0,0 +1,41 @@ +## 1. Preference Profile Aggregator + +- [x] 1.1 Define preference signal extraction patterns +- [x] 1.2 Implement preference extraction from memory content +- [x] 1.3 Design preference profile data structure +- [x] 1.4 Implement preference aggregation by scope +- [x] 1.5 Implement confidence calculation with decay + +## 2. Preference Conflict Resolution + +- [x] 2.1 Implement recency-based priority logic +- [x] 2.2 Implement direct signal vs inferred signal priority +- [x] 2.3 Add conflict logging +- [x] 2.4 Implement confidence adjustment after resolution + +## 3. Scope Precedence + +- [x] 3.1 Implement project > global precedence logic +- [x] 3.2 Add scope-specific query support +- [x] 3.3 Implement effective preference merge + +## 4. Preference Prompt Injection + +- [x] 4.1 Define injection layer structure (preferences/decisions/success-patterns) +- [x] 4.2 Implement budget-based injection limiting +- [x] 4.3 Add configurable injection modes +- [x] 4.4 Implement session start injection trigger +- [x] 4.5 Implement task-context-aware injection + +## 5. Integration + +- [x] 5.1 Wire preference learning into capture pipeline +- [x] 5.2 Wire preference injection into context building +- [x] 5.3 Add configuration options + +## 6. Testing + +- [x] 6.1 Add unit tests for preference extraction +- [x] 6.2 Add unit tests for conflict resolution +- [x] 6.3 Add integration tests for injection +- [x] 6.4 Test scope precedence behavior diff --git a/openspec/changes/archive/2026-03-28-add-retry-recovery-evidence/.openspec.yaml b/openspec/changes/archive/2026-03-28-add-retry-recovery-evidence/.openspec.yaml new file mode 100644 index 0000000..65bf7c9 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-retry-recovery-evidence/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-28 diff --git a/openspec/changes/archive/2026-03-28-add-retry-recovery-evidence/design.md b/openspec/changes/archive/2026-03-28-add-retry-recovery-evidence/design.md new file mode 100644 index 0000000..2c22b54 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-retry-recovery-evidence/design.md @@ -0,0 +1,39 @@ +## Context + +When tasks fail, the system should be able to suggest retry strategies based on historical evidence—not by reimplementing execution engine, but by providing intelligence hints. The backlog identifies BL-019 and BL-020 as the core retry/recovery evidence capabilities. This integrates with OpenCode/OMO events. + +## Goals / Non-Goals + +**Goals:** +- Track retry attempts and outcomes as evidence +- Suggest retry budgets based on task type history +- Recommend backoff/cooldown strategies +- Suggest fallback strategies after repeated failures + +**Non-Goals:** +- Implementing retry execution (OMO responsibility) +- Complex retry policies +- Automatic recovery actions + +## Decisions + +### Decision: Evidence-Based Suggestions Only +Provide hints/suggestions, not direct execution control. + +**Rationale:** Respects separation of concerns. OMO owns execution. Evidence layer provides intelligence. + +### Decision: Reuse Episode Table +Store retry evidence in episodic_tasks table with additional fields. + +**Rationale:** Avoids schema proliferation. Retry is a special case of task episode. + +### Decision: Simple Budget Calculation +Calculate suggested budget = median(previous_attempts) + 1. + +**Rationale:** Simple heuristic. More sophisticated models can be added later. + +## Risks / Trade-offs + +- [Risk] Suggestion quality → **Mitigation**: Confidence scoring, manual override option +- [Risk] Stale evidence → **Mitigation**: Age-based decay, minimum sample threshold +- [Risk] Integration complexity → **Mitigation**: Event-based integration with existing hooks diff --git a/openspec/changes/archive/2026-03-28-add-retry-recovery-evidence/proposal.md b/openspec/changes/archive/2026-03-28-add-retry-recovery-evidence/proposal.md new file mode 100644 index 0000000..090f061 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-retry-recovery-evidence/proposal.md @@ -0,0 +1,25 @@ +## Why + +When tasks fail, the system currently has no memory of retry strategies that worked before. Adding retry/recovery evidence tracking enables the system to suggest appropriate retry budgets, backoff strategies, and fallback approaches based on historical evidence—not by reimplementing execution engine, but by providing intelligence hints. + +## What Changes + +- Add retry/recovery evidence model that captures retry attempts, outcomes, and recovery strategies +- Add retry budget and stop condition suggestions based on historical evidence +- Add backoff/cooldown signal integration from OpenCode/OMO events +- Add strategy switching suggestions (fallback approaches) based on past successes + +## Capabilities + +### New Capabilities +- `retry-recovery-evidence`: Evidence model for tracking retry attempts and outcomes +- `retry-budget-suggestion`: Suggest appropriate retry budgets based on task type history +- `strategy-switching-suggester`: Recommend fallback strategies after repeated failures + +### Modified Capabilities +- None + +## Impact +- Evidence storage (can reuse existing memory or new table) +- Integration points with OpenCode/OMO event system +- No direct execution control—suggestions only diff --git a/openspec/changes/archive/2026-03-28-add-retry-recovery-evidence/specs/retry-budget-suggestion/spec.md b/openspec/changes/archive/2026-03-28-add-retry-recovery-evidence/specs/retry-budget-suggestion/spec.md new file mode 100644 index 0000000..71d91a9 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-retry-recovery-evidence/specs/retry-budget-suggestion/spec.md @@ -0,0 +1,22 @@ +## ADDED Requirements + +### Requirement: Budget suggestion based on history +The system SHALL suggest retry budget based on median previous attempts. + +#### Scenario: Suggest budget +- **WHEN** task of type "npm install" has history of 2-3 retries +- **THEN** suggested budget is 3 retries + +### Requirement: Stop condition suggestion +The system SHALL suggest when to stop retrying based on failure patterns. + +#### Scenario: Suggest stop +- **WHEN** all 3+ retries failed with same error +- **THEN** suggestion is to stop and escalate + +### Requirement: Minimum sample threshold +The system SHALL require minimum sample size before suggesting budget. + +#### Scenario: Insufficient data +- **WHEN** task has fewer than 3 historical examples +- **THEN** no budget suggestion is provided diff --git a/openspec/changes/archive/2026-03-28-add-retry-recovery-evidence/specs/retry-recovery-evidence/spec.md b/openspec/changes/archive/2026-03-28-add-retry-recovery-evidence/specs/retry-recovery-evidence/spec.md new file mode 100644 index 0000000..cff11ed --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-retry-recovery-evidence/specs/retry-recovery-evidence/spec.md @@ -0,0 +1,22 @@ +## ADDED Requirements + +### Requirement: Retry attempt tracking +The system SHALL record retry attempts with attempt number and outcome. + +#### Scenario: Retry recorded +- **WHEN** task fails and is retried +- **THEN** retry attempt is recorded with attempt number and outcome + +### Requirement: Recovery strategy tracking +The system SHALL record which recovery strategies were attempted. + +#### Scenario: Strategy recorded +- **WHEN** task uses "restart service" as recovery +- **THEN** recovery strategy is recorded in evidence + +### Requirement: Evidence query by task type +The system SHALL allow querying evidence by task type or error type. + +#### Scenario: Query by error type +- **WHEN** querying evidence for "TypeError" failures +- **THEN** returns all retry/recovery records for that error type diff --git a/openspec/changes/archive/2026-03-28-add-retry-recovery-evidence/specs/strategy-switching-suggester/spec.md b/openspec/changes/archive/2026-03-28-add-retry-recovery-evidence/specs/strategy-switching-suggester/spec.md new file mode 100644 index 0000000..cbcba07 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-retry-recovery-evidence/specs/strategy-switching-suggester/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Fallback strategy suggestion +The system SHALL suggest fallback approaches after repeated failures. + +#### Scenario: Suggest fallback +- **WHEN** task "npm build" failed 3 times +- **AND** similar task succeeded with "npm run build:prod" +- **THEN** suggests alternative command + +### Requirement: Backoff strategy suggestion +The system SHALL suggest exponential backoff after failed retries. + +#### Scenario: Suggest backoff +- **WHEN** 2 rapid retries failed +- **THEN** suggests waiting 5s before next retry + +### Requirement: Strategy confidence +The system SHALL provide confidence score for suggested strategies. + +#### Scenario: High confidence suggestion +- **WHEN** strategy succeeded in 5+ similar cases +- **THEN** confidence is 0.8+ diff --git a/openspec/changes/archive/2026-03-28-add-retry-recovery-evidence/tasks.md b/openspec/changes/archive/2026-03-28-add-retry-recovery-evidence/tasks.md new file mode 100644 index 0000000..1ae0c33 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-retry-recovery-evidence/tasks.md @@ -0,0 +1,28 @@ +## 1. Retry Evidence Model + +- [x] 1.1 Define retry attempt record structure +- [x] 1.2 Add retry tracking to task episodes +- [x] 1.3 Implement recovery strategy recording + +## 2. Budget Suggestion + +- [x] 2.1 Implement median-based budget calculation +- [x] 2.2 Add minimum sample threshold (3) +- [x] 2.3 Implement stop condition detection + +## 3. Backoff Integration + +- [x] 3.1 Add backoff signal parsing from OMO events +- [x] 3.2 Implement backoff suggestion logic + +## 4. Strategy Switching + +- [x] 4.1 Implement fallback strategy suggestion +- [x] 4.2 Add confidence scoring for strategies +- [x] 4.3 Integrate with similar task recall + +## 5. Testing + +- [x] 5.1 Add unit tests for budget calculation +- [x] 5.2 Add unit tests for strategy suggestion +- [x] 5.3 Add integration tests diff --git a/openspec/changes/archive/2026-03-28-add-task-episode-learning/.openspec.yaml b/openspec/changes/archive/2026-03-28-add-task-episode-learning/.openspec.yaml new file mode 100644 index 0000000..65bf7c9 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-task-episode-learning/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-28 diff --git a/openspec/changes/archive/2026-03-28-add-task-episode-learning/design.md b/openspec/changes/archive/2026-03-28-add-task-episode-learning/design.md new file mode 100644 index 0000000..0f963ed --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-task-episode-learning/design.md @@ -0,0 +1,40 @@ +## Context + +Building on the episodic task schema, this change enables capturing task episodes and learning from them. The backlog identifies BL-014 through BL-018 as the core episodic learning capabilities. These enable the system to remember how similar tasks were solved. + +## Goals / Non-Goals + +**Goals:** +- Implement task episode capture on session events +- Parse validation outcomes (type/build/test) +- Classify failures using taxonomy +- Extract success patterns +- Implement similar task recall + +**Non-Goals:** +- Automatic retry execution (just evidence/hints) +- Complex workflow orchestration +- ML-based pattern extraction (rule-based only for v1) + +## Decisions + +### Decision: Event-Based Capture +Trigger episode capture on OpenCode session events (session start, tool execution, session end). + +**Rationale:** Matches existing event pipeline. No new infrastructure needed. + +### Decision: Rule-Based Pattern Extraction +Extract patterns using keyword/structure matching rather than ML. + +**Rationale:** Simpler to implement, more predictable, easier to debug. ML layer can be added later. + +### Decision: Similarity Threshold for Recall +Only recall tasks with cosine similarity >= 0.85. + +**Rationale:** Higher threshold reduces noise. Can be made configurable later. + +## Risks / Trade-offs + +- [Risk] Episode storage bloat → **Mitigation**: TTL or manual cleanup for old episodes +- [Risk] Pattern extraction false positives → **Mitigation**: Confidence threshold, manual review flag +- [Risk] Similar task recall overhead → **Mitigation**: Async background recall, cache results diff --git a/openspec/changes/archive/2026-03-28-add-task-episode-learning/proposal.md b/openspec/changes/archive/2026-03-28-add-task-episode-learning/proposal.md new file mode 100644 index 0000000..072e55d --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-task-episode-learning/proposal.md @@ -0,0 +1,28 @@ +## Why + +Users repeat similar tasks across sessions. Without episodic learning, the system cannot recall how similar tasks were solved before. Adding task episode capture and pattern extraction enables the system to learn from past successes and failures, reducing redundant attempts. + +## What Changes + +- Add task episode capture mechanism that tracks task execution start/end, commands, and outcomes +- Add validation outcome ingestion (type/build/test results) +- Add failure taxonomy classification (syntax, runtime, logic, resource, unknown) +- Add success pattern extraction from completed episodes +- Add similar task recall for task initialization + +## Capabilities + +### New Capabilities +- `task-episode-capture`: Track task execution with start/end, commands, outcomes +- `validation-outcome-ingestion`: Parse and store type/build/test validation results +- `failure-taxonomy`: Standardized failure classification system +- `success-pattern-extraction`: Extract patterns from successful task completions +- `similar-task-recall`: Find and present similar past tasks before execution + +### Modified Capabilities +- None + +## Impact +- New event hooks for task lifecycle tracking +- Pattern storage in memory or separate table +- Similar task matching via vector search diff --git a/openspec/changes/archive/2026-03-28-add-task-episode-learning/specs/failure-taxonomy/spec.md b/openspec/changes/archive/2026-03-28-add-task-episode-learning/specs/failure-taxonomy/spec.md new file mode 100644 index 0000000..8ab0003 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-task-episode-learning/specs/failure-taxonomy/spec.md @@ -0,0 +1,36 @@ +## ADDED Requirements + +### Requirement: Syntax failure classification +The system SHALL classify failures with syntax errors as "syntax". + +#### Scenario: Syntax error detected +- **WHEN** error message contains "SyntaxError" or "unexpected token" +- **THEN** failure is classified as "syntax" + +### Requirement: Runtime failure classification +The system SHALL classify runtime errors (exceptions, crashes) as "runtime". + +#### Scenario: Runtime error detected +- **WHEN** error is a JavaScript Error or Python Exception +- **THEN** failure is classified as "runtime" + +### Requirement: Logic failure classification +The system SHALL classify logical errors (wrong output, incorrect behavior) as "logic". + +#### Scenario: Logic error detected +- **WHEN** test fails with assertion error showing wrong expected value +- **THEN** failure is classified as "logic" + +### Requirement: Resource failure classification +The system SHALL classify resource exhaustion (memory, timeout, network) as "resource". + +#### Scenario: Resource error detected +- **WHEN** error is "OutOfMemory", "ETIMEDOUT", or "ECONNREFUSED" +- **THEN** failure is classified as "resource" + +### Requirement: Unknown failure classification +The system SHALL classify unclassifiable errors as "unknown". + +#### Scenario: Unknown error +- **WHEN** error does not match any known pattern +- **THEN** failure is classified as "unknown" diff --git a/openspec/changes/archive/2026-03-28-add-task-episode-learning/specs/similar-task-recall/spec.md b/openspec/changes/archive/2026-03-28-add-task-episode-learning/specs/similar-task-recall/spec.md new file mode 100644 index 0000000..114dbcc --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-task-episode-learning/specs/similar-task-recall/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Similar task search +The system SHALL find similar past tasks using vector similarity. + +#### Scenario: Similar task found +- **WHEN** new task "fix auth bug" starts +- **AND** past task "fix login bug" has similarity >= 0.85 +- **THEN** past task is recalled and presented + +### Requirement: Recall with context +The system SHALL provide full episode context when recalling similar tasks. + +#### Scenario: Context provided +- **WHEN** similar task is recalled +- **THEN** response includes command sequence, validation outcomes, and final state + +### Requirement: Recall threshold configuration +The system SHALL allow configuring minimum similarity threshold for recall. + +#### Scenario: Custom threshold +- **WHEN** similarity threshold is set to 0.9 +- **THEN** only tasks with >= 0.9 similarity are recalled diff --git a/openspec/changes/archive/2026-03-28-add-task-episode-learning/specs/success-pattern-extraction/spec.md b/openspec/changes/archive/2026-03-28-add-task-episode-learning/specs/success-pattern-extraction/spec.md new file mode 100644 index 0000000..6953e59 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-task-episode-learning/specs/success-pattern-extraction/spec.md @@ -0,0 +1,22 @@ +## ADDED Requirements + +### Requirement: Extract commands from successful episodes +The system SHALL extract command sequences from successful task episodes. + +#### Scenario: Commands extracted +- **WHEN** task episode completes with state "success" +- **THEN** command sequence is stored as a success pattern + +### Requirement: Extract working approaches +The system SHALL extract working approaches (libraries, configurations) from successful episodes. + +#### Scenario: Approach extracted +- **WHEN** successful episode used "jest" for testing and "prettier" for formatting +- **THEN** these tools are recorded in success pattern + +### Requirement: Pattern confidence scoring +The system SHALL calculate confidence based on frequency of pattern occurrence. + +#### Scenario: High confidence pattern +- **WHEN** a pattern appears in 5+ successful episodes +- **THEN** confidence is scored at 0.8+ diff --git a/openspec/changes/archive/2026-03-28-add-task-episode-learning/specs/task-episode-capture/spec.md b/openspec/changes/archive/2026-03-28-add-task-episode-learning/specs/task-episode-capture/spec.md new file mode 100644 index 0000000..6fcee1f --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-task-episode-learning/specs/task-episode-capture/spec.md @@ -0,0 +1,22 @@ +## ADDED Requirements + +### Requirement: Task episode capture on session start +The system SHALL create a task episode record when a new task session begins. + +#### Scenario: Session task starts +- **WHEN** a new task session begins +- **THEN** an episode record is created with state "pending" and start timestamp + +### Requirement: Task episode capture on command execution +The system SHALL record command executions within a task episode. + +#### Scenario: Command recorded +- **WHEN** a command "npm run build" is executed within task "task-123" +- **THEN** the command is added to the episode's command list + +### Requirement: Task episode completion +The system SHALL finalize task episode on completion with outcome. + +#### Scenario: Task completes +- **WHEN** task "task-123" completes with outcome "success" +- **THEN** episode record is updated with end timestamp and final state diff --git a/openspec/changes/archive/2026-03-28-add-task-episode-learning/specs/validation-outcome-ingestion/spec.md b/openspec/changes/archive/2026-03-28-add-task-episode-learning/specs/validation-outcome-ingestion/spec.md new file mode 100644 index 0000000..1d184fe --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-task-episode-learning/specs/validation-outcome-ingestion/spec.md @@ -0,0 +1,26 @@ +## ADDED Requirements + +### Requirement: Type check result ingestion +The system SHALL parse and store type check results from validation output. + +#### Scenario: Type check passes +- **WHEN** type check runs and passes with no errors +- **THEN** validation outcome is recorded as "type-check-pass" + +#### Scenario: Type check fails +- **WHEN** type check reports 3 errors +- **THEN** validation outcome is recorded with error count and types + +### Requirement: Build result ingestion +The system SHALL parse and store build results. + +#### Scenario: Build succeeds +- **WHEN** build command succeeds +- **THEN** validation outcome is recorded as "build-pass" + +### Requirement: Test result ingestion +The system SHALL parse and store test execution results. + +#### Scenario: Tests pass +- **WHEN** test suite runs with 10 passed, 0 failed +- **THEN** validation outcome is recorded with pass/fail counts diff --git a/openspec/changes/archive/2026-03-28-add-task-episode-learning/tasks.md b/openspec/changes/archive/2026-03-28-add-task-episode-learning/tasks.md new file mode 100644 index 0000000..8d6d057 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-task-episode-learning/tasks.md @@ -0,0 +1,38 @@ +## 1. Task Episode Capture + +- [x] 1.1 Implement task episode capture on session start +- [x] 1.2 Add command recording during task execution +- [x] 1.3 Implement task completion with outcome + +## 2. Validation Outcome Ingestion + +- [x] 2.1 Add type check result parser +- [x] 2.2 Add build result parser +- [x] 2.3 Add test result parser +- [x] 2.4 Integrate with task episode records + +## 3. Failure Taxonomy + +- [x] 3.1 Implement syntax error classifier +- [x] 3.2 Implement runtime error classifier +- [x] 3.3 Implement logic error classifier +- [x] 3.4 Implement resource error classifier +- [x] 3.5 Implement unknown error classifier + +## 4. Success Pattern Extraction + +- [x] 4.1 Extract command sequences from successful episodes +- [x] 4.2 Extract working approaches (tools, configs) +- [x] 4.3 Implement confidence scoring + +## 5. Similar Task Recall + +- [x] 5.1 Implement vector-based task similarity search (keyword-based placeholder) +- [x] 5.2 Add similarity threshold filtering (0.85) +- [x] 5.3 Implement context retrieval for similar tasks + +## 6. Testing + +- [x] 6.1 Add unit tests for validation parsing +- [x] 6.2 Add unit tests for failure classification +- [ ] 6.3 Add integration tests for similar task recall diff --git a/openspec/changes/archive/2026-03-28-extend-memory-metadata/.openspec.yaml b/openspec/changes/archive/2026-03-28-extend-memory-metadata/.openspec.yaml new file mode 100644 index 0000000..65bf7c9 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-extend-memory-metadata/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-28 diff --git a/openspec/changes/archive/2026-03-28-extend-memory-metadata/design.md b/openspec/changes/archive/2026-03-28-extend-memory-metadata/design.md new file mode 100644 index 0000000..ac94138 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-extend-memory-metadata/design.md @@ -0,0 +1,37 @@ +## Context + +The existing MemoryRecord schema contains: id, content, scope, timestamp, importance, embedding, vector. The FeedbackEvent schema tracks capture/recall/feedback events. Future features (preference learning, episodic tasks, citation tracking) require additional metadata fields. The backlog identifies BL-001, BL-002 as foundational infrastructure changes. + +## Goals / Non-Goals + +**Goals:** +- Extend MemoryRecord with userId, teamId, sourceSessionId, confidence, tags[], status, parentId +- Extend FeedbackEvent with sourceSessionId, confidenceDelta, relatedMemoryId, context +- Implement schema migration mechanism for backward compatibility + +**Non-Goals:** +- Multi-user authentication/authorization (deferred) +- Complex relationship traversal (parent-child beyond single level) + +## Decisions + +### Decision: Field Addition Strategy +Add new columns rather than modifying existing ones. Existing queries continue to work. + +**Rationale:** LanceDB supports schema evolution. Adding columns is backward compatible. + +### Decision: Migration Timing +Run migrations on provider initialization, not on each operation. + +**Rationale:** Single-point check is simpler, avoids per-operation overhead, easier to reason about. + +### Decision: Optional Fields +All new fields are optional (nullable). Default null for new records. + +**Rationale:** Backward compatibility—existing memories continue to work without requiring migration of old records. + +## Risks / Trade-offs + +- [Risk] Schema version confusion → **Mitigation**: Track schemaVersion explicitly, document upgrade path +- [Risk] Query performance with new nullable filters → **Mitigation**: Add indexes only if needed, measure first +- [Risk] Feedback events without relatedMemoryId → **Mitigation**: Allow null, treat as unlinked feedback diff --git a/openspec/changes/archive/2026-03-28-extend-memory-metadata/proposal.md b/openspec/changes/archive/2026-03-28-extend-memory-metadata/proposal.md new file mode 100644 index 0000000..9b03c89 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-extend-memory-metadata/proposal.md @@ -0,0 +1,28 @@ +## Why + +Current MemoryRecord and FeedbackEvent schemas lack fields needed for preference learning, episodic task records, and advanced memory governance. Extending metadata now enables future features (preference aggregation, conflict resolution, citation tracking) without schema migrations later. + +## What Changes + +- Extend MemoryRecord schema with: userId, teamId, sourceSessionId, confidence, tags[], status, parentId +- Extend FeedbackEvent schema with: sourceSessionId, confidenceDelta, relatedMemoryId, context +- Add schema migration mechanism for backward compatibility + +## Capabilities + +### New Capabilities + +- `memory-record-metadata`: Extended MemoryRecord schema with user/team identification, source tracking, confidence scoring, tagging, soft-delete status, and parent-child relationships +- `feedback-event-metadata`: Extended FeedbackEvent schema with source session tracking, confidence delta, related memory reference, and contextual data +- `memory-schema-migration`: Schema versioning and migration mechanism for backward compatibility + +### Modified Capabilities + +- `memory-auto-capture-and-recall`: May need to emit new metadata fields during capture +- `memory-effectiveness-evaluation`: May reference new FeedbackEvent fields + +## Impact + +- Schema changes to LanceDB tables (add columns, not modify existing) +- Migration logic in provider initialization +- Potential impact on existing queries (should be backward compatible) diff --git a/openspec/changes/archive/2026-03-28-extend-memory-metadata/specs/feedback-event-metadata/spec.md b/openspec/changes/archive/2026-03-28-extend-memory-metadata/specs/feedback-event-metadata/spec.md new file mode 100644 index 0000000..c3038b5 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-extend-memory-metadata/specs/feedback-event-metadata/spec.md @@ -0,0 +1,36 @@ +# feedback-event-metadata Specification + +## Purpose +Extend FeedbackEvent schema with additional fields for better tracking and attribution. + +## ADDED Requirements + +### Requirement: Source session tracking +The system SHALL support optional sourceSessionId field on FeedbackEvent. + +#### Scenario: Feedback with source session +- **WHEN** a feedback event is created with sourceSessionId +- **THEN** the sourceSessionId is stored for audit + +### Requirement: Confidence delta tracking +The system SHALL support optional confidenceDelta field to track how feedback affects memory confidence. + +#### Scenario: Feedback with confidence adjustment +- **WHEN** a user marks a memory as wrong +- **THEN** the feedback event may include confidenceDelta +- **AND** downstream can adjust memory confidence + +### Requirement: Related memory reference +The system SHALL support optional relatedMemoryId field on FeedbackEvent. + +#### Scenario: Feedback linked to memory +- **WHEN** feedback is created for a specific memory +- **THEN** the relatedMemoryId references the target memory + +### Requirement: Context field +The system SHALL support optional context field for additional feedback context. + +#### Scenario: Feedback with context +- **WHEN** feedback is created with context data +- **THEN** the context is stored as JSON +- **AND** the context is available in effectiveness summaries diff --git a/openspec/changes/archive/2026-03-28-extend-memory-metadata/specs/memory-record-metadata/spec.md b/openspec/changes/archive/2026-03-28-extend-memory-metadata/specs/memory-record-metadata/spec.md new file mode 100644 index 0000000..c89c4a4 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-extend-memory-metadata/specs/memory-record-metadata/spec.md @@ -0,0 +1,56 @@ +# memory-record-metadata Specification + +## Purpose +Extend MemoryRecord schema with additional metadata fields for preference learning, user identification, and governance. + +## ADDED Requirements + +### Requirement: User identification fields +The system SHALL support optional userId and teamId fields on MemoryRecord. + +#### Scenario: Memory with userId +- **WHEN** a memory is created with userId field +- **THEN** the userId is stored and queryable +- **AND** existing queries without userId continue to work + +#### Scenario: Memory with teamId +- **WHEN** a memory is created with teamId field +- **THEN** the teamId is stored and queryable + +### Requirement: Source tracking fields +The system SHALL support sourceSessionId field to track the session that originated the memory. + +#### Scenario: Memory with source session +- **WHEN** a memory is created with sourceSessionId +- **THEN** the sourceSessionId is stored for audit trails + +### Requirement: Confidence scoring +The system SHALL support optional confidence field (0.0-1.0) on MemoryRecord. + +#### Scenario: Memory with confidence score +- **WHEN** a memory is created with confidence value +- **THEN** the confidence is stored as a float between 0 and 1 + +### Requirement: Tags support +The system SHALL support optional tags array on MemoryRecord. + +#### Scenario: Memory with tags +- **WHEN** a memory is created with tags ["typescript", "preference"] +- **AND** a subsequent search queries for tag:typescript +- **THEN** the memory is returned in results + +### Requirement: Soft-delete status field +The system SHALL support optional status field for soft-delete. + +#### Scenario: Memory status field +- **WHEN** a memory is created without status +- **THEN** status defaults to "active" +- **AND** memories with status "disabled" are excluded from search + +### Requirement: Parent-child relationships +The system SHALL support optional parentId field for memory relationships. + +#### Scenario: Memory with parent +- **WHEN** a memory is created with parentId referencing another memory +- **THEN** the relationship is stored +- **AND** queries can filter by parentId diff --git a/openspec/changes/archive/2026-03-28-extend-memory-metadata/specs/memory-schema-migration/spec.md b/openspec/changes/archive/2026-03-28-extend-memory-metadata/specs/memory-schema-migration/spec.md new file mode 100644 index 0000000..91eb123 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-extend-memory-metadata/specs/memory-schema-migration/spec.md @@ -0,0 +1,38 @@ +# memory-schema-migration Specification + +## Purpose +Provide a mechanism for schema evolution while maintaining backward compatibility. + +## ADDED Requirements + +### Requirement: Schema versioning +The system SHALL track schema version in metadata. + +#### Scenario: Schema version recorded +- **WHEN** provider initializes +- **THEN** current schema version is recorded +- **AND** version is queryable for diagnostics + +### Requirement: Automatic schema migration +The system SHALL automatically add missing columns during initialization. + +#### Scenario: Migration adds new columns +- **WHEN** provider initializes with existing database missing new columns +- **THEN** missing columns are added automatically +- **AND** existing data is preserved + +### Requirement: Migration is idempotent +The system SHALL ensure migration can be run multiple times safely. + +#### Scenario: Repeated migration +- **WHEN** migration runs on already-migrated database +- **THEN** no errors occur +- **AND** existing data is unchanged + +### Requirement: Migration failure handling +The system SHALL handle migration failures gracefully. + +#### Scenario: Migration fails +- **WHEN** migration encounters an error +- **THEN** the error is logged +- **AND** provider initialization fails with clear error message diff --git a/openspec/changes/archive/2026-03-28-extend-memory-metadata/tasks.md b/openspec/changes/archive/2026-03-28-extend-memory-metadata/tasks.md new file mode 100644 index 0000000..fb6e22f --- /dev/null +++ b/openspec/changes/archive/2026-03-28-extend-memory-metadata/tasks.md @@ -0,0 +1,39 @@ +## 1. Schema Design + +- [x] 1.1 Define TypeScript interfaces for extended MemoryRecord +- [x] 1.2 Define TypeScript interfaces for extended FeedbackEvent +- [x] 1.3 Document new optional fields + +## 2. Database Schema Updates + +- [x] 2.1 Add userId column to memories table (nullable) +- [x] 2.2 Add teamId column to memories table (nullable) +- [x] 2.3 Add sourceSessionId column to memories table (nullable) +- [x] 2.4 Add confidence column to memories table (nullable, float) +- [x] 2.5 Add tags column to memories table (nullable, JSON array) +- [x] 2.6 Add status column to memories table (nullable, default 'active') +- [x] 2.7 Add parentId column to memories table (nullable) +- [x] 2.8 Add sourceSessionId column to effectiveness_events (nullable) +- [x] 2.9 Add confidenceDelta column to effectiveness_events (nullable) +- [x] 2.10 Add relatedMemoryId column to effectiveness_events (nullable) +- [x] 2.11 Add context column to effectiveness_events (nullable, JSON) + +## 3. Migration Mechanism Implementation + +- [x] 3.1 Add schema version tracking +- [x] 3.2 Implement migration runner on init +- [x] 3.3 Add column existence check before add +- [x] 3.4 Add migration logging +- [x] 3.5 Handle migration failures gracefully + +## 4. Backward Compatibility + +- [x] 4.1 Ensure existing queries work without changes +- [x] 4.2 Add null-safe field handling in code +- [x] 4.3 Test upgrade from existing database + +## 5. Testing + +- [x] 5.1 Add unit tests for new field handling +- [x] 5.2 Add migration tests +- [x] 5.3 Test backward compatibility diff --git a/package.json b/package.json index e1bc68e..0fbf8cb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lancedb-opencode-pro", - "version": "0.2.6", + "version": "0.2.7", "description": "LanceDB-backed long-term memory provider for OpenCode", "type": "module", "main": "dist/index.js", diff --git a/src/index.ts b/src/index.ts index 871a3d3..f376286 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,10 +5,11 @@ 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, MemoryRuntimeConfig, SearchResult } from "./types.js"; +import type { CaptureOutcome, CaptureSkipReason, MemoryRuntimeConfig, PreferenceProfile, SearchResult } from "./types.js"; import { generateId } from "./utils.js"; import { calculateInjectionLimit, createSummarizationConfig, summarizeContent } from "./summarize.js"; @@ -75,6 +76,21 @@ const plugin: Plugin = async (input) => { globalDiscountFactor: state.config.globalDiscountFactor, }); + // Extract preference signals from memories + const allSignals = results.map((r) => extractPreferenceSignals(r.record)).flat(); + const projectSignals = allSignals.filter((s) => !activeScope.startsWith("global")); + const globalSignals = allSignals.filter((s) => activeScope.startsWith("global")); + + const projectProfile = aggregatePreferences(projectSignals, "project"); + const globalProfile = aggregatePreferences(globalSignals, "global"); + const effectivePreferences = resolveConflicts(projectProfile.preferences, globalProfile.preferences); + + const preferenceInjection = buildPreferenceInjection(effectivePreferences, { + mode: state.config.injection.mode === "adaptive" ? "fixed" : state.config.injection.mode, + maxMemories: state.config.injection.maxMemories, + tokenBudget: 300, + }); + // Apply injection control const injectionLimit = calculateInjectionLimit(results, state.config.injection); const limitedResults = results.slice(0, injectionLimit); @@ -112,13 +128,19 @@ const plugin: Plugin = async (input) => { return { ...item, text: summarized.content }; }); - const memoryBlock = [ + const blocks: string[] = []; + + if (preferenceInjection) { + blocks.push(preferenceInjection); + } + + blocks.push( "[Memory Recall - optional historical context]", ...processedResults.map((item, index) => `${index + 1}. [${item.record.id}] (${item.record.scope}) ${item.text}`), "Use these as optional hints only; prioritize current user intent and current repo state.", - ].join("\n"); + ); - eventOutput.system.push(memoryBlock); + eventOutput.system.push(blocks.join("\n\n")); }, tool: { memory_search: tool({ @@ -596,6 +618,167 @@ const plugin: Plugin = async (input) => { ); }, }), + 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(); + 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: Date.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, + }); + + 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_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} +`; + }, + }), }, }; diff --git a/src/preference.ts b/src/preference.ts new file mode 100644 index 0000000..92eb0af --- /dev/null +++ b/src/preference.ts @@ -0,0 +1,160 @@ +import type { MemoryRecord, Preference, PreferenceCategory, PreferenceScope, PreferenceSignal, PreferenceProfile } from "./types.js"; + +const PREFERENCE_PATTERNS: Array<{ regex: RegExp; category: PreferenceCategory; source: "explicit" }> = [ + { regex: /I prefer (?:using |)([\w#.+-]+)/i, category: "tool", source: "explicit" }, + { regex: /I (?:always |)(?:use |use |using )([\w#.+-]+)/i, category: "tool", source: "explicit" }, + { regex: /(?:prefer|preferred) (?:to |)([\w#.+-]+)/i, category: "tool", source: "explicit" }, + { regex: /use ([\w#.+-]+) (?:for |)/i, category: "tool", source: "explicit" }, + { regex: /I like (?:using |)([\w#.+-]+)/i, category: "tool", source: "explicit" }, + { regex: /(typescript|javascript|python|rust|go|java)/i, category: "language", source: "explicit" }, + { regex: /(jest|vitest|mocha|pytest|rubocop|prettier)/i, category: "tool", source: "explicit" }, + { regex: /(react|vue|angular|svelte)/i, category: "tool", source: "explicit" }, + { regex: /(eslint|prettier|black|ruff|gofmt)/i, category: "style", source: "explicit" }, + { regex: /avoid (?:using |)([\w#.+-]+)/i, category: "tool", source: "explicit" }, + { regex: /test(-|ing) (?:with |)([\w#.+-]+)/i, category: "tool", source: "explicit" }, +]; + +const DEFAULT_DECAY_HALF_LIFE_DAYS = 30; + +export function extractPreferenceSignals(memory: MemoryRecord): PreferenceSignal[] { + const signals: PreferenceSignal[] = []; + const text = memory.text; + + for (const pattern of PREFERENCE_PATTERNS) { + const match = text.match(pattern.regex); + if (match) { + signals.push({ + key: normalizePreferenceKey(match[1]), + value: match[1], + category: pattern.category, + source: pattern.source, + timestamp: memory.timestamp, + memoryId: memory.id, + }); + } + } + + return signals; +} + +function normalizePreferenceKey(value: string): string { + return value.toLowerCase().trim().replace(/\s+/g, "-"); +} + +export function aggregatePreferences( + signals: PreferenceSignal[], + scope: PreferenceScope, +): PreferenceProfile { + const preferenceMap = new Map(); + + for (const signal of signals) { + const existing = preferenceMap.get(signal.key); + if (existing) { + existing.count += 1; + if (signal.timestamp > existing.signal.timestamp) { + existing.signal = signal; + } + } else { + preferenceMap.set(signal.key, { signal, count: 1 }); + } + } + + const preferences: Preference[] = []; + const now = Date.now(); + + for (const [key, data] of preferenceMap) { + const confidence = calculateConfidence(data.count, data.signal.timestamp, now); + preferences.push({ + key, + value: data.signal.value, + category: data.signal.category, + confidence, + scope, + lastUpdated: data.signal.timestamp, + sourceCount: data.count, + }); + } + + preferences.sort((a, b) => b.confidence - a.confidence); + + return { + scope, + preferences, + updatedAt: now, + }; +} + +function calculateConfidence(count: number, timestamp: number, now: number): number { + const baseConfidence = Math.min(count / 5, 1); + const ageDays = (now - timestamp) / (1000 * 60 * 60 * 24); + const decayFactor = Math.pow(0.5, ageDays / DEFAULT_DECAY_HALF_LIFE_DAYS); + return baseConfidence * decayFactor; +} + +export function resolveConflicts( + projectPrefs: Preference[], + globalPrefs: Preference[], +): Preference[] { + const prefMap = new Map(); + + for (const pref of globalPrefs) { + prefMap.set(pref.key, { ...pref, scope: "global" }); + } + + for (const pref of projectPrefs) { + const existing = prefMap.get(pref.key); + if (!existing) { + prefMap.set(pref.key, { ...pref, scope: "project" }); + } else { + const winner = resolveSingleConflict(pref, existing); + prefMap.set(pref.key, winner); + } + } + + return Array.from(prefMap.values()).sort((a, b) => b.confidence - a.confidence); +} + +function resolveSingleConflict(a: Preference, b: Preference): Preference { + if (a.lastUpdated > b.lastUpdated) { + return { ...a, scope: "project" }; + } + return { ...b, scope: "global" }; +} + +export interface InjectionConfig { + mode: "budget" | "fixed"; + maxMemories: number; + tokenBudget?: number; +} + +export function buildPreferenceInjection( + preferences: Preference[], + config: InjectionConfig, +): string { + if (preferences.length === 0) { + return ""; + } + + const lines: string[] = []; + lines.push("## User Preferences"); + + if (config.mode === "fixed") { + const selected = preferences.slice(0, config.maxMemories); + for (const pref of selected) { + lines.push(`- [${pref.category}] ${pref.value} (confidence: ${Math.round(pref.confidence * 100)}%)`); + } + } else { + let currentTokens = 0; + const budget = config.tokenBudget ?? 500; + for (const pref of preferences) { + const estimatedTokens = pref.value.length / 4; + if (currentTokens + estimatedTokens > budget) { + break; + } + lines.push(`- [${pref.category}] ${pref.value}`); + currentTokens += estimatedTokens; + } + } + + return lines.join("\n"); +} diff --git a/src/store.ts b/src/store.ts index a8db5b1..78bbdf8 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,6 +1,7 @@ import { mkdir } from "node:fs/promises"; import { dirname } from "node:path"; -import type { CaptureSkipReason, EffectivenessSummary, MemoryEffectivenessEvent, MemoryRecord, RecallSource, SearchResult } from "./types.js"; +import type { CaptureSkipReason, EffectivenessSummary, EpisodicTaskRecord, MemoryEffectivenessEvent, MemoryRecord, RecallSource, SearchResult, SuccessPattern, TaskState, ValidationOutcome } from "./types.js"; +import { generateId } from "./utils.js"; import { cosineSimilarity, tokenize } from "./utils.js"; type LanceModule = typeof import("@lancedb/lancedb"); @@ -52,6 +53,7 @@ export class MemoryStore { private connection: LanceConnection | null = null; private table: LanceTable | null = null; private eventTable: LanceTable | null = null; + private episodicTaskTable: LanceTable | null = null; private indexState = { vector: false, fts: false, @@ -82,10 +84,17 @@ export class MemoryStore { lastRecalled: 0, recallCount: 0, projectCount: 0, - schemaVersion: 1, + schemaVersion: 2, embeddingModel: "bootstrap", vectorDim, metadataJson: "{}", + userId: undefined, + teamId: undefined, + sourceSessionId: undefined, + confidence: undefined, + tags: undefined, + status: "active", + parentId: undefined, }; this.table = await this.connection.createTable(TABLE_NAME, [bootstrap]); await this.table.delete("id = '__bootstrap__'"); @@ -125,11 +134,23 @@ export class MemoryStore { async put(record: MemoryRecord): Promise { const table = this.requireTable(); - await table.add([record]); + const recordWithDefaults: MemoryRecord = { + ...record, + userId: record.userId ?? undefined, + teamId: record.teamId ?? undefined, + sourceSessionId: record.sourceSessionId ?? undefined, + confidence: record.confidence ?? undefined, + tags: record.tags ?? undefined, + status: record.status ?? "active", + parentId: record.parentId ?? undefined, + }; + await table.add([recordWithDefaults]); this.invalidateScope(record.scope); } async putEvent(event: MemoryEffectivenessEvent): Promise { + const feedbackEvent = event.type === "feedback" ? event : null; + const captureEvent = event.type === "capture" ? event : null; await this.requireEventTable().add([ { id: event.id, @@ -149,6 +170,10 @@ export class MemoryStore { reason: event.type === "feedback" ? event.reason ?? "" : "", labelsJson: event.type === "feedback" ? JSON.stringify(event.labels ?? []) : "[]", metadataJson: event.metadataJson, + sourceSessionId: feedbackEvent?.sourceSessionId ?? captureEvent?.sourceSessionId ?? "", + confidenceDelta: feedbackEvent?.confidenceDelta ?? null, + relatedMemoryId: feedbackEvent?.relatedMemoryId ?? "", + context: feedbackEvent?.context ? JSON.stringify(feedbackEvent.context) : null, }, ]); } @@ -241,6 +266,16 @@ export class MemoryStore { return true; } + async softDeleteMemory(id: string, scopes: string[]): Promise { + const rows = await this.readByScopes(scopes); + const match = rows.find((row) => this.matchesId(row.id, id)); + if (!match) return false; + await this.requireTable().delete(`id = '${escapeSql(match.id)}'`); + await this.requireTable().add([{ ...match, status: "disabled" }]); + this.invalidateScope(match.scope); + return true; + } + async updateMemoryScope(id: string, newScope: string, scopes: string[]): Promise { const rows = await this.readByScopes(scopes); const match = rows.find((row) => this.matchesId(row.id, id)); @@ -278,6 +313,14 @@ export class MemoryStore { return rows.sort((a, b) => b.timestamp - a.timestamp).slice(0, limit); } + async listSince(scope: string, sinceTimestamp: number, limit: number = 100): Promise { + const rows = await this.readByScopesIncludingMerged([scope]); + return rows + .filter((row) => row.timestamp >= sinceTimestamp) + .sort((a, b) => b.timestamp - a.timestamp) + .slice(0, limit); + } + async pruneScope(scope: string, maxEntries: number): Promise { const rows = await this.list(scope, 100000); if (rows.length <= maxEntries) return 0; @@ -361,6 +404,7 @@ export class MemoryStore { await this.requireTable().delete(`id = '${escapeSql(older.id)}'`); await this.requireTable().add([{ ...older, + status: "merged", metadataJson: JSON.stringify({ ...parseMetadata(older.metadataJson), ...updatedOlderMeta }), }]); @@ -630,7 +674,353 @@ export class MemoryStore { return this.eventTable; } - private async readEventsByScopes(scopes: string[]): Promise { + private async ensureEpisodicTaskTable(vectorDim: number): Promise { + const EPISODIC_TABLE_NAME = "episodic_tasks"; + if (this.episodicTaskTable) return; + + try { + this.episodicTaskTable = await this.connection!.openTable(EPISODIC_TABLE_NAME); + } catch { + const bootstrap: EpisodicTaskRecord = { + id: "__bootstrap__", + sessionId: "", + scope: "global", + taskId: "", + state: "pending", + startTime: 0, + endTime: 0, + commandsJson: "[]", + validationOutcomesJson: "[]", + successPatternsJson: "[]", + retryAttemptsJson: "[]", + recoveryStrategiesJson: "[]", + metadataJson: "{}", + }; + this.episodicTaskTable = await this.connection!.createTable(EPISODIC_TABLE_NAME, [bootstrap]); + await this.episodicTaskTable.delete("id = '__bootstrap__'"); + } + } + + private requireEpisodicTaskTable(): LanceTable { + if (!this.episodicTaskTable) { + throw new Error("MemoryStore episodic task table is not initialized"); + } + return this.episodicTaskTable; + } + + async createTaskEpisode(record: EpisodicTaskRecord): Promise { + await this.ensureEpisodicTaskTable(384); + await this.requireEpisodicTaskTable().add([record]); + } + + async updateTaskState(taskId: string, state: TaskState, scope: string, failureType?: string, errorMessage?: string): Promise { + await this.ensureEpisodicTaskTable(384); + const table = this.requireEpisodicTaskTable(); + const rows = await table.query().where(`taskId = '${escapeSql(taskId)}' AND scope = '${escapeSql(scope)}'`).toArray(); + if (rows.length === 0) return false; + + const existing = rows[0] as unknown as EpisodicTaskRecord; + const updated: EpisodicTaskRecord = { + ...existing, + state, + endTime: state !== "running" && state !== "pending" ? Date.now() : undefined, + failureType: failureType as EpisodicTaskRecord["failureType"], + errorMessage, + }; + await table.delete(`id = '${escapeSql(existing.id)}'`); + await table.add([updated]); + return true; + } + + async getTaskEpisode(taskId: string, scope: string): Promise { + await this.ensureEpisodicTaskTable(384); + const rows = await this.requireEpisodicTaskTable() + .query() + .where(`taskId = '${escapeSql(taskId)}' AND scope = '${escapeSql(scope)}'`) + .toArray(); + if (rows.length === 0) return null; + return rows[0] as unknown as EpisodicTaskRecord; + } + + async queryTaskEpisodes(scope: string, state?: TaskState, sinceTimestamp?: number): Promise { + await this.ensureEpisodicTaskTable(384); + const table = this.requireEpisodicTaskTable(); + let whereClause = `scope = '${escapeSql(scope)}'`; + if (state) { + whereClause += ` AND state = '${escapeSql(state)}'`; + } + if (sinceTimestamp) { + whereClause += ` AND startTime >= ${sinceTimestamp}`; + } + const rows = await table.query().where(whereClause).toArray(); + return rows as unknown as EpisodicTaskRecord[]; + } + + async addCommandToEpisode(taskId: string, scope: string, command: string): Promise { + await this.ensureEpisodicTaskTable(384); + const table = this.requireEpisodicTaskTable(); + const rows = await table.query().where(`taskId = '${escapeSql(taskId)}' AND scope = '${escapeSql(scope)}'`).toArray(); + if (rows.length === 0) return false; + + const existing = rows[0] as unknown as EpisodicTaskRecord; + const commands: string[] = existing.commandsJson ? JSON.parse(existing.commandsJson) : []; + commands.push(command); + + const updated: EpisodicTaskRecord = { + ...existing, + commandsJson: JSON.stringify(commands), + }; + await table.delete(`id = '${escapeSql(existing.id)}'`); + await table.add([updated]); + return true; + } + + async addValidationOutcome(taskId: string, scope: string, outcome: ValidationOutcome): Promise { + await this.ensureEpisodicTaskTable(384); + const table = this.requireEpisodicTaskTable(); + const rows = await table.query().where(`taskId = '${escapeSql(taskId)}' AND scope = '${escapeSql(scope)}'`).toArray(); + if (rows.length === 0) return false; + + const existing = rows[0] as unknown as EpisodicTaskRecord; + const outcomes: ValidationOutcome[] = existing.validationOutcomesJson ? JSON.parse(existing.validationOutcomesJson) : []; + outcomes.push(outcome); + + const updated: EpisodicTaskRecord = { + ...existing, + validationOutcomesJson: JSON.stringify(outcomes), + }; + await table.delete(`id = '${escapeSql(existing.id)}'`); + await table.add([updated]); + return true; + } + + async addSuccessPatterns(taskId: string, scope: string, patterns: SuccessPattern[]): Promise { + await this.ensureEpisodicTaskTable(384); + const table = this.requireEpisodicTaskTable(); + const rows = await table.query().where(`taskId = '${escapeSql(taskId)}' AND scope = '${escapeSql(scope)}'`).toArray(); + if (rows.length === 0) return false; + + const existing = rows[0] as unknown as EpisodicTaskRecord; + const existingPatterns: SuccessPattern[] = existing.successPatternsJson ? JSON.parse(existing.successPatternsJson) : []; + const allPatterns = [...existingPatterns, ...patterns]; + + const updated: EpisodicTaskRecord = { + ...existing, + successPatternsJson: JSON.stringify(allPatterns), + }; + await table.delete(`id = '${escapeSql(existing.id)}'`); + await table.add([updated]); + return true; + } + + async findSimilarTasks(scope: string, taskDescription: string, minSimilarity: number = 0.85): Promise { + await this.ensureEpisodicTaskTable(384); + const table = this.requireEpisodicTaskTable(); + // Simple text-based matching - can be enhanced with vector similarity later + const rows = await table.query().where(`scope = '${escapeSql(scope)}' AND state = 'success'`).toArray(); + const episodes = rows as unknown as EpisodicTaskRecord[]; + + // Simple keyword-based similarity (placeholder for vector similarity) + const keywords = taskDescription.toLowerCase().split(/\s+/).filter(k => k.length > 2); + + const scored = episodes.map(ep => { + const metadata = JSON.parse(ep.metadataJson || "{}"); + const description = (metadata.description || "").toLowerCase(); + const taskId = ep.taskId.toLowerCase(); + const commands = JSON.parse(ep.commandsJson || "[]").join(" ").toLowerCase(); + + const text = `${taskId} ${description} ${commands}`; + let matchCount = 0; + for (const kw of keywords) { + if (text.includes(kw)) matchCount++; + } + const similarity = keywords.length > 0 ? matchCount / keywords.length : 0; + return { episode: ep, similarity }; + }); + + return scored + .filter(s => s.similarity >= minSimilarity) + .sort((a, b) => b.similarity - a.similarity) + .map(s => s.episode); + } + + async extractSuccessPatternsFromScope(scope: string): Promise<{ pattern: SuccessPattern; count: number }[]> { + await this.ensureEpisodicTaskTable(384); + const table = this.requireEpisodicTaskTable(); + const rows = await table.query().where(`scope = '${escapeSql(scope)}' AND state = 'success'`).toArray(); + const episodes = rows as unknown as EpisodicTaskRecord[]; + + const commandSequenceCount = new Map(); + const toolCount = new Map(); + + for (const ep of episodes) { + const commands: string[] = JSON.parse(ep.commandsJson || "[]"); + if (commands.length > 0) { + const seq = commands.join(" | "); + commandSequenceCount.set(seq, (commandSequenceCount.get(seq) || 0) + 1); + } + + // Extract tools from commands (simple heuristic) + for (const cmd of commands) { + const toolMatch = cmd.match(/^(npm|yarn|pnpm|npx|yarn|cargo|go|pytest|jest|tsc|eslint|prettier)/); + if (toolMatch) { + toolCount.set(toolMatch[1], (toolCount.get(toolMatch[1]) || 0) + 1); + } + } + } + + const patterns: { pattern: SuccessPattern; count: number }[] = []; + + // Create patterns from frequent command sequences + for (const [seq, count] of commandSequenceCount) { + const commands = seq.split(" | "); + const confidence = Math.min(0.5 + (count * 0.1), 1.0); + patterns.push({ + pattern: { + commands, + tools: commands.map(c => c.split(" ")[0]).filter(Boolean), + confidence, + extractedAt: Date.now(), + }, + count, + }); + } + + return patterns.sort((a, b) => b.count - a.count); + } + + async addRetryAttempt(taskId: string, scope: string, attempt: { attemptNumber: number; outcome: "success" | "failed" | "abandoned"; errorMessage?: string; failureType?: string }): Promise { + await this.ensureEpisodicTaskTable(384); + const table = this.requireEpisodicTaskTable(); + const rows = await table.query().where(`taskId = '${escapeSql(taskId)}' AND scope = '${escapeSql(scope)}'`).toArray(); + if (rows.length === 0) return false; + + const existing = rows[0] as unknown as EpisodicTaskRecord; + const attempts = JSON.parse(existing.retryAttemptsJson || "[]"); + attempts.push({ + ...attempt, + timestamp: Date.now(), + }); + + const updated: EpisodicTaskRecord = { + ...existing, + retryAttemptsJson: JSON.stringify(attempts), + }; + await table.delete(`id = '${escapeSql(existing.id)}'`); + await table.add([updated]); + return true; + } + + async addRecoveryStrategy(taskId: string, scope: string, strategy: { name: string; succeeded: boolean }): Promise { + await this.ensureEpisodicTaskTable(384); + const table = this.requireEpisodicTaskTable(); + const rows = await table.query().where(`taskId = '${escapeSql(taskId)}' AND scope = '${escapeSql(scope)}'`).toArray(); + if (rows.length === 0) return false; + + const existing = rows[0] as unknown as EpisodicTaskRecord; + const strategies = JSON.parse(existing.recoveryStrategiesJson || "[]"); + strategies.push({ + ...strategy, + attemptedAt: Date.now(), + }); + + const updated: EpisodicTaskRecord = { + ...existing, + recoveryStrategiesJson: JSON.stringify(strategies), + }; + await table.delete(`id = '${escapeSql(existing.id)}'`); + await table.add([updated]); + return true; + } + + async suggestRetryBudget(scope: string, minSamples: number = 3): Promise<{ suggestedRetries: number; confidence: number; basedOnCount: number; shouldStop: boolean; stopReason?: string } | null> { + await this.ensureEpisodicTaskTable(384); + const table = this.requireEpisodicTaskTable(); + const rows = await table.query().where(`scope = '${escapeSql(scope)}' AND state = 'failed'`).toArray(); + const failedEpisodes = rows as unknown as EpisodicTaskRecord[]; + + if (failedEpisodes.length < minSamples) { + return null; + } + + const retryCounts: number[] = []; + let sameErrorCount = 0; + const firstError = failedEpisodes[0]?.errorMessage; + + for (const ep of failedEpisodes) { + const attempts = JSON.parse(ep.retryAttemptsJson || "[]"); + retryCounts.push(attempts.length); + + if (ep.errorMessage === firstError && attempts.length > 0) { + sameErrorCount++; + } + } + + if (retryCounts.length === 0) { + return null; + } + + const sorted = [...retryCounts].sort((a, b) => a - b); + const median = sorted[Math.floor(sorted.length / 2)]; + const suggestedRetries = median + 1; + const confidence = Math.min(0.5 + (retryCounts.length * 0.1), 1.0); + + const shouldStop = sameErrorCount >= 3; + const stopReason = shouldStop ? "Multiple retries failed with same error" : undefined; + + return { + suggestedRetries, + confidence, + basedOnCount: retryCounts.length, + shouldStop, + stopReason, + }; + } + + async suggestRecoveryStrategies(scope: string, taskId: string): Promise<{ strategy: string; reason: string; confidence: number; basedOnTask?: string }[]> { + await this.ensureEpisodicTaskTable(384); + const table = this.requireEpisodicTaskTable(); + const suggestions: { strategy: string; reason: string; confidence: number; basedOnTask?: string }[] = []; + + const failedRows = await table.query().where(`scope = '${escapeSql(scope)}' AND state = 'failed'`).toArray(); + const failedEpisodes = failedRows as unknown as EpisodicTaskRecord[]; + + const successRows = await table.query().where(`scope = '${escapeSql(scope)}' AND state = 'success'`).toArray(); + const successEpisodes = successRows as unknown as EpisodicTaskRecord[]; + + if (failedEpisodes.length >= 3 && successEpisodes.length > 0) { + const failedTaskIds = failedEpisodes.map(e => e.taskId); + const similarSuccess = successEpisodes.find(e => { + const eId = e.taskId.toLowerCase(); + return failedTaskIds.some(fId => eId.includes(fId) || fId.includes(eId)); + }); + + if (similarSuccess) { + const commands = JSON.parse(similarSuccess.commandsJson || "[]"); + if (commands.length > 0) { + suggestions.push({ + strategy: `Try: ${commands[0]}`, + reason: "Similar task succeeded with this approach", + confidence: 0.7, + basedOnTask: similarSuccess.taskId, + }); + } + } + } + + const recentFailed = failedEpisodes.filter(e => Date.now() - e.startTime < 3600000); + if (recentFailed.length >= 2) { + suggestions.push({ + strategy: "Consider exponential backoff", + reason: "Multiple failures in short timeframe", + confidence: 0.6, + }); + } + + return suggestions; + } + + async readEventsByScopes(scopes: string[]): Promise { const table = this.requireEventTable(); if (scopes.length === 0) return []; const whereExpr = scopes.map((scope) => `scope = '${escapeSql(scope)}'`).join(" OR "); @@ -655,6 +1045,10 @@ export class MemoryStore { "reason", "labelsJson", "metadataJson", + "sourceSessionId", + "confidenceDelta", + "relatedMemoryId", + "context", ]) .limit(100000) .toArray(); @@ -686,6 +1080,13 @@ export class MemoryStore { "embeddingModel", "vectorDim", "metadataJson", + "userId", + "teamId", + "sourceSessionId", + "confidence", + "tags", + "status", + "parentId", ]) .limit(100000) .toArray(); @@ -701,7 +1102,7 @@ export class MemoryStore { const whereExpr = scopes.map((scope) => `scope = '${escapeSql(scope)}'`).join(" OR "); const rows = await table .query() - .where(`(${whereExpr}) AND metadataJson NOT LIKE '%"status":"merged"%'`) + .where(`(${whereExpr}) AND (status != 'disabled' OR status IS NULL OR status = '') AND NOT (status = 'merged') AND NOT (metadataJson LIKE '%"status":"merged"%')`) .select([ "id", "text", @@ -717,6 +1118,13 @@ export class MemoryStore { "embeddingModel", "vectorDim", "metadataJson", + "userId", + "teamId", + "sourceSessionId", + "confidence", + "tags", + "status", + "parentId", ]) .limit(100000) .toArray(); @@ -767,6 +1175,27 @@ export class MemoryStore { if (!fieldNames.has("projectCount")) { missing.push({ name: "projectCount", valueSql: "CAST(0 AS INT)" }); } + if (!fieldNames.has("userId")) { + missing.push({ name: "userId", valueSql: "CAST(NULL AS STRING)" }); + } + if (!fieldNames.has("teamId")) { + missing.push({ name: "teamId", valueSql: "CAST(NULL AS STRING)" }); + } + if (!fieldNames.has("sourceSessionId")) { + missing.push({ name: "sourceSessionId", valueSql: "CAST(NULL AS STRING)" }); + } + if (!fieldNames.has("confidence")) { + missing.push({ name: "confidence", valueSql: "CAST(NULL AS DOUBLE)" }); + } + if (!fieldNames.has("tags")) { + missing.push({ name: "tags", valueSql: "CAST(NULL AS STRING)" }); + } + if (!fieldNames.has("status")) { + missing.push({ name: "status", valueSql: "CAST('active' AS STRING)" }); + } + if (!fieldNames.has("parentId")) { + missing.push({ name: "parentId", valueSql: "CAST(NULL AS STRING)" }); + } if (missing.length === 0) { return; @@ -786,17 +1215,36 @@ export class MemoryStore { private async ensureEventTableCompatibility(): Promise { const table = this.requireEventTable(); const schema = await table.schema(); - const hasSourceColumn = schema.fields.some((field) => field.name === EVENTS_SOURCE_COLUMN); - if (hasSourceColumn) { + const fieldNames = new Set(schema.fields.map((field) => field.name)); + + const missing: Array<{ name: string; valueSql: string }> = []; + if (!fieldNames.has(EVENTS_SOURCE_COLUMN)) { + missing.push({ name: EVENTS_SOURCE_COLUMN, valueSql: "CAST(NULL AS STRING)" }); + } + if (!fieldNames.has("sourceSessionId")) { + missing.push({ name: "sourceSessionId", valueSql: "CAST(NULL AS STRING)" }); + } + if (!fieldNames.has("confidenceDelta")) { + missing.push({ name: "confidenceDelta", valueSql: "CAST(NULL AS DOUBLE)" }); + } + if (!fieldNames.has("relatedMemoryId")) { + missing.push({ name: "relatedMemoryId", valueSql: "CAST(NULL AS STRING)" }); + } + if (!fieldNames.has("context")) { + missing.push({ name: "context", valueSql: "CAST(NULL AS STRING)" }); + } + + if (missing.length === 0) { return; } try { - await table.addColumns([{ name: EVENTS_SOURCE_COLUMN, valueSql: "CAST(NULL AS STRING)" }]); + await table.addColumns(missing); } catch (error) { const reason = error instanceof Error ? error.message : String(error); + const names = missing.map((col) => col.name).join(", "); throw new Error( - `Failed to patch ${EVENTS_TABLE_NAME} schema for ${EVENTS_SOURCE_COLUMN}: ${reason}`, + `Failed to patch ${EVENTS_TABLE_NAME} schema for columns [${names}]: ${reason}`, ); } } @@ -811,6 +1259,13 @@ function normalizeRow(row: Record): MemoryRecord | null { return null; } + const tagsRaw = row.tags; + const parsedTags = typeof tagsRaw === "string" && tagsRaw.length > 0 + ? JSON.parse(tagsRaw) as string[] + : Array.isArray(tagsRaw) + ? tagsRaw as string[] + : undefined; + return { id: row.id, text: row.text, @@ -826,6 +1281,13 @@ function normalizeRow(row: Record): MemoryRecord | null { embeddingModel: String(row.embeddingModel ?? "unknown"), vectorDim: Number(row.vectorDim ?? vector.length), metadataJson: String(row.metadataJson ?? "{}"), + userId: typeof row.userId === "string" && row.userId.length > 0 ? row.userId : undefined, + teamId: typeof row.teamId === "string" && row.teamId.length > 0 ? row.teamId : undefined, + sourceSessionId: typeof row.sourceSessionId === "string" && row.sourceSessionId.length > 0 ? row.sourceSessionId : undefined, + confidence: typeof row.confidence === "number" ? row.confidence : undefined, + tags: parsedTags, + status: (row.status as MemoryRecord["status"]) ?? "active", + parentId: typeof row.parentId === "string" && row.parentId.length > 0 ? row.parentId : undefined, }; } @@ -871,6 +1333,10 @@ function normalizeEventRow(row: Record): MemoryEffectivenessEve const labelsJson = typeof row.labelsJson === "string" ? row.labelsJson : "[]"; const labels = JSON.parse(labelsJson) as string[]; const helpfulValue = Number(row.helpful ?? -1); + const contextRaw = row.context; + const parsedContext = typeof contextRaw === "string" && contextRaw.length > 0 + ? JSON.parse(contextRaw) as Record + : undefined; return { ...base, type: "feedback", @@ -878,6 +1344,10 @@ function normalizeEventRow(row: Record): MemoryEffectivenessEve helpful: helpfulValue < 0 ? undefined : helpfulValue === 1, labels: Array.isArray(labels) ? labels.filter((item): item is string => typeof item === "string") : [], reason: typeof row.reason === "string" && row.reason.length > 0 ? row.reason : undefined, + sourceSessionId: typeof row.sourceSessionId === "string" && row.sourceSessionId.length > 0 ? row.sourceSessionId : undefined, + confidenceDelta: typeof row.confidenceDelta === "number" ? row.confidenceDelta : undefined, + relatedMemoryId: typeof row.relatedMemoryId === "string" && row.relatedMemoryId.length > 0 ? row.relatedMemoryId : undefined, + context: parsedContext, }; } diff --git a/src/types.ts b/src/types.ts index be2b592..74a1219 100644 --- a/src/types.ts +++ b/src/types.ts @@ -42,6 +42,8 @@ export type RecallSource = "system-transform" | "manual-search"; export type MemoryScope = "project" | "global"; +export type SchemaVersion = 1 | 2; + export interface EmbeddingConfig { provider: EmbeddingProvider; model: string; @@ -115,6 +117,8 @@ export interface MemoryRuntimeConfig { maxEntriesPerScope: number; } +export type MemoryStatus = "active" | "disabled" | "merged"; + export interface MemoryRecord { id: string; text: string; @@ -130,6 +134,14 @@ export interface MemoryRecord { embeddingModel: string; vectorDim: number; metadataJson: string; + // Extended fields (optional for backward compatibility) + userId?: string; + teamId?: string; + sourceSessionId?: string; + confidence?: number; + tags?: string[]; + status?: MemoryStatus; + parentId?: string; } export interface SearchResult { @@ -164,6 +176,8 @@ export interface CaptureEvent extends MemoryEffectivenessEventBase { type: "capture"; outcome: CaptureOutcome; skipReason?: CaptureSkipReason; + // Extended fields (optional for backward compatibility) + sourceSessionId?: string; } export interface RecallEvent extends MemoryEffectivenessEventBase { @@ -179,6 +193,11 @@ export interface FeedbackEvent extends MemoryEffectivenessEventBase { helpful?: boolean; labels?: string[]; reason?: string; + // Extended fields (optional for backward compatibility) + sourceSessionId?: string; + confidenceDelta?: number; + relatedMemoryId?: string; + context?: Record; } export type MemoryEffectivenessEvent = CaptureEvent | RecallEvent | FeedbackEvent; @@ -229,3 +248,102 @@ export interface EffectivenessSummary { consolidatedCount: number; }; } + +export type PreferenceCategory = "language" | "tool" | "style" | "workflow" | "other"; +export type PreferenceScope = "project" | "global"; +export type PreferenceSource = "explicit" | "inferred"; + +export interface PreferenceSignal { + key: string; + value: string; + category: PreferenceCategory; + source: PreferenceSource; + timestamp: number; + memoryId: string; +} + +export interface Preference { + key: string; + value: string; + category: PreferenceCategory; + confidence: number; + scope: PreferenceScope; + lastUpdated: number; + sourceCount: number; +} + +export interface PreferenceProfile { + scope: string; + preferences: Preference[]; + updatedAt: number; +} + +export type TaskState = "pending" | "running" | "success" | "failed" | "timeout"; +export type FailureType = "syntax" | "runtime" | "logic" | "resource" | "unknown"; +export type ValidationType = "type-check" | "build" | "test"; +export type ValidationStatus = "pass" | "fail" | "skipped"; + +export interface ValidationOutcome { + type: ValidationType; + status: ValidationStatus; + timestamp: number; + errorCount?: number; + errorTypes?: string[]; + passedCount?: number; + failedCount?: number; + output?: string; +} + +export interface SuccessPattern { + commands: string[]; + tools: string[]; + confidence: number; + extractedAt: number; +} + +export interface RetryAttempt { + attemptNumber: number; + timestamp: number; + outcome: "success" | "failed" | "abandoned"; + errorMessage?: string; + failureType?: FailureType; +} + +export interface RecoveryStrategy { + name: string; + attemptedAt: number; + succeeded: boolean; +} + +export interface RetryBudgetSuggestion { + suggestedRetries: number; + confidence: number; + basedOnCount: number; + shouldStop: boolean; + stopReason?: string; +} + +export interface StrategySuggestion { + strategy: string; + reason: string; + confidence: number; + basedOnTask?: string; +} + +export interface EpisodicTaskRecord { + id: string; + sessionId: string; + scope: string; + taskId: string; + state: TaskState; + startTime: number; + endTime?: number; + failureType?: FailureType; + errorMessage?: string; + commandsJson: string; + validationOutcomesJson: string; + successPatternsJson: string; + retryAttemptsJson: string; + recoveryStrategiesJson: string; + metadataJson: string; +} diff --git a/src/utils.ts b/src/utils.ts index 5a17457..1767210 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,7 @@ import { createHash, randomUUID } from "node:crypto"; import { homedir } from "node:os"; import { join } from "node:path"; +import type { FailureType } from "./types.js"; export function expandHomePath(input: string): string { if (input === "~") return homedir(); @@ -70,3 +71,165 @@ export function parseJsonObject(value: string | undefined, fallback: T): T { return fallback; } } + +const SYNTAX_PATTERNS = [ + /SyntaxError/i, + /unexpected token/i, + /unexpected character/i, + /parse error/i, + /Invalid syntax/i, + /syntax error/i, + /unterminated string/i, + /unterminated/i, + /Expected .+ but found/i, +]; + +const RUNTIME_PATTERNS = [ + /ReferenceError/i, + /TypeError/i, + /RangeError/i, + /ReferenceError/i, + /^Error:/i, + /^Exception:/i, + /Cannot read property/i, + /is not a function/i, + /is not defined/i, + /Cannot read/i, + /is null/i, + /is not an object/i, + /unhandled promise rejection/i, + /UnhandledPromiseRejection/i, +]; + +const LOGIC_PATTERNS = [ + /AssertionError/i, + /assert.*failed/i, + /expected .+ but got/i, + /expected .+ received/i, + /test failed/i, + /assertion failed/i, + /does not equal/i, + /not equal/i, +]; + +const RESOURCE_PATTERNS = [ + /OutOfMemoryError/i, + /JavaScript heap out of memory/i, + /ETIMEDOUT/i, + /ECONNREFUSED/i, + /ECONNRESET/i, + /ENOMEM/i, + /EADDRINUSE/i, + /timeout/i, + /memory limit/i, + /disk full/i, + /no space left/i, + /resource.*exhausted/i, +]; + +export function classifyFailure(errorMessage: string): FailureType { + const lowerMessage = errorMessage.toLowerCase(); + + for (const pattern of SYNTAX_PATTERNS) { + if (pattern.test(errorMessage)) { + return "syntax"; + } + } + + for (const pattern of RUNTIME_PATTERNS) { + if (pattern.test(errorMessage)) { + return "runtime"; + } + } + + for (const pattern of LOGIC_PATTERNS) { + if (pattern.test(errorMessage)) { + return "logic"; + } + } + + for (const pattern of RESOURCE_PATTERNS) { + if (pattern.test(errorMessage)) { + return "resource"; + } + } + + return "unknown"; +} + +const TYPE_CHECK_PATTERNS = [ + /tsc|typescript.*error/i, + /type error/i, + /property .* does not exist/i, + /argument of type/i, + /Type '.*' is not assignable/i, +]; + +const BUILD_PATTERNS = [ + /build failed/i, + /compilation failed/i, + /webpack.*error/i, + /vite.*error/i, + /esbuild.*error/i, + /rollup.*error/i, + /failed to build/i, +]; + +const TEST_PATTERNS = [ + /test.*failed/i, + /\d+ passed, \d+ failed/i, + /PASS|FAIL/i, + /failed.*test/i, +]; + +export function parseValidationOutput(output: string, type: "type-check" | "build" | "test"): { + status: "pass" | "fail" | "skipped"; + errorCount?: number; + errorTypes?: string[]; + passedCount?: number; + failedCount?: number; +} { + const hasError = (pattern: RegExp) => pattern.test(output); + const extractCount = (pattern: RegExp) => { + const match = output.match(pattern); + return match ? parseInt(match[1], 10) : undefined; + }; + + switch (type) { + case "type-check": { + const errorCount = extractCount(/(\d+)\s+error/i) || extractCount(/Found (\d+) error/i); + if (errorCount !== undefined) { + return { + status: errorCount > 0 ? "fail" : "pass", + errorCount, + errorTypes: TYPE_CHECK_PATTERNS.filter(p => hasError(p)).map(p => p.source), + }; + } + return { status: hasError(/error|fail/i) ? "fail" : "pass" }; + } + case "build": { + const errorCount = extractCount(/(\d+)\s+error/i); + if (errorCount !== undefined) { + return { + status: errorCount > 0 ? "fail" : "pass", + errorCount, + }; + } + return { status: hasError(/failed|error/i) ? "fail" : "pass" }; + } + case "test": { + const passed = extractCount(/(\d+)\s+passed/i); + const failed = extractCount(/(\d+)\s+failed/i); + if (passed !== undefined || failed !== undefined) { + return { + status: (failed && failed > 0) ? "fail" : "pass", + passedCount: passed, + failedCount: failed, + }; + } + return { status: hasError(/fail|error/i) ? "fail" : "pass" }; + } + default: + return { status: "skipped" }; + } +} diff --git a/test/foundation/foundation.test.ts b/test/foundation/foundation.test.ts index 3dae6c2..c2bbb6e 100644 --- a/test/foundation/foundation.test.ts +++ b/test/foundation/foundation.test.ts @@ -789,3 +789,111 @@ test("consolidateDuplicates merges two similar records correctly after self-merg await cleanupDbPath(dbPath); } }); + +// ───────────────────────────────────────────── +// Explicit Memory Commands Support (§3) +// ───────────────────────────────────────────── +test("softDeleteMemory marks record as disabled instead of deleting", async () => { + const { store, dbPath } = await createTestStore(); + try { + const scope = "project:soft-delete"; + const now = Date.now(); + const record = createTestRecord({ + id: "mem-to-disable", + scope, + text: "This memory will be disabled", + vector: createVector(384, 0.5), + timestamp: now, + metadataJson: JSON.stringify({}), + }); + await store.put(record); + + const result = await store.softDeleteMemory("mem-to-disable", [scope]); + assert.equal(result, true, "softDelete should return true"); + + const listResult = await store.list(scope, 10); + assert.equal(listResult.length, 0, "disabled memory should not appear in list"); + + const searchResult = await store.search({ + query: "disabled", + queryVector: createVector(384, 0.5), + scopes: [scope], + limit: 10, + vectorWeight: 1.0, + bm25Weight: 0.0, + minScore: 0.0, + }); + assert.equal(searchResult.length, 0, "disabled memory should not appear in search"); + } finally { + await cleanupDbPath(dbPath); + } +}); + +test("softDeleteMemory returns false when record not found", async () => { + const { store, dbPath } = await createTestStore(); + try { + const result = await store.softDeleteMemory("non-existent-id", ["project:test"]); + assert.equal(result, false, "should return false for non-existent record"); + } finally { + await cleanupDbPath(dbPath); + } +}); + +test("listSince returns memories newer than timestamp", async () => { + const { store, dbPath } = await createTestStore(); + try { + const scope = "project:list-since"; + const now = Date.now(); + const oldTimestamp = now - 10 * 24 * 60 * 60 * 1000; + const newTimestamp = now - 2 * 24 * 60 * 60 * 1000; + + await store.put(createTestRecord({ + id: "old-memory", + scope, + text: "Old memory", + vector: createVector(384, 0.1), + timestamp: oldTimestamp, + metadataJson: JSON.stringify({}), + })); + await store.put(createTestRecord({ + id: "new-memory", + scope, + text: "New memory", + vector: createVector(384, 0.2), + timestamp: newTimestamp, + metadataJson: JSON.stringify({}), + })); + + const sinceTimestamp = now - 5 * 24 * 60 * 60 * 1000; + const result = await store.listSince(scope, sinceTimestamp, 10); + + assert.equal(result.length, 1, "should return only memories newer than sinceTimestamp"); + assert.equal(result[0].id, "new-memory", "should return the newer memory"); + } finally { + await cleanupDbPath(dbPath); + } +}); + +test("listSince respects limit parameter", async () => { + const { store, dbPath } = await createTestStore(); + try { + const scope = "project:list-since-limit"; + const now = Date.now(); + + for (let i = 0; i < 10; i++) { + await store.put(createTestRecord({ + id: `mem-${i}`, + scope, + text: `Memory ${i}`, + vector: createVector(384, i / 10), + timestamp: now - i * 60 * 60 * 1000, + metadataJson: JSON.stringify({}), + })); + } + + const result = await store.listSince(scope, 0, 3); + assert.equal(result.length, 3, "should respect limit"); + } finally { + await cleanupDbPath(dbPath); + } +}); diff --git a/test/unit/episodic-task.test.ts b/test/unit/episodic-task.test.ts new file mode 100644 index 0000000..0e9ce69 --- /dev/null +++ b/test/unit/episodic-task.test.ts @@ -0,0 +1,113 @@ +import assert from "node:assert"; +import test from "node:test"; +import { MemoryStore } from "../../src/store.js"; + +async function createTestStore() { + const dbPath = await createTempDbPath(); + const store = new MemoryStore(dbPath); + await store.init(384); + return { store, dbPath }; +} + +async function createTempDbPath(): Promise { + const tmp = await import("node:fs/promises").then(m => m.mkdtemp("/tmp/test-episodic-")); + return tmp; +} + +async function cleanupDbPath(dbPath: string) { + try { + await import("node:fs/promises").then(m => m.rm(dbPath, { recursive: true, force: true })); + } catch {} +} + +test("createTaskEpisode creates a new task episode record", async () => { + const { store, dbPath } = await createTestStore(); + try { + const record = { + id: "ep-1", + sessionId: "session-1", + scope: "project:test", + taskId: "task-1", + state: "running" as const, + startTime: Date.now(), + commandsJson: "[]", + validationOutcomesJson: "[]", + successPatternsJson: "[]", + retryAttemptsJson: "[]", + recoveryStrategiesJson: "[]", + metadataJson: "{}", + }; + + await store.createTaskEpisode(record); + + const retrieved = await store.getTaskEpisode("task-1", "project:test"); + assert.ok(retrieved, "should retrieve created task episode"); + assert.equal(retrieved?.taskId, "task-1"); + assert.equal(retrieved?.state, "running"); + } finally { + await cleanupDbPath(dbPath); + } +}); + +test("updateTaskState updates task state", async () => { + const { store, dbPath } = await createTestStore(); + try { + const record = { + id: "ep-2", + sessionId: "session-1", + scope: "project:test", + taskId: "task-2", + state: "running" as const, + startTime: Date.now(), + commandsJson: "[]", + validationOutcomesJson: "[]", + successPatternsJson: "[]", + retryAttemptsJson: "[]", + recoveryStrategiesJson: "[]", + metadataJson: "{}", + }; + + await store.createTaskEpisode(record); + + const updated = await store.updateTaskState("task-2", "success", "project:test"); + assert.equal(updated, true, "should return true for successful update"); + + const retrieved = await store.getTaskEpisode("task-2", "project:test"); + assert.equal(retrieved?.state, "success"); + assert.ok(retrieved?.endTime, "should have endTime set"); + } finally { + await cleanupDbPath(dbPath); + } +}); + +test("queryTaskEpisodes filters by state", async () => { + const { store, dbPath } = await createTestStore(); + try { + const now = Date.now(); + await store.createTaskEpisode({ id: "ep-3", sessionId: "s1", scope: "project:test", taskId: "t1", state: "success", startTime: now, commandsJson: "[]", validationOutcomesJson: "[]", successPatternsJson: "[]", retryAttemptsJson: "[]", recoveryStrategiesJson: "[]", metadataJson: "{}" }); + await store.createTaskEpisode({ id: "ep-4", sessionId: "s2", scope: "project:test", taskId: "t2", state: "failed", startTime: now, commandsJson: "[]", validationOutcomesJson: "[]", successPatternsJson: "[]", retryAttemptsJson: "[]", recoveryStrategiesJson: "[]", metadataJson: "{}" }); + await store.createTaskEpisode({ id: "ep-5", sessionId: "s3", scope: "project:test", taskId: "t3", state: "failed", startTime: now, commandsJson: "[]", validationOutcomesJson: "[]", successPatternsJson: "[]", retryAttemptsJson: "[]", recoveryStrategiesJson: "[]", metadataJson: "{}" }); + + const failedTasks = await store.queryTaskEpisodes("project:test", "failed"); + assert.equal(failedTasks.length, 2, "should return 2 failed tasks"); + } finally { + await cleanupDbPath(dbPath); + } +}); + +test("queryTaskEpisodes filters by timestamp", async () => { + const { store, dbPath } = await createTestStore(); + try { + const oldTime = Date.now() - 10 * 24 * 60 * 60 * 1000; + const newTime = Date.now(); + + await store.createTaskEpisode({ id: "ep-6", sessionId: "s1", scope: "project:time", taskId: "old-task", state: "success", startTime: oldTime, commandsJson: "[]", validationOutcomesJson: "[]", successPatternsJson: "[]", retryAttemptsJson: "[]", recoveryStrategiesJson: "[]", metadataJson: "{}" }); + await store.createTaskEpisode({ id: "ep-7", sessionId: "s2", scope: "project:time", taskId: "new-task", state: "success", startTime: newTime, commandsJson: "[]", validationOutcomesJson: "[]", successPatternsJson: "[]", retryAttemptsJson: "[]", recoveryStrategiesJson: "[]", metadataJson: "{}" }); + + const recentTasks = await store.queryTaskEpisodes("project:time", undefined, Date.now() - 5 * 24 * 60 * 60 * 1000); + assert.equal(recentTasks.length, 1, "should return only recent tasks"); + assert.equal(recentTasks[0].taskId, "new-task"); + } finally { + await cleanupDbPath(dbPath); + } +}); diff --git a/test/unit/preference.test.ts b/test/unit/preference.test.ts new file mode 100644 index 0000000..fb25831 --- /dev/null +++ b/test/unit/preference.test.ts @@ -0,0 +1,104 @@ +import assert from "node:assert"; +import test from "node:test"; +import { extractPreferenceSignals, aggregatePreferences, resolveConflicts, buildPreferenceInjection } from "../../src/preference.js"; +import type { MemoryRecord } from "../../src/types.js"; + +function createTestMemory(overrides: Partial = {}): MemoryRecord { + return { + id: overrides.id ?? "test-id", + text: overrides.text ?? "test text", + vector: overrides.vector ?? new Array(384).fill(0), + category: overrides.category ?? "other", + scope: overrides.scope ?? "project:test", + importance: overrides.importance ?? 0.5, + timestamp: overrides.timestamp ?? Date.now(), + lastRecalled: 0, + recallCount: 0, + projectCount: 0, + schemaVersion: 1, + embeddingModel: "test", + vectorDim: 384, + metadataJson: "{}", + }; +} + +test("extractPreferenceSignals extracts tool preferences", () => { + const memory = createTestMemory({ text: "I prefer using TypeScript for new projects" }); + const signals = extractPreferenceSignals(memory); + + assert.ok(signals.length > 0, "should extract at least one signal"); + assert.equal(signals[0].category, "tool"); +}); + +test("extractPreferenceSignals extracts language preferences", () => { + const memory = createTestMemory({ text: "I prefer using Rust for systems programming" }); + const signals = extractPreferenceSignals(memory); + + assert.ok(signals.length > 0, "should extract at least one signal"); + const hasLanguage = signals.some(s => s.category === "language"); + assert.ok(hasLanguage, "should have language preference"); +}); + +test("extractPreferenceSignals returns empty for no preferences", () => { + const memory = createTestMemory({ text: "This is just some regular text without preferences" }); + const signals = extractPreferenceSignals(memory); + + assert.equal(signals.length, 0, "should return empty for no preferences"); +}); + +test("aggregatePreferences combines signals by key", () => { + const now = Date.now(); + const memory1 = createTestMemory({ id: "mem-1", text: "I prefer using Vitest", timestamp: now - 1000 }); + const memory2 = createTestMemory({ id: "mem-2", text: "I prefer using Vitest for testing", timestamp: now }); + + const signals1 = extractPreferenceSignals(memory1); + const signals2 = extractPreferenceSignals(memory2); + + const profile = aggregatePreferences([...signals1, ...signals2], "project"); + + assert.ok(profile.preferences.length > 0, "should have preferences"); + const vitestPref = profile.preferences.find(p => p.value.toLowerCase().includes("vitest")); + assert.ok(vitestPref, "should find Vitest preference"); +}); + +test("resolveConflicts prefers recent over old", () => { + const now = Date.now(); + const oldPref = { key: "tool-jest", value: "Jest", category: "tool" as const, confidence: 0.8, scope: "global" as const, lastUpdated: now - 100000, sourceCount: 3 }; + const newPref = { key: "tool-vitest", value: "Vitest", category: "tool" as const, confidence: 0.6, scope: "project" as const, lastUpdated: now, sourceCount: 1 }; + + const resolved = resolveConflicts([newPref], [oldPref]); + + assert.equal(resolved.length, 2); +}); + +test("buildPreferenceInjection creates formatted output", () => { + const preferences = [ + { key: "tool-typescript", value: "TypeScript", category: "language" as const, confidence: 0.9, scope: "project" as const, lastUpdated: Date.now(), sourceCount: 5 }, + { key: "tool-jest", value: "Jest", category: "tool" as const, confidence: 0.7, scope: "project" as const, lastUpdated: Date.now(), sourceCount: 3 }, + ]; + + const injection = buildPreferenceInjection(preferences, { mode: "fixed", maxMemories: 5 }); + + assert.ok(injection.includes("User Preferences"), "should include header"); + assert.ok(injection.includes("TypeScript"), "should include TypeScript"); + assert.ok(injection.includes("Jest"), "should include Jest"); +}); + +test("buildPreferenceInjection respects maxMemories limit", () => { + const preferences = [ + { key: "tool-1", value: "Tool1", category: "tool" as const, confidence: 0.9, scope: "project" as const, lastUpdated: Date.now(), sourceCount: 5 }, + { key: "tool-2", value: "Tool2", category: "tool" as const, confidence: 0.8, scope: "project" as const, lastUpdated: Date.now(), sourceCount: 4 }, + { key: "tool-3", value: "Tool3", category: "tool" as const, confidence: 0.7, scope: "project" as const, lastUpdated: Date.now(), sourceCount: 3 }, + ]; + + const injection = buildPreferenceInjection(preferences, { mode: "fixed", maxMemories: 2 }); + + const toolCount = (injection.match(/- \[/g) || []).length; + assert.equal(toolCount, 2, "should respect maxMemories limit"); +}); + +test("buildPreferenceInjection returns empty for no preferences", () => { + const injection = buildPreferenceInjection([], { mode: "fixed", maxMemories: 5 }); + + assert.equal(injection, "", "should return empty string"); +}); diff --git a/test/unit/validation.test.ts b/test/unit/validation.test.ts new file mode 100644 index 0000000..428a799 --- /dev/null +++ b/test/unit/validation.test.ts @@ -0,0 +1,67 @@ +import assert from "node:assert"; +import test from "node:test"; +import { parseValidationOutput, classifyFailure } from "../../src/utils.js"; + +test("parseValidationOutput parses type-check pass", () => { + const result = parseValidationOutput("Found 0 errors", "type-check"); + assert.equal(result.status, "pass"); + assert.equal(result.errorCount, 0); +}); + +test("parseValidationOutput parses type-check fail", () => { + const result = parseValidationOutput("Found 5 errors", "type-check"); + assert.equal(result.status, "fail"); + assert.equal(result.errorCount, 5); +}); + +test("parseValidationOutput parses build pass", () => { + const result = parseValidationOutput("Build succeeded", "build"); + assert.equal(result.status, "pass"); +}); + +test("parseValidationOutput parses build fail", () => { + const result = parseValidationOutput("Build failed with 3 errors", "build"); + assert.equal(result.status, "fail"); + assert.equal(result.errorCount, 3); +}); + +test("parseValidationOutput parses test pass", () => { + const result = parseValidationOutput("10 passed, 0 failed", "test"); + assert.equal(result.status, "pass"); + assert.equal(result.passedCount, 10); + assert.equal(result.failedCount, 0); +}); + +test("parseValidationOutput parses test fail", () => { + const result = parseValidationOutput("5 passed, 3 failed", "test"); + assert.equal(result.status, "fail"); + assert.equal(result.passedCount, 5); + assert.equal(result.failedCount, 3); +}); + +test("classifyFailure identifies syntax errors", () => { + assert.equal(classifyFailure("SyntaxError: unexpected token"), "syntax"); + assert.equal(classifyFailure("Parse error: invalid syntax"), "syntax"); + assert.equal(classifyFailure("unexpected character at line 1"), "syntax"); +}); + +test("classifyFailure identifies runtime errors", () => { + assert.equal(classifyFailure("ReferenceError: x is not defined"), "runtime"); + assert.equal(classifyFailure("TypeError: Cannot read property 'foo'"), "runtime"); + assert.equal(classifyFailure("Error: something went wrong"), "runtime"); +}); + +test("classifyFailure identifies logic errors", () => { + assert.equal(classifyFailure("AssertionError: expected 5 but got 3"), "logic"); + assert.equal(classifyFailure("test failed: expected 'a' received 'b'"), "logic"); +}); + +test("classifyFailure identifies resource errors", () => { + assert.equal(classifyFailure("ETIMEDOUT: connection timeout"), "resource"); + assert.equal(classifyFailure("ECONNREFUSED: connection refused"), "resource"); + assert.equal(classifyFailure("OutOfMemoryError"), "resource"); +}); + +test("classifyFailure returns unknown for unrecognized", () => { + assert.equal(classifyFailure("some unknown error message"), "unknown"); +});