@xingjisoft/neutrino 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/agents.js CHANGED
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { createRequire } from "node:module";
5
+ import { claudeProjectKey, sameCwd } from "./cwd.js";
5
6
  const HOME = os.homedir();
6
7
  function parseJsonlLines(filePath) {
7
8
  let raw;
@@ -27,6 +28,37 @@ function parseJsonlLines(filePath) {
27
28
  }
28
29
  return out;
29
30
  }
31
+ const HEAD_BYTES = 64 * 1024;
32
+ /**
33
+ * Read at most `maxBytes` from the start of a file.
34
+ *
35
+ * Session transcripts can be several megabytes, but the working directory is
36
+ * always recorded up front, so scanning just the head keeps the history list
37
+ * fast when a project holds many sessions.
38
+ */
39
+ function readHead(filePath, maxBytes = HEAD_BYTES) {
40
+ let fd = null;
41
+ try {
42
+ fd = fs.openSync(filePath, "r");
43
+ const len = Math.min(fs.fstatSync(fd).size, maxBytes);
44
+ if (len <= 0)
45
+ return "";
46
+ const buf = Buffer.alloc(len);
47
+ fs.readSync(fd, buf, 0, len, 0);
48
+ return buf.toString("utf-8");
49
+ }
50
+ catch {
51
+ return "";
52
+ }
53
+ finally {
54
+ if (fd !== null) {
55
+ try {
56
+ fs.closeSync(fd);
57
+ }
58
+ catch { }
59
+ }
60
+ }
61
+ }
30
62
  function contentText(content) {
31
63
  if (typeof content === "string")
32
64
  return content;
@@ -78,9 +110,27 @@ function claudeTitle(lines) {
78
110
  }
79
111
  return "";
80
112
  }
81
- function listClaude() {
113
+ const CWD_FIELD_RE = /"cwd"\s*:\s*("(?:[^"\\]|\\.)*")/;
114
+ /** Working directory recorded in a Claude Code transcript ("" when unknown). */
115
+ function claudeSessionCwd(filePath) {
116
+ const m = CWD_FIELD_RE.exec(readHead(filePath));
117
+ if (!m)
118
+ return "";
119
+ try {
120
+ const v = JSON.parse(m[1]);
121
+ return typeof v === "string" ? v : "";
122
+ }
123
+ catch {
124
+ return "";
125
+ }
126
+ }
127
+ function listClaude(cwd) {
82
128
  const metas = [];
129
+ const wantKey = claudeProjectKey(cwd);
83
130
  for (const dir of claudeProjectDirs()) {
131
+ // Claude names each project dir after its cwd, so the dir name still tells
132
+ // us the directory when a transcript predates the `cwd` field.
133
+ const dirKey = path.basename(dir).toLowerCase();
84
134
  let files;
85
135
  try {
86
136
  files = fs.readdirSync(dir);
@@ -99,6 +149,10 @@ function listClaude() {
99
149
  catch {
100
150
  continue;
101
151
  }
152
+ // History is scoped to the current project: skip other directories.
153
+ const sessionCwd = claudeSessionCwd(fp);
154
+ if (sessionCwd ? !sameCwd(sessionCwd, cwd) : dirKey !== wantKey)
155
+ continue;
102
156
  const id = f.slice(0, -".jsonl".length);
103
157
  const title = cleanTitle(claudeTitle(parseJsonlLines(fp)) || id.slice(0, 8));
104
158
  metas.push({
@@ -216,11 +270,41 @@ function codexMessageText(payload) {
216
270
  .join("")
217
271
  .trim();
218
272
  }
219
- function listCodex() {
273
+ /** Working directory recorded in a Codex rollout file ("" when unknown). */
274
+ function codexSessionCwd(filePath) {
275
+ let fallback = "";
276
+ for (const line of readHead(filePath).split("\n")) {
277
+ const t = line.trim();
278
+ if (!t)
279
+ continue;
280
+ let o;
281
+ try {
282
+ o = JSON.parse(t);
283
+ }
284
+ catch {
285
+ continue;
286
+ }
287
+ const payload = o.payload;
288
+ const c = typeof payload?.cwd === "string" ? payload.cwd : typeof o.cwd === "string" ? o.cwd : "";
289
+ if (!c)
290
+ continue;
291
+ // session_meta is authoritative; other records (turn_context) only serve
292
+ // as a fallback for older rollouts.
293
+ if (o.type === "session_meta")
294
+ return c;
295
+ if (!fallback)
296
+ fallback = c;
297
+ }
298
+ return fallback;
299
+ }
300
+ function listCodex(cwd) {
220
301
  const index = codexIndex();
221
302
  const files = codexRolloutFiles();
222
303
  const metas = [];
223
304
  for (const [id, fp] of files) {
305
+ // History is scoped to the current project: skip other directories.
306
+ if (!sameCwd(codexSessionCwd(fp), cwd))
307
+ continue;
224
308
  const info = index.get(id);
225
309
  let title = info?.title ?? "";
226
310
  let updated = info?.updated_at ?? "";
@@ -320,15 +404,28 @@ function opencodeModelName(modelJson) {
320
404
  return "";
321
405
  }
322
406
  }
323
- function listOpencode() {
407
+ function listOpencode(cwd) {
324
408
  const db = opencodeDbHandle();
325
409
  if (!db)
326
410
  return [];
327
- try {
328
- const rows = db
329
- .prepare("SELECT id, title, agent, model, time_created, time_updated FROM session ORDER BY time_updated DESC")
330
- .all();
331
- return rows.map((r) => ({
411
+ // Current schemas store the project directory on the session row; older ones
412
+ // keep it on the related project row. Try both, newest schema first.
413
+ const queries = [
414
+ "SELECT id, title, agent, model, time_created, time_updated, directory AS dir FROM session ORDER BY time_updated DESC",
415
+ "SELECT s.id AS id, s.title AS title, s.agent AS agent, s.model AS model, s.time_created AS time_created, s.time_updated AS time_updated, p.worktree AS dir FROM session s LEFT JOIN project p ON p.id = s.project_id ORDER BY s.time_updated DESC",
416
+ ];
417
+ for (const sql of queries) {
418
+ let rows;
419
+ try {
420
+ rows = db.prepare(sql).all();
421
+ }
422
+ catch {
423
+ continue; // this schema has no project path column, try the next query
424
+ }
425
+ // History is scoped to the current project: skip other directories.
426
+ return rows
427
+ .filter((r) => sameCwd(typeof r.dir === "string" ? r.dir : "", cwd))
428
+ .map((r) => ({
332
429
  id: String(r.id ?? ""),
333
430
  title: cleanTitle(typeof r.title === "string" ? r.title : "").slice(0, 80),
334
431
  created_at: iso(r.time_created),
@@ -336,9 +433,7 @@ function listOpencode() {
336
433
  model: opencodeModelName(r.model) || (typeof r.agent === "string" ? r.agent : ""),
337
434
  }));
338
435
  }
339
- catch {
340
- return [];
341
- }
436
+ return [];
342
437
  }
343
438
  function loadOpencode(id) {
344
439
  const db = opencodeDbHandle();
@@ -380,12 +475,16 @@ function loadOpencode(id) {
380
475
  }
381
476
  }
382
477
  // ---------------- Public API ----------------
383
- export function listAgentSessions(agent) {
478
+ /**
479
+ * Sessions of `agent` that were started in `cwd` (the directory neutrino runs
480
+ * in), newest first. Sessions from other directories are not shown.
481
+ */
482
+ export function listAgentSessions(agent, cwd = process.cwd()) {
384
483
  if (agent === "claude")
385
- return listClaude();
484
+ return listClaude(cwd);
386
485
  if (agent === "codex")
387
- return listCodex();
388
- return listOpencode();
486
+ return listCodex(cwd);
487
+ return listOpencode(cwd);
389
488
  }
390
489
  export function loadAgentMessages(agent, id) {
391
490
  if (agent === "claude")