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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/dist/capture.js +271 -61
- package/dist/cli.js +338 -62
- package/dist/deliver.js +200 -62
- package/dist/mcp-record.js +203 -65
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
12327
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
12604
|
-
|
|
12605
|
-
|
|
12606
|
-
|
|
12607
|
-
|
|
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
|
-
|
|
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") {
|