gsd-pi 2.75.0-dev.2203010a0 → 2.75.0-dev.96d4bb599

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 (109) hide show
  1. package/dist/onboarding.d.ts +5 -1
  2. package/dist/onboarding.js +5 -3
  3. package/dist/resources/extensions/gsd/auto-model-selection.js +1 -1
  4. package/dist/resources/extensions/gsd/bootstrap/db-tools.js +23 -19
  5. package/dist/resources/extensions/gsd/bootstrap/memory-tools.js +128 -0
  6. package/dist/resources/extensions/gsd/bootstrap/register-extension.js +2 -0
  7. package/dist/resources/extensions/gsd/bootstrap/system-context.js +17 -4
  8. package/dist/resources/extensions/gsd/commands/handlers/onboarding.js +52 -68
  9. package/dist/resources/extensions/gsd/commands/handlers/ops.js +5 -0
  10. package/dist/resources/extensions/gsd/commands-memory.js +462 -0
  11. package/dist/resources/extensions/gsd/gsd-db.js +237 -4
  12. package/dist/resources/extensions/gsd/memory-embeddings.js +219 -0
  13. package/dist/resources/extensions/gsd/memory-extractor.js +78 -27
  14. package/dist/resources/extensions/gsd/memory-ingest.js +218 -0
  15. package/dist/resources/extensions/gsd/memory-relations.js +189 -0
  16. package/dist/resources/extensions/gsd/memory-source-store.js +113 -0
  17. package/dist/resources/extensions/gsd/memory-store.js +299 -6
  18. package/dist/resources/extensions/gsd/model-router.js +9 -5
  19. package/dist/resources/extensions/gsd/notification-overlay.js +7 -22
  20. package/dist/resources/extensions/gsd/tools/memory-tools.js +306 -0
  21. package/dist/resources/extensions/gsd/tools/skip-slice.js +78 -0
  22. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  23. package/dist/web/standalone/.next/BUILD_ID +1 -1
  24. package/dist/web/standalone/.next/app-path-routes-manifest.json +14 -14
  25. package/dist/web/standalone/.next/build-manifest.json +2 -2
  26. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  27. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  28. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  36. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/index.html +1 -1
  44. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app-paths-manifest.json +14 -14
  51. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  52. package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
  53. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  54. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  55. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  56. package/package.json +1 -1
  57. package/packages/mcp-server/dist/server.d.ts.map +1 -1
  58. package/packages/mcp-server/dist/server.js +12 -10
  59. package/packages/mcp-server/dist/server.js.map +1 -1
  60. package/packages/mcp-server/dist/session-manager.d.ts.map +1 -1
  61. package/packages/mcp-server/dist/session-manager.js +8 -1
  62. package/packages/mcp-server/dist/session-manager.js.map +1 -1
  63. package/packages/mcp-server/dist/workflow-tools.d.ts +1 -0
  64. package/packages/mcp-server/dist/workflow-tools.d.ts.map +1 -1
  65. package/packages/mcp-server/dist/workflow-tools.js +113 -14
  66. package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
  67. package/packages/mcp-server/src/mcp-server.test.ts +40 -4
  68. package/packages/mcp-server/src/server.ts +12 -10
  69. package/packages/mcp-server/src/session-manager.ts +10 -3
  70. package/packages/mcp-server/src/workflow-tools.test.ts +91 -1
  71. package/packages/mcp-server/src/workflow-tools.ts +128 -18
  72. package/packages/mcp-server/tsconfig.tsbuildinfo +1 -1
  73. package/src/resources/extensions/gsd/auto-model-selection.ts +1 -1
  74. package/src/resources/extensions/gsd/bootstrap/db-tools.ts +24 -20
  75. package/src/resources/extensions/gsd/bootstrap/memory-tools.ts +158 -0
  76. package/src/resources/extensions/gsd/bootstrap/register-extension.ts +2 -0
  77. package/src/resources/extensions/gsd/bootstrap/system-context.ts +20 -4
  78. package/src/resources/extensions/gsd/commands/handlers/onboarding.ts +65 -98
  79. package/src/resources/extensions/gsd/commands/handlers/ops.ts +5 -0
  80. package/src/resources/extensions/gsd/commands-memory.ts +551 -0
  81. package/src/resources/extensions/gsd/gsd-db.ts +273 -4
  82. package/src/resources/extensions/gsd/memory-embeddings.ts +235 -0
  83. package/src/resources/extensions/gsd/memory-extractor.ts +100 -34
  84. package/src/resources/extensions/gsd/memory-ingest.ts +286 -0
  85. package/src/resources/extensions/gsd/memory-relations.ts +240 -0
  86. package/src/resources/extensions/gsd/memory-source-store.ts +138 -0
  87. package/src/resources/extensions/gsd/memory-store.ts +351 -7
  88. package/src/resources/extensions/gsd/model-router.ts +10 -5
  89. package/src/resources/extensions/gsd/notification-overlay.ts +9 -19
  90. package/src/resources/extensions/gsd/tests/auto-model-selection.test.ts +12 -0
  91. package/src/resources/extensions/gsd/tests/complete-slice.test.ts +2 -2
  92. package/src/resources/extensions/gsd/tests/complete-task.test.ts +2 -2
  93. package/src/resources/extensions/gsd/tests/escalation.test.ts +2 -2
  94. package/src/resources/extensions/gsd/tests/gsd-db.test.ts +1 -1
  95. package/src/resources/extensions/gsd/tests/md-importer.test.ts +1 -1
  96. package/src/resources/extensions/gsd/tests/memory-embeddings.test.ts +213 -0
  97. package/src/resources/extensions/gsd/tests/memory-ingest.test.ts +153 -0
  98. package/src/resources/extensions/gsd/tests/memory-maintenance.test.ts +107 -0
  99. package/src/resources/extensions/gsd/tests/memory-relations.test.ts +175 -0
  100. package/src/resources/extensions/gsd/tests/memory-store.test.ts +2 -2
  101. package/src/resources/extensions/gsd/tests/memory-tools.test.ts +295 -0
  102. package/src/resources/extensions/gsd/tests/model-router.test.ts +50 -0
  103. package/src/resources/extensions/gsd/tests/notification-overlay.test.ts +56 -37
  104. package/src/resources/extensions/gsd/tests/skip-slice-cascades-tasks.test.ts +125 -0
  105. package/src/resources/extensions/gsd/tools/memory-tools.ts +380 -0
  106. package/src/resources/extensions/gsd/tools/skip-slice.ts +133 -0
  107. package/src/resources/extensions/gsd/workflow-logger.ts +3 -1
  108. /package/dist/web/standalone/.next/static/{8FZqxNe9FxQDmsbRzR8tA → o61X3klsB6C0UE0X1x3PA}/_buildManifest.js +0 -0
  109. /package/dist/web/standalone/.next/static/{8FZqxNe9FxQDmsbRzR8tA → o61X3klsB6C0UE0X1x3PA}/_ssgManifest.js +0 -0
@@ -15,7 +15,10 @@ import {
15
15
  markMemoryUnitProcessed,
16
16
  decayMemoriesBefore,
17
17
  supersedeLowestRankedMemories,
18
+ deleteMemoryEmbedding,
19
+ deleteMemoryRelationsFor,
18
20
  } from './gsd-db.js';
21
+ import { createMemoryRelation, isValidRelation } from './memory-relations.js';
19
22
 
20
23
  // ─── Types ──────────────────────────────────────────────────────────────────
21
24
 
@@ -31,6 +34,8 @@ export interface Memory {
31
34
  updated_at: string;
32
35
  superseded_by: string | null;
33
36
  hit_count: number;
37
+ scope: string;
38
+ tags: string[];
34
39
  }
35
40
 
36
41
  export type MemoryActionCreate = {
@@ -38,6 +43,8 @@ export type MemoryActionCreate = {
38
43
  category: string;
39
44
  content: string;
40
45
  confidence?: number;
46
+ scope?: string;
47
+ tags?: string[];
41
48
  };
42
49
 
43
50
  export type MemoryActionUpdate = {
@@ -58,11 +65,20 @@ export type MemoryActionSupersede = {
58
65
  superseded_by: string;
59
66
  };
60
67
 
68
+ export type MemoryActionLink = {
69
+ action: 'LINK';
70
+ from: string;
71
+ to: string;
72
+ rel: string;
73
+ confidence?: number;
74
+ };
75
+
61
76
  export type MemoryAction =
62
77
  | MemoryActionCreate
63
78
  | MemoryActionUpdate
64
79
  | MemoryActionReinforce
65
- | MemoryActionSupersede;
80
+ | MemoryActionSupersede
81
+ | MemoryActionLink;
66
82
 
67
83
  // ─── Category Display Order ─────────────────────────────────────────────────
68
84
 
@@ -90,9 +106,21 @@ function rowToMemory(row: Record<string, unknown>): Memory {
90
106
  updated_at: row['updated_at'] as string,
91
107
  superseded_by: (row['superseded_by'] as string) ?? null,
92
108
  hit_count: row['hit_count'] as number,
109
+ scope: (row['scope'] as string) ?? 'project',
110
+ tags: parseTags(row['tags']),
93
111
  };
94
112
  }
95
113
 
114
+ function parseTags(raw: unknown): string[] {
115
+ if (typeof raw !== 'string' || raw.length === 0) return [];
116
+ try {
117
+ const parsed = JSON.parse(raw);
118
+ return Array.isArray(parsed) ? parsed.filter((t): t is string => typeof t === 'string') : [];
119
+ } catch {
120
+ return [];
121
+ }
122
+ }
123
+
96
124
  // ─── Query Functions ────────────────────────────────────────────────────────
97
125
 
98
126
  /**
@@ -134,6 +162,280 @@ export function getActiveMemoriesRanked(limit = 30): Memory[] {
134
162
  }
135
163
  }
136
164
 
165
+ // ─── Hybrid query (keyword FTS + optional semantic) ─────────────────────────
166
+
167
+ export interface QueryMemoriesFilters {
168
+ category?: string;
169
+ scope?: string;
170
+ tag?: string;
171
+ include_superseded?: boolean;
172
+ }
173
+
174
+ export interface QueryMemoriesOptions extends QueryMemoriesFilters {
175
+ query: string;
176
+ k?: number;
177
+ /**
178
+ * Optional query-side embedding. When provided and embeddings exist in the
179
+ * DB, results are fused with cosine similarity via reciprocal-rank-fusion.
180
+ */
181
+ queryVector?: Float32Array | null;
182
+ /** RRF fusion constant (default 60). */
183
+ rrfK?: number;
184
+ }
185
+
186
+ export interface RankedMemory {
187
+ memory: Memory;
188
+ score: number;
189
+ keywordRank: number | null;
190
+ semanticRank: number | null;
191
+ confidenceBoost: number;
192
+ reason: 'keyword' | 'semantic' | 'both' | 'ranked';
193
+ }
194
+
195
+ export function queryMemoriesRanked(opts: QueryMemoriesOptions): RankedMemory[] {
196
+ if (!isDbAvailable()) return [];
197
+ const adapter = _getAdapter();
198
+ if (!adapter) return [];
199
+
200
+ const k = clampLimit(opts.k, 10);
201
+ const rrfK = opts.rrfK ?? 60;
202
+ const activeClause = opts.include_superseded === true ? '' : 'WHERE superseded_by IS NULL';
203
+ const trimmedQuery = (opts.query ?? '').trim();
204
+
205
+ // 1) Keyword hits — try FTS5 first, fall back to LIKE when unavailable.
206
+ const keywordHits = trimmedQuery ? keywordSearch(adapter, trimmedQuery, activeClause, 50) : [];
207
+
208
+ // 2) Semantic hits — cosine over memory_embeddings. Requires opts.queryVector.
209
+ const semanticHits = opts.queryVector
210
+ ? semanticSearch(adapter, opts.queryVector, activeClause, 50)
211
+ : [];
212
+
213
+ if (keywordHits.length === 0 && semanticHits.length === 0 && !trimmedQuery) {
214
+ // No query at all — fall back to the existing ranked-by-score listing.
215
+ return getActiveMemoriesRanked(k).map((memory) => ({
216
+ memory,
217
+ score: memory.confidence * (1 + memory.hit_count * 0.1),
218
+ keywordRank: null,
219
+ semanticRank: null,
220
+ confidenceBoost: memory.confidence * (1 + memory.hit_count * 0.1),
221
+ reason: 'ranked' as const,
222
+ })).filter((hit) => passesFilters(hit.memory, opts));
223
+ }
224
+
225
+ // 3) Reciprocal rank fusion — each hit contributes 1/(rrfK + rank).
226
+ const fused = new Map<string, { memory: Memory; kwRank: number | null; semRank: number | null; score: number }>();
227
+
228
+ for (let i = 0; i < keywordHits.length; i++) {
229
+ const hit = keywordHits[i];
230
+ const existing = fused.get(hit.id);
231
+ const rrf = 1 / (rrfK + i + 1);
232
+ if (existing) {
233
+ existing.kwRank = i + 1;
234
+ existing.score += rrf;
235
+ } else {
236
+ fused.set(hit.id, { memory: hit, kwRank: i + 1, semRank: null, score: rrf });
237
+ }
238
+ }
239
+
240
+ for (let i = 0; i < semanticHits.length; i++) {
241
+ const hit = semanticHits[i];
242
+ const existing = fused.get(hit.id);
243
+ const rrf = 1 / (rrfK + i + 1);
244
+ if (existing) {
245
+ existing.semRank = i + 1;
246
+ existing.score += rrf;
247
+ } else {
248
+ fused.set(hit.id, { memory: hit, kwRank: null, semRank: i + 1, score: rrf });
249
+ }
250
+ }
251
+
252
+ // 4) Apply filters + confidence boost, then sort.
253
+ const ranked: RankedMemory[] = [];
254
+ for (const entry of fused.values()) {
255
+ if (!passesFilters(entry.memory, opts)) continue;
256
+ const boost = entry.memory.confidence * (1 + entry.memory.hit_count * 0.1);
257
+ const reason: RankedMemory['reason'] =
258
+ entry.kwRank != null && entry.semRank != null
259
+ ? 'both'
260
+ : entry.kwRank != null
261
+ ? 'keyword'
262
+ : 'semantic';
263
+ ranked.push({
264
+ memory: entry.memory,
265
+ score: entry.score * boost,
266
+ keywordRank: entry.kwRank,
267
+ semanticRank: entry.semRank,
268
+ confidenceBoost: boost,
269
+ reason,
270
+ });
271
+ }
272
+
273
+ ranked.sort((a, b) => b.score - a.score);
274
+ return ranked.slice(0, k);
275
+ }
276
+
277
+ function clampLimit(value: unknown, fallback: number): number {
278
+ if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
279
+ if (value < 1) return 1;
280
+ if (value > 100) return 100;
281
+ return Math.floor(value);
282
+ }
283
+
284
+ function passesFilters(memory: Memory, filters: QueryMemoriesFilters): boolean {
285
+ if (filters.category && memory.category.toLowerCase() !== filters.category.toLowerCase()) return false;
286
+ if (filters.scope && memory.scope !== filters.scope) return false;
287
+ if (filters.tag) {
288
+ const needle = filters.tag.toLowerCase();
289
+ if (!memory.tags.map((t) => t.toLowerCase()).includes(needle)) return false;
290
+ }
291
+ return true;
292
+ }
293
+
294
+ function keywordSearch(
295
+ adapter: NonNullable<ReturnType<typeof _getAdapter>>,
296
+ rawQuery: string,
297
+ activeClause: string,
298
+ limit: number,
299
+ ): Memory[] {
300
+ const ftsAvailable = isFtsAvailable(adapter);
301
+ if (ftsAvailable) {
302
+ try {
303
+ const matchExpr = toFtsMatchExpr(rawQuery);
304
+ if (!matchExpr) return [];
305
+ const activePart = activeClause ? `AND m.${activeClause.replace(/^WHERE\s+/i, '')}` : '';
306
+ const rows = adapter.prepare(
307
+ `SELECT m.*
308
+ FROM memories_fts f
309
+ JOIN memories m ON m.seq = f.rowid
310
+ WHERE memories_fts MATCH :match
311
+ ${activePart}
312
+ ORDER BY bm25(memories_fts)
313
+ LIMIT :limit`,
314
+ ).all({ ':match': matchExpr, ':limit': limit });
315
+ return rows.map(rowToMemory);
316
+ } catch {
317
+ // fall through to LIKE
318
+ }
319
+ }
320
+
321
+ // LIKE fallback — scans the candidate pool.
322
+ const terms = rawQuery
323
+ .toLowerCase()
324
+ .split(/[^a-z0-9_]+/)
325
+ .filter((t) => t.length >= 2);
326
+ if (terms.length === 0) return [];
327
+
328
+ const rows = adapter.prepare(`SELECT * FROM memories ${activeClause}`).all();
329
+ const scored: Array<{ memory: Memory; score: number }> = [];
330
+ for (const row of rows) {
331
+ const memory = rowToMemory(row);
332
+ const lower = memory.content.toLowerCase();
333
+ let score = 0;
334
+ for (const term of terms) {
335
+ const idx = lower.indexOf(term);
336
+ if (idx === -1) continue;
337
+ score += 1 + (term.length >= 5 ? 0.5 : 0);
338
+ }
339
+ if (score > 0) scored.push({ memory, score });
340
+ }
341
+ scored.sort((a, b) => b.score - a.score);
342
+ return scored.slice(0, limit).map((s) => s.memory);
343
+ }
344
+
345
+ function isFtsAvailable(adapter: NonNullable<ReturnType<typeof _getAdapter>>): boolean {
346
+ try {
347
+ const row = adapter
348
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='memories_fts'")
349
+ .get();
350
+ return !!row;
351
+ } catch {
352
+ return false;
353
+ }
354
+ }
355
+
356
+ function toFtsMatchExpr(query: string): string | null {
357
+ // Build a tolerant AND expression: quote each bare term with a trailing *.
358
+ const tokens = query
359
+ .toLowerCase()
360
+ .split(/[^a-z0-9_]+/)
361
+ .filter((t) => t.length >= 2)
362
+ .slice(0, 8);
363
+ if (tokens.length === 0) return null;
364
+ return tokens.map((t) => `"${t.replace(/"/g, '""')}"*`).join(' OR ');
365
+ }
366
+
367
+ function semanticSearch(
368
+ adapter: NonNullable<ReturnType<typeof _getAdapter>>,
369
+ queryVector: Float32Array,
370
+ activeClause: string,
371
+ limit: number,
372
+ ): Memory[] {
373
+ try {
374
+ const rows = adapter
375
+ .prepare(
376
+ `SELECT m.*, e.vector as embedding_vector, e.dim as embedding_dim
377
+ FROM memories m
378
+ JOIN memory_embeddings e ON e.memory_id = m.id
379
+ ${activeClause}`,
380
+ )
381
+ .all();
382
+
383
+ const scored: Array<{ memory: Memory; sim: number }> = [];
384
+ for (const row of rows) {
385
+ const dim = row['embedding_dim'] as number;
386
+ if (dim !== queryVector.length) continue;
387
+ const vector = unpackVector(row['embedding_vector'], dim);
388
+ if (!vector) continue;
389
+ const sim = cosine(queryVector, vector);
390
+ if (sim <= 0) continue;
391
+ scored.push({ memory: rowToMemory(row), sim });
392
+ }
393
+ scored.sort((a, b) => b.sim - a.sim);
394
+ return scored.slice(0, limit).map((s) => s.memory);
395
+ } catch {
396
+ return [];
397
+ }
398
+ }
399
+
400
+ function unpackVector(blob: unknown, dim: number): Float32Array | null {
401
+ if (!blob) return null;
402
+ try {
403
+ let view: Uint8Array | null = null;
404
+ if (blob instanceof Float32Array) return blob;
405
+ if (blob instanceof Uint8Array) view = blob;
406
+ else if (blob instanceof ArrayBuffer) view = new Uint8Array(blob);
407
+ else if ((blob as Buffer).buffer && (blob as Buffer).byteLength != null) {
408
+ const buf = blob as Buffer;
409
+ view = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
410
+ } else if (Array.isArray(blob)) {
411
+ return new Float32Array(blob as number[]);
412
+ }
413
+ if (!view || view.byteLength % 4 !== 0) return null;
414
+ const aligned = new ArrayBuffer(view.byteLength);
415
+ new Uint8Array(aligned).set(view);
416
+ const f32 = new Float32Array(aligned);
417
+ return f32.length === dim ? f32 : null;
418
+ } catch {
419
+ return null;
420
+ }
421
+ }
422
+
423
+ function cosine(a: Float32Array, b: Float32Array): number {
424
+ if (a.length === 0 || a.length !== b.length) return 0;
425
+ let dot = 0;
426
+ let na = 0;
427
+ let nb = 0;
428
+ for (let i = 0; i < a.length; i++) {
429
+ const x = a[i];
430
+ const y = b[i];
431
+ dot += x * y;
432
+ na += x * x;
433
+ nb += y * y;
434
+ }
435
+ if (na === 0 || nb === 0) return 0;
436
+ return dot / (Math.sqrt(na) * Math.sqrt(nb));
437
+ }
438
+
137
439
  /**
138
440
  * Generate the next memory ID: MEM + zero-padded 3-digit from MAX(seq).
139
441
  * Returns MEM001 if no memories exist.
@@ -173,6 +475,8 @@ export function createMemory(fields: {
173
475
  confidence?: number;
174
476
  source_unit_type?: string;
175
477
  source_unit_id?: string;
478
+ scope?: string;
479
+ tags?: string[];
176
480
  }): string | null {
177
481
  if (!isDbAvailable()) return null;
178
482
  const adapter = _getAdapter();
@@ -191,6 +495,8 @@ export function createMemory(fields: {
191
495
  sourceUnitId: fields.source_unit_id ?? null,
192
496
  createdAt: now,
193
497
  updatedAt: now,
498
+ scope: fields.scope ?? 'project',
499
+ tags: fields.tags ?? [],
194
500
  });
195
501
  // Derive the real ID from the assigned seq (SELECT is still fine via adapter)
196
502
  const row = adapter.prepare('SELECT seq FROM memories WHERE id = :id').get({ ':id': placeholder });
@@ -285,11 +591,12 @@ export function markUnitProcessed(unitKey: string, activityFile: string): boolea
285
591
  /**
286
592
  * Reduce confidence for memories not updated within the last N processed units.
287
593
  * "Stale" = updated_at is older than the Nth most recent processed_at.
594
+ * Returns the number of decayed memory IDs for observability.
288
595
  */
289
- export function decayStaleMemories(thresholdUnits = 20): void {
290
- if (!isDbAvailable()) return;
596
+ export function decayStaleMemories(thresholdUnits = 20): string[] {
597
+ if (!isDbAvailable()) return [];
291
598
  const adapter = _getAdapter();
292
- if (!adapter) return;
599
+ if (!adapter) return [];
293
600
 
294
601
  try {
295
602
  // Find the timestamp of the Nth most recent processed unit (read-only SELECT)
@@ -299,17 +606,24 @@ export function decayStaleMemories(thresholdUnits = 20): void {
299
606
  LIMIT 1 OFFSET :offset`,
300
607
  ).get({ ':offset': thresholdUnits - 1 });
301
608
 
302
- if (!row) return; // not enough processed units yet
609
+ if (!row) return []; // not enough processed units yet
303
610
 
304
611
  const cutoff = row['processed_at'] as string;
612
+ const affected = adapter.prepare(
613
+ `SELECT id FROM memories
614
+ WHERE superseded_by IS NULL AND updated_at < :cutoff AND confidence > 0.1`,
615
+ ).all({ ':cutoff': cutoff }).map((r) => r['id'] as string);
616
+
305
617
  decayMemoriesBefore(cutoff, new Date().toISOString());
618
+ return affected;
306
619
  } catch {
307
- // non-fatal
620
+ return [];
308
621
  }
309
622
  }
310
623
 
311
624
  /**
312
- * Supersede lowest-ranked memories when count exceeds cap.
625
+ * Supersede lowest-ranked memories when count exceeds cap. Cascades to the
626
+ * embedding and relation rows so those tables don't grow unboundedly.
313
627
  */
314
628
  export function enforceMemoryCap(max = 50): void {
315
629
  if (!isDbAvailable()) return;
@@ -324,7 +638,21 @@ export function enforceMemoryCap(max = 50): void {
324
638
  if (count <= max) return;
325
639
 
326
640
  const excess = count - max;
641
+ // Capture the about-to-be-superseded IDs first so we can cascade cleanup.
642
+ const victims = adapter.prepare(
643
+ `SELECT id FROM memories
644
+ WHERE superseded_by IS NULL
645
+ ORDER BY (confidence * (1.0 + hit_count * 0.1)) ASC
646
+ LIMIT :limit`,
647
+ ).all({ ':limit': excess }).map((row) => row['id'] as string);
648
+
327
649
  supersedeLowestRankedMemories(excess, new Date().toISOString());
650
+
651
+ if (victims.length === 0) return;
652
+ for (const id of victims) {
653
+ try { deleteMemoryEmbedding(id); } catch { /* non-fatal */ }
654
+ try { deleteMemoryRelationsFor(id); } catch { /* non-fatal */ }
655
+ }
328
656
  } catch {
329
657
  // non-fatal
330
658
  }
@@ -354,6 +682,8 @@ export function applyMemoryActions(
354
682
  confidence: action.confidence,
355
683
  source_unit_type: unitType,
356
684
  source_unit_id: unitId,
685
+ scope: action.scope,
686
+ tags: action.tags,
357
687
  });
358
688
  break;
359
689
  case 'UPDATE':
@@ -365,6 +695,9 @@ export function applyMemoryActions(
365
695
  case 'SUPERSEDE':
366
696
  supersedeMemory(action.id, action.superseded_by);
367
697
  break;
698
+ case 'LINK':
699
+ applyLinkAction(action);
700
+ break;
368
701
  }
369
702
  }
370
703
  enforceMemoryCap();
@@ -374,6 +707,17 @@ export function applyMemoryActions(
374
707
  }
375
708
  }
376
709
 
710
+ // ─── LINK action ────────────────────────────────────────────────────────────
711
+
712
+ function applyLinkAction(action: MemoryActionLink): void {
713
+ try {
714
+ if (!isValidRelation(action.rel)) return;
715
+ createMemoryRelation(action.from, action.to, action.rel, action.confidence);
716
+ } catch {
717
+ // Link failures should never break memory extraction.
718
+ }
719
+ }
720
+
377
721
  // ─── Prompt Formatting ──────────────────────────────────────────────────────
378
722
 
379
723
  /**
@@ -279,8 +279,9 @@ export function scoreEligibleModels(
279
279
  capabilityOverrides?: Record<string, Partial<ModelCapabilities>>,
280
280
  ): Array<{ modelId: string; score: number }> {
281
281
  const scored = eligibleModelIds.map(modelId => {
282
- const builtin = MODEL_CAPABILITY_PROFILES[modelId];
283
- const override = capabilityOverrides?.[modelId];
282
+ const bareId = bareModelId(modelId);
283
+ const builtin = MODEL_CAPABILITY_PROFILES[bareId];
284
+ const override = capabilityOverrides?.[modelId] ?? capabilityOverrides?.[bareId];
284
285
  const profile: ModelCapabilities = builtin
285
286
  ? override ? { ...builtin, ...override } : builtin
286
287
  : { coding: 50, debugging: 50, research: 50, reasoning: 50, speed: 50, longContext: 50, instruction: 50 };
@@ -516,7 +517,7 @@ export function defaultRoutingConfig(): DynamicRoutingConfig {
516
517
 
517
518
  function getModelTier(modelId: string): ComplexityTier {
518
519
  // Strip provider prefix if present
519
- const bareId = modelId.includes("/") ? modelId.split("/").pop()! : modelId;
520
+ const bareId = bareModelId(modelId);
520
521
 
521
522
  // Check exact match first
522
523
  if (MODEL_CAPABILITY_TIER[bareId]) return MODEL_CAPABILITY_TIER[bareId];
@@ -532,7 +533,7 @@ function getModelTier(modelId: string): ComplexityTier {
532
533
 
533
534
  /** Check if a model ID has a known capability tier mapping. (#2192) */
534
535
  function isKnownModel(modelId: string): boolean {
535
- const bareId = modelId.includes("/") ? modelId.split("/").pop()! : modelId;
536
+ const bareId = bareModelId(modelId);
536
537
  if (MODEL_CAPABILITY_TIER[bareId]) return true;
537
538
  for (const knownId of Object.keys(MODEL_CAPABILITY_TIER)) {
538
539
  if (bareId.includes(knownId) || knownId.includes(bareId)) return true;
@@ -541,7 +542,7 @@ function isKnownModel(modelId: string): boolean {
541
542
  }
542
543
 
543
544
  function getModelCost(modelId: string): number {
544
- const bareId = modelId.includes("/") ? modelId.split("/").pop()! : modelId;
545
+ const bareId = bareModelId(modelId);
545
546
 
546
547
  if (MODEL_COST_PER_1K_INPUT[bareId] !== undefined) {
547
548
  return MODEL_COST_PER_1K_INPUT[bareId];
@@ -556,6 +557,10 @@ function getModelCost(modelId: string): number {
556
557
  return 999;
557
558
  }
558
559
 
560
+ function bareModelId(modelId: string): string {
561
+ return modelId.includes("/") ? modelId.split("/").pop()! : modelId;
562
+ }
563
+
559
564
  // ─── Tool Compatibility Filter (ADR-005 Phase 3) ───────────────────────────
560
565
 
561
566
  /**
@@ -3,7 +3,7 @@
3
3
  // Toggled with Ctrl+Alt+N (⌃⌥N on macOS), Ctrl+Shift+N fallback, or /gsd notifications.
4
4
 
5
5
  import type { Theme } from "@gsd/pi-coding-agent";
6
- import { truncateToWidth, visibleWidth, matchesKey, Key } from "@gsd/pi-tui";
6
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi, matchesKey, Key } from "@gsd/pi-tui";
7
7
 
8
8
  import {
9
9
  readNotifications,
@@ -29,25 +29,15 @@ function severityIcon(severity: NotifySeverity): string {
29
29
  }
30
30
  }
31
31
 
32
- /** Word-wrap plain text to fit within maxWidth columns. */
32
+ /** Column-aware word wrap using pi-tui's native wrapper (handles unicode/ANSI). */
33
33
  function wrapText(text: string, maxWidth: number): string[] {
34
- if (text.length <= maxWidth) return [text];
35
- const words = text.split(/\s+/);
36
- const lines: string[] = [];
37
- let current = "";
38
- for (const word of words) {
39
- if (current.length === 0) {
40
- current = word;
41
- } else if (current.length + 1 + word.length <= maxWidth) {
42
- current += " " + word;
43
- } else {
44
- lines.push(current);
45
- current = word;
46
- }
47
- }
48
- if (current.length > 0) lines.push(current);
49
- // If a single word exceeds maxWidth, truncate it
50
- return lines.map((l) => l.length > maxWidth ? l.slice(0, maxWidth - 1) + "…" : l);
34
+ if (maxWidth <= 0) return [text];
35
+ const lines = wrapTextWithAnsi(text, maxWidth);
36
+ // Safety clamp: if any line still exceeds maxWidth (e.g. unbreakable long token),
37
+ // truncate it with an ellipsis so it cannot bleed past the box border.
38
+ return lines.map((l) =>
39
+ visibleWidth(l) > maxWidth ? truncateToWidth(l, maxWidth, "…") : l,
40
+ );
51
41
  }
52
42
 
53
43
  function formatTimestamp(ts: string): string {
@@ -247,6 +247,18 @@ test("model policy receives task metadata for requirement-vector decisions", ()
247
247
  );
248
248
  });
249
249
 
250
+ test("dynamic routing passes provider-qualified model keys to the router", () => {
251
+ const src = readFileSync(join(__dirname, "..", "auto-model-selection.ts"), "utf-8");
252
+ assert.ok(
253
+ src.includes("routingEligibleModels.map(m => `${m.provider}/${m.id}`)"),
254
+ "selectAndApplyModel should preserve provider prefixes for dynamic routing candidates",
255
+ );
256
+ assert.ok(
257
+ !src.includes("routingEligibleModels.map(m => m.id)"),
258
+ "selectAndApplyModel must not strip providers before resolving tier_models",
259
+ );
260
+ });
261
+
250
262
  test("resolveModelId: anthropic wins over claude-code when session provider is not claude-code", () => {
251
263
  const availableModels = [
252
264
  { id: "claude-sonnet-4-6", provider: "claude-code" },
@@ -125,9 +125,9 @@ console.log('\n=== complete-slice: schema v6 migration ===');
125
125
 
126
126
  const adapter = _getAdapter()!;
127
127
 
128
- // Verify schema version is current (v17 with ADR-011 P2 escalation columns)
128
+ // Verify schema version is current (v20 memory system Phase 4 relations)
129
129
  const versionRow = adapter.prepare('SELECT MAX(version) as v FROM schema_version').get();
130
- assertEq(versionRow?.['v'], 17, 'schema version should be 17');
130
+ assertEq(versionRow?.['v'], 20, 'schema version should be 20');
131
131
 
132
132
  // Verify slices table has full_summary_md and full_uat_md columns
133
133
  const cols = adapter.prepare("PRAGMA table_info(slices)").all();
@@ -109,9 +109,9 @@ console.log('\n=== complete-task: schema v5 migration ===');
109
109
 
110
110
  const adapter = _getAdapter()!;
111
111
 
112
- // Verify schema version is current (v17 with ADR-011 P2 escalation columns)
112
+ // Verify schema version is current (v20 memory system Phase 4 relations)
113
113
  const versionRow = adapter.prepare('SELECT MAX(version) as v FROM schema_version').get();
114
- assertEq(versionRow?.['v'], 17, 'schema version should be 17');
114
+ assertEq(versionRow?.['v'], 20, 'schema version should be 20');
115
115
 
116
116
  // Verify all 4 new tables exist
117
117
  const tables = adapter.prepare(
@@ -327,7 +327,7 @@ test("ADR-011 P2: listEscalationArtifacts filters to actionable by default", (t)
327
327
  assert.equal(all.length, 2, "both surface with --all");
328
328
  });
329
329
 
330
- test("ADR-011 P2: schema v17 fresh DB has all escalation columns on tasks + source on decisions", (t) => {
330
+ test("ADR-011 P2: schema v20 fresh DB has all escalation columns on tasks + source on decisions", (t) => {
331
331
  const base = makeBase();
332
332
  t.after(() => cleanup(base));
333
333
  openDatabase(join(base, ".gsd", "gsd.db"));
@@ -348,7 +348,7 @@ test("ADR-011 P2: schema v17 fresh DB has all escalation columns on tasks + sour
348
348
  assert.ok(decCols.includes("source"), "decisions table must have source column");
349
349
 
350
350
  const version = adapter.prepare("SELECT MAX(version) as v FROM schema_version").get();
351
- assert.equal(version?.["v"], 17);
351
+ assert.equal(version?.["v"], 20);
352
352
  });
353
353
 
354
354
  test("ADR-011 P2: findUnappliedEscalationOverride returns null when escalation_pending=1 (still pending)", (t) => {
@@ -81,7 +81,7 @@ describe('gsd-db', () => {
81
81
  // Check schema_version table
82
82
  const adapter = _getAdapter()!;
83
83
  const version = adapter.prepare('SELECT MAX(version) as version FROM schema_version').get();
84
- assert.deepStrictEqual(version?.['version'], 17, 'schema version should be 17');
84
+ assert.deepStrictEqual(version?.['version'], 20, 'schema version should be 20');
85
85
 
86
86
  // Check tables exist by querying them
87
87
  const dRows = adapter.prepare('SELECT count(*) as cnt FROM decisions').get();
@@ -363,7 +363,7 @@ test('md-importer: schema v1→v2 migration', () => {
363
363
  openDatabase(':memory:');
364
364
  const adapter = _getAdapter();
365
365
  const version = adapter?.prepare('SELECT MAX(version) as v FROM schema_version').get();
366
- assert.deepStrictEqual(version?.v, 17, 'new DB should be at schema version 17');
366
+ assert.deepStrictEqual(version?.v, 20, 'new DB should be at schema version 20');
367
367
 
368
368
  // Artifacts table should exist
369
369
  const tableCheck = adapter?.prepare("SELECT count(*) as c FROM sqlite_master WHERE type='table' AND name='artifacts'").get();