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.
@@ -46925,6 +46925,8 @@ var spoolDir = () => path6.join(sphicaHome(), "spool");
46925
46925
  var stateFile = () => path6.join(sphicaHome(), "capture.json");
46926
46926
  var rejectedDir = () => path6.join(spoolDir(), "rejected");
46927
46927
  var unregisteredDir = () => path6.join(spoolDir(), "unregistered");
46928
+ var callsDir = () => path6.join(spoolDir(), "calls");
46929
+ var callsRejectedDir = () => path6.join(callsDir(), "rejected");
46928
46930
  var HOLD_DAYS = 30;
46929
46931
  var HOLD_MAX = 1000;
46930
46932
  var baselineDir = () => path6.join(sphicaHome(), "worktree");
@@ -46973,7 +46975,7 @@ ${z2}`,
46973
46975
  originalBytes: all
46974
46976
  };
46975
46977
  }
46976
- function prune(held) {
46978
+ function prune(held, removed = () => {}) {
46977
46979
  let files;
46978
46980
  try {
46979
46981
  files = fs6.readdirSync(held).filter((f) => f.endsWith(".json") && !f.startsWith(".")).sort();
@@ -46983,16 +46985,21 @@ function prune(held) {
46983
46985
  const cutoff = Date.now() - HOLD_DAYS * 24 * 60 * 60 * 1000;
46984
46986
  const stale = files.filter((f) => Number(f.split("-")[0]) < cutoff);
46985
46987
  const over = files.slice(0, Math.max(0, files.length - HOLD_MAX));
46986
- for (const f of new Set([...stale, ...over]))
46988
+ for (const f of new Set([...stale, ...over])) {
46987
46989
  fs6.rmSync(path6.join(held, f), { force: true });
46990
+ removed();
46991
+ }
46988
46992
  }
46989
- function spool(record4) {
46990
- const dir = spoolDir();
46993
+ function put(dir, record4) {
46991
46994
  fs6.mkdirSync(dir, { recursive: true, mode: 448 });
46992
46995
  const name = `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 10)}.json`;
46993
46996
  const tmp = path6.join(dir, `.${name}`);
46994
46997
  fs6.writeFileSync(tmp, JSON.stringify(record4), { mode: 384 });
46995
46998
  fs6.renameSync(tmp, path6.join(dir, name));
46999
+ return path6.join(dir, name);
47000
+ }
47001
+ function spool(record4) {
47002
+ put(spoolDir(), record4);
46996
47003
  }
46997
47004
  var branchOf = (root) => {
46998
47005
  try {
@@ -47047,9 +47054,11 @@ function captureNotice(file2 = dbFile()) {
47047
47054
  if (!fs6.existsSync(file2))
47048
47055
  return panel("sphica: no database, so conversations are not recorded", [file2], "Create it with sphica init");
47049
47056
  const s = readState();
47057
+ if (s.unreadable)
47058
+ return panel("sphica: cannot read the recording queue", [spoolDir(), s.unreadable], "Check with sphica doctor");
47050
47059
  if (s.stuck)
47051
47060
  return panel("sphica: cannot send recordings", [`${s.pending} pending / failed: ${plain2(s.stuck.slice(0, 120))}`], "Check with sphica doctor");
47052
- if (s.rejected > 0)
47061
+ if (s.rejected)
47053
47062
  return panel(`sphica: the database rejected ${plural2(s.rejected, "record")}`, [rejectedDir(), `Move them back to ${spoolDir()} to resend`], "Fix them first, then check with sphica doctor");
47054
47063
  return null;
47055
47064
  }
@@ -47240,38 +47249,49 @@ function onHook(host, input2) {
47240
47249
  }
47241
47250
  return { flush: false };
47242
47251
  }
47243
- function writeState(s) {
47244
- try {
47245
- fs6.writeFileSync(stateFile(), JSON.stringify(s));
47246
- } catch {}
47247
- }
47248
- function readState() {
47249
- const count = (dir) => {
47250
- try {
47251
- return fs6.readdirSync(dir).filter((f) => f.endsWith(".json") && !f.startsWith(".")).length;
47252
- } catch {
47253
- return 0;
47254
- }
47255
- };
47256
- const counts = {
47257
- pending: count(spoolDir()),
47258
- rejected: count(rejectedDir()),
47259
- unregistered: count(unregisteredDir())
47260
- };
47252
+ function readStateFile() {
47261
47253
  let raw = {};
47262
47254
  try {
47263
47255
  const parsed = JSON.parse(fs6.readFileSync(stateFile(), "utf8"));
47264
47256
  if (parsed && typeof parsed === "object")
47265
47257
  raw = parsed;
47266
47258
  } catch {}
47267
- const error62 = typeof raw.error === "string" ? raw.error || "unknown failure" : null;
47259
+ const p = raw.pruned;
47268
47260
  return {
47269
47261
  flushedAt: typeof raw.flushedAt === "string" ? raw.flushedAt : undefined,
47270
- error: error62,
47262
+ error: typeof raw.error === "string" ? raw.error || "unknown failure" : null,
47271
47263
  deferred: typeof raw.deferred === "number" ? raw.deferred : undefined,
47272
- ...counts,
47273
- stuck: error62 && counts.pending > 0 ? error62 : null
47264
+ pruned: p && typeof p.at === "string" && typeof p.count === "number" ? { at: p.at, count: p.count } : undefined
47265
+ };
47266
+ }
47267
+ function writeState(s) {
47268
+ try {
47269
+ fs6.writeFileSync(stateFile(), JSON.stringify({ ...s, pruned: s.pruned ?? readStateFile().pruned }));
47270
+ } catch {}
47271
+ }
47272
+ function readState() {
47273
+ const queue = listQueue(spoolDir());
47274
+ const counts = {
47275
+ pending: queue.files?.length ?? null,
47276
+ rejected: listQueue(rejectedDir()).files?.length ?? null,
47277
+ unregistered: listQueue(unregisteredDir()).files?.length ?? null
47274
47278
  };
47279
+ const state = readStateFile();
47280
+ const stuck = state.error && (counts.pending ?? 0) > 0 ? state.error : null;
47281
+ return { ...state, ...counts, unreadable: queue.code, stuck };
47282
+ }
47283
+ function listQueue(dir) {
47284
+ try {
47285
+ const names = fs6.readdirSync(dir);
47286
+ return {
47287
+ files: names.filter((f) => f.endsWith(".json") && !f.startsWith(".")),
47288
+ temp: names.filter((f) => f.endsWith(".json") && f.startsWith(".")),
47289
+ code: null
47290
+ };
47291
+ } catch (e) {
47292
+ const code = e.code ?? "unknown";
47293
+ return code === "ENOENT" ? { files: [], temp: [], code: null } : { files: null, temp: [], code };
47294
+ }
47275
47295
  }
47276
47296
  function lock() {
47277
47297
  const file2 = path6.join(spoolDir(), ".lock");
@@ -47410,16 +47430,36 @@ var queued = (from) => {
47410
47430
  return [];
47411
47431
  }
47412
47432
  };
47433
+ var CODE_NAMES = { 18: "TOOBIG", 19: "CONSTRAINT", 20: "MISMATCH", 25: "RANGE" };
47434
+ function setAside(from, name, dir, reason2) {
47435
+ fs6.mkdirSync(dir, { recursive: true, mode: 448 });
47436
+ try {
47437
+ fs6.renameSync(path6.join(from, name), path6.join(dir, name));
47438
+ } catch (e) {
47439
+ if (e.code === "ENOENT")
47440
+ return;
47441
+ throw e;
47442
+ }
47443
+ try {
47444
+ fs6.writeFileSync(path6.join(dir, `${name}.reason`), reason2, { mode: 384 });
47445
+ } catch {}
47446
+ }
47413
47447
  async function sendBatch(db, names) {
47414
47448
  const held = unregisteredDir();
47415
47449
  const records = [];
47416
47450
  for (const { name, from } of names) {
47451
+ let raw;
47417
47452
  let r;
47418
47453
  try {
47419
- r = current(JSON.parse(fs6.readFileSync(path6.join(from, name), "utf8")));
47454
+ raw = JSON.parse(fs6.readFileSync(path6.join(from, name), "utf8"));
47455
+ } catch {
47456
+ setAside(from, name, rejectedDir(), "unreadable");
47457
+ continue;
47458
+ }
47459
+ try {
47460
+ r = current(raw);
47420
47461
  } catch {
47421
- fs6.mkdirSync(rejectedDir(), { recursive: true, mode: 448 });
47422
- fs6.renameSync(path6.join(from, name), path6.join(rejectedDir(), name));
47462
+ setAside(from, name, rejectedDir(), typeof raw?.project === "string" ? "version" : "no-project");
47423
47463
  continue;
47424
47464
  }
47425
47465
  if (r)
@@ -47446,21 +47486,12 @@ async function sendBatch(db, names) {
47446
47486
  } catch (e2) {
47447
47487
  if (!rejected(e2))
47448
47488
  throw e2;
47449
- bad.push(x);
47450
- }
47451
- }
47452
- }
47453
- if (bad.length) {
47454
- fs6.mkdirSync(rejectedDir(), { recursive: true, mode: 448 });
47455
- for (const x of bad) {
47456
- try {
47457
- fs6.renameSync(path6.join(x.from, x.name), path6.join(rejectedDir(), x.name));
47458
- } catch (e) {
47459
- if (e.code !== "ENOENT")
47460
- throw e;
47489
+ bad.push({ ...x, why: `sqlite:${CODE_NAMES[sqliteCode(e2) ?? -1] ?? "unknown"}` });
47461
47490
  }
47462
47491
  }
47463
47492
  }
47493
+ for (const x of bad)
47494
+ setAside(x.from, x.name, rejectedDir(), x.why);
47464
47495
  if (strayed.length) {
47465
47496
  fs6.mkdirSync(held, { recursive: true, mode: 448 });
47466
47497
  for (const x of strayed) {
@@ -47480,6 +47511,60 @@ async function sendBatch(db, names) {
47480
47511
  fs6.rmSync(path6.join(x.from, x.name), { force: true });
47481
47512
  return { sent, rejected: bad.length };
47482
47513
  }
47514
+ async function sendObservations(db, names) {
47515
+ const aside = (name, why) => {
47516
+ try {
47517
+ setAside(callsDir(), name, callsRejectedDir(), why);
47518
+ } catch {}
47519
+ };
47520
+ const rows = [];
47521
+ for (const { name, from } of names) {
47522
+ let text2;
47523
+ try {
47524
+ text2 = fs6.readFileSync(path6.join(from, name), "utf8");
47525
+ } catch {
47526
+ continue;
47527
+ }
47528
+ let o = null;
47529
+ let why = "unreadable";
47530
+ try {
47531
+ o = observation(JSON.parse(text2));
47532
+ why = "shape";
47533
+ } catch {}
47534
+ if (o)
47535
+ rows.push({ name, o });
47536
+ else
47537
+ aside(name, why);
47538
+ }
47539
+ if (!rows.length)
47540
+ return true;
47541
+ const insert = (part) => db.insertInto("capture_tool_call").values(part.map((x) => observationRow(x.o))).execute();
47542
+ const bad = new Map;
47543
+ try {
47544
+ await insert(rows);
47545
+ } catch (e) {
47546
+ if (!rejected(e))
47547
+ return false;
47548
+ for (const x of rows)
47549
+ try {
47550
+ await insert([x]);
47551
+ } catch (e2) {
47552
+ if (!rejected(e2))
47553
+ return false;
47554
+ bad.set(x.name, `sqlite:${CODE_NAMES[sqliteCode(e2) ?? -1] ?? "unknown"}`);
47555
+ }
47556
+ }
47557
+ for (const x of rows) {
47558
+ const why = bad.get(x.name);
47559
+ if (why)
47560
+ aside(x.name, why);
47561
+ else
47562
+ try {
47563
+ fs6.rmSync(path6.join(callsDir(), x.name), { force: true });
47564
+ } catch {}
47565
+ }
47566
+ return true;
47567
+ }
47483
47568
  async function flush(file2 = dbFile(), budgetMs = FLUSH_BUDGET_MS) {
47484
47569
  const deadline = Date.now() + budgetMs;
47485
47570
  const dir = spoolDir();
@@ -47487,6 +47572,8 @@ async function flush(file2 = dbFile(), budgetMs = FLUSH_BUDGET_MS) {
47487
47572
  const total = { sent: 0, deferred: 0, rejected: 0 };
47488
47573
  let batches = 0;
47489
47574
  let queueBatches = 0;
47575
+ let removed = 0;
47576
+ const pruned = () => removed ? { at: new Date().toISOString(), count: removed } : undefined;
47490
47577
  for (let first = true;; first = false) {
47491
47578
  let unlock = lock();
47492
47579
  while (!unlock && Date.now() < deadline) {
@@ -47508,12 +47595,20 @@ async function flush(file2 = dbFile(), budgetMs = FLUSH_BUDGET_MS) {
47508
47595
  const sentBefore = batches;
47509
47596
  try {
47510
47597
  if (first) {
47511
- prune(held);
47598
+ prune(held, () => removed++);
47512
47599
  for (const part of chunks(queued(held), BATCH)) {
47513
47600
  if (late2())
47514
47601
  break;
47515
47602
  await send(part);
47516
47603
  }
47604
+ for (const part of chunks(queued(callsDir()), BATCH)) {
47605
+ if (late2())
47606
+ break;
47607
+ client ??= openWriter("capture", file2);
47608
+ if (!await sendObservations(client, part))
47609
+ break;
47610
+ batches++;
47611
+ }
47517
47612
  }
47518
47613
  while (queueBatches === 0 || !late2()) {
47519
47614
  const part = queued(dir).slice(0, BATCH);
@@ -47522,12 +47617,19 @@ async function flush(file2 = dbFile(), budgetMs = FLUSH_BUDGET_MS) {
47522
47617
  await send(part);
47523
47618
  queueBatches++;
47524
47619
  }
47525
- prune(held);
47620
+ prune(held, () => removed++);
47526
47621
  total.deferred = queued(held).length;
47527
47622
  if (batches > sentBefore)
47528
- writeState({ flushedAt: new Date().toISOString(), error: null, deferred: total.deferred });
47623
+ writeState({
47624
+ flushedAt: new Date().toISOString(),
47625
+ error: null,
47626
+ deferred: total.deferred,
47627
+ pruned: pruned()
47628
+ });
47629
+ else if (removed)
47630
+ writeState({ ...readStateFile(), pruned: pruned() });
47529
47631
  } catch (e) {
47530
- writeState({ flushedAt: new Date().toISOString(), error: reason(e).slice(0, 300) });
47632
+ writeState({ flushedAt: new Date().toISOString(), error: reason(e).slice(0, 300), pruned: pruned() });
47531
47633
  throw e;
47532
47634
  } finally {
47533
47635
  await client?.destroy().catch(() => {});
@@ -47546,27 +47648,63 @@ async function readInput(stream) {
47546
47648
  }
47547
47649
  var RECORD_TOOLS = "mcp__plugin_sphica_record__";
47548
47650
  var OBSERVE_WAIT_MS = 5000;
47549
- var hostId = (v) => v && /^[\x21-\x7e]{1,200}$/.test(v) ? v : null;
47550
- async function observeRecordCall(file2, input2, owner) {
47551
- const session = hostId(input2.session_id);
47552
- const toolUse = hostId(input2.tool_use_id);
47553
- const tool = hostId(input2.tool_name);
47554
- if (!session || !toolUse || !tool?.startsWith(RECORD_TOOLS))
47651
+ var hostId = (v) => typeof v === "string" && /^[\x21-\x7e]{1,200}$/.test(v) ? v : null;
47652
+ var OBSERVATION_KEYS = ["at", "host", "owner", "session", "tool", "toolUse", "turn", "v"].join();
47653
+ function observation(raw) {
47654
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
47655
+ return null;
47656
+ const o = raw;
47657
+ if (Object.keys(o).sort().join() !== OBSERVATION_KEYS)
47658
+ return null;
47659
+ if (o.v !== 1 || o.host !== "claude-code" || !hostId(o.session) || !hostId(o.toolUse))
47660
+ return null;
47661
+ if (!hostId(o.tool)?.startsWith(RECORD_TOOLS))
47662
+ return null;
47663
+ if (o.turn !== null && !hostId(o.turn))
47664
+ return null;
47665
+ if (o.owner !== 0 && o.owner !== 1)
47666
+ return null;
47667
+ 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;
47668
+ if (!at || Number.isNaN(Date.parse(at)) || new Date(at).toISOString() !== at)
47669
+ return null;
47670
+ return o;
47671
+ }
47672
+ var observationRow = (o) => ({
47673
+ host: o.host,
47674
+ session_external: o.session,
47675
+ turn_id: o.turn,
47676
+ tool_use_id: o.toolUse,
47677
+ tool_name: o.tool,
47678
+ owner_turn: o.owner,
47679
+ observed_at: o.at
47680
+ });
47681
+ async function observeRecordCall(file2, input2, owner, waitMs = OBSERVE_WAIT_MS) {
47682
+ const o = observation({
47683
+ v: 1,
47684
+ host: "claude-code",
47685
+ session: hostId(input2.session_id),
47686
+ turn: hostId(input2.prompt_id),
47687
+ toolUse: hostId(input2.tool_use_id),
47688
+ tool: hostId(input2.tool_name),
47689
+ owner: owner ? 1 : 0,
47690
+ at: iso(Date.now())
47691
+ });
47692
+ if (!o)
47555
47693
  return;
47556
- const cap = openWriter("capture", file2, OBSERVE_WAIT_MS);
47694
+ let kept = null;
47557
47695
  try {
47558
- await cap.insertInto("capture_tool_call").values({
47559
- host: "claude-code",
47560
- session_external: session,
47561
- turn_id: hostId(input2.prompt_id),
47562
- tool_use_id: toolUse,
47563
- tool_name: tool,
47564
- owner_turn: owner ? 1 : 0,
47565
- observed_at: iso(Date.now())
47566
- }).execute();
47696
+ kept = put(callsDir(), o);
47697
+ } catch {}
47698
+ const cap = openWriter("capture", file2, waitMs);
47699
+ try {
47700
+ await cap.insertInto("capture_tool_call").values(observationRow(o)).execute();
47567
47701
  } finally {
47568
47702
  await cap.destroy().catch(() => {});
47569
47703
  }
47704
+ if (kept)
47705
+ try {
47706
+ fs6.rmSync(kept, { force: true });
47707
+ } catch {}
47570
47708
  }
47571
47709
  async function main() {
47572
47710
  if (process.argv[2] === "--flush") {
@@ -48664,8 +48802,8 @@ async function checkRecord(db, target, raw, facts = prepareRecord(target.root, r
48664
48802
  problems.push(`${key}: commit ${commit.slice(0, 12)} does not hold ${p} in the repository${target.root ? "" : " (no working tree to check)"}; the anchor keeps no commit`);
48665
48803
  commit = undefined;
48666
48804
  }
48667
- const observation = a.role === "evidence" && !commit && target.sessionId ? (await db.selectFrom("edit_observation").select("id").where("session_id", "=", target.sessionId).where("path", "=", p).orderBy("id", "desc").executeTakeFirst())?.id ?? null : null;
48668
- const planned = { ...a, symbol: symbol2, commit, path: p, observation };
48805
+ const observation2 = a.role === "evidence" && !commit && target.sessionId ? (await db.selectFrom("edit_observation").select("id").where("session_id", "=", target.sessionId).where("path", "=", p).orderBy("id", "desc").executeTakeFirst())?.id ?? null : null;
48806
+ const planned = { ...a, symbol: symbol2, commit, path: p, observation: observation2 };
48669
48807
  if (a.symbol && !symbol2)
48670
48808
  fallbacks.add(planned);
48671
48809
  anchors.push(planned);
@@ -48674,7 +48812,7 @@ async function checkRecord(db, target, raw, facts = prepareRecord(target.root, r
48674
48812
  symbol: symbol2,
48675
48813
  role: a.role,
48676
48814
  held: Boolean(commit),
48677
- observed: observation !== null
48815
+ observed: observation2 !== null
48678
48816
  });
48679
48817
  if (wrong)
48680
48818
  problems.push(`${key}: ${wrong}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sphica",
3
- "version": "0.6.32",
3
+ "version": "0.6.34",
4
4
  "description": "Records Claude Code and Codex sessions on your machine and keeps past implementation and decisions, with their sources, for your agent to find.",
5
5
  "license": "MIT",
6
6
  "type": "module",