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
@@ -128,6 +128,14 @@ Actions (return JSON array):
128
128
  - UPDATE: {"action": "UPDATE", "id": "<MEM###>", "content": "<revised text>"}
129
129
  - REINFORCE: {"action": "REINFORCE", "id": "<MEM###>"}
130
130
  - SUPERSEDE: {"action": "SUPERSEDE", "id": "<MEM###>", "superseded_by": "<MEM###>"}
131
+ - LINK: {"action": "LINK", "from": "<MEM###>", "to": "<MEM###>", "rel": "<rel>", "confidence": <0.6-0.95>}
132
+
133
+ Link relation types:
134
+ - related_to — two memories cover the same area
135
+ - depends_on — "to" is a prerequisite for "from"
136
+ - contradicts — "from" conflicts with "to"
137
+ - elaborates — "from" expands on "to"
138
+ - supersedes — "from" replaces "to" (rarely needed; prefer SUPERSEDE)
131
139
 
132
140
  Rules:
133
141
  - Don't create memories for one-off bug fixes or temporary state
@@ -135,6 +143,7 @@ Rules:
135
143
  - Keep content to 1-3 sentences
136
144
  - Confidence: 0.6 tentative, 0.8 solid, 0.95 well-confirmed
137
145
  - Prefer fewer high-quality memories over many low-quality ones
146
+ - Only LINK memories that genuinely relate — don't fabricate edges
138
147
  - Return empty array [] if nothing worth remembering
139
148
  - NEVER include secrets, API keys, or passwords
140
149
 
@@ -262,6 +271,21 @@ export function parseMemoryResponse(raw: string): MemoryAction[] {
262
271
  });
263
272
  }
264
273
  break;
274
+ case 'LINK':
275
+ if (
276
+ typeof item.from === 'string' &&
277
+ typeof item.to === 'string' &&
278
+ typeof item.rel === 'string'
279
+ ) {
280
+ actions.push({
281
+ action: 'LINK',
282
+ from: item.from,
283
+ to: item.to,
284
+ rel: item.rel,
285
+ confidence: typeof item.confidence === 'number' ? item.confidence : undefined,
286
+ });
287
+ }
288
+ break;
265
289
  }
266
290
  }
267
291
 
@@ -273,6 +297,77 @@ export function parseMemoryResponse(raw: string): MemoryAction[] {
273
297
 
274
298
  // ─── Main Extraction Function ───────────────────────────────────────────────
275
299
 
300
+ export interface ExtractFromTranscriptOptions {
301
+ /** Logical source type (e.g. "execute-task", "note", "file", "url", "artifact"). */
302
+ sourceType: string;
303
+ /** Stable identifier for the source (unit id, source id, URL, etc.). */
304
+ sourceId: string;
305
+ /** Optional scope for any CREATE actions ("project" / "global" / custom). */
306
+ scope?: string;
307
+ /** Optional tags applied to any CREATE actions. */
308
+ tags?: string[];
309
+ /** Bypass the mutex + rate-limit when caller has already vetted them. */
310
+ force?: boolean;
311
+ }
312
+
313
+ /**
314
+ * Core extractor — shared by unit post-processing and explicit ingest.
315
+ * Returns the applied actions for observability. Never throws.
316
+ */
317
+ export async function extractMemoriesFromTranscript(
318
+ transcript: string,
319
+ llmCallFn: LLMCallFn,
320
+ opts: ExtractFromTranscriptOptions,
321
+ ): Promise<import('./memory-store.js').MemoryAction[]> {
322
+ if (!opts.force) {
323
+ if (_extracting) return [];
324
+ const now = Date.now();
325
+ if (now - _lastExtractionTime < MIN_EXTRACTION_INTERVAL_MS) return [];
326
+ _lastExtractionTime = now;
327
+ }
328
+
329
+ const acquireMutex = !_extracting;
330
+ if (acquireMutex) _extracting = true;
331
+ try {
332
+ const trimmed = transcript.trim();
333
+ if (!trimmed) return [];
334
+
335
+ const safeTranscript = redactSecrets(trimmed);
336
+ const activeMemories = getActiveMemories().map((m) => ({
337
+ id: m.id,
338
+ category: m.category,
339
+ content: m.content,
340
+ }));
341
+
342
+ const userPrompt = buildExtractionUserPrompt(
343
+ opts.sourceType,
344
+ opts.sourceId,
345
+ activeMemories,
346
+ safeTranscript,
347
+ );
348
+
349
+ const response = await llmCallFn(EXTRACTION_SYSTEM, userPrompt);
350
+ const actions = parseMemoryResponse(response);
351
+
352
+ if (actions.length === 0) return [];
353
+
354
+ const decorated = actions.map((action): import('./memory-store.js').MemoryAction => {
355
+ if (action.action !== 'CREATE') return action;
356
+ return {
357
+ ...action,
358
+ scope: action.scope ?? opts.scope,
359
+ tags: action.tags ?? opts.tags,
360
+ };
361
+ });
362
+ applyMemoryActions(decorated, opts.sourceType, opts.sourceId);
363
+ return decorated;
364
+ } catch {
365
+ return [];
366
+ } finally {
367
+ if (acquireMutex) _extracting = false;
368
+ }
369
+ }
370
+
276
371
  /**
277
372
  * Extract memories from a completed unit's activity log.
278
373
  * Fire-and-forget — never throws, mutex-guarded, respects rate limiting.
@@ -283,22 +378,16 @@ export async function extractMemoriesFromUnit(
283
378
  unitId: string,
284
379
  llmCallFn: LLMCallFn,
285
380
  ): Promise<void> {
286
- // Mutex guard
287
381
  if (_extracting) return;
288
382
 
289
- // Rate limit
290
383
  const now = Date.now();
291
384
  if (now - _lastExtractionTime < MIN_EXTRACTION_INTERVAL_MS) return;
292
385
 
293
- // Skip certain unit types
294
386
  if (SKIP_TYPES.has(unitType)) return;
295
387
 
296
388
  const unitKey = `${unitType}/${unitId}`;
297
-
298
- // Already processed
299
389
  if (isUnitProcessed(unitKey)) return;
300
390
 
301
- // Check file size
302
391
  try {
303
392
  const stat = statSync(activityFile);
304
393
  if (stat.size < MIN_ACTIVITY_SIZE) return;
@@ -308,41 +397,18 @@ export async function extractMemoriesFromUnit(
308
397
 
309
398
  _extracting = true;
310
399
  _lastExtractionTime = now;
311
-
312
400
  try {
313
- // Read and parse activity file
314
401
  const raw = readFileSync(activityFile, 'utf-8');
315
402
  const transcript = extractTranscriptFromActivity(raw);
316
403
  if (!transcript.trim()) return;
317
404
 
318
- // Redact secrets
319
- const safeTranscript = redactSecrets(transcript);
405
+ await extractMemoriesFromTranscript(transcript, llmCallFn, {
406
+ sourceType: unitType,
407
+ sourceId: unitId,
408
+ force: true,
409
+ });
320
410
 
321
- // Get current memories for context
322
- const activeMemories = getActiveMemories().map(m => ({
323
- id: m.id,
324
- category: m.category,
325
- content: m.content,
326
- }));
327
-
328
- // Build prompts
329
- const userPrompt = buildExtractionUserPrompt(unitType, unitId, activeMemories, safeTranscript);
330
-
331
- // Call LLM
332
- const response = await llmCallFn(EXTRACTION_SYSTEM, userPrompt);
333
-
334
- // Parse response
335
- const actions = parseMemoryResponse(response);
336
-
337
- // Apply actions
338
- if (actions.length > 0) {
339
- applyMemoryActions(actions, unitType, unitId);
340
- }
341
-
342
- // Decay stale memories periodically
343
411
  decayStaleMemories(20);
344
-
345
- // Mark unit as processed
346
412
  markUnitProcessed(unitKey, activityFile);
347
413
  } catch {
348
414
  // Non-fatal — memory extraction failure should never affect auto-mode
@@ -0,0 +1,286 @@
1
+ // GSD Memory Ingest — turn raw content into memories
2
+ //
3
+ // Provides four entry points: ingestNote (inline text), ingestFile (local
4
+ // path), ingestUrl (HTTP resource), and ingestArtifact (a named .gsd/ artifact
5
+ // for a given milestone). Each one inserts a row into `memory_sources` and,
6
+ // if an LLM call is available, fires the extractor against the content with
7
+ // source-specific scope/tags.
8
+ //
9
+ // All four functions are safe to call without an LLM — they still persist the
10
+ // source. This means ingestion is decoupled from extraction; a later
11
+ // `/gsd memory rebuild` can re-extract from persisted sources.
12
+
13
+ import { existsSync, readFileSync, statSync } from "node:fs";
14
+ import { basename, isAbsolute, resolve } from "node:path";
15
+ import type { ExtensionContext } from "@gsd/pi-coding-agent";
16
+
17
+ import { createMemorySource, type MemorySource, type MemorySourceKind } from "./memory-source-store.js";
18
+ import { buildMemoryLLMCall, extractMemoriesFromTranscript } from "./memory-extractor.js";
19
+ import type { MemoryAction } from "./memory-store.js";
20
+ import { resolveMilestoneFile } from "./paths.js";
21
+ import { logWarning } from "./workflow-logger.js";
22
+
23
+ // ─── Types ──────────────────────────────────────────────────────────────────
24
+
25
+ export interface IngestOptions {
26
+ scope?: string;
27
+ tags?: string[];
28
+ /** Skip LLM extraction — just persist the source row. */
29
+ extract?: boolean;
30
+ /**
31
+ * Soft upper bound on source content size (bytes). Files/URLs above this
32
+ * are truncated before hashing and storing. Default 256 KiB.
33
+ */
34
+ maxBytes?: number;
35
+ }
36
+
37
+ export interface IngestResult {
38
+ sourceId: string;
39
+ duplicate: boolean;
40
+ extracted: MemoryAction[];
41
+ kind: MemorySourceKind;
42
+ title: string | null;
43
+ uri: string | null;
44
+ }
45
+
46
+ const DEFAULT_MAX_BYTES = 256 * 1024;
47
+
48
+ function truncate(content: string, maxBytes: number): string {
49
+ const buf = Buffer.from(content, "utf-8");
50
+ if (buf.byteLength <= maxBytes) return content;
51
+ return `${buf.subarray(0, maxBytes).toString("utf-8")}\n\n…[truncated to ${maxBytes} bytes]`;
52
+ }
53
+
54
+ async function maybeExtract(
55
+ ctx: ExtensionContext | null,
56
+ source: { kind: MemorySourceKind; id: string },
57
+ content: string,
58
+ opts: IngestOptions,
59
+ ): Promise<MemoryAction[]> {
60
+ if (opts.extract === false || !ctx) return [];
61
+ const llmCallFn = buildMemoryLLMCall(ctx);
62
+ if (!llmCallFn) return [];
63
+ try {
64
+ return await extractMemoriesFromTranscript(content, llmCallFn, {
65
+ sourceType: source.kind,
66
+ sourceId: source.id,
67
+ scope: opts.scope,
68
+ tags: opts.tags,
69
+ force: true,
70
+ });
71
+ } catch (err) {
72
+ logWarning("memory-ingest", `extraction failed: ${(err as Error).message}`);
73
+ return [];
74
+ }
75
+ }
76
+
77
+ function sourceCreateFailure(kind: MemorySourceKind): IngestResult {
78
+ return {
79
+ sourceId: "",
80
+ duplicate: false,
81
+ extracted: [],
82
+ kind,
83
+ title: null,
84
+ uri: null,
85
+ };
86
+ }
87
+
88
+ // ─── ingestNote ─────────────────────────────────────────────────────────────
89
+
90
+ export async function ingestNote(
91
+ note: string,
92
+ ctx: ExtensionContext | null,
93
+ opts: IngestOptions = {},
94
+ ): Promise<IngestResult> {
95
+ const trimmed = note.trim();
96
+ if (!trimmed) return sourceCreateFailure("note");
97
+
98
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
99
+ const content = truncate(trimmed, maxBytes);
100
+
101
+ const created = createMemorySource({
102
+ kind: "note",
103
+ uri: null,
104
+ title: content.slice(0, 80).replace(/\s+/g, " ").trim(),
105
+ content,
106
+ scope: opts.scope,
107
+ tags: opts.tags,
108
+ });
109
+ if (!created) return sourceCreateFailure("note");
110
+
111
+ const extracted = created.duplicate
112
+ ? []
113
+ : await maybeExtract(ctx, { kind: "note", id: created.id }, content, opts);
114
+
115
+ return {
116
+ sourceId: created.id,
117
+ duplicate: created.duplicate,
118
+ extracted,
119
+ kind: "note",
120
+ title: content.slice(0, 80),
121
+ uri: null,
122
+ };
123
+ }
124
+
125
+ // ─── ingestFile ─────────────────────────────────────────────────────────────
126
+
127
+ export async function ingestFile(
128
+ path: string,
129
+ ctx: ExtensionContext | null,
130
+ opts: IngestOptions = {},
131
+ ): Promise<IngestResult> {
132
+ const abs = isAbsolute(path) ? path : resolve(process.cwd(), path);
133
+ if (!existsSync(abs)) {
134
+ throw new Error(`File not found: ${abs}`);
135
+ }
136
+ const stat = statSync(abs);
137
+ if (!stat.isFile()) {
138
+ throw new Error(`Not a file: ${abs}`);
139
+ }
140
+
141
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
142
+ const raw = readFileSync(abs, "utf-8");
143
+ const content = truncate(raw, maxBytes);
144
+ const title = basename(abs);
145
+
146
+ const created = createMemorySource({
147
+ kind: "file",
148
+ uri: abs,
149
+ title,
150
+ content,
151
+ scope: opts.scope,
152
+ tags: opts.tags,
153
+ });
154
+ if (!created) return { ...sourceCreateFailure("file"), uri: abs, title };
155
+
156
+ const extracted = created.duplicate
157
+ ? []
158
+ : await maybeExtract(ctx, { kind: "file", id: created.id }, content, opts);
159
+
160
+ return {
161
+ sourceId: created.id,
162
+ duplicate: created.duplicate,
163
+ extracted,
164
+ kind: "file",
165
+ title,
166
+ uri: abs,
167
+ };
168
+ }
169
+
170
+ // ─── ingestUrl ──────────────────────────────────────────────────────────────
171
+
172
+ export async function ingestUrl(
173
+ url: string,
174
+ ctx: ExtensionContext | null,
175
+ opts: IngestOptions = {},
176
+ ): Promise<IngestResult> {
177
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
178
+ let body: string;
179
+ let title: string | null = null;
180
+ try {
181
+ const res = await fetch(url, { redirect: "follow" });
182
+ if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
183
+ body = await res.text();
184
+ const titleMatch = body.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
185
+ if (titleMatch) title = titleMatch[1].trim().slice(0, 200);
186
+ } catch (err) {
187
+ throw new Error(`Fetch failed for ${url}: ${(err as Error).message}`);
188
+ }
189
+
190
+ const content = truncate(stripHtml(body), maxBytes);
191
+ if (!content.trim()) {
192
+ throw new Error(`URL produced empty content: ${url}`);
193
+ }
194
+
195
+ const created = createMemorySource({
196
+ kind: "url",
197
+ uri: url,
198
+ title: title ?? url,
199
+ content,
200
+ scope: opts.scope,
201
+ tags: opts.tags,
202
+ });
203
+ if (!created) return { ...sourceCreateFailure("url"), uri: url, title };
204
+
205
+ const extracted = created.duplicate
206
+ ? []
207
+ : await maybeExtract(ctx, { kind: "url", id: created.id }, content, opts);
208
+
209
+ return {
210
+ sourceId: created.id,
211
+ duplicate: created.duplicate,
212
+ extracted,
213
+ kind: "url",
214
+ title: title ?? url,
215
+ uri: url,
216
+ };
217
+ }
218
+
219
+ function stripHtml(html: string): string {
220
+ return html
221
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
222
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
223
+ .replace(/<!--[\s\S]*?-->/g, " ")
224
+ .replace(/<[^>]+>/g, " ")
225
+ .replace(/\s+/g, " ")
226
+ .trim();
227
+ }
228
+
229
+ // ─── ingestArtifact ─────────────────────────────────────────────────────────
230
+
231
+ /**
232
+ * Ingest a named artifact from a milestone directory (e.g. LEARNINGS,
233
+ * SUMMARY, CONTEXT). Resolves through `resolveMilestoneFile` so worktree
234
+ * layouts are handled correctly.
235
+ */
236
+ export async function ingestArtifact(
237
+ basePath: string,
238
+ milestoneId: string,
239
+ artifactType: string,
240
+ ctx: ExtensionContext | null,
241
+ opts: IngestOptions = {},
242
+ ): Promise<IngestResult> {
243
+ const file = resolveMilestoneFile(basePath, milestoneId, artifactType);
244
+ if (!file || !existsSync(file)) {
245
+ throw new Error(`Artifact not found: ${milestoneId}-${artifactType}.md`);
246
+ }
247
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
248
+ const content = truncate(readFileSync(file, "utf-8"), maxBytes);
249
+ const title = `${milestoneId}-${artifactType}`;
250
+ const created = createMemorySource({
251
+ kind: "artifact",
252
+ uri: file,
253
+ title,
254
+ content,
255
+ scope: opts.scope,
256
+ tags: [...(opts.tags ?? []), milestoneId, artifactType.toLowerCase()],
257
+ });
258
+ if (!created) return { ...sourceCreateFailure("artifact"), uri: file, title };
259
+
260
+ const extracted = created.duplicate
261
+ ? []
262
+ : await maybeExtract(ctx, { kind: "artifact", id: created.id }, content, opts);
263
+
264
+ return {
265
+ sourceId: created.id,
266
+ duplicate: created.duplicate,
267
+ extracted,
268
+ kind: "artifact",
269
+ title,
270
+ uri: file,
271
+ };
272
+ }
273
+
274
+ // ─── Helpers ────────────────────────────────────────────────────────────────
275
+
276
+ export function summarizeIngest(result: IngestResult): string {
277
+ if (!result.sourceId) return "Ingest failed: could not persist source.";
278
+ const status = result.duplicate ? "duplicate (content_hash match)" : "new source";
279
+ const extracted = result.extracted.length === 0
280
+ ? "no memories extracted"
281
+ : `${result.extracted.length} memor${result.extracted.length === 1 ? "y" : "ies"} applied`;
282
+ const label = result.title ? ` "${result.title}"` : "";
283
+ return `Ingested ${result.kind}${label} as ${result.sourceId} (${status}, ${extracted}).`;
284
+ }
285
+
286
+ export type { MemorySource };