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/cli.js
CHANGED
|
@@ -16472,6 +16472,8 @@ var spoolDir = () => path8.join(sphicaHome(), "spool");
|
|
|
16472
16472
|
var stateFile = () => path8.join(sphicaHome(), "capture.json");
|
|
16473
16473
|
var rejectedDir = () => path8.join(spoolDir(), "rejected");
|
|
16474
16474
|
var unregisteredDir = () => path8.join(spoolDir(), "unregistered");
|
|
16475
|
+
var callsDir = () => path8.join(spoolDir(), "calls");
|
|
16476
|
+
var callsRejectedDir = () => path8.join(callsDir(), "rejected");
|
|
16475
16477
|
var HOLD_DAYS = 30;
|
|
16476
16478
|
var HOLD_MAX = 1000;
|
|
16477
16479
|
var baselineDir = () => path8.join(sphicaHome(), "worktree");
|
|
@@ -16520,7 +16522,7 @@ ${z}`,
|
|
|
16520
16522
|
originalBytes: all
|
|
16521
16523
|
};
|
|
16522
16524
|
}
|
|
16523
|
-
function prune(held) {
|
|
16525
|
+
function prune(held, removed = () => {}) {
|
|
16524
16526
|
let files;
|
|
16525
16527
|
try {
|
|
16526
16528
|
files = fs8.readdirSync(held).filter((f) => f.endsWith(".json") && !f.startsWith(".")).sort();
|
|
@@ -16530,16 +16532,21 @@ function prune(held) {
|
|
|
16530
16532
|
const cutoff = Date.now() - HOLD_DAYS * 24 * 60 * 60 * 1000;
|
|
16531
16533
|
const stale = files.filter((f) => Number(f.split("-")[0]) < cutoff);
|
|
16532
16534
|
const over = files.slice(0, Math.max(0, files.length - HOLD_MAX));
|
|
16533
|
-
for (const f of new Set([...stale, ...over]))
|
|
16535
|
+
for (const f of new Set([...stale, ...over])) {
|
|
16534
16536
|
fs8.rmSync(path8.join(held, f), { force: true });
|
|
16537
|
+
removed();
|
|
16538
|
+
}
|
|
16535
16539
|
}
|
|
16536
|
-
function
|
|
16537
|
-
const dir = spoolDir();
|
|
16540
|
+
function put(dir, record) {
|
|
16538
16541
|
fs8.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
16539
16542
|
const name = `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 10)}.json`;
|
|
16540
16543
|
const tmp = path8.join(dir, `.${name}`);
|
|
16541
16544
|
fs8.writeFileSync(tmp, JSON.stringify(record), { mode: 384 });
|
|
16542
16545
|
fs8.renameSync(tmp, path8.join(dir, name));
|
|
16546
|
+
return path8.join(dir, name);
|
|
16547
|
+
}
|
|
16548
|
+
function spool(record) {
|
|
16549
|
+
put(spoolDir(), record);
|
|
16543
16550
|
}
|
|
16544
16551
|
var branchOf = (root) => {
|
|
16545
16552
|
try {
|
|
@@ -16594,9 +16601,11 @@ function captureNotice(file = dbFile()) {
|
|
|
16594
16601
|
if (!fs8.existsSync(file))
|
|
16595
16602
|
return panel("sphica: no database, so conversations are not recorded", [file], "Create it with sphica init");
|
|
16596
16603
|
const s = readState();
|
|
16604
|
+
if (s.unreadable)
|
|
16605
|
+
return panel("sphica: cannot read the recording queue", [spoolDir(), s.unreadable], "Check with sphica doctor");
|
|
16597
16606
|
if (s.stuck)
|
|
16598
16607
|
return panel("sphica: cannot send recordings", [`${s.pending} pending / failed: ${plain(s.stuck.slice(0, 120))}`], "Check with sphica doctor");
|
|
16599
|
-
if (s.rejected
|
|
16608
|
+
if (s.rejected)
|
|
16600
16609
|
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");
|
|
16601
16610
|
return null;
|
|
16602
16611
|
}
|
|
@@ -16787,37 +16796,116 @@ function onHook(host, input) {
|
|
|
16787
16796
|
}
|
|
16788
16797
|
return { flush: false };
|
|
16789
16798
|
}
|
|
16799
|
+
function readStateFile() {
|
|
16800
|
+
let raw = {};
|
|
16801
|
+
try {
|
|
16802
|
+
const parsed = JSON.parse(fs8.readFileSync(stateFile(), "utf8"));
|
|
16803
|
+
if (parsed && typeof parsed === "object")
|
|
16804
|
+
raw = parsed;
|
|
16805
|
+
} catch {}
|
|
16806
|
+
const p = raw.pruned;
|
|
16807
|
+
return {
|
|
16808
|
+
flushedAt: typeof raw.flushedAt === "string" ? raw.flushedAt : undefined,
|
|
16809
|
+
error: typeof raw.error === "string" ? raw.error || "unknown failure" : null,
|
|
16810
|
+
deferred: typeof raw.deferred === "number" ? raw.deferred : undefined,
|
|
16811
|
+
pruned: p && typeof p.at === "string" && typeof p.count === "number" ? { at: p.at, count: p.count } : undefined
|
|
16812
|
+
};
|
|
16813
|
+
}
|
|
16790
16814
|
function writeState(s) {
|
|
16791
16815
|
try {
|
|
16792
|
-
fs8.writeFileSync(stateFile(), JSON.stringify(s));
|
|
16816
|
+
fs8.writeFileSync(stateFile(), JSON.stringify({ ...s, pruned: s.pruned ?? readStateFile().pruned }));
|
|
16793
16817
|
} catch {}
|
|
16794
16818
|
}
|
|
16795
16819
|
function readState() {
|
|
16796
|
-
const
|
|
16820
|
+
const queue = listQueue(spoolDir());
|
|
16821
|
+
const counts = {
|
|
16822
|
+
pending: queue.files?.length ?? null,
|
|
16823
|
+
rejected: listQueue(rejectedDir()).files?.length ?? null,
|
|
16824
|
+
unregistered: listQueue(unregisteredDir()).files?.length ?? null
|
|
16825
|
+
};
|
|
16826
|
+
const state = readStateFile();
|
|
16827
|
+
const stuck = state.error && (counts.pending ?? 0) > 0 ? state.error : null;
|
|
16828
|
+
return { ...state, ...counts, unreadable: queue.code, stuck };
|
|
16829
|
+
}
|
|
16830
|
+
function listQueue(dir) {
|
|
16831
|
+
try {
|
|
16832
|
+
const names = fs8.readdirSync(dir);
|
|
16833
|
+
return {
|
|
16834
|
+
files: names.filter((f) => f.endsWith(".json") && !f.startsWith(".")),
|
|
16835
|
+
temp: names.filter((f) => f.endsWith(".json") && f.startsWith(".")),
|
|
16836
|
+
code: null
|
|
16837
|
+
};
|
|
16838
|
+
} catch (e) {
|
|
16839
|
+
const code = e.code ?? "unknown";
|
|
16840
|
+
return code === "ENOENT" ? { files: [], temp: [], code: null } : { files: null, temp: [], code };
|
|
16841
|
+
}
|
|
16842
|
+
}
|
|
16843
|
+
var TEMP_AGE_MS = 60000;
|
|
16844
|
+
var REASON = /^[a-z]+(-[a-z]+)?(:[A-Z]+)?$/;
|
|
16845
|
+
function queueReport(now = Date.now()) {
|
|
16846
|
+
const dirs = {
|
|
16847
|
+
queue: spoolDir(),
|
|
16848
|
+
calls: callsDir(),
|
|
16849
|
+
rejected: rejectedDir(),
|
|
16850
|
+
unregistered: unregisteredDir(),
|
|
16851
|
+
callsRejected: callsRejectedDir()
|
|
16852
|
+
};
|
|
16853
|
+
const listed = Object.fromEntries(Object.entries(dirs).map(([k, d]) => [k, listQueue(d)]));
|
|
16854
|
+
const report2 = (k) => {
|
|
16855
|
+
const temp = { count: 0, oldestMs: 0, bytes: 0 };
|
|
16856
|
+
for (const name of listed[k].temp)
|
|
16857
|
+
try {
|
|
16858
|
+
const st = fs8.statSync(path8.join(dirs[k], name));
|
|
16859
|
+
const age = now - st.mtimeMs;
|
|
16860
|
+
if (age < TEMP_AGE_MS)
|
|
16861
|
+
continue;
|
|
16862
|
+
temp.count++;
|
|
16863
|
+
temp.bytes += st.size;
|
|
16864
|
+
temp.oldestMs = Math.max(temp.oldestMs, age);
|
|
16865
|
+
} catch {}
|
|
16866
|
+
return { files: listed[k].files?.length ?? null, code: listed[k].code, temp };
|
|
16867
|
+
};
|
|
16868
|
+
const reasons = (k) => {
|
|
16869
|
+
const out = {};
|
|
16870
|
+
for (const name of listed[k].files ?? []) {
|
|
16871
|
+
let why = "unknown";
|
|
16872
|
+
try {
|
|
16873
|
+
const text = fs8.readFileSync(path8.join(dirs[k], `${name}.reason`), "utf8");
|
|
16874
|
+
if (REASON.test(text))
|
|
16875
|
+
why = text;
|
|
16876
|
+
} catch {}
|
|
16877
|
+
out[why] = (out[why] ?? 0) + 1;
|
|
16878
|
+
}
|
|
16879
|
+
return out;
|
|
16880
|
+
};
|
|
16881
|
+
const read = (dir, name) => {
|
|
16797
16882
|
try {
|
|
16798
|
-
return fs8.
|
|
16883
|
+
return JSON.parse(fs8.readFileSync(path8.join(dir, name), "utf8"));
|
|
16799
16884
|
} catch {
|
|
16800
|
-
return
|
|
16885
|
+
return null;
|
|
16801
16886
|
}
|
|
16802
16887
|
};
|
|
16803
|
-
const
|
|
16804
|
-
|
|
16805
|
-
|
|
16806
|
-
|
|
16807
|
-
|
|
16808
|
-
|
|
16809
|
-
|
|
16810
|
-
|
|
16811
|
-
|
|
16812
|
-
|
|
16813
|
-
|
|
16814
|
-
|
|
16888
|
+
const held = new Map;
|
|
16889
|
+
for (const name of listed.unregistered.files ?? []) {
|
|
16890
|
+
const key = read(dirs.unregistered, name)?.project;
|
|
16891
|
+
const project = typeof key === "string" ? key : "unreadable";
|
|
16892
|
+
const age = Math.max(0, now - (Number(name.split("-")[0]) || now));
|
|
16893
|
+
const h2 = held.get(project) ?? { count: 0, oldestMs: 0 };
|
|
16894
|
+
held.set(project, { count: h2.count + 1, oldestMs: Math.max(h2.oldestMs, age) });
|
|
16895
|
+
}
|
|
16896
|
+
const waiting = new Set;
|
|
16897
|
+
for (const name of listed.calls.files ?? []) {
|
|
16898
|
+
const o = observation(read(dirs.calls, name));
|
|
16899
|
+
if (o)
|
|
16900
|
+
waiting.add(o.toolUse);
|
|
16901
|
+
}
|
|
16815
16902
|
return {
|
|
16816
|
-
|
|
16817
|
-
|
|
16818
|
-
|
|
16819
|
-
...
|
|
16820
|
-
|
|
16903
|
+
dirs: Object.fromEntries(Object.keys(dirs).map((k) => [k, report2(k)])),
|
|
16904
|
+
rejected: reasons("rejected"),
|
|
16905
|
+
callsRejected: reasons("callsRejected"),
|
|
16906
|
+
held: [...held].map(([project, h2]) => ({ project, ...h2 })).sort((a2, b) => a2.project.localeCompare(b.project)),
|
|
16907
|
+
waiting,
|
|
16908
|
+
pruned: readStateFile().pruned
|
|
16821
16909
|
};
|
|
16822
16910
|
}
|
|
16823
16911
|
function lock() {
|
|
@@ -16956,16 +17044,36 @@ var queued = (from) => {
|
|
|
16956
17044
|
return [];
|
|
16957
17045
|
}
|
|
16958
17046
|
};
|
|
17047
|
+
var CODE_NAMES = { 18: "TOOBIG", 19: "CONSTRAINT", 20: "MISMATCH", 25: "RANGE" };
|
|
17048
|
+
function setAside(from, name, dir, reason2) {
|
|
17049
|
+
fs8.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
17050
|
+
try {
|
|
17051
|
+
fs8.renameSync(path8.join(from, name), path8.join(dir, name));
|
|
17052
|
+
} catch (e) {
|
|
17053
|
+
if (e.code === "ENOENT")
|
|
17054
|
+
return;
|
|
17055
|
+
throw e;
|
|
17056
|
+
}
|
|
17057
|
+
try {
|
|
17058
|
+
fs8.writeFileSync(path8.join(dir, `${name}.reason`), reason2, { mode: 384 });
|
|
17059
|
+
} catch {}
|
|
17060
|
+
}
|
|
16959
17061
|
async function sendBatch(db, names) {
|
|
16960
17062
|
const held = unregisteredDir();
|
|
16961
17063
|
const records = [];
|
|
16962
17064
|
for (const { name, from } of names) {
|
|
17065
|
+
let raw;
|
|
16963
17066
|
let r2;
|
|
16964
17067
|
try {
|
|
16965
|
-
|
|
17068
|
+
raw = JSON.parse(fs8.readFileSync(path8.join(from, name), "utf8"));
|
|
17069
|
+
} catch {
|
|
17070
|
+
setAside(from, name, rejectedDir(), "unreadable");
|
|
17071
|
+
continue;
|
|
17072
|
+
}
|
|
17073
|
+
try {
|
|
17074
|
+
r2 = current(raw);
|
|
16966
17075
|
} catch {
|
|
16967
|
-
|
|
16968
|
-
fs8.renameSync(path8.join(from, name), path8.join(rejectedDir(), name));
|
|
17076
|
+
setAside(from, name, rejectedDir(), typeof raw?.project === "string" ? "version" : "no-project");
|
|
16969
17077
|
continue;
|
|
16970
17078
|
}
|
|
16971
17079
|
if (r2)
|
|
@@ -16992,21 +17100,12 @@ async function sendBatch(db, names) {
|
|
|
16992
17100
|
} catch (e2) {
|
|
16993
17101
|
if (!rejected(e2))
|
|
16994
17102
|
throw e2;
|
|
16995
|
-
bad.push(x);
|
|
16996
|
-
}
|
|
16997
|
-
}
|
|
16998
|
-
}
|
|
16999
|
-
if (bad.length) {
|
|
17000
|
-
fs8.mkdirSync(rejectedDir(), { recursive: true, mode: 448 });
|
|
17001
|
-
for (const x of bad) {
|
|
17002
|
-
try {
|
|
17003
|
-
fs8.renameSync(path8.join(x.from, x.name), path8.join(rejectedDir(), x.name));
|
|
17004
|
-
} catch (e) {
|
|
17005
|
-
if (e.code !== "ENOENT")
|
|
17006
|
-
throw e;
|
|
17103
|
+
bad.push({ ...x, why: `sqlite:${CODE_NAMES[sqliteCode(e2) ?? -1] ?? "unknown"}` });
|
|
17007
17104
|
}
|
|
17008
17105
|
}
|
|
17009
17106
|
}
|
|
17107
|
+
for (const x of bad)
|
|
17108
|
+
setAside(x.from, x.name, rejectedDir(), x.why);
|
|
17010
17109
|
if (strayed.length) {
|
|
17011
17110
|
fs8.mkdirSync(held, { recursive: true, mode: 448 });
|
|
17012
17111
|
for (const x of strayed) {
|
|
@@ -17026,6 +17125,60 @@ async function sendBatch(db, names) {
|
|
|
17026
17125
|
fs8.rmSync(path8.join(x.from, x.name), { force: true });
|
|
17027
17126
|
return { sent, rejected: bad.length };
|
|
17028
17127
|
}
|
|
17128
|
+
async function sendObservations(db, names) {
|
|
17129
|
+
const aside = (name, why) => {
|
|
17130
|
+
try {
|
|
17131
|
+
setAside(callsDir(), name, callsRejectedDir(), why);
|
|
17132
|
+
} catch {}
|
|
17133
|
+
};
|
|
17134
|
+
const rows = [];
|
|
17135
|
+
for (const { name, from } of names) {
|
|
17136
|
+
let text;
|
|
17137
|
+
try {
|
|
17138
|
+
text = fs8.readFileSync(path8.join(from, name), "utf8");
|
|
17139
|
+
} catch {
|
|
17140
|
+
continue;
|
|
17141
|
+
}
|
|
17142
|
+
let o = null;
|
|
17143
|
+
let why = "unreadable";
|
|
17144
|
+
try {
|
|
17145
|
+
o = observation(JSON.parse(text));
|
|
17146
|
+
why = "shape";
|
|
17147
|
+
} catch {}
|
|
17148
|
+
if (o)
|
|
17149
|
+
rows.push({ name, o });
|
|
17150
|
+
else
|
|
17151
|
+
aside(name, why);
|
|
17152
|
+
}
|
|
17153
|
+
if (!rows.length)
|
|
17154
|
+
return true;
|
|
17155
|
+
const insert = (part) => db.insertInto("capture_tool_call").values(part.map((x) => observationRow(x.o))).execute();
|
|
17156
|
+
const bad = new Map;
|
|
17157
|
+
try {
|
|
17158
|
+
await insert(rows);
|
|
17159
|
+
} catch (e) {
|
|
17160
|
+
if (!rejected(e))
|
|
17161
|
+
return false;
|
|
17162
|
+
for (const x of rows)
|
|
17163
|
+
try {
|
|
17164
|
+
await insert([x]);
|
|
17165
|
+
} catch (e2) {
|
|
17166
|
+
if (!rejected(e2))
|
|
17167
|
+
return false;
|
|
17168
|
+
bad.set(x.name, `sqlite:${CODE_NAMES[sqliteCode(e2) ?? -1] ?? "unknown"}`);
|
|
17169
|
+
}
|
|
17170
|
+
}
|
|
17171
|
+
for (const x of rows) {
|
|
17172
|
+
const why = bad.get(x.name);
|
|
17173
|
+
if (why)
|
|
17174
|
+
aside(x.name, why);
|
|
17175
|
+
else
|
|
17176
|
+
try {
|
|
17177
|
+
fs8.rmSync(path8.join(callsDir(), x.name), { force: true });
|
|
17178
|
+
} catch {}
|
|
17179
|
+
}
|
|
17180
|
+
return true;
|
|
17181
|
+
}
|
|
17029
17182
|
async function flush(file = dbFile(), budgetMs = FLUSH_BUDGET_MS) {
|
|
17030
17183
|
const deadline = Date.now() + budgetMs;
|
|
17031
17184
|
const dir = spoolDir();
|
|
@@ -17033,6 +17186,8 @@ async function flush(file = dbFile(), budgetMs = FLUSH_BUDGET_MS) {
|
|
|
17033
17186
|
const total = { sent: 0, deferred: 0, rejected: 0 };
|
|
17034
17187
|
let batches = 0;
|
|
17035
17188
|
let queueBatches = 0;
|
|
17189
|
+
let removed = 0;
|
|
17190
|
+
const pruned = () => removed ? { at: new Date().toISOString(), count: removed } : undefined;
|
|
17036
17191
|
for (let first = true;; first = false) {
|
|
17037
17192
|
let unlock = lock();
|
|
17038
17193
|
while (!unlock && Date.now() < deadline) {
|
|
@@ -17054,12 +17209,20 @@ async function flush(file = dbFile(), budgetMs = FLUSH_BUDGET_MS) {
|
|
|
17054
17209
|
const sentBefore = batches;
|
|
17055
17210
|
try {
|
|
17056
17211
|
if (first) {
|
|
17057
|
-
prune(held);
|
|
17212
|
+
prune(held, () => removed++);
|
|
17058
17213
|
for (const part of chunks(queued(held), BATCH)) {
|
|
17059
17214
|
if (late())
|
|
17060
17215
|
break;
|
|
17061
17216
|
await send(part);
|
|
17062
17217
|
}
|
|
17218
|
+
for (const part of chunks(queued(callsDir()), BATCH)) {
|
|
17219
|
+
if (late())
|
|
17220
|
+
break;
|
|
17221
|
+
client ??= openWriter("capture", file);
|
|
17222
|
+
if (!await sendObservations(client, part))
|
|
17223
|
+
break;
|
|
17224
|
+
batches++;
|
|
17225
|
+
}
|
|
17063
17226
|
}
|
|
17064
17227
|
while (queueBatches === 0 || !late()) {
|
|
17065
17228
|
const part = queued(dir).slice(0, BATCH);
|
|
@@ -17068,12 +17231,19 @@ async function flush(file = dbFile(), budgetMs = FLUSH_BUDGET_MS) {
|
|
|
17068
17231
|
await send(part);
|
|
17069
17232
|
queueBatches++;
|
|
17070
17233
|
}
|
|
17071
|
-
prune(held);
|
|
17234
|
+
prune(held, () => removed++);
|
|
17072
17235
|
total.deferred = queued(held).length;
|
|
17073
17236
|
if (batches > sentBefore)
|
|
17074
|
-
writeState({
|
|
17237
|
+
writeState({
|
|
17238
|
+
flushedAt: new Date().toISOString(),
|
|
17239
|
+
error: null,
|
|
17240
|
+
deferred: total.deferred,
|
|
17241
|
+
pruned: pruned()
|
|
17242
|
+
});
|
|
17243
|
+
else if (removed)
|
|
17244
|
+
writeState({ ...readStateFile(), pruned: pruned() });
|
|
17075
17245
|
} catch (e) {
|
|
17076
|
-
writeState({ flushedAt: new Date().toISOString(), error: reason(e).slice(0, 300) });
|
|
17246
|
+
writeState({ flushedAt: new Date().toISOString(), error: reason(e).slice(0, 300), pruned: pruned() });
|
|
17077
17247
|
throw e;
|
|
17078
17248
|
} finally {
|
|
17079
17249
|
await client?.destroy().catch(() => {});
|
|
@@ -17092,27 +17262,63 @@ async function readInput(stream) {
|
|
|
17092
17262
|
}
|
|
17093
17263
|
var RECORD_TOOLS = "mcp__plugin_sphica_record__";
|
|
17094
17264
|
var OBSERVE_WAIT_MS = 5000;
|
|
17095
|
-
var hostId = (v) => v && /^[\x21-\x7e]{1,200}$/.test(v) ? v : null;
|
|
17096
|
-
|
|
17097
|
-
|
|
17098
|
-
|
|
17099
|
-
|
|
17100
|
-
|
|
17265
|
+
var hostId = (v) => typeof v === "string" && /^[\x21-\x7e]{1,200}$/.test(v) ? v : null;
|
|
17266
|
+
var OBSERVATION_KEYS = ["at", "host", "owner", "session", "tool", "toolUse", "turn", "v"].join();
|
|
17267
|
+
function observation(raw) {
|
|
17268
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
17269
|
+
return null;
|
|
17270
|
+
const o = raw;
|
|
17271
|
+
if (Object.keys(o).sort().join() !== OBSERVATION_KEYS)
|
|
17272
|
+
return null;
|
|
17273
|
+
if (o.v !== 1 || o.host !== "claude-code" || !hostId(o.session) || !hostId(o.toolUse))
|
|
17274
|
+
return null;
|
|
17275
|
+
if (!hostId(o.tool)?.startsWith(RECORD_TOOLS))
|
|
17276
|
+
return null;
|
|
17277
|
+
if (o.turn !== null && !hostId(o.turn))
|
|
17278
|
+
return null;
|
|
17279
|
+
if (o.owner !== 0 && o.owner !== 1)
|
|
17280
|
+
return null;
|
|
17281
|
+
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;
|
|
17282
|
+
if (!at || Number.isNaN(Date.parse(at)) || new Date(at).toISOString() !== at)
|
|
17283
|
+
return null;
|
|
17284
|
+
return o;
|
|
17285
|
+
}
|
|
17286
|
+
var observationRow = (o) => ({
|
|
17287
|
+
host: o.host,
|
|
17288
|
+
session_external: o.session,
|
|
17289
|
+
turn_id: o.turn,
|
|
17290
|
+
tool_use_id: o.toolUse,
|
|
17291
|
+
tool_name: o.tool,
|
|
17292
|
+
owner_turn: o.owner,
|
|
17293
|
+
observed_at: o.at
|
|
17294
|
+
});
|
|
17295
|
+
async function observeRecordCall(file, input, owner, waitMs = OBSERVE_WAIT_MS) {
|
|
17296
|
+
const o = observation({
|
|
17297
|
+
v: 1,
|
|
17298
|
+
host: "claude-code",
|
|
17299
|
+
session: hostId(input.session_id),
|
|
17300
|
+
turn: hostId(input.prompt_id),
|
|
17301
|
+
toolUse: hostId(input.tool_use_id),
|
|
17302
|
+
tool: hostId(input.tool_name),
|
|
17303
|
+
owner: owner ? 1 : 0,
|
|
17304
|
+
at: iso(Date.now())
|
|
17305
|
+
});
|
|
17306
|
+
if (!o)
|
|
17101
17307
|
return;
|
|
17102
|
-
|
|
17308
|
+
let kept = null;
|
|
17309
|
+
try {
|
|
17310
|
+
kept = put(callsDir(), o);
|
|
17311
|
+
} catch {}
|
|
17312
|
+
const cap = openWriter("capture", file, waitMs);
|
|
17103
17313
|
try {
|
|
17104
|
-
await cap.insertInto("capture_tool_call").values(
|
|
17105
|
-
host: "claude-code",
|
|
17106
|
-
session_external: session,
|
|
17107
|
-
turn_id: hostId(input.prompt_id),
|
|
17108
|
-
tool_use_id: toolUse,
|
|
17109
|
-
tool_name: tool,
|
|
17110
|
-
owner_turn: owner ? 1 : 0,
|
|
17111
|
-
observed_at: iso(Date.now())
|
|
17112
|
-
}).execute();
|
|
17314
|
+
await cap.insertInto("capture_tool_call").values(observationRow(o)).execute();
|
|
17113
17315
|
} finally {
|
|
17114
17316
|
await cap.destroy().catch(() => {});
|
|
17115
17317
|
}
|
|
17318
|
+
if (kept)
|
|
17319
|
+
try {
|
|
17320
|
+
fs8.rmSync(kept, { force: true });
|
|
17321
|
+
} catch {}
|
|
17116
17322
|
}
|
|
17117
17323
|
async function main() {
|
|
17118
17324
|
if (process.argv[2] === "--flush") {
|
|
@@ -18433,6 +18639,46 @@ Run --help for usage`),
|
|
|
18433
18639
|
exceptionWhileRunningCommand: (e) => failed(reason(e)),
|
|
18434
18640
|
commandErrorResult: (e) => failed(e.message)
|
|
18435
18641
|
};
|
|
18642
|
+
async function adoptionStops(db, waiting, say2) {
|
|
18643
|
+
const calls = await db.selectFrom("record_call as c").innerJoin("project as p", "p.id", "c.project_id").select(["p.id", "p.name", "p.key", "c.host", "c.tool_use_id", "c.called_at"]).where((eb) => eb.or([
|
|
18644
|
+
eb("c.host", "is", null),
|
|
18645
|
+
eb.and([eb("c.host", "=", "codex"), eb("c.caller_session", "is", null)]),
|
|
18646
|
+
eb.and([
|
|
18647
|
+
eb("c.host", "=", "claude-code"),
|
|
18648
|
+
eb.not(eb.exists(eb.selectFrom("tool_call_observation as o").select("o.id").where("o.host", "=", "claude-code").whereRef("o.tool_use_id", "=", "c.tool_use_id")))
|
|
18649
|
+
])
|
|
18650
|
+
])).orderBy("p.name").orderBy("c.called_at").execute();
|
|
18651
|
+
const turnless = await db.selectFrom("tool_call_observation as o").innerJoin("session as se", (j) => j.on("se.host", "=", "claude-code").onRef("se.external_id", "=", "o.session_external")).innerJoin("project as p", "p.id", "se.project_id").select((eb) => [
|
|
18652
|
+
"p.id",
|
|
18653
|
+
"p.name",
|
|
18654
|
+
"p.key",
|
|
18655
|
+
eb.fn.count("se.id").distinct().as("sessions"),
|
|
18656
|
+
eb.fn.min("o.observed_at").as("first")
|
|
18657
|
+
]).where("o.host", "=", "claude-code").where("o.turn_id", "is", null).groupBy(["p.id", "p.name", "p.key"]).orderBy("p.name").execute();
|
|
18658
|
+
const groups = new Map;
|
|
18659
|
+
for (const c of calls) {
|
|
18660
|
+
const k = `${c.id}\x00${c.host ?? ""}`;
|
|
18661
|
+
groups.set(k, [...groups.get(k) ?? [], c]);
|
|
18662
|
+
}
|
|
18663
|
+
const keys = new Map;
|
|
18664
|
+
for (const c of [...calls, ...turnless])
|
|
18665
|
+
keys.set(c.name, (keys.get(c.name) ?? new Set).add(c.key));
|
|
18666
|
+
const named = (p) => inline((keys.get(p.name)?.size ?? 0) > 1 ? `${p.name} (${p.key})` : p.name);
|
|
18667
|
+
for (const group2 of groups.values()) {
|
|
18668
|
+
const [first] = group2;
|
|
18669
|
+
if (!first)
|
|
18670
|
+
continue;
|
|
18671
|
+
const from = new Date(first.called_at).toLocaleString("sv-SE");
|
|
18672
|
+
const n2 = group2.length;
|
|
18673
|
+
const text = first.host === "claude-code" ? (() => {
|
|
18674
|
+
const resend = group2.filter((c) => c.tool_use_id && waiting.has(c.tool_use_id)).length;
|
|
18675
|
+
return `Claude Code replies from ${from} on are not adopted as AI decisions: ${plural(n2, "record tool call")} without a hook observation (${resend} waiting to be resent, ${n2 - resend} that cannot be)`;
|
|
18676
|
+
})() : first.host === "codex" ? `Codex replies from ${from} on are not adopted as AI decisions: ${plural(n2, "record tool call")} that named no session` : `replies of every host from ${from} on are not adopted as AI decisions: ${plural(n2, "record tool call")} that named no host`;
|
|
18677
|
+
say2("warn", "AI adoption", `${named(first)}: ${text}`);
|
|
18678
|
+
}
|
|
18679
|
+
for (const t2 of turnless)
|
|
18680
|
+
say2("warn", "AI adoption", `${named(t2)}: Claude Code replies in ${plural(Number(t2.sessions), "session")} from ${new Date(t2.first ?? "").toLocaleString("sv-SE")} on are not adopted as AI decisions: a record tool's hook did not know the turn`);
|
|
18681
|
+
}
|
|
18436
18682
|
async function doctor(cwd) {
|
|
18437
18683
|
const issues = [];
|
|
18438
18684
|
const count = (m, label) => {
|
|
@@ -18484,7 +18730,36 @@ async function doctor(cwd) {
|
|
|
18484
18730
|
}
|
|
18485
18731
|
}
|
|
18486
18732
|
const s = readState();
|
|
18487
|
-
|
|
18733
|
+
if (s.unreadable)
|
|
18734
|
+
say2("fail", "Recording", `the queue cannot be read (${inline(s.unreadable)}) at ${inline(spoolDir())}`);
|
|
18735
|
+
else
|
|
18736
|
+
say2(s.stuck ? "fail" : "ok", "Recording", `${s.pending} pending${s.flushedAt ? ` / last sent ${new Date(s.flushedAt).toLocaleString("sv-SE")}` : ""}${s.stuck ? ` / failed: ${plain(s.stuck)} (sent again after the next turn)` : ""}`);
|
|
18737
|
+
const q = queueReport();
|
|
18738
|
+
const dirOf = {
|
|
18739
|
+
queue: spoolDir(),
|
|
18740
|
+
calls: callsDir(),
|
|
18741
|
+
rejected: rejectedDir(),
|
|
18742
|
+
unregistered: unregisteredDir(),
|
|
18743
|
+
callsRejected: callsRejectedDir()
|
|
18744
|
+
};
|
|
18745
|
+
const minutes = (ms) => ms < 2 * 3600000 ? `${Math.round(ms / 60000)} min` : ms < 2 * 86400000 ? `${Math.round(ms / 3600000)} h` : `${Math.round(ms / 86400000)} days`;
|
|
18746
|
+
const reasons = (r2) => Object.entries(r2).sort(([a2], [b]) => a2.localeCompare(b)).map(([why, n2]) => `${inline(why)} ${n2}`).join(", ");
|
|
18747
|
+
for (const [k, d] of Object.entries(q.dirs)) {
|
|
18748
|
+
if (d.code && k !== "queue")
|
|
18749
|
+
say2("fail", "Queue directory", `${inline(dirOf[k])} cannot be read (${inline(d.code)})`);
|
|
18750
|
+
if (d.temp.count)
|
|
18751
|
+
say2("warn", "Left-behind files", `${plural(d.temp.count, "temporary file")} older than a minute in ${inline(dirOf[k])} (oldest ${minutes(d.temp.oldestMs)}, ${d.temp.bytes} bytes): written but never put in the queue`);
|
|
18752
|
+
}
|
|
18753
|
+
if (q.dirs.rejected.files)
|
|
18754
|
+
say2("warn", "Rejected records", `${plural(q.dirs.rejected.files, "record")} in ${inline(rejectedDir())}: ${reasons(q.rejected)}. Fix them, then move them back to ${inline(spoolDir())} to resend`);
|
|
18755
|
+
if (q.held.length)
|
|
18756
|
+
say2("none", "Held recordings", `for projects not registered, in ${inline(unregisteredDir())}: ${q.held.map((h2) => `${inline(h2.project)} ${h2.count} (oldest ${minutes(h2.oldestMs)})`).join(" / ")}. Register a project with sphica init to send its recordings`);
|
|
18757
|
+
if (q.pruned)
|
|
18758
|
+
say2("none", "Last prune", `${plural(q.pruned.count, "held recording")} expired at ${new Date(q.pruned.at).toLocaleString("sv-SE")}`);
|
|
18759
|
+
if (q.dirs.calls.files)
|
|
18760
|
+
say2("none", "Observations to resend", `${plural(q.dirs.calls.files, "record tool observation")} ${q.dirs.calls.files === 1 ? "waits" : "wait"} in ${inline(callsDir())} for the next send`);
|
|
18761
|
+
if (q.dirs.callsRejected.files)
|
|
18762
|
+
say2("warn", "Observations not sent", `${plural(q.dirs.callsRejected.files, "file")} in ${inline(callsRejectedDir())}: ${reasons(q.callsRejected)}`);
|
|
18488
18763
|
if (usable2) {
|
|
18489
18764
|
try {
|
|
18490
18765
|
await withDb("reader", async (db) => {
|
|
@@ -18493,6 +18768,7 @@ async function doctor(cwd) {
|
|
|
18493
18768
|
const twice = await db.selectFrom("unit").select("project_id").where("lifecycle", "in", ["active", "candidate"]).groupBy(["project_id", "content_hash"]).having((eb) => eb.fn.countAll(), ">", 1).execute();
|
|
18494
18769
|
if (twice.length)
|
|
18495
18770
|
say2("warn", "Records", `${plural(twice.length, "set")} of live records hold the same words. Read them with Sphica's read and withdraw the extra ones with /sphica:glean`);
|
|
18771
|
+
await adoptionStops(db, q.waiting, say2);
|
|
18496
18772
|
const { found, ambiguous } = localRoots();
|
|
18497
18773
|
const homes = [os4.homedir()];
|
|
18498
18774
|
try {
|