@unblocklabs/unblock-memory 0.7.1 → 0.7.3

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 (54) hide show
  1. package/dist/src/analysis.js +36 -25
  2. package/dist/src/config.js +2 -17
  3. package/dist/src/contracts.d.ts +1 -1
  4. package/dist/src/extraction-chunk.d.ts +1 -0
  5. package/dist/src/extraction-chunk.js +1 -0
  6. package/dist/src/extraction-model.js +5 -4
  7. package/dist/src/extraction-runtime.js +2 -7
  8. package/dist/src/extraction-source.js +6 -2
  9. package/dist/src/extraction-store.d.ts +3 -0
  10. package/dist/src/extraction-store.js +14 -10
  11. package/dist/src/extraction-worker.js +31 -15
  12. package/dist/src/manager.d.ts +1 -1
  13. package/dist/src/manager.js +66 -85
  14. package/dist/src/memory-whisperer.js +6 -10
  15. package/dist/src/people-primer-config.js +1 -4
  16. package/dist/src/people-primer.js +6 -8
  17. package/dist/src/people-store.js +9 -1
  18. package/dist/src/people-tools.js +2 -6
  19. package/dist/src/plugin.js +6 -14
  20. package/dist/src/quality-audit.js +11 -2
  21. package/dist/src/query-contract.js +18 -10
  22. package/dist/src/response-audit.js +3 -3
  23. package/dist/src/response-judge.js +1 -2
  24. package/dist/src/response-reviews.d.ts +1 -1
  25. package/dist/src/response-reviews.js +4 -2
  26. package/dist/src/response-store.js +3 -2
  27. package/dist/src/retrieval-telemetry.d.ts +1 -1
  28. package/dist/src/review-tools.d.ts +24 -0
  29. package/dist/src/review-tools.js +22 -26
  30. package/dist/src/runtime.js +2 -1
  31. package/dist/src/session-projector.js +8 -4
  32. package/dist/src/session-sync.js +2 -1
  33. package/dist/src/skill-whisperer.js +51 -24
  34. package/dist/src/training-candidates.d.ts +12 -13
  35. package/dist/src/training-candidates.js +11 -81
  36. package/dist/src/training-gate.js +2 -2
  37. package/dist/src/training-queries.js +5 -2
  38. package/dist/src/training-retrieval.d.ts +15 -2
  39. package/dist/src/training-retrieval.js +63 -39
  40. package/dist/src/training-store.d.ts +2 -8
  41. package/dist/src/training-store.js +15 -20
  42. package/dist/src/typesafe-client.d.ts +5 -0
  43. package/dist/src/typesafe-client.js +4 -0
  44. package/dist/src/typesafe-review.js +4 -6
  45. package/dist/src/typesafe.d.ts +1 -9
  46. package/dist/src/typesafe.js +4 -10
  47. package/dist/src/whisperer-context.d.ts +5 -1
  48. package/dist/src/whisperer-context.js +12 -7
  49. package/dist/src/workspace-path-classifier.js +39 -32
  50. package/docs/configuration.md +3 -2
  51. package/docs/memory-training.md +8 -7
  52. package/docs/retrieval.md +15 -1
  53. package/openclaw.plugin.json +2 -2
  54. package/package.json +6 -4
@@ -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 {
@@ -261,8 +260,8 @@ function byteSlice(text, maxBytes) {
261
260
  return "";
262
261
  return bytes.subarray(0, maxBytes - 3).toString("utf8").replace(/\uFFFD$/u, "") + "…";
263
262
  }
264
- function members(db, runId, clusterId, limit, offset = 0, sort = "representative", maxExcerptBytes = MAX_EXCERPT_BYTES, maxTotalBytes = MAX_TOTAL_EXCERPT_BYTES, maxTotalAliases = MAX_TOTAL_ALIASES, temporal = {}) {
265
- const representativeOrder = clusterId === -1
263
+ function representativeOrder(clusterId) {
264
+ return clusterId === -1
266
265
  ? "m.outlier_score DESC, m.hash, m.seq"
267
266
  : `CASE WHEN m.representative_rank IS NULL THEN 1 ELSE 0 END,
268
267
  m.representative_rank,
@@ -270,9 +269,11 @@ function members(db, runId, clusterId, limit, offset = 0, sort = "representative
270
269
  m.outlier_score,
271
270
  m.hash,
272
271
  m.seq`;
272
+ }
273
+ function members(db, runId, clusterId, limit, offset = 0, sort = "representative", maxExcerptBytes = MAX_EXCERPT_BYTES, maxTotalBytes = MAX_TOTAL_EXCERPT_BYTES, maxTotalAliases = MAX_TOTAL_ALIASES, temporal = {}) {
273
274
  const score = clusterId === -1 ? "m.outlier_score" : "m.probability";
274
275
  const order = {
275
- representative: representativeOrder,
276
+ representative: representativeOrder(clusterId),
276
277
  score_desc: `${score} DESC, m.hash, m.seq`,
277
278
  score_asc: `${score} ASC, m.hash, m.seq`,
278
279
  date_desc: "julianday(COALESCE(m.event_time, m.source_modified_at)) DESC, m.hash, m.seq",
@@ -396,24 +397,34 @@ function readMetadata(run) {
396
397
  }
397
398
  return { stale: false, staleSince: null, analyzedAt: run.completed_at };
398
399
  }
400
+ function clusterPreview(db, runId, clusterId, maxBytes, aliasLimit) {
401
+ const row = db.prepare(`
402
+ SELECT m.hash, m.seq, m.probability, m.pos, m.chunk_len, m.doc
403
+ FROM memory_analysis_available_memberships m
404
+ WHERE m.run_id = ? AND m.cluster_id = ?
405
+ ORDER BY ${representativeOrder(clusterId)}
406
+ LIMIT 1
407
+ `).get(runId, clusterId);
408
+ if (!row)
409
+ return undefined;
410
+ return {
411
+ hash: row.hash,
412
+ seq: row.seq,
413
+ probability: row.probability,
414
+ text: byteSlice(row.doc.slice(row.pos, row.pos + row.chunk_len), maxBytes),
415
+ sourcePaths: sourcePaths(db, row.hash, Math.min(MAX_ALIASES_PER_MEMBER, aliasLimit)),
416
+ };
417
+ }
399
418
  function toSummary(db, run, row, includePreview, previewBytes = 600, aliasLimit = MAX_TOTAL_ALIASES) {
400
419
  const preview = includePreview
401
- ? members(db, run.id, row.cluster_id, 1, 0, "representative", previewBytes, previewBytes, aliasLimit)[0]
420
+ ? clusterPreview(db, run.id, row.cluster_id, previewBytes, aliasLimit)
402
421
  : undefined;
403
422
  return {
404
423
  clusterId: clusterReference(run.id, row.cluster_id),
405
424
  size: row.size,
406
425
  availableSize: row.available_size,
407
426
  meanProbability: row.mean_probability,
408
- ...(preview ? {
409
- preview: {
410
- hash: preview.hash,
411
- seq: preview.seq,
412
- probability: preview.probability,
413
- text: preview.text,
414
- sourcePaths: preview.sourcePaths,
415
- },
416
- } : {}),
427
+ ...(preview ? { preview } : {}),
417
428
  };
418
429
  }
419
430
  function noiseRow(db, runId) {
@@ -291,23 +291,8 @@ function resolvePeople(value) {
291
291
  };
292
292
  }
293
293
  export function resolveConfig(value) {
294
- if (value === undefined || value === null) {
295
- return {
296
- corpora: DEFAULT_CORPORA,
297
- extraction: resolveExtraction(undefined, DEFAULT_CORPORA),
298
- keepEmbeddingModelWarm: true,
299
- analysis: {},
300
- typesafe: { ...DEFAULT_TYPESAFE_CONFIG },
301
- qualityAudit: { ...DEFAULT_QUALITY_AUDIT },
302
- evidenceReview: { enabled: false, corpora: [] },
303
- responseAudit: resolveResponseAudit(undefined, DEFAULT_CORPORA),
304
- insideOut: resolveInsideOut(undefined),
305
- peoplePrimer: resolvePeoplePrimer(undefined, DEFAULT_CORPORA, false),
306
- people: DEFAULT_PEOPLE_CONFIG,
307
- skillWhisperer: DEFAULT_SKILL_WHISPERER,
308
- memoryWhisperer: { ...DEFAULT_MEMORY_WHISPERER, api: resolveQueryApi(undefined) },
309
- };
310
- }
294
+ if (value === undefined || value === null)
295
+ value = {};
311
296
  if (typeof value !== "object" || Array.isArray(value)) {
312
297
  throw new Error("unblock-memory config must be an object");
313
298
  }
@@ -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" | "close">;
16
16
  export type ManagerSessionConfig = {
17
17
  agentId: string;
18
18
  agentName: string;