@pi-unipi/memory 0.1.4 → 0.1.5

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/commands.ts CHANGED
@@ -7,6 +7,8 @@
7
7
 
8
8
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
9
  import { MemoryStorage, searchAllProjects, listAllProjects } from "./storage.js";
10
+ import { showMemorySettings } from "./tui/settings-tui.js";
11
+ import { isEmbeddingReady, hasModelChanged, loadEmbeddingConfig } from "./settings.js";
10
12
 
11
13
  /**
12
14
  * Register memory commands.
@@ -175,4 +177,28 @@ For each item, use the memory_store tool to save it with an appropriate title an
175
177
  );
176
178
  },
177
179
  });
180
+
181
+ // --- /unipi:memory-settings ---
182
+ pi.registerCommand("unipi:memory-settings", {
183
+ description: "Configure embedding provider and model for vector search",
184
+ handler: async (_args, ctx) => {
185
+ // Quick status if called with no TUI
186
+ if (!ctx.hasUI) {
187
+ const config = loadEmbeddingConfig();
188
+ const ready = isEmbeddingReady();
189
+ const migrated = hasModelChanged();
190
+ ctx.ui.notify(
191
+ `Embedding: ${ready ? "✓ Ready" : "✗ Not configured"}\n` +
192
+ `Provider: ${config.provider}\n` +
193
+ `Model: ${config.model}\n` +
194
+ `Dimensions: ${config.dimensions}\n` +
195
+ (migrated ? "⚠ Model changed — re-embed needed" : ""),
196
+ "info"
197
+ );
198
+ return;
199
+ }
200
+
201
+ await showMemorySettings(pi);
202
+ },
203
+ });
178
204
  }
package/embedding.ts CHANGED
@@ -1,22 +1,220 @@
1
1
  /**
2
2
  * @unipi/memory — Embedding generation
3
3
  *
4
- * Placeholder for future embedding support.
5
- * Currently uses fuzzy text search only.
4
+ * Primary: OpenRouter API (openai/text-embedding-3-small)
5
+ * Fallback: fuzzy-only mode (returns null)
6
+ *
7
+ * Embedding dimensions default to 384 for sqlite-vec compatibility.
8
+ * openai/text-embedding-3 supports custom dimensions via API param.
6
9
  */
7
10
 
11
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
12
+ import {
13
+ loadEmbeddingConfig,
14
+ getApiKey,
15
+ markModelUsed,
16
+ isEmbeddingReady,
17
+ type EmbeddingConfig,
18
+ } from "./settings.js";
19
+
20
+ /** Cached config to avoid reading file on every call */
21
+ let cachedConfig: EmbeddingConfig | null = null;
22
+ let lastConfigLoad = 0;
23
+ const CONFIG_CACHE_MS = 30_000; // 30 seconds
24
+
25
+ function getConfig(): EmbeddingConfig {
26
+ const now = Date.now();
27
+ if (!cachedConfig || now - lastConfigLoad > CONFIG_CACHE_MS) {
28
+ cachedConfig = loadEmbeddingConfig();
29
+ lastConfigLoad = now;
30
+ }
31
+ return cachedConfig;
32
+ }
33
+
34
+ /** Force refresh config cache */
35
+ export function refreshConfig(): void {
36
+ cachedConfig = null;
37
+ lastConfigLoad = 0;
38
+ }
39
+
8
40
  /**
9
- * Generate an embedding for the given text.
10
- * Returns null (fuzzy-only mode).
11
- *
12
- * Future: Use LLM or local model for embeddings.
41
+ * Generate an embedding for the given text via OpenRouter API.
42
+ * Returns null if not configured or on error.
13
43
  */
14
44
  export async function generateEmbedding(
15
- _text: string,
16
- _ai?: any
45
+ text: string,
46
+ _ai?: ExtensionAPI | any
17
47
  ): Promise<Float32Array | null> {
18
- // Fuzzy-only mode for now
19
- return null;
48
+ const config = getConfig();
49
+ const apiKey = getApiKey();
50
+
51
+ if (config.provider !== "openrouter" || !apiKey || !config.model) {
52
+ return null; // Fuzzy-only mode
53
+ }
54
+
55
+ try {
56
+ const truncated = text.slice(0, 8000); // OpenRouter/OpenAI limit ~8192 tokens
57
+
58
+ const body: any = {
59
+ model: config.model,
60
+ input: truncated,
61
+ };
62
+
63
+ // openai/text-embedding-3 supports custom dimensions
64
+ // ada-002 does NOT — only add if not ada
65
+ if (!config.model.includes("ada-002")) {
66
+ body.dimensions = config.dimensions;
67
+ }
68
+
69
+ const response = await fetch("https://openrouter.ai/api/v1/embeddings", {
70
+ method: "POST",
71
+ headers: {
72
+ "Authorization": `Bearer ${apiKey}`,
73
+ "Content-Type": "application/json",
74
+ "HTTP-Referer": "https://github.com/Neuron-Mr-White/unipi",
75
+ "X-Title": "unipi-memory",
76
+ },
77
+ body: JSON.stringify(body),
78
+ signal: AbortSignal.timeout(15_000),
79
+ });
80
+
81
+ if (!response.ok) {
82
+ const errText = await response.text().catch(() => "unknown");
83
+ console.warn(`[unipi/memory] Embedding API error ${response.status}: ${errText}`);
84
+ return null;
85
+ }
86
+
87
+ const data = await response.json() as any;
88
+ const values = data?.data?.[0]?.embedding;
89
+
90
+ if (!Array.isArray(values)) {
91
+ console.warn("[unipi/memory] Unexpected embedding response format");
92
+ return null;
93
+ }
94
+
95
+ // Convert to Float32Array, truncate to configured dimensions
96
+ const dims = config.dimensions;
97
+ const vec = new Float32Array(dims);
98
+ for (let i = 0; i < Math.min(values.length, dims); i++) {
99
+ vec[i] = values[i];
100
+ }
101
+
102
+ return vec;
103
+ } catch (err: any) {
104
+ if (err?.name === "TimeoutError") {
105
+ console.warn("[unipi/memory] Embedding API timeout");
106
+ } else {
107
+ console.warn("[unipi/memory] Embedding error:", err?.message || err);
108
+ }
109
+ return null;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Generate embeddings for multiple texts in a single API call.
115
+ * More efficient than calling generateEmbedding() per text.
116
+ * Returns array of Float32Array (null for failures).
117
+ */
118
+ export async function generateEmbeddingsBatch(
119
+ texts: string[],
120
+ _ai?: ExtensionAPI | any
121
+ ): Promise<(Float32Array | null)[]> {
122
+ const config = getConfig();
123
+ const apiKey = getApiKey();
124
+
125
+ if (config.provider !== "openrouter" || !apiKey || !config.model) {
126
+ return texts.map(() => null);
127
+ }
128
+
129
+ try {
130
+ const truncated = texts.map((t) => t.slice(0, 8000));
131
+
132
+ const body: any = {
133
+ model: config.model,
134
+ input: truncated,
135
+ };
136
+
137
+ if (!config.model.includes("ada-002")) {
138
+ body.dimensions = config.dimensions;
139
+ }
140
+
141
+ const response = await fetch("https://openrouter.ai/api/v1/embeddings", {
142
+ method: "POST",
143
+ headers: {
144
+ "Authorization": `Bearer ${apiKey}`,
145
+ "Content-Type": "application/json",
146
+ "HTTP-Referer": "https://github.com/Neuron-Mr-White/unipi",
147
+ "X-Title": "unipi-memory",
148
+ },
149
+ body: JSON.stringify(body),
150
+ signal: AbortSignal.timeout(30_000),
151
+ });
152
+
153
+ if (!response.ok) {
154
+ return texts.map(() => null);
155
+ }
156
+
157
+ const data = await response.json() as any;
158
+ const dims = config.dimensions;
159
+
160
+ return (data?.data || []).map((item: any) => {
161
+ if (!Array.isArray(item.embedding)) return null;
162
+ const vec = new Float32Array(dims);
163
+ for (let i = 0; i < Math.min(item.embedding.length, dims); i++) {
164
+ vec[i] = item.embedding[i];
165
+ }
166
+ return vec;
167
+ });
168
+ } catch {
169
+ return texts.map(() => null);
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Re-embed all memories across all projects.
175
+ * Returns count of successfully re-embedded memories.
176
+ */
177
+ export async function reembedAllMemories(pi: ExtensionAPI): Promise<number> {
178
+ const { getAllProjectDirs, MemoryStorage } = await import("./storage.js");
179
+ const projectDirs = getAllProjectDirs();
180
+ let count = 0;
181
+
182
+ for (const { name: projectName, dir } of projectDirs) {
183
+ try {
184
+ const storage = new MemoryStorage(projectName);
185
+ storage.init();
186
+
187
+ const memories = storage.listAll();
188
+ if (memories.length === 0) {
189
+ storage.close();
190
+ continue;
191
+ }
192
+
193
+ // Load full records
194
+ const fullRecords = memories
195
+ .map((m) => storage.getById(m.id))
196
+ .filter((r): r is NonNullable<typeof r> => r !== null);
197
+
198
+ // Generate embeddings in batch
199
+ const texts = fullRecords.map((r) => `${r.title} ${r.content}`);
200
+ const embeddings = await generateEmbeddingsBatch(texts, pi);
201
+
202
+ // Update records
203
+ for (let i = 0; i < fullRecords.length; i++) {
204
+ if (embeddings[i]) {
205
+ fullRecords[i].embedding = embeddings[i];
206
+ storage.store(fullRecords[i]);
207
+ count++;
208
+ }
209
+ }
210
+
211
+ storage.close();
212
+ } catch (err) {
213
+ console.warn(`[unipi/memory] Failed to re-embed project ${projectName}:`, err);
214
+ }
215
+ }
216
+
217
+ return count;
20
218
  }
21
219
 
22
220
  /**
package/index.ts CHANGED
@@ -26,8 +26,9 @@ import {
26
26
  searchAllProjects,
27
27
  listAllProjects,
28
28
  } from "./storage.js";
29
- import { registerMemoryTools, MEMORY_TOOLS } from "./tools.js";
29
+ import { registerMemoryTools, MEMORY_TOOLS, GLOBAL_SEARCH_ALIAS } from "./tools.js";
30
30
  import { registerMemoryCommands } from "./commands.js";
31
+ import { isEmbeddingReady, hasModelChanged } from "./settings.js";
31
32
 
32
33
  /** Package version */
33
34
  const VERSION = getPackageVersion(new URL(".", import.meta.url).pathname);
@@ -47,6 +48,10 @@ function getStorage(): MemoryStorage {
47
48
  }
48
49
 
49
50
  export default function (pi: ExtensionAPI) {
51
+ // Lifecycle state — tracks whether recall/store have happened this session
52
+ let recallDone = false;
53
+ let storeDone = false;
54
+
50
55
  // Register skills directory
51
56
  const skillsDir = new URL("./skills", import.meta.url).pathname;
52
57
  pi.on("resources_discover", async (_event, _ctx) => {
@@ -56,11 +61,15 @@ export default function (pi: ExtensionAPI) {
56
61
  });
57
62
 
58
63
  // Register tools and commands
59
- registerMemoryTools(pi, getStorage);
64
+ registerMemoryTools(pi, getStorage, () => { recallDone = true; storeDone = true; });
60
65
  registerMemoryCommands(pi, getStorage);
61
66
 
62
67
  // Session lifecycle
63
68
  pi.on("session_start", async (_event, ctx) => {
69
+ // Reset lifecycle flags
70
+ recallDone = false;
71
+ storeDone = false;
72
+
64
73
  // Initialize project storage
65
74
  const projectName = getProjectName(ctx.cwd);
66
75
  projectStorage = new MemoryStorage(projectName);
@@ -78,13 +87,14 @@ export default function (pi: ExtensionAPI) {
78
87
  "unipi:memory-forget",
79
88
  "unipi:global-memory-search",
80
89
  "unipi:global-memory-list",
90
+ "unipi:memory-settings",
81
91
  ],
82
92
  tools: [
83
93
  MEMORY_TOOLS.STORE,
84
94
  MEMORY_TOOLS.SEARCH,
85
95
  MEMORY_TOOLS.DELETE,
86
96
  MEMORY_TOOLS.LIST,
87
- MEMORY_TOOLS.GLOBAL_SEARCH,
97
+ GLOBAL_SEARCH_ALIAS,
88
98
  MEMORY_TOOLS.GLOBAL_LIST,
89
99
  ],
90
100
  });
@@ -146,59 +156,76 @@ export default function (pi: ExtensionAPI) {
146
156
  const projectCount = projectStorage.listAll().length;
147
157
  const allMemories = listAllProjects();
148
158
  const projectCountAll = allMemories.length;
159
+ const vecReady = isEmbeddingReady();
160
+ const vecIcon = vecReady ? "⚡" : "📝";
149
161
  ctx.ui.setStatus(
150
162
  "unipi-memory",
151
- `🧠 memory ${projectCount}p/${projectCountAll}all`
163
+ `${vecIcon} memory ${projectCount}p/${projectCountAll}all${hasModelChanged() ? " ⚠" : ""}`
152
164
  );
153
165
  }
154
166
  });
155
167
 
156
- // Inject memory titles at session start
168
+ // Inject memory recall reminder at agent start (hidden message, not system prompt)
157
169
  pi.on("before_agent_start", async (event, ctx) => {
170
+ if (recallDone) return;
158
171
  if (!projectStorage) return;
159
172
 
160
173
  const projectName = getProjectName(ctx.cwd);
161
174
  const projectMemories = projectStorage.listAll();
162
175
 
163
176
  if (projectMemories.length === 0) {
164
- return; // No memories to inject
165
- }
166
-
167
- let injection = "\n\n<memory>\n";
168
- injection += `Available memories for project "${projectName}":\n\n`;
169
-
170
- // Project memories
171
- for (const m of projectMemories) {
172
- injection += `- ${m.title}\n`;
177
+ recallDone = true; // Nothing to recall, skip
178
+ return;
173
179
  }
174
180
 
175
- injection += "\nUse memory_search to retrieve full content. Use memory_store to save new memories.\n";
176
- injection += "Use global_memory_search to search across ALL projects.\n";
177
- injection += "</memory>";
181
+ const titleList = projectMemories.slice(0, 20).map(m => `- ${m.title}`).join("\n");
182
+ const extra = projectMemories.length > 20 ? `\n... and ${projectMemories.length - 20} more` : "";
178
183
 
179
184
  return {
180
- systemPrompt: event.systemPrompt + injection,
185
+ message: {
186
+ customType: "unipi-memory-recall-reminder",
187
+ content: [
188
+ "## 🧠 Memory System Active",
189
+ "",
190
+ `You have ${projectMemories.length} memories stored for project "${projectName}".`,
191
+ "**BEFORE starting work**, call `memory_search` with relevant keywords to check for existing context.",
192
+ "",
193
+ "Available memories:",
194
+ titleList + extra,
195
+ "",
196
+ "**AFTER completing the task**, if you learned something non-obvious,",
197
+ "call `memory_store` to save it for future sessions.",
198
+ "",
199
+ "Guardrails: read max 10 memory results per search. Update existing memories instead of creating duplicates.",
200
+ ].join("\n"),
201
+ display: false,
202
+ },
181
203
  };
182
204
  });
183
205
 
184
- // Auto-consolidation on compaction
185
- pi.on("session_before_compact", async (event, ctx) => {
186
- const { preparation } = event;
187
-
188
- // Extract summary text
189
- const summary = preparation.previousSummary || "";
190
-
191
- if (!summary || summary.length < 100) {
192
- // Summary too short to extract memories from
193
- return;
194
- }
195
-
196
- // For now, just log that consolidation would happen
197
- // Future: Use LLM to extract memories
198
- console.log("[unipi/memory] Auto-consolidation triggered, summary length:", summary.length);
206
+ // After each agent response, remind LLM to save if it hasn't yet
207
+ pi.on("agent_end", async (_event, _ctx) => {
208
+ if (storeDone || !recallDone) return;
209
+
210
+ pi.sendMessage(
211
+ {
212
+ customType: "unipi-memory-retro-reminder",
213
+ content: [
214
+ "**🧠 Memory reminder:** If you learned something non-obvious in this task,",
215
+ "call `memory_store` to save it as a memory for future sessions.",
216
+ "Update existing memories instead of creating duplicates.",
217
+ ].join(" "),
218
+ display: false,
219
+ },
220
+ {
221
+ deliverAs: "nextTurn",
222
+ },
223
+ );
224
+ });
199
225
 
200
- // Don't modify the compaction summary - return unchanged
201
- return {};
226
+ // After compaction, reset recall state so reminder re-injects
227
+ pi.on("session_compact", async (_event, _ctx) => {
228
+ recallDone = false;
202
229
  });
203
230
 
204
231
  // Cleanup on shutdown
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/memory",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Persistent cross-session memory with vector search for Pi coding agent",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,8 +36,10 @@
36
36
  "storage.ts",
37
37
  "search.ts",
38
38
  "embedding.ts",
39
+ "settings.ts",
39
40
  "tools.ts",
40
41
  "commands.ts",
42
+ "tui/**/*",
41
43
  "skills/**/*",
42
44
  "README.md"
43
45
  ],
package/settings.ts ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * @unipi/memory — Embedding settings
3
+ *
4
+ * Manages embedding configuration: provider, model, API key.
5
+ * Stored in ~/.unipi/memory/config.json
6
+ */
7
+
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import * as os from "node:os";
11
+
12
+ /** Embedding provider type */
13
+ export type EmbeddingProvider = "openrouter" | "none";
14
+
15
+ /** Embedding configuration */
16
+ export interface EmbeddingConfig {
17
+ /** Provider for embeddings */
18
+ provider: EmbeddingProvider;
19
+ /** Model ID (e.g. "openai/text-embedding-3-small") */
20
+ model: string;
21
+ /** OpenRouter API key (encrypted or plaintext) */
22
+ apiKey?: string;
23
+ /** Embedding dimensions (default 384 for compatibility) */
24
+ dimensions: number;
25
+ /** Model that was used to generate existing embeddings */
26
+ lastModel?: string;
27
+ /** Whether to show migration warning on startup */
28
+ suppressMigrationWarning?: boolean;
29
+ }
30
+
31
+ /** Default configuration */
32
+ const DEFAULT_CONFIG: EmbeddingConfig = {
33
+ provider: "none",
34
+ model: "openai/text-embedding-3-small",
35
+ dimensions: 384,
36
+ suppressMigrationWarning: false,
37
+ };
38
+
39
+ /** Known embedding models on OpenRouter */
40
+ export const OPENROUTER_EMBEDDING_MODELS = [
41
+ {
42
+ id: "openai/text-embedding-3-small",
43
+ name: "OpenAI text-embedding-3-small",
44
+ dimensions: 1536,
45
+ costPer1k: "$0.00002",
46
+ description: "Fast, cheap, good quality. Supports custom dimensions.",
47
+ },
48
+ {
49
+ id: "openai/text-embedding-3-large",
50
+ name: "OpenAI text-embedding-3-large",
51
+ dimensions: 3072,
52
+ costPer1k: "$0.00013",
53
+ description: "Highest quality. Supports custom dimensions.",
54
+ },
55
+ {
56
+ id: "openai/text-embedding-ada-002",
57
+ name: "OpenAI text-embedding-ada-002 (legacy)",
58
+ dimensions: 1536,
59
+ costPer1k: "$0.0001",
60
+ description: "Legacy model. Does NOT support custom dimensions.",
61
+ },
62
+ ];
63
+
64
+ /** Get config file path */
65
+ function getConfigPath(): string {
66
+ return path.join(os.homedir(), ".unipi", "memory", "config.json");
67
+ }
68
+
69
+ /** Load embedding config */
70
+ export function loadEmbeddingConfig(): EmbeddingConfig {
71
+ const configPath = getConfigPath();
72
+ try {
73
+ if (fs.existsSync(configPath)) {
74
+ const raw = fs.readFileSync(configPath, "utf-8");
75
+ const parsed = JSON.parse(raw);
76
+ return { ...DEFAULT_CONFIG, ...parsed };
77
+ }
78
+ } catch {
79
+ // Ignore parse errors
80
+ }
81
+ return { ...DEFAULT_CONFIG };
82
+ }
83
+
84
+ /** Save embedding config */
85
+ export function saveEmbeddingConfig(config: EmbeddingConfig): void {
86
+ const configPath = getConfigPath();
87
+ const dir = path.dirname(configPath);
88
+ if (!fs.existsSync(dir)) {
89
+ fs.mkdirSync(dir, { recursive: true });
90
+ }
91
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
92
+ }
93
+
94
+ /** Update partial config */
95
+ export function updateEmbeddingConfig(partial: Partial<EmbeddingConfig>): EmbeddingConfig {
96
+ const config = loadEmbeddingConfig();
97
+ const updated = { ...config, ...partial };
98
+ saveEmbeddingConfig(updated);
99
+ return updated;
100
+ }
101
+
102
+ /** Check if embeddings are configured and usable */
103
+ export function isEmbeddingReady(): boolean {
104
+ const config = loadEmbeddingConfig();
105
+ return config.provider === "openrouter" && !!config.apiKey && !!config.model;
106
+ }
107
+
108
+ /** Check if model changed since last embedding generation */
109
+ export function hasModelChanged(): boolean {
110
+ const config = loadEmbeddingConfig();
111
+ if (!config.lastModel) return false;
112
+ return config.model !== config.lastModel;
113
+ }
114
+
115
+ /** Mark current model as the one used for embedding generation */
116
+ export function markModelUsed(): void {
117
+ updateEmbeddingConfig({ lastModel: loadEmbeddingConfig().model });
118
+ }
119
+
120
+ /** Get API key from env or config */
121
+ export function getApiKey(): string | undefined {
122
+ const config = loadEmbeddingConfig();
123
+ if (config.apiKey) return config.apiKey;
124
+ return process.env.OPENROUTER_API_KEY || process.env.OPEN_ROUTER_API_KEY;
125
+ }
126
+
127
+ /** Set API key */
128
+ export function setApiKey(key: string): void {
129
+ updateEmbeddingConfig({ apiKey: key, provider: "openrouter" });
130
+ }
131
+
132
+ /** Remove API key and reset provider */
133
+ export function clearApiKey(): void {
134
+ updateEmbeddingConfig({ apiKey: undefined, provider: "none" });
135
+ }
@@ -9,7 +9,6 @@ allowed-tools:
9
9
  - memory_search
10
10
  - memory_delete
11
11
  - memory_list
12
- - global_memory_store
13
12
  - global_memory_search
14
13
  - global_memory_list
15
14
  - read
@@ -87,16 +86,18 @@ memory_list()
87
86
  memory_delete(title: "auth_jwt_prefer_refresh_tokens")
88
87
  ```
89
88
 
90
- ## Project vs Cross-Project Search
89
+ ## Search Scope
91
90
 
92
- | Action | Scope | Tools |
93
- |--------|-------|-------|
91
+ `memory_search` searches ALL projects by default. Use `scope` param to narrow:
92
+
93
+ | Action | Scope | Tool |
94
+ |--------|-------|------|
94
95
  | **Store** | Always project-scoped | `memory_store` |
95
- | **Search this project** | Current project only | `memory_search` |
96
- | **Search all projects** | Cross-project | `global_memory_search` |
96
+ | **Search all projects** | Cross-project (default) | `memory_search(query)` or `memory_search(query, scope="all")` |
97
+ | **Search this project** | Current project only | `memory_search(query, scope="project")` |
97
98
  | **List all** | Cross-project | `global_memory_list` |
98
99
 
99
- **All memories are project-scoped.** When you store a memory, it belongs to the current project. Use `global_memory_search` to search across ALL projects when looking for past work or user preferences.
100
+ **All memories are project-scoped.** When you store a memory, it belongs to the current project. `memory_search` searches everything by default no need to call a separate global search.
100
101
 
101
102
  ## Update-First Principle
102
103
 
@@ -108,7 +109,27 @@ memory_delete(title: "auth_jwt_prefer_refresh_tokens")
108
109
 
109
110
  This prevents memory duplication and keeps memory clean.
110
111
 
111
- ## Consolidation
112
+ ## Vector Search (Embeddings)
113
+
114
+ Memory supports vector similarity search via OpenRouter API.
115
+
116
+ ### Setup
117
+ 1. Run `/unipi:memory-settings`
118
+ 2. Add your OpenRouter API key
119
+ 3. Select embedding model (default: `openai/text-embedding-3-small`)
120
+
121
+ ### How it works
122
+ - Embeddings are generated when storing/searching memories
123
+ - Search combines **vector similarity** + **fuzzy text matching** for best results
124
+ - Vector search finds semantically similar memories even without exact keyword matches
125
+
126
+ ### Model compatibility
127
+ ⚠ **Different embedding models produce incompatible vectors.**
128
+ If you switch models, existing embeddings won't match new searches.
129
+ Use `/unipi:memory-settings` → "Re-embed All Memories" to fix.
130
+
131
+ ### No API key?
132
+ Falls back to fuzzy text-only search. Still works, just less semantic.
112
133
 
113
134
  When the user runs `/unipi:memory-consolidate` or during compaction:
114
135
 
@@ -149,3 +170,4 @@ You can read these files directly with the `read` tool for full context.
149
170
  | Use vague titles | Use specific `<category>_<detail>` format |
150
171
  | Store in wrong scope | Project-specific = project scope, universal = global |
151
172
  | Forget to update | When context changes, update the memory |
173
+ | Switch embedding models without re-embedding | Re-embed or accept fuzzy-only fallback |
package/tools.ts CHANGED
@@ -23,28 +23,34 @@ export const MEMORY_TOOLS = {
23
23
  SEARCH: "memory_search",
24
24
  DELETE: "memory_delete",
25
25
  LIST: "memory_list",
26
- GLOBAL_SEARCH: "global_memory_search",
27
26
  GLOBAL_LIST: "global_memory_list",
28
27
  } as const;
29
28
 
29
+ // Keep old name as alias for backward compat
30
+ export const GLOBAL_SEARCH_ALIAS = "global_memory_search";
31
+
30
32
  /**
31
33
  * Register memory tools.
34
+ * @param onActivity - called when recall/store happens (marks lifecycle state)
32
35
  */
33
36
  export function registerMemoryTools(
34
37
  pi: ExtensionAPI,
35
- getStorage: () => MemoryStorage
38
+ getStorage: () => MemoryStorage,
39
+ onActivity?: () => void
36
40
  ): void {
37
41
  // --- memory_store tool ---
38
42
  pi.registerTool({
39
43
  name: MEMORY_TOOLS.STORE,
40
44
  label: "Store Memory",
41
45
  description:
42
- "Store or update a memory for cross-session recall. Use for user preferences, project decisions, code patterns, and conversation summaries.",
46
+ "IMPORTANT: Call at the END of every non-trivial task to save what you learned. " +
47
+ "Store or update a memory for cross-session recall — user preferences, project decisions, " +
48
+ "code patterns, and conversation summaries. Update existing memories instead of creating duplicates.",
43
49
  promptSnippet: "Store a memory for cross-session recall.",
44
50
  promptGuidelines: [
45
- "Use memory_store to remember important user preferences, decisions, patterns, or summaries.",
46
- "Memory is scoped to the current project.",
47
- "Update existing memories instead of creating duplicates.",
51
+ "IMPORTANT: Always call memory_store when you learn something non-obvious.",
52
+ "Search for existing similar memories first — update if found, create if not.",
53
+ "Memory is scoped to the current project. Use for decisions, preferences, patterns, summaries.",
48
54
  ],
49
55
  parameters: Type.Object({
50
56
  title: Type.String({
@@ -64,6 +70,7 @@ export function registerMemoryTools(
64
70
  }),
65
71
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
66
72
  const storage = getStorage();
73
+ onActivity?.(); // Mark store as done for lifecycle
67
74
 
68
75
  // Check if similar memory exists
69
76
  const existing = storage.getByTitle(params.title);
@@ -127,63 +134,112 @@ export function registerMemoryTools(
127
134
  },
128
135
  });
129
136
 
130
- // --- memory_search tool ---
137
+ // --- memory_search tool (unified: searches all projects by default) ---
131
138
  pi.registerTool({
132
139
  name: MEMORY_TOOLS.SEARCH,
133
140
  label: "Search Memory",
134
141
  description:
135
- "Search current project memories by keyword. Returns ranked results with snippets.",
136
- promptSnippet: "Search project memories for relevant context.",
142
+ "IMPORTANT: Call BEFORE starting work to check for existing context. " +
143
+ "Searches memories by keyword. Searches ALL projects by default — returns results with " +
144
+ "[project_name] prefix. Use scope='project' to limit to current project only.",
145
+ promptSnippet: "Search memories for relevant context before starting work.",
137
146
  promptGuidelines: [
138
- "Use memory_search before making decisions when you suspect past work exists.",
147
+ "IMPORTANT: Always call memory_search before making decisions when you suspect past work exists.",
148
+ "Searches all projects by default — no need to call a separate global search.",
139
149
  "Search for user preferences when setting up new features.",
140
150
  "Search for patterns when implementing similar functionality.",
141
- "Use global_memory_search to search across ALL projects.",
142
151
  ],
143
152
  parameters: Type.Object({
144
153
  query: Type.String({ description: "Search query" }),
145
154
  limit: Type.Optional(
146
155
  Type.Number({ description: "Max results (default 10)", default: 10 })
147
156
  ),
157
+ scope: Type.Optional(
158
+ Type.String({
159
+ description: "Search scope: 'all' (default, searches all projects) or 'project' (current project only)",
160
+ enum: ["all", "project"],
161
+ default: "all",
162
+ })
163
+ ),
148
164
  }),
149
165
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
150
- const storage = getStorage();
166
+ onActivity?.(); // Mark recall as done for lifecycle
167
+ const limit = params.limit || 10;
168
+ const scope = (params as any).scope || "all";
169
+
170
+ if (scope === "project") {
171
+ // Project-only search (original behavior)
172
+ const storage = getStorage();
173
+ const results = storage.search(params.query, limit);
174
+
175
+ if (results.length === 0) {
176
+ return {
177
+ content: [{ type: "text", text: `No memories found for: "${params.query}"` }],
178
+ details: { results: [] },
179
+ };
180
+ }
151
181
 
152
- const embedding = await generateEmbedding(params.query, pi);
182
+ const output = results
183
+ .map((r, i) => `${i + 1}. **${r.record.title}** (${r.record.type})\n ${r.snippet}`)
184
+ .join("\n\n");
153
185
 
154
- const results = hybridSearch(
155
- storage,
156
- params.query,
157
- params.limit || 10,
158
- embedding
159
- );
186
+ return {
187
+ content: [{ type: "text", text: `Found ${results.length} memories:\n\n${output}` }],
188
+ details: { results: results.map((r) => r.record.id) },
189
+ };
190
+ }
191
+
192
+ // Default: search ALL projects
193
+ const results = searchAllProjects(params.query, limit);
160
194
 
161
195
  if (results.length === 0) {
162
196
  return {
163
- content: [
164
- {
165
- type: "text",
166
- text: `No memories found for: "${params.query}"`,
167
- },
168
- ],
197
+ content: [{ type: "text", text: `No memories found across projects for: "${params.query}"` }],
169
198
  details: { results: [] },
170
199
  };
171
200
  }
172
201
 
173
202
  const output = results
174
- .map(
175
- (r, i) =>
176
- `${i + 1}. **${r.record.title}** (${r.record.type})\n ${r.snippet}`
177
- )
203
+ .map((r, i) => `${i + 1}. [${r.record.project}] **${r.record.title}** (${r.record.type})\n ${r.snippet}`)
178
204
  .join("\n\n");
179
205
 
180
206
  return {
181
- content: [
182
- {
183
- type: "text",
184
- text: `Found ${results.length} memories:\n\n${output}`,
185
- },
186
- ],
207
+ content: [{ type: "text", text: `Found ${results.length} memories across projects:\n\n${output}` }],
208
+ details: { results: results.map((r) => r.record.id) },
209
+ };
210
+ },
211
+ });
212
+
213
+ // --- global_memory_search alias (backward compat, delegates to memory_search) ---
214
+ pi.registerTool({
215
+ name: GLOBAL_SEARCH_ALIAS,
216
+ label: "Search All Projects",
217
+ description:
218
+ "Alias for memory_search with scope='all'. Searches memories across ALL projects.",
219
+ promptSnippet: "Search memories across all projects.",
220
+ parameters: Type.Object({
221
+ query: Type.String({ description: "Search query" }),
222
+ limit: Type.Optional(
223
+ Type.Number({ description: "Max results (default 10)", default: 10 })
224
+ ),
225
+ }),
226
+ async execute(_toolCallId, params, _signal, _onUpdate) {
227
+ onActivity?.();
228
+ const results = searchAllProjects(params.query, params.limit || 10);
229
+
230
+ if (results.length === 0) {
231
+ return {
232
+ content: [{ type: "text", text: `No memories found across projects for: "${params.query}"` }],
233
+ details: { results: [] },
234
+ };
235
+ }
236
+
237
+ const output = results
238
+ .map((r, i) => `${i + 1}. [${r.record.project}] **${r.record.title}** (${r.record.type})\n ${r.snippet}`)
239
+ .join("\n\n");
240
+
241
+ return {
242
+ content: [{ type: "text", text: `Found ${results.length} memories across projects:\n\n${output}` }],
187
243
  details: { results: results.map((r) => r.record.id) },
188
244
  };
189
245
  },
@@ -257,55 +313,7 @@ export function registerMemoryTools(
257
313
  },
258
314
  });
259
315
 
260
- // --- global_memory_search tool ---
261
- pi.registerTool({
262
- name: MEMORY_TOOLS.GLOBAL_SEARCH,
263
- label: "Search All Projects",
264
- description: "Search memories across ALL projects. Returns results with project names.",
265
- promptSnippet: "Search memories across all projects.",
266
- promptGuidelines: [
267
- "Use global_memory_search when looking for memories from other projects.",
268
- "Returns results with [project_name] prefix to identify source.",
269
- ],
270
- parameters: Type.Object({
271
- query: Type.String({ description: "Search query" }),
272
- limit: Type.Optional(
273
- Type.Number({ description: "Max results (default 10)", default: 10 })
274
- ),
275
- }),
276
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
277
- const results = searchAllProjects(params.query, params.limit || 10);
278
-
279
- if (results.length === 0) {
280
- return {
281
- content: [
282
- {
283
- type: "text",
284
- text: `No memories found across projects for: "${params.query}"`,
285
- },
286
- ],
287
- details: { results: [] },
288
- };
289
- }
290
-
291
- const output = results
292
- .map(
293
- (r, i) =>
294
- `${i + 1}. [${r.record.project}] **${r.record.title}** (${r.record.type})\n ${r.snippet}`
295
- )
296
- .join("\n\n");
297
316
 
298
- return {
299
- content: [
300
- {
301
- type: "text",
302
- text: `Found ${results.length} memories across projects:\n\n${output}`,
303
- },
304
- ],
305
- details: { results: results.map((r) => r.record.id) },
306
- };
307
- },
308
- });
309
317
 
310
318
  // --- global_memory_list tool ---
311
319
  pi.registerTool({
@@ -0,0 +1,301 @@
1
+ /**
2
+ * @unipi/memory — Settings TUI
3
+ *
4
+ * Interactive settings dialog for embedding configuration.
5
+ * Uses pi's UI primitives (select, input, notify).
6
+ */
7
+
8
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
+ import {
10
+ loadEmbeddingConfig,
11
+ saveEmbeddingConfig,
12
+ setApiKey,
13
+ clearApiKey,
14
+ getApiKey,
15
+ isEmbeddingReady,
16
+ hasModelChanged,
17
+ markModelUsed,
18
+ OPENROUTER_EMBEDDING_MODELS,
19
+ type EmbeddingConfig,
20
+ } from "../settings.js";
21
+
22
+ /** pi.ui type that's available when TUI is present */
23
+ type PiUI = {
24
+ select: (opts: { title: string; message: string; options: Array<{ label: string; value: string; description?: string }> }) => Promise<string | null | undefined>;
25
+ input: (opts: { title: string; message: string; placeholder?: string; validate?: (value: string) => Promise<string | null> }) => Promise<string | null | undefined>;
26
+ notify: (opts: { message: string; level: string }) => Promise<void>;
27
+ };
28
+
29
+ /**
30
+ * Show memory settings dialog.
31
+ * Main entry point for /unipi:memory-settings command.
32
+ */
33
+ export async function showMemorySettings(pi: ExtensionAPI): Promise<void> {
34
+ // Cast to access pi.ui which exists at runtime but isn't typed
35
+ const ui = (pi as any).ui as PiUI;
36
+ let running = true;
37
+
38
+ while (running) {
39
+ const config = loadEmbeddingConfig();
40
+ const hasKey = !!getApiKey();
41
+ const ready = isEmbeddingReady();
42
+
43
+ // Build status lines
44
+ const statusLines = [
45
+ `Provider: ${config.provider === "none" ? "None (fuzzy-only)" : "OpenRouter"}`,
46
+ `Model: ${config.model || "N/A"}`,
47
+ `Dimensions: ${config.dimensions}`,
48
+ `API Key: ${hasKey ? "✓ Set" : "✗ Not set"}`,
49
+ `Status: ${ready ? "✓ Ready" : "⚠ Not configured"}`,
50
+ ];
51
+
52
+ if (hasModelChanged() && !config.suppressMigrationWarning) {
53
+ statusLines.push("");
54
+ statusLines.push("⚠ Model changed — old embeddings incompatible.");
55
+ statusLines.push(" Re-embed to use vector search with new model.");
56
+ }
57
+
58
+ const options = [];
59
+
60
+ // API key management
61
+ if (hasKey) {
62
+ options.push({
63
+ label: "🔑 Update API Key",
64
+ value: "__update_key__",
65
+ description: "Update your OpenRouter API key",
66
+ });
67
+ options.push({
68
+ label: "🗑️ Remove API Key",
69
+ value: "__remove_key__",
70
+ description: "Remove API key and disable vector search",
71
+ });
72
+ } else {
73
+ options.push({
74
+ label: "🔑 Add API Key",
75
+ value: "__add_key__",
76
+ description: "Add OpenRouter API key to enable vector search",
77
+ });
78
+ }
79
+
80
+ // Model selection
81
+ options.push({
82
+ label: `📦 Select Model (current: ${config.model})`,
83
+ value: "__select_model__",
84
+ description: "Choose embedding model from OpenRouter",
85
+ });
86
+
87
+ // Dimensions
88
+ options.push({
89
+ label: `📐 Dimensions: ${config.dimensions}`,
90
+ value: "__dimensions__",
91
+ description: "Embedding dimensions (lower = faster, less storage)",
92
+ });
93
+
94
+ // Re-embed
95
+ if (ready && hasModelChanged()) {
96
+ options.push({
97
+ label: "🔄 Re-embed All Memories",
98
+ value: "__reembed__",
99
+ description: "Re-generate all embeddings with current model",
100
+ });
101
+ }
102
+
103
+ // Suppress warning
104
+ if (hasModelChanged() && !config.suppressMigrationWarning) {
105
+ options.push({
106
+ label: "🔕 Suppress Migration Warning",
107
+ value: "__suppress__",
108
+ description: "Hide the model change warning",
109
+ });
110
+ }
111
+
112
+ options.push({
113
+ label: "← Back",
114
+ value: "__exit__",
115
+ description: "Exit settings",
116
+ });
117
+
118
+ const selected = await ui.select({
119
+ title: "🧠 Memory Settings",
120
+ message: statusLines.join("\n"),
121
+ options,
122
+ });
123
+
124
+ if (!selected || selected === "__exit__") {
125
+ running = false;
126
+ continue;
127
+ }
128
+
129
+ switch (selected) {
130
+ case "__add_key__":
131
+ case "__update_key__":
132
+ await handleApiKeyInput(ui);
133
+ break;
134
+ case "__remove_key__":
135
+ clearApiKey();
136
+ await ui.notify({
137
+ message: "API key removed. Vector search disabled.",
138
+ level: "info",
139
+ });
140
+ break;
141
+ case "__select_model__":
142
+ await handleModelSelection(ui);
143
+ break;
144
+ case "__dimensions__":
145
+ await handleDimensionsInput(ui);
146
+ break;
147
+ case "__reembed__":
148
+ await handleReembed(ui, pi);
149
+ break;
150
+ case "__suppress__":
151
+ const cfg = loadEmbeddingConfig();
152
+ cfg.suppressMigrationWarning = true;
153
+ saveEmbeddingConfig(cfg);
154
+ await ui.notify({
155
+ message: "Migration warning suppressed.",
156
+ level: "info",
157
+ });
158
+ break;
159
+ }
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Handle API key input.
165
+ */
166
+ async function handleApiKeyInput(ui: PiUI): Promise<void> {
167
+ const key = await ui.input({
168
+ title: "OpenRouter API Key",
169
+ message: "Enter your OpenRouter API key (sk-or-v1-...):",
170
+ placeholder: "sk-or-v1-...",
171
+ validate: async (value: string) => {
172
+ if (!value || value.trim().length === 0) {
173
+ return "API key cannot be empty";
174
+ }
175
+ if (!value.startsWith("sk-or-") && !value.startsWith("sk-")) {
176
+ return "Key should start with sk-or- or sk-";
177
+ }
178
+ return null;
179
+ },
180
+ });
181
+
182
+ if (key) {
183
+ setApiKey(key.trim());
184
+ await ui.notify({
185
+ message: "API key saved. Vector search enabled.",
186
+ level: "success",
187
+ });
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Handle model selection.
193
+ */
194
+ async function handleModelSelection(ui: PiUI): Promise<void> {
195
+ const config = loadEmbeddingConfig();
196
+
197
+ const options = OPENROUTER_EMBEDDING_MODELS.map((m) => ({
198
+ label: `${m.name}${m.id === config.model ? " ✓" : ""}`,
199
+ value: m.id,
200
+ description: `${m.description} (${m.dimensions}d, ~${m.costPer1k}/1k tokens)`,
201
+ }));
202
+
203
+ // Add custom option
204
+ options.push({
205
+ label: "✏️ Custom Model ID",
206
+ value: "__custom__",
207
+ description: "Enter a custom OpenRouter model ID",
208
+ });
209
+
210
+ const selected = await ui.select({
211
+ title: "Select Embedding Model",
212
+ message: "Choose an embedding model. ⚠ Changing model invalidates existing embeddings.",
213
+ options,
214
+ });
215
+
216
+ if (!selected) return;
217
+
218
+ let modelId = selected;
219
+
220
+ if (selected === "__custom__") {
221
+ const custom = await ui.input({
222
+ title: "Custom Model ID",
223
+ message: "Enter the OpenRouter model ID:",
224
+ placeholder: "openai/text-embedding-3-small",
225
+ });
226
+ if (!custom) return;
227
+ modelId = custom.trim();
228
+ }
229
+
230
+ // Find model info for dimensions
231
+ const modelInfo = OPENROUTER_EMBEDDING_MODELS.find((m) => m.id === modelId);
232
+ const dimensions = modelInfo?.dimensions ?? 384;
233
+
234
+ config.model = modelId;
235
+ config.dimensions = dimensions;
236
+ saveEmbeddingConfig(config);
237
+
238
+ await ui.notify({
239
+ message: `Model set to ${modelId} (${dimensions}d).${hasModelChanged() ? " Re-embed existing memories to use new model." : ""}`,
240
+ level: "success",
241
+ });
242
+ }
243
+
244
+ /**
245
+ * Handle dimensions input.
246
+ */
247
+ async function handleDimensionsInput(ui: PiUI): Promise<void> {
248
+ const config = loadEmbeddingConfig();
249
+
250
+ const dimStr = await ui.input({
251
+ title: "Embedding Dimensions",
252
+ message: `Enter dimensions (default: 384). Lower = faster, less storage.\nNote: openai/text-embedding-3 supports 256-3072.\nada-002 only supports 1536.`,
253
+ placeholder: "384",
254
+ validate: async (value: string) => {
255
+ const num = parseInt(value, 10);
256
+ if (isNaN(num) || num < 64 || num > 3072) {
257
+ return "Must be a number between 64 and 3072";
258
+ }
259
+ return null;
260
+ },
261
+ });
262
+
263
+ if (dimStr) {
264
+ const dims = parseInt(dimStr, 10);
265
+ config.dimensions = dims;
266
+ saveEmbeddingConfig(config);
267
+
268
+ await ui.notify({
269
+ message: `Dimensions set to ${dims}. Re-embed existing memories to apply.`,
270
+ level: "success",
271
+ });
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Handle re-embedding all memories.
277
+ * This is a destructive operation — warns user first.
278
+ */
279
+ async function handleReembed(ui: PiUI, pi: ExtensionAPI): Promise<void> {
280
+ const confirm = await ui.select({
281
+ title: "Re-embed All Memories",
282
+ message: "⚠ This will re-generate ALL embeddings using the current model.\nOld embeddings will be overwritten.\nThis may take a while and costs API calls.",
283
+ options: [
284
+ { label: "Yes, re-embed all", value: "yes", description: "Proceed with re-embedding" },
285
+ { label: "Cancel", value: "no", description: "Abort" },
286
+ ],
287
+ });
288
+
289
+ if (confirm !== "yes") return;
290
+
291
+ // Import here to avoid circular deps
292
+ const { reembedAllMemories } = await import("../embedding.js");
293
+ const count = await reembedAllMemories(pi);
294
+
295
+ markModelUsed();
296
+
297
+ await ui.notify({
298
+ message: `Re-embedded ${count} memories with current model.`,
299
+ level: "success",
300
+ });
301
+ }