@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 +20 -7
- package/dist/src/analysis.d.ts +9 -2
- package/dist/src/analysis.js +85 -11
- package/dist/src/curation.d.ts +70 -0
- package/dist/src/curation.js +191 -0
- package/dist/src/manager.d.ts +17 -0
- package/dist/src/manager.js +126 -1
- package/dist/src/plugin.js +77 -0
- package/dist/src/runtime.js +1 -0
- package/openclaw.plugin.json +5 -3
- package/package.json +2 -2
- package/skills/memory-curator/SKILL.md +10 -1
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).
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
package/dist/src/analysis.d.ts
CHANGED
|
@@ -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
|
-
|
|
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 {};
|
package/dist/src/analysis.js
CHANGED
|
@@ -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.
|
|
226
|
-
date_asc: "m.
|
|
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
|
|
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
|
|
295
|
+
SELECT d.modified_at
|
|
235
296
|
FROM documents d
|
|
236
297
|
WHERE d.hash = m.hash AND d.active = 1
|
|
237
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/src/manager.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/manager.js
CHANGED
|
@@ -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 () =>
|
|
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
|
}
|
package/dist/src/plugin.js
CHANGED
|
@@ -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
|
}
|
package/dist/src/runtime.js
CHANGED
|
@@ -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,
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "unblock-memory",
|
|
3
3
|
"name": "Unblock Memory",
|
|
4
|
-
"version": "0.2.
|
|
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.
|
|
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.
|
|
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,
|