sphica 0.3.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 = 5;
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,19 +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([
10860
- "refs",
10861
- "downsides",
10862
- "next",
10863
- "metadata",
10864
- "connectors",
10865
- "files",
10866
- "handles",
10867
- "paths"
10868
- ]);
10877
+ var JSON_COLUMNS = new Set(["next"]);
10869
10878
  var TOP_LEVEL = /^\$\[\d+\]\."([^"]+)"$/;
10870
10879
  var parseJson = new ParseJSONResultsPlugin({
10871
10880
  shouldParse: (_value, jsonPath) => JSON_COLUMNS.has(jsonPath.match(TOP_LEVEL)?.[1] ?? "")
@@ -10910,13 +10919,27 @@ var HIRAGANA_ONLY = /^[\p{Script=Hiragana}ー]+$/u;
10910
10919
  var STOP = new Set(["the", "a", "an", "of", "to", "in", "is", "and", "or", "for", "on", "it", "be"]);
10911
10920
  var IDENT = /#\d+|[a-z0-9][a-z0-9_./#-]*[a-z0-9]/g;
10912
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
+ }
10913
10936
  function terms(text) {
10914
10937
  const norm = text.normalize("NFKC").toLowerCase();
10915
10938
  const out = [];
10916
10939
  const keep = (w) => {
10917
10940
  if (w.length > MAX_TERM || STOP.has(w) || HIRAGANA_ONLY.test(w))
10918
10941
  return;
10919
- out.push(w);
10942
+ out.push(OKURIGANA.exec(w)?.[1] ?? singular(w));
10920
10943
  };
10921
10944
  for (const s of segmenter.segment(norm))
10922
10945
  if (s.isWordLike)
@@ -10926,6 +10949,48 @@ function terms(text) {
10926
10949
  keep(m[0]);
10927
10950
  return out.filter(Boolean);
10928
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
+ ]);
10929
10994
  var sha256 = (s) => crypto.createHash("sha256").update(s).digest();
10930
10995
  function uuidFrom(...parts) {
10931
10996
  const b = crypto.createHash("sha256").update(parts.join("\x00")).digest().subarray(0, 16);
@@ -11119,16 +11184,21 @@ var DDL = () => ddl ??= new Set([
11119
11184
  return code;
11120
11185
  }));
11121
11186
  var readsDataVersion = (p1, p2) => p1 === "data_version" && p2 === null;
11122
- 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"]);
11123
11188
  var TRIGGER_WRITES = {
11124
- capture_conversation_insert: new Set(["conversation"]),
11125
- capture_message_insert: new Set(["message"]),
11126
- capture_message_file_insert: new Set(["message_file"]),
11127
- 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"])
11128
11197
  };
11129
11198
  var CAPTURE_READS = {
11130
11199
  project: new Set(["id", "key", "name"]),
11131
- message: new Set(["id"])
11200
+ session: new Set(["id"]),
11201
+ source: new Set(["id", "session_id", "external_id", "kind"])
11132
11202
  };
11133
11203
  function captureAuthorizer(own, action, p1, p2, triggerOrView) {
11134
11204
  const table = p1 ?? "";
@@ -11148,7 +11218,7 @@ function captureAuthorizer(own, action, p1, p2, triggerOrView) {
11148
11218
  return CAPTURE_READS[table]?.has(p2 ?? "") ? C2.SQLITE_OK : C2.SQLITE_DENY;
11149
11219
  }
11150
11220
  if (action === C2.SQLITE_FUNCTION)
11151
- 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;
11152
11222
  if (action === C2.SQLITE_PRAGMA)
11153
11223
  return readsDataVersion(p1, p2) ? C2.SQLITE_OK : C2.SQLITE_DENY;
11154
11224
  if (action === C2.SQLITE_SELECT || action === C2.SQLITE_TRANSACTION || action === C2.SQLITE_SAVEPOINT)
@@ -11168,7 +11238,7 @@ function connectWriter(role, file = dbFile(), create = false) {
11168
11238
  requireFile(file);
11169
11239
  const raw = new DatabaseSync2(file);
11170
11240
  try {
11171
- prepare(raw, role === "ingest");
11241
+ prepare(raw, create ? "none" : role === "ingest" ? "revision" : "generation");
11172
11242
  raw.function("sphica_terms", { deterministic: true }, (text) => terms(String(text ?? "")).join(" "));
11173
11243
  } catch (e) {
11174
11244
  raw.close();
@@ -11199,8 +11269,7 @@ function openWriter(role, file = dbFile()) {
11199
11269
  }
11200
11270
 
11201
11271
  // server/src/knowledge.ts
11202
- var indexesMessage = (origin, speakerKind) => speakerKind !== "bot" && !(origin !== "github" && speakerKind === "assistant");
11203
- var conversationId = (projectId, origin, externalId) => uuidFrom(String(projectId), origin, externalId);
11272
+ var sessionId = (projectId, host, externalId) => uuidFrom(String(projectId), host, externalId);
11204
11273
 
11205
11274
  // server/src/panel.ts
11206
11275
  import { stripVTControlCharacters, styleText } from "node:util";
@@ -11219,8 +11288,15 @@ var plain2 = (s) => visible(stripVTControlCharacters(s.replace(STRING_SEQUENCE,
11219
11288
  import { execFileSync } from "node:child_process";
11220
11289
  import fs2 from "node:fs";
11221
11290
  import os2 from "node:os";
11291
+ import path3 from "node:path";
11292
+
11293
+ // server/src/anchors.ts
11222
11294
  import path2 from "node:path";
11223
- 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");
11224
11300
  var LOCAL_KEY = /^[a-z0-9][a-z0-9._-]*$/;
11225
11301
  function normalizeRemote(url) {
11226
11302
  const raw = String(url ?? "").trim();
@@ -11265,32 +11341,32 @@ function localMap() {
11265
11341
  } catch {
11266
11342
  m = null;
11267
11343
  }
11268
- 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)))
11269
11345
  throw new Error(`${localFile()} is not a valid JSON project table. Fix or delete it, then name the project again.`);
11270
11346
  return m;
11271
11347
  }
11272
11348
  function identify(dir) {
11273
- const given = path2.resolve(dir);
11349
+ const given = path3.resolve(dir);
11274
11350
  const top = git(given, "rev-parse", "--show-toplevel");
11275
11351
  const root = top || given;
11276
11352
  const remote = top ? normalizeRemote(git(root, "remote", "get-url", "origin")) : null;
11277
11353
  if (remote)
11278
11354
  return { key: `git:${remote}`, root, name: remote.split("/").slice(1).join("/") || remote };
11279
11355
  const map = localMap();
11280
- for (let d = root;; d = path2.dirname(d)) {
11356
+ for (let d = root;; d = path3.dirname(d)) {
11281
11357
  const local = map[d];
11282
11358
  if (local && LOCAL_KEY.test(local))
11283
11359
  return { key: `local:${local}`, root: d, name: local };
11284
- if (top || path2.dirname(d) === d)
11360
+ if (top || path3.dirname(d) === d)
11285
11361
  return null;
11286
11362
  }
11287
11363
  }
11288
11364
  function relativeTo(root, file, cwd = root) {
11289
- const abs = path2.resolve(cwd, file);
11290
- const rel = path2.relative(root, abs);
11291
- 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))
11292
11368
  return null;
11293
- return rel.split(path2.sep).join("/");
11369
+ return rel.split(path3.sep).join("/");
11294
11370
  }
11295
11371
  function patchPaths(patch) {
11296
11372
  const out = [];
@@ -11303,23 +11379,104 @@ function patchPaths(patch) {
11303
11379
  return out;
11304
11380
  }
11305
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
+
11306
11441
  // server/src/capture.ts
11307
- var spoolDir = () => path3.join(os3.homedir(), ".sphica", "spool");
11308
- var stateFile = () => path3.join(os3.homedir(), ".sphica", "capture.json");
11309
- var rejectedDir = () => path3.join(spoolDir(), "rejected");
11310
- 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");
11311
11446
  var HOLD_DAYS = 30;
11312
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
+ }
11313
11468
  var MAX_MESSAGE = 128 * 1024;
11314
11469
  var KEEP = 8 * 1024;
11315
11470
  function fit(body) {
11316
11471
  const all = bytes(body);
11317
11472
  if (all <= MAX_MESSAGE) {
11318
11473
  const kept = mask(body);
11319
- return { body: kept, truncated: false, originalBytes: bytes(kept) };
11474
+ return { body: kept, truncated: false, redacted: kept !== body, originalBytes: all };
11320
11475
  }
11321
- const a = head(mask(head(body, KEEP * 2)), KEEP);
11322
- 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);
11323
11480
  const cut = all - bytes(a) - bytes(z);
11324
11481
  return {
11325
11482
  body: `${a}
@@ -11328,13 +11485,14 @@ function fit(body) {
11328
11485
 
11329
11486
  ${z}`,
11330
11487
  truncated: true,
11488
+ redacted: mask(start) !== start || mask(end) !== end,
11331
11489
  originalBytes: all
11332
11490
  };
11333
11491
  }
11334
11492
  function prune(held) {
11335
11493
  let files;
11336
11494
  try {
11337
- 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();
11338
11496
  } catch {
11339
11497
  return;
11340
11498
  }
@@ -11342,21 +11500,21 @@ function prune(held) {
11342
11500
  const stale = files.filter((f) => Number(f.split("-")[0]) < cutoff);
11343
11501
  const over = files.slice(0, Math.max(0, files.length - HOLD_MAX));
11344
11502
  for (const f of new Set([...stale, ...over]))
11345
- fs3.rmSync(path3.join(held, f), { force: true });
11503
+ fs4.rmSync(path5.join(held, f), { force: true });
11346
11504
  }
11347
11505
  function spool(record) {
11348
11506
  const dir = spoolDir();
11349
- fs3.mkdirSync(dir, { recursive: true, mode: 448 });
11507
+ fs4.mkdirSync(dir, { recursive: true, mode: 448 });
11350
11508
  const name = `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 10)}.json`;
11351
- const tmp = path3.join(dir, `.${name}`);
11352
- fs3.writeFileSync(tmp, JSON.stringify(record), { mode: 384 });
11353
- 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));
11354
11512
  }
11355
11513
  var branchOf = (root) => {
11356
11514
  try {
11357
- const dotgit = path3.join(root, ".git");
11358
- const gitdir = fs3.statSync(dotgit).isFile() ? path3.resolve(root, fs3.readFileSync(dotgit, "utf8").match(/^gitdir: (.+)$/m)?.[1]?.trim() ?? "") : dotgit;
11359
- 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();
11360
11518
  return h.startsWith("ref: refs/heads/") ? h.slice("ref: refs/heads/".length) : null;
11361
11519
  } catch {
11362
11520
  return null;
@@ -11377,63 +11535,30 @@ var INJECTED = [
11377
11535
  /^(?:Another Claude|A peer) session sent a message(?: while you were working)?:/
11378
11536
  ];
11379
11537
  var digest = (s) => sha256(s).toString("hex").slice(0, 16);
11380
- var saidDir = () => path3.join(spoolDir(), "said");
11381
- var RENAME_TRIES = 20;
11382
- var RENAME_WAIT_MS = 15;
11383
- var sleepSync = (ms) => {
11384
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
11385
- };
11386
- function remember(session, id) {
11387
- const dir = saidDir();
11388
- fs3.mkdirSync(dir, { recursive: true, mode: 448 });
11389
- const file = path3.join(dir, uuidFrom(session));
11390
- const tmp = `${file}.${process.pid}`;
11391
- fs3.writeFileSync(tmp, id, { mode: 384 });
11392
- for (let i = 0;; i++) {
11393
- try {
11394
- fs3.renameSync(tmp, file);
11395
- break;
11396
- } catch (e) {
11397
- const code = e.code;
11398
- if (i >= RENAME_TRIES || code !== "EPERM" && code !== "EBUSY") {
11399
- fs3.rmSync(tmp, { force: true });
11400
- throw e;
11401
- }
11402
- sleepSync(RENAME_WAIT_MS);
11403
- }
11404
- }
11405
- const old = Date.now() - 30 * 86400000;
11406
- for (const f of fs3.readdirSync(dir)) {
11407
- const st = fs3.statSync(path3.join(dir, f), { throwIfNoEntry: false });
11408
- if (st && st.mtimeMs < old)
11409
- fs3.rmSync(path3.join(dir, f), { force: true });
11410
- }
11411
- }
11412
- function lastSaid(session) {
11413
- try {
11414
- return fs3.readFileSync(path3.join(saidDir(), uuidFrom(session)), "utf8");
11415
- } catch {
11416
- return null;
11417
- }
11418
- }
11419
11538
  function answersOf(input) {
11420
11539
  const response = input.tool_response;
11421
11540
  const answers = response?.answers;
11422
11541
  if (!answers || typeof answers !== "object")
11423
11542
  return null;
11424
- const lines = Object.entries(answers).map(([q, a]) => {
11425
- const notes = response?.annotations?.[q]?.notes;
11426
- 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() ? `
11427
11553
  Notes: ${notes.trim()}` : "";
11428
- return `Q: ${q}
11429
- A: ${Array.isArray(a) ? a.join(" / ") : String(a)}${memo}`;
11430
- });
11431
- return lines.length ? lines.join(`
11554
+ return `A${i + 1}: ${Array.isArray(a) ? a.join(" / ") : String(a)}${memo}`;
11555
+ }).join(`
11432
11556
 
11433
- `) : null;
11557
+ `)
11558
+ };
11434
11559
  }
11435
11560
  function captureNotice(file = dbFile()) {
11436
- if (!fs3.existsSync(file))
11561
+ if (!fs4.existsSync(file))
11437
11562
  return panel("sphica: no database, so conversations are not recorded", [file], "Create it with sphica init");
11438
11563
  const s = readState();
11439
11564
  if (s.stuck)
@@ -11442,6 +11567,34 @@ function captureNotice(file = dbFile()) {
11442
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");
11443
11568
  return null;
11444
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
+ }
11445
11598
  function onHook(host, input) {
11446
11599
  const event = input.hook_event_name;
11447
11600
  const owner = () => isOwnerTurn(input, undefined, undefined, host === "codex" ? process.env.CODEX_THREAD_ID : undefined);
@@ -11450,9 +11603,14 @@ function onHook(host, input) {
11450
11603
  return { flush: false };
11451
11604
  const file = process.env.CLAUDE_ENV_FILE;
11452
11605
  if (file && input.session_id && /^[A-Za-z0-9_-]+$/.test(input.session_id)) {
11453
- fs3.appendFileSync(file, `export SPHICA_PARENT_SESSION=${input.session_id}
11606
+ fs4.appendFileSync(file, `export SPHICA_PARENT_SESSION=${input.session_id}
11454
11607
  `);
11455
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();
11456
11614
  return { flush: false, notice: captureNotice() };
11457
11615
  }
11458
11616
  if (!owner())
@@ -11467,7 +11625,7 @@ function onHook(host, input) {
11467
11625
  return { flush: false };
11468
11626
  const at = new Date().toISOString();
11469
11627
  const base = {
11470
- v: 1,
11628
+ v: 2,
11471
11629
  host,
11472
11630
  session: String(input.session_id),
11473
11631
  project: place.key,
@@ -11475,23 +11633,37 @@ function onHook(host, input) {
11475
11633
  turn,
11476
11634
  at
11477
11635
  };
11478
- const say = (key, speaker, raw) => {
11636
+ const say = (key, speaker, raw, when = at) => {
11479
11637
  const kept = fit(clean(raw).trim());
11480
11638
  if (!kept.body.trim())
11481
11639
  return;
11482
11640
  const id = `${key}:${digest(kept.body)}`;
11483
- spool({ ...base, kind: "message", id, speaker, ...kept });
11484
- if (speaker === "self")
11485
- remember(base.session, id);
11641
+ spool({ ...base, at: when, kind: "message", id, speaker, ...kept });
11486
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
+ }
11487
11651
  if (event === "UserPromptSubmit" && input.prompt) {
11488
11652
  const prompt = input.prompt.trimStart();
11489
11653
  if (!INJECTED.some((r) => r.test(prompt)))
11490
- say(`${turn}:self`, "self", prompt);
11654
+ say(`${turn}:owner`, "owner", prompt);
11491
11655
  }
11492
11656
  if (event === "Stop") {
11493
11657
  if (input.last_assistant_message)
11494
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
+ }
11495
11667
  return { flush: true };
11496
11668
  }
11497
11669
  if (event === "PostToolUse") {
@@ -11499,31 +11671,31 @@ function onHook(host, input) {
11499
11671
  const ti = input.tool_input ?? {};
11500
11672
  if (tool === "AskUserQuestion") {
11501
11673
  const said = answersOf(input);
11502
- if (said)
11503
- 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
+ }
11504
11679
  return { flush: false };
11505
11680
  }
11506
11681
  if (tool === "Read")
11507
11682
  return { flush: false };
11508
- const message = lastSaid(base.session);
11509
- if (!message)
11510
- return { flush: false };
11511
11683
  const cwd = input.cwd ?? place.root;
11512
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) ?? []);
11513
11685
  for (const p of files)
11514
- spool({ ...base, kind: "file", message, path: p, action: "edit" });
11686
+ spool({ ...base, kind: "edit", event: input.tool_use_id ?? null, path: p, via: "tool" });
11515
11687
  }
11516
11688
  return { flush: false };
11517
11689
  }
11518
11690
  function writeState(s) {
11519
11691
  try {
11520
- fs3.writeFileSync(stateFile(), JSON.stringify(s));
11692
+ fs4.writeFileSync(stateFile(), JSON.stringify(s));
11521
11693
  } catch {}
11522
11694
  }
11523
11695
  function readState() {
11524
11696
  const count = (dir) => {
11525
11697
  try {
11526
- 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;
11527
11699
  } catch {
11528
11700
  return 0;
11529
11701
  }
@@ -11535,7 +11707,7 @@ function readState() {
11535
11707
  };
11536
11708
  let raw = {};
11537
11709
  try {
11538
- const parsed = JSON.parse(fs3.readFileSync(stateFile(), "utf8"));
11710
+ const parsed = JSON.parse(fs4.readFileSync(stateFile(), "utf8"));
11539
11711
  if (parsed && typeof parsed === "object")
11540
11712
  raw = parsed;
11541
11713
  } catch {}
@@ -11549,25 +11721,25 @@ function readState() {
11549
11721
  };
11550
11722
  }
11551
11723
  function lock() {
11552
- const file = path3.join(spoolDir(), ".lock");
11553
- fs3.mkdirSync(spoolDir(), { recursive: true, mode: 448 });
11724
+ const file = path5.join(spoolDir(), ".lock");
11725
+ fs4.mkdirSync(spoolDir(), { recursive: true, mode: 448 });
11554
11726
  for (let attempt = 0;attempt < 2; attempt++) {
11555
11727
  try {
11556
- fs3.writeFileSync(file, String(process.pid), { flag: "wx", mode: 384 });
11728
+ fs4.writeFileSync(file, String(process.pid), { flag: "wx", mode: 384 });
11557
11729
  return () => {
11558
11730
  try {
11559
- if (fs3.readFileSync(file, "utf8") === String(process.pid))
11560
- fs3.rmSync(file, { force: true });
11731
+ if (fs4.readFileSync(file, "utf8") === String(process.pid))
11732
+ fs4.rmSync(file, { force: true });
11561
11733
  } catch {}
11562
11734
  };
11563
11735
  } catch (e) {
11564
11736
  if (e.code !== "EEXIST")
11565
11737
  throw e;
11566
11738
  }
11567
- const st = fs3.statSync(file, { throwIfNoEntry: false });
11739
+ const st = fs4.statSync(file, { throwIfNoEntry: false });
11568
11740
  if (!st)
11569
11741
  continue;
11570
- const holder = Number(fs3.readFileSync(file, "utf8") || 0);
11742
+ const holder = Number(fs4.readFileSync(file, "utf8") || 0);
11571
11743
  const fresh = Date.now() - st.mtimeMs < 5 * 60000;
11572
11744
  const alive = (() => {
11573
11745
  if (holder <= 0)
@@ -11580,7 +11752,7 @@ function lock() {
11580
11752
  })();
11581
11753
  if (alive && fresh)
11582
11754
  return null;
11583
- fs3.rmSync(file, { force: true });
11755
+ fs4.rmSync(file, { force: true });
11584
11756
  }
11585
11757
  return null;
11586
11758
  }
@@ -11589,27 +11761,21 @@ var ROWS = 1000;
11589
11761
  var chunks = (xs, n = ROWS) => Array.from({ length: Math.ceil(xs.length / n) }, (_, i) => xs.slice(i * n, (i + 1) * n));
11590
11762
  async function write(db, batch, projects) {
11591
11763
  return inTransaction(db, async (trx) => {
11592
- const conversations = new Map;
11764
+ const sessions = new Map;
11593
11765
  for (const r of batch) {
11594
11766
  const p = projects.get(r.project);
11595
11767
  if (!p)
11596
11768
  continue;
11597
- const id = conversationId(p.id, r.host, r.session);
11598
- const prev = conversations.get(id);
11769
+ const id = sessionId(p.id, r.host, r.session);
11770
+ const prev = sessions.get(id);
11599
11771
  if (!prev || Date.parse(r.at) < Date.parse(prev.at))
11600
- conversations.set(id, {
11601
- project: p.id,
11602
- host: r.host,
11603
- session: r.session,
11604
- branch: r.branch,
11605
- at: r.at
11606
- });
11772
+ sessions.set(id, { project: p.id, host: r.host, session: r.session, branch: r.branch, at: r.at });
11607
11773
  }
11608
- for (const part of chunks([...conversations]))
11609
- 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]) => ({
11610
11776
  id,
11611
11777
  project_id: v.project,
11612
- origin: v.host,
11778
+ host: v.host,
11613
11779
  external_id: v.session,
11614
11780
  branch: v.branch,
11615
11781
  started_at: iso(v.at)
@@ -11618,44 +11784,49 @@ async function write(db, batch, projects) {
11618
11784
  const p = m.kind === "message" ? projects.get(m.project) : undefined;
11619
11785
  if (m.kind !== "message" || !p)
11620
11786
  return [];
11621
- const conversation = conversationId(p.id, m.host, m.session);
11622
- return [{ m, conversation, id: uuidFrom(conversation, m.id) }];
11623
- });
11624
- let before = 0;
11625
- for (const part of chunks(messages)) {
11626
- const ids = part.map((x) => x.id);
11627
- 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))
11628
11799
  await trx.insertInto("capture_message").values(part.map((x) => ({
11629
- id: x.id,
11630
- conversation_id: x.conversation,
11631
11800
  external_id: x.m.id,
11801
+ session_id: x.session,
11632
11802
  turn_id: x.m.turn,
11633
- speaker_kind: x.m.speaker,
11634
- 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,
11635
11807
  truncated: x.m.truncated ? 1 : 0,
11808
+ redacted: x.m.redacted ? 1 : 0,
11636
11809
  original_bytes: x.m.originalBytes,
11637
- sent_at: iso(x.m.at),
11638
- content_hash: sha256(x.m.body),
11639
- indexed: indexesMessage(x.m.host, x.m.speaker) ? 1 : 0
11810
+ content_hash: sha256(x.m.body)
11640
11811
  }))).execute();
11641
- }
11642
- let after = 0;
11643
- for (const part of chunks(messages))
11644
- after += (await trx.selectFrom("message").select("id").where("id", "in", part.map((x) => x.id)).execute()).length;
11645
- const files = batch.flatMap((r) => {
11646
- const p = r.kind === "file" ? projects.get(r.project) : undefined;
11647
- 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)
11648
11816
  return [];
11649
11817
  return [
11650
11818
  {
11651
- 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,
11652
11822
  path: r.path,
11653
- action: r.action
11823
+ via: r.via,
11824
+ observed_at: iso(r.at)
11654
11825
  }
11655
11826
  ];
11656
11827
  });
11657
- for (const part of chunks(files))
11658
- 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();
11659
11830
  return after - before;
11660
11831
  });
11661
11832
  }
@@ -11671,7 +11842,7 @@ async function flush(file = dbFile()) {
11671
11842
  const held = unregisteredDir();
11672
11843
  const list = (from) => {
11673
11844
  try {
11674
- 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 }));
11675
11846
  } catch {
11676
11847
  return [];
11677
11848
  }
@@ -11681,15 +11852,18 @@ async function flush(file = dbFile()) {
11681
11852
  return { sent: 0, deferred: 0, rejected: 0 };
11682
11853
  const records = [];
11683
11854
  for (const { name, from } of names) {
11855
+ let r;
11684
11856
  try {
11685
- records.push({
11686
- name,
11687
- from,
11688
- r: JSON.parse(fs3.readFileSync(path3.join(from, name), "utf8"))
11689
- });
11857
+ r = current(JSON.parse(fs4.readFileSync(path5.join(from, name), "utf8")));
11690
11858
  } catch {
11691
- 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;
11692
11862
  }
11863
+ if (r)
11864
+ records.push({ name, from, r });
11865
+ else
11866
+ fs4.rmSync(path5.join(from, name), { force: true });
11693
11867
  }
11694
11868
  const db = openWriter("capture", file);
11695
11869
  client = db;
@@ -11703,8 +11877,7 @@ async function flush(file = dbFile()) {
11703
11877
  } catch (e) {
11704
11878
  if (!rejected(e))
11705
11879
  throw e;
11706
- const ordered = [...known].sort((a, b) => Number(a.r.kind === "file") - Number(b.r.kind === "file"));
11707
- for (const x of ordered) {
11880
+ for (const x of known) {
11708
11881
  try {
11709
11882
  sent += await write(db, [x.r], projects);
11710
11883
  } catch (e2) {
@@ -11714,15 +11887,11 @@ async function flush(file = dbFile()) {
11714
11887
  }
11715
11888
  }
11716
11889
  }
11717
- const lost = new Set(bad.flatMap((x) => x.r.kind === "message" && x.r.speaker === "self" ? [`${x.r.session}\x00${x.r.id}`] : []));
11718
- for (const x of known)
11719
- if (x.r.kind === "file" && lost.has(`${x.r.session}\x00${x.r.message}`) && !bad.includes(x))
11720
- bad.push(x);
11721
11890
  if (bad.length) {
11722
- fs3.mkdirSync(rejectedDir(), { recursive: true, mode: 448 });
11891
+ fs4.mkdirSync(rejectedDir(), { recursive: true, mode: 448 });
11723
11892
  for (const x of bad) {
11724
11893
  try {
11725
- 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));
11726
11895
  } catch (e) {
11727
11896
  if (e.code !== "ENOENT")
11728
11897
  throw e;
@@ -11730,12 +11899,12 @@ async function flush(file = dbFile()) {
11730
11899
  }
11731
11900
  }
11732
11901
  if (strayed.length) {
11733
- fs3.mkdirSync(held, { recursive: true, mode: 448 });
11902
+ fs4.mkdirSync(held, { recursive: true, mode: 448 });
11734
11903
  for (const x of strayed) {
11735
11904
  if (x.from === held)
11736
11905
  continue;
11737
11906
  try {
11738
- 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));
11739
11908
  } catch (e) {
11740
11909
  if (e.code !== "ENOENT")
11741
11910
  throw e;
@@ -11745,7 +11914,7 @@ async function flush(file = dbFile()) {
11745
11914
  const moved = new Set([...bad, ...strayed].map((x) => x.name));
11746
11915
  for (const x of records)
11747
11916
  if (!moved.has(x.name))
11748
- fs3.rmSync(path3.join(x.from, x.name), { force: true });
11917
+ fs4.rmSync(path5.join(x.from, x.name), { force: true });
11749
11918
  prune(held);
11750
11919
  writeState({ flushedAt: new Date().toISOString(), error: null, deferred: strayed.length });
11751
11920
  return { sent, deferred: strayed.length, rejected: bad.length };
@@ -11785,11 +11954,12 @@ if (process.argv[1] && /capture\.(ts|js)$/.test(process.argv[1])) {
11785
11954
  export {
11786
11955
  MAX_MESSAGE,
11787
11956
  answersOf,
11957
+ branchOf,
11788
11958
  captureNotice,
11959
+ current,
11789
11960
  fit,
11790
11961
  flush,
11791
11962
  isOwnerTurn,
11792
- lock,
11793
11963
  onHook,
11794
11964
  readInput,
11795
11965
  readState,