sphica 0.6.32 → 0.6.34

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/deliver.js CHANGED
@@ -11979,6 +11979,8 @@ var spoolDir = () => path6.join(sphicaHome(), "spool");
11979
11979
  var stateFile = () => path6.join(sphicaHome(), "capture.json");
11980
11980
  var rejectedDir = () => path6.join(spoolDir(), "rejected");
11981
11981
  var unregisteredDir = () => path6.join(spoolDir(), "unregistered");
11982
+ var callsDir = () => path6.join(spoolDir(), "calls");
11983
+ var callsRejectedDir = () => path6.join(callsDir(), "rejected");
11982
11984
  var HOLD_DAYS = 30;
11983
11985
  var HOLD_MAX = 1000;
11984
11986
  var baselineDir = () => path6.join(sphicaHome(), "worktree");
@@ -12027,7 +12029,7 @@ ${z}`,
12027
12029
  originalBytes: all
12028
12030
  };
12029
12031
  }
12030
- function prune(held) {
12032
+ function prune(held, removed = () => {}) {
12031
12033
  let files;
12032
12034
  try {
12033
12035
  files = fs5.readdirSync(held).filter((f) => f.endsWith(".json") && !f.startsWith(".")).sort();
@@ -12037,16 +12039,21 @@ function prune(held) {
12037
12039
  const cutoff = Date.now() - HOLD_DAYS * 24 * 60 * 60 * 1000;
12038
12040
  const stale = files.filter((f) => Number(f.split("-")[0]) < cutoff);
12039
12041
  const over = files.slice(0, Math.max(0, files.length - HOLD_MAX));
12040
- for (const f of new Set([...stale, ...over]))
12042
+ for (const f of new Set([...stale, ...over])) {
12041
12043
  fs5.rmSync(path6.join(held, f), { force: true });
12044
+ removed();
12045
+ }
12042
12046
  }
12043
- function spool(record) {
12044
- const dir = spoolDir();
12047
+ function put(dir, record) {
12045
12048
  fs5.mkdirSync(dir, { recursive: true, mode: 448 });
12046
12049
  const name = `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 10)}.json`;
12047
12050
  const tmp = path6.join(dir, `.${name}`);
12048
12051
  fs5.writeFileSync(tmp, JSON.stringify(record), { mode: 384 });
12049
12052
  fs5.renameSync(tmp, path6.join(dir, name));
12053
+ return path6.join(dir, name);
12054
+ }
12055
+ function spool(record) {
12056
+ put(spoolDir(), record);
12050
12057
  }
12051
12058
  var branchOf = (root) => {
12052
12059
  try {
@@ -12101,9 +12108,11 @@ function captureNotice(file = dbFile()) {
12101
12108
  if (!fs5.existsSync(file))
12102
12109
  return panel("sphica: no database, so conversations are not recorded", [file], "Create it with sphica init");
12103
12110
  const s = readState();
12111
+ if (s.unreadable)
12112
+ return panel("sphica: cannot read the recording queue", [spoolDir(), s.unreadable], "Check with sphica doctor");
12104
12113
  if (s.stuck)
12105
12114
  return panel("sphica: cannot send recordings", [`${s.pending} pending / failed: ${plain2(s.stuck.slice(0, 120))}`], "Check with sphica doctor");
12106
- if (s.rejected > 0)
12115
+ if (s.rejected)
12107
12116
  return panel(`sphica: the database rejected ${plural(s.rejected, "record")}`, [rejectedDir(), `Move them back to ${spoolDir()} to resend`], "Fix them first, then check with sphica doctor");
12108
12117
  return null;
12109
12118
  }
@@ -12294,38 +12303,49 @@ function onHook(host, input) {
12294
12303
  }
12295
12304
  return { flush: false };
12296
12305
  }
12297
- function writeState(s) {
12298
- try {
12299
- fs5.writeFileSync(stateFile(), JSON.stringify(s));
12300
- } catch {}
12301
- }
12302
- function readState() {
12303
- const count = (dir) => {
12304
- try {
12305
- return fs5.readdirSync(dir).filter((f) => f.endsWith(".json") && !f.startsWith(".")).length;
12306
- } catch {
12307
- return 0;
12308
- }
12309
- };
12310
- const counts = {
12311
- pending: count(spoolDir()),
12312
- rejected: count(rejectedDir()),
12313
- unregistered: count(unregisteredDir())
12314
- };
12306
+ function readStateFile() {
12315
12307
  let raw = {};
12316
12308
  try {
12317
12309
  const parsed = JSON.parse(fs5.readFileSync(stateFile(), "utf8"));
12318
12310
  if (parsed && typeof parsed === "object")
12319
12311
  raw = parsed;
12320
12312
  } catch {}
12321
- const error = typeof raw.error === "string" ? raw.error || "unknown failure" : null;
12313
+ const p = raw.pruned;
12322
12314
  return {
12323
12315
  flushedAt: typeof raw.flushedAt === "string" ? raw.flushedAt : undefined,
12324
- error,
12316
+ error: typeof raw.error === "string" ? raw.error || "unknown failure" : null,
12325
12317
  deferred: typeof raw.deferred === "number" ? raw.deferred : undefined,
12326
- ...counts,
12327
- stuck: error && counts.pending > 0 ? error : null
12318
+ pruned: p && typeof p.at === "string" && typeof p.count === "number" ? { at: p.at, count: p.count } : undefined
12319
+ };
12320
+ }
12321
+ function writeState(s) {
12322
+ try {
12323
+ fs5.writeFileSync(stateFile(), JSON.stringify({ ...s, pruned: s.pruned ?? readStateFile().pruned }));
12324
+ } catch {}
12325
+ }
12326
+ function readState() {
12327
+ const queue = listQueue(spoolDir());
12328
+ const counts = {
12329
+ pending: queue.files?.length ?? null,
12330
+ rejected: listQueue(rejectedDir()).files?.length ?? null,
12331
+ unregistered: listQueue(unregisteredDir()).files?.length ?? null
12328
12332
  };
12333
+ const state = readStateFile();
12334
+ const stuck = state.error && (counts.pending ?? 0) > 0 ? state.error : null;
12335
+ return { ...state, ...counts, unreadable: queue.code, stuck };
12336
+ }
12337
+ function listQueue(dir) {
12338
+ try {
12339
+ const names = fs5.readdirSync(dir);
12340
+ return {
12341
+ files: names.filter((f) => f.endsWith(".json") && !f.startsWith(".")),
12342
+ temp: names.filter((f) => f.endsWith(".json") && f.startsWith(".")),
12343
+ code: null
12344
+ };
12345
+ } catch (e) {
12346
+ const code = e.code ?? "unknown";
12347
+ return code === "ENOENT" ? { files: [], temp: [], code: null } : { files: null, temp: [], code };
12348
+ }
12329
12349
  }
12330
12350
  function lock() {
12331
12351
  const file = path6.join(spoolDir(), ".lock");
@@ -12463,16 +12483,36 @@ var queued = (from) => {
12463
12483
  return [];
12464
12484
  }
12465
12485
  };
12486
+ var CODE_NAMES = { 18: "TOOBIG", 19: "CONSTRAINT", 20: "MISMATCH", 25: "RANGE" };
12487
+ function setAside(from, name, dir, reason2) {
12488
+ fs5.mkdirSync(dir, { recursive: true, mode: 448 });
12489
+ try {
12490
+ fs5.renameSync(path6.join(from, name), path6.join(dir, name));
12491
+ } catch (e) {
12492
+ if (e.code === "ENOENT")
12493
+ return;
12494
+ throw e;
12495
+ }
12496
+ try {
12497
+ fs5.writeFileSync(path6.join(dir, `${name}.reason`), reason2, { mode: 384 });
12498
+ } catch {}
12499
+ }
12466
12500
  async function sendBatch(db, names) {
12467
12501
  const held = unregisteredDir();
12468
12502
  const records = [];
12469
12503
  for (const { name, from } of names) {
12504
+ let raw;
12470
12505
  let r;
12471
12506
  try {
12472
- r = current(JSON.parse(fs5.readFileSync(path6.join(from, name), "utf8")));
12507
+ raw = JSON.parse(fs5.readFileSync(path6.join(from, name), "utf8"));
12508
+ } catch {
12509
+ setAside(from, name, rejectedDir(), "unreadable");
12510
+ continue;
12511
+ }
12512
+ try {
12513
+ r = current(raw);
12473
12514
  } catch {
12474
- fs5.mkdirSync(rejectedDir(), { recursive: true, mode: 448 });
12475
- fs5.renameSync(path6.join(from, name), path6.join(rejectedDir(), name));
12515
+ setAside(from, name, rejectedDir(), typeof raw?.project === "string" ? "version" : "no-project");
12476
12516
  continue;
12477
12517
  }
12478
12518
  if (r)
@@ -12499,21 +12539,12 @@ async function sendBatch(db, names) {
12499
12539
  } catch (e2) {
12500
12540
  if (!rejected(e2))
12501
12541
  throw e2;
12502
- bad.push(x);
12503
- }
12504
- }
12505
- }
12506
- if (bad.length) {
12507
- fs5.mkdirSync(rejectedDir(), { recursive: true, mode: 448 });
12508
- for (const x of bad) {
12509
- try {
12510
- fs5.renameSync(path6.join(x.from, x.name), path6.join(rejectedDir(), x.name));
12511
- } catch (e) {
12512
- if (e.code !== "ENOENT")
12513
- throw e;
12542
+ bad.push({ ...x, why: `sqlite:${CODE_NAMES[sqliteCode(e2) ?? -1] ?? "unknown"}` });
12514
12543
  }
12515
12544
  }
12516
12545
  }
12546
+ for (const x of bad)
12547
+ setAside(x.from, x.name, rejectedDir(), x.why);
12517
12548
  if (strayed.length) {
12518
12549
  fs5.mkdirSync(held, { recursive: true, mode: 448 });
12519
12550
  for (const x of strayed) {
@@ -12533,6 +12564,60 @@ async function sendBatch(db, names) {
12533
12564
  fs5.rmSync(path6.join(x.from, x.name), { force: true });
12534
12565
  return { sent, rejected: bad.length };
12535
12566
  }
12567
+ async function sendObservations(db, names) {
12568
+ const aside = (name, why) => {
12569
+ try {
12570
+ setAside(callsDir(), name, callsRejectedDir(), why);
12571
+ } catch {}
12572
+ };
12573
+ const rows = [];
12574
+ for (const { name, from } of names) {
12575
+ let text;
12576
+ try {
12577
+ text = fs5.readFileSync(path6.join(from, name), "utf8");
12578
+ } catch {
12579
+ continue;
12580
+ }
12581
+ let o = null;
12582
+ let why = "unreadable";
12583
+ try {
12584
+ o = observation(JSON.parse(text));
12585
+ why = "shape";
12586
+ } catch {}
12587
+ if (o)
12588
+ rows.push({ name, o });
12589
+ else
12590
+ aside(name, why);
12591
+ }
12592
+ if (!rows.length)
12593
+ return true;
12594
+ const insert = (part) => db.insertInto("capture_tool_call").values(part.map((x) => observationRow(x.o))).execute();
12595
+ const bad = new Map;
12596
+ try {
12597
+ await insert(rows);
12598
+ } catch (e) {
12599
+ if (!rejected(e))
12600
+ return false;
12601
+ for (const x of rows)
12602
+ try {
12603
+ await insert([x]);
12604
+ } catch (e2) {
12605
+ if (!rejected(e2))
12606
+ return false;
12607
+ bad.set(x.name, `sqlite:${CODE_NAMES[sqliteCode(e2) ?? -1] ?? "unknown"}`);
12608
+ }
12609
+ }
12610
+ for (const x of rows) {
12611
+ const why = bad.get(x.name);
12612
+ if (why)
12613
+ aside(x.name, why);
12614
+ else
12615
+ try {
12616
+ fs5.rmSync(path6.join(callsDir(), x.name), { force: true });
12617
+ } catch {}
12618
+ }
12619
+ return true;
12620
+ }
12536
12621
  async function flush(file = dbFile(), budgetMs = FLUSH_BUDGET_MS) {
12537
12622
  const deadline = Date.now() + budgetMs;
12538
12623
  const dir = spoolDir();
@@ -12540,6 +12625,8 @@ async function flush(file = dbFile(), budgetMs = FLUSH_BUDGET_MS) {
12540
12625
  const total = { sent: 0, deferred: 0, rejected: 0 };
12541
12626
  let batches = 0;
12542
12627
  let queueBatches = 0;
12628
+ let removed = 0;
12629
+ const pruned = () => removed ? { at: new Date().toISOString(), count: removed } : undefined;
12543
12630
  for (let first = true;; first = false) {
12544
12631
  let unlock = lock();
12545
12632
  while (!unlock && Date.now() < deadline) {
@@ -12561,12 +12648,20 @@ async function flush(file = dbFile(), budgetMs = FLUSH_BUDGET_MS) {
12561
12648
  const sentBefore = batches;
12562
12649
  try {
12563
12650
  if (first) {
12564
- prune(held);
12651
+ prune(held, () => removed++);
12565
12652
  for (const part of chunks(queued(held), BATCH)) {
12566
12653
  if (late())
12567
12654
  break;
12568
12655
  await send(part);
12569
12656
  }
12657
+ for (const part of chunks(queued(callsDir()), BATCH)) {
12658
+ if (late())
12659
+ break;
12660
+ client ??= openWriter("capture", file);
12661
+ if (!await sendObservations(client, part))
12662
+ break;
12663
+ batches++;
12664
+ }
12570
12665
  }
12571
12666
  while (queueBatches === 0 || !late()) {
12572
12667
  const part = queued(dir).slice(0, BATCH);
@@ -12575,12 +12670,19 @@ async function flush(file = dbFile(), budgetMs = FLUSH_BUDGET_MS) {
12575
12670
  await send(part);
12576
12671
  queueBatches++;
12577
12672
  }
12578
- prune(held);
12673
+ prune(held, () => removed++);
12579
12674
  total.deferred = queued(held).length;
12580
12675
  if (batches > sentBefore)
12581
- writeState({ flushedAt: new Date().toISOString(), error: null, deferred: total.deferred });
12676
+ writeState({
12677
+ flushedAt: new Date().toISOString(),
12678
+ error: null,
12679
+ deferred: total.deferred,
12680
+ pruned: pruned()
12681
+ });
12682
+ else if (removed)
12683
+ writeState({ ...readStateFile(), pruned: pruned() });
12582
12684
  } catch (e) {
12583
- writeState({ flushedAt: new Date().toISOString(), error: reason(e).slice(0, 300) });
12685
+ writeState({ flushedAt: new Date().toISOString(), error: reason(e).slice(0, 300), pruned: pruned() });
12584
12686
  throw e;
12585
12687
  } finally {
12586
12688
  await client?.destroy().catch(() => {});
@@ -12599,27 +12701,63 @@ async function readInput(stream) {
12599
12701
  }
12600
12702
  var RECORD_TOOLS = "mcp__plugin_sphica_record__";
12601
12703
  var OBSERVE_WAIT_MS = 5000;
12602
- var hostId = (v) => v && /^[\x21-\x7e]{1,200}$/.test(v) ? v : null;
12603
- async function observeRecordCall(file, input, owner) {
12604
- const session = hostId(input.session_id);
12605
- const toolUse = hostId(input.tool_use_id);
12606
- const tool = hostId(input.tool_name);
12607
- if (!session || !toolUse || !tool?.startsWith(RECORD_TOOLS))
12704
+ var hostId = (v) => typeof v === "string" && /^[\x21-\x7e]{1,200}$/.test(v) ? v : null;
12705
+ var OBSERVATION_KEYS = ["at", "host", "owner", "session", "tool", "toolUse", "turn", "v"].join();
12706
+ function observation(raw) {
12707
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
12708
+ return null;
12709
+ const o = raw;
12710
+ if (Object.keys(o).sort().join() !== OBSERVATION_KEYS)
12711
+ return null;
12712
+ if (o.v !== 1 || o.host !== "claude-code" || !hostId(o.session) || !hostId(o.toolUse))
12713
+ return null;
12714
+ if (!hostId(o.tool)?.startsWith(RECORD_TOOLS))
12715
+ return null;
12716
+ if (o.turn !== null && !hostId(o.turn))
12717
+ return null;
12718
+ if (o.owner !== 0 && o.owner !== 1)
12719
+ return null;
12720
+ const at = typeof o.at === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(o.at) ? o.at : null;
12721
+ if (!at || Number.isNaN(Date.parse(at)) || new Date(at).toISOString() !== at)
12722
+ return null;
12723
+ return o;
12724
+ }
12725
+ var observationRow = (o) => ({
12726
+ host: o.host,
12727
+ session_external: o.session,
12728
+ turn_id: o.turn,
12729
+ tool_use_id: o.toolUse,
12730
+ tool_name: o.tool,
12731
+ owner_turn: o.owner,
12732
+ observed_at: o.at
12733
+ });
12734
+ async function observeRecordCall(file, input, owner, waitMs = OBSERVE_WAIT_MS) {
12735
+ const o = observation({
12736
+ v: 1,
12737
+ host: "claude-code",
12738
+ session: hostId(input.session_id),
12739
+ turn: hostId(input.prompt_id),
12740
+ toolUse: hostId(input.tool_use_id),
12741
+ tool: hostId(input.tool_name),
12742
+ owner: owner ? 1 : 0,
12743
+ at: iso(Date.now())
12744
+ });
12745
+ if (!o)
12608
12746
  return;
12609
- const cap = openWriter("capture", file, OBSERVE_WAIT_MS);
12747
+ let kept = null;
12748
+ try {
12749
+ kept = put(callsDir(), o);
12750
+ } catch {}
12751
+ const cap = openWriter("capture", file, waitMs);
12610
12752
  try {
12611
- await cap.insertInto("capture_tool_call").values({
12612
- host: "claude-code",
12613
- session_external: session,
12614
- turn_id: hostId(input.prompt_id),
12615
- tool_use_id: toolUse,
12616
- tool_name: tool,
12617
- owner_turn: owner ? 1 : 0,
12618
- observed_at: iso(Date.now())
12619
- }).execute();
12753
+ await cap.insertInto("capture_tool_call").values(observationRow(o)).execute();
12620
12754
  } finally {
12621
12755
  await cap.destroy().catch(() => {});
12622
12756
  }
12757
+ if (kept)
12758
+ try {
12759
+ fs5.rmSync(kept, { force: true });
12760
+ } catch {}
12623
12761
  }
12624
12762
  async function main() {
12625
12763
  if (process.argv[2] === "--flush") {