antechamber 1.0.40 → 2.0.1

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 (70) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/README.md +88 -141
  3. package/bin/opencode-antechamber +14 -4
  4. package/dist/approvals.d.ts +9 -1
  5. package/dist/approvals.js +10 -2
  6. package/dist/catalog.d.ts +13 -2
  7. package/dist/catalog.js +88 -52
  8. package/dist/cli.d.ts +1 -1
  9. package/dist/cli.js +1 -1
  10. package/dist/commands/context.d.ts +5 -2
  11. package/dist/commands/context.js +8 -4
  12. package/dist/commands/doctor.js +4 -3
  13. package/dist/commands/setup.js +9 -28
  14. package/dist/config.d.ts +6 -2
  15. package/dist/config.js +18 -4
  16. package/dist/file-watch.d.ts +35 -0
  17. package/dist/file-watch.js +163 -0
  18. package/dist/latency.d.ts +10 -0
  19. package/dist/latency.js +115 -0
  20. package/dist/misc-routes.js +14 -12
  21. package/dist/pi-event-translation.js +44 -2
  22. package/dist/route-services.d.ts +8 -3
  23. package/dist/routes-v1.d.ts +1 -1
  24. package/dist/routes-v1.js +29 -418
  25. package/dist/routes-v2.js +252 -34
  26. package/dist/self-update.d.ts +2 -1
  27. package/dist/self-update.js +5 -1
  28. package/dist/server.d.ts +17 -3
  29. package/dist/server.js +191 -18
  30. package/dist/session-commands.d.ts +3 -1
  31. package/dist/session-commands.js +13 -7
  32. package/dist/session-history.js +3 -0
  33. package/dist/session-store.d.ts +19 -10
  34. package/dist/session-store.js +266 -128
  35. package/dist/session-tree.d.ts +15 -1
  36. package/dist/session-tree.js +2 -2
  37. package/dist/sessions.d.ts +85 -1
  38. package/dist/sessions.js +238 -10
  39. package/dist/sse.d.ts +0 -2
  40. package/dist/sse.js +13 -46
  41. package/dist/text-generation.d.ts +20 -0
  42. package/dist/text-generation.js +93 -0
  43. package/dist/tool-map.js +1 -3
  44. package/dist/types.d.ts +60 -0
  45. package/dist/v2.d.ts +8 -1
  46. package/dist/v2.js +24 -4
  47. package/dist/work-status.js +1 -1
  48. package/extensions/antechamber/README.md +100 -0
  49. package/extensions/antechamber/bin/opencode-antechamber +42 -0
  50. package/extensions/{omo-dag/docs/DESIGN.md → antechamber/docs/GRAPH.md} +5 -5
  51. package/extensions/{omo-work/docs/DESIGN.md → antechamber/docs/WORK.md} +3 -3
  52. package/extensions/antechamber/package.json +60 -0
  53. package/extensions/{omo-dag → antechamber}/panel/index.html +8 -1
  54. package/extensions/antechamber/panel/main.js +261 -0
  55. package/extensions/antechamber/runtime/launcher.js +201 -0
  56. package/extensions/antechamber/service/main.js +884 -0
  57. package/package.json +75 -26
  58. package/extensions/omo-compat/README.md +0 -48
  59. package/extensions/omo-compat/icon.svg +0 -1
  60. package/extensions/omo-compat/package.json +0 -43
  61. package/extensions/omo-dag/README.md +0 -167
  62. package/extensions/omo-dag/package.json +0 -33
  63. package/extensions/omo-dag/panel/main.js +0 -176
  64. package/extensions/omo-dag/service/main.js +0 -457
  65. package/extensions/omo-work/README.md +0 -88
  66. package/extensions/omo-work/package.json +0 -30
  67. package/extensions/omo-work/service/main.js +0 -149
  68. /package/extensions/{omo-dag → antechamber}/icon.svg +0 -0
  69. /package/extensions/{omo-work → antechamber}/status/index.html +0 -0
  70. /package/extensions/{omo-work → antechamber}/status/main.js +0 -0
@@ -6,7 +6,7 @@
6
6
  * so the adapter never needs its own transcript store — only:
7
7
  *
8
8
  * 1. a scan of that directory to rebuild the sidebar after a restart, and
9
- * 2. a small registry JSON for adapter-owned metadata that pi does not
9
+ * 2. a SQLite registry for adapter-owned metadata that pi does not
10
10
  * keep (titles, conversation cursors and child-task metadata).
11
11
  *
12
12
  * OpenChamber session ids for restored sessions are derived deterministically
@@ -14,8 +14,9 @@
14
14
  * the same OpenChamber id, even if the registry file is lost.
15
15
  */
16
16
  import { createHash } from "node:crypto";
17
- import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
17
+ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, } from "node:fs";
18
18
  import { join } from "node:path";
19
+ import { DatabaseSync } from "node:sqlite";
19
20
  import { piAgentDir } from "./agent-dir.js";
20
21
  import { isPromptPart } from "./prompt-parts.js";
21
22
  import { defaultStateDir, envSetting } from "./config.js";
@@ -42,9 +43,6 @@ const TITLE_MAX_LINES = 200;
42
43
  const TITLE_MAX_CHARS = 60;
43
44
  const REGISTRY_DIRECTORY_MODE = 0o700;
44
45
  const REGISTRY_FILE_MODE = 0o600;
45
- const REGISTRY_LOCK_RETRY_MS = 10;
46
- const REGISTRY_LOCK_STALE_MS = 5_000;
47
- const registryLockWaiter = new Int32Array(new SharedArrayBuffer(4));
48
46
  function readHead(path, bytes = SNIFF_BYTES) {
49
47
  const fd = openSync(path, "r");
50
48
  try {
@@ -359,7 +357,7 @@ export function scanPiSessions(directory) {
359
357
  }
360
358
  /**
361
359
  * Persistent oc-session-id → metadata registry (title overrides, known pi
362
- * paths). Atomic writes, lazy load, corrupt file degrades to an empty map.
360
+ * paths). Legacy JSON records are validated at migration; SQLite writes are atomic.
363
361
  */
364
362
  function record(value) {
365
363
  return value !== null && typeof value === "object" && !Array.isArray(value);
@@ -387,12 +385,31 @@ function sessionTreeState(value) {
387
385
  if (value.promptMetadata !== undefined &&
388
386
  (!record(value.promptMetadata) || !Object.values(value.promptMetadata).every((metadata) => record(metadata))))
389
387
  return false;
388
+ if (value.promptDescriptions !== undefined && !stringRecord(value.promptDescriptions))
389
+ return false;
390
+ if (value.compactionReasons !== undefined &&
391
+ (!stringRecord(value.compactionReasons) ||
392
+ !Object.values(value.compactionReasons).every((reason) => reason === "manual" || reason === "auto")))
393
+ return false;
390
394
  if (value.messageParts !== undefined &&
391
395
  (!record(value.messageParts) ||
392
396
  !Object.values(value.messageParts).every((parts) => Array.isArray(parts) && parts.every((part) => typeof part === "string"))))
393
397
  return false;
394
398
  if (value.shellDispatch !== undefined && !booleanRecord(value.shellDispatch))
395
399
  return false;
400
+ const finite = (item) => typeof item === "number" && Number.isFinite(item);
401
+ if (value.lastIdle !== undefined && !finite(value.lastIdle))
402
+ return false;
403
+ if (value.compactionAdmissions !== undefined &&
404
+ (!record(value.compactionAdmissions) || !Object.values(value.compactionAdmissions).every(finite)))
405
+ return false;
406
+ if (value.idleBoundaries !== undefined &&
407
+ (!record(value.idleBoundaries) ||
408
+ !Object.values(value.idleBoundaries).every((boundary) => record(boundary) &&
409
+ finite(boundary.at) &&
410
+ ["succeeded", "failed", "interrupted"].includes(boundary.outcome) &&
411
+ (boundary.error === undefined || record(boundary.error)))))
412
+ return false;
396
413
  if (value.leafId !== undefined && value.leafId !== null && typeof value.leafId !== "string")
397
414
  return false;
398
415
  if (value.fileSize !== undefined && (typeof value.fileSize !== "number" || !Number.isFinite(value.fileSize)))
@@ -448,6 +465,13 @@ function registryEntry(value) {
448
465
  return false;
449
466
  if (value.metadata !== undefined && !record(value.metadata))
450
467
  return false;
468
+ if (value.instructions !== undefined &&
469
+ (!record(value.instructions) ||
470
+ !Object.entries(value.instructions).every(([key, entry]) => /^[a-z0-9][a-z0-9._-]*$/.test(key) &&
471
+ record(entry) &&
472
+ "value" in entry &&
473
+ (entry.removed === undefined || entry.removed === true))))
474
+ return false;
451
475
  if (value.task !== undefined && !childTask(value.task))
452
476
  return false;
453
477
  if (value.title !== undefined && typeof value.title !== "string")
@@ -456,6 +480,8 @@ function registryEntry(value) {
456
480
  return false;
457
481
  if (value.archived !== undefined && (typeof value.archived !== "number" || !Number.isFinite(value.archived)))
458
482
  return false;
483
+ if (value.viewed !== undefined && (typeof value.viewed !== "number" || !Number.isFinite(value.viewed)))
484
+ return false;
459
485
  if (value.model !== undefined &&
460
486
  (!record(value.model) || typeof value.model.providerID !== "string" || typeof value.model.modelID !== "string"))
461
487
  return false;
@@ -484,140 +510,231 @@ function errorCode(error) {
484
510
  export class SessionStore {
485
511
  stateDir;
486
512
  file;
487
- entries;
488
- /** The registry bytes `entries` was parsed from or last written as; equal bytes on disk mean the cache is current. */
489
- raw;
513
+ db;
514
+ version;
515
+ entries = new Map();
490
516
  batchState;
491
517
  constructor(stateDir) {
492
518
  const dir = stateDir ?? envSetting("STATE_DIR") ?? defaultStateDir();
493
519
  this.stateDir = dir;
494
- this.file = join(dir, "sessions.json");
495
- }
496
- load() {
497
- if (this.batchState !== undefined) {
498
- // Shallow: changes replace whole entries, and get() clones an entry before handing it out.
499
- this.batchState.entries ??= new Map(this.committed());
500
- return this.batchState.entries;
501
- }
502
- return this.committed();
503
- }
504
- committed() {
505
- if (this.entries !== undefined)
506
- return this.entries;
507
- this.raw = this.readRaw();
508
- this.entries = this.parseEntries(this.raw);
509
- return this.entries;
520
+ this.file = join(dir, "sessions.sqlite");
510
521
  }
511
- readRaw() {
512
- try {
513
- return readFileSync(this.file);
514
- }
515
- catch {
522
+ database(write = false) {
523
+ if (this.db !== undefined)
524
+ return this.db;
525
+ const legacy = join(this.stateDir, "sessions.json");
526
+ if (!write && !existsSync(this.file) && !existsSync(legacy))
516
527
  return undefined;
517
- }
518
- }
519
- parseEntries(raw) {
520
- const entries = new Map();
521
- if (raw === undefined)
522
- return entries;
528
+ let db;
523
529
  try {
524
- const parsed = JSON.parse(raw.toString("utf8"));
525
- if (!record(parsed))
526
- return entries;
527
- for (const [id, value] of Object.entries(parsed))
528
- if (registryEntry(value))
529
- entries.set(id, value);
530
+ mkdirSync(this.stateDir, { recursive: true, mode: REGISTRY_DIRECTORY_MODE });
531
+ chmodSync(this.stateDir, REGISTRY_DIRECTORY_MODE);
532
+ // SQLite inherits the database mode for WAL/SHM; set it before enabling WAL.
533
+ closeSync(openSync(this.file, "a", REGISTRY_FILE_MODE));
534
+ chmodSync(this.file, REGISTRY_FILE_MODE);
535
+ db = new DatabaseSync(this.file);
536
+ db.exec("PRAGMA busy_timeout = 5000; PRAGMA journal_mode = WAL;");
537
+ db.exec(`CREATE TABLE IF NOT EXISTS sessions (
538
+ id TEXT PRIMARY KEY, payload TEXT NOT NULL, pi_session_id TEXT NOT NULL,
539
+ directory TEXT NOT NULL, parent_id TEXT, has_task INTEGER NOT NULL
540
+ );
541
+ CREATE INDEX IF NOT EXISTS sessions_pi ON sessions(pi_session_id);
542
+ CREATE INDEX IF NOT EXISTS sessions_directory ON sessions(directory);
543
+ CREATE INDEX IF NOT EXISTS sessions_children ON sessions(parent_id) WHERE has_task = 1;
544
+ CREATE TABLE IF NOT EXISTS registry_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);`);
545
+ this.migrate(db, legacy);
546
+ for (const suffix of ["-wal", "-shm"]) {
547
+ const path = this.file + suffix;
548
+ if (existsSync(path))
549
+ chmodSync(path, REGISTRY_FILE_MODE);
550
+ }
551
+ this.db = db;
552
+ return db;
530
553
  }
531
- catch {
532
- /* corrupt — start empty */
554
+ catch (error) {
555
+ db?.close();
556
+ throw new SessionPersistenceError(this.file, error);
533
557
  }
534
- return entries;
535
558
  }
536
- withRegistryLock(action) {
537
- const lock = `${this.file}.lock`;
538
- let fd;
559
+ migrate(db, legacy) {
560
+ const meta = (key) => db.prepare("SELECT value FROM registry_meta WHERE key = ?").get(key)?.value;
561
+ const setMeta = (key, value) => db
562
+ .prepare("INSERT INTO registry_meta VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
563
+ .run(key, value);
564
+ db.prepare("INSERT OR IGNORE INTO registry_meta VALUES ('created_at', ?)").run(String(Date.now()));
565
+ if (!existsSync(legacy))
566
+ return;
567
+ db.exec("BEGIN IMMEDIATE");
568
+ let backup;
569
+ let merged = 0;
539
570
  try {
540
- mkdirSync(this.stateDir, { recursive: true, mode: REGISTRY_DIRECTORY_MODE });
541
- chmodSync(this.stateDir, REGISTRY_DIRECTORY_MODE);
542
- for (;;) {
571
+ const imported = meta("legacy_backup");
572
+ const parse = () => {
543
573
  try {
544
- fd = openSync(lock, "wx", REGISTRY_FILE_MODE);
545
- break;
574
+ const parsed = JSON.parse(readFileSync(legacy, "utf8"));
575
+ return record(parsed) ? parsed : undefined;
546
576
  }
547
577
  catch (error) {
548
- if (errorCode(error) !== "EEXIST")
578
+ if (!(error instanceof SyntaxError))
549
579
  throw error;
550
- try {
551
- if (Date.now() - statSync(lock).mtimeMs >= REGISTRY_LOCK_STALE_MS) {
552
- unlinkSync(lock);
580
+ // Preserve malformed JSON untouched; a later repair can still be imported.
581
+ return undefined;
582
+ }
583
+ };
584
+ if (typeof imported === "string" && !existsSync(imported))
585
+ backup = imported;
586
+ else if (imported === undefined && db.prepare("SELECT 1 FROM sessions LIMIT 1").get() === undefined) {
587
+ const parsed = parse();
588
+ if (parsed !== undefined) {
589
+ const upsert = this.upsert(db);
590
+ for (const [id, entry] of Object.entries(parsed))
591
+ if (registryEntry(entry))
592
+ this.writeRow(upsert, id, entry);
593
+ backup = `${legacy}.migrated-${Date.now()}-${process.pid}`;
594
+ setMeta("legacy_backup", backup);
595
+ setMeta("reconciled_at", String(Date.now()));
596
+ }
597
+ }
598
+ else {
599
+ // A sessions.json next to a populated registry was written by an older antechamber after the migration (a
600
+ // rollback). Sessions it changed since then are newer than the rows here; older entries, including ones
601
+ // deleted since, stay as the registry has them.
602
+ const since = Number(meta("reconciled_at") ?? /\.migrated-(\d+)-/.exec(String(imported))?.[1] ?? meta("created_at") ?? Infinity);
603
+ const parsed = parse();
604
+ if (parsed !== undefined) {
605
+ const upsert = this.upsert(db);
606
+ for (const [id, entry] of Object.entries(parsed)) {
607
+ if (!registryEntry(entry) || !(entry.updated > since))
608
+ continue;
609
+ const current = this.read(db, id);
610
+ if (current !== undefined && current.updated >= entry.updated)
553
611
  continue;
554
- }
612
+ this.writeRow(upsert, id, entry);
613
+ merged += 1;
555
614
  }
556
- catch (staleLockError) {
557
- if (errorCode(staleLockError) !== "ENOENT")
558
- throw staleLockError;
559
- continue;
615
+ if (merged > 0) {
616
+ backup = `${legacy}.merged-${Date.now()}-${process.pid}`;
617
+ setMeta("reconciled_at", String(Date.now()));
560
618
  }
561
- Atomics.wait(registryLockWaiter, 0, 0, REGISTRY_LOCK_RETRY_MS);
562
619
  }
563
620
  }
564
- return action();
621
+ db.exec("COMMIT");
565
622
  }
566
623
  catch (error) {
567
- throw new SessionPersistenceError(this.file, error);
624
+ db.exec("ROLLBACK");
625
+ throw error;
568
626
  }
569
- finally {
570
- if (fd !== undefined) {
571
- closeSync(fd);
572
- unlinkSync(lock);
627
+ if (merged > 0)
628
+ this.entries.clear();
629
+ if (merged > 0)
630
+ console.warn(`[session-store] merged ${merged} sessions an older antechamber wrote to ${legacy}`);
631
+ // The durable marker handles a crash after COMMIT but before rename, including an empty import.
632
+ // Never overwrite an existing backup or import the same legacy file after sessions are deleted.
633
+ if (backup !== undefined && !existsSync(backup)) {
634
+ try {
635
+ chmodSync(legacy, REGISTRY_FILE_MODE);
636
+ renameSync(legacy, backup);
637
+ }
638
+ catch (error) {
639
+ if (errorCode(error) !== "ENOENT")
640
+ throw error;
573
641
  }
574
642
  }
575
643
  }
576
- persist(entries) {
577
- const obj = Object.fromEntries([...entries].map(([id, entry]) => [id, persistedEntry(entry)]));
578
- const tmp = `${this.file}.${process.pid}.tmp`;
579
- const data = Buffer.from(JSON.stringify(obj));
580
- writeFileSync(tmp, data, { mode: REGISTRY_FILE_MODE });
581
- chmodSync(tmp, REGISTRY_FILE_MODE);
582
- renameSync(tmp, this.file);
583
- return data;
584
- }
585
- mutate(change) {
586
- if (this.batchState !== undefined) {
587
- this.batchState.changes.push(change);
588
- change(this.load());
589
- return;
644
+ upsert(db) {
645
+ return db.prepare(`INSERT INTO sessions (id, payload, pi_session_id, directory, parent_id, has_task)
646
+ VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET
647
+ payload=excluded.payload, pi_session_id=excluded.pi_session_id, directory=excluded.directory,
648
+ parent_id=excluded.parent_id, has_task=excluded.has_task`);
649
+ }
650
+ writeRow(statement, id, entry) {
651
+ statement.run(id, JSON.stringify(persistedEntry(entry)), entry.piSessionId, entry.directory, entry.parentID ?? null, entry.task === undefined ? 0 : 1);
652
+ }
653
+ refresh(db) {
654
+ const version = db.prepare("PRAGMA data_version").get()?.data_version;
655
+ if (typeof version !== "number" && typeof version !== "bigint")
656
+ throw new Error("Invalid SQLite data_version");
657
+ if (this.version !== version) {
658
+ this.entries.clear();
659
+ this.version = version;
660
+ }
661
+ }
662
+ read(db, id) {
663
+ const cached = this.entries.get(id);
664
+ if (cached !== undefined)
665
+ return cached;
666
+ const payload = db.prepare("SELECT payload FROM sessions WHERE id = ?").get(id)?.payload;
667
+ if (typeof payload !== "string")
668
+ return undefined;
669
+ const entry = JSON.parse(payload);
670
+ if (!registryEntry(entry))
671
+ return undefined;
672
+ this.entries.set(id, entry);
673
+ return entry;
674
+ }
675
+ persist(changes) {
676
+ try {
677
+ mkdirSync(this.stateDir, { recursive: true, mode: REGISTRY_DIRECTORY_MODE });
678
+ chmodSync(this.stateDir, REGISTRY_DIRECTORY_MODE);
679
+ const db = this.database(true);
680
+ db.exec("BEGIN IMMEDIATE");
681
+ const changed = new Map();
682
+ try {
683
+ this.refresh(db);
684
+ for (const { id, apply } of changes) {
685
+ const previous = changed.has(id) ? changed.get(id) : this.read(db, id);
686
+ const next = apply(previous);
687
+ if (next !== undefined || previous !== undefined)
688
+ changed.set(id, next);
689
+ }
690
+ const upsert = this.upsert(db);
691
+ const remove = db.prepare("DELETE FROM sessions WHERE id = ?");
692
+ for (const [id, entry] of changed) {
693
+ if (entry === undefined)
694
+ remove.run(id);
695
+ else
696
+ this.writeRow(upsert, id, entry);
697
+ }
698
+ db.exec("COMMIT");
699
+ }
700
+ catch (error) {
701
+ db.exec("ROLLBACK");
702
+ throw error;
703
+ }
704
+ for (const [id, entry] of changed) {
705
+ if (entry === undefined)
706
+ this.entries.delete(id);
707
+ else
708
+ this.entries.set(id, entry);
709
+ }
710
+ }
711
+ catch (error) {
712
+ if (error instanceof SessionPersistenceError)
713
+ throw error;
714
+ throw new SessionPersistenceError(this.file, error);
590
715
  }
591
- this.withRegistryLock(() => {
592
- // Another process may have written since; only parse again when the bytes differ.
593
- const raw = this.readRaw();
594
- const entries = this.entries !== undefined && raw !== undefined && this.raw !== undefined && raw.equals(this.raw)
595
- ? new Map(this.entries)
596
- : this.parseEntries(raw);
597
- const written = change(entries) ? this.persist(entries) : raw;
598
- this.entries = entries;
599
- this.raw = written;
600
- });
601
716
  }
602
- /** Synchronous restoration reads its own changes, then merges them under one short-lived write lock. */
717
+ mutate(id, apply) {
718
+ const batch = this.batchState;
719
+ if (batch === undefined)
720
+ this.persist([{ id, apply }]);
721
+ else {
722
+ const previous = this.get(id);
723
+ batch.changes.push({ id, apply });
724
+ batch.entries.set(id, apply(previous));
725
+ }
726
+ }
727
+ /** Stage only touched rows; replay partial updates against the latest rows under SQLite's write lock. */
603
728
  batch(action) {
604
729
  if (this.batchState !== undefined)
605
730
  return action();
606
- const changes = [];
607
- const batch = { entries: undefined, isolated: new WeakSet(), changes };
731
+ const batch = { entries: new Map(), changes: [] };
608
732
  this.batchState = batch;
609
733
  try {
610
734
  const result = action();
611
735
  this.batchState = undefined;
612
- if (batch.changes.length !== 0) {
613
- this.mutate((entries) => {
614
- let changed = false;
615
- for (const change of batch.changes)
616
- if (change(entries))
617
- changed = true;
618
- return changed;
619
- });
620
- }
736
+ if (batch.changes.length !== 0)
737
+ this.persist(batch.changes);
621
738
  return result;
622
739
  }
623
740
  finally {
@@ -625,49 +742,70 @@ export class SessionStore {
625
742
  }
626
743
  }
627
744
  get(ocSessionId) {
628
- const entries = this.load();
629
- const entry = entries.get(ocSessionId);
630
- if (this.batchState === undefined || entry === undefined || this.batchState.isolated.has(entry))
745
+ const batch = this.batchState;
746
+ if (batch?.entries.has(ocSessionId))
747
+ return batch.entries.get(ocSessionId);
748
+ const db = this.database();
749
+ if (db === undefined)
750
+ return undefined;
751
+ this.refresh(db);
752
+ const entry = this.read(db, ocSessionId);
753
+ if (batch === undefined)
631
754
  return entry;
632
- const staged = structuredClone(entry);
633
- entries.set(ocSessionId, staged);
634
- this.batchState.isolated.add(staged);
755
+ const staged = entry === undefined ? undefined : structuredClone(entry);
756
+ batch.entries.set(ocSessionId, staged);
635
757
  return staged;
636
758
  }
637
759
  idFor(piSessionId) {
638
- return [...this.load()].find(([, entry]) => entry.piSessionId === piSessionId)?.[0];
760
+ return this.matching("SELECT id FROM sessions WHERE pi_session_id = ? ORDER BY rowid", [piSessionId], (entry) => entry.piSessionId === piSessionId)[0];
639
761
  }
640
762
  childIds() {
641
- return [...this.load()]
642
- .filter(([, entry]) => entry.task !== undefined && entry.parentID !== undefined)
643
- .map(([id]) => id);
763
+ return this.matching("SELECT id FROM sessions WHERE has_task = 1 AND parent_id IS NOT NULL ORDER BY rowid", [], (entry) => entry.task !== undefined && entry.parentID !== undefined);
764
+ }
765
+ matching(query, parameters, matches) {
766
+ const db = this.database();
767
+ if (db !== undefined)
768
+ this.refresh(db);
769
+ const ids = new Set();
770
+ for (const row of db?.prepare(query).all(...parameters) ?? [])
771
+ if (typeof row.id === "string")
772
+ ids.add(row.id);
773
+ if (this.batchState === undefined)
774
+ return [...ids];
775
+ for (const id of this.batchState?.entries.keys() ?? [])
776
+ ids.add(id);
777
+ return [...ids].filter((id) => {
778
+ const entry = this.get(id);
779
+ return entry !== undefined && matches(entry);
780
+ });
644
781
  }
645
782
  set(ocSessionId, entry) {
646
783
  const saved = this.batchState === undefined ? entry : structuredClone(entry);
647
- this.mutate((entries) => {
648
- entries.set(ocSessionId, this.batchState === undefined ? saved : structuredClone(saved));
649
- return true;
650
- });
784
+ this.mutate(ocSessionId, () => (this.batchState === undefined ? saved : structuredClone(saved)));
651
785
  }
652
786
  /** Merge a partial update (e.g. title override, updated timestamp). */
653
787
  update(ocSessionId, patch) {
654
788
  const saved = this.batchState === undefined ? patch : structuredClone(patch);
655
- this.mutate((entries) => {
656
- const previous = entries.get(ocSessionId);
657
- if (previous === undefined)
658
- return false;
659
- entries.set(ocSessionId, {
789
+ this.mutate(ocSessionId, (previous) => previous === undefined
790
+ ? undefined
791
+ : {
660
792
  ...previous,
661
793
  ...(this.batchState === undefined ? saved : structuredClone(saved)),
662
794
  });
663
- return true;
664
- });
665
795
  }
666
796
  remove(ocSessionId) {
667
- this.mutate((entries) => entries.delete(ocSessionId));
797
+ this.mutate(ocSessionId, () => undefined);
668
798
  }
669
799
  /** Every directory the registry knows about — used to resolve bare session ids. */
670
800
  directories() {
671
- return [...new Set([...this.load().values()].map((e) => e.directory))];
801
+ const db = this.database();
802
+ const directories = new Set();
803
+ for (const row of db?.prepare("SELECT DISTINCT directory FROM sessions").all() ?? [])
804
+ if (typeof row.directory === "string")
805
+ directories.add(row.directory);
806
+ if (this.batchState === undefined)
807
+ return [...directories];
808
+ const ids = this.matching("SELECT id FROM sessions", [], () => true);
809
+ return [...new Set(ids.map((id) => this.get(id).directory))];
672
810
  }
673
811
  }
@@ -1,6 +1,6 @@
1
1
  import { type PiRpcClient } from "./pi-rpc.js";
2
2
  import type { ManagedSession } from "./sessions.js";
3
- import type { OCMessageWithParts } from "./types.js";
3
+ import type { OCAssistantMessage, OCMessageWithParts, OCSession } from "./types.js";
4
4
  import type { OCPromptPart } from "./types.js";
5
5
  export interface SessionTreeState {
6
6
  messageEntries: Record<string, string>;
@@ -10,6 +10,10 @@ export interface SessionTreeState {
10
10
  promptParts?: Record<string, OCPromptPart[]>;
11
11
  /** Client prompt metadata; native RPC does not store it. */
12
12
  promptMetadata?: Record<string, Record<string, unknown>>;
13
+ /** Client-provided synthetic notice labels; native RPC stores only the context text. */
14
+ promptDescriptions?: Record<string, string>;
15
+ /** Native compaction reasons by the durable compaction entry ID. */
16
+ compactionReasons?: Record<string, "manual" | "auto">;
13
17
  /** Actual transport dispatch, not inferred from a card's terminal state. */
14
18
  shellDispatch?: Record<string, boolean>;
15
19
  /** Identity-only dispatched admissions; never rendered without a native entry. */
@@ -25,6 +29,16 @@ export interface SessionTreeState {
25
29
  previousLeafId: string | null;
26
30
  targetId: string | null;
27
31
  };
32
+ /** Execution boundaries observed over RPC, retained with the durable message identities. */
33
+ idleBoundaries?: Record<string, {
34
+ at: number;
35
+ outcome: NonNullable<OCSession["outcome"]>;
36
+ error?: NonNullable<OCAssistantMessage["error"]>;
37
+ }>;
38
+ /** The last idle time, restored as time.idle so /view accepts a watermark seen before a restart. */
39
+ lastIdle?: number;
40
+ /** When each compaction was admitted, keyed by its admitted message ID. */
41
+ compactionAdmissions?: Record<string, number>;
28
42
  }
29
43
  export interface TreeEntry {
30
44
  type: string;
@@ -332,8 +332,8 @@ function entryForMessage(s, tree, messageID) {
332
332
  }
333
333
  /**
334
334
  * The last entry of an assistant reply: the reply, or the latest result of its own tool calls.
335
- * OpenChamber 2.x /btw names a final reply (no tool calls); 1.24's /btw can name any completed
336
- * step, whose tool results belong to the same OpenChamber message.
335
+ * A final reply has no tool calls. When an API caller selects a tool-bearing
336
+ * reply, its completed tool results belong to the same message.
337
337
  */
338
338
  function replyEnd(tree, reply, leafId) {
339
339
  const content = reply.message?.content;