@pi-unipi/memory 0.1.4 → 0.1.6
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 +26 -0
- package/embedding.ts +208 -10
- package/index.ts +91 -41
- package/package.json +3 -1
- package/settings.ts +135 -0
- package/skills/memory/SKILL.md +30 -8
- package/storage.ts +85 -0
- package/tools.ts +91 -83
- package/tui/settings-tui.ts +301 -0
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
|
-
*
|
|
5
|
-
*
|
|
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
|
|
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
|
-
|
|
16
|
-
_ai?: any
|
|
45
|
+
text: string,
|
|
46
|
+
_ai?: ExtensionAPI | any
|
|
17
47
|
): Promise<Float32Array | null> {
|
|
18
|
-
|
|
19
|
-
|
|
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,15 +61,24 @@ 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);
|
|
67
|
-
|
|
76
|
+
try {
|
|
77
|
+
projectStorage.init();
|
|
78
|
+
} catch (err) {
|
|
79
|
+
console.warn("[unipi/memory] Failed to initialize storage, running without memory:", (err as any)?.message ?? err);
|
|
80
|
+
projectStorage = null;
|
|
81
|
+
}
|
|
68
82
|
|
|
69
83
|
|
|
70
84
|
// Announce module
|
|
@@ -78,13 +92,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
78
92
|
"unipi:memory-forget",
|
|
79
93
|
"unipi:global-memory-search",
|
|
80
94
|
"unipi:global-memory-list",
|
|
95
|
+
"unipi:memory-settings",
|
|
81
96
|
],
|
|
82
97
|
tools: [
|
|
83
98
|
MEMORY_TOOLS.STORE,
|
|
84
99
|
MEMORY_TOOLS.SEARCH,
|
|
85
100
|
MEMORY_TOOLS.DELETE,
|
|
86
101
|
MEMORY_TOOLS.LIST,
|
|
87
|
-
|
|
102
|
+
GLOBAL_SEARCH_ALIAS,
|
|
88
103
|
MEMORY_TOOLS.GLOBAL_LIST,
|
|
89
104
|
],
|
|
90
105
|
});
|
|
@@ -117,8 +132,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
117
132
|
};
|
|
118
133
|
}
|
|
119
134
|
|
|
120
|
-
|
|
121
|
-
|
|
135
|
+
let projectMemories: Array<{ id: string; title: string; type: string }> = [];
|
|
136
|
+
let allMemories: Array<{ project: string; id: string; title: string; type: string }> = [];
|
|
137
|
+
try {
|
|
138
|
+
projectMemories = projectStorage.listAll();
|
|
139
|
+
allMemories = listAllProjects();
|
|
140
|
+
} catch (err) {
|
|
141
|
+
console.warn("[unipi/memory] Failed to list memories for info panel:", err);
|
|
142
|
+
}
|
|
122
143
|
const uniqueProjects = [...new Set(allMemories.map((m) => m.project))];
|
|
123
144
|
|
|
124
145
|
// Get 3 most recent memories (sorted by updated DESC in listAll)
|
|
@@ -143,62 +164,91 @@ export default function (pi: ExtensionAPI) {
|
|
|
143
164
|
|
|
144
165
|
// Show memory status in UI
|
|
145
166
|
if (ctx.hasUI) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
167
|
+
let projectCount = 0;
|
|
168
|
+
let projectCountAll = 0;
|
|
169
|
+
try {
|
|
170
|
+
projectCount = projectStorage?.listAll()?.length ?? 0;
|
|
171
|
+
projectCountAll = listAllProjects().length;
|
|
172
|
+
} catch (err) {
|
|
173
|
+
console.warn("[unipi/memory] Failed to count memories for status:", err);
|
|
174
|
+
}
|
|
175
|
+
const vecReady = isEmbeddingReady();
|
|
176
|
+
const vecIcon = vecReady ? "⚡" : "📝";
|
|
149
177
|
ctx.ui.setStatus(
|
|
150
178
|
"unipi-memory",
|
|
151
|
-
|
|
179
|
+
`${vecIcon} memory ${projectCount}p/${projectCountAll}all${hasModelChanged() ? " ⚠" : ""}`
|
|
152
180
|
);
|
|
153
181
|
}
|
|
154
182
|
});
|
|
155
183
|
|
|
156
|
-
// Inject memory
|
|
184
|
+
// Inject memory recall reminder at agent start (hidden message, not system prompt)
|
|
157
185
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
186
|
+
if (recallDone) return;
|
|
158
187
|
if (!projectStorage) return;
|
|
159
188
|
|
|
160
189
|
const projectName = getProjectName(ctx.cwd);
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
190
|
+
let projectMemories: Array<{ id: string; title: string; type: string }> = [];
|
|
191
|
+
try {
|
|
192
|
+
projectMemories = projectStorage.listAll();
|
|
193
|
+
} catch (err) {
|
|
194
|
+
console.warn("[unipi/memory] Failed to list memories for recall:", err);
|
|
195
|
+
recallDone = true; // Skip recall on error
|
|
196
|
+
return;
|
|
165
197
|
}
|
|
166
198
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
// Project memories
|
|
171
|
-
for (const m of projectMemories) {
|
|
172
|
-
injection += `- ${m.title}\n`;
|
|
199
|
+
if (projectMemories.length === 0) {
|
|
200
|
+
recallDone = true; // Nothing to recall, skip
|
|
201
|
+
return;
|
|
173
202
|
}
|
|
174
203
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
injection += "</memory>";
|
|
204
|
+
const titleList = projectMemories.slice(0, 20).map(m => `- ${m.title}`).join("\n");
|
|
205
|
+
const extra = projectMemories.length > 20 ? `\n... and ${projectMemories.length - 20} more` : "";
|
|
178
206
|
|
|
179
207
|
return {
|
|
180
|
-
|
|
208
|
+
message: {
|
|
209
|
+
customType: "unipi-memory-recall-reminder",
|
|
210
|
+
content: [
|
|
211
|
+
"## 🧠 Memory System Active",
|
|
212
|
+
"",
|
|
213
|
+
`You have ${projectMemories.length} memories stored for project "${projectName}".`,
|
|
214
|
+
"**BEFORE starting work**, call `memory_search` with relevant keywords to check for existing context.",
|
|
215
|
+
"",
|
|
216
|
+
"Available memories:",
|
|
217
|
+
titleList + extra,
|
|
218
|
+
"",
|
|
219
|
+
"**AFTER completing the task**, if you learned something non-obvious,",
|
|
220
|
+
"call `memory_store` to save it for future sessions.",
|
|
221
|
+
"",
|
|
222
|
+
"Guardrails: read max 10 memory results per search. Update existing memories instead of creating duplicates.",
|
|
223
|
+
].join("\n"),
|
|
224
|
+
display: false,
|
|
225
|
+
},
|
|
181
226
|
};
|
|
182
227
|
});
|
|
183
228
|
|
|
184
|
-
//
|
|
185
|
-
pi.on("
|
|
186
|
-
|
|
229
|
+
// After each agent response, remind LLM to save if it hasn't yet
|
|
230
|
+
pi.on("agent_end", async (_event, _ctx) => {
|
|
231
|
+
if (storeDone || !recallDone) return;
|
|
187
232
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
233
|
+
pi.sendMessage(
|
|
234
|
+
{
|
|
235
|
+
customType: "unipi-memory-retro-reminder",
|
|
236
|
+
content: [
|
|
237
|
+
"**🧠 Memory reminder:** If you learned something non-obvious in this task,",
|
|
238
|
+
"call `memory_store` to save it as a memory for future sessions.",
|
|
239
|
+
"Update existing memories instead of creating duplicates.",
|
|
240
|
+
].join(" "),
|
|
241
|
+
display: false,
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
deliverAs: "nextTurn",
|
|
245
|
+
},
|
|
246
|
+
);
|
|
247
|
+
});
|
|
199
248
|
|
|
200
|
-
|
|
201
|
-
|
|
249
|
+
// After compaction, reset recall state so reminder re-injects
|
|
250
|
+
pi.on("session_compact", async (_event, _ctx) => {
|
|
251
|
+
recallDone = false;
|
|
202
252
|
});
|
|
203
253
|
|
|
204
254
|
// Cleanup on shutdown
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/memory",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
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
|
+
}
|
package/skills/memory/SKILL.md
CHANGED
|
@@ -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
|
-
##
|
|
89
|
+
## Search Scope
|
|
91
90
|
|
|
92
|
-
|
|
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
|
|
96
|
-
| **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.
|
|
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
|
-
##
|
|
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/storage.ts
CHANGED
|
@@ -173,6 +173,12 @@ export class MemoryStorage {
|
|
|
173
173
|
|
|
174
174
|
/**
|
|
175
175
|
* Initialize the storage (create DB, tables, load extension).
|
|
176
|
+
*
|
|
177
|
+
* Uses retry logic to handle concurrent access from multiple Pi sessions,
|
|
178
|
+
* especially on WSL/Windows filesystem where SQLite locking can be flaky.
|
|
179
|
+
*
|
|
180
|
+
* IMPORTANT: We never delete the DB here — another session may have it open.
|
|
181
|
+
* If all retries fail, we throw and let this session run without memory.
|
|
176
182
|
*/
|
|
177
183
|
init(): void {
|
|
178
184
|
// Ensure directory exists
|
|
@@ -181,6 +187,51 @@ export class MemoryStorage {
|
|
|
181
187
|
}
|
|
182
188
|
|
|
183
189
|
const dbPath = path.join(this.scopeDir, MEMORY_DB_NAME);
|
|
190
|
+
const maxRetries = 5;
|
|
191
|
+
|
|
192
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
193
|
+
try {
|
|
194
|
+
this.initDb(dbPath);
|
|
195
|
+
return; // Success
|
|
196
|
+
} catch (err: any) {
|
|
197
|
+
const isTransient =
|
|
198
|
+
err?.message?.includes("disk I/O error") ||
|
|
199
|
+
err?.code === "SQLITE_IOERR" ||
|
|
200
|
+
err?.code === "SQLITE_BUSY" ||
|
|
201
|
+
err?.message?.includes("database is locked");
|
|
202
|
+
|
|
203
|
+
this.close();
|
|
204
|
+
|
|
205
|
+
if (isTransient && attempt < maxRetries) {
|
|
206
|
+
// Likely concurrent access — back off and retry.
|
|
207
|
+
// Do NOT delete the DB: another session may have it open
|
|
208
|
+
// and deleting open files on WSL/Windows is unsafe.
|
|
209
|
+
const delayMs = 50 * Math.pow(2, attempt - 1); // 50, 100, 200, 400
|
|
210
|
+
console.warn(
|
|
211
|
+
`[unipi/memory] Transient error on attempt ${attempt}/${maxRetries}, retrying in ${delayMs}ms...`
|
|
212
|
+
);
|
|
213
|
+
const end = Date.now() + delayMs;
|
|
214
|
+
while (Date.now() < end) { /* busy wait */ }
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Either non-transient error, or retries exhausted.
|
|
219
|
+
// Log and throw — this session will run without memory.
|
|
220
|
+
if (isTransient) {
|
|
221
|
+
console.warn(
|
|
222
|
+
"[unipi/memory] Could not open database after retries. " +
|
|
223
|
+
"Another session may have the DB locked. Memory unavailable this session."
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
throw err;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Open database and set up schema. Called by init() with retry logic.
|
|
233
|
+
*/
|
|
234
|
+
private initDb(dbPath: string): void {
|
|
184
235
|
this.db = new Database(dbPath);
|
|
185
236
|
|
|
186
237
|
// Enable WAL mode for concurrent reads
|
|
@@ -216,6 +267,9 @@ export class MemoryStorage {
|
|
|
216
267
|
} catch {
|
|
217
268
|
// vec0 table may already exist or sqlite-vec not loaded
|
|
218
269
|
}
|
|
270
|
+
|
|
271
|
+
// Verify database is usable
|
|
272
|
+
this.db.prepare("SELECT 1 FROM memories LIMIT 0").get();
|
|
219
273
|
}
|
|
220
274
|
|
|
221
275
|
/**
|
|
@@ -228,6 +282,37 @@ export class MemoryStorage {
|
|
|
228
282
|
}
|
|
229
283
|
}
|
|
230
284
|
|
|
285
|
+
/**
|
|
286
|
+
* Remove corrupted database files (db, wal, shm).
|
|
287
|
+
*/
|
|
288
|
+
private removeCorruptedDb(): void {
|
|
289
|
+
const dbPath = path.join(this.scopeDir, MEMORY_DB_NAME);
|
|
290
|
+
const files = [dbPath, `${dbPath}-wal`, `${dbPath}-shm`];
|
|
291
|
+
for (const file of files) {
|
|
292
|
+
try {
|
|
293
|
+
if (fs.existsSync(file)) {
|
|
294
|
+
fs.unlinkSync(file);
|
|
295
|
+
console.warn(`[unipi/memory] Removed corrupted file: ${file}`);
|
|
296
|
+
}
|
|
297
|
+
} catch {
|
|
298
|
+
// Ignore removal errors
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Check if database is healthy.
|
|
305
|
+
*/
|
|
306
|
+
isHealthy(): boolean {
|
|
307
|
+
if (!this.db) return false;
|
|
308
|
+
try {
|
|
309
|
+
this.db.prepare("SELECT 1").get();
|
|
310
|
+
return true;
|
|
311
|
+
} catch {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
231
316
|
/**
|
|
232
317
|
* Store or update a memory record.
|
|
233
318
|
*/
|
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
|
-
"
|
|
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
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"
|
|
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
|
-
"
|
|
136
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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
|
+
}
|