@mlx-bun/module-chat 0.0.0-stage → 1.0.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.
@@ -0,0 +1,265 @@
1
+ /** Shared browser/server protocol. Data only; no SDK, DOM, or native imports. */
2
+ export const APP_ROUTE_IDS = ["chat", "quantize", "finetune", "dataset", "status"] as const;
3
+
4
+ export type ApprovalDecision = "allow" | "deny";
5
+
6
+ export interface ImageAttachment {
7
+ data: string;
8
+ mimeType: string;
9
+ }
10
+
11
+ export type ClientMessage =
12
+ | { type: "prompt"; text: string; images?: ImageAttachment[] }
13
+ | { type: "abort" }
14
+ // `editedArgs`: when the user edited the proposed arguments in the
15
+ // approval card's textarea before approving, the (re-parsed) JSON object
16
+ // to substitute for the tool's original input — mutated onto the pi SDK's
17
+ // ToolCallEvent.input in place (the SDK's documented mutation contract;
18
+ // see installApprovalGate). Ignored on "deny". `alwaysAllow`: persist this
19
+ // tool to the durable always-allow config (chat/tool-approvals.ts) so
20
+ // future calls to the SAME TOOL NAME skip the card entirely, on this and
21
+ // every other session.
22
+ | { type: "approval"; callId: string; decision: ApprovalDecision; editedArgs?: Record<string, unknown>; alwaysAllow?: boolean }
23
+ // Toggle the model's reasoning channel on/off (only meaningful when the
24
+ // model supports thinking — ready.thinking). Maps to Pi's session thinking
25
+ // level: "medium" (on) ↔ "off". Pi sends it as enable_thinking to the server.
26
+ | { type: "set_thinking"; enabled: boolean }
27
+ // Opt-in toggle (default OFF): "let the agent
28
+ // touch files on this machine". Persisted per-browser by the client
29
+ // (localStorage `mlxbun.codingTools`); the ALLOWLIST decision is enforced
30
+ // here, server-side. buildPiAgentSurface's tool list is fixed for the
31
+ // life of an AgentSession (pi's DefaultResourceLoader config is baked in
32
+ // at createAgentSessionServices time), so this can't rewire an
33
+ // in-progress session — it takes effect starting with the next
34
+ // new_session/open_session/fork_session (createRuntimeFactory reads
35
+ // this.codingToolsRequested fresh every time the SDK calls it, including
36
+ // from AgentSessionRuntime.newSession()'s internal re-invocation). The
37
+ // `coding_tools` ServerMessage below reports the honest current-vs-pending
38
+ // state so the UI can say so instead of implying it applies immediately.
39
+ | { type: "set_coding_tools"; enabled: boolean }
40
+ // Select the active LoRA adapter for subsequent turns (null = none/base).
41
+ // app.html mounts it (POST /v1/adapters) before sending this; the
42
+ // before_provider_request hook injects it into the provider payload.
43
+ | { type: "set_adapter"; id: string | null }
44
+ // Per-request sampling overrides. Each field is optional; null/undefined
45
+ // means "leave it to the server default" (the mode-aware recommended
46
+ // value resolved in toOptions). A present numeric value is injected into
47
+ // the provider payload and always wins. The before_provider_request hook
48
+ // injects whatever is set here.
49
+ //
50
+ // `scope` (`02d723a:docs/design/web-chat-redesign.md` Appendix A,
51
+ // beat-matrix Axis 4 "per-message sampling scope"): "session" (default,
52
+ // omitting the field means this too) persists
53
+ // the override on the connection until changed — today's behavior,
54
+ // unchanged. "next_turn" stores a ONE-SHOT override applied to exactly
55
+ // the next prompt's injection, then cleared automatically — it never
56
+ // touches the session-level values (a later plain set_sampling with
57
+ // scope:"session" or omitted still sees whatever was there before this
58
+ // message). Composition when both are present: next_turn's SET fields
59
+ // win over session's for that one request; anything next_turn leaves
60
+ // null/unset falls back to the session-level override for that field
61
+ // (composeSampling — session-level itself falls back to the server's
62
+ // mode-aware default per injectSampling's existing contract).
63
+ | {
64
+ type: "set_sampling";
65
+ scope?: "session" | "next_turn";
66
+ temperature?: number | null;
67
+ top_p?: number | null;
68
+ top_k?: number | null;
69
+ min_p?: number | null;
70
+ xtc_probability?: number | null;
71
+ xtc_threshold?: number | null;
72
+ repetition_penalty?: number | null;
73
+ repetition_context_size?: number | null;
74
+ presence_penalty?: number | null;
75
+ frequency_penalty?: number | null;
76
+ seed?: number | null;
77
+ }
78
+ // Per-session USER system prompt, layered ON TOP OF (not replacing) the
79
+ // built-in surface prompt (buildWebChatSystemPrompt + memoryHint). null
80
+ // clears it. Applied by the before_agent_start hook on the NEXT turn (pi
81
+ // calls it fresh inside every session.prompt(), so this is a true
82
+ // per-turn injection, not a session-creation-only setting — see
83
+ // installSystemPromptHook). Presets v1 (composer.ts) send this alongside
84
+ // set_sampling to apply a saved bundle.
85
+ | { type: "set_system_prompt"; text: string | null }
86
+ // Session management (recent-chats sidebar + new chat).
87
+ | { type: "new_session" }
88
+ | { type: "list_sessions" }
89
+ | { type: "open_session"; path: string }
90
+ | { type: "fork_session"; path: string }
91
+ | { type: "delete_session"; path: string }
92
+ // Message actions. All three navigate the session's leaf
93
+ // pointer via AgentSession.navigateTree() then re-prompt — a sibling
94
+ // branch in the SAME session file, never a new one (see the helpers above
95
+ // findLastUserMessageEntry for the full rationale). Regenerate re-sends the
96
+ // last user message's original content verbatim; edit_resend re-sends it
97
+ // with edited text (images preserved); switch_sibling moves the active
98
+ // leaf to a previously-created sibling (an earlier edit/regeneration)
99
+ // without sending anything new.
100
+ | { type: "regenerate" }
101
+ | { type: "edit_resend"; text: string }
102
+ | { type: "switch_sibling"; entryId: string }
103
+ // App-aware assistant (`02d723a:docs/design/web-chat-redesign.md` Appendix A,
104
+ // beat-matrix Axis 12): pushed
105
+ // by chat.ts on every route change AND on wizard-step change (quantize/
106
+ // finetune/dataset), never on a timer. `context` is opaque here (the
107
+ // browser's AppContext shape, packages/module-chat/src/panel/assistant.ts) — stored verbatim
108
+ // as the connection's currentAppContext and returned by the
109
+ // get_current_app_context tool, plus mined for the compact one-line
110
+ // ambient context auto-prepended to the NEXT prompt (see
111
+ // installAppContextHook / ambientContextLine). This is a DIFFERENT frame
112
+ // from the server->client `context` ServerMessage (context-window usage,
113
+ // above) — same name, opposite direction, disambiguated by which type
114
+ // union it's a member of; the two are never in scope at the same call
115
+ // site (ClientMessage here vs. ServerMessage below).
116
+ | { type: "context"; context: AppUiContext };
117
+
118
+ export interface UiSnapshotElement {
119
+ ref: string;
120
+ tag: string;
121
+ label: string;
122
+ kind: "interactive" | "region";
123
+ role?: string;
124
+ selector: string;
125
+ spotlightId?: string;
126
+ }
127
+
128
+ export interface UiSnapshot {
129
+ route: string;
130
+ capturedAt: string;
131
+ elements: UiSnapshotElement[];
132
+ }
133
+
134
+ export interface WizardStep {
135
+ index: number;
136
+ count: number;
137
+ label: string;
138
+ }
139
+
140
+ export interface AppUiContext {
141
+ route: string;
142
+ view?: string;
143
+ step?: WizardStep;
144
+ snapshot: UiSnapshot;
145
+ }
146
+
147
+ export type AppRouteId = (typeof APP_ROUTE_IDS)[number];
148
+
149
+ export interface ReadyGenDefaults {
150
+ temperature: number | null;
151
+ topP: number | null;
152
+ topK: number | null;
153
+ }
154
+
155
+ export type ServerMessage =
156
+ // `vision`: whether the loaded model can accept images (drives the UI's
157
+ // image-attach affordance — false on e4b until the SigLIP sidecar lands).
158
+ // `audio`: whether the loaded model can accept `input_audio` parts (audio
159
+ // tower loaded or loadable). Discovery-only for now: pi's Model.input type
160
+ // has no "audio" modality, so unlike `vision` this flag never feeds the
161
+ // provider declaration — clients use it instead of probing for a 400.
162
+ // `thinking`: whether the model has a switchable reasoning channel (drives
163
+ // the UI's thinking on/off toggle; false hides it).
164
+ // transcription: a Whisper checkpoint is configured or resolvable for
165
+ // /v1/audio/sessions — drives the composer's hold-to-talk mic button.
166
+ | { type: "ready"; model: string; vision: boolean; audio: boolean; thinking: boolean; genDefaults: ReadyGenDefaults; transcription?: boolean }
167
+ | { type: "turn_start" }
168
+ | { type: "text_delta"; delta: string }
169
+ | { type: "thinking_delta"; delta: string }
170
+ | { type: "tool_start"; callId: string; tool: string; args: unknown }
171
+ | { type: "tool_approval_request"; callId: string; tool: string; args: unknown }
172
+ | { type: "tool_update"; callId: string; chunk: unknown }
173
+ | { type: "tool_end"; callId: string; ok: boolean; result: unknown }
174
+ | { type: "turn_end" }
175
+ | { type: "queue_update"; steering: readonly string[]; followUp: readonly string[] }
176
+ // Replay a session's transcript (rebuilds the thread); and the sidebar list.
177
+ | { type: "history"; items: HistoryItem[] }
178
+ | { type: "sessions"; items: SessionListItem[]; activePath?: string }
179
+ // Sibling group for the LAST user message (edit-and-resend's `< i/n >`
180
+ // toggle). Sent after every history rebuild and after a
181
+ // completed turn. `count` <= 1 means no toggle to show (the common case:
182
+ // no edits/regenerations yet). `entryId` is the currently-active sibling's
183
+ // id; `siblingIds` is the full ordered group so the client can resolve
184
+ // "previous/next" locally and send switch_sibling with a concrete id
185
+ // (never inferred/guessed — the server is the source of truth for order).
186
+ | { type: "siblings"; entryId?: string; index: number; count: number; siblingIds: string[] }
187
+ // Context-window usage indicator. tokens/percent are null right after a
188
+ // compaction until the next assistant reply (pi can't estimate yet).
189
+ | { type: "context"; tokens: number | null; contextWindow: number; percent: number | null }
190
+ // Effective vs. pending codingTools state (set_coding_tools above).
191
+ // `active` is what the CURRENT session actually built with (gates
192
+ // whether the approval card can ever fire); `pending` is the browser's
193
+ // most recent request, which only takes effect on the next
194
+ // new/opened/forked session — surfaced so the settings copy can say
195
+ // "will apply to your next new chat" instead of lying about immediacy.
196
+ | { type: "coding_tools"; active: boolean; pending: boolean }
197
+ // Full always-allow set for THIS machine (chat/tool-approvals.ts), sent on
198
+ // `ready` and after any change, so the settings panel can list/forget
199
+ // durable per-tool approvals without a separate REST round-trip.
200
+ | { type: "tool_approvals"; alwaysAllow: string[] }
201
+ // App-aware assistant: the navigate_app / spotlight_ui tools'
202
+ // side-channel notifications, mapped 1:1 from the tool call's params —
203
+ // never guessed or re-derived, so chat.ts's router/spotlight get exactly
204
+ // what the model asked for. `route` here is always a validated
205
+ // AppRouteId (isAppRouteId already ran server-side before this frame is
206
+ // ever sent — see navigate_app's execute). ui_spotlight's fields mirror
207
+ // SpotlightRequest 1:1 (assistant.ts resolves ref/label/selector/target
208
+ // client-side, in that precedence order, against its last snapshot).
209
+ | { type: "ui_navigate"; route: AppRouteId }
210
+ | { type: "ui_spotlight"; ref?: string; label?: string; selector?: string; target?: string; message?: string; route?: AppRouteId }
211
+ | { type: "error"; message: string };
212
+
213
+ export interface HistoryToolItem {
214
+ callId: string;
215
+ name: string;
216
+ args: unknown;
217
+ /** Tool result text, filled from the matching toolResult message. */
218
+ result: string;
219
+ }
220
+
221
+ export interface HistoryItem {
222
+ role: "user" | "assistant";
223
+ text: string;
224
+ /** Model reasoning/thinking, kept separate from the final answer. */
225
+ thinking?: string;
226
+ tools: HistoryToolItem[];
227
+ /** Session entry id (user items only). Lets the browser correlate "the
228
+ * last user message" in the replayed thread with the `siblings` frame's
229
+ * entryId, so it knows which DOM node gets the edit/sibling-toggle UI.
230
+ * Undefined for assistant items — they aren't fork targets. */
231
+ entryId?: string;
232
+ }
233
+
234
+ export interface SessionListItem {
235
+ path: string;
236
+ id: string;
237
+ title: string;
238
+ /** Last-modified epoch ms (newest first). */
239
+ modified: number;
240
+ messageCount: number;
241
+ forked: boolean;
242
+ }
243
+
244
+ export interface SamplingOverrides {
245
+ temperature?: number | null;
246
+ top_p?: number | null;
247
+ top_k?: number | null;
248
+ min_p?: number | null;
249
+ xtc_probability?: number | null;
250
+ xtc_threshold?: number | null;
251
+ repetition_penalty?: number | null;
252
+ repetition_context_size?: number | null;
253
+ presence_penalty?: number | null;
254
+ frequency_penalty?: number | null;
255
+ seed?: number | null;
256
+ }
257
+
258
+ /** The shared envelope of the module's JSON endpoints and of the app's other `/api/*` ones the panel reads: success or
259
+ * failure, with an error message and whatever fields the endpoint adds. */
260
+ export interface ApiEnvelope {
261
+ ok?: boolean;
262
+ error?: string;
263
+ message?: string;
264
+ [key: string]: unknown;
265
+ }
@@ -0,0 +1,72 @@
1
+ import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
2
+
3
+ export interface PiModelOptions {
4
+ name?: string;
5
+ contextWindow?: number;
6
+ maxTokens?: number;
7
+ reasoning?: boolean;
8
+ vision?: boolean;
9
+ }
10
+
11
+ /** Capabilities advertised by the local model through Pi's OpenAI adapter. */
12
+ export function piModelDefinition(options: PiModelOptions = {}) {
13
+ return {
14
+ id: "local",
15
+ name: options.name ?? "mlx-bun (local)",
16
+ api: "openai-completions" as const,
17
+ reasoning: options.reasoning ?? false,
18
+ compat: { supportsDeveloperRole: false, sendSessionAffinityHeaders: true,
19
+ ...(options.reasoning ? { thinkingFormat: "qwen-chat-template" as const } : {}) },
20
+ input: (options.vision ? ["text", "image"] : ["text"]) as ("text" | "image")[],
21
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
22
+ contextWindow: options.contextWindow ?? 32768,
23
+ maxTokens: options.maxTokens ?? 100_000,
24
+ };
25
+ }
26
+
27
+ export const PI_PROVIDER_ID = "mlx-bun";
28
+ export const PI_API_KEY = "sk-mlx-bun-local";
29
+ export const PI_LOCAL_MODEL_ID = piModelDefinition().id;
30
+ export const PI_API = piModelDefinition().api;
31
+ export const DEFAULT_CONTEXT_WINDOW = piModelDefinition().contextWindow;
32
+ export const DEFAULT_MAX_TOKENS = piModelDefinition().maxTokens;
33
+
34
+
35
+ /** A resolved pi model handle. Not re-exported from the package index, so we
36
+ * derive it from the registry's `find()` return type. */
37
+ export type PiModel = NonNullable<ReturnType<ModelRegistry["find"]>>;
38
+
39
+ export interface PiProviderOptions extends PiModelOptions {
40
+ /** The key Pi sends as its bearer token; default the shared local key. The module's model bridge checks a per-run secret. */
41
+ apiKey?: string;
42
+ }
43
+
44
+ export interface PiProviderWiring {
45
+ authStorage: AuthStorage;
46
+ modelRegistry: ModelRegistry;
47
+ model: PiModel;
48
+ }
49
+
50
+ /**
51
+ * Build the in-memory auth + registry and resolve the local model for a pi
52
+ * session pointed at `baseUrl` (e.g. "http://127.0.0.1:8080/v1"). The runtime
53
+ * key satisfies pi's auth check for our zero-cost local provider; no disk
54
+ * (no models.json, no ~/.pi cross-talk).
55
+ */
56
+ export function buildPiProvider(baseUrl: string, opts: PiProviderOptions = {}): PiProviderWiring {
57
+ const apiKey = opts.apiKey ?? PI_API_KEY;
58
+ const authStorage = AuthStorage.inMemory();
59
+ authStorage.setRuntimeApiKey(PI_PROVIDER_ID, apiKey);
60
+
61
+ const modelRegistry = ModelRegistry.inMemory(authStorage);
62
+ modelRegistry.registerProvider(PI_PROVIDER_ID, {
63
+ baseUrl,
64
+ apiKey,
65
+ api: PI_API,
66
+ models: [piModelDefinition(opts)],
67
+ });
68
+
69
+ const model = modelRegistry.find(PI_PROVIDER_ID, PI_LOCAL_MODEL_ID);
70
+ if (!model) throw new Error("failed to register mlx-bun/local model");
71
+ return { authStorage, modelRegistry, model };
72
+ }
package/src/routes.ts ADDED
@@ -0,0 +1,49 @@
1
+ // The module's HTTP routes: the read-only session surface (search and export over the session
2
+ // directory) and the durable tool-approval settings. Paths are resolved per request, so a host
3
+ // creates storage entries on first use, not at startup.
4
+ import type { RouteHandler } from "@mlx-bun/app-core";
5
+ import { readSessionFile, sessionEntries } from "./session-files";
6
+ import { searchSessions } from "./session-search";
7
+ import { listAlwaysAllowedTools, revokeToolAlwaysAllowed } from "./tool-approvals";
8
+
9
+ const error = (message: string, status = 400) => Response.json({ ok: false, error: message }, { status });
10
+
11
+ export interface ChatRoutePaths {
12
+ /** The session directory the Pi backend persists to. */
13
+ sessionDir(): string;
14
+ /** The always-allow file the approval gate reads. */
15
+ approvalsFile(): string;
16
+ }
17
+
18
+ /** Handlers by route id (`manifest.routes`). */
19
+ export function createChatHandlers(paths: ChatRoutePaths): Record<"sessions-search" | "sessions-export" | "approvals" | "approvals-revoke", RouteHandler> {
20
+ const guarded = (handler: RouteHandler): RouteHandler => async request => {
21
+ try { return await handler(request); } catch (cause) {
22
+ console.error(`[chat] ${request.method} ${new URL(request.url).pathname}: ${cause instanceof Error ? cause.message : String(cause)}`);
23
+ return error(cause instanceof Error ? cause.message : String(cause), 500);
24
+ }
25
+ };
26
+ return {
27
+ "sessions-search": guarded(async request => {
28
+ const query = (new URL(request.url).searchParams.get("q") ?? "").trim();
29
+ if (!query) return error("q is required");
30
+ return Response.json({ ok: true, results: await searchSessions(paths.sessionDir(), query) });
31
+ }),
32
+ "sessions-export": guarded(async request => {
33
+ const path = (new URL(request.url).searchParams.get("path") ?? "").trim();
34
+ if (!path) return error("path is required");
35
+ const file = await readSessionFile(paths.sessionDir(), path);
36
+ if (!file.ok) return file.reason === "forbidden"
37
+ ? error("path must be under the session directory", 403) : error("session not found", 404);
38
+ return Response.json({ ok: true, path, entries: sessionEntries(file.content) });
39
+ }),
40
+ approvals: guarded(() => Response.json({ ok: true, alwaysAllow: listAlwaysAllowedTools(paths.approvalsFile()) })),
41
+ "approvals-revoke": guarded(async request => {
42
+ const body: unknown = await request.json().catch(() => undefined);
43
+ const tool = body && typeof body === "object" && !Array.isArray(body) && "tool" in body ? body.tool : undefined;
44
+ if (typeof tool !== "string" || !tool) return error("tool required");
45
+ const file = revokeToolAlwaysAllowed(tool, paths.approvalsFile());
46
+ return Response.json({ ok: true, alwaysAllow: Object.keys(file.allows).sort() });
47
+ }),
48
+ };
49
+ }
@@ -0,0 +1,36 @@
1
+ import { readFile, realpath } from "node:fs/promises";
2
+ import { resolve, sep } from "node:path";
3
+
4
+ export function isUnderSessionDir(path: string, sessionDir: string): boolean {
5
+ const root = resolve(sessionDir), candidate = resolve(path);
6
+ return candidate === root || candidate.startsWith(root + sep);
7
+ }
8
+
9
+ export type SessionRead = { ok: true; content: string } | { ok: false; reason: "forbidden" | "not_found" };
10
+
11
+ /** Check both the submitted path and its resolved target. Configured directory
12
+ * aliases are allowed, but neither a leaf nor parent symlink may escape it. */
13
+ export async function readSessionFile(sessionDir: string, path: string): Promise<SessionRead> {
14
+ if (!isUnderSessionDir(path, sessionDir)) return { ok: false, reason: "forbidden" };
15
+ try {
16
+ const [root, target] = await Promise.all([realpath(sessionDir), realpath(path)]);
17
+ if (!isUnderSessionDir(target, root)) return { ok: false, reason: "forbidden" };
18
+ return { ok: true, content: await readFile(target, "utf8") };
19
+ } catch { return { ok: false, reason: "not_found" }; }
20
+ }
21
+
22
+ /** The working directory a Pi session file records in its header, if any. */
23
+ export function recordedSessionCwd(content: string): string | undefined {
24
+ const header = sessionEntries(content).find(entry => (entry as { type?: unknown }).type === "session") as { cwd?: unknown } | undefined;
25
+ return typeof header?.cwd === "string" && header.cwd ? header.cwd : undefined;
26
+ }
27
+
28
+ /** Preserve raw JSON values and tolerate malformed or mid-append lines. */
29
+ export function sessionEntries(content: string): unknown[] {
30
+ const entries: unknown[] = [];
31
+ for (const line of content.split("\n")) {
32
+ if (!line.trim()) continue;
33
+ try { entries.push(JSON.parse(line.trim())); } catch { /* incomplete JSONL line */ }
34
+ }
35
+ return entries;
36
+ }
@@ -0,0 +1,175 @@
1
+ // Read-only substring search over Pi's JSONL transcripts; no SDK runtime or index.
2
+ import { readdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { readSessionFile } from "./session-files";
5
+
6
+ export interface SearchMatch {
7
+ /** Plain text around the match; the consumer owns HTML escaping/highlighting. */
8
+ snippet: string;
9
+ /** Matched [start,end) UTF-16 offsets within the snippet. */
10
+ ranges: Array<[number, number]>;
11
+ role: string;
12
+ }
13
+
14
+ export interface SessionSearchResult {
15
+ sessionPath: string;
16
+ sessionTitle: string;
17
+ matches: SearchMatch[];
18
+ }
19
+
20
+ interface ParsedLine {
21
+ type?: string;
22
+ message?: { role?: string; content?: unknown; timestamp?: number };
23
+ name?: string;
24
+ }
25
+
26
+ /** Best-effort JSON.parse of one JSONL line; never throws. Mirrors
27
+ * pi-coding-agent's own parseSessionEntryLine tolerance (skip malformed
28
+ * lines rather than fail the whole file). */
29
+ function parseLine(line: string): ParsedLine | null {
30
+ const trimmed = line.trim();
31
+ if (!trimmed) return null;
32
+ try {
33
+ return JSON.parse(trimmed) as ParsedLine;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ /** Flatten a message's content (string | content-block array) to plain text,
40
+ * same shape pi-coding-agent's own extractTextContent handles. */
41
+ function contentText(content: unknown): string {
42
+ if (typeof content === "string") return content;
43
+ if (Array.isArray(content)) {
44
+ return content
45
+ .filter((p): p is { type: string; text: string } =>
46
+ !!p && (p as { type?: string }).type === "text" && typeof (p as { text?: unknown }).text === "string")
47
+ .map((p) => p.text)
48
+ .join(" ");
49
+ }
50
+ return "";
51
+ }
52
+
53
+ const MAX_MATCHES_PER_SESSION = 10;
54
+ const MAX_TOTAL_MATCHES = 50;
55
+ const SNIPPET_RADIUS = 60;
56
+
57
+ /** Build one snippet window (±SNIPPET_RADIUS chars) around a single match
58
+ * index in `text`, plus the match's own [start,end) offsets translated into
59
+ * the snippet's local coordinate space. Pure — independently testable. */
60
+ export function buildSnippet(text: string, matchStart: number, matchLen: number): { snippet: string; range: [number, number] } {
61
+ const start = Math.max(0, matchStart - SNIPPET_RADIUS);
62
+ const end = Math.min(text.length, matchStart + matchLen + SNIPPET_RADIUS);
63
+ const prefix = start > 0 ? "…" : "";
64
+ const suffix = end < text.length ? "…" : "";
65
+ const snippet = prefix + text.slice(start, end) + suffix;
66
+ const localStart = matchStart - start + prefix.length;
67
+ return { snippet, range: [localStart, localStart + matchLen] };
68
+ }
69
+
70
+ /** Map folded code units back to original offsets. Lowercasing can expand
71
+ * characters (İ → i + combining dot), so slicing by folded offsets is wrong. */
72
+ function foldWithIndex(text: string): { folded: string; index: number[] } {
73
+ let folded = "";
74
+ const index: number[] = [];
75
+ for (let i = 0; i < text.length; i++) {
76
+ const piece = text[i]!.toLowerCase();
77
+ for (let j = 0; j < piece.length; j++) index.push(i);
78
+ folded += piece;
79
+ }
80
+ return { folded, index };
81
+ }
82
+
83
+ /** Translate both ends of each folded substring match to original offsets. */
84
+ function findOccurrences(textLower: string, qLower: string, index: readonly number[]): Array<[number, number]> {
85
+ if (!qLower) return [];
86
+ const offsets: Array<[number, number]> = [];
87
+ let i = 0;
88
+ for (;;) {
89
+ const idx = textLower.indexOf(qLower, i);
90
+ if (idx === -1) break;
91
+ const start = index[idx] ?? idx;
92
+ const lastFoldedIdx = idx + qLower.length - 1;
93
+ const end = index[lastFoldedIdx] ?? lastFoldedIdx;
94
+ offsets.push([start, end - start + 1]);
95
+ i = idx + qLower.length;
96
+ }
97
+ return offsets;
98
+ }
99
+
100
+ /** Scan one session file's raw JSONL content for `query` (case-insensitive
101
+ * substring), returning up to MAX_MATCHES_PER_SESSION per session. Pure
102
+ * given the file content string — split out from the disk read so it's
103
+ * unit-testable without touching the filesystem. */
104
+ export function searchSessionContent(content: string, query: string): SessionSearchResult | null {
105
+ const qLower = query.toLowerCase();
106
+ if (!qLower) return null;
107
+
108
+ const lines = content.split("\n");
109
+ const sessionPath = "";
110
+ let title = "";
111
+ let firstUserMessage = "";
112
+ const matches: SearchMatch[] = [];
113
+
114
+ for (const line of lines) {
115
+ const entry = parseLine(line);
116
+ if (!entry) continue;
117
+ if (entry.type === "session_info" && typeof entry.name === "string" && entry.name.trim()) {
118
+ title = entry.name.trim();
119
+ continue;
120
+ }
121
+ if (entry.type !== "message" || !entry.message) continue;
122
+ const role = entry.message.role;
123
+ if (role !== "user" && role !== "assistant") continue;
124
+ const text = contentText(entry.message.content);
125
+ if (!text) continue;
126
+ if (!firstUserMessage && role === "user") firstUserMessage = text;
127
+
128
+ if (matches.length >= MAX_MATCHES_PER_SESSION) continue;
129
+ const { folded: textLower, index } = foldWithIndex(text);
130
+ const offsets = findOccurrences(textLower, qLower, index);
131
+ for (const [off, len] of offsets) {
132
+ if (matches.length >= MAX_MATCHES_PER_SESSION) break;
133
+ const { snippet, range } = buildSnippet(text, off, len);
134
+ matches.push({ snippet, ranges: [range], role });
135
+ }
136
+ }
137
+
138
+ if (matches.length === 0) return null;
139
+ return {
140
+ sessionPath,
141
+ sessionTitle: (title || firstUserMessage || "New chat").slice(0, 80),
142
+ matches,
143
+ };
144
+ }
145
+
146
+ /** Flat directory scan in listing order, capped across sessions. Missing,
147
+ * unreadable, or concurrently removed files do not fail the remaining search. */
148
+ export async function searchSessions(sessionDir: string, query: string): Promise<SessionSearchResult[]> {
149
+ const trimmed = query.trim();
150
+ if (!trimmed) return [];
151
+
152
+ let files: string[];
153
+ try {
154
+ files = (await readdir(sessionDir)).filter((f) => f.endsWith(".jsonl"));
155
+ } catch {
156
+ return []; // no session dir yet — never throw, just no results
157
+ }
158
+
159
+ const results: SessionSearchResult[] = [];
160
+ let total = 0;
161
+ for (const file of files) {
162
+ if (total >= MAX_TOTAL_MATCHES) break;
163
+ const path = join(sessionDir, file);
164
+ const fileContent = await readSessionFile(sessionDir, path);
165
+ if (!fileContent.ok) continue; // deleted, unreadable, or outside via a symlink
166
+ const result = searchSessionContent(fileContent.content, trimmed);
167
+ if (!result) continue;
168
+ result.sessionPath = path;
169
+ const remaining = MAX_TOTAL_MATCHES - total;
170
+ if (result.matches.length > remaining) result.matches = result.matches.slice(0, remaining);
171
+ total += result.matches.length;
172
+ results.push(result);
173
+ }
174
+ return results;
175
+ }
package/src/surface.ts ADDED
@@ -0,0 +1,26 @@
1
+ import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ import { createWebTools } from "./web-tools";
3
+
4
+ /** Optional read-only capabilities other modules contribute to a chat (`extensions.ts` builds it from the registry's
5
+ * `chat.tool` and `chat.guidance`); named for memory, the first contributor. */
6
+ export interface MemorySurface {
7
+ /** The contributors attest that every advertised tool only reads state. */
8
+ readOnly: true;
9
+ toolNames: readonly string[];
10
+ customTools: readonly ToolDefinition[];
11
+ skillPaths: readonly string[];
12
+ hint: string;
13
+ }
14
+
15
+ /** Assemble definitions without discovering stores, materializing skills, or widening access. */
16
+ export function buildPiAgentSurface(memory?: MemorySurface) {
17
+ if (memory && memory.readOnly !== true) throw new Error("Memory tools must be explicitly read-only");
18
+ return {
19
+ memoryEnabled: memory !== undefined,
20
+ memoryToolNames: [...(memory?.toolNames ?? [])],
21
+ readOnlyToolNames: [...(memory?.toolNames ?? [])],
22
+ customTools: [...createWebTools(), ...(memory?.customTools ?? [])],
23
+ skillPaths: [...(memory?.skillPaths ?? [])],
24
+ memoryHint: memory?.hint ?? "",
25
+ };
26
+ }