diff --git a/.env.example b/.env.example index a2a639ea..e1bda5cc 100644 --- a/.env.example +++ b/.env.example @@ -167,6 +167,7 @@ # AGENTMEMORY_INJECT_CONTEXT=true # Inject recalled memories back into agent prompts (#143). Default off — hooks capture observations but do not modify conversation. # AGENTMEMORY_COMPRESS_FILE_ROOTS=/repo/docs,/Users/me/notes # Extra narrow roots allowed for memory_compress_file. Defaults to the daemon cwd when safe; avoid home or /. # CONSOLIDATION_ENABLED=true # Run the 4-tier consolidation pipeline (memories → semantic → procedural). Default off — opt in once you've measured the LLM cost. +# AGENTMEMORY_HIGH_ORDER_CONTEXT=false # Opt out of semantic/procedural/crystal/insight context blocks and smart-search arrays. Unset follows CONSOLIDATION_ENABLED. # EVICTION_ENABLED=true # Run mem::evict on a timer. Default on; set false to disable scheduled eviction. # EVICTION_INTERVAL_MS=86400000 # Scheduled mem::evict interval in ms. Default 24h. # CONSOLIDATION_DECAY_DAYS=30 # Age (days) after which non-reinforced memories decay during consolidation diff --git a/README.md b/README.md index 3e0a16ea..5a0cf203 100644 --- a/README.md +++ b/README.md @@ -977,6 +977,24 @@ Inspired by how human brains process memory — not unlike sleep consolidation. Memories decay over time (Ebbinghaus curve). Frequently accessed memories strengthen. Stale memories auto-evict. Contradictions are detected and resolved. +### Injected Context Blocks + +`mem::context` uses the existing token budget to inject bounded blocks. When high-order context is enabled, the injected candidates include: + +| Block | Source | +|------|--------| +| `Project Profile` | `KV.profiles` | +| `Lessons Learned` | `KV.lessons` | +| `Distilled Insights` | `KV.insights` | +| `Architectural Facts` | `KV.semantic` | +| `Procedural Memories` | `KV.procedural` | +| `Crystals` | `KV.crystals` | +| Recent session summaries and observations | `KV.summaries` and `KV.observations(...)` | + +Set `AGENTMEMORY_HIGH_ORDER_CONTEXT=false` to omit the four high-order blocks (`Distilled Insights`, `Architectural Facts`, `Procedural Memories`, and `Crystals`) and their `memory_smart_search` side-channel arrays. When unset, high-order context follows `CONSOLIDATION_ENABLED`; set `AGENTMEMORY_HIGH_ORDER_CONTEXT=true` to read already-stored high-order rows even when automatic consolidation is disabled. + +`memory_smart_search` returns high-order matches as optional `semantic`, `procedural`, `crystals`, and `insights` arrays beside normal observation results. + ### What Gets Captured | Hook | Captures | @@ -1778,6 +1796,7 @@ Create `~/.agentmemory/.env`: # PostToolUse regardless of this flag. # GRAPH_EXTRACTION_ENABLED=false # CONSOLIDATION_ENABLED=false # on by default when an LLM provider is configured +# AGENTMEMORY_HIGH_ORDER_CONTEXT=false # opt out of semantic/procedural/crystal/insight context + smart-search arrays # EVICTION_ENABLED=true # ON by default. Runs mem::evict on a timer. # EVICTION_INTERVAL_MS=86400000 # Default: 24h # LESSON_DECAY_ENABLED=true diff --git a/docs/todos/2026-06-19-issue-295-context-memory-layers/plan.md b/docs/todos/2026-06-19-issue-295-context-memory-layers/plan.md new file mode 100644 index 00000000..1f0b5b77 --- /dev/null +++ b/docs/todos/2026-06-19-issue-295-context-memory-layers/plan.md @@ -0,0 +1,171 @@ +# Issue 295 High-Order Context Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make existing semantic, procedural, crystal, and insight tiers visible through `mem::context` and `mem::smart-search` behind `AGENTMEMORY_HIGH_ORDER_CONTEXT`. + +**Architecture:** Use a read-path-only change over existing KV scopes. Add one config helper, gate all four high-order tier reads, render each tier as a distinct context block through the existing token-budget loop, and add optional smart-search side-channel arrays without new storage, routes, tools, auth, dependencies, or version changes. + +**Tech Stack:** TypeScript ESM, iii-sdk registered functions, StateKV, Vitest, README docs. + +--- + +## Files + +- Modify `src/config.ts`: add `isHighOrderContextEnabled()`. +- Modify `src/functions/context.ts`: gate existing insight/semantic reads, add procedural/crystal blocks, visible `[tier:id]` IDs, and conservative project/agent scoping. +- Modify `src/functions/smart-search.ts`: add bounded read-time search for semantic/procedural/crystals, gate existing insights, and keep observation/lesson behavior unchanged. +- Modify `src/functions/query.ts`: flatten the new smart-search side arrays so `mem::query` callers do not silently lose them. +- Modify `test/consolidation-default.test.ts`: red/green config flag tests. +- Modify `test/context-insights-semantic.test.ts`: red/green context high-order tier tests and opt-out coverage. +- Modify `test/smart-search.test.ts`: red/green high-order smart-search tests, `includeInsights:false`, and scoping. +- Modify `test/query-integration.test.ts`: red/green normalization for new smart-search arrays. +- Modify `README.md` and `.env.example`: document injected blocks, search side channels, and flag behavior. + +## Acceptance Mapping + +| Acceptance | Planned proof | +| --- | --- | +| `mem::context` reads semantic/procedural/crystals/insights when enabled | Context tests seed all four KV scopes and assert distinct blocks with IDs | +| Each tier is distinct with source IDs | Context tests assert headings and `[tier:id]` markers | +| Token budget respected | Context test uses tiny budget and verifies whole high-order blocks are skipped by existing loop | +| `memory_smart_search` indexes/searches four tiers | Smart-search tests assert `semantic`, `procedural`, `crystals`, and `insights` arrays | +| `AGENTMEMORY_HIGH_ORDER_CONTEXT=false` opts out | Config, context, and smart-search disabled tests | +| Docs updated | README/.env diff and stale-reference search | + +## Task 1: Config Gate + +**Files:** `test/consolidation-default.test.ts`, `src/config.ts` + +- [x] Add `AGENTMEMORY_HIGH_ORDER_CONTEXT` to the env cleanup list in `test/consolidation-default.test.ts`. +- [x] Add failing tests for: + - unset high-order flag follows `CONSOLIDATION_ENABLED=true`; + - unset high-order flag is false with no provider/consolidation; + - `AGENTMEMORY_HIGH_ORDER_CONTEXT=false` overrides enabled consolidation; + - `AGENTMEMORY_HIGH_ORDER_CONTEXT=true` overrides disabled consolidation. +- [x] Run: + +```bash +corepack pnpm exec vitest run --exclude test/integration.test.ts test/consolidation-default.test.ts +``` + +Expected red: `isHighOrderContextEnabled` is not exported. + +- [x] Implement: + +```ts +export function isHighOrderContextEnabled(): boolean { + const env = getMergedEnv(); + const explicit = env["AGENTMEMORY_HIGH_ORDER_CONTEXT"]; + if (explicit === "false" || explicit === "0") return false; + if (explicit === "true" || explicit === "1") return true; + return isConsolidationEnabled(); +} +``` + +## Task 2: Context Tests And Implementation + +**Files:** `test/context-insights-semantic.test.ts`, `src/functions/context.ts` + +- [x] Extend the config mock with `highOrderContext: true` and `isHighOrderContextEnabled`. +- [x] Add `ProceduralMemory` and `Crystal` helpers. +- [x] Add failing tests that: + - seed semantic, procedural, crystal, and insight rows and assert headings plus `[semantic:id]`, `[procedural:id]`, `[crystal:id]`, `[insight:id]`; + - set high-order disabled and assert no high-order headings or markers appear; + - use a tiny budget and assert high-order blocks are skipped whole; + - exclude other-project/unresolved procedural source sessions; + - exclude ambiguous crystals in isolated mode and include only crystals tied to current-agent sessions. +- [x] Run: + +```bash +corepack pnpm exec vitest run --exclude test/integration.test.ts test/context-insights-semantic.test.ts +``` + +Expected red: procedural/crystal blocks and visible IDs are missing; disabled mode still reads/renders existing semantic/insights. + +- [x] Implement in `src/functions/context.ts`: + - import `ProceduralMemory`, `Crystal`, and `isHighOrderContextEnabled`; + - when disabled, read none of `KV.insights`, `KV.semantic`, `KV.procedural`, `KV.crystals`; + - keep lessons outside the gate; + - derive semantic/procedural visibility from source sessions, allowing source-less rows only in shared mode; + - derive crystal visibility from `project` and/or `sessionId`, failing closed in isolated mode; + - render `## Distilled Insights`, `## Architectural Facts`, `## Procedural Memories`, and `## Crystals` with visible typed IDs; + - keep `ContextBlock.sourceIds` and the final block sort/budget loop unchanged. + +## Task 3: Smart Search Tests And Implementation + +**Files:** `test/smart-search.test.ts`, `src/functions/smart-search.ts` + +- [x] Extend the config mock with `highOrderContext: true` and `isHighOrderContextEnabled`. +- [x] Add semantic/procedural/crystal test helpers. +- [x] Add failing tests that: + - seed all high-order tiers and assert `semantic`, `procedural`, `crystals`, and existing `insights`; + - disable the high-order gate and assert no high-order arrays and no `mem::insight-search` call; + - keep `includeInsights:false` insight-only while semantic/procedural/crystals remain enabled; + - filter project/agent scope conservatively; + - tolerate one high-order `kv.list` failure without suppressing observation results. +- [x] Run: + +```bash +corepack pnpm exec vitest run --exclude test/integration.test.ts test/smart-search.test.ts +``` + +Expected red: semantic/procedural/crystal arrays are absent; disabled mode still calls insight search. + +- [x] Implement in `src/functions/smart-search.ts`: + - import high-order types and `isHighOrderContextEnabled`; + - add local compact result interfaces; + - add deterministic term-overlap scoring with strength/confidence/frequency/recency weighting; + - add read-only helpers for `KV.semantic`, `KV.procedural`, and `KV.crystals`; + - gate existing insight recall with the high-order flag and preserve `includeInsights:false`; + - return optional `semantic`, `procedural`, and `crystals` arrays beside existing `results`, `lessons`, and `insights`. + +## Task 4: Query Normalization + +**Files:** `test/query-integration.test.ts`, `src/functions/query.ts` + +- [x] Add a failing test for a `smart_search` response containing `results`, `lessons`, `insights`, `semantic`, `procedural`, and `crystals`. +- [x] Assert normalized `_id` and `_kind` are `obs`, `lesson`, `insight`, `semantic`, `procedural`, and `crystal`. +- [x] Implement: + - include `semantic`, `procedural`, and `crystals` in `smart_search` flatten keys; + - recognize `semanticId`, `proceduralId`, and `crystalId` in `_id`; + - map those IDs to `_kind`. + +## Task 5: Docs + +**Files:** `README.md`, `.env.example` + +- [x] Document the high-order context block list near the 4-tier consolidation section. +- [x] Document `AGENTMEMORY_HIGH_ORDER_CONTEXT=false` as the opt-out and `true` as an opt-in for existing rows when consolidation is disabled. +- [x] Distinguish it from `AGENTMEMORY_INJECT_CONTEXT`, which controls hook stdout injection. +- [x] Add `.env.example` flag near behavior flags. +- [x] Run: + +```bash +rg -n "AGENTMEMORY_HIGH_ORDER_CONTEXT|Distilled Insights|Architectural Facts|Procedural Memories|Crystals" README.md .env.example src test +``` + +## Verification + +Run targeted checks first: + +```bash +corepack pnpm exec vitest run --exclude test/integration.test.ts test/consolidation-default.test.ts test/context-insights-semantic.test.ts test/context-lessons.test.ts test/smart-search.test.ts test/query-integration.test.ts +``` + +Then run repo-native checks: + +```bash +corepack pnpm run lint +corepack pnpm run build +corepack pnpm test +``` + +Security gates for this non-trivial TypeScript/docs/test change: + +```bash +semgrep scan --config p/default --error --metrics=off . +gitleaks protect --staged --redact +``` + +OSV is not required unless dependency, lockfile, vendored, container, or package-manager surfaces change. diff --git a/docs/todos/2026-06-19-issue-295-context-memory-layers/todo.md b/docs/todos/2026-06-19-issue-295-context-memory-layers/todo.md new file mode 100644 index 00000000..5179e611 --- /dev/null +++ b/docs/todos/2026-06-19-issue-295-context-memory-layers/todo.md @@ -0,0 +1,161 @@ +# Issue 295 Context High-Order Memory Layers + +Scope: GitHub issue #295 in branch `issue/295-context-inject-memory-layers`. + +## Validity Evidence + +- Worktree: `/Users/A1538552/.codex/worktrees/4a39/agentmemory`. +- Start state: detached `HEAD` at `499b53fc4a0f58d6f7b2daf674a7943de023d75a`, matching local `origin/main`; switched to new branch `issue/295-context-inject-memory-layers`. +- Remote boundary: use only `origin` (`https://github.com/wbugitlab1/agentmemory.git`); do not target `upstream` (`https://github.com/rohitg00/agentmemory.git`). +- GitHub issue #295 is open and requests injecting semantic, procedural, crystals, and insights into `mem::context`, plus indexing those tiers in smart search. +- The named parent batch task record `docs/todos/2026-06-19-issue-triage-batch-288-312/todo.md` was not present in this worktree during initial inspection. +- Local source validation is in progress. + +## Sprint Contract + +Goal: Make high-order memory layers available to agents through `mem::context` and `memory_smart_search` while preserving token budgets and opt-out behavior. + +Scope: +- `src/functions/context.ts` +- `src/functions/smart-search.ts` +- `src/types.ts` only if current type definitions require local type-safe formatting of existing persisted objects +- Focused tests under `test/` +- README, `.env.example`, and generated skill config reference documentation for injected context contents and the new environment flag + +Non-goals: +- No new MCP tools, REST endpoints, persisted KV scopes, migrations, auth changes, dependencies, provider changes, version bumps, or consolidation cadence changes. +- No backfill or rewrite of existing user memory. +- No work on issues other than #295. + +Acceptance criteria: +- `mem::context` reads `KV.semantic`, `KV.procedural`, `KV.crystals`, and `KV.insights` when high-order context is enabled. +- Each enabled tier is formatted as a distinct context block with source IDs or stable identifiers. +- Existing token-budget behavior is respected by the outer context block selection. +- `memory_smart_search` searches the same four high-order tiers on demand. +- `AGENTMEMORY_HIGH_ORDER_CONTEXT=false` opts out; default behavior is enabled when `CONSOLIDATION_ENABLED=true`. +- Tests prove inclusion for each tier when enabled and exclusion when disabled. +- README documents the new injected block list. + +Intended verification: +- Red targeted vitest tests for missing high-order context and smart-search coverage before production code edits. +- Green targeted vitest tests for context and smart search. +- `corepack pnpm run lint`, `corepack pnpm run build`, and `corepack pnpm test` where dependency state permits. +- Required security gates before final handoff or commit for this non-trivial TypeScript/docs/test change: Semgrep and staged Gitleaks after staging if committing. + +Known boundaries: +- The delegated automation request authorizes routine issue reads, branch setup, valid-issue push, PR creation, clean PR merge to `origin/main`, and post-success thread archival after green verification. +- Human Checkpoints still apply for invalid closure, scope expansion, API/auth/persistence/schema/dependency/architecture/remote/project-policy changes, skipped/failing/flaky verification, divergent arena conclusions, or accepted security/quality risks. +- `upstream` remote exists but is out of scope. + +Stop conditions: +- The fix requires schema migration, new persisted storage, new MCP/REST surfaces, dependency changes, or auth/security boundary changes. +- Arena candidates diverge on issue validity or scope in a way that cannot be resolved from repo evidence. +- Verification is blocked by package-manager hardening, missing dependencies, security findings, or scanner failures that cannot be resolved inside scope. + +## Feature / Verification Matrix + +| Change | Verification method | Status | Evidence | +| --- | --- | --- | --- | +| Inject semantic memories in context | Red/green context test with seeded `KV.semantic` | Complete | Red targeted vitest initially failed with missing high-order support; green targeted matrix passed 128 tests, and `corepack pnpm test` passed 2843 tests | +| Inject procedural memories in context | Red/green context test with seeded `KV.procedural` | Complete | `test/context-insights-semantic.test.ts` covers visible `[procedural:id]`, source-session scoping, and token-budget drop | +| Inject crystals in context | Red/green context test with seeded `KV.crystals` | Complete | `test/context-insights-semantic.test.ts` covers visible `[crystal:id]`, project scoping, and isolated-mode fail-closed ownership | +| Inject insights in context | Red/green context test with seeded `KV.insights` | Complete | Existing insight block is now gated with visible `[insight:id]`; context tests cover enabled and disabled behavior | +| Respect high-order context opt-out | Red/green context test with `AGENTMEMORY_HIGH_ORDER_CONTEXT=false` | Complete | Config, context, and smart-search tests cover false/true/unset semantics and disabled no-read behavior | +| Search high-order tiers through smart search | Red/green smart-search tests across semantic/procedural/crystals/insights | Complete | `test/smart-search.test.ts` covers semantic/procedural/crystals arrays, insight gating, `includeInsights:false`, scoped results, and high-order failure isolation | +| Update injection docs and generated config reference | README/.env/plugin reference diff plus stale-reference search | Complete | `corepack pnpm run skills:gen` updated `plugin/skills/agentmemory-config/REFERENCE.md`; `corepack pnpm run skills:check` passed | + +## Subagent Ledger + +| Workstream | Scope | Edits allowed | Expected output | Result | Residual risk | +| --- | --- | --- | --- | --- | --- | +| Arena candidate A | Implementation design for #295 | No repo edits | Candidate plan and rationale under `/tmp/arena-295-context/candidate-a/` | Recommended read-path gate, distinct blocks, side-channel smart-search arrays, and strict non-goals | Crystal ownership and response names need implementation proof | +| Arena candidate B | Implementation design for #295 | No repo edits | Candidate plan and rationale under `/tmp/arena-295-context/candidate-b/` | Selected base; strongest repo validation, stale upstream distinction, and full context/search/test/doc plan | Included optional query/.env follow-through requiring parent scoping | +| Arena candidate C | Implementation design for #295 | No repo edits | Candidate plan and rationale under `/tmp/arena-295-context/candidate-c/` | Converged on read-path approach; strongest typed visible-ID and disabled-read wording | Did not independently fetch issue | +| Arena judge | Read-only comparison of candidates | No repo edits | Score table and base recommendation | Recommended Candidate B with A/C grafts | Query normalization conditionality left to parent evidence | + +## Arena Frame + +Artifact: one synthesized implementation approach for issue #295, to be turned into TDD tests and a surgical patch in this worktree. + +Rubric: +- Validates the issue from current repo evidence and rejects stale or out-of-scope subclaims. +- Preserves existing iii-engine, KV, MCP, REST, schema, auth, and token-budget boundaries. +- Covers `mem::context` and `memory_smart_search` with distinct high-order tier handling and opt-out behavior. +- Uses TDD with focused tests that fail on current code and pass after the patch. +- Keeps the implementation small, typed, and aligned with existing context/search formatting patterns. +- Documents user-visible injected context behavior without overstating unsupported tiers or flags. + +## Progress + +- Read repo instructions and required workflow skills. +- Confirmed git state and remotes. +- Created branch `issue/295-context-inject-memory-layers` from `origin/main` SHA `499b53fc4a0f58d6f7b2daf674a7943de023d75a`. +- Read GitHub issue #295 through `gh issue view`. +- Created this task record before implementation edits. +- Completed arena: + - Candidate B selected as base by parent and cross-judge. + - Grafted Candidate A's strict non-goals, budget discipline, and docs restraint. + - Grafted Candidate C's typed visible IDs and disabled-read behavior. + - Rejected vector/BM25 high-order indexing, new MCP/REST inputs, cross-tier `expandIds`, facets, standalone fallback changes, and public upstream BM25 issue scope. +- Created implementation plan at `docs/todos/2026-06-19-issue-295-context-memory-layers/plan.md`. +- Local source validation result: issue is valid but partially stale. `mem::context` already injects insights and semantic facts; remaining gaps are procedural/crystal context, high-order opt-out, smart-search semantic/procedural/crystal coverage, and docs. +- Added `isHighOrderContextEnabled()` to `src/config.ts`; unset follows `CONSOLIDATION_ENABLED`, explicit `false`/`0` opts out, and explicit `true`/`1` opts in for already-stored high-order rows. +- Updated `mem::context` to gate insight/semantic reads, add procedural/crystal blocks, render visible typed IDs, and preserve the existing whole-block token-budget selection. +- Updated `mem::smart-search` to return optional `semantic`, `procedural`, and `crystals` arrays beside existing results/lessons/insights when high-order context is enabled; disabled mode skips all four high-order reads including `mem::insight-search`. +- Updated `mem::query` normalization so smart-search high-order side arrays flatten with `_kind` values `semantic`, `procedural`, and `crystal`. +- Updated README, `.env.example`, and generated config skill reference docs. + +## Verification Evidence + +- Dependency setup prerequisite: `corepack pnpm exec vitest ...` was initially blocked by pnpm ignored-build hardening, so `corepack pnpm install --frozen-lockfile --ignore-scripts` was run. It did not leave package manifest, lockfile, or package-manager config changes. +- Red targeted command failed before implementation as expected: + `corepack pnpm exec vitest run --exclude test/integration.test.ts test/consolidation-default.test.ts test/context-insights-semantic.test.ts test/smart-search.test.ts test/query-integration.test.ts` + with missing `isHighOrderContextEnabled`, missing context IDs/blocks, and missing smart-search/query high-order arrays. +- Green targeted matrix: + `corepack pnpm exec vitest run --exclude test/integration.test.ts test/consolidation-default.test.ts test/context-insights-semantic.test.ts test/context-lessons.test.ts test/smart-search.test.ts test/query-integration.test.ts` + passed 5 files / 128 tests. +- `corepack pnpm run skills:gen` refreshed `plugin/skills/agentmemory-config/REFERENCE.md` after full-test drift detection. +- `corepack pnpm run skills:check` passed 15 skills. +- `corepack pnpm exec vitest run --exclude test/integration.test.ts test/plugin-surface-contract.test.ts` passed 11 tests. +- `corepack pnpm test` passed 207 files / 2843 tests. +- `corepack pnpm run lint` passed. +- `corepack pnpm run build` passed, with existing tsdown plugin-timing and ineffective dynamic-import warnings. +- `git diff --check` passed. +- `semgrep scan --config p/default --error --metrics=off .` passed with 0 findings across 926 tracked files. +- `git diff --cached --check` passed after staging the intended files. +- `gitleaks protect --staged --redact` passed with no leaks found. +- Independent read-only adversarial review reported `NO FINDINGS` on the staged diff. +- OSV was not required because dependency, lockfile, container, vendored, and package-manager surfaces are unchanged. +- Before push, `origin/main` advanced by two commits. Merged `origin/main` into this branch; the only conflict was the generated config reference variable count, resolved by rerunning `corepack pnpm run skills:gen`. +- Post-merge verification passed: + - Targeted issue/plugin matrix: 6 files / 139 tests. + - `corepack pnpm run skills:check`: 15 skills checked. + - `corepack pnpm test`: 207 files / 2849 tests. + - `corepack pnpm run lint`: passed. + - `corepack pnpm run build`: passed with existing tsdown timing and ineffective dynamic-import warnings. + - `semgrep scan --config p/default --error --metrics=off .`: 0 findings across 930 tracked files. + - `git diff --cached --check`: passed. + - `gitleaks protect --staged --redact`: no leaks found. + +## Review Notes + +- Focused simplification pass kept high-order searches as local typed helper functions; no shared abstraction was added because context formatting and smart-search scoring need different output contracts. +- Review evidence checked staged `src/config.ts`, `src/functions/context.ts`, `src/functions/smart-search.ts`, `src/functions/query.ts`, docs, env, generated config reference, tests, and related type definitions. +- No MCP tools, REST endpoints, schemas, auth behavior, dependencies, version fields, or persisted storage boundaries changed. +- Sprint Contract and Feature / Verification Matrix acceptance criteria are met. The only stale part of the original issue was that insight and semantic context blocks already existed before this branch; this branch gated and identified them while adding the missing procedural/crystal/search/docs behavior. + +## Arena Synthesis + +Base: Candidate B. + +Grafts: +- Candidate A: keep all high-order tier reads read-only over existing KV scopes; do not add indexes, embeddings, backfills, new route/tool inputs, facets, or cross-tier expansion. +- Candidate A: all context blocks must enter the existing `ContextBlock` selection loop so the current token budget drops whole blocks. +- Candidate C: render visible typed row IDs such as `[semantic:sem_id]`, `[procedural:proc_id]`, `[crystal:crys_id]`, and `[insight:ins_id]`. +- Candidate C: when `AGENTMEMORY_HIGH_ORDER_CONTEXT=false`, do not read the four high-order KV scopes and do not call `mem::insight-search`. +- Parent evidence: include `mem::query` normalization because current query flattening explicitly enumerates smart-search side arrays; without it, query callers would silently miss the new high-order search arrays. + +Rejected: +- Public upstream `rohitg00/agentmemory#295` BM25/vector-index issue scope; this branch follows the provided fork issue #295 contract. +- Standalone MCP fallback high-order storage/search changes; fallback persistence format is a separate boundary. +- Renaming existing `## Architectural Facts` or broad public API/tool count changes. diff --git a/plugin/skills/agentmemory-config/REFERENCE.md b/plugin/skills/agentmemory-config/REFERENCE.md index 7cf493d2..34208ed0 100644 --- a/plugin/skills/agentmemory-config/REFERENCE.md +++ b/plugin/skills/agentmemory-config/REFERENCE.md @@ -3,7 +3,7 @@ Generated by scanning `src/` for `AGENTMEMORY_*` usage. Do not edit the block below by hand; run `corepack pnpm run skills:gen` after adding or removing a variable. Internal markers ending in two underscores are excluded. -Configuration is read from the environment and from `~/.agentmemory/.env` (no `export` prefix). 74 recognized variables: +Configuration is read from the environment and from `~/.agentmemory/.env` (no `export` prefix). 75 recognized variables: - `AGENTMEMORY_AGENT_ID` - `AGENTMEMORY_AGENT_SCOPE` @@ -42,6 +42,7 @@ Configuration is read from the environment and from `~/.agentmemory/.env` (no `e - `AGENTMEMORY_HEALTH_MEM_RSS_FLOOR_MB` - `AGENTMEMORY_HEALTH_MEM_SYSTEM_FREE_FLOOR_RATIO` - `AGENTMEMORY_HEALTH_MEM_WARN_PCT` +- `AGENTMEMORY_HIGH_ORDER_CONTEXT` - `AGENTMEMORY_HOST` - `AGENTMEMORY_III_CONFIG` - `AGENTMEMORY_III_VERSION` diff --git a/src/config.ts b/src/config.ts index ecb42dfb..ba305de1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -576,6 +576,14 @@ export function isConsolidationEnabled(): boolean { return hasLLMProviderConfigured(env); } +export function isHighOrderContextEnabled(): boolean { + const env = getMergedEnv(); + const explicit = env["AGENTMEMORY_HIGH_ORDER_CONTEXT"]; + if (explicit === "false" || explicit === "0") return false; + if (explicit === "true" || explicit === "1") return true; + return isConsolidationEnabled(); +} + function hasLLMProviderConfigured(env: Record): boolean { const provider = (env["AGENTMEMORY_PROVIDER"] || "").toLowerCase(); if (provider === "noop") return false; diff --git a/src/functions/context.ts b/src/functions/context.ts index 15b79de5..0abe2dc2 100644 --- a/src/functions/context.ts +++ b/src/functions/context.ts @@ -9,6 +9,8 @@ import type { Lesson, Insight, SemanticMemory, + ProceduralMemory, + Crystal, } from "../types.js"; import { KV } from "../state/schema.js"; import { StateKV } from "../state/kv.js"; @@ -20,7 +22,11 @@ import { renderPinnedContext, } from "./slots.js"; import { sessionAttributionLabel } from "./session-attribution.js"; -import { getAgentId, isAgentScopeIsolated } from "../config.js"; +import { + getAgentId, + isAgentScopeIsolated, + isHighOrderContextEnabled, +} from "../config.js"; import { filterInsightsBySourceMemoryScope } from "./insight-visibility.js"; function estimateTokens(text: string): number { @@ -35,6 +41,75 @@ function escapeXmlAttr(s: string): string { .replace(/>/g, ">"); } +function previewLine(value: string, max = 280): string { + return value.length > max ? `${value.slice(0, max)}...` : value; +} + +function safeTime(value: string | undefined): number { + if (!value) return 0; + const time = new Date(value).getTime(); + return Number.isFinite(time) ? time : 0; +} + +function sourceSessionScope( + sourceSessionIds: string[] | undefined, + sessionById: Map, + project: string, + isolated: boolean, + agentId: string | undefined, +): { include: boolean; matchesProject: boolean } { + const sourceIds = sourceSessionIds ?? []; + const sourceSessions = sourceIds + .map((sid) => sessionById.get(sid)) + .filter((s): s is Session => !!s); + + if (isolated) { + const include = + !!agentId && + sourceSessions.length > 0 && + sourceSessions.every( + (s) => s.project === project && s.agentId === agentId, + ); + return { include, matchesProject: include }; + } + + if (sourceIds.length === 0) return { include: true, matchesProject: false }; + if (sourceSessions.length !== sourceIds.length) { + return { include: false, matchesProject: false }; + } + + const matchesProject = sourceSessions.every((s) => s.project === project); + return { include: matchesProject, matchesProject }; +} + +function crystalMatchesScope( + crystal: Crystal, + sessionById: Map, + project: string, + isolated: boolean, + agentId: string | undefined, +): boolean { + const sourceSession = crystal.sessionId + ? sessionById.get(crystal.sessionId) + : undefined; + + if (isolated) { + return ( + !!agentId && + !!sourceSession && + sourceSession.project === project && + sourceSession.agentId === agentId && + (!crystal.project || crystal.project === project) + ); + } + + if (crystal.project && crystal.project !== project) return false; + if (sourceSession && sourceSession.project !== project) return false; + if (crystal.project === project) return true; + if (sourceSession) return sourceSession.project === project; + return !crystal.project && !crystal.sessionId; +} + export function registerContextFunction( sdk: ISdk, kv: StateKV, @@ -45,8 +120,17 @@ export function registerContextFunction( const budget = data.budget || tokenBudget; const blocks: ContextBlock[] = []; const isolated = isAgentScopeIsolated(); + const highOrderEnabled = isHighOrderContextEnabled(); - const [pinnedSlots, profile, lessons, insights, semanticAll] = await Promise.all([ + const [ + pinnedSlots, + profile, + lessons, + insights, + semanticAll, + proceduralAll, + crystalsAll, + ] = await Promise.all([ isSlotsEnabled() ? listPinnedSlots(kv).catch(() => [] as MemorySlot[]) : Promise.resolve([] as MemorySlot[]), @@ -54,8 +138,22 @@ export function registerContextFunction( .get(KV.profiles, data.project) .catch(() => null), kv.list(KV.lessons).catch(() => [] as Lesson[]), - kv.list(KV.insights).catch(() => [] as Insight[]), - kv.list(KV.semantic).catch(() => [] as SemanticMemory[]), + highOrderEnabled + ? kv.list(KV.insights).catch(() => [] as Insight[]) + : Promise.resolve([] as Insight[]), + highOrderEnabled + ? kv + .list(KV.semantic) + .catch(() => [] as SemanticMemory[]) + : Promise.resolve([] as SemanticMemory[]), + highOrderEnabled + ? kv + .list(KV.procedural) + .catch(() => [] as ProceduralMemory[]) + : Promise.resolve([] as ProceduralMemory[]), + highOrderEnabled + ? kv.list(KV.crystals).catch(() => [] as Crystal[]) + : Promise.resolve([] as Crystal[]), ]); const slotContent = renderPinnedContext(pinnedSlots); @@ -170,7 +268,10 @@ export function registerContextFunction( if (relevantInsights.length > 0) { const items = relevantInsights - .map((i) => `- (${i.confidence.toFixed(2)}) ${i.title}: ${i.content}`) + .map( + (i) => + `- [insight:${i.id}] (${i.confidence.toFixed(2)}) ${i.title}: ${i.content}`, + ) .join("\n"); const insightsContent = `## Distilled Insights\n${items}`; const mostRecent = relevantInsights.reduce((acc, i) => { @@ -261,25 +362,13 @@ export function registerContextFunction( const sessionById = new Map(allSessions.map((s) => [s.id, s])); const relevantSemantic = semanticAll .map((fact) => { - const sourceIds = fact.sourceSessionIds ?? []; - const sourceSessions = sourceIds - .map((sid) => sessionById.get(sid)) - .filter((s): s is Session => !!s); - let include = sourceIds.length === 0 && !isolated; - let matchesProject = false; - if (sourceIds.length > 0 && sourceSessions.length !== sourceIds.length) { - include = false; - matchesProject = false; - } else if (sourceIds.length > 0) { - matchesProject = sourceSessions.every((s) => s.project === data.project); - include = matchesProject; - } - if (isolated) { - include = !!contextAgentId && sourceSessions.length > 0 && sourceSessions.every( - (s) => s.project === data.project && s.agentId === contextAgentId, - ); - matchesProject = include; - } + const { include, matchesProject } = sourceSessionScope( + fact.sourceSessionIds, + sessionById, + data.project, + isolated, + contextAgentId, + ); if (!include) return null; const boost = matchesProject ? 1.5 : 1; const score = fact.strength * fact.confidence * boost; @@ -291,7 +380,10 @@ export function registerContextFunction( if (relevantSemantic.length > 0) { const items = relevantSemantic - .map(({ fact }) => `- (${fact.confidence.toFixed(2)}) ${fact.fact}`) + .map( + ({ fact }) => + `- [semantic:${fact.id}] (${fact.confidence.toFixed(2)}) ${fact.fact}`, + ) .join("\n"); const semanticContent = `## Architectural Facts\n${items}`; const mostRecent = relevantSemantic.reduce((acc, { fact }) => { @@ -307,6 +399,87 @@ export function registerContextFunction( }); } + const relevantProcedural = proceduralAll + .map((proc) => { + const { include, matchesProject } = sourceSessionScope( + proc.sourceSessionIds, + sessionById, + data.project, + isolated, + contextAgentId, + ); + if (!include) return null; + const boost = matchesProject ? 1.5 : 1; + const score = + proc.strength * Math.log1p(Math.max(0, proc.frequency)) * boost; + return { proc, score }; + }) + .filter( + (x): x is { proc: ProceduralMemory; score: number } => x !== null, + ) + .sort((a, b) => b.score - a.score) + .slice(0, 10); + + if (relevantProcedural.length > 0) { + const items = relevantProcedural + .map(({ proc }) => { + const steps = proc.steps.slice(0, 5).join(" -> "); + const outcome = proc.expectedOutcome + ? `\n Outcome: ${previewLine(proc.expectedOutcome)}` + : ""; + return `- [procedural:${proc.id}] ${proc.name} (strength ${proc.strength.toFixed(2)}, frequency ${proc.frequency})\n Trigger: ${previewLine(proc.triggerCondition)}\n Steps: ${previewLine(steps)}${outcome}`; + }) + .join("\n"); + const proceduralContent = `## Procedural Memories\n${items}`; + const mostRecent = relevantProcedural.reduce((acc, { proc }) => { + const t = safeTime(proc.updatedAt); + return t > acc ? t : acc; + }, 0); + blocks.push({ + type: "memory", + content: proceduralContent, + tokens: estimateTokens(proceduralContent), + recency: mostRecent, + sourceIds: relevantProcedural.map(({ proc }) => proc.id), + }); + } + + const relevantCrystals = crystalsAll + .filter((crystal) => + crystalMatchesScope( + crystal, + sessionById, + data.project, + isolated, + contextAgentId, + ), + ) + .sort((a, b) => safeTime(b.createdAt) - safeTime(a.createdAt)) + .slice(0, 5); + + if (relevantCrystals.length > 0) { + const items = relevantCrystals + .map((crystal) => { + const outcomes = crystal.keyOutcomes.slice(0, 3).join("; "); + const files = crystal.filesAffected.slice(0, 5).join(", "); + const lessons = crystal.lessons.slice(0, 3).join("; "); + return `- [crystal:${crystal.id}] ${previewLine(crystal.narrative)}\n Outcomes: ${previewLine(outcomes)}\n Files: ${previewLine(files)}\n Lessons: ${previewLine(lessons)}`; + }) + .join("\n"); + const crystalContent = `## Crystals\n${items}`; + const mostRecent = relevantCrystals.reduce((acc, crystal) => { + const t = safeTime(crystal.createdAt); + return t > acc ? t : acc; + }, 0); + blocks.push({ + type: "memory", + content: crystalContent, + tokens: estimateTokens(crystalContent), + recency: mostRecent, + sourceIds: relevantCrystals.map((crystal) => crystal.id), + }); + } + blocks.sort((a, b) => b.recency - a.recency); let usedTokens = 0; diff --git a/src/functions/query.ts b/src/functions/query.ts index d79fe135..12bcff88 100644 --- a/src/functions/query.ts +++ b/src/functions/query.ts @@ -228,10 +228,20 @@ export function normalizeQueryRecords(payload: unknown, sourceOp: string): Query const keys = sourceOp === "lesson_recall" ? ["lessons"] - : sourceOp === "insight_list" + : sourceOp === "insight_list" ? ["insights"] : sourceOp === "smart_search" - ? ["results", "items", "memories", "lessons", "insights", "sessions"] + ? [ + "results", + "items", + "memories", + "lessons", + "insights", + "semantic", + "procedural", + "crystals", + "sessions", + ] : sourceOp === "vision_search" ? ["results", "matches"] : ["results", "items", "memories", "lessons", "sessions"]; @@ -472,6 +482,9 @@ function normalizeRecord(value: unknown, sourceOp: string): QueryRecord { record["obsId"] ?? record["insightId"] ?? record["lessonId"] ?? + record["semanticId"] ?? + record["proceduralId"] ?? + record["crystalId"] ?? record["memoryId"] ?? record["targetId"] ?? record["id"] ?? @@ -504,6 +517,9 @@ function queryRecordKind( if (sourceOp === "facet_query") return "facet_target"; if (sourceOp === "insight_list") return "insight"; if ("insightId" in record) return "insight"; + if ("semanticId" in record) return "semantic"; + if ("proceduralId" in record) return "procedural"; + if ("crystalId" in record) return "crystal"; if (sourceOp === "lesson_recall" || ("confidence" in record && "content" in record)) { return "lesson"; } diff --git a/src/functions/smart-search.ts b/src/functions/smart-search.ts index 4ecc1df7..091c3fe8 100644 --- a/src/functions/smart-search.ts +++ b/src/functions/smart-search.ts @@ -4,10 +4,13 @@ import type { CompactLessonResult, CompactSearchResult, CompressedObservation, + Crystal, HybridSearchResult, Insight, Lesson, Memory, + ProceduralMemory, + SemanticMemory, Session, } from "../types.js"; import { KV } from "../state/schema.js"; @@ -20,6 +23,7 @@ import { getAgentId, isAgentScopeIsolated, getFollowupWindowSeconds, + isHighOrderContextEnabled, } from "../config.js"; import { logger } from "../logger.js"; import { getCounters } from "../telemetry/setup.js"; @@ -114,6 +118,46 @@ const DEGENERATE_NAMED_CONCEPTS = new Set([ const INSIGHT_CONTENT_PREVIEW_CHARS = 240; type SmartSearchFormat = "full" | "compact" | "narrative"; +interface CompactSemanticResult { + semanticId: string; + fact: string; + confidence: number; + strength: number; + score: number; + updatedAt: string; + sourceSessionIds: string[]; + sourceMemoryIds: string[]; +} + +interface CompactProceduralResult { + proceduralId: string; + name: string; + triggerCondition: string; + steps: string[]; + expectedOutcome?: string; + frequency: number; + strength: number; + score: number; + updatedAt: string; + tags: string[]; + concepts: string[]; + sourceSessionIds: string[]; + sourceObservationIds: string[]; +} + +interface CompactCrystalResult { + crystalId: string; + narrative: string; + keyOutcomes: string[]; + filesAffected: string[]; + lessons: string[]; + sourceActionIds: string[]; + score: number; + createdAt: string; + project?: string; + sessionId?: string; +} + function parseSmartSearchFormat(value: unknown): SmartSearchFormat | null { const format = typeof value === "string" && value.trim().length > 0 @@ -171,6 +215,89 @@ function containsPhrase(value: string | undefined, phrase: string): boolean { return ` ${normalizePhrase(value ?? "")} `.includes(` ${phrase} `); } +function safeTime(value: string | undefined): number { + if (!value) return 0; + const time = new Date(value).getTime(); + return Number.isFinite(time) ? time : 0; +} + +function recencyBoost(value: string | undefined): number { + const time = safeTime(value); + if (time === 0) return 1; + const daysSince = Math.max(0, (Date.now() - time) / (1000 * 60 * 60 * 24)); + return 1 / (1 + daysSince * 0.01); +} + +function textMatchScore(query: string, parts: Array): number { + const normalizedQuery = normalizePhrase(query); + const haystack = normalizePhrase(parts.filter(Boolean).join(" ")); + if (!normalizedQuery || !haystack) return 0; + if (haystack.includes(normalizedQuery)) return 1; + const terms = normalizedQuery.split(" ").filter((term) => term.length > 2); + if (terms.length === 0) return 0; + const hits = terms.filter((term) => haystack.includes(term)).length; + return hits === 0 ? 0 : hits / terms.length; +} + +function sourceSessionScope( + sourceSessionIds: string[] | undefined, + sessionById: Map, + project: string | undefined, + agentId: string | undefined, +): { include: boolean; matchesProject: boolean } { + const sourceIds = sourceSessionIds ?? []; + const sourceSessions = sourceIds + .map((sid) => sessionById.get(sid)) + .filter((s): s is Session => !!s); + + if (agentId) { + const include = + sourceSessions.length > 0 && + sourceSessions.every( + (s) => + s.agentId === agentId && + (project === undefined || s.project === project), + ); + return { include, matchesProject: include && project !== undefined }; + } + + if (sourceIds.length === 0) return { include: true, matchesProject: false }; + if (sourceSessions.length !== sourceIds.length) { + return { include: project === undefined, matchesProject: false }; + } + if (project === undefined) return { include: true, matchesProject: false }; + + const matchesProject = sourceSessions.every((s) => s.project === project); + return { include: matchesProject, matchesProject }; +} + +function crystalMatchesScope( + crystal: Crystal, + sessionById: Map, + project: string | undefined, + agentId: string | undefined, +): boolean { + const sourceSession = crystal.sessionId + ? sessionById.get(crystal.sessionId) + : undefined; + + if (agentId) { + return ( + !!sourceSession && + sourceSession.agentId === agentId && + (project === undefined || sourceSession.project === project) && + (!crystal.project || project === undefined || crystal.project === project) + ); + } + + if (project === undefined) return true; + if (crystal.project && crystal.project !== project) return false; + if (sourceSession && sourceSession.project !== project) return false; + if (crystal.project === project) return true; + if (sourceSession) return sourceSession.project === project; + return !crystal.project && !crystal.sessionId; +} + function boostHybridResults( results: HybridSearchResult[], namedConcept: string | null, @@ -432,8 +559,11 @@ export function registerSmartSearchFunction( ? Math.min(limit * 3, 10) : lessonLimit; const insightLimit = Math.min(limit, 10); + const highOrderLimit = Math.min(limit, 10); + const highOrderEnabled = isHighOrderContextEnabled(); const includeLessons = data.includeLessons !== false; - const includeInsights = data.includeInsights !== false; + const includeInsights = + highOrderEnabled && data.includeInsights !== false; // Over-fetch when filtering. Hybrid search can't filter on // agentId, project, or timestamp inside every retrieval stream, so we ask @@ -445,7 +575,14 @@ export function registerSmartSearchFunction( ? Math.min(limit * 10, 500) : namedConceptSearchLimit; - const [hybridResults, lessons, insights] = await Promise.all([ + const [ + hybridResults, + lessons, + insights, + semantic, + procedural, + crystals, + ] = await Promise.all([ searchFn(data.query, overFetchLimit), includeLessons ? recallLessons( @@ -460,6 +597,33 @@ export function registerSmartSearchFunction( includeInsights ? recallInsights(sdk, kv, data.query, insightLimit, filterProject, filterAgentId) : Promise.resolve([]), + highOrderEnabled + ? searchSemanticMemories( + kv, + data.query, + highOrderLimit, + filterProject, + filterAgentId, + ) + : Promise.resolve([]), + highOrderEnabled + ? searchProceduralMemories( + kv, + data.query, + highOrderLimit, + filterProject, + filterAgentId, + ) + : Promise.resolve([]), + highOrderEnabled + ? searchCrystals( + kv, + data.query, + Math.min(limit, 5), + filterProject, + filterAgentId, + ) + : Promise.resolve([]), ]); const agentFilteredHybrid = filterAgentId @@ -553,6 +717,9 @@ export function registerSmartSearchFunction( results: compact.length, lessons: lessons.length, insights: insights.length, + semantic: semantic.length, + procedural: procedural.length, + crystals: crystals.length, }); const results = format === "full" @@ -583,9 +750,17 @@ export function registerSmartSearchFunction( results: typeof results; lessons?: CompactLessonResult[]; insights?: CompactInsightResult[]; + semantic?: CompactSemanticResult[]; + procedural?: CompactProceduralResult[]; + crystals?: CompactCrystalResult[]; } = { mode: format, results }; if (includeLessons) response.lessons = lessons; if (includeInsights) response.insights = insights; + if (highOrderEnabled) { + response.semantic = semantic; + response.procedural = procedural; + response.crystals = crystals; + } return response; }, ); @@ -676,6 +851,183 @@ async function recallInsights( } } +async function loadSessionMap(kv: StateKV): Promise> { + const sessions = await kv.list(KV.sessions).catch(() => []); + return new Map(sessions.map((session) => [session.id, session])); +} + +async function searchSemanticMemories( + kv: StateKV, + query: string, + limit: number, + project?: string, + agentId?: string, +): Promise { + try { + const [items, sessionById] = await Promise.all([ + kv.list(KV.semantic), + loadSessionMap(kv), + ]); + return items + .map((item) => { + const scope = sourceSessionScope( + item.sourceSessionIds, + sessionById, + project, + agentId, + ); + if (!scope.include) return null; + const textScore = textMatchScore(query, [item.fact]); + if (textScore === 0) return null; + const projectBoost = scope.matchesProject ? 1.5 : 1; + const score = + textScore * + item.confidence * + item.strength * + recencyBoost(item.updatedAt) * + projectBoost; + return { item, score }; + }) + .filter( + (hit): hit is { item: SemanticMemory; score: number } => hit !== null, + ) + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map(({ item, score }) => ({ + semanticId: item.id, + fact: item.fact, + confidence: item.confidence, + strength: item.strength, + score: Math.round(score * 1000) / 1000, + updatedAt: item.updatedAt, + sourceSessionIds: item.sourceSessionIds, + sourceMemoryIds: item.sourceMemoryIds, + })); + } catch (err) { + logger.warn("Smart search: semantic memory search failed; returning empty semantic list", { + error: err instanceof Error ? err.message : String(err), + }); + return []; + } +} + +async function searchProceduralMemories( + kv: StateKV, + query: string, + limit: number, + project?: string, + agentId?: string, +): Promise { + try { + const [items, sessionById] = await Promise.all([ + kv.list(KV.procedural), + loadSessionMap(kv), + ]); + return items + .map((item) => { + const scope = sourceSessionScope( + item.sourceSessionIds, + sessionById, + project, + agentId, + ); + if (!scope.include) return null; + const textScore = textMatchScore(query, [ + item.name, + item.triggerCondition, + item.expectedOutcome, + item.steps.join(" "), + (item.tags ?? []).join(" "), + (item.concepts ?? []).join(" "), + ]); + if (textScore === 0) return null; + const projectBoost = scope.matchesProject ? 1.5 : 1; + const score = + textScore * + item.strength * + Math.log1p(Math.max(0, item.frequency)) * + recencyBoost(item.updatedAt) * + projectBoost; + return { item, score }; + }) + .filter( + (hit): hit is { item: ProceduralMemory; score: number } => hit !== null, + ) + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map(({ item, score }) => ({ + proceduralId: item.id, + name: item.name, + triggerCondition: item.triggerCondition, + steps: item.steps, + expectedOutcome: item.expectedOutcome, + frequency: item.frequency, + strength: item.strength, + score: Math.round(score * 1000) / 1000, + updatedAt: item.updatedAt, + tags: item.tags ?? [], + concepts: item.concepts ?? [], + sourceSessionIds: item.sourceSessionIds, + sourceObservationIds: item.sourceObservationIds ?? [], + })); + } catch (err) { + logger.warn("Smart search: procedural memory search failed; returning empty procedural list", { + error: err instanceof Error ? err.message : String(err), + }); + return []; + } +} + +async function searchCrystals( + kv: StateKV, + query: string, + limit: number, + project?: string, + agentId?: string, +): Promise { + try { + const [items, sessionById] = await Promise.all([ + kv.list(KV.crystals), + loadSessionMap(kv), + ]); + return items + .map((item) => { + if (!crystalMatchesScope(item, sessionById, project, agentId)) { + return null; + } + const textScore = textMatchScore(query, [ + item.narrative, + item.keyOutcomes.join(" "), + item.filesAffected.join(" "), + item.lessons.join(" "), + ]); + if (textScore === 0) return null; + const score = textScore * recencyBoost(item.createdAt); + return { item, score }; + }) + .filter((hit): hit is { item: Crystal; score: number } => hit !== null) + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map(({ item, score }) => ({ + crystalId: item.id, + narrative: item.narrative, + keyOutcomes: item.keyOutcomes, + filesAffected: item.filesAffected, + lessons: item.lessons, + sourceActionIds: item.sourceActionIds, + score: Math.round(score * 1000) / 1000, + createdAt: item.createdAt, + project: item.project, + sessionId: item.sessionId, + })); + } catch (err) { + logger.warn("Smart search: crystal search failed; returning empty crystal list", { + error: err instanceof Error ? err.message : String(err), + }); + return []; + } +} + async function detectFollowup( kv: StateKV, sessionId: string, diff --git a/test/consolidation-default.test.ts b/test/consolidation-default.test.ts index 8de2bf8f..e26bb484 100644 --- a/test/consolidation-default.test.ts +++ b/test/consolidation-default.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; const ENV_KEYS = [ "CONSOLIDATION_ENABLED", + "AGENTMEMORY_HIGH_ORDER_CONTEXT", "AGENTMEMORY_PROVIDER", "ANTHROPIC_API_KEY", "OPENAI_API_KEY", @@ -171,4 +172,32 @@ describe("isConsolidationEnabled default behavior", () => { const cfg = await freshConfig(); expect(cfg.isConsolidationEnabled()).toBe(false); }); + + it("high-order context defaults to false when consolidation is disabled", async () => { + writeEnv(""); + const cfg = await freshConfig(); + expect(cfg.isHighOrderContextEnabled()).toBe(false); + }); + + it("high-order context defaults to true when CONSOLIDATION_ENABLED=true", async () => { + writeEnv("CONSOLIDATION_ENABLED=true"); + const cfg = await freshConfig(); + expect(cfg.isHighOrderContextEnabled()).toBe(true); + }); + + it("AGENTMEMORY_HIGH_ORDER_CONTEXT=false overrides enabled consolidation", async () => { + writeEnv( + "CONSOLIDATION_ENABLED=true\nAGENTMEMORY_HIGH_ORDER_CONTEXT=false", + ); + const cfg = await freshConfig(); + expect(cfg.isHighOrderContextEnabled()).toBe(false); + }); + + it("AGENTMEMORY_HIGH_ORDER_CONTEXT=true overrides disabled consolidation", async () => { + writeEnv( + "CONSOLIDATION_ENABLED=false\nAGENTMEMORY_HIGH_ORDER_CONTEXT=true", + ); + const cfg = await freshConfig(); + expect(cfg.isHighOrderContextEnabled()).toBe(true); + }); }); diff --git a/test/context-insights-semantic.test.ts b/test/context-insights-semantic.test.ts index 7f28d788..b611485e 100644 --- a/test/context-insights-semantic.test.ts +++ b/test/context-insights-semantic.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; const configState = { agentId: undefined as string | undefined, isolated: false, + highOrderContext: true, }; vi.mock("../src/config.js", async (importOriginal) => { @@ -11,12 +12,19 @@ vi.mock("../src/config.js", async (importOriginal) => { ...actual, getAgentId: () => configState.agentId, isAgentScopeIsolated: () => configState.isolated, + isHighOrderContextEnabled: () => configState.highOrderContext, }; }); import { registerContextFunction } from "../src/functions/context.js"; import { KV } from "../src/state/schema.js"; -import type { Insight, SemanticMemory, Session } from "../src/types.js"; +import type { + Crystal, + Insight, + ProceduralMemory, + SemanticMemory, + Session, +} from "../src/types.js"; function mockKV() { const store = new Map>(); @@ -96,6 +104,42 @@ function makeSemantic(over: Partial = {}): SemanticMemory { }; } +function makeProcedural( + over: Partial = {}, +): ProceduralMemory { + const now = new Date().toISOString(); + return { + id: over.id ?? `proc_${Math.random().toString(36).slice(2)}`, + name: over.name ?? "default procedure", + steps: over.steps ?? ["do the first step", "do the second step"], + triggerCondition: over.triggerCondition ?? "when a trigger appears", + expectedOutcome: over.expectedOutcome, + frequency: over.frequency ?? 1, + sourceSessionIds: over.sourceSessionIds ?? [], + sourceObservationIds: over.sourceObservationIds, + tags: over.tags, + concepts: over.concepts, + strength: over.strength ?? 0.8, + createdAt: over.createdAt ?? now, + updatedAt: over.updatedAt ?? now, + }; +} + +function makeCrystal(over: Partial = {}): Crystal { + const now = new Date().toISOString(); + return { + id: over.id ?? `crys_${Math.random().toString(36).slice(2)}`, + narrative: over.narrative ?? "default crystal narrative", + keyOutcomes: over.keyOutcomes ?? [], + filesAffected: over.filesAffected ?? [], + lessons: over.lessons ?? [], + sourceActionIds: over.sourceActionIds ?? [], + sessionId: over.sessionId, + project: over.project, + createdAt: over.createdAt ?? now, + }; +} + function makeSession(over: Partial = {}): Session { return { id: over.id ?? `ses_${Math.random().toString(36).slice(2)}`, @@ -129,6 +173,24 @@ async function seedSemantic( return fact; } +async function seedProcedural( + kv: ReturnType, + partial: Partial, +) { + const procedure = makeProcedural(partial); + await kv.set(KV.procedural, procedure.id, procedure); + return procedure; +} + +async function seedCrystal( + kv: ReturnType, + partial: Partial, +) { + const crystal = makeCrystal(partial); + await kv.set(KV.crystals, crystal.id, crystal); + return crystal; +} + async function seedSession( kv: ReturnType, partial: Partial, @@ -145,6 +207,7 @@ describe("mem::context - insights auto-injection (#566)", () => { beforeEach(() => { configState.agentId = undefined; configState.isolated = false; + configState.highOrderContext = true; kv = mockKV(); handler = wireContext(kv); }); @@ -152,6 +215,7 @@ describe("mem::context - insights auto-injection (#566)", () => { afterEach(() => { configState.agentId = undefined; configState.isolated = false; + configState.highOrderContext = true; }); it("includes a 'Distilled Insights' block when KV has insights for the project", async () => { @@ -419,6 +483,7 @@ describe("mem::context - semantic facts auto-injection (#566)", () => { beforeEach(() => { configState.agentId = undefined; configState.isolated = false; + configState.highOrderContext = true; kv = mockKV(); handler = wireContext(kv); }); @@ -426,6 +491,7 @@ describe("mem::context - semantic facts auto-injection (#566)", () => { afterEach(() => { configState.agentId = undefined; configState.isolated = false; + configState.highOrderContext = true; }); it("includes an 'Architectural Facts' block when a semantic fact source session is in the current project", async () => { @@ -629,3 +695,197 @@ describe("mem::context - semantic facts auto-injection (#566)", () => { expect(result.context).not.toContain("unresolved-semantic-marker"); }); }); + +describe("mem::context - high-order tier injection (#295)", () => { + let kv: ReturnType; + let handler: ContextHandler; + + beforeEach(() => { + configState.agentId = undefined; + configState.isolated = false; + configState.highOrderContext = true; + kv = mockKV(); + handler = wireContext(kv); + }); + + afterEach(() => { + configState.agentId = undefined; + configState.isolated = false; + configState.highOrderContext = true; + }); + + async function seedAllHighOrderMarkers() { + await seedSession(kv, { + id: "ses_context_src", + project: "/tmp/proj", + agentId: "agent-a", + }); + await seedSemantic(kv, { + id: "sem_context", + fact: "context semantic fact marker", + sourceSessionIds: ["ses_context_src"], + confidence: 0.95, + strength: 0.9, + }); + await seedProcedural(kv, { + id: "proc_context", + name: "context procedural marker", + triggerCondition: "when deploying context changes", + steps: ["run focused tests", "run lint"], + expectedOutcome: "verified high-order context", + sourceSessionIds: ["ses_context_src"], + frequency: 3, + strength: 0.8, + }); + await seedCrystal(kv, { + id: "crys_context", + narrative: "context crystal marker", + keyOutcomes: ["validated high-order context"], + filesAffected: ["src/functions/context.ts"], + lessons: ["keep block budget bounded"], + project: "/tmp/proj", + sessionId: "ses_context_src", + }); + await seedInsight(kv, { + id: "ins_context", + title: "context insight marker", + content: "visible insight content", + project: "/tmp/proj", + confidence: 0.92, + }); + } + + it("injects each high-order tier as a distinct identified block when enabled", async () => { + await seedAllHighOrderMarkers(); + + const result = await handler({ + sessionId: "ses_current", + project: "/tmp/proj", + budget: 10000, + }); + + expect(result.context).toContain("## Distilled Insights"); + expect(result.context).toContain("[insight:ins_context]"); + expect(result.context).toContain("context insight marker"); + + expect(result.context).toContain("## Architectural Facts"); + expect(result.context).toContain("[semantic:sem_context]"); + expect(result.context).toContain("context semantic fact marker"); + + expect(result.context).toContain("## Procedural Memories"); + expect(result.context).toContain("[procedural:proc_context]"); + expect(result.context).toContain("context procedural marker"); + + expect(result.context).toContain("## Crystals"); + expect(result.context).toContain("[crystal:crys_context]"); + expect(result.context).toContain("context crystal marker"); + }); + + it("omits all high-order tier blocks when high-order context is disabled", async () => { + await seedAllHighOrderMarkers(); + configState.highOrderContext = false; + + const result = await handler({ + sessionId: "ses_current", + project: "/tmp/proj", + budget: 10000, + }); + + expect(result.context).not.toContain("## Distilled Insights"); + expect(result.context).not.toContain("## Architectural Facts"); + expect(result.context).not.toContain("## Procedural Memories"); + expect(result.context).not.toContain("## Crystals"); + expect(result.context).not.toContain("context insight marker"); + expect(result.context).not.toContain("context semantic fact marker"); + expect(result.context).not.toContain("context procedural marker"); + expect(result.context).not.toContain("context crystal marker"); + }); + + it("lets the existing block budget skip high-order blocks that do not fit", async () => { + await seedAllHighOrderMarkers(); + + const result = await handler({ + sessionId: "ses_current", + project: "/tmp/proj", + budget: 8, + }); + + expect(result.context).toBe(""); + expect(result.blocks).toBe(0); + }); + + it("excludes procedural memories with other-project or unresolved source sessions", async () => { + await seedSession(kv, { + id: "ses_other_project", + project: "/tmp/other-project", + }); + await seedProcedural(kv, { + id: "proc_other", + name: "other project procedure marker", + triggerCondition: "when out of scope", + sourceSessionIds: ["ses_other_project"], + }); + await seedProcedural(kv, { + id: "proc_unresolved", + name: "unresolved procedure marker", + triggerCondition: "when missing source", + sourceSessionIds: ["ses_missing"], + }); + + const result = await handler({ + sessionId: "ses_current", + project: "/tmp/proj", + budget: 10000, + }); + + expect(result.context).not.toContain("other project procedure marker"); + expect(result.context).not.toContain("unresolved procedure marker"); + }); + + it("filters crystals by session ownership in isolated mode", async () => { + configState.isolated = true; + configState.agentId = "agent-a"; + await seedSession(kv, { + id: "ses_current", + project: "/tmp/proj", + agentId: "agent-a", + }); + await seedSession(kv, { + id: "ses_agent_a", + project: "/tmp/proj", + agentId: "agent-a", + }); + await seedSession(kv, { + id: "ses_agent_b", + project: "/tmp/proj", + agentId: "agent-b", + }); + await seedCrystal(kv, { + id: "crys_own", + narrative: "own agent crystal marker", + project: "/tmp/proj", + sessionId: "ses_agent_a", + }); + await seedCrystal(kv, { + id: "crys_other", + narrative: "other agent crystal marker", + project: "/tmp/proj", + sessionId: "ses_agent_b", + }); + await seedCrystal(kv, { + id: "crys_ambiguous", + narrative: "ambiguous crystal marker", + project: "/tmp/proj", + }); + + const result = await handler({ + sessionId: "ses_current", + project: "/tmp/proj", + budget: 10000, + }); + + expect(result.context).toContain("own agent crystal marker"); + expect(result.context).not.toContain("other agent crystal marker"); + expect(result.context).not.toContain("ambiguous crystal marker"); + }); +}); diff --git a/test/query-integration.test.ts b/test/query-integration.test.ts index 180b64ad..7f518f49 100644 --- a/test/query-integration.test.ts +++ b/test/query-integration.test.ts @@ -577,6 +577,92 @@ describe("mem::query", () => { ]); }); + it("merges smart_search high-order result arrays", async () => { + const h = createHarness({ + smartSearchResponse: { + mode: "compact", + results: [ + { + obsId: "obs_2", + sessionId: "s2", + title: "Layout", + score: 0.4, + timestamp: "2026-06-18T11:00:00Z", + }, + ], + lessons: [ + { + lessonId: "lsn_2", + content: "Smart lesson", + confidence: 0.7, + tags: ["ui"], + createdAt: "2026-06-18T11:30:00Z", + }, + ], + insights: [ + { + insightId: "ins_2", + title: "Smart insight", + content: "Insight content", + confidence: 0.8, + tags: ["ui"], + createdAt: "2026-06-18T11:45:00Z", + }, + ], + semantic: [ + { + semanticId: "sem_2", + fact: "Smart semantic fact", + confidence: 0.9, + score: 0.8, + updatedAt: "2026-06-18T11:50:00Z", + }, + ], + procedural: [ + { + proceduralId: "proc_2", + name: "Smart procedure", + triggerCondition: "when querying", + steps: ["search"], + score: 0.7, + updatedAt: "2026-06-18T11:55:00Z", + }, + ], + crystals: [ + { + crystalId: "crys_2", + narrative: "Smart crystal", + keyOutcomes: ["query surfaced high-order rows"], + score: 0.6, + createdAt: "2026-06-18T12:00:00Z", + }, + ], + }, + }); + + const result = await h.handler({ + pipeline: [{ op: "smart_search", query: "auth" }], + }); + + expect(result).toMatchObject({ success: true }); + expect((result as any).records.map((r: any) => r._id)).toEqual([ + "obs_2", + "lsn_2", + "ins_2", + "sem_2", + "proc_2", + "crys_2", + ]); + expect((result as any).records.map((r: any) => r._kind)).toEqual([ + "observation", + "lesson", + "insight", + "semantic", + "procedural", + "crystal", + ]); + }); + it("forwards includeInsights through smart_search producer payloads", async () => { const h = createHarness(); diff --git a/test/smart-search.test.ts b/test/smart-search.test.ts index 1d33dfa7..13195254 100644 --- a/test/smart-search.test.ts +++ b/test/smart-search.test.ts @@ -7,20 +7,24 @@ vi.mock("../src/logger.js", () => ({ const configState = { agentId: undefined as string | undefined, isolated: false, + highOrderContext: true, }; vi.mock("../src/config.js", () => ({ getAgentId: () => configState.agentId, isAgentScopeIsolated: () => configState.isolated, getFollowupWindowSeconds: () => 180, + isHighOrderContextEnabled: () => configState.highOrderContext, })); import { registerSmartSearchFunction } from "../src/functions/smart-search.js"; import { KV } from "../src/state/schema.js"; import type { CompressedObservation, + Crystal, HybridSearchResult, CompactSearchResult, + ProceduralMemory, SemanticMemory, Session, } from "../src/types.js"; @@ -82,6 +86,60 @@ function makeObs( }; } +function makeSemantic( + overrides: Partial = {}, +): SemanticMemory { + const now = "2026-06-18T00:00:00Z"; + return { + id: overrides.id ?? "sem_default", + fact: overrides.fact ?? "default semantic fact", + confidence: overrides.confidence ?? 0.9, + sourceSessionIds: overrides.sourceSessionIds ?? [], + sourceMemoryIds: overrides.sourceMemoryIds ?? [], + accessCount: overrides.accessCount ?? 0, + lastAccessedAt: overrides.lastAccessedAt ?? now, + strength: overrides.strength ?? 0.8, + createdAt: overrides.createdAt ?? now, + updatedAt: overrides.updatedAt ?? now, + }; +} + +function makeProcedural( + overrides: Partial = {}, +): ProceduralMemory { + const now = "2026-06-18T00:00:00Z"; + return { + id: overrides.id ?? "proc_default", + name: overrides.name ?? "default procedure", + steps: overrides.steps ?? ["do the first step", "do the second step"], + triggerCondition: overrides.triggerCondition ?? "when a trigger appears", + expectedOutcome: overrides.expectedOutcome, + frequency: overrides.frequency ?? 1, + sourceSessionIds: overrides.sourceSessionIds ?? [], + sourceObservationIds: overrides.sourceObservationIds, + tags: overrides.tags, + concepts: overrides.concepts, + strength: overrides.strength ?? 0.8, + createdAt: overrides.createdAt ?? now, + updatedAt: overrides.updatedAt ?? now, + }; +} + +function makeCrystal(overrides: Partial = {}): Crystal { + const now = "2026-06-18T00:00:00Z"; + return { + id: overrides.id ?? "crys_default", + narrative: overrides.narrative ?? "default crystal narrative", + keyOutcomes: overrides.keyOutcomes ?? [], + filesAffected: overrides.filesAffected ?? [], + lessons: overrides.lessons ?? [], + sourceActionIds: overrides.sourceActionIds ?? [], + sessionId: overrides.sessionId, + project: overrides.project, + createdAt: overrides.createdAt ?? now, + }; +} + describe("Smart Search Function", () => { let sdk: ReturnType; let kv: ReturnType; @@ -90,6 +148,7 @@ describe("Smart Search Function", () => { beforeEach(async () => { configState.agentId = undefined; configState.isolated = false; + configState.highOrderContext = true; sdk = mockSdk(); kv = mockKV(); @@ -1369,4 +1428,234 @@ describe("Smart Search Function", () => { expect(result.insights).toEqual([]); }); }); + + describe("high-order tier search (#295)", () => { + async function seedHighOrderSearchRows() { + searchResults = []; + await kv.set(KV.sessions, "ses_high_order", { + id: "ses_high_order", + project: "my-project", + cwd: "/tmp", + startedAt: "2026-06-18T00:00:00Z", + status: "completed", + observationCount: 0, + agentId: "agent-a", + } as Session); + await kv.set(KV.semantic, "sem_deploy", makeSemantic({ + id: "sem_deploy", + fact: "railway deploys require explicit health checks", + sourceSessionIds: ["ses_high_order"], + })); + await kv.set(KV.procedural, "proc_deploy", makeProcedural({ + id: "proc_deploy", + name: "railway deploy checklist", + steps: ["run tests", "check health endpoint"], + triggerCondition: "before railway deploy", + sourceSessionIds: ["ses_high_order"], + frequency: 3, + })); + await kv.set(KV.crystals, "crys_deploy", makeCrystal({ + id: "crys_deploy", + narrative: "railway deployment stabilized", + keyOutcomes: ["health check added"], + filesAffected: ["src/server.ts"], + lessons: ["verify health before rollout"], + project: "my-project", + sessionId: "ses_high_order", + })); + sdk.registerFunction("mem::insight-search", async () => ({ + success: true, + insights: [ + { + id: "ins_deploy", + title: "railway insight", + content: "deploy failures clustered around missing health checks", + confidence: 0.91, + createdAt: "2026-06-18T00:00:00Z", + tags: ["railway"], + sourceMemoryIds: [], + sourceLessonIds: [], + sourceCrystalIds: ["crys_deploy"], + score: 0.8, + }, + ], + })); + } + + it("searches semantic, procedural, crystal, and insight tiers when enabled", async () => { + await seedHighOrderSearchRows(); + + const result = (await sdk.trigger("mem::smart-search", { + query: "railway health", + project: "my-project", + })) as { + semantic?: Array<{ semanticId: string }>; + procedural?: Array<{ proceduralId: string }>; + crystals?: Array<{ crystalId: string }>; + insights?: Array<{ insightId: string }>; + }; + + expect(result.semantic?.map((r) => r.semanticId)).toEqual(["sem_deploy"]); + expect(result.procedural?.map((r) => r.proceduralId)).toEqual([ + "proc_deploy", + ]); + expect(result.crystals?.map((r) => r.crystalId)).toEqual(["crys_deploy"]); + expect(result.insights?.map((r) => r.insightId)).toEqual(["ins_deploy"]); + }); + + it("omits high-order tier search arrays when high-order context is disabled", async () => { + await seedHighOrderSearchRows(); + configState.highOrderContext = false; + const insightSearch = vi.fn(async () => ({ success: true, insights: [] })); + sdk.registerFunction("mem::insight-search", insightSearch); + + const result = (await sdk.trigger("mem::smart-search", { + query: "railway health", + project: "my-project", + })) as { + results: CompactSearchResult[]; + semantic?: unknown; + procedural?: unknown; + crystals?: unknown; + insights?: unknown; + }; + + expect(result.results).toEqual([]); + expect(result.semantic).toBeUndefined(); + expect(result.procedural).toBeUndefined(); + expect(result.crystals).toBeUndefined(); + expect(result.insights).toBeUndefined(); + expect(insightSearch).not.toHaveBeenCalled(); + }); + + it("keeps includeInsights:false limited to insights while other high-order tiers remain enabled", async () => { + await seedHighOrderSearchRows(); + const insightSearch = vi.fn(async () => { + throw new Error("mem::insight-search should not run"); + }); + sdk.registerFunction("mem::insight-search", insightSearch); + + const result = (await sdk.trigger("mem::smart-search", { + query: "railway health", + project: "my-project", + includeInsights: false, + })) as { + semantic?: Array<{ semanticId: string }>; + procedural?: Array<{ proceduralId: string }>; + crystals?: Array<{ crystalId: string }>; + insights?: unknown; + }; + + expect(result.semantic?.map((r) => r.semanticId)).toEqual(["sem_deploy"]); + expect(result.procedural?.map((r) => r.proceduralId)).toEqual([ + "proc_deploy", + ]); + expect(result.crystals?.map((r) => r.crystalId)).toEqual(["crys_deploy"]); + expect(result.insights).toBeUndefined(); + expect(insightSearch).not.toHaveBeenCalled(); + }); + + it("filters semantic and procedural search hits by source-session project", async () => { + searchResults = []; + await kv.set(KV.sessions, "ses_other_project", { + id: "ses_other_project", + project: "other-project", + cwd: "/tmp", + startedAt: "2026-06-18T00:00:00Z", + status: "completed", + observationCount: 0, + } as Session); + await kv.set(KV.semantic, "sem_other", makeSemantic({ + id: "sem_other", + fact: "railway health belongs elsewhere", + sourceSessionIds: ["ses_other_project"], + })); + await kv.set(KV.procedural, "proc_other", makeProcedural({ + id: "proc_other", + name: "railway deploy elsewhere", + triggerCondition: "before railway health checks elsewhere", + sourceSessionIds: ["ses_other_project"], + })); + + const result = (await sdk.trigger("mem::smart-search", { + query: "railway health", + project: "my-project", + })) as { semantic?: unknown[]; procedural?: unknown[] }; + + expect(result.semantic).toEqual([]); + expect(result.procedural).toEqual([]); + }); + + it("filters crystal search hits by session ownership in isolated mode", async () => { + configState.isolated = true; + configState.agentId = "agent-a"; + searchResults = []; + await kv.set(KV.sessions, "ses_agent_a", { + id: "ses_agent_a", + project: "my-project", + cwd: "/tmp", + startedAt: "2026-06-18T00:00:00Z", + status: "completed", + observationCount: 0, + agentId: "agent-a", + } as Session); + await kv.set(KV.sessions, "ses_agent_b", { + id: "ses_agent_b", + project: "my-project", + cwd: "/tmp", + startedAt: "2026-06-18T00:00:00Z", + status: "completed", + observationCount: 0, + agentId: "agent-b", + } as Session); + await kv.set(KV.crystals, "crys_own", makeCrystal({ + id: "crys_own", + narrative: "railway own crystal marker", + project: "my-project", + sessionId: "ses_agent_a", + })); + await kv.set(KV.crystals, "crys_other", makeCrystal({ + id: "crys_other", + narrative: "railway other crystal marker", + project: "my-project", + sessionId: "ses_agent_b", + })); + await kv.set(KV.crystals, "crys_ambiguous", makeCrystal({ + id: "crys_ambiguous", + narrative: "railway ambiguous crystal marker", + project: "my-project", + })); + + const result = (await sdk.trigger("mem::smart-search", { + query: "railway crystal", + project: "my-project", + agentId: "agent-a", + })) as { crystals?: Array<{ crystalId: string }> }; + + expect(result.crystals?.map((r) => r.crystalId)).toEqual(["crys_own"]); + }); + + it("tolerates a high-order tier list failure while preserving observation results", async () => { + const failingKv = { + ...kv, + list: async (scope: string): Promise => { + if (scope === KV.semantic) throw new Error("semantic offline"); + return kv.list(scope); + }, + }; + const failingSdk = mockSdk(); + registerSmartSearchFunction( + failingSdk as never, + failingKv as never, + async () => searchResults, + ); + + const result = (await failingSdk.trigger("mem::smart-search", { + query: "auth", + })) as { results: CompactSearchResult[]; semantic?: unknown[] }; + + expect(result.results.length).toBe(2); + expect(result.semantic).toEqual([]); + }); + }); });