@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,192 @@
1
+ import type { Api, Model } from "@earendil-works/pi-ai";
2
+
3
+ import type { JsonObject, NotificationTarget, SessionManagerLike } from "../types.ts";
4
+
5
+ export type MemoryCategory =
6
+ | "identity"
7
+ | "response_style"
8
+ | "coding_pref"
9
+ | "workflow"
10
+ | "project";
11
+
12
+ export type MemoryDurability = "durable" | "semi_durable" | "ephemeral";
13
+ export type MemoryApplicability = "user" | "project" | "unknown";
14
+
15
+ export type MemorySource = "explicit" | "heuristic" | "repetition" | "llm_extracted" | "consolidated";
16
+
17
+ export type MemoryScope = {
18
+ userId?: string;
19
+ assistantId?: string;
20
+ sessionId?: string;
21
+ };
22
+
23
+ export type MemoryMessage = {
24
+ role: string;
25
+ content: string;
26
+ };
27
+
28
+ export type MemoryRecord = {
29
+ id?: string;
30
+ text: string;
31
+ category?: MemoryCategory;
32
+ categories: string[];
33
+ metadata: JsonObject;
34
+ score?: number;
35
+ scope?: MemoryScope;
36
+ createdAt?: number;
37
+ lastRetrieved?: number;
38
+ retrievalCount?: number;
39
+ };
40
+
41
+ export type AddMemoryInput = {
42
+ text?: string;
43
+ messages?: MemoryMessage[];
44
+ metadata?: JsonObject;
45
+ category?: MemoryCategory;
46
+ categories?: string[];
47
+ scope?: MemoryScope;
48
+ };
49
+
50
+ export type MemorySearchInput = {
51
+ query: string;
52
+ scope?: MemoryScope;
53
+ categories?: string[];
54
+ limit?: number;
55
+ threshold?: number;
56
+ filters?: JsonObject;
57
+ };
58
+
59
+ export type MemoryListInput = {
60
+ scope?: MemoryScope;
61
+ };
62
+
63
+ export type UpdateMemoryInput = {
64
+ text?: string;
65
+ metadata?: JsonObject;
66
+ };
67
+
68
+ export type ConversationCaptureInput = {
69
+ messages: MemoryMessage[];
70
+ metadata?: JsonObject;
71
+ scope?: MemoryScope;
72
+ };
73
+
74
+ export type MemoryCandidate = {
75
+ text: string;
76
+ normalized: string;
77
+ category: MemoryCategory;
78
+ durability: MemoryDurability;
79
+ applicability?: MemoryApplicability;
80
+ source: MemorySource;
81
+ confidence: number;
82
+ explicit: boolean;
83
+ reasons: string[];
84
+ metadata: JsonObject;
85
+ };
86
+
87
+ export type PrefilterResult = {
88
+ hasCandidate: boolean;
89
+ shouldRetrieve: boolean;
90
+ candidateReasons: string[];
91
+ candidates: MemoryCandidate[];
92
+ };
93
+
94
+ export type LocalSignal = {
95
+ key: string;
96
+ text: string;
97
+ normalized: string;
98
+ category: MemoryCategory;
99
+ durability: MemoryDurability;
100
+ applicability?: MemoryApplicability;
101
+ source: MemorySource;
102
+ explicit: boolean;
103
+ count: number;
104
+ lastSeenAt: number;
105
+ strongestConfidence: number;
106
+ reasons: string[];
107
+ metadata: JsonObject;
108
+ retrievalCount?: number;
109
+ lastRetrievedAt?: number;
110
+ promotedAt?: number;
111
+ lastPromotionScore?: number;
112
+ lastDecisionAction?: MemoryPolicyAction;
113
+ };
114
+
115
+ export type MemoryPolicyAction = "save" | "pending" | "discard";
116
+
117
+ export type ExtractionStability = "stable" | "likely_stable" | "uncertain";
118
+ export type ExtractionSensitivity = "safe" | "sensitive";
119
+
120
+ export type ExtractionCandidate = {
121
+ text: string;
122
+ category: MemoryCategory;
123
+ durability: MemoryDurability;
124
+ confidence: number;
125
+ reason: string;
126
+ stability: ExtractionStability;
127
+ sensitivity: ExtractionSensitivity;
128
+ suggestedAction: MemoryPolicyAction;
129
+ applicability: MemoryApplicability;
130
+ applicabilityConfidence?: number;
131
+ applicabilityReason?: string;
132
+ };
133
+
134
+ export type MemoryPolicyDecision = {
135
+ action: MemoryPolicyAction;
136
+ score: number;
137
+ shouldPromote: boolean;
138
+ reasons: string[];
139
+ };
140
+
141
+ export type ConsolidationReport = {
142
+ merged: number;
143
+ deleted: number;
144
+ };
145
+
146
+ export type MemoryExtractorResolution = {
147
+ model: Model<Api>;
148
+ apiKey: string;
149
+ headers?: Record<string, string>;
150
+ };
151
+
152
+ export type MemoryCaptureEventBase = {
153
+ sessionManager: SessionManagerLike;
154
+ target?: NotificationTarget;
155
+ extractor?: {
156
+ resolve: () => Promise<MemoryExtractorResolution | null>;
157
+ };
158
+ };
159
+
160
+ export type MemoryCaptureEvent =
161
+ | (MemoryCaptureEventBase & {
162
+ type: "user_input";
163
+ text: string;
164
+ })
165
+ | (MemoryCaptureEventBase & {
166
+ type: "session_before_compact";
167
+ })
168
+ | (MemoryCaptureEventBase & {
169
+ type: "session_before_switch";
170
+ reason: string;
171
+ })
172
+ | (MemoryCaptureEventBase & {
173
+ type: "session_shutdown";
174
+ reason: string;
175
+ });
176
+
177
+ export type MemoryCapturePlan = {
178
+ runHeuristics: boolean;
179
+ runLlmExtraction: boolean;
180
+ captureConversation: boolean;
181
+ consolidate: boolean;
182
+ extractionReason?: string;
183
+ conversationReason?: string;
184
+ };
185
+
186
+ export type MemoryCaptureResult = {
187
+ plan: MemoryCapturePlan;
188
+ automaticCaptureQueued: boolean;
189
+ llmExtractionQueued: boolean;
190
+ conversationCaptureQueued: boolean;
191
+ consolidationQueued: boolean;
192
+ };
@@ -0,0 +1,11 @@
1
+ import type { NotificationTarget } from "./types.ts";
2
+
3
+ export function notify(target: NotificationTarget | undefined, message: string, level: "info" | "error"): void {
4
+ if (!target) return;
5
+
6
+ try {
7
+ target.ui.notify(message, level);
8
+ } catch {
9
+ // ignore notify failures
10
+ }
11
+ }
package/src/queue.ts ADDED
@@ -0,0 +1,42 @@
1
+ import { notify } from "./notifications.ts";
2
+ import type { NotificationTarget } from "./types.ts";
3
+ import { describeError } from "./utils.ts";
4
+
5
+ let nextWriteJobId = 1;
6
+ let pendingWriteQueue: Promise<void> = Promise.resolve();
7
+
8
+ export function enqueueWriteTask(options: {
9
+ label: string;
10
+ task: () => Promise<void>;
11
+ target?: NotificationTarget;
12
+ successMessage?: string;
13
+ onSuccess?: () => void;
14
+ onFailure?: () => void;
15
+ }): number {
16
+ const jobId = nextWriteJobId++;
17
+
18
+ const run = async () => {
19
+ try {
20
+ await options.task();
21
+ if (options.successMessage) {
22
+ notify(options.target, options.successMessage, "info");
23
+ }
24
+ options.onSuccess?.();
25
+ } catch (error) {
26
+ options.onFailure?.();
27
+ const message = `${options.label} failed: ${describeError(error)}`;
28
+ if (options.target) {
29
+ notify(options.target, message, "error");
30
+ } else {
31
+ console.error(message);
32
+ }
33
+ }
34
+ };
35
+
36
+ pendingWriteQueue = pendingWriteQueue.catch(() => undefined).then(run);
37
+ return jobId;
38
+ }
39
+
40
+ export async function flushPendingWrites(): Promise<void> {
41
+ await pendingWriteQueue.catch(() => undefined);
42
+ }
package/src/session.ts ADDED
@@ -0,0 +1,72 @@
1
+ import { DEFAULT_AGENT_ID } from "./constants.ts";
2
+ import type { MemoryMessage } from "./memory/types.ts";
3
+ import { flushPendingWrites } from "./queue.ts";
4
+ import type { SessionEntryLike, SessionManagerLike } from "./types.ts";
5
+ import { extractTextContent } from "./utils.ts";
6
+
7
+ export { flushPendingWrites };
8
+
9
+ export function ensureMessages(text?: string, messages?: MemoryMessage[]): MemoryMessage[] {
10
+ if (messages && messages.length > 0) return messages;
11
+ if (text && text.trim()) {
12
+ return [{ role: "user", content: text.trim() }];
13
+ }
14
+ throw new Error("Provide either text or messages.");
15
+ }
16
+
17
+ export function resolveAssistantId(assistantId?: string): string {
18
+ const trimmed = assistantId?.trim();
19
+ return trimmed || DEFAULT_AGENT_ID;
20
+ }
21
+
22
+ export function collectSessionMessages(sessionManager: SessionManagerLike): MemoryMessage[] {
23
+ const branch = sessionManager.getBranch();
24
+ const messages: MemoryMessage[] = [];
25
+
26
+ for (const entry of branch) {
27
+ if (!entry || typeof entry !== "object") continue;
28
+ const typedEntry = entry as SessionEntryLike;
29
+
30
+ if (typedEntry.type !== "message") continue;
31
+ const role = typedEntry.message?.role;
32
+ if (role !== "user" && role !== "assistant") continue;
33
+
34
+ const content = extractTextContent(typedEntry.message?.content);
35
+ if (!content) continue;
36
+ messages.push({ role, content });
37
+ }
38
+
39
+ return messages;
40
+ }
41
+
42
+ export function buildSessionSignature(sessionManager: SessionManagerLike): string {
43
+ const sessionFile = sessionManager.getSessionFile?.() || "ephemeral";
44
+ const leafId = sessionManager.getLeafId?.() || "no-leaf";
45
+ return `${sessionFile}::${leafId}`;
46
+ }
47
+
48
+ function looksLikeToolOrCodeChatter(content: string): boolean {
49
+ return /```|^\$ |\bstderr\b|\bstdout\b|traceback|exception|stack trace|npm ERR!/im.test(content);
50
+ }
51
+
52
+ export function selectMemoryWorthMessages(messages: MemoryMessage[]): MemoryMessage[] {
53
+ const filtered = messages.filter((message) => {
54
+ const trimmed = message.content.trim();
55
+ return trimmed.length >= 20 && !looksLikeToolOrCodeChatter(trimmed);
56
+ });
57
+ return filtered.slice(-20);
58
+ }
59
+
60
+ export function selectExtractorMessages(messages: MemoryMessage[]): MemoryMessage[] {
61
+ const filtered = messages.filter((message) => {
62
+ const trimmed = message.content.trim();
63
+ if (trimmed.length < 24 || looksLikeToolOrCodeChatter(trimmed)) return false;
64
+ if (message.role === "user") return true;
65
+ return /\b(prefer|usually|always|never|avoid|default|remember|name is|we(?:'|’)re|we are|our stack|standardi[sz]e)\b/i.test(trimmed);
66
+ });
67
+
68
+ const preferred = filtered.slice(-12);
69
+ const userCount = preferred.filter((message) => message.role === "user").length;
70
+ if (preferred.length >= 4 && userCount >= 2) return preferred;
71
+ return messages.slice(-8).filter((message) => message.content.trim().length >= 24);
72
+ }
package/src/tools.ts ADDED
@@ -0,0 +1,172 @@
1
+ import { Type } from "@earendil-works/pi-ai";
2
+ import { defineTool } from "@earendil-works/pi-coding-agent";
3
+
4
+ import { memoryService } from "./memory/runtime.ts";
5
+ import type { JsonObject } from "./types.ts";
6
+ import { formatJson } from "./utils.ts";
7
+
8
+ const scopeSchema = Type.Object({
9
+ userId: Type.Optional(Type.String({ description: "User identifier." })),
10
+ assistantId: Type.Optional(Type.String({ description: "Assistant identifier." })),
11
+ sessionId: Type.Optional(Type.String({ description: "Session/run identifier." })),
12
+ });
13
+
14
+ const metadataSchema = Type.Object({}, { additionalProperties: true, description: "Optional metadata." });
15
+
16
+ export const memoryAddTool = defineTool({
17
+ name: "memory_add",
18
+ label: "Memory Add",
19
+ description: "Store a memory record using the configured memory backend.",
20
+ promptSnippet: "Store important user or agent facts in long-term memory.",
21
+ promptGuidelines: [
22
+ "Use memory_add when the user explicitly asks to save a stable preference, identity detail, or workflow default.",
23
+ ],
24
+ parameters: Type.Object({
25
+ text: Type.Optional(Type.String({ description: "Convenience text memory to store." })),
26
+ messages: Type.Optional(
27
+ Type.Array(
28
+ Type.Object({
29
+ role: Type.String({ description: "Message role." }),
30
+ content: Type.String({ description: "Message content." }),
31
+ }),
32
+ { description: "Conversation messages to capture as memory." },
33
+ ),
34
+ ),
35
+ category: Type.Optional(Type.String({ description: "Primary memory category." })),
36
+ categories: Type.Optional(Type.Array(Type.String({ description: "Additional categories." }))),
37
+ scope: Type.Optional(scopeSchema),
38
+ metadata: Type.Optional(metadataSchema),
39
+ }),
40
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
41
+ await memoryService.add({
42
+ ...(params.text ? { text: params.text } : {}),
43
+ ...(params.messages ? { messages: params.messages } : {}),
44
+ ...(params.category ? { category: params.category as never } : {}),
45
+ ...(params.categories ? { categories: params.categories } : {}),
46
+ ...(params.scope ? { scope: params.scope } : {}),
47
+ ...(params.metadata ? { metadata: params.metadata as JsonObject } : {}),
48
+ });
49
+
50
+ const result = { queued: false, saved: true };
51
+
52
+ return {
53
+ content: [{ type: "text", text: formatJson(result) }],
54
+ details: result,
55
+ };
56
+ },
57
+ });
58
+
59
+ export const memorySearchTool = defineTool({
60
+ name: "memory_search",
61
+ label: "Memory Search",
62
+ description: "Search stored memories using the configured memory backend.",
63
+ promptSnippet: "Search memory for relevant saved facts.",
64
+ promptGuidelines: [
65
+ "Use memory_search before answering questions that depend on previously saved preferences or identity details.",
66
+ ],
67
+ parameters: Type.Object({
68
+ query: Type.String({ description: "Natural-language search query." }),
69
+ categories: Type.Optional(Type.Array(Type.String({ description: "Optional category filters." }))),
70
+ scope: Type.Optional(scopeSchema),
71
+ limit: Type.Optional(Type.Number({ description: "Maximum number of results." })),
72
+ threshold: Type.Optional(Type.Number({ description: "Optional backend similarity threshold." })),
73
+ filters: Type.Optional(Type.Object({}, { additionalProperties: true, description: "Backend-specific filters such as source, auto_saved, createdAfter, createdBefore, minRetrievalCount, maxRetrievalCount, minConfidence, metadata." })),
74
+ }),
75
+ async execute(_toolCallId, params) {
76
+ const result = await memoryService.search({
77
+ query: params.query,
78
+ ...(params.categories ? { categories: params.categories } : {}),
79
+ ...(params.scope ? { scope: params.scope } : {}),
80
+ ...(params.limit !== undefined ? { limit: params.limit } : {}),
81
+ ...(params.threshold !== undefined ? { threshold: params.threshold } : {}),
82
+ ...(params.filters ? { filters: params.filters as JsonObject } : {}),
83
+ });
84
+
85
+ return {
86
+ content: [{ type: "text", text: formatJson(result) }],
87
+ details: result,
88
+ };
89
+ },
90
+ });
91
+
92
+ export const memoryListTool = defineTool({
93
+ name: "memory_list",
94
+ label: "Memory List",
95
+ description: "List memories from the configured memory backend.",
96
+ parameters: Type.Object({
97
+ scope: Type.Optional(scopeSchema),
98
+ }),
99
+ async execute(_toolCallId, params) {
100
+ const result = await memoryService.list(params.scope);
101
+ return {
102
+ content: [{ type: "text", text: formatJson(result) }],
103
+ details: result,
104
+ };
105
+ },
106
+ });
107
+
108
+ export const memoryGetTool = defineTool({
109
+ name: "memory_get",
110
+ label: "Memory Get",
111
+ description: "Fetch a specific memory by ID.",
112
+ parameters: Type.Object({
113
+ id: Type.String({ description: "Memory ID." }),
114
+ }),
115
+ async execute(_toolCallId, params) {
116
+ const result = await memoryService.get(params.id);
117
+ return {
118
+ content: [{ type: "text", text: formatJson(result) }],
119
+ details: result,
120
+ };
121
+ },
122
+ });
123
+
124
+ export const memoryUpdateTool = defineTool({
125
+ name: "memory_update",
126
+ label: "Memory Update",
127
+ description: "Update a specific memory.",
128
+ parameters: Type.Object({
129
+ id: Type.String({ description: "Memory ID." }),
130
+ text: Type.Optional(Type.String({ description: "Replacement memory text." })),
131
+ metadata: Type.Optional(metadataSchema),
132
+ }),
133
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
134
+ await memoryService.update(params.id, {
135
+ ...(params.text ? { text: params.text } : {}),
136
+ ...(params.metadata ? { metadata: params.metadata as JsonObject } : {}),
137
+ });
138
+
139
+ const result = { updated: true, id: params.id };
140
+
141
+ return {
142
+ content: [{ type: "text", text: formatJson(result) }],
143
+ details: result,
144
+ };
145
+ },
146
+ });
147
+
148
+ export const memoryDeleteTool = defineTool({
149
+ name: "memory_delete",
150
+ label: "Memory Delete",
151
+ description: "Delete a specific memory.",
152
+ parameters: Type.Object({
153
+ id: Type.String({ description: "Memory ID." }),
154
+ }),
155
+ async execute(_toolCallId, params) {
156
+ await memoryService.delete(params.id);
157
+ const result = { deleted: true, id: params.id };
158
+ return {
159
+ content: [{ type: "text", text: formatJson(result) }],
160
+ details: result,
161
+ };
162
+ },
163
+ });
164
+
165
+ export const memoryTools = [
166
+ memoryAddTool,
167
+ memorySearchTool,
168
+ memoryListTool,
169
+ memoryGetTool,
170
+ memoryUpdateTool,
171
+ memoryDeleteTool,
172
+ ] as const;
package/src/types.ts ADDED
@@ -0,0 +1,81 @@
1
+ export type NoodleDbMode = "local" | "cloud";
2
+
3
+ export type NoodleEmbeddingProvider = "openai" | "lm_studio" | "ollama" | "custom";
4
+
5
+ export type NoodleExtractorMode = "off" | "conservative" | "balanced" | "proactive";
6
+
7
+ export type NoodleExtractorConfig = {
8
+ /**
9
+ * Behavior profile for proactive extraction.
10
+ * off = disable extractor
11
+ * conservative = fewer runs, higher save threshold
12
+ * balanced = default tradeoff
13
+ * proactive = more candidate discovery, more review load
14
+ */
15
+ mode?: NoodleExtractorMode;
16
+ /**
17
+ * Model ID to use for extraction (e.g. "claude-haiku-4-5-20251001").
18
+ * Must be a model already configured in Pi. Defaults to Pi's configured
19
+ * extractor default when unset.
20
+ */
21
+ model?: string;
22
+ /** Number of user turns between automatic extraction runs. Defaults by mode when unset. */
23
+ triggerEvery?: number;
24
+ /** Show the extractor debug widget in Pi while developing. */
25
+ debug?: boolean;
26
+ };
27
+
28
+ export type NoodleConfig = {
29
+ db: {
30
+ mode: NoodleDbMode;
31
+ /** File path for local mode */
32
+ path: string;
33
+ /** Turso URL for cloud mode (e.g. libsql://my-db.turso.io) */
34
+ url?: string;
35
+ /** Turso auth token for cloud mode */
36
+ authToken?: string;
37
+ };
38
+ embedding: {
39
+ /** Human-readable provider label (openai, lm_studio, ollama, custom) */
40
+ provider: NoodleEmbeddingProvider;
41
+ /** API key or placeholder */
42
+ apiKey: string;
43
+ /** Base URL for the /v1/embeddings endpoint */
44
+ baseUrl: string;
45
+ /** Model name */
46
+ model: string;
47
+ /** Optional explicit embedding dimension override for custom/nonstandard providers. */
48
+ dimensions?: number;
49
+ };
50
+ extractor?: NoodleExtractorConfig;
51
+ };
52
+
53
+ export type NoodleConfigPartial = {
54
+ db?: Partial<NoodleConfig["db"]> & { mode?: NoodleDbMode };
55
+ embedding?: Partial<NoodleConfig["embedding"]>;
56
+ extractor?: Partial<NoodleExtractorConfig>;
57
+ };
58
+
59
+ export type JsonObject = Record<string, unknown>;
60
+
61
+ export type NotifyLevel = "info" | "error";
62
+
63
+ export type NotificationTarget = {
64
+ ui: {
65
+ notify: (message: string, level: NotifyLevel) => void;
66
+ };
67
+ };
68
+
69
+ export type SessionManagerLike = {
70
+ getBranch: () => unknown[];
71
+ getSessionFile?: () => string | null | undefined;
72
+ getLeafId?: () => string | null | undefined;
73
+ };
74
+
75
+ export type SessionEntryLike = {
76
+ type?: string;
77
+ message?: {
78
+ role?: string;
79
+ content?: unknown;
80
+ };
81
+ };
package/src/utils.ts ADDED
@@ -0,0 +1,68 @@
1
+ import type { JsonObject } from "./types.ts";
2
+
3
+ export function maskSecret(value: string): string {
4
+ if (value.length <= 8) return "*".repeat(value.length);
5
+ return `${value.slice(0, 4)}…${value.slice(-4)}`;
6
+ }
7
+
8
+ export function describeError(error: unknown): string {
9
+ return error instanceof Error ? error.message : String(error);
10
+ }
11
+
12
+ export function formatJson(value: unknown): string {
13
+ return JSON.stringify(value, null, 2);
14
+ }
15
+
16
+ export function isJsonObject(value: unknown): value is JsonObject {
17
+ return value !== null && typeof value === "object" && !Array.isArray(value);
18
+ }
19
+
20
+ export function asStringArray(value: unknown): string[] {
21
+ if (typeof value === "string") return [value];
22
+ if (!Array.isArray(value)) return [];
23
+ return value.filter((item): item is string => typeof item === "string");
24
+ }
25
+
26
+ export function asFiniteNumber(value: unknown): number | undefined {
27
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
28
+ }
29
+
30
+ export function parseJsonObject(value: unknown, fallback: JsonObject = {}): JsonObject {
31
+ if (typeof value !== "string") return fallback;
32
+ try {
33
+ const parsed = JSON.parse(value);
34
+ return isJsonObject(parsed) ? parsed : fallback;
35
+ } catch {
36
+ return fallback;
37
+ }
38
+ }
39
+
40
+ export function parseJsonStringArray(value: unknown, fallback: string[] = []): string[] {
41
+ if (typeof value !== "string") return fallback;
42
+ try {
43
+ const parsed = JSON.parse(value);
44
+ if (typeof parsed === "string" || Array.isArray(parsed)) {
45
+ return asStringArray(parsed);
46
+ }
47
+ return fallback;
48
+ } catch {
49
+ return fallback;
50
+ }
51
+ }
52
+
53
+ export function extractTextContent(content: unknown): string {
54
+ if (typeof content === "string") return content;
55
+ if (!Array.isArray(content)) return "";
56
+
57
+ return content
58
+ .flatMap((part) => {
59
+ if (!part || typeof part !== "object") return [];
60
+ const typedPart = part as { type?: string; text?: string };
61
+ if (typedPart.type === "text" && typeof typedPart.text === "string") {
62
+ return [typedPart.text];
63
+ }
64
+ return [];
65
+ })
66
+ .join("\n")
67
+ .trim();
68
+ }
package/src/web/dev.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { memoryService } from "../memory/runtime.ts";
2
+ import { startMemoryExplorer } from "./server.ts";
3
+
4
+ const port = parseInt(process.env["PORT"] ?? "3000", 10);
5
+
6
+ startMemoryExplorer(memoryService, port, { dev: true, openBrowser: true });
7
+ console.log("Editing src/web/index.html will hot-reload the browser. Ctrl+C to stop.");