@phi-code-admin/phi-code 0.77.2 → 0.78.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.
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Now powered by sigma-memory package which provides:
5
5
  * - NotesManager: Markdown files management
6
- * - OntologyManager: Knowledge graph with entities and relations
6
+ * - OntologyManager: Knowledge graph with entities and relations
7
7
  * - VectorStore: Embedded vector search (sql.js + local embeddings)
8
8
  *
9
9
  * Features:
@@ -18,13 +18,52 @@
18
18
  * 2. Memory files are stored in ~/.phi/memory/
19
19
  */
20
20
 
21
- import { Type } from "@sinclair/typebox";
22
- import type { ExtensionAPI, ExtensionContext } from "phi-code";
21
+ import { createHash } from "node:crypto";
22
+ import { readFileSync } from "node:fs";
23
23
  import { access } from "node:fs/promises";
24
24
  import { join } from "node:path";
25
- import { readFileSync } from "node:fs";
25
+ import { Type } from "@sinclair/typebox";
26
+ import type { ExtensionAPI, ExtensionContext } from "phi-code";
26
27
  import { SigmaMemory } from "sigma-memory";
27
28
 
29
+ /**
30
+ * Build a unique, human-readable filename for a single memory fact.
31
+ *
32
+ * Combines a kebab-case slug of the first words of the content with a short
33
+ * content hash. This prevents same-day clobber (each fact gets its own file)
34
+ * while staying deterministic: re-writing the exact same fact yields the same
35
+ * name instead of piling up duplicates.
36
+ */
37
+ function buildFactFilename(content: string): string {
38
+ const slug = content
39
+ .toLowerCase()
40
+ .replace(/[^a-z0-9\s-]/g, " ")
41
+ .trim()
42
+ .split(/\s+/)
43
+ .slice(0, 6)
44
+ .join("-")
45
+ .replace(/-+/g, "-")
46
+ .replace(/^-|-$/g, "")
47
+ .slice(0, 60);
48
+ const hash = createHash("sha256").update(content).digest("hex").slice(0, 8);
49
+ const date = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
50
+ const stem = slug ? `${date}-${slug}-${hash}` : `${date}-${hash}`;
51
+ return `${stem}.md`;
52
+ }
53
+
54
+ /**
55
+ * Prepend a minimal YAML frontmatter ({name, description}) if absent, so each
56
+ * fact file carries lightweight metadata for later listing and search.
57
+ */
58
+ function withFrontmatter(content: string, name: string): string {
59
+ if (content.startsWith("---\n") || content.startsWith("---\r\n")) {
60
+ return content;
61
+ }
62
+ const firstLine = content.split("\n", 1)[0]?.trim() ?? "";
63
+ const description = (firstLine || name).replace(/"/g, "'").slice(0, 120);
64
+ return `---\nname: "${name}"\ndescription: "${description}"\n---\n\n${content}`;
65
+ }
66
+
28
67
  export default function memoryExtension(pi: ExtensionAPI) {
29
68
  // Initialize sigma-memory with embedded vector store
30
69
  const sigmaMemory = new SigmaMemory();
@@ -41,7 +80,8 @@ export default function memoryExtension(pi: ExtensionAPI) {
41
80
  name: "memory_search",
42
81
  label: "Memory Search",
43
82
  description: "Search for content in memory using unified search (notes + ontology + vector search)",
44
- promptSnippet: "Search project memory (notes, ontology, vector search). ALWAYS call before answering questions about prior work, decisions, or project context.",
83
+ promptSnippet:
84
+ "Search project memory (notes, ontology, vector search). ALWAYS call before answering questions about prior work, decisions, or project context.",
45
85
  promptGuidelines: [
46
86
  "MANDATORY: Before starting ANY task, call memory_search with relevant keywords. This is not optional.",
47
87
  "When starting work on a topic, search memory for existing notes and learnings.",
@@ -57,64 +97,72 @@ export default function memoryExtension(pi: ExtensionAPI) {
57
97
 
58
98
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
59
99
  const { query } = params as { query: string };
60
-
100
+
61
101
  try {
62
102
  const results = await sigmaMemory.search(query);
63
-
103
+
64
104
  if (results.length === 0) {
65
105
  return {
66
- content: [{ type: "text", text: `No results found for "${query}". Use memory_write to create some memory files!` }],
67
- details: { found: false, query, resultCount: 0 }
106
+ content: [
107
+ {
108
+ type: "text",
109
+ text: `No results found for "${query}". Use memory_write to create some memory files!`,
110
+ },
111
+ ],
112
+ details: { found: false, query, resultCount: 0 },
68
113
  };
69
114
  }
70
115
 
71
116
  // Format results by source
72
117
  let resultText = `Found ${results.length} results for "${query}":\n\n`;
73
-
74
- const groupedResults = results.reduce((groups, result) => {
75
- if (!groups[result.source]) groups[result.source] = [];
76
- groups[result.source].push(result);
77
- return groups;
78
- }, {} as Record<string, typeof results>);
118
+
119
+ const groupedResults = results.reduce(
120
+ (groups, result) => {
121
+ if (!groups[result.source]) groups[result.source] = [];
122
+ groups[result.source].push(result);
123
+ return groups;
124
+ },
125
+ {} as Record<string, typeof results>,
126
+ );
79
127
 
80
128
  for (const [source, sourceResults] of Object.entries(groupedResults)) {
81
129
  resultText += `## ${source.toUpperCase()} (${sourceResults.length} results)\n\n`;
82
-
83
- for (const result of sourceResults.slice(0, 5)) { // Limit to 5 results per source
130
+
131
+ for (const result of sourceResults.slice(0, 5)) {
132
+ // Limit to 5 results per source
84
133
  resultText += `**Score: ${result.score.toFixed(2)}** | Type: ${result.type}\n`;
85
-
86
- if (result.source === 'notes') {
134
+
135
+ if (result.source === "notes") {
87
136
  const data = result.data;
88
137
  resultText += `File: ${data.file} (line ${data.line})\n`;
89
138
  resultText += `> ${data.content}\n\n`;
90
- } else if (result.source === 'ontology') {
139
+ } else if (result.source === "ontology") {
91
140
  const data = result.data;
92
- if (result.type === 'entity') {
141
+ if (result.type === "entity") {
93
142
  resultText += `Entity: ${data.name} (${data.type})\n`;
94
143
  resultText += `Properties: ${JSON.stringify(data.properties)}\n\n`;
95
- } else if (result.type === 'relation') {
144
+ } else if (result.type === "relation") {
96
145
  resultText += `Relation: ${data.type} (${data.from} → ${data.to})\n`;
97
146
  resultText += `Properties: ${JSON.stringify(data.properties)}\n\n`;
98
147
  }
99
- } else if (result.source === 'vectors') {
148
+ } else if (result.source === "vectors") {
100
149
  const data = result.data;
101
150
  resultText += `File: ${data.file} (line ${data.line})\n`;
102
151
  resultText += `> ${data.content}\n\n`;
103
152
  }
104
153
  }
105
-
106
- resultText += '---\n\n';
154
+
155
+ resultText += "---\n\n";
107
156
  }
108
157
 
109
158
  return {
110
159
  content: [{ type: "text", text: resultText }],
111
- details: { found: true, query, resultCount: results.length, sources: Object.keys(groupedResults) }
160
+ details: { found: true, query, resultCount: results.length, sources: Object.keys(groupedResults) },
112
161
  };
113
-
114
162
  } catch (error) {
115
163
  return {
116
164
  content: [{ type: "text", text: `Memory search failed: ${error}` }],
117
- details: { error: String(error), found: false, query }
165
+ details: { error: String(error), found: false, query },
118
166
  };
119
167
  }
120
168
  },
@@ -125,7 +173,7 @@ export default function memoryExtension(pi: ExtensionAPI) {
125
173
  */
126
174
  pi.registerTool({
127
175
  name: "memory_write",
128
- label: "Memory Write",
176
+ label: "Memory Write",
129
177
  description: "Write content to a memory file. If no filename provided, uses today's date.",
130
178
  parameters: Type.Object({
131
179
  content: Type.String({ description: "Content to write to the memory file" }),
@@ -136,24 +184,29 @@ export default function memoryExtension(pi: ExtensionAPI) {
136
184
  const { content, file } = params as { content: string; file?: string };
137
185
 
138
186
  try {
139
- // Write to notes
140
- sigmaMemory.notes.write(content, file);
141
- const filename = file || new Date().toISOString().split('T')[0] + '.md';
187
+ // Generate a unique per-fact filename when the caller did not pass
188
+ // one, so each memory_write becomes its own file instead of
189
+ // clobbering today's note. An explicit file name is still honored.
190
+ const targetName = file || buildFactFilename(content);
191
+ const finalContent = withFrontmatter(content, targetName);
192
+
193
+ // write() returns the name actually written (with a "-N" suffix if
194
+ // a same-name file already existed), so we never overwrite data.
195
+ const filename = sigmaMemory.notes.write(finalContent, targetName);
142
196
 
143
197
  // Auto-index in vector store (non-blocking)
144
- sigmaMemory.vectors.addDocument(filename, content).catch(() => {
198
+ sigmaMemory.vectors.addDocument(filename, finalContent).catch(() => {
145
199
  // Vector indexing failed silently — notes still saved
146
200
  });
147
-
201
+
148
202
  return {
149
203
  content: [{ type: "text", text: `Content written to ${filename} (indexed for vector search)` }],
150
- details: { filename, contentLength: content.length, vectorIndexed: true }
204
+ details: { filename, contentLength: content.length, vectorIndexed: true },
151
205
  };
152
-
153
206
  } catch (error) {
154
207
  return {
155
208
  content: [{ type: "text", text: `Failed to write to memory: ${error}` }],
156
- details: { error: String(error) }
209
+ details: { error: String(error) },
157
210
  };
158
211
  }
159
212
  },
@@ -177,21 +230,23 @@ export default function memoryExtension(pi: ExtensionAPI) {
177
230
  if (!file) {
178
231
  // List all available memory files
179
232
  const files = sigmaMemory.notes.list();
180
-
233
+
181
234
  if (files.length === 0) {
182
235
  return {
183
236
  content: [{ type: "text", text: "No memory files found." }],
184
- details: { action: "list", fileCount: 0 }
237
+ details: { action: "list", fileCount: 0 },
185
238
  };
186
239
  }
187
240
 
188
241
  const fileList = files
189
- .map(f => `- ${f.name} (${(f.size / 1024).toFixed(1)} KB, ${new Date(f.date).toLocaleDateString()})`)
190
- .join('\n');
242
+ .map(
243
+ (f) => `- ${f.name} (${(f.size / 1024).toFixed(1)} KB, ${new Date(f.date).toLocaleDateString()})`,
244
+ )
245
+ .join("\n");
191
246
 
192
247
  return {
193
248
  content: [{ type: "text", text: `Available memory files (${files.length}):\n\n${fileList}` }],
194
- details: { action: "list", fileCount: files.length }
249
+ details: { action: "list", fileCount: files.length },
195
250
  };
196
251
  }
197
252
 
@@ -200,13 +255,12 @@ export default function memoryExtension(pi: ExtensionAPI) {
200
255
 
201
256
  return {
202
257
  content: [{ type: "text", text: `**${file}:**\n\n${content}` }],
203
- details: { action: "read", found: true, filename: file, contentLength: content.length }
258
+ details: { action: "read", found: true, filename: file, contentLength: content.length },
204
259
  };
205
-
206
260
  } catch (error) {
207
261
  return {
208
262
  content: [{ type: "text", text: `Failed to read memory: ${error}` }],
209
- details: { error: String(error), action: "read", filename: file }
263
+ details: { error: String(error), action: "read", filename: file },
210
264
  };
211
265
  }
212
266
  },
@@ -218,21 +272,32 @@ export default function memoryExtension(pi: ExtensionAPI) {
218
272
  pi.registerTool({
219
273
  name: "ontology_add",
220
274
  label: "Ontology Add",
221
- description: "Add an entity or relation to the project knowledge graph. Entities represent things (projects, files, services, people). Relations connect them.",
275
+ description:
276
+ "Add an entity or relation to the project knowledge graph. Entities represent things (projects, files, services, people). Relations connect them.",
222
277
  promptGuidelines: [
223
278
  "When discovering project architecture (services, databases, APIs), add entities and relations to the ontology.",
224
279
  "When learning about how components connect, add relations (e.g. 'api-server' → 'uses' → 'postgres-db').",
225
280
  ],
226
281
  parameters: Type.Object({
227
- type: Type.Union([Type.Literal("entity"), Type.Literal("relation")], { description: "What to add: 'entity' or 'relation'" }),
282
+ type: Type.Union([Type.Literal("entity"), Type.Literal("relation")], {
283
+ description: "What to add: 'entity' or 'relation'",
284
+ }),
228
285
  // Entity fields
229
- entityType: Type.Optional(Type.String({ description: "Entity type (e.g. Project, Service, Database, File, Person, Tool)" })),
286
+ entityType: Type.Optional(
287
+ Type.String({ description: "Entity type (e.g. Project, Service, Database, File, Person, Tool)" }),
288
+ ),
230
289
  name: Type.Optional(Type.String({ description: "Entity name (e.g. 'my-api', 'postgres-db')" })),
231
- properties: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Key-value properties (e.g. {language: 'TypeScript', port: '3000'})" })),
290
+ properties: Type.Optional(
291
+ Type.Record(Type.String(), Type.String(), {
292
+ description: "Key-value properties (e.g. {language: 'TypeScript', port: '3000'})",
293
+ }),
294
+ ),
232
295
  // Relation fields
233
296
  from: Type.Optional(Type.String({ description: "Source entity ID" })),
234
297
  to: Type.Optional(Type.String({ description: "Target entity ID" })),
235
- relationType: Type.Optional(Type.String({ description: "Relation type (e.g. 'uses', 'depends-on', 'deployed-on', 'created-by')" })),
298
+ relationType: Type.Optional(
299
+ Type.String({ description: "Relation type (e.g. 'uses', 'depends-on', 'deployed-on', 'created-by')" }),
300
+ ),
236
301
  }),
237
302
 
238
303
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
@@ -240,7 +305,10 @@ export default function memoryExtension(pi: ExtensionAPI) {
240
305
  try {
241
306
  if (p.type === "entity") {
242
307
  if (!p.entityType || !p.name) {
243
- return { content: [{ type: "text", text: "Entity requires 'entityType' and 'name'" }], isError: true };
308
+ return {
309
+ content: [{ type: "text", text: "Entity requires 'entityType' and 'name'" }],
310
+ isError: true,
311
+ };
244
312
  }
245
313
  const id = sigmaMemory.ontology.addEntity({
246
314
  type: p.entityType,
@@ -253,31 +321,34 @@ export default function memoryExtension(pi: ExtensionAPI) {
253
321
  };
254
322
  } else if (p.type === "relation") {
255
323
  if (!p.from || !p.to || !p.relationType) {
256
- return { content: [{ type: "text", text: "Relation requires 'from', 'to', and 'relationType'" }], isError: true };
324
+ return {
325
+ content: [{ type: "text", text: "Relation requires 'from', 'to', and 'relationType'" }],
326
+ isError: true,
327
+ };
257
328
  }
258
-
329
+
259
330
  // Try finding source entity by ID first, then by name
260
331
  let sourceEntity = sigmaMemory.ontology.findEntity({ id: p.from })[0];
261
332
  if (!sourceEntity) {
262
333
  // Try finding by name (case-insensitive)
263
334
  const allEntities = sigmaMemory.ontology.findEntity({});
264
- sourceEntity = allEntities.find(e => e.name.toLowerCase() === p.from.toLowerCase());
335
+ sourceEntity = allEntities.find((e) => e.name.toLowerCase() === p.from.toLowerCase());
265
336
  if (!sourceEntity) {
266
337
  return { content: [{ type: "text", text: `Source entity not found: ${p.from}` }], isError: true };
267
338
  }
268
339
  }
269
-
340
+
270
341
  // Try finding target entity by ID first, then by name
271
342
  let targetEntity = sigmaMemory.ontology.findEntity({ id: p.to })[0];
272
343
  if (!targetEntity) {
273
344
  // Try finding by name (case-insensitive)
274
345
  const allEntities = sigmaMemory.ontology.findEntity({});
275
- targetEntity = allEntities.find(e => e.name.toLowerCase() === p.to.toLowerCase());
346
+ targetEntity = allEntities.find((e) => e.name.toLowerCase() === p.to.toLowerCase());
276
347
  if (!targetEntity) {
277
348
  return { content: [{ type: "text", text: `Target entity not found: ${p.to}` }], isError: true };
278
349
  }
279
350
  }
280
-
351
+
281
352
  const id = sigmaMemory.ontology.addRelation({
282
353
  from: sourceEntity.id,
283
354
  to: targetEntity.id,
@@ -285,7 +356,12 @@ export default function memoryExtension(pi: ExtensionAPI) {
285
356
  properties: p.properties || {},
286
357
  });
287
358
  return {
288
- content: [{ type: "text", text: `Relation added: \`${sourceEntity.name}\` → **${p.relationType}** → \`${targetEntity.name}\` — ID: \`${id}\`` }],
359
+ content: [
360
+ {
361
+ type: "text",
362
+ text: `Relation added: \`${sourceEntity.name}\` → **${p.relationType}** → \`${targetEntity.name}\` — ID: \`${id}\``,
363
+ },
364
+ ],
289
365
  details: { id, from: sourceEntity.id, to: targetEntity.id, type: p.relationType },
290
366
  };
291
367
  }
@@ -302,15 +378,22 @@ export default function memoryExtension(pi: ExtensionAPI) {
302
378
  pi.registerTool({
303
379
  name: "ontology_query",
304
380
  label: "Ontology Query",
305
- description: "Query the project knowledge graph. Find entities by type/name, get relations, find paths between entities, or get stats.",
381
+ description:
382
+ "Query the project knowledge graph. Find entities by type/name, get relations, find paths between entities, or get stats.",
306
383
  parameters: Type.Object({
307
- action: Type.Union([
308
- Type.Literal("find"),
309
- Type.Literal("relations"),
310
- Type.Literal("path"),
311
- Type.Literal("stats"),
312
- Type.Literal("graph"),
313
- ], { description: "Query action: find (entities), relations (of entity), path (between entities), stats, graph (full export)" }),
384
+ action: Type.Union(
385
+ [
386
+ Type.Literal("find"),
387
+ Type.Literal("relations"),
388
+ Type.Literal("path"),
389
+ Type.Literal("stats"),
390
+ Type.Literal("graph"),
391
+ ],
392
+ {
393
+ description:
394
+ "Query action: find (entities), relations (of entity), path (between entities), stats, graph (full export)",
395
+ },
396
+ ),
314
397
  entityType: Type.Optional(Type.String({ description: "Filter by entity type (for 'find' action)" })),
315
398
  name: Type.Optional(Type.String({ description: "Filter by name (partial match, for 'find' action)" })),
316
399
  entityId: Type.Optional(Type.String({ description: "Entity ID (for 'relations' action)" })),
@@ -325,21 +408,26 @@ export default function memoryExtension(pi: ExtensionAPI) {
325
408
  case "find": {
326
409
  const results = sigmaMemory.ontology.findEntity({ type: p.entityType, name: p.name });
327
410
  if (results.length === 0) return { content: [{ type: "text", text: "No entities found." }] };
328
- const text = results.map(e => `- **${e.name}** (${e.type}) ID:\`${e.id}\` ${JSON.stringify(e.properties)}`).join("\n");
411
+ const text = results
412
+ .map((e) => `- **${e.name}** (${e.type}) ID:\`${e.id}\` ${JSON.stringify(e.properties)}`)
413
+ .join("\n");
329
414
  return { content: [{ type: "text", text: `Found ${results.length} entities:\n${text}` }] };
330
415
  }
331
416
  case "relations": {
332
417
  if (!p.entityId) return { content: [{ type: "text", text: "'entityId' required" }], isError: true };
333
418
  const rels = sigmaMemory.ontology.findRelations(p.entityId);
334
419
  if (rels.length === 0) return { content: [{ type: "text", text: "No relations found." }] };
335
- const text = rels.map(r => `- \`${r.from}\` → **${r.type}** → \`${r.to}\``).join("\n");
420
+ const text = rels.map((r) => `- \`${r.from}\` → **${r.type}** → \`${r.to}\``).join("\n");
336
421
  return { content: [{ type: "text", text: `Found ${rels.length} relations:\n${text}` }] };
337
422
  }
338
423
  case "path": {
339
- if (!p.fromId || !p.toId) return { content: [{ type: "text", text: "'fromId' and 'toId' required" }], isError: true };
424
+ if (!p.fromId || !p.toId)
425
+ return { content: [{ type: "text", text: "'fromId' and 'toId' required" }], isError: true };
340
426
  const path = sigmaMemory.ontology.queryPath(p.fromId, p.toId);
341
427
  if (!path) return { content: [{ type: "text", text: "No path found between these entities." }] };
342
- const text = path.map(s => `${s.entity.name}${s.relation ? ` → [${s.relation.type}]` : ""}`).join(" → ");
428
+ const text = path
429
+ .map((s) => `${s.entity.name}${s.relation ? ` → [${s.relation.type}]` : ""}`)
430
+ .join(" → ");
343
431
  return { content: [{ type: "text", text: `Path: ${text}` }] };
344
432
  }
345
433
  case "stats": {
@@ -355,7 +443,10 @@ export default function memoryExtension(pi: ExtensionAPI) {
355
443
  return { content: [{ type: "text", text: JSON.stringify(graph, null, 2) }] };
356
444
  }
357
445
  default:
358
- return { content: [{ type: "text", text: "Action must be: find, relations, path, stats, graph" }], isError: true };
446
+ return {
447
+ content: [{ type: "text", text: "Action must be: find, relations, path, stats, graph" }],
448
+ isError: true,
449
+ };
359
450
  }
360
451
  } catch (error) {
361
452
  return { content: [{ type: "text", text: `Ontology query error: ${error}` }], isError: true };
@@ -375,37 +466,36 @@ export default function memoryExtension(pi: ExtensionAPI) {
375
466
  async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
376
467
  try {
377
468
  const status = await sigmaMemory.status();
378
-
469
+
379
470
  let statusText = "# Memory Status\n\n";
380
-
471
+
381
472
  // Notes status
382
473
  statusText += `## Notes\n`;
383
474
  statusText += `- Files: ${status.notes.count}\n`;
384
475
  statusText += `- Total size: ${(status.notes.totalSize / 1024).toFixed(1)} KB\n`;
385
- statusText += `- Last modified: ${status.notes.lastModified ? new Date(status.notes.lastModified).toLocaleString() : 'Never'}\n\n`;
386
-
476
+ statusText += `- Last modified: ${status.notes.lastModified ? new Date(status.notes.lastModified).toLocaleString() : "Never"}\n\n`;
477
+
387
478
  // Ontology status
388
479
  statusText += `## Ontology\n`;
389
480
  statusText += `- Entities: ${status.ontology.entities}\n`;
390
481
  statusText += `- Relations: ${status.ontology.relations}\n`;
391
482
  statusText += `- Entities by type: ${JSON.stringify(status.ontology.entitiesByType)}\n`;
392
483
  statusText += `- Relations by type: ${JSON.stringify(status.ontology.relationsByType)}\n\n`;
393
-
484
+
394
485
  // Vector store status
395
486
  statusText += `## Vector Search (embedded)\n`;
396
487
  statusText += `- Documents: ${status.vectors.documentCount}\n`;
397
488
  statusText += `- Chunks: ${status.vectors.chunkCount}\n`;
398
- statusText += `- Last update: ${status.vectors.lastUpdate || 'Never'}\n`;
489
+ statusText += `- Last update: ${status.vectors.lastUpdate || "Never"}\n`;
399
490
 
400
491
  return {
401
492
  content: [{ type: "text", text: statusText }],
402
- details: { status }
493
+ details: { status },
403
494
  };
404
-
405
495
  } catch (error) {
406
496
  return {
407
497
  content: [{ type: "text", text: `Failed to get memory status: ${error}` }],
408
- details: { error: String(error) }
498
+ details: { error: String(error) },
409
499
  };
410
500
  }
411
501
  },
@@ -498,4 +588,4 @@ These two tool calls are not optional. Skipping them violates project rules.
498
588
  // Non-critical, don't spam errors
499
589
  }
500
590
  });
501
- }
591
+ }
@@ -21,6 +21,7 @@ import {
21
21
  buildOpenCodeGoProviderConfig,
22
22
  getOpenCodeGoModels,
23
23
  } from "./providers/opencode-go.js";
24
+ import { formatWindow, inferContextWindow, parseContextWindow } from "./providers/context-window.js";
24
25
  import { fetchLiveModels, peekCache, resetLiveModelsCache, toPersistedModel } from "./providers/live-models.js";
25
26
 
26
27
  const PROVIDER_DISPLAY: Record<string, string> = {
@@ -190,6 +191,96 @@ export default function modelsExtension(pi: ExtensionAPI) {
190
191
  },
191
192
  });
192
193
 
194
+ pi.registerCommand("context", {
195
+ description:
196
+ "Show or set the active model's context window (e.g. `/context 256k`, `/context 1M`, `/context auto`). Drives when the conversation auto-compacts.",
197
+ handler: async (args, ctx) => {
198
+ const model = ctx.model;
199
+ if (!model) {
200
+ ctx.ui.notify("No active model. Select one with `/model` first.", "warning");
201
+ return;
202
+ }
203
+ const provider = model.provider;
204
+ const modelId = model.id;
205
+ const arg = args.trim();
206
+
207
+ const readOverrideWindow = (): number | undefined => {
208
+ const overrides = store.getProvider(provider)?.modelOverrides as
209
+ | Record<string, { contextWindow?: number }>
210
+ | undefined;
211
+ return overrides?.[modelId]?.contextWindow;
212
+ };
213
+
214
+ const writeOverrides = (overrides: Record<string, unknown>): void => {
215
+ const stored = store.getProvider(provider) ?? {};
216
+ watcher.muteForWrite("models_json_changed");
217
+ store.setKey(provider, stored.apiKey ?? "local", { modelOverrides: overrides });
218
+ };
219
+
220
+ try {
221
+ if (arg === "") {
222
+ const source = readOverrideWindow() !== undefined ? "manual override" : "provider / inferred";
223
+ ctx.ui.notify(
224
+ `**${modelId}** (\`${provider}\`) context window: \`${formatWindow(model.contextWindow)}\` (${source}).\n` +
225
+ "Set the real value with `/context 256k`, `/context 1M`, or `/context 200000`. " +
226
+ "Reset to the detected value with `/context auto`.\n" +
227
+ "This is what determines when the conversation auto-compacts.",
228
+ "info",
229
+ );
230
+ return;
231
+ }
232
+
233
+ if (arg.toLowerCase() === "auto" || arg.toLowerCase() === "reset") {
234
+ const stored = store.getProvider(provider) ?? {};
235
+ const overrides = { ...((stored.modelOverrides as Record<string, unknown>) ?? {}) };
236
+ const entry = overrides[modelId];
237
+ if (entry && typeof entry === "object") {
238
+ const next = { ...(entry as Record<string, unknown>) };
239
+ delete next.contextWindow;
240
+ if (Object.keys(next).length === 0) delete overrides[modelId];
241
+ else overrides[modelId] = next;
242
+ }
243
+ writeOverrides(overrides);
244
+
245
+ // Revert the active model to the persisted/inferred window.
246
+ const persistedModels = (store.getProvider(provider)?.models as
247
+ | Array<{ id?: string; contextWindow?: number }>
248
+ | undefined) ?? [];
249
+ const persisted = persistedModels.find((m) => m?.id === modelId)?.contextWindow;
250
+ const reverted = persisted && persisted > 0 ? persisted : inferContextWindow(modelId, undefined, provider);
251
+ await pi.setModel({ ...model, contextWindow: reverted });
252
+ ctx.ui.notify(`Cleared context override for **${modelId}**. Reverted to \`${formatWindow(reverted)}\`.`, "info");
253
+ return;
254
+ }
255
+
256
+ const value = parseContextWindow(arg);
257
+ if (!value) {
258
+ ctx.ui.notify("Invalid value. Use e.g. `256k`, `1M`, or `200000`.", "warning");
259
+ return;
260
+ }
261
+
262
+ // Immediate effect: the footer and auto-compaction use the new window right away.
263
+ await pi.setModel({ ...model, contextWindow: value });
264
+
265
+ // Persist as a per-model override so it survives restarts and the background
266
+ // refresh (which rewrites `models` but leaves `modelOverrides` untouched).
267
+ const stored = store.getProvider(provider) ?? {};
268
+ const overrides = { ...((stored.modelOverrides as Record<string, unknown>) ?? {}) };
269
+ const existing = (overrides[modelId] as Record<string, unknown> | undefined) ?? {};
270
+ overrides[modelId] = { ...existing, contextWindow: value };
271
+ writeOverrides(overrides);
272
+
273
+ ctx.ui.notify(
274
+ `Context window for **${modelId}** set to \`${formatWindow(value)}\` (saved). ` +
275
+ `Auto-compaction now triggers near ${formatWindow(value)}.`,
276
+ "info",
277
+ );
278
+ } catch (err) {
279
+ ctx.ui.notify(`/context error: ${err instanceof Error ? err.message : String(err)}`, "error");
280
+ }
281
+ },
282
+ });
283
+
193
284
  async function listCommand(target: string | undefined, ctx: { ui: { notify: (m: string, t?: "info" | "warning" | "error") => void } }): Promise<void> {
194
285
  const providers = target ? [target] : store.listProviders();
195
286
  if (providers.length === 0) {
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Pure helpers for the /plan orchestrator. Kept separate from orchestrator.ts so
3
+ * they can be unit-tested (orchestrator.ts itself wires the Pi extension runtime).
4
+ *
5
+ * Everything here is text/regex only: the orchestration pipeline is driven by
6
+ * canonical text contracts in the .phi/plans/*.md handoff files, never by a
7
+ * model's structured-output (the upstream proxy does not guarantee valid JSON).
8
+ */
9
+
10
+ export type PhaseVerdict = "PASS" | "FAIL" | "BLOCKED" | "SKIP";
11
+
12
+ /**
13
+ * Parse the canonical "VERDICT: PASS|FAIL|BLOCKED|SKIP" line a phase writes at the
14
+ * top of its report. Tolerant of leading markdown hashes and surrounding markup.
15
+ * Returns the first verdict found, or null when none is present.
16
+ */
17
+ export function parsePhaseVerdict(content: string): PhaseVerdict | null {
18
+ if (!content) return null;
19
+ const m = content.match(/^\s{0,3}#{0,4}\s*\**\s*VERDICT\s*\**\s*:?\s*\**\s*(PASS|FAIL|BLOCKED|SKIP)\b/im);
20
+ return m ? (m[1].toUpperCase() as PhaseVerdict) : null;
21
+ }
22
+
23
+ /**
24
+ * Extract the body of a markdown section by heading name (e.g. "BLOCKING",
25
+ * "HANDOFF"), up to the next heading or end of file. Returns "" if absent.
26
+ */
27
+ export function extractSection(content: string, heading: string): string {
28
+ if (!content) return "";
29
+ const re = new RegExp(`#{1,6}\\s*\\**\\s*${heading}\\b[^\\n]*\\n([\\s\\S]*?)(?:\\n#{1,6}\\s|$)`, "i");
30
+ const m = content.match(re);
31
+ return m ? m[1].trim() : "";
32
+ }
33
+
34
+ export function extractBlockingFindings(content: string): string {
35
+ return extractSection(content, "BLOCKING");
36
+ }
37
+
38
+ export function extractHandoff(content: string): string {
39
+ return extractSection(content, "HANDOFF");
40
+ }
41
+
42
+ /**
43
+ * Detect a TRANSIENT provider/proxy failure in a phase's messages (timeout, 5xx,
44
+ * 429, connection reset, broken JSON tool call) that warrants a one-shot retry on
45
+ * a fallback model. A genuine 401 auth failure is NOT transient (handled as fatal
46
+ * by the caller) and is explicitly excluded.
47
+ */
48
+ export function isTransientError(messages: Array<{ content?: unknown }>): boolean {
49
+ for (const msg of messages || []) {
50
+ const content = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content ?? "");
51
+ if (content.includes("401")) continue;
52
+ if (/\b(429|500|502|503|504)\b/.test(content)) return true;
53
+ if (
54
+ /timed?\s?out|timeout|connection reset|ECONNRESET|ETIMEDOUT|socket hang ?up|overloaded|rate.?limit(ed)?|too many requests|invalid json|failed to parse|stream (error|interrupted|closed)|service unavailable|bad gateway|gateway timeout/i.test(
55
+ content,
56
+ )
57
+ ) {
58
+ return true;
59
+ }
60
+ }
61
+ return false;
62
+ }