@memtensor/memos-local-openclaw-plugin 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +196 -84
  2. package/dist/ingest/dedup.d.ts +8 -0
  3. package/dist/ingest/dedup.d.ts.map +1 -1
  4. package/dist/ingest/dedup.js +21 -0
  5. package/dist/ingest/dedup.js.map +1 -1
  6. package/dist/ingest/providers/anthropic.d.ts +14 -0
  7. package/dist/ingest/providers/anthropic.d.ts.map +1 -1
  8. package/dist/ingest/providers/anthropic.js +104 -0
  9. package/dist/ingest/providers/anthropic.js.map +1 -1
  10. package/dist/ingest/providers/bedrock.d.ts +14 -0
  11. package/dist/ingest/providers/bedrock.d.ts.map +1 -1
  12. package/dist/ingest/providers/bedrock.js +100 -0
  13. package/dist/ingest/providers/bedrock.js.map +1 -1
  14. package/dist/ingest/providers/gemini.d.ts +14 -0
  15. package/dist/ingest/providers/gemini.d.ts.map +1 -1
  16. package/dist/ingest/providers/gemini.js +96 -0
  17. package/dist/ingest/providers/gemini.js.map +1 -1
  18. package/dist/ingest/providers/index.d.ts +22 -0
  19. package/dist/ingest/providers/index.d.ts.map +1 -1
  20. package/dist/ingest/providers/index.js +68 -0
  21. package/dist/ingest/providers/index.js.map +1 -1
  22. package/dist/ingest/providers/openai.d.ts +22 -0
  23. package/dist/ingest/providers/openai.d.ts.map +1 -1
  24. package/dist/ingest/providers/openai.js +143 -0
  25. package/dist/ingest/providers/openai.js.map +1 -1
  26. package/dist/ingest/task-processor.d.ts +2 -0
  27. package/dist/ingest/task-processor.d.ts.map +1 -1
  28. package/dist/ingest/task-processor.js +15 -0
  29. package/dist/ingest/task-processor.js.map +1 -1
  30. package/dist/ingest/worker.d.ts +2 -0
  31. package/dist/ingest/worker.d.ts.map +1 -1
  32. package/dist/ingest/worker.js +115 -12
  33. package/dist/ingest/worker.js.map +1 -1
  34. package/dist/recall/engine.d.ts.map +1 -1
  35. package/dist/recall/engine.js +1 -0
  36. package/dist/recall/engine.js.map +1 -1
  37. package/dist/skill/bundled-memory-guide.d.ts +6 -0
  38. package/dist/skill/bundled-memory-guide.d.ts.map +1 -0
  39. package/dist/skill/bundled-memory-guide.js +95 -0
  40. package/dist/skill/bundled-memory-guide.js.map +1 -0
  41. package/dist/skill/evaluator.d.ts +31 -0
  42. package/dist/skill/evaluator.d.ts.map +1 -0
  43. package/dist/skill/evaluator.js +194 -0
  44. package/dist/skill/evaluator.js.map +1 -0
  45. package/dist/skill/evolver.d.ts +22 -0
  46. package/dist/skill/evolver.d.ts.map +1 -0
  47. package/dist/skill/evolver.js +193 -0
  48. package/dist/skill/evolver.js.map +1 -0
  49. package/dist/skill/generator.d.ts +25 -0
  50. package/dist/skill/generator.d.ts.map +1 -0
  51. package/dist/skill/generator.js +477 -0
  52. package/dist/skill/generator.js.map +1 -0
  53. package/dist/skill/installer.d.ts +16 -0
  54. package/dist/skill/installer.d.ts.map +1 -0
  55. package/dist/skill/installer.js +89 -0
  56. package/dist/skill/installer.js.map +1 -0
  57. package/dist/skill/upgrader.d.ts +19 -0
  58. package/dist/skill/upgrader.d.ts.map +1 -0
  59. package/dist/skill/upgrader.js +263 -0
  60. package/dist/skill/upgrader.js.map +1 -0
  61. package/dist/skill/validator.d.ts +29 -0
  62. package/dist/skill/validator.d.ts.map +1 -0
  63. package/dist/skill/validator.js +227 -0
  64. package/dist/skill/validator.js.map +1 -0
  65. package/dist/storage/sqlite.d.ts +75 -1
  66. package/dist/storage/sqlite.d.ts.map +1 -1
  67. package/dist/storage/sqlite.js +417 -6
  68. package/dist/storage/sqlite.js.map +1 -1
  69. package/dist/types.d.ts +78 -0
  70. package/dist/types.d.ts.map +1 -1
  71. package/dist/types.js +6 -0
  72. package/dist/types.js.map +1 -1
  73. package/dist/viewer/html.d.ts +1 -1
  74. package/dist/viewer/html.d.ts.map +1 -1
  75. package/dist/viewer/html.js +1549 -113
  76. package/dist/viewer/html.js.map +1 -1
  77. package/dist/viewer/server.d.ts +13 -0
  78. package/dist/viewer/server.d.ts.map +1 -1
  79. package/dist/viewer/server.js +289 -4
  80. package/dist/viewer/server.js.map +1 -1
  81. package/index.ts +489 -181
  82. package/package.json +1 -1
  83. package/skill/memos-memory-guide/SKILL.md +86 -0
  84. package/src/ingest/dedup.ts +29 -0
  85. package/src/ingest/providers/anthropic.ts +130 -0
  86. package/src/ingest/providers/bedrock.ts +126 -0
  87. package/src/ingest/providers/gemini.ts +124 -0
  88. package/src/ingest/providers/index.ts +86 -4
  89. package/src/ingest/providers/openai.ts +174 -0
  90. package/src/ingest/task-processor.ts +16 -0
  91. package/src/ingest/worker.ts +126 -21
  92. package/src/recall/engine.ts +1 -0
  93. package/src/skill/bundled-memory-guide.ts +91 -0
  94. package/src/skill/evaluator.ts +220 -0
  95. package/src/skill/evolver.ts +169 -0
  96. package/src/skill/generator.ts +506 -0
  97. package/src/skill/installer.ts +59 -0
  98. package/src/skill/upgrader.ts +257 -0
  99. package/src/skill/validator.ts +227 -0
  100. package/src/storage/sqlite.ts +508 -6
  101. package/src/types.ts +77 -0
  102. package/src/viewer/html.ts +1549 -113
  103. package/src/viewer/server.ts +285 -4
  104. package/skill/SKILL.md +0 -59
@@ -2,7 +2,7 @@ import Database from "better-sqlite3";
2
2
  import { createHash } from "crypto";
3
3
  import * as fs from "fs";
4
4
  import * as path from "path";
5
- import type { Chunk, ChunkRef, Task, TaskStatus, Logger } from "../types";
5
+ import type { Chunk, ChunkRef, DedupStatus, Task, TaskStatus, Skill, SkillStatus, SkillVersion, TaskSkillLink, TaskSkillRelation, Logger } from "../types";
6
6
 
7
7
  export class SqliteStore {
8
8
  private db: Database.Database;
@@ -95,6 +95,14 @@ export class SqliteStore {
95
95
 
96
96
  this.migrateTaskId();
97
97
  this.migrateContentHash();
98
+ this.migrateSkillTables();
99
+ this.migrateSkillId();
100
+ this.migrateSkillQualityScore();
101
+ this.migrateTaskSkillMeta();
102
+ this.migrateToolCalls();
103
+ this.migrateMergeFields();
104
+ this.migrateApiLogs();
105
+ this.migrateDedupStatus();
98
106
  this.log.debug("Database schema initialized");
99
107
  }
100
108
 
@@ -125,6 +133,295 @@ export class SqliteStore {
125
133
  }
126
134
  }
127
135
 
136
+ private migrateSkillTables(): void {
137
+ this.db.exec(`
138
+ CREATE TABLE IF NOT EXISTS skills (
139
+ id TEXT PRIMARY KEY,
140
+ name TEXT NOT NULL UNIQUE,
141
+ description TEXT NOT NULL DEFAULT '',
142
+ version INTEGER NOT NULL DEFAULT 1,
143
+ status TEXT NOT NULL DEFAULT 'active',
144
+ tags TEXT NOT NULL DEFAULT '[]',
145
+ source_type TEXT NOT NULL DEFAULT 'task',
146
+ dir_path TEXT NOT NULL DEFAULT '',
147
+ installed INTEGER NOT NULL DEFAULT 0,
148
+ created_at INTEGER NOT NULL,
149
+ updated_at INTEGER NOT NULL
150
+ );
151
+ CREATE INDEX IF NOT EXISTS idx_skills_status ON skills(status);
152
+ CREATE INDEX IF NOT EXISTS idx_skills_name ON skills(name);
153
+
154
+ CREATE TABLE IF NOT EXISTS skill_versions (
155
+ id TEXT PRIMARY KEY,
156
+ skill_id TEXT NOT NULL REFERENCES skills(id),
157
+ version INTEGER NOT NULL,
158
+ content TEXT NOT NULL,
159
+ changelog TEXT NOT NULL DEFAULT '',
160
+ upgrade_type TEXT NOT NULL DEFAULT 'create',
161
+ source_task_id TEXT,
162
+ metrics TEXT NOT NULL DEFAULT '{}',
163
+ created_at INTEGER NOT NULL,
164
+ UNIQUE(skill_id, version)
165
+ );
166
+ CREATE INDEX IF NOT EXISTS idx_skill_versions_skill ON skill_versions(skill_id);
167
+
168
+ CREATE TABLE IF NOT EXISTS task_skills (
169
+ task_id TEXT NOT NULL REFERENCES tasks(id),
170
+ skill_id TEXT NOT NULL REFERENCES skills(id),
171
+ relation TEXT NOT NULL DEFAULT 'generated_from',
172
+ version_at INTEGER NOT NULL DEFAULT 1,
173
+ created_at INTEGER NOT NULL,
174
+ PRIMARY KEY (task_id, skill_id)
175
+ );
176
+ `);
177
+ }
178
+
179
+ private migrateSkillId(): void {
180
+ const cols = this.db.prepare("PRAGMA table_info(chunks)").all() as Array<{ name: string }>;
181
+ if (!cols.some((c) => c.name === "skill_id")) {
182
+ this.db.exec("ALTER TABLE chunks ADD COLUMN skill_id TEXT");
183
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_chunks_skill ON chunks(skill_id)");
184
+ this.log.info("Migrated: added skill_id column to chunks");
185
+ }
186
+ }
187
+
188
+ private migrateSkillQualityScore(): void {
189
+ const skillCols = this.db.prepare("PRAGMA table_info(skills)").all() as Array<{ name: string }>;
190
+ if (!skillCols.some((c) => c.name === "quality_score")) {
191
+ this.db.exec("ALTER TABLE skills ADD COLUMN quality_score REAL");
192
+ this.log.info("Migrated: added quality_score column to skills");
193
+ }
194
+
195
+ const versionCols = this.db.prepare("PRAGMA table_info(skill_versions)").all() as Array<{ name: string }>;
196
+ if (!versionCols.some((c) => c.name === "quality_score")) {
197
+ this.db.exec("ALTER TABLE skill_versions ADD COLUMN quality_score REAL");
198
+ this.log.info("Migrated: added quality_score column to skill_versions");
199
+ }
200
+ if (!versionCols.some((c) => c.name === "change_summary")) {
201
+ this.db.exec("ALTER TABLE skill_versions ADD COLUMN change_summary TEXT NOT NULL DEFAULT ''");
202
+ this.log.info("Migrated: added change_summary column to skill_versions");
203
+ }
204
+ }
205
+
206
+ private migrateTaskSkillMeta(): void {
207
+ const cols = this.db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
208
+ if (!cols.some((c) => c.name === "skill_status")) {
209
+ this.db.exec("ALTER TABLE tasks ADD COLUMN skill_status TEXT DEFAULT NULL");
210
+ this.db.exec("ALTER TABLE tasks ADD COLUMN skill_reason TEXT DEFAULT NULL");
211
+ this.log.info("Migrated: added skill_status/skill_reason columns to tasks");
212
+ }
213
+ }
214
+
215
+ setTaskSkillMeta(taskId: string, meta: { skillStatus: string; skillReason: string }): void {
216
+ this.db.prepare("UPDATE tasks SET skill_status = ?, skill_reason = ?, updated_at = ? WHERE id = ?")
217
+ .run(meta.skillStatus, meta.skillReason, Date.now(), taskId);
218
+ }
219
+
220
+ private migrateMergeFields(): void {
221
+ const cols = this.db.prepare("PRAGMA table_info(chunks)").all() as Array<{ name: string }>;
222
+ if (!cols.some((c) => c.name === "merge_count")) {
223
+ this.db.exec("ALTER TABLE chunks ADD COLUMN merge_count INTEGER NOT NULL DEFAULT 0");
224
+ this.db.exec("ALTER TABLE chunks ADD COLUMN last_hit_at INTEGER");
225
+ this.db.exec("ALTER TABLE chunks ADD COLUMN merge_history TEXT NOT NULL DEFAULT '[]'");
226
+ this.log.info("Migrated: added merge_count/last_hit_at/merge_history columns to chunks");
227
+ }
228
+ }
229
+
230
+ private migrateApiLogs(): void {
231
+ this.db.exec(`
232
+ CREATE TABLE IF NOT EXISTS api_logs (
233
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
234
+ tool_name TEXT NOT NULL,
235
+ input_data TEXT NOT NULL DEFAULT '{}',
236
+ output_data TEXT NOT NULL DEFAULT '',
237
+ duration_ms INTEGER NOT NULL DEFAULT 0,
238
+ success INTEGER NOT NULL DEFAULT 1,
239
+ called_at INTEGER NOT NULL
240
+ );
241
+ CREATE INDEX IF NOT EXISTS idx_api_logs_at ON api_logs(called_at);
242
+ CREATE INDEX IF NOT EXISTS idx_api_logs_name ON api_logs(tool_name);
243
+ `);
244
+ }
245
+
246
+ private migrateDedupStatus(): void {
247
+ const cols = this.db.prepare("PRAGMA table_info(chunks)").all() as Array<{ name: string }>;
248
+ if (!cols.some((c) => c.name === "dedup_status")) {
249
+ this.db.exec("ALTER TABLE chunks ADD COLUMN dedup_status TEXT NOT NULL DEFAULT 'active'");
250
+ this.db.exec("ALTER TABLE chunks ADD COLUMN dedup_target TEXT DEFAULT NULL");
251
+ this.db.exec("ALTER TABLE chunks ADD COLUMN dedup_reason TEXT DEFAULT NULL");
252
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_chunks_dedup_status ON chunks(dedup_status)");
253
+ this.log.info("Migrated: added dedup_status/dedup_target/dedup_reason columns to chunks");
254
+ }
255
+ }
256
+
257
+ recordApiLog(toolName: string, input: unknown, output: string, durationMs: number, success: boolean): void {
258
+ const inputStr = typeof input === "string" ? input : JSON.stringify(input ?? {});
259
+ this.db.prepare(
260
+ "INSERT INTO api_logs (tool_name, input_data, output_data, duration_ms, success, called_at) VALUES (?, ?, ?, ?, ?, ?)",
261
+ ).run(toolName, inputStr, output, Math.round(durationMs), success ? 1 : 0, Date.now());
262
+ }
263
+
264
+ getApiLogs(limit: number = 50, offset: number = 0, toolFilter?: string): {
265
+ logs: Array<{ id: number; toolName: string; input: string; output: string; durationMs: number; success: boolean; calledAt: number }>;
266
+ total: number;
267
+ } {
268
+ const whereClause = toolFilter ? " WHERE tool_name = ?" : "";
269
+ const filterParams: unknown[] = toolFilter ? [toolFilter] : [];
270
+
271
+ const countRow = this.db.prepare("SELECT COUNT(*) as c FROM api_logs" + whereClause).get(...filterParams) as { c: number };
272
+
273
+ const rows = this.db.prepare(
274
+ "SELECT id, tool_name, input_data, output_data, duration_ms, success, called_at FROM api_logs" +
275
+ whereClause + " ORDER BY called_at DESC LIMIT ? OFFSET ?",
276
+ ).all(...filterParams, limit, offset) as Array<{
277
+ id: number; tool_name: string; input_data: string; output_data: string;
278
+ duration_ms: number; success: number; called_at: number;
279
+ }>;
280
+
281
+ return {
282
+ logs: rows.map((r) => ({
283
+ id: r.id,
284
+ toolName: r.tool_name,
285
+ input: r.input_data,
286
+ output: r.output_data,
287
+ durationMs: r.duration_ms,
288
+ success: r.success === 1,
289
+ calledAt: r.called_at,
290
+ })),
291
+ total: countRow.c,
292
+ };
293
+ }
294
+
295
+ getApiLogToolNames(): string[] {
296
+ const rows = this.db.prepare("SELECT DISTINCT tool_name FROM api_logs ORDER BY tool_name").all() as Array<{ tool_name: string }>;
297
+ return rows.map((r) => r.tool_name);
298
+ }
299
+
300
+ recordMergeHit(chunkId: string, action: "DUPLICATE" | "UPDATE", reason: string, oldSummary?: string, newSummary?: string): void {
301
+ const chunk = this.getChunk(chunkId);
302
+ if (!chunk) return;
303
+
304
+ const history = JSON.parse(chunk.mergeHistory || "[]") as any[];
305
+ const entry: Record<string, unknown> = { at: Date.now(), action, reason };
306
+ if (action === "UPDATE" && oldSummary && newSummary) {
307
+ entry.from = oldSummary;
308
+ entry.to = newSummary;
309
+ }
310
+ history.push(entry);
311
+
312
+ this.db.prepare(`
313
+ UPDATE chunks SET merge_count = merge_count + 1, last_hit_at = ?, merge_history = ?, updated_at = ?
314
+ WHERE id = ?
315
+ `).run(Date.now(), JSON.stringify(history), Date.now(), chunkId);
316
+ }
317
+
318
+ updateChunkSummaryAndContent(chunkId: string, newSummary: string, appendContent: string): void {
319
+ this.db.prepare(`
320
+ UPDATE chunks SET summary = ?, content = content || ? || ?, updated_at = ? WHERE id = ?
321
+ `).run(newSummary, "\n\n---\n\n", appendContent, Date.now(), chunkId);
322
+ }
323
+
324
+ private migrateToolCalls(): void {
325
+ this.db.exec(`
326
+ CREATE TABLE IF NOT EXISTS tool_calls (
327
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
328
+ tool_name TEXT NOT NULL,
329
+ duration_ms INTEGER NOT NULL,
330
+ success INTEGER NOT NULL DEFAULT 1,
331
+ called_at INTEGER NOT NULL
332
+ );
333
+ CREATE INDEX IF NOT EXISTS idx_tool_calls_at ON tool_calls(called_at);
334
+ CREATE INDEX IF NOT EXISTS idx_tool_calls_name ON tool_calls(tool_name);
335
+ `);
336
+ }
337
+
338
+ recordToolCall(toolName: string, durationMs: number, success: boolean): void {
339
+ this.db.prepare(
340
+ "INSERT INTO tool_calls (tool_name, duration_ms, success, called_at) VALUES (?, ?, ?, ?)",
341
+ ).run(toolName, Math.round(durationMs), success ? 1 : 0, Date.now());
342
+ }
343
+
344
+ getToolMetrics(minutes: number): {
345
+ tools: string[];
346
+ series: Array<{ minute: string; [tool: string]: number | string }>;
347
+ aggregated: Array<{ tool: string; totalCalls: number; avgMs: number; p95Ms: number; errorCount: number }>;
348
+ } {
349
+ const since = Date.now() - minutes * 60 * 1000;
350
+
351
+ const rows = this.db.prepare(
352
+ `SELECT tool_name,
353
+ duration_ms,
354
+ success,
355
+ strftime('%Y-%m-%d %H:%M', called_at/1000, 'unixepoch', 'localtime') as minute_key
356
+ FROM tool_calls
357
+ WHERE called_at >= ?
358
+ ORDER BY called_at`,
359
+ ).all(since) as Array<{ tool_name: string; duration_ms: number; success: number; minute_key: string }>;
360
+
361
+ const toolSet = new Set<string>();
362
+ const minuteMap = new Map<string, Map<string, { total: number; count: number }>>();
363
+ const aggMap = new Map<string, { durations: number[]; errors: number }>();
364
+
365
+ for (const r of rows) {
366
+ toolSet.add(r.tool_name);
367
+
368
+ if (!aggMap.has(r.tool_name)) aggMap.set(r.tool_name, { durations: [], errors: 0 });
369
+ const agg = aggMap.get(r.tool_name)!;
370
+ agg.durations.push(r.duration_ms);
371
+ if (!r.success) agg.errors++;
372
+
373
+ if (!minuteMap.has(r.minute_key)) minuteMap.set(r.minute_key, new Map());
374
+ const toolMap = minuteMap.get(r.minute_key)!;
375
+ if (!toolMap.has(r.tool_name)) toolMap.set(r.tool_name, { total: 0, count: 0 });
376
+ const entry = toolMap.get(r.tool_name)!;
377
+ entry.total += r.duration_ms;
378
+ entry.count++;
379
+ }
380
+
381
+ const tools = Array.from(toolSet).sort();
382
+
383
+ const allMinutes: string[] = [];
384
+ if (minutes > 0) {
385
+ const startMinute = new Date(since);
386
+ startMinute.setSeconds(0, 0);
387
+ const now = new Date();
388
+ for (let t = startMinute.getTime(); t <= now.getTime(); t += 60000) {
389
+ const d = new Date(t);
390
+ const pad = (n: number) => String(n).padStart(2, "0");
391
+ allMinutes.push(`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`);
392
+ }
393
+ }
394
+
395
+ const series = allMinutes.map((m) => {
396
+ const entry: { minute: string; [tool: string]: number | string } = { minute: m };
397
+ const toolMap = minuteMap.get(m);
398
+ for (const t of tools) {
399
+ const data = toolMap?.get(t);
400
+ entry[t] = data ? Math.round(data.total / data.count) : 0;
401
+ }
402
+ return entry;
403
+ });
404
+
405
+ const p95 = (arr: number[]) => {
406
+ if (arr.length === 0) return 0;
407
+ const sorted = [...arr].sort((a, b) => a - b);
408
+ return sorted[Math.floor(sorted.length * 0.95)] ?? sorted[sorted.length - 1];
409
+ };
410
+
411
+ const aggregated = tools.map((t) => {
412
+ const agg = aggMap.get(t)!;
413
+ return {
414
+ tool: t,
415
+ totalCalls: agg.durations.length,
416
+ avgMs: Math.round(agg.durations.reduce((s, v) => s + v, 0) / agg.durations.length),
417
+ p95Ms: p95(agg.durations),
418
+ errorCount: agg.errors,
419
+ };
420
+ });
421
+
422
+ return { tools, series, aggregated };
423
+ }
424
+
128
425
  /** Record a viewer API call for analytics (list, search, etc.). */
129
426
  recordViewerEvent(eventType: string): void {
130
427
  this.db.prepare("INSERT INTO viewer_events (event_type, created_at) VALUES (?, ?)").run(eventType, Date.now());
@@ -202,8 +499,8 @@ export class SqliteStore {
202
499
 
203
500
  insertChunk(chunk: Chunk): void {
204
501
  const stmt = this.db.prepare(`
205
- INSERT OR REPLACE INTO chunks (id, session_key, turn_id, seq, role, content, kind, summary, task_id, content_hash, created_at, updated_at)
206
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
502
+ INSERT OR REPLACE INTO chunks (id, session_key, turn_id, seq, role, content, kind, summary, task_id, content_hash, dedup_status, dedup_target, dedup_reason, created_at, updated_at)
503
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
207
504
  `);
208
505
  stmt.run(
209
506
  chunk.id,
@@ -216,11 +513,20 @@ export class SqliteStore {
216
513
  chunk.summary,
217
514
  chunk.taskId,
218
515
  contentHash(chunk.content),
516
+ chunk.dedupStatus ?? "active",
517
+ chunk.dedupTarget ?? null,
518
+ chunk.dedupReason ?? null,
219
519
  chunk.createdAt,
220
520
  chunk.updatedAt,
221
521
  );
222
522
  }
223
523
 
524
+ markDedupStatus(chunkId: string, status: "duplicate" | "merged", targetChunkId: string | null, reason: string): void {
525
+ this.db.prepare(
526
+ "UPDATE chunks SET dedup_status = ?, dedup_target = ?, dedup_reason = ?, updated_at = ? WHERE id = ?",
527
+ ).run(status, targetChunkId, reason, Date.now(), chunkId);
528
+ }
529
+
224
530
  updateSummary(chunkId: string, summary: string): void {
225
531
  this.db.prepare("UPDATE chunks SET summary = ?, updated_at = ? WHERE id = ?").run(
226
532
  summary,
@@ -277,7 +583,7 @@ export class SqliteStore {
277
583
  SELECT c.id as chunk_id, rank
278
584
  FROM chunks_fts f
279
585
  JOIN chunks c ON c.rowid = f.rowid
280
- WHERE chunks_fts MATCH ?
586
+ WHERE chunks_fts MATCH ? AND c.dedup_status = 'active'
281
587
  ORDER BY rank
282
588
  LIMIT ?
283
589
  `).all(sanitized, limit) as Array<{ chunk_id: string; rank: number }>;
@@ -311,7 +617,7 @@ export class SqliteStore {
311
617
  const rows = this.db.prepare(`
312
618
  SELECT c.id as chunk_id, c.content, c.role, c.created_at
313
619
  FROM chunks c
314
- WHERE (${whereClause})${roleClause}
620
+ WHERE (${whereClause})${roleClause} AND c.dedup_status = 'active'
315
621
  ORDER BY c.created_at DESC
316
622
  LIMIT ?
317
623
  `).all(...params) as Array<{ chunk_id: string; content: string; role: string; created_at: number }>;
@@ -331,7 +637,9 @@ export class SqliteStore {
331
637
 
332
638
  getAllEmbeddings(): Array<{ chunkId: string; vector: number[] }> {
333
639
  const rows = this.db.prepare(
334
- "SELECT chunk_id, vector, dimensions FROM embeddings",
640
+ `SELECT e.chunk_id, e.vector, e.dimensions FROM embeddings e
641
+ JOIN chunks c ON c.id = e.chunk_id
642
+ WHERE c.dedup_status = 'active'`,
335
643
  ).all() as Array<{ chunk_id: string; vector: Buffer; dimensions: number }>;
336
644
 
337
645
  return rows.map((r) => ({
@@ -395,9 +703,14 @@ export class SqliteStore {
395
703
  }
396
704
 
397
705
  deleteAll(): number {
706
+ this.db.exec("PRAGMA foreign_keys = OFF");
707
+ this.db.prepare("DELETE FROM task_skills").run();
708
+ this.db.prepare("DELETE FROM skill_versions").run();
709
+ this.db.prepare("DELETE FROM skills").run();
398
710
  this.db.prepare("DELETE FROM chunks").run();
399
711
  this.db.prepare("DELETE FROM tasks").run();
400
712
  this.db.prepare("DELETE FROM viewer_events").run();
713
+ this.db.exec("PRAGMA foreign_keys = ON");
401
714
  const remaining = this.countChunks();
402
715
  return remaining === 0 ? 1 : 0;
403
716
  }
@@ -511,6 +824,119 @@ export class SqliteStore {
511
824
  return row.cnt;
512
825
  }
513
826
 
827
+ // ─── Skill CRUD ───
828
+
829
+ insertSkill(skill: Skill): void {
830
+ this.db.prepare(`
831
+ INSERT OR REPLACE INTO skills (id, name, description, version, status, tags, source_type, dir_path, installed, quality_score, created_at, updated_at)
832
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
833
+ `).run(skill.id, skill.name, skill.description, skill.version, skill.status, skill.tags, skill.sourceType, skill.dirPath, skill.installed, skill.qualityScore, skill.createdAt, skill.updatedAt);
834
+ }
835
+
836
+ getSkill(skillId: string): Skill | null {
837
+ const row = this.db.prepare("SELECT * FROM skills WHERE id = ?").get(skillId) as SkillRow | undefined;
838
+ return row ? rowToSkill(row) : null;
839
+ }
840
+
841
+ getSkillByName(name: string): Skill | null {
842
+ const row = this.db.prepare("SELECT * FROM skills WHERE name = ?").get(name) as SkillRow | undefined;
843
+ return row ? rowToSkill(row) : null;
844
+ }
845
+
846
+ updateSkill(skillId: string, fields: { description?: string; version?: number; status?: SkillStatus; installed?: number; qualityScore?: number | null; updatedAt?: number }): void {
847
+ const sets: string[] = [];
848
+ const params: unknown[] = [];
849
+ if (fields.description !== undefined) { sets.push("description = ?"); params.push(fields.description); }
850
+ if (fields.version !== undefined) { sets.push("version = ?"); params.push(fields.version); }
851
+ if (fields.status !== undefined) { sets.push("status = ?"); params.push(fields.status); }
852
+ if (fields.installed !== undefined) { sets.push("installed = ?"); params.push(fields.installed); }
853
+ if (fields.qualityScore !== undefined) { sets.push("quality_score = ?"); params.push(fields.qualityScore); }
854
+ if (sets.length === 0) return;
855
+ sets.push("updated_at = ?");
856
+ params.push(fields.updatedAt ?? Date.now());
857
+ params.push(skillId);
858
+ this.db.prepare(`UPDATE skills SET ${sets.join(", ")} WHERE id = ?`).run(...params);
859
+ }
860
+
861
+ listSkills(opts: { status?: string } = {}): Skill[] {
862
+ const cond = opts.status ? "WHERE status = ?" : "";
863
+ const params = opts.status ? [opts.status] : [];
864
+ const rows = this.db.prepare(`SELECT * FROM skills ${cond} ORDER BY updated_at DESC`).all(...params) as SkillRow[];
865
+ return rows.map(rowToSkill);
866
+ }
867
+
868
+ // ─── Skill Versions ───
869
+
870
+ insertSkillVersion(sv: SkillVersion): void {
871
+ this.db.prepare(`
872
+ INSERT OR REPLACE INTO skill_versions (id, skill_id, version, content, changelog, change_summary, upgrade_type, source_task_id, metrics, quality_score, created_at)
873
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
874
+ `).run(sv.id, sv.skillId, sv.version, sv.content, sv.changelog, sv.changeSummary, sv.upgradeType, sv.sourceTaskId, sv.metrics, sv.qualityScore, sv.createdAt);
875
+ }
876
+
877
+ getLatestSkillVersion(skillId: string): SkillVersion | null {
878
+ const row = this.db.prepare("SELECT * FROM skill_versions WHERE skill_id = ? ORDER BY version DESC LIMIT 1").get(skillId) as SkillVersionRow | undefined;
879
+ return row ? rowToSkillVersion(row) : null;
880
+ }
881
+
882
+ getSkillVersions(skillId: string): SkillVersion[] {
883
+ const rows = this.db.prepare("SELECT * FROM skill_versions WHERE skill_id = ? ORDER BY version DESC").all(skillId) as SkillVersionRow[];
884
+ return rows.map(rowToSkillVersion);
885
+ }
886
+
887
+ getSkillVersion(skillId: string, version: number): SkillVersion | null {
888
+ const row = this.db.prepare("SELECT * FROM skill_versions WHERE skill_id = ? AND version = ?").get(skillId, version) as SkillVersionRow | undefined;
889
+ return row ? rowToSkillVersion(row) : null;
890
+ }
891
+
892
+ // ─── Task-Skill Links ───
893
+
894
+ linkTaskSkill(taskId: string, skillId: string, relation: TaskSkillRelation, versionAt: number): void {
895
+ this.db.prepare(`
896
+ INSERT OR REPLACE INTO task_skills (task_id, skill_id, relation, version_at, created_at)
897
+ VALUES (?, ?, ?, ?, ?)
898
+ `).run(taskId, skillId, relation, versionAt, Date.now());
899
+ }
900
+
901
+ getSkillsByTask(taskId: string): Array<{ skill: Skill; relation: TaskSkillRelation; versionAt: number }> {
902
+ const rows = this.db.prepare(`
903
+ SELECT s.*, ts.relation, ts.version_at
904
+ FROM task_skills ts JOIN skills s ON s.id = ts.skill_id
905
+ WHERE ts.task_id = ?
906
+ `).all(taskId) as Array<SkillRow & { relation: string; version_at: number }>;
907
+ return rows.map(r => ({
908
+ skill: rowToSkill(r),
909
+ relation: r.relation as TaskSkillRelation,
910
+ versionAt: r.version_at,
911
+ }));
912
+ }
913
+
914
+ getTasksBySkill(skillId: string): Array<{ task: Task; relation: TaskSkillRelation }> {
915
+ const rows = this.db.prepare(`
916
+ SELECT t.*, ts.relation
917
+ FROM task_skills ts JOIN tasks t ON t.id = ts.task_id
918
+ WHERE ts.skill_id = ?
919
+ ORDER BY t.started_at DESC
920
+ `).all(skillId) as Array<TaskRow & { relation: string }>;
921
+ return rows.map(r => ({
922
+ task: rowToTask(r),
923
+ relation: r.relation as TaskSkillRelation,
924
+ }));
925
+ }
926
+
927
+ countSkills(status?: string): number {
928
+ const cond = status ? "WHERE status = ?" : "";
929
+ const params = status ? [status] : [];
930
+ const row = this.db.prepare(`SELECT COUNT(*) as c FROM skills ${cond}`).get(...params) as { c: number };
931
+ return row.c;
932
+ }
933
+
934
+ // ─── Chunk-Skill ───
935
+
936
+ setChunkSkillId(chunkId: string, skillId: string): void {
937
+ this.db.prepare("UPDATE chunks SET skill_id = ?, updated_at = ? WHERE id = ?").run(skillId, Date.now(), chunkId);
938
+ }
939
+
514
940
  close(): void {
515
941
  this.db.close();
516
942
  }
@@ -548,6 +974,13 @@ interface ChunkRow {
548
974
  kind: string;
549
975
  summary: string;
550
976
  task_id: string | null;
977
+ skill_id: string | null;
978
+ dedup_status: string;
979
+ dedup_target: string | null;
980
+ dedup_reason: string | null;
981
+ merge_count: number;
982
+ last_hit_at: number | null;
983
+ merge_history: string;
551
984
  created_at: number;
552
985
  updated_at: number;
553
986
  }
@@ -564,6 +997,13 @@ function rowToChunk(row: ChunkRow): Chunk {
564
997
  summary: row.summary,
565
998
  embedding: null,
566
999
  taskId: row.task_id,
1000
+ skillId: row.skill_id ?? null,
1001
+ dedupStatus: (row.dedup_status ?? "active") as DedupStatus,
1002
+ dedupTarget: row.dedup_target ?? null,
1003
+ dedupReason: row.dedup_reason ?? null,
1004
+ mergeCount: row.merge_count ?? 0,
1005
+ lastHitAt: row.last_hit_at ?? null,
1006
+ mergeHistory: row.merge_history ?? "[]",
567
1007
  createdAt: row.created_at,
568
1008
  updatedAt: row.updated_at,
569
1009
  };
@@ -593,6 +1033,68 @@ function rowToTask(row: TaskRow): Task {
593
1033
  };
594
1034
  }
595
1035
 
1036
+ interface SkillRow {
1037
+ id: string;
1038
+ name: string;
1039
+ description: string;
1040
+ version: number;
1041
+ status: string;
1042
+ tags: string;
1043
+ source_type: string;
1044
+ dir_path: string;
1045
+ installed: number;
1046
+ quality_score: number | null;
1047
+ created_at: number;
1048
+ updated_at: number;
1049
+ }
1050
+
1051
+ function rowToSkill(row: SkillRow): Skill {
1052
+ return {
1053
+ id: row.id,
1054
+ name: row.name,
1055
+ description: row.description,
1056
+ version: row.version,
1057
+ status: row.status as Skill["status"],
1058
+ tags: row.tags,
1059
+ sourceType: row.source_type as Skill["sourceType"],
1060
+ dirPath: row.dir_path,
1061
+ installed: row.installed,
1062
+ qualityScore: row.quality_score ?? null,
1063
+ createdAt: row.created_at,
1064
+ updatedAt: row.updated_at,
1065
+ };
1066
+ }
1067
+
1068
+ interface SkillVersionRow {
1069
+ id: string;
1070
+ skill_id: string;
1071
+ version: number;
1072
+ content: string;
1073
+ changelog: string;
1074
+ change_summary: string;
1075
+ upgrade_type: string;
1076
+ source_task_id: string | null;
1077
+ metrics: string;
1078
+ quality_score: number | null;
1079
+ created_at: number;
1080
+ }
1081
+
1082
+ function rowToSkillVersion(row: SkillVersionRow): SkillVersion {
1083
+ return {
1084
+ id: row.id,
1085
+ skillId: row.skill_id,
1086
+ version: row.version,
1087
+ content: row.content,
1088
+ changelog: row.changelog,
1089
+ changeSummary: row.change_summary ?? "",
1090
+ upgradeType: row.upgrade_type as SkillVersion["upgradeType"],
1091
+ sourceTaskId: row.source_task_id,
1092
+ metrics: row.metrics,
1093
+ qualityScore: row.quality_score ?? null,
1094
+ createdAt: row.created_at,
1095
+ };
1096
+ }
1097
+
596
1098
  function contentHash(content: string): string {
597
1099
  return createHash("sha256").update(content).digest("hex").slice(0, 16);
598
1100
  }