@freesyntax/notch-cli 0.5.20 → 0.5.21

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,509 @@
1
+ import {
2
+ OLLAMA_CLOUD_BASE_URL,
3
+ OLLAMA_LOCAL_BASE_URL,
4
+ detectDaemon,
5
+ formatBytes,
6
+ isCloudBaseUrl,
7
+ isCloudModel,
8
+ listModels,
9
+ pullModel,
10
+ showModel
11
+ } from "./chunk-GFVLHUSS.js";
12
+ import {
13
+ clearOllamaCreds,
14
+ findByokProvider,
15
+ ollamaCredsPath,
16
+ readOllamaCreds,
17
+ writeOllamaCreds
18
+ } from "./chunk-443G6HCC.js";
19
+
20
+ // src/commands/ollama-launch.ts
21
+ import fs from "fs/promises";
22
+ import path from "path";
23
+ import readline from "readline";
24
+ import chalk from "chalk";
25
+ var readCreds = readOllamaCreds;
26
+ var writeCreds = writeOllamaCreds;
27
+ var clearCreds = clearOllamaCreds;
28
+ var credsPath = ollamaCredsPath;
29
+ function parseFlags(argv) {
30
+ const out = { positional: [], cloud: false, yes: false, help: false };
31
+ for (let i = 0; i < argv.length; i++) {
32
+ const a = argv[i];
33
+ if (a === "--cloud") out.cloud = true;
34
+ else if (a === "-y" || a === "--yes") out.yes = true;
35
+ else if (a === "--help" || a === "-h") out.help = true;
36
+ else if (a === "--model" || a === "-m") out.model = argv[++i];
37
+ else if (a.startsWith("--model=")) out.model = a.slice("--model=".length);
38
+ else if (a === "--host") out.host = argv[++i];
39
+ else if (a.startsWith("--host=")) out.host = a.slice("--host=".length);
40
+ else if (a === "--api-key") out.apiKey = argv[++i];
41
+ else if (a.startsWith("--api-key=")) out.apiKey = a.slice("--api-key=".length);
42
+ else if (a === "--compat") {
43
+ const v = argv[++i];
44
+ if (v === "openai" || v === "anthropic") out.compat = v;
45
+ } else if (a.startsWith("--compat=")) {
46
+ const v = a.slice("--compat=".length);
47
+ if (v === "openai" || v === "anthropic") out.compat = v;
48
+ } else if (!a.startsWith("-")) {
49
+ out.positional.push(a);
50
+ }
51
+ }
52
+ return out;
53
+ }
54
+ async function resolveEndpoint(flags) {
55
+ let baseUrl;
56
+ if (flags.host) {
57
+ baseUrl = flags.host;
58
+ } else if (flags.cloud) {
59
+ baseUrl = OLLAMA_CLOUD_BASE_URL;
60
+ } else if (process.env.OLLAMA_HOST) {
61
+ baseUrl = process.env.OLLAMA_HOST.startsWith("http") ? process.env.OLLAMA_HOST : `http://${process.env.OLLAMA_HOST}`;
62
+ } else {
63
+ baseUrl = OLLAMA_LOCAL_BASE_URL;
64
+ }
65
+ const mode = isCloudBaseUrl(baseUrl) ? "cloud" : "local";
66
+ let apiKey = flags.apiKey ?? process.env.OLLAMA_API_KEY ?? void 0;
67
+ if (!apiKey && mode === "cloud") {
68
+ const stored = await readCreds();
69
+ if (stored && stored.endpoint === baseUrl) {
70
+ apiKey = stored.apiKey;
71
+ }
72
+ }
73
+ return { baseUrl, apiKey, mode };
74
+ }
75
+ async function promptLine(prompt, opts = {}) {
76
+ if (!process.stdin.isTTY) {
77
+ throw new Error("Interactive prompt requested but stdin is not a TTY.");
78
+ }
79
+ if (!opts.mask) {
80
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
81
+ const answer = await new Promise((resolve) => {
82
+ rl.question(prompt, (ans) => resolve(ans));
83
+ });
84
+ rl.close();
85
+ return answer.trim();
86
+ }
87
+ return new Promise((resolve, reject) => {
88
+ process.stdout.write(prompt);
89
+ const stdin = process.stdin;
90
+ const wasRaw = stdin.isRaw;
91
+ stdin.setRawMode(true);
92
+ stdin.resume();
93
+ let buf = "";
94
+ const onData = (chunk) => {
95
+ for (const byte of chunk) {
96
+ if (byte === 3) {
97
+ cleanup();
98
+ process.stdout.write("\n");
99
+ reject(new Error("Cancelled"));
100
+ return;
101
+ }
102
+ if (byte === 13 || byte === 10) {
103
+ cleanup();
104
+ process.stdout.write("\n");
105
+ resolve(buf.trim());
106
+ return;
107
+ }
108
+ if (byte === 127 || byte === 8) {
109
+ if (buf.length > 0) {
110
+ buf = buf.slice(0, -1);
111
+ process.stdout.write("\b \b");
112
+ }
113
+ continue;
114
+ }
115
+ if (byte < 32) continue;
116
+ buf += String.fromCharCode(byte);
117
+ process.stdout.write("*");
118
+ }
119
+ };
120
+ const cleanup = () => {
121
+ stdin.removeListener("data", onData);
122
+ stdin.setRawMode(wasRaw ?? false);
123
+ stdin.pause();
124
+ };
125
+ stdin.on("data", onData);
126
+ });
127
+ }
128
+ function pickFromList(items, title) {
129
+ if (items.length === 0) return Promise.resolve(null);
130
+ if (!process.stdin.isTTY) return Promise.resolve(items[0] ?? null);
131
+ return new Promise((resolve) => {
132
+ let cursor = 0;
133
+ const render = (first) => {
134
+ if (!first) process.stdout.write(`\x1B[${items.length + 2}A\x1B[J`);
135
+ process.stdout.write(chalk.gray(` ${title} (\u2191\u2193 to move, Enter to select, Esc to cancel)
136
+
137
+ `));
138
+ for (let i = 0; i < items.length; i++) {
139
+ const pointer = i === cursor ? chalk.cyan("\u276F") : " ";
140
+ const label = i === cursor ? chalk.bold(items[i]) : chalk.gray(items[i]);
141
+ process.stdout.write(` ${pointer} ${label}
142
+ `);
143
+ }
144
+ };
145
+ render(true);
146
+ const stdin = process.stdin;
147
+ const wasRaw = stdin.isRaw;
148
+ stdin.setRawMode(true);
149
+ stdin.resume();
150
+ const onKey = (key) => {
151
+ const s = key.toString();
152
+ if (s === "\x1B[A") {
153
+ cursor = (cursor - 1 + items.length) % items.length;
154
+ render(false);
155
+ } else if (s === "\x1B[B") {
156
+ cursor = (cursor + 1) % items.length;
157
+ render(false);
158
+ } else if (s === "\r" || s === "\n") {
159
+ cleanup();
160
+ resolve(items[cursor] ?? null);
161
+ } else if (s === "\x1B" || s === "") {
162
+ cleanup();
163
+ resolve(null);
164
+ }
165
+ };
166
+ const cleanup = () => {
167
+ stdin.removeListener("data", onKey);
168
+ stdin.setRawMode(wasRaw ?? false);
169
+ stdin.pause();
170
+ };
171
+ stdin.on("data", onKey);
172
+ });
173
+ }
174
+ function renderProgressLine(ev, lastStatus) {
175
+ if (ev.kind === "progress") {
176
+ const width = 30;
177
+ const filled = Math.max(0, Math.min(width, Math.round(ev.percent / 100 * width)));
178
+ const bar = `${"\u2588".repeat(filled)}${"\u2591".repeat(width - filled)}`;
179
+ const suffix = `${ev.percent.toFixed(1).padStart(5)}% ${formatBytes(ev.completed)}/${formatBytes(ev.total)}`;
180
+ const label = ev.message || lastStatus.label;
181
+ lastStatus.label = label;
182
+ process.stdout.write(`\r ${chalk.cyan(bar)} ${chalk.gray(suffix)} ${chalk.dim(label)}\x1B[K`);
183
+ } else if (ev.kind === "status") {
184
+ lastStatus.label = ev.message;
185
+ process.stdout.write(`\r ${chalk.gray(ev.message)}\x1B[K`);
186
+ } else {
187
+ process.stdout.write("\n");
188
+ }
189
+ }
190
+ async function persistOllamaChoice(projectRoot, opts) {
191
+ const p = path.resolve(projectRoot, ".notch.json");
192
+ let current = {};
193
+ try {
194
+ const raw = await fs.readFile(p, "utf-8");
195
+ current = JSON.parse(raw);
196
+ } catch (err) {
197
+ const code = err.code;
198
+ if (code && code !== "ENOENT") throw err;
199
+ }
200
+ const byok = {
201
+ provider: opts.providerId,
202
+ model: opts.modelId
203
+ };
204
+ if (opts.baseUrl) byok.baseUrl = opts.baseUrl;
205
+ if (opts.apiShape) byok.apiShape = opts.apiShape;
206
+ current.byok = byok;
207
+ await fs.writeFile(p, JSON.stringify(current, null, 2) + "\n", "utf-8");
208
+ }
209
+ async function cmdList(flags) {
210
+ const { baseUrl, apiKey, mode } = await resolveEndpoint(flags);
211
+ const ok = await detectDaemon(baseUrl, { apiKey });
212
+ if (!ok) {
213
+ printNotReachable(baseUrl, mode);
214
+ return 1;
215
+ }
216
+ let rows;
217
+ try {
218
+ rows = await listModels(baseUrl, { apiKey });
219
+ } catch (err) {
220
+ console.error(chalk.red(` ${err.message}`));
221
+ return 1;
222
+ }
223
+ if (rows.length === 0) {
224
+ console.log(chalk.gray(`
225
+ No models installed at ${baseUrl}.`));
226
+ console.log(chalk.gray(` Pull one: notch ollama pull llama3.2:latest
227
+ `));
228
+ return 0;
229
+ }
230
+ const header = ` ${"name".padEnd(36)} ${"params".padEnd(8)} ${"quant".padEnd(10)} ${"size".padStart(8)}`;
231
+ console.log(chalk.gray(`
232
+ ${mode === "cloud" ? "Ollama Cloud" : "Ollama local"} @ ${baseUrl}
233
+ `));
234
+ console.log(chalk.gray(header));
235
+ console.log(chalk.gray(` ${"-".repeat(header.length - 2)}`));
236
+ for (const r of rows) {
237
+ console.log(
238
+ ` ${chalk.white(r.name.padEnd(36))} ${chalk.gray(r.parameterSize.padEnd(8))} ${chalk.gray(r.quantization.padEnd(10))} ${chalk.gray(formatBytes(r.sizeBytes))}`
239
+ );
240
+ }
241
+ console.log("");
242
+ return 0;
243
+ }
244
+ async function cmdPull(flags) {
245
+ const modelName = flags.positional[0] ?? flags.model;
246
+ if (!modelName) {
247
+ console.error(chalk.red(" Usage: notch ollama pull <model>"));
248
+ return 2;
249
+ }
250
+ const { baseUrl, apiKey, mode } = await resolveEndpoint(flags);
251
+ const ok = await detectDaemon(baseUrl, { apiKey });
252
+ if (!ok) {
253
+ printNotReachable(baseUrl, mode);
254
+ return 1;
255
+ }
256
+ console.log(chalk.cyan(`
257
+ Pulling ${chalk.bold(modelName)} from ${baseUrl}...
258
+ `));
259
+ const lastStatus = { label: "" };
260
+ try {
261
+ for await (const ev of pullModel(modelName, baseUrl, { apiKey })) {
262
+ renderProgressLine(ev, lastStatus);
263
+ }
264
+ } catch (err) {
265
+ process.stdout.write("\n");
266
+ console.error(chalk.red(` Pull failed: ${err.message}`));
267
+ return 1;
268
+ }
269
+ console.log(chalk.green(` \u2713 ${modelName} ready.
270
+ `));
271
+ return 0;
272
+ }
273
+ async function cmdStatus(flags) {
274
+ const { baseUrl, apiKey, mode } = await resolveEndpoint(flags);
275
+ const ok = await detectDaemon(baseUrl, { apiKey });
276
+ console.log("");
277
+ console.log(chalk.gray(" Endpoint"), chalk.white(baseUrl));
278
+ console.log(chalk.gray(" Mode "), chalk.white(mode));
279
+ console.log(chalk.gray(" Auth "), apiKey ? chalk.green("yes (api key)") : chalk.yellow("none"));
280
+ console.log(chalk.gray(" Reach "), ok ? chalk.green("ok") : chalk.red("unreachable"));
281
+ console.log("");
282
+ return ok ? 0 : 1;
283
+ }
284
+ async function cmdLogin(flags) {
285
+ const endpoint = flags.host ?? OLLAMA_CLOUD_BASE_URL;
286
+ console.log(chalk.cyan(`
287
+ Ollama login \u2014 paste your API key for ${endpoint}`));
288
+ console.log(chalk.gray(` Get one at https://ollama.com/settings/keys
289
+ `));
290
+ let apiKey = flags.apiKey;
291
+ if (!apiKey) {
292
+ apiKey = await promptLine(" API key: ", { mask: true });
293
+ }
294
+ if (!apiKey) {
295
+ console.error(chalk.red(" No key provided."));
296
+ return 2;
297
+ }
298
+ const reachable = await detectDaemon(endpoint, { apiKey, timeoutMs: 8e3 });
299
+ if (!reachable) {
300
+ console.error(chalk.red(` Could not authenticate against ${endpoint} with the provided key.`));
301
+ return 1;
302
+ }
303
+ await writeCreds({ apiKey, endpoint, createdAt: Date.now() });
304
+ console.log(chalk.green(` \u2713 Key stored at ${credsPath()}`));
305
+ console.log(chalk.gray(` The CLI will auto-load it when you run \`notch ollama --cloud\`.
306
+ `));
307
+ return 0;
308
+ }
309
+ async function cmdLogout(_flags) {
310
+ const existed = await readCreds();
311
+ await clearCreds();
312
+ if (existed) {
313
+ console.log(chalk.green(`
314
+ \u2713 Cleared Ollama credentials for ${existed.endpoint}
315
+ `));
316
+ } else {
317
+ console.log(chalk.gray("\n No stored Ollama credentials.\n"));
318
+ }
319
+ return 0;
320
+ }
321
+ async function cmdLaunch(flags, opts) {
322
+ const { baseUrl, apiKey, mode } = await resolveEndpoint(flags);
323
+ const ok = await detectDaemon(baseUrl, { apiKey });
324
+ if (!ok) {
325
+ printNotReachable(baseUrl, mode);
326
+ return 1;
327
+ }
328
+ let rows = [];
329
+ try {
330
+ rows = await listModels(baseUrl, { apiKey });
331
+ } catch (err) {
332
+ console.error(chalk.red(` Could not list models: ${err.message}`));
333
+ return 1;
334
+ }
335
+ let chosen = flags.model;
336
+ if (!chosen) {
337
+ if (rows.length === 0) {
338
+ console.log(chalk.yellow(`
339
+ No models installed at ${baseUrl}.`));
340
+ const suggest = ["qwen3-coder:30b", "gpt-oss:20b", "llama3.2:latest"];
341
+ const pick = await pickFromList(
342
+ suggest,
343
+ "Pick one to pull"
344
+ );
345
+ if (!pick) return 0;
346
+ chosen = pick;
347
+ const pullCode = await cmdPull({ ...flags, positional: [chosen] });
348
+ if (pullCode !== 0) return pullCode;
349
+ } else {
350
+ const pick = await pickFromList(rows.map((r) => r.name), "Select a model");
351
+ if (!pick) return 0;
352
+ chosen = pick;
353
+ }
354
+ } else if (mode === "local" && !rows.some((r) => r.name === chosen) && !isCloudModel(chosen)) {
355
+ const consent = flags.yes ? true : (await promptLine(` Model ${chosen} isn't installed. Pull now? [Y/n] `)).toLowerCase() !== "n";
356
+ if (consent) {
357
+ const pullCode = await cmdPull({ ...flags, positional: [chosen] });
358
+ if (pullCode !== 0) return pullCode;
359
+ } else {
360
+ console.error(chalk.red(" Aborted: model not installed."));
361
+ return 1;
362
+ }
363
+ }
364
+ if (mode === "local") {
365
+ try {
366
+ const info = await showModel(chosen, baseUrl, { apiKey });
367
+ if (typeof info.contextLength === "number" && info.contextLength < 64e3) {
368
+ console.warn(
369
+ chalk.yellow(
370
+ ` \u26A0 ${chosen} exposes only ${info.contextLength.toLocaleString()} tokens of context. Notch recommends \u226564,000 \u2014 agent loops will truncate aggressively.`
371
+ )
372
+ );
373
+ }
374
+ } catch {
375
+ }
376
+ }
377
+ const compat = flags.compat ?? "openai";
378
+ let providerId;
379
+ if (mode === "cloud") {
380
+ providerId = "ollama-cloud";
381
+ if (flags.compat === "anthropic") {
382
+ console.warn(chalk.yellow(
383
+ " ! --compat anthropic is ignored for Ollama Cloud (only the local daemon exposes /v1/messages)."
384
+ ));
385
+ }
386
+ } else if (compat === "anthropic") {
387
+ providerId = "ollama-anthropic";
388
+ } else {
389
+ providerId = "ollama";
390
+ }
391
+ const providerInfo = findByokProvider(providerId);
392
+ if (!providerInfo) {
393
+ console.error(chalk.red(` Internal: provider ${providerId} missing from catalog.`));
394
+ return 1;
395
+ }
396
+ try {
397
+ await persistOllamaChoice(opts.projectRoot, {
398
+ providerId,
399
+ modelId: chosen,
400
+ // Only persist baseUrl when it differs from the provider default
401
+ // — otherwise the catalog default carries forward and survives
402
+ // future CLI upgrades.
403
+ baseUrl: baseUrl === providerInfo.baseUrl || mode === "cloud" && baseUrl === OLLAMA_CLOUD_BASE_URL ? void 0 : baseUrl,
404
+ apiShape: providerInfo.apiShape
405
+ });
406
+ } catch (err) {
407
+ console.warn(chalk.yellow(` ! Could not write .notch.json: ${err.message}`));
408
+ }
409
+ console.log("");
410
+ console.log(chalk.green(` \u2713 Ollama configured.`));
411
+ console.log(chalk.gray(" provider "), chalk.white(providerId));
412
+ console.log(chalk.gray(" model "), chalk.white(chosen));
413
+ console.log(chalk.gray(" endpoint "), chalk.white(baseUrl));
414
+ console.log(chalk.gray(" compat "), chalk.white(providerInfo.apiShape ?? "openai"));
415
+ console.log("");
416
+ if (mode === "cloud" && apiKey && !process.env.OLLAMA_API_KEY) {
417
+ console.log(chalk.gray(` Ollama Cloud key loaded from ${credsPath()}`));
418
+ }
419
+ console.log(chalk.gray(` Run: notch (the selection is saved in .notch.json)`));
420
+ console.log("");
421
+ return 0;
422
+ }
423
+ function printNotReachable(baseUrl, mode) {
424
+ console.error(chalk.red(`
425
+ Cannot reach Ollama at ${baseUrl}.`));
426
+ if (mode === "local") {
427
+ console.error(chalk.gray(` Start the daemon: ollama serve`));
428
+ console.error(chalk.gray(` Or install: https://ollama.com/download
429
+ `));
430
+ } else {
431
+ console.error(chalk.gray(` Check that OLLAMA_API_KEY is set (notch ollama login).
432
+ `));
433
+ }
434
+ }
435
+ function printHelp() {
436
+ console.log(`
437
+ Notch CLI \u2014 Ollama integration
438
+
439
+ Usage:
440
+ notch ollama Interactive setup + launch
441
+ notch ollama launch Same as above (explicit)
442
+ notch ollama list List installed / cloud models
443
+ notch ollama pull <model> Pull a model with a progress bar
444
+ notch ollama status Show daemon reachability
445
+ notch ollama login Store OLLAMA_API_KEY for Cloud
446
+ notch ollama logout Clear stored Ollama credentials
447
+ notch ollama bench Benchmark multiple models on a prompt
448
+
449
+ Flags (for every subcommand):
450
+ --cloud Use https://ollama.com (Ollama Cloud)
451
+ --host <url> Override the daemon base URL
452
+ --model, -m <name> Model to use / pull / bench
453
+ --api-key <key> API key override (env: OLLAMA_API_KEY)
454
+ --compat <openai|anthropic> Wire protocol for local daemon
455
+ -y, --yes Auto-confirm destructive prompts
456
+
457
+ Examples:
458
+ notch ollama Detect daemon, pick a model, save to .notch.json
459
+ notch ollama --cloud Target Ollama Cloud
460
+ notch ollama --compat anthropic --model qwen3-coder:30b
461
+ notch ollama pull llama3.2:latest
462
+ notch ollama bench "summarize this repo" --model llama3.2,qwen3-coder:30b
463
+ `);
464
+ }
465
+ async function runOllamaCli(argv, cwd) {
466
+ const flags = parseFlags(argv);
467
+ if (flags.help && flags.positional.length === 0) {
468
+ printHelp();
469
+ return 0;
470
+ }
471
+ const sub = flags.positional[0] ?? "launch";
472
+ const subFlags = { ...flags, positional: flags.positional.slice(1) };
473
+ switch (sub) {
474
+ case "launch":
475
+ case void 0:
476
+ return cmdLaunch(subFlags, { projectRoot: cwd });
477
+ case "list":
478
+ case "ls":
479
+ return cmdList(subFlags);
480
+ case "pull":
481
+ return cmdPull(subFlags);
482
+ case "status":
483
+ return cmdStatus(subFlags);
484
+ case "login":
485
+ return cmdLogin(subFlags);
486
+ case "logout":
487
+ return cmdLogout(subFlags);
488
+ case "bench": {
489
+ const { runOllamaBench } = await import("./ollama-bench-QQHBIG2D.js");
490
+ return runOllamaBench(subFlags, { projectRoot: cwd });
491
+ }
492
+ case "help":
493
+ printHelp();
494
+ return 0;
495
+ default:
496
+ console.error(chalk.red(` Unknown subcommand: ollama ${sub}`));
497
+ printHelp();
498
+ return 2;
499
+ }
500
+ }
501
+
502
+ export {
503
+ parseFlags,
504
+ resolveEndpoint,
505
+ renderProgressLine,
506
+ persistOllamaChoice,
507
+ printNotReachable,
508
+ runOllamaCli
509
+ };
@@ -0,0 +1,167 @@
1
+ import {
2
+ readIndex,
3
+ readRollout,
4
+ rolloutPath
5
+ } from "./chunk-QKM27RHS.js";
6
+
7
+ // src/session/session-index.ts
8
+ import fsp from "fs/promises";
9
+ import path from "path";
10
+ import os from "os";
11
+ var INDEX_PATH = path.join(os.homedir(), ".notch", "sessions.index.json");
12
+ var INDEX_SCHEMA = 1;
13
+ async function ensureDir() {
14
+ await fsp.mkdir(path.dirname(INDEX_PATH), { recursive: true });
15
+ }
16
+ async function readIndexFile() {
17
+ try {
18
+ const raw = await fsp.readFile(INDEX_PATH, "utf-8");
19
+ const parsed = JSON.parse(raw);
20
+ if (parsed.schema !== INDEX_SCHEMA) {
21
+ return { schema: INDEX_SCHEMA, updatedAt: Date.now(), entries: {} };
22
+ }
23
+ return parsed;
24
+ } catch {
25
+ return { schema: INDEX_SCHEMA, updatedAt: Date.now(), entries: {} };
26
+ }
27
+ }
28
+ async function writeIndexFile(file) {
29
+ await ensureDir();
30
+ file.updatedAt = Date.now();
31
+ const tmp = INDEX_PATH + ".tmp";
32
+ await fsp.writeFile(tmp, JSON.stringify(file, null, 2));
33
+ await fsp.rename(tmp, INDEX_PATH);
34
+ }
35
+ async function deriveEntryFromRollout(id) {
36
+ let records;
37
+ try {
38
+ records = await readRollout(id);
39
+ } catch {
40
+ return null;
41
+ }
42
+ if (records.length === 0) return null;
43
+ const header = records[0];
44
+ const rolloutIdx = await readIndex(id).catch(() => null);
45
+ let firstUser = "";
46
+ let forkSourceId;
47
+ let forkFromSeq;
48
+ let branches = 0;
49
+ let cwd;
50
+ {
51
+ const headerPayload = header.payload;
52
+ if (headerPayload?.cwd) cwd = headerPayload.cwd;
53
+ if (headerPayload?.forkSourceId) {
54
+ forkSourceId = headerPayload.forkSourceId;
55
+ forkFromSeq = headerPayload.forkFromSeq;
56
+ }
57
+ }
58
+ for (const r of records) {
59
+ if (r.type === "user-message" && !firstUser) {
60
+ const payload = r.payload;
61
+ firstUser = (payload?.content ?? "").slice(0, 160);
62
+ }
63
+ if (r.type === "branch-fork") {
64
+ branches++;
65
+ }
66
+ }
67
+ const lastRecord = records[records.length - 1];
68
+ const lastTurnAt = lastRecord && typeof lastRecord.ts === "number" ? lastRecord.ts : Date.now();
69
+ const createdAtIso = header.payload?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString();
70
+ const createdAt = new Date(createdAtIso).getTime();
71
+ const turnCount = rolloutIdx?.messageCount ?? records.filter((r) => r.type === "user-message" || r.type === "assistant-message").length;
72
+ return {
73
+ id,
74
+ project: header.payload?.project,
75
+ model: header.payload?.model,
76
+ createdAt: Number.isFinite(createdAt) ? createdAt : Date.now(),
77
+ lastTurnAt,
78
+ turnCount,
79
+ preview: firstUser,
80
+ cwd,
81
+ branches,
82
+ forkSourceId,
83
+ forkFromSeq,
84
+ hasActiveStream: !!rolloutIdx?.activeStream
85
+ };
86
+ }
87
+ async function upsertSessionIndexEntry(id, patch) {
88
+ const file = await readIndexFile();
89
+ const derived = await deriveEntryFromRollout(id);
90
+ if (!derived) return;
91
+ file.entries[id] = { ...derived, ...patch ?? {} };
92
+ await writeIndexFile(file);
93
+ }
94
+ async function rebuildIndex() {
95
+ await ensureDir();
96
+ const rolloutDir = path.dirname(rolloutPath("placeholder"));
97
+ let files;
98
+ try {
99
+ files = (await fsp.readdir(rolloutDir)).filter((f) => f.endsWith(".jsonl"));
100
+ } catch {
101
+ files = [];
102
+ }
103
+ const entries = {};
104
+ let skipped = 0;
105
+ for (const f of files) {
106
+ const id = f.replace(/\.jsonl$/, "");
107
+ const entry = await deriveEntryFromRollout(id);
108
+ if (entry) entries[id] = entry;
109
+ else skipped++;
110
+ }
111
+ await writeIndexFile({ schema: INDEX_SCHEMA, updatedAt: Date.now(), entries });
112
+ return { indexed: Object.keys(entries).length, skipped };
113
+ }
114
+ async function querySessions(opts = {}) {
115
+ const file = await readIndexFile();
116
+ const all = Object.values(file.entries);
117
+ const since = opts.since ? typeof opts.since === "number" ? opts.since : new Date(opts.since).getTime() : 0;
118
+ const search = opts.search?.toLowerCase();
119
+ const filtered = all.filter((e) => {
120
+ if (opts.project && e.project !== opts.project) return false;
121
+ if (opts.model && e.model !== opts.model) return false;
122
+ if (opts.cwd && e.cwd !== opts.cwd) return false;
123
+ if (since && e.lastTurnAt < since) return false;
124
+ if (opts.nonEmptyOnly && e.turnCount === 0) return false;
125
+ if (opts.interruptedOnly && !e.hasActiveStream) return false;
126
+ if (search) {
127
+ const hay = `${e.preview ?? ""} ${e.project ?? ""} ${e.model ?? ""} ${e.id}`.toLowerCase();
128
+ if (!hay.includes(search)) return false;
129
+ }
130
+ return true;
131
+ });
132
+ filtered.sort((a, b) => b.lastTurnAt - a.lastTurnAt);
133
+ return opts.limit ? filtered.slice(0, opts.limit) : filtered;
134
+ }
135
+ async function getSession(id) {
136
+ const file = await readIndexFile();
137
+ return file.entries[id] ?? null;
138
+ }
139
+ async function forgetSession(id) {
140
+ const file = await readIndexFile();
141
+ if (id in file.entries) {
142
+ delete file.entries[id];
143
+ await writeIndexFile(file);
144
+ }
145
+ }
146
+ async function tagSession(id, tags) {
147
+ const file = await readIndexFile();
148
+ const existing = file.entries[id];
149
+ if (!existing) return;
150
+ const merged = Array.from(/* @__PURE__ */ new Set([...existing.tags ?? [], ...tags]));
151
+ file.entries[id] = { ...existing, tags: merged };
152
+ await writeIndexFile(file);
153
+ }
154
+ function indexFilePath() {
155
+ return INDEX_PATH;
156
+ }
157
+
158
+ export {
159
+ deriveEntryFromRollout,
160
+ upsertSessionIndexEntry,
161
+ rebuildIndex,
162
+ querySessions,
163
+ getSession,
164
+ forgetSession,
165
+ tagSession,
166
+ indexFilePath
167
+ };