@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.
@@ -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">;
@@ -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;
@@ -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 === "files")
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
- const prunedDocuments = await pruneStaleCollections(store, new Set(this.#collectionNames()));
256
- if (prunedDocuments > 0)
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 [...this.#sources.keys()];
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 [...this.#sources.keys()];
328
+ return publicSources.map((source) => source.collection);
281
329
  }
282
- const known = new Set([...this.#sources.values()].map((source) => source.corpus));
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 [...this.#sources.values()]
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 invalidatesAnalysis = update.indexed + update.updated + update.removed > 0 ||
301
- update.needsEmbedding > 0 ||
302
- params?.force === true;
303
- if (invalidatesAnalysis && analysisStore.internal) {
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
- let chunksEmbedded = 0;
307
- for (const collection of collections.length > 0 ? collections : [undefined]) {
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
- ...(collection ? { collection } : {}),
369
+ collection: source.collection,
310
370
  force: params?.force,
311
371
  chunkStrategy: "semantic",
312
372
  });
313
- chunksEmbedded += completedEmbeddingCount(embed);
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 () => readCluster((await this.#getAnalysisStore()).internal.db, params.clusterId, params.topK, params.offset, params.sort));
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
- : { name, kind: "files", paths: sources.map((source) => source.configuredPath) }),
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
  }