{"version":3,"file":"episodic-memory.mjs","names":[],"sources":["../../../../../../../ai/src/memory/episodic-memory.ts"],"sourcesContent":["import type { CacheDriver, CacheSimilarHit } from \"@warlock.js/cache\";\nimport type { EmbedderContract } from \"../contracts/embedder.contract\";\nimport type {\n  MemoryItem,\n  RecalledMemory,\n} from \"../contracts/memory/memory-item.type\";\nimport { deriveMemoryId } from \"./derive-id\";\n\n/**\n * Shape persisted per episode. `ts` is the wall-clock time the episode\n * was remembered — the basis for the recency half of the blended recall\n * score. The vector lives in the driver's index (via `set({ vector })`),\n * so it is not duplicated here.\n */\ntype StoredEpisode = {\n  id: string;\n  text: string;\n  ts: number;\n  /** Isolation key the episode was written under; absent = the shared pool. */\n  scope?: string;\n  metadata?: Record<string, unknown>;\n};\n\n/**\n * How many extra candidates to pull from `similar()` before re-ranking by\n * the recency-blended score and slicing to `k`. Recency can promote a\n * slightly-less-similar-but-recent episode past a stale exact match, so\n * the raw top-`k` by similarity alone would miss it — overscan, then\n * re-rank.\n */\nconst RECALL_OVERSCAN = 5;\n\n/**\n * Episodic recall tier (memory core M2).\n *\n * Holds a durable, timestamped log of *what happened* — events/episodes —\n * and retrieves the ones most relevant to a query, **blended with\n * recency** so recent episodes outrank stale ones at equal similarity.\n * That recency weighting is the whole difference from the {@link\n * import(\"./semantic-memory\").SemanticMemory} tier (pure similarity over\n * timeless facts): episodic memory is time-anchored.\n *\n * Like the semantic tier it delegates the similarity search to the\n * `@warlock.js/cache` driver's `similar()` and never implements ANN\n * itself; it adds a stored `ts` per entry and a decay curve at recall.\n * The blended `score` stays in `[0, 1]` so a consumer can merge episodic\n * hits with the other tiers and sort on one field.\n *\n * Internal to the `memory()` factory — never exported on the package\n * surface.\n */\nexport class EpisodicMemory {\n  public constructor(\n    private readonly embedder: EmbedderContract,\n    private readonly store: CacheDriver<any, any>,\n    private readonly namespace: string,\n    private readonly recencyWeight: number,\n    private readonly halfLifeMs: number,\n    private readonly now: () => number,\n  ) {}\n\n  /**\n   * Embed the episode text and index it under a namespaced, id-derived\n   * key, stamping the current time. Re-remembering the same id overwrites\n   * the prior entry (and refreshes its timestamp).\n   */\n  public async remember(item: MemoryItem): Promise<void> {\n    const id = item.id ?? deriveMemoryId(item.text);\n    const { vector } = await this.embedder.embed(item.text);\n\n    const value: StoredEpisode = {\n      id,\n      text: item.text,\n      ts: this.now(),\n      scope: item.scope,\n      metadata: item.metadata,\n    };\n\n    await this.store.set(this.keyFor(id, item.scope), value, { vector });\n  }\n\n  /**\n   * Embed `query`, pull the nearest episodes clearing the similarity\n   * `threshold`, then re-rank each by a recency-blended score before\n   * returning the top `k`. The similarity floor still gates relevance —\n   * recency only reorders episodes that already cleared it, it never\n   * surfaces an irrelevant-but-recent one.\n   *\n   * Episodes written under a different `scope` (another tenant /\n   * session) are dropped here, before scoring and slicing, so they can\n   * neither leak nor consume a slot. An unscoped recall reads only\n   * unscoped episodes.\n   */\n  public async recall(\n    query: string,\n    k: number,\n    threshold: number,\n    scope?: string,\n  ): Promise<RecalledMemory[]> {\n    const { vector } = await this.embedder.embed(query);\n\n    const hits = await this.store.similar<StoredEpisode>(vector, {\n      topK: Math.max(k * RECALL_OVERSCAN, k),\n      threshold,\n    });\n\n    const prefix = `${this.namespace}.`;\n    const now = this.now();\n\n    return hits\n      .filter(\n        (hit: CacheSimilarHit<StoredEpisode>) =>\n          hit.key.startsWith(prefix) && hit.value?.scope === scope,\n      )\n      .map((hit: CacheSimilarHit<StoredEpisode>) => ({\n        id: hit.value.id,\n        text: hit.value.text,\n        tier: \"episodic\" as const,\n        score: this.blend(hit.score, hit.value.ts, now),\n        metadata: hit.value.metadata,\n      }))\n      .sort((first, second) => second.score - first.score)\n      .slice(0, k);\n  }\n\n  /** Drop every episode written under this instance's namespace. */\n  public async clear(): Promise<void> {\n    await this.store.removeNamespace(this.namespace);\n  }\n\n  /**\n   * Combine raw similarity with an exponential recency decay:\n   * `(1 - w)·similarity + w·0.5^(age / halfLife)`. A just-remembered\n   * episode contributes a recency of `1`; one `halfLife` old, `0.5`;\n   * older trends toward `0`. With `recencyWeight` 0 the score is pure\n   * similarity (an opt-out back to semantic-style ranking).\n   */\n  private blend(similarity: number, ts: number, now: number): number {\n    const ageMs = Math.max(0, now - ts);\n    const recency = 0.5 ** (ageMs / this.halfLifeMs);\n\n    return (1 - this.recencyWeight) * similarity + this.recencyWeight * recency;\n  }\n\n  /**\n   * Namespaced key for an entry. Mirrors the semantic tier's dot\n   * separator so the prefix used here matches the `hit.key` the driver\n   * returns from `similar()`, and its hashed scope segment so two\n   * scopes never overwrite one another's identical text. Unscoped keys\n   * keep their pre-4.15.0 shape.\n   */\n  private keyFor(id: string, scope?: string): string {\n    return scope === undefined\n      ? `${this.namespace}.${id}`\n      : `${this.namespace}.${deriveMemoryId(scope)}.${id}`;\n  }\n}\n"],"mappings":";;;;;;;;;;AA8BA,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;AAqBxB,IAAa,iBAAb,MAA4B;CAC1B,AAAO,YACL,AAAiB,UACjB,AAAiB,OACjB,AAAiB,WACjB,AAAiB,eACjB,AAAiB,YACjB,AAAiB,KACjB;EANiB;EACA;EACA;EACA;EACA;EACA;CAChB;;;;;;CAOH,MAAa,SAAS,MAAiC;EACrD,MAAM,KAAK,KAAK,MAAM,eAAe,KAAK,IAAI;EAC9C,MAAM,EAAE,WAAW,MAAM,KAAK,SAAS,MAAM,KAAK,IAAI;EAEtD,MAAM,QAAuB;GAC3B;GACA,MAAM,KAAK;GACX,IAAI,KAAK,IAAI;GACb,OAAO,KAAK;GACZ,UAAU,KAAK;EACjB;EAEA,MAAM,KAAK,MAAM,IAAI,KAAK,OAAO,IAAI,KAAK,KAAK,GAAG,OAAO,EAAE,OAAO,CAAC;CACrE;;;;;;;;;;;;;CAcA,MAAa,OACX,OACA,GACA,WACA,OAC2B;EAC3B,MAAM,EAAE,WAAW,MAAM,KAAK,SAAS,MAAM,KAAK;EAElD,MAAM,OAAO,MAAM,KAAK,MAAM,QAAuB,QAAQ;GAC3D,MAAM,KAAK,IAAI,IAAI,iBAAiB,CAAC;GACrC;EACF,CAAC;EAED,MAAM,SAAS,GAAG,KAAK,UAAU;EACjC,MAAM,MAAM,KAAK,IAAI;EAErB,OAAO,KACJ,QACE,QACC,IAAI,IAAI,WAAW,MAAM,KAAK,IAAI,OAAO,UAAU,KACvD,CAAC,CACA,KAAK,SAAyC;GAC7C,IAAI,IAAI,MAAM;GACd,MAAM,IAAI,MAAM;GAChB,MAAM;GACN,OAAO,KAAK,MAAM,IAAI,OAAO,IAAI,MAAM,IAAI,GAAG;GAC9C,UAAU,IAAI,MAAM;EACtB,EAAE,CAAC,CACF,MAAM,OAAO,WAAW,OAAO,QAAQ,MAAM,KAAK,CAAC,CACnD,MAAM,GAAG,CAAC;CACf;;CAGA,MAAa,QAAuB;EAClC,MAAM,KAAK,MAAM,gBAAgB,KAAK,SAAS;CACjD;;;;;;;;CASA,AAAQ,MAAM,YAAoB,IAAY,KAAqB;EAEjE,MAAM,UAAU,OADF,KAAK,IAAI,GAAG,MAAM,EACJ,IAAI,KAAK;EAErC,QAAQ,IAAI,KAAK,iBAAiB,aAAa,KAAK,gBAAgB;CACtE;;;;;;;;CASA,AAAQ,OAAO,IAAY,OAAwB;EACjD,OAAO,UAAU,SACb,GAAG,KAAK,UAAU,GAAG,OACrB,GAAG,KAAK,UAAU,GAAG,eAAe,KAAK,EAAE,GAAG;CACpD;AACF"}