sphica 0.4.0 → 0.5.0

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/capture.js CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  // server/src/capture.ts
4
4
  import { spawn } from "node:child_process";
5
- import fs3 from "node:fs";
5
+ import fs4 from "node:fs";
6
6
  import os3 from "node:os";
7
- import path3 from "node:path";
7
+ import path5 from "node:path";
8
8
 
9
9
  // server/node_modules/kysely/dist/util/object-utils.js
10
10
  function isUndefined(obj) {
@@ -10813,7 +10813,8 @@ import fs from "node:fs";
10813
10813
  import os from "node:os";
10814
10814
  import path from "node:path";
10815
10815
  import { constants as C, DatabaseSync } from "node:sqlite";
10816
- var SCHEMA_REVISION = 7;
10816
+ var SCHEMA_GENERATION = 2;
10817
+ var SCHEMA_REVISION = 1;
10817
10818
  var dbFile = () => process.env.SPHICA_DB || path.join(os.homedir(), ".sphica", "sphica.db");
10818
10819
  function requireRuntime() {
10819
10820
  const proto = DatabaseSync.prototype;
@@ -10824,18 +10825,35 @@ function requireFile(file) {
10824
10825
  if (!fs.existsSync(file))
10825
10826
  throw new Error(`No database at ${file}. Create it with \`sphica init\`.`);
10826
10827
  }
10827
- function prepare(raw, checkVersion) {
10828
+ function prepare(raw, check) {
10828
10829
  raw.enableDefensive(true);
10829
10830
  raw.exec("pragma foreign_keys = on");
10830
10831
  raw.exec("pragma busy_timeout = 5000");
10831
- if (!checkVersion)
10832
+ if (check === "none")
10833
+ return;
10834
+ checkGeneration(raw);
10835
+ if (check === "generation")
10832
10836
  return;
10833
10837
  const got = raw.prepare("pragma user_version").get()?.user_version;
10834
10838
  if (got === SCHEMA_REVISION)
10835
10839
  return;
10836
- if (!got)
10840
+ throw new Error(`The database schema is revision ${got}, but this Sphica expects revision ${SCHEMA_REVISION}. ` + ((got ?? 0) < SCHEMA_REVISION ? "Move it aside (it is left unchanged), then run `sphica init`." : "Update sphica."));
10841
+ }
10842
+ function generationOf(raw) {
10843
+ const has = raw.prepare("select 1 from sqlite_schema where type = 'table' and name = 'sphica_generation'").get();
10844
+ if (!has) {
10845
+ const any = raw.prepare("select 1 from sqlite_schema where type = 'table' and name = 'project'").get();
10846
+ return any ? 1 : null;
10847
+ }
10848
+ return raw.prepare("select generation from sphica_generation").get()?.generation ?? null;
10849
+ }
10850
+ function checkGeneration(raw) {
10851
+ const got = generationOf(raw);
10852
+ if (got === SCHEMA_GENERATION)
10853
+ return;
10854
+ if (got === null)
10837
10855
  throw new Error("The database has no sphica schema. Create it with `sphica init`.");
10838
- throw new Error(`The database schema is revision ${got}, but this Sphica expects revision ${SCHEMA_REVISION}. ` + (got < SCHEMA_REVISION ? "Run `sphica db migrate`." : "Update sphica."));
10856
+ throw new Error(got < SCHEMA_GENERATION ? "The database was made by Sphica 0.4 or earlier, and this Sphica cannot read it. Move it aside (it is left unchanged), then run `sphica init`." : "The database was made by a newer Sphica. Update sphica.");
10839
10857
  }
10840
10858
  var READER_FUNCTIONS = new Set([
10841
10859
  "bm25",
@@ -10853,10 +10871,10 @@ var READER_FUNCTIONS = new Set([
10853
10871
  "min",
10854
10872
  "substr"
10855
10873
  ]);
10856
- var SHADOW = /^(knowledge|message)_fts_(data|idx|docsize|config)$/;
10874
+ var SHADOW = /^(unit|source)_fts_(data|idx|docsize|config)$/;
10857
10875
 
10858
10876
  // server/src/db.ts
10859
- var JSON_COLUMNS = new Set(["refs", "downsides", "next", "files", "paths"]);
10877
+ var JSON_COLUMNS = new Set(["next"]);
10860
10878
  var TOP_LEVEL = /^\$\[\d+\]\."([^"]+)"$/;
10861
10879
  var parseJson = new ParseJSONResultsPlugin({
10862
10880
  shouldParse: (_value, jsonPath) => JSON_COLUMNS.has(jsonPath.match(TOP_LEVEL)?.[1] ?? "")
@@ -10901,13 +10919,27 @@ var HIRAGANA_ONLY = /^[\p{Script=Hiragana}ー]+$/u;
10901
10919
  var STOP = new Set(["the", "a", "an", "of", "to", "in", "is", "and", "or", "for", "on", "it", "be"]);
10902
10920
  var IDENT = /#\d+|[a-z0-9][a-z0-9_./#-]*[a-z0-9]/g;
10903
10921
  var MAX_TERM = 100;
10922
+ var OKURIGANA = /^(\p{Script=Han}+)[\p{Script=Hiragana}ー]+$/u;
10923
+ function singular(w) {
10924
+ if (!/^[a-z]{4,}$/.test(w))
10925
+ return w;
10926
+ if (w.endsWith("ies"))
10927
+ return `${w.slice(0, -3)}y`;
10928
+ if (w.endsWith("ie"))
10929
+ return `${w.slice(0, -2)}y`;
10930
+ if (/(?:ss|us|x|z|ch|sh)es$/.test(w))
10931
+ return w.slice(0, -2);
10932
+ if (/(?:ch|sh|us)e$/.test(w))
10933
+ return w.slice(0, -1);
10934
+ return w.endsWith("s") && !/(?:ss|us|is)$/.test(w) ? w.slice(0, -1) : w;
10935
+ }
10904
10936
  function terms(text) {
10905
10937
  const norm = text.normalize("NFKC").toLowerCase();
10906
10938
  const out = [];
10907
10939
  const keep = (w) => {
10908
10940
  if (w.length > MAX_TERM || STOP.has(w) || HIRAGANA_ONLY.test(w))
10909
10941
  return;
10910
- out.push(w);
10942
+ out.push(OKURIGANA.exec(w)?.[1] ?? singular(w));
10911
10943
  };
10912
10944
  for (const s of segmenter.segment(norm))
10913
10945
  if (s.isWordLike)
@@ -10917,6 +10949,48 @@ function terms(text) {
10917
10949
  keep(m[0]);
10918
10950
  return out.filter(Boolean);
10919
10951
  }
10952
+ var QUESTION = new Set([
10953
+ ...[
10954
+ "why",
10955
+ "what",
10956
+ "which",
10957
+ "when",
10958
+ "where",
10959
+ "who",
10960
+ "how",
10961
+ "do",
10962
+ "does",
10963
+ "did",
10964
+ "we",
10965
+ "our",
10966
+ "us",
10967
+ "you",
10968
+ "i",
10969
+ "are",
10970
+ "was",
10971
+ "were"
10972
+ ],
10973
+ ...[
10974
+ "will",
10975
+ "would",
10976
+ "should",
10977
+ "can",
10978
+ "could",
10979
+ "there",
10980
+ "this",
10981
+ "that",
10982
+ "with",
10983
+ "from",
10984
+ "any",
10985
+ "ever",
10986
+ "long",
10987
+ "use",
10988
+ "used",
10989
+ "using"
10990
+ ],
10991
+ ...["add", "get", "make", "have", "has", "not", "no", "yes", "reason"],
10992
+ ...["理由", "仕組", "何", "方", "場合", "今", "件"]
10993
+ ]);
10920
10994
  var sha256 = (s) => crypto.createHash("sha256").update(s).digest();
10921
10995
  function uuidFrom(...parts) {
10922
10996
  const b = crypto.createHash("sha256").update(parts.join("\x00")).digest().subarray(0, 16);
@@ -11110,16 +11184,21 @@ var DDL = () => ddl ??= new Set([
11110
11184
  return code;
11111
11185
  }));
11112
11186
  var readsDataVersion = (p1, p2) => p1 === "data_version" && p2 === null;
11113
- var CAPTURE_VIEWS = new Set(["capture_conversation", "capture_message", "capture_message_file"]);
11187
+ var CAPTURE_VIEWS = new Set(["capture_session", "capture_message", "capture_edit", "capture_delivery"]);
11114
11188
  var TRIGGER_WRITES = {
11115
- capture_conversation_insert: new Set(["conversation"]),
11116
- capture_message_insert: new Set(["message"]),
11117
- capture_message_file_insert: new Set(["message_file"]),
11118
- message_fts_ai: new Set(["message_fts"])
11189
+ capture_session_insert: new Set(["session"]),
11190
+ capture_message_insert: new Set(["source"]),
11191
+ capture_edit_insert: new Set(["edit_observation"]),
11192
+ capture_delivery_insert: new Set(["delivery", "delivery_unit"]),
11193
+ source_fts_ai: new Set(["source_fts"])
11194
+ };
11195
+ var TRIGGER_FUNCTIONS = {
11196
+ capture_delivery_insert: new Set(["coalesce", "json_each", "last_insert_rowid"])
11119
11197
  };
11120
11198
  var CAPTURE_READS = {
11121
11199
  project: new Set(["id", "key", "name"]),
11122
- message: new Set(["id"])
11200
+ session: new Set(["id"]),
11201
+ source: new Set(["id", "session_id", "external_id", "kind"])
11123
11202
  };
11124
11203
  function captureAuthorizer(own, action, p1, p2, triggerOrView) {
11125
11204
  const table = p1 ?? "";
@@ -11139,7 +11218,7 @@ function captureAuthorizer(own, action, p1, p2, triggerOrView) {
11139
11218
  return CAPTURE_READS[table]?.has(p2 ?? "") ? C2.SQLITE_OK : C2.SQLITE_DENY;
11140
11219
  }
11141
11220
  if (action === C2.SQLITE_FUNCTION)
11142
- return p2 === "sphica_terms" ? C2.SQLITE_OK : C2.SQLITE_DENY;
11221
+ return p2 === "sphica_terms" || triggerOrView !== null && TRIGGER_FUNCTIONS[triggerOrView]?.has(p2 ?? "") ? C2.SQLITE_OK : C2.SQLITE_DENY;
11143
11222
  if (action === C2.SQLITE_PRAGMA)
11144
11223
  return readsDataVersion(p1, p2) ? C2.SQLITE_OK : C2.SQLITE_DENY;
11145
11224
  if (action === C2.SQLITE_SELECT || action === C2.SQLITE_TRANSACTION || action === C2.SQLITE_SAVEPOINT)
@@ -11159,7 +11238,7 @@ function connectWriter(role, file = dbFile(), create = false) {
11159
11238
  requireFile(file);
11160
11239
  const raw = new DatabaseSync2(file);
11161
11240
  try {
11162
- prepare(raw, role === "ingest");
11241
+ prepare(raw, create ? "none" : role === "ingest" ? "revision" : "generation");
11163
11242
  raw.function("sphica_terms", { deterministic: true }, (text) => terms(String(text ?? "")).join(" "));
11164
11243
  } catch (e) {
11165
11244
  raw.close();
@@ -11190,8 +11269,7 @@ function openWriter(role, file = dbFile()) {
11190
11269
  }
11191
11270
 
11192
11271
  // server/src/knowledge.ts
11193
- var indexesMessage = (speakerKind) => speakerKind === "self";
11194
- var conversationId = (projectId, origin, externalId) => uuidFrom(String(projectId), origin, externalId);
11272
+ var sessionId = (projectId, host, externalId) => uuidFrom(String(projectId), host, externalId);
11195
11273
 
11196
11274
  // server/src/panel.ts
11197
11275
  import { stripVTControlCharacters, styleText } from "node:util";
@@ -11210,8 +11288,15 @@ var plain2 = (s) => visible(stripVTControlCharacters(s.replace(STRING_SEQUENCE,
11210
11288
  import { execFileSync } from "node:child_process";
11211
11289
  import fs2 from "node:fs";
11212
11290
  import os2 from "node:os";
11291
+ import path3 from "node:path";
11292
+
11293
+ // server/src/anchors.ts
11213
11294
  import path2 from "node:path";
11214
- var localFile = () => path2.join(os2.homedir(), ".sphica", "projects.json");
11295
+ var MAX_BYTES = 2 * 1024 * 1024;
11296
+ var leaves = (rel) => rel === ".." || rel.startsWith(`..${path2.sep}`) || path2.isAbsolute(rel);
11297
+
11298
+ // server/src/project.ts
11299
+ var localFile = () => path3.join(os2.homedir(), ".sphica", "projects.json");
11215
11300
  var LOCAL_KEY = /^[a-z0-9][a-z0-9._-]*$/;
11216
11301
  function normalizeRemote(url) {
11217
11302
  const raw = String(url ?? "").trim();
@@ -11256,32 +11341,32 @@ function localMap() {
11256
11341
  } catch {
11257
11342
  m = null;
11258
11343
  }
11259
- if (!m || typeof m !== "object" || Array.isArray(m) || Object.entries(m).some(([root, name]) => !path2.isAbsolute(root) || typeof name !== "string" || !LOCAL_KEY.test(name)))
11344
+ if (!m || typeof m !== "object" || Array.isArray(m) || Object.entries(m).some(([root, name]) => !path3.isAbsolute(root) || typeof name !== "string" || !LOCAL_KEY.test(name)))
11260
11345
  throw new Error(`${localFile()} is not a valid JSON project table. Fix or delete it, then name the project again.`);
11261
11346
  return m;
11262
11347
  }
11263
11348
  function identify(dir) {
11264
- const given = path2.resolve(dir);
11349
+ const given = path3.resolve(dir);
11265
11350
  const top = git(given, "rev-parse", "--show-toplevel");
11266
11351
  const root = top || given;
11267
11352
  const remote = top ? normalizeRemote(git(root, "remote", "get-url", "origin")) : null;
11268
11353
  if (remote)
11269
11354
  return { key: `git:${remote}`, root, name: remote.split("/").slice(1).join("/") || remote };
11270
11355
  const map = localMap();
11271
- for (let d = root;; d = path2.dirname(d)) {
11356
+ for (let d = root;; d = path3.dirname(d)) {
11272
11357
  const local = map[d];
11273
11358
  if (local && LOCAL_KEY.test(local))
11274
11359
  return { key: `local:${local}`, root: d, name: local };
11275
- if (top || path2.dirname(d) === d)
11360
+ if (top || path3.dirname(d) === d)
11276
11361
  return null;
11277
11362
  }
11278
11363
  }
11279
11364
  function relativeTo(root, file, cwd = root) {
11280
- const abs = path2.resolve(cwd, file);
11281
- const rel = path2.relative(root, abs);
11282
- if (!rel || rel === ".." || rel.startsWith(`..${path2.sep}`) || path2.isAbsolute(rel))
11365
+ const abs = path3.resolve(cwd, file);
11366
+ const rel = path3.relative(root, abs);
11367
+ if (!rel || leaves(rel))
11283
11368
  return null;
11284
- return rel.split(path2.sep).join("/");
11369
+ return rel.split(path3.sep).join("/");
11285
11370
  }
11286
11371
  function patchPaths(patch) {
11287
11372
  const out = [];
@@ -11294,23 +11379,104 @@ function patchPaths(patch) {
11294
11379
  return out;
11295
11380
  }
11296
11381
 
11382
+ // server/src/worktree.ts
11383
+ import { execFileSync as execFileSync2 } from "node:child_process";
11384
+ import fs3 from "node:fs";
11385
+ import path4 from "node:path";
11386
+ var MAX_PATHS = 200;
11387
+ var git2 = (root, args) => {
11388
+ try {
11389
+ return execFileSync2("git", ["-C", root, ...args], {
11390
+ encoding: "utf8",
11391
+ stdio: ["ignore", "pipe", "ignore"],
11392
+ timeout: 5000,
11393
+ maxBuffer: 16 * 1024 * 1024
11394
+ });
11395
+ } catch {
11396
+ return null;
11397
+ }
11398
+ };
11399
+ var usable = (p) => p !== "" && !p.includes("\\");
11400
+ function snapshot(root) {
11401
+ const out = git2(root, ["status", "--porcelain=v2", "-z", "--untracked-files=all"]);
11402
+ if (out === null)
11403
+ return null;
11404
+ const fields = out.split("\x00");
11405
+ const entries = {};
11406
+ for (let i = 0;i < fields.length; i++) {
11407
+ const f = fields[i] ?? "";
11408
+ const skip = { "1": 8, "2": 9, u: 10, "?": 1 }[f[0] ?? ""];
11409
+ if (!skip)
11410
+ continue;
11411
+ const parts = f.split(" ");
11412
+ const p = parts.slice(skip).join(" ");
11413
+ if (f[0] === "2")
11414
+ i++;
11415
+ if (!usable(p))
11416
+ continue;
11417
+ let st;
11418
+ try {
11419
+ st = fs3.lstatSync(path4.join(root, p), { throwIfNoEntry: false });
11420
+ } catch {
11421
+ st = undefined;
11422
+ }
11423
+ entries[p] = `${parts.slice(0, skip).join(" ")} ${st ? `${st.size}:${st.mtimeMs}` : "gone"}`;
11424
+ }
11425
+ const head2 = git2(root, ["rev-parse", "--verify", "-q", "HEAD"])?.trim() || null;
11426
+ return { head: head2, entries };
11427
+ }
11428
+ function changed(root, before, after) {
11429
+ const paths = new Set;
11430
+ for (const p of new Set([...Object.keys(before.entries), ...Object.keys(after.entries)]))
11431
+ if (before.entries[p] !== after.entries[p])
11432
+ paths.add(p);
11433
+ if (before.head && after.head && before.head !== after.head && git2(root, ["merge-base", "--is-ancestor", before.head, after.head]) !== null) {
11434
+ for (const p of (git2(root, ["diff", "--name-only", "-z", before.head, after.head]) ?? "").split("\x00"))
11435
+ if (usable(p))
11436
+ paths.add(p);
11437
+ }
11438
+ return [...paths].sort().slice(0, MAX_PATHS);
11439
+ }
11440
+
11297
11441
  // server/src/capture.ts
11298
- var spoolDir = () => path3.join(os3.homedir(), ".sphica", "spool");
11299
- var stateFile = () => path3.join(os3.homedir(), ".sphica", "capture.json");
11300
- var rejectedDir = () => path3.join(spoolDir(), "rejected");
11301
- var unregisteredDir = () => path3.join(spoolDir(), "unregistered");
11442
+ var spoolDir = () => path5.join(os3.homedir(), ".sphica", "spool");
11443
+ var stateFile = () => path5.join(os3.homedir(), ".sphica", "capture.json");
11444
+ var rejectedDir = () => path5.join(spoolDir(), "rejected");
11445
+ var unregisteredDir = () => path5.join(spoolDir(), "unregistered");
11302
11446
  var HOLD_DAYS = 30;
11303
11447
  var HOLD_MAX = 1000;
11448
+ var baselineDir = () => path5.join(os3.homedir(), ".sphica", "worktree");
11449
+ function current(raw) {
11450
+ const r = raw;
11451
+ if (r.v === 2)
11452
+ return raw;
11453
+ if (r.v !== 1)
11454
+ throw new Error(`unknown queue record version ${String(r.v)}`);
11455
+ const old = raw;
11456
+ if (old.kind === "message")
11457
+ return {
11458
+ ...old,
11459
+ v: 2,
11460
+ kind: "message",
11461
+ speaker: old.speaker === "self" ? "owner" : "assistant",
11462
+ redacted: false
11463
+ };
11464
+ if (old.action !== "edit")
11465
+ return null;
11466
+ return { ...old, v: 2, kind: "edit", event: null, path: old.path, via: "tool" };
11467
+ }
11304
11468
  var MAX_MESSAGE = 128 * 1024;
11305
11469
  var KEEP = 8 * 1024;
11306
11470
  function fit(body) {
11307
11471
  const all = bytes(body);
11308
11472
  if (all <= MAX_MESSAGE) {
11309
11473
  const kept = mask(body);
11310
- return { body: kept, truncated: false, originalBytes: bytes(kept) };
11474
+ return { body: kept, truncated: false, redacted: kept !== body, originalBytes: all };
11311
11475
  }
11312
- const a = head(mask(head(body, KEEP * 2)), KEEP);
11313
- const z = tail(mask(tail(body, KEEP * 2)), KEEP);
11476
+ const start = head(body, KEEP * 2);
11477
+ const end = tail(body, KEEP * 2);
11478
+ const a = head(mask(start), KEEP);
11479
+ const z = tail(mask(end), KEEP);
11314
11480
  const cut = all - bytes(a) - bytes(z);
11315
11481
  return {
11316
11482
  body: `${a}
@@ -11319,13 +11485,14 @@ function fit(body) {
11319
11485
 
11320
11486
  ${z}`,
11321
11487
  truncated: true,
11488
+ redacted: mask(start) !== start || mask(end) !== end,
11322
11489
  originalBytes: all
11323
11490
  };
11324
11491
  }
11325
11492
  function prune(held) {
11326
11493
  let files;
11327
11494
  try {
11328
- files = fs3.readdirSync(held).filter((f) => f.endsWith(".json") && !f.startsWith(".")).sort();
11495
+ files = fs4.readdirSync(held).filter((f) => f.endsWith(".json") && !f.startsWith(".")).sort();
11329
11496
  } catch {
11330
11497
  return;
11331
11498
  }
@@ -11333,21 +11500,21 @@ function prune(held) {
11333
11500
  const stale = files.filter((f) => Number(f.split("-")[0]) < cutoff);
11334
11501
  const over = files.slice(0, Math.max(0, files.length - HOLD_MAX));
11335
11502
  for (const f of new Set([...stale, ...over]))
11336
- fs3.rmSync(path3.join(held, f), { force: true });
11503
+ fs4.rmSync(path5.join(held, f), { force: true });
11337
11504
  }
11338
11505
  function spool(record) {
11339
11506
  const dir = spoolDir();
11340
- fs3.mkdirSync(dir, { recursive: true, mode: 448 });
11507
+ fs4.mkdirSync(dir, { recursive: true, mode: 448 });
11341
11508
  const name = `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 10)}.json`;
11342
- const tmp = path3.join(dir, `.${name}`);
11343
- fs3.writeFileSync(tmp, JSON.stringify(record), { mode: 384 });
11344
- fs3.renameSync(tmp, path3.join(dir, name));
11509
+ const tmp = path5.join(dir, `.${name}`);
11510
+ fs4.writeFileSync(tmp, JSON.stringify(record), { mode: 384 });
11511
+ fs4.renameSync(tmp, path5.join(dir, name));
11345
11512
  }
11346
11513
  var branchOf = (root) => {
11347
11514
  try {
11348
- const dotgit = path3.join(root, ".git");
11349
- const gitdir = fs3.statSync(dotgit).isFile() ? path3.resolve(root, fs3.readFileSync(dotgit, "utf8").match(/^gitdir: (.+)$/m)?.[1]?.trim() ?? "") : dotgit;
11350
- const h = fs3.readFileSync(path3.join(gitdir, "HEAD"), "utf8").trim();
11515
+ const dotgit = path5.join(root, ".git");
11516
+ const gitdir = fs4.statSync(dotgit).isFile() ? path5.resolve(root, fs4.readFileSync(dotgit, "utf8").match(/^gitdir: (.+)$/m)?.[1]?.trim() ?? "") : dotgit;
11517
+ const h = fs4.readFileSync(path5.join(gitdir, "HEAD"), "utf8").trim();
11351
11518
  return h.startsWith("ref: refs/heads/") ? h.slice("ref: refs/heads/".length) : null;
11352
11519
  } catch {
11353
11520
  return null;
@@ -11368,63 +11535,30 @@ var INJECTED = [
11368
11535
  /^(?:Another Claude|A peer) session sent a message(?: while you were working)?:/
11369
11536
  ];
11370
11537
  var digest = (s) => sha256(s).toString("hex").slice(0, 16);
11371
- var saidDir = () => path3.join(spoolDir(), "said");
11372
- var RENAME_TRIES = 20;
11373
- var RENAME_WAIT_MS = 15;
11374
- var sleepSync = (ms) => {
11375
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
11376
- };
11377
- function remember(session, id) {
11378
- const dir = saidDir();
11379
- fs3.mkdirSync(dir, { recursive: true, mode: 448 });
11380
- const file = path3.join(dir, uuidFrom(session));
11381
- const tmp = `${file}.${process.pid}`;
11382
- fs3.writeFileSync(tmp, id, { mode: 384 });
11383
- for (let i = 0;; i++) {
11384
- try {
11385
- fs3.renameSync(tmp, file);
11386
- break;
11387
- } catch (e) {
11388
- const code = e.code;
11389
- if (i >= RENAME_TRIES || code !== "EPERM" && code !== "EBUSY") {
11390
- fs3.rmSync(tmp, { force: true });
11391
- throw e;
11392
- }
11393
- sleepSync(RENAME_WAIT_MS);
11394
- }
11395
- }
11396
- const old = Date.now() - 30 * 86400000;
11397
- for (const f of fs3.readdirSync(dir)) {
11398
- const st = fs3.statSync(path3.join(dir, f), { throwIfNoEntry: false });
11399
- if (st && st.mtimeMs < old)
11400
- fs3.rmSync(path3.join(dir, f), { force: true });
11401
- }
11402
- }
11403
- function lastSaid(session) {
11404
- try {
11405
- return fs3.readFileSync(path3.join(saidDir(), uuidFrom(session)), "utf8");
11406
- } catch {
11407
- return null;
11408
- }
11409
- }
11410
11538
  function answersOf(input) {
11411
11539
  const response = input.tool_response;
11412
11540
  const answers = response?.answers;
11413
11541
  if (!answers || typeof answers !== "object")
11414
11542
  return null;
11415
- const lines = Object.entries(answers).map(([q, a]) => {
11416
- const notes = response?.annotations?.[q]?.notes;
11417
- const memo = typeof notes === "string" && notes.trim() ? `
11543
+ const pairs = Object.entries(answers);
11544
+ if (!pairs.length)
11545
+ return null;
11546
+ return {
11547
+ questions: pairs.map(([q], i) => `Q${i + 1}: ${q}`).join(`
11548
+
11549
+ `),
11550
+ answers: pairs.map(([q, a], i) => {
11551
+ const notes = response?.annotations?.[q]?.notes;
11552
+ const memo = typeof notes === "string" && notes.trim() ? `
11418
11553
  Notes: ${notes.trim()}` : "";
11419
- return `Q: ${q}
11420
- A: ${Array.isArray(a) ? a.join(" / ") : String(a)}${memo}`;
11421
- });
11422
- return lines.length ? lines.join(`
11554
+ return `A${i + 1}: ${Array.isArray(a) ? a.join(" / ") : String(a)}${memo}`;
11555
+ }).join(`
11423
11556
 
11424
- `) : null;
11557
+ `)
11558
+ };
11425
11559
  }
11426
11560
  function captureNotice(file = dbFile()) {
11427
- if (!fs3.existsSync(file))
11561
+ if (!fs4.existsSync(file))
11428
11562
  return panel("sphica: no database, so conversations are not recorded", [file], "Create it with sphica init");
11429
11563
  const s = readState();
11430
11564
  if (s.stuck)
@@ -11433,6 +11567,34 @@ function captureNotice(file = dbFile()) {
11433
11567
  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");
11434
11568
  return null;
11435
11569
  }
11570
+ var baselineFile = (host, session) => path5.join(baselineDir(), `${digest(`${host}\x00${session}`)}.json`);
11571
+ function readBaseline(file) {
11572
+ try {
11573
+ return JSON.parse(fs4.readFileSync(file, "utf8"));
11574
+ } catch {
11575
+ return null;
11576
+ }
11577
+ }
11578
+ function writeBaseline(file, b) {
11579
+ fs4.mkdirSync(path5.dirname(file), { recursive: true, mode: 448 });
11580
+ const tmp = `${file}.${process.pid}.tmp`;
11581
+ fs4.writeFileSync(tmp, JSON.stringify(b), { mode: 384 });
11582
+ fs4.renameSync(tmp, file);
11583
+ }
11584
+ function pruneBaselines() {
11585
+ const cutoff = Date.now() - HOLD_DAYS * 24 * 60 * 60 * 1000;
11586
+ let files;
11587
+ try {
11588
+ files = fs4.readdirSync(baselineDir());
11589
+ } catch {
11590
+ return;
11591
+ }
11592
+ for (const f of files) {
11593
+ const file = path5.join(baselineDir(), f);
11594
+ if ((fs4.statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? Date.now()) < cutoff)
11595
+ fs4.rmSync(file, { force: true });
11596
+ }
11597
+ }
11436
11598
  function onHook(host, input) {
11437
11599
  const event = input.hook_event_name;
11438
11600
  const owner = () => isOwnerTurn(input, undefined, undefined, host === "codex" ? process.env.CODEX_THREAD_ID : undefined);
@@ -11441,9 +11603,14 @@ function onHook(host, input) {
11441
11603
  return { flush: false };
11442
11604
  const file = process.env.CLAUDE_ENV_FILE;
11443
11605
  if (file && input.session_id && /^[A-Za-z0-9_-]+$/.test(input.session_id)) {
11444
- fs3.appendFileSync(file, `export SPHICA_PARENT_SESSION=${input.session_id}
11606
+ fs4.appendFileSync(file, `export SPHICA_PARENT_SESSION=${input.session_id}
11445
11607
  `);
11446
11608
  }
11609
+ const place2 = identify(input.cwd ?? process.cwd());
11610
+ const now = place2 && snapshot(place2.root);
11611
+ if (now)
11612
+ writeBaseline(baselineFile(host, String(input.session_id)), { ...now, running: false });
11613
+ pruneBaselines();
11447
11614
  return { flush: false, notice: captureNotice() };
11448
11615
  }
11449
11616
  if (!owner())
@@ -11458,7 +11625,7 @@ function onHook(host, input) {
11458
11625
  return { flush: false };
11459
11626
  const at = new Date().toISOString();
11460
11627
  const base = {
11461
- v: 1,
11628
+ v: 2,
11462
11629
  host,
11463
11630
  session: String(input.session_id),
11464
11631
  project: place.key,
@@ -11466,23 +11633,37 @@ function onHook(host, input) {
11466
11633
  turn,
11467
11634
  at
11468
11635
  };
11469
- const say = (key, speaker, raw) => {
11636
+ const say = (key, speaker, raw, when = at) => {
11470
11637
  const kept = fit(clean(raw).trim());
11471
11638
  if (!kept.body.trim())
11472
11639
  return;
11473
11640
  const id = `${key}:${digest(kept.body)}`;
11474
- spool({ ...base, kind: "message", id, speaker, ...kept });
11475
- if (speaker === "self")
11476
- remember(base.session, id);
11641
+ spool({ ...base, at: when, kind: "message", id, speaker, ...kept });
11477
11642
  };
11643
+ const baseline = baselineFile(host, base.session);
11644
+ if (event === "UserPromptSubmit") {
11645
+ if (!readBaseline(baseline)?.running) {
11646
+ const now = snapshot(place.root);
11647
+ if (now)
11648
+ writeBaseline(baseline, { ...now, running: true });
11649
+ }
11650
+ }
11478
11651
  if (event === "UserPromptSubmit" && input.prompt) {
11479
11652
  const prompt = input.prompt.trimStart();
11480
11653
  if (!INJECTED.some((r) => r.test(prompt)))
11481
- say(`${turn}:self`, "self", prompt);
11654
+ say(`${turn}:owner`, "owner", prompt);
11482
11655
  }
11483
11656
  if (event === "Stop") {
11484
11657
  if (input.last_assistant_message)
11485
11658
  say(`${turn}:assistant`, "assistant", input.last_assistant_message);
11659
+ const now = snapshot(place.root);
11660
+ if (now) {
11661
+ const before = readBaseline(baseline);
11662
+ if (before)
11663
+ for (const p of changed(place.root, before, now))
11664
+ spool({ ...base, kind: "edit", event: null, path: p, via: "status" });
11665
+ writeBaseline(baseline, { ...now, running: false });
11666
+ }
11486
11667
  return { flush: true };
11487
11668
  }
11488
11669
  if (event === "PostToolUse") {
@@ -11490,31 +11671,31 @@ function onHook(host, input) {
11490
11671
  const ti = input.tool_input ?? {};
11491
11672
  if (tool === "AskUserQuestion") {
11492
11673
  const said = answersOf(input);
11493
- if (said)
11494
- say(`${turn}:ask:${input.tool_use_id ?? at}`, "self", said);
11674
+ if (said) {
11675
+ const id = `${turn}:ask:${input.tool_use_id ?? at}`;
11676
+ say(`${id}:q`, "assistant", said.questions, new Date(Date.parse(at) - 1).toISOString());
11677
+ say(id, "owner", said.answers);
11678
+ }
11495
11679
  return { flush: false };
11496
11680
  }
11497
11681
  if (tool === "Read")
11498
11682
  return { flush: false };
11499
- const message = lastSaid(base.session);
11500
- if (!message)
11501
- return { flush: false };
11502
11683
  const cwd = input.cwd ?? place.root;
11503
11684
  const files = (tool === "apply_patch" ? patchPaths(String(ti.command ?? "")) : [ti.file_path, ti.notebook_path].filter((p) => typeof p === "string")).flatMap((p) => relativeTo(place.root, p, cwd) ?? []);
11504
11685
  for (const p of files)
11505
- spool({ ...base, kind: "file", message, path: p, action: "edit" });
11686
+ spool({ ...base, kind: "edit", event: input.tool_use_id ?? null, path: p, via: "tool" });
11506
11687
  }
11507
11688
  return { flush: false };
11508
11689
  }
11509
11690
  function writeState(s) {
11510
11691
  try {
11511
- fs3.writeFileSync(stateFile(), JSON.stringify(s));
11692
+ fs4.writeFileSync(stateFile(), JSON.stringify(s));
11512
11693
  } catch {}
11513
11694
  }
11514
11695
  function readState() {
11515
11696
  const count = (dir) => {
11516
11697
  try {
11517
- return fs3.readdirSync(dir).filter((f) => f.endsWith(".json") && !f.startsWith(".")).length;
11698
+ return fs4.readdirSync(dir).filter((f) => f.endsWith(".json") && !f.startsWith(".")).length;
11518
11699
  } catch {
11519
11700
  return 0;
11520
11701
  }
@@ -11526,7 +11707,7 @@ function readState() {
11526
11707
  };
11527
11708
  let raw = {};
11528
11709
  try {
11529
- const parsed = JSON.parse(fs3.readFileSync(stateFile(), "utf8"));
11710
+ const parsed = JSON.parse(fs4.readFileSync(stateFile(), "utf8"));
11530
11711
  if (parsed && typeof parsed === "object")
11531
11712
  raw = parsed;
11532
11713
  } catch {}
@@ -11540,25 +11721,25 @@ function readState() {
11540
11721
  };
11541
11722
  }
11542
11723
  function lock() {
11543
- const file = path3.join(spoolDir(), ".lock");
11544
- fs3.mkdirSync(spoolDir(), { recursive: true, mode: 448 });
11724
+ const file = path5.join(spoolDir(), ".lock");
11725
+ fs4.mkdirSync(spoolDir(), { recursive: true, mode: 448 });
11545
11726
  for (let attempt = 0;attempt < 2; attempt++) {
11546
11727
  try {
11547
- fs3.writeFileSync(file, String(process.pid), { flag: "wx", mode: 384 });
11728
+ fs4.writeFileSync(file, String(process.pid), { flag: "wx", mode: 384 });
11548
11729
  return () => {
11549
11730
  try {
11550
- if (fs3.readFileSync(file, "utf8") === String(process.pid))
11551
- fs3.rmSync(file, { force: true });
11731
+ if (fs4.readFileSync(file, "utf8") === String(process.pid))
11732
+ fs4.rmSync(file, { force: true });
11552
11733
  } catch {}
11553
11734
  };
11554
11735
  } catch (e) {
11555
11736
  if (e.code !== "EEXIST")
11556
11737
  throw e;
11557
11738
  }
11558
- const st = fs3.statSync(file, { throwIfNoEntry: false });
11739
+ const st = fs4.statSync(file, { throwIfNoEntry: false });
11559
11740
  if (!st)
11560
11741
  continue;
11561
- const holder = Number(fs3.readFileSync(file, "utf8") || 0);
11742
+ const holder = Number(fs4.readFileSync(file, "utf8") || 0);
11562
11743
  const fresh = Date.now() - st.mtimeMs < 5 * 60000;
11563
11744
  const alive = (() => {
11564
11745
  if (holder <= 0)
@@ -11571,7 +11752,7 @@ function lock() {
11571
11752
  })();
11572
11753
  if (alive && fresh)
11573
11754
  return null;
11574
- fs3.rmSync(file, { force: true });
11755
+ fs4.rmSync(file, { force: true });
11575
11756
  }
11576
11757
  return null;
11577
11758
  }
@@ -11580,27 +11761,21 @@ var ROWS = 1000;
11580
11761
  var chunks = (xs, n = ROWS) => Array.from({ length: Math.ceil(xs.length / n) }, (_, i) => xs.slice(i * n, (i + 1) * n));
11581
11762
  async function write(db, batch, projects) {
11582
11763
  return inTransaction(db, async (trx) => {
11583
- const conversations = new Map;
11764
+ const sessions = new Map;
11584
11765
  for (const r of batch) {
11585
11766
  const p = projects.get(r.project);
11586
11767
  if (!p)
11587
11768
  continue;
11588
- const id = conversationId(p.id, r.host, r.session);
11589
- const prev = conversations.get(id);
11769
+ const id = sessionId(p.id, r.host, r.session);
11770
+ const prev = sessions.get(id);
11590
11771
  if (!prev || Date.parse(r.at) < Date.parse(prev.at))
11591
- conversations.set(id, {
11592
- project: p.id,
11593
- host: r.host,
11594
- session: r.session,
11595
- branch: r.branch,
11596
- at: r.at
11597
- });
11772
+ sessions.set(id, { project: p.id, host: r.host, session: r.session, branch: r.branch, at: r.at });
11598
11773
  }
11599
- for (const part of chunks([...conversations]))
11600
- await trx.insertInto("capture_conversation").values(part.map(([id, v]) => ({
11774
+ for (const part of chunks([...sessions]))
11775
+ await trx.insertInto("capture_session").values(part.map(([id, v]) => ({
11601
11776
  id,
11602
11777
  project_id: v.project,
11603
- origin: v.host,
11778
+ host: v.host,
11604
11779
  external_id: v.session,
11605
11780
  branch: v.branch,
11606
11781
  started_at: iso(v.at)
@@ -11609,44 +11784,49 @@ async function write(db, batch, projects) {
11609
11784
  const p = m.kind === "message" ? projects.get(m.project) : undefined;
11610
11785
  if (m.kind !== "message" || !p)
11611
11786
  return [];
11612
- const conversation = conversationId(p.id, m.host, m.session);
11613
- return [{ m, conversation, id: uuidFrom(conversation, m.id) }];
11614
- });
11615
- let before = 0;
11616
- for (const part of chunks(messages)) {
11617
- const ids = part.map((x) => x.id);
11618
- before += (await trx.selectFrom("message").select("id").where("id", "in", ids).execute()).length;
11787
+ return [{ m, session: sessionId(p.id, m.host, m.session) }];
11788
+ });
11789
+ const present = async () => {
11790
+ let n = 0;
11791
+ for (const part of chunks(messages))
11792
+ for (const [session, ids] of Map.groupBy(part, (x) => x.session))
11793
+ n += (await trx.selectFrom("source").select("id").where("kind", "=", "session_message").where("session_id", "=", session).where("external_id", "in", ids.map((x) => x.m.id)).execute()).length;
11794
+ return n;
11795
+ };
11796
+ const before = await present();
11797
+ const now = iso(Date.now());
11798
+ for (const part of chunks(messages))
11619
11799
  await trx.insertInto("capture_message").values(part.map((x) => ({
11620
- id: x.id,
11621
- conversation_id: x.conversation,
11622
11800
  external_id: x.m.id,
11801
+ session_id: x.session,
11623
11802
  turn_id: x.m.turn,
11624
- speaker_kind: x.m.speaker,
11625
- body: x.m.body,
11803
+ speaker: x.m.speaker,
11804
+ created_at: iso(x.m.at),
11805
+ captured_at: now,
11806
+ text: x.m.body,
11626
11807
  truncated: x.m.truncated ? 1 : 0,
11808
+ redacted: x.m.redacted ? 1 : 0,
11627
11809
  original_bytes: x.m.originalBytes,
11628
- sent_at: iso(x.m.at),
11629
- content_hash: sha256(x.m.body),
11630
- indexed: indexesMessage(x.m.speaker) ? 1 : 0
11810
+ content_hash: sha256(x.m.body)
11631
11811
  }))).execute();
11632
- }
11633
- let after = 0;
11634
- for (const part of chunks(messages))
11635
- after += (await trx.selectFrom("message").select("id").where("id", "in", part.map((x) => x.id)).execute()).length;
11636
- const files = batch.flatMap((r) => {
11637
- const p = r.kind === "file" ? projects.get(r.project) : undefined;
11638
- if (r.kind !== "file" || !p)
11812
+ const after = await present();
11813
+ const edits = batch.flatMap((r) => {
11814
+ const p = r.kind === "edit" ? projects.get(r.project) : undefined;
11815
+ if (r.kind !== "edit" || !p)
11639
11816
  return [];
11640
11817
  return [
11641
11818
  {
11642
- message_id: uuidFrom(conversationId(p.id, r.host, r.session), r.message),
11819
+ session_id: sessionId(p.id, r.host, r.session),
11820
+ turn_id: r.turn,
11821
+ tool_event_id: r.event,
11643
11822
  path: r.path,
11644
- action: r.action
11823
+ via: r.via,
11824
+ observed_at: iso(r.at)
11645
11825
  }
11646
11826
  ];
11647
11827
  });
11648
- for (const part of chunks(files))
11649
- await trx.insertInto("capture_message_file").values(part).execute();
11828
+ for (const part of chunks(edits))
11829
+ await trx.insertInto("capture_edit").values(part).execute();
11650
11830
  return after - before;
11651
11831
  });
11652
11832
  }
@@ -11662,7 +11842,7 @@ async function flush(file = dbFile()) {
11662
11842
  const held = unregisteredDir();
11663
11843
  const list = (from) => {
11664
11844
  try {
11665
- return fs3.readdirSync(from).filter((f) => f.endsWith(".json") && !f.startsWith(".")).map((name) => ({ name, from }));
11845
+ return fs4.readdirSync(from).filter((f) => f.endsWith(".json") && !f.startsWith(".")).map((name) => ({ name, from }));
11666
11846
  } catch {
11667
11847
  return [];
11668
11848
  }
@@ -11672,15 +11852,18 @@ async function flush(file = dbFile()) {
11672
11852
  return { sent: 0, deferred: 0, rejected: 0 };
11673
11853
  const records = [];
11674
11854
  for (const { name, from } of names) {
11855
+ let r;
11675
11856
  try {
11676
- records.push({
11677
- name,
11678
- from,
11679
- r: JSON.parse(fs3.readFileSync(path3.join(from, name), "utf8"))
11680
- });
11857
+ r = current(JSON.parse(fs4.readFileSync(path5.join(from, name), "utf8")));
11681
11858
  } catch {
11682
- fs3.rmSync(path3.join(from, name), { force: true });
11859
+ fs4.mkdirSync(rejectedDir(), { recursive: true, mode: 448 });
11860
+ fs4.renameSync(path5.join(from, name), path5.join(rejectedDir(), name));
11861
+ continue;
11683
11862
  }
11863
+ if (r)
11864
+ records.push({ name, from, r });
11865
+ else
11866
+ fs4.rmSync(path5.join(from, name), { force: true });
11684
11867
  }
11685
11868
  const db = openWriter("capture", file);
11686
11869
  client = db;
@@ -11694,8 +11877,7 @@ async function flush(file = dbFile()) {
11694
11877
  } catch (e) {
11695
11878
  if (!rejected(e))
11696
11879
  throw e;
11697
- const ordered = [...known].sort((a, b) => Number(a.r.kind === "file") - Number(b.r.kind === "file"));
11698
- for (const x of ordered) {
11880
+ for (const x of known) {
11699
11881
  try {
11700
11882
  sent += await write(db, [x.r], projects);
11701
11883
  } catch (e2) {
@@ -11705,15 +11887,11 @@ async function flush(file = dbFile()) {
11705
11887
  }
11706
11888
  }
11707
11889
  }
11708
- const lost = new Set(bad.flatMap((x) => x.r.kind === "message" && x.r.speaker === "self" ? [`${x.r.session}\x00${x.r.id}`] : []));
11709
- for (const x of known)
11710
- if (x.r.kind === "file" && lost.has(`${x.r.session}\x00${x.r.message}`) && !bad.includes(x))
11711
- bad.push(x);
11712
11890
  if (bad.length) {
11713
- fs3.mkdirSync(rejectedDir(), { recursive: true, mode: 448 });
11891
+ fs4.mkdirSync(rejectedDir(), { recursive: true, mode: 448 });
11714
11892
  for (const x of bad) {
11715
11893
  try {
11716
- fs3.renameSync(path3.join(x.from, x.name), path3.join(rejectedDir(), x.name));
11894
+ fs4.renameSync(path5.join(x.from, x.name), path5.join(rejectedDir(), x.name));
11717
11895
  } catch (e) {
11718
11896
  if (e.code !== "ENOENT")
11719
11897
  throw e;
@@ -11721,12 +11899,12 @@ async function flush(file = dbFile()) {
11721
11899
  }
11722
11900
  }
11723
11901
  if (strayed.length) {
11724
- fs3.mkdirSync(held, { recursive: true, mode: 448 });
11902
+ fs4.mkdirSync(held, { recursive: true, mode: 448 });
11725
11903
  for (const x of strayed) {
11726
11904
  if (x.from === held)
11727
11905
  continue;
11728
11906
  try {
11729
- fs3.renameSync(path3.join(x.from, x.name), path3.join(held, x.name));
11907
+ fs4.renameSync(path5.join(x.from, x.name), path5.join(held, x.name));
11730
11908
  } catch (e) {
11731
11909
  if (e.code !== "ENOENT")
11732
11910
  throw e;
@@ -11736,7 +11914,7 @@ async function flush(file = dbFile()) {
11736
11914
  const moved = new Set([...bad, ...strayed].map((x) => x.name));
11737
11915
  for (const x of records)
11738
11916
  if (!moved.has(x.name))
11739
- fs3.rmSync(path3.join(x.from, x.name), { force: true });
11917
+ fs4.rmSync(path5.join(x.from, x.name), { force: true });
11740
11918
  prune(held);
11741
11919
  writeState({ flushedAt: new Date().toISOString(), error: null, deferred: strayed.length });
11742
11920
  return { sent, deferred: strayed.length, rejected: bad.length };
@@ -11776,11 +11954,12 @@ if (process.argv[1] && /capture\.(ts|js)$/.test(process.argv[1])) {
11776
11954
  export {
11777
11955
  MAX_MESSAGE,
11778
11956
  answersOf,
11957
+ branchOf,
11779
11958
  captureNotice,
11959
+ current,
11780
11960
  fit,
11781
11961
  flush,
11782
11962
  isOwnerTurn,
11783
- lock,
11784
11963
  onHook,
11785
11964
  readInput,
11786
11965
  readState,