sphica 0.5.7 → 0.6.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.
@@ -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" });
@@ -45993,22 +45993,54 @@ function captureAuthorizer(own2, action, p1, p2, triggerOrView) {
45993
45993
  return C2.SQLITE_DENY;
45994
45994
  }
45995
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"]);
45996
45998
  function ingestAuthorizer(action, p1, p2) {
45997
45999
  if (DDL().has(action))
45998
46000
  return C2.SQLITE_DENY;
45999
- 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 ?? ""))
46000
46004
  return C2.SQLITE_DENY;
46001
46005
  if (action === C2.SQLITE_PRAGMA)
46002
46006
  return readsDataVersion(p1, p2) ? C2.SQLITE_OK : C2.SQLITE_DENY;
46003
46007
  return C2.SQLITE_OK;
46004
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
+ }
46005
46037
  function connectWriter(role, file2 = dbFile(), create = false) {
46006
46038
  requireRuntime();
46007
46039
  if (!create)
46008
46040
  requireFile(file2);
46009
46041
  const raw = new DatabaseSync2(file2);
46010
46042
  try {
46011
- prepare(raw, create ? "none" : role === "ingest" ? "revision" : "generation");
46043
+ prepare(raw, create ? "none" : role === "ingest" || role === "forget" ? "revision" : "generation");
46012
46044
  raw.function("sphica_terms", { deterministic: true }, (text) => terms(String(text ?? "")).join(" "));
46013
46045
  } catch (e) {
46014
46046
  raw.close();
@@ -46016,6 +46048,8 @@ function connectWriter(role, file2 = dbFile(), create = false) {
46016
46048
  }
46017
46049
  if (role === "ingest")
46018
46050
  raw.setAuthorizer(ingestAuthorizer);
46051
+ else if (role === "forget")
46052
+ raw.setAuthorizer(forgetAuthorizer);
46019
46053
  else if (role === "capture") {
46020
46054
  let own2 = false;
46021
46055
  raw.setAuthorizer((action, p1, p2, _db, triggerOrView) => captureAuthorizer(own2, action, p1, p2, triggerOrView));
@@ -46649,6 +46683,8 @@ function lock() {
46649
46683
  return null;
46650
46684
  }
46651
46685
  var BATCH = 500;
46686
+ var FLUSH_BUDGET_MS = 30000;
46687
+ var TOOL_FLUSH_BUDGET_MS = 2000;
46652
46688
  var ROWS = 1000;
46653
46689
  var chunks = (xs, n = ROWS) => Array.from({ length: Math.ceil(xs.length / n) }, (_, i) => xs.slice(i * n, (i + 1) * n));
46654
46690
  async function write(db, batch, projects) {
@@ -46724,98 +46760,135 @@ async function write(db, batch, projects) {
46724
46760
  }
46725
46761
  var REJECTED = new Set([18, 19, 20, 25]);
46726
46762
  var rejected = (e) => REJECTED.has(sqliteCode(e) ?? -1);
46727
- async function flush(file2 = dbFile()) {
46728
- const unlock = lock();
46729
- if (!unlock)
46730
- return { sent: 0, deferred: 0, rejected: 0, busy: true };
46731
- const dir = spoolDir();
46732
- let client = null;
46763
+ var queued = (from) => {
46764
+ try {
46765
+ return fs5.readdirSync(from).filter((f) => f.endsWith(".json") && !f.startsWith(".")).sort().map((name) => ({ name, from }));
46766
+ } catch {
46767
+ return [];
46768
+ }
46769
+ };
46770
+ async function sendBatch(db, names) {
46771
+ const held = unregisteredDir();
46772
+ const records = [];
46773
+ for (const { name, from } of names) {
46774
+ let r;
46775
+ try {
46776
+ r = current(JSON.parse(fs5.readFileSync(path5.join(from, name), "utf8")));
46777
+ } catch {
46778
+ fs5.mkdirSync(rejectedDir(), { recursive: true, mode: 448 });
46779
+ fs5.renameSync(path5.join(from, name), path5.join(rejectedDir(), name));
46780
+ continue;
46781
+ }
46782
+ if (r)
46783
+ records.push({ name, from, r });
46784
+ else
46785
+ fs5.rmSync(path5.join(from, name), { force: true });
46786
+ }
46787
+ const projects = new Map((await db.selectFrom("project").select(["id", "key", "name"]).where("key", "in", [...new Set(records.map((x) => x.r.project))]).execute()).map((p) => [p.key, { id: p.id, name: p.name }]));
46788
+ const known = records.filter((x) => projects.has(x.r.project));
46789
+ const strayed = records.filter((x) => !projects.has(x.r.project));
46790
+ let sent = 0;
46791
+ const bad = [];
46733
46792
  try {
46734
- const held = unregisteredDir();
46735
- const list = (from) => {
46793
+ sent = await write(db, known.map((x) => x.r), projects);
46794
+ } catch (e) {
46795
+ if (!rejected(e))
46796
+ throw e;
46797
+ for (const x of known) {
46736
46798
  try {
46737
- return fs5.readdirSync(from).filter((f) => f.endsWith(".json") && !f.startsWith(".")).map((name) => ({ name, from }));
46738
- } catch {
46739
- return [];
46799
+ sent += await write(db, [x.r], projects);
46800
+ } catch (e2) {
46801
+ if (!rejected(e2))
46802
+ throw e2;
46803
+ bad.push(x);
46740
46804
  }
46741
- };
46742
- const names = [...list(dir), ...list(held)].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0).slice(0, BATCH);
46743
- if (names.length === 0)
46744
- return { sent: 0, deferred: 0, rejected: 0 };
46745
- const records = [];
46746
- for (const { name, from } of names) {
46747
- let r;
46805
+ }
46806
+ }
46807
+ if (bad.length) {
46808
+ fs5.mkdirSync(rejectedDir(), { recursive: true, mode: 448 });
46809
+ for (const x of bad) {
46748
46810
  try {
46749
- r = current(JSON.parse(fs5.readFileSync(path5.join(from, name), "utf8")));
46750
- } catch {
46751
- fs5.mkdirSync(rejectedDir(), { recursive: true, mode: 448 });
46752
- fs5.renameSync(path5.join(from, name), path5.join(rejectedDir(), name));
46753
- continue;
46754
- }
46755
- if (r)
46756
- records.push({ name, from, r });
46757
- else
46758
- fs5.rmSync(path5.join(from, name), { force: true });
46759
- }
46760
- const db = openWriter("capture", file2);
46761
- client = db;
46762
- const projects = new Map((await db.selectFrom("project").select(["id", "key", "name"]).where("key", "in", [...new Set(records.map((x) => x.r.project))]).execute()).map((p) => [p.key, { id: p.id, name: p.name }]));
46763
- const known = records.filter((x) => projects.has(x.r.project));
46764
- const strayed = records.filter((x) => !projects.has(x.r.project));
46765
- let sent = 0;
46766
- const bad = [];
46767
- try {
46768
- sent = await write(db, known.map((x) => x.r), projects);
46769
- } catch (e) {
46770
- if (!rejected(e))
46771
- throw e;
46772
- for (const x of known) {
46773
- try {
46774
- sent += await write(db, [x.r], projects);
46775
- } catch (e2) {
46776
- if (!rejected(e2))
46777
- throw e2;
46778
- bad.push(x);
46779
- }
46811
+ fs5.renameSync(path5.join(x.from, x.name), path5.join(rejectedDir(), x.name));
46812
+ } catch (e) {
46813
+ if (e.code !== "ENOENT")
46814
+ throw e;
46780
46815
  }
46781
46816
  }
46782
- if (bad.length) {
46783
- fs5.mkdirSync(rejectedDir(), { recursive: true, mode: 448 });
46784
- for (const x of bad) {
46785
- try {
46786
- fs5.renameSync(path5.join(x.from, x.name), path5.join(rejectedDir(), x.name));
46787
- } catch (e) {
46788
- if (e.code !== "ENOENT")
46789
- throw e;
46790
- }
46817
+ }
46818
+ if (strayed.length) {
46819
+ fs5.mkdirSync(held, { recursive: true, mode: 448 });
46820
+ for (const x of strayed) {
46821
+ if (x.from === held)
46822
+ continue;
46823
+ try {
46824
+ fs5.renameSync(path5.join(x.from, x.name), path5.join(held, x.name));
46825
+ } catch (e) {
46826
+ if (e.code !== "ENOENT")
46827
+ throw e;
46791
46828
  }
46792
46829
  }
46793
- if (strayed.length) {
46794
- fs5.mkdirSync(held, { recursive: true, mode: 448 });
46795
- for (const x of strayed) {
46796
- if (x.from === held)
46797
- continue;
46798
- try {
46799
- fs5.renameSync(path5.join(x.from, x.name), path5.join(held, x.name));
46800
- } catch (e) {
46801
- if (e.code !== "ENOENT")
46802
- throw e;
46830
+ }
46831
+ const moved = new Set([...bad, ...strayed].map((x) => x.name));
46832
+ for (const x of records)
46833
+ if (!moved.has(x.name))
46834
+ fs5.rmSync(path5.join(x.from, x.name), { force: true });
46835
+ return { sent, rejected: bad.length };
46836
+ }
46837
+ async function flush(file2 = dbFile(), budgetMs = FLUSH_BUDGET_MS) {
46838
+ const deadline = Date.now() + budgetMs;
46839
+ const dir = spoolDir();
46840
+ const held = unregisteredDir();
46841
+ const total = { sent: 0, deferred: 0, rejected: 0 };
46842
+ let batches = 0;
46843
+ let queueBatches = 0;
46844
+ for (let first = true;; first = false) {
46845
+ let unlock = lock();
46846
+ while (!unlock && Date.now() < deadline) {
46847
+ await new Promise((resolve) => setTimeout(resolve, Math.min(100, deadline - Date.now())));
46848
+ if (Date.now() < deadline)
46849
+ unlock = lock();
46850
+ }
46851
+ if (!unlock)
46852
+ return first ? { ...total, busy: true } : total;
46853
+ let client = null;
46854
+ const send = async (names) => {
46855
+ client ??= openWriter("capture", file2);
46856
+ const r = await sendBatch(client, names);
46857
+ total.sent += r.sent;
46858
+ total.rejected += r.rejected;
46859
+ batches++;
46860
+ };
46861
+ const late2 = () => batches > 0 && Date.now() >= deadline;
46862
+ const sentBefore = batches;
46863
+ try {
46864
+ if (first) {
46865
+ prune(held);
46866
+ for (const part of chunks(queued(held), BATCH)) {
46867
+ if (late2())
46868
+ break;
46869
+ await send(part);
46803
46870
  }
46804
46871
  }
46872
+ while (queueBatches === 0 || !late2()) {
46873
+ const part = queued(dir).slice(0, BATCH);
46874
+ if (part.length === 0)
46875
+ break;
46876
+ await send(part);
46877
+ queueBatches++;
46878
+ }
46879
+ prune(held);
46880
+ total.deferred = queued(held).length;
46881
+ if (batches > sentBefore)
46882
+ writeState({ flushedAt: new Date().toISOString(), error: null, deferred: total.deferred });
46883
+ } catch (e) {
46884
+ writeState({ flushedAt: new Date().toISOString(), error: reason(e).slice(0, 300) });
46885
+ throw e;
46886
+ } finally {
46887
+ await client?.destroy().catch(() => {});
46888
+ unlock();
46805
46889
  }
46806
- const moved = new Set([...bad, ...strayed].map((x) => x.name));
46807
- for (const x of records)
46808
- if (!moved.has(x.name))
46809
- fs5.rmSync(path5.join(x.from, x.name), { force: true });
46810
- prune(held);
46811
- writeState({ flushedAt: new Date().toISOString(), error: null, deferred: strayed.length });
46812
- return { sent, deferred: strayed.length, rejected: bad.length };
46813
- } catch (e) {
46814
- writeState({ flushedAt: new Date().toISOString(), error: reason(e).slice(0, 300) });
46815
- throw e;
46816
- } finally {
46817
- await client?.destroy().catch(() => {});
46818
- unlock();
46890
+ if (Date.now() >= deadline || queued(dir).length === 0)
46891
+ return total;
46819
46892
  }
46820
46893
  }
46821
46894
  async function readInput(stream) {
@@ -47040,11 +47113,13 @@ async function storeItems(db, projectId2, items) {
47040
47113
  const kept = fit(it.text);
47041
47114
  const hash2 = sha256(kept.body);
47042
47115
  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();
47043
- if (latest && Buffer.from(latest.content_hash).equals(hash2)) {
47116
+ 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();
47117
+ const lastForgotten = Math.max(0, ...forgotten.map((f) => f.revision));
47118
+ if (latest && latest.revision > lastForgotten && Buffer.from(latest.content_hash).equals(hash2)) {
47044
47119
  ids.push(latest.id);
47045
47120
  continue;
47046
47121
  }
47047
- if (!latest && !kept.body.trim()) {
47122
+ if (forgotten.some((f) => Buffer.from(f.content_hash).equals(hash2)) || !latest && !kept.body.trim()) {
47048
47123
  ids.push(null);
47049
47124
  continue;
47050
47125
  }
@@ -47057,7 +47132,7 @@ async function storeItems(db, projectId2, items) {
47057
47132
  kind: it.kind,
47058
47133
  artifact: it.artifact,
47059
47134
  external_id: it.externalId,
47060
- revision: (latest?.revision ?? 0) + 1,
47135
+ revision: Math.max(latest?.revision ?? 0, lastForgotten) + 1,
47061
47136
  author_kind: kind,
47062
47137
  author_login: it.author?.login ?? null,
47063
47138
  author_external_id: authorId,
@@ -47066,7 +47141,7 @@ async function storeItems(db, projectId2, items) {
47066
47141
  event_kind: it.event,
47067
47142
  url: it.url,
47068
47143
  created_at: created,
47069
- available_at: latest ? null : created,
47144
+ available_at: latest || lastForgotten ? null : created,
47070
47145
  captured_at: now,
47071
47146
  text: kept.body,
47072
47147
  truncated: kept.truncated ? 1 : 0,
@@ -47102,7 +47177,7 @@ async function pullSources(db, projectId2, number5) {
47102
47177
  `pr:${number5}`,
47103
47178
  ...(await db.selectFrom("artifact_link").select("to_artifact").where("project_id", "=", projectId2).where("from_artifact", "=", `pr:${number5}`).execute()).map((l) => l.to_artifact)
47104
47179
  ];
47105
- 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([
47180
+ 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([
47106
47181
  "s.id",
47107
47182
  "s.kind",
47108
47183
  "s.artifact",
@@ -47748,7 +47823,9 @@ async function checkGlean(db, target, raw) {
47748
47823
  if (!target.root)
47749
47824
  throw new Error("the repository is not known");
47750
47825
  excerpt = readExcerpt(target.root, op.file);
47751
- if (!locate3(excerpt.raw, op.quote))
47826
+ if ((await forgottenExcerpt(db, target.projectId, excerpt)).same)
47827
+ errors3.push(`${what}: the owner forgot ${excerpt.path} lines ${op.file.lines.join("-")}; cite something else`);
47828
+ else if (!locate3(excerpt.raw, op.quote))
47752
47829
  errors3.push(`${what}: quote not found in ${excerpt.path} lines ${op.file.lines.join("-")}`);
47753
47830
  else if (!quoteSpan(excerpt.raw, excerpt.text, op.quote))
47754
47831
  errors3.push(`${what}: the quote also appears in text Sphica masks; quote a longer or different part`);
@@ -47863,19 +47940,30 @@ async function checkGlean(db, target, raw) {
47863
47940
  }
47864
47941
  return { errors: errors3, problems, units, ops };
47865
47942
  }
47943
+ async function forgottenExcerpt(db, projectId2, x) {
47944
+ 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();
47945
+ const hash2 = sha256(x.text);
47946
+ return {
47947
+ same: rows.some((r) => Buffer.from(r.content_hash).equals(hash2)),
47948
+ last: Math.max(0, ...rows.map((r) => r.revision))
47949
+ };
47950
+ }
47866
47951
  async function excerptSource(trx, projectId2, x) {
47867
47952
  const external2 = `file:${x.path}@${x.blob}#L${x.lines[0]}-${x.lines[1]}`;
47868
47953
  const hash2 = sha256(x.text);
47869
47954
  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();
47870
- if (found?.content_hash.equals(hash2))
47955
+ const forgotten = await forgottenExcerpt(trx, projectId2, x);
47956
+ if (found && found.revision > forgotten.last && found.content_hash.equals(hash2))
47871
47957
  return found.id;
47958
+ if (forgotten.same)
47959
+ throw new Error(`the owner forgot ${x.path} lines ${x.lines.join("-")}; cite something else`);
47872
47960
  const now = iso(Date.now());
47873
47961
  const row = await trx.insertInto("source").values({
47874
47962
  project_id: projectId2,
47875
47963
  kind: "file_excerpt",
47876
47964
  artifact: `file:${x.path}`,
47877
47965
  external_id: external2,
47878
- revision: (found?.revision ?? 0) + 1,
47966
+ revision: Math.max(found?.revision ?? 0, forgotten.last) + 1,
47879
47967
  author_kind: "person",
47880
47968
  created_at: now,
47881
47969
  captured_at: now,
@@ -48088,7 +48176,7 @@ async function liveUnits(db, projectId2, limit = 40) {
48088
48176
  // server/src/extract.ts
48089
48177
  var newRunId = () => crypto2.randomBytes(9).toString("base64url");
48090
48178
  async function pendingText(db, projectId2) {
48091
- await flush().catch(() => {});
48179
+ await flush(undefined, TOOL_FLUSH_BUDGET_MS).catch(() => {});
48092
48180
  const rows = await pendingSessions(db, projectId2);
48093
48181
  if (!rows.length)
48094
48182
  return "Every captured session has been traced.";
@@ -48110,14 +48198,14 @@ async function sessionOf(db, projectId2, given) {
48110
48198
  return s.id;
48111
48199
  }
48112
48200
  async function beginTrace(db, projectId2, session) {
48113
- await flush().catch(() => {});
48201
+ await flush(undefined, TOOL_FLUSH_BUDGET_MS).catch(() => {});
48114
48202
  const s = await sessionOf(db, projectId2, session);
48115
48203
  const run = newRunId();
48116
48204
  await openRun(db, { projectId: projectId2, origin: "trace", target: `session:${s}`, sessionId: s, draftId: run });
48117
48205
  return run;
48118
48206
  }
48119
48207
  async function beginGlean(db, projectId2, session) {
48120
- await flush().catch(() => {});
48208
+ await flush(undefined, TOOL_FLUSH_BUDGET_MS).catch(() => {});
48121
48209
  const s = await sessionOf(db, projectId2, session);
48122
48210
  const run = newRunId();
48123
48211
  await openRun(db, { projectId: projectId2, origin: "glean", target: "glean", sessionId: s, draftId: run });
@@ -48127,7 +48215,7 @@ async function beginHarvest(db, projectId2, number5, get) {
48127
48215
  const project = await db.selectFrom("project").select("key").where("id", "=", projectId2).executeTakeFirst();
48128
48216
  const pull = await readPull(get, number5, repoOf(project?.key ?? ""));
48129
48217
  return inTransaction(db, async (trx) => {
48130
- await storeItems(trx, projectId2, pull.items);
48218
+ const kept = (await storeItems(trx, projectId2, pull.items)).filter((id) => id !== null).length;
48131
48219
  await linkIssues(trx, projectId2, number5, pull.closes);
48132
48220
  const run = newRunId();
48133
48221
  await openRun(trx, {
@@ -48137,7 +48225,7 @@ async function beginHarvest(db, projectId2, number5, get) {
48137
48225
  sessionId: null,
48138
48226
  draftId: run
48139
48227
  });
48140
- return { run, sources: pull.items.length };
48228
+ return { run, sources: kept };
48141
48229
  });
48142
48230
  }
48143
48231
  async function bound(db, id, projectId2) {
@@ -48288,6 +48376,153 @@ async function saveText(db, id, projectId2, root, record3) {
48288
48376
  });
48289
48377
  }
48290
48378
 
48379
+ // server/src/forget.ts
48380
+ class Preview extends Error {
48381
+ outcome;
48382
+ constructor(outcome) {
48383
+ super("preview");
48384
+ this.outcome = outcome;
48385
+ }
48386
+ }
48387
+ var unknown3 = (id) => new Error(`s${id} is not a source of this project`);
48388
+ async function forgetIn(trx, projectId2, ids, at) {
48389
+ const unique = [...new Set(ids)];
48390
+ const rows = unique.length ? await trx.selectFrom("source").where("id", "in", unique).where("project_id", "=", projectId2).select([
48391
+ "id",
48392
+ "kind",
48393
+ "artifact",
48394
+ "external_id",
48395
+ "revision",
48396
+ "content_hash",
48397
+ sql`length(cast(text as blob))`.as("bytes")
48398
+ ]).orderBy("id").execute() : [];
48399
+ const gone = unique.length ? await trx.selectFrom("source_forgotten").where("source_id", "in", unique).where("project_id", "=", projectId2).select("source_id").execute() : [];
48400
+ const already = gone.map((g) => g.source_id).sort((a, b) => a - b);
48401
+ for (const id of unique)
48402
+ if (!rows.some((r) => r.id === id) && !already.includes(id))
48403
+ throw unknown3(id);
48404
+ const outcome = {
48405
+ sources: rows.map((r) => ({ id: r.id, kind: r.kind, artifact: r.artifact, bytes: r.bytes })),
48406
+ already,
48407
+ units: [],
48408
+ references: 0
48409
+ };
48410
+ if (!rows.length)
48411
+ return outcome;
48412
+ const targets = rows.map((r) => r.id);
48413
+ const touched = await trx.selectFrom("unit as u").where("u.project_id", "=", projectId2).where((eb) => eb.or(["unit_evidence", "unit_adoption"].flatMap((t) => [
48414
+ eb.exists(eb.selectFrom(t).select(`${t}.id`).whereRef(`${t}.unit_id`, "=", "u.id").where(`${t}.source_id`, "in", targets)),
48415
+ eb.exists(eb.selectFrom(t).select(`${t}.id`).whereRef(`${t}.unit_id`, "=", "u.id").where(`${t}.retraction_source_id`, "in", targets))
48416
+ ]))).select(["u.id", "u.key", "u.lifecycle"]).orderBy("u.id").execute();
48417
+ 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);
48418
+ const removed = new Map;
48419
+ for (const u of touched)
48420
+ removed.set(u.id, await count("unit_evidence", u.id) + await count("unit_adoption", u.id));
48421
+ const batch = (await trx.insertInto("forget_batch").values({ project_id: projectId2, at }).returning("id").executeTakeFirstOrThrow()).id;
48422
+ await trx.insertInto("source_forgotten").values(rows.map((r) => ({
48423
+ source_id: r.id,
48424
+ project_id: projectId2,
48425
+ artifact: r.artifact,
48426
+ kind: r.kind,
48427
+ external_id: r.external_id,
48428
+ revision: r.revision,
48429
+ content_hash: r.content_hash,
48430
+ batch_id: batch
48431
+ }))).execute();
48432
+ for (const t of ["unit_evidence", "unit_adoption"])
48433
+ await trx.deleteFrom(t).where("retraction_source_id", "in", targets).where("retracted_at", "is not", null).where("source_id", "not in", targets).execute();
48434
+ outcome.references = Number((await trx.deleteFrom("external_reference").where("owner_source_id", "in", targets).executeTakeFirst()).numDeletedRows);
48435
+ await trx.deleteFrom("source").where("id", "in", targets).execute();
48436
+ const reason2 = `sources ${targets.map((id) => `s${id}`).join(", ")} forgotten by the owner`;
48437
+ for (const u of touched) {
48438
+ let after = u.lifecycle;
48439
+ if (u.lifecycle === "active") {
48440
+ await trx.insertInto("unit_state").values({ unit_id: u.id, from_state: "active", to_state: "candidate", at, reason: reason2, forget_id: batch }).execute();
48441
+ after = "candidate";
48442
+ try {
48443
+ await trx.insertInto("unit_state").values({
48444
+ unit_id: u.id,
48445
+ from_state: "candidate",
48446
+ to_state: "active",
48447
+ at,
48448
+ reason: "support checked again after forgetting sources",
48449
+ forget_id: batch
48450
+ }).execute();
48451
+ after = "active";
48452
+ } catch (e) {
48453
+ if (!ACTIVATION.test(e.message))
48454
+ throw e;
48455
+ }
48456
+ }
48457
+ outcome.units.push({ key: u.key, before: u.lifecycle, after, removed: removed.get(u.id) ?? 0 });
48458
+ }
48459
+ return outcome;
48460
+ }
48461
+ async function previewForget(file2, projectId2, ids) {
48462
+ const db = openWriter("forget", file2);
48463
+ try {
48464
+ return await db.connection().execute(async (c) => {
48465
+ await sql`begin immediate`.execute(c);
48466
+ try {
48467
+ throw new Preview(await forgetIn(c, projectId2, ids, iso(Date.now())));
48468
+ } catch (e) {
48469
+ await sql`rollback`.execute(c).catch(() => {});
48470
+ if (e instanceof Preview)
48471
+ return e.outcome;
48472
+ throw e;
48473
+ }
48474
+ });
48475
+ } finally {
48476
+ await db.destroy();
48477
+ }
48478
+ }
48479
+ async function applyForget(file2, projectId2, ids, confirmed, signal) {
48480
+ const db = openWriter("forget", file2);
48481
+ try {
48482
+ return await db.connection().execute(async (c) => {
48483
+ await sql`pragma secure_delete = on`.execute(c);
48484
+ await sql`begin immediate`.execute(c);
48485
+ let outcome;
48486
+ try {
48487
+ outcome = await forgetIn(c, projectId2, ids, iso(Date.now()));
48488
+ if (JSON.stringify(outcome) !== JSON.stringify(confirmed))
48489
+ throw new Error("What these sources support changed after you confirmed. Nothing was forgotten; look at the preview again");
48490
+ if (signal?.aborted)
48491
+ throw new Error("The call was cancelled, so nothing was forgotten");
48492
+ await sql`commit`.execute(c);
48493
+ } catch (e) {
48494
+ await sql`rollback`.execute(c).catch(() => {});
48495
+ throw e;
48496
+ }
48497
+ try {
48498
+ await sql`insert into source_fts (source_fts) values ('optimize')`.execute(c);
48499
+ const checkpoint = await sql`pragma wal_checkpoint(TRUNCATE)`.execute(c);
48500
+ return { outcome, cleanup: checkpoint.rows[0]?.busy === 0 ? "done" : "incomplete" };
48501
+ } catch {
48502
+ return { outcome, cleanup: "incomplete" };
48503
+ }
48504
+ });
48505
+ } finally {
48506
+ await db.destroy();
48507
+ }
48508
+ }
48509
+ function forgetText(o) {
48510
+ const lines = o.sources.map((s) => `- s${s.id} ${s.kind} in ${inline(s.artifact).slice(0, 120)} (${plural2(s.bytes, "byte")})`);
48511
+ if (o.already.length)
48512
+ lines.push(`- already forgotten: ${o.already.map((id) => `s${id}`).join(", ")} (only the cleanup runs again)`);
48513
+ for (const u of o.units) {
48514
+ const change = u.before === u.after ? `stays ${u.after}` : `${u.before} → ${u.after}`;
48515
+ lines.push(`- record ${inline(u.key).slice(0, 120)}: ${change}, loses ${plural2(u.removed, "citation")}`);
48516
+ }
48517
+ if (o.references)
48518
+ lines.push(`- ${plural2(o.references, "unfetched reference")} the forgotten messages gave`);
48519
+ if (o.units.length)
48520
+ lines.push("Records keep their own text: if one repeats the forgotten words, they stay in it.");
48521
+ lines.push("Copies outside the database (capture's waiting and set-aside files, backups) are not touched.");
48522
+ return lines.join(`
48523
+ `);
48524
+ }
48525
+
48291
48526
  // server/src/frame.ts
48292
48527
  import crypto3 from "node:crypto";
48293
48528
  function framed(body) {
@@ -48345,7 +48580,7 @@ var tool = (fn) => fn().then((t) => reply(t), (e) => reply(`Sphica: ${head(reaso
48345
48580
  var server = new McpServer({ name: "sphica-record", version: versionAt(ROOT) ?? "unknown" }, {
48346
48581
  capabilities: { experimental: { "codex/sandbox-state-meta": {} } },
48347
48582
  instructions: [
48348
- "Writes Sphica records for the trace, harvest, and glean Skills. Use these tools only while running one of those Skills.",
48583
+ "Writes Sphica records for the trace, harvest, glean, and forget Skills. Use these tools only while running one of those Skills.",
48349
48584
  "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.",
48350
48585
  "Always pass the repository root as cwd."
48351
48586
  ].join(`
@@ -48426,4 +48661,66 @@ server.registerTool("record_save", {
48426
48661
  const p = await projectOf(a.cwd, extra._meta);
48427
48662
  return saveText(conn(), a.run, p.projectId, p.root, a.record);
48428
48663
  }));
48664
+ 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)");
48665
+ var idsOf = (refs) => refs.map((r) => Number(r.slice(1)));
48666
+ var ANSWER_MS = 10 * 60 * 1000;
48667
+ server.registerTool("forget_preview", {
48668
+ title: "What forgetting sources would do",
48669
+ description: "Shows which sources would be removed and which records would lose citations or leave active. Changes nothing. Never shows the text.",
48670
+ inputSchema: { sources: SOURCES, cwd: CWD },
48671
+ annotations: READ
48672
+ }, async (a, extra) => tool(async () => {
48673
+ const p = await projectOf(a.cwd, extra._meta);
48674
+ return forgetText(await previewForget(dbFile(), p.projectId, idsOf(a.sources)));
48675
+ }));
48676
+ server.registerTool("forget_apply", {
48677
+ title: "Forget sources",
48678
+ 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.",
48679
+ inputSchema: { sources: SOURCES, cwd: CWD },
48680
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }
48681
+ }, async (a, extra) => tool(async () => {
48682
+ const p = await projectOf(a.cwd, extra._meta);
48683
+ const ids = idsOf(a.sources);
48684
+ const seen = await previewForget(dbFile(), p.projectId, ids);
48685
+ const n = seen.sources.length;
48686
+ if (n > 0) {
48687
+ const refused = (why) => new Error(`${why}, so nothing was forgotten`);
48688
+ if (!server.server.getClientCapabilities()?.elicitation)
48689
+ throw refused("This host cannot ask you directly (run /sphica:forget in Claude Code)");
48690
+ let answer;
48691
+ try {
48692
+ answer = await server.server.elicitInput({
48693
+ mode: "form",
48694
+ message: `Forget ${plural2(n, "source")} for good?
48695
+ ${forgetText(seen)}`,
48696
+ requestedSchema: {
48697
+ type: "object",
48698
+ properties: {
48699
+ confirm: {
48700
+ type: "string",
48701
+ title: "Number of sources",
48702
+ description: `Type ${n} to forget ${plural2(n, "source")}`
48703
+ }
48704
+ },
48705
+ required: ["confirm"]
48706
+ }
48707
+ }, { timeout: ANSWER_MS, signal: extra.signal });
48708
+ } catch (e) {
48709
+ throw refused(`The confirmation did not come back (${head(reason(e), 200)})`);
48710
+ }
48711
+ if (extra.signal.aborted)
48712
+ throw refused("The call was cancelled");
48713
+ if (answer.action !== "accept")
48714
+ throw refused(`You ${answer.action === "decline" ? "declined" : "cancelled"}`);
48715
+ if (String(answer.content?.confirm ?? "").trim() !== String(n))
48716
+ throw refused(`The number typed does not match ${n}`);
48717
+ }
48718
+ const done = await applyForget(dbFile(), p.projectId, ids, seen, extra.signal);
48719
+ return [
48720
+ n ? `Forgot ${plural2(n, "source")}.` : "Nothing new to forget.",
48721
+ forgetText(done.outcome),
48722
+ 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."
48723
+ ].join(`
48724
+ `);
48725
+ }));
48429
48726
  await server.connect(new StdioServerTransport);