@memtensor/memos-local-openclaw-plugin 0.1.3 → 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 (117) hide show
  1. package/.env.example +13 -5
  2. package/README.md +283 -91
  3. package/dist/capture/index.d.ts +5 -7
  4. package/dist/capture/index.d.ts.map +1 -1
  5. package/dist/capture/index.js +72 -43
  6. package/dist/capture/index.js.map +1 -1
  7. package/dist/ingest/dedup.d.ts +8 -0
  8. package/dist/ingest/dedup.d.ts.map +1 -1
  9. package/dist/ingest/dedup.js +21 -0
  10. package/dist/ingest/dedup.js.map +1 -1
  11. package/dist/ingest/providers/anthropic.d.ts +16 -0
  12. package/dist/ingest/providers/anthropic.d.ts.map +1 -1
  13. package/dist/ingest/providers/anthropic.js +214 -1
  14. package/dist/ingest/providers/anthropic.js.map +1 -1
  15. package/dist/ingest/providers/bedrock.d.ts +16 -5
  16. package/dist/ingest/providers/bedrock.d.ts.map +1 -1
  17. package/dist/ingest/providers/bedrock.js +210 -6
  18. package/dist/ingest/providers/bedrock.js.map +1 -1
  19. package/dist/ingest/providers/gemini.d.ts +16 -0
  20. package/dist/ingest/providers/gemini.d.ts.map +1 -1
  21. package/dist/ingest/providers/gemini.js +202 -1
  22. package/dist/ingest/providers/gemini.js.map +1 -1
  23. package/dist/ingest/providers/index.d.ts +31 -0
  24. package/dist/ingest/providers/index.d.ts.map +1 -1
  25. package/dist/ingest/providers/index.js +134 -4
  26. package/dist/ingest/providers/index.js.map +1 -1
  27. package/dist/ingest/providers/openai.d.ts +24 -0
  28. package/dist/ingest/providers/openai.d.ts.map +1 -1
  29. package/dist/ingest/providers/openai.js +255 -1
  30. package/dist/ingest/providers/openai.js.map +1 -1
  31. package/dist/ingest/task-processor.d.ts +65 -0
  32. package/dist/ingest/task-processor.d.ts.map +1 -0
  33. package/dist/ingest/task-processor.js +354 -0
  34. package/dist/ingest/task-processor.js.map +1 -0
  35. package/dist/ingest/worker.d.ts +3 -1
  36. package/dist/ingest/worker.d.ts.map +1 -1
  37. package/dist/ingest/worker.js +131 -23
  38. package/dist/ingest/worker.js.map +1 -1
  39. package/dist/recall/engine.d.ts +1 -0
  40. package/dist/recall/engine.d.ts.map +1 -1
  41. package/dist/recall/engine.js +22 -11
  42. package/dist/recall/engine.js.map +1 -1
  43. package/dist/recall/mmr.d.ts.map +1 -1
  44. package/dist/recall/mmr.js +3 -1
  45. package/dist/recall/mmr.js.map +1 -1
  46. package/dist/skill/bundled-memory-guide.d.ts +6 -0
  47. package/dist/skill/bundled-memory-guide.d.ts.map +1 -0
  48. package/dist/skill/bundled-memory-guide.js +95 -0
  49. package/dist/skill/bundled-memory-guide.js.map +1 -0
  50. package/dist/skill/evaluator.d.ts +31 -0
  51. package/dist/skill/evaluator.d.ts.map +1 -0
  52. package/dist/skill/evaluator.js +194 -0
  53. package/dist/skill/evaluator.js.map +1 -0
  54. package/dist/skill/evolver.d.ts +22 -0
  55. package/dist/skill/evolver.d.ts.map +1 -0
  56. package/dist/skill/evolver.js +193 -0
  57. package/dist/skill/evolver.js.map +1 -0
  58. package/dist/skill/generator.d.ts +25 -0
  59. package/dist/skill/generator.d.ts.map +1 -0
  60. package/dist/skill/generator.js +477 -0
  61. package/dist/skill/generator.js.map +1 -0
  62. package/dist/skill/installer.d.ts +16 -0
  63. package/dist/skill/installer.d.ts.map +1 -0
  64. package/dist/skill/installer.js +89 -0
  65. package/dist/skill/installer.js.map +1 -0
  66. package/dist/skill/upgrader.d.ts +19 -0
  67. package/dist/skill/upgrader.d.ts.map +1 -0
  68. package/dist/skill/upgrader.js +263 -0
  69. package/dist/skill/upgrader.js.map +1 -0
  70. package/dist/skill/validator.d.ts +29 -0
  71. package/dist/skill/validator.d.ts.map +1 -0
  72. package/dist/skill/validator.js +227 -0
  73. package/dist/skill/validator.js.map +1 -0
  74. package/dist/storage/sqlite.d.ts +141 -1
  75. package/dist/storage/sqlite.d.ts.map +1 -1
  76. package/dist/storage/sqlite.js +664 -7
  77. package/dist/storage/sqlite.js.map +1 -1
  78. package/dist/types.d.ts +93 -0
  79. package/dist/types.d.ts.map +1 -1
  80. package/dist/types.js +8 -0
  81. package/dist/types.js.map +1 -1
  82. package/dist/viewer/html.d.ts +1 -1
  83. package/dist/viewer/html.d.ts.map +1 -1
  84. package/dist/viewer/html.js +2391 -159
  85. package/dist/viewer/html.js.map +1 -1
  86. package/dist/viewer/server.d.ts +16 -0
  87. package/dist/viewer/server.d.ts.map +1 -1
  88. package/dist/viewer/server.js +346 -3
  89. package/dist/viewer/server.js.map +1 -1
  90. package/index.ts +572 -89
  91. package/openclaw.plugin.json +20 -45
  92. package/package.json +3 -4
  93. package/skill/memos-memory-guide/SKILL.md +86 -0
  94. package/src/capture/index.ts +85 -45
  95. package/src/ingest/dedup.ts +29 -0
  96. package/src/ingest/providers/anthropic.ts +258 -1
  97. package/src/ingest/providers/bedrock.ts +256 -6
  98. package/src/ingest/providers/gemini.ts +252 -1
  99. package/src/ingest/providers/index.ts +156 -8
  100. package/src/ingest/providers/openai.ts +304 -1
  101. package/src/ingest/task-processor.ts +396 -0
  102. package/src/ingest/worker.ts +145 -34
  103. package/src/recall/engine.ts +23 -12
  104. package/src/recall/mmr.ts +3 -1
  105. package/src/skill/bundled-memory-guide.ts +91 -0
  106. package/src/skill/evaluator.ts +220 -0
  107. package/src/skill/evolver.ts +169 -0
  108. package/src/skill/generator.ts +506 -0
  109. package/src/skill/installer.ts +59 -0
  110. package/src/skill/upgrader.ts +257 -0
  111. package/src/skill/validator.ts +227 -0
  112. package/src/storage/sqlite.ts +802 -7
  113. package/src/types.ts +96 -0
  114. package/src/viewer/html.ts +2391 -159
  115. package/src/viewer/server.ts +346 -3
  116. package/SKILL.md +0 -43
  117. package/www/index.html +0 -632
@@ -1,7 +1,8 @@
1
1
  import Database from "better-sqlite3";
2
+ import { createHash } from "crypto";
2
3
  import * as fs from "fs";
3
4
  import * as path from "path";
4
- import type { Chunk, ChunkRef, Logger } from "../types";
5
+ import type { Chunk, ChunkRef, DedupStatus, Task, TaskStatus, Skill, SkillStatus, SkillVersion, TaskSkillLink, TaskSkillRelation, Logger } from "../types";
5
6
 
6
7
  export class SqliteStore {
7
8
  private db: Database.Database;
@@ -69,16 +70,437 @@ export class SqliteStore {
69
70
  dimensions INTEGER NOT NULL,
70
71
  updated_at INTEGER NOT NULL
71
72
  );
73
+
74
+ CREATE TABLE IF NOT EXISTS viewer_events (
75
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
76
+ event_type TEXT NOT NULL,
77
+ created_at INTEGER NOT NULL
78
+ );
79
+ CREATE INDEX IF NOT EXISTS idx_viewer_events_created ON viewer_events(created_at);
80
+ CREATE INDEX IF NOT EXISTS idx_viewer_events_type ON viewer_events(event_type);
81
+
82
+ CREATE TABLE IF NOT EXISTS tasks (
83
+ id TEXT PRIMARY KEY,
84
+ session_key TEXT NOT NULL,
85
+ title TEXT NOT NULL DEFAULT '',
86
+ summary TEXT NOT NULL DEFAULT '',
87
+ status TEXT NOT NULL DEFAULT 'active',
88
+ started_at INTEGER NOT NULL,
89
+ ended_at INTEGER,
90
+ updated_at INTEGER NOT NULL
91
+ );
92
+ CREATE INDEX IF NOT EXISTS idx_tasks_session ON tasks(session_key);
93
+ CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
72
94
  `);
95
+
96
+ this.migrateTaskId();
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();
73
106
  this.log.debug("Database schema initialized");
74
107
  }
75
108
 
109
+ private migrateTaskId(): void {
110
+ const cols = this.db.prepare("PRAGMA table_info(chunks)").all() as Array<{ name: string }>;
111
+ if (!cols.some((c) => c.name === "task_id")) {
112
+ this.db.exec("ALTER TABLE chunks ADD COLUMN task_id TEXT REFERENCES tasks(id)");
113
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_chunks_task ON chunks(task_id)");
114
+ this.log.info("Migrated: added task_id column to chunks");
115
+ }
116
+ }
117
+
118
+ private migrateContentHash(): void {
119
+ const cols = this.db.prepare("PRAGMA table_info(chunks)").all() as Array<{ name: string }>;
120
+ if (!cols.some((c) => c.name === "content_hash")) {
121
+ this.db.exec("ALTER TABLE chunks ADD COLUMN content_hash TEXT");
122
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_chunks_dedup ON chunks(session_key, role, content_hash)");
123
+
124
+ // Backfill existing rows
125
+ const rows = this.db.prepare("SELECT id, content FROM chunks WHERE content_hash IS NULL").all() as Array<{ id: string; content: string }>;
126
+ const updateStmt = this.db.prepare("UPDATE chunks SET content_hash = ? WHERE id = ?");
127
+ for (const r of rows) {
128
+ updateStmt.run(contentHash(r.content), r.id);
129
+ }
130
+ if (rows.length > 0) {
131
+ this.log.info(`Migrated: backfilled content_hash for ${rows.length} chunks`);
132
+ }
133
+ }
134
+ }
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
+
425
+ /** Record a viewer API call for analytics (list, search, etc.). */
426
+ recordViewerEvent(eventType: string): void {
427
+ this.db.prepare("INSERT INTO viewer_events (event_type, created_at) VALUES (?, ?)").run(eventType, Date.now());
428
+ }
429
+
430
+ /**
431
+ * Return metrics for the last N days: writes per day (from chunks), viewer calls per day.
432
+ */
433
+ getMetrics(days: number): {
434
+ writesPerDay: Array<{ date: string; count: number }>;
435
+ viewerCallsPerDay: Array<{ date: string; list: number; search: number; total: number }>;
436
+ roleBreakdown: Record<string, number>;
437
+ kindBreakdown: Record<string, number>;
438
+ totals: { memories: number; sessions: number; embeddings: number; todayWrites: number; todayViewerCalls: number };
439
+ } {
440
+ const since = Date.now() - days * 86400 * 1000;
441
+ const now = new Date();
442
+ const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
443
+
444
+ const writesRows = this.db
445
+ .prepare(
446
+ `SELECT date(created_at/1000, 'unixepoch', 'localtime') as d, COUNT(*) as c
447
+ FROM chunks WHERE created_at >= ? GROUP BY d ORDER BY d`,
448
+ )
449
+ .all(since) as Array<{ d: string; c: number }>;
450
+ const writesPerDay = writesRows.map((r) => ({ date: r.d, count: r.c }));
451
+
452
+ const eventsRows = this.db
453
+ .prepare(
454
+ `SELECT date(created_at/1000, 'unixepoch', 'localtime') as d, event_type, COUNT(*) as c
455
+ FROM viewer_events WHERE created_at >= ? GROUP BY d, event_type ORDER BY d`,
456
+ )
457
+ .all(since) as Array<{ d: string; event_type: string; c: number }>;
458
+ const byDate = new Map<string, { list: number; search: number }>();
459
+ for (const r of eventsRows) {
460
+ let row = byDate.get(r.d);
461
+ if (!row) {
462
+ row = { list: 0, search: 0 };
463
+ byDate.set(r.d, row);
464
+ }
465
+ if (r.event_type === "list") row.list += r.c;
466
+ else if (r.event_type === "search") row.search += r.c;
467
+ }
468
+ const viewerCallsPerDay = Array.from(byDate.entries())
469
+ .sort((a, b) => a[0].localeCompare(b[0]))
470
+ .map(([date, v]) => ({ date, list: v.list, search: v.search, total: v.list + v.search }));
471
+
472
+ const roles = this.db.prepare("SELECT role, COUNT(*) as count FROM chunks GROUP BY role").all() as Array<{ role: string; count: number }>;
473
+ const kinds = this.db.prepare("SELECT kind, COUNT(*) as count FROM chunks GROUP BY kind").all() as Array<{ kind: string; count: number }>;
474
+ const roleBreakdown = Object.fromEntries(roles.map((r) => [r.role, r.count]));
475
+ const kindBreakdown = Object.fromEntries(kinds.map((k) => [k.kind, k.count]));
476
+
477
+ const totalChunks = (this.db.prepare("SELECT COUNT(*) as c FROM chunks").get() as { c: number }).c;
478
+ const totalSessions = (this.db.prepare("SELECT COUNT(DISTINCT session_key) as c FROM chunks").get() as { c: number }).c;
479
+ const totalEmbeddings = (this.db.prepare("SELECT COUNT(*) as c FROM embeddings").get() as { c: number }).c;
480
+ const todayWrites = (this.db.prepare("SELECT COUNT(*) as c FROM chunks WHERE created_at >= ?").get(todayStart) as { c: number }).c;
481
+ const todayViewerCalls = (this.db.prepare("SELECT COUNT(*) as c FROM viewer_events WHERE created_at >= ?").get(todayStart) as { c: number }).c;
482
+
483
+ return {
484
+ writesPerDay,
485
+ viewerCallsPerDay,
486
+ roleBreakdown,
487
+ kindBreakdown,
488
+ totals: {
489
+ memories: totalChunks,
490
+ sessions: totalSessions,
491
+ embeddings: totalEmbeddings,
492
+ todayWrites,
493
+ todayViewerCalls,
494
+ },
495
+ };
496
+ }
497
+
76
498
  // ─── Write ───
77
499
 
78
500
  insertChunk(chunk: Chunk): void {
79
501
  const stmt = this.db.prepare(`
80
- INSERT OR REPLACE INTO chunks (id, session_key, turn_id, seq, role, content, kind, summary, created_at, updated_at)
81
- 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
82
504
  `);
83
505
  stmt.run(
84
506
  chunk.id,
@@ -89,11 +511,22 @@ export class SqliteStore {
89
511
  chunk.content,
90
512
  chunk.kind,
91
513
  chunk.summary,
514
+ chunk.taskId,
515
+ contentHash(chunk.content),
516
+ chunk.dedupStatus ?? "active",
517
+ chunk.dedupTarget ?? null,
518
+ chunk.dedupReason ?? null,
92
519
  chunk.createdAt,
93
520
  chunk.updatedAt,
94
521
  );
95
522
  }
96
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
+
97
530
  updateSummary(chunkId: string, summary: string): void {
98
531
  this.db.prepare("UPDATE chunks SET summary = ?, updated_at = ? WHERE id = ?").run(
99
532
  summary,
@@ -150,7 +583,7 @@ export class SqliteStore {
150
583
  SELECT c.id as chunk_id, rank
151
584
  FROM chunks_fts f
152
585
  JOIN chunks c ON c.rowid = f.rowid
153
- WHERE chunks_fts MATCH ?
586
+ WHERE chunks_fts MATCH ? AND c.dedup_status = 'active'
154
587
  ORDER BY rank
155
588
  LIMIT ?
156
589
  `).all(sanitized, limit) as Array<{ chunk_id: string; rank: number }>;
@@ -167,11 +600,46 @@ export class SqliteStore {
167
600
  }
168
601
  }
169
602
 
603
+ // ─── Pattern Search (LIKE-based, for CJK text where FTS tokenization is weak) ───
604
+
605
+ patternSearch(patterns: string[], opts: { role?: string; limit?: number } = {}): Array<{ chunkId: string; content: string; role: string; createdAt: number }> {
606
+ if (patterns.length === 0) return [];
607
+ const limit = opts.limit ?? 10;
608
+
609
+ const conditions = patterns.map(() => "c.content LIKE ?");
610
+ const whereClause = conditions.join(" OR ");
611
+ const roleClause = opts.role ? " AND c.role = ?" : "";
612
+ const params: (string | number)[] = patterns.map(p => `%${p}%`);
613
+ if (opts.role) params.push(opts.role);
614
+ params.push(limit);
615
+
616
+ try {
617
+ const rows = this.db.prepare(`
618
+ SELECT c.id as chunk_id, c.content, c.role, c.created_at
619
+ FROM chunks c
620
+ WHERE (${whereClause})${roleClause} AND c.dedup_status = 'active'
621
+ ORDER BY c.created_at DESC
622
+ LIMIT ?
623
+ `).all(...params) as Array<{ chunk_id: string; content: string; role: string; created_at: number }>;
624
+
625
+ return rows.map(r => ({
626
+ chunkId: r.chunk_id,
627
+ content: r.content,
628
+ role: r.role,
629
+ createdAt: r.created_at,
630
+ }));
631
+ } catch {
632
+ return [];
633
+ }
634
+ }
635
+
170
636
  // ─── Vector Search ───
171
637
 
172
638
  getAllEmbeddings(): Array<{ chunkId: string; vector: number[] }> {
173
639
  const rows = this.db.prepare(
174
- "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'`,
175
643
  ).all() as Array<{ chunk_id: string; vector: Buffer; dimensions: number }>;
176
644
 
177
645
  return rows.map((r) => ({
@@ -235,8 +703,111 @@ export class SqliteStore {
235
703
  }
236
704
 
237
705
  deleteAll(): number {
238
- const result = this.db.prepare("DELETE FROM chunks").run();
239
- return result.changes;
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();
710
+ this.db.prepare("DELETE FROM chunks").run();
711
+ this.db.prepare("DELETE FROM tasks").run();
712
+ this.db.prepare("DELETE FROM viewer_events").run();
713
+ this.db.exec("PRAGMA foreign_keys = ON");
714
+ const remaining = this.countChunks();
715
+ return remaining === 0 ? 1 : 0;
716
+ }
717
+
718
+ // ─── Task CRUD ───
719
+
720
+ insertTask(task: Task): void {
721
+ this.db.prepare(`
722
+ INSERT OR REPLACE INTO tasks (id, session_key, title, summary, status, started_at, ended_at, updated_at)
723
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
724
+ `).run(task.id, task.sessionKey, task.title, task.summary, task.status, task.startedAt, task.endedAt, task.updatedAt);
725
+ }
726
+
727
+ getTask(taskId: string): Task | null {
728
+ const row = this.db.prepare("SELECT * FROM tasks WHERE id = ?").get(taskId) as TaskRow | undefined;
729
+ return row ? rowToTask(row) : null;
730
+ }
731
+
732
+ getActiveTask(sessionKey: string): Task | null {
733
+ const row = this.db.prepare(
734
+ "SELECT * FROM tasks WHERE session_key = ? AND status = 'active' ORDER BY started_at DESC LIMIT 1",
735
+ ).get(sessionKey) as TaskRow | undefined;
736
+ return row ? rowToTask(row) : null;
737
+ }
738
+
739
+ getAllActiveTasks(): Task[] {
740
+ const rows = this.db.prepare(
741
+ "SELECT * FROM tasks WHERE status = 'active' ORDER BY started_at DESC",
742
+ ).all() as TaskRow[];
743
+ return rows.map(rowToTask);
744
+ }
745
+
746
+ updateTask(taskId: string, fields: { title?: string; summary?: string; status?: TaskStatus; endedAt?: number }): boolean {
747
+ const sets: string[] = [];
748
+ const params: unknown[] = [];
749
+ if (fields.title !== undefined) { sets.push("title = ?"); params.push(fields.title); }
750
+ if (fields.summary !== undefined) { sets.push("summary = ?"); params.push(fields.summary); }
751
+ if (fields.status !== undefined) { sets.push("status = ?"); params.push(fields.status); }
752
+ if (fields.endedAt !== undefined) { sets.push("ended_at = ?"); params.push(fields.endedAt); }
753
+ if (sets.length === 0) return false;
754
+ sets.push("updated_at = ?");
755
+ params.push(Date.now());
756
+ params.push(taskId);
757
+ const result = this.db.prepare(`UPDATE tasks SET ${sets.join(", ")} WHERE id = ?`).run(...params);
758
+ return result.changes > 0;
759
+ }
760
+
761
+ getChunksByTask(taskId: string): Chunk[] {
762
+ const rows = this.db.prepare("SELECT * FROM chunks WHERE task_id = ? ORDER BY created_at, seq").all(taskId) as ChunkRow[];
763
+ return rows.map(rowToChunk);
764
+ }
765
+
766
+ listTasks(opts: { status?: string; limit?: number; offset?: number } = {}): { tasks: Task[]; total: number } {
767
+ const conditions: string[] = [];
768
+ const params: unknown[] = [];
769
+ if (opts.status) { conditions.push("status = ?"); params.push(opts.status); }
770
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
771
+
772
+ const countRow = this.db.prepare(`SELECT COUNT(*) as c FROM tasks ${whereClause}`).get(...params) as { c: number };
773
+ const total = countRow.c;
774
+
775
+ const limit = opts.limit ?? 50;
776
+ const offset = opts.offset ?? 0;
777
+ const rows = this.db.prepare(
778
+ `SELECT * FROM tasks ${whereClause} ORDER BY started_at DESC LIMIT ? OFFSET ?`,
779
+ ).all(...params, limit, offset) as TaskRow[];
780
+
781
+ return { tasks: rows.map(rowToTask), total };
782
+ }
783
+
784
+ countChunksByTask(taskId: string): number {
785
+ const row = this.db.prepare("SELECT COUNT(*) as c FROM chunks WHERE task_id = ?").get(taskId) as { c: number };
786
+ return row.c;
787
+ }
788
+
789
+ setChunkTaskId(chunkId: string, taskId: string): void {
790
+ this.db.prepare("UPDATE chunks SET task_id = ?, updated_at = ? WHERE id = ?").run(taskId, Date.now(), chunkId);
791
+ }
792
+
793
+ getUnassignedChunks(sessionKey: string): Chunk[] {
794
+ const rows = this.db.prepare(
795
+ "SELECT * FROM chunks WHERE session_key = ? AND task_id IS NULL ORDER BY created_at, seq",
796
+ ).all(sessionKey) as ChunkRow[];
797
+ return rows.map(rowToChunk);
798
+ }
799
+
800
+ /**
801
+ * Check if a chunk with the same (session_key, role, content_hash) already exists.
802
+ * Uses indexed content_hash for O(1) lookup to prevent duplicate ingestion
803
+ * when agent_end sends the full conversation history every turn.
804
+ */
805
+ chunkExistsByContent(sessionKey: string, role: string, content: string): boolean {
806
+ const hash = contentHash(content);
807
+ const row = this.db.prepare(
808
+ "SELECT 1 FROM chunks WHERE session_key = ? AND role = ? AND content_hash = ? LIMIT 1",
809
+ ).get(sessionKey, role, hash);
810
+ return !!row;
240
811
  }
241
812
 
242
813
  // ─── Util ───
@@ -248,6 +819,124 @@ export class SqliteStore {
248
819
  return rows.map((r) => r.id);
249
820
  }
250
821
 
822
+ countChunks(): number {
823
+ const row = this.db.prepare("SELECT COUNT(*) AS cnt FROM chunks").get() as { cnt: number };
824
+ return row.cnt;
825
+ }
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
+
251
940
  close(): void {
252
941
  this.db.close();
253
942
  }
@@ -284,6 +973,14 @@ interface ChunkRow {
284
973
  content: string;
285
974
  kind: string;
286
975
  summary: string;
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;
287
984
  created_at: number;
288
985
  updated_at: number;
289
986
  }
@@ -299,7 +996,105 @@ function rowToChunk(row: ChunkRow): Chunk {
299
996
  kind: row.kind as Chunk["kind"],
300
997
  summary: row.summary,
301
998
  embedding: null,
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 ?? "[]",
1007
+ createdAt: row.created_at,
1008
+ updatedAt: row.updated_at,
1009
+ };
1010
+ }
1011
+
1012
+ interface TaskRow {
1013
+ id: string;
1014
+ session_key: string;
1015
+ title: string;
1016
+ summary: string;
1017
+ status: string;
1018
+ started_at: number;
1019
+ ended_at: number | null;
1020
+ updated_at: number;
1021
+ }
1022
+
1023
+ function rowToTask(row: TaskRow): Task {
1024
+ return {
1025
+ id: row.id,
1026
+ sessionKey: row.session_key,
1027
+ title: row.title,
1028
+ summary: row.summary,
1029
+ status: row.status as Task["status"],
1030
+ startedAt: row.started_at,
1031
+ endedAt: row.ended_at,
1032
+ updatedAt: row.updated_at,
1033
+ };
1034
+ }
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,
302
1063
  createdAt: row.created_at,
303
1064
  updatedAt: row.updated_at,
304
1065
  };
305
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
+
1098
+ function contentHash(content: string): string {
1099
+ return createHash("sha256").update(content).digest("hex").slice(0, 16);
1100
+ }