@unblocklabs/unblock-memory 0.7.0 → 0.7.2

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.
Files changed (41) hide show
  1. package/dist/src/analysis.js +11 -12
  2. package/dist/src/contracts.d.ts +1 -1
  3. package/dist/src/extraction-chunk.d.ts +1 -0
  4. package/dist/src/extraction-chunk.js +1 -0
  5. package/dist/src/extraction-model.js +5 -4
  6. package/dist/src/extraction-runtime.js +2 -7
  7. package/dist/src/extraction-source.js +6 -2
  8. package/dist/src/extraction-store.d.ts +3 -0
  9. package/dist/src/extraction-store.js +14 -10
  10. package/dist/src/extraction-worker.js +31 -15
  11. package/dist/src/manager.d.ts +1 -1
  12. package/dist/src/manager.js +63 -79
  13. package/dist/src/memory-whisperer.js +6 -10
  14. package/dist/src/people-primer.js +6 -8
  15. package/dist/src/people-store.js +2 -0
  16. package/dist/src/people-tools.js +2 -6
  17. package/dist/src/plugin.js +6 -14
  18. package/dist/src/query-contract.js +18 -10
  19. package/dist/src/response-audit.js +3 -3
  20. package/dist/src/response-judge.js +1 -2
  21. package/dist/src/response-store.js +2 -1
  22. package/dist/src/retrieval-telemetry.d.ts +1 -1
  23. package/dist/src/review-tools.d.ts +24 -0
  24. package/dist/src/review-tools.js +22 -26
  25. package/dist/src/session-projector.js +8 -4
  26. package/dist/src/skill-whisperer.js +44 -15
  27. package/dist/src/training-candidates.d.ts +12 -13
  28. package/dist/src/training-candidates.js +11 -81
  29. package/dist/src/training-gate.js +2 -2
  30. package/dist/src/training-queries.js +5 -2
  31. package/dist/src/training-retrieval.d.ts +15 -2
  32. package/dist/src/training-retrieval.js +63 -39
  33. package/dist/src/typesafe-client.d.ts +5 -0
  34. package/dist/src/typesafe-client.js +4 -0
  35. package/dist/src/typesafe-review.js +4 -6
  36. package/dist/src/typesafe.d.ts +1 -9
  37. package/dist/src/typesafe.js +4 -10
  38. package/docs/configuration.md +3 -2
  39. package/docs/retrieval.md +15 -1
  40. package/openclaw.plugin.json +2 -2
  41. package/package.json +2 -2
@@ -200,22 +200,21 @@ export function latestAnalysisCollections(db) {
200
200
  return undefined;
201
201
  }
202
202
  }
203
- function count(db, sql, runId) {
204
- return db.prepare(sql).get(runId)?.count ?? 0;
205
- }
206
203
  function analysisSummary(db, run) {
207
- const clusters = count(db, "SELECT COUNT(*) AS count FROM memory_analysis_clusters WHERE run_id = ?", run.id);
208
- const members = count(db, "SELECT COUNT(*) AS count FROM memory_analysis_memberships WHERE run_id = ?", run.id);
209
- const expectedNonNoise = count(db, "SELECT COALESCE(SUM(size), 0) AS count FROM memory_analysis_clusters WHERE run_id = ?", run.id);
210
- const nonNoise = count(db, "SELECT COUNT(*) AS count FROM memory_analysis_memberships WHERE run_id = ? AND cluster_id <> -1", run.id);
211
- const noise = count(db, "SELECT COUNT(*) AS count FROM memory_analysis_memberships WHERE run_id = ? AND cluster_id = -1", run.id);
212
- const unassigned = count(db, `
213
- SELECT COUNT(*) AS count
204
+ const { clusters, expectedNonNoise } = db.prepare(`
205
+ SELECT COUNT(*) AS clusters, COALESCE(SUM(size), 0) AS expectedNonNoise
206
+ FROM memory_analysis_clusters WHERE run_id = ?
207
+ `).get(run.id);
208
+ const { members, nonNoise, noise, unassigned } = db.prepare(`
209
+ SELECT COUNT(*) AS members,
210
+ COALESCE(SUM(m.cluster_id <> -1), 0) AS nonNoise,
211
+ COALESCE(SUM(m.cluster_id = -1), 0) AS noise,
212
+ COALESCE(SUM(m.cluster_id <> -1 AND c.cluster_id IS NULL), 0) AS unassigned
214
213
  FROM memory_analysis_memberships m
215
214
  LEFT JOIN memory_analysis_clusters c
216
215
  ON c.run_id = m.run_id AND c.cluster_id = m.cluster_id
217
- WHERE m.run_id = ? AND m.cluster_id <> -1 AND c.cluster_id IS NULL
218
- `, run.id);
216
+ WHERE m.run_id = ?
217
+ `).get(run.id);
219
218
  if (nonNoise !== expectedNonNoise || members !== nonNoise + noise || unassigned > 0)
220
219
  return undefined;
221
220
  return {
@@ -29,7 +29,7 @@ export type SessionSearchFilter = {
29
29
  conversationId?: string;
30
30
  };
31
31
  export type MemoryRequestContext = Pick<OpenClawPluginToolContext, "sessionKey" | "sessionId" | "messageChannel" | "agentAccountId" | "nativeChannelId" | "deliveryContext">;
32
- export type CorpusSearchOptions = NonNullable<Parameters<MemorySearchManagerContract["search"]>[1]> & {
32
+ export type CorpusSearchOptions = Omit<NonNullable<Parameters<MemorySearchManagerContract["search"]>[1]>, "lexicalOnly"> & {
33
33
  corpora?: readonly string[];
34
34
  /** Internal vector-hint budget; oversized matched chunks are omitted, never sliced. */
35
35
  maxSnippetChars?: number;
@@ -25,5 +25,6 @@ export declare function readExtractionChunk(params: {
25
25
  newIds: string[];
26
26
  entryCount: number;
27
27
  fence: string;
28
+ exhausted: boolean;
28
29
  cursor: string;
29
30
  }>;
@@ -98,6 +98,7 @@ export async function readExtractionChunk(params) {
98
98
  const finish = (index, offset = 0) => {
99
99
  const next = page.messages[index];
100
100
  return { kind: "page", messages, newIds, entryCount, fence: startingFence ?? page.cursor,
101
+ exhausted: !next && !page.hasMore,
101
102
  cursor: next ? PREFIX + JSON.stringify({ before, fence: page.cursor, index, offset, id: next.id, hash: digest(next) }) : page.cursor };
102
103
  };
103
104
  for (let i = resume?.index ?? 0; i < page.messages.length; i++) {
@@ -2,7 +2,7 @@ import { Type } from "typebox";
2
2
  import { Value } from "typebox/value";
3
3
  import { Tiktoken } from "js-tiktoken/lite";
4
4
  import o200kBase from "js-tiktoken/ranks/o200k_base";
5
- import { requestTypeSafe } from "./typesafe-client.js";
5
+ import { requestTypeSafe, TYPESAFE_NOUL_SCHEMA as noul } from "./typesafe-client.js";
6
6
  export const EXTRACTION_VERSION = "lasting-facts-v11";
7
7
  // Jev allows 32k for state plus its longest question. Leave room for validation
8
8
  // instructions, proposal evidence and tokenizer differences, not just Luna input.
@@ -34,6 +34,7 @@ Only extract facts supported by at least one NEW message. Context-only messages
34
34
  If a fact is already in existing memories, omit it. Source dates control chronology, not processing order: during backfill, never replace a newer observed fact with an older one, or emit the older value as an undated duplicate. If new evidence clearly corrects/changes an existing memory, set replaces to its exact id and emit the concise replacement. Otherwise replaces is null. Never replace unrelated facts. If a previous claim is explicitly withdrawn without a replacement, retain a concise qualified correction rather than inventing a value.
35
35
  Cite exact contiguous substrings copied from the supplied messages using their ids. Preserve Markdown markers, mentions, whitespace and punctuation inside each quote. Never join separated spans into one quote or clean up its formatting. Prefer short exact spans; use separate evidence entries when needed. Include enough evidence to resolve corrections and speaker attribution. Output an empty memories array when nothing qualifies. Do not fill a quota.`;
36
36
  let tokenizer;
37
+ let promptTokens;
37
38
  // o200k_base is an explicit budgeting proxy, not a claim about the host's private tokenizer.
38
39
  function extractionTokens(text) {
39
40
  return (tokenizer ??= new Tiktoken(o200kBase)).encode(text, [], []).length;
@@ -45,7 +46,7 @@ function extractionInput(messages, newIds, existing) {
45
46
  return JSON.stringify({ messages: messages.map(datedMessage), newMessageIds: newIds, existing, schema: proposalSchema });
46
47
  }
47
48
  export function extractionOverhead(existing) {
48
- return extractionTokens(EXTRACTION_PROMPT) + extractionTokens(extractionInput([], [], existing)) + 512;
49
+ return (promptTokens ??= extractionTokens(EXTRACTION_PROMPT)) + extractionTokens(extractionInput([], [], existing)) + 512;
49
50
  }
50
51
  export function extractionMessageTokens(message) {
51
52
  return extractionTokens(JSON.stringify(datedMessage(message))) + extractionTokens(JSON.stringify(message.id)) + 8;
@@ -84,8 +85,8 @@ export async function extractWithLuna(runtime, agentId, messages, newIds, existi
84
85
  const judgments = Type.Object({ answers: Type.Object({
85
86
  supported: Type.Object({ type: Type.Literal("choice"), choice: Type.Union([Type.Literal("supported"), Type.Literal("contradicted"), Type.Literal("unsupported")]),
86
87
  probabilities: Type.Object({ supported: Type.Number({ minimum: 0, maximum: 1 }), contradicted: Type.Number({ minimum: 0, maximum: 1 }), unsupported: Type.Number({ minimum: 0, maximum: 1 }) }) }),
87
- useful: Type.Object({ type: Type.Literal("noul"), noul: Type.Number({ minimum: 0, maximum: 1 }) }),
88
- replacement: Type.Optional(Type.Object({ type: Type.Literal("noul"), noul: Type.Number({ minimum: 0, maximum: 1 }) })),
88
+ useful: noul,
89
+ replacement: Type.Optional(noul),
89
90
  }) });
90
91
  export async function validateExtractedMemory(params) {
91
92
  const { proposal, messages, existing } = params;
@@ -7,6 +7,7 @@ import { resolveAgentDir, resolveStateDir } from "openclaw/plugin-sdk/memory-cor
7
7
  import { MEMORY_DATABASE, hasMemoryTable } from "./memory-database.js";
8
8
  import { ExtractionStore } from "./extraction-store.js";
9
9
  import { extractionSessions } from "./extraction-source.js";
10
+ import { dateOption } from "./date-option.js";
10
11
  export function registerExtraction(api, config, runtime) {
11
12
  let lifetime = new AbortController();
12
13
  let timer, running;
@@ -36,13 +37,7 @@ export function registerExtraction(api, config, runtime) {
36
37
  .option("--since <date>", "Explicit bounded backfill from YYYY-MM-DD UTC; repeat to resume")
37
38
  .option("--session <id>", "Restrict this run to one session")
38
39
  .action(async (opts) => {
39
- let since;
40
- if (opts.since !== undefined) {
41
- since = Date.parse(opts.since);
42
- if (!/^\d{4}-\d{2}-\d{2}$/.test(opts.since) || !Number.isFinite(since) || new Date(since).toISOString().slice(0, 10) !== opts.since) {
43
- throw new Error("since must be a valid YYYY-MM-DD UTC date");
44
- }
45
- }
40
+ const since = opts.since === undefined ? undefined : dateOption(opts.since, 0);
46
41
  try {
47
42
  // Native harnesses live in the Gateway, not the cold CLI metadata loader.
48
43
  const result = await callGatewayFromCli("unblock-memory.extract", { timeout: "600000" }, { agentId: opts.agent, ...(since === undefined ? {} : { since }), ...(opts.session ? { sessionId: opts.session } : {}) });
@@ -11,13 +11,17 @@ export function extractionSessions(databasePath, agentId, chatTypes) {
11
11
  }
12
12
  return db.prepare(`SELECT w.session_id,w.session_key,w.chat_type,COALESCE(w.channel,c.channel) AS provider,
13
13
  COALESCE(w.account_id,c.account_id) AS account_id,COALESCE(c.native_channel_id,c.native_direct_user_id,c.peer_id,w.primary_conversation_id) AS conversation_id,
14
- COALESCE(w.started_at,w.created_at) AS started_at, COALESCE(w.transcript_updated_at,w.updated_at,w.created_at) AS changed_at FROM session_windows w
14
+ COALESCE(w.started_at,w.created_at) AS started_at, COALESCE(w.transcript_updated_at,w.updated_at,w.created_at) AS changed_at,
15
+ r.generation,(SELECT MAX(seq) FROM transcript_events WHERE session_id=w.session_id) AS tail FROM session_windows w
15
16
  LEFT JOIN conversations c ON c.conversation_id=w.primary_conversation_id
17
+ LEFT JOIN transcript_rewrite_watermarks r ON r.session_id=w.session_id
16
18
  WHERE w.chat_type IN (${chatTypes.map(() => "?").join(",")}) ORDER BY w.created_at,w.session_id`)
17
19
  .all(...chatTypes).map(r => ({ sessionId: String(r.session_id), sessionKey: String(r.session_key),
18
20
  chatType: r.chat_type, provider: r.provider === null ? undefined : String(r.provider),
19
21
  accountId: r.account_id === null ? undefined : String(r.account_id),
20
- conversationId: r.conversation_id === null ? undefined : String(r.conversation_id), startedAt: Number(r.started_at), changedAt: Number(r.changed_at) }));
22
+ conversationId: r.conversation_id === null ? undefined : String(r.conversation_id), startedAt: Number(r.started_at), changedAt: Number(r.changed_at),
23
+ // The host uses this append-stable watermark for transcript cache validation.
24
+ sourceRevision: r.generation === null ? undefined : JSON.stringify([r.generation, r.tail]) }));
21
25
  }
22
26
  finally {
23
27
  db.close();
@@ -3,11 +3,13 @@ import type { SessionMetadata } from "./session-projector.js";
3
3
  export type ExtractionSession = SessionMetadata & {
4
4
  sessionKey: string;
5
5
  changedAt?: number;
6
+ sourceRevision?: string;
6
7
  };
7
8
  type Checkpoint = {
8
9
  cursor: string | null;
9
10
  context: ExtractionMessage[];
10
11
  since: number;
12
+ completeRevision: string | null;
11
13
  };
12
14
  export type ExtractedRecord = {
13
15
  id: string;
@@ -42,6 +44,7 @@ export declare class ExtractionStore {
42
44
  }[];
43
45
  owner: string;
44
46
  version: string;
47
+ completeRevision?: string;
45
48
  }): number;
46
49
  error(sessionId: string, code: string): void;
47
50
  report(): {
@@ -20,7 +20,7 @@ export class ExtractionStore {
20
20
  session_id TEXT PRIMARY KEY, metadata TEXT NOT NULL CHECK(json_valid(metadata)), cursor TEXT,
21
21
  extract_since INTEGER NOT NULL,
22
22
  context TEXT NOT NULL DEFAULT '[]' CHECK(json_valid(context)), updated_at INTEGER NOT NULL,
23
- error TEXT
23
+ error TEXT, complete_revision TEXT
24
24
  ) STRICT;
25
25
  CREATE TABLE IF NOT EXISTS extracted_memories (
26
26
  id TEXT NOT NULL, revision INTEGER NOT NULL, session_id TEXT NOT NULL REFERENCES extraction_sessions(session_id),
@@ -33,6 +33,9 @@ export class ExtractionStore {
33
33
  CREATE UNIQUE INDEX IF NOT EXISTS extracted_duplicate ON extracted_memories(session_id,digest) WHERE status='active';
34
34
  INSERT OR IGNORE INTO memory_schema VALUES('extraction',1);
35
35
  `);
36
+ if (!this.db.prepare("SELECT 1 FROM pragma_table_info('extraction_sessions') WHERE name='complete_revision'").get()) {
37
+ this.db.exec("ALTER TABLE extraction_sessions ADD COLUMN complete_revision TEXT");
38
+ }
36
39
  this.db.prepare("INSERT OR IGNORE INTO extraction_worker(id,live_since) VALUES(1,?)").run(Date.now());
37
40
  }
38
41
  catch (error) {
@@ -58,23 +61,24 @@ export class ExtractionStore {
58
61
  checkpoint(session, since) {
59
62
  this.db.prepare(`INSERT INTO extraction_sessions(session_id,metadata,updated_at,extract_since) VALUES(?,?,?,?)
60
63
  ON CONFLICT(session_id) DO UPDATE SET metadata=excluded.metadata`).run(session.sessionId, JSON.stringify(session), Date.now(), since ?? this.liveSince());
61
- const row = this.db.prepare("SELECT cursor,context,extract_since FROM extraction_sessions WHERE session_id=?").get(session.sessionId);
64
+ const row = this.db.prepare("SELECT cursor,context,extract_since,complete_revision FROM extraction_sessions WHERE session_id=?").get(session.sessionId);
62
65
  if (since !== undefined && since < Number(row.extract_since)) {
63
66
  // A broader explicitly approved backfill replays this session. Existing facts are
64
67
  // supplied to Luna and exact duplicates are ignored; history is never deleted.
65
- this.db.prepare("UPDATE extraction_sessions SET cursor=NULL,context='[]',extract_since=? WHERE session_id=?").run(since, session.sessionId);
66
- return { cursor: null, context: [], since };
68
+ this.db.prepare("UPDATE extraction_sessions SET cursor=NULL,context='[]',complete_revision=NULL,extract_since=? WHERE session_id=?").run(since, session.sessionId);
69
+ return { cursor: null, context: [], since, completeRevision: null };
67
70
  }
68
- return { cursor: row.cursor, context: JSON.parse(String(row.context)), since: Number(row.extract_since) };
71
+ return { cursor: row.cursor, context: JSON.parse(String(row.context)), since: Number(row.extract_since),
72
+ completeRevision: row.complete_revision };
69
73
  }
70
74
  records(sessionId, paths) {
71
75
  const keys = paths?.filter(path => /^[a-f0-9-]+\/[1-9]\d*$/.test(path)).map(path => path.split("/"));
72
76
  if (keys && !keys.length)
73
77
  return [];
74
78
  return this.db.prepare(`SELECT m.*,s.metadata FROM extracted_memories m JOIN extraction_sessions s USING(session_id)
75
- WHERE status='active' AND (? IS NULL OR session_id=?)
79
+ WHERE status='active' ${sessionId === undefined ? "" : "AND session_id=?"}
76
80
  ${keys ? `AND (m.id,m.revision) IN (VALUES ${keys.map(() => "(?,?)").join(",")})` : ""}
77
- ORDER BY observed_at,id`).all(sessionId ?? null, sessionId ?? null, ...(keys?.flat() ?? []))
81
+ ORDER BY observed_at,id`).all(...(sessionId === undefined ? [] : [sessionId]), ...(keys?.flat() ?? []))
78
82
  .map(r => ({ id: String(r.id), revision: Number(r.revision), text: String(r.text), sessionId: String(r.session_id),
79
83
  observedAt: Number(r.observed_at), evidence: JSON.parse(String(r.evidence)), metadata: JSON.parse(String(r.metadata)), judgment: JSON.parse(String(r.judgment)) }));
80
84
  }
@@ -83,7 +87,7 @@ export class ExtractionStore {
83
87
  try {
84
88
  this.renew(owner);
85
89
  this.db.prepare("UPDATE extracted_memories SET status='withdrawn' WHERE session_id=? AND status='active'").run(sessionId);
86
- this.db.prepare("UPDATE extraction_sessions SET cursor=?,context='[]',error=NULL WHERE session_id=?").run(cursor, sessionId);
90
+ this.db.prepare("UPDATE extraction_sessions SET cursor=?,context='[]',complete_revision=NULL,error=NULL WHERE session_id=?").run(cursor, sessionId);
87
91
  this.db.exec("COMMIT");
88
92
  }
89
93
  catch (error) {
@@ -119,8 +123,8 @@ export class ExtractionStore {
119
123
  .run(id, revision, params.session.sessionId, proposal.text.trim(), digest, JSON.stringify(proposal.evidence), observedAt, JSON.stringify(judgment), params.version, Date.now());
120
124
  written++;
121
125
  }
122
- this.db.prepare("UPDATE extraction_sessions SET cursor=?,context=?,updated_at=?,error=NULL WHERE session_id=?")
123
- .run(params.cursor, JSON.stringify(params.context), Date.now(), params.session.sessionId);
126
+ this.db.prepare("UPDATE extraction_sessions SET cursor=?,context=?,updated_at=?,complete_revision=?,error=NULL WHERE session_id=?")
127
+ .run(params.cursor, JSON.stringify(params.context), Date.now(), params.completeRevision ?? null, params.session.sessionId);
124
128
  this.db.exec("COMMIT");
125
129
  return written;
126
130
  }
@@ -32,9 +32,10 @@ export async function runExtraction(params) {
32
32
  const visible = new Set(sessions.map(s => s.sessionId));
33
33
  if (params.sessionId && !visible.has(params.sessionId))
34
34
  throw new Error("Unknown or unapproved extraction session");
35
- for (const memory of store.records())
36
- if (!visible.has(memory.sessionId))
37
- store.reset(memory.sessionId, null, owner);
35
+ for (const { session_id } of store.db.prepare("SELECT DISTINCT session_id FROM extracted_memories WHERE status='active'").all()) {
36
+ if (!visible.has(String(session_id)))
37
+ store.reset(String(session_id), null, owner);
38
+ }
38
39
  // Oldest last-checked first: bounded runs must not starve later sessions.
39
40
  const checked = new Map(store.db.prepare("SELECT session_id,updated_at FROM extraction_sessions").all()
40
41
  .map(r => [String(r.session_id), Number(r.updated_at)]));
@@ -48,6 +49,10 @@ export async function runExtraction(params) {
48
49
  if (processed >= config.maxBatches)
49
50
  break;
50
51
  const checkpoint = store.checkpoint(session, params.since);
52
+ if (session.sourceRevision !== undefined && checkpoint.completeRevision === session.sourceRevision) {
53
+ unchanged++;
54
+ continue;
55
+ }
51
56
  let stage = "read";
52
57
  try {
53
58
  const existing = store.records(session.sessionId);
@@ -69,7 +74,8 @@ export async function runExtraction(params) {
69
74
  continue;
70
75
  }
71
76
  if (!page.entryCount) {
72
- store.db.prepare("UPDATE extraction_sessions SET updated_at=? WHERE session_id=?").run(Date.now(), session.sessionId);
77
+ store.db.prepare("UPDATE extraction_sessions SET updated_at=?,complete_revision=? WHERE session_id=?")
78
+ .run(Date.now(), page.exhausted ? session.sourceRevision ?? null : null, session.sessionId);
73
79
  unchanged++;
74
80
  continue;
75
81
  }
@@ -82,17 +88,26 @@ export async function runExtraction(params) {
82
88
  const proposals = newIds.length ? await (params.extract ?? extractWithLuna)(params.runtime, params.agentId, messages, newIds, existing.map(m => ({ id: m.id, text: m.text, observedAt: m.observedAt })), signal) : [];
83
89
  const accepted = [];
84
90
  stage = "validate";
85
- for (const proposal of proposals) {
91
+ for (let start = 0; start < proposals.length; start += 4) {
86
92
  signal.throwIfAborted();
87
- const judgment = await (params.validate ?? validateExtractedMemory)({ proposal, messages, newIds,
88
- existing, apiKey, signal, thresholds: config });
89
- const observedAt = judgment.accepted ? Math.max(...proposal.evidence.map(e => messages.find(m => m.id === e.messageId).timestamp)) : 0;
90
- const prior = existing.find(m => m.id === proposal.replaces);
91
- if (judgment.accepted && (!prior || prior.observedAt <= observedAt))
92
- accepted.push({ proposal: { ...proposal,
93
- evidence: proposal.evidence.map(e => ({ ...e, messageId: messages.find(m => m.id === e.messageId).sourceMessageId ?? e.messageId })) }, judgment, observedAt });
94
- else
95
- rejectedCount++;
93
+ // Isolated judgments overlap, but failed chunks drain before their lease is released.
94
+ const judgments = await Promise.allSettled(proposals.slice(start, start + 4).map(async (proposal) => ({ proposal,
95
+ judgment: await (params.validate ?? validateExtractedMemory)({ proposal, messages, newIds, existing, apiKey, signal, thresholds: config }) })));
96
+ const failure = judgments.find(result => result.status === "rejected");
97
+ if (failure)
98
+ throw failure.reason;
99
+ for (const result of judgments) {
100
+ if (result.status !== "fulfilled")
101
+ continue;
102
+ const { proposal, judgment } = result.value;
103
+ const observedAt = judgment.accepted ? Math.max(...proposal.evidence.map(e => messages.find(m => m.id === e.messageId).timestamp)) : 0;
104
+ const prior = existing.find(m => m.id === proposal.replaces);
105
+ if (judgment.accepted && (!prior || prior.observedAt <= observedAt))
106
+ accepted.push({ proposal: { ...proposal,
107
+ evidence: proposal.evidence.map(e => ({ ...e, messageId: messages.find(m => m.id === e.messageId).sourceMessageId ?? e.messageId })) }, judgment, observedAt });
108
+ else
109
+ rejectedCount++;
110
+ }
96
111
  }
97
112
  // A branch rewrite during inference invalidates its proposals. Appends are safe and processed next time.
98
113
  const check = await readPage(params.agentId, params.agentName, session, page.fence);
@@ -107,7 +122,8 @@ export async function runExtraction(params) {
107
122
  const context = extractionHistory(messages, config.historyMessages);
108
123
  stage = "commit";
109
124
  const written = store.commit({ session, expected: checkpoint.cursor, cursor: page.cursor,
110
- context, accepted, owner, version: EXTRACTION_VERSION });
125
+ context, accepted, owner, version: EXTRACTION_VERSION,
126
+ completeRevision: page.exhausted ? session.sourceRevision : undefined });
111
127
  processed++;
112
128
  acceptedCount += written;
113
129
  }
@@ -12,7 +12,7 @@ import { reviewIndexedClaim } from "./evidence-review.js";
12
12
  import { reviewClusterIngestion } from "./cluster-review.js";
13
13
  import type { QueryPair } from "./query-contract.js";
14
14
  export { expandSessionSearchHit } from "./memory-passage.js";
15
- export type ManagerStore = Pick<QMDStore, "update" | "embed" | "getStatus" | "listCollections" | "searchLex" | "vsearch" | "get" | "getDocumentBody" | "close">;
15
+ export type ManagerStore = Pick<QMDStore, "update" | "embed" | "getStatus" | "listCollections" | "vsearch" | "get" | "getDocumentBody" | "close">;
16
16
  export type ManagerSessionConfig = {
17
17
  agentId: string;
18
18
  agentName: string;
@@ -74,16 +74,6 @@ function frontmatterValue(body, key) {
74
74
  .find((value) => value !== undefined);
75
75
  return raw?.replace(/^(?:"(.*)"|'(.*)')$/u, "$1$2").trim();
76
76
  }
77
- function embeddingText(name, description, model) {
78
- return model.toLowerCase().includes("qwen3-embedding")
79
- ? `${name}\n${description}`
80
- : `title: ${name} | text: ${description}`;
81
- }
82
- function queryText(query, model) {
83
- return model.toLowerCase().includes("qwen3-embedding")
84
- ? `Instruct: Retrieve relevant documents for the given query\nQuery: ${query}`
85
- : `task: search result | query: ${query}`;
86
- }
87
77
  function cosineSimilarity(left, right) {
88
78
  if (left.length !== right.length || left.length === 0)
89
79
  return 0;
@@ -192,22 +182,6 @@ function lineSpan(body, position, text) {
192
182
  const endLine = startLine + Math.max(0, text.split("\n").length - 1);
193
183
  return { startLine, endLine };
194
184
  }
195
- function lexicalResult(hit, corpus, session) {
196
- const body = hit.body ?? hit.title;
197
- const endLine = Math.max(1, body.split("\n").length);
198
- return {
199
- path: hit.filepath,
200
- startLine: 1,
201
- endLine,
202
- score: hit.score,
203
- textScore: hit.score,
204
- snippet: body,
205
- source: "memory",
206
- corpus,
207
- ...(session ? { session } : {}),
208
- citation: `${hit.displayPath}#L1-L${endLine}`,
209
- };
210
- }
211
185
  function sessionAllowedPaths(metadataByPath, collection, filter) {
212
186
  const startedFrom = filter.startedFrom === undefined ? undefined : Date.parse(filter.startedFrom);
213
187
  const startedTo = filter.startedTo === undefined ? undefined : Date.parse(filter.startedTo);
@@ -251,6 +225,7 @@ export class QmdMemoryManager {
251
225
  #watcher;
252
226
  #watchReady;
253
227
  #watchTimer;
228
+ #watchCollections = new Set();
254
229
  #watchError;
255
230
  #closed = false;
256
231
  #files = 0;
@@ -360,12 +335,17 @@ export class QmdMemoryManager {
360
335
  this.#skillIndex = undefined;
361
336
  if (!matchingSources.some((source) => source.kind !== "skills"))
362
337
  return;
338
+ for (const source of matchingSources)
339
+ if (source.kind !== "skills")
340
+ this.#watchCollections.add(source.collection);
363
341
  this.#dirty = true;
364
342
  if (this.#watchTimer)
365
343
  clearTimeout(this.#watchTimer);
366
344
  this.#watchTimer = setTimeout(() => {
367
345
  this.#watchTimer = undefined;
368
- void this.sync({ reason: "watch" }).catch(() => undefined);
346
+ const collections = [...this.#watchCollections];
347
+ this.#watchCollections.clear();
348
+ void this.#sync({ reason: "watch" }, collections).catch(() => undefined);
369
349
  }, WATCH_DEBOUNCE_MS);
370
350
  });
371
351
  }
@@ -489,23 +469,36 @@ export class QmdMemoryManager {
489
469
  const allowed = new Set(sessionAllowedPaths(new Map(records.map(r => [extractedPath(r), r.metadata])), EXTRACTED_COLLECTION, filter)[EXTRACTED_COLLECTION]);
490
470
  return records.filter(r => allowed.has(extractedPath(r)));
491
471
  }
492
- async #updateAndEmbed(store, collection, force) {
472
+ async #updateAndEmbed(store, collections, force) {
493
473
  // Extracted facts are DB projections, not files: never run filesystem sync on them.
494
- const update = await store.update({ collections: collection ? [collection] : this.#qmdSources().map(source => source.collection) });
495
- this.#cleanupRemovedDocuments?.(update.updated + update.removed);
496
474
  const analysisStore = store;
497
- const changed = update.indexed + update.updated + update.removed > 0 || update.needsEmbedding > 0;
498
- if ((changed || force) && analysisStore.internal)
499
- markMemoryAnalysisStale(analysisStore.internal.db);
500
- const embed = await store.embed({
501
- ...(collection ? { collection } : {}),
502
- force,
503
- chunkStrategy: "semantic",
504
- });
505
- this.#recordEmbedding(embed);
506
- const chunksEmbedded = completedEmbeddingCount(embed);
507
- if (!changed && !force && chunksEmbedded > 0 && analysisStore.internal) {
508
- markMemoryAnalysisStale(analysisStore.internal.db);
475
+ let changedDocuments = 0;
476
+ try {
477
+ const update = await store.update({ collections: collections ? [...collections] : this.#qmdSources().map(source => source.collection) });
478
+ changedDocuments = update.updated + update.removed;
479
+ if ((update.indexed + changedDocuments > 0 || update.needsEmbedding > 0 || force) && analysisStore.internal) {
480
+ markMemoryAnalysisStale(analysisStore.internal.db);
481
+ }
482
+ }
483
+ catch (error) {
484
+ // QMD may commit earlier collections before a later collection fails.
485
+ if (analysisStore.internal)
486
+ markMemoryAnalysisStale(analysisStore.internal.db);
487
+ changedDocuments = Math.max(1, changedDocuments);
488
+ throw error;
489
+ }
490
+ finally {
491
+ // A later collection failing must not leave earlier removed plaintext behind.
492
+ this.#cleanupRemovedDocuments?.(changedDocuments);
493
+ }
494
+ let chunksEmbedded = 0;
495
+ for (const collection of collections?.length ? collections : [undefined]) {
496
+ const embed = await store.embed({ ...(collection ? { collection } : {}), force, chunkStrategy: "semantic" });
497
+ this.#recordEmbedding(embed);
498
+ const count = completedEmbeddingCount(embed);
499
+ chunksEmbedded += count;
500
+ if (count > 0 && analysisStore.internal)
501
+ markMemoryAnalysisStale(analysisStore.internal.db);
509
502
  }
510
503
  return chunksEmbedded;
511
504
  }
@@ -516,16 +509,14 @@ export class QmdMemoryManager {
516
509
  this.#dirty = status.needsEmbedding > 0;
517
510
  }
518
511
  sync(params) {
512
+ return this.#sync(params);
513
+ }
514
+ #sync(params, affectedCollections) {
519
515
  const run = async () => {
520
516
  const store = await this.#getStore();
521
517
  this.#dirty = true;
522
- const collections = this.#qmdSources().filter((source) => source.kind !== "sessions");
523
- if (collections.length === 0) {
524
- await this.#updateAndEmbed(store, undefined, params?.force);
525
- }
526
- for (const source of collections) {
527
- await this.#updateAndEmbed(store, source.collection, params?.force);
528
- }
518
+ const collections = affectedCollections ?? this.#qmdSources().filter((source) => source.kind !== "sessions").map(source => source.collection);
519
+ await this.#updateAndEmbed(store, collections.length ? collections : undefined, params?.force);
529
520
  await this.#refreshIndexStatus(store);
530
521
  };
531
522
  return this.#enqueue(run);
@@ -544,7 +535,7 @@ export class QmdMemoryManager {
544
535
  index: async () => {
545
536
  onPhase?.("indexing");
546
537
  const store = await this.#getStore();
547
- return this.#updateAndEmbed(store, sessions.collection);
538
+ return this.#updateAndEmbed(store, [sessions.collection]);
548
539
  },
549
540
  });
550
541
  this.#sessionMetadata = sessionMetadataByPath(synced.manifest);
@@ -699,18 +690,28 @@ export class QmdMemoryManager {
699
690
  VALUES (?, ?, ?, ?, ?, ?, ?)
700
691
  `);
701
692
  const curation = this.#getCuration();
693
+ const fingerprints = new Map();
702
694
  for (const annotation of curation.annotations()) {
703
695
  if (!annotation.contentFingerprint) {
704
696
  insert.run(annotation.collection, annotation.path, null, null, annotation.eventTime, annotation.basis, 1);
705
697
  continue;
706
698
  }
707
- const rows = findChunks.all(annotation.collection, annotation.path);
708
- const matched = rows.find((row) => chunkFingerprint(row.doc.slice(row.pos, row.pos + row.chunk_len)) === annotation.contentFingerprint);
709
- curation.updateAnnotationLocation({
710
- annotation,
711
- qmdHash: matched?.hash ?? null,
712
- qmdSeq: matched?.seq ?? null,
713
- });
699
+ const key = JSON.stringify([annotation.collection, annotation.path]);
700
+ let chunks = fingerprints.get(key);
701
+ if (!chunks) {
702
+ chunks = new Map();
703
+ const rows = findChunks.all(annotation.collection, annotation.path);
704
+ for (const row of rows) {
705
+ const fingerprint = chunkFingerprint(row.doc.slice(row.pos, row.pos + row.chunk_len));
706
+ if (!chunks.has(fingerprint))
707
+ chunks.set(fingerprint, row);
708
+ }
709
+ fingerprints.set(key, chunks);
710
+ }
711
+ const matched = chunks.get(annotation.contentFingerprint);
712
+ if (annotation.qmdHash !== (matched?.hash ?? null) || annotation.qmdSeq !== (matched?.seq ?? null)) {
713
+ curation.updateAnnotationLocation({ annotation, qmdHash: matched?.hash ?? null, qmdSeq: matched?.seq ?? null });
714
+ }
714
715
  if (matched) {
715
716
  insert.run(annotation.collection, annotation.path, matched.hash, matched.seq, annotation.eventTime, annotation.basis, 0);
716
717
  }
@@ -779,7 +780,7 @@ export class QmdMemoryManager {
779
780
  }
780
781
  async search(query, opts) {
781
782
  const started = performance.now();
782
- const operation = opts?.lexicalOnly ? "lexical" : "vector";
783
+ const operation = "vector";
783
784
  let results;
784
785
  try {
785
786
  results = await this.#search(query, opts);
@@ -820,25 +821,6 @@ export class QmdMemoryManager {
820
821
  if (collections.includes(EXTRACTED_COLLECTION)) {
821
822
  allowedPaths = { ...allowedPaths, [EXTRACTED_COLLECTION]: currentRecords().map(extractedPath) };
822
823
  }
823
- if (opts?.lexicalOnly) {
824
- const hits = await store.searchLex(query, {
825
- limit: opts.maxResults ?? 5,
826
- collection: collections,
827
- });
828
- const current = new Map(currentRecords(hits.map(hit => hit.filepath)).map(r => [`qmd://${EXTRACTED_COLLECTION}/${extractedPath(r)}`, r]));
829
- return hits.flatMap((hit) => {
830
- if (hit.collectionName === EXTRACTED_COLLECTION) {
831
- const record = current.get(hit.filepath);
832
- return record && hit.score >= (opts.minScore ?? 0) ? [extractedHit(record, hit.score)] : [];
833
- }
834
- const corpus = this.#sources.get(hit.collectionName)?.corpus;
835
- const prefix = `qmd://${hit.collectionName}/`;
836
- const session = corpus === "sessions" && hit.filepath.startsWith(prefix)
837
- ? this.#sessionMetadata.get(hit.filepath.slice(prefix.length))
838
- : undefined;
839
- return hit.score >= (opts.minScore ?? 0) && corpus ? [lexicalResult(hit, corpus, session)] : [];
840
- });
841
- }
842
824
  const hits = await store.vsearch(query, {
843
825
  collection: collections,
844
826
  limit: opts?.maxResults ?? 5,
@@ -944,6 +926,7 @@ export class QmdMemoryManager {
944
926
  if (!store.internal?.llm)
945
927
  throw new Error("Skill Whisperer requires the QMD embedding model");
946
928
  const llm = store.internal.llm;
929
+ const { formatDocForEmbedding, formatQueryForEmbedding } = await qmdModule;
947
930
  let skillIndex = this.#skillIndex;
948
931
  if (!skillIndex) {
949
932
  const pending = (async () => {
@@ -968,7 +951,7 @@ export class QmdMemoryManager {
968
951
  }
969
952
  }
970
953
  const skills = [...metadata.values()];
971
- const embeddings = await llm.embedBatch(skills.map(({ candidate, description }) => embeddingText(candidate.name, description, llm.embedModelName)));
954
+ const embeddings = await llm.embedBatch(skills.map(({ candidate, description }) => formatDocForEmbedding(description, candidate.name, llm.embedModelName)));
972
955
  return skills.flatMap(({ candidate }, index) => {
973
956
  const embedding = embeddings[index]?.embedding;
974
957
  return embedding ? [{ ...candidate, score: 0, embedding }] : [];
@@ -981,7 +964,7 @@ export class QmdMemoryManager {
981
964
  });
982
965
  this.#skillIndex = skillIndex;
983
966
  }
984
- const queryEmbedding = await llm.embed(queryText(query, llm.embedModelName), { isQuery: true });
967
+ const queryEmbedding = await llm.embed(formatQueryForEmbedding(query, llm.embedModelName), { isQuery: true });
985
968
  if (!queryEmbedding)
986
969
  return [];
987
970
  const candidates = await skillIndex;
@@ -1064,6 +1047,7 @@ export class QmdMemoryManager {
1064
1047
  if (this.#watchTimer)
1065
1048
  clearTimeout(this.#watchTimer);
1066
1049
  this.#watchTimer = undefined;
1050
+ this.#watchCollections.clear();
1067
1051
  await this.#watcher?.close();
1068
1052
  this.#watcher = undefined;
1069
1053
  this.#watchReady = undefined;
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { judgeTypeSafeMemories } from "./typesafe.js";
2
+ import { judgeMemoryPassage, memoryUsefulnessRequest } from "./typesafe.js";
3
3
  import { resolveTypeSafeApiKey, TypeSafeRequestError } from "./typesafe-client.js";
4
4
  import { complementaryIndices, reviewMemoryRedundancy } from "./typesafe-review.js";
5
5
  import { ApiQueryGenerator, QueryApiError, queryConversation } from "./query-generator.js";
@@ -164,15 +164,11 @@ export function registerMemoryWhisperer(api, runtime, config, typesafe, diagnost
164
164
  timeoutMs: typesafe.timeoutMs };
165
165
  try {
166
166
  const { hit, excerpt } = candidate;
167
- const [probability] = await judgeTypeSafeMemories({
168
- apiKey, timeoutMs: typesafe.timeoutMs, signal,
169
- conversation, asOf,
170
- candidates: [{
171
- excerpt, corpus: hit.corpus, sourcePath: hit.path,
172
- dates: [...new Set(hit.sessionMessages?.flatMap(message => message.timestamp ? [message.timestamp] : [])
173
- ?? (hit.messageTimestamp ? [hit.messageTimestamp] : []))],
174
- }],
175
- });
167
+ const { probability } = await judgeMemoryPassage(memoryUsefulnessRequest(conversation, {
168
+ excerpt, corpus: hit.corpus, sourcePath: hit.path,
169
+ dates: [...new Set(hit.sessionMessages?.flatMap(message => message.timestamp ? [message.timestamp] : [])
170
+ ?? (hit.messageTimestamp ? [hit.messageTimestamp] : []))],
171
+ }, asOf), { apiKey, timeoutMs: typesafe.timeoutMs, signal });
176
172
  if (signal.aborted)
177
173
  return [];
178
174
  requestsSucceeded++;