@unblocklabs/unblock-memory 0.2.6 → 0.3.0
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 +71 -11
- package/dist/src/analysis.d.ts +12 -2
- package/dist/src/analysis.js +106 -12
- package/dist/src/config.d.ts +12 -1
- package/dist/src/config.js +69 -15
- package/dist/src/curation.d.ts +70 -0
- package/dist/src/curation.js +191 -0
- package/dist/src/manager.d.ts +23 -0
- package/dist/src/manager.js +257 -29
- package/dist/src/plugin.js +80 -1
- package/dist/src/runtime.d.ts +8 -0
- package/dist/src/runtime.js +14 -2
- package/dist/src/skill-whisperer.d.ts +16 -0
- package/dist/src/skill-whisperer.js +113 -0
- package/dist/src/sources.d.ts +4 -3
- package/dist/src/sources.js +34 -1
- package/openclaw.plugin.json +40 -4
- package/package.json +2 -2
- package/skills/memory-curator/SKILL.md +12 -1
|
@@ -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">;
|
|
@@ -15,6 +16,11 @@ export type ManagerSessionConfig = {
|
|
|
15
16
|
outputDir: string;
|
|
16
17
|
timezone: string;
|
|
17
18
|
};
|
|
19
|
+
export type SkillSearchCandidate = {
|
|
20
|
+
name: string;
|
|
21
|
+
path: string;
|
|
22
|
+
score: number;
|
|
23
|
+
};
|
|
18
24
|
export declare function enableSecureDelete(store: QMDStore): void;
|
|
19
25
|
export declare function cleanupRemovedDocuments(store: QMDStore, changedDocuments?: number): number;
|
|
20
26
|
export declare function pruneStaleCollections(store: QMDStore, configuredCollections: ReadonlySet<string>): Promise<number>;
|
|
@@ -28,6 +34,7 @@ export declare class QmdMemoryManager implements MemorySearchManagerContract {
|
|
|
28
34
|
#private;
|
|
29
35
|
constructor(params: {
|
|
30
36
|
dbPath: string;
|
|
37
|
+
curationPath?: string;
|
|
31
38
|
workspaceDir: string;
|
|
32
39
|
sources: readonly ResolvedSource[];
|
|
33
40
|
storeFactory?: () => Promise<ManagerStore>;
|
|
@@ -47,7 +54,23 @@ export declare class QmdMemoryManager implements MemorySearchManagerContract {
|
|
|
47
54
|
offset?: number;
|
|
48
55
|
sort?: MemoryClusterSort;
|
|
49
56
|
}): Promise<MemoryClusterDetail>;
|
|
57
|
+
listMaintenanceTasks(params?: {
|
|
58
|
+
status?: MaintenanceStatus;
|
|
59
|
+
limit?: number;
|
|
60
|
+
}): import("./curation.js").MaintenanceTask[];
|
|
61
|
+
updateMaintenanceTask(params: {
|
|
62
|
+
id: string;
|
|
63
|
+
status: Exclude<MaintenanceStatus, "pending">;
|
|
64
|
+
note?: string;
|
|
65
|
+
annotation?: {
|
|
66
|
+
scope: "chunk" | "document";
|
|
67
|
+
eventTime: string;
|
|
68
|
+
basis: TemporalBasis;
|
|
69
|
+
evidence: string;
|
|
70
|
+
};
|
|
71
|
+
}): import("./curation.js").MaintenanceTask | undefined;
|
|
50
72
|
search(query: string, opts?: CorpusSearchOptions): Promise<CorpusMemorySearchResult[]>;
|
|
73
|
+
searchSkills(query: string, minScore: number, limit: number): Promise<SkillSearchCandidate[]>;
|
|
51
74
|
readFile(params: {
|
|
52
75
|
relPath: string;
|
|
53
76
|
from?: number;
|
package/dist/src/manager.js
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
1
2
|
import { mkdir, stat } from "node:fs/promises";
|
|
2
|
-
import { dirname } from "node:path";
|
|
3
|
+
import { basename, dirname, resolve } from "node:path";
|
|
3
4
|
import chokidar from "chokidar";
|
|
4
|
-
import { ensureMemoryAnalysisSchema, latestAnalysisRunId, markMemoryAnalysisStale, readAnalysisSummary, readCluster, readClusters, runAnalysisWorker, } from "./analysis.js";
|
|
5
|
+
import { ensureMemoryAnalysisSchema, latestAnalysisCollections, latestAnalysisRunId, markMemoryAnalysisStale, readAnalysisSummary, readCluster, readClusters, runAnalysisWorker, } from "./analysis.js";
|
|
6
|
+
import { CurationStore, chunkFingerprint, } from "./curation.js";
|
|
5
7
|
import { readSessionManifest, sessionMetadataByPath, syncSessionProjections, } from "./session-sync.js";
|
|
6
8
|
import { parseSafeVirtualPath } from "./sources.js";
|
|
7
9
|
const DEFAULT_READ_LINES = 120;
|
|
8
10
|
const MAX_READ_CHARS = 12_000;
|
|
9
11
|
const WATCH_DEBOUNCE_MS = 250;
|
|
10
12
|
const qmdModule = import("@unblocklabs/qmd");
|
|
13
|
+
function markStaleForAnalysisCollectionChange(db, collections, hasSkills) {
|
|
14
|
+
const current = collections.toSorted();
|
|
15
|
+
const previous = latestAnalysisCollections(db)?.toSorted();
|
|
16
|
+
if (previous
|
|
17
|
+
? previous.join("\0") !== current.join("\0")
|
|
18
|
+
: hasSkills && latestAnalysisRunId(db) !== undefined) {
|
|
19
|
+
markMemoryAnalysisStale(db);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
11
22
|
function completedEmbeddingCount(result) {
|
|
12
23
|
if (result.errors > 0) {
|
|
13
24
|
throw new Error(`QMD failed to embed ${result.errors} chunk${result.errors === 1 ? "" : "s"}`);
|
|
@@ -135,6 +146,7 @@ function sessionAllowedPaths(metadataByPath, collection, filter) {
|
|
|
135
146
|
export class QmdMemoryManager {
|
|
136
147
|
#dbPath;
|
|
137
148
|
#workspaceDir;
|
|
149
|
+
#curationPath;
|
|
138
150
|
#sources;
|
|
139
151
|
#storeFactory;
|
|
140
152
|
#keepModelsWarm;
|
|
@@ -142,6 +154,7 @@ export class QmdMemoryManager {
|
|
|
142
154
|
#analysisRunner;
|
|
143
155
|
#sessions;
|
|
144
156
|
#store;
|
|
157
|
+
#curation;
|
|
145
158
|
#cleanupRemovedDocuments;
|
|
146
159
|
#operationChain;
|
|
147
160
|
#watcher;
|
|
@@ -155,6 +168,7 @@ export class QmdMemoryManager {
|
|
|
155
168
|
#sessionManifestMtimeNs;
|
|
156
169
|
constructor(params) {
|
|
157
170
|
this.#dbPath = params.dbPath;
|
|
171
|
+
this.#curationPath = params.curationPath ?? `${params.dbPath}.curation.sqlite`;
|
|
158
172
|
this.#workspaceDir = params.workspaceDir;
|
|
159
173
|
this.#sources = new Map(params.sources.map((source) => [source.collection, source]));
|
|
160
174
|
this.#storeFactory = params.storeFactory;
|
|
@@ -200,7 +214,7 @@ export class QmdMemoryManager {
|
|
|
200
214
|
}
|
|
201
215
|
#startWatcher() {
|
|
202
216
|
const paths = [...new Set([...this.#sources.values()]
|
|
203
|
-
.filter((source) => source.kind
|
|
217
|
+
.filter((source) => source.kind !== "sessions")
|
|
204
218
|
.map((source) => source.watchPath))];
|
|
205
219
|
if (paths.length === 0 || this.#watcher)
|
|
206
220
|
return;
|
|
@@ -235,8 +249,10 @@ export class QmdMemoryManager {
|
|
|
235
249
|
if (this.#storeFactory) {
|
|
236
250
|
this.#store = await this.#storeFactory();
|
|
237
251
|
const store = this.#store;
|
|
238
|
-
if (store.internal)
|
|
252
|
+
if (store.internal) {
|
|
239
253
|
ensureMemoryAnalysisSchema(store.internal.db);
|
|
254
|
+
markStaleForAnalysisCollectionChange(store.internal.db, this.#analysisCollectionNames(), this.#skillCollectionNames().length > 0);
|
|
255
|
+
}
|
|
240
256
|
return this.#store;
|
|
241
257
|
}
|
|
242
258
|
const { createStore } = await qmdModule;
|
|
@@ -252,8 +268,26 @@ export class QmdMemoryManager {
|
|
|
252
268
|
});
|
|
253
269
|
enableSecureDelete(store);
|
|
254
270
|
ensureMemoryAnalysisSchema(store.internal.db);
|
|
255
|
-
|
|
256
|
-
|
|
271
|
+
markStaleForAnalysisCollectionChange(store.internal.db, this.#analysisCollectionNames(), this.#skillCollectionNames().length > 0);
|
|
272
|
+
const configuredCollections = new Set(this.#allCollectionNames());
|
|
273
|
+
const staleCollections = (await store.getStatus()).collections
|
|
274
|
+
.map((collection) => collection.name)
|
|
275
|
+
.filter((collection) => !configuredCollections.has(collection));
|
|
276
|
+
const appearsInAnalysis = store.internal.db.prepare(`
|
|
277
|
+
SELECT 1
|
|
278
|
+
FROM memory_analysis_memberships membership
|
|
279
|
+
JOIN documents document ON document.hash = membership.hash
|
|
280
|
+
WHERE membership.run_id = (
|
|
281
|
+
SELECT id FROM memory_analysis_runs
|
|
282
|
+
WHERE completed_at IS NOT NULL
|
|
283
|
+
ORDER BY completed_at DESC, created_at DESC, id DESC
|
|
284
|
+
LIMIT 1
|
|
285
|
+
) AND document.collection = ?
|
|
286
|
+
LIMIT 1
|
|
287
|
+
`);
|
|
288
|
+
const prunedAnalysisInput = staleCollections.some((collection) => appearsInAnalysis.get(collection));
|
|
289
|
+
const prunedDocuments = await pruneStaleCollections(store, configuredCollections);
|
|
290
|
+
if (prunedDocuments > 0 && prunedAnalysisInput)
|
|
257
291
|
markMemoryAnalysisStale(store.internal.db);
|
|
258
292
|
try {
|
|
259
293
|
await ensureSemanticChunking(store);
|
|
@@ -268,22 +302,36 @@ export class QmdMemoryManager {
|
|
|
268
302
|
this.#store = store;
|
|
269
303
|
return store;
|
|
270
304
|
}
|
|
305
|
+
#allCollectionNames() {
|
|
306
|
+
return [...this.#sources.keys()];
|
|
307
|
+
}
|
|
308
|
+
#analysisCollectionNames() {
|
|
309
|
+
return [...this.#sources.values()]
|
|
310
|
+
.filter((source) => source.kind !== "skills")
|
|
311
|
+
.map((source) => source.collection);
|
|
312
|
+
}
|
|
313
|
+
#skillCollectionNames() {
|
|
314
|
+
return [...this.#sources.values()]
|
|
315
|
+
.filter((source) => source.kind === "skills")
|
|
316
|
+
.map((source) => source.collection);
|
|
317
|
+
}
|
|
271
318
|
#collectionNames(corpora) {
|
|
319
|
+
const publicSources = [...this.#sources.values()].filter((source) => source.kind !== "skills");
|
|
272
320
|
if (corpora === undefined)
|
|
273
|
-
return
|
|
321
|
+
return publicSources.map((source) => source.collection);
|
|
274
322
|
if (corpora.length === 0)
|
|
275
323
|
throw new Error("memory_search corpora must not be empty");
|
|
276
324
|
const selected = new Set(corpora);
|
|
277
325
|
if (selected.has("all")) {
|
|
278
326
|
if (selected.size > 1)
|
|
279
327
|
throw new Error('memory_search corpus "all" must be used alone');
|
|
280
|
-
return
|
|
328
|
+
return publicSources.map((source) => source.collection);
|
|
281
329
|
}
|
|
282
|
-
const known = new Set(
|
|
330
|
+
const known = new Set(publicSources.map((source) => source.corpus));
|
|
283
331
|
const unknown = [...selected].find((corpus) => !known.has(corpus));
|
|
284
332
|
if (unknown)
|
|
285
333
|
throw new Error(`memory_search unknown corpus: ${unknown}`);
|
|
286
|
-
return
|
|
334
|
+
return publicSources
|
|
287
335
|
.filter((source) => selected.has(source.corpus))
|
|
288
336
|
.map((source) => source.collection);
|
|
289
337
|
}
|
|
@@ -291,29 +339,39 @@ export class QmdMemoryManager {
|
|
|
291
339
|
const run = async () => {
|
|
292
340
|
const store = await this.#getStore();
|
|
293
341
|
this.#dirty = true;
|
|
294
|
-
const collections = [...this.#sources.values()]
|
|
295
|
-
.filter((source) => source.kind === "files")
|
|
296
|
-
.map((source) => source.collection);
|
|
297
|
-
const update = await store.update({ collections });
|
|
298
|
-
this.#cleanupRemovedDocuments?.(update.updated + update.removed);
|
|
299
342
|
const analysisStore = store;
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
343
|
+
const collections = [...this.#sources.values()].filter((source) => source.kind !== "sessions");
|
|
344
|
+
let analysisMarkedStale = false;
|
|
345
|
+
const markAnalysisStale = () => {
|
|
346
|
+
if (analysisMarkedStale || !analysisStore.internal)
|
|
347
|
+
return;
|
|
304
348
|
markMemoryAnalysisStale(analysisStore.internal.db);
|
|
349
|
+
analysisMarkedStale = true;
|
|
350
|
+
};
|
|
351
|
+
if (collections.length === 0) {
|
|
352
|
+
const update = await store.update({ collections: [] });
|
|
353
|
+
this.#cleanupRemovedDocuments?.(update.updated + update.removed);
|
|
354
|
+
if (update.indexed + update.updated + update.removed > 0 ||
|
|
355
|
+
update.needsEmbedding > 0 || params?.force === true) {
|
|
356
|
+
markAnalysisStale();
|
|
357
|
+
}
|
|
358
|
+
const embed = await store.embed({ force: params?.force, chunkStrategy: "semantic" });
|
|
359
|
+
if (completedEmbeddingCount(embed) > 0)
|
|
360
|
+
markAnalysisStale();
|
|
305
361
|
}
|
|
306
|
-
|
|
307
|
-
|
|
362
|
+
for (const source of collections) {
|
|
363
|
+
const update = await store.update({ collections: [source.collection] });
|
|
364
|
+
this.#cleanupRemovedDocuments?.(update.updated + update.removed);
|
|
365
|
+
const changed = update.indexed + update.updated + update.removed > 0 || update.needsEmbedding > 0;
|
|
366
|
+
if (source.kind !== "skills" && (changed || params?.force === true))
|
|
367
|
+
markAnalysisStale();
|
|
308
368
|
const embed = await store.embed({
|
|
309
|
-
|
|
369
|
+
collection: source.collection,
|
|
310
370
|
force: params?.force,
|
|
311
371
|
chunkStrategy: "semantic",
|
|
312
372
|
});
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
if (!invalidatesAnalysis && chunksEmbedded > 0 && analysisStore.internal) {
|
|
316
|
-
markMemoryAnalysisStale(analysisStore.internal.db);
|
|
373
|
+
if (source.kind !== "skills" && completedEmbeddingCount(embed) > 0)
|
|
374
|
+
markAnalysisStale();
|
|
317
375
|
}
|
|
318
376
|
const status = await store.getStatus();
|
|
319
377
|
const indexedCollections = await store.listCollections();
|
|
@@ -377,6 +435,7 @@ export class QmdMemoryManager {
|
|
|
377
435
|
await this.#analysisRunner({
|
|
378
436
|
executable: this.#analysisExecutable,
|
|
379
437
|
dbPath: this.#dbPath,
|
|
438
|
+
collections: this.#analysisCollectionNames(),
|
|
380
439
|
options,
|
|
381
440
|
signal,
|
|
382
441
|
});
|
|
@@ -391,7 +450,126 @@ export class QmdMemoryManager {
|
|
|
391
450
|
return this.#enqueue(async () => readClusters((await this.#getAnalysisStore()).internal.db, limit));
|
|
392
451
|
}
|
|
393
452
|
fetchCluster(params) {
|
|
394
|
-
return this.#enqueue(async () =>
|
|
453
|
+
return this.#enqueue(async () => {
|
|
454
|
+
const db = (await this.#getAnalysisStore()).internal.db;
|
|
455
|
+
this.#loadTemporalAnnotations(db);
|
|
456
|
+
const detail = readCluster(db, params.clusterId, params.topK, params.offset, params.sort, { sessionCollection: this.#sessions?.collection });
|
|
457
|
+
if (params.sort === "date_asc" || params.sort === "date_desc") {
|
|
458
|
+
for (const member of detail.members ?? []) {
|
|
459
|
+
if (member.eventTime !== null)
|
|
460
|
+
continue;
|
|
461
|
+
const safe = parseSafeVirtualPath(member.eventTimeSource, this.#sources);
|
|
462
|
+
if (!safe)
|
|
463
|
+
continue;
|
|
464
|
+
this.#getCuration().addTask({
|
|
465
|
+
type: "ambiguous_event_time",
|
|
466
|
+
corpus: safe.source.corpus,
|
|
467
|
+
collection: safe.source.collection,
|
|
468
|
+
path: safe.relativePath,
|
|
469
|
+
reason: "cluster chronology has no reliable event time",
|
|
470
|
+
contentFingerprint: member.contentFingerprint,
|
|
471
|
+
detail: "Inspect the document and relevant evidence; annotate a date only when one can be supported.",
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (detail.runId && detail.members) {
|
|
476
|
+
this.#addDuplicateTasks(db, detail.runId, detail.members);
|
|
477
|
+
}
|
|
478
|
+
return detail;
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
listMaintenanceTasks(params = {}) {
|
|
482
|
+
return this.#getCuration().listTasks(params);
|
|
483
|
+
}
|
|
484
|
+
updateMaintenanceTask(params) {
|
|
485
|
+
return this.#getCuration().updateTask(params);
|
|
486
|
+
}
|
|
487
|
+
#getCuration() {
|
|
488
|
+
this.#curation ??= new CurationStore(this.#curationPath);
|
|
489
|
+
return this.#curation;
|
|
490
|
+
}
|
|
491
|
+
#loadTemporalAnnotations(db) {
|
|
492
|
+
db.exec("DELETE FROM memory_temporal_annotations");
|
|
493
|
+
const findChunks = db.prepare(`
|
|
494
|
+
SELECT d.hash, vectors.seq, vectors.pos, vectors.chunk_len, content.doc
|
|
495
|
+
FROM documents d
|
|
496
|
+
JOIN content ON content.hash = d.hash
|
|
497
|
+
JOIN content_vectors vectors ON vectors.hash = d.hash
|
|
498
|
+
WHERE d.collection = ? AND d.path = ? AND d.active = 1
|
|
499
|
+
ORDER BY vectors.seq
|
|
500
|
+
`);
|
|
501
|
+
const insert = db.prepare(`
|
|
502
|
+
INSERT OR REPLACE INTO memory_temporal_annotations
|
|
503
|
+
(collection, path, qmd_hash, qmd_seq, event_time, basis, document_wide)
|
|
504
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
505
|
+
`);
|
|
506
|
+
const curation = this.#getCuration();
|
|
507
|
+
for (const annotation of curation.annotations()) {
|
|
508
|
+
if (!annotation.contentFingerprint) {
|
|
509
|
+
insert.run(annotation.collection, annotation.path, null, null, annotation.eventTime, annotation.basis, 1);
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
const rows = findChunks.all(annotation.collection, annotation.path);
|
|
513
|
+
const matched = rows.find((row) => chunkFingerprint(row.doc.slice(row.pos, row.pos + row.chunk_len)) === annotation.contentFingerprint);
|
|
514
|
+
curation.updateAnnotationLocation({
|
|
515
|
+
annotation,
|
|
516
|
+
qmdHash: matched?.hash ?? null,
|
|
517
|
+
qmdSeq: matched?.seq ?? null,
|
|
518
|
+
});
|
|
519
|
+
if (matched) {
|
|
520
|
+
insert.run(annotation.collection, annotation.path, matched.hash, matched.seq, annotation.eventTime, annotation.basis, 0);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
#addDuplicateTasks(db, runId, members) {
|
|
525
|
+
if (members.length === 0)
|
|
526
|
+
return;
|
|
527
|
+
const pageMatch = members.map(() => "(duplicates.canonical_hash = ? AND duplicates.canonical_seq = ?) OR " +
|
|
528
|
+
"(duplicates.duplicate_hash = ? AND duplicates.duplicate_seq = ?)").join(" OR ");
|
|
529
|
+
const pageParams = members.flatMap((member) => [member.hash, member.seq, member.hash, member.seq]);
|
|
530
|
+
const sessionCollections = [...this.#sources.values()]
|
|
531
|
+
.filter((source) => source.kind === "sessions")
|
|
532
|
+
.map((source) => source.collection);
|
|
533
|
+
const excludeSessions = sessionCollections.length > 0
|
|
534
|
+
? `duplicate_document.collection NOT IN (${sessionCollections.map(() => "?").join(", ")})`
|
|
535
|
+
: "1 = 1";
|
|
536
|
+
const rows = db.prepare(`
|
|
537
|
+
SELECT
|
|
538
|
+
duplicate_document.collection,
|
|
539
|
+
duplicate_document.path,
|
|
540
|
+
duplicates.content_fingerprint,
|
|
541
|
+
COUNT(*) AS occurrence_count
|
|
542
|
+
FROM memory_analysis_duplicate_occurrences duplicates
|
|
543
|
+
JOIN (SELECT DISTINCT hash FROM documents WHERE active = 1) canonical_document
|
|
544
|
+
ON canonical_document.hash = duplicates.canonical_hash
|
|
545
|
+
JOIN documents duplicate_document
|
|
546
|
+
ON duplicate_document.hash = duplicates.duplicate_hash
|
|
547
|
+
AND duplicate_document.active = 1
|
|
548
|
+
WHERE duplicates.run_id = ?
|
|
549
|
+
AND (${pageMatch})
|
|
550
|
+
AND ${excludeSessions}
|
|
551
|
+
GROUP BY duplicate_document.collection, duplicate_document.path,
|
|
552
|
+
duplicates.content_fingerprint
|
|
553
|
+
ORDER BY duplicate_document.collection, duplicate_document.path,
|
|
554
|
+
duplicates.content_fingerprint
|
|
555
|
+
LIMIT 10
|
|
556
|
+
`).all(runId, ...pageParams, ...sessionCollections);
|
|
557
|
+
const curation = this.#getCuration();
|
|
558
|
+
for (const row of rows) {
|
|
559
|
+
const source = this.#sources.get(row.collection);
|
|
560
|
+
if (!source || source.kind === "sessions")
|
|
561
|
+
continue;
|
|
562
|
+
curation.addTask({
|
|
563
|
+
type: "exact_duplicate",
|
|
564
|
+
corpus: source.corpus,
|
|
565
|
+
collection: row.collection,
|
|
566
|
+
path: row.path,
|
|
567
|
+
reason: "exact chunk content repeats in this source document",
|
|
568
|
+
contentFingerprint: row.content_fingerprint,
|
|
569
|
+
detail: `${row.occurrence_count} exact duplicate occurrence${row.occurrence_count === 1 ? "" : "s"}. ` +
|
|
570
|
+
"Review the source and propose cleanup only if repetition is accidental.",
|
|
571
|
+
});
|
|
572
|
+
}
|
|
395
573
|
}
|
|
396
574
|
async #getAnalysisStore() {
|
|
397
575
|
const store = await this.#getStore();
|
|
@@ -466,10 +644,54 @@ export class QmdMemoryManager {
|
|
|
466
644
|
}];
|
|
467
645
|
});
|
|
468
646
|
}
|
|
647
|
+
async searchSkills(query, minScore, limit) {
|
|
648
|
+
const collections = this.#skillCollectionNames();
|
|
649
|
+
if (collections.length === 0)
|
|
650
|
+
return [];
|
|
651
|
+
await this.#operationChain;
|
|
652
|
+
const hits = await (await this.#getStore()).vsearch(query, {
|
|
653
|
+
collection: collections,
|
|
654
|
+
limit,
|
|
655
|
+
minScore,
|
|
656
|
+
expand: false,
|
|
657
|
+
});
|
|
658
|
+
const sourceOrder = new Map([...this.#sources.keys()].map((collection, index) => [collection, index]));
|
|
659
|
+
const candidates = new Map();
|
|
660
|
+
for (const hit of hits) {
|
|
661
|
+
const safe = parseSafeVirtualPath(hit.file, this.#sources);
|
|
662
|
+
if (!safe || safe.source.kind !== "skills")
|
|
663
|
+
continue;
|
|
664
|
+
const path = realpathSync(resolve(safe.source.root, safe.relativePath));
|
|
665
|
+
if (basename(path).toLowerCase() !== "skill.md")
|
|
666
|
+
continue;
|
|
667
|
+
const frontmatter = /^---\s*\n([\s\S]*?)\n---(?:\n|$)/u.exec(hit.body)?.[1];
|
|
668
|
+
const configuredName = frontmatter?.split("\n")
|
|
669
|
+
.map((line) => /^name:\s*(.+?)\s*$/u.exec(line)?.[1])
|
|
670
|
+
.find((name) => name !== undefined)
|
|
671
|
+
?.replace(/^(?:"(.*)"|'(.*)')$/u, "$1$2");
|
|
672
|
+
const candidate = {
|
|
673
|
+
name: configuredName?.trim() || basename(dirname(path)),
|
|
674
|
+
path,
|
|
675
|
+
score: hit.score,
|
|
676
|
+
};
|
|
677
|
+
const key = candidate.name.toLowerCase();
|
|
678
|
+
const order = sourceOrder.get(safe.source.collection) ?? Number.MAX_SAFE_INTEGER;
|
|
679
|
+
const current = candidates.get(key);
|
|
680
|
+
if (!current || order < current.sourceOrder ||
|
|
681
|
+
(order === current.sourceOrder && candidate.score > current.candidate.score)) {
|
|
682
|
+
candidates.set(key, { candidate, sourceOrder: order });
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
return [...candidates.values()]
|
|
686
|
+
.map(({ candidate }) => candidate)
|
|
687
|
+
.sort((left, right) => right.score - left.score)
|
|
688
|
+
.slice(0, limit);
|
|
689
|
+
}
|
|
469
690
|
async readFile(params) {
|
|
470
691
|
const safe = parseSafeVirtualPath(params.relPath, this.#sources);
|
|
471
|
-
if (!safe)
|
|
692
|
+
if (!safe || safe.source.kind === "skills") {
|
|
472
693
|
return { status: "not_found", text: "", path: params.relPath };
|
|
694
|
+
}
|
|
473
695
|
await this.#operationChain;
|
|
474
696
|
const store = await this.#getStore();
|
|
475
697
|
const doc = await store.get(safe.normalized);
|
|
@@ -505,7 +727,11 @@ export class QmdMemoryManager {
|
|
|
505
727
|
custom: {
|
|
506
728
|
corpora: [...corpora].map(([name, sources]) => sources[0]?.kind === "sessions"
|
|
507
729
|
? { name, kind: "sessions", chatTypes: sources[0].chatTypes }
|
|
508
|
-
: {
|
|
730
|
+
: {
|
|
731
|
+
name,
|
|
732
|
+
kind: sources[0]?.kind === "skills" ? "skills" : "files",
|
|
733
|
+
paths: sources.map((source) => source.configuredPath),
|
|
734
|
+
}),
|
|
509
735
|
...(this.#watchError ? { watchError: this.#watchError } : {}),
|
|
510
736
|
},
|
|
511
737
|
};
|
|
@@ -529,5 +755,7 @@ export class QmdMemoryManager {
|
|
|
529
755
|
await this.#operationChain?.catch(() => undefined);
|
|
530
756
|
await this.#store?.close();
|
|
531
757
|
this.#store = undefined;
|
|
758
|
+
this.#curation?.close();
|
|
759
|
+
this.#curation = undefined;
|
|
532
760
|
}
|
|
533
761
|
}
|