omegon 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/.gitattributes +3 -0
  2. package/AGENTS.md +16 -0
  3. package/LICENSE +15 -0
  4. package/README.md +289 -0
  5. package/bin/pi.mjs +30 -0
  6. package/extensions/00-secrets/index.ts +1126 -0
  7. package/extensions/01-auth/auth.ts +401 -0
  8. package/extensions/01-auth/index.ts +289 -0
  9. package/extensions/auto-compact.ts +42 -0
  10. package/extensions/bootstrap/deps.ts +291 -0
  11. package/extensions/bootstrap/index.ts +811 -0
  12. package/extensions/chronos/chronos.sh +487 -0
  13. package/extensions/chronos/index.ts +148 -0
  14. package/extensions/cleave/assessment.ts +754 -0
  15. package/extensions/cleave/bridge.ts +31 -0
  16. package/extensions/cleave/conflicts.ts +250 -0
  17. package/extensions/cleave/dispatcher.ts +808 -0
  18. package/extensions/cleave/guardrails.ts +426 -0
  19. package/extensions/cleave/index.ts +3121 -0
  20. package/extensions/cleave/lifecycle-emitter.ts +20 -0
  21. package/extensions/cleave/openspec.ts +811 -0
  22. package/extensions/cleave/planner.ts +260 -0
  23. package/extensions/cleave/review.ts +579 -0
  24. package/extensions/cleave/skills.ts +355 -0
  25. package/extensions/cleave/types.ts +261 -0
  26. package/extensions/cleave/workspace.ts +861 -0
  27. package/extensions/cleave/worktree.ts +243 -0
  28. package/extensions/core-renderers.ts +253 -0
  29. package/extensions/dashboard/context-gauge.ts +58 -0
  30. package/extensions/dashboard/file-watch.ts +14 -0
  31. package/extensions/dashboard/footer.ts +1145 -0
  32. package/extensions/dashboard/git.ts +185 -0
  33. package/extensions/dashboard/index.ts +478 -0
  34. package/extensions/dashboard/memory-audit.ts +34 -0
  35. package/extensions/dashboard/overlay-data.ts +705 -0
  36. package/extensions/dashboard/overlay.ts +365 -0
  37. package/extensions/dashboard/render-utils.ts +54 -0
  38. package/extensions/dashboard/types.ts +191 -0
  39. package/extensions/dashboard/uri-helper.ts +45 -0
  40. package/extensions/debug.ts +69 -0
  41. package/extensions/defaults.ts +282 -0
  42. package/extensions/design-tree/dashboard-state.ts +161 -0
  43. package/extensions/design-tree/design-card.ts +362 -0
  44. package/extensions/design-tree/index.ts +2130 -0
  45. package/extensions/design-tree/lifecycle-emitter.ts +41 -0
  46. package/extensions/design-tree/tree.ts +1607 -0
  47. package/extensions/design-tree/types.ts +163 -0
  48. package/extensions/distill.ts +127 -0
  49. package/extensions/effort/index.ts +395 -0
  50. package/extensions/effort/tiers.ts +146 -0
  51. package/extensions/effort/types.ts +105 -0
  52. package/extensions/lib/git-state.ts +227 -0
  53. package/extensions/lib/local-models.ts +157 -0
  54. package/extensions/lib/model-preferences.ts +51 -0
  55. package/extensions/lib/model-routing.ts +720 -0
  56. package/extensions/lib/operator-fallback.ts +205 -0
  57. package/extensions/lib/operator-profile.ts +360 -0
  58. package/extensions/lib/slash-command-bridge.ts +253 -0
  59. package/extensions/lib/typebox-helpers.ts +16 -0
  60. package/extensions/local-inference/index.ts +727 -0
  61. package/extensions/mcp-bridge/README.md +220 -0
  62. package/extensions/mcp-bridge/index.ts +951 -0
  63. package/extensions/mcp-bridge/lib.ts +365 -0
  64. package/extensions/mcp-bridge/mcp.json +3 -0
  65. package/extensions/mcp-bridge/package.json +11 -0
  66. package/extensions/model-budget.ts +752 -0
  67. package/extensions/offline-driver.ts +403 -0
  68. package/extensions/openspec/archive-gate.ts +164 -0
  69. package/extensions/openspec/branch-cleanup.ts +64 -0
  70. package/extensions/openspec/dashboard-state.ts +50 -0
  71. package/extensions/openspec/index.ts +1917 -0
  72. package/extensions/openspec/lifecycle-emitter.ts +65 -0
  73. package/extensions/openspec/lifecycle-files.ts +70 -0
  74. package/extensions/openspec/lifecycle.ts +50 -0
  75. package/extensions/openspec/reconcile.ts +187 -0
  76. package/extensions/openspec/spec.ts +1385 -0
  77. package/extensions/openspec/types.ts +98 -0
  78. package/extensions/project-memory/DESIGN-global-mind.md +198 -0
  79. package/extensions/project-memory/README.md +202 -0
  80. package/extensions/project-memory/api-types.ts +382 -0
  81. package/extensions/project-memory/compaction-policy.ts +29 -0
  82. package/extensions/project-memory/core.ts +164 -0
  83. package/extensions/project-memory/embeddings.ts +230 -0
  84. package/extensions/project-memory/extraction-v2.ts +861 -0
  85. package/extensions/project-memory/factstore.ts +2177 -0
  86. package/extensions/project-memory/index.ts +3459 -0
  87. package/extensions/project-memory/injection-metrics.ts +91 -0
  88. package/extensions/project-memory/jsonl-io.ts +12 -0
  89. package/extensions/project-memory/lifecycle.ts +331 -0
  90. package/extensions/project-memory/migration.ts +293 -0
  91. package/extensions/project-memory/package.json +9 -0
  92. package/extensions/project-memory/sci-renderers.ts +7 -0
  93. package/extensions/project-memory/template.ts +103 -0
  94. package/extensions/project-memory/triggers.ts +52 -0
  95. package/extensions/project-memory/types.ts +102 -0
  96. package/extensions/render/composition/fonts/Inter-Bold.ttf +0 -0
  97. package/extensions/render/composition/fonts/Inter-Regular.ttf +0 -0
  98. package/extensions/render/composition/fonts/Tomorrow-Bold.ttf +0 -0
  99. package/extensions/render/composition/fonts/Tomorrow-Regular.ttf +0 -0
  100. package/extensions/render/composition/package-lock.json +534 -0
  101. package/extensions/render/composition/package.json +22 -0
  102. package/extensions/render/composition/render.mjs +246 -0
  103. package/extensions/render/composition/test-comp.tsx +87 -0
  104. package/extensions/render/composition/types.ts +24 -0
  105. package/extensions/render/excalidraw/UPSTREAM.md +81 -0
  106. package/extensions/render/excalidraw/elements.ts +764 -0
  107. package/extensions/render/excalidraw/index.ts +66 -0
  108. package/extensions/render/excalidraw/types.ts +223 -0
  109. package/extensions/render/excalidraw-renderer/pyproject.toml +8 -0
  110. package/extensions/render/excalidraw-renderer/render_excalidraw.py +182 -0
  111. package/extensions/render/excalidraw-renderer/render_template.html +59 -0
  112. package/extensions/render/index.ts +830 -0
  113. package/extensions/render/native-diagrams/index.ts +57 -0
  114. package/extensions/render/native-diagrams/motifs.ts +542 -0
  115. package/extensions/render/native-diagrams/raster.ts +8 -0
  116. package/extensions/render/native-diagrams/scene.ts +75 -0
  117. package/extensions/render/native-diagrams/spec.ts +204 -0
  118. package/extensions/render/native-diagrams/svg.ts +116 -0
  119. package/extensions/sci-ui.ts +304 -0
  120. package/extensions/session-log.ts +174 -0
  121. package/extensions/shared-state.ts +146 -0
  122. package/extensions/spinner-verbs.ts +91 -0
  123. package/extensions/style.ts +281 -0
  124. package/extensions/terminal-title.ts +191 -0
  125. package/extensions/tool-profile/index.ts +291 -0
  126. package/extensions/tool-profile/profiles.ts +290 -0
  127. package/extensions/types.d.ts +9 -0
  128. package/extensions/vault/index.ts +185 -0
  129. package/extensions/version-check.ts +90 -0
  130. package/extensions/view/index.ts +859 -0
  131. package/extensions/view/uri-resolver.ts +148 -0
  132. package/extensions/web-search/index.ts +182 -0
  133. package/extensions/web-search/providers.ts +121 -0
  134. package/extensions/web-ui/index.ts +110 -0
  135. package/extensions/web-ui/server.ts +265 -0
  136. package/extensions/web-ui/state.ts +462 -0
  137. package/extensions/web-ui/static/index.html +145 -0
  138. package/extensions/web-ui/types.ts +284 -0
  139. package/package.json +76 -0
  140. package/prompts/init.md +75 -0
  141. package/prompts/new-repo.md +54 -0
  142. package/prompts/oci-login.md +56 -0
  143. package/prompts/status.md +50 -0
  144. package/settings.json +4 -0
  145. package/skills/cleave/SKILL.md +218 -0
  146. package/skills/git/SKILL.md +209 -0
  147. package/skills/git/_reference/ci-validation.md +204 -0
  148. package/skills/oci/SKILL.md +338 -0
  149. package/skills/openspec/SKILL.md +346 -0
  150. package/skills/pi-extensions/SKILL.md +191 -0
  151. package/skills/pi-tui/SKILL.md +517 -0
  152. package/skills/python/SKILL.md +189 -0
  153. package/skills/rust/SKILL.md +268 -0
  154. package/skills/security/SKILL.md +206 -0
  155. package/skills/style/SKILL.md +264 -0
  156. package/skills/typescript/SKILL.md +225 -0
  157. package/skills/vault/SKILL.md +102 -0
  158. package/themes/alpharius-legacy.json +85 -0
  159. package/themes/alpharius.conf +59 -0
  160. package/themes/alpharius.json +88 -0
@@ -0,0 +1,861 @@
1
+ /**
2
+ * Project Memory — Extraction v2
3
+ *
4
+ * Updated extraction for SQLite-backed fact store.
5
+ * The extraction agent outputs JSONL actions instead of rewriting a markdown file.
6
+ *
7
+ * Action types:
8
+ * observe — "I see this fact in the conversation" (reinforces or adds)
9
+ * reinforce — "This existing fact is still true" (by ID)
10
+ * supersede — "This new fact replaces that old one" (by ID + new content)
11
+ * archive — "This fact appears stale/wrong" (by ID)
12
+ * connect — "These two facts are related" (global extraction only)
13
+ */
14
+
15
+ import { spawn, type ChildProcess } from "node:child_process";
16
+ import type { MemoryConfig } from "./types.ts";
17
+ import type { Fact, Edge } from "./factstore.ts";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Shared subprocess runner
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /** Track the currently running extraction process for cancellation */
24
+ let activeProc: ChildProcess | null = null;
25
+
26
+ /** Track all spawned processes for cleanup on module unload */
27
+ const allProcs = new Set<ChildProcess>();
28
+
29
+ /** Track the active direct-HTTP extraction AbortController for cancellation */
30
+ let activeDirectAbort: AbortController | null = null;
31
+
32
+ function killProc(proc: ChildProcess): void {
33
+ try {
34
+ if (proc.pid) process.kill(-proc.pid, "SIGTERM");
35
+ } catch {
36
+ try { proc.kill("SIGTERM"); } catch { /* already dead */ }
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Kill the active extraction — subprocess OR direct HTTP fetch.
42
+ * Returns true if something was killed/aborted.
43
+ */
44
+ export function killActiveExtraction(): boolean {
45
+ let killed = false;
46
+ if (activeProc) {
47
+ killProc(activeProc);
48
+ activeProc = null;
49
+ killed = true;
50
+ }
51
+ if (activeDirectAbort) {
52
+ activeDirectAbort.abort();
53
+ activeDirectAbort = null;
54
+ killed = true;
55
+ }
56
+ return killed;
57
+ }
58
+
59
+ /**
60
+ * Kill ALL tracked subprocesses AND abort any direct HTTP extraction.
61
+ * Use during shutdown/reload to prevent orphaned processes and hanging fetches.
62
+ */
63
+ export function killAllSubprocesses(): void {
64
+ for (const proc of allProcs) {
65
+ killProc(proc);
66
+ }
67
+ allProcs.clear();
68
+ activeProc = null;
69
+ if (activeDirectAbort) {
70
+ activeDirectAbort.abort();
71
+ activeDirectAbort = null;
72
+ }
73
+ }
74
+
75
+ /** Check if an extraction is currently in progress */
76
+ export function isExtractionRunning(): boolean {
77
+ return activeProc !== null || activeDirectAbort !== null;
78
+ }
79
+
80
+ /**
81
+ * Spawn a pi subprocess with a system prompt and user message.
82
+ * Returns the raw stdout output. Handles timeout, cleanup, code fence stripping.
83
+ */
84
+ function spawnExtraction(opts: {
85
+ cwd: string;
86
+ model: string;
87
+ systemPrompt: string;
88
+ userMessage: string;
89
+ timeout: number;
90
+ label: string;
91
+ }): Promise<string> {
92
+ return new Promise<string>((resolve, reject) => {
93
+ if (activeProc) {
94
+ reject(new Error(`${opts.label}: extraction already in progress`));
95
+ return;
96
+ }
97
+
98
+ const args = [
99
+ "--model", opts.model,
100
+ "--no-session", "--no-tools", "--no-extensions",
101
+ "--no-skills", "--no-themes", "--thinking", "off",
102
+ "--system-prompt", opts.systemPrompt,
103
+ "-p", opts.userMessage,
104
+ ];
105
+
106
+ const proc = spawn("pi", args, {
107
+ cwd: opts.cwd,
108
+ stdio: ["ignore", "pipe", "pipe"],
109
+ // Detach into new session so child has no controlling terminal.
110
+ // Prevents child pi from opening /dev/tty and setting kitty keyboard
111
+ // protocol, which corrupts parent terminal state if child is killed.
112
+ detached: true,
113
+ env: { ...process.env, TERM: "dumb" },
114
+ });
115
+ activeProc = proc;
116
+ allProcs.add(proc);
117
+
118
+ let stdout = "";
119
+ let stderr = "";
120
+
121
+ proc.stdout.on("data", (d: Buffer) => { stdout += d.toString(); });
122
+ proc.stderr.on("data", (d: Buffer) => { stderr += d.toString(); });
123
+
124
+ let escalationTimer: ReturnType<typeof setTimeout> | null = null;
125
+ const killThisProc = (signal: NodeJS.Signals) => {
126
+ try {
127
+ if (proc.pid) process.kill(-proc.pid, signal);
128
+ } catch {
129
+ try { proc.kill(signal); } catch { /* already dead */ }
130
+ }
131
+ };
132
+ const timeoutHandle = setTimeout(() => {
133
+ killThisProc("SIGTERM");
134
+ escalationTimer = setTimeout(() => {
135
+ if (!proc.killed) killThisProc("SIGKILL");
136
+ }, 5000);
137
+ reject(new Error(`${opts.label} timed out`));
138
+ }, opts.timeout);
139
+
140
+ proc.on("close", (code) => {
141
+ clearTimeout(timeoutHandle);
142
+ if (escalationTimer) clearTimeout(escalationTimer);
143
+ activeProc = null;
144
+ allProcs.delete(proc);
145
+
146
+ const output = stdout.trim();
147
+ if (code === 0 && output) {
148
+ // Strip code fences if the model wraps output
149
+ const cleaned = output
150
+ .replace(/^```(?:jsonl?|json)?\n?/, "")
151
+ .replace(/\n?```\s*$/, "");
152
+ resolve(cleaned);
153
+ } else if (code === 0 && !output) {
154
+ resolve("");
155
+ } else {
156
+ reject(new Error(`${opts.label} failed (exit ${code}): ${stderr.slice(0, 500)}`));
157
+ }
158
+ });
159
+
160
+ proc.on("error", (err) => {
161
+ clearTimeout(timeoutHandle);
162
+ activeProc = null;
163
+ allProcs.delete(proc);
164
+ reject(err);
165
+ });
166
+ });
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Phase 1: Project extraction
171
+ // ---------------------------------------------------------------------------
172
+
173
+ function buildExtractionPrompt(maxLines: number): string {
174
+ return `You are a project memory curator. You receive:
175
+ 1. Current active facts (with IDs) from the project's memory database
176
+ 2. Recent conversation context from a coding session
177
+
178
+ Your job: output JSONL (one JSON object per line) describing what you observed.
179
+
180
+ ACTION TYPES:
181
+
182
+ {"type":"observe","section":"Architecture","content":"The project uses SQLite for storage"}
183
+ → You saw evidence of this fact in the conversation. If it already exists, it gets reinforced.
184
+ If it's new, it gets added.
185
+
186
+ {"type":"reinforce","id":"abc123"}
187
+ → An existing fact (by ID) is confirmed still true by the conversation context.
188
+
189
+ {"type":"supersede","id":"abc123","section":"Architecture","content":"The project migrated from SQLite to PostgreSQL"}
190
+ → A specific existing fact is wrong/outdated. Provide the replacement.
191
+
192
+ {"type":"archive","id":"abc123"}
193
+ → A specific existing fact is clearly wrong, obsolete, or no longer relevant.
194
+
195
+ RULES:
196
+ - Output ONLY valid JSONL. One JSON object per line. No commentary, no explanation.
197
+ - Focus on DURABLE technical facts — architecture, decisions, constraints, patterns, bugs.
198
+ - DO NOT output facts about transient details (debugging steps, file contents, command output).
199
+ - DO NOT output facts that are obvious from reading code (basic imports, boilerplate).
200
+ - Prefer "observe" for new facts. Use "supersede" only when you can identify the specific old fact being replaced.
201
+ - Use "reinforce" when the conversation confirms an existing fact without changing it.
202
+ - Use "archive" sparingly — only when a fact is clearly contradicted.
203
+ - Keep fact content self-contained and concise (one line, no bullet prefix).
204
+ - Valid sections: Architecture, Decisions, Constraints, Known Issues, Patterns & Conventions, Specs
205
+
206
+ FACT DENSITY — POINTERS OVER CONTENT:
207
+ - Facts are injected into every agent turn. Every token counts.
208
+ - For implementation details (formulas, method signatures, schemas, config shapes):
209
+ store a POINTER fact — name the concept + reference the file path. The agent can
210
+ read the file when it actually needs the details.
211
+ GOOD: "project-memory pressure system: 3 tiers (40%/65%/85%). See extensions/project-memory/pressure.ts"
212
+ BAD: "project-memory degeneracy pressure uses computeDegeneracyPressure(pct, onset, warning, k=3) with formula (e^(k*t)-1)/(e^k-1) where t=..."
213
+ - INLINE the content only when the fact is frequently needed and short enough that a
214
+ file read would waste more tokens than the inline content (e.g., env var names,
215
+ CLI flags, version numbers, short constraints).
216
+ - When in doubt: if the fact exceeds ~40 words, it probably belongs as a pointer.
217
+
218
+ TARGET: aim for at most ${maxLines} active facts total. If the memory is near capacity, use "archive" on the least relevant facts to make room.
219
+
220
+ If the conversation contains nothing worth remembering, output nothing.`;
221
+ }
222
+
223
+ /**
224
+ * Format current facts for the extraction agent's input.
225
+ * Shows facts with IDs so the agent can reference them.
226
+ */
227
+ export function formatFactsForExtraction(facts: Fact[]): string {
228
+ if (facts.length === 0) return "(no existing facts)";
229
+
230
+ const lines: string[] = [];
231
+ let currentSection = "";
232
+
233
+ for (const fact of facts) {
234
+ if (fact.section !== currentSection) {
235
+ currentSection = fact.section;
236
+ lines.push(`\n## ${currentSection}`);
237
+ }
238
+ const date = fact.created_at.split("T")[0];
239
+ const rc = fact.reinforcement_count;
240
+ lines.push(`[${fact.id}] ${fact.content} (${date}, reinforced ${rc}x)`);
241
+ }
242
+
243
+ return lines.join("\n");
244
+ }
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // Direct Ollama extraction (no pi subprocess overhead)
248
+ // ---------------------------------------------------------------------------
249
+
250
+ /**
251
+ * Known cloud model prefixes. If a model starts with any of these, it's cloud.
252
+ * Everything else is assumed local (Ollama).
253
+ *
254
+ * This is an allowlist approach — new cloud providers must be added here.
255
+ * The alternative (detecting local by "name:tag" pattern) is too fragile
256
+ * since Ollama accepts bare names without tags.
257
+ */
258
+ const CLOUD_MODEL_PREFIXES = [
259
+ "claude-", // Anthropic
260
+ "gpt-", // OpenAI
261
+ "o1-", "o3-", "o4-", // OpenAI reasoning
262
+ "gemini-", // Google
263
+ "mistral-", // Mistral cloud (not devstral which is local)
264
+ "command-", // Cohere
265
+ ];
266
+
267
+ /**
268
+ * Check if extraction model is a local Ollama model.
269
+ * Uses an explicit cloud-prefix allowlist. Models with a "/" are assumed
270
+ * to be provider-qualified cloud models (e.g., "openai/gpt-4").
271
+ */
272
+ function isLocalModel(model: string): boolean {
273
+ if (model.includes("/")) return false;
274
+ for (const prefix of CLOUD_MODEL_PREFIXES) {
275
+ if (model.startsWith(prefix)) return false;
276
+ }
277
+ return true;
278
+ }
279
+
280
+ /** Fallback cloud model when local extraction fails and Ollama is unreachable. */
281
+ const CLOUD_FALLBACK_MODEL = "claude-sonnet-4-6";
282
+
283
+ /**
284
+ * Run extraction directly via Ollama HTTP API.
285
+ * ~10x faster than spawning a pi subprocess — no process startup overhead.
286
+ * Returns null if Ollama is unreachable (caller should fall back to subprocess).
287
+ */
288
+ async function runExtractionDirect(
289
+ systemPrompt: string,
290
+ userMessage: string,
291
+ config: MemoryConfig,
292
+ opts?: { ollamaUrl?: string },
293
+ ): Promise<string | null> {
294
+ const baseUrl = opts?.ollamaUrl ?? process.env.LOCAL_INFERENCE_URL ?? "http://localhost:11434";
295
+ const timeout = config.extractionTimeout;
296
+
297
+ // Create an AbortController that can be killed externally via killActiveExtraction().
298
+ // Combines our controller with a timeout signal so either trigger aborts the fetch.
299
+ const controller = new AbortController();
300
+ activeDirectAbort = controller;
301
+
302
+ try {
303
+ const resp = await fetch(`${baseUrl}/api/chat`, {
304
+ method: "POST",
305
+ headers: { "Content-Type": "application/json" },
306
+ body: JSON.stringify({
307
+ model: config.extractionModel,
308
+ stream: false,
309
+ options: {
310
+ temperature: 0.2,
311
+ num_predict: 2048,
312
+ num_ctx: 32768,
313
+ },
314
+ messages: [
315
+ { role: "system", content: systemPrompt },
316
+ { role: "user", content: userMessage },
317
+ ],
318
+ }),
319
+ signal: typeof AbortSignal.any === "function"
320
+ ? AbortSignal.any([controller.signal, AbortSignal.timeout(timeout)])
321
+ : controller.signal, // Node <20.3: external abort works, timeout relies on Ollama's own
322
+ });
323
+
324
+ if (!resp.ok) return null;
325
+
326
+ const data = await resp.json() as { message?: { content?: string } };
327
+ const raw = data.message?.content?.trim();
328
+ if (!raw) return null;
329
+
330
+ // Strip code fences and <think> blocks from reasoning models
331
+ return raw
332
+ .replace(/^```(?:jsonl?|json)?\n?/, "")
333
+ .replace(/\n?```\s*$/, "")
334
+ .replace(/<think>[\s\S]*?<\/think>\s*/g, "")
335
+ .trim();
336
+ } catch {
337
+ return null;
338
+ } finally {
339
+ if (activeDirectAbort === controller) {
340
+ activeDirectAbort = null;
341
+ }
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Run project extraction (Phase 1).
347
+ * Returns raw JSONL output from the extraction agent.
348
+ *
349
+ * When extractionModel is a local model, talks directly to Ollama HTTP API
350
+ * (no subprocess overhead). Falls back to pi subprocess for cloud models
351
+ * or if Ollama is unreachable.
352
+ */
353
+ export async function runExtractionV2(
354
+ cwd: string,
355
+ currentFacts: Fact[],
356
+ recentConversation: string,
357
+ config: MemoryConfig,
358
+ ): Promise<string> {
359
+ const prompt = buildExtractionPrompt(config.maxLines);
360
+ const factsFormatted = formatFactsForExtraction(currentFacts);
361
+
362
+ const userMessage = [
363
+ "Current active facts:\n",
364
+ factsFormatted,
365
+ "\n\n---\n\nRecent conversation:\n\n",
366
+ recentConversation,
367
+ "\n\nOutput JSONL actions based on what you observe.",
368
+ ].join("");
369
+
370
+ // Try direct Ollama path for local models (bypasses pi subprocess entirely)
371
+ if (isLocalModel(config.extractionModel)) {
372
+ const result = await runExtractionDirect(prompt, userMessage, config);
373
+ if (result !== null) return result;
374
+ // Ollama unreachable — fall through to subprocess with cloud fallback
375
+ }
376
+
377
+ return spawnExtraction({
378
+ cwd,
379
+ model: isLocalModel(config.extractionModel) ? CLOUD_FALLBACK_MODEL : config.extractionModel,
380
+ systemPrompt: prompt,
381
+ userMessage,
382
+ timeout: config.extractionTimeout,
383
+ label: "Project extraction",
384
+ });
385
+ }
386
+
387
+ // ---------------------------------------------------------------------------
388
+ // Phase 2: Global extraction
389
+ // ---------------------------------------------------------------------------
390
+
391
+ function buildGlobalExtractionPrompt(): string {
392
+ return `You are a cross-project knowledge synthesizer. You receive:
393
+ 1. New facts just extracted from a project-scoped coding session
394
+ 2. Existing facts in the global knowledge base (with IDs)
395
+ 3. Existing connections (edges) between global facts
396
+
397
+ Your job: identify generalizable knowledge and meaningful connections between facts.
398
+
399
+ ACTION TYPES:
400
+
401
+ {"type":"observe","section":"Architecture","content":"Embedded DBs preferred over client-server for CLI tooling"}
402
+ → A new fact that generalizes beyond its source project. Rewrite to be project-agnostic.
403
+
404
+ {"type":"reinforce","id":"abc123"}
405
+ → An existing global fact is confirmed by this project's evidence.
406
+
407
+ {"type":"connect","source":"<fact_id>","target":"<fact_id>","relation":"runs_on","description":"k8s deployment depends on host OS kernel features"}
408
+ → Two GLOBAL facts are meaningfully related. Both source and target must be IDs from
409
+ the EXISTING GLOBAL FACTS section — not from the new project facts.
410
+ First promote a project fact via "observe", then connect the promoted global copy.
411
+ The relation is a short verb phrase describing the directional relationship.
412
+ Common patterns: runs_on, depends_on, motivated_by, contradicts, enables,
413
+ generalizes, instance_of, requires, conflicts_with, replaces, preceded_by
414
+ But use whatever verb phrase best captures the relationship.
415
+
416
+ {"type":"supersede","id":"abc123","section":"Decisions","content":"Updated understanding..."}
417
+ → An existing global fact is outdated. Provide the replacement.
418
+
419
+ {"type":"archive","id":"abc123"}
420
+ → An existing global fact is clearly wrong or obsolete.
421
+
422
+ RULES:
423
+ - Output ONLY valid JSONL. One JSON object per line.
424
+ - Only promote facts that would be useful across MULTIPLE projects.
425
+ - Rewrite promoted facts to remove project-specific names, paths, and details.
426
+ - Connections must reference GLOBAL fact IDs only (from "EXISTING GLOBAL FACTS" section).
427
+ To connect a new project fact, first promote it with "observe", then in the NEXT
428
+ extraction cycle it will have a global ID you can reference.
429
+ - Connections should represent genuine analytical insight, not surface keyword overlap.
430
+ - Prefer fewer, high-quality connections over many weak ones.
431
+ - A connection between facts in different sections is more valuable than within the same section.
432
+ - If the new project facts don't contain anything generalizable, output nothing.
433
+
434
+ FACT DENSITY — keep facts concise (~40 words max). For implementation details,
435
+ reference file paths instead of inlining formulas/schemas/signatures. Global facts
436
+ especially must be lean since they're injected across ALL projects.`;
437
+ }
438
+
439
+ /**
440
+ * Format new project facts + existing global facts + edges for global extraction.
441
+ */
442
+ export function formatGlobalExtractionInput(
443
+ newProjectFacts: Fact[],
444
+ globalFacts: Fact[],
445
+ globalEdges: Edge[],
446
+ ): string {
447
+ const lines: string[] = [];
448
+
449
+ lines.push("=== NEW PROJECT FACTS (candidates for promotion — these IDs are project-scoped, NOT referenceable in connect actions) ===");
450
+ if (newProjectFacts.length === 0) {
451
+ lines.push("(none)");
452
+ } else {
453
+ for (const f of newProjectFacts) {
454
+ lines.push(`(${f.section}) ${f.content}`);
455
+ }
456
+ }
457
+
458
+ lines.push("\n=== EXISTING GLOBAL FACTS (use these IDs in connect actions) ===");
459
+ if (globalFacts.length === 0) {
460
+ lines.push("(empty — this is the first global extraction)");
461
+ } else {
462
+ let currentSection = "";
463
+ for (const f of globalFacts) {
464
+ if (f.section !== currentSection) {
465
+ currentSection = f.section;
466
+ lines.push(`\n## ${currentSection}`);
467
+ }
468
+ const rc = f.reinforcement_count;
469
+ lines.push(`[${f.id}] ${f.content} (reinforced ${rc}x)`);
470
+ }
471
+ }
472
+
473
+ if (globalEdges.length > 0) {
474
+ lines.push("\n=== EXISTING CONNECTIONS ===");
475
+ for (const e of globalEdges) {
476
+ lines.push(`[${e.source_fact_id}] --${e.relation}--> [${e.target_fact_id}]: ${e.description}`);
477
+ }
478
+ }
479
+
480
+ return lines.join("\n");
481
+ }
482
+
483
+ /**
484
+ * Run global extraction (Phase 2).
485
+ * Only called when Phase 1 produced new facts.
486
+ * Uses direct Ollama path for local models, falls back to pi subprocess.
487
+ */
488
+ export async function runGlobalExtraction(
489
+ cwd: string,
490
+ newProjectFacts: Fact[],
491
+ globalFacts: Fact[],
492
+ globalEdges: Edge[],
493
+ config: MemoryConfig,
494
+ ): Promise<string> {
495
+ const input = formatGlobalExtractionInput(newProjectFacts, globalFacts, globalEdges);
496
+
497
+ const userMessage = [
498
+ input,
499
+ "\n\nOutput JSONL actions: promote generalizable facts and identify connections between GLOBAL facts.",
500
+ ].join("");
501
+
502
+ const systemPrompt = buildGlobalExtractionPrompt();
503
+
504
+ // Try direct Ollama path for local models
505
+ if (isLocalModel(config.extractionModel)) {
506
+ const result = await runExtractionDirect(systemPrompt, userMessage, config);
507
+ if (result !== null) return result;
508
+ }
509
+
510
+ return spawnExtraction({
511
+ cwd,
512
+ model: isLocalModel(config.extractionModel) ? CLOUD_FALLBACK_MODEL : config.extractionModel,
513
+ systemPrompt,
514
+ userMessage,
515
+ timeout: config.extractionTimeout,
516
+ label: "Global extraction",
517
+ });
518
+ }
519
+
520
+ // ---------------------------------------------------------------------------
521
+ // Phase 3: Episode generation
522
+ // ---------------------------------------------------------------------------
523
+
524
+ const EPISODE_PROMPT = `You are a session narrator. You receive the tail of a coding session conversation.
525
+ Your job: produce a JSON object summarizing what happened.
526
+
527
+ Output format (MUST be valid JSON, nothing else):
528
+ {"title":"<Short title, 5-10 words>","narrative":"<2-4 sentence summary: what was the goal, what was accomplished, what decisions were made, what's still open>"}
529
+
530
+ RULES:
531
+ - Title should be specific and descriptive (e.g., "Migrated auth from JWT to OIDC" not "Working on auth")
532
+ - Narrative should capture the ARC: goal → actions → outcome → open threads
533
+ - Focus on decisions and outcomes, not mechanical steps
534
+ - Keep narrative under 300 words
535
+ - Output ONLY the JSON object. No markdown, no commentary.`;
536
+
537
+ export interface EpisodeOutput {
538
+ title: string;
539
+ narrative: string;
540
+ }
541
+
542
+ /**
543
+ * Session telemetry collected during a session — used to build template episodes
544
+ * when all model-based generation fails.
545
+ */
546
+ export interface SessionTelemetry {
547
+ /** ISO date string for the session */
548
+ date: string;
549
+ /** Total tool calls made during the session */
550
+ toolCallCount: number;
551
+ /** Files that were written (via Write tool) */
552
+ filesWritten: string[];
553
+ /** Files that were edited (via Edit tool) */
554
+ filesEdited: string[];
555
+ }
556
+
557
+ /**
558
+ * Generate a session episode via direct Ollama HTTP API call.
559
+ * ~10x faster than spawning a pi subprocess — no process startup overhead.
560
+ * Falls back to subprocess-based generation if Ollama is unreachable.
561
+ */
562
+ export async function generateEpisodeDirect(
563
+ recentConversation: string,
564
+ config: MemoryConfig,
565
+ opts?: { ollamaUrl?: string; model?: string },
566
+ ): Promise<EpisodeOutput | null> {
567
+ const baseUrl = opts?.ollamaUrl ?? process.env.LOCAL_INFERENCE_URL ?? "http://localhost:11434";
568
+ const model = opts?.model ?? process.env.LOCAL_EPISODE_MODEL ?? "qwen3:30b";
569
+ const timeout = Math.min(config.shutdownExtractionTimeout, 10_000);
570
+
571
+ try {
572
+ const resp = await fetch(`${baseUrl}/api/chat`, {
573
+ method: "POST",
574
+ headers: { "Content-Type": "application/json" },
575
+ body: JSON.stringify({
576
+ model,
577
+ stream: false,
578
+ options: { temperature: 0.3, num_predict: 512 },
579
+ messages: [
580
+ { role: "system", content: EPISODE_PROMPT },
581
+ { role: "user", content: `Session conversation:\n\n${recentConversation}\n\nOutput the episode JSON.` },
582
+ ],
583
+ }),
584
+ signal: AbortSignal.timeout(timeout),
585
+ });
586
+
587
+ if (!resp.ok) return null;
588
+
589
+ const data = await resp.json() as { message?: { content?: string } };
590
+ const raw = data.message?.content?.trim();
591
+ if (!raw) return null;
592
+
593
+ const cleaned = raw
594
+ .replace(/^```(?:json)?\n?/, "")
595
+ .replace(/\n?```\s*$/, "")
596
+ // Strip <think>...</think> blocks from reasoning models
597
+ .replace(/<think>[\s\S]*?<\/think>\s*/g, "")
598
+ .trim();
599
+ const parsed = JSON.parse(cleaned);
600
+
601
+ if (parsed.title && parsed.narrative) {
602
+ return { title: parsed.title, narrative: parsed.narrative };
603
+ }
604
+ return null;
605
+ } catch {
606
+ return null;
607
+ }
608
+ }
609
+
610
+ /**
611
+ * Generate a session episode summary from recent conversation.
612
+ * Uses pi subprocess (slower fallback). Prefer generateEpisodeDirect().
613
+ */
614
+ export async function generateEpisode(
615
+ cwd: string,
616
+ recentConversation: string,
617
+ config: MemoryConfig,
618
+ ): Promise<EpisodeOutput | null> {
619
+ try {
620
+ const raw = await spawnExtraction({
621
+ cwd,
622
+ model: config.extractionModel,
623
+ systemPrompt: EPISODE_PROMPT,
624
+ userMessage: `Session conversation:\n\n${recentConversation}\n\nOutput the episode JSON.`,
625
+ timeout: config.shutdownExtractionTimeout,
626
+ label: "Episode generation",
627
+ });
628
+
629
+ if (!raw.trim()) return null;
630
+
631
+ // Strip any markdown code fences
632
+ const cleaned = raw.replace(/^```(?:json)?\n?/, "").replace(/\n?```\s*$/, "").trim();
633
+ const parsed = JSON.parse(cleaned);
634
+
635
+ if (parsed.title && parsed.narrative) {
636
+ return { title: parsed.title, narrative: parsed.narrative };
637
+ }
638
+ return null;
639
+ } catch {
640
+ return null;
641
+ }
642
+ }
643
+
644
+ /**
645
+ * Build a minimum viable episode from raw session telemetry.
646
+ * Zero I/O — assembled deterministically from already-collected data.
647
+ * This is the guaranteed floor: always emitted when every model in the fallback chain fails.
648
+ */
649
+ export function buildTemplateEpisode(telemetry: SessionTelemetry): EpisodeOutput {
650
+ const allModified = [...new Set([...telemetry.filesWritten, ...telemetry.filesEdited])];
651
+
652
+ // Infer topics from file paths (directory names)
653
+ const skipDirs = new Set([".", "..", "src", "lib", "dist", "extensions", "tests"]);
654
+ const topics = new Set<string>();
655
+ for (const f of allModified) {
656
+ const parts = f.replace(/\\/g, "/").split("/");
657
+ for (const p of parts.slice(0, -1)) {
658
+ if (p && !skipDirs.has(p) && !p.startsWith(".")) topics.add(p);
659
+ }
660
+ }
661
+
662
+ const topicStr = topics.size > 0
663
+ ? `Work touched: ${[...topics].slice(0, 4).join(", ")}.`
664
+ : "";
665
+
666
+ const fileList = allModified.length > 0
667
+ ? allModified.slice(0, 5).map(f => f.split("/").pop() ?? f).join(", ") +
668
+ (allModified.length > 5 ? ` (+${allModified.length - 5} more)` : "")
669
+ : "no files modified";
670
+
671
+ const title = allModified.length > 0
672
+ ? `Session ${telemetry.date}: modified ${allModified.length} file${allModified.length !== 1 ? "s" : ""}`
673
+ : `Session ${telemetry.date}`;
674
+
675
+ const narrative =
676
+ `Session on ${telemetry.date} — ${telemetry.toolCallCount} tool calls. ` +
677
+ `Files modified: ${fileList}. ${topicStr}` +
678
+ ` (Template episode — model generation unavailable for this session.)`;
679
+
680
+ return { title, narrative };
681
+ }
682
+
683
+ /**
684
+ * Generate a session episode with a reliability-ordered fallback chain:
685
+ * 1. Cloud primary (config.episodeModel — codex-spark by default)
686
+ * 2. Cloud retribution tier (haiku — fast, cheap, always available)
687
+ * 3. Ollama (direct HTTP — only if user has LOCAL_EPISODE_MODEL configured)
688
+ * 4. Template episode (deterministic, zero I/O) — always succeeds
689
+ *
690
+ * Cloud is first because: (1) it's always available if pi is configured at all,
691
+ * (2) retribution-tier cost is negligible (~$0.0001/call), (3) model quality
692
+ * is substantially better than typical local models for narrative generation.
693
+ * Ollama is tried last as an optional local preference, not a dependency.
694
+ *
695
+ * Step timeouts are taken from config.episodeStepTimeout, capped so the total
696
+ * chain fits within config.shutdownExtractionTimeout.
697
+ */
698
+ export async function generateEpisodeWithFallback(
699
+ recentConversation: string,
700
+ telemetry: SessionTelemetry,
701
+ config: MemoryConfig,
702
+ cwd: string,
703
+ ): Promise<EpisodeOutput> {
704
+ const stepTimeout = Math.min(
705
+ config.episodeStepTimeout,
706
+ Math.floor(config.shutdownExtractionTimeout / 3),
707
+ );
708
+
709
+ if (config.episodeFallbackChain) {
710
+ // Step 1: Cloud primary (episodeModel — codex-spark by default)
711
+ // Always available if the user has a provider configured.
712
+ try {
713
+ const raw = await spawnExtraction({
714
+ cwd,
715
+ model: config.episodeModel,
716
+ systemPrompt: EPISODE_PROMPT,
717
+ userMessage: `Session conversation:\n\n${recentConversation}\n\nOutput the episode JSON.`,
718
+ timeout: stepTimeout,
719
+ label: "Episode generation (primary)",
720
+ });
721
+ if (raw.trim()) {
722
+ const cleaned = raw.replace(/^```(?:json)?\n?/, "").replace(/\n?```\s*$/, "").trim();
723
+ const parsed = JSON.parse(cleaned);
724
+ if (parsed.title && parsed.narrative) return parsed as EpisodeOutput;
725
+ }
726
+ } catch {
727
+ // Fall through
728
+ }
729
+
730
+ // Step 2: Cloud retribution tier (haiku — fast, cheap, independent model)
731
+ try {
732
+ const raw = await spawnExtraction({
733
+ cwd,
734
+ model: "claude-haiku-4-5",
735
+ systemPrompt: EPISODE_PROMPT,
736
+ userMessage: `Session conversation:\n\n${recentConversation}\n\nOutput the episode JSON.`,
737
+ timeout: stepTimeout,
738
+ label: "Episode generation (retribution fallback)",
739
+ });
740
+ if (raw.trim()) {
741
+ const cleaned = raw.replace(/^```(?:json)?\n?/, "").replace(/\n?```\s*$/, "").trim();
742
+ const parsed = JSON.parse(cleaned);
743
+ if (parsed.title && parsed.narrative) return parsed as EpisodeOutput;
744
+ }
745
+ } catch {
746
+ // Fall through
747
+ }
748
+
749
+ // Step 3: Ollama (optional — only meaningful if user has a local model running)
750
+ if (process.env.LOCAL_EPISODE_MODEL || process.env.LOCAL_INFERENCE_URL) {
751
+ try {
752
+ const result = await generateEpisodeDirect(recentConversation, config);
753
+ if (result) return result;
754
+ } catch {
755
+ // Fall through to template
756
+ }
757
+ }
758
+ } else {
759
+ // Chain disabled — try cloud primary only, no Ollama
760
+ try {
761
+ const raw = await spawnExtraction({
762
+ cwd,
763
+ model: config.episodeModel,
764
+ systemPrompt: EPISODE_PROMPT,
765
+ userMessage: `Session conversation:\n\n${recentConversation}\n\nOutput the episode JSON.`,
766
+ timeout: stepTimeout,
767
+ label: "Episode generation",
768
+ });
769
+ if (raw.trim()) {
770
+ const cleaned = raw.replace(/^```(?:json)?\n?/, "").replace(/\n?```\s*$/, "").trim();
771
+ const parsed = JSON.parse(cleaned);
772
+ if (parsed.title && parsed.narrative) return parsed as EpisodeOutput;
773
+ }
774
+ } catch {
775
+ // Fall through
776
+ }
777
+ }
778
+
779
+ // Step 4: Template episode — guaranteed floor, zero I/O
780
+ return buildTemplateEpisode(telemetry);
781
+ }
782
+
783
+ // ---------------------------------------------------------------------------
784
+ // Per-section archival pruning pass
785
+ // ---------------------------------------------------------------------------
786
+
787
+ const SECTION_PRUNING_PROMPT = `You are a memory curator for a project-memory system.
788
+ You will receive a list of facts from a single memory section that has exceeded its size limit.
789
+ Your job: identify facts to archive (remove from active memory) to bring the section under the target count.
790
+
791
+ Rules:
792
+ - Archive duplicates, overly-specific details, outdated implementation notes, and facts that are
793
+ superseded by other facts in the same list.
794
+ - KEEP: architectural decisions, design rationale, critical constraints, patterns that prevent bugs,
795
+ and any fact that is still clearly relevant and has no equivalent in the list.
796
+ - Prefer to archive older, less-reinforced, or more transient facts.
797
+ - Return ONLY a JSON array of fact IDs to archive. Example: ["id1", "id2", "id3"]
798
+ - If unsure whether to archive, keep it.`;
799
+
800
+ /**
801
+ * Run a targeted LLM archival pass over a single section when it exceeds the ceiling.
802
+ * Returns the list of fact IDs recommended for archival.
803
+ */
804
+ export async function runSectionPruningPass(
805
+ section: string,
806
+ facts: Fact[],
807
+ targetCount: number,
808
+ config: MemoryConfig,
809
+ ): Promise<string[]> {
810
+ if (facts.length <= targetCount) return [];
811
+
812
+ const excessCount = facts.length - targetCount;
813
+ const factList = facts.map((f, i) =>
814
+ `${i + 1}. [ID: ${f.id}] [reinforced: ${f.reinforcement_count}x] [age: ${Math.round((Date.now() - new Date(f.created_at).getTime()) / 86400000)}d] ${f.content}`
815
+ ).join("\n");
816
+
817
+ const userMessage = [
818
+ `Section: ${section}`,
819
+ `Current count: ${facts.length} (target: ≤${targetCount}, archive at least ${excessCount})`,
820
+ ``,
821
+ `Facts (sorted by confidence descending — lowest confidence facts are at the bottom):`,
822
+ factList,
823
+ ``,
824
+ `Return a JSON array of fact IDs to archive. Archive at least ${excessCount} to bring the section under ${targetCount + 1}.`,
825
+ ].join("\n");
826
+
827
+ // Try direct Ollama path for local models
828
+ if (isLocalModel(config.extractionModel)) {
829
+ try {
830
+ const raw = await runExtractionDirect(SECTION_PRUNING_PROMPT, userMessage, config);
831
+ if (raw) {
832
+ const cleaned = raw.replace(/^```(?:json)?\n?/, "").replace(/\n?```\s*$/, "").trim();
833
+ const parsed = JSON.parse(cleaned);
834
+ if (Array.isArray(parsed)) return parsed.filter((id: unknown) => typeof id === "string");
835
+ }
836
+ } catch {
837
+ // Fall through to cloud
838
+ }
839
+ }
840
+
841
+ // Cloud fallback: use episodeModel (cloud tier, always available)
842
+ try {
843
+ const raw = await spawnExtraction({
844
+ cwd: process.cwd(),
845
+ model: config.episodeModel,
846
+ systemPrompt: SECTION_PRUNING_PROMPT,
847
+ userMessage,
848
+ timeout: 30_000,
849
+ label: `Section pruning (${section})`,
850
+ });
851
+ if (raw.trim()) {
852
+ const cleaned = raw.replace(/^```(?:json)?\n?/, "").replace(/\n?```\s*$/, "").trim();
853
+ const parsed = JSON.parse(cleaned);
854
+ if (Array.isArray(parsed)) return parsed.filter((id: unknown) => typeof id === "string");
855
+ }
856
+ } catch {
857
+ // Best effort — return empty (no archival) rather than corrupt state
858
+ }
859
+
860
+ return [];
861
+ }