sphica 0.4.0 → 0.5.1

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,8 @@
2
2
 
3
3
  // server/src/capture.ts
4
4
  import { spawn } from "node:child_process";
5
- import fs3 from "node:fs";
6
- import os3 from "node:os";
7
- import path3 from "node:path";
5
+ import fs4 from "node:fs";
6
+ import path5 from "node:path";
8
7
 
9
8
  // server/node_modules/kysely/dist/util/object-utils.js
10
9
  function isUndefined(obj) {
@@ -10813,8 +10812,10 @@ import fs from "node:fs";
10813
10812
  import os from "node:os";
10814
10813
  import path from "node:path";
10815
10814
  import { constants as C, DatabaseSync } from "node:sqlite";
10816
- var SCHEMA_REVISION = 7;
10817
- var dbFile = () => process.env.SPHICA_DB || path.join(os.homedir(), ".sphica", "sphica.db");
10815
+ var SCHEMA_GENERATION = 2;
10816
+ var SCHEMA_REVISION = 1;
10817
+ var sphicaHome = () => process.env.SPHICA_HOME || path.join(os.homedir(), ".sphica");
10818
+ var dbFile = () => process.env.SPHICA_DB || path.join(sphicaHome(), "sphica.db");
10818
10819
  function requireRuntime() {
10819
10820
  const proto = DatabaseSync.prototype;
10820
10821
  if (typeof proto.setAuthorizer !== "function" || typeof proto.enableDefensive !== "function")
@@ -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";
@@ -11209,9 +11287,15 @@ var plain2 = (s) => visible(stripVTControlCharacters(s.replace(STRING_SEQUENCE,
11209
11287
  // server/src/project.ts
11210
11288
  import { execFileSync } from "node:child_process";
11211
11289
  import fs2 from "node:fs";
11212
- import os2 from "node:os";
11290
+ import path3 from "node:path";
11291
+
11292
+ // server/src/anchors.ts
11213
11293
  import path2 from "node:path";
11214
- var localFile = () => path2.join(os2.homedir(), ".sphica", "projects.json");
11294
+ var MAX_BYTES = 2 * 1024 * 1024;
11295
+ var leaves = (rel) => rel === ".." || rel.startsWith(`..${path2.sep}`) || path2.isAbsolute(rel);
11296
+
11297
+ // server/src/project.ts
11298
+ var localFile = () => path3.join(sphicaHome(), "projects.json");
11215
11299
  var LOCAL_KEY = /^[a-z0-9][a-z0-9._-]*$/;
11216
11300
  function normalizeRemote(url) {
11217
11301
  const raw = String(url ?? "").trim();
@@ -11256,32 +11340,32 @@ function localMap() {
11256
11340
  } catch {
11257
11341
  m = null;
11258
11342
  }
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)))
11343
+ 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
11344
  throw new Error(`${localFile()} is not a valid JSON project table. Fix or delete it, then name the project again.`);
11261
11345
  return m;
11262
11346
  }
11263
11347
  function identify(dir) {
11264
- const given = path2.resolve(dir);
11348
+ const given = path3.resolve(dir);
11265
11349
  const top = git(given, "rev-parse", "--show-toplevel");
11266
11350
  const root = top || given;
11267
11351
  const remote = top ? normalizeRemote(git(root, "remote", "get-url", "origin")) : null;
11268
11352
  if (remote)
11269
11353
  return { key: `git:${remote}`, root, name: remote.split("/").slice(1).join("/") || remote };
11270
11354
  const map = localMap();
11271
- for (let d = root;; d = path2.dirname(d)) {
11355
+ for (let d = root;; d = path3.dirname(d)) {
11272
11356
  const local = map[d];
11273
11357
  if (local && LOCAL_KEY.test(local))
11274
11358
  return { key: `local:${local}`, root: d, name: local };
11275
- if (top || path2.dirname(d) === d)
11359
+ if (top || path3.dirname(d) === d)
11276
11360
  return null;
11277
11361
  }
11278
11362
  }
11279
11363
  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))
11364
+ const abs = path3.resolve(cwd, file);
11365
+ const rel = path3.relative(root, abs);
11366
+ if (!rel || leaves(rel))
11283
11367
  return null;
11284
- return rel.split(path2.sep).join("/");
11368
+ return rel.split(path3.sep).join("/");
11285
11369
  }
11286
11370
  function patchPaths(patch) {
11287
11371
  const out = [];
@@ -11294,23 +11378,104 @@ function patchPaths(patch) {
11294
11378
  return out;
11295
11379
  }
11296
11380
 
11381
+ // server/src/worktree.ts
11382
+ import { execFileSync as execFileSync2 } from "node:child_process";
11383
+ import fs3 from "node:fs";
11384
+ import path4 from "node:path";
11385
+ var MAX_PATHS = 200;
11386
+ var git2 = (root, args) => {
11387
+ try {
11388
+ return execFileSync2("git", ["-C", root, ...args], {
11389
+ encoding: "utf8",
11390
+ stdio: ["ignore", "pipe", "ignore"],
11391
+ timeout: 5000,
11392
+ maxBuffer: 16 * 1024 * 1024
11393
+ });
11394
+ } catch {
11395
+ return null;
11396
+ }
11397
+ };
11398
+ var usable = (p) => p !== "" && !p.includes("\\");
11399
+ function snapshot(root) {
11400
+ const out = git2(root, ["status", "--porcelain=v2", "-z", "--untracked-files=all"]);
11401
+ if (out === null)
11402
+ return null;
11403
+ const fields = out.split("\x00");
11404
+ const entries = {};
11405
+ for (let i = 0;i < fields.length; i++) {
11406
+ const f = fields[i] ?? "";
11407
+ const skip = { "1": 8, "2": 9, u: 10, "?": 1 }[f[0] ?? ""];
11408
+ if (!skip)
11409
+ continue;
11410
+ const parts = f.split(" ");
11411
+ const p = parts.slice(skip).join(" ");
11412
+ if (f[0] === "2")
11413
+ i++;
11414
+ if (!usable(p))
11415
+ continue;
11416
+ let st;
11417
+ try {
11418
+ st = fs3.lstatSync(path4.join(root, p), { throwIfNoEntry: false });
11419
+ } catch {
11420
+ st = undefined;
11421
+ }
11422
+ entries[p] = `${parts.slice(0, skip).join(" ")} ${st ? `${st.size}:${st.mtimeMs}` : "gone"}`;
11423
+ }
11424
+ const head2 = git2(root, ["rev-parse", "--verify", "-q", "HEAD"])?.trim() || null;
11425
+ return { head: head2, entries };
11426
+ }
11427
+ function changed(root, before, after) {
11428
+ const paths = new Set;
11429
+ for (const p of new Set([...Object.keys(before.entries), ...Object.keys(after.entries)]))
11430
+ if (before.entries[p] !== after.entries[p])
11431
+ paths.add(p);
11432
+ if (before.head && after.head && before.head !== after.head && git2(root, ["merge-base", "--is-ancestor", before.head, after.head]) !== null) {
11433
+ for (const p of (git2(root, ["diff", "--name-only", "-z", before.head, after.head]) ?? "").split("\x00"))
11434
+ if (usable(p))
11435
+ paths.add(p);
11436
+ }
11437
+ return [...paths].sort().slice(0, MAX_PATHS);
11438
+ }
11439
+
11297
11440
  // 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");
11441
+ var spoolDir = () => path5.join(sphicaHome(), "spool");
11442
+ var stateFile = () => path5.join(sphicaHome(), "capture.json");
11443
+ var rejectedDir = () => path5.join(spoolDir(), "rejected");
11444
+ var unregisteredDir = () => path5.join(spoolDir(), "unregistered");
11302
11445
  var HOLD_DAYS = 30;
11303
11446
  var HOLD_MAX = 1000;
11447
+ var baselineDir = () => path5.join(sphicaHome(), "worktree");
11448
+ function current(raw) {
11449
+ const r = raw;
11450
+ if (r.v === 2)
11451
+ return raw;
11452
+ if (r.v !== 1)
11453
+ throw new Error(`unknown queue record version ${String(r.v)}`);
11454
+ const old = raw;
11455
+ if (old.kind === "message")
11456
+ return {
11457
+ ...old,
11458
+ v: 2,
11459
+ kind: "message",
11460
+ speaker: old.speaker === "self" ? "owner" : "assistant",
11461
+ redacted: false
11462
+ };
11463
+ if (old.action !== "edit")
11464
+ return null;
11465
+ return { ...old, v: 2, kind: "edit", event: null, path: old.path, via: "tool" };
11466
+ }
11304
11467
  var MAX_MESSAGE = 128 * 1024;
11305
11468
  var KEEP = 8 * 1024;
11306
11469
  function fit(body) {
11307
11470
  const all = bytes(body);
11308
11471
  if (all <= MAX_MESSAGE) {
11309
11472
  const kept = mask(body);
11310
- return { body: kept, truncated: false, originalBytes: bytes(kept) };
11473
+ return { body: kept, truncated: false, redacted: kept !== body, originalBytes: all };
11311
11474
  }
11312
- const a = head(mask(head(body, KEEP * 2)), KEEP);
11313
- const z = tail(mask(tail(body, KEEP * 2)), KEEP);
11475
+ const start = head(body, KEEP * 2);
11476
+ const end = tail(body, KEEP * 2);
11477
+ const a = head(mask(start), KEEP);
11478
+ const z = tail(mask(end), KEEP);
11314
11479
  const cut = all - bytes(a) - bytes(z);
11315
11480
  return {
11316
11481
  body: `${a}
@@ -11319,13 +11484,14 @@ function fit(body) {
11319
11484
 
11320
11485
  ${z}`,
11321
11486
  truncated: true,
11487
+ redacted: mask(start) !== start || mask(end) !== end,
11322
11488
  originalBytes: all
11323
11489
  };
11324
11490
  }
11325
11491
  function prune(held) {
11326
11492
  let files;
11327
11493
  try {
11328
- files = fs3.readdirSync(held).filter((f) => f.endsWith(".json") && !f.startsWith(".")).sort();
11494
+ files = fs4.readdirSync(held).filter((f) => f.endsWith(".json") && !f.startsWith(".")).sort();
11329
11495
  } catch {
11330
11496
  return;
11331
11497
  }
@@ -11333,21 +11499,21 @@ function prune(held) {
11333
11499
  const stale = files.filter((f) => Number(f.split("-")[0]) < cutoff);
11334
11500
  const over = files.slice(0, Math.max(0, files.length - HOLD_MAX));
11335
11501
  for (const f of new Set([...stale, ...over]))
11336
- fs3.rmSync(path3.join(held, f), { force: true });
11502
+ fs4.rmSync(path5.join(held, f), { force: true });
11337
11503
  }
11338
11504
  function spool(record) {
11339
11505
  const dir = spoolDir();
11340
- fs3.mkdirSync(dir, { recursive: true, mode: 448 });
11506
+ fs4.mkdirSync(dir, { recursive: true, mode: 448 });
11341
11507
  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));
11508
+ const tmp = path5.join(dir, `.${name}`);
11509
+ fs4.writeFileSync(tmp, JSON.stringify(record), { mode: 384 });
11510
+ fs4.renameSync(tmp, path5.join(dir, name));
11345
11511
  }
11346
11512
  var branchOf = (root) => {
11347
11513
  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();
11514
+ const dotgit = path5.join(root, ".git");
11515
+ const gitdir = fs4.statSync(dotgit).isFile() ? path5.resolve(root, fs4.readFileSync(dotgit, "utf8").match(/^gitdir: (.+)$/m)?.[1]?.trim() ?? "") : dotgit;
11516
+ const h = fs4.readFileSync(path5.join(gitdir, "HEAD"), "utf8").trim();
11351
11517
  return h.startsWith("ref: refs/heads/") ? h.slice("ref: refs/heads/".length) : null;
11352
11518
  } catch {
11353
11519
  return null;
@@ -11368,63 +11534,30 @@ var INJECTED = [
11368
11534
  /^(?:Another Claude|A peer) session sent a message(?: while you were working)?:/
11369
11535
  ];
11370
11536
  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
11537
  function answersOf(input) {
11411
11538
  const response = input.tool_response;
11412
11539
  const answers = response?.answers;
11413
11540
  if (!answers || typeof answers !== "object")
11414
11541
  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() ? `
11542
+ const pairs = Object.entries(answers);
11543
+ if (!pairs.length)
11544
+ return null;
11545
+ return {
11546
+ questions: pairs.map(([q], i) => `Q${i + 1}: ${q}`).join(`
11547
+
11548
+ `),
11549
+ answers: pairs.map(([q, a], i) => {
11550
+ const notes = response?.annotations?.[q]?.notes;
11551
+ const memo = typeof notes === "string" && notes.trim() ? `
11418
11552
  Notes: ${notes.trim()}` : "";
11419
- return `Q: ${q}
11420
- A: ${Array.isArray(a) ? a.join(" / ") : String(a)}${memo}`;
11421
- });
11422
- return lines.length ? lines.join(`
11553
+ return `A${i + 1}: ${Array.isArray(a) ? a.join(" / ") : String(a)}${memo}`;
11554
+ }).join(`
11423
11555
 
11424
- `) : null;
11556
+ `)
11557
+ };
11425
11558
  }
11426
11559
  function captureNotice(file = dbFile()) {
11427
- if (!fs3.existsSync(file))
11560
+ if (!fs4.existsSync(file))
11428
11561
  return panel("sphica: no database, so conversations are not recorded", [file], "Create it with sphica init");
11429
11562
  const s = readState();
11430
11563
  if (s.stuck)
@@ -11433,6 +11566,34 @@ function captureNotice(file = dbFile()) {
11433
11566
  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
11567
  return null;
11435
11568
  }
11569
+ var baselineFile = (host, session) => path5.join(baselineDir(), `${digest(`${host}\x00${session}`)}.json`);
11570
+ function readBaseline(file) {
11571
+ try {
11572
+ return JSON.parse(fs4.readFileSync(file, "utf8"));
11573
+ } catch {
11574
+ return null;
11575
+ }
11576
+ }
11577
+ function writeBaseline(file, b) {
11578
+ fs4.mkdirSync(path5.dirname(file), { recursive: true, mode: 448 });
11579
+ const tmp = `${file}.${process.pid}.tmp`;
11580
+ fs4.writeFileSync(tmp, JSON.stringify(b), { mode: 384 });
11581
+ fs4.renameSync(tmp, file);
11582
+ }
11583
+ function pruneBaselines() {
11584
+ const cutoff = Date.now() - HOLD_DAYS * 24 * 60 * 60 * 1000;
11585
+ let files;
11586
+ try {
11587
+ files = fs4.readdirSync(baselineDir());
11588
+ } catch {
11589
+ return;
11590
+ }
11591
+ for (const f of files) {
11592
+ const file = path5.join(baselineDir(), f);
11593
+ if ((fs4.statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? Date.now()) < cutoff)
11594
+ fs4.rmSync(file, { force: true });
11595
+ }
11596
+ }
11436
11597
  function onHook(host, input) {
11437
11598
  const event = input.hook_event_name;
11438
11599
  const owner = () => isOwnerTurn(input, undefined, undefined, host === "codex" ? process.env.CODEX_THREAD_ID : undefined);
@@ -11441,9 +11602,14 @@ function onHook(host, input) {
11441
11602
  return { flush: false };
11442
11603
  const file = process.env.CLAUDE_ENV_FILE;
11443
11604
  if (file && input.session_id && /^[A-Za-z0-9_-]+$/.test(input.session_id)) {
11444
- fs3.appendFileSync(file, `export SPHICA_PARENT_SESSION=${input.session_id}
11605
+ fs4.appendFileSync(file, `export SPHICA_PARENT_SESSION=${input.session_id}
11445
11606
  `);
11446
11607
  }
11608
+ const place2 = identify(input.cwd ?? process.cwd());
11609
+ const now = place2 && snapshot(place2.root);
11610
+ if (now)
11611
+ writeBaseline(baselineFile(host, String(input.session_id)), { ...now, running: false });
11612
+ pruneBaselines();
11447
11613
  return { flush: false, notice: captureNotice() };
11448
11614
  }
11449
11615
  if (!owner())
@@ -11458,7 +11624,7 @@ function onHook(host, input) {
11458
11624
  return { flush: false };
11459
11625
  const at = new Date().toISOString();
11460
11626
  const base = {
11461
- v: 1,
11627
+ v: 2,
11462
11628
  host,
11463
11629
  session: String(input.session_id),
11464
11630
  project: place.key,
@@ -11466,23 +11632,37 @@ function onHook(host, input) {
11466
11632
  turn,
11467
11633
  at
11468
11634
  };
11469
- const say = (key, speaker, raw) => {
11635
+ const say = (key, speaker, raw, when = at) => {
11470
11636
  const kept = fit(clean(raw).trim());
11471
11637
  if (!kept.body.trim())
11472
11638
  return;
11473
11639
  const id = `${key}:${digest(kept.body)}`;
11474
- spool({ ...base, kind: "message", id, speaker, ...kept });
11475
- if (speaker === "self")
11476
- remember(base.session, id);
11640
+ spool({ ...base, at: when, kind: "message", id, speaker, ...kept });
11477
11641
  };
11642
+ const baseline = baselineFile(host, base.session);
11643
+ if (event === "UserPromptSubmit") {
11644
+ if (!readBaseline(baseline)?.running) {
11645
+ const now = snapshot(place.root);
11646
+ if (now)
11647
+ writeBaseline(baseline, { ...now, running: true });
11648
+ }
11649
+ }
11478
11650
  if (event === "UserPromptSubmit" && input.prompt) {
11479
11651
  const prompt = input.prompt.trimStart();
11480
11652
  if (!INJECTED.some((r) => r.test(prompt)))
11481
- say(`${turn}:self`, "self", prompt);
11653
+ say(`${turn}:owner`, "owner", prompt);
11482
11654
  }
11483
11655
  if (event === "Stop") {
11484
11656
  if (input.last_assistant_message)
11485
11657
  say(`${turn}:assistant`, "assistant", input.last_assistant_message);
11658
+ const now = snapshot(place.root);
11659
+ if (now) {
11660
+ const before = readBaseline(baseline);
11661
+ if (before)
11662
+ for (const p of changed(place.root, before, now))
11663
+ spool({ ...base, kind: "edit", event: null, path: p, via: "status" });
11664
+ writeBaseline(baseline, { ...now, running: false });
11665
+ }
11486
11666
  return { flush: true };
11487
11667
  }
11488
11668
  if (event === "PostToolUse") {
@@ -11490,31 +11670,31 @@ function onHook(host, input) {
11490
11670
  const ti = input.tool_input ?? {};
11491
11671
  if (tool === "AskUserQuestion") {
11492
11672
  const said = answersOf(input);
11493
- if (said)
11494
- say(`${turn}:ask:${input.tool_use_id ?? at}`, "self", said);
11673
+ if (said) {
11674
+ const id = `${turn}:ask:${input.tool_use_id ?? at}`;
11675
+ say(`${id}:q`, "assistant", said.questions, new Date(Date.parse(at) - 1).toISOString());
11676
+ say(id, "owner", said.answers);
11677
+ }
11495
11678
  return { flush: false };
11496
11679
  }
11497
11680
  if (tool === "Read")
11498
11681
  return { flush: false };
11499
- const message = lastSaid(base.session);
11500
- if (!message)
11501
- return { flush: false };
11502
11682
  const cwd = input.cwd ?? place.root;
11503
11683
  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
11684
  for (const p of files)
11505
- spool({ ...base, kind: "file", message, path: p, action: "edit" });
11685
+ spool({ ...base, kind: "edit", event: input.tool_use_id ?? null, path: p, via: "tool" });
11506
11686
  }
11507
11687
  return { flush: false };
11508
11688
  }
11509
11689
  function writeState(s) {
11510
11690
  try {
11511
- fs3.writeFileSync(stateFile(), JSON.stringify(s));
11691
+ fs4.writeFileSync(stateFile(), JSON.stringify(s));
11512
11692
  } catch {}
11513
11693
  }
11514
11694
  function readState() {
11515
11695
  const count = (dir) => {
11516
11696
  try {
11517
- return fs3.readdirSync(dir).filter((f) => f.endsWith(".json") && !f.startsWith(".")).length;
11697
+ return fs4.readdirSync(dir).filter((f) => f.endsWith(".json") && !f.startsWith(".")).length;
11518
11698
  } catch {
11519
11699
  return 0;
11520
11700
  }
@@ -11526,7 +11706,7 @@ function readState() {
11526
11706
  };
11527
11707
  let raw = {};
11528
11708
  try {
11529
- const parsed = JSON.parse(fs3.readFileSync(stateFile(), "utf8"));
11709
+ const parsed = JSON.parse(fs4.readFileSync(stateFile(), "utf8"));
11530
11710
  if (parsed && typeof parsed === "object")
11531
11711
  raw = parsed;
11532
11712
  } catch {}
@@ -11540,25 +11720,25 @@ function readState() {
11540
11720
  };
11541
11721
  }
11542
11722
  function lock() {
11543
- const file = path3.join(spoolDir(), ".lock");
11544
- fs3.mkdirSync(spoolDir(), { recursive: true, mode: 448 });
11723
+ const file = path5.join(spoolDir(), ".lock");
11724
+ fs4.mkdirSync(spoolDir(), { recursive: true, mode: 448 });
11545
11725
  for (let attempt = 0;attempt < 2; attempt++) {
11546
11726
  try {
11547
- fs3.writeFileSync(file, String(process.pid), { flag: "wx", mode: 384 });
11727
+ fs4.writeFileSync(file, String(process.pid), { flag: "wx", mode: 384 });
11548
11728
  return () => {
11549
11729
  try {
11550
- if (fs3.readFileSync(file, "utf8") === String(process.pid))
11551
- fs3.rmSync(file, { force: true });
11730
+ if (fs4.readFileSync(file, "utf8") === String(process.pid))
11731
+ fs4.rmSync(file, { force: true });
11552
11732
  } catch {}
11553
11733
  };
11554
11734
  } catch (e) {
11555
11735
  if (e.code !== "EEXIST")
11556
11736
  throw e;
11557
11737
  }
11558
- const st = fs3.statSync(file, { throwIfNoEntry: false });
11738
+ const st = fs4.statSync(file, { throwIfNoEntry: false });
11559
11739
  if (!st)
11560
11740
  continue;
11561
- const holder = Number(fs3.readFileSync(file, "utf8") || 0);
11741
+ const holder = Number(fs4.readFileSync(file, "utf8") || 0);
11562
11742
  const fresh = Date.now() - st.mtimeMs < 5 * 60000;
11563
11743
  const alive = (() => {
11564
11744
  if (holder <= 0)
@@ -11571,7 +11751,7 @@ function lock() {
11571
11751
  })();
11572
11752
  if (alive && fresh)
11573
11753
  return null;
11574
- fs3.rmSync(file, { force: true });
11754
+ fs4.rmSync(file, { force: true });
11575
11755
  }
11576
11756
  return null;
11577
11757
  }
@@ -11580,27 +11760,21 @@ var ROWS = 1000;
11580
11760
  var chunks = (xs, n = ROWS) => Array.from({ length: Math.ceil(xs.length / n) }, (_, i) => xs.slice(i * n, (i + 1) * n));
11581
11761
  async function write(db, batch, projects) {
11582
11762
  return inTransaction(db, async (trx) => {
11583
- const conversations = new Map;
11763
+ const sessions = new Map;
11584
11764
  for (const r of batch) {
11585
11765
  const p = projects.get(r.project);
11586
11766
  if (!p)
11587
11767
  continue;
11588
- const id = conversationId(p.id, r.host, r.session);
11589
- const prev = conversations.get(id);
11768
+ const id = sessionId(p.id, r.host, r.session);
11769
+ const prev = sessions.get(id);
11590
11770
  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
- });
11771
+ sessions.set(id, { project: p.id, host: r.host, session: r.session, branch: r.branch, at: r.at });
11598
11772
  }
11599
- for (const part of chunks([...conversations]))
11600
- await trx.insertInto("capture_conversation").values(part.map(([id, v]) => ({
11773
+ for (const part of chunks([...sessions]))
11774
+ await trx.insertInto("capture_session").values(part.map(([id, v]) => ({
11601
11775
  id,
11602
11776
  project_id: v.project,
11603
- origin: v.host,
11777
+ host: v.host,
11604
11778
  external_id: v.session,
11605
11779
  branch: v.branch,
11606
11780
  started_at: iso(v.at)
@@ -11609,44 +11783,49 @@ async function write(db, batch, projects) {
11609
11783
  const p = m.kind === "message" ? projects.get(m.project) : undefined;
11610
11784
  if (m.kind !== "message" || !p)
11611
11785
  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;
11786
+ return [{ m, session: sessionId(p.id, m.host, m.session) }];
11787
+ });
11788
+ const present = async () => {
11789
+ let n = 0;
11790
+ for (const part of chunks(messages))
11791
+ for (const [session, ids] of Map.groupBy(part, (x) => x.session))
11792
+ 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;
11793
+ return n;
11794
+ };
11795
+ const before = await present();
11796
+ const now = iso(Date.now());
11797
+ for (const part of chunks(messages))
11619
11798
  await trx.insertInto("capture_message").values(part.map((x) => ({
11620
- id: x.id,
11621
- conversation_id: x.conversation,
11622
11799
  external_id: x.m.id,
11800
+ session_id: x.session,
11623
11801
  turn_id: x.m.turn,
11624
- speaker_kind: x.m.speaker,
11625
- body: x.m.body,
11802
+ speaker: x.m.speaker,
11803
+ created_at: iso(x.m.at),
11804
+ captured_at: now,
11805
+ text: x.m.body,
11626
11806
  truncated: x.m.truncated ? 1 : 0,
11807
+ redacted: x.m.redacted ? 1 : 0,
11627
11808
  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
11809
+ content_hash: sha256(x.m.body)
11631
11810
  }))).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)
11811
+ const after = await present();
11812
+ const edits = batch.flatMap((r) => {
11813
+ const p = r.kind === "edit" ? projects.get(r.project) : undefined;
11814
+ if (r.kind !== "edit" || !p)
11639
11815
  return [];
11640
11816
  return [
11641
11817
  {
11642
- message_id: uuidFrom(conversationId(p.id, r.host, r.session), r.message),
11818
+ session_id: sessionId(p.id, r.host, r.session),
11819
+ turn_id: r.turn,
11820
+ tool_event_id: r.event,
11643
11821
  path: r.path,
11644
- action: r.action
11822
+ via: r.via,
11823
+ observed_at: iso(r.at)
11645
11824
  }
11646
11825
  ];
11647
11826
  });
11648
- for (const part of chunks(files))
11649
- await trx.insertInto("capture_message_file").values(part).execute();
11827
+ for (const part of chunks(edits))
11828
+ await trx.insertInto("capture_edit").values(part).execute();
11650
11829
  return after - before;
11651
11830
  });
11652
11831
  }
@@ -11662,7 +11841,7 @@ async function flush(file = dbFile()) {
11662
11841
  const held = unregisteredDir();
11663
11842
  const list = (from) => {
11664
11843
  try {
11665
- return fs3.readdirSync(from).filter((f) => f.endsWith(".json") && !f.startsWith(".")).map((name) => ({ name, from }));
11844
+ return fs4.readdirSync(from).filter((f) => f.endsWith(".json") && !f.startsWith(".")).map((name) => ({ name, from }));
11666
11845
  } catch {
11667
11846
  return [];
11668
11847
  }
@@ -11672,15 +11851,18 @@ async function flush(file = dbFile()) {
11672
11851
  return { sent: 0, deferred: 0, rejected: 0 };
11673
11852
  const records = [];
11674
11853
  for (const { name, from } of names) {
11854
+ let r;
11675
11855
  try {
11676
- records.push({
11677
- name,
11678
- from,
11679
- r: JSON.parse(fs3.readFileSync(path3.join(from, name), "utf8"))
11680
- });
11856
+ r = current(JSON.parse(fs4.readFileSync(path5.join(from, name), "utf8")));
11681
11857
  } catch {
11682
- fs3.rmSync(path3.join(from, name), { force: true });
11858
+ fs4.mkdirSync(rejectedDir(), { recursive: true, mode: 448 });
11859
+ fs4.renameSync(path5.join(from, name), path5.join(rejectedDir(), name));
11860
+ continue;
11683
11861
  }
11862
+ if (r)
11863
+ records.push({ name, from, r });
11864
+ else
11865
+ fs4.rmSync(path5.join(from, name), { force: true });
11684
11866
  }
11685
11867
  const db = openWriter("capture", file);
11686
11868
  client = db;
@@ -11694,8 +11876,7 @@ async function flush(file = dbFile()) {
11694
11876
  } catch (e) {
11695
11877
  if (!rejected(e))
11696
11878
  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) {
11879
+ for (const x of known) {
11699
11880
  try {
11700
11881
  sent += await write(db, [x.r], projects);
11701
11882
  } catch (e2) {
@@ -11705,15 +11886,11 @@ async function flush(file = dbFile()) {
11705
11886
  }
11706
11887
  }
11707
11888
  }
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
11889
  if (bad.length) {
11713
- fs3.mkdirSync(rejectedDir(), { recursive: true, mode: 448 });
11890
+ fs4.mkdirSync(rejectedDir(), { recursive: true, mode: 448 });
11714
11891
  for (const x of bad) {
11715
11892
  try {
11716
- fs3.renameSync(path3.join(x.from, x.name), path3.join(rejectedDir(), x.name));
11893
+ fs4.renameSync(path5.join(x.from, x.name), path5.join(rejectedDir(), x.name));
11717
11894
  } catch (e) {
11718
11895
  if (e.code !== "ENOENT")
11719
11896
  throw e;
@@ -11721,12 +11898,12 @@ async function flush(file = dbFile()) {
11721
11898
  }
11722
11899
  }
11723
11900
  if (strayed.length) {
11724
- fs3.mkdirSync(held, { recursive: true, mode: 448 });
11901
+ fs4.mkdirSync(held, { recursive: true, mode: 448 });
11725
11902
  for (const x of strayed) {
11726
11903
  if (x.from === held)
11727
11904
  continue;
11728
11905
  try {
11729
- fs3.renameSync(path3.join(x.from, x.name), path3.join(held, x.name));
11906
+ fs4.renameSync(path5.join(x.from, x.name), path5.join(held, x.name));
11730
11907
  } catch (e) {
11731
11908
  if (e.code !== "ENOENT")
11732
11909
  throw e;
@@ -11736,7 +11913,7 @@ async function flush(file = dbFile()) {
11736
11913
  const moved = new Set([...bad, ...strayed].map((x) => x.name));
11737
11914
  for (const x of records)
11738
11915
  if (!moved.has(x.name))
11739
- fs3.rmSync(path3.join(x.from, x.name), { force: true });
11916
+ fs4.rmSync(path5.join(x.from, x.name), { force: true });
11740
11917
  prune(held);
11741
11918
  writeState({ flushedAt: new Date().toISOString(), error: null, deferred: strayed.length });
11742
11919
  return { sent, deferred: strayed.length, rejected: bad.length };
@@ -11776,11 +11953,12 @@ if (process.argv[1] && /capture\.(ts|js)$/.test(process.argv[1])) {
11776
11953
  export {
11777
11954
  MAX_MESSAGE,
11778
11955
  answersOf,
11956
+ branchOf,
11779
11957
  captureNotice,
11958
+ current,
11780
11959
  fit,
11781
11960
  flush,
11782
11961
  isOwnerTurn,
11783
- lock,
11784
11962
  onHook,
11785
11963
  readInput,
11786
11964
  readState,