@pi-unipi/memory 0.1.13 → 2.0.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.
package/README.md CHANGED
@@ -1,37 +1,8 @@
1
1
  # @pi-unipi/memory
2
2
 
3
- Persistent cross-session memory with vector search for Pi coding agent.
3
+ Persistent memory that survives across sessions. Stores facts, preferences, and decisions in SQLite with vector search, so the agent remembers what you told it last week.
4
4
 
5
- ## Features
6
-
7
- - **Two-tier storage:** SQLite + sqlite-vec for vector search, markdown files for human-readable memory
8
- - **Project-scoped + global memory:** Each project gets its own DB, global memories accessible cross-project
9
- - **Hybrid search:** Vector similarity + fuzzy text matching for best recall
10
- - **Session injection:** Agent sees memory titles at session start
11
- - **Auto-consolidation:** Memories extracted during compaction
12
- - **Update-first:** Prevents memory duplication
13
-
14
- ## Installation
15
-
16
- ```bash
17
- # All-in-one (includes memory)
18
- pi install npm:unipi
19
-
20
- # Standalone
21
- pi install npm:@pi-unipi/memory
22
- ```
23
-
24
- ## Tools
25
-
26
- | Tool | Description |
27
- |------|-------------|
28
- | `memory_store` | Store/update memory (project scope) |
29
- | `memory_search` | Search memories (project scope) |
30
- | `memory_delete` | Delete memory by ID or title |
31
- | `memory_list` | List all project memories |
32
- | `global_memory_store` | Store/update memory (global scope) |
33
- | `global_memory_search` | Search global memories |
34
- | `global_memory_list` | List all global memories |
5
+ Two storage tiers: SQLite + sqlite-vec for vector similarity search, markdown files for human-readable memories you can edit by hand. Project-scoped memories stay separate per codebase, global memories are accessible everywhere.
35
6
 
36
7
  ## Commands
37
8
 
@@ -45,9 +16,31 @@ pi install npm:@pi-unipi/memory
45
16
  | `/unipi:global-memory-search <term>` | Search global memories |
46
17
  | `/unipi:global-memory-list` | List all global memories |
47
18
 
48
- ## Memory File Format
19
+ ## Special Triggers
20
+
21
+ At session start, the agent sees memory titles injected into context. This gives it a summary of what it should remember without loading full memory content.
22
+
23
+ During compaction (if `@pi-unipi/compactor` is installed), memories are auto-extracted from the conversation. The `memory-consolidate` command also triggers this manually.
49
24
 
50
- Memories are stored as markdown with YAML frontmatter:
25
+ Memory registers with the info-screen dashboard, showing project memory count, total count, and consolidation count. The footer subscribes to `MEMORY_STORED`, `MEMORY_DELETED`, and `MEMORYCONSOLIDATED` events to display memory stats.
26
+
27
+ ## Agent Tools
28
+
29
+ | Tool | Scope | Description |
30
+ |------|-------|-------------|
31
+ | `memory_store` | Project | Store or update a memory |
32
+ | `memory_search` | Project | Search memories by query |
33
+ | `memory_delete` | Project | Delete memory by ID or title |
34
+ | `memory_list` | Project | List all project memories |
35
+ | `global_memory_store` | Global | Store or update global memory |
36
+ | `global_memory_search` | Global | Search global memories |
37
+ | `global_memory_list` | Global | List all global memories |
38
+
39
+ The agent uses `memory_store` when it learns something worth remembering — a user preference, a technical decision, a code pattern. `memory_search` is used to recall relevant context before answering questions.
40
+
41
+ ## Memory Format
42
+
43
+ Memories are markdown files with YAML frontmatter:
51
44
 
52
45
  ```markdown
53
46
  ---
@@ -65,16 +58,18 @@ User prefers short-lived access tokens (15min) with long-lived refresh tokens (3
65
58
  Always implement token rotation on refresh.
66
59
  ```
67
60
 
68
- ## Naming Convention
61
+ ### Naming Convention
69
62
 
70
- **Format:** `<most_important>_<less_important>_<lesser>`
63
+ Format: `<most_important>_<less_important>_<lesser>`
71
64
 
72
65
  Examples:
73
66
  - `auth_jwt_prefer_refresh_tokens`
74
67
  - `db_postgres_use_connection_pooling`
75
68
  - `style_typescript_strict_mode_always`
76
69
 
77
- ## Storage Layout
70
+ ## Configurables
71
+
72
+ Memory has no configuration file. Storage paths are fixed:
78
73
 
79
74
  ```
80
75
  ~/.unipi/memory/
@@ -88,7 +83,11 @@ Examples:
88
83
 
89
84
  ## Dependencies
90
85
 
91
- - `better-sqlite3` - SQLite database
92
- - `sqlite-vec` - Vector search extension
93
- - `js-yaml` - YAML frontmatter parsing
94
- - `@pi-unipi/core` - Shared utilities
86
+ - `better-sqlite3` SQLite database
87
+ - `sqlite-vec` Vector search extension
88
+ - `js-yaml` YAML frontmatter parsing
89
+ - `@pi-unipi/core` Shared utilities
90
+
91
+ ## License
92
+
93
+ MIT
package/embedding.ts CHANGED
@@ -55,7 +55,7 @@ export async function generateEmbedding(
55
55
  try {
56
56
  const truncated = text.slice(0, 8000); // OpenRouter/OpenAI limit ~8192 tokens
57
57
 
58
- const body: any = {
58
+ const body: Record<string, unknown> = {
59
59
  model: config.model,
60
60
  input: truncated,
61
61
  };
@@ -99,8 +99,8 @@ export async function generateEmbedding(
99
99
  }
100
100
 
101
101
  return vec;
102
- } catch (err: any) {
103
- if (err?.name === "TimeoutError") {
102
+ } catch (err: unknown) {
103
+ if (err instanceof Error && err.name === "TimeoutError") {
104
104
  // Removed console.warn — timeout causes fallback to fuzzy search.
105
105
  } else {
106
106
  // Removed console.warn — embedding error causes fallback to fuzzy search.
@@ -128,7 +128,7 @@ export async function generateEmbeddingsBatch(
128
128
  try {
129
129
  const truncated = texts.map((t) => t.slice(0, 8000));
130
130
 
131
- const body: any = {
131
+ const body: Record<string, unknown> = {
132
132
  model: config.model,
133
133
  input: truncated,
134
134
  };
@@ -153,10 +153,10 @@ export async function generateEmbeddingsBatch(
153
153
  return texts.map(() => null);
154
154
  }
155
155
 
156
- const data = await response.json() as any;
156
+ const data = await response.json() as { data?: Array<{ embedding?: number[] }> };
157
157
  const dims = config.dimensions;
158
158
 
159
- return (data?.data || []).map((item: any) => {
159
+ return (data?.data || []).map((item) => {
160
160
  if (!Array.isArray(item.embedding)) return null;
161
161
  const vec = new Float32Array(dims);
162
162
  for (let i = 0; i < Math.min(item.embedding.length, dims); i++) {
@@ -233,7 +233,7 @@ export function bufferToVector(buf: Buffer): Float32Array {
233
233
  /**
234
234
  * Check if embeddings are available (sqlite-vec loaded).
235
235
  */
236
- export function hasEmbeddings(db: any): boolean {
236
+ export function hasEmbeddings(db: { prepare(sql: string): { get(...args: unknown[]): unknown } }): boolean {
237
237
  try {
238
238
  db.prepare("SELECT * FROM memories_vec LIMIT 1").get();
239
239
  return true;
package/index.ts CHANGED
@@ -17,8 +17,7 @@ import {
17
17
 
18
18
  // Get info registry from global (avoids direct import issues with pi's extension loading)
19
19
  function getInfoRegistry() {
20
- const g = globalThis as any;
21
- return g.__unipi_info_registry;
20
+ return globalThis.__unipi_info_registry;
22
21
  }
23
22
  import {
24
23
  MemoryStorage,
@@ -61,7 +60,10 @@ export default function (pi: ExtensionAPI) {
61
60
  });
62
61
 
63
62
  // Register tools and commands
64
- registerMemoryTools(pi, getStorage, () => { recallDone = true; storeDone = true; });
63
+ registerMemoryTools(pi, getStorage, {
64
+ onRecall: () => { recallDone = true; },
65
+ onStore: () => { storeDone = true; },
66
+ });
65
67
  registerMemoryCommands(pi, getStorage);
66
68
 
67
69
  // Session lifecycle
@@ -186,10 +188,22 @@ export default function (pi: ExtensionAPI) {
186
188
  });
187
189
 
188
190
  // Inject memory recall reminder at agent start (hidden message, not system prompt)
189
- pi.on("before_agent_start", async (event, ctx) => {
191
+ pi.on("before_agent_start", async (_event, ctx) => {
190
192
  if (recallDone) return;
191
193
  if (!projectStorage) return;
192
194
 
195
+ // Workflow sandboxes and user presets can change the active tool set. Only
196
+ // instruct the agent to use memory tools that are actually callable now.
197
+ const activeTools = new Set(pi.getActiveTools());
198
+ const canSearch = activeTools.has(MEMORY_TOOLS.SEARCH) || activeTools.has(GLOBAL_SEARCH_ALIAS);
199
+ const canStore = activeTools.has(MEMORY_TOOLS.STORE);
200
+
201
+ if (!canSearch && !canStore) {
202
+ recallDone = true;
203
+ storeDone = true;
204
+ return;
205
+ }
206
+
193
207
  const projectName = getProjectName(ctx.cwd);
194
208
  let projectMemories: Array<{ id: string; title: string; type: string }> = [];
195
209
  try {
@@ -199,31 +213,49 @@ export default function (pi: ExtensionAPI) {
199
213
  return;
200
214
  }
201
215
 
202
- if (projectMemories.length === 0) {
203
- recallDone = true; // Nothing to recall, skip
216
+ if (projectMemories.length === 0 && !canStore) {
217
+ recallDone = true; // Nothing to recall and no store tool available
204
218
  return;
205
219
  }
206
220
 
207
- const titleList = projectMemories.slice(0, 20).map(m => `- ${m.title}`).join("\n");
208
- const extra = projectMemories.length > 20 ? `\n... and ${projectMemories.length - 20} more` : "";
221
+ const lines = [
222
+ "## 🧠 Memory System Active",
223
+ "",
224
+ `You have ${projectMemories.length} memories stored for project "${projectName}".`,
225
+ ];
226
+
227
+ if (canSearch && projectMemories.length > 0) {
228
+ const titleList = projectMemories.slice(0, 20).map(m => `- ${m.title}`).join("\n");
229
+ const extra = projectMemories.length > 20 ? `\n... and ${projectMemories.length - 20} more` : "";
230
+ lines.push(
231
+ "**BEFORE starting work**, call `memory_search` with relevant keywords to check for existing context.",
232
+ "",
233
+ "Available memories:",
234
+ titleList + extra,
235
+ );
236
+ } else {
237
+ recallDone = true;
238
+ }
239
+
240
+ if (canStore) {
241
+ lines.push(
242
+ "",
243
+ "**AFTER completing the task**, if you learned something non-obvious,",
244
+ "call `memory_store` to save it for future sessions.",
245
+ );
246
+ } else {
247
+ storeDone = true;
248
+ }
249
+
250
+ lines.push(
251
+ "",
252
+ "Guardrails: read max 10 memory results per search. Update existing memories instead of creating duplicates.",
253
+ );
209
254
 
210
255
  return {
211
256
  message: {
212
257
  customType: "unipi-memory-recall-reminder",
213
- content: [
214
- "## 🧠 Memory System Active",
215
- "",
216
- `You have ${projectMemories.length} memories stored for project "${projectName}".`,
217
- "**BEFORE starting work**, call `memory_search` with relevant keywords to check for existing context.",
218
- "",
219
- "Available memories:",
220
- titleList + extra,
221
- "",
222
- "**AFTER completing the task**, if you learned something non-obvious,",
223
- "call `memory_store` to save it for future sessions.",
224
- "",
225
- "Guardrails: read max 10 memory results per search. Update existing memories instead of creating duplicates.",
226
- ].join("\n"),
258
+ content: lines.join("\n"),
227
259
  display: false,
228
260
  },
229
261
  };
@@ -232,6 +264,7 @@ export default function (pi: ExtensionAPI) {
232
264
  // After each agent response, remind LLM to save if it hasn't yet
233
265
  pi.on("agent_end", async (_event, _ctx) => {
234
266
  if (storeDone || !recallDone) return;
267
+ if (!pi.getActiveTools().includes(MEMORY_TOOLS.STORE)) return;
235
268
 
236
269
  pi.sendMessage(
237
270
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/memory",
3
- "version": "0.1.13",
3
+ "version": "2.0.0",
4
4
  "description": "Persistent cross-session memory with vector search for Pi coding agent",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/storage.ts CHANGED
@@ -13,6 +13,29 @@ import * as path from "node:path";
13
13
  import * as os from "node:os";
14
14
  import { randomUUID } from "node:crypto";
15
15
 
16
+ /** Memory row from SQLite queries */
17
+ interface MemoryRow {
18
+ id: string;
19
+ title: string;
20
+ content?: string;
21
+ type?: string;
22
+ project?: string;
23
+ tags?: string;
24
+ created?: string;
25
+ updated?: string;
26
+ embedding?: { buffer: ArrayBuffer };
27
+ }
28
+
29
+ /** Search result row from vector queries */
30
+ interface SearchResultRow {
31
+ id: string;
32
+ title: string;
33
+ distance: number;
34
+ rowid?: number;
35
+ title_match?: number;
36
+ content_match?: number;
37
+ }
38
+
16
39
  /** Memory record interface */
17
40
  export interface MemoryRecord {
18
41
  id: string;
@@ -208,12 +231,14 @@ export class MemoryStorage {
208
231
  try {
209
232
  this.initDb(dbPath);
210
233
  return; // Success
211
- } catch (err: any) {
234
+ } catch (err: unknown) {
235
+ const errMsg = err instanceof Error ? err.message : "";
236
+ const errCode = (err instanceof Error && 'code' in err) ? (err as NodeJS.ErrnoException).code : undefined;
212
237
  const isTransient =
213
- err?.message?.includes("disk I/O error") ||
214
- err?.code === "SQLITE_IOERR" ||
215
- err?.code === "SQLITE_BUSY" ||
216
- err?.message?.includes("database is locked");
238
+ errMsg.includes("disk I/O error") ||
239
+ errCode === "SQLITE_IOERR" ||
240
+ errCode === "SQLITE_BUSY" ||
241
+ errMsg.includes("database is locked");
217
242
 
218
243
  this.close();
219
244
 
@@ -433,7 +458,7 @@ export class MemoryStorage {
433
458
 
434
459
  // Get existing IDs from DB
435
460
  const existingIds = new Set(
436
- (this.db.prepare("SELECT id FROM memories").all() as any[])
461
+ (this.db.prepare("SELECT id FROM memories").all() as MemoryRow[])
437
462
  .map(r => r.id)
438
463
  );
439
464
 
@@ -494,7 +519,7 @@ export class MemoryStorage {
494
519
  findSimilarByTitle(title: string, threshold = 0.6): Array<{ record: MemoryRecord; similarity: number }> {
495
520
  if (!this.db) throw new Error("Storage not initialized");
496
521
 
497
- const allRows = this.db.prepare("SELECT id, title FROM memories").all() as any[];
522
+ const allRows = this.db.prepare("SELECT id, title FROM memories").all() as MemoryRow[];
498
523
  const results: Array<{ record: MemoryRecord; similarity: number }> = [];
499
524
 
500
525
  const normalizedTitle = title.toLowerCase().replace(/[^a-z0-9]+/g, " ");
@@ -526,18 +551,18 @@ export class MemoryStorage {
526
551
  getById(id: string): MemoryRecord | null {
527
552
  if (!this.db) throw new Error("Storage not initialized");
528
553
 
529
- const row = this.db.prepare("SELECT * FROM memories WHERE id = ?").get(id) as any;
554
+ const row = this.db.prepare("SELECT * FROM memories WHERE id = ?").get(id) as MemoryRow | undefined;
530
555
  if (!row) return null;
531
556
 
532
557
  return {
533
558
  id: row.id,
534
559
  title: row.title,
535
- content: row.content,
560
+ content: row.content ?? "",
536
561
  tags: JSON.parse(row.tags || "[]"),
537
- project: row.project,
538
- type: row.type,
539
- created: row.created,
540
- updated: row.updated,
562
+ project: row.project ?? "",
563
+ type: (row.type ?? "summary") as MemoryRecord["type"],
564
+ created: row.created ?? "",
565
+ updated: row.updated ?? "",
541
566
  embedding: row.embedding ? new Float32Array(row.embedding.buffer) : null,
542
567
  };
543
568
  }
@@ -549,34 +574,34 @@ export class MemoryStorage {
549
574
  if (!this.db) throw new Error("Storage not initialized");
550
575
 
551
576
  // Try exact match first
552
- const exact = this.db.prepare("SELECT * FROM memories WHERE title = ?").get(title) as any;
577
+ const exact = this.db.prepare("SELECT * FROM memories WHERE title = ?").get(title) as MemoryRow | undefined;
553
578
  if (exact) {
554
579
  return {
555
580
  id: exact.id,
556
581
  title: exact.title,
557
- content: exact.content,
582
+ content: exact.content ?? "",
558
583
  tags: JSON.parse(exact.tags || "[]"),
559
- project: exact.project,
560
- type: exact.type,
561
- created: exact.created,
562
- updated: exact.updated,
584
+ project: exact.project ?? "",
585
+ type: (exact.type ?? "summary") as MemoryRecord["type"],
586
+ created: exact.created ?? "",
587
+ updated: exact.updated ?? "",
563
588
  embedding: exact.embedding ? new Float32Array(exact.embedding.buffer) : null,
564
589
  };
565
590
  }
566
591
 
567
592
  // Try case-insensitive match
568
- const row = this.db.prepare("SELECT * FROM memories WHERE LOWER(title) = LOWER(?)").get(title) as any;
593
+ const row = this.db.prepare("SELECT * FROM memories WHERE LOWER(title) = LOWER(?)").get(title) as MemoryRow | undefined;
569
594
  if (!row) return null;
570
595
 
571
596
  return {
572
597
  id: row.id,
573
598
  title: row.title,
574
- content: row.content,
599
+ content: row.content ?? "",
575
600
  tags: JSON.parse(row.tags || "[]"),
576
- project: row.project,
577
- type: row.type,
578
- created: row.created,
579
- updated: row.updated,
601
+ project: row.project ?? "",
602
+ type: (row.type ?? "summary") as MemoryRecord["type"],
603
+ created: row.created ?? "",
604
+ updated: row.updated ?? "",
580
605
  embedding: row.embedding ? new Float32Array(row.embedding.buffer) : null,
581
606
  };
582
607
  }
@@ -587,8 +612,8 @@ export class MemoryStorage {
587
612
  listAll(): Array<{ id: string; title: string; type: string }> {
588
613
  if (!this.db) throw new Error("Storage not initialized");
589
614
 
590
- const rows = this.db.prepare("SELECT id, title, type FROM memories ORDER BY updated DESC").all() as any[];
591
- return rows.map((r) => ({ id: r.id, title: r.title, type: r.type }));
615
+ const rows = this.db.prepare("SELECT id, title, type FROM memories ORDER BY updated DESC").all() as MemoryRow[];
616
+ return rows.map((r) => ({ id: r.id, title: r.title, type: r.type ?? "" }));
592
617
  }
593
618
 
594
619
  /**
@@ -647,7 +672,7 @@ export class MemoryStorage {
647
672
  ORDER BY distance
648
673
  LIMIT ?`
649
674
  )
650
- .all(Buffer.from(embedding.buffer), limit * 2) as any[];
675
+ .all(Buffer.from(embedding.buffer), limit * 2) as SearchResultRow[];
651
676
 
652
677
  for (const vr of vecResults) {
653
678
  const memoryId = this.rowidToId(Number(vr.rowid));
@@ -685,11 +710,11 @@ export class MemoryStorage {
685
710
  `%${query}%`,
686
711
  ...queryWords.flatMap(w => [`%${w}%`, `%${w}%`]),
687
712
  limit * 2
688
- ) as any[];
713
+ ) as SearchResultRow[];
689
714
 
690
715
  for (const fr of fuzzyResults) {
691
716
  const existing = results.get(fr.id);
692
- const fuzzyScore = (fr.title_match * 0.7 + fr.content_match * 0.3);
717
+ const fuzzyScore = ((fr.title_match ?? 0) * 0.7 + (fr.content_match ?? 0) * 0.3);
693
718
  const record = this.getById(fr.id);
694
719
  if (record) {
695
720
  const snippet = this.extractSnippet(record.content, query);
package/tools.ts CHANGED
@@ -29,14 +29,21 @@ export const MEMORY_TOOLS = {
29
29
  // Keep old name as alias for backward compat
30
30
  export const GLOBAL_SEARCH_ALIAS = "global_memory_search";
31
31
 
32
+ export interface MemoryToolActivityCallbacks {
33
+ /** Called when a recall-style tool is used (search/list). */
34
+ onRecall?: () => void;
35
+ /** Called when memory state is changed (store/delete). */
36
+ onStore?: () => void;
37
+ }
38
+
32
39
  /**
33
40
  * Register memory tools.
34
- * @param onActivity - called when recall/store happens (marks lifecycle state)
41
+ * @param activity - callbacks for lifecycle reminders
35
42
  */
36
43
  export function registerMemoryTools(
37
44
  pi: ExtensionAPI,
38
45
  getStorage: () => MemoryStorage,
39
- onActivity?: () => void
46
+ activity?: MemoryToolActivityCallbacks
40
47
  ): void {
41
48
  // --- memory_store tool ---
42
49
  pi.registerTool({
@@ -69,8 +76,8 @@ export function registerMemoryTools(
69
76
  ),
70
77
  }),
71
78
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
79
+ activity?.onStore?.(); // Mark store as done for lifecycle
72
80
  const storage = getStorage();
73
- onActivity?.(); // Mark store as done for lifecycle
74
81
 
75
82
  // Step 1: Check for exact duplicate
76
83
  const existing = storage.getByTitle(params.title);
@@ -224,7 +231,7 @@ export function registerMemoryTools(
224
231
  ),
225
232
  }),
226
233
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
227
- onActivity?.(); // Mark recall as done for lifecycle
234
+ activity?.onRecall?.(); // Mark recall as done for lifecycle
228
235
  const limit = params.limit || 10;
229
236
  const scope = (params as any).scope || "all";
230
237
 
@@ -285,7 +292,7 @@ export function registerMemoryTools(
285
292
  ),
286
293
  }),
287
294
  async execute(_toolCallId, params, _signal, _onUpdate) {
288
- onActivity?.();
295
+ activity?.onRecall?.();
289
296
  const results = searchAllProjects(params.query, params.limit || 10);
290
297
 
291
298
  if (results.length === 0) {
@@ -317,6 +324,7 @@ export function registerMemoryTools(
317
324
  id: Type.Optional(Type.String({ description: "Memory ID to delete" })),
318
325
  }),
319
326
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
327
+ activity?.onStore?.();
320
328
  const storage = getStorage();
321
329
 
322
330
  let deleted = false;
@@ -348,6 +356,7 @@ export function registerMemoryTools(
348
356
  promptSnippet: "List all project memories.",
349
357
  parameters: Type.Object({}),
350
358
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
359
+ activity?.onRecall?.();
351
360
  const storage = getStorage();
352
361
  const memories = storage.listAll();
353
362
 
@@ -384,6 +393,7 @@ export function registerMemoryTools(
384
393
  promptSnippet: "List all memories across projects.",
385
394
  parameters: Type.Object({}),
386
395
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
396
+ activity?.onRecall?.();
387
397
  const memories = listAllProjects();
388
398
 
389
399
  if (memories.length === 0) {