@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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +231 -0
  3. package/index.ts +1 -0
  4. package/package.json +70 -0
  5. package/src/AGENTS.md +33 -0
  6. package/src/commands/index.ts +51 -0
  7. package/src/commands/memory-crud.ts +136 -0
  8. package/src/commands/review.ts +291 -0
  9. package/src/commands/setup.ts +189 -0
  10. package/src/commands/status.ts +32 -0
  11. package/src/commands/ui.ts +14 -0
  12. package/src/commands/web.ts +40 -0
  13. package/src/commands.ts +1 -0
  14. package/src/config/schema.ts +234 -0
  15. package/src/config-screen.ts +439 -0
  16. package/src/config.ts +159 -0
  17. package/src/constants.ts +1 -0
  18. package/src/debug-overlay.ts +230 -0
  19. package/src/extension.ts +166 -0
  20. package/src/index.ts +1 -0
  21. package/src/memory/backend.ts +22 -0
  22. package/src/memory/embedder.ts +7 -0
  23. package/src/memory/embedders/lm-studio.ts +25 -0
  24. package/src/memory/embedders/openai.ts +66 -0
  25. package/src/memory/extractor.ts +189 -0
  26. package/src/memory/policy.ts +325 -0
  27. package/src/memory/project-identity.ts +51 -0
  28. package/src/memory/runtime.ts +70 -0
  29. package/src/memory/service.ts +761 -0
  30. package/src/memory/turso-backend.ts +716 -0
  31. package/src/memory/types.ts +192 -0
  32. package/src/notifications.ts +11 -0
  33. package/src/queue.ts +42 -0
  34. package/src/session.ts +72 -0
  35. package/src/tools.ts +172 -0
  36. package/src/types.ts +81 -0
  37. package/src/utils.ts +68 -0
  38. package/src/web/dev.ts +7 -0
  39. package/src/web/index.html +1963 -0
  40. package/src/web/manager.ts +92 -0
  41. package/src/web/run.ts +33 -0
  42. package/src/web/server.ts +212 -0
  43. package/tsconfig.json +17 -0
@@ -0,0 +1,291 @@
1
+ import { memoryService } from "../memory/runtime.ts";
2
+ import type { MemoryRecord } from "../memory/types.ts";
3
+ import { describeError } from "../utils.ts";
4
+ import type { CtxUi } from "./ui.ts";
5
+
6
+ type PendingCandidate = ReturnType<typeof memoryService.listPendingCandidates>[number];
7
+
8
+ type ReviewService = {
9
+ list: typeof memoryService.list;
10
+ delete: typeof memoryService.delete;
11
+ listPendingCandidates: typeof memoryService.listPendingCandidates;
12
+ dismissPendingCandidate: typeof memoryService.dismissPendingCandidate;
13
+ promotePendingCandidate: typeof memoryService.promotePendingCandidate;
14
+ };
15
+
16
+ const SAVED_PREFIX = "[s";
17
+ const PENDING_PREFIX = "[p";
18
+ const ACTION_SAVE_ALL = "Save all pending candidates";
19
+ const ACTION_DELETE_ALL = "Delete all listed items";
20
+ const ACTION_DONE = "Exit review";
21
+ const ACTION_BACK = "Back";
22
+
23
+ export async function runReview(ui: CtxUi, service: ReviewService = memoryService): Promise<void> {
24
+ try {
25
+ let autoSaved = await listAutoSaved(service);
26
+ let pending = service.listPendingCandidates();
27
+ let deletedSaved = 0;
28
+ let dismissedPending = 0;
29
+ let savedPending = 0;
30
+
31
+ if (autoSaved.length === 0 && pending.length === 0) {
32
+ ui.notify("No auto-saved or pending memory candidates to review.", "info");
33
+ return;
34
+ }
35
+
36
+ ui.notify(
37
+ "Select an item to review it individually. [sN] means saved memory and [pN] means pending candidate. Saved items can be deleted; pending items can be saved or deleted. Bottom actions apply to the whole list.",
38
+ "info",
39
+ );
40
+
41
+ while (true) {
42
+ const choice = await ui.select("Review memories", buildReviewOptions(autoSaved, pending));
43
+
44
+ if (!choice || choice === ACTION_DONE) {
45
+ notifySummary(ui, deletedSaved, dismissedPending, savedPending);
46
+ return;
47
+ }
48
+
49
+ if (choice === ACTION_SAVE_ALL) {
50
+ const count = await saveAllPendingCandidates(ui, service, pending);
51
+ if (count === 0) continue;
52
+ savedPending += count;
53
+ pending = service.listPendingCandidates();
54
+ continue;
55
+ }
56
+
57
+ if (choice === ACTION_DELETE_ALL) {
58
+ const deleted = await deleteAllListedItems(ui, service, autoSaved, pending);
59
+ if (!deleted) continue;
60
+ deletedSaved += deleted.deletedSaved;
61
+ dismissedPending += deleted.dismissedPending;
62
+ autoSaved = [];
63
+ pending = [];
64
+ notifySummary(ui, deletedSaved, dismissedPending, savedPending);
65
+ return;
66
+ }
67
+
68
+ if (choice.startsWith(SAVED_PREFIX)) {
69
+ const selected = selectByPrefix(choice, autoSaved, SAVED_PREFIX);
70
+ if (!selected) continue;
71
+
72
+ const action = await ui.select(
73
+ `Saved memory ${choice.slice(0, choice.indexOf(" "))}`,
74
+ ["Delete this saved memory", ACTION_BACK],
75
+ );
76
+ if (action !== "Delete this saved memory") continue;
77
+
78
+ const deleted = await deleteSavedMemory(ui, service, selected);
79
+ if (!deleted) continue;
80
+ autoSaved = autoSaved.filter((memory) => memory !== selected);
81
+ deletedSaved += 1;
82
+ }
83
+
84
+ if (choice.startsWith(PENDING_PREFIX)) {
85
+ const selected = selectByPrefix(choice, pending, PENDING_PREFIX);
86
+ if (!selected) continue;
87
+
88
+ const action = await ui.select(
89
+ `Pending candidate ${choice.slice(0, choice.indexOf(" "))}`,
90
+ ["Save this pending candidate", "Delete this pending candidate", ACTION_BACK],
91
+ );
92
+
93
+ if (action === "Save this pending candidate") {
94
+ const saved = await savePendingCandidate(ui, service, selected);
95
+ if (!saved) continue;
96
+ pending = pending.filter((candidate) => candidate.key !== selected.key);
97
+ savedPending += 1;
98
+ continue;
99
+ }
100
+
101
+ if (action !== "Delete this pending candidate") continue;
102
+
103
+ const dismissed = await dismissPendingCandidate(ui, service, selected);
104
+ if (!dismissed) continue;
105
+ pending = pending.filter((candidate) => candidate.key !== selected.key);
106
+ dismissedPending += 1;
107
+ }
108
+
109
+ if (autoSaved.length === 0 && pending.length === 0) {
110
+ notifySummary(ui, deletedSaved, dismissedPending, savedPending);
111
+ return;
112
+ }
113
+ }
114
+ } catch (error) {
115
+ ui.notify(`Review failed: ${describeError(error)}`, "error");
116
+ }
117
+ }
118
+
119
+ async function listAutoSaved(service: ReviewService): Promise<MemoryRecord[]> {
120
+ const memories = await service.list();
121
+ return memories
122
+ .filter((memory) => {
123
+ const source = memory.metadata.source as string | undefined;
124
+ return source === "heuristic" || source === "repetition" || source === "llm_extracted" || source === "consolidated";
125
+ })
126
+ .slice(0, 10);
127
+ }
128
+
129
+ function buildReviewOptions(autoSaved: MemoryRecord[], pending: PendingCandidate[]): string[] {
130
+ const options = [
131
+ ...autoSaved.map((memory, index) => formatSavedOption(memory, index)),
132
+ ...pending.map((candidate, index) => formatPendingOption(candidate, index)),
133
+ ];
134
+
135
+ if (pending.length > 0) options.push(ACTION_SAVE_ALL);
136
+ if (autoSaved.length > 0 || pending.length > 0) options.push(ACTION_DELETE_ALL);
137
+ options.push(ACTION_DONE);
138
+ return options;
139
+ }
140
+
141
+ function selectByPrefix<T>(choice: string, items: T[], prefix: string): T | null {
142
+ const index = parsePrefixedIndex(choice, prefix);
143
+ return index === null ? null : (items[index] ?? null);
144
+ }
145
+
146
+ function parsePrefixedIndex(choice: string, prefix: string): number | null {
147
+ const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
148
+ const match = choice.match(new RegExp(`^${escapedPrefix}(\\d+)\\]`));
149
+ if (!match) return null;
150
+ const value = parseInt(match[1] ?? "", 10) - 1;
151
+ return Number.isNaN(value) || value < 0 ? null : value;
152
+ }
153
+
154
+ function formatSavedOption(memory: MemoryRecord, index: number): string {
155
+ const source = memory.metadata.source ?? "?";
156
+ const category = memory.category ?? memory.categories[0] ?? "?";
157
+ const confidence = typeof memory.metadata.confidence === "number"
158
+ ? ` ${Math.round((memory.metadata.confidence as number) * 100)}%`
159
+ : "";
160
+ return `[s${index + 1}] ${summarize(memory.text)} (${category}, ${source}${confidence})`;
161
+ }
162
+
163
+ function formatPendingOption(candidate: PendingCandidate, index: number): string {
164
+ return `[p${index + 1}] ${summarize(candidate.text)} (score ${candidate.score}, seen ${candidate.count}×, ${Math.round(candidate.strongestConfidence * 100)}%)`;
165
+ }
166
+
167
+ async function savePendingCandidate(ui: CtxUi, service: ReviewService, candidate: PendingCandidate): Promise<boolean> {
168
+ const ok = await ui.confirm(
169
+ "Save pending candidate?",
170
+ `${candidate.text}\n\nThis promotes it from pending review into saved memory.`,
171
+ );
172
+ if (!ok) {
173
+ ui.notify("No changes made.", "info");
174
+ return false;
175
+ }
176
+
177
+ const saved = await service.promotePendingCandidate(candidate.key);
178
+ if (!saved) {
179
+ ui.notify("Could not save that pending candidate.", "error");
180
+ return false;
181
+ }
182
+
183
+ ui.notify(`Saved pending candidate: ${summarize(candidate.text)}`, "info");
184
+ return true;
185
+ }
186
+
187
+ async function saveAllPendingCandidates(ui: CtxUi, service: ReviewService, pending: PendingCandidate[]): Promise<number> {
188
+ const ok = await ui.confirm(
189
+ "Save all pending candidates?",
190
+ `${pending.length} pending candidate${pending.length === 1 ? "" : "s"} will be promoted into saved memory.`,
191
+ );
192
+ if (!ok) {
193
+ ui.notify("No changes made.", "info");
194
+ return 0;
195
+ }
196
+
197
+ let savedCount = 0;
198
+ for (const candidate of pending) {
199
+ if (await service.promotePendingCandidate(candidate.key)) {
200
+ savedCount += 1;
201
+ }
202
+ }
203
+ return savedCount;
204
+ }
205
+
206
+ async function deleteSavedMemory(ui: CtxUi, service: ReviewService, memory: MemoryRecord): Promise<boolean> {
207
+ const ok = await ui.confirm(
208
+ "Delete auto-saved memory?",
209
+ `${memory.text}\n\nThis removes it from saved memories.`,
210
+ );
211
+ if (!ok) {
212
+ ui.notify("No changes made.", "info");
213
+ return false;
214
+ }
215
+
216
+ if (memory.id) {
217
+ await service.delete(memory.id);
218
+ }
219
+ ui.notify(`Deleted saved memory: ${summarize(memory.text)}`, "info");
220
+ return true;
221
+ }
222
+
223
+ async function deleteAllListedItems(
224
+ ui: CtxUi,
225
+ service: ReviewService,
226
+ autoSaved: MemoryRecord[],
227
+ pending: PendingCandidate[],
228
+ ): Promise<{ deletedSaved: number; dismissedPending: number } | null> {
229
+ const ok = await ui.confirm(
230
+ "Delete all listed items?",
231
+ `${autoSaved.length} saved memor${autoSaved.length === 1 ? "y" : "ies"} and ${pending.length} pending candidate${pending.length === 1 ? "" : "s"} will be removed from this review list.`,
232
+ );
233
+ if (!ok) {
234
+ ui.notify("No changes made.", "info");
235
+ return null;
236
+ }
237
+
238
+ let deletedSaved = 0;
239
+ for (const memory of autoSaved) {
240
+ if (memory.id) {
241
+ await service.delete(memory.id);
242
+ deletedSaved += 1;
243
+ }
244
+ }
245
+
246
+ let dismissedPending = 0;
247
+ for (const candidate of pending) {
248
+ if (service.dismissPendingCandidate(candidate.key)) {
249
+ dismissedPending += 1;
250
+ }
251
+ }
252
+
253
+ ui.notify(
254
+ `Deleted ${deletedSaved} saved memor${deletedSaved === 1 ? "y" : "ies"} and ${dismissedPending} pending candidate${dismissedPending === 1 ? "" : "s"}.`,
255
+ "info",
256
+ );
257
+ return { deletedSaved, dismissedPending };
258
+ }
259
+
260
+ async function dismissPendingCandidate(ui: CtxUi, service: ReviewService, candidate: PendingCandidate): Promise<boolean> {
261
+ const ok = await ui.confirm(
262
+ "Delete pending candidate?",
263
+ `${candidate.text}\n\nThis removes it from the pending review queue.`,
264
+ );
265
+ if (!ok) {
266
+ ui.notify("No changes made.", "info");
267
+ return false;
268
+ }
269
+
270
+ service.dismissPendingCandidate(candidate.key);
271
+ ui.notify(`Deleted pending candidate: ${summarize(candidate.text)}`, "info");
272
+ return true;
273
+ }
274
+
275
+ function notifySummary(ui: CtxUi, deletedSaved: number, dismissedPending: number, savedPending: number): void {
276
+ if (deletedSaved === 0 && dismissedPending === 0 && savedPending === 0) {
277
+ ui.notify("Review finished — no changes made.", "info");
278
+ return;
279
+ }
280
+
281
+ ui.notify(
282
+ `Review updated: saved ${savedPending}, removed ${deletedSaved} saved, and deleted ${dismissedPending} pending candidates.`,
283
+ "info",
284
+ );
285
+ }
286
+
287
+ function summarize(text: string, max = 90): string {
288
+ const singleLine = text.replace(/\s+/g, " ").trim();
289
+ if (singleLine.length <= max) return singleLine;
290
+ return `${singleLine.slice(0, Math.max(0, max - 1))}…`;
291
+ }
@@ -0,0 +1,189 @@
1
+ import { resolveConfig, resolveConfigPath, writeConfig } from "../config.ts";
2
+ import { runConfigScreen } from "../config-screen.ts";
3
+ import {
4
+ applyDraftDefaults,
5
+ createDraft,
6
+ type DraftConfig,
7
+ summarizeDraft,
8
+ toPartialConfig,
9
+ validateDraft,
10
+ } from "../config/schema.ts";
11
+ import { describeError } from "../utils.ts";
12
+ import type { CtxUi } from "./ui.ts";
13
+
14
+ const DB_MODE_OPTIONS = [
15
+ "Local — SQLite file on disk (default)",
16
+ "Cloud — Turso hosted libSQL (sync everywhere)",
17
+ ];
18
+
19
+ const PROVIDER_OPTIONS = [
20
+ "OpenAI — text-embedding-3-small (needs API key)",
21
+ "LM Studio — local at http://localhost:1234/v1",
22
+ "Ollama — local at http://localhost:11434/v1",
23
+ "Custom — any /v1/embeddings endpoint",
24
+ ];
25
+
26
+ const EXTRACTOR_MODE_OPTIONS = [
27
+ "Off — disable proactive extraction",
28
+ "Balanced — default tradeoff",
29
+ "Conservative — lower cost, higher precision",
30
+ "Proactive — more discovery, more review",
31
+ ];
32
+
33
+ export async function runSetup(ui: CtxUi): Promise<void> {
34
+ try {
35
+ ui.notify(`Config will be saved to: ${resolveConfigPath()}`, "info");
36
+
37
+ const current = resolveConfig();
38
+ const screenResult = await runConfigScreen(ui, current);
39
+ if (screenResult) {
40
+ if (screenResult.cancelled) {
41
+ ui.notify("Setup cancelled.", "info");
42
+ return;
43
+ }
44
+ writeConfig(screenResult.partial);
45
+ ui.notify("Config saved. /reload to apply.", "info");
46
+ return;
47
+ }
48
+
49
+ const draft = await collectDraftFromPrompts(ui, createDraft(current));
50
+ const errors = validateDraft(draft);
51
+ if (errors.length > 0) {
52
+ ui.notify(`Setup failed: ${errors[0]}`, "error");
53
+ return;
54
+ }
55
+
56
+ const ok = await ui.confirm("Save config?", summarizeDraft(draft).join("\n"));
57
+ if (!ok) {
58
+ ui.notify("Setup cancelled.", "info");
59
+ return;
60
+ }
61
+
62
+ writeConfig(toPartialConfig(draft));
63
+ ui.notify("Config saved. /reload to apply.", "info");
64
+ } catch (error) {
65
+ ui.notify(`Setup failed: ${describeError(error)}`, "error");
66
+ }
67
+ }
68
+
69
+ async function collectDraftFromPrompts(ui: CtxUi, draft: DraftConfig): Promise<DraftConfig> {
70
+ const dbChoice = await ui.select("Database mode", DB_MODE_OPTIONS);
71
+ draft.dbMode = dbChoice?.startsWith("Cloud") ? "cloud" : "local";
72
+
73
+ if (draft.dbMode === "local") {
74
+ draft.dbPath = (await ui.input("Database file path", draft.dbPath)) || draft.dbPath;
75
+ } else {
76
+ draft.dbUrl = await requireInput(
77
+ ui,
78
+ "Turso database URL (libsql://…)",
79
+ 'URL must start with "libsql://"',
80
+ (value) => value.startsWith("libsql://"),
81
+ draft.dbUrl,
82
+ );
83
+ draft.dbAuthToken = await requireInput(
84
+ ui,
85
+ "Turso auth token",
86
+ "Auth token is required for cloud databases",
87
+ (value) => value.length > 0,
88
+ draft.dbAuthToken,
89
+ );
90
+ }
91
+
92
+ const providerChoice = await ui.select("Embedding provider", PROVIDER_OPTIONS);
93
+ draft.embeddingProvider = parseProviderChoice(providerChoice ?? "");
94
+ applyDraftDefaults(draft);
95
+
96
+ switch (draft.embeddingProvider) {
97
+ case "openai":
98
+ draft.embeddingApiKey = await requireInput(
99
+ ui,
100
+ "OpenAI API key",
101
+ "API key is required for OpenAI embeddings",
102
+ (value) => value.length > 0,
103
+ draft.embeddingApiKey,
104
+ );
105
+ draft.embeddingModel = (await ui.input("Model name", draft.embeddingModel)) || draft.embeddingModel;
106
+ break;
107
+ case "lm_studio":
108
+ draft.embeddingBaseUrl = (await ui.input("LM Studio base URL", draft.embeddingBaseUrl)) || draft.embeddingBaseUrl;
109
+ break;
110
+ case "ollama":
111
+ draft.embeddingModel = await requireInput(
112
+ ui,
113
+ "Ollama embedding model",
114
+ "Model name is required (e.g. nomic-embed-text)",
115
+ (value) => value.length > 0,
116
+ draft.embeddingModel,
117
+ );
118
+ draft.embeddingBaseUrl = (await ui.input("Ollama base URL", draft.embeddingBaseUrl)) || draft.embeddingBaseUrl;
119
+ break;
120
+ case "custom":
121
+ draft.embeddingBaseUrl = await requireInput(
122
+ ui,
123
+ "Embedding base URL",
124
+ "Base URL is required",
125
+ (value) => value.length > 0,
126
+ draft.embeddingBaseUrl,
127
+ );
128
+ draft.embeddingModel = await requireInput(
129
+ ui,
130
+ "Model name",
131
+ "Model name is required",
132
+ (value) => value.length > 0,
133
+ draft.embeddingModel,
134
+ );
135
+ draft.embeddingApiKey = (await ui.input("API key (or placeholder)", draft.embeddingApiKey)) || draft.embeddingApiKey;
136
+ break;
137
+ }
138
+
139
+ const modeChoice = await ui.select("Memory mode", EXTRACTOR_MODE_OPTIONS);
140
+ draft.extractorMode = parseExtractorModeChoice(modeChoice ?? "");
141
+ applyDraftDefaults(draft);
142
+
143
+ if (draft.extractorMode !== "off") {
144
+ draft.extractorModel = (await ui.input(
145
+ "Model ID to use for extraction (leave blank to disable extraction)",
146
+ draft.extractorModel,
147
+ )) || draft.extractorModel;
148
+ draft.extractorTriggerEvery = (await ui.input(
149
+ `Extract every N turns (default ${draft.extractorTriggerEvery})`,
150
+ draft.extractorTriggerEvery,
151
+ )) || draft.extractorTriggerEvery;
152
+ draft.extractorDebug = await ui.confirm(
153
+ "Show extractor debug widget?",
154
+ "Enable the live extractor debug widget while developing.",
155
+ );
156
+ }
157
+
158
+ return applyDraftDefaults(draft);
159
+ }
160
+
161
+ async function requireInput(
162
+ ui: CtxUi,
163
+ prompt: string,
164
+ errorMessage: string,
165
+ validate: (value: string) => boolean,
166
+ defaultValue?: string,
167
+ ): Promise<string> {
168
+ let value = (await ui.input(prompt, defaultValue)) ?? "";
169
+ while (!validate(value.trim())) {
170
+ ui.notify(errorMessage, "error");
171
+ value = (await ui.input(prompt, defaultValue)) ?? "";
172
+ }
173
+ return value.trim();
174
+ }
175
+
176
+ function parseProviderChoice(choice: string): DraftConfig["embeddingProvider"] {
177
+ const lower = choice.toLowerCase();
178
+ if (lower.startsWith("openai")) return "openai";
179
+ if (lower.includes("lm") || lower.includes("studio")) return "lm_studio";
180
+ if (lower.startsWith("ollama")) return "ollama";
181
+ return "custom";
182
+ }
183
+
184
+ function parseExtractorModeChoice(choice: string): DraftConfig["extractorMode"] {
185
+ if (choice.startsWith("Off")) return "off";
186
+ if (choice.startsWith("Conservative")) return "conservative";
187
+ if (choice.startsWith("Proactive")) return "proactive";
188
+ return "balanced";
189
+ }
@@ -0,0 +1,32 @@
1
+ import { resolveConfig, resolveConfigPath } from "../config.ts";
2
+ import { maskSecret } from "../utils.ts";
3
+ import type { CtxUi } from "./ui.ts";
4
+
5
+ export function runStatus(ui: CtxUi): void {
6
+ const config = resolveConfig();
7
+ ui.notify("─── Noodle Memory ───", "info");
8
+ ui.notify("Commands: /noodle remember | /noodle forget | /noodle edit | /noodle review | /noodle settings | /noodle web", "info");
9
+ ui.notify(`Config: ${resolveConfigPath()}`, "info");
10
+ ui.notify(`Database: ${config.db.mode} ${config.db.mode === "cloud" ? (config.db.url ?? "") : config.db.path}`, "info");
11
+ if (config.db.mode === "cloud" && config.db.authToken) {
12
+ ui.notify(`Auth token: ${maskSecret(config.db.authToken)}`, "info");
13
+ }
14
+ ui.notify(
15
+ `Embedding: ${config.embedding.provider} ${config.embedding.model}${config.embedding.dimensions ? ` ${config.embedding.dimensions}d` : ""}`,
16
+ "info",
17
+ );
18
+ ui.notify(`Endpoint: ${config.embedding.baseUrl}`, "info");
19
+ ui.notify(`API key: ${maskSecret(config.embedding.apiKey)}`, "info");
20
+
21
+ const extractor = config.extractor;
22
+ if ((extractor?.mode ?? "off") !== "off") {
23
+ const modelLabel = extractor?.model ?? "(none — extraction disabled)";
24
+ ui.notify(
25
+ `Memory mode: ${extractor?.mode ?? "balanced"} ${modelLabel} every ${extractor?.triggerEvery ?? 10} turns debug ${extractor?.debug ? "on" : "off"}`,
26
+ "info",
27
+ );
28
+ return;
29
+ }
30
+
31
+ ui.notify("Memory mode: off", "info");
32
+ }
@@ -0,0 +1,14 @@
1
+ export type CtxUi = {
2
+ select: (title: string, options: string[]) => Promise<string | undefined>;
3
+ input: (prompt: string, defaultValue?: string) => Promise<string | undefined>;
4
+ confirm: (title: string, message: string) => Promise<boolean>;
5
+ notify: (message: string, level: "info" | "error") => void;
6
+ custom?: <T>(
7
+ factory: (
8
+ tui: unknown,
9
+ theme: unknown,
10
+ keybindings: unknown,
11
+ done: (result: T) => void,
12
+ ) => unknown,
13
+ ) => Promise<T>;
14
+ };
@@ -0,0 +1,40 @@
1
+ import {
2
+ isExplorerRunning,
3
+ openExplorerBrowser,
4
+ readExplorerState,
5
+ spawnExplorer,
6
+ stopExplorer,
7
+ } from "../web/manager.ts";
8
+ import type { CtxUi } from "./ui.ts";
9
+
10
+ export async function runWeb(ui: CtxUi, subcommand: string): Promise<void> {
11
+ if (subcommand.match(/web\s+stop\b/)) {
12
+ ui.notify(stopExplorer() ? "Memory Explorer stopped." : "Memory Explorer is not running.", "info");
13
+ return;
14
+ }
15
+
16
+ const dev = /\bdev\b/.test(subcommand);
17
+ const portMatch = subcommand.match(/\b(\d{2,5})\b/);
18
+ const port = portMatch?.[1] ? parseInt(portMatch[1], 10) : 3000;
19
+
20
+ if (isExplorerRunning()) {
21
+ const running = readExplorerState();
22
+ const activePort = running?.port ?? port;
23
+ openExplorerBrowser(activePort);
24
+ ui.notify(`Memory Explorer already running at http://localhost:${activePort}`, "info");
25
+ return;
26
+ }
27
+
28
+ const spawned = spawnExplorer(port, dev);
29
+ if (!spawned) {
30
+ ui.notify("Failed to start Memory Explorer.", "error");
31
+ return;
32
+ }
33
+
34
+ ui.notify(
35
+ dev
36
+ ? `Memory Explorer (dev) starting at http://localhost:${port} — use /noodle web stop when done`
37
+ : `Memory Explorer started at http://localhost:${port} — closes automatically when all tabs are closed`,
38
+ "info",
39
+ );
40
+ }
@@ -0,0 +1 @@
1
+ export { registerCommands } from "./commands/index.ts";