@brianmichel/pi-noodle 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +231 -0
- package/index.ts +1 -0
- package/package.json +70 -0
- package/src/AGENTS.md +33 -0
- package/src/commands/index.ts +51 -0
- package/src/commands/memory-crud.ts +136 -0
- package/src/commands/review.ts +291 -0
- package/src/commands/setup.ts +189 -0
- package/src/commands/status.ts +32 -0
- package/src/commands/ui.ts +14 -0
- package/src/commands/web.ts +40 -0
- package/src/commands.ts +1 -0
- package/src/config/schema.ts +234 -0
- package/src/config-screen.ts +439 -0
- package/src/config.ts +159 -0
- package/src/constants.ts +1 -0
- package/src/debug-overlay.ts +230 -0
- package/src/extension.ts +166 -0
- package/src/index.ts +1 -0
- package/src/memory/backend.ts +22 -0
- package/src/memory/embedder.ts +7 -0
- package/src/memory/embedders/lm-studio.ts +25 -0
- package/src/memory/embedders/openai.ts +66 -0
- package/src/memory/extractor.ts +189 -0
- package/src/memory/policy.ts +325 -0
- package/src/memory/project-identity.ts +51 -0
- package/src/memory/runtime.ts +70 -0
- package/src/memory/service.ts +761 -0
- package/src/memory/turso-backend.ts +716 -0
- package/src/memory/types.ts +192 -0
- package/src/notifications.ts +11 -0
- package/src/queue.ts +42 -0
- package/src/session.ts +72 -0
- package/src/tools.ts +172 -0
- package/src/types.ts +81 -0
- package/src/utils.ts +68 -0
- package/src/web/dev.ts +7 -0
- package/src/web/index.html +1963 -0
- package/src/web/manager.ts +92 -0
- package/src/web/run.ts +33 -0
- package/src/web/server.ts +212 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { DEFAULT_EXTRACTOR_MODE, DISABLED_EXTRACTOR_MODE, defaultExtractorTriggerEvery } from "../config.ts";
|
|
2
|
+
import type {
|
|
3
|
+
NoodleConfig,
|
|
4
|
+
NoodleConfigPartial,
|
|
5
|
+
NoodleDbMode,
|
|
6
|
+
NoodleEmbeddingProvider,
|
|
7
|
+
NoodleExtractorMode,
|
|
8
|
+
} from "../types.ts";
|
|
9
|
+
|
|
10
|
+
export const EMBEDDING_PROVIDERS = ["openai", "lm_studio", "ollama", "custom"] as const;
|
|
11
|
+
|
|
12
|
+
export const FIELD = {
|
|
13
|
+
DB_MODE: "dbMode",
|
|
14
|
+
DB_PATH: "dbPath",
|
|
15
|
+
DB_URL: "dbUrl",
|
|
16
|
+
DB_AUTH_TOKEN: "dbAuthToken",
|
|
17
|
+
EMBEDDING_PROVIDER: "embeddingProvider",
|
|
18
|
+
EMBEDDING_API_KEY: "embeddingApiKey",
|
|
19
|
+
EMBEDDING_BASE_URL: "embeddingBaseUrl",
|
|
20
|
+
EMBEDDING_MODEL: "embeddingModel",
|
|
21
|
+
EXTRACTOR_MODE: "extractorMode",
|
|
22
|
+
EXTRACTOR_MODEL: "extractorModel",
|
|
23
|
+
EXTRACTOR_TRIGGER_EVERY: "extractorTriggerEvery",
|
|
24
|
+
EXTRACTOR_DEBUG: "extractorDebug",
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
export type ConfigFieldId = (typeof FIELD)[keyof typeof FIELD];
|
|
28
|
+
|
|
29
|
+
export type DraftConfig = {
|
|
30
|
+
dbMode: NoodleDbMode;
|
|
31
|
+
dbPath: string;
|
|
32
|
+
dbUrl: string;
|
|
33
|
+
dbAuthToken: string;
|
|
34
|
+
embeddingProvider: NoodleEmbeddingProvider;
|
|
35
|
+
embeddingApiKey: string;
|
|
36
|
+
embeddingBaseUrl: string;
|
|
37
|
+
embeddingModel: string;
|
|
38
|
+
extractorMode: NoodleExtractorMode;
|
|
39
|
+
extractorModel: string;
|
|
40
|
+
extractorTriggerEvery: string;
|
|
41
|
+
extractorDebug: boolean;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function createDraft(config: NoodleConfig): DraftConfig {
|
|
45
|
+
return applyDraftDefaults({
|
|
46
|
+
dbMode: config.db.mode,
|
|
47
|
+
dbPath: config.db.path,
|
|
48
|
+
dbUrl: config.db.url ?? "libsql://",
|
|
49
|
+
dbAuthToken: config.db.authToken ?? "",
|
|
50
|
+
embeddingProvider: normalizeEmbeddingProvider(config.embedding.provider),
|
|
51
|
+
embeddingApiKey: config.embedding.apiKey,
|
|
52
|
+
embeddingBaseUrl: config.embedding.baseUrl,
|
|
53
|
+
embeddingModel: config.embedding.model,
|
|
54
|
+
extractorMode: config.extractor?.mode ?? DISABLED_EXTRACTOR_MODE,
|
|
55
|
+
extractorModel: config.extractor?.model ?? "",
|
|
56
|
+
extractorTriggerEvery: String(
|
|
57
|
+
config.extractor?.triggerEvery ??
|
|
58
|
+
defaultExtractorTriggerEvery(config.extractor?.mode ?? DEFAULT_EXTRACTOR_MODE),
|
|
59
|
+
),
|
|
60
|
+
extractorDebug: config.extractor?.debug ?? false,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function applyDraftDefaults(draft: DraftConfig): DraftConfig {
|
|
65
|
+
draft.embeddingProvider = normalizeEmbeddingProvider(draft.embeddingProvider);
|
|
66
|
+
|
|
67
|
+
if (!draft.dbUrl) draft.dbUrl = "libsql://";
|
|
68
|
+
if (!draft.extractorModel) draft.extractorModel = "";
|
|
69
|
+
if (!draft.extractorTriggerEvery) {
|
|
70
|
+
draft.extractorTriggerEvery = String(defaultExtractorTriggerEvery(activeExtractorMode(draft.extractorMode)));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
switch (draft.embeddingProvider) {
|
|
74
|
+
case "openai":
|
|
75
|
+
draft.embeddingBaseUrl = "https://api.openai.com/v1";
|
|
76
|
+
if (!draft.embeddingModel) draft.embeddingModel = "text-embedding-3-small";
|
|
77
|
+
break;
|
|
78
|
+
case "lm_studio":
|
|
79
|
+
if (!draft.embeddingBaseUrl) draft.embeddingBaseUrl = "http://localhost:1234/v1";
|
|
80
|
+
draft.embeddingApiKey = "lm-studio";
|
|
81
|
+
draft.embeddingModel = "";
|
|
82
|
+
break;
|
|
83
|
+
case "ollama":
|
|
84
|
+
if (!draft.embeddingBaseUrl) draft.embeddingBaseUrl = "http://localhost:11434/v1";
|
|
85
|
+
draft.embeddingApiKey = "ollama";
|
|
86
|
+
if (!draft.embeddingModel) draft.embeddingModel = "nomic-embed-text";
|
|
87
|
+
break;
|
|
88
|
+
case "custom":
|
|
89
|
+
if (!draft.embeddingBaseUrl) draft.embeddingBaseUrl = "https://api.openai.com/v1";
|
|
90
|
+
if (!draft.embeddingModel) draft.embeddingModel = "text-embedding-3-small";
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return draft;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function validateDraft(draft: DraftConfig): string[] {
|
|
98
|
+
const errors: string[] = [];
|
|
99
|
+
|
|
100
|
+
if (draft.dbMode === "local" && !draft.dbPath.trim()) {
|
|
101
|
+
errors.push("Database file path is required for local mode.");
|
|
102
|
+
}
|
|
103
|
+
if (draft.dbMode === "cloud") {
|
|
104
|
+
if (!draft.dbUrl.trim().startsWith("libsql://")) {
|
|
105
|
+
errors.push('Turso database URL must start with "libsql://".');
|
|
106
|
+
}
|
|
107
|
+
if (!draft.dbAuthToken.trim()) {
|
|
108
|
+
errors.push("Turso auth token is required for cloud mode.");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
switch (draft.embeddingProvider) {
|
|
113
|
+
case "openai":
|
|
114
|
+
if (!draft.embeddingApiKey.trim()) errors.push("OpenAI API key is required.");
|
|
115
|
+
if (!draft.embeddingModel.trim()) errors.push("Model name is required.");
|
|
116
|
+
break;
|
|
117
|
+
case "lm_studio":
|
|
118
|
+
if (!draft.embeddingBaseUrl.trim()) errors.push("LM Studio base URL is required.");
|
|
119
|
+
break;
|
|
120
|
+
case "ollama":
|
|
121
|
+
if (!draft.embeddingModel.trim()) errors.push("Ollama embedding model is required.");
|
|
122
|
+
if (!draft.embeddingBaseUrl.trim()) errors.push("Ollama base URL is required.");
|
|
123
|
+
break;
|
|
124
|
+
case "custom":
|
|
125
|
+
if (!draft.embeddingBaseUrl.trim()) errors.push("Embedding base URL is required.");
|
|
126
|
+
if (!draft.embeddingModel.trim()) errors.push("Model name is required.");
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (draft.extractorMode !== "off") {
|
|
131
|
+
const turns = parseInt(draft.extractorTriggerEvery.trim(), 10);
|
|
132
|
+
if (Number.isNaN(turns) || turns < 1) {
|
|
133
|
+
errors.push("Extract every N turns must be a positive integer.");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return errors;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function toPartialConfig(draft: DraftConfig): NoodleConfigPartial {
|
|
141
|
+
const partial: NoodleConfigPartial = {
|
|
142
|
+
db: {
|
|
143
|
+
mode: draft.dbMode,
|
|
144
|
+
path: draft.dbPath.trim(),
|
|
145
|
+
...(draft.dbMode === "cloud"
|
|
146
|
+
? { url: draft.dbUrl.trim(), authToken: draft.dbAuthToken.trim() }
|
|
147
|
+
: {}),
|
|
148
|
+
},
|
|
149
|
+
embedding: {
|
|
150
|
+
provider: draft.embeddingProvider,
|
|
151
|
+
apiKey: draft.embeddingApiKey,
|
|
152
|
+
baseUrl: draft.embeddingBaseUrl.trim(),
|
|
153
|
+
model: draft.embeddingModel.trim(),
|
|
154
|
+
},
|
|
155
|
+
extractor: draft.extractorMode !== "off"
|
|
156
|
+
? {
|
|
157
|
+
mode: draft.extractorMode,
|
|
158
|
+
...(draft.extractorModel.trim() ? { model: draft.extractorModel.trim() } : {}),
|
|
159
|
+
triggerEvery:
|
|
160
|
+
parseInt(draft.extractorTriggerEvery.trim(), 10) ||
|
|
161
|
+
defaultExtractorTriggerEvery(activeExtractorMode(draft.extractorMode)),
|
|
162
|
+
debug: draft.extractorDebug,
|
|
163
|
+
}
|
|
164
|
+
: { mode: "off" },
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
if (draft.embeddingProvider === "lm_studio") {
|
|
168
|
+
partial.embedding!.apiKey = "lm-studio";
|
|
169
|
+
partial.embedding!.model = "";
|
|
170
|
+
}
|
|
171
|
+
if (draft.embeddingProvider === "ollama") {
|
|
172
|
+
partial.embedding!.apiKey = "ollama";
|
|
173
|
+
}
|
|
174
|
+
if (draft.embeddingProvider === "openai") {
|
|
175
|
+
partial.embedding!.baseUrl = "https://api.openai.com/v1";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return partial;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function summarizeDraft(draft: DraftConfig): string[] {
|
|
182
|
+
return [
|
|
183
|
+
`Database: ${draft.dbMode} ${draft.dbMode === "cloud" ? draft.dbUrl.trim() : draft.dbPath.trim()}`,
|
|
184
|
+
`Embedding: ${draft.embeddingProvider} ${draft.embeddingModel.trim() || draft.embeddingBaseUrl.trim()}`,
|
|
185
|
+
draft.extractorMode !== "off" && draft.extractorModel.trim()
|
|
186
|
+
? `Memory mode: ${draft.extractorMode} ${draft.extractorModel.trim()} every ${parseInt(draft.extractorTriggerEvery.trim(), 10) || defaultExtractorTriggerEvery(activeExtractorMode(draft.extractorMode))} turns debug ${draft.extractorDebug ? "on" : "off"}`
|
|
187
|
+
: "Memory mode: off",
|
|
188
|
+
];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function labelForField(id: ConfigFieldId): string {
|
|
192
|
+
switch (id) {
|
|
193
|
+
case FIELD.DB_MODE:
|
|
194
|
+
return "Database mode";
|
|
195
|
+
case FIELD.DB_PATH:
|
|
196
|
+
return "Database file path";
|
|
197
|
+
case FIELD.DB_URL:
|
|
198
|
+
return "Turso database URL";
|
|
199
|
+
case FIELD.DB_AUTH_TOKEN:
|
|
200
|
+
return "Turso auth token";
|
|
201
|
+
case FIELD.EMBEDDING_PROVIDER:
|
|
202
|
+
return "Embedding provider";
|
|
203
|
+
case FIELD.EMBEDDING_API_KEY:
|
|
204
|
+
return "API key";
|
|
205
|
+
case FIELD.EMBEDDING_BASE_URL:
|
|
206
|
+
return "Embedding base URL";
|
|
207
|
+
case FIELD.EMBEDDING_MODEL:
|
|
208
|
+
return "Model name";
|
|
209
|
+
case FIELD.EXTRACTOR_MODE:
|
|
210
|
+
return "Memory mode";
|
|
211
|
+
case FIELD.EXTRACTOR_MODEL:
|
|
212
|
+
return "Extractor model ID";
|
|
213
|
+
case FIELD.EXTRACTOR_TRIGGER_EVERY:
|
|
214
|
+
return "Extract every N turns";
|
|
215
|
+
case FIELD.EXTRACTOR_DEBUG:
|
|
216
|
+
return "Extractor debug widget";
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function normalizeEmbeddingProvider(value: string): NoodleEmbeddingProvider {
|
|
221
|
+
if (value === "openai" || value === "lm_studio" || value === "ollama" || value === "custom") {
|
|
222
|
+
return value;
|
|
223
|
+
}
|
|
224
|
+
return "custom";
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function parseExtractorMode(value: string): NoodleExtractorMode {
|
|
228
|
+
if (value === "off" || value === "conservative" || value === "proactive") return value;
|
|
229
|
+
return "balanced";
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function activeExtractorMode(mode: NoodleExtractorMode): Exclude<NoodleExtractorMode, "off"> {
|
|
233
|
+
return mode === "off" ? "balanced" : mode;
|
|
234
|
+
}
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
Editor,
|
|
4
|
+
type EditorTheme,
|
|
5
|
+
Key,
|
|
6
|
+
matchesKey,
|
|
7
|
+
type Component,
|
|
8
|
+
type SettingItem,
|
|
9
|
+
SettingsList,
|
|
10
|
+
type TUI,
|
|
11
|
+
truncateToWidth,
|
|
12
|
+
} from "@earendil-works/pi-tui";
|
|
13
|
+
|
|
14
|
+
import { resolveConfigPath } from "./config.ts";
|
|
15
|
+
import {
|
|
16
|
+
applyDraftDefaults,
|
|
17
|
+
createDraft,
|
|
18
|
+
type ConfigFieldId,
|
|
19
|
+
type DraftConfig,
|
|
20
|
+
EMBEDDING_PROVIDERS,
|
|
21
|
+
FIELD,
|
|
22
|
+
labelForField,
|
|
23
|
+
parseExtractorMode,
|
|
24
|
+
summarizeDraft,
|
|
25
|
+
toPartialConfig,
|
|
26
|
+
validateDraft,
|
|
27
|
+
} from "./config/schema.ts";
|
|
28
|
+
import type { NoodleConfig, NoodleConfigPartial, NoodleEmbeddingProvider } from "./types.ts";
|
|
29
|
+
import { maskSecret } from "./utils.ts";
|
|
30
|
+
|
|
31
|
+
type DoneFn<T> = (result: T) => void;
|
|
32
|
+
type TuiLike = TUI;
|
|
33
|
+
type ComponentLike = Component;
|
|
34
|
+
|
|
35
|
+
type ThemeLike = {
|
|
36
|
+
fg?: (color: string, text: string) => string;
|
|
37
|
+
bold?: (text: string) => string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type CustomUi = {
|
|
41
|
+
custom?: <T>(
|
|
42
|
+
factory: (
|
|
43
|
+
tui: unknown,
|
|
44
|
+
theme: unknown,
|
|
45
|
+
keybindings: unknown,
|
|
46
|
+
done: DoneFn<T>,
|
|
47
|
+
) => unknown,
|
|
48
|
+
) => Promise<T>;
|
|
49
|
+
confirm: (title: string, message: string) => Promise<boolean>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type ConfigScreenResult =
|
|
53
|
+
| { cancelled: true }
|
|
54
|
+
| { cancelled: false; partial: NoodleConfigPartial };
|
|
55
|
+
|
|
56
|
+
type TextFieldSpec = {
|
|
57
|
+
id: ConfigFieldId;
|
|
58
|
+
label: string;
|
|
59
|
+
description: string;
|
|
60
|
+
value: string;
|
|
61
|
+
rawValue?: string;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export async function runConfigScreen(
|
|
65
|
+
ui: CustomUi,
|
|
66
|
+
current: NoodleConfig,
|
|
67
|
+
): Promise<ConfigScreenResult | null> {
|
|
68
|
+
if (!ui.custom) return null;
|
|
69
|
+
|
|
70
|
+
const draft = createDraft(current);
|
|
71
|
+
|
|
72
|
+
return await ui.custom<ConfigScreenResult>((rawTui, rawTheme, _kb, done) => {
|
|
73
|
+
const tui = rawTui as TuiLike;
|
|
74
|
+
const theme = rawTheme as ThemeLike;
|
|
75
|
+
let status = "Edit settings like Pi’s built-in settings. Press S to save.";
|
|
76
|
+
let settingsList = createSettingsList();
|
|
77
|
+
|
|
78
|
+
const color = (name: string, text: string) => theme.fg?.(name, text) ?? text;
|
|
79
|
+
const bold = (text: string) => theme.bold?.(text) ?? text;
|
|
80
|
+
|
|
81
|
+
function refresh(): void {
|
|
82
|
+
settingsList.invalidate();
|
|
83
|
+
tui.requestRender();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function rebuild(message?: string): void {
|
|
87
|
+
if (message) status = message;
|
|
88
|
+
settingsList = createSettingsList();
|
|
89
|
+
tui.requestRender();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function createSettingsList(): SettingsList {
|
|
93
|
+
const items = buildItems(draft, tui, theme, applyChange);
|
|
94
|
+
return new SettingsList(
|
|
95
|
+
items,
|
|
96
|
+
Math.min(items.length + 4, 16),
|
|
97
|
+
getSettingsListTheme(),
|
|
98
|
+
applyChange,
|
|
99
|
+
() => done({ cancelled: true }),
|
|
100
|
+
{ enableSearch: true },
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function applyChange(id: string, value: string): void {
|
|
105
|
+
applyDraftChange(draft, id as ConfigFieldId, value);
|
|
106
|
+
rebuild(`Updated ${labelForField(id as ConfigFieldId)}.`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function save(): Promise<void> {
|
|
110
|
+
const errors = validateDraft(draft);
|
|
111
|
+
if (errors.length > 0) {
|
|
112
|
+
rebuild(`Fix: ${errors[0]}`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const ok = await ui.confirm("Save noodle config?", summarizeDraft(draft).join("\n"));
|
|
117
|
+
if (!ok) {
|
|
118
|
+
rebuild("Save cancelled.");
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
done({ cancelled: false, partial: toPartialConfig(draft) });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
render(width: number): string[] {
|
|
127
|
+
const errors = validateDraft(draft);
|
|
128
|
+
const lines = [
|
|
129
|
+
color("accent", bold("Noodle config")),
|
|
130
|
+
`File: ${resolveConfigPath()}`,
|
|
131
|
+
"Env vars still override saved values.",
|
|
132
|
+
color("dim", "Type to search • Enter/Space to change • S to save • Esc to cancel"),
|
|
133
|
+
"",
|
|
134
|
+
...settingsList.render(width).map((line) => truncateToWidth(line, width)),
|
|
135
|
+
"",
|
|
136
|
+
errors.length > 0
|
|
137
|
+
? color("warning", `Validation: ${errors[0]}`)
|
|
138
|
+
: color("success", "Validation: OK"),
|
|
139
|
+
`Status: ${status}`,
|
|
140
|
+
];
|
|
141
|
+
return lines.map((line) => truncateToWidth(line, width));
|
|
142
|
+
},
|
|
143
|
+
invalidate(): void {
|
|
144
|
+
settingsList.invalidate();
|
|
145
|
+
},
|
|
146
|
+
handleInput(data: string): void {
|
|
147
|
+
if (data === "s" || data === "S") {
|
|
148
|
+
void save();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
settingsList.handleInput(data);
|
|
152
|
+
refresh();
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function buildItems(
|
|
159
|
+
draft: DraftConfig,
|
|
160
|
+
tui: TuiLike,
|
|
161
|
+
theme: ThemeLike,
|
|
162
|
+
applyChange: (id: string, value: string) => void,
|
|
163
|
+
): SettingItem[] {
|
|
164
|
+
const items: SettingItem[] = [
|
|
165
|
+
choiceItem(FIELD.DB_MODE, "Database mode", draft.dbMode, ["local", "cloud"], "Where memories are stored: local SQLite file or Turso cloud libSQL."),
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
if (draft.dbMode === "local") {
|
|
169
|
+
items.push(textFieldItem(draft, tui, theme, applyChange, {
|
|
170
|
+
id: FIELD.DB_PATH,
|
|
171
|
+
label: "Database file path",
|
|
172
|
+
description: "Path to the local SQLite/libSQL database file.",
|
|
173
|
+
value: draft.dbPath,
|
|
174
|
+
}));
|
|
175
|
+
} else {
|
|
176
|
+
items.push(
|
|
177
|
+
textFieldItem(draft, tui, theme, applyChange, {
|
|
178
|
+
id: FIELD.DB_URL,
|
|
179
|
+
label: "Turso database URL",
|
|
180
|
+
description: 'Hosted libSQL URL. Should start with "libsql://".',
|
|
181
|
+
value: draft.dbUrl,
|
|
182
|
+
}),
|
|
183
|
+
textFieldItem(draft, tui, theme, applyChange, {
|
|
184
|
+
id: FIELD.DB_AUTH_TOKEN,
|
|
185
|
+
label: "Turso auth token",
|
|
186
|
+
description: "Access token for the Turso database.",
|
|
187
|
+
value: maskSecret(draft.dbAuthToken),
|
|
188
|
+
rawValue: draft.dbAuthToken,
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
items.push(choiceItem(
|
|
194
|
+
FIELD.EMBEDDING_PROVIDER,
|
|
195
|
+
"Embedding provider",
|
|
196
|
+
draft.embeddingProvider,
|
|
197
|
+
[...EMBEDDING_PROVIDERS],
|
|
198
|
+
"Provider used to generate embeddings for memory search.",
|
|
199
|
+
));
|
|
200
|
+
|
|
201
|
+
items.push(...buildEmbeddingItems(draft, tui, theme, applyChange));
|
|
202
|
+
|
|
203
|
+
items.push(choiceItem(
|
|
204
|
+
FIELD.EXTRACTOR_MODE,
|
|
205
|
+
"Memory mode",
|
|
206
|
+
draft.extractorMode,
|
|
207
|
+
["off", "conservative", "balanced", "proactive"],
|
|
208
|
+
"Off disables extraction. Conservative saves less, proactive discovers more, balanced is the default.",
|
|
209
|
+
));
|
|
210
|
+
|
|
211
|
+
if (draft.extractorMode !== "off") {
|
|
212
|
+
items.push(
|
|
213
|
+
textFieldItem(draft, tui, theme, applyChange, {
|
|
214
|
+
id: FIELD.EXTRACTOR_MODEL,
|
|
215
|
+
label: "Extractor model ID",
|
|
216
|
+
description: "Pi model ID used for extraction. Change this to trade quality, speed, and cost.",
|
|
217
|
+
value: draft.extractorModel,
|
|
218
|
+
}),
|
|
219
|
+
textFieldItem(draft, tui, theme, applyChange, {
|
|
220
|
+
id: FIELD.EXTRACTOR_TRIGGER_EVERY,
|
|
221
|
+
label: "Extract every N turns",
|
|
222
|
+
description: "How often to run automatic extraction. Leave the mode default unless you want manual control.",
|
|
223
|
+
value: draft.extractorTriggerEvery,
|
|
224
|
+
}),
|
|
225
|
+
choiceItem(
|
|
226
|
+
FIELD.EXTRACTOR_DEBUG,
|
|
227
|
+
"Extractor debug widget",
|
|
228
|
+
draft.extractorDebug ? "on" : "off",
|
|
229
|
+
["off", "on"],
|
|
230
|
+
"Show the live extractor debug widget in Pi while developing.",
|
|
231
|
+
),
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return items;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function buildEmbeddingItems(
|
|
239
|
+
draft: DraftConfig,
|
|
240
|
+
tui: TuiLike,
|
|
241
|
+
theme: ThemeLike,
|
|
242
|
+
applyChange: (id: string, value: string) => void,
|
|
243
|
+
): SettingItem[] {
|
|
244
|
+
switch (draft.embeddingProvider) {
|
|
245
|
+
case "openai":
|
|
246
|
+
return [
|
|
247
|
+
textFieldItem(draft, tui, theme, applyChange, {
|
|
248
|
+
id: FIELD.EMBEDDING_API_KEY,
|
|
249
|
+
label: "OpenAI API key",
|
|
250
|
+
description: "Required for OpenAI embeddings.",
|
|
251
|
+
value: maskSecret(draft.embeddingApiKey),
|
|
252
|
+
rawValue: draft.embeddingApiKey,
|
|
253
|
+
}),
|
|
254
|
+
textFieldItem(draft, tui, theme, applyChange, {
|
|
255
|
+
id: FIELD.EMBEDDING_MODEL,
|
|
256
|
+
label: "Model name",
|
|
257
|
+
description: "Embedding model ID, usually text-embedding-3-small.",
|
|
258
|
+
value: draft.embeddingModel,
|
|
259
|
+
}),
|
|
260
|
+
];
|
|
261
|
+
case "lm_studio":
|
|
262
|
+
return [textFieldItem(draft, tui, theme, applyChange, {
|
|
263
|
+
id: FIELD.EMBEDDING_BASE_URL,
|
|
264
|
+
label: "LM Studio base URL",
|
|
265
|
+
description: "Local OpenAI-compatible embeddings endpoint.",
|
|
266
|
+
value: draft.embeddingBaseUrl,
|
|
267
|
+
})];
|
|
268
|
+
case "ollama":
|
|
269
|
+
return [
|
|
270
|
+
textFieldItem(draft, tui, theme, applyChange, {
|
|
271
|
+
id: FIELD.EMBEDDING_MODEL,
|
|
272
|
+
label: "Ollama embedding model",
|
|
273
|
+
description: "Model to call, e.g. nomic-embed-text.",
|
|
274
|
+
value: draft.embeddingModel,
|
|
275
|
+
}),
|
|
276
|
+
textFieldItem(draft, tui, theme, applyChange, {
|
|
277
|
+
id: FIELD.EMBEDDING_BASE_URL,
|
|
278
|
+
label: "Ollama base URL",
|
|
279
|
+
description: "Usually http://localhost:11434/v1.",
|
|
280
|
+
value: draft.embeddingBaseUrl,
|
|
281
|
+
}),
|
|
282
|
+
];
|
|
283
|
+
case "custom":
|
|
284
|
+
return [
|
|
285
|
+
textFieldItem(draft, tui, theme, applyChange, {
|
|
286
|
+
id: FIELD.EMBEDDING_BASE_URL,
|
|
287
|
+
label: "Embedding base URL",
|
|
288
|
+
description: "Any OpenAI-compatible /v1/embeddings endpoint.",
|
|
289
|
+
value: draft.embeddingBaseUrl,
|
|
290
|
+
}),
|
|
291
|
+
textFieldItem(draft, tui, theme, applyChange, {
|
|
292
|
+
id: FIELD.EMBEDDING_MODEL,
|
|
293
|
+
label: "Model name",
|
|
294
|
+
description: "Model to send to the embeddings endpoint.",
|
|
295
|
+
value: draft.embeddingModel,
|
|
296
|
+
}),
|
|
297
|
+
textFieldItem(draft, tui, theme, applyChange, {
|
|
298
|
+
id: FIELD.EMBEDDING_API_KEY,
|
|
299
|
+
label: "API key",
|
|
300
|
+
description: "Optional key or placeholder required by your provider.",
|
|
301
|
+
value: maskSecret(draft.embeddingApiKey),
|
|
302
|
+
rawValue: draft.embeddingApiKey,
|
|
303
|
+
}),
|
|
304
|
+
];
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function choiceItem(
|
|
309
|
+
id: string,
|
|
310
|
+
label: string,
|
|
311
|
+
currentValue: string,
|
|
312
|
+
values: string[],
|
|
313
|
+
description: string,
|
|
314
|
+
): SettingItem {
|
|
315
|
+
return { id, label, currentValue, values, description };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function textFieldItem(
|
|
319
|
+
_draft: DraftConfig,
|
|
320
|
+
tui: TuiLike,
|
|
321
|
+
theme: ThemeLike,
|
|
322
|
+
applyChange: (id: string, value: string) => void,
|
|
323
|
+
spec: TextFieldSpec,
|
|
324
|
+
): SettingItem {
|
|
325
|
+
return {
|
|
326
|
+
id: spec.id,
|
|
327
|
+
label: spec.label,
|
|
328
|
+
currentValue: spec.value,
|
|
329
|
+
description: spec.description,
|
|
330
|
+
submenu: (existing, done) =>
|
|
331
|
+
createTextEditor(
|
|
332
|
+
tui,
|
|
333
|
+
theme,
|
|
334
|
+
spec.label,
|
|
335
|
+
spec.rawValue ?? existing,
|
|
336
|
+
spec.description,
|
|
337
|
+
(value) => {
|
|
338
|
+
if (value !== undefined) applyChange(spec.id, value);
|
|
339
|
+
done(value);
|
|
340
|
+
},
|
|
341
|
+
),
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function createTextEditor(
|
|
346
|
+
tui: TuiLike,
|
|
347
|
+
theme: ThemeLike,
|
|
348
|
+
label: string,
|
|
349
|
+
initialValue: string,
|
|
350
|
+
description: string,
|
|
351
|
+
done: (value?: string) => void,
|
|
352
|
+
): ComponentLike {
|
|
353
|
+
const editorTheme: EditorTheme = {
|
|
354
|
+
borderColor: (s) => theme.fg?.("accent", s) ?? s,
|
|
355
|
+
selectList: {
|
|
356
|
+
selectedPrefix: (t) => theme.fg?.("accent", t) ?? t,
|
|
357
|
+
selectedText: (t) => theme.fg?.("accent", t) ?? t,
|
|
358
|
+
description: (t) => theme.fg?.("muted", t) ?? t,
|
|
359
|
+
scrollInfo: (t) => theme.fg?.("dim", t) ?? t,
|
|
360
|
+
noMatch: (t) => theme.fg?.("warning", t) ?? t,
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
const editor = new Editor(tui, editorTheme);
|
|
364
|
+
editor.setText(initialValue);
|
|
365
|
+
editor.onSubmit = (value) => done(value.trim());
|
|
366
|
+
|
|
367
|
+
const color = (name: string, text: string) => theme.fg?.(name, text) ?? text;
|
|
368
|
+
const bold = (text: string) => theme.bold?.(text) ?? text;
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
render(width: number): string[] {
|
|
372
|
+
return [
|
|
373
|
+
color("accent", bold(label)),
|
|
374
|
+
color("dim", description),
|
|
375
|
+
"",
|
|
376
|
+
...editor.render(width),
|
|
377
|
+
"",
|
|
378
|
+
color("dim", "Enter to save • Esc to cancel"),
|
|
379
|
+
].map((line) => truncateToWidth(line, width));
|
|
380
|
+
},
|
|
381
|
+
invalidate(): void {},
|
|
382
|
+
handleInput(data: string): void {
|
|
383
|
+
if (matchesKey(data, Key.escape)) {
|
|
384
|
+
done(undefined);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
editor.handleInput(data);
|
|
388
|
+
tui.requestRender();
|
|
389
|
+
},
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function applyDraftChange(draft: DraftConfig, id: ConfigFieldId, value: string): void {
|
|
394
|
+
switch (id) {
|
|
395
|
+
case FIELD.DB_MODE:
|
|
396
|
+
draft.dbMode = value === "cloud" ? "cloud" : "local";
|
|
397
|
+
break;
|
|
398
|
+
case FIELD.DB_PATH:
|
|
399
|
+
draft.dbPath = value;
|
|
400
|
+
break;
|
|
401
|
+
case FIELD.DB_URL:
|
|
402
|
+
draft.dbUrl = value;
|
|
403
|
+
break;
|
|
404
|
+
case FIELD.DB_AUTH_TOKEN:
|
|
405
|
+
draft.dbAuthToken = value;
|
|
406
|
+
break;
|
|
407
|
+
case FIELD.EMBEDDING_PROVIDER:
|
|
408
|
+
draft.embeddingProvider = normalizeProviderSelection(value);
|
|
409
|
+
break;
|
|
410
|
+
case FIELD.EMBEDDING_API_KEY:
|
|
411
|
+
draft.embeddingApiKey = value;
|
|
412
|
+
break;
|
|
413
|
+
case FIELD.EMBEDDING_BASE_URL:
|
|
414
|
+
draft.embeddingBaseUrl = value;
|
|
415
|
+
break;
|
|
416
|
+
case FIELD.EMBEDDING_MODEL:
|
|
417
|
+
draft.embeddingModel = value;
|
|
418
|
+
break;
|
|
419
|
+
case FIELD.EXTRACTOR_MODE:
|
|
420
|
+
draft.extractorMode = parseExtractorMode(value);
|
|
421
|
+
break;
|
|
422
|
+
case FIELD.EXTRACTOR_MODEL:
|
|
423
|
+
draft.extractorModel = value;
|
|
424
|
+
break;
|
|
425
|
+
case FIELD.EXTRACTOR_TRIGGER_EVERY:
|
|
426
|
+
draft.extractorTriggerEvery = value;
|
|
427
|
+
break;
|
|
428
|
+
case FIELD.EXTRACTOR_DEBUG:
|
|
429
|
+
draft.extractorDebug = value === "on";
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
applyDraftDefaults(draft);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function normalizeProviderSelection(value: string): NoodleEmbeddingProvider {
|
|
437
|
+
if (value === "openai" || value === "lm_studio" || value === "ollama") return value;
|
|
438
|
+
return "custom";
|
|
439
|
+
}
|