sphica 0.5.6 → 0.6.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.
@@ -34719,9 +34719,6 @@ class StdioServerTransport {
34719
34719
  }
34720
34720
  }
34721
34721
 
34722
- // server/src/db-write.ts
34723
- import { constants as C2, DatabaseSync as DatabaseSync2 } from "node:sqlite";
34724
-
34725
34722
  // server/node_modules/kysely/dist/util/object-utils.js
34726
34723
  function isUndefined(obj) {
34727
34724
  return typeof obj === "undefined" || obj === undefined;
@@ -45530,7 +45527,7 @@ import os from "node:os";
45530
45527
  import path from "node:path";
45531
45528
  import { constants as C, DatabaseSync } from "node:sqlite";
45532
45529
  var SCHEMA_GENERATION = 2;
45533
- var SCHEMA_REVISION = 1;
45530
+ var SCHEMA_REVISION = 2;
45534
45531
  var sphicaHome = () => process.env.SPHICA_HOME || path.join(os.homedir(), ".sphica");
45535
45532
  var dbFile = () => process.env.SPHICA_DB || path.join(sphicaHome(), "sphica.db");
45536
45533
  function requireRuntime() {
@@ -45554,7 +45551,7 @@ function prepare(raw, check2) {
45554
45551
  const got = raw.prepare("pragma user_version").get()?.user_version;
45555
45552
  if (got === SCHEMA_REVISION)
45556
45553
  return;
45557
- 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."));
45554
+ throw new Error(`The database schema is revision ${got}, but this Sphica expects revision ${SCHEMA_REVISION}. ` + ((got ?? 0) < SCHEMA_REVISION ? "Update the sphica CLI (`npm i -g sphica`), then run `sphica init` to migrate it (records are kept)." : "Update sphica."));
45558
45555
  }
45559
45556
  function generationOf(raw) {
45560
45557
  const has = raw.prepare("select 1 from sqlite_schema where type = 'table' and name = 'sphica_generation'").get();
@@ -45626,6 +45623,9 @@ function sqliteCode(e) {
45626
45623
  return x?.code === "ERR_SQLITE_ERROR" && typeof x.errcode === "number" ? x.errcode & 255 : null;
45627
45624
  }
45628
45625
 
45626
+ // server/src/db-write.ts
45627
+ import { constants as C2, DatabaseSync as DatabaseSync2 } from "node:sqlite";
45628
+
45629
45629
  // server/src/text.ts
45630
45630
  import crypto from "node:crypto";
45631
45631
  var segmenter = new Intl.Segmenter("ja", { granularity: "word" });
@@ -45822,27 +45822,80 @@ var MYSQL_PASSWORD = /(\s-p)(?:'[^'\n]*'|"[^"\n]*"|(?=[^\s-])\S+)/;
45822
45822
  var URL_CREDENTIALS = /\b((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|rediss?|amqps?|https?):\/\/[^:\s/@]*:)[^\s/]*@([^@\s/?#]+)/g;
45823
45823
  var KEY_BEGIN = /-----BEGIN [A-Z ]*PRIVATE KEY-----/g;
45824
45824
  var KEY_END = /-----END [A-Z ]*PRIVATE KEY-----/g;
45825
- function maskPrivateKeys(text) {
45825
+ function keyBlocks(text) {
45826
45826
  const ends = [...text.matchAll(KEY_END)].map((m) => [m.index, m.index + m[0].length]);
45827
- if (ends.length === 0)
45828
- return text;
45829
- let out = "";
45827
+ const out = [];
45830
45828
  let last = 0;
45831
45829
  let e = 0;
45832
- for (const m of text.matchAll(KEY_BEGIN)) {
45833
- const after = m.index + m[0].length;
45834
- if (m.index < last)
45835
- continue;
45836
- while (e < ends.length && (ends[e]?.[0] ?? 0) < after)
45837
- e++;
45838
- const end = ends[e];
45839
- if (!end)
45840
- break;
45841
- out += `${text.slice(last, m.index)}[redacted: private key]`;
45842
- last = end[1];
45830
+ if (ends.length > 0)
45831
+ for (const m of text.matchAll(KEY_BEGIN)) {
45832
+ const after = m.index + m[0].length;
45833
+ if (m.index < last)
45834
+ continue;
45835
+ while (e < ends.length && (ends[e]?.[0] ?? 0) < after)
45836
+ e++;
45837
+ const end = ends[e];
45838
+ if (!end)
45839
+ break;
45840
+ out.push([m.index, end[1]]);
45841
+ last = end[1];
45842
+ }
45843
+ return out;
45844
+ }
45845
+ function maskPrivateKeys(text) {
45846
+ let out = "";
45847
+ let last = 0;
45848
+ for (const [a, b] of keyBlocks(text)) {
45849
+ out += `${text.slice(last, a)}[redacted: private key]`;
45850
+ last = b;
45843
45851
  }
45844
45852
  return out + text.slice(last);
45845
45853
  }
45854
+ function privateKeyRanges(text) {
45855
+ return byteRanges(text, keyBlocks(text));
45856
+ }
45857
+ function byteRanges(text, ranges) {
45858
+ const out = [];
45859
+ let at = 0;
45860
+ let pos = 0;
45861
+ for (const [a, b] of ranges) {
45862
+ pos += bytes(text.slice(at, a));
45863
+ const end = pos + bytes(text.slice(a, b));
45864
+ out.push([pos, end]);
45865
+ at = b;
45866
+ pos = end;
45867
+ }
45868
+ return out;
45869
+ }
45870
+ var PLACEHOLDER = /\[redacted(?:: [^\]\n]{1,40})?\]/g;
45871
+ function placeholderRanges(masked) {
45872
+ return [...masked.matchAll(PLACEHOLDER)].map((x) => [x.index, x.index + x[0].length]);
45873
+ }
45874
+ function starts(hay, needle) {
45875
+ const out = [];
45876
+ for (let at = hay.indexOf(needle);at >= 0; at = hay.indexOf(needle, at + 1))
45877
+ out.push(at);
45878
+ return out;
45879
+ }
45880
+ function quoteSpan(raw, masked, quote) {
45881
+ const q = Buffer.from(quote, "utf8");
45882
+ if (q.length === 0)
45883
+ return null;
45884
+ const m = Buffer.from(masked, "utf8");
45885
+ if (raw === masked) {
45886
+ const at = m.indexOf(q);
45887
+ return at < 0 ? null : [at, at + q.length];
45888
+ }
45889
+ const holes = byteRanges(masked, placeholderRanges(masked));
45890
+ let h = 0;
45891
+ const kept = starts(m, q).filter((at) => {
45892
+ while ((holes[h]?.[1] ?? Number.POSITIVE_INFINITY) <= at)
45893
+ h++;
45894
+ return !((holes[h]?.[0] ?? Number.POSITIVE_INFINITY) < at + q.length);
45895
+ });
45896
+ const first = kept[0];
45897
+ return first !== undefined && kept.length === starts(Buffer.from(raw, "utf8"), q).length ? [first, first + q.length] : null;
45898
+ }
45846
45899
  function mask(text) {
45847
45900
  let out = maskFields(maskPrivateKeys(text).replace(URL_CREDENTIALS, "$1[redacted]@$2").replace(AUTH_HEADER, "$1[redacted]").replace(HEADER_BEARER, "$1[redacted]").replace(ENV_ASSIGN, "$1$2[redacted]")).replace(MYSQL_COMMAND, (command) => command.replace(MYSQL_PASSWORD, "$1[redacted]"));
45848
45901
  for (const [re, what] of SECRETS)
@@ -45940,22 +45993,54 @@ function captureAuthorizer(own2, action, p1, p2, triggerOrView) {
45940
45993
  return C2.SQLITE_DENY;
45941
45994
  }
45942
45995
  var writes = (action) => action === C2.SQLITE_INSERT || action === C2.SQLITE_UPDATE || action === C2.SQLITE_DELETE;
45996
+ var FORGET_ONLY = new Set(["forget_batch", "source_forgotten"]);
45997
+ var KEPT_BY_INGEST = new Set(["source", "unit_evidence", "unit_adoption"]);
45943
45998
  function ingestAuthorizer(action, p1, p2) {
45944
45999
  if (DDL().has(action))
45945
46000
  return C2.SQLITE_DENY;
45946
- if (writes(action) && p1 === "owner_identity")
46001
+ if (writes(action) && (p1 === "owner_identity" || FORGET_ONLY.has(p1 ?? "")))
46002
+ return C2.SQLITE_DENY;
46003
+ if (action === C2.SQLITE_DELETE && KEPT_BY_INGEST.has(p1 ?? ""))
45947
46004
  return C2.SQLITE_DENY;
45948
46005
  if (action === C2.SQLITE_PRAGMA)
45949
46006
  return readsDataVersion(p1, p2) ? C2.SQLITE_OK : C2.SQLITE_DENY;
45950
46007
  return C2.SQLITE_OK;
45951
46008
  }
46009
+ var FORGET_WRITES = {
46010
+ [C2.SQLITE_INSERT]: new Set(["forget_batch", "source_forgotten", "unit_state", "source_fts"]),
46011
+ [C2.SQLITE_DELETE]: new Set([
46012
+ "source",
46013
+ "external_reference",
46014
+ "unit_evidence",
46015
+ "unit_adoption",
46016
+ "source_processing",
46017
+ "source_fts"
46018
+ ])
46019
+ };
46020
+ var FORGET_UPDATES = {
46021
+ unit: new Set(["lifecycle", "revision"]),
46022
+ unit_state: new Set(["source_id"])
46023
+ };
46024
+ function forgetAuthorizer(action, p1, p2) {
46025
+ if (SHADOW.test(p1 ?? "") && writes(action))
46026
+ return C2.SQLITE_OK;
46027
+ if (action === C2.SQLITE_UPDATE)
46028
+ return FORGET_UPDATES[p1 ?? ""]?.has(p2 ?? "") ? C2.SQLITE_OK : C2.SQLITE_DENY;
46029
+ if (action === C2.SQLITE_INSERT || action === C2.SQLITE_DELETE)
46030
+ return FORGET_WRITES[action]?.has(p1 ?? "") ? C2.SQLITE_OK : C2.SQLITE_DENY;
46031
+ if (action === C2.SQLITE_PRAGMA)
46032
+ return readsDataVersion(p1, p2) || p1 === "secure_delete" && ["on", "1", "true"].includes((p2 ?? "").toLowerCase()) || p1 === "wal_checkpoint" ? C2.SQLITE_OK : C2.SQLITE_DENY;
46033
+ if (action === C2.SQLITE_READ || action === C2.SQLITE_SELECT || action === C2.SQLITE_FUNCTION || action === C2.SQLITE_TRANSACTION || action === C2.SQLITE_SAVEPOINT)
46034
+ return C2.SQLITE_OK;
46035
+ return C2.SQLITE_DENY;
46036
+ }
45952
46037
  function connectWriter(role, file2 = dbFile(), create = false) {
45953
46038
  requireRuntime();
45954
46039
  if (!create)
45955
46040
  requireFile(file2);
45956
46041
  const raw = new DatabaseSync2(file2);
45957
46042
  try {
45958
- prepare(raw, create ? "none" : role === "ingest" ? "revision" : "generation");
46043
+ prepare(raw, create ? "none" : role === "ingest" || role === "forget" ? "revision" : "generation");
45959
46044
  raw.function("sphica_terms", { deterministic: true }, (text) => terms(String(text ?? "")).join(" "));
45960
46045
  } catch (e) {
45961
46046
  raw.close();
@@ -45963,6 +46048,8 @@ function connectWriter(role, file2 = dbFile(), create = false) {
45963
46048
  }
45964
46049
  if (role === "ingest")
45965
46050
  raw.setAuthorizer(ingestAuthorizer);
46051
+ else if (role === "forget")
46052
+ raw.setAuthorizer(forgetAuthorizer);
45966
46053
  else if (role === "capture") {
45967
46054
  let own2 = false;
45968
46055
  raw.setAuthorizer((action, p1, p2, _db, triggerOrView) => captureAuthorizer(own2, action, p1, p2, triggerOrView));
@@ -46053,14 +46140,61 @@ function readText(root, rel) {
46053
46140
  function findSymbol(text, symbol2) {
46054
46141
  const re = new RegExp(`(?<![\\w$])${literal3(symbol2)}(?![\\w$])`);
46055
46142
  const lines = text.split(/\r?\n/);
46056
- const i = lines.findIndex((l) => re.test(l));
46057
- return i < 0 ? null : { line: i + 1, excerpt: (lines[i] ?? "").trim().slice(0, 200) };
46143
+ return { lines, i: lines.findIndex((l) => re.test(l)) };
46144
+ }
46145
+ function swallowed(root, rel, symbol2) {
46146
+ if (mask(symbol2) !== symbol2)
46147
+ return true;
46148
+ const text = root ? readText(root, rel) : null;
46149
+ if (text === undefined)
46150
+ return true;
46151
+ if (text === null)
46152
+ return false;
46153
+ const re = new RegExp(`(?<![\\w$])${literal3(symbol2)}(?![\\w$])`, "g");
46154
+ let raw = 0;
46155
+ for (let m = re.exec(text);m; m = re.exec(text))
46156
+ raw++;
46157
+ if (raw === 0)
46158
+ return false;
46159
+ const masked = mask(text);
46160
+ const holes = placeholderRanges(masked);
46161
+ let h = 0;
46162
+ let kept = 0;
46163
+ re.lastIndex = 0;
46164
+ for (let m = re.exec(masked);m; m = re.exec(masked)) {
46165
+ while ((holes[h]?.[1] ?? Number.POSITIVE_INFINITY) < m.index)
46166
+ h++;
46167
+ if (!((holes[h]?.[0] ?? Number.POSITIVE_INFINITY) <= m.index + symbol2.length))
46168
+ kept++;
46169
+ }
46170
+ return kept !== raw;
46171
+ }
46172
+ function masksSymbol(root, rel, symbol2) {
46173
+ const trimmed = symbol2.trim();
46174
+ return swallowed(root, rel, symbol2) || trimmed !== symbol2 && trimmed !== "" && swallowed(root, rel, trimmed);
46058
46175
  }
46059
46176
  function locate(root, rel, symbol2) {
46060
46177
  if (!root)
46061
46178
  return null;
46062
46179
  const text = readText(root, rel);
46063
- return typeof text === "string" ? findSymbol(text, symbol2) : null;
46180
+ if (typeof text !== "string")
46181
+ return null;
46182
+ const { lines, i } = findSymbol(text, symbol2);
46183
+ if (i < 0)
46184
+ return null;
46185
+ const line = lines[i] ?? "";
46186
+ const from = bytes(lines.slice(0, i).join(`
46187
+ `)) + (i > 0 ? 1 : 0);
46188
+ if (privateKeyRanges(lines.join(`
46189
+ `)).some(([a, b]) => a < from + bytes(line) && b > from))
46190
+ return { line: i + 1, excerpt: "[redacted: private key]" };
46191
+ const before = lines.slice(0, i).map((l) => `${l}
46192
+ `).join("");
46193
+ const after = lines.slice(i + 1).map((l) => `
46194
+ ${l}`).join("");
46195
+ const own2 = mask(line);
46196
+ const cut = mask(before + line) !== mask(before) + own2 || mask(line + after) !== own2 + mask(after);
46197
+ return { line: i + 1, excerpt: cut ? "[redacted]" : own2.trim().slice(0, 200) };
46064
46198
  }
46065
46199
 
46066
46200
  // server/src/project.ts
@@ -46940,11 +47074,13 @@ async function storeItems(db, projectId2, items) {
46940
47074
  const kept = fit(it.text);
46941
47075
  const hash2 = sha256(kept.body);
46942
47076
  const latest = await db.selectFrom("source").select(["id", "revision", "content_hash"]).where("project_id", "=", projectId2).where("kind", "=", it.kind).where("external_id", "=", it.externalId).orderBy("revision", "desc").executeTakeFirst();
46943
- if (latest && Buffer.from(latest.content_hash).equals(hash2)) {
47077
+ const forgotten = await db.selectFrom("source_forgotten").select(["revision", "content_hash"]).where("project_id", "=", projectId2).where("kind", "=", it.kind).where("external_id", "=", it.externalId).execute();
47078
+ const lastForgotten = Math.max(0, ...forgotten.map((f) => f.revision));
47079
+ if (latest && latest.revision > lastForgotten && Buffer.from(latest.content_hash).equals(hash2)) {
46944
47080
  ids.push(latest.id);
46945
47081
  continue;
46946
47082
  }
46947
- if (!latest && !kept.body.trim()) {
47083
+ if (forgotten.some((f) => Buffer.from(f.content_hash).equals(hash2)) || !latest && !kept.body.trim()) {
46948
47084
  ids.push(null);
46949
47085
  continue;
46950
47086
  }
@@ -46957,7 +47093,7 @@ async function storeItems(db, projectId2, items) {
46957
47093
  kind: it.kind,
46958
47094
  artifact: it.artifact,
46959
47095
  external_id: it.externalId,
46960
- revision: (latest?.revision ?? 0) + 1,
47096
+ revision: Math.max(latest?.revision ?? 0, lastForgotten) + 1,
46961
47097
  author_kind: kind,
46962
47098
  author_login: it.author?.login ?? null,
46963
47099
  author_external_id: authorId,
@@ -46966,7 +47102,7 @@ async function storeItems(db, projectId2, items) {
46966
47102
  event_kind: it.event,
46967
47103
  url: it.url,
46968
47104
  created_at: created,
46969
- available_at: latest ? null : created,
47105
+ available_at: latest || lastForgotten ? null : created,
46970
47106
  captured_at: now,
46971
47107
  text: kept.body,
46972
47108
  truncated: kept.truncated ? 1 : 0,
@@ -47002,7 +47138,7 @@ async function pullSources(db, projectId2, number5) {
47002
47138
  `pr:${number5}`,
47003
47139
  ...(await db.selectFrom("artifact_link").select("to_artifact").where("project_id", "=", projectId2).where("from_artifact", "=", `pr:${number5}`).execute()).map((l) => l.to_artifact)
47004
47140
  ];
47005
- return db.selectFrom("source as s").where("s.project_id", "=", projectId2).where("s.artifact", "in", artifacts).where(({ not, exists, selectFrom }) => not(exists(selectFrom("source as n").select("n.id").whereRef("n.project_id", "=", "s.project_id").whereRef("n.kind", "=", "s.kind").whereRef("n.external_id", "=", "s.external_id").whereRef("n.revision", ">", "s.revision")))).select([
47141
+ return db.selectFrom("source as s").where("s.project_id", "=", projectId2).where("s.artifact", "in", artifacts).where(({ not, exists, selectFrom }) => not(exists(selectFrom("source as n").select("n.id").whereRef("n.project_id", "=", "s.project_id").whereRef("n.kind", "=", "s.kind").whereRef("n.external_id", "=", "s.external_id").whereRef("n.revision", ">", "s.revision")))).where(({ not, exists, selectFrom }) => not(exists(selectFrom("source_forgotten as f").select("f.source_id").whereRef("f.project_id", "=", "s.project_id").whereRef("f.kind", "=", "s.kind").whereRef("f.external_id", "=", "s.external_id").whereRef("f.revision", ">", "s.revision")))).select([
47006
47142
  "s.id",
47007
47143
  "s.kind",
47008
47144
  "s.artifact",
@@ -47212,19 +47348,36 @@ async function checkRecord(db, target, raw) {
47212
47348
  adoption.push({ source: s.id, start: span[0], end: span[1], route });
47213
47349
  }
47214
47350
  const anchors = [];
47351
+ const fallbacks = new Set;
47215
47352
  for (const a of u.anchors) {
47216
47353
  const p = repoPath(a.path);
47217
47354
  if (!p) {
47218
47355
  problems.push(`${key}: anchor path ${JSON.stringify(head(a.path, 80))} is not inside the repository; left out`);
47219
47356
  continue;
47220
47357
  }
47358
+ const symbol2 = a.symbol && masksSymbol(target.root, p, a.symbol) ? undefined : a.symbol;
47359
+ if (a.symbol && !symbol2)
47360
+ problems.push(`${key}: anchor symbol in ${p} is text Sphica masks; the anchor keeps only its path`);
47221
47361
  let commit = a.commit;
47222
47362
  if (commit && !(target.root && commitHolds(target.root, commit, p))) {
47223
47363
  problems.push(`${key}: commit ${commit.slice(0, 12)} does not hold ${p} in the repository${target.root ? "" : " (no working tree to check)"}; the anchor keeps no commit`);
47224
47364
  commit = undefined;
47225
47365
  }
47226
47366
  const observation = a.role === "evidence" && !commit && target.sessionId ? (await db.selectFrom("edit_observation").select("id").where("session_id", "=", target.sessionId).where("path", "=", p).orderBy("id", "desc").executeTakeFirst())?.id ?? null : null;
47227
- anchors.push({ ...a, commit, path: p, observation });
47367
+ const planned = { ...a, symbol: symbol2, commit, path: p, observation };
47368
+ if (a.symbol && !symbol2)
47369
+ fallbacks.add(planned);
47370
+ anchors.push(planned);
47371
+ }
47372
+ const place = (x) => `${x.path}\x00${x.role}\x00${x.commit}\x00${x.lines ? [x.lines[0], Math.max(...x.lines)] : ""}`;
47373
+ const covered = new Set(anchors.filter((x) => !x.symbol && !fallbacks.has(x)).map(place));
47374
+ for (const x of [...fallbacks]) {
47375
+ if (!covered.has(place(x))) {
47376
+ covered.add(place(x));
47377
+ continue;
47378
+ }
47379
+ problems.push(`${key}: another path-only anchor on ${x.path} already covers it; left out`);
47380
+ anchors.splice(anchors.indexOf(x), 1);
47228
47381
  }
47229
47382
  const aliases = [...new Set(u.aliases.map((a) => a.trim()))];
47230
47383
  const bad = aliases.filter((a) => !a || a.length > 40);
@@ -47346,12 +47499,13 @@ ${checked.errors.map((e) => ` ${e}`).join(`
47346
47499
  }).onConflict((oc) => oc.doNothing()).execute();
47347
47500
  }
47348
47501
  for (const a of p.anchors) {
47349
- const at = a.lines ? null : a.symbol ? locate(target.root, a.path, a.symbol) : null;
47502
+ const symbol2 = a.symbol && !masksSymbol(target.root, a.path, a.symbol) ? a.symbol : undefined;
47503
+ const at = a.lines ? null : symbol2 ? locate(target.root, a.path, symbol2) : null;
47350
47504
  const lines = a.lines ?? (at ? [at.line, at.line] : null);
47351
47505
  await trx.insertInto("unit_anchor").values({
47352
47506
  unit_id: id,
47353
47507
  path: a.path,
47354
- symbol: a.symbol ?? null,
47508
+ symbol: symbol2 ?? null,
47355
47509
  commit_sha: a.commit ?? null,
47356
47510
  line_start: lines?.[0] ?? null,
47357
47511
  line_end: lines ? Math.max(lines[0], lines[1]) : null,
@@ -47549,15 +47703,24 @@ function readExcerpt(root, file2) {
47549
47703
  } catch {
47550
47704
  throw new Error(`${p} is not UTF-8 text`);
47551
47705
  }
47552
- const starts = [0];
47706
+ const starts2 = [0];
47553
47707
  for (let i = 0;i < buf.length; i++)
47554
47708
  if (buf[i] === 10 && i + 1 < buf.length)
47555
- starts.push(i + 1);
47709
+ starts2.push(i + 1);
47556
47710
  const [a, b] = file2.lines;
47557
- if (a > b || b > starts.length)
47558
- throw new Error(`${p} has ${starts.length} lines; lines ${a}-${b} are not in it`);
47559
- const text2 = buf.subarray(starts[a - 1], b < starts.length ? starts[b] : buf.length).toString("utf8");
47560
- return { path: p, commit, blob, size, lines: [a, b], text: text2 };
47711
+ if (a > b || b > starts2.length)
47712
+ throw new Error(`${p} has ${starts2.length} lines; lines ${a}-${b} are not in it`);
47713
+ const from = starts2[a - 1] ?? 0;
47714
+ const to = b < starts2.length ? starts2[b] ?? buf.length : buf.length;
47715
+ if (privateKeyRanges(buf.toString("utf8")).some(([s, e]) => s < to && e > from && (s < from || e > to)))
47716
+ throw new Error(`${p} lines ${a}-${b} are inside a private key; cite lines outside it`);
47717
+ const raw = buf.subarray(from, to).toString("utf8");
47718
+ const text2 = mask(raw);
47719
+ const before = buf.subarray(0, from).toString("utf8");
47720
+ const after = buf.subarray(to).toString("utf8");
47721
+ if (mask(before + raw) !== mask(before) + text2 || mask(raw + after) !== text2 + mask(after))
47722
+ throw new Error(`${p} lines ${a}-${b} cut through text Sphica masks; cite more lines around it`);
47723
+ return { path: p, commit, blob, size, lines: [a, b], raw, text: text2 };
47561
47724
  }
47562
47725
  var locate3 = (body, q) => {
47563
47726
  const at = Buffer.from(body, "utf8").indexOf(Buffer.from(q, "utf8"));
@@ -47621,8 +47784,12 @@ async function checkGlean(db, target, raw) {
47621
47784
  if (!target.root)
47622
47785
  throw new Error("the repository is not known");
47623
47786
  excerpt = readExcerpt(target.root, op.file);
47624
- if (!locate3(excerpt.text, op.quote))
47787
+ if ((await forgottenExcerpt(db, target.projectId, excerpt)).same)
47788
+ errors3.push(`${what}: the owner forgot ${excerpt.path} lines ${op.file.lines.join("-")}; cite something else`);
47789
+ else if (!locate3(excerpt.raw, op.quote))
47625
47790
  errors3.push(`${what}: quote not found in ${excerpt.path} lines ${op.file.lines.join("-")}`);
47791
+ else if (!quoteSpan(excerpt.raw, excerpt.text, op.quote))
47792
+ errors3.push(`${what}: the quote also appears in text Sphica masks; quote a longer or different part`);
47626
47793
  } catch (e) {
47627
47794
  errors3.push(`${what}: ${e.message}`);
47628
47795
  }
@@ -47643,6 +47810,10 @@ async function checkGlean(db, target, raw) {
47643
47810
  }
47644
47811
  if (op.op === "anchor" && !repoPath(op.path))
47645
47812
  errors3.push(`${what}: the path is not inside the repository`);
47813
+ const pinned = op.op === "anchor" ? op : op.op === "replace_anchor" ? op.to : null;
47814
+ const pinnedPath = pinned && repoPath(pinned.path);
47815
+ if (pinned?.symbol && pinnedPath && masksSymbol(target.root, pinnedPath, pinned.symbol))
47816
+ errors3.push(`${what}: the symbol is text Sphica masks; anchor a name, not a key`);
47646
47817
  if (op.op === "anchor" && op.commit) {
47647
47818
  const rel2 = repoPath(op.path);
47648
47819
  if (rel2 && !(target.root && commitHolds(target.root, op.commit, rel2)))
@@ -47730,26 +47901,38 @@ async function checkGlean(db, target, raw) {
47730
47901
  }
47731
47902
  return { errors: errors3, problems, units, ops };
47732
47903
  }
47904
+ async function forgottenExcerpt(db, projectId2, x) {
47905
+ const rows = await db.selectFrom("source_forgotten").select(["revision", "content_hash"]).where("project_id", "=", projectId2).where("kind", "=", "file_excerpt").where("external_id", "=", `file:${x.path}@${x.blob}#L${x.lines[0]}-${x.lines[1]}`).execute();
47906
+ const hash2 = sha256(x.text);
47907
+ return {
47908
+ same: rows.some((r) => Buffer.from(r.content_hash).equals(hash2)),
47909
+ last: Math.max(0, ...rows.map((r) => r.revision))
47910
+ };
47911
+ }
47733
47912
  async function excerptSource(trx, projectId2, x) {
47734
47913
  const external2 = `file:${x.path}@${x.blob}#L${x.lines[0]}-${x.lines[1]}`;
47735
- const found = await trx.selectFrom("source").select("id").where("project_id", "=", projectId2).where("kind", "=", "file_excerpt").where("external_id", "=", external2).executeTakeFirst();
47736
- if (found)
47914
+ const hash2 = sha256(x.text);
47915
+ const found = await trx.selectFrom("source").select(["id", "revision", "content_hash"]).where("project_id", "=", projectId2).where("kind", "=", "file_excerpt").where("external_id", "=", external2).orderBy("revision", "desc").executeTakeFirst();
47916
+ const forgotten = await forgottenExcerpt(trx, projectId2, x);
47917
+ if (found && found.revision > forgotten.last && found.content_hash.equals(hash2))
47737
47918
  return found.id;
47919
+ if (forgotten.same)
47920
+ throw new Error(`the owner forgot ${x.path} lines ${x.lines.join("-")}; cite something else`);
47738
47921
  const now = iso(Date.now());
47739
- const partial2 = Buffer.byteLength(x.text, "utf8") !== x.size;
47740
47922
  const row = await trx.insertInto("source").values({
47741
47923
  project_id: projectId2,
47742
47924
  kind: "file_excerpt",
47743
47925
  artifact: `file:${x.path}`,
47744
47926
  external_id: external2,
47745
- revision: 1,
47927
+ revision: Math.max(found?.revision ?? 0, forgotten.last) + 1,
47746
47928
  author_kind: "person",
47747
47929
  created_at: now,
47748
47930
  captured_at: now,
47749
47931
  text: x.text,
47750
- truncated: partial2 ? 1 : 0,
47932
+ truncated: bytes(x.raw) !== x.size ? 1 : 0,
47933
+ redacted: x.text !== x.raw ? 1 : 0,
47751
47934
  original_bytes: x.size,
47752
- content_hash: sha256(x.text),
47935
+ content_hash: hash2,
47753
47936
  path: x.path,
47754
47937
  line_start: x.lines[0],
47755
47938
  line_end: x.lines[1],
@@ -47799,7 +47982,10 @@ ${c.errors.map((e) => ` ${e}`).join(`
47799
47982
  touched.set(p.unitId, op.unit);
47800
47983
  if (op.op === "add_evidence") {
47801
47984
  const s = p.excerpt ? { id: await excerptSource(trx, target.projectId, p.excerpt), text: p.excerpt.text } : await trx.selectFrom("source").select(["id", "text"]).where("id", "=", Number(op.source?.slice(1))).executeTakeFirstOrThrow();
47802
- const at = locate3(s.text, op.quote) ?? [0, 0];
47985
+ const at = (p.excerpt ? quoteSpan(p.excerpt.raw, s.text, op.quote) : locate3(s.text, op.quote)) ?? [
47986
+ 0,
47987
+ 0
47988
+ ];
47803
47989
  await trx.insertInto("unit_evidence").values({
47804
47990
  unit_id: p.unitId,
47805
47991
  source_id: s.id,
@@ -47826,11 +48012,13 @@ ${c.errors.map((e) => ` ${e}`).join(`
47826
48012
  changed2.push(`${op.unit}: adopted`);
47827
48013
  } else if (op.op === "anchor" || op.op === "replace_anchor") {
47828
48014
  const to = op.op === "anchor" ? op : op.to;
47829
- const at = to.symbol ? locate(target.root, repoPath(to.path) ?? to.path, to.symbol) : null;
48015
+ const rel = repoPath(to.path) ?? to.path;
48016
+ const symbol2 = to.symbol && !masksSymbol(target.root, rel, to.symbol) ? to.symbol : null;
48017
+ const at = symbol2 ? locate(target.root, rel, symbol2) : null;
47830
48018
  const added = await trx.insertInto("unit_anchor").values({
47831
48019
  unit_id: p.unitId,
47832
- path: repoPath(to.path) ?? to.path,
47833
- symbol: to.symbol ?? null,
48020
+ path: rel,
48021
+ symbol: symbol2,
47834
48022
  commit_sha: op.op === "anchor" ? op.commit ?? null : null,
47835
48023
  line_start: at?.line ?? null,
47836
48024
  line_end: at?.line ?? null,
@@ -47988,7 +48176,7 @@ async function beginHarvest(db, projectId2, number5, get) {
47988
48176
  const project = await db.selectFrom("project").select("key").where("id", "=", projectId2).executeTakeFirst();
47989
48177
  const pull = await readPull(get, number5, repoOf(project?.key ?? ""));
47990
48178
  return inTransaction(db, async (trx) => {
47991
- await storeItems(trx, projectId2, pull.items);
48179
+ const kept = (await storeItems(trx, projectId2, pull.items)).filter((id) => id !== null).length;
47992
48180
  await linkIssues(trx, projectId2, number5, pull.closes);
47993
48181
  const run = newRunId();
47994
48182
  await openRun(trx, {
@@ -47998,7 +48186,7 @@ async function beginHarvest(db, projectId2, number5, get) {
47998
48186
  sessionId: null,
47999
48187
  draftId: run
48000
48188
  });
48001
- return { run, sources: pull.items.length };
48189
+ return { run, sources: kept };
48002
48190
  });
48003
48191
  }
48004
48192
  async function bound(db, id, projectId2) {
@@ -48149,6 +48337,153 @@ async function saveText(db, id, projectId2, root, record3) {
48149
48337
  });
48150
48338
  }
48151
48339
 
48340
+ // server/src/forget.ts
48341
+ class Preview extends Error {
48342
+ outcome;
48343
+ constructor(outcome) {
48344
+ super("preview");
48345
+ this.outcome = outcome;
48346
+ }
48347
+ }
48348
+ var unknown3 = (id) => new Error(`s${id} is not a source of this project`);
48349
+ async function forgetIn(trx, projectId2, ids, at) {
48350
+ const unique = [...new Set(ids)];
48351
+ const rows = unique.length ? await trx.selectFrom("source").where("id", "in", unique).where("project_id", "=", projectId2).select([
48352
+ "id",
48353
+ "kind",
48354
+ "artifact",
48355
+ "external_id",
48356
+ "revision",
48357
+ "content_hash",
48358
+ sql`length(cast(text as blob))`.as("bytes")
48359
+ ]).orderBy("id").execute() : [];
48360
+ const gone = unique.length ? await trx.selectFrom("source_forgotten").where("source_id", "in", unique).where("project_id", "=", projectId2).select("source_id").execute() : [];
48361
+ const already = gone.map((g) => g.source_id).sort((a, b) => a - b);
48362
+ for (const id of unique)
48363
+ if (!rows.some((r) => r.id === id) && !already.includes(id))
48364
+ throw unknown3(id);
48365
+ const outcome = {
48366
+ sources: rows.map((r) => ({ id: r.id, kind: r.kind, artifact: r.artifact, bytes: r.bytes })),
48367
+ already,
48368
+ units: [],
48369
+ references: 0
48370
+ };
48371
+ if (!rows.length)
48372
+ return outcome;
48373
+ const targets = rows.map((r) => r.id);
48374
+ const touched = await trx.selectFrom("unit as u").where("u.project_id", "=", projectId2).where((eb) => eb.or(["unit_evidence", "unit_adoption"].flatMap((t) => [
48375
+ eb.exists(eb.selectFrom(t).select(`${t}.id`).whereRef(`${t}.unit_id`, "=", "u.id").where(`${t}.source_id`, "in", targets)),
48376
+ eb.exists(eb.selectFrom(t).select(`${t}.id`).whereRef(`${t}.unit_id`, "=", "u.id").where(`${t}.retraction_source_id`, "in", targets))
48377
+ ]))).select(["u.id", "u.key", "u.lifecycle"]).orderBy("u.id").execute();
48378
+ const count = async (t, unitId) => Number((await trx.selectFrom(t).where("unit_id", "=", unitId).where((eb) => eb.or([eb("source_id", "in", targets), eb("retraction_source_id", "in", targets)])).select((eb) => eb.fn.countAll().as("n")).executeTakeFirstOrThrow()).n);
48379
+ const removed = new Map;
48380
+ for (const u of touched)
48381
+ removed.set(u.id, await count("unit_evidence", u.id) + await count("unit_adoption", u.id));
48382
+ const batch = (await trx.insertInto("forget_batch").values({ project_id: projectId2, at }).returning("id").executeTakeFirstOrThrow()).id;
48383
+ await trx.insertInto("source_forgotten").values(rows.map((r) => ({
48384
+ source_id: r.id,
48385
+ project_id: projectId2,
48386
+ artifact: r.artifact,
48387
+ kind: r.kind,
48388
+ external_id: r.external_id,
48389
+ revision: r.revision,
48390
+ content_hash: r.content_hash,
48391
+ batch_id: batch
48392
+ }))).execute();
48393
+ for (const t of ["unit_evidence", "unit_adoption"])
48394
+ await trx.deleteFrom(t).where("retraction_source_id", "in", targets).where("retracted_at", "is not", null).where("source_id", "not in", targets).execute();
48395
+ outcome.references = Number((await trx.deleteFrom("external_reference").where("owner_source_id", "in", targets).executeTakeFirst()).numDeletedRows);
48396
+ await trx.deleteFrom("source").where("id", "in", targets).execute();
48397
+ const reason2 = `sources ${targets.map((id) => `s${id}`).join(", ")} forgotten by the owner`;
48398
+ for (const u of touched) {
48399
+ let after = u.lifecycle;
48400
+ if (u.lifecycle === "active") {
48401
+ await trx.insertInto("unit_state").values({ unit_id: u.id, from_state: "active", to_state: "candidate", at, reason: reason2, forget_id: batch }).execute();
48402
+ after = "candidate";
48403
+ try {
48404
+ await trx.insertInto("unit_state").values({
48405
+ unit_id: u.id,
48406
+ from_state: "candidate",
48407
+ to_state: "active",
48408
+ at,
48409
+ reason: "support checked again after forgetting sources",
48410
+ forget_id: batch
48411
+ }).execute();
48412
+ after = "active";
48413
+ } catch (e) {
48414
+ if (!ACTIVATION.test(e.message))
48415
+ throw e;
48416
+ }
48417
+ }
48418
+ outcome.units.push({ key: u.key, before: u.lifecycle, after, removed: removed.get(u.id) ?? 0 });
48419
+ }
48420
+ return outcome;
48421
+ }
48422
+ async function previewForget(file2, projectId2, ids) {
48423
+ const db = openWriter("forget", file2);
48424
+ try {
48425
+ return await db.connection().execute(async (c) => {
48426
+ await sql`begin immediate`.execute(c);
48427
+ try {
48428
+ throw new Preview(await forgetIn(c, projectId2, ids, iso(Date.now())));
48429
+ } catch (e) {
48430
+ await sql`rollback`.execute(c).catch(() => {});
48431
+ if (e instanceof Preview)
48432
+ return e.outcome;
48433
+ throw e;
48434
+ }
48435
+ });
48436
+ } finally {
48437
+ await db.destroy();
48438
+ }
48439
+ }
48440
+ async function applyForget(file2, projectId2, ids, confirmed, signal) {
48441
+ const db = openWriter("forget", file2);
48442
+ try {
48443
+ return await db.connection().execute(async (c) => {
48444
+ await sql`pragma secure_delete = on`.execute(c);
48445
+ await sql`begin immediate`.execute(c);
48446
+ let outcome;
48447
+ try {
48448
+ outcome = await forgetIn(c, projectId2, ids, iso(Date.now()));
48449
+ if (JSON.stringify(outcome) !== JSON.stringify(confirmed))
48450
+ throw new Error("What these sources support changed after you confirmed. Nothing was forgotten; look at the preview again");
48451
+ if (signal?.aborted)
48452
+ throw new Error("The call was cancelled, so nothing was forgotten");
48453
+ await sql`commit`.execute(c);
48454
+ } catch (e) {
48455
+ await sql`rollback`.execute(c).catch(() => {});
48456
+ throw e;
48457
+ }
48458
+ try {
48459
+ await sql`insert into source_fts (source_fts) values ('optimize')`.execute(c);
48460
+ const checkpoint = await sql`pragma wal_checkpoint(TRUNCATE)`.execute(c);
48461
+ return { outcome, cleanup: checkpoint.rows[0]?.busy === 0 ? "done" : "incomplete" };
48462
+ } catch {
48463
+ return { outcome, cleanup: "incomplete" };
48464
+ }
48465
+ });
48466
+ } finally {
48467
+ await db.destroy();
48468
+ }
48469
+ }
48470
+ function forgetText(o) {
48471
+ const lines = o.sources.map((s) => `- s${s.id} ${s.kind} in ${inline(s.artifact).slice(0, 120)} (${plural2(s.bytes, "byte")})`);
48472
+ if (o.already.length)
48473
+ lines.push(`- already forgotten: ${o.already.map((id) => `s${id}`).join(", ")} (only the cleanup runs again)`);
48474
+ for (const u of o.units) {
48475
+ const change = u.before === u.after ? `stays ${u.after}` : `${u.before} → ${u.after}`;
48476
+ lines.push(`- record ${inline(u.key).slice(0, 120)}: ${change}, loses ${plural2(u.removed, "citation")}`);
48477
+ }
48478
+ if (o.references)
48479
+ lines.push(`- ${plural2(o.references, "unfetched reference")} the forgotten messages gave`);
48480
+ if (o.units.length)
48481
+ lines.push("Records keep their own text: if one repeats the forgotten words, they stay in it.");
48482
+ lines.push("Copies outside the database (capture's waiting and set-aside files, backups) are not touched.");
48483
+ return lines.join(`
48484
+ `);
48485
+ }
48486
+
48152
48487
  // server/src/frame.ts
48153
48488
  import crypto3 from "node:crypto";
48154
48489
  function framed(body) {
@@ -48206,7 +48541,7 @@ var tool = (fn) => fn().then((t) => reply(t), (e) => reply(`Sphica: ${head(reaso
48206
48541
  var server = new McpServer({ name: "sphica-record", version: versionAt(ROOT) ?? "unknown" }, {
48207
48542
  capabilities: { experimental: { "codex/sandbox-state-meta": {} } },
48208
48543
  instructions: [
48209
- "Writes Sphica records for the trace, harvest, and glean Skills. Use these tools only while running one of those Skills.",
48544
+ "Writes Sphica records for the trace, harvest, glean, and forget Skills. Use these tools only while running one of those Skills.",
48210
48545
  "Flow: begin (trace_begin, harvest_begin, or glean_begin) returns a run id; context shows what the run may cite; check the record; save it.",
48211
48546
  "Always pass the repository root as cwd."
48212
48547
  ].join(`
@@ -48287,4 +48622,66 @@ server.registerTool("record_save", {
48287
48622
  const p = await projectOf(a.cwd, extra._meta);
48288
48623
  return saveText(conn(), a.run, p.projectId, p.root, a.record);
48289
48624
  }));
48625
+ var SOURCES = exports_external.array(exports_external.string().regex(/^s[1-9][0-9]{0,15}$/)).min(1).max(50).describe("Sources to forget, as search and read show them (s12)");
48626
+ var idsOf = (refs) => refs.map((r) => Number(r.slice(1)));
48627
+ var ANSWER_MS = 10 * 60 * 1000;
48628
+ server.registerTool("forget_preview", {
48629
+ title: "What forgetting sources would do",
48630
+ description: "Shows which sources would be removed and which records would lose citations or leave active. Changes nothing. Never shows the text.",
48631
+ inputSchema: { sources: SOURCES, cwd: CWD },
48632
+ annotations: READ
48633
+ }, async (a, extra) => tool(async () => {
48634
+ const p = await projectOf(a.cwd, extra._meta);
48635
+ return forgetText(await previewForget(dbFile(), p.projectId, idsOf(a.sources)));
48636
+ }));
48637
+ server.registerTool("forget_apply", {
48638
+ title: "Forget sources",
48639
+ description: "Asks the owner in the host to confirm by typing the number of sources, then removes them, their index entries, and the bytes left in the file, and judges the records that cited them again. Nothing is removed without that answer.",
48640
+ inputSchema: { sources: SOURCES, cwd: CWD },
48641
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }
48642
+ }, async (a, extra) => tool(async () => {
48643
+ const p = await projectOf(a.cwd, extra._meta);
48644
+ const ids = idsOf(a.sources);
48645
+ const seen = await previewForget(dbFile(), p.projectId, ids);
48646
+ const n = seen.sources.length;
48647
+ if (n > 0) {
48648
+ const refused = (why) => new Error(`${why}, so nothing was forgotten`);
48649
+ if (!server.server.getClientCapabilities()?.elicitation)
48650
+ throw refused("This host cannot ask you directly (run /sphica:forget in Claude Code)");
48651
+ let answer;
48652
+ try {
48653
+ answer = await server.server.elicitInput({
48654
+ mode: "form",
48655
+ message: `Forget ${plural2(n, "source")} for good?
48656
+ ${forgetText(seen)}`,
48657
+ requestedSchema: {
48658
+ type: "object",
48659
+ properties: {
48660
+ confirm: {
48661
+ type: "string",
48662
+ title: "Number of sources",
48663
+ description: `Type ${n} to forget ${plural2(n, "source")}`
48664
+ }
48665
+ },
48666
+ required: ["confirm"]
48667
+ }
48668
+ }, { timeout: ANSWER_MS, signal: extra.signal });
48669
+ } catch (e) {
48670
+ throw refused(`The confirmation did not come back (${head(reason(e), 200)})`);
48671
+ }
48672
+ if (extra.signal.aborted)
48673
+ throw refused("The call was cancelled");
48674
+ if (answer.action !== "accept")
48675
+ throw refused(`You ${answer.action === "decline" ? "declined" : "cancelled"}`);
48676
+ if (String(answer.content?.confirm ?? "").trim() !== String(n))
48677
+ throw refused(`The number typed does not match ${n}`);
48678
+ }
48679
+ const done = await applyForget(dbFile(), p.projectId, ids, seen, extra.signal);
48680
+ return [
48681
+ n ? `Forgot ${plural2(n, "source")}.` : "Nothing new to forget.",
48682
+ forgetText(done.outcome),
48683
+ done.cleanup === "done" ? "The deleted text was cleared from the database file." : "Clearing the deleted text from the database file did not finish (another session may be reading it), so it may stay there until you run forget_apply with the same sources again."
48684
+ ].join(`
48685
+ `);
48686
+ }));
48290
48687
  await server.connect(new StdioServerTransport);