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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +4 -3
- package/db/migrations/0002.sql +145 -0
- package/db/schema.sql +54 -9
- package/dist/capture.js +159 -85
- package/dist/cli.js +191 -88
- package/dist/deliver.js +157 -85
- package/dist/mcp-record.js +399 -102
- package/dist/mcp.js +2 -2
- package/package.json +1 -1
- package/skills/forget/SKILL.md +53 -0
- package/skills/forget/agents/openai.yaml +2 -0
package/dist/mcp-record.js
CHANGED
|
@@ -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 =
|
|
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 ? "
|
|
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
|
-
|
|
46728
|
-
|
|
46729
|
-
|
|
46730
|
-
|
|
46731
|
-
|
|
46732
|
-
|
|
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
|
-
|
|
46735
|
-
|
|
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
|
-
|
|
46738
|
-
} catch {
|
|
46739
|
-
|
|
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
|
-
|
|
46743
|
-
|
|
46744
|
-
|
|
46745
|
-
const
|
|
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
|
-
|
|
46750
|
-
} catch {
|
|
46751
|
-
|
|
46752
|
-
|
|
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
|
-
|
|
46783
|
-
|
|
46784
|
-
|
|
46785
|
-
|
|
46786
|
-
|
|
46787
|
-
|
|
46788
|
-
|
|
46789
|
-
|
|
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
|
-
|
|
46794
|
-
|
|
46795
|
-
|
|
46796
|
-
|
|
46797
|
-
|
|
46798
|
-
|
|
46799
|
-
|
|
46800
|
-
|
|
46801
|
-
|
|
46802
|
-
|
|
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
|
-
|
|
46807
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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:
|
|
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
|
|
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);
|