@unblocklabs/unblock-memory 0.2.6 → 0.2.7

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.
package/README.md CHANGED
@@ -122,9 +122,10 @@ does not sync sessions at startup or on a schedule; refreshes are manual through
122
122
  `memory_sync_sessions`.
123
123
 
124
124
  Indexes live at `~/.openclaw/agents/<agentId>/unblock-memory/index.sqlite` (or the
125
- equivalent configured OpenClaw state directory). The first lookup builds the
126
- index; Markdown filesystem changes queue a debounced, serialized background
127
- refresh.
125
+ equivalent configured OpenClaw state directory). Durable agent-supplied event
126
+ dates and maintenance proposals live separately in `curation.sqlite`, so a QMD
127
+ index rebuild does not discard them. The first lookup builds the index;
128
+ Markdown filesystem changes queue a debounced, serialized background refresh.
128
129
 
129
130
  ## Memory analysis
130
131
 
@@ -175,10 +176,22 @@ and a deterministic seed. Omitting them uses the worker's defaults.
175
176
  `memory_fetch_cluster` accepts `topK` (1–50), a zero-based `offset`, and
176
177
  `sort`: `representative` (the default), `score_desc`, `score_asc`, `date_desc`,
177
178
  or `date_asc`. Score is cluster membership probability for normal clusters and
178
- outlier score for noise. Each member includes `sourceDate`, the latest
179
- modification time among its active source aliases. For projected sessions that
180
- date is the session start time. Responses include page totals and the next
181
- offset when more members remain.
179
+ outlier score for noise. Each member reports raw `sourceModifiedAt` separately
180
+ from `eventTime` and `eventTimeBasis`. Session start times and dated memory paths
181
+ resolve programmatically; reviewed annotations resolve otherwise ambiguous
182
+ chunks or whole documents. Date sorting uses resolved event time when available
183
+ and the clearly labeled source modification time only as a fallback. Responses
184
+ include page totals and the next offset when more members remain.
185
+
186
+ A chronological cluster read creates a coalesced maintenance proposal only for
187
+ returned documents whose event time remains ambiguous; it does not scan the
188
+ whole corpus for chores. Persisted exact-duplicate analysis can likewise create
189
+ review proposals for non-session Markdown. `memory_list_maintenance_tasks`
190
+ returns at most ten tasks, while `memory_update_maintenance_task` can resolve,
191
+ defer, or mark one irrelevant and optionally attach a supported event date.
192
+ These tools never edit or delete source Markdown. Duplicate cleanup remains a
193
+ reviewed source change outside the maintenance tool, and generated session
194
+ projections must never be edited directly.
182
195
 
183
196
  Member excerpts are capped at 2 KB each and 12 KB across a response; source
184
197
  aliases are capped at five per member and 50 across a response. These budgets
@@ -1,5 +1,8 @@
1
1
  import type { QMDStore } from "@unblocklabs/qmd";
2
2
  type AnalysisDatabase = QMDStore["internal"]["db"];
3
+ export type TemporalReadOptions = {
4
+ sessionCollection?: string;
5
+ };
3
6
  export type MemoryReclusterOptions = {
4
7
  space?: {
5
8
  method?: "umap" | "none";
@@ -30,7 +33,11 @@ type MemoryAnalysisMember = {
30
33
  x: number;
31
34
  y: number;
32
35
  representativeRank: number | null;
33
- sourceDate: string;
36
+ sourceModifiedAt: string;
37
+ eventTime: string | null;
38
+ eventTimeBasis: "path" | "frontmatter" | "session" | "agent_verified" | null;
39
+ eventTimeSource: string;
40
+ contentFingerprint: string;
34
41
  text: string;
35
42
  sourcePaths: string[];
36
43
  };
@@ -94,5 +101,5 @@ export declare function runAnalysisWorker(params: {
94
101
  export declare function latestAnalysisRunId(db: AnalysisDatabase): string | undefined;
95
102
  export declare function readAnalysisSummary(db: AnalysisDatabase): MemoryAnalysisSummary | undefined;
96
103
  export declare function readClusters(db: AnalysisDatabase, requestedLimit?: number): MemoryClusterList;
97
- export declare function readCluster(db: AnalysisDatabase, clusterReferenceId: string, requestedLimit?: number, requestedOffset?: number, sort?: MemoryClusterSort): MemoryClusterDetail;
104
+ export declare function readCluster(db: AnalysisDatabase, clusterReferenceId: string, requestedLimit?: number, requestedOffset?: number, sort?: MemoryClusterSort, temporal?: TemporalReadOptions): MemoryClusterDetail;
98
105
  export {};
@@ -48,6 +48,17 @@ export function ensureMemoryAnalysisSchema(db) {
48
48
  CREATE INDEX IF NOT EXISTS idx_memory_analysis_memberships_cluster
49
49
  ON memory_analysis_memberships(run_id, cluster_id, representative_rank);
50
50
 
51
+ CREATE TABLE IF NOT EXISTS memory_analysis_duplicate_occurrences (
52
+ run_id TEXT NOT NULL,
53
+ content_fingerprint TEXT NOT NULL,
54
+ canonical_hash TEXT NOT NULL,
55
+ canonical_seq INTEGER NOT NULL,
56
+ duplicate_hash TEXT NOT NULL,
57
+ duplicate_seq INTEGER NOT NULL,
58
+ PRIMARY KEY (run_id, duplicate_hash, duplicate_seq),
59
+ FOREIGN KEY (run_id) REFERENCES memory_analysis_runs(id) ON DELETE CASCADE
60
+ );
61
+
51
62
  CREATE VIEW IF NOT EXISTS memory_analysis_available_memberships AS
52
63
  SELECT
53
64
  m.run_id, m.hash, m.seq, m.cluster_id, m.probability, m.outlier_score,
@@ -61,6 +72,18 @@ export function ensureMemoryAnalysisSchema(db) {
61
72
  WHERE d.hash = m.hash AND d.active = 1
62
73
  );
63
74
  `);
75
+ db.exec(`
76
+ CREATE TEMP TABLE IF NOT EXISTS memory_temporal_annotations (
77
+ collection TEXT NOT NULL,
78
+ path TEXT NOT NULL,
79
+ qmd_hash TEXT,
80
+ qmd_seq INTEGER,
81
+ event_time TEXT NOT NULL,
82
+ basis TEXT NOT NULL,
83
+ document_wide INTEGER NOT NULL,
84
+ PRIMARY KEY (collection, path, qmd_hash, qmd_seq, document_wide)
85
+ );
86
+ `);
64
87
  }
65
88
  export function markMemoryAnalysisStale(db) {
66
89
  db.prepare(`
@@ -208,7 +231,7 @@ function byteSlice(text, maxBytes) {
208
231
  return "";
209
232
  return bytes.subarray(0, maxBytes - 3).toString("utf8").replace(/\uFFFD$/u, "") + "…";
210
233
  }
211
- function members(db, runId, clusterId, limit, offset = 0, sort = "representative", maxExcerptBytes = MAX_EXCERPT_BYTES, maxTotalBytes = MAX_TOTAL_EXCERPT_BYTES, maxTotalAliases = MAX_TOTAL_ALIASES) {
234
+ function members(db, runId, clusterId, limit, offset = 0, sort = "representative", maxExcerptBytes = MAX_EXCERPT_BYTES, maxTotalBytes = MAX_TOTAL_EXCERPT_BYTES, maxTotalAliases = MAX_TOTAL_ALIASES, temporal = {}) {
212
235
  const representativeOrder = clusterId === -1
213
236
  ? "m.outlier_score DESC, m.hash, m.seq"
214
237
  : `CASE WHEN m.representative_rank IS NULL THEN 1 ELSE 0 END,
@@ -222,32 +245,79 @@ function members(db, runId, clusterId, limit, offset = 0, sort = "representative
222
245
  representative: representativeOrder,
223
246
  score_desc: `${score} DESC, m.hash, m.seq`,
224
247
  score_asc: `${score} ASC, m.hash, m.seq`,
225
- date_desc: "m.source_date DESC, m.hash, m.seq",
226
- date_asc: "m.source_date ASC, m.hash, m.seq",
248
+ date_desc: "julianday(COALESCE(m.event_time, m.source_modified_at)) DESC, m.hash, m.seq",
249
+ date_asc: "julianday(COALESCE(m.event_time, m.source_modified_at)) ASC, m.hash, m.seq",
227
250
  }[sort];
228
251
  const rows = db.prepare(`
229
- WITH member_rows AS (
252
+ WITH candidate_times AS (
253
+ SELECT
254
+ m.hash,
255
+ m.seq,
256
+ d.collection,
257
+ d.path,
258
+ d.modified_at AS source_modified_at,
259
+ CASE
260
+ WHEN d.collection = ? THEN d.modified_at
261
+ WHEN d.path GLOB '*[12][0-9][0-9][0-9]-[01][0-9]-[0-3][0-9].md'
262
+ THEN substr(d.path, length(d.path) - 12, 10) || 'T00:00:00.000Z'
263
+ ELSE annotation.event_time
264
+ END AS event_time,
265
+ CASE
266
+ WHEN d.collection = ? THEN 'session'
267
+ WHEN d.path GLOB '*[12][0-9][0-9][0-9]-[01][0-9]-[0-3][0-9].md' THEN 'path'
268
+ ELSE annotation.basis
269
+ END AS event_time_basis,
270
+ CASE
271
+ WHEN d.collection = ? THEN 1
272
+ WHEN d.path GLOB '*[12][0-9][0-9][0-9]-[01][0-9]-[0-3][0-9].md' THEN 2
273
+ WHEN annotation.event_time IS NOT NULL THEN 3
274
+ ELSE 4
275
+ END AS priority
276
+ FROM memory_analysis_available_memberships m
277
+ JOIN documents d ON d.hash = m.hash AND d.active = 1
278
+ LEFT JOIN memory_temporal_annotations annotation
279
+ ON annotation.collection = d.collection
280
+ AND annotation.path = d.path
281
+ AND (annotation.document_wide = 1 OR
282
+ (annotation.qmd_hash = m.hash AND annotation.qmd_seq = m.seq))
283
+ WHERE m.run_id = ? AND m.cluster_id = ?
284
+ ), ranked_times AS (
285
+ SELECT *, ROW_NUMBER() OVER (
286
+ PARTITION BY hash, seq
287
+ ORDER BY priority, julianday(COALESCE(event_time, source_modified_at)) DESC, collection, path
288
+ ) AS rank
289
+ FROM candidate_times
290
+ ), member_rows AS (
230
291
  SELECT
231
292
  m.hash, m.seq, m.probability, m.outlier_score, m.x, m.y,
232
293
  m.representative_rank, m.pos, m.chunk_len, m.doc,
233
294
  (
234
- SELECT MAX(d.modified_at)
295
+ SELECT d.modified_at
235
296
  FROM documents d
236
297
  WHERE d.hash = m.hash AND d.active = 1
237
- ) AS source_date
298
+ ORDER BY julianday(d.modified_at) DESC, d.collection, d.path
299
+ LIMIT 1
300
+ ) AS source_modified_at,
301
+ temporal.event_time,
302
+ temporal.event_time_basis,
303
+ temporal.collection AS event_collection,
304
+ temporal.path AS event_path
238
305
  FROM memory_analysis_available_memberships m
306
+ JOIN ranked_times temporal
307
+ ON temporal.hash = m.hash AND temporal.seq = m.seq AND temporal.rank = 1
239
308
  WHERE m.run_id = ? AND m.cluster_id = ?
240
309
  )
241
310
  SELECT * FROM member_rows m
242
311
  ORDER BY ${order}
243
312
  LIMIT ? OFFSET ?
244
- `).all(runId, clusterId, limit, offset);
313
+ `).all(temporal.sessionCollection ?? "", temporal.sessionCollection ?? "", temporal.sessionCollection ?? "", runId, clusterId, runId, clusterId, limit, offset);
245
314
  let remaining = maxTotalBytes;
246
315
  let remainingAliases = maxTotalAliases;
247
316
  return rows.map((row, index) => {
248
317
  const remainingRows = rows.length - index;
249
318
  const excerptBudget = Math.min(maxExcerptBytes, Math.floor(remaining / remainingRows));
250
- const text = byteSlice(row.doc.slice(row.pos, row.pos + row.chunk_len), excerptBudget);
319
+ const fullText = row.doc.slice(row.pos, row.pos + row.chunk_len);
320
+ const text = byteSlice(fullText, excerptBudget);
251
321
  remaining -= Buffer.byteLength(text);
252
322
  const aliasBudget = Math.min(MAX_ALIASES_PER_MEMBER, Math.floor(remainingAliases / remainingRows));
253
323
  const aliases = sourcePaths(db, row.hash, aliasBudget);
@@ -260,7 +330,11 @@ function members(db, runId, clusterId, limit, offset = 0, sort = "representative
260
330
  x: row.x,
261
331
  y: row.y,
262
332
  representativeRank: row.representative_rank,
263
- sourceDate: row.source_date,
333
+ sourceModifiedAt: row.source_modified_at,
334
+ eventTime: row.event_time,
335
+ eventTimeBasis: row.event_time_basis,
336
+ eventTimeSource: `qmd://${row.event_collection}/${row.event_path}`,
337
+ contentFingerprint: createHash("sha256").update(fullText).digest("hex"),
264
338
  text,
265
339
  sourcePaths: aliases,
266
340
  };
@@ -380,7 +454,7 @@ function resolveClusterId(db, runId, reference) {
380
454
  `).all(runId, runId);
381
455
  return clusterIds.find((row) => clusterReference(runId, row.cluster_id) === reference)?.cluster_id;
382
456
  }
383
- export function readCluster(db, clusterReferenceId, requestedLimit = DEFAULT_MEMBER_LIMIT, requestedOffset = 0, sort = "representative") {
457
+ export function readCluster(db, clusterReferenceId, requestedLimit = DEFAULT_MEMBER_LIMIT, requestedOffset = 0, sort = "representative", temporal = {}) {
384
458
  const run = latestValidRun(db);
385
459
  if (!run) {
386
460
  return { status: "not_analyzed", ...readMetadata() };
@@ -408,7 +482,7 @@ export function readCluster(db, clusterReferenceId, requestedLimit = DEFAULT_MEM
408
482
  const limit = Math.max(1, Math.min(MAX_MEMBER_LIMIT, Math.floor(requestedLimit)));
409
483
  const offset = Math.max(0, Math.floor(requestedOffset));
410
484
  const total = availableSize(db, run.id, clusterId);
411
- const pageMembers = members(db, run.id, clusterId, limit, offset, sort);
485
+ const pageMembers = members(db, run.id, clusterId, limit, offset, sort, MAX_EXCERPT_BYTES, MAX_TOTAL_EXCERPT_BYTES, MAX_TOTAL_ALIASES, temporal);
412
486
  const nextOffset = offset + pageMembers.length;
413
487
  const hasMore = nextOffset < total;
414
488
  return {
@@ -0,0 +1,70 @@
1
+ declare const TEMPORAL_BASES: readonly ["path", "frontmatter", "session", "agent_verified"];
2
+ export type TemporalBasis = typeof TEMPORAL_BASES[number];
3
+ declare const MAINTENANCE_TASK_TYPES: readonly ["ambiguous_event_time", "exact_duplicate"];
4
+ export type MaintenanceTaskType = typeof MAINTENANCE_TASK_TYPES[number];
5
+ declare const MAINTENANCE_STATUSES: readonly ["pending", "resolved", "deferred", "irrelevant"];
6
+ export type MaintenanceStatus = typeof MAINTENANCE_STATUSES[number];
7
+ export type TemporalAnnotation = {
8
+ corpus: string;
9
+ collection: string;
10
+ path: string;
11
+ contentFingerprint: string;
12
+ eventTime: string;
13
+ basis: TemporalBasis;
14
+ evidence: string;
15
+ qmdHash: string | null;
16
+ qmdSeq: number | null;
17
+ createdAt: string;
18
+ updatedAt: string;
19
+ };
20
+ export type MaintenanceTask = {
21
+ id: string;
22
+ type: MaintenanceTaskType;
23
+ corpus: string;
24
+ collection: string;
25
+ path: string;
26
+ reason: string;
27
+ contentFingerprint: string;
28
+ detail: string | null;
29
+ resolutionNote: string | null;
30
+ status: MaintenanceStatus;
31
+ createdAt: string;
32
+ updatedAt: string;
33
+ };
34
+ export declare function chunkFingerprint(text: string): string;
35
+ export declare class CurationStore {
36
+ #private;
37
+ constructor(path: string);
38
+ close(): void;
39
+ annotations(): TemporalAnnotation[];
40
+ addTask(candidate: {
41
+ type: MaintenanceTaskType;
42
+ corpus: string;
43
+ collection: string;
44
+ path: string;
45
+ reason: string;
46
+ contentFingerprint?: string;
47
+ detail?: string;
48
+ }): void;
49
+ listTasks(params?: {
50
+ status?: MaintenanceStatus;
51
+ limit?: number;
52
+ }): MaintenanceTask[];
53
+ updateTask(params: {
54
+ id: string;
55
+ status: Exclude<MaintenanceStatus, "pending">;
56
+ note?: string;
57
+ annotation?: {
58
+ scope: "chunk" | "document";
59
+ eventTime: string;
60
+ basis: TemporalBasis;
61
+ evidence: string;
62
+ };
63
+ }): MaintenanceTask | undefined;
64
+ updateAnnotationLocation(params: {
65
+ annotation: TemporalAnnotation;
66
+ qmdHash: string | null;
67
+ qmdSeq: number | null;
68
+ }): void;
69
+ }
70
+ export {};
@@ -0,0 +1,191 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { chmodSync, mkdirSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import { DatabaseSync } from "node:sqlite";
5
+ const TEMPORAL_BASES = ["path", "frontmatter", "session", "agent_verified"];
6
+ const MAINTENANCE_TASK_TYPES = ["ambiguous_event_time", "exact_duplicate"];
7
+ const MAINTENANCE_STATUSES = ["pending", "resolved", "deferred", "irrelevant"];
8
+ function annotation(row) {
9
+ return {
10
+ corpus: row.corpus,
11
+ collection: row.collection,
12
+ path: row.path,
13
+ contentFingerprint: row.content_fingerprint,
14
+ eventTime: row.event_time,
15
+ basis: row.basis,
16
+ evidence: row.evidence,
17
+ qmdHash: row.qmd_hash,
18
+ qmdSeq: row.qmd_seq,
19
+ createdAt: row.created_at,
20
+ updatedAt: row.updated_at,
21
+ };
22
+ }
23
+ function task(row) {
24
+ return {
25
+ id: row.id,
26
+ type: row.type,
27
+ corpus: row.corpus,
28
+ collection: row.collection,
29
+ path: row.path,
30
+ reason: row.reason,
31
+ contentFingerprint: row.content_fingerprint,
32
+ detail: row.detail,
33
+ resolutionNote: row.resolution_note,
34
+ status: row.status,
35
+ createdAt: row.created_at,
36
+ updatedAt: row.updated_at,
37
+ };
38
+ }
39
+ export function chunkFingerprint(text) {
40
+ return createHash("sha256").update(text).digest("hex");
41
+ }
42
+ export class CurationStore {
43
+ #db;
44
+ constructor(path) {
45
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
46
+ this.#db = new DatabaseSync(path);
47
+ chmodSync(path, 0o600);
48
+ this.#db.exec(`
49
+ PRAGMA journal_mode = WAL;
50
+ PRAGMA busy_timeout = 5000;
51
+
52
+ CREATE TABLE IF NOT EXISTS temporal_annotations (
53
+ corpus TEXT NOT NULL,
54
+ collection TEXT NOT NULL,
55
+ path TEXT NOT NULL,
56
+ content_fingerprint TEXT NOT NULL DEFAULT '',
57
+ event_time TEXT NOT NULL,
58
+ basis TEXT NOT NULL CHECK (basis IN ('path', 'frontmatter', 'session', 'agent_verified')),
59
+ evidence TEXT NOT NULL,
60
+ qmd_hash TEXT,
61
+ qmd_seq INTEGER,
62
+ created_at TEXT NOT NULL,
63
+ updated_at TEXT NOT NULL,
64
+ PRIMARY KEY (corpus, collection, path, content_fingerprint)
65
+ );
66
+
67
+ `);
68
+ this.#ensureMaintenanceSchema();
69
+ }
70
+ #ensureMaintenanceSchema() {
71
+ this.#db.exec(`
72
+ CREATE TABLE IF NOT EXISTS maintenance_tasks (
73
+ id TEXT PRIMARY KEY,
74
+ type TEXT NOT NULL CHECK (type IN ('ambiguous_event_time', 'exact_duplicate')),
75
+ corpus TEXT NOT NULL,
76
+ collection TEXT NOT NULL,
77
+ path TEXT NOT NULL,
78
+ reason TEXT NOT NULL,
79
+ content_fingerprint TEXT NOT NULL,
80
+ detail TEXT,
81
+ resolution_note TEXT,
82
+ status TEXT NOT NULL CHECK (status IN ('pending', 'resolved', 'deferred', 'irrelevant')),
83
+ created_at TEXT NOT NULL,
84
+ updated_at TEXT NOT NULL,
85
+ UNIQUE (type, corpus, collection, path, reason, content_fingerprint)
86
+ );
87
+ `);
88
+ this.#db.exec(`
89
+ CREATE INDEX IF NOT EXISTS maintenance_tasks_status_created
90
+ ON maintenance_tasks(status, created_at);
91
+ `);
92
+ }
93
+ close() {
94
+ this.#db.close();
95
+ }
96
+ annotations() {
97
+ return this.#db.prepare(`
98
+ SELECT * FROM temporal_annotations
99
+ ORDER BY collection, path, content_fingerprint
100
+ `).all().map((row) => annotation(row));
101
+ }
102
+ addTask(candidate) {
103
+ const now = new Date().toISOString();
104
+ this.#db.prepare(`
105
+ INSERT INTO maintenance_tasks
106
+ (id, type, corpus, collection, path, reason, content_fingerprint, detail, status, created_at, updated_at)
107
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)
108
+ ON CONFLICT(type, corpus, collection, path, reason, content_fingerprint) DO UPDATE SET
109
+ detail = CASE
110
+ WHEN maintenance_tasks.status = 'pending' THEN excluded.detail
111
+ ELSE maintenance_tasks.detail
112
+ END,
113
+ updated_at = CASE
114
+ WHEN maintenance_tasks.status = 'pending' THEN excluded.updated_at
115
+ ELSE maintenance_tasks.updated_at
116
+ END
117
+ `).run(randomUUID(), candidate.type, candidate.corpus, candidate.collection, candidate.path, candidate.reason, candidate.contentFingerprint ?? "", candidate.detail ?? null, now, now);
118
+ }
119
+ listTasks(params = {}) {
120
+ const status = params.status ?? "pending";
121
+ const limit = Math.max(1, Math.min(10, Math.floor(params.limit ?? 5)));
122
+ return this.#db.prepare(`
123
+ SELECT * FROM maintenance_tasks
124
+ WHERE status = ?
125
+ ORDER BY created_at, id
126
+ LIMIT ?
127
+ `).all(status, limit).map((row) => task(row));
128
+ }
129
+ updateTask(params) {
130
+ this.#db.exec("BEGIN IMMEDIATE");
131
+ try {
132
+ const row = this.#db.prepare("SELECT * FROM maintenance_tasks WHERE id = ?")
133
+ .get(params.id);
134
+ if (!row) {
135
+ this.#db.exec("COMMIT");
136
+ return undefined;
137
+ }
138
+ const now = new Date().toISOString();
139
+ if (row.type === "ambiguous_event_time" && params.status === "resolved" && !params.annotation) {
140
+ throw new Error("resolving an ambiguous event-time task requires a date annotation");
141
+ }
142
+ if (params.annotation) {
143
+ if (row.type !== "ambiguous_event_time") {
144
+ throw new Error("date annotations can only resolve ambiguous event-time tasks");
145
+ }
146
+ if (params.status !== "resolved") {
147
+ throw new Error("date annotations require resolved status");
148
+ }
149
+ if (!Number.isFinite(Date.parse(params.annotation.eventTime))) {
150
+ throw new Error("date annotation eventTime must be an ISO 8601 timestamp");
151
+ }
152
+ const fingerprint = params.annotation.scope === "document" ? "" : row.content_fingerprint;
153
+ if (params.annotation.scope === "chunk" && !fingerprint) {
154
+ throw new Error("chunk annotation requires a content fingerprint");
155
+ }
156
+ this.#db.prepare(`
157
+ INSERT INTO temporal_annotations
158
+ (corpus, collection, path, content_fingerprint, event_time, basis, evidence,
159
+ qmd_hash, qmd_seq, created_at, updated_at)
160
+ VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)
161
+ ON CONFLICT(corpus, collection, path, content_fingerprint) DO UPDATE SET
162
+ event_time = excluded.event_time,
163
+ basis = excluded.basis,
164
+ evidence = excluded.evidence,
165
+ qmd_hash = NULL,
166
+ qmd_seq = NULL,
167
+ updated_at = excluded.updated_at
168
+ `).run(row.corpus, row.collection, row.path, fingerprint, params.annotation.eventTime, params.annotation.basis, params.annotation.evidence, now, now);
169
+ }
170
+ this.#db.prepare(`
171
+ UPDATE maintenance_tasks
172
+ SET status = ?, resolution_note = ?, updated_at = ?
173
+ WHERE id = ?
174
+ `).run(params.status, params.note ?? null, now, params.id);
175
+ const updated = task(this.#db.prepare("SELECT * FROM maintenance_tasks WHERE id = ?").get(params.id));
176
+ this.#db.exec("COMMIT");
177
+ return updated;
178
+ }
179
+ catch (error) {
180
+ this.#db.exec("ROLLBACK");
181
+ throw error;
182
+ }
183
+ }
184
+ updateAnnotationLocation(params) {
185
+ this.#db.prepare(`
186
+ UPDATE temporal_annotations
187
+ SET qmd_hash = ?, qmd_seq = ?
188
+ WHERE corpus = ? AND collection = ? AND path = ? AND content_fingerprint = ?
189
+ `).run(params.qmdHash, params.qmdSeq, params.annotation.corpus, params.annotation.collection, params.annotation.path, params.annotation.contentFingerprint);
190
+ }
191
+ }
@@ -2,6 +2,7 @@ import type { QMDStore } from "@unblocklabs/qmd";
2
2
  import { type AnalysisRunner, type MemoryAnalysisSummary, type MemoryClusterDetail, type MemoryClusterList, type MemoryClusterSort, type MemoryReclusterOptions } from "./analysis.js";
3
3
  import type { CorpusMemorySearchResult, CorpusSearchOptions, MemoryEmbeddingProbeResult, MemoryProviderStatus, MemoryReadResult, MemorySearchManagerContract, MemorySyncParams } from "./contracts.js";
4
4
  import type { ChatType } from "./config.js";
5
+ import { type MaintenanceStatus, type TemporalBasis } from "./curation.js";
5
6
  import { type SessionSyncResult } from "./session-sync.js";
6
7
  import { type ResolvedSource } from "./sources.js";
7
8
  export type ManagerStore = Pick<QMDStore, "update" | "embed" | "getStatus" | "listCollections" | "searchLex" | "vsearch" | "get" | "getDocumentBody" | "close">;
@@ -28,6 +29,7 @@ export declare class QmdMemoryManager implements MemorySearchManagerContract {
28
29
  #private;
29
30
  constructor(params: {
30
31
  dbPath: string;
32
+ curationPath?: string;
31
33
  workspaceDir: string;
32
34
  sources: readonly ResolvedSource[];
33
35
  storeFactory?: () => Promise<ManagerStore>;
@@ -47,6 +49,21 @@ export declare class QmdMemoryManager implements MemorySearchManagerContract {
47
49
  offset?: number;
48
50
  sort?: MemoryClusterSort;
49
51
  }): Promise<MemoryClusterDetail>;
52
+ listMaintenanceTasks(params?: {
53
+ status?: MaintenanceStatus;
54
+ limit?: number;
55
+ }): import("./curation.js").MaintenanceTask[];
56
+ updateMaintenanceTask(params: {
57
+ id: string;
58
+ status: Exclude<MaintenanceStatus, "pending">;
59
+ note?: string;
60
+ annotation?: {
61
+ scope: "chunk" | "document";
62
+ eventTime: string;
63
+ basis: TemporalBasis;
64
+ evidence: string;
65
+ };
66
+ }): import("./curation.js").MaintenanceTask | undefined;
50
67
  search(query: string, opts?: CorpusSearchOptions): Promise<CorpusMemorySearchResult[]>;
51
68
  readFile(params: {
52
69
  relPath: string;
@@ -2,6 +2,7 @@ import { mkdir, stat } from "node:fs/promises";
2
2
  import { dirname } from "node:path";
3
3
  import chokidar from "chokidar";
4
4
  import { ensureMemoryAnalysisSchema, latestAnalysisRunId, markMemoryAnalysisStale, readAnalysisSummary, readCluster, readClusters, runAnalysisWorker, } from "./analysis.js";
5
+ import { CurationStore, chunkFingerprint, } from "./curation.js";
5
6
  import { readSessionManifest, sessionMetadataByPath, syncSessionProjections, } from "./session-sync.js";
6
7
  import { parseSafeVirtualPath } from "./sources.js";
7
8
  const DEFAULT_READ_LINES = 120;
@@ -135,6 +136,7 @@ function sessionAllowedPaths(metadataByPath, collection, filter) {
135
136
  export class QmdMemoryManager {
136
137
  #dbPath;
137
138
  #workspaceDir;
139
+ #curationPath;
138
140
  #sources;
139
141
  #storeFactory;
140
142
  #keepModelsWarm;
@@ -142,6 +144,7 @@ export class QmdMemoryManager {
142
144
  #analysisRunner;
143
145
  #sessions;
144
146
  #store;
147
+ #curation;
145
148
  #cleanupRemovedDocuments;
146
149
  #operationChain;
147
150
  #watcher;
@@ -155,6 +158,7 @@ export class QmdMemoryManager {
155
158
  #sessionManifestMtimeNs;
156
159
  constructor(params) {
157
160
  this.#dbPath = params.dbPath;
161
+ this.#curationPath = params.curationPath ?? `${params.dbPath}.curation.sqlite`;
158
162
  this.#workspaceDir = params.workspaceDir;
159
163
  this.#sources = new Map(params.sources.map((source) => [source.collection, source]));
160
164
  this.#storeFactory = params.storeFactory;
@@ -391,7 +395,126 @@ export class QmdMemoryManager {
391
395
  return this.#enqueue(async () => readClusters((await this.#getAnalysisStore()).internal.db, limit));
392
396
  }
393
397
  fetchCluster(params) {
394
- return this.#enqueue(async () => readCluster((await this.#getAnalysisStore()).internal.db, params.clusterId, params.topK, params.offset, params.sort));
398
+ return this.#enqueue(async () => {
399
+ const db = (await this.#getAnalysisStore()).internal.db;
400
+ this.#loadTemporalAnnotations(db);
401
+ const detail = readCluster(db, params.clusterId, params.topK, params.offset, params.sort, { sessionCollection: this.#sessions?.collection });
402
+ if (params.sort === "date_asc" || params.sort === "date_desc") {
403
+ for (const member of detail.members ?? []) {
404
+ if (member.eventTime !== null)
405
+ continue;
406
+ const safe = parseSafeVirtualPath(member.eventTimeSource, this.#sources);
407
+ if (!safe)
408
+ continue;
409
+ this.#getCuration().addTask({
410
+ type: "ambiguous_event_time",
411
+ corpus: safe.source.corpus,
412
+ collection: safe.source.collection,
413
+ path: safe.relativePath,
414
+ reason: "cluster chronology has no reliable event time",
415
+ contentFingerprint: member.contentFingerprint,
416
+ detail: "Inspect the document and relevant evidence; annotate a date only when one can be supported.",
417
+ });
418
+ }
419
+ }
420
+ if (detail.runId && detail.members) {
421
+ this.#addDuplicateTasks(db, detail.runId, detail.members);
422
+ }
423
+ return detail;
424
+ });
425
+ }
426
+ listMaintenanceTasks(params = {}) {
427
+ return this.#getCuration().listTasks(params);
428
+ }
429
+ updateMaintenanceTask(params) {
430
+ return this.#getCuration().updateTask(params);
431
+ }
432
+ #getCuration() {
433
+ this.#curation ??= new CurationStore(this.#curationPath);
434
+ return this.#curation;
435
+ }
436
+ #loadTemporalAnnotations(db) {
437
+ db.exec("DELETE FROM memory_temporal_annotations");
438
+ const findChunks = db.prepare(`
439
+ SELECT d.hash, vectors.seq, vectors.pos, vectors.chunk_len, content.doc
440
+ FROM documents d
441
+ JOIN content ON content.hash = d.hash
442
+ JOIN content_vectors vectors ON vectors.hash = d.hash
443
+ WHERE d.collection = ? AND d.path = ? AND d.active = 1
444
+ ORDER BY vectors.seq
445
+ `);
446
+ const insert = db.prepare(`
447
+ INSERT OR REPLACE INTO memory_temporal_annotations
448
+ (collection, path, qmd_hash, qmd_seq, event_time, basis, document_wide)
449
+ VALUES (?, ?, ?, ?, ?, ?, ?)
450
+ `);
451
+ const curation = this.#getCuration();
452
+ for (const annotation of curation.annotations()) {
453
+ if (!annotation.contentFingerprint) {
454
+ insert.run(annotation.collection, annotation.path, null, null, annotation.eventTime, annotation.basis, 1);
455
+ continue;
456
+ }
457
+ const rows = findChunks.all(annotation.collection, annotation.path);
458
+ const matched = rows.find((row) => chunkFingerprint(row.doc.slice(row.pos, row.pos + row.chunk_len)) === annotation.contentFingerprint);
459
+ curation.updateAnnotationLocation({
460
+ annotation,
461
+ qmdHash: matched?.hash ?? null,
462
+ qmdSeq: matched?.seq ?? null,
463
+ });
464
+ if (matched) {
465
+ insert.run(annotation.collection, annotation.path, matched.hash, matched.seq, annotation.eventTime, annotation.basis, 0);
466
+ }
467
+ }
468
+ }
469
+ #addDuplicateTasks(db, runId, members) {
470
+ if (members.length === 0)
471
+ return;
472
+ const pageMatch = members.map(() => "(duplicates.canonical_hash = ? AND duplicates.canonical_seq = ?) OR " +
473
+ "(duplicates.duplicate_hash = ? AND duplicates.duplicate_seq = ?)").join(" OR ");
474
+ const pageParams = members.flatMap((member) => [member.hash, member.seq, member.hash, member.seq]);
475
+ const sessionCollections = [...this.#sources.values()]
476
+ .filter((source) => source.kind === "sessions")
477
+ .map((source) => source.collection);
478
+ const excludeSessions = sessionCollections.length > 0
479
+ ? `duplicate_document.collection NOT IN (${sessionCollections.map(() => "?").join(", ")})`
480
+ : "1 = 1";
481
+ const rows = db.prepare(`
482
+ SELECT
483
+ duplicate_document.collection,
484
+ duplicate_document.path,
485
+ duplicates.content_fingerprint,
486
+ COUNT(*) AS occurrence_count
487
+ FROM memory_analysis_duplicate_occurrences duplicates
488
+ JOIN (SELECT DISTINCT hash FROM documents WHERE active = 1) canonical_document
489
+ ON canonical_document.hash = duplicates.canonical_hash
490
+ JOIN documents duplicate_document
491
+ ON duplicate_document.hash = duplicates.duplicate_hash
492
+ AND duplicate_document.active = 1
493
+ WHERE duplicates.run_id = ?
494
+ AND (${pageMatch})
495
+ AND ${excludeSessions}
496
+ GROUP BY duplicate_document.collection, duplicate_document.path,
497
+ duplicates.content_fingerprint
498
+ ORDER BY duplicate_document.collection, duplicate_document.path,
499
+ duplicates.content_fingerprint
500
+ LIMIT 10
501
+ `).all(runId, ...pageParams, ...sessionCollections);
502
+ const curation = this.#getCuration();
503
+ for (const row of rows) {
504
+ const source = this.#sources.get(row.collection);
505
+ if (!source || source.kind === "sessions")
506
+ continue;
507
+ curation.addTask({
508
+ type: "exact_duplicate",
509
+ corpus: source.corpus,
510
+ collection: row.collection,
511
+ path: row.path,
512
+ reason: "exact chunk content repeats in this source document",
513
+ contentFingerprint: row.content_fingerprint,
514
+ detail: `${row.occurrence_count} exact duplicate occurrence${row.occurrence_count === 1 ? "" : "s"}. ` +
515
+ "Review the source and propose cleanup only if repetition is accidental.",
516
+ });
517
+ }
395
518
  }
396
519
  async #getAnalysisStore() {
397
520
  const store = await this.#getStore();
@@ -529,5 +652,7 @@ export class QmdMemoryManager {
529
652
  await this.#operationChain?.catch(() => undefined);
530
653
  await this.#store?.close();
531
654
  this.#store = undefined;
655
+ this.#curation?.close();
656
+ this.#curation = undefined;
532
657
  }
533
658
  }
@@ -219,6 +219,81 @@ function createFetchClusterTool(runtime, ctx) {
219
219
  },
220
220
  };
221
221
  }
222
+ const maintenanceStatus = Type.Union([
223
+ Type.Literal("pending"),
224
+ Type.Literal("resolved"),
225
+ Type.Literal("deferred"),
226
+ Type.Literal("irrelevant"),
227
+ ]);
228
+ const listMaintenanceParameters = Type.Object({
229
+ status: Type.Optional(maintenanceStatus),
230
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 10 })),
231
+ }, { additionalProperties: false });
232
+ function createListMaintenanceTool(runtime, ctx) {
233
+ const active = getContext(ctx);
234
+ if (!active)
235
+ return null;
236
+ return {
237
+ name: "memory_list_maintenance_tasks",
238
+ label: "List Memory Maintenance Tasks",
239
+ description: "List a bounded curation inbox of memory chronology and duplicate-review proposals.",
240
+ parameters: listMaintenanceParameters,
241
+ async execute(_toolCallId, params) {
242
+ const options = Value.Parse(listMaintenanceParameters, params);
243
+ const { manager, error } = await runtime.getMemorySearchManager(active);
244
+ if (!manager)
245
+ return jsonResult({ status: "unavailable", error: error ?? "memory unavailable" });
246
+ return jsonResult({ status: "ok", tasks: manager.listMaintenanceTasks(options) });
247
+ },
248
+ };
249
+ }
250
+ const isoTimestamp = Type.String({
251
+ pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,9})?(?:Z|[+-]\\d{2}:\\d{2})$",
252
+ });
253
+ const updateMaintenanceParameters = Type.Object({
254
+ taskId: Type.String({ pattern: "\\S" }),
255
+ action: Type.Union([
256
+ Type.Literal("resolve"),
257
+ Type.Literal("defer"),
258
+ Type.Literal("irrelevant"),
259
+ ]),
260
+ note: Type.Optional(Type.String({ minLength: 1, maxLength: 500 })),
261
+ annotation: Type.Optional(Type.Object({
262
+ scope: Type.Optional(Type.Union([Type.Literal("chunk"), Type.Literal("document")])),
263
+ eventTime: isoTimestamp,
264
+ basis: Type.Union([
265
+ Type.Literal("path"),
266
+ Type.Literal("frontmatter"),
267
+ Type.Literal("session"),
268
+ Type.Literal("agent_verified"),
269
+ ]),
270
+ evidence: Type.String({ minLength: 1, maxLength: 500 }),
271
+ }, { additionalProperties: false })),
272
+ }, { additionalProperties: false });
273
+ function createUpdateMaintenanceTool(runtime, ctx) {
274
+ const active = getContext(ctx);
275
+ if (!active)
276
+ return null;
277
+ return {
278
+ name: "memory_update_maintenance_task",
279
+ label: "Update Memory Maintenance Task",
280
+ description: "Resolve, defer, or dismiss a memory-maintenance proposal. This tool never edits source Markdown.",
281
+ parameters: updateMaintenanceParameters,
282
+ async execute(_toolCallId, params) {
283
+ const { taskId, action, note, annotation } = Value.Parse(updateMaintenanceParameters, params);
284
+ const { manager, error } = await runtime.getMemorySearchManager(active);
285
+ if (!manager)
286
+ return jsonResult({ status: "unavailable", error: error ?? "memory unavailable" });
287
+ const updated = manager.updateMaintenanceTask({
288
+ id: taskId,
289
+ status: action === "resolve" ? "resolved" : action === "defer" ? "deferred" : "irrelevant",
290
+ note,
291
+ ...(annotation ? { annotation: { ...annotation, scope: annotation.scope ?? "chunk" } } : {}),
292
+ });
293
+ return jsonResult(updated ? { status: "ok", task: updated } : { status: "not_found" });
294
+ },
295
+ };
296
+ }
222
297
  function formatDateInTimezone(timestamp, timezone) {
223
298
  const parts = new Intl.DateTimeFormat("en-US", {
224
299
  timeZone: timezone,
@@ -312,4 +387,6 @@ export function registerUnblockMemory(api) {
312
387
  api.registerTool((ctx) => createReclusterTool(runtime, ctx), { names: ["memory_recluster"] });
313
388
  api.registerTool((ctx) => createListClustersTool(runtime, ctx), { names: ["memory_list_clusters"] });
314
389
  api.registerTool((ctx) => createFetchClusterTool(runtime, ctx), { names: ["memory_fetch_cluster"] });
390
+ api.registerTool((ctx) => createListMaintenanceTool(runtime, ctx), { names: ["memory_list_maintenance_tasks"] });
391
+ api.registerTool((ctx) => createUpdateMaintenanceTool(runtime, ctx), { names: ["memory_update_maintenance_task"] });
315
392
  }
@@ -204,6 +204,7 @@ export class QmdMemoryRuntime {
204
204
  const manager = new QmdMemoryManager({
205
205
  workspaceDir,
206
206
  dbPath: join(stateDir, "index.sqlite"),
207
+ curationPath: join(stateDir, "curation.sqlite"),
207
208
  sources,
208
209
  keepModelsWarm: this.#keepEmbeddingModelWarm,
209
210
  analysisExecutable: this.#analysisExecutable,
@@ -1,18 +1,20 @@
1
1
  {
2
2
  "id": "unblock-memory",
3
3
  "name": "Unblock Memory",
4
- "version": "0.2.6",
4
+ "version": "0.2.7",
5
5
  "description": "Indexes, retrieves, and analyzes configured workspace memory with existing QMD vectors.",
6
6
  "kind": "memory",
7
7
  "activation": { "onStartup": false },
8
8
  "skills": ["./skills"],
9
- "contracts": { "tools": ["memory_search", "memory_get", "memory_sync_sessions", "memory_sync_status", "memory_recluster", "memory_list_clusters", "memory_fetch_cluster"] },
9
+ "contracts": { "tools": ["memory_search", "memory_get", "memory_sync_sessions", "memory_sync_status", "memory_recluster", "memory_list_clusters", "memory_fetch_cluster", "memory_list_maintenance_tasks", "memory_update_maintenance_task"] },
10
10
  "toolMetadata": {
11
11
  "memory_sync_sessions": { "sideEffecting": true },
12
12
  "memory_sync_status": { "replaySafe": true },
13
13
  "memory_recluster": { "sideEffecting": true },
14
14
  "memory_list_clusters": { "replaySafe": true },
15
- "memory_fetch_cluster": { "replaySafe": true }
15
+ "memory_fetch_cluster": { "replaySafe": true },
16
+ "memory_list_maintenance_tasks": { "replaySafe": true },
17
+ "memory_update_maintenance_task": { "sideEffecting": true }
16
18
  },
17
19
  "uiHints": {
18
20
  "keepEmbeddingModelWarm": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unblocklabs/unblock-memory",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "Workspace-native memory for OpenClaw, powered by QMD",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,7 +30,7 @@
30
30
  "preflight": "npm run knip && npm run build && npm run typecheck && npm test && npm run plugin:inspect && npm run plugin:inspect:runtime && npm pack --dry-run"
31
31
  },
32
32
  "dependencies": {
33
- "@unblocklabs/qmd": "https://github.com/unblocklabs-ai/qmd/releases/download/v2.9.2/unblocklabs-qmd-2.9.2.tgz",
33
+ "@unblocklabs/qmd": "https://github.com/unblocklabs-ai/qmd/releases/download/v2.9.3/unblocklabs-qmd-2.9.3.tgz",
34
34
  "chokidar": "5.0.0",
35
35
  "picomatch": "^4.0.5",
36
36
  "typebox": "1.3.6"
@@ -16,7 +16,9 @@ weak, duplicative, or easily looked-up knowledge.
16
16
  `memory_recluster`, then list again.
17
17
  2. Fetch a useful cluster with `memory_fetch_cluster`. Start with
18
18
  `sort: "representative"`; use `score_desc`, `date_asc`, or `date_desc` and
19
- pagination when relevance, evolution, or recent state matters.
19
+ pagination when relevance, evolution, or recent state matters. Treat
20
+ `eventTime` as event chronology; `sourceModifiedAt` is only a labeled
21
+ fallback when `eventTime` is unresolved.
20
22
  3. State the question the cluster raises: what may be repeated, contradictory,
21
23
  changing, or worth understanding?
22
24
  4. Search existing knowledge with `memory_search`, using
@@ -67,6 +69,13 @@ rigid document template.
67
69
  ## Finish the cycle
68
70
 
69
71
  - Do not rewrite raw memory or session projections.
72
+ - Review a small page from `memory_list_maintenance_tasks`. For ambiguous dates,
73
+ investigate supporting evidence and use `memory_update_maintenance_task` to
74
+ attach a chunk or document date only when supported; otherwise defer or mark
75
+ it irrelevant. For exact-duplicate proposals, decide whether cleanup should
76
+ be proposed, but do not treat repetition across historical files as an error.
77
+ The maintenance tools never change source Markdown, and generated session
78
+ projections must never be manually cleaned.
70
79
  - Verify an updated file with `memory_search`, using
71
80
  `corpora: ["knowledge"]`, and check all-corpora ranking when useful.
72
81
  - Report the questions investigated, evidence consulted beyond each cluster,