@phi-code-admin/phi-code 0.77.3 → 0.79.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,159 @@
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
+
67
+ /** Hard caps for the deterministic recall manifest (no LLM involved). */
68
+ const MANIFEST_MAX_ENTRIES = 40;
69
+ const MANIFEST_MAX_CHARS = 2000;
70
+ const MANIFEST_DESC_MAX = 100;
71
+ // Chars reserved for the trailing "… (N notes total …)" overflow line so the
72
+ // final manifest still respects MANIFEST_MAX_CHARS once it is appended.
73
+ const MANIFEST_OVERFLOW_RESERVE = 96;
74
+
75
+ /**
76
+ * Extract a one-line summary for a single note file, deterministically.
77
+ *
78
+ * Prefers the YAML frontmatter `description:` field (written by
79
+ * withFrontmatter). Falls back to the first non-empty, non-frontmatter line.
80
+ * Returns a trimmed, single-line string truncated to MANIFEST_DESC_MAX chars.
81
+ */
82
+ function summarizeNote(content: string): string {
83
+ const text = content.replace(/\r\n/g, "\n");
84
+ let body = text;
85
+
86
+ // If a frontmatter block is present, scan it for a description first.
87
+ if (text.startsWith("---\n")) {
88
+ const end = text.indexOf("\n---", 4);
89
+ if (end !== -1) {
90
+ const fm = text.slice(4, end);
91
+ for (const rawLine of fm.split("\n")) {
92
+ const m = rawLine.match(/^\s*description\s*:\s*(.+?)\s*$/);
93
+ if (m) {
94
+ // Strip surrounding quotes if present.
95
+ const value = m[1].replace(/^["']|["']$/g, "").trim();
96
+ if (value) return collapseLine(value);
97
+ }
98
+ }
99
+ // No description in frontmatter: summarize the body that follows it.
100
+ body = text.slice(end + 4);
101
+ }
102
+ }
103
+
104
+ for (const rawLine of body.split("\n")) {
105
+ const line = rawLine.replace(/^#+\s*/, "").trim();
106
+ if (line && line !== "---") {
107
+ return collapseLine(line);
108
+ }
109
+ }
110
+ return "";
111
+ }
112
+
113
+ /** Collapse internal whitespace to single spaces and truncate cleanly. */
114
+ function collapseLine(value: string): string {
115
+ const flat = value.replace(/\s+/g, " ").trim();
116
+ return flat.length > MANIFEST_DESC_MAX ? `${flat.slice(0, MANIFEST_DESC_MAX - 1).trimEnd()}…` : flat;
117
+ }
118
+
119
+ /**
120
+ * Build a deterministic one-line-per-file manifest of all memory notes.
121
+ *
122
+ * Purely local: lists note files and reads their frontmatter / first line.
123
+ * No LLM call, no vector search. Bounded to MANIFEST_MAX_ENTRIES entries and
124
+ * MANIFEST_MAX_CHARS characters so it stays cheap to inject every turn.
125
+ *
126
+ * Returns an empty string when there are no notes, so callers can skip
127
+ * injection entirely.
128
+ */
129
+ function buildMemoryManifest(sigmaMemory: SigmaMemory): string {
130
+ let files: Array<{ name: string; size: number; date: string }>;
131
+ try {
132
+ files = sigmaMemory.notes.list();
133
+ } catch {
134
+ return "";
135
+ }
136
+ if (files.length === 0) {
137
+ return "";
138
+ }
139
+
140
+ const lines: string[] = [];
141
+ let total = 0;
142
+ let truncated = false;
143
+
144
+ for (const file of files) {
145
+ if (lines.length >= MANIFEST_MAX_ENTRIES) {
146
+ truncated = true;
147
+ break;
148
+ }
149
+ let summary = "";
150
+ try {
151
+ summary = summarizeNote(sigmaMemory.notes.read(file.name));
152
+ } catch {
153
+ // Unreadable note: still list its name so the model knows it exists.
154
+ summary = "";
155
+ }
156
+ const entry = summary ? `- ${file.name}: ${summary}` : `- ${file.name}`;
157
+ if (total + entry.length + 1 > MANIFEST_MAX_CHARS - MANIFEST_OVERFLOW_RESERVE) {
158
+ truncated = true;
159
+ break;
160
+ }
161
+ lines.push(entry);
162
+ total += entry.length + 1;
163
+ }
164
+
165
+ if (lines.length === 0) {
166
+ return "";
167
+ }
168
+ if (truncated || lines.length < files.length) {
169
+ lines.push(`- … (${files.length} notes total; use memory_read/memory_search for full content)`);
170
+ }
171
+ return lines.join("\n");
172
+ }
173
+
28
174
  export default function memoryExtension(pi: ExtensionAPI) {
29
175
  // Initialize sigma-memory with embedded vector store
30
176
  const sigmaMemory = new SigmaMemory();
@@ -41,7 +187,8 @@ export default function memoryExtension(pi: ExtensionAPI) {
41
187
  name: "memory_search",
42
188
  label: "Memory Search",
43
189
  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.",
190
+ promptSnippet:
191
+ "Search project memory (notes, ontology, vector search). ALWAYS call before answering questions about prior work, decisions, or project context.",
45
192
  promptGuidelines: [
46
193
  "MANDATORY: Before starting ANY task, call memory_search with relevant keywords. This is not optional.",
47
194
  "When starting work on a topic, search memory for existing notes and learnings.",
@@ -57,64 +204,72 @@ export default function memoryExtension(pi: ExtensionAPI) {
57
204
 
58
205
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
59
206
  const { query } = params as { query: string };
60
-
207
+
61
208
  try {
62
209
  const results = await sigmaMemory.search(query);
63
-
210
+
64
211
  if (results.length === 0) {
65
212
  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 }
213
+ content: [
214
+ {
215
+ type: "text",
216
+ text: `No results found for "${query}". Use memory_write to create some memory files!`,
217
+ },
218
+ ],
219
+ details: { found: false, query, resultCount: 0 },
68
220
  };
69
221
  }
70
222
 
71
223
  // Format results by source
72
224
  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>);
225
+
226
+ const groupedResults = results.reduce(
227
+ (groups, result) => {
228
+ if (!groups[result.source]) groups[result.source] = [];
229
+ groups[result.source].push(result);
230
+ return groups;
231
+ },
232
+ {} as Record<string, typeof results>,
233
+ );
79
234
 
80
235
  for (const [source, sourceResults] of Object.entries(groupedResults)) {
81
236
  resultText += `## ${source.toUpperCase()} (${sourceResults.length} results)\n\n`;
82
-
83
- for (const result of sourceResults.slice(0, 5)) { // Limit to 5 results per source
237
+
238
+ for (const result of sourceResults.slice(0, 5)) {
239
+ // Limit to 5 results per source
84
240
  resultText += `**Score: ${result.score.toFixed(2)}** | Type: ${result.type}\n`;
85
-
86
- if (result.source === 'notes') {
241
+
242
+ if (result.source === "notes") {
87
243
  const data = result.data;
88
244
  resultText += `File: ${data.file} (line ${data.line})\n`;
89
245
  resultText += `> ${data.content}\n\n`;
90
- } else if (result.source === 'ontology') {
246
+ } else if (result.source === "ontology") {
91
247
  const data = result.data;
92
- if (result.type === 'entity') {
248
+ if (result.type === "entity") {
93
249
  resultText += `Entity: ${data.name} (${data.type})\n`;
94
250
  resultText += `Properties: ${JSON.stringify(data.properties)}\n\n`;
95
- } else if (result.type === 'relation') {
251
+ } else if (result.type === "relation") {
96
252
  resultText += `Relation: ${data.type} (${data.from} → ${data.to})\n`;
97
253
  resultText += `Properties: ${JSON.stringify(data.properties)}\n\n`;
98
254
  }
99
- } else if (result.source === 'vectors') {
255
+ } else if (result.source === "vectors") {
100
256
  const data = result.data;
101
257
  resultText += `File: ${data.file} (line ${data.line})\n`;
102
258
  resultText += `> ${data.content}\n\n`;
103
259
  }
104
260
  }
105
-
106
- resultText += '---\n\n';
261
+
262
+ resultText += "---\n\n";
107
263
  }
108
264
 
109
265
  return {
110
266
  content: [{ type: "text", text: resultText }],
111
- details: { found: true, query, resultCount: results.length, sources: Object.keys(groupedResults) }
267
+ details: { found: true, query, resultCount: results.length, sources: Object.keys(groupedResults) },
112
268
  };
113
-
114
269
  } catch (error) {
115
270
  return {
116
271
  content: [{ type: "text", text: `Memory search failed: ${error}` }],
117
- details: { error: String(error), found: false, query }
272
+ details: { error: String(error), found: false, query },
118
273
  };
119
274
  }
120
275
  },
@@ -125,7 +280,7 @@ export default function memoryExtension(pi: ExtensionAPI) {
125
280
  */
126
281
  pi.registerTool({
127
282
  name: "memory_write",
128
- label: "Memory Write",
283
+ label: "Memory Write",
129
284
  description: "Write content to a memory file. If no filename provided, uses today's date.",
130
285
  parameters: Type.Object({
131
286
  content: Type.String({ description: "Content to write to the memory file" }),
@@ -136,24 +291,29 @@ export default function memoryExtension(pi: ExtensionAPI) {
136
291
  const { content, file } = params as { content: string; file?: string };
137
292
 
138
293
  try {
139
- // Write to notes
140
- sigmaMemory.notes.write(content, file);
141
- const filename = file || new Date().toISOString().split('T')[0] + '.md';
294
+ // Generate a unique per-fact filename when the caller did not pass
295
+ // one, so each memory_write becomes its own file instead of
296
+ // clobbering today's note. An explicit file name is still honored.
297
+ const targetName = file || buildFactFilename(content);
298
+ const finalContent = withFrontmatter(content, targetName);
299
+
300
+ // write() returns the name actually written (with a "-N" suffix if
301
+ // a same-name file already existed), so we never overwrite data.
302
+ const filename = sigmaMemory.notes.write(finalContent, targetName);
142
303
 
143
304
  // Auto-index in vector store (non-blocking)
144
- sigmaMemory.vectors.addDocument(filename, content).catch(() => {
305
+ sigmaMemory.vectors.addDocument(filename, finalContent).catch(() => {
145
306
  // Vector indexing failed silently — notes still saved
146
307
  });
147
-
308
+
148
309
  return {
149
310
  content: [{ type: "text", text: `Content written to ${filename} (indexed for vector search)` }],
150
- details: { filename, contentLength: content.length, vectorIndexed: true }
311
+ details: { filename, contentLength: content.length, vectorIndexed: true },
151
312
  };
152
-
153
313
  } catch (error) {
154
314
  return {
155
315
  content: [{ type: "text", text: `Failed to write to memory: ${error}` }],
156
- details: { error: String(error) }
316
+ details: { error: String(error) },
157
317
  };
158
318
  }
159
319
  },
@@ -177,21 +337,34 @@ export default function memoryExtension(pi: ExtensionAPI) {
177
337
  if (!file) {
178
338
  // List all available memory files
179
339
  const files = sigmaMemory.notes.list();
180
-
340
+
181
341
  if (files.length === 0) {
182
342
  return {
183
343
  content: [{ type: "text", text: "No memory files found." }],
184
- details: { action: "list", fileCount: 0 }
344
+ details: { action: "list", fileCount: 0 },
185
345
  };
186
346
  }
187
347
 
188
348
  const fileList = files
189
- .map(f => `- ${f.name} (${(f.size / 1024).toFixed(1)} KB, ${new Date(f.date).toLocaleDateString()})`)
190
- .join('\n');
349
+ .map(
350
+ (f) => `- ${f.name} (${(f.size / 1024).toFixed(1)} KB, ${new Date(f.date).toLocaleDateString()})`,
351
+ )
352
+ .join("\n");
353
+
354
+ // Lead with the deterministic recall manifest (file + summary)
355
+ // so a no-arg memory_read surfaces what each note contains, not
356
+ // just file names. Falls back gracefully when empty.
357
+ const manifest = buildMemoryManifest(sigmaMemory);
358
+ const manifestSection = manifest ? `## Memory index (summaries)\n\n${manifest}\n\n` : "";
191
359
 
192
360
  return {
193
- content: [{ type: "text", text: `Available memory files (${files.length}):\n\n${fileList}` }],
194
- details: { action: "list", fileCount: files.length }
361
+ content: [
362
+ {
363
+ type: "text",
364
+ text: `${manifestSection}## Available memory files (${files.length})\n\n${fileList}`,
365
+ },
366
+ ],
367
+ details: { action: "list", fileCount: files.length },
195
368
  };
196
369
  }
197
370
 
@@ -200,13 +373,12 @@ export default function memoryExtension(pi: ExtensionAPI) {
200
373
 
201
374
  return {
202
375
  content: [{ type: "text", text: `**${file}:**\n\n${content}` }],
203
- details: { action: "read", found: true, filename: file, contentLength: content.length }
376
+ details: { action: "read", found: true, filename: file, contentLength: content.length },
204
377
  };
205
-
206
378
  } catch (error) {
207
379
  return {
208
380
  content: [{ type: "text", text: `Failed to read memory: ${error}` }],
209
- details: { error: String(error), action: "read", filename: file }
381
+ details: { error: String(error), action: "read", filename: file },
210
382
  };
211
383
  }
212
384
  },
@@ -218,21 +390,32 @@ export default function memoryExtension(pi: ExtensionAPI) {
218
390
  pi.registerTool({
219
391
  name: "ontology_add",
220
392
  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.",
393
+ description:
394
+ "Add an entity or relation to the project knowledge graph. Entities represent things (projects, files, services, people). Relations connect them.",
222
395
  promptGuidelines: [
223
396
  "When discovering project architecture (services, databases, APIs), add entities and relations to the ontology.",
224
397
  "When learning about how components connect, add relations (e.g. 'api-server' → 'uses' → 'postgres-db').",
225
398
  ],
226
399
  parameters: Type.Object({
227
- type: Type.Union([Type.Literal("entity"), Type.Literal("relation")], { description: "What to add: 'entity' or 'relation'" }),
400
+ type: Type.Union([Type.Literal("entity"), Type.Literal("relation")], {
401
+ description: "What to add: 'entity' or 'relation'",
402
+ }),
228
403
  // Entity fields
229
- entityType: Type.Optional(Type.String({ description: "Entity type (e.g. Project, Service, Database, File, Person, Tool)" })),
404
+ entityType: Type.Optional(
405
+ Type.String({ description: "Entity type (e.g. Project, Service, Database, File, Person, Tool)" }),
406
+ ),
230
407
  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'})" })),
408
+ properties: Type.Optional(
409
+ Type.Record(Type.String(), Type.String(), {
410
+ description: "Key-value properties (e.g. {language: 'TypeScript', port: '3000'})",
411
+ }),
412
+ ),
232
413
  // Relation fields
233
414
  from: Type.Optional(Type.String({ description: "Source entity ID" })),
234
415
  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')" })),
416
+ relationType: Type.Optional(
417
+ Type.String({ description: "Relation type (e.g. 'uses', 'depends-on', 'deployed-on', 'created-by')" }),
418
+ ),
236
419
  }),
237
420
 
238
421
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
@@ -240,7 +423,10 @@ export default function memoryExtension(pi: ExtensionAPI) {
240
423
  try {
241
424
  if (p.type === "entity") {
242
425
  if (!p.entityType || !p.name) {
243
- return { content: [{ type: "text", text: "Entity requires 'entityType' and 'name'" }], isError: true };
426
+ return {
427
+ content: [{ type: "text", text: "Entity requires 'entityType' and 'name'" }],
428
+ isError: true,
429
+ };
244
430
  }
245
431
  const id = sigmaMemory.ontology.addEntity({
246
432
  type: p.entityType,
@@ -253,31 +439,34 @@ export default function memoryExtension(pi: ExtensionAPI) {
253
439
  };
254
440
  } else if (p.type === "relation") {
255
441
  if (!p.from || !p.to || !p.relationType) {
256
- return { content: [{ type: "text", text: "Relation requires 'from', 'to', and 'relationType'" }], isError: true };
442
+ return {
443
+ content: [{ type: "text", text: "Relation requires 'from', 'to', and 'relationType'" }],
444
+ isError: true,
445
+ };
257
446
  }
258
-
447
+
259
448
  // Try finding source entity by ID first, then by name
260
449
  let sourceEntity = sigmaMemory.ontology.findEntity({ id: p.from })[0];
261
450
  if (!sourceEntity) {
262
451
  // Try finding by name (case-insensitive)
263
452
  const allEntities = sigmaMemory.ontology.findEntity({});
264
- sourceEntity = allEntities.find(e => e.name.toLowerCase() === p.from.toLowerCase());
453
+ sourceEntity = allEntities.find((e) => e.name.toLowerCase() === p.from.toLowerCase());
265
454
  if (!sourceEntity) {
266
455
  return { content: [{ type: "text", text: `Source entity not found: ${p.from}` }], isError: true };
267
456
  }
268
457
  }
269
-
458
+
270
459
  // Try finding target entity by ID first, then by name
271
460
  let targetEntity = sigmaMemory.ontology.findEntity({ id: p.to })[0];
272
461
  if (!targetEntity) {
273
462
  // Try finding by name (case-insensitive)
274
463
  const allEntities = sigmaMemory.ontology.findEntity({});
275
- targetEntity = allEntities.find(e => e.name.toLowerCase() === p.to.toLowerCase());
464
+ targetEntity = allEntities.find((e) => e.name.toLowerCase() === p.to.toLowerCase());
276
465
  if (!targetEntity) {
277
466
  return { content: [{ type: "text", text: `Target entity not found: ${p.to}` }], isError: true };
278
467
  }
279
468
  }
280
-
469
+
281
470
  const id = sigmaMemory.ontology.addRelation({
282
471
  from: sourceEntity.id,
283
472
  to: targetEntity.id,
@@ -285,7 +474,12 @@ export default function memoryExtension(pi: ExtensionAPI) {
285
474
  properties: p.properties || {},
286
475
  });
287
476
  return {
288
- content: [{ type: "text", text: `Relation added: \`${sourceEntity.name}\` → **${p.relationType}** → \`${targetEntity.name}\` — ID: \`${id}\`` }],
477
+ content: [
478
+ {
479
+ type: "text",
480
+ text: `Relation added: \`${sourceEntity.name}\` → **${p.relationType}** → \`${targetEntity.name}\` — ID: \`${id}\``,
481
+ },
482
+ ],
289
483
  details: { id, from: sourceEntity.id, to: targetEntity.id, type: p.relationType },
290
484
  };
291
485
  }
@@ -302,15 +496,22 @@ export default function memoryExtension(pi: ExtensionAPI) {
302
496
  pi.registerTool({
303
497
  name: "ontology_query",
304
498
  label: "Ontology Query",
305
- description: "Query the project knowledge graph. Find entities by type/name, get relations, find paths between entities, or get stats.",
499
+ description:
500
+ "Query the project knowledge graph. Find entities by type/name, get relations, find paths between entities, or get stats.",
306
501
  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)" }),
502
+ action: Type.Union(
503
+ [
504
+ Type.Literal("find"),
505
+ Type.Literal("relations"),
506
+ Type.Literal("path"),
507
+ Type.Literal("stats"),
508
+ Type.Literal("graph"),
509
+ ],
510
+ {
511
+ description:
512
+ "Query action: find (entities), relations (of entity), path (between entities), stats, graph (full export)",
513
+ },
514
+ ),
314
515
  entityType: Type.Optional(Type.String({ description: "Filter by entity type (for 'find' action)" })),
315
516
  name: Type.Optional(Type.String({ description: "Filter by name (partial match, for 'find' action)" })),
316
517
  entityId: Type.Optional(Type.String({ description: "Entity ID (for 'relations' action)" })),
@@ -325,21 +526,26 @@ export default function memoryExtension(pi: ExtensionAPI) {
325
526
  case "find": {
326
527
  const results = sigmaMemory.ontology.findEntity({ type: p.entityType, name: p.name });
327
528
  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");
529
+ const text = results
530
+ .map((e) => `- **${e.name}** (${e.type}) ID:\`${e.id}\` ${JSON.stringify(e.properties)}`)
531
+ .join("\n");
329
532
  return { content: [{ type: "text", text: `Found ${results.length} entities:\n${text}` }] };
330
533
  }
331
534
  case "relations": {
332
535
  if (!p.entityId) return { content: [{ type: "text", text: "'entityId' required" }], isError: true };
333
536
  const rels = sigmaMemory.ontology.findRelations(p.entityId);
334
537
  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");
538
+ const text = rels.map((r) => `- \`${r.from}\` → **${r.type}** → \`${r.to}\``).join("\n");
336
539
  return { content: [{ type: "text", text: `Found ${rels.length} relations:\n${text}` }] };
337
540
  }
338
541
  case "path": {
339
- if (!p.fromId || !p.toId) return { content: [{ type: "text", text: "'fromId' and 'toId' required" }], isError: true };
542
+ if (!p.fromId || !p.toId)
543
+ return { content: [{ type: "text", text: "'fromId' and 'toId' required" }], isError: true };
340
544
  const path = sigmaMemory.ontology.queryPath(p.fromId, p.toId);
341
545
  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(" → ");
546
+ const text = path
547
+ .map((s) => `${s.entity.name}${s.relation ? ` → [${s.relation.type}]` : ""}`)
548
+ .join(" → ");
343
549
  return { content: [{ type: "text", text: `Path: ${text}` }] };
344
550
  }
345
551
  case "stats": {
@@ -355,7 +561,10 @@ export default function memoryExtension(pi: ExtensionAPI) {
355
561
  return { content: [{ type: "text", text: JSON.stringify(graph, null, 2) }] };
356
562
  }
357
563
  default:
358
- return { content: [{ type: "text", text: "Action must be: find, relations, path, stats, graph" }], isError: true };
564
+ return {
565
+ content: [{ type: "text", text: "Action must be: find, relations, path, stats, graph" }],
566
+ isError: true,
567
+ };
359
568
  }
360
569
  } catch (error) {
361
570
  return { content: [{ type: "text", text: `Ontology query error: ${error}` }], isError: true };
@@ -375,37 +584,36 @@ export default function memoryExtension(pi: ExtensionAPI) {
375
584
  async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
376
585
  try {
377
586
  const status = await sigmaMemory.status();
378
-
587
+
379
588
  let statusText = "# Memory Status\n\n";
380
-
589
+
381
590
  // Notes status
382
591
  statusText += `## Notes\n`;
383
592
  statusText += `- Files: ${status.notes.count}\n`;
384
593
  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
-
594
+ statusText += `- Last modified: ${status.notes.lastModified ? new Date(status.notes.lastModified).toLocaleString() : "Never"}\n\n`;
595
+
387
596
  // Ontology status
388
597
  statusText += `## Ontology\n`;
389
598
  statusText += `- Entities: ${status.ontology.entities}\n`;
390
599
  statusText += `- Relations: ${status.ontology.relations}\n`;
391
600
  statusText += `- Entities by type: ${JSON.stringify(status.ontology.entitiesByType)}\n`;
392
601
  statusText += `- Relations by type: ${JSON.stringify(status.ontology.relationsByType)}\n\n`;
393
-
602
+
394
603
  // Vector store status
395
604
  statusText += `## Vector Search (embedded)\n`;
396
605
  statusText += `- Documents: ${status.vectors.documentCount}\n`;
397
606
  statusText += `- Chunks: ${status.vectors.chunkCount}\n`;
398
- statusText += `- Last update: ${status.vectors.lastUpdate || 'Never'}\n`;
607
+ statusText += `- Last update: ${status.vectors.lastUpdate || "Never"}\n`;
399
608
 
400
609
  return {
401
610
  content: [{ type: "text", text: statusText }],
402
- details: { status }
611
+ details: { status },
403
612
  };
404
-
405
613
  } catch (error) {
406
614
  return {
407
615
  content: [{ type: "text", text: `Failed to get memory status: ${error}` }],
408
- details: { error: String(error) }
616
+ details: { error: String(error) },
409
617
  };
410
618
  }
411
619
  },
@@ -439,10 +647,19 @@ export default function memoryExtension(pi: ExtensionAPI) {
439
647
  // Neutralize angle brackets so user content cannot close the
440
648
  // <system-reminder> block early and inject trusted instructions.
441
649
  const safe = truncated.replace(/[<>]/g, (c) => (c === "<" ? "&lt;" : "&gt;")).replace(/"/g, '\\"');
650
+
651
+ // Deterministic recall index: a one-line-per-file manifest of memory
652
+ // notes so the model ALWAYS sees which facts exist without having to
653
+ // guess and call memory_search blindly. Built locally (no LLM).
654
+ const manifest = buildMemoryManifest(sigmaMemory);
655
+ const manifestBlock = manifest
656
+ ? `\nMEMORY INDEX (existing saved notes, read with \`memory_read <file>\`):\n${manifest}\n`
657
+ : "";
658
+
442
659
  const reminder = `<system-reminder>
443
660
  You are about to respond to a new user message:
444
661
  "${safe}"
445
-
662
+ ${manifestBlock}
446
663
  REMINDER (project rule, applies every turn):
447
664
  1. Call \`memory_search\` FIRST with keywords from the user's intent. Recent
448
665
  project context, prior decisions, and saved learnings are accessible
@@ -498,4 +715,4 @@ These two tool calls are not optional. Skipping them violates project rules.
498
715
  // Non-critical, don't spam errors
499
716
  }
500
717
  });
501
- }
718
+ }