@amemhq/core 1.0.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/cli-migrate.ts","../src/embedding.ts","../src/storage.ts","../src/llm.ts","../src/prompts.ts","../src/memory.ts","../src/config.ts","../src/migrate.ts"],"sourcesContent":["#!/usr/bin/env node\n/**\n * amem-migrate — move a memory store onto a different embedding model.\n *\n * Written for the person who got here from an error message, not for someone\n * embedding the engine. A developer using `@amemhq/core` directly has\n * `migrateCollection()` and `switchToMigrated()` and can sequence them however\n * their deployment wants; this is the other audience, who installed a plugin and\n * whose memory has stopped working.\n *\n * So it takes no decisions from the caller that it can take itself. It works out\n * which phase the store is in, does the next safe thing, and prints the one\n * command that comes after. The target collection name is derived; nobody has to\n * know Qdrant has collections at all.\n *\n * Exactly one step is irreversible — dropping the old collection to free its name\n * for the alias — and that one has its own flag rather than being the tail of a\n * run that started out read-only.\n */\nimport { migrateCollection, switchToMigrated } from './migrate.js'\nimport { getCollection, collectionDimRaw, countPointsRaw, scrollIdsRaw, resolveAliasRaw } from './storage.js'\nimport { getEmbeddingModel, getEmbeddingDim } from './embedding.js'\n\nconst USAGE = `amem-migrate — move a memory store onto a different embedding model\n\n amem-migrate what state the store is in, and what comes next\n amem-migrate --apply do the next step; safe to interrupt and re-run\n amem-migrate --switch put the new store behind the old name (irreversible)\n\nOptions\n --from-collection <name> the store to migrate. Defaults to AMEM_COLLECTION.\n --to-collection <name> where to build it. Derived from the source if omitted.\n --no-refresh-fields skip re-extracting keywords for notes that never had\n them. Makes the run completely offline.\n -h, --help\n\nMigrates onto amem's current default unless AMEM_EMBED_MODEL says otherwise:\n\n AMEM_EMBED_MODEL=Alibaba-NLP/gte-multilingual-base amem-migrate --apply\n\nNothing before --switch touches the original. If a run looks wrong, delete the\ntarget and start again.`\n\nexport interface MigrateArgs {\n help: boolean\n apply: boolean\n switchOver: boolean\n from?: string\n to?: string\n refreshFields: boolean\n}\n\nexport function parseArgs(argv: string[]): MigrateArgs {\n const value = (flag: string): string | undefined => {\n const i = argv.indexOf(flag)\n return i === -1 ? undefined : argv[i + 1]\n }\n return {\n help: argv.includes('-h') || argv.includes('--help'),\n apply: argv.includes('--apply'),\n switchOver: argv.includes('--switch'),\n from: value('--from-collection'),\n to: value('--to-collection'),\n refreshFields: !argv.includes('--no-refresh-fields'),\n }\n}\n\n/**\n * `amem_notes` → `amem_notes_v2`, and `amem_notes_v2` → `amem_notes_v3`.\n *\n * Derived rather than asked for: the name is an implementation detail of a\n * mechanism the user is not supposed to have to learn, and a wrong guess at it is\n * how you end up with two half-migrated stores.\n */\nexport function deriveTarget(source: string): string {\n const m = source.match(/^(.*)_v(\\d+)$/)\n return m ? `${m[1]}_v${Number(m[2]) + 1}` : `${source}_v2`\n}\n\n/**\n * The collection flags this run was given, so every \"now run …\" line it prints is\n * copy-pasteable as-is.\n *\n * Without it a mode B operator who passed `--from-collection` would be told to run\n * a bare `amem-migrate --apply`, which falls back to AMEM_COLLECTION and migrates\n * a different store. Only the collection flags are carried: forgetting\n * `--no-refresh-fields` costs an LLM call, forgetting these loses the plot.\n */\nexport function carried(args: MigrateArgs): string {\n return (args.from ? ` --from-collection ${args.from}` : '') + (args.to ? ` --to-collection ${args.to}` : '')\n}\n\ntype Phase =\n | { kind: 'no-source' }\n | { kind: 'already-current'; model: string }\n | { kind: 'not-started'; notes: number }\n | { kind: 'partial'; done: number; notes: number }\n | { kind: 'ready-to-switch'; notes: number }\n | { kind: 'switched'; points: number }\n\nasync function detect(from: string, to: string): Promise<Phase> {\n const alias = await resolveAliasRaw(from)\n if (alias !== null) return { kind: 'switched', points: await countPointsRaw(from) }\n\n const sourceDim = await collectionDimRaw(from)\n if (sourceDim === null) return { kind: 'no-source' }\n\n const modelDim = await getEmbeddingDim()\n if (sourceDim === modelDim) return { kind: 'already-current', model: getEmbeddingModel() }\n\n const notes = await countPointsRaw(from)\n const targetDim = await collectionDimRaw(to)\n if (targetDim === null) return { kind: 'not-started', notes }\n\n const done = (await scrollIdsRaw(to)).size\n return done >= notes ? { kind: 'ready-to-switch', notes } : { kind: 'partial', done, notes }\n}\n\nasync function main(): Promise<void> {\n const args = parseArgs(process.argv.slice(2))\n if (args.help) {\n console.log(USAGE)\n return\n }\n\n const from = args.from ?? getCollection()\n const to = args.to ?? deriveTarget(from)\n const flags = carried(args)\n const phase = await detect(from, to)\n\n console.log(`store: ${from}`)\n console.log(`model: ${getEmbeddingModel()}`)\n\n switch (phase.kind) {\n case 'no-source':\n console.error(`\\nNo collection named \"${from}\". Nothing to migrate.`)\n process.exitCode = 1\n return\n\n case 'switched':\n console.log(`\\n\"${from}\" is already an alias — ${phase.points} notes, nothing to do.`)\n return\n\n case 'already-current':\n console.log(`\\nAlready on ${phase.model}. Nothing to migrate.`)\n return\n\n case 'not-started':\n if (!args.apply) {\n console.log(`\\n${phase.notes} notes to rebuild into \"${to}\".`)\n console.log(`Run \"amem-migrate${flags} --apply\" to start. \"${from}\" is only read.`)\n return\n }\n break\n\n case 'partial':\n if (!args.apply) {\n console.log(`\\n${phase.done} of ${phase.notes} rebuilt into \"${to}\".`)\n console.log(`Run \"amem-migrate${flags} --apply\" to carry on from there.`)\n return\n }\n break\n\n case 'ready-to-switch':\n if (!args.switchOver) {\n console.log(`\\nAll ${phase.notes} notes are in \"${to}\". \"${from}\" is untouched.`)\n console.log(`Check it, then run \"amem-migrate${flags} --switch\" to put \"${to}\" behind the name \"${from}\".`)\n console.log(`That drops \"${from}\" and cannot be undone.`)\n return\n }\n await switchToMigrated({ name: from, to })\n console.log(`\\nDone. Nothing to change in your config — \"${from}\" now resolves to \"${to}\".`)\n return\n }\n\n if (args.switchOver) {\n console.error(`\\nNot finished yet — run \"amem-migrate${flags} --apply\" until it is before switching.`)\n process.exitCode = 1\n return\n }\n\n const result = await migrateCollection({ from, to, dryRun: false, refreshFields: args.refreshFields })\n console.log(`\\n${result.migrated + result.skipped} of ${result.total} rebuilt.`)\n if (result.migrated + result.skipped >= result.total) {\n console.log(`Check \"${to}\", then run \"amem-migrate${flags} --switch\".`)\n } else {\n console.log(`Run \"amem-migrate${flags} --apply\" again to carry on.`)\n }\n}\n\nmain().catch((err: unknown) => {\n console.error(`amem-migrate: ${err instanceof Error ? err.message : String(err)}`)\n process.exitCode = 1\n})\n","/**\n * embedding.ts — Local ONNX embedding via @huggingface/transformers\n * Matches Python: SentenceTransformer.encode(text, normalize_embeddings=True)\n *\n * The model is selectable, and changing it is a breaking change whenever the\n * dimension differs — Qdrant fixes a collection's vector size at creation. So\n * nothing here decides on its own: a collection that already exists keeps the\n * model it was built with (see `pinEmbeddingModel`), and moving is a deliberate\n * `amem-migrate` run. See docs/reference/embedding-models.md.\n */\n\n// Dynamic import to avoid issues with CJS bundling\nlet pipeline: any = null\nlet extractor: any = null\nlet loadedKey: string | null = null\nlet cachedDim: number | null = null\n\n/**\n * What a fresh install embeds with.\n *\n * Chosen for having no architectural question mark rather than for topping a\n * leaderboard: XLM-RoBERTa, natively supported by Transformers.js, its ONNX\n * maintained by that library's own author. 8192 tokens against the 128 of the\n * model this replaced, which is the whole reason for the change — anything longer\n * than a couple of sentences was being truncated before it reached the vector.\n */\nexport const DEFAULT_EMBEDDING_MODEL = 'Xenova/bge-m3'\n\n/**\n * The default before 2.0.0. Every store built by an earlier version holds its\n * vectors, and nothing recorded that at the time — `LEGACY_DEFAULT_DIM` is how a\n * collection from back then is recognised.\n */\nexport const LEGACY_DEFAULT_EMBEDDING_MODEL = 'Xenova/paraphrase-multilingual-MiniLM-L12-v2'\nexport const LEGACY_DEFAULT_DIM = 384\n\n/**\n * Weight precision for the model amem itself ships.\n *\n * Deliberately not a global default. Transformers.js falls back to fp32, which\n * for bge-m3 is a 2.16 GB download against 1.08 GB at fp16 — but several models\n * this project documents publish *only* fp32 (`multilingual-e5-large-instruct`,\n * `Qwen3-Embedding-4B`), so forcing fp16 on whatever the user picked would fail\n * to load on our say-so. It applies to our choice, and to nothing else.\n */\nconst DEFAULT_MODEL_DTYPE = 'fp16'\n\n/**\n * Set when a collection says which model built it, so the engine keeps using that\n * one instead of whatever the current default happens to be.\n *\n * Without this, changing `DEFAULT_EMBEDDING_MODEL` would break every existing\n * install on upgrade. With it, the default is only ever what a *new* store gets.\n */\nlet pinnedModel: string | null = null\n\n/** Pin the model to what a collection was built with. `null` clears it. */\nexport function pinEmbeddingModel(model: string | null): void {\n if (pinnedModel === model) return\n pinnedModel = model\n extractor = null\n loadedKey = null\n cachedDim = null\n}\n\n/** What the pin currently holds, or null. The caller that set it checks for conflicts. */\nexport function getPinnedEmbeddingModel(): string | null {\n return pinnedModel\n}\n\n/**\n * Which model this process embeds with: the env var, else whatever the open\n * collection was built with, else the shipped default.\n *\n * An explicit `AMEM_EMBED_MODEL` outranks the pin on purpose — someone who set it\n * is migrating deliberately, and silently overriding them with the collection's\n * old model would make the setting look broken.\n */\nexport function getEmbeddingModel(): string {\n return process.env.AMEM_EMBED_MODEL?.trim() || pinnedModel || DEFAULT_EMBEDDING_MODEL\n}\n\n/** How token embeddings are collapsed into one sentence vector. */\nexport type PoolingMode = 'mean' | 'cls'\n\n/**\n * Models trained with CLS pooling, by repo basename.\n *\n * Unlike the vector dimension — which is measured, because a table is silently\n * wrong for anything not in it — pooling cannot be probed: both modes return a\n * correctly-shaped, plausible vector, and only one of them is the model the\n * benchmark measured. So this is a table, and it is wrong-by-omission on purpose:\n * an unlisted model falls back to `mean`, which is what every version before this\n * one did unconditionally.\n *\n * Keyed on the basename so the ONNX mirror and the original resolve alike —\n * `Xenova/bge-m3` and `BAAI/bge-m3` are the same model. Each entry was read from\n * that model's own `1_Pooling/config.json`.\n */\nconst CLS_POOLED_MODELS = new Set([\n 'bge-m3',\n 'bge-base-zh-v1.5',\n 'bge-small-zh-v1.5',\n 'bge-base-en-v1.5',\n 'bge-small-en-v1.5',\n 'bge-large-en-v1.5',\n 'gte-multilingual-base',\n 'gte-modernbert-base',\n 'gte-large-en-v1.5',\n 'snowflake-arctic-embed-m',\n 'snowflake-arctic-embed-l',\n])\n\n/**\n * Which pooling this process uses: `AMEM_EMBED_POOLING`, else the model's known\n * mode, else `mean`.\n *\n * Getting this wrong does not fail. `encode()` returns a normalized vector of the\n * right width either way, and search keeps working because notes and queries are\n * embedded by the same function — it just retrieves worse than the model can,\n * with nothing to indicate it. That is why the mode is resolved rather than\n * assumed.\n */\nexport function getEmbeddingPooling(): PoolingMode {\n const explicit = process.env.AMEM_EMBED_POOLING?.trim().toLowerCase()\n if (explicit === 'mean' || explicit === 'cls') return explicit\n const basename = getEmbeddingModel().split('/').pop()?.toLowerCase() ?? ''\n return CLS_POOLED_MODELS.has(basename) ? 'cls' : 'mean'\n}\n\n/**\n * Where inference runs. Unset means Transformers.js picks, which on Node is `cpu`.\n *\n * It is not CPU-only for lack of anything else: `onnxruntime-node`'s macOS arm64\n * binary links CoreML.framework and exports the CoreML provider, and\n * Transformers.js lists `coreml` (macOS), `dml` (Windows), `cuda` (Linux x64) and\n * `webgpu` alongside `cpu`. It simply defaults to `cpu` and amem never asked for\n * anything else.\n *\n * Whether asking helps is **unmeasured**. CoreML partitions a graph operator by\n * operator and falls back to CPU for the ones it cannot take, so it can lose to\n * plain CPU on some models and pay a compile cost on first load. Hence: opt-in,\n * default unchanged, and no recommendation until someone benchmarks it.\n */\nexport function getEmbeddingDevice(): string | undefined {\n return process.env.AMEM_EMBED_DEVICE?.trim() || undefined\n}\n\n/**\n * Weight precision. Unset means Transformers.js picks, which on Node is `fp32` —\n * the largest download of every variant a model publishes.\n *\n * Passed through rather than validated against a list: Transformers.js already\n * rejects an unknown value and names the valid ones, and a list here would go\n * stale the moment it gains a quantization.\n */\nexport function getEmbeddingDtype(): string | undefined {\n const explicit = process.env.AMEM_EMBED_DTYPE?.trim()\n if (explicit) return explicit\n // Only for the model we chose. See DEFAULT_MODEL_DTYPE.\n return getEmbeddingModel() === DEFAULT_EMBEDDING_MODEL ? DEFAULT_MODEL_DTYPE : undefined\n}\n\n/** Everything that decides which weights are resident. */\nfunction extractorKey(): string {\n return `${getEmbeddingModel()}|${getEmbeddingDevice() ?? ''}|${getEmbeddingDtype() ?? ''}`\n}\n\nasync function getExtractor() {\n const wanted = extractorKey()\n // Re-resolving on every call keeps the env vars honest in tests and lets a\n // long-lived process pick up a change without a restart; the cached vector\n // dimension belongs to the OLD model, so drop it with the extractor.\n if (extractor && loadedKey === wanted) return extractor\n if (!pipeline) {\n const mod = await import('@huggingface/transformers')\n pipeline = mod.pipeline\n }\n const device = getEmbeddingDevice()\n const dtype = getEmbeddingDtype()\n extractor = await pipeline('feature-extraction', getEmbeddingModel(), {\n revision: 'main',\n // Omitted entirely when unset, so an unconfigured install gets exactly the\n // library defaults it got before these existed.\n ...(device ? { device } : {}),\n ...(dtype ? { dtype } : {}),\n })\n loadedKey = wanted\n cachedDim = null\n return extractor\n}\n\n/**\n * The vector width this model produces, measured rather than looked up.\n *\n * A hardcoded table would be wrong the moment someone points AMEM_EMBED_MODEL at\n * something not in it, and wrong silently — the collection would be created with\n * the wrong size and every insert would fail. Encoding one short string costs one\n * forward pass on a model that has to load anyway, and is right for any model.\n */\nexport async function getEmbeddingDim(): Promise<number> {\n if (cachedDim !== null && loadedKey === extractorKey()) return cachedDim\n const probe = await encode('dimension probe')\n cachedDim = probe.length\n return cachedDim\n}\n\n/**\n * Pool token embeddings into one vector, then L2 normalize.\n * Matches sentence-transformers encode(normalize_embeddings=True).\n *\n * `cls` takes the first token, which is what BGE- and GTE-family models were\n * trained to read; `mean` averages over the attention mask.\n */\nfunction poolNormalize(output: number[][], attentionMask: number[], mode: PoolingMode): number[] {\n const seqLen = output.length\n const dim = output[0].length\n const pooled = new Array(dim).fill(0)\n\n if (mode === 'cls') {\n for (let j = 0; j < dim; j++) pooled[j] = output[0][j]\n } else {\n let maskSum = 0\n for (let i = 0; i < seqLen; i++) {\n const m = attentionMask[i]\n maskSum += m\n for (let j = 0; j < dim; j++) {\n pooled[j] += output[i][j] * m\n }\n }\n for (let j = 0; j < dim; j++) {\n pooled[j] /= Math.max(maskSum, 1e-9)\n }\n }\n\n // L2 normalize\n let norm = 0\n for (const v of pooled) norm += v * v\n norm = Math.sqrt(norm)\n return pooled.map((v) => v / Math.max(norm, 1e-9))\n}\n\n/**\n * Encode text to a normalized embedding vector. The width is the model's — 1024\n * for the default, 384 for the one before it — so nothing here should assume a\n * number.\n * Singleton model, loaded once and reused.\n */\nexport async function encode(text: string): Promise<number[]> {\n const ext = await getExtractor()\n const pooling = getEmbeddingPooling()\n const result = await ext(text, { pooling, normalize: true })\n\n // result.data is a Float32Array of shape [dim]\n // @huggingface/transformers v3 returns already pooled+normalized when pooling+normalize options given\n if (result && result.data) {\n return Array.from(result.data as Float32Array)\n }\n\n // Fallback: manual mean pool if result is nested\n const tensor = result as any\n if (tensor.dims && tensor.dims.length === 3) {\n // shape: [1, seq_len, dim]\n const seqLen = tensor.dims[1]\n const dim = tensor.dims[2]\n const raw: number[][] = []\n for (let i = 0; i < seqLen; i++) {\n const row: number[] = []\n for (let j = 0; j < dim; j++) {\n row.push(tensor.data[i * dim + j])\n }\n raw.push(row)\n }\n return poolNormalize(raw, new Array(seqLen).fill(1), pooling)\n }\n\n throw new Error('Unexpected embedding output shape')\n}\n\n/**\n * Load the model now rather than on the first encode(). A long-lived service\n * pays the download at startup, not on a user's first write.\n */\nexport async function loadModel(): Promise<void> {\n await getExtractor()\n}\n\n/**\n * Whether the model is resident. Synchronous and I/O-free — unlike encode() it\n * can never trigger the several-hundred-megabyte download, so a health check is\n * free to poll it.\n */\nexport function isModelLoaded(): boolean {\n return extractor !== null\n}\n\n/**\n * Cosine similarity between two normalized vectors (already L2-normalized → just dot product)\n */\nexport function cosineSimilarity(a: number[], b: number[]): number {\n let dot = 0\n for (let i = 0; i < a.length; i++) dot += a[i] * b[i]\n return dot\n}\n","/**\n * storage.ts — Qdrant vector storage for A-MEM\n * Uses native fetch (Node 18+) to avoid undici compatibility issues with Node v26\n * Collection: amem_notes, cosine, width set by the embedding model, with\n * agent_id isolation\n */\n\nimport { canWrite, canRead, SYSTEM_ACTOR } from './auth.js'\nimport {\n DEFAULT_EMBEDDING_MODEL,\n LEGACY_DEFAULT_DIM,\n LEGACY_DEFAULT_EMBEDDING_MODEL,\n getEmbeddingDim,\n getEmbeddingModel,\n getPinnedEmbeddingModel,\n pinEmbeddingModel,\n} from './embedding.js'\n\n// ── Types ─────────────────────────────────────────────────────────────────────\n\n// ── Story 32: Per-agent config types ──────────────────────────────────────────\n\n/** Per-agent override config. If collection is set, mode B (isolated collection) is used. */\nexport interface AgentAmemConfig {\n agentId?: string\n collection?: string\n}\n\n/** Top-level plugin config shape (superset — existing fields preserved). */\nexport interface AmemPluginConfig {\n agentId?: string\n collection?: string\n topK?: number\n /** Per-agent overrides keyed by agentId */\n agents?: Record<string, AgentAmemConfig>\n // ── Story 35: LLM settings, so a host can pick the model without env vars ────\n // Env vars still win over all three. There is deliberately no key field — see\n // the precedence note in llm.ts.\n llmProvider?: string\n llmModel?: string\n llmBaseURL?: string\n // ── Story 42: the optional `strong` tier ────────────────────────────────────\n // Each falls back to its `fast` counterpart individually, so setting only\n // `llmStrongModel` keeps the same provider/endpoint. Unset entirely = strong\n // is fast, i.e. today's single-model behaviour.\n llmStrongProvider?: string\n llmStrongModel?: string\n llmStrongBaseURL?: string\n /** Which tier the agent_end CRUD decision runs on: `fast` (default) or `strong`. */\n llmCrudRole?: 'fast' | 'strong'\n /** Story 43: run the nightly contradiction sweep. Default true. */\n conflictSweep?: boolean\n // ── Story 41: CRUD write safety ─────────────────────────────────────────────\n /** Similarity floor for accepting an LLM-chosen UPDATE target. Raise it for\n * cheaper models — a rejected update is stored as a new memory, never lost. */\n crudUpdateMinSim?: number\n}\n\n/** One entry in a note's evolution history (Story 13-B) */\nexport interface EvolutionEntry {\n triggeredBy: string // ID of the new note that caused this evolution\n triggeredAt: string // ISO timestamp\n oldContext: string\n newContext: string\n oldTags: string[]\n newTags: string[]\n action?: 'update_neighbor' | 'strengthen' | 'consolidate' | 'crud_update'\n /** Story 41: the content this entry replaced, so an overwrite stays recoverable. */\n oldContent?: string\n suggestedConnections?: string[]\n tagsUpdated?: string[]\n}\n\nexport interface MemoryNote {\n id: string\n content: string\n keywords: string[]\n tags: string[]\n context: string\n links: string[] // linked note IDs\n embedding: number[]\n timestamp: string\n agent_id: string // \"main\" | \"subagent-xxx\" | \"shared\"\n hash: string // md5(content), for exact-match dedup\n // ── Story 13-A: retrieval heat tracking ──────────────────────────────────\n retrieval_count: number // times this note has been returned by queryByEmbedding\n last_accessed: string // ISO timestamp of most recent retrieval\n // ── Story 13-B: evolution history ────────────────────────────────────────\n evolution_history: EvolutionEntry[] // log of tag/context changes\n // ── Story 13-E: coarse category ──────────────────────────────────────────\n category: string // e.g. \"Technical\" | \"Business\" | … | \"General\"\n is_active: boolean\n // ── Story 26A: knowledge type classification ──────────────────────────────\n note_type: 'memory' | 'knowledge' // memory: episodic; knowledge: durable reference\n // ── Story 26B: topic tags for knowledge notes ─────────────────────────────────────────\n topics: string[] // subject tags, e.g. [\"TypeScript\", \"Qdrant\"]; empty for memory notes\n // ── Story 29: dedup pending merge flag ──────────────────────────────────────\n pending_merge: boolean // true when similarity 0.72-0.85 — candidate for future merge\n // ── Story 30: evolution mechanism ──────────────────────────────────────────\n evolution_type?: 'EVOLVE' | 'CONFLICT' | 'EXPAND' | 'NEW'\n conflict: boolean\n // ── Story 43: which note it conflicts with, and why ─────────────────────────\n // `conflict` alone is a bare boolean — it cannot say WHO the note contradicts,\n // so a reviewer has to reconstruct the pair by hand. These make a conflict\n // renderable as ONE decision instead of two disconnected entries.\n conflicts_with?: string[]\n conflict_reason?: string\n /**\n * Story 43: when this note was last included in a contradiction scan.\n * Absent = never scanned. Lets the sweep skip batches it has already judged,\n * which is what makes a daily run cost one or two calls instead of re-reading\n * the whole store every night.\n */\n conflict_scanned_at?: string\n // ── Story 44: who this memory is ABOUT ──────────────────────────────────────\n // Distinct from `agent_id`/`owner`, which say whose STORE it lives in. A\n // companion meeting several players needs both: one memory store, many people\n // it holds memories about.\n //\n // [] a fact about the world, or about the agent itself — always visible\n // [a] about one person — surfaced only when that person is present\n // [a, b] a shared experience — surfaced for either of them\n //\n // An array rather than a single value because shared experience is the normal\n // case for a companion (\"we fought the dragon together\"), not an edge case.\n // Defaulting to [] keeps every pre-existing memory a world fact, so behaviour\n // is unchanged until subjects are actually used.\n subjects: string[]\n // ── Story 31: quality scoring ──────────────────────────────────────────────\n ephemeral: boolean // true when content contains temporal signal words\n low_quality: boolean // true when content is too short or otherwise low-quality\n // ── Story 32: per-agent ownership and access control ─────────────────────\n owner: string // agent_id of the writer\n readers: string[] // [\"*\"] = all agents; [agentId] = owner-only\n writers: string[] // default [owner]; enforcement TODO in Story 33\n}\n\nexport interface QueryResult {\n note: MemoryNote\n score: number\n}\n\n// ── Config ────────────────────────────────────────────────────────────────────\nconst QDRANT_URL = 'http://localhost:6333'\n/** The collection this process reads and writes unless told otherwise. */\nexport const getCollection = () => process.env.AMEM_COLLECTION || 'amem_notes'\n/**\n * Raised when the configured model's vector width does not match the collection\n * that already exists. Its own class so the plugin can log it loudly instead of\n * as one more startup warning — this one needs the operator to act.\n */\nexport class EmbeddingDimensionMismatchError extends Error {\n constructor(\n readonly collection: string,\n readonly collectionDim: number,\n readonly modelDim: number,\n readonly model: string\n ) {\n super(\n `Collection \"${collection}\" stores ${collectionDim}-dimension vectors, but ` +\n `the embedding model \"${model}\" produces ${modelDim}. Qdrant fixes a ` +\n `collection's vector size at creation and cannot change it, so writes and ` +\n `searches would both fail.\\n` +\n `Either set AMEM_EMBED_MODEL back to the model this collection was built ` +\n `with, or ${migrationHint(collection, model)}`\n )\n this.name = 'EmbeddingDimensionMismatchError'\n }\n}\n\n/**\n * The tail both mismatch errors share: the one command that fixes it.\n *\n * `--from-collection` is always passed, even though it defaults to\n * AMEM_COLLECTION. A mode B collection is named by the plugin's `collection`\n * setting (or an `agents.<id>.collection` override) and handed straight to\n * `createStorageContext`, which never consults the env var — so the default would\n * migrate the wrong store and leave the operator looking at the same error.\n */\nfunction migrationHint(collection: string, targetModel: string): string {\n return (\n `migrate onto it:\\n\\n` +\n ` AMEM_EMBED_MODEL=${targetModel} \\\\\\n` +\n ` npx --package=@amemhq/core amem-migrate --from-collection ${collection}\\n\\n` +\n `That only reports; it takes --apply to write anything, and \"${collection}\" is ` +\n `read either way. The new store ends up behind the name you already use, so ` +\n `there is nothing to change in your config afterwards. ` +\n `See https://amem.owo.lc/reference/embedding-models.`\n )\n}\n\n/**\n * Which embedding model built a collection, and what the process wants to use.\n *\n * Vector width is the only thing Qdrant can check for us, and two models of the\n * same width are indistinguishable to it. This is the case that check misses.\n */\nexport class EmbeddingModelMismatchError extends Error {\n constructor(\n readonly collection: string,\n readonly collectionModel: string,\n readonly configuredModel: string\n ) {\n super(\n `Collection \"${collection}\" was built with the embedding model ` +\n `\"${collectionModel}\", but this process is configured for ` +\n `\"${configuredModel}\". Both produce vectors of the same width, so nothing ` +\n `would fail — searches would just quietly compare vectors from two ` +\n `different models.\\n` +\n `Either set AMEM_EMBED_MODEL back to \"${collectionModel}\", or ` +\n migrationHint(collection, configuredModel)\n )\n this.name = 'EmbeddingModelMismatchError'\n }\n}\n\n/**\n * Two collections open in one process that need two different models.\n *\n * Only reachable in mode B, and normally only mid-migration: per-agent\n * collections built before 2.0.0 all resolve to the same old model, until one of\n * them is migrated and the others are not. One process embeds with one model, so\n * this has to stop rather than pick a winner — picking would write vectors of the\n * wrong width into whichever collection lost.\n */\nexport class MixedEmbeddingModelsError extends Error {\n constructor(\n readonly collection: string,\n readonly wanted: string,\n readonly inUse: string\n ) {\n super(\n `Collection \"${collection}\" was built with \"${wanted}\", but this process is ` +\n `already embedding with \"${inUse}\" for another collection. One process can ` +\n `only use one model.\\n` +\n `Migrate the remaining collections so they all agree:\\n\\n` +\n ` npx --package=@amemhq/core amem-migrate --from-collection ${collection}\\n\\n` +\n `Or set AMEM_EMBED_MODEL to pin every collection to one model, which is only ` +\n `correct if they really were all built with it.`\n )\n this.name = 'MixedEmbeddingModelsError'\n }\n}\n\n/** What `GET /collections/{name}` gives us that we act on. */\ntype CollectionInfo = {\n config?: {\n params?: { vectors?: { size?: number } }\n /** Qdrant >= 1.16. Absent on older servers and on collections predating it. */\n metadata?: { embedding_model?: string }\n }\n}\n\n/**\n * Record which model built a collection, best-effort.\n *\n * Deliberately not part of the create call: an older Qdrant rejects a request\n * body it does not recognise, and failing to note the model must never be the\n * reason a collection cannot be created. Collection metadata landed in Qdrant\n * 1.16 (PR #7123); against anything older this is a no-op and the engine behaves\n * exactly as it did before.\n */\nasync function recordCollectionModel(collection: string, model: string): Promise<void> {\n try {\n await qdrant('PATCH', `/collections/${collection}`, { metadata: { embedding_model: model } })\n } catch {\n // Older Qdrant, or a permissions setup that forbids PATCH. Falling back to\n // the dimension check alone is exactly the previous behaviour.\n }\n}\n\n// ── HTTP helpers ──────────────────────────────────────────────────────────────\nasync function qdrant(method: string, path: string, body?: unknown): Promise<unknown> {\n const res = await fetch(`${QDRANT_URL}${path}`, {\n method,\n headers: { 'Content-Type': 'application/json' },\n body: body ? JSON.stringify(body) : undefined,\n })\n const data = (await res.json()) as { status: string; result?: unknown; error?: string }\n if (!res.ok || (data.status && data.status !== 'ok' && data.status !== 'acknowledged')) {\n throw new Error(`Qdrant ${method} ${path} failed: ${data.error || JSON.stringify(data)}`)\n }\n return data.result\n}\n\n/**\n * Ask Qdrant whether it can serve, right now.\n *\n * `ensureCollection()` cannot answer this: it latches `_collectionReady` and\n * short-circuits on every later call, so once it has succeeded it keeps\n * reporting success long after Qdrant has gone away. `/readyz` answers in plain\n * text, so it deliberately bypasses the JSON-parsing `qdrant()` helper above.\n */\nexport async function pingQdrant(): Promise<void> {\n const res = await fetch(`${QDRANT_URL}/readyz`)\n if (!res.ok) throw new Error(`Qdrant GET /readyz failed: ${res.status}`)\n}\n\n// ── Collection init ───────────────────────────────────────────────────────────\nlet _collectionReady = false\n/** Track ready state per named collection (for mode B isolated collections). */\nconst _collectionReadyMap = new Map<string, boolean>()\n\n/** Reset the collection-ready flag. Used in tests after dropping the collection. */\nexport function resetCollectionReady(): void {\n _collectionReady = false\n _collectionReadyMap.clear()\n // The pin is a commitment to the collections this process has opened. Once\n // those are gone, so is it — otherwise one test's store decides the next one's\n // model.\n pinEmbeddingModel(null)\n}\n\n/**\n * Ensure the given Qdrant collection exists with the correct schema.\n * If collectionName is omitted, uses process.env.AMEM_COLLECTION (default: amem_notes).\n * Mode B agents pass their dedicated collection name here.\n */\nexport async function ensureCollection(collectionName?: string): Promise<void> {\n const col = collectionName || getCollection()\n if (collectionName) {\n if (_collectionReadyMap.get(col)) return\n } else {\n if (_collectionReady) return\n }\n const markReady = () => {\n if (collectionName) _collectionReadyMap.set(col, true)\n else _collectionReady = true\n }\n let existing: CollectionInfo | null = null\n try {\n existing = (await qdrant('GET', `/collections/${col}`)) as CollectionInfo\n } catch {\n // Collection does not exist — create it below\n }\n\n if (existing) {\n const collectionDim = existing.config?.params?.vectors?.size\n const recorded = existing.config?.metadata?.embedding_model\n const explicit = process.env.AMEM_EMBED_MODEL?.trim()\n\n // Settle which model this collection needs BEFORE measuring anything.\n // Measuring loads the model, and on the path where this collection turns out\n // to predate the current default that would mean downloading a gigabyte of\n // weights only to conclude they are the wrong ones.\n //\n // Skipped entirely when AMEM_EMBED_MODEL is set: someone who set it is\n // migrating deliberately, and quietly overriding them with the collection's\n // own model would make the setting look broken. The checks below still catch\n // it if they are wrong.\n // No record, and the width of the only default this project shipped before\n // the field existed. Nothing else could plausibly have built it: choosing a\n // different model has always meant setting the env var, and it is unset here.\n const inferLegacy = !explicit && recorded === undefined && collectionDim === LEGACY_DEFAULT_DIM\n\n if (!explicit) {\n const wanted =\n // The collection says what built it, which outranks whatever the shipped\n // default happens to be today. This is what keeps changing the default\n // from breaking every install that already has data.\n typeof recorded === 'string' ? recorded : inferLegacy ? LEGACY_DEFAULT_EMBEDDING_MODEL : DEFAULT_EMBEDDING_MODEL\n\n const inUse = getPinnedEmbeddingModel()\n if (inUse !== null && inUse !== wanted) throw new MixedEmbeddingModelsError(col, wanted, inUse)\n // Pinned even when it equals the default, so the next collection through\n // here has something to conflict with.\n pinEmbeddingModel(wanted)\n\n if (wanted === LEGACY_DEFAULT_EMBEDDING_MODEL) {\n // Every startup, by design. This is a store that still works, so nothing\n // forces the issue — but it is silently truncating and the only way the\n // operator finds out is being told.\n console.warn(\n `[amem] \"${col}\" is on ${LEGACY_DEFAULT_EMBEDDING_MODEL} (${LEGACY_DEFAULT_DIM}-dim).\\n` +\n `[amem] ${DEFAULT_EMBEDDING_MODEL} reads 8192 tokens where that one stops at 128, so ` +\n `anything longer is being truncated before it reaches the vector.\\n` +\n `[amem] To move: npx --package=@amemhq/core amem-migrate --from-collection ${col}`\n )\n }\n }\n\n // Check the dimension NOW rather than letting the first upsert fail. Qdrant\n // rejects a wrong-width vector at insert time, which surfaces as a raw\n // storage error in the middle of a working session — long after the change\n // that caused it, and nowhere near the setting to blame.\n if (typeof collectionDim === 'number') {\n const modelDim = await getEmbeddingDim()\n if (collectionDim !== modelDim) {\n throw new EmbeddingDimensionMismatchError(col, collectionDim, modelDim, getEmbeddingModel())\n }\n }\n\n const current = getEmbeddingModel()\n if (typeof recorded === 'string' && recorded !== current) {\n // Same width, different model — only reachable when AMEM_EMBED_MODEL was\n // set, since otherwise the pin above made them equal. The dimension check\n // cannot see this and nothing downstream would: both models produce\n // well-formed vectors of the right size, so the store silently ends up\n // holding two incompatible geometries and retrieval quietly degrades.\n throw new EmbeddingModelMismatchError(col, recorded, current)\n }\n if (recorded === undefined && !inferLegacy) {\n // Predates the field. The dimension matched and the model was configured\n // rather than guessed, so it is provably what wrote these vectors — record\n // it while that is still true.\n //\n // Not when it was inferred: writing a guess into the metadata makes it\n // permanent, and a store that had genuinely been on some other 384-dim\n // model would be mislabelled with nothing left to tell from. Inference is\n // cheap to repeat on every open; a wrong record is forever.\n await recordCollectionModel(col, current)\n }\n\n markReady()\n return\n }\n\n try {\n // Measured, not looked up: a hardcoded table would be silently wrong for any\n // model not in it, and the collection would be created at the wrong width.\n const size = await getEmbeddingDim()\n await qdrant('PUT', `/collections/${col}`, {\n vectors: { size, distance: 'Cosine' },\n })\n } catch (err) {\n // If another concurrent call already created it, that's fine\n if (!(err instanceof Error) || !err.message.includes('already exists')) throw err\n }\n const created = getEmbeddingModel()\n await recordCollectionModel(col, created)\n // Same commitment the existing-collection path makes, for the same reason: a\n // second collection opened later must conflict rather than silently repoint\n // this one at a different model.\n if (!process.env.AMEM_EMBED_MODEL?.trim()) pinEmbeddingModel(created)\n // Index agent_id for fast filtering\n await qdrant('PUT', `/collections/${col}/index`, {\n field_name: 'agent_id',\n field_schema: 'keyword',\n })\n // Index hash for exact-match dedup\n await qdrant('PUT', `/collections/${col}/index`, {\n field_name: 'hash',\n field_schema: 'keyword',\n })\n // Story 26B: Index topics for knowledge note filtering\n await qdrant('PUT', `/collections/${col}/index`, {\n field_name: 'topics',\n field_schema: 'keyword',\n })\n // Story 44: index subjects so \"memories about this person\" is an index lookup.\n await qdrant('PUT', `/collections/${col}/index`, {\n field_name: 'subjects',\n field_schema: 'keyword',\n })\n markReady()\n}\n\n// ── Raw access for migration ──────────────────────────────────────────────────\n// These deliberately bypass ensureCollection. A migration reads a collection\n// whose vectors were written by the OLD model, so the dimension check that\n// protects normal operation would reject exactly the read the migration needs.\n\n/** Scroll every point in a collection, no filter, no readiness check. */\nexport async function scrollAllRaw(\n collection: string,\n limit = 10000\n): Promise<Array<{ id: string; payload: Record<string, unknown>; vector: number[] }>> {\n const out: Array<{ id: string; payload: Record<string, unknown>; vector: number[] }> = []\n let offset: unknown = undefined\n for (;;) {\n const body: Record<string, unknown> = { with_payload: true, with_vector: true, limit }\n if (offset !== undefined && offset !== null) body.offset = offset\n const res = (await qdrant('POST', `/collections/${collection}/points/scroll`, body)) as {\n points: Array<{ id: string; payload: Record<string, unknown>; vector: number[] }>\n next_page_offset?: unknown\n }\n out.push(...res.points)\n offset = res.next_page_offset\n if (offset === undefined || offset === null || res.points.length === 0) break\n }\n return out\n}\n\n/** How many points a collection holds. Used to verify a backfill. */\nexport async function countPointsRaw(collection: string): Promise<number> {\n const res = (await qdrant('POST', `/collections/${collection}/points/count`, { exact: true })) as {\n count: number\n }\n return res.count\n}\n\n/** The vector width a collection was created with, or null if it does not exist. */\nexport async function collectionDimRaw(collection: string): Promise<number | null> {\n try {\n const info = (await qdrant('GET', `/collections/${collection}`)) as {\n config?: { params?: { vectors?: { size?: number } } }\n }\n return info.config?.params?.vectors?.size ?? null\n } catch {\n return null\n }\n}\n\n/** Create a collection at an explicit width, with the same payload indexes as ensureCollection. */\nexport async function createCollectionRaw(collection: string, size: number): Promise<void> {\n await qdrant('PUT', `/collections/${collection}`, { vectors: { size, distance: 'Cosine' } })\n // The migration target is built by the model configured right now — the same\n // one that produced `size`. Record it so the new collection self-describes from\n // the moment it exists, rather than on whatever later run first opens it.\n await recordCollectionModel(collection, getEmbeddingModel())\n for (const field_name of ['agent_id', 'hash', 'topics', 'subjects']) {\n await qdrant('PUT', `/collections/${collection}/index`, { field_name, field_schema: 'keyword' })\n }\n}\n\n/** Upsert prepared points into a collection. */\nexport async function upsertPointsRaw(\n collection: string,\n points: Array<{ id: string; vector: number[]; payload: Record<string, unknown> }>\n): Promise<void> {\n await qdrant('PUT', `/collections/${collection}/points?wait=true`, { points })\n}\n\n/**\n * Just the ids in a collection. `scrollAllRaw` pulls payloads and vectors too,\n * which is the whole store over the wire — a resumed migration only needs to know\n * what it already wrote.\n */\nexport async function scrollIdsRaw(collection: string, limit = 10000): Promise<Set<string>> {\n const ids = new Set<string>()\n let offset: unknown = undefined\n for (;;) {\n const body: Record<string, unknown> = { with_payload: false, with_vector: false, limit }\n if (offset !== undefined && offset !== null) body.offset = offset\n const res = (await qdrant('POST', `/collections/${collection}/points/scroll`, body)) as {\n points: Array<{ id: string }>\n next_page_offset?: unknown\n }\n for (const p of res.points) ids.add(String(p.id))\n offset = res.next_page_offset\n if (offset === undefined || offset === null || res.points.length === 0) break\n }\n return ids\n}\n\n/** Drop a collection. Only the migration cutover uses this. */\nexport async function deleteCollectionRaw(collection: string): Promise<void> {\n await qdrant('DELETE', `/collections/${collection}`)\n}\n\n/** Which collection an alias points at, or null if the name is not an alias. */\nexport async function resolveAliasRaw(alias: string): Promise<string | null> {\n try {\n const res = (await qdrant('GET', `/aliases`)) as {\n aliases: Array<{ alias_name: string; collection_name: string }>\n }\n return res.aliases.find((a) => a.alias_name === alias)?.collection_name ?? null\n } catch {\n return null\n }\n}\n\n/** Create an alias for a name nothing currently holds. */\nexport async function createAliasRaw(alias: string, collection: string): Promise<void> {\n await qdrant('POST', `/collections/aliases`, {\n actions: [{ create_alias: { collection_name: collection, alias_name: alias } }],\n })\n}\n\n/**\n * Point `alias` at `collection`, replacing whatever it pointed at.\n *\n * Both actions go in one request because Qdrant applies them atomically — a\n * separate delete and create would leave a window where the name resolves to\n * nothing, and that name is what every reader is configured to use.\n */\nexport async function setAliasRaw(alias: string, collection: string): Promise<void> {\n await qdrant('POST', `/collections/aliases`, {\n actions: [\n { delete_alias: { alias_name: alias } },\n { create_alias: { collection_name: collection, alias_name: alias } },\n ],\n })\n}\n\n// ── Payload mapping ───────────────────────────────────────────────────────────\nexport function noteToPoint(note: MemoryNote) {\n return {\n id: note.id,\n vector: note.embedding,\n payload: {\n content: note.content,\n keywords: note.keywords,\n tags: note.tags,\n context: note.context,\n links: note.links,\n timestamp: note.timestamp,\n agent_id: note.agent_id,\n hash: note.hash,\n // 13-A\n retrieval_count: note.retrieval_count ?? 0,\n last_accessed: note.last_accessed || note.timestamp,\n // 13-B: stored as JSON string (Qdrant payload can't handle nested array-of-objects)\n evolution_history: JSON.stringify(note.evolution_history ?? []),\n // 13-E\n category: note.category || 'General',\n is_active: note.is_active !== false,\n // 26B\n topics: note.topics ?? [],\n // 26A\n note_type: note.note_type || 'memory',\n // 29\n pending_merge: note.pending_merge ?? false,\n // 30\n evolution_type: note.evolution_type || '',\n conflict: note.conflict ?? false,\n conflicts_with: note.conflicts_with ?? [],\n conflict_reason: note.conflict_reason ?? '',\n conflict_scanned_at: note.conflict_scanned_at ?? '',\n subjects: note.subjects ?? [],\n // 31\n ephemeral: note.ephemeral ?? false,\n low_quality: note.low_quality ?? false,\n // 32\n owner: note.owner || note.agent_id,\n readers: note.readers ?? [note.agent_id],\n writers: note.writers ?? [note.agent_id],\n },\n }\n}\n\nexport function pointToNote(point: { id: string; payload: Record<string, unknown>; vector?: number[] }): MemoryNote {\n const p = point.payload\n const timestamp = (p.timestamp as string) || ''\n\n // 13-B: deserialize evolution_history from JSON string\n let evolutionHistory: EvolutionEntry[] = []\n try {\n const raw = p.evolution_history\n if (typeof raw === 'string' && raw.length > 0) {\n evolutionHistory = JSON.parse(raw) as EvolutionEntry[]\n } else if (Array.isArray(raw)) {\n // handle legacy case where it was stored as array\n evolutionHistory = raw as EvolutionEntry[]\n }\n } catch {\n evolutionHistory = []\n }\n\n return {\n id: String(point.id),\n content: (p.content as string) || '',\n keywords: (p.keywords as string[]) || [],\n tags: (p.tags as string[]) || [],\n context: (p.context as string) || '',\n links: (p.links as string[]) || [],\n timestamp,\n agent_id: (p.agent_id as string) || 'main',\n embedding: point.vector || [],\n hash: (p.hash as string) || '',\n // 13-A\n retrieval_count: typeof p.retrieval_count === 'number' ? p.retrieval_count : 0,\n last_accessed: (p.last_accessed as string) || timestamp,\n // 13-B\n evolution_history: evolutionHistory,\n // 13-E\n category: (p.category as string) || 'General',\n is_active: p.is_active !== false,\n // 26A\n note_type: ((p.note_type as string) === 'knowledge' ? 'knowledge' : 'memory') as 'memory' | 'knowledge',\n // 26B\n topics: Array.isArray(p.topics) ? (p.topics as string[]) : [],\n // 29\n pending_merge: p.pending_merge === true,\n // 30\n evolution_type:\n typeof p.evolution_type === 'string' && ['EVOLVE', 'CONFLICT', 'EXPAND', 'NEW'].includes(p.evolution_type)\n ? (p.evolution_type as 'EVOLVE' | 'CONFLICT' | 'EXPAND' | 'NEW')\n : undefined,\n conflict: p.conflict === true,\n conflicts_with: Array.isArray(p.conflicts_with)\n ? (p.conflicts_with as unknown[]).filter((v): v is string => typeof v === 'string')\n : [],\n conflict_reason: typeof p.conflict_reason === 'string' ? p.conflict_reason : '',\n conflict_scanned_at: typeof p.conflict_scanned_at === 'string' ? p.conflict_scanned_at : '',\n subjects: Array.isArray(p.subjects)\n ? (p.subjects as unknown[]).filter((v): v is string => typeof v === 'string')\n : [],\n // 31\n ephemeral: p.ephemeral === true,\n low_quality: p.low_quality === true,\n // 32\n owner: (p.owner as string) || (p.agent_id as string) || 'main',\n readers: Array.isArray(p.readers) ? (p.readers as string[]) : [(p.agent_id as string) || 'main'],\n writers: Array.isArray(p.writers) ? (p.writers as string[]) : [(p.agent_id as string) || 'main'],\n }\n}\n\n// ── Agent filter ──────────────────────────────────────────────────────────────\nfunction agentFilter(agentId: string, subject?: string) {\n const must: unknown[] = [\n {\n should: [\n { key: 'agent_id', match: { value: agentId } },\n { key: 'agent_id', match: { value: 'shared' } },\n ],\n },\n ]\n\n // Story 44: scope to one person when asked. A memory is in scope if it names\n // them, OR if it names nobody — an empty `subjects` is a fact about the world\n // or about the agent itself, which stays relevant whoever is present. A shared\n // experience names several people and so surfaces for each of them.\n //\n // Omitting `subject` means \"no person scoping\", which is what every existing\n // caller does and what keeps behaviour unchanged.\n if (subject !== undefined) {\n must.push({\n should: [{ key: 'subjects', match: { value: subject } }, { is_empty: { key: 'subjects' } }],\n })\n }\n\n return {\n must,\n must_not: [{ key: 'is_active', match: { value: false } }],\n }\n}\n\n// ── CRUD ──────────────────────────────────────────────────────────────────────\n\n/**\n * Core CRUD implementation scoped to a specific collection and agent filter mode.\n * collectionName: which Qdrant collection to operate on.\n * modeBIsolated: if true, skip the \"also include shared\" filter in agentFilter\n * (mode B collections are already per-agent, so no cross-agent filter needed).\n */\nfunction makeCrud(collectionName: string, modeBIsolated = false) {\n const col = collectionName\n\n function scopedAgentFilter(agentId: string, subject?: string) {\n if (modeBIsolated) {\n // Mode B: the collection is already agent-isolated, so no agent clause is\n // needed — but subject scoping still applies. Mode B separates AGENTS;\n // `subjects` separates the PEOPLE one agent holds memories about, and a\n // single agent in its own collection still meets several of them.\n const must: unknown[] = []\n if (subject !== undefined) {\n must.push({\n should: [{ key: 'subjects', match: { value: subject } }, { is_empty: { key: 'subjects' } }],\n })\n }\n return {\n ...(must.length > 0 && { must }),\n must_not: [{ key: 'is_active', match: { value: false } }],\n }\n }\n return agentFilter(agentId, subject)\n }\n\n return {\n async addNote(note: MemoryNote): Promise<void> {\n await ensureCollection(col)\n await qdrant('PUT', `/collections/${col}/points?wait=true`, {\n points: [noteToPoint(note)],\n })\n },\n\n /**\n * Story 36: this is the one read that bypasses the agent filter — it fetches\n * straight by UUID. An unreadable note comes back as `null`, indistinguishable\n * from missing, so nothing leaks and callers already handle it.\n *\n * `reader` is required. It used to be optional, and omitting it skipped the\n * check — which meant the safe behaviour was the one you had to remember to\n * ask for. Pass `SYSTEM_ACTOR` to read as the engine itself; that reads as a\n * deliberate act at the call site, where an absent argument did not.\n */\n async getNote(id: string, reader: string): Promise<MemoryNote | null> {\n await ensureCollection(col)\n try {\n const result = (await qdrant('POST', `/collections/${col}/points`, {\n ids: [id],\n with_payload: true,\n with_vector: true,\n })) as Array<{ id: string; payload: Record<string, unknown>; vector: number[] }>\n if (!result.length) return null\n const note = pointToNote(result[0])\n if (reader !== SYSTEM_ACTOR && !canRead(note, reader)) return null\n return note\n } catch {\n return null\n }\n },\n\n async updateNote(note: MemoryNote): Promise<void> {\n await ensureCollection(col)\n await qdrant('PUT', `/collections/${col}/points?wait=true`, {\n points: [noteToPoint(note)],\n })\n },\n\n async findByHash(hash: string, agentId: string): Promise<MemoryNote | null> {\n await ensureCollection(col)\n const body = {\n filter: {\n must: [\n { key: 'hash', match: { value: hash } },\n { key: 'is_active', match: { value: true } },\n ...(modeBIsolated\n ? []\n : [\n {\n should: [\n { key: 'agent_id', match: { value: agentId } },\n { key: 'agent_id', match: { value: 'shared' } },\n ],\n },\n ]),\n ],\n },\n with_payload: true,\n with_vector: true,\n limit: 1,\n }\n const result = (await qdrant('POST', `/collections/${col}/points/scroll`, body)) as {\n points: Array<{ id: string; payload: Record<string, unknown>; vector: number[] }>\n }\n if (!result.points.length) return null\n return pointToNote(result.points[0])\n },\n\n /**\n * Story 33: enforces the writers policy. Returns false — without writing —\n * when the caller may not write. This fetch-then-check path exists for callers\n * that only have an id (the plugin's CRUD hook); callers already holding the\n * note can check `canWrite` themselves and skip a round trip.\n *\n * `caller` is required. Pass `SYSTEM_ACTOR` for the engine's own maintenance\n * writes. The self-read below is a genuine `SYSTEM_ACTOR` case: it fetches the\n * note in order to decide whether the caller may write it, and gating that\n * fetch on the same policy it exists to evaluate would be circular.\n */\n async updateNoteContent(\n id: string,\n content: string,\n embedding: number[],\n hash: string,\n caller: string\n ): Promise<boolean> {\n await ensureCollection(col)\n let existing: MemoryNote | null = null\n if (caller !== SYSTEM_ACTOR) {\n existing = await this.getNote(id, SYSTEM_ACTOR)\n if (existing && !canWrite(existing, caller)) return false\n }\n await qdrant('PUT', `/collections/${col}/points/vectors?wait=true`, {\n points: [{ id, vector: embedding }],\n })\n const payload: Record<string, unknown> = { content, hash }\n // Story 41: this overwrite is destructive. Keep the replaced text so a\n // mis-targeted UPDATE stays recoverable — the guard has false negatives,\n // and this is the last line before content is gone for good.\n //\n // This used to happen only on the caller-scoped CRUD path, because the\n // dedup and merge paths passed no identity and so never triggered the\n // fetch. That was an artifact of the optional parameter rather than a\n // decision: folding a near-duplicate and merging two notes both destroy\n // text that had recovery value too. Now every non-system write snapshots,\n // at the cost of one point read on paths that were already making an LLM\n // call.\n if (existing) {\n const history: EvolutionEntry[] = [\n ...(existing.evolution_history ?? []),\n {\n triggeredBy: '',\n triggeredAt: new Date().toISOString(),\n oldContext: existing.context,\n newContext: existing.context,\n oldTags: existing.tags,\n newTags: existing.tags,\n action: 'crud_update',\n oldContent: existing.content,\n },\n ]\n payload.evolution_history = JSON.stringify(history)\n }\n await qdrant('POST', `/collections/${col}/points/payload?wait=true`, {\n payload,\n points: [id],\n })\n return true\n },\n\n async queryByEmbedding(\n embedding: number[],\n topK: number,\n agentId: string,\n scoreThreshold = 0.0,\n subject?: string\n ): Promise<QueryResult[]> {\n await ensureCollection(col)\n const result = (await qdrant('POST', `/collections/${col}/points/search`, {\n vector: embedding,\n limit: topK,\n with_payload: true,\n with_vector: true,\n score_threshold: scoreThreshold,\n filter: scopedAgentFilter(agentId, subject),\n })) as Array<{ id: string; score: number; payload: Record<string, unknown>; vector: number[] }>\n\n const queryResults = result.map((r) => ({\n note: pointToNote(r),\n score: r.score,\n }))\n\n if (queryResults.length > 0) {\n const now = new Date().toISOString()\n const ids = queryResults.map((r) => r.note.id)\n const patches = queryResults.map((r) => ({\n id: r.note.id,\n retrieval_count: (r.note.retrieval_count || 0) + 1,\n }))\n Promise.all([\n qdrant('POST', `/collections/${col}/points/payload?wait=false`, {\n payload: { last_accessed: now },\n points: ids,\n }),\n ...patches.map((p) =>\n qdrant('POST', `/collections/${col}/points/payload?wait=false`, {\n payload: { retrieval_count: p.retrieval_count },\n points: [p.id],\n })\n ),\n ]).catch((err: unknown) => {\n console.error(`[amem] retrieval tracking patch failed: ${(err as Error).message}`)\n })\n for (const r of queryResults) {\n r.note.retrieval_count = (r.note.retrieval_count || 0) + 1\n r.note.last_accessed = now\n }\n }\n\n return queryResults\n },\n\n async listNotes(agentId?: string, subject?: string): Promise<MemoryNote[]> {\n await ensureCollection(col)\n const body: Record<string, unknown> = {\n with_payload: true,\n with_vector: true,\n limit: 10000,\n }\n if (agentId) body.filter = scopedAgentFilter(agentId, subject)\n\n const result = (await qdrant('POST', `/collections/${col}/points/scroll`, body)) as {\n points: Array<{ id: string; payload: Record<string, unknown>; vector: number[] }>\n }\n return result.points.map(pointToNote)\n },\n\n async deleteNote(id: string): Promise<void> {\n await ensureCollection(col)\n await qdrant('POST', `/collections/${col}/points/delete`, {\n points: [id],\n })\n },\n\n /** Story 33: see `updateNoteContent` — returns false, unwritten, when denied. */\n async invalidateNote(id: string, caller: string): Promise<boolean> {\n await ensureCollection(col)\n if (caller !== SYSTEM_ACTOR) {\n const existing = await this.getNote(id, SYSTEM_ACTOR)\n if (existing && !canWrite(existing, caller)) return false\n }\n await qdrant('POST', `/collections/${col}/points/payload?wait=true`, {\n payload: { is_active: false },\n points: [id],\n })\n return true\n },\n\n async getNotesByDatePrefix(datePrefix: string, agentId: string): Promise<MemoryNote[]> {\n await ensureCollection(col)\n const filterClauses: unknown[] = [{ key: 'is_active', match: { value: true } }]\n if (!modeBIsolated) {\n filterClauses.push({\n should: [\n { key: 'agent_id', match: { value: agentId } },\n { key: 'agent_id', match: { value: 'shared' } },\n ],\n })\n }\n const body: Record<string, unknown> = {\n filter: { must: filterClauses },\n with_payload: true,\n with_vector: true,\n limit: 10000,\n }\n const result = (await qdrant('POST', `/collections/${col}/points/scroll`, body)) as {\n points: Array<{ id: string; payload: Record<string, unknown>; vector: number[] }>\n }\n return result.points.map(pointToNote).filter((n) => n.timestamp.startsWith(datePrefix))\n },\n\n async countNotes(agentId?: string): Promise<number> {\n await ensureCollection(col)\n const body: Record<string, unknown> = { exact: true }\n if (agentId) body.filter = scopedAgentFilter(agentId)\n const result = (await qdrant('POST', `/collections/${col}/points/count`, body)) as { count: number }\n return result.count\n },\n\n async updateNoteLinks(id: string, links: string[]): Promise<void> {\n await ensureCollection(col)\n await qdrant('POST', `/collections/${col}/points/payload?wait=true`, {\n payload: { links },\n points: [id],\n })\n },\n\n async patchNotePayload(id: string, fields: Record<string, unknown>): Promise<void> {\n await ensureCollection(col)\n await qdrant('POST', `/collections/${col}/points/payload?wait=true`, {\n payload: fields,\n points: [id],\n })\n },\n\n async replaceLinkReferences(oldId: string, newId: string, agentId: string): Promise<void> {\n const notes = await this.listNotes(agentId)\n for (const note of notes) {\n // Story 33: listNotes also returns other agents' shared notes. Rewriting\n // their links is a mutation we may not be authorized to make; leaving the\n // stale link is harmless (it points at an invalidated note, which queries\n // already filter out).\n if (!canWrite(note, agentId)) continue\n if (note.links.includes(oldId)) {\n const newLinks = note.links.map((linkId) => (linkId === oldId ? newId : linkId))\n const filteredLinks = newLinks.filter((linkId) => linkId !== note.id)\n const uniqueLinks = Array.from(new Set(filteredLinks))\n await this.updateNoteLinks(note.id, uniqueLinks)\n }\n }\n },\n }\n}\n\nexport type StorageContext = ReturnType<typeof makeCrud>\n\n/**\n * Create a StorageContext scoped to a specific collection (mode B) or the default collection (mode A).\n * Mode A (same collection): pass collectionName = undefined → uses AMEM_COLLECTION env var.\n * Mode B (isolated collection): pass collectionName = 'amem_notes_<agentId>' and modeBIsolated = true.\n */\nexport function createStorageContext(collectionName?: string, modeBIsolated = false): StorageContext {\n return makeCrud(collectionName || getCollection(), modeBIsolated)\n}\n\n// ── Legacy top-level exports (backwards compat, use default collection) ────────\n\nexport async function addNote(note: MemoryNote): Promise<void> {\n return makeCrud(getCollection()).addNote(note)\n}\n\nexport async function getNote(id: string, reader: string): Promise<MemoryNote | null> {\n return makeCrud(getCollection()).getNote(id, reader)\n}\n\nexport async function updateNote(note: MemoryNote): Promise<void> {\n return makeCrud(getCollection()).updateNote(note)\n}\n\nexport async function findByHash(hash: string, agentId: string): Promise<MemoryNote | null> {\n return makeCrud(getCollection()).findByHash(hash, agentId)\n}\n\nexport async function updateNoteContent(\n id: string,\n content: string,\n embedding: number[],\n hash: string,\n caller: string\n): Promise<boolean> {\n return makeCrud(getCollection()).updateNoteContent(id, content, embedding, hash, caller)\n}\n\nexport async function queryByEmbedding(\n embedding: number[],\n topK: number,\n agentId: string,\n scoreThreshold = 0.0,\n subject?: string\n): Promise<QueryResult[]> {\n return makeCrud(getCollection()).queryByEmbedding(embedding, topK, agentId, scoreThreshold, subject)\n}\n\nexport async function listNotes(agentId?: string, subject?: string): Promise<MemoryNote[]> {\n return makeCrud(getCollection()).listNotes(agentId, subject)\n}\n\nexport async function deleteNote(id: string): Promise<void> {\n return makeCrud(getCollection()).deleteNote(id)\n}\n\nexport async function invalidateNote(id: string, caller: string): Promise<boolean> {\n return makeCrud(getCollection()).invalidateNote(id, caller)\n}\n\nexport async function getNotesByDatePrefix(datePrefix: string, agentId: string): Promise<MemoryNote[]> {\n return makeCrud(getCollection()).getNotesByDatePrefix(datePrefix, agentId)\n}\n\nexport async function countNotes(agentId?: string): Promise<number> {\n return makeCrud(getCollection()).countNotes(agentId)\n}\n\nexport async function updateNoteLinks(id: string, links: string[]): Promise<void> {\n return makeCrud(getCollection()).updateNoteLinks(id, links)\n}\n\nexport async function patchNotePayload(id: string, fields: Record<string, unknown>): Promise<void> {\n return makeCrud(getCollection()).patchNotePayload(id, fields)\n}\n\nexport async function replaceLinkReferences(oldId: string, newId: string, agentId: string): Promise<void> {\n return makeCrud(getCollection()).replaceLinkReferences(oldId, newId, agentId)\n}\n","/**\n * llm.ts — LLM helpers for A-MEM note construction, linking, evolution\n */\n\nimport Anthropic from '@anthropic-ai/sdk'\nimport OpenAI from 'openai'\nimport { t } from './prompts.js'\n\n// ── Provider selection ────────────────────────────────────────────────────────\n// The engine's LLM calls are all \"prompt in, text out\" — no streaming, tools, or\n// vision — so one switch covers every backend. `anthropic` (default) keeps the\n// native Messages API; `openai` speaks the Chat Completions API, which every\n// OpenAI-compatible gateway implements (OpenAI, DeepSeek, OpenRouter, Groq,\n// Together, Ollama, vLLM, LM Studio…). Point the base URL at whichever one.\n//\n// Story 35: these used to be top-level consts, which froze the choice at import\n// and left a host with no way in short of relaunching the process. They are now\n// resolved per call, so a host can hand the engine a model through\n// `configureLlm()` after import. Precedence, highest first:\n//\n// 1. environment variable — the operator's override, always wins\n// 2. configureLlm() — what the host (e.g. openclaw.json) asked for\n// 3. built-in default — per provider\n//\n// Note what is deliberately absent: there is no way to inject an API key. Keys\n// come from the environment only. Configuration arrives from a host config file,\n// and an apiKey field here would be an invitation to pipe a user's credentials\n// out of that file and into the memory engine. Endpoint and model, yes; secrets,\n// no.\n\n/**\n * Which tier a call runs on (Story 42).\n *\n * The engine's calls split cleanly by how much model capability they actually\n * need. Published results are consistent that memory quality is mostly\n * architecture-bound — extraction differs ~2 points between a cheap and a strong\n * model — with ONE exception: judging whether a new fact CONTRADICTS a stored\n * one, where the gap is large. So the frequent, easy calls run `fast`, and the\n * rare, genuinely hard judgements can run `strong` if the operator configures\n * one. See docs/guide/design-rationale.md for the evidence.\n */\nexport type LlmRole = 'fast' | 'strong'\n\n/** Provider/model/endpoint for one role. */\nexport interface LlmRoleConfig {\n provider?: string\n model?: string\n baseURL?: string\n}\n\n/** Runtime LLM settings a host may inject. See the precedence note above. */\nexport interface LlmConfig extends LlmRoleConfig {\n /** Per-request timeout in ms for the SDK client. Guards against a slow or\n * stuck endpoint hanging the whole addMemory pipeline. Default 30000.\n * Shared by both roles — it is a transport concern, not a tier one. */\n timeoutMs?: number\n /**\n * Optional `strong` tier. Each field falls back to the `fast` value\n * INDIVIDUALLY, so all three useful shapes work:\n * - only `model` → same endpoint, better model (gpt-4o-mini → gpt-4o)\n * - all three → a wholly separate backend (local Ollama + cloud Claude)\n * - nothing → strong IS fast, i.e. today's single-model behaviour\n * There is deliberately no built-in strong default: inventing one would start\n * spending an existing user's money on a pricier model without them asking.\n */\n strong?: LlmRoleConfig\n /** Which role the agent_end CRUD decision uses. Default `fast`. */\n crudRole?: LlmRole\n}\n\nlet _override: LlmConfig = {}\n\n/**\n * Point the engine's LLM calls at a provider/model/endpoint chosen by the host.\n *\n * Environment variables still win over anything passed here, and any field left\n * undefined falls through to the default — so `configureLlm({ model })` changes\n * only the model. Safe to call before or after the first LLM call.\n */\nexport function configureLlm(cfg: LlmConfig): void {\n _override = { ...cfg }\n // The clients capture the base URL and key chain at construction, so a cached\n // one would keep talking to the old endpoint. Drop them and let the next call\n // rebuild against the new settings.\n _anthropicClients.clear()\n _openaiClients.clear()\n}\n\n// Resolution runs on every call, so a bad provider value would log on every call.\nconst _warned = new Set<string>()\nfunction warnOnce(key: string, message: string): void {\n if (_warned.has(key)) return\n _warned.add(key)\n console.error(message)\n}\n\n// `||`, not `??`, on purpose: an env var set to the empty string means \"unset\"\n// here. With `??` an exported-but-empty AMEM_LLM_MODEL would outrank a perfectly\n// valid model from openclaw.json and silently win, which is a miserable thing to\n// debug.\nfunction resolveProvider(role: LlmRole = 'fast'): string {\n // Strong falls back to fast per field, so an operator who only names a strong\n // MODEL keeps the same provider and endpoint — the common \"same API, better\n // model\" case.\n const raw =\n role === 'strong' ? process.env.AMEM_LLM_STRONG_PROVIDER || _override.strong?.provider || undefined : undefined\n const p = (raw || process.env.AMEM_LLM_PROVIDER || _override.provider || 'anthropic').trim().toLowerCase()\n if (p !== 'anthropic' && p !== 'openai') {\n // An unrecognised value silently routes to the anthropic path with the wrong\n // model/endpoint — surface it instead of failing invisibly on every call.\n warnOnce(`provider:${p}`, `[amem] unknown LLM provider \"${p}\"; falling back to anthropic`)\n }\n return p\n}\n\nfunction resolveModel(role: LlmRole = 'fast'): string {\n const strong =\n role === 'strong' ? process.env.AMEM_LLM_STRONG_MODEL || _override.strong?.model || undefined : undefined\n return (\n strong ||\n process.env.AMEM_LLM_MODEL ||\n _override.model ||\n (resolveProvider(role) === 'openai' ? 'gpt-4o-mini' : 'claude-sonnet-4-6')\n )\n}\n\nfunction resolveBaseURL(role: LlmRole = 'fast'): string | undefined {\n const strong =\n role === 'strong' ? process.env.AMEM_LLM_STRONG_BASE_URL || _override.strong?.baseURL || undefined : undefined\n return strong || process.env.AMEM_LLM_BASE_URL || _override.baseURL || undefined\n}\n\n/**\n * Which role the agent_end CRUD decision runs on. Defaults to `fast` — see the\n * note at its call site. An unrecognised value falls back to `fast` rather than\n * failing, and warns once.\n */\nfunction resolveCrudRole(): LlmRole {\n const raw = (process.env.AMEM_LLM_CRUD_ROLE || _override.crudRole || 'fast').trim().toLowerCase()\n if (raw === 'strong') return 'strong'\n if (raw !== 'fast') {\n warnOnce(`crudRole:${raw}`, `[amem] unknown AMEM_LLM_CRUD_ROLE \"${raw}\"; using fast`)\n }\n return 'fast'\n}\n\nconst DEFAULT_TIMEOUT_MS = 30_000\nfunction resolveTimeoutMs(): number {\n const envVal = Number(process.env.AMEM_LLM_TIMEOUT)\n if (Number.isFinite(envVal) && envVal > 0) return envVal\n if (_override.timeoutMs && _override.timeoutMs > 0) return _override.timeoutMs\n return DEFAULT_TIMEOUT_MS\n}\n\n// ── Clients (lazy, keyed by endpoint) ─────────────────────────────────────────\n// Constructed on first use, not at import, so loading the engine never builds a\n// client for the provider you are not using — nor demands its API key. Local\n// OpenAI-compatible servers (Ollama, vLLM) accept any key, so a placeholder lets\n// them run keyless.\n//\n// Keyed by base URL rather than held as a singleton: the two roles may point at\n// different backends (a local Ollama for `fast`, a hosted API for `strong`), and\n// a single cached client would silently send one role's calls to the other's\n// endpoint.\nconst _anthropicClients = new Map<string, Anthropic>()\nfunction anthropic(baseURL: string | undefined): Anthropic {\n const key = baseURL ?? ''\n let client = _anthropicClients.get(key)\n if (!client) {\n client = new Anthropic({\n ...(process.env.AMEM_LLM_API_KEY && { apiKey: process.env.AMEM_LLM_API_KEY }),\n ...(baseURL && { baseURL }),\n timeout: resolveTimeoutMs(),\n })\n _anthropicClients.set(key, client)\n }\n return client\n}\n\nconst _openaiClients = new Map<string, OpenAI>()\nfunction openai(baseURL: string | undefined): OpenAI {\n const key = baseURL ?? ''\n let client = _openaiClients.get(key)\n if (!client) {\n client = new OpenAI({\n // AMEM_LLM_API_KEY first (engine convention), then the SDK's own\n // OPENAI_API_KEY (the standard) — passing an explicit key blocks the SDK's\n // env fallback, so read it here. Placeholder last, so keyless local servers\n // (Ollama, vLLM) still work.\n apiKey: process.env.AMEM_LLM_API_KEY || process.env.OPENAI_API_KEY || 'sk-no-key-required',\n ...(baseURL && { baseURL }),\n timeout: resolveTimeoutMs(),\n })\n _openaiClients.set(key, client)\n }\n return client\n}\n\n// ── Base LLM call ─────────────────────────────────────────────────────────────\nexport async function llmCall(prompt: string, maxTokens = 500, role: LlmRole = 'fast'): Promise<string | null> {\n const provider = resolveProvider(role)\n const model = resolveModel(role)\n const baseURL = resolveBaseURL(role)\n // Gemini thinking models consume extra tokens for reasoning; scale up automatically\n const isThinking = model.includes('gemini') || model.includes('pro-agent')\n const effectiveMaxTokens = isThinking ? Math.max(maxTokens * 8, 4000) : maxTokens\n try {\n return provider === 'openai'\n ? await openaiCall(prompt, model, effectiveMaxTokens, baseURL)\n : await anthropicCall(prompt, model, effectiveMaxTokens, baseURL)\n } catch (e) {\n console.error(`[amem] LLM call failed: ${(e as Error).message}`)\n return null\n }\n}\n\nasync function anthropicCall(\n prompt: string,\n model: string,\n maxTokens: number,\n baseURL: string | undefined\n): Promise<string | null> {\n const resp = await anthropic(baseURL).messages.create({\n model,\n max_tokens: maxTokens,\n messages: [{ role: 'user', content: prompt }],\n })\n for (const block of resp.content) {\n if (block.type === 'text') return block.text.trim()\n }\n return null\n}\n\nasync function openaiCall(\n prompt: string,\n model: string,\n maxTokens: number,\n baseURL: string | undefined\n): Promise<string | null> {\n // OpenAI's own reasoning models (o1/o3, gpt-5) reject `max_tokens` and require\n // `max_completion_tokens`; everything else takes `max_tokens`. Same budget for\n // our single-shot completions — only the parameter name differs. Match OpenAI\n // names narrowly: a broad `includes('reason')` would wrongly catch other\n // gateways' models (e.g. DeepSeek's `deepseek-reasoner`, which uses max_tokens).\n const isReasoning = /^o\\d/.test(model) || model.startsWith('gpt-5')\n const resp = await openai(baseURL).chat.completions.create({\n model,\n ...(isReasoning ? { max_completion_tokens: maxTokens } : { max_tokens: maxTokens }),\n messages: [{ role: 'user', content: prompt }],\n })\n return resp.choices[0]?.message?.content?.trim() ?? null\n}\n\n// ── Response cleaning + tolerant JSON parsing ─────────────────────────────────\n\n/**\n * Remove reasoning-model scaffolding that would otherwise break JSON.parse:\n * `<think>…</think>` blocks and chat special tokens (`<|eot_id|>`, `<|im_end|>`,\n * …). Open-weight models reachable via any OpenAI-compatible `baseURL`\n * (DeepSeek-R1, Qwen, LLaMA-3 through Ollama/vLLM) emit these around otherwise\n * valid JSON. Without stripping them every JSON task silently falls back to its\n * default on a good response — and nothing in the logs says why.\n */\nfunction stripReasoning(raw: string): string {\n return raw\n .replace(/<think>[\\s\\S]*?<\\/think>/gi, '')\n .replace(/<\\|(?:eot_id|im_start|im_end|begin_of_text|end_of_text|endoftext)\\|>/g, '')\n .trim()\n}\n\nfunction stripFences(raw: string): string {\n raw = stripReasoning(raw)\n if (raw.startsWith('```')) {\n const lines = raw.split('\\n')\n lines.shift()\n if (lines[lines.length - 1] === '```') lines.pop()\n raw = lines.join('\\n').trim()\n }\n // Handle models that wrap JSON in outer quotes: \"{ ... }\" or \"[...]\"\n if ((raw.startsWith('\"') && raw.endsWith('\"')) || (raw.startsWith(\"'\") && raw.endsWith(\"'\"))) {\n try {\n raw = JSON.parse(raw)\n } catch {\n /* keep as-is */\n }\n }\n return raw\n}\n\n/**\n * Parse a JSON object from an LLM response, tolerant of a leading preamble\n * sentence before the object (common with smaller instruction-tuned models).\n * Drop-in for `JSON.parse(stripFences(raw))`: it still THROWS when nothing\n * parses, so every caller's existing try/catch → default still fires, and it\n * returns `any` for the same reason JSON.parse does — callers guard each field.\n */\nfunction parseJsonLoose(raw: string): any {\n const cleaned = stripFences(raw)\n try {\n return JSON.parse(cleaned)\n } catch (e) {\n const m = cleaned.match(/\\{[\\s\\S]*\\}/)\n if (m) return JSON.parse(m[0]) // may throw again → caller's catch handles it\n throw e\n }\n}\n\n// ── Note construction ─────────────────────────────────────────────────────────\n\n/**\n * Valid category values (Story 13-E).\n * \"General\" is the fallback for anything that doesn't fit a specific bucket.\n */\nexport type NoteCategory = 'Technical' | 'Business' | 'Personal' | 'Project' | 'Research' | 'System' | 'General'\n\nexport interface NoteStructure {\n keywords: string[]\n tags: string[]\n context: string\n /** Story 13-E: coarse-grained category */\n category: NoteCategory\n /** Story 26A: episodic memory vs durable knowledge */\n note_type: 'memory' | 'knowledge'\n /** Story 26B: topic tags, non-empty only for knowledge notes */\n topics: string[]\n /** Story 27: LLM self-reported confidence in note_type classification */\n confidence: 'high' | 'medium' | 'low'\n}\n\nconst VALID_CONFIDENCE = new Set<string>(['high', 'medium', 'low'])\n\nconst VALID_CATEGORIES = new Set<string>([\n 'Technical',\n 'Business',\n 'Personal',\n 'Project',\n 'Research',\n 'System',\n 'General',\n])\n\nexport async function llmConstructNote(content: string): Promise<NoteStructure> {\n const prompt = `Analyze the following text and respond with valid JSON only (no markdown fences, no explanation, no comments). All string values must use standard double quotes and be properly escaped:\n{\n \"keywords\": [\"keyword1\", \"keyword2\"],\n \"tags\": [\"tag1\", \"tag2\"],\n \"context\": \"one sentence summary in the same language as the input\",\n \"category\": \"Technical|Business|Personal|Project|Research|System|General\",\n \"note_type\": \"memory|knowledge\",\n \"topics\": [\"Topic1\", \"Topic2\"],\n \"confidence\": \"high|medium|low\"\n}\n\nCategory guide:\n- Technical: code, tools, configuration, APIs, debugging\n- Business: company, finance, compliance, contracts, invoices\n- Personal: personal state, habits, preferences, emotions\n- Project: project progress, decisions, milestones\n- Research: research, literature, evaluation, comparison\n- System: system services, monitoring, operations\n- General: anything that does not fit the above\n\nnote_type guide:\n- knowledge: books, methodologies, tools, domain knowledge, reference material — durable, no strong time component\n- memory: events, decisions, preferences, states, observations — episodic, time-sensitive\n\ntopics guide (Story 26B):\n- Only populate for knowledge notes (note_type=knowledge). For memory notes, return [].\n- List 1-5 concise subject tags representing the main topics of this knowledge, e.g. [\"TypeScript\", \"Qdrant\", \"Vector DB\"].\n\nconfidence guide (Story 27):\n- high: note_type is unambiguous — clearly episodic (event/decision/state) or clearly durable knowledge (tool doc/methodology)\n- medium: some ambiguity — e.g. \"learned X method\" could be either memory or knowledge\n- low: LLM is uncertain — vague, fragmentary, or mixed content\n\nText: ${content}`\n\n const raw = await llmCall(prompt, 400)\n if (!raw)\n return {\n keywords: [],\n tags: [],\n context: '',\n category: 'General',\n note_type: 'memory',\n topics: [],\n confidence: 'medium',\n }\n\n try {\n const data = parseJsonLoose(raw)\n const rawCategory = typeof data.category === 'string' ? data.category : 'General'\n const category: NoteCategory = VALID_CATEGORIES.has(rawCategory) ? (rawCategory as NoteCategory) : 'General'\n const note_type: 'memory' | 'knowledge' = data.note_type === 'knowledge' ? 'knowledge' : 'memory'\n const topics: string[] =\n note_type === 'knowledge' && Array.isArray(data.topics)\n ? (data.topics as unknown[]).filter((v): v is string => typeof v === 'string')\n : []\n const rawConfidence = typeof data.confidence === 'string' ? data.confidence : 'medium'\n const confidence: 'high' | 'medium' | 'low' = VALID_CONFIDENCE.has(rawConfidence)\n ? (rawConfidence as 'high' | 'medium' | 'low')\n : 'medium'\n return {\n keywords: Array.isArray(data.keywords) ? data.keywords : [],\n tags: Array.isArray(data.tags) ? data.tags : [],\n context: typeof data.context === 'string' ? data.context : '',\n category,\n note_type,\n topics,\n confidence,\n }\n } catch (e) {\n console.error(`[amem] Note construction parse failed: ${(e as Error).message}`)\n return {\n keywords: [],\n tags: [],\n context: '',\n category: 'General',\n note_type: 'memory',\n topics: [],\n confidence: 'medium',\n }\n }\n}\n\n// ── Link judgment ─────────────────────────────────────────────────────────────\nexport async function llmShouldLink(noteContent: string, candidateContent: string): Promise<boolean> {\n const prompt = `Do these two memory notes have a meaningful relationship that would be useful to link?\nReply with only \"yes\" or \"no\".\n\nNote A: ${noteContent}\nNote B: ${candidateContent}`\n\n const raw = await llmCall(prompt, 10)\n if (!raw) return false\n return raw.toLowerCase().startsWith('yes')\n}\n\n// ── CRUD Decision ────────────────────────────────────────────────────────────\nexport interface MemoryOperation {\n action: 'NEW' | 'UPDATE' | 'DELETE' | 'NONE'\n fact: string\n existingIdx?: number // For UPDATE/DELETE: integer index into existingMemories (guards against hallucination)\n reason?: string\n}\n\nexport async function llmCrudDecision(\n userText: string,\n assistantText: string,\n existingMemories: Array<{ idx: number; content: string }>\n): Promise<MemoryOperation[]> {\n const memoryList =\n existingMemories.length > 0 ? existingMemories.map((m) => `[${m.idx}] ${m.content}`).join('\\n') : '(none)'\n\n const prompt = t.crudDecision(userText.slice(0, 500), assistantText.slice(0, 500), memoryList)\n\n try {\n // Story 42: defaults to `fast`. It IS a contradiction judgement, but it runs\n // on every turn, and its one destructive failure mode (overwriting the wrong\n // memory) is already handled architecturally by the Story 41 guard rather\n // than by buying a bigger model. Operators who want it on `strong` can say so.\n const raw = await llmCall(prompt, 400, resolveCrudRole())\n if (!raw) return []\n // Strip reasoning scaffolding first — this path extracts the array straight\n // from the response and would otherwise trip over a <think> block.\n const match = stripReasoning(raw).match(/\\[.*\\]/s)\n if (!match) return []\n const parsed = JSON.parse(match[0])\n if (!Array.isArray(parsed)) return []\n const ops: MemoryOperation[] = []\n for (const item of parsed) {\n if (!item || typeof item !== 'object') continue\n const action = item.action\n if (!['NEW', 'UPDATE', 'DELETE', 'NONE'].includes(action)) continue\n if (action === 'NONE') continue\n const op: MemoryOperation = {\n action,\n fact: typeof item.fact === 'string' ? item.fact : '',\n reason: typeof item.reason === 'string' ? item.reason : undefined,\n }\n if (typeof item.existingIdx === 'number') {\n op.existingIdx = item.existingIdx\n }\n ops.push(op)\n }\n return ops.slice(0, 3)\n } catch (e) {\n console.error(`[amem] llmCrudDecision failed: ${(e as Error).message}`)\n return []\n }\n}\n\n// ── Merge judgment ───────────────────────────────────────────────────────────\nexport async function llmShouldMerge(\n contentA: string,\n contentB: string\n): Promise<{ shouldMerge: boolean; merged?: string }> {\n const prompt = t.shouldMerge(contentA, contentB)\n\n // Story 42: merge adjudication is the contradiction class — the one place a\n // stronger model measurably helps. Runs on `strong` when one is configured.\n const raw = await llmCall(prompt, 300, 'strong')\n if (!raw) return { shouldMerge: false }\n\n try {\n const data = parseJsonLoose(raw)\n if (typeof data.shouldMerge !== 'boolean') return { shouldMerge: false }\n if (data.shouldMerge && typeof data.merged === 'string') {\n return { shouldMerge: true, merged: data.merged }\n }\n return { shouldMerge: false }\n } catch (e) {\n console.error(`[amem] llmShouldMerge parse failed: ${(e as Error).message}`)\n return { shouldMerge: false }\n }\n}\n\n// ── Note evolution ────────────────────────────────────────────────────────────\n\n// ── Story 30: Evolution type judgment ────────────────────────────────────────\nexport type EvolutionType = 'EVOLVE' | 'CONFLICT' | 'EXPAND' | 'NEW'\n\nconst VALID_EVOLUTION_TYPES = new Set<string>(['EVOLVE', 'CONFLICT', 'EXPAND', 'NEW'])\n\nexport async function llmEvolutionJudge(\n oldContent: string,\n newContent: string\n): Promise<{ type: EvolutionType; mergedContent?: string }> {\n const prompt = t.evolutionJudge(oldContent, newContent)\n\n // Story 42: EVOLVE/CONFLICT/EXPAND/NEW is literally contradiction\n // classification — the tier-sensitive call. Runs on `strong` when configured.\n const raw = await llmCall(prompt, 300, 'strong')\n if (!raw) return { type: 'NEW' }\n\n try {\n const data = parseJsonLoose(raw)\n const type: EvolutionType = VALID_EVOLUTION_TYPES.has(data.type) ? (data.type as EvolutionType) : 'NEW'\n return {\n type,\n mergedContent: typeof data.mergedContent === 'string' ? data.mergedContent : undefined,\n }\n } catch (e) {\n console.error(`[amem] llmEvolutionJudge parse failed: ${(e as Error).message}`)\n return { type: 'NEW' }\n }\n}\n\n// ── Note evolution (legacy) ──────────────────────────────────────────────────\nexport interface EvolvedNote {\n tags: string[] | null\n context: string | null\n shouldStrengthen: boolean\n suggestedConnections: string[]\n tagsToUpdate: string[]\n}\n\nexport async function llmEvolveNote(\n content: string,\n linkedNotes: Array<{ id: string; content: string }>\n): Promise<EvolvedNote> {\n const linkedStr = linkedNotes.map((n) => `- ID: ${n.id}\\n Content: ${n.content}`).join('\\n')\n const prompt = `A memory note has gained new connections. Update its context, tags, and decide whether to strengthen connections with specific neighbors.\nReply with JSON only (no markdown):\n{\n \"tags\": [\"tag1\", \"tag2\", ...],\n \"context\": \"updated one sentence summary\",\n \"should_strengthen\": true|false,\n \"suggested_connections\": [\"neighbor_id_1\", \"neighbor_id_2\", ...],\n \"tags_to_update\": [\"tag_1\", ..., \"tag_n\"]\n}\n\nGuidelines:\n- \"tags\" and \"context\" are for updating the original note based on new connections.\n- \"should_strengthen\" is a decision whether this note should strengthen its connections to any of the newly linked notes (neighbors).\n- \"suggested_connections\" must contain only IDs from the newly linked notes (neighbors) listed below.\n- \"tags_to_update\" are updated tags for the original note itself if we strengthen connections.\n\nOriginal note content: ${content}\n\nNewly linked notes (neighbors):\n${linkedStr}`\n\n const raw = await llmCall(prompt, 500)\n if (!raw) return { tags: null, context: null, shouldStrengthen: false, suggestedConnections: [], tagsToUpdate: [] }\n\n try {\n const data = parseJsonLoose(raw)\n return {\n tags: Array.isArray(data.tags) ? data.tags : null,\n context: typeof data.context === 'string' ? data.context : null,\n shouldStrengthen: typeof data.should_strengthen === 'boolean' ? data.should_strengthen : false,\n suggestedConnections: Array.isArray(data.suggested_connections) ? data.suggested_connections.map(String) : [],\n tagsToUpdate: Array.isArray(data.tags_to_update) ? data.tags_to_update.map(String) : [],\n }\n } catch (e) {\n console.error(`[amem] Evolution parse failed: ${(e as Error).message}`)\n return { tags: null, context: null, shouldStrengthen: false, suggestedConnections: [], tagsToUpdate: [] }\n }\n}\n\n// ── Story 43: batched contradiction scan ──────────────────────────────────────\n\n/** One contradicting pair, as indices into the batch that was scanned. */\nexport interface ConflictPair {\n a: number\n b: number\n reason: string\n /**\n * Which side the model judged to be SUPERSEDED — the one no longer true.\n * `null` when it could not tell, which is common and must be respected.\n *\n * This is deliberately a semantic judgement rather than a timestamp\n * comparison. A note carries only its INGESTION time, and the two clocks come\n * apart constantly: \"back in 2019 I was vegetarian\", written today, is newer\n * on the wall clock and older in fact. The evidence for which fact is current\n * lives in the TEXT (\"moved last month\", \"used to\", \"switched to\"), which is\n * exactly what the model is already reading.\n */\n supersededIndex: number | null\n}\n\n/**\n * Ask the model which memories in a batch contradict each other.\n *\n * Deliberately NOT pairwise. The engine's existing consolidation pairs notes by\n * cosine similarity, which structurally cannot surface the contradictions that\n * matter here — \"is vegetarian\" and \"loved the steak\" sit far apart in embedding\n * space. Handing the model the whole batch at once is what lets it notice a pair\n * that no similarity gate would have put together.\n *\n * Runs on the `strong` tier: this is the contradiction-judgement class, the one\n * place where model capability measurably pays.\n *\n * Indices are validated against the batch size, so a hallucinated number is\n * dropped rather than mis-targeting a note (the Story 41 lesson).\n */\nexport async function llmConflictScan(contents: string[]): Promise<ConflictPair[]> {\n if (contents.length < 2) return []\n const numbered = contents.map((c, i) => `[${i}] ${c}`).join('\\n')\n\n try {\n const raw = await llmCall(t.conflictScan(numbered), 600, 'strong')\n if (!raw) return []\n const cleaned = stripReasoning(raw)\n const match = cleaned.match(/\\[[\\s\\S]*\\]/)\n if (!match) return []\n const parsed = JSON.parse(match[0])\n if (!Array.isArray(parsed)) return []\n\n const pairs: ConflictPair[] = []\n const seen = new Set<string>()\n for (const item of parsed) {\n if (!item || typeof item !== 'object') continue\n const { a, b } = item as { a: unknown; b: unknown }\n if (typeof a !== 'number' || typeof b !== 'number') continue\n if (!Number.isInteger(a) || !Number.isInteger(b)) continue\n // A hallucinated index must never reach a note. Bounds-check both.\n if (a < 0 || b < 0 || a >= contents.length || b >= contents.length) continue\n if (a === b) continue\n const key = a < b ? `${a}:${b}` : `${b}:${a}`\n if (seen.has(key)) continue\n seen.add(key)\n // Only accept a superseded marker that names one of THIS pair. Anything\n // else (a hallucinated index, a third note, a non-number) means \"unknown\",\n // which callers must treat as \"do not retire\".\n const rawSup = (item as { superseded?: unknown }).superseded\n const supersededIndex = rawSup === a || rawSup === b ? (rawSup as number) : null\n\n pairs.push({\n a,\n b,\n reason: typeof (item as { reason?: unknown }).reason === 'string' ? (item as { reason: string }).reason : '',\n supersededIndex,\n })\n }\n return pairs\n } catch (e) {\n console.error(`[amem] llmConflictScan failed: ${(e as Error).message}`)\n return []\n }\n}\n","/**\n * prompts.ts — Locale-aware prompt templates for A-MEM LLM functions\n *\n * Only the 3 content-sensitive functions need locale variants:\n * - crudDecision: extracts facts from conversation (needs natural language output)\n * - shouldMerge: merges duplicate memories (needs natural language output)\n * - evolutionJudge: judges memory evolution (needs natural language output)\n *\n * The other 3 functions (constructNote, shouldLink, evolveNote) produce\n * structured/binary output and work equally well in English for any input language.\n *\n * PROMPT_VERSION: 1 — created: 2026-06-24\n * When updating any locale, review the other locale for behavioral parity.\n */\n\n// ── Types ────────────────────────────────────────────────────────────────────\n\nexport type PromptLocale = 'en' | 'zh'\n\nexport interface LocalePrompts {\n crudDecision: (userText: string, assistantText: string, memoryList: string) => string\n shouldMerge: (contentA: string, contentB: string) => string\n evolutionJudge: (oldContent: string, newContent: string) => string\n /** Story 43: scan a BATCH of notes for mutually contradictory pairs. */\n conflictScan: (numberedNotes: string) => string\n}\n\n// ── Locale resolution ────────────────────────────────────────────────────────\n\nconst LOCALE: PromptLocale = (process.env.AMEM_PROMPT_LOCALE as PromptLocale) === 'zh' ? 'zh' : 'en'\n\n// ── English templates ────────────────────────────────────────────────────────\n\nconst en: LocalePrompts = {\n crudDecision: (\n userText,\n assistantText,\n memoryList\n ) => `You are a memory management agent. Analyze the conversation and decide what memory operations are needed.\n\n## Conversation\n\nUser: ${userText}\nAssistant: ${assistantText}\n\n## Existing relevant memories (identified by integer idx)\n\n${memoryList}\n\n## Task\n\nExtract only genuinely important long-term facts (decisions, preferences, account info, project status, key insights). Skip small talk, confirmations, and information already captured in existing memories.\n\n## Operation types\n- NEW: Extract a brand new fact not present in existing memories\n- UPDATE: New information refines or supersedes an existing memory; specify existingIdx\n- DELETE: An existing memory is outdated, contradicted, or wrong; specify existingIdx, fact = original content\n- NONE: Nothing worth recording, or information already fully captured\n\n## Output format\n\nReturn a JSON array. Each item:\n{\"action\": \"NEW\"|\"UPDATE\"|\"DELETE\"|\"NONE\", \"fact\": \"fact content\", \"existingIdx\": integer or omit, \"reason\": \"optional\"}\n\nReturn at most 3 operations. If nothing is worth recording, return [].\nReturn only the JSON array, no other text.\n\nExamples:\n\n1. New preference:\n[{\"action\": \"NEW\", \"fact\": \"User prefers TypeScript over JavaScript\", \"reason\": \"Explicitly stated tech preference\"}]\n\n2. Updating an existing memory (idx 0 was \"User is evaluating React and Vue\"):\n[{\"action\": \"UPDATE\", \"fact\": \"User decided to use React (dropped Vue)\", \"existingIdx\": 0, \"reason\": \"Decision finalized, update evaluation status\"}]\n\n3. Conversation is just \"Sure, thanks\" / \"Got it\" with no new info:\n[]`,\n\n shouldMerge: (\n contentA,\n contentB\n ) => `You are a memory deduplication assistant. Determine whether two memories express essentially the same information.\n\nMemory A: ${contentA}\nMemory B: ${contentB}\n\nRules:\n- If both memories express the same core fact (possibly different wording or granularity), return:\n {\"shouldMerge\": true, \"merged\": \"Concise merged statement preserving key details from both, more complete than either alone\"}\n- If the memories are complementary, on different topics, or contain different specific facts, return:\n {\"shouldMerge\": false}\n\nReturn only JSON, no other text.\n\nExamples:\n\n1. Should merge (different granularity):\nA: \"Project uses PostgreSQL\"\nB: \"Project's primary database is PostgreSQL 16, deployed on AWS RDS\"\n-> {\"shouldMerge\": true, \"merged\": \"Project uses PostgreSQL 16 as primary database, deployed on AWS RDS\"}\n\n2. Should NOT merge (complementary but distinct):\nA: \"User prefers VS Code\"\nB: \"User's VS Code uses One Dark Pro theme\"\n-> {\"shouldMerge\": false}`,\n\n evolutionJudge: (\n oldContent,\n newContent\n ) => `You are a memory evolution judge. Analyze the relationship between an old and new memory and return JSON.\n\nOld memory: ${oldContent}\nNew memory: ${newContent}\n\nClassification rules:\n\n- EVOLVE: New content deepens or updates the old memory (e.g. \"Considering Next.js\" -> \"Decided on Next.js 14 App Router\")\n Return: {\"type\": \"EVOLVE\", \"mergedContent\": \"Merged content preserving the evolution trajectory\"}\n\n- CONFLICT: Old and new information directly contradict each other on the same attribute (e.g. \"Uses MySQL as primary DB\" vs \"Migrated to PostgreSQL\")\n Return: {\"type\": \"CONFLICT\"}\n\n- EXPAND: New information supplements the old memory on the same topic (e.g. \"Handles backend dev\" + \"Backend uses Go and gRPC\")\n Return: {\"type\": \"EXPAND\", \"mergedContent\": \"Merged content integrating both pieces of information\"}\n\n- NEW: Completely unrelated information, no substantive connection to the old memory\n Return: {\"type\": \"NEW\"}\n\nReturn only JSON, no other text.`,\n conflictScan: (numberedNotes) => `You are auditing a person's memory store for CONTRADICTIONS.\n\nBelow are numbered memories. Find pairs that CANNOT both be true of the same person at the same time.\n\n${numberedNotes}\n\nWhat counts as a contradiction:\n- The same attribute holding two incompatible values (\"lives in Paris\" vs \"moved to Berlin\")\n- A stated preference or constraint that a later memory violates (\"is vegetarian\" vs \"loved the steak\")\n- A fact that a later memory supersedes (\"uses MySQL\" vs \"migrated to PostgreSQL\")\n\nWhat does NOT count — be strict, these are the common false positives:\n- Additive facts. Two things can both be true (\"has a dog named Buddy\" + \"adopted a second dog, Scout\" is NOT a contradiction)\n- Change over time that both memories already acknowledge\n- Merely similar or related topics\n- Different contexts (likes coffee at work, tea at home)\n\nFor each contradicting pair, also say which one is SUPERSEDED — the one that is\nno longer true. Judge this from the WORDING, not from any assumed order: phrases\nlike \"used to\", \"back in 2019\", \"moved last month\", \"switched to\" tell you which\nstatement describes the past. The memories are NOT listed in chronological order,\nand the number does not imply age.\n\nIf you cannot tell which one is superseded, set it to null. That is a normal and\nuseful answer — say null rather than guessing, because a wrong guess retires a\nmemory that is still true.\n\nReturn ONLY a JSON array. Empty array if nothing genuinely contradicts:\n[{\"a\": 0, \"b\": 3, \"superseded\": 0, \"reason\": \"one short sentence naming the incompatible attribute\"}]\n\n\"superseded\" must be either the value of \"a\", the value of \"b\", or null.\nUse the numbers shown. Report a pair once. Prefer returning nothing over guessing.`,\n}\n\n// ── Chinese templates ────────────────────────────────────────────────────────\n\nconst zh: LocalePrompts = {\n crudDecision: (userText, assistantText, memoryList) => `你是一个记忆管理 agent,负责分析对话内容并决定如何操作记忆库。\n\n## 对话内容\n\n用户:${userText}\n助手:${assistantText}\n\n## 已有相关记忆(用整数 idx 标识)\n\n${memoryList}\n\n## 任务\n\n分析上述对话,决定需要哪些记忆操作。只提取真正重要的长期事实(决策、偏好、账号信息、项目状态、关键洞察)。跳过闲聊、确认语、重复信息。\n\n## 操作类型\n- NEW:提取全新事实(已有记忆中没有的信息)\n- UPDATE:新信息更新了某条已有记忆,用 existingIdx 指定要更新的条目\n- DELETE:某条已有记忆已经过时、发生冲突或错误,用 existingIdx 指定,fact 填原内容\n- NONE:不值得记录或已有完全相同的信息\n\n## 输出格式\n\n返回 JSON 数组,每条格式:\n{\"action\": \"NEW\"|\"UPDATE\"|\"DELETE\"|\"NONE\", \"fact\": \"事实内容\", \"existingIdx\": 整数或省略, \"reason\": \"原因(可选)\"}\n\n每次最多返回 3 条操作。如果没有值得操作的内容,返回 []。\n只返回 JSON 数组,不要任何其他文字。\n\n示例:\n\n1. 提取新偏好:\n[{\"action\": \"NEW\", \"fact\": \"用户偏好 TypeScript 而非 JavaScript\", \"reason\": \"明确表达的技术偏好\"}]\n\n2. 更新已有记忆(idx 0 原为\"用户正在评估 React 和 Vue\"):\n[{\"action\": \"UPDATE\", \"fact\": \"用户决定使用 React(放弃了 Vue)\", \"existingIdx\": 0, \"reason\": \"决策已明确,更新评估状态\"}]\n\n3. 对话仅为\"好的,谢谢\"/\"没问题\"等确认语,无新信息:\n[]`,\n\n shouldMerge: (contentA, contentB) => `你是一个记忆去重助手,负责判断两条记忆是否表达了本质相同的信息。\n\n记忆A:${contentA}\n记忆B:${contentB}\n\n判断规则:\n- 如果两条记忆表达的是本质相同的信息(可能措辞不同、粒度不同,但核心事实一致),返回 JSON:\n {\"shouldMerge\": true, \"merged\": \"合并后的简洁表述,保留两条记忆的关键信息,比任何一条都更完整\"}\n- 如果两条记忆是互补信息、不同主题、或包含不同的具体事实,返回 JSON:\n {\"shouldMerge\": false}\n\n只返回 JSON,不要任何其他文字。\n\n示例:\n\n1. 应合并(粒度不同):\nA: \"项目使用 PostgreSQL 数据库\"\nB: \"项目的主数据库是 PostgreSQL 16,部署在 AWS RDS 上\"\n→ {\"shouldMerge\": true, \"merged\": \"项目使用 PostgreSQL 16 作为主数据库,部署在 AWS RDS 上\"}\n\n2. 不应合并(互补但不同):\nA: \"用户喜欢用 VS Code\"\nB: \"用户的 VS Code 使用 One Dark Pro 主题\"\n→ {\"shouldMerge\": false}`,\n\n evolutionJudge: (oldContent, newContent) => `你是一个记忆演化判断助手。分析以下两条记忆的关系并返回 JSON。\n\n旧记忆:${oldContent}\n新记忆:${newContent}\n\n判断规则:\n\n- EVOLVE:新内容是对旧记忆的深化/更新(如「正在考虑用 Next.js」→「决定用 Next.js 14 App Router」)\n 返回:{\"type\": \"EVOLVE\", \"mergedContent\": \"融合后的完整内容,保留演化轨迹\"}\n\n- CONFLICT:新旧信息在同一属性上直接矛盾(如「使用 MySQL 作为主数据库」vs「已迁移到 PostgreSQL」)\n 返回:{\"type\": \"CONFLICT\"}\n\n- EXPAND:新信息是对旧记忆同一主题的补充扩展(如「负责后端开发」+「后端使用 Go 和 gRPC」)\n 返回:{\"type\": \"EXPAND\", \"mergedContent\": \"合并后的完整内容,整合双方信息\"}\n\n- NEW:全新信息,与旧记忆无实质关联(如「喜欢 dark mode」vs「下周要去出差」)\n 返回:{\"type\": \"NEW\"}\n\n只返回 JSON,不要任何其他文字。`,\n conflictScan: (numberedNotes) => `你在审计一个人的记忆库,找出其中**互相矛盾**的条目。\n\n下面是编号的记忆。找出那些**不可能同时为真**的配对。\n\n${numberedNotes}\n\n算矛盾的情况:\n- 同一属性上出现互斥的值(「住在巴黎」vs「搬到了柏林」)\n- 后来的记忆违反了先前陈述的偏好或约束(「吃素」vs「那块牛排很好吃」)\n- 后来的事实取代了先前的(「用 MySQL」vs「已迁移到 PostgreSQL」)\n\n**不算**矛盾 —— 请严格,以下是最常见的误判:\n- 累加的事实。两者可以同时成立(「养了一只狗叫 Buddy」+「又领养了第二只叫 Scout」**不是**矛盾)\n- 两条记忆本身已经体现了随时间的变化\n- 只是主题相似或相关\n- 场景不同(在公司喝咖啡,在家喝茶)\n\n对每一对矛盾,还要指出哪一条是**已失效的**(不再为真的那条)。请从**措辞**判断,不要假设顺序:\n「以前」「2019 年那会儿」「上个月搬了」「改用了」这类说法能告诉你哪条描述的是过去。\n这些记忆**不是按时间顺序排列的**,编号也不代表新旧。\n\n如果无法判断哪条已失效,就填 null。这是一个**正常且有用**的回答 —— 宁可填 null 也不要猜,\n因为猜错会让一条**仍然为真**的记忆被停用。\n\n只返回 JSON 数组。没有真正矛盾就返回空数组:\n[{\"a\": 0, \"b\": 3, \"superseded\": 0, \"reason\": \"一句话说明是哪个属性互斥\"}]\n\n\"superseded\" 只能是 \"a\" 的值、\"b\" 的值,或 null。\n使用上面显示的编号。同一对只报一次。**宁可不报,也不要猜。**`,\n}\n\n// ── Export ────────────────────────────────────────────────────────────────────\n\nconst templates: Record<PromptLocale, LocalePrompts> = { en, zh }\n\nexport const t = templates[LOCALE]\n","/**\n * memory.ts — A-MEM core logic: addMemory, searchMemory, listMemories\n * Full TypeScript port of amem_client.py\n */\n\nimport { v4 as uuidv4 } from 'uuid'\nimport { createHash } from 'crypto'\nimport * as fs from 'fs'\nimport * as path from 'path'\nimport { encode, cosineSimilarity } from './embedding.js'\nimport { createStorageContext, type MemoryNote, type StorageContext } from './storage.js'\nimport { canWrite } from './auth.js'\nimport {\n llmConstructNote,\n llmShouldLink,\n llmEvolveNote,\n llmShouldMerge,\n llmEvolutionJudge,\n llmConflictScan,\n} from './llm.js'\nimport { shouldRunEvolution } from './evo-counter.js'\nimport { getDataDir } from './config.js'\nimport { Jieba } from '@node-rs/jieba'\n\n/**\n * Strip line breaks from an identifier before it reaches a log line. Agent ids\n * come from session/config input and these logs go to a plain console, so a\n * crafted id could otherwise forge extra log entries (CodeQL: js/log-injection).\n */\nconst logSafe = (id: string): string => id.replace(/[\\r\\n]/g, '')\n\n// ── BM25 helpers ──────────────────────────────────────────────────────────────\n\n// Lazy-initialized Jieba instance (Story 21: Chinese word segmentation)\nlet _jieba: Jieba | null = null\nfunction getJieba(): Jieba {\n if (!_jieba) _jieba = new Jieba()\n return _jieba\n}\n\n/**\n * Tokenize text for BM25 indexing.\n * Story 21: Chinese text is segmented with Jieba (HMM mode) before indexing.\n * Non-Chinese text falls back to whitespace/word-boundary splitting.\n * Mixed text (e.g. \"检索Qdrant结果\") is handled correctly — Jieba preserves\n * ASCII tokens as-is while segmenting CJK spans.\n */\nexport function simpleTokenize(text: string): string[] {\n const hasChinese = /[\\u4e00-\\u9fff]/.test(text)\n if (hasChinese) {\n // Jieba cut with HMM=true for unknown word recognition\n return getJieba()\n .cut(text, true)\n .map((t) => t.toLowerCase().trim())\n .filter((t) => t.length > 0 && /[\\w\\u4e00-\\u9fff]/.test(t))\n }\n return Array.from(text.toLowerCase().matchAll(/[\\w]+/g)).map((m) => m[0])\n}\n\nexport interface BM25State {\n ids: string[]\n corpus: string[][]\n idf: Map<string, number>\n avgdl: number\n}\n\nexport function buildBM25(notes: MemoryNote[]): BM25State {\n const ids = notes.map((n) => n.id)\n const corpus = notes.map((n) => {\n const text = [n.content, ...n.keywords, ...n.tags].join(' ')\n return simpleTokenize(text)\n })\n\n // IDF\n const df = new Map<string, number>()\n for (const tokens of corpus) {\n for (const t of new Set(tokens)) df.set(t, (df.get(t) ?? 0) + 1)\n }\n const N = corpus.length\n const idf = new Map<string, number>()\n df.forEach((freq, term) => {\n idf.set(term, Math.log((N - freq + 0.5) / (freq + 0.5) + 1))\n })\n\n const avgdl = corpus.reduce((s, t) => s + t.length, 0) / Math.max(N, 1)\n return { ids, corpus, idf, avgdl }\n}\n\nexport function bm25Score(state: BM25State, queryTokens: string[], k1 = 1.5, b = 0.75): [string, number][] {\n const scores: [string, number][] = state.ids.map((id, i) => {\n const doc = state.corpus[i]\n const dl = doc.length\n const tf = new Map<string, number>()\n for (const t of doc) tf.set(t, (tf.get(t) ?? 0) + 1)\n\n let score = 0\n for (const t of queryTokens) {\n const f = tf.get(t) ?? 0\n if (f === 0) continue\n const idfVal = state.idf.get(t) ?? 0\n score += idfVal * ((f * (k1 + 1)) / (f + k1 * (1 - b + b * (dl / state.avgdl))))\n }\n return [id, score]\n })\n return scores.sort((a, b) => b[1] - a[1])\n}\n\n// ── RRF merge ─────────────────────────────────────────────────────────────────\nexport function rrfMerge(embIds: string[], bm25Ids: string[], k = 60): [string, number][] {\n const scores = new Map<string, number>()\n embIds.forEach((id, rank) => scores.set(id, (scores.get(id) ?? 0) + 1 / (k + rank + 1)))\n bm25Ids.forEach((id, rank) => scores.set(id, (scores.get(id) ?? 0) + 1 / (k + rank + 1)))\n return Array.from(scores.entries()).sort((a, b) => b[1] - a[1])\n}\n\n// ── Build embedding text (same as Python) ─────────────────────────────────────\nexport function buildEmbedText(note: Pick<MemoryNote, 'content' | 'keywords' | 'tags' | 'context'>): string {\n let text = note.content\n if (note.keywords.length) text += ' ' + note.keywords.join(' ')\n if (note.tags.length) text += ' ' + note.tags.join(' ')\n if (note.context) text += ' ' + note.context\n return text\n}\n\n// ── Story 31: Quality gate ────────────────────────────────────────────────────\nconst EPHEMERAL_SIGNALS = ['待跑', '等确认', '昨日', '明天完成']\n\nexport interface QualityCheckResult {\n ok: boolean\n ephemeral: boolean\n reason?: string\n}\n\nexport function checkQuality(content: string): QualityCheckResult {\n const trimmed = content.trim()\n if (trimmed.length < 10) {\n return { ok: false, ephemeral: false, reason: `内容过短(${trimmed.length} 字,最少 10 字)` }\n }\n const ephemeral = EPHEMERAL_SIGNALS.some((w) => trimmed.includes(w))\n return { ok: true, ephemeral }\n}\n\n// ── Story 32: default storage context helper ──────────────────────────────────\n/** Returns the default storage context (mode A: shared collection, agent filter). */\nfunction defaultCtx(): StorageContext {\n return createStorageContext()\n}\n\n// ── addMemory ─────────────────────────────────────────────────────────────────\nexport async function addMemory(\n content: string,\n agentId = 'main',\n opts?: {\n scope?: 'private' | 'shared'\n storageCtx?: StorageContext\n /**\n * Story 44: who this memory is about. Empty (the default) means it is about\n * the world or the agent itself, and stays visible whoever is present.\n */\n subjects?: string[]\n }\n): Promise<string> {\n const scope = opts?.scope ?? 'private'\n const subjects = opts?.subjects ?? []\n const ctx = opts?.storageCtx ?? defaultCtx()\n\n // ── Story 31: Quality gate ──────────────────────────────────────────────────\n const quality = checkQuality(content)\n if (!quality.ok) {\n throw new Error(`[quality] 写入拒绝: ${quality.reason}`)\n }\n\n // ── Story 32: effective agent_id for the stored note ─────────────────────────\n // shared scope writes agent_id='shared'; private scope writes the real agentId\n const effectiveNoteAgentId = scope === 'shared' ? 'shared' : agentId\n\n // ── Layer 1: Exact hash dedup (before LLM & embedding, cheapest check) ──────\n const hash = createHash('md5').update(content).digest('hex')\n const existingByHash = await ctx.findByHash(hash, agentId)\n if (existingByHash) {\n console.log(`[add] dedup: exact hash match, skipping (id=${existingByHash.id.slice(0, 8)})`)\n return existingByHash.id\n }\n\n console.log('[add] Constructing note...')\n\n // Step 1: Note Construction\n const { keywords, tags, context, category, note_type, topics } = await llmConstructNote(content)\n console.log(` keywords: ${keywords.join(', ')}`)\n console.log(` tags: ${tags.join(', ')}`)\n console.log(` context: ${context}`)\n console.log(` category: ${category}`)\n console.log(` note_type: ${note_type}`)\n console.log(` topics: ${topics.join(', ')}`)\n\n const fieldsText = buildEmbedText({ content, keywords, tags, context })\n const embedding = await encode(fieldsText)\n\n // ── Layer 2: High-similarity vector dedup (UPDATE instead of INSERT) ─────────\n const topMatch = await ctx.queryByEmbedding(embedding, 1, agentId, 0.0)\n if (topMatch.length > 0 && topMatch[0].score >= 0.85) {\n // Story 33: the query also returns SHARED notes owned by other agents. Only\n // fold into the match when we may write it; otherwise fall through and insert\n // our own note rather than overwriting someone else's memory.\n if (canWrite(topMatch[0].note, agentId)) {\n console.log(`[add] dedup: high-sim match (sim=${topMatch[0].score.toFixed(3)}), updating existing`)\n await ctx.updateNoteContent(topMatch[0].note.id, content, embedding, hash, agentId)\n return topMatch[0].note.id\n }\n console.log(\n `[add] dedup: high-sim match ${topMatch[0].note.id.slice(0, 8)} is not writable by ${logSafe(agentId)} — inserting a new note instead`\n )\n }\n\n // ── Layer 2b: Pending merge flag for borderline similarity (0.72-0.85) ──────\n const pendingMerge = topMatch.length > 0 && topMatch[0].score >= 0.72 && topMatch[0].score < 0.85\n if (pendingMerge) {\n console.log(`[add] dedup: borderline sim (sim=${topMatch[0].score.toFixed(3)}), marking pending_merge=true`)\n }\n\n // ── Story 32: ownership and access control fields ─────────────────────────\n const readers: string[] = scope === 'shared' ? ['*'] : [agentId]\n const writers: string[] = [agentId]\n\n const note: MemoryNote = {\n id: uuidv4(),\n subjects,\n content,\n timestamp: new Date().toISOString(),\n keywords,\n tags,\n context,\n embedding,\n links: [],\n agent_id: effectiveNoteAgentId,\n hash,\n // 13-A\n retrieval_count: 0,\n last_accessed: new Date().toISOString(),\n // 13-B\n evolution_history: [],\n // 13-E\n category,\n is_active: true,\n // 26A\n note_type,\n // 26B\n topics,\n // 29\n pending_merge: pendingMerge,\n // 30\n conflict: false,\n // 31\n ephemeral: quality.ephemeral,\n low_quality: false,\n // 32\n owner: agentId,\n readers,\n writers,\n }\n\n // Save first\n await ctx.addNote(note)\n console.log(` saved note ${note.id}`)\n\n // Step 2: Link Generation\n try {\n const total = await ctx.countNotes(agentId)\n if (total > 1) {\n const candidates = await ctx.queryByEmbedding(embedding, 6, agentId, 0.0)\n\n const linkedIds: string[] = []\n const linkedContents: string[] = []\n\n for (const { note: cand, score } of candidates) {\n if (cand.id === note.id) continue\n if (score < 0.3) continue\n\n console.log(` candidate ${cand.id.slice(0, 8)}... sim=${score.toFixed(3)}, asking LLM...`)\n const shouldLink = await llmShouldLink(content, cand.content)\n if (shouldLink) {\n linkedIds.push(cand.id)\n linkedContents.push(cand.content)\n console.log(` → linked!`)\n }\n }\n\n if (linkedIds.length > 0) {\n note.links = linkedIds\n await ctx.updateNote(note)\n\n // Bidirectional links — Story 33: only write the back-link into notes we\n // may write. A linked note can be another agent's shared note; the forward\n // link on our own note still stands.\n for (const lid of linkedIds) {\n const linked = await ctx.getNote(lid, agentId)\n if (linked && !linked.links.includes(note.id)) {\n if (!canWrite(linked, agentId)) {\n console.log(`[link] back-link into ${lid.slice(0, 8)} skipped — not writable by ${logSafe(agentId)}`)\n continue\n }\n linked.links.push(note.id)\n await ctx.updateNote(linked)\n }\n }\n\n // Step 3: Memory Evolution (up to 3) — gated by evo_threshold (Story 13-C)\n if (shouldRunEvolution()) {\n console.log(` [evo] threshold reached, running evolution for ${Math.min(linkedIds.length, 3)} linked notes`)\n for (const lid of linkedIds.slice(0, 3)) {\n const linked = await ctx.getNote(lid, agentId)\n if (!linked) continue\n // Story 33: evolution rewrites the linked note's tags/context/embedding.\n // Skip notes we may not write (e.g. another agent's shared note) — this\n // one check covers every mutation the evolution of `linked` would make.\n if (!canWrite(linked, agentId)) {\n console.log(` [evo] skipping ${lid.slice(0, 8)} — not writable by ${logSafe(agentId)}`)\n continue\n }\n\n // Gather link contents and IDs — Story 36: `linked` may be a shared\n // note whose links name its owner's private notes. Reading by id\n // bypasses the agent filter, so pass the caller; unreadable\n // neighbours come back null and never reach the LLM prompt.\n const linkedNotes: Array<{ id: string; content: string }> = []\n for (const llid of linked.links.slice(0, 5)) {\n if (llid === note.id) continue\n const ln = await ctx.getNote(llid, agentId)\n if (ln) linkedNotes.push({ id: ln.id, content: ln.content })\n }\n linkedNotes.push({ id: note.id, content }) // new note exactly once\n\n const oldTags = [...linked.tags]\n const oldContext = linked.context\n\n const {\n tags: newTags,\n context: newContext,\n shouldStrengthen,\n suggestedConnections,\n tagsToUpdate,\n } = await llmEvolveNote(linked.content, linkedNotes)\n\n let evolved = false\n\n // Standard update_neighbor action\n if (newTags !== null || newContext !== null) {\n if (newTags !== null) linked.tags = newTags\n if (newContext !== null) linked.context = newContext\n\n linked.evolution_history = linked.evolution_history || []\n linked.evolution_history.push({\n triggeredBy: note.id,\n triggeredAt: new Date().toISOString(),\n oldContext,\n newContext: newContext ?? oldContext,\n oldTags,\n newTags: newTags ?? oldTags,\n action: 'update_neighbor',\n })\n evolved = true\n }\n\n let noteChanged = false\n let noteTagsChanged = false\n\n // Strengthen action\n if (shouldStrengthen && suggestedConnections.length > 0) {\n // 1. 双向链接绑定\n for (const targetId of suggestedConnections) {\n if (!note.links.includes(targetId)) {\n note.links.push(targetId)\n noteChanged = true\n }\n // Story 36: targetId comes from the LLM's suggestedConnections —\n // an arbitrary id, not something the agent filter vetted.\n const target = await ctx.getNote(targetId, agentId)\n if (target && !target.links.includes(note.id)) {\n // Story 33: strengthen reaches notes via the link neighbourhood,\n // which can include notes this agent may not write.\n if (canWrite(target, agentId)) {\n target.links.push(note.id)\n await ctx.updateNote(target)\n } else {\n console.log(` [evo] strengthen back-link into ${targetId.slice(0, 8)} skipped — not writable`)\n }\n }\n }\n // 2. 更新新写入 memory 的 note.tags\n if (tagsToUpdate.length > 0) {\n note.tags = tagsToUpdate\n noteChanged = true\n noteTagsChanged = true\n }\n\n // 3. 记录 strengthen 操作到被加强的邻居 (linked) 的 evolution_history 中\n linked.evolution_history = linked.evolution_history || []\n linked.evolution_history.push({\n triggeredBy: note.id,\n triggeredAt: new Date().toISOString(),\n oldContext: linked.context,\n newContext: linked.context,\n oldTags: [...linked.tags],\n newTags: [...linked.tags],\n action: 'strengthen',\n suggestedConnections,\n tagsUpdated: tagsToUpdate,\n })\n evolved = true\n }\n\n if (noteChanged) {\n if (noteTagsChanged) {\n note.embedding = await encode(buildEmbedText(note))\n }\n await ctx.updateNote(note)\n }\n\n if (evolved) {\n // Re-compute embedding after evolution\n if (newTags !== null || newContext !== null) {\n linked.embedding = await encode(buildEmbedText(linked))\n }\n await ctx.updateNote(linked)\n console.log(` evolved/strengthened note ${lid.slice(0, 8)}...`)\n }\n }\n } else {\n console.log(` [evo] threshold not reached, skipping evolution this round`)\n }\n }\n }\n } catch (e) {\n console.error(`[warn] Link/Evolution phase failed: ${(e as Error).message}`)\n }\n\n console.log(`[done] Note added: ${note.id}`)\n return note.id\n}\n\n// ── addEpisodic ───────────────────────────────────────────────────────────────\n/**\n * The cheap write path: quality gate → embed the raw content → store.\n *\n * Deliberately skips LLM note construction, similarity dedup, link generation\n * and evolution, so a real-time caller (a game brain logging events tick by\n * tick) never pays for an LLM round-trip. Cost is one embed + one upsert.\n *\n * Episodic notes are an **append-only, faithful event log**: the same content\n * written twice is two events, so there is no hash or vector dedup here.\n * Evolution rewrites a note's context over time — precisely what you do not\n * want for \"remember the time the ender dragon killed us\". The offline\n * consolidation pass distils these raw events into long-term, linked notes.\n */\nexport async function addEpisodic(\n content: string,\n agentId = 'main',\n opts?: {\n scope?: 'private' | 'shared'\n storageCtx?: StorageContext\n /** Story 44: who this episode is about. Empty = world/self. */\n subjects?: string[]\n }\n): Promise<string> {\n const scope = opts?.scope ?? 'private'\n const subjects = opts?.subjects ?? []\n const ctx = opts?.storageCtx ?? defaultCtx()\n\n const quality = checkQuality(content)\n if (!quality.ok) {\n throw new Error(`[quality] 写入拒绝: ${quality.reason}`)\n }\n\n // No note construction, so keywords/tags/context/topics stay empty and the\n // embedding covers the raw content alone.\n const embedding = await encode(content)\n const now = new Date().toISOString()\n\n const note: MemoryNote = {\n id: uuidv4(),\n subjects,\n content,\n timestamp: now,\n keywords: [],\n tags: [],\n context: '',\n embedding,\n links: [],\n agent_id: scope === 'shared' ? 'shared' : agentId,\n hash: createHash('md5').update(content).digest('hex'),\n retrieval_count: 0,\n last_accessed: now,\n evolution_history: [],\n category: 'General',\n is_active: true,\n note_type: 'memory',\n topics: [],\n pending_merge: false,\n conflict: false,\n ephemeral: quality.ephemeral,\n low_quality: false,\n owner: agentId,\n readers: scope === 'shared' ? ['*'] : [agentId],\n writers: [agentId],\n }\n\n await ctx.addNote(note)\n return note.id\n}\n\n// ── searchMemory ──────────────────────────────────────────────────────────────\nexport interface SearchResult {\n id: string\n content: string\n context: string\n tags: string[]\n keywords: string[]\n links: string[]\n timestamp: string\n /**\n * Cosine similarity to the query. **Not** what ordered this list — that is\n * `rrf`, which fuses the dense and BM25 rankings and then applies a heat/recency\n * boost. The two disagree often, and a consumer that reads `similarity` as the\n * ranking score concludes the ranking is broken.\n */\n similarity: number\n /**\n * The fused score the matches are sorted by, and 0 for anything no retriever\n * ranked. It is `via`, not this, that says why a row is here.\n */\n rrf: number\n /**\n * Why this note is in the results.\n *\n * `match` — it was retrieved for the query and ranked by `rrf`.\n * `link` — it was **not** retrieved; it is here because it links to one that\n * was, within two hops and above the relevance gate. These are appended in\n * discovery order after the matches and have no `rrf` of their own, so reading\n * the tail of the list as \"lower-ranked matches\" is wrong.\n */\n via: 'match' | 'link'\n // Story 26B\n topics: string[]\n note_type: 'memory' | 'knowledge'\n}\n\nexport async function searchMemory(\n query: string,\n topK = 5,\n agentId = 'main',\n opts?: {\n useBfs?: boolean\n // Story 22: BFS relevance gate — linked notes with cos-sim below this threshold\n // are skipped to reduce noise. Set to 0 to disable (admit all linked notes).\n bfsSimThreshold?: number\n // Story 26B: if set, only return knowledge notes that contain ALL of these topics\n topicsFilter?: string[]\n // Story 32: optional storage context for mode B isolation\n storageCtx?: StorageContext\n /**\n * Story 44: scope retrieval to one person. Returns memories that name them\n * plus memories that name nobody (world facts, facts about the agent).\n * Omitted = no person scoping, i.e. today's behaviour.\n */\n subject?: string\n }\n): Promise<SearchResult[]> {\n const useBfs = opts?.useBfs !== false // default true\n const subject = opts?.subject\n const bfsSimThreshold = opts?.bfsSimThreshold ?? 0.25 // Story 22 default\n const ctx = opts?.storageCtx ?? defaultCtx()\n const total = await ctx.countNotes(agentId)\n if (total === 0) return []\n\n // Embedding retrieval\n const queryEmbedding = await encode(query)\n const n = Math.min(Math.max(topK * 4, 20), total)\n const embResults = await ctx.queryByEmbedding(queryEmbedding, n, agentId, 0.0, subject)\n\n // BM25 retrieval\n // Scope this too: it feeds BM25 AND the BFS neighbourhood map, so leaving it\n // unscoped would leak another person's memories through keyword search or a\n // link expansion even though the vector path was filtered.\n const allNotes = await ctx.listNotes(agentId, subject)\n const bm25State = buildBM25(allNotes)\n const queryTokens = simpleTokenize(query)\n // Only notes the query actually hits.\n //\n // `bm25Score` is a scorer, not a retriever: it returns every note in the store,\n // and the ones that share no term with the query score exactly 0 and sort among\n // themselves in scroll order. Slicing that without filtering handed RRF up to\n // `n` notes chosen by nothing at all, at the same rank weights as the dense\n // hits — measured on a 50-note store with a query that hit no term, the first\n // zero-scoring note and the top dense result both came out at 0.0163934. Scroll\n // order is stable, so it was the same notes polluting every such query rather\n // than noise that averages out.\n //\n // `> 0` is exactly \"contains a query term\" here: the idf is the `+ 1` variant,\n // which stays positive even for a term present in every note, so a real match\n // can never be filtered out by this.\n //\n // With nothing left, RRF degenerates to the dense ranking, which is the correct\n // answer for a query with no lexical hits.\n const bm25Ranked = bm25Score(bm25State, queryTokens)\n .filter(([, score]) => score > 0)\n .slice(0, n)\n\n // RRF fusion with retrieval_count heat boost (Story 13-A)\n const merged = rrfMerge(\n embResults.map((r) => r.note.id),\n bm25Ranked.map((r) => r[0])\n )\n\n // Story 23: heat boost with time decay\n // Older frequently-retrieved notes should not permanently outrank fresher ones.\n // Score = RRF × (1 + 0.05 × ln(1 + retrieval_count) / (age_days + 1))\n // age_days is measured from last_accessed so re-retrieval resets the clock.\n const now = Date.now()\n const noteMap = new Map(allNotes.map((n) => [n.id, n]))\n const boostedMerged: [string, number][] = merged.map(([id, rrfScore]) => {\n const note = noteMap.get(id)\n if (!note) return [id, rrfScore]\n // Story 26A: knowledge notes are timeless — skip time decay boost\n if (note.note_type === 'knowledge') return [id, rrfScore]\n const lastAccessed = new Date(note.last_accessed || note.timestamp).getTime()\n const ageDays = (now - lastAccessed) / 86_400_000\n const recencyBoost = 1 + (0.05 * Math.log(1 + (note.retrieval_count || 0))) / (ageDays + 1)\n return [id, rrfScore * recencyBoost]\n })\n boostedMerged.sort((a, b) => b[1] - a[1])\n\n const topIds = boostedMerged.slice(0, topK).map(([id]) => id)\n\n // Story 18: 2-hop BFS link expansion (can be disabled via opts.useBfs=false for ablation)\n // Walk the link graph up to 2 hops from each top result to surface\n // contextually related notes that scored too low for direct retrieval.\n const BFS_MAX_HOPS = 2\n const BFS_MAX_EXPAND = 8 // max extra notes to add via BFS (cap to avoid bloat)\n const visitedIds = new Set<string>(topIds)\n const bfsQueue: Array<{ id: string; hop: number }> = useBfs ? topIds.map((id) => ({ id, hop: 0 })) : []\n const bfsExtra: string[] = [] // IDs discovered via BFS, in discovery order\n /**\n * How close each link-expanded note is to the query.\n *\n * The gate below already computes this and used to drop it on the floor, which\n * left every expanded note reporting `similarity: 0` — `embSimMap` only holds\n * the dense top-n, and a note that made the dense top-n would have been\n * retrieved directly rather than expanded into. So the one number a caller has\n * for judging an expanded note was always zero, and expansion read as broken.\n */\n const bfsSimMap = new Map<string, number>()\n\n while (bfsQueue.length > 0 && bfsExtra.length < BFS_MAX_EXPAND) {\n const item = bfsQueue.shift()!\n if (item.hop >= BFS_MAX_HOPS) continue\n const note = noteMap.get(item.id)\n if (!note) continue\n for (const linkedId of note.links) {\n if (visitedIds.has(linkedId)) continue\n visitedIds.add(linkedId)\n // Only include active notes (is_active !== false)\n const linked = noteMap.get(linkedId)\n if (!linked || linked.is_active === false) continue\n // Computed even when the gate is off, because it is also what the caller\n // sees. An empty embedding scores 0 and is dropped by any gate above 0,\n // which is what the previous truthiness check already did — `[]` is truthy.\n const sim = cosineSimilarity(queryEmbedding, linked.embedding)\n // Story 22: relevance gate — skip BFS nodes too far from the query\n if (bfsSimThreshold > 0 && sim < bfsSimThreshold) continue\n bfsSimMap.set(linkedId, sim)\n bfsExtra.push(linkedId)\n bfsQueue.push({ id: linkedId, hop: item.hop + 1 })\n if (bfsExtra.length >= BFS_MAX_EXPAND) break\n }\n }\n\n // Story 26B: apply topicsFilter — keep only knowledge notes that contain ALL requested topics\n const topicsFilter = opts?.topicsFilter\n const filteredTopIds =\n topicsFilter && topicsFilter.length > 0\n ? topIds.filter((id) => {\n const note = noteMap.get(id)\n if (!note) return false\n if (note.note_type !== 'knowledge') return true // pass-through non-knowledge notes\n return topicsFilter.every((t) => note.topics.map((s) => s.toLowerCase()).includes(t.toLowerCase()))\n })\n : topIds\n\n // Build result map\n const embSimMap = new Map(embResults.map((r) => [r.note.id, r.score]))\n const rrfMap = new Map(boostedMerged.map(([id, score]) => [id, score]))\n\n const results: SearchResult[] = []\n const ordered: Array<[string, SearchResult['via']]> = [\n ...filteredTopIds.map((id) => [id, 'match'] as [string, SearchResult['via']]),\n ...bfsExtra.map((id) => [id, 'link'] as [string, SearchResult['via']]),\n ]\n for (const [id, via] of ordered) {\n const note = noteMap.get(id)\n if (!note) continue\n results.push({\n id: note.id,\n content: note.content,\n context: note.context,\n tags: note.tags,\n keywords: note.keywords,\n links: note.links,\n timestamp: note.timestamp,\n similarity: embSimMap.get(id) ?? bfsSimMap.get(id) ?? 0,\n rrf: rrfMap.get(id) ?? 0,\n via,\n topics: note.topics ?? [],\n note_type: note.note_type ?? 'memory',\n })\n }\n\n return results\n}\n\n// ── listMemories ──────────────────────────────────────────────────────────────\nexport async function listMemories(agentId = 'main', storageCtx?: StorageContext): Promise<{ count: number }> {\n const ctx = storageCtx ?? defaultCtx()\n const count = await ctx.countNotes(agentId)\n return { count }\n}\n\n// ── mergeSimilarNotes ──────────────────────────────────────────────────────────\n\n/** Sleep helper */\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms))\n}\n\n/**\n * Merge semantically similar notes written today.\n * Called asynchronously from agent_end hook; failures are silent.\n * Returns the number of notes merged (deleted).\n *\n * Story 30: pending_merge=true notes are routed through LLM evolution judgment\n * (EVOLVE/CONFLICT/EXPAND/NEW) instead of simple merge.\n * Story 32: shared notes (agent_id='shared') are never merged/consolidated.\n */\nexport async function mergeSimilarNotes(agentId: string, storageCtx?: StorageContext): Promise<number> {\n const ctx = storageCtx ?? defaultCtx()\n const today = new Date().toISOString().slice(0, 10) // \"YYYY-MM-DD\"\n const allNotes = await ctx.getNotesByDatePrefix(today, agentId)\n\n // Story 32: only process private notes — skip shared entries entirely\n const notes = allNotes.filter((n) => n.agent_id !== 'shared')\n\n // ── Story 30: Evolution processing for pending_merge notes ────────────────\n const pendingNotes = notes.filter((n) => n.pending_merge === true)\n let evolvedCount = 0\n\n for (const pendingNote of pendingNotes) {\n // Find the closest non-pending neighbor\n let bestSim = -1\n let bestNeighbor: MemoryNote | null = null\n for (const other of notes) {\n if (other.id === pendingNote.id) continue\n if (other.pending_merge) continue\n if (!other.embedding.length || !pendingNote.embedding.length) continue\n const sim = cosineSimilarity(pendingNote.embedding, other.embedding)\n if (sim > bestSim) {\n bestSim = sim\n bestNeighbor = other\n }\n }\n\n if (!bestNeighbor) {\n await ctx.patchNotePayload(pendingNote.id, { pending_merge: false })\n continue\n }\n\n const judgment = await llmEvolutionJudge(bestNeighbor.content, pendingNote.content)\n console.log(\n `[merge] evolution judgment: ${pendingNote.id.slice(0, 8)} → ${bestNeighbor.id.slice(0, 8)}: ${judgment.type}`\n )\n\n if (judgment.type === 'EVOLVE') {\n const oldHistory = bestNeighbor.evolution_history || []\n oldHistory.push({\n triggeredBy: pendingNote.id,\n triggeredAt: new Date().toISOString(),\n oldContext: bestNeighbor.context,\n newContext: bestNeighbor.context,\n oldTags: [...bestNeighbor.tags],\n newTags: [...bestNeighbor.tags],\n action: 'consolidate',\n })\n const mergedContent = judgment.mergedContent || pendingNote.content\n const newEmbedding = await encode(buildEmbedText({ ...bestNeighbor, content: mergedContent }))\n const newHash = createHash('md5').update(mergedContent).digest('hex')\n await ctx.updateNoteContent(bestNeighbor.id, mergedContent, newEmbedding, newHash, agentId)\n await ctx.patchNotePayload(bestNeighbor.id, {\n evolution_history: JSON.stringify(oldHistory),\n evolution_type: 'EVOLVE',\n })\n await ctx.deleteNote(pendingNote.id)\n evolvedCount++\n } else if (judgment.type === 'CONFLICT') {\n await ctx.patchNotePayload(pendingNote.id, { pending_merge: false, conflict: true, evolution_type: 'CONFLICT' })\n await ctx.patchNotePayload(bestNeighbor.id, { conflict: true, evolution_type: 'CONFLICT' })\n } else if (judgment.type === 'EXPAND') {\n const oldHistory = bestNeighbor.evolution_history || []\n oldHistory.push({\n triggeredBy: pendingNote.id,\n triggeredAt: new Date().toISOString(),\n oldContext: bestNeighbor.context,\n newContext: bestNeighbor.context,\n oldTags: [...bestNeighbor.tags],\n newTags: [...bestNeighbor.tags],\n action: 'consolidate',\n })\n const mergedContent = judgment.mergedContent || `${bestNeighbor.content};${pendingNote.content}`\n const newEmbedding = await encode(buildEmbedText({ ...bestNeighbor, content: mergedContent }))\n const newHash = createHash('md5').update(mergedContent).digest('hex')\n await ctx.updateNoteContent(bestNeighbor.id, mergedContent, newEmbedding, newHash, agentId)\n await ctx.patchNotePayload(bestNeighbor.id, {\n evolution_history: JSON.stringify(oldHistory),\n evolution_type: 'EXPAND',\n })\n await ctx.deleteNote(pendingNote.id)\n evolvedCount++\n } else {\n // NEW — just clear pending_merge\n await ctx.patchNotePayload(pendingNote.id, { pending_merge: false, evolution_type: 'NEW' })\n }\n\n await sleep(200)\n }\n\n // ── Original merge logic for non-pending notes ────────────────────────────\n // Not enough notes to bother\n if (notes.length < 5) return evolvedCount\n\n // Build list of (i, j) candidate pairs with sim >= 0.80\n // Exclude pending_merge notes (already handled above)\n const pendingIds = new Set(pendingNotes.map((n) => n.id))\n\n interface SimPair {\n i: number\n j: number\n sim: number\n }\n\n const pairs: SimPair[] = []\n for (let i = 0; i < notes.length; i++) {\n if (pendingIds.has(notes[i].id)) continue\n for (let j = i + 1; j < notes.length; j++) {\n if (pendingIds.has(notes[j].id)) continue\n if (!notes[i].embedding.length || !notes[j].embedding.length) continue\n const sim = cosineSimilarity(notes[i].embedding, notes[j].embedding)\n if (sim >= 0.8) {\n pairs.push({ i, j, sim })\n }\n }\n }\n\n if (pairs.length === 0) return evolvedCount\n\n // Sort by similarity descending, take top 10\n pairs.sort((a, b) => b.sim - a.sim)\n const topPairs = pairs.slice(0, 10)\n\n // Track deleted IDs to skip stale pairs\n const deletedIds = new Set<string>()\n let mergedCount = 0\n\n for (const { i, j } of topPairs) {\n const noteA = notes[i]\n const noteB = notes[j]\n\n // Skip if either has already been deleted\n if (deletedIds.has(noteA.id) || deletedIds.has(noteB.id)) continue\n\n const result = await llmShouldMerge(noteA.content, noteB.content)\n\n if (result.shouldMerge && result.merged) {\n // Keep the longer note (more complete), update its content, delete the other\n const [keepNote, dropNote] = noteA.content.length >= noteB.content.length ? [noteA, noteB] : [noteB, noteA]\n\n const newEmbedding = await encode(result.merged)\n const newHash = createHash('md5').update(result.merged).digest('hex')\n await ctx.updateNoteContent(keepNote.id, result.merged, newEmbedding, newHash, agentId)\n await ctx.deleteNote(dropNote.id)\n deletedIds.add(dropNote.id)\n mergedCount++\n }\n\n // Rate-limit: 200ms between LLM calls\n await sleep(200)\n }\n\n return evolvedCount + mergedCount\n}\n\n/**\n * Consolidate semantically similar memories.\n * Performs deep deduplication by category and similarity score.\n * Story 32: shared notes (agent_id='shared') are skipped entirely.\n */\nexport async function consolidateMemories(agentId: string, logger?: any, storageCtx?: StorageContext): Promise<number> {\n const ctx = storageCtx ?? defaultCtx()\n const log = {\n info: (msg: string) => (logger ? logger.info(msg) : console.log(msg)),\n warn: (msg: string) => (logger ? logger.warn(msg) : console.warn(msg)),\n error: (msg: string) => (logger ? logger.error(msg) : console.error(msg)),\n }\n\n log.info(`[Consolidation] Starting consolidation for agentId: ${agentId}`)\n\n // 1. 加载记忆:获取所有活动(is_active: true)记忆条目\n const rawNotes = await ctx.listNotes(agentId)\n // Story 32: skip shared notes — they are read-only for non-owners\n const allNotes = rawNotes.filter((n) => n.agent_id !== 'shared')\n log.info(\n `[Consolidation] Loaded ${allNotes.length} active private notes (${rawNotes.length - allNotes.length} shared skipped).`\n )\n\n // 2. 分类分组:根据 category 字段将记忆分组\n // Story 26A: skip knowledge notes — they are durable and should not be merged\n const groups = new Map<string, MemoryNote[]>()\n for (const note of allNotes) {\n if (note.note_type === 'knowledge') continue\n const category = note.category || 'General'\n if (!groups.has(category)) {\n groups.set(category, [])\n }\n groups.get(category)!.push(note)\n }\n\n // 3. 两两比对:在每个分类分组内计算余弦相似度\n interface CandidatePair {\n noteA: MemoryNote\n noteB: MemoryNote\n similarity: number\n }\n const candidates: CandidatePair[] = []\n\n for (const [category, groupNotes] of groups.entries()) {\n log.info(`[Consolidation] Category \"${category}\" has ${groupNotes.length} notes.`)\n for (let i = 0; i < groupNotes.length; i++) {\n for (let j = i + 1; j < groupNotes.length; j++) {\n const noteA = groupNotes[i]\n const noteB = groupNotes[j]\n if (!noteA.embedding.length || !noteB.embedding.length) continue\n const sim = cosineSimilarity(noteA.embedding, noteB.embedding)\n if (sim >= 0.75) {\n candidates.push({ noteA, noteB, similarity: sim })\n }\n }\n }\n }\n\n // 4. 筛选候选对:按相似度从高到低排序,限制最多 15 对\n candidates.sort((a, b) => b.similarity - a.similarity)\n const topPairs = candidates.slice(0, 15)\n log.info(\n `[Consolidation] Found ${candidates.length} candidate pairs with similarity >= 0.75. Processing top ${topPairs.length}.`\n )\n\n const processedIds = new Set<string>()\n let mergedCount = 0\n\n // Helper: append log to log file\n function logMergeToFile(keepId: string, dropId: string, mergedContent: string) {\n const logDir = path.join(getDataDir(), 'logs')\n const logFile = path.join(logDir, 'amem-consolidate.log')\n const timestamp = new Date().toISOString()\n const logMsg = `[${timestamp}] Consolidated: KeepNote ${keepId} and DropNote ${dropId}. Merged length: ${mergedContent.length} chars.\\n`\n\n try {\n fs.mkdirSync(logDir, { recursive: true })\n fs.appendFileSync(logFile, logMsg, 'utf8')\n } catch (err) {\n log.error(`[Consolidation] Failed to write log: ${(err as Error).message}`)\n }\n }\n\n // 5. LLM 融合决策与信息继承\n for (const { noteA, noteB, similarity } of topPairs) {\n if (processedIds.has(noteA.id) || processedIds.has(noteB.id)) {\n log.info(\n `[Consolidation] Skipping pair (${noteA.id.slice(0, 8)}, ${noteB.id.slice(0, 8)}) as one or both already merged.`\n )\n continue\n }\n\n log.info(\n `[Consolidation] Evaluating pair (${noteA.id.slice(0, 8)}, ${noteB.id.slice(0, 8)}) with sim ${similarity.toFixed(4)}...`\n )\n const mergeDecision = await llmShouldMerge(noteA.content, noteB.content)\n\n if (mergeDecision.shouldMerge && mergeDecision.merged) {\n log.info(` -> LLM decision: MERGE!`)\n\n // 比较两条记忆的长度,将较长的保留作为主节点 (KeepNote)\n const [keepNote, dropNote] = noteA.content.length >= noteB.content.length ? [noteA, noteB] : [noteB, noteA]\n\n log.info(\n ` -> KeepNote: ${keepNote.id.slice(0, 8)} (len: ${keepNote.content.length}), DropNote: ${dropNote.id.slice(0, 8)} (len: ${dropNote.content.length})`\n )\n\n const oldContext = keepNote.context\n const oldTags = [...keepNote.tags]\n\n // 内容更新\n keepNote.content = mergeDecision.merged\n\n // 元数据继承:\n // - 合并 tags 与 keywords(合并后去重)\n keepNote.tags = Array.from(new Set([...keepNote.tags, ...dropNote.tags]))\n keepNote.keywords = Array.from(new Set([...keepNote.keywords, ...dropNote.keywords]))\n\n // - 合并 links 链接数组(去重且排除自身 and DropNote)\n keepNote.links = Array.from(new Set([...keepNote.links, ...dropNote.links])).filter(\n (id) => id !== keepNote.id && id !== dropNote.id\n )\n\n // - retrieval_count 累加\n keepNote.retrieval_count = (keepNote.retrieval_count || 0) + (dropNote.retrieval_count || 0)\n\n // - last_accessed 取最新的时间戳\n const keepAccessTime = new Date(keepNote.last_accessed || keepNote.timestamp).getTime()\n const dropAccessTime = new Date(dropNote.last_accessed || dropNote.timestamp).getTime()\n keepNote.last_accessed =\n keepAccessTime >= dropAccessTime\n ? keepNote.last_accessed || keepNote.timestamp\n : dropNote.last_accessed || dropNote.timestamp\n\n // 重算 embedding 与 MD5 hash\n const embedText = buildEmbedText(keepNote)\n keepNote.embedding = await encode(embedText)\n keepNote.hash = createHash('md5').update(keepNote.content).digest('hex')\n\n // 将 DropNote 的合并事件记录在 KeepNote 的 evolution_history 中\n keepNote.evolution_history = keepNote.evolution_history || []\n keepNote.evolution_history.push({\n triggeredBy: dropNote.id,\n triggeredAt: new Date().toISOString(),\n oldContext,\n newContext: keepNote.context,\n oldTags,\n newTags: keepNote.tags,\n action: 'consolidate',\n })\n\n // 更新 KeepNote\n await ctx.updateNote(keepNote)\n\n // 软删除 DropNote\n await ctx.invalidateNote(dropNote.id, agentId)\n\n // 级联更新 links\n await ctx.replaceLinkReferences(dropNote.id, keepNote.id, agentId)\n\n // 写入日志\n logMergeToFile(keepNote.id, dropNote.id, keepNote.content)\n\n processedIds.add(keepNote.id)\n processedIds.add(dropNote.id)\n mergedCount++\n } else {\n log.info(` -> LLM decision: DO NOT MERGE.`)\n }\n\n // Rate limit sleep\n await sleep(200)\n }\n\n log.info(`[Consolidation] Completed consolidation run. Merged ${mergedCount} pairs.`)\n return mergedCount\n}\n\n// ── Story 43: cold-layer contradiction sweep ──────────────────────────────────\n\n/**\n * How a detected contradiction is handled.\n *\n * `review` (default) marks both notes and leaves the decision to a human — the\n * safe option, because even a strong model is only around 55% accurate at\n * spotting implicit contradictions.\n *\n * `auto` additionally retires the older note of each pair. It needs no human,\n * but at that accuracy roughly two in five retirements will silence a memory\n * that was still true. The retirement is a soft delete, so it is recoverable —\n * but for a system answering in real time, \"recoverable\" only helps once someone\n * notices. Documented as such; opt in deliberately.\n */\nexport type ConflictMode = 'review' | 'auto'\n\nfunction resolveConflictMode(override?: ConflictMode): ConflictMode {\n const raw = (process.env.AMEM_CONFLICT_MODE || override || 'review').trim().toLowerCase()\n return raw === 'auto' ? 'auto' : 'review'\n}\n\n/** How many notes go to the model in one scan. Bounds cost and prompt size. */\nconst CONFLICT_BATCH_SIZE = 25\n\nexport interface ConflictSweepResult {\n scanned: number\n pairsFound: number\n retired: number\n batchesScanned: number\n batchesSkipped: number\n}\n\n/**\n * Find memories that contradict each other and mark them.\n *\n * This is the cold half of the tiering split. The per-turn CRUD decision runs on\n * a cheap model, which is safe (the update guard stops it writing to the wrong\n * note) but dull — it misses contradictions it should have caught. This sweep is\n * what catches them: it runs offline, in batches, on the strong tier, and it\n * sees far more context than any single turn does.\n *\n * Batches by category and hands each batch to the model whole, rather than\n * pairing by similarity — see llmConflictScan for why that distinction is the\n * entire point.\n */\nexport async function conflictSweep(\n agentId: string,\n opts?: {\n mode?: ConflictMode\n storageCtx?: StorageContext\n logger?: { info: (m: string) => void }\n /** Re-read every batch, including ones already judged. For a full re-sweep\n * after changing the prompt or the model. */\n force?: boolean\n }\n): Promise<ConflictSweepResult> {\n const ctx = opts?.storageCtx ?? defaultCtx()\n const force = opts?.force === true\n const mode = resolveConflictMode(opts?.mode)\n const log = opts?.logger?.info ?? ((m: string) => console.log(m))\n\n const raw = await ctx.listNotes(agentId)\n // Only this agent's own episodic notes: shared notes belong to someone else,\n // and knowledge notes are durable reference rather than claims about a person.\n const notes = raw.filter((n) => n.agent_id !== 'shared' && n.note_type !== 'knowledge' && n.is_active !== false)\n\n const groups = new Map<string, MemoryNote[]>()\n for (const n of notes) {\n const c = n.category || 'General'\n if (!groups.has(c)) groups.set(c, [])\n groups.get(c)!.push(n)\n }\n\n let pairsFound = 0\n let retired = 0\n\n let batchesScanned = 0\n let batchesSkipped = 0\n\n for (const [category, groupNotes] of groups.entries()) {\n // Newest first, so a note written today batches with the most recent notes\n // in its category — the ones it is most likely to contradict.\n groupNotes.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp))\n\n for (let start = 0; start < groupNotes.length; start += CONFLICT_BATCH_SIZE) {\n const batch = groupNotes.slice(start, start + CONFLICT_BATCH_SIZE)\n if (batch.length < 2) continue\n\n // Skip a batch the model has already read in full. Without this the sweep\n // re-judges every old pair every night: ~40 calls a run here, growing with\n // the store, to re-derive answers it already had. With it, a run costs one\n // call per batch that actually gained a note.\n //\n // The tradeoff, stated plainly: a new note is compared against the batch\n // it lands in, not against the category's entire history. Contradictions\n // between two OLD notes that were never batched together are not found.\n // That is the price of not paying for a full re-read daily; a full sweep\n // is still available by clearing conflict_scanned_at.\n if (!force && batch.every((n) => n.conflict_scanned_at)) {\n batchesSkipped++\n continue\n }\n batchesScanned++\n\n const pairs = await llmConflictScan(batch.map((n) => n.content))\n for (const { a, b, reason, supersededIndex } of pairs) {\n const noteA = batch[a]\n const noteB = batch[b]\n if (!noteA || !noteB) continue\n pairsFound++\n\n // Mark BOTH sides, each pointing at the other, so the pair can be shown\n // as one decision instead of two disconnected review entries.\n await ctx.patchNotePayload(noteA.id, {\n conflict: true,\n evolution_type: 'CONFLICT',\n conflicts_with: Array.from(new Set([...(noteA.conflicts_with ?? []), noteB.id])),\n conflict_reason: reason,\n })\n await ctx.patchNotePayload(noteB.id, {\n conflict: true,\n evolution_type: 'CONFLICT',\n conflicts_with: Array.from(new Set([...(noteB.conflicts_with ?? []), noteA.id])),\n conflict_reason: reason,\n })\n log(`[conflict] ${category}: ${noteA.id.slice(0, 8)} ↔ ${noteB.id.slice(0, 8)} — ${reason}`)\n\n if (mode === 'auto') {\n // Retire the side the MODEL judged superseded — never the older-by-\n // wall-clock one. A note records when it was WRITTEN, not when the fact\n // became true, and the two come apart constantly: \"back in 2019 I was\n // vegetarian\", written today, is the newer row and the older fact.\n // Retiring by ingestion time would then silence the CURRENT memory.\n const superseded = supersededIndex === a ? noteA : supersededIndex === b ? noteB : null\n if (!superseded) {\n // The model could not tell. Marking already happened above, so the\n // conflict is visible for review — but nothing gets retired on a\n // guess. This is the safe half of auto mode, not a failure.\n log(`[conflict] auto: no superseded side identified — marked only, nothing retired`)\n } else {\n const ok = await ctx.invalidateNote(superseded.id, agentId)\n if (ok) {\n retired++\n log(`[conflict] auto-retired the superseded note ${superseded.id.slice(0, 8)}`)\n }\n }\n }\n }\n\n // Mark the whole batch, not just the notes in a pair: what was judged is\n // the batch as a set, so re-reading it would ask the same question again.\n const scannedAt = new Date().toISOString()\n for (const n of batch) {\n await ctx.patchNotePayload(n.id, { conflict_scanned_at: scannedAt })\n }\n }\n }\n\n log(\n `[conflict] ${batchesScanned} batch(es) scanned, ${batchesSkipped} already up to date; ` +\n `${pairsFound} pair(s) found, ${retired} retired`\n )\n return { scanned: notes.length, pairsFound, retired, batchesScanned, batchesSkipped }\n}\n","/**\n * config.ts — runtime configuration for the amem engine.\n *\n * `dataDir` holds the evolution-throttle counter and consolidation logs.\n * Defaults to ~/.amem so the engine stays framework-agnostic; the OpenClaw\n * plugin calls configure({ dataDir: '<home>/.openclaw' }) to preserve its\n * existing on-disk location. Override via AMEM_DATA_DIR env var or configure().\n */\nimport * as os from 'os'\nimport * as path from 'path'\n\nlet _dataDir = process.env.AMEM_DATA_DIR || path.join(os.homedir(), '.amem')\n\nexport function configure(opts: { dataDir?: string }): void {\n if (opts.dataDir) _dataDir = opts.dataDir\n}\n\nexport function getDataDir(): string {\n return _dataDir\n}\n","/**\n * migrate.ts — rebuild a collection under a different embedding model.\n *\n * Changing the embedding model is a breaking change whenever the vector width\n * differs: Qdrant fixes a collection's size at creation and cannot alter it. So\n * the move is always build-alongside → backfill → verify → switch, never in\n * place. The source collection is read and never written, which is what makes\n * the whole thing reversible: if anything looks wrong, point AMEM_COLLECTION\n * back at it.\n *\n * Re-embedding costs no LLM calls. Every field that feeds the vector — content,\n * keywords, tags, context — is already in the payload, so a backfill is local\n * compute. The one exception is notes written before the extraction pipeline\n * filled those fields in: `refreshFields` re-runs construction for those, and\n * only those, because a vector built from a note with no keywords or tags is\n * built from less text than the same note would produce today.\n */\nimport {\n scrollAllRaw,\n countPointsRaw,\n collectionDimRaw,\n scrollIdsRaw,\n deleteCollectionRaw,\n resolveAliasRaw,\n setAliasRaw,\n createAliasRaw,\n createCollectionRaw,\n upsertPointsRaw,\n pointToNote,\n noteToPoint,\n type MemoryNote,\n} from './storage.js'\nimport { encode, getEmbeddingDim, getEmbeddingModel } from './embedding.js'\nimport { llmConstructNote } from './llm.js'\nimport { buildEmbedText } from './memory.js'\n\nexport interface MigrateResult {\n /** Points found in the source. */\n total: number\n /** Notes whose derived fields were empty — the pre-pipeline cohort. */\n missingDerived: number\n /** Notes whose fields were re-extracted. 0 unless refreshFields. */\n refreshed: number\n /** Notes written into the target by THIS run. 0 on a dry run. */\n migrated: number\n /** Notes a previous interrupted run had already written. */\n skipped: number\n sourceDim: number | null\n targetDim: number\n model: string\n dryRun: boolean\n}\n\n/** A note that predates the extraction pipeline filling these in. */\nfunction missingDerivedFields(n: MemoryNote): boolean {\n return n.keywords.length === 0 || n.tags.length === 0\n}\n\nexport async function migrateCollection(opts: {\n /** Source collection. Read-only; never modified. */\n from: string\n /** Target collection. Created if absent; must not already hold points. */\n to: string\n /** Re-extract keywords/tags/context for notes that never had them. Default true. */\n refreshFields?: boolean\n /** Report what would happen and write nothing. Default TRUE — opt in to writing. */\n dryRun?: boolean\n logger?: { info: (m: string) => void; warn: (m: string) => void }\n}): Promise<MigrateResult> {\n const { from, to } = opts\n const refreshFields = opts.refreshFields !== false\n const dryRun = opts.dryRun !== false\n const log = opts.logger?.info ?? ((m: string) => console.log(m))\n const warn = opts.logger?.warn ?? ((m: string) => console.warn(m))\n\n if (from === to) throw new Error(`migrate: source and target are the same collection (\"${from}\")`)\n\n const model = getEmbeddingModel()\n const targetDim = await getEmbeddingDim()\n const sourceDim = await collectionDimRaw(from)\n if (sourceDim === null) throw new Error(`migrate: source collection \"${from}\" does not exist`)\n\n const points = await scrollAllRaw(from)\n const notes = points.map(pointToNote)\n const missingDerived = notes.filter(missingDerivedFields).length\n\n log(\n `[migrate] ${from} (${sourceDim}d, ${notes.length} notes) → ${to} (${targetDim}d, ${model}); ` +\n `${missingDerived} note(s) missing keywords/tags`\n )\n\n if (dryRun) {\n log('[migrate] dry run — nothing written. Pass dryRun: false to apply.')\n return {\n total: notes.length,\n missingDerived,\n refreshed: 0,\n migrated: 0,\n skipped: 0,\n sourceDim,\n targetDim,\n model,\n dryRun: true,\n }\n }\n\n // A target that already holds points is either an interrupted run of this same\n // migration or somebody else's data. Ids are preserved across the rebuild, so\n // the two are distinguishable: everything already there must be a point we put\n // there, which is to say a subset of the source. Anything else means the name\n // is wrong, and silently mixing two stores is not recoverable by pointing a\n // config back.\n let alreadyDone = new Set<string>()\n const existingTargetDim = await collectionDimRaw(to)\n if (existingTargetDim === null) {\n await createCollectionRaw(to, targetDim)\n log(`[migrate] created ${to} at ${targetDim}d`)\n } else {\n if (existingTargetDim !== targetDim) {\n throw new Error(`migrate: target \"${to}\" exists at ${existingTargetDim}d but the model produces ${targetDim}d`)\n }\n const present = await scrollIdsRaw(to)\n if (present.size > 0) {\n const sourceIds = new Set(notes.map((n) => n.id))\n const foreign = [...present].filter((id) => !sourceIds.has(id))\n if (foreign.length > 0) {\n throw new Error(\n `migrate: target \"${to}\" holds ${foreign.length} point(s) that are not in \"${from}\" ` +\n `(e.g. ${foreign[0]}). That is not an interrupted migration — use a different target.`\n )\n }\n alreadyDone = present\n log(`[migrate] resuming: ${present.size} of ${notes.length} already in ${to}`)\n }\n }\n\n let refreshed = 0\n let migrated = 0\n const BATCH = 64\n let buffer: Array<{ id: string; vector: number[]; payload: Record<string, unknown> }> = []\n\n const flush = async () => {\n if (!buffer.length) return\n await upsertPointsRaw(to, buffer)\n migrated += buffer.length\n buffer = []\n }\n\n for (const note of notes) {\n // Written by an earlier run. Skipping it is the point of resuming: for the\n // notes that need re-extraction this is an LLM call not paid twice.\n if (alreadyDone.has(note.id)) continue\n\n if (refreshFields && missingDerivedFields(note)) {\n try {\n const built = await llmConstructNote(note.content)\n // Only fill gaps. A note that already has tags keeps the ones it has —\n // this is a backfill for what was never extracted, not a re-labelling of\n // everything, which would churn memories the user may have curated.\n if (note.keywords.length === 0) note.keywords = built.keywords\n if (note.tags.length === 0) note.tags = built.tags\n if (!note.context) note.context = built.context\n refreshed++\n } catch (e) {\n warn(`[migrate] re-extract failed for ${note.id.slice(0, 8)} — keeping as-is: ${(e as Error).message}`)\n }\n }\n\n const point = noteToPoint({ ...note, embedding: await encode(buildEmbedText(note)) })\n buffer.push(point as { id: string; vector: number[]; payload: Record<string, unknown> })\n if (buffer.length >= BATCH) {\n await flush()\n log(`[migrate] ${alreadyDone.size + migrated}/${notes.length}`)\n }\n }\n await flush()\n\n const finalCount = await countPointsRaw(to)\n if (finalCount !== notes.length) {\n warn(`[migrate] target holds ${finalCount} point(s) but the source had ${notes.length} — check before switching`)\n }\n\n log(`[migrate] done: ${migrated} written, ${alreadyDone.size} already present, ${refreshed} re-extracted.`)\n\n return {\n total: notes.length,\n missingDerived,\n refreshed,\n migrated,\n skipped: alreadyDone.size,\n sourceDim,\n targetDim,\n model,\n dryRun: false,\n }\n}\n\n/**\n * Put the migrated collection behind the name the source used, and drop the\n * source.\n *\n * This is the only irreversible step in the whole migration, which is why it is\n * a separate call rather than the tail of `migrateCollection`. Everything before\n * it leaves the original untouched and can simply be abandoned.\n *\n * Qdrant cannot rename a collection and cannot create an alias over a name a real\n * collection holds (409), so freeing the name means deleting it — after checking\n * the target holds at least as much as the source, because that check is the last\n * thing standing between a half-finished migration and a deleted store.\n */\nexport async function switchToMigrated(opts: {\n /** The name readers are configured with. Becomes an alias. */\n name: string\n /** The collection built by `migrateCollection`. */\n to: string\n logger?: { info: (m: string) => void; warn: (m: string) => void }\n}): Promise<{ name: string; to: string; moved: number }> {\n const { name, to } = opts\n const log = opts.logger?.info ?? ((m: string) => console.log(m))\n\n if (name === to) throw new Error(`switch: \"${name}\" and \"${to}\" are the same collection`)\n\n const already = await resolveAliasRaw(name)\n if (already === to) {\n log(`[switch] \"${name}\" already points at \"${to}\" — nothing to do`)\n return { name, to, moved: await countPointsRaw(to) }\n }\n\n const targetCount = await countPointsRaw(to)\n if (targetCount === 0) throw new Error(`switch: \"${to}\" is empty — migrate into it first`)\n\n if (already === null) {\n // `name` is a real collection: the pre-migration store. Verify before it goes.\n const sourceCount = await countPointsRaw(name)\n if (targetCount < sourceCount) {\n throw new Error(\n `switch: \"${to}\" holds ${targetCount} point(s) but \"${name}\" still holds ${sourceCount}. ` +\n `The migration is not finished — run it again before switching.`\n )\n }\n log(`[switch] verified ${targetCount} in \"${to}\" against ${sourceCount} in \"${name}\"`)\n await deleteCollectionRaw(name)\n log(`[switch] dropped \"${name}\"`)\n // create, not set: setAliasRaw deletes first, and there is no alias to\n // delete on a name that was a real collection a moment ago.\n await createAliasRaw(name, to)\n } else {\n // `name` is already an alias pointing somewhere else — a later migration.\n // Nothing to delete, and the swap is atomic.\n await setAliasRaw(name, to)\n }\n\n log(`[switch] \"${name}\" now resolves to \"${to}\"`)\n return { name, to, moved: targetCount }\n}\n"],"mappings":"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","names":["path","Anthropic","OpenAI"]}
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ interface MigrateArgs {
3
+ help: boolean;
4
+ apply: boolean;
5
+ switchOver: boolean;
6
+ from?: string;
7
+ to?: string;
8
+ refreshFields: boolean;
9
+ }
10
+ declare function parseArgs(argv: string[]): MigrateArgs;
11
+ /**
12
+ * `amem_notes` → `amem_notes_v2`, and `amem_notes_v2` → `amem_notes_v3`.
13
+ *
14
+ * Derived rather than asked for: the name is an implementation detail of a
15
+ * mechanism the user is not supposed to have to learn, and a wrong guess at it is
16
+ * how you end up with two half-migrated stores.
17
+ */
18
+ declare function deriveTarget(source: string): string;
19
+ /**
20
+ * The collection flags this run was given, so every "now run …" line it prints is
21
+ * copy-pasteable as-is.
22
+ *
23
+ * Without it a mode B operator who passed `--from-collection` would be told to run
24
+ * a bare `amem-migrate --apply`, which falls back to AMEM_COLLECTION and migrates
25
+ * a different store. Only the collection flags are carried: forgetting
26
+ * `--no-refresh-fields` costs an LLM call, forgetting these loses the plot.
27
+ */
28
+ declare function carried(args: MigrateArgs): string;
29
+
30
+ export { type MigrateArgs, carried, deriveTarget, parseArgs };
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ interface MigrateArgs {
3
+ help: boolean;
4
+ apply: boolean;
5
+ switchOver: boolean;
6
+ from?: string;
7
+ to?: string;
8
+ refreshFields: boolean;
9
+ }
10
+ declare function parseArgs(argv: string[]): MigrateArgs;
11
+ /**
12
+ * `amem_notes` → `amem_notes_v2`, and `amem_notes_v2` → `amem_notes_v3`.
13
+ *
14
+ * Derived rather than asked for: the name is an implementation detail of a
15
+ * mechanism the user is not supposed to have to learn, and a wrong guess at it is
16
+ * how you end up with two half-migrated stores.
17
+ */
18
+ declare function deriveTarget(source: string): string;
19
+ /**
20
+ * The collection flags this run was given, so every "now run …" line it prints is
21
+ * copy-pasteable as-is.
22
+ *
23
+ * Without it a mode B operator who passed `--from-collection` would be told to run
24
+ * a bare `amem-migrate --apply`, which falls back to AMEM_COLLECTION and migrates
25
+ * a different store. Only the collection flags are carried: forgetting
26
+ * `--no-refresh-fields` costs an LLM call, forgetting these loses the plot.
27
+ */
28
+ declare function carried(args: MigrateArgs): string;
29
+
30
+ export { type MigrateArgs, carried, deriveTarget, parseArgs };