@pi-unipi/memory 0.1.3 → 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/README.md +3 -3
- package/commands.ts +26 -0
- package/embedding.ts +208 -10
- package/index.ts +63 -36
- package/package.json +4 -2
- package/settings.ts +135 -0
- package/skills/memory/SKILL.md +30 -8
- package/tools.ts +91 -83
- package/tui/settings-tui.ts +301 -0
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# @unipi/memory
|
|
1
|
+
# @pi-unipi/memory
|
|
2
2
|
|
|
3
3
|
Persistent cross-session memory with vector search for Pi coding agent.
|
|
4
4
|
|
|
@@ -18,7 +18,7 @@ Persistent cross-session memory with vector search for Pi coding agent.
|
|
|
18
18
|
pi install npm:unipi
|
|
19
19
|
|
|
20
20
|
# Standalone
|
|
21
|
-
pi install npm:@unipi/memory
|
|
21
|
+
pi install npm:@pi-unipi/memory
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
## Tools
|
|
@@ -91,4 +91,4 @@ Examples:
|
|
|
91
91
|
- `better-sqlite3` - SQLite database
|
|
92
92
|
- `sqlite-vec` - Vector search extension
|
|
93
93
|
- `js-yaml` - YAML frontmatter parsing
|
|
94
|
-
- `@unipi/core` - Shared utilities
|
|
94
|
+
- `@pi-unipi/core` - Shared utilities
|
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
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
MODULES,
|
|
14
14
|
emitEvent,
|
|
15
15
|
getPackageVersion,
|
|
16
|
-
} from "@unipi/core";
|
|
16
|
+
} from "@pi-unipi/core";
|
|
17
17
|
|
|
18
18
|
// Get info registry from global (avoids direct import issues with pi's extension loading)
|
|
19
19
|
function getInfoRegistry() {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
163
|
+
`${vecIcon} memory ${projectCount}p/${projectCountAll}all${hasModelChanged() ? " ⚠" : ""}`
|
|
152
164
|
);
|
|
153
165
|
}
|
|
154
166
|
});
|
|
155
167
|
|
|
156
|
-
// Inject memory
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
185
|
-
pi.on("
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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.
|
|
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
|
],
|
|
@@ -45,7 +47,7 @@
|
|
|
45
47
|
"better-sqlite3": "^12.9.0",
|
|
46
48
|
"sqlite-vec": "^0.1.9",
|
|
47
49
|
"js-yaml": "^4.1.0",
|
|
48
|
-
"@unipi/core": "*",
|
|
50
|
+
"@pi-unipi/core": "*",
|
|
49
51
|
"@pi-unipi/info-screen": "*"
|
|
50
52
|
},
|
|
51
53
|
"peerDependencies": {
|
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/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
|
+
}
|