sphica 0.6.18 → 0.6.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp.js CHANGED
@@ -34743,6 +34743,7 @@ function singular(w) {
34743
34743
  return w.slice(0, -1);
34744
34744
  return w.endsWith("s") && !/(?:ss|us|is)$/.test(w) ? w.slice(0, -1) : w;
34745
34745
  }
34746
+ var identTerm = (x) => singular(x.normalize("NFKC").toLowerCase());
34746
34747
  function terms(text) {
34747
34748
  const norm = text.normalize("NFKC").toLowerCase();
34748
34749
  const out = [];
@@ -34801,8 +34802,9 @@ var QUESTION = new Set([
34801
34802
  ...["add", "get", "make", "have", "has", "not", "no", "yes", "reason"],
34802
34803
  ...["理由", "仕組", "何", "方", "場合", "今", "件"]
34803
34804
  ]);
34805
+ var FOLDED_QUESTION = new Set([...QUESTION].flatMap((w) => terms(w)));
34804
34806
  function queryTerms(question) {
34805
- return [...new Set(terms(question).filter((w) => !QUESTION.has(w)))].slice(0, 24);
34807
+ return [...new Set(terms(question).filter((w) => !FOLDED_QUESTION.has(w)))].slice(0, 24);
34806
34808
  }
34807
34809
  function ftsQuery(question) {
34808
34810
  const ws = queryTerms(question);
@@ -45627,347 +45629,6 @@ function parseObject(obj, jsonPath, options) {
45627
45629
  return target;
45628
45630
  }
45629
45631
 
45630
- // server/src/search.ts
45631
- var UNIT_PAGE = 200;
45632
- var UNIT_SCAN_MAX = 2000;
45633
- var SOURCE_PAGE = 50;
45634
- var SOURCE_SCAN_MAX = 600;
45635
- var SOURCE_SCAN_BYTES = 64 * 1024 * 1024;
45636
- var LIFE_ORDER = { active: 0, candidate: 1, superseded: 2, withdrawn: 3 };
45637
- var strong = (matched, of) => matched * 2 > of;
45638
- async function searchUnits(db, projectId, q) {
45639
- const wanted = queryTerms(q.question);
45640
- const match = ftsQuery(q.question);
45641
- if (!match)
45642
- return { hits: [], weaker: 0, terms: wanted, stopped: false, read: 0 };
45643
- let query = db.selectFrom(sql`(select unit_fts.rowid as rowid, bm25(unit_fts, 3, 2, 1) as rank from unit_fts join unit on unit.id = unit_fts.rowid where unit_fts match ${match} and unit.project_id = ${projectId})`.as("f")).innerJoin("unit as u", "u.id", "f.rowid").where("u.project_id", "=", projectId).where("u.extraction", "=", "supported");
45644
- if (q.kinds?.length)
45645
- query = query.where("u.kind", "in", q.kinds);
45646
- if (q.lifecycles?.length)
45647
- query = query.where("u.lifecycle", "in", q.lifecycles);
45648
- if (q.path)
45649
- query = query.where(({ exists, selectFrom }) => exists(selectFrom("unit_anchor as a").select("a.id").whereRef("a.unit_id", "=", "u.id").where("a.path", "=", q.path ?? "").where("a.retired_at", "is", null)));
45650
- const ranked = await query.select(["u.id", "f.rank"]).orderBy("f.rank").orderBy("u.id").limit(UNIT_SCAN_MAX + 1).execute();
45651
- const stopped = ranked.length > UNIT_SCAN_MAX;
45652
- let weaker = 0;
45653
- let read = 0;
45654
- const hits = [];
45655
- for (let at = 0;at < Math.min(ranked.length, UNIT_SCAN_MAX); at += UNIT_PAGE) {
45656
- const part = ranked.slice(at, Math.min(at + UNIT_PAGE, UNIT_SCAN_MAX));
45657
- const order = new Map(part.map((r, i) => [r.id, i]));
45658
- const rows = (await query.select([
45659
- "u.id",
45660
- "u.key",
45661
- "u.kind",
45662
- "u.stance",
45663
- "u.lifecycle",
45664
- "u.text",
45665
- "u.why",
45666
- "u.scope_note",
45667
- "u.revisit_when",
45668
- "u.content_hash",
45669
- "f.rank"
45670
- ]).where("u.id", "in", part.map((r) => r.id)).execute()).sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
45671
- read += rows.length;
45672
- weaker += await judgeUnits(db, rows, wanted, hits);
45673
- }
45674
- const shown = new Set(hits.map((h) => h.id));
45675
- for (const h of [...hits].filter((x) => x.lifecycle === "superseded"))
45676
- for (const n of await liveSuccessors(db, h.id))
45677
- if (!shown.has(n.id) && (!q.kinds?.length || q.kinds.includes(n.kind)) && (!q.lifecycles?.length || q.lifecycles.includes(n.lifecycle))) {
45678
- shown.add(n.id);
45679
- hits.push({
45680
- ...n,
45681
- options: [],
45682
- anchors: [],
45683
- matched: [],
45684
- aliasOnly: false,
45685
- successorOf: h.key,
45686
- rank: h.rank
45687
- });
45688
- }
45689
- hits.sort((a, b) => (LIFE_ORDER[a.lifecycle] ?? 9) - (LIFE_ORDER[b.lifecycle] ?? 9) || b.matched.length - a.matched.length || a.rank - b.rank);
45690
- return {
45691
- hits: hits.slice(0, q.limit).map(({ rank: _rank, ...h }) => h),
45692
- weaker,
45693
- terms: wanted,
45694
- stopped,
45695
- read
45696
- };
45697
- }
45698
- async function liveSuccessors(db, id) {
45699
- const seen = new Set([id]);
45700
- const found = [];
45701
- const replaced = new Set;
45702
- for (let frontier = [id];frontier.length; ) {
45703
- const next = await db.selectFrom("unit_link as l").innerJoin("unit as n", "n.id", "l.from_unit").where("l.to_unit", "in", frontier).where("l.kind", "=", "supersedes").where("n.extraction", "=", "supported").where("n.lifecycle", "<>", "candidate").select([
45704
- "l.to_unit",
45705
- "n.id",
45706
- "n.key",
45707
- "n.kind",
45708
- "n.stance",
45709
- "n.lifecycle",
45710
- "n.text",
45711
- "n.why",
45712
- "n.revisit_when"
45713
- ]).execute();
45714
- frontier = [];
45715
- for (const { to_unit, ...n } of next) {
45716
- replaced.add(to_unit);
45717
- if (seen.has(n.id))
45718
- continue;
45719
- seen.add(n.id);
45720
- found.push(n);
45721
- frontier.push(n.id);
45722
- }
45723
- }
45724
- return found.filter((n) => !replaced.has(n.id));
45725
- }
45726
- async function judgeUnits(db, rows, wanted, hits) {
45727
- const ids = rows.map((r) => r.id);
45728
- const [options, anchors, aliases, fields] = ids.length ? await Promise.all([
45729
- db.selectFrom("unit_option").select(["unit_id", "text", "outcome", "why", "reconsider_when"]).where("unit_id", "in", ids).orderBy("position").execute(),
45730
- db.selectFrom("unit_anchor").select(["unit_id", "path", "symbol", "role"]).where("unit_id", "in", ids).where("retired_at", "is", null).orderBy("id").execute(),
45731
- db.selectFrom("unit_alias").select(["unit_id", "terms", "content_hash"]).where("unit_id", "in", ids).orderBy("id", "desc").execute(),
45732
- db.selectFrom("unit_field as f").innerJoin("field_def as d", "d.id", "f.field_def_id").select(["f.unit_id", "d.name", "f.value"]).where("f.unit_id", "in", ids).execute()
45733
- ]) : [[], [], [], []];
45734
- let weaker = 0;
45735
- for (const r of rows) {
45736
- const opts = options.filter((o) => o.unit_id === r.id);
45737
- const anch = anchors.filter((a) => a.unit_id === r.id);
45738
- const alias = aliases.find((a) => a.unit_id === r.id && Buffer.from(a.content_hash).equals(Buffer.from(r.content_hash)));
45739
- const own2 = new Set(terms([
45740
- r.text,
45741
- r.why,
45742
- r.scope_note,
45743
- r.revisit_when,
45744
- ...opts.flatMap((o) => [o.text, o.why, o.reconsider_when]),
45745
- ...anch.flatMap((a) => [a.path, a.symbol]),
45746
- ...fields.filter((f) => f.unit_id === r.id).flatMap((f) => [f.name, f.value])
45747
- ].filter(Boolean).join(`
45748
- `)));
45749
- const extra = new Set(alias ? terms(JSON.parse(alias.terms).join(" ")) : []);
45750
- const matched = wanted.filter((w) => own2.has(w) || extra.has(w));
45751
- const ident = new Set(anch.flatMap((a) => [a.path, a.symbol].flatMap((x) => x ? [x.normalize("NFKC").toLowerCase()] : [])));
45752
- if (!strong(matched.length, wanted.length) && !matched.some((w) => ident.has(w))) {
45753
- weaker++;
45754
- continue;
45755
- }
45756
- hits.push({
45757
- id: r.id,
45758
- key: r.key,
45759
- kind: r.kind,
45760
- stance: r.stance,
45761
- lifecycle: r.lifecycle,
45762
- text: r.text,
45763
- why: r.why,
45764
- revisit_when: r.revisit_when,
45765
- options: opts.map((o) => ({ text: o.text, outcome: o.outcome, why: o.why })),
45766
- anchors: anch.map((a) => ({ path: a.path, symbol: a.symbol, role: a.role })),
45767
- matched,
45768
- aliasOnly: matched.every((w) => !own2.has(w)),
45769
- rank: r.rank
45770
- });
45771
- }
45772
- return weaker;
45773
- }
45774
- async function searchSources(db, projectId, question, limit, owner) {
45775
- const wanted = queryTerms(question);
45776
- const match = ftsQuery(question);
45777
- if (!match)
45778
- return { hits: [], weaker: 0, terms: wanted, stopped: false, read: 0 };
45779
- const query = db.selectFrom(sql`(select source_fts.rowid as rowid, bm25(source_fts) as rank from source_fts join source on source.id = source_fts.rowid where source_fts match ${match} and source.project_id = ${projectId})`.as("f")).innerJoin("source as s", "s.id", "f.rowid").where("s.project_id", "=", projectId).$if(owner !== undefined, (q) => q.where("s.kind", "=", "session_message").where("s.author_kind", "=", "owner").$if((owner?.notSessions.length ?? 0) > 0, (q2) => q2.where("s.session_id", "not in", owner?.notSessions ?? [])));
45780
- const ranked = await query.select(["s.id", "f.rank"]).orderBy("f.rank").orderBy("s.id").limit(SOURCE_SCAN_MAX + 1).execute();
45781
- let weaker = 0;
45782
- let stopped = false;
45783
- const hits = [];
45784
- let read = 0;
45785
- let bytes2 = 0;
45786
- scan:
45787
- for (let at = 0;at < ranked.length; at += SOURCE_PAGE) {
45788
- const ids = ranked.slice(at, at + SOURCE_PAGE).map((r) => r.id);
45789
- const byId = new Map((await db.selectFrom("source").select([
45790
- "id",
45791
- "kind",
45792
- "artifact",
45793
- "author_kind",
45794
- "author_login",
45795
- "created_at",
45796
- "text",
45797
- "session_id",
45798
- "turn_id"
45799
- ]).where("id", "in", ids).execute()).map((r) => [r.id, r]));
45800
- for (const id of ids) {
45801
- if (read >= SOURCE_SCAN_MAX || bytes2 >= SOURCE_SCAN_BYTES) {
45802
- stopped = true;
45803
- break scan;
45804
- }
45805
- const r = byId.get(id);
45806
- if (!r)
45807
- continue;
45808
- read++;
45809
- bytes2 += Buffer.byteLength(r.text);
45810
- const own2 = new Set(terms(r.text));
45811
- const matched = wanted.filter((w) => own2.has(w));
45812
- if (!strong(matched.length, wanted.length)) {
45813
- weaker++;
45814
- continue;
45815
- }
45816
- hits.push({
45817
- id: r.id,
45818
- kind: r.kind,
45819
- artifact: r.artifact,
45820
- author: r.author_login ?? r.author_kind,
45821
- created_at: r.created_at,
45822
- text: r.text,
45823
- matched,
45824
- session: r.session_id,
45825
- turn: r.turn_id
45826
- });
45827
- if (hits.length >= limit)
45828
- break scan;
45829
- }
45830
- }
45831
- if (!stopped && hits.length < limit && ranked.length > SOURCE_SCAN_MAX)
45832
- stopped = true;
45833
- return { hits, weaker, terms: wanted, stopped, read };
45834
- }
45835
-
45836
- // server/src/asked.ts
45837
- var DECIDED_KINDS = new Set(["decision", "constraint"]);
45838
- var DECIDED_LIFECYCLES = new Set(["active", "superseded", "withdrawn"]);
45839
- var PER_MESSAGE = 10;
45840
- async function askedBefore(db, projectId, q) {
45841
- const kept = (u) => (!q.kinds?.length || q.kinds.includes(u.kind)) && (!q.lifecycles?.length || q.lifecycles.includes(u.lifecycle));
45842
- const found = await searchSources(db, projectId, q.question, Number.POSITIVE_INFINITY, {
45843
- notSessions: q.notSessions
45844
- });
45845
- const ids = found.hits.map((h) => h.id);
45846
- const cited = ids.length ? await citing(db, ids) : [];
45847
- const traced = new Set(ids.length ? (await db.selectFrom("source_processing").select("source_id").where("source_id", "in", ids).execute()).map((r) => r.source_id) : []);
45848
- const decided = new Set(cited.filter((c) => DECIDED_KINDS.has(c.kind) && DECIDED_LIFECYCLES.has(c.lifecycle)).map((c) => c.source_id));
45849
- const all = [];
45850
- for (const m of found.hits) {
45851
- const direct = unique(cited.filter((c) => c.source_id === m.id));
45852
- const shown = direct.filter(kept);
45853
- all.push({
45854
- message: m,
45855
- led: shown.map((u) => ({ ...u, now: [] })),
45856
- hidden: direct.length - shown.length,
45857
- context: [],
45858
- direct: new Set(direct.map((u) => u.id)),
45859
- decided: decided.has(m.id),
45860
- traced: traced.has(m.id),
45861
- reply: null
45862
- });
45863
- }
45864
- const messages = all.slice(0, q.limit);
45865
- for (const e of messages) {
45866
- for (const u of e.led)
45867
- if (u.lifecycle === "superseded")
45868
- u.now = await liveSuccessors(db, u.id);
45869
- await sameTurn(db, e, kept);
45870
- }
45871
- const sessions = new Map;
45872
- for (const e of all) {
45873
- const s = e.message.session ?? "";
45874
- const was = sessions.get(s) ?? { decided: false, traced: true };
45875
- sessions.set(s, { decided: was.decided || e.decided, traced: was.traced && e.traced });
45876
- }
45877
- const none = [...sessions.values()].every((s) => !s.decided);
45878
- const repeats = sessions.size >= 2 && none ? {
45879
- untraced: [...sessions].filter(([, s]) => !s.traced).map(([id]) => id),
45880
- traced: [...sessions].filter(([, s]) => s.traced).map(([id]) => id)
45881
- } : null;
45882
- return {
45883
- messages,
45884
- repeats,
45885
- terms: found.terms,
45886
- weaker: found.weaker,
45887
- stopped: found.stopped,
45888
- read: found.read
45889
- };
45890
- }
45891
- async function citing(db, sources) {
45892
- const [evidence, adoption] = await Promise.all([
45893
- db.selectFrom("unit_evidence as e").innerJoin("unit as u", "u.id", "e.unit_id").where("e.source_id", "in", sources).where("e.retracted_at", "is", null).where("u.extraction", "=", "supported").select(["e.source_id", "e.role", "u.id", "u.key", "u.kind", "u.lifecycle"]).orderBy("u.id").execute(),
45894
- db.selectFrom("unit_adoption as a").innerJoin("unit as u", "u.id", "a.unit_id").where("a.source_id", "in", sources).where("a.retracted_at", "is", null).where("u.extraction", "=", "supported").select(["a.source_id", "u.id", "u.key", "u.kind", "u.lifecycle"]).orderBy("u.id").execute()
45895
- ]);
45896
- return [...evidence, ...adoption.map((a) => ({ ...a, role: "adoption" }))];
45897
- }
45898
- var unique = (xs) => [...new Map(xs.map((x) => [x.id, x])).values()];
45899
- async function sameTurn(db, e, kept) {
45900
- const { session, turn, id } = e.message;
45901
- if (!session || !turn)
45902
- return;
45903
- const others = await db.selectFrom("source").select(["id", "author_kind"]).where("session_id", "=", session).where("turn_id", "=", turn).where("id", "<>", id).orderBy("id").execute();
45904
- e.reply = others.findLast((o) => o.author_kind === "assistant")?.id ?? null;
45905
- if (!others.length)
45906
- return;
45907
- const speaker = new Map(others.map((o) => [o.id, o.author_kind]));
45908
- const seen = new Set;
45909
- for (const c of await citing(db, others.map((o) => o.id))) {
45910
- const k = `${c.id}\x00${c.source_id}\x00${c.role}`;
45911
- if (e.direct.has(c.id) || !kept(c) || seen.has(k))
45912
- continue;
45913
- seen.add(k);
45914
- e.context.push({
45915
- key: c.key,
45916
- kind: c.kind,
45917
- lifecycle: c.lifecycle,
45918
- speaker: speaker.get(c.source_id) ?? "",
45919
- role: c.role
45920
- });
45921
- }
45922
- }
45923
- var UNKNOWN_SESSION = "Current session unknown; results and session counts may include its messages. Pass session to exclude it.";
45924
- function askedText(r, known = true) {
45925
- const parts = r.messages.map((e) => {
45926
- const m = e.message;
45927
- const lines = [`## s${m.id}: ${inline(m.artifact)}, ${m.created_at}`, inline(head(m.text, 600))];
45928
- if (e.led.length) {
45929
- lines.push("Led to:");
45930
- for (const u of e.led.slice(0, PER_MESSAGE))
45931
- lines.push(`- ${inline(u.key)} (${u.kind}, ${u.lifecycle}${u.now.length ? `; now ${u.now.map((n) => `${inline(n.key)} (${n.lifecycle})`).join(", ")}` : ""})`);
45932
- }
45933
- if (e.led.length > PER_MESSAGE)
45934
- lines.push(`and ${e.led.length - PER_MESSAGE} more tied records`);
45935
- if (e.hidden)
45936
- lines.push(`${e.hidden} tied record${e.hidden === 1 ? "" : "s"} hidden by the filters.`);
45937
- if (!e.decided)
45938
- lines.push(`No recorded decision.${e.traced ? "" : ` Not traced yet: run /sphica:trace ${inline(m.session ?? "")}.`}`);
45939
- if (e.context.length) {
45940
- lines.push("Same turn (context, not necessarily the answer):");
45941
- for (const c of e.context.slice(0, PER_MESSAGE))
45942
- lines.push(`- ${inline(c.key)} (${c.kind}, ${c.lifecycle}): quotes the ${c.speaker} (${c.role})`);
45943
- if (e.context.length > PER_MESSAGE)
45944
- lines.push(`and ${e.context.length - PER_MESSAGE} more in the same turn`);
45945
- }
45946
- if (e.reply !== null)
45947
- lines.push(`Reply: s${e.reply} (read it with read)`);
45948
- return lines.join(`
45949
- `);
45950
- });
45951
- const out = [`${known ? "Earlier owner" : "Owner"} messages matching: ${r.terms.join(", ")}`, ...parts];
45952
- if (r.repeats) {
45953
- const n = r.repeats.untraced.length + r.repeats.traced.length;
45954
- const within = r.stopped ? ` (within the first ${r.read} candidates by rank)` : "";
45955
- out.push([
45956
- known ? `Asked in ${n} sessions with no recorded decision${within}:` : `Matching messages in ${n} sessions with no recorded decision (current session may be included)${within}:`,
45957
- ...r.repeats.untraced.length ? [`- not traced yet: ${r.repeats.untraced.map(inline).join(", ")} (run /sphica:trace with each)`] : [],
45958
- ...r.repeats.traced.length ? [`- traced: ${r.repeats.traced.map(inline).join(", ")}`] : []
45959
- ].join(`
45960
- `));
45961
- }
45962
- if (r.stopped)
45963
- out.push(`Stopped after ${r.read} candidates by rank; more may match.`);
45964
- if (!known)
45965
- out.push(UNKNOWN_SESSION);
45966
- return out.join(`
45967
-
45968
- `);
45969
- }
45970
-
45971
45632
  // server/src/kysely-node-sqlite.ts
45972
45633
  var plain2 = (row) => {
45973
45634
  const out = {};
@@ -46030,7 +45691,7 @@ var HOST_MARKS = new Set([".orphaned_at", ".in_use"]);
46030
45691
 
46031
45692
  // server/src/sqlite.ts
46032
45693
  var SCHEMA_GENERATION = 2;
46033
- var SCHEMA_REVISION = 7;
45694
+ var SCHEMA_REVISION = 8;
46034
45695
  var sphicaHome = () => process.env.SPHICA_HOME || path2.join(os.homedir(), ".sphica");
46035
45696
  var dbFile = () => process.env.SPHICA_DB || path2.join(sphicaHome(), "sphica.db");
46036
45697
  function requireRuntime() {
@@ -46057,176 +45718,636 @@ function prepare(raw, check2, busyMs = 5000) {
46057
45718
  throw new RevisionMismatch(got ?? 0);
46058
45719
  }
46059
45720
 
46060
- class RevisionMismatch extends Error {
46061
- constructor(got) {
46062
- super(`The database schema is revision ${got}, but this Sphica expects revision ${SCHEMA_REVISION}. ` + (got < SCHEMA_REVISION ? `Run \`npm i -g sphica@${packageVersionAt(ROOT) ?? "latest"}\`, then \`sphica init\` to migrate it (records are kept, and a backup is made first).` : "Update sphica."));
45721
+ class RevisionMismatch extends Error {
45722
+ constructor(got) {
45723
+ super(`The database schema is revision ${got}, but this Sphica expects revision ${SCHEMA_REVISION}. ` + (got < SCHEMA_REVISION ? `Run \`npm i -g sphica@${packageVersionAt(ROOT) ?? "latest"}\`, then \`sphica init\` to migrate it (records are kept, and a backup is made first).` : "Update sphica."));
45724
+ }
45725
+ }
45726
+ function generationOf(raw) {
45727
+ const has = raw.prepare("select 1 from sqlite_schema where type = 'table' and name = 'sphica_generation'").get();
45728
+ if (!has) {
45729
+ const any3 = raw.prepare("select 1 from sqlite_schema where type = 'table' and name = 'project'").get();
45730
+ return any3 ? 1 : null;
45731
+ }
45732
+ return raw.prepare("select generation from sphica_generation").get()?.generation ?? null;
45733
+ }
45734
+ function checkGeneration(raw) {
45735
+ const got = generationOf(raw);
45736
+ if (got === SCHEMA_GENERATION)
45737
+ return;
45738
+ if (got === null)
45739
+ throw new Error("The database has no sphica schema. Create it with `sphica init`.");
45740
+ throw new Error(got < SCHEMA_GENERATION ? "The database was made by Sphica 0.4 or earlier, and this Sphica cannot read it. Move it aside (it is left unchanged), then run `sphica init`." : "The database was made by a newer Sphica. Update sphica.");
45741
+ }
45742
+ var READER_FUNCTIONS = new Set([
45743
+ "bm25",
45744
+ "coalesce",
45745
+ "count",
45746
+ "instr",
45747
+ "json_array_length",
45748
+ "json_extract",
45749
+ "json_group_array",
45750
+ "json_object",
45751
+ "length",
45752
+ "lower",
45753
+ "match",
45754
+ "max",
45755
+ "min",
45756
+ "substr"
45757
+ ]);
45758
+ function connectReader(file2 = dbFile()) {
45759
+ requireRuntime();
45760
+ requireFile(file2);
45761
+ const raw = new DatabaseSync(file2, { readOnly: true });
45762
+ try {
45763
+ prepare(raw, "revision");
45764
+ } catch (e) {
45765
+ raw.close();
45766
+ throw e;
45767
+ }
45768
+ raw.setAuthorizer((action, p1, p2) => {
45769
+ if (action === C.SQLITE_READ || action === C.SQLITE_SELECT || action === C.SQLITE_RECURSIVE)
45770
+ return C.SQLITE_OK;
45771
+ if (action === C.SQLITE_FUNCTION)
45772
+ return READER_FUNCTIONS.has((p2 ?? "").toLowerCase()) ? C.SQLITE_OK : C.SQLITE_DENY;
45773
+ if (action === C.SQLITE_PRAGMA && p1 === "data_version" && p2 === null)
45774
+ return C.SQLITE_OK;
45775
+ return C.SQLITE_DENY;
45776
+ });
45777
+ return raw;
45778
+ }
45779
+
45780
+ // server/src/db.ts
45781
+ var JSON_COLUMNS = new Set(["next"]);
45782
+ var TOP_LEVEL = /^\$\[\d+\]\."([^"]+)"$/;
45783
+ var parseJson = new ParseJSONResultsPlugin({
45784
+ shouldParse: (_value, jsonPath) => JSON_COLUMNS.has(jsonPath.match(TOP_LEVEL)?.[1] ?? "")
45785
+ });
45786
+ function kyselyOn(connect) {
45787
+ return new Kysely({
45788
+ dialect: new SqliteDialect({ database: async () => adapt(connect()) }),
45789
+ plugins: [parseJson]
45790
+ });
45791
+ }
45792
+ function openReader(file2 = dbFile()) {
45793
+ return kyselyOn(() => connectReader(file2));
45794
+ }
45795
+
45796
+ // server/src/knowledge.ts
45797
+ var UNIT_KINDS = [
45798
+ "decision",
45799
+ "implementation",
45800
+ "finding",
45801
+ "dead_end",
45802
+ "question",
45803
+ "constraint"
45804
+ ];
45805
+ var STANCES = ["do", "dont", "defer"];
45806
+ var LIFECYCLES = ["candidate", "active", "superseded", "withdrawn"];
45807
+ var OPTION_OUTCOMES = ["chosen", "rejected", "deferred", "proposed"];
45808
+ var EVIDENCE_ROLES = [
45809
+ "states",
45810
+ "proposes",
45811
+ "rejects",
45812
+ "explains",
45813
+ "implements",
45814
+ "reconsiders"
45815
+ ];
45816
+ var WORK_STATUSES = ["active", "blocked", "paused", "done", "abandoned"];
45817
+ var FIELD_TYPES = ["text", "enum", "integer", "date"];
45818
+ var HOSTS = ["claude-code", "codex"];
45819
+ var sessionId = (projectId, host, externalId) => uuidFrom(String(projectId), host, externalId);
45820
+
45821
+ // server/src/anchors.ts
45822
+ import fs3 from "node:fs";
45823
+ import path3 from "node:path";
45824
+ var MAX_BYTES = 2 * 1024 * 1024;
45825
+ var leaves = (rel) => rel === ".." || rel.startsWith(`..${path3.sep}`) || path3.isAbsolute(rel);
45826
+ var literal3 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
45827
+ function readBytes(root, rel) {
45828
+ const abs = path3.join(root, rel);
45829
+ if (leaves(path3.relative(root, abs)))
45830
+ return;
45831
+ const st = fs3.lstatSync(abs, { throwIfNoEntry: false });
45832
+ if (!st)
45833
+ return null;
45834
+ if (!st.isFile() || st.size > MAX_BYTES)
45835
+ return;
45836
+ try {
45837
+ const inside = path3.relative(fs3.realpathSync(root), fs3.realpathSync(abs));
45838
+ if (leaves(inside))
45839
+ return;
45840
+ const buf = fs3.readFileSync(abs);
45841
+ return buf.includes(0) ? undefined : buf;
45842
+ } catch {
45843
+ return;
45844
+ }
45845
+ }
45846
+ function readText(root, rel) {
45847
+ const buf = readBytes(root, rel);
45848
+ return buf ? buf.toString("utf8") : buf;
45849
+ }
45850
+ function findSymbol(text, symbol2) {
45851
+ const re = new RegExp(`(?<![\\w$])${literal3(symbol2)}(?![\\w$])`);
45852
+ const lines = text.split(/\r?\n/);
45853
+ return { lines, i: lines.findIndex((l) => re.test(l)) };
45854
+ }
45855
+ function fileState(root, rel) {
45856
+ if (!root)
45857
+ return "unknown";
45858
+ if (leaves(path3.relative(root, path3.join(root, rel))))
45859
+ return "unknown";
45860
+ let realRoot;
45861
+ try {
45862
+ realRoot = fs3.realpathSync(root);
45863
+ } catch {
45864
+ return "unknown";
45865
+ }
45866
+ let at = root;
45867
+ for (const part of rel.split("/")) {
45868
+ at = path3.join(at, part);
45869
+ let st;
45870
+ try {
45871
+ st = fs3.lstatSync(at, { throwIfNoEntry: false });
45872
+ } catch (e) {
45873
+ if (e.code === "ENOTDIR")
45874
+ return "gone";
45875
+ return "unknown";
45876
+ }
45877
+ if (!st)
45878
+ return "gone";
45879
+ if (st.isSymbolicLink()) {
45880
+ try {
45881
+ if (leaves(path3.relative(realRoot, fs3.realpathSync(at))))
45882
+ return "unknown";
45883
+ } catch {
45884
+ return "unknown";
45885
+ }
45886
+ }
45887
+ }
45888
+ return "present";
45889
+ }
45890
+ function checkAnchor(root, a) {
45891
+ if (!root)
45892
+ return { state: "unknown", line: null };
45893
+ const text = readText(root, a.path);
45894
+ if (text === null)
45895
+ return { state: "missing", line: null };
45896
+ if (text === undefined)
45897
+ return { state: "unknown", line: null };
45898
+ if (!a.symbol)
45899
+ return { state: "located", line: a.line_start };
45900
+ const { i } = findSymbol(text, a.symbol);
45901
+ if (i < 0)
45902
+ return { state: "missing", line: null };
45903
+ return {
45904
+ state: a.line_start === null || a.line_start === i + 1 ? "located" : "moved",
45905
+ line: i + 1
45906
+ };
45907
+ }
45908
+
45909
+ // server/src/git.ts
45910
+ import { execFileSync } from "node:child_process";
45911
+ function cleanGit(root, args, max = 1024 * 1024) {
45912
+ const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_")));
45913
+ return execFileSync("git", ["-C", root, ...args], {
45914
+ env,
45915
+ maxBuffer: max,
45916
+ stdio: ["ignore", "pipe", "ignore"],
45917
+ timeout: 1e4
45918
+ });
45919
+ }
45920
+
45921
+ // server/src/record.ts
45922
+ var KEY = /^[a-z0-9][a-z0-9._/-]{0,63}$/;
45923
+ var FIELD_NAME = /^[a-z][a-z0-9_]{0,39}$/;
45924
+ var SOURCE_REF = /^s[1-9][0-9]{0,15}$/;
45925
+ var MAINTAINERS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
45926
+ var text = (max) => exports_external.string().trim().min(1).max(max);
45927
+ var Quote = exports_external.object({ source: exports_external.string().regex(SOURCE_REF, "cite a source ref such as s12"), quote: exports_external.string() }).strict();
45928
+ var Evidence = Quote.extend({
45929
+ role: exports_external.enum(EVIDENCE_ROLES).exclude(["reconsiders"]),
45930
+ reported_speaker: text(100).optional()
45931
+ }).strict();
45932
+ var Unit = exports_external.object({
45933
+ key: exports_external.string().regex(KEY, "use lowercase letters, digits, and . _ - / (at most 64)"),
45934
+ kind: exports_external.enum(UNIT_KINDS),
45935
+ stance: exports_external.enum(STANCES).optional(),
45936
+ text: text(2000),
45937
+ why: text(2000).optional(),
45938
+ scope_note: text(1000).optional(),
45939
+ revisit_when: text(1000).optional(),
45940
+ no_code_surface: text(500).optional(),
45941
+ options: exports_external.array(exports_external.object({
45942
+ text: text(500),
45943
+ outcome: exports_external.enum(OPTION_OUTCOMES),
45944
+ why: text(2000).optional(),
45945
+ reconsider_when: text(1000).optional(),
45946
+ reconsider_quote: Quote.optional(),
45947
+ evidence: exports_external.array(Evidence.partial({ role: true })).max(10).default([])
45948
+ }).strict()).max(12).default([]),
45949
+ evidence: exports_external.array(Evidence).max(20).default([]),
45950
+ adoption: exports_external.array(Quote).max(5).default([]),
45951
+ anchors: exports_external.array(exports_external.object({
45952
+ path: text(500),
45953
+ symbol: text(200).optional(),
45954
+ role: exports_external.enum(["applies_to", "evidence"]),
45955
+ commit: exports_external.string().regex(/^[0-9a-f]{40}$/, "a full 40-character commit sha").optional(),
45956
+ lines: exports_external.tuple([exports_external.number().int().positive(), exports_external.number().int().positive()]).optional()
45957
+ }).strict()).max(20).default([]),
45958
+ aliases: exports_external.array(exports_external.string()).max(12).default([]),
45959
+ supersedes: exports_external.string().min(1).optional(),
45960
+ conflicts: exports_external.array(exports_external.string().min(1)).max(5).default([]),
45961
+ fields: exports_external.array(exports_external.object({
45962
+ name: exports_external.string().regex(FIELD_NAME, "a defined field name"),
45963
+ value: text(200),
45964
+ quote: Quote
45965
+ }).strict()).max(12).default([])
45966
+ }).strict();
45967
+ var FieldDef = exports_external.object({
45968
+ name: exports_external.string().regex(FIELD_NAME, "a lowercase letter, then lowercase letters, digits, and _ (at most 40)"),
45969
+ type: exports_external.enum(FIELD_TYPES),
45970
+ label: text(100),
45971
+ description: text(500),
45972
+ enum: exports_external.array(text(100)).min(1).max(30).optional(),
45973
+ kinds: exports_external.array(exports_external.enum(UNIT_KINDS)).max(UNIT_KINDS.length).default([]),
45974
+ quote: Quote
45975
+ }).strict();
45976
+ var Work = exports_external.object({
45977
+ key: exports_external.string().regex(KEY),
45978
+ title: text(200),
45979
+ goal: text(1000),
45980
+ current: text(2000),
45981
+ next: exports_external.array(text(500)).max(10).default([]),
45982
+ status: exports_external.enum(WORK_STATUSES)
45983
+ }).strict();
45984
+ var Record = exports_external.object({
45985
+ field_defs: exports_external.array(FieldDef).max(10).default([]),
45986
+ units: exports_external.array(Unit).max(50),
45987
+ work: Work.optional()
45988
+ }).strict();
45989
+ function repoPath(p) {
45990
+ const s = p.trim().replace(/^\.\//, "");
45991
+ if (!s || s.startsWith("/") || s.includes("\\") || /^[A-Za-z]:/.test(s) || /\p{Cc}/u.test(s))
45992
+ return null;
45993
+ const parts = s.split("/");
45994
+ if (parts.some((x) => x === ".." || x === "." || x === ""))
45995
+ return null;
45996
+ return s;
45997
+ }
45998
+
45999
+ // server/src/search.ts
46000
+ var UNIT_PAGE = 200;
46001
+ var UNIT_SCAN_MAX = 2000;
46002
+ var SOURCE_PAGE = 50;
46003
+ var SOURCE_SCAN_MAX = 600;
46004
+ var SOURCE_SCAN_BYTES = 64 * 1024 * 1024;
46005
+ var LIFE_ORDER = { active: 0, candidate: 1, superseded: 2, withdrawn: 3 };
46006
+ var strong = (matched, of) => matched * 2 > of;
46007
+ var PATH_REFUSED = "path must be relative to the repository root, such as src/x.ts: absolute paths, .., backslashes, and empty parts are refused";
46008
+ async function searchUnits(db, projectId, q) {
46009
+ const wanted = queryTerms(q.question);
46010
+ const path4 = q.path === undefined ? undefined : repoPath(q.path);
46011
+ if (path4 === null)
46012
+ return { hits: [], weaker: 0, terms: wanted, stopped: false, read: 0, refused: PATH_REFUSED };
46013
+ const match = ftsQuery(q.question);
46014
+ if (!match)
46015
+ return { hits: [], weaker: 0, terms: wanted, stopped: false, read: 0 };
46016
+ let query = db.selectFrom(sql`(select unit_fts.rowid as rowid, bm25(unit_fts, 3, 2, 1) as rank from unit_fts cross join unit on unit.id = unit_fts.rowid where unit_fts match ${match} and unit.project_id = ${projectId})`.as("f")).innerJoin("unit as u", "u.id", "f.rowid").where("u.project_id", "=", projectId).where("u.extraction", "=", "supported");
46017
+ if (q.kinds?.length)
46018
+ query = query.where("u.kind", "in", q.kinds);
46019
+ if (q.lifecycles?.length)
46020
+ query = query.where("u.lifecycle", "in", q.lifecycles);
46021
+ if (path4)
46022
+ query = query.where(({ exists, selectFrom }) => exists(selectFrom("unit_anchor as a").select("a.id").whereRef("a.unit_id", "=", "u.id").where("a.path", "=", path4).where("a.retired_at", "is", null)));
46023
+ const ranked = await query.select(["u.id", "f.rank"]).orderBy("f.rank").orderBy("u.id").limit(UNIT_SCAN_MAX + 1).execute();
46024
+ const stopped = ranked.length > UNIT_SCAN_MAX;
46025
+ let weaker = 0;
46026
+ let read = 0;
46027
+ const hits = [];
46028
+ for (let at = 0;at < Math.min(ranked.length, UNIT_SCAN_MAX); at += UNIT_PAGE) {
46029
+ const part = ranked.slice(at, Math.min(at + UNIT_PAGE, UNIT_SCAN_MAX));
46030
+ const order = new Map(part.map((r, i) => [r.id, i]));
46031
+ const rows = (await query.select([
46032
+ "u.id",
46033
+ "u.key",
46034
+ "u.kind",
46035
+ "u.stance",
46036
+ "u.lifecycle",
46037
+ "u.text",
46038
+ "u.why",
46039
+ "u.scope_note",
46040
+ "u.revisit_when",
46041
+ "u.content_hash",
46042
+ "f.rank"
46043
+ ]).where("u.id", "in", part.map((r) => r.id)).execute()).sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
46044
+ read += rows.length;
46045
+ weaker += await judgeUnits(db, rows, wanted, hits);
46063
46046
  }
46047
+ const shown = new Set(hits.map((h) => h.id));
46048
+ for (const h of [...hits].filter((x) => x.lifecycle === "superseded"))
46049
+ for (const n of await liveSuccessors(db, h.id))
46050
+ if (!shown.has(n.id) && (!q.kinds?.length || q.kinds.includes(n.kind)) && (!q.lifecycles?.length || q.lifecycles.includes(n.lifecycle))) {
46051
+ shown.add(n.id);
46052
+ hits.push({
46053
+ ...n,
46054
+ options: [],
46055
+ anchors: [],
46056
+ matched: [],
46057
+ aliasOnly: false,
46058
+ successorOf: h.key,
46059
+ rank: h.rank
46060
+ });
46061
+ }
46062
+ hits.sort((a, b) => (LIFE_ORDER[a.lifecycle] ?? 9) - (LIFE_ORDER[b.lifecycle] ?? 9) || b.matched.length - a.matched.length || a.rank - b.rank);
46063
+ return {
46064
+ hits: hits.slice(0, q.limit).map(({ rank: _rank, ...h }) => h),
46065
+ weaker,
46066
+ terms: wanted,
46067
+ stopped,
46068
+ read
46069
+ };
46064
46070
  }
46065
- function generationOf(raw) {
46066
- const has = raw.prepare("select 1 from sqlite_schema where type = 'table' and name = 'sphica_generation'").get();
46067
- if (!has) {
46068
- const any3 = raw.prepare("select 1 from sqlite_schema where type = 'table' and name = 'project'").get();
46069
- return any3 ? 1 : null;
46071
+ async function liveSuccessors(db, id) {
46072
+ const seen = new Set([id]);
46073
+ const found = [];
46074
+ const replaced = new Set;
46075
+ for (let frontier = [id];frontier.length; ) {
46076
+ const next = await db.selectFrom("unit_link as l").innerJoin("unit as n", "n.id", "l.from_unit").where("l.to_unit", "in", frontier).where("l.kind", "=", "supersedes").where("n.extraction", "=", "supported").where("n.lifecycle", "<>", "candidate").select([
46077
+ "l.to_unit",
46078
+ "n.id",
46079
+ "n.key",
46080
+ "n.kind",
46081
+ "n.stance",
46082
+ "n.lifecycle",
46083
+ "n.text",
46084
+ "n.why",
46085
+ "n.revisit_when"
46086
+ ]).execute();
46087
+ frontier = [];
46088
+ for (const { to_unit, ...n } of next) {
46089
+ replaced.add(to_unit);
46090
+ if (seen.has(n.id))
46091
+ continue;
46092
+ seen.add(n.id);
46093
+ found.push(n);
46094
+ frontier.push(n.id);
46095
+ }
46070
46096
  }
46071
- return raw.prepare("select generation from sphica_generation").get()?.generation ?? null;
46072
- }
46073
- function checkGeneration(raw) {
46074
- const got = generationOf(raw);
46075
- if (got === SCHEMA_GENERATION)
46076
- return;
46077
- if (got === null)
46078
- throw new Error("The database has no sphica schema. Create it with `sphica init`.");
46079
- throw new Error(got < SCHEMA_GENERATION ? "The database was made by Sphica 0.4 or earlier, and this Sphica cannot read it. Move it aside (it is left unchanged), then run `sphica init`." : "The database was made by a newer Sphica. Update sphica.");
46097
+ return found.filter((n) => !replaced.has(n.id));
46080
46098
  }
46081
- var READER_FUNCTIONS = new Set([
46082
- "bm25",
46083
- "coalesce",
46084
- "count",
46085
- "instr",
46086
- "json_array_length",
46087
- "json_extract",
46088
- "json_group_array",
46089
- "json_object",
46090
- "length",
46091
- "lower",
46092
- "match",
46093
- "max",
46094
- "min",
46095
- "substr"
46096
- ]);
46097
- function connectReader(file2 = dbFile()) {
46098
- requireRuntime();
46099
- requireFile(file2);
46100
- const raw = new DatabaseSync(file2, { readOnly: true });
46101
- try {
46102
- prepare(raw, "revision");
46103
- } catch (e) {
46104
- raw.close();
46105
- throw e;
46099
+ async function judgeUnits(db, rows, wanted, hits) {
46100
+ const ids = rows.map((r) => r.id);
46101
+ const [options, anchors, aliases, fields] = ids.length ? await Promise.all([
46102
+ db.selectFrom("unit_option").select(["unit_id", "text", "outcome", "why", "reconsider_when"]).where("unit_id", "in", ids).orderBy("position").execute(),
46103
+ db.selectFrom("unit_anchor").select(["unit_id", "path", "symbol", "role"]).where("unit_id", "in", ids).where("retired_at", "is", null).orderBy("id").execute(),
46104
+ db.selectFrom("unit_alias").select(["unit_id", "terms", "content_hash"]).where("unit_id", "in", ids).orderBy("id", "desc").execute(),
46105
+ db.selectFrom("unit_field as f").innerJoin("field_def as d", "d.id", "f.field_def_id").select(["f.unit_id", "d.name", "f.value"]).where("f.unit_id", "in", ids).execute()
46106
+ ]) : [[], [], [], []];
46107
+ let weaker = 0;
46108
+ for (const r of rows) {
46109
+ const opts = options.filter((o) => o.unit_id === r.id);
46110
+ const anch = anchors.filter((a) => a.unit_id === r.id);
46111
+ const alias = aliases.find((a) => a.unit_id === r.id && Buffer.from(a.content_hash).equals(Buffer.from(r.content_hash)));
46112
+ const own2 = new Set(terms([
46113
+ r.text,
46114
+ r.why,
46115
+ r.scope_note,
46116
+ r.revisit_when,
46117
+ ...opts.flatMap((o) => [o.text, o.why, o.reconsider_when]),
46118
+ ...anch.flatMap((a) => [a.path, a.symbol]),
46119
+ ...fields.filter((f) => f.unit_id === r.id).flatMap((f) => [f.name, f.value])
46120
+ ].filter(Boolean).join(`
46121
+ `)));
46122
+ const extra = new Set(alias ? terms(JSON.parse(alias.terms).join(" ")) : []);
46123
+ const matched = wanted.filter((w) => own2.has(w) || extra.has(w));
46124
+ const ident = new Set(anch.flatMap((a) => [a.path, a.symbol].flatMap((x) => x ? [identTerm(x)] : [])));
46125
+ if (!strong(matched.length, wanted.length) && !matched.some((w) => ident.has(w))) {
46126
+ weaker++;
46127
+ continue;
46128
+ }
46129
+ hits.push({
46130
+ id: r.id,
46131
+ key: r.key,
46132
+ kind: r.kind,
46133
+ stance: r.stance,
46134
+ lifecycle: r.lifecycle,
46135
+ text: r.text,
46136
+ why: r.why,
46137
+ revisit_when: r.revisit_when,
46138
+ options: opts.map((o) => ({ text: o.text, outcome: o.outcome, why: o.why })),
46139
+ anchors: anch.map((a) => ({ path: a.path, symbol: a.symbol, role: a.role })),
46140
+ matched,
46141
+ aliasOnly: matched.every((w) => !own2.has(w)),
46142
+ rank: r.rank
46143
+ });
46106
46144
  }
46107
- raw.setAuthorizer((action, p1, p2) => {
46108
- if (action === C.SQLITE_READ || action === C.SQLITE_SELECT || action === C.SQLITE_RECURSIVE)
46109
- return C.SQLITE_OK;
46110
- if (action === C.SQLITE_FUNCTION)
46111
- return READER_FUNCTIONS.has((p2 ?? "").toLowerCase()) ? C.SQLITE_OK : C.SQLITE_DENY;
46112
- if (action === C.SQLITE_PRAGMA && p1 === "data_version" && p2 === null)
46113
- return C.SQLITE_OK;
46114
- return C.SQLITE_DENY;
46115
- });
46116
- return raw;
46145
+ return weaker;
46117
46146
  }
46118
-
46119
- // server/src/db.ts
46120
- var JSON_COLUMNS = new Set(["next"]);
46121
- var TOP_LEVEL = /^\$\[\d+\]\."([^"]+)"$/;
46122
- var parseJson = new ParseJSONResultsPlugin({
46123
- shouldParse: (_value, jsonPath) => JSON_COLUMNS.has(jsonPath.match(TOP_LEVEL)?.[1] ?? "")
46124
- });
46125
- function kyselyOn(connect) {
46126
- return new Kysely({
46127
- dialect: new SqliteDialect({ database: async () => adapt(connect()) }),
46128
- plugins: [parseJson]
46147
+ async function searchSources(db, projectId, question, limit, owner) {
46148
+ const wanted = queryTerms(question);
46149
+ const match = ftsQuery(question);
46150
+ if (!match)
46151
+ return { hits: [], weaker: 0, terms: wanted, stopped: false, read: 0 };
46152
+ const query = db.selectFrom(sql`(select source_fts.rowid as rowid, bm25(source_fts) as rank from source_fts cross join source on source.id = source_fts.rowid where source_fts match ${match} and source.project_id = ${projectId})`.as("f")).innerJoin("source as s", "s.id", "f.rowid").where("s.project_id", "=", projectId).$if(owner !== undefined, (q) => q.where("s.kind", "=", "session_message").where("s.author_kind", "=", "owner").$if((owner?.notSessions.length ?? 0) > 0, (q2) => q2.where("s.session_id", "not in", owner?.notSessions ?? [])));
46153
+ const ranked = await query.select(["s.id", "f.rank"]).orderBy("f.rank").orderBy("s.id").limit(SOURCE_SCAN_MAX + 1).execute();
46154
+ let weaker = 0;
46155
+ let stopped = false;
46156
+ const hits = [];
46157
+ let read = 0;
46158
+ let bytes2 = 0;
46159
+ scan:
46160
+ for (let at = 0;at < ranked.length; at += SOURCE_PAGE) {
46161
+ const ids = ranked.slice(at, at + SOURCE_PAGE).map((r) => r.id);
46162
+ const byId = new Map((await db.selectFrom("source").select([
46163
+ "id",
46164
+ "kind",
46165
+ "artifact",
46166
+ "author_kind",
46167
+ "author_login",
46168
+ "created_at",
46169
+ "text",
46170
+ "session_id",
46171
+ "turn_id"
46172
+ ]).where("id", "in", ids).execute()).map((r) => [r.id, r]));
46173
+ for (const id of ids) {
46174
+ if (read >= SOURCE_SCAN_MAX || bytes2 >= SOURCE_SCAN_BYTES) {
46175
+ stopped = true;
46176
+ break scan;
46177
+ }
46178
+ const r = byId.get(id);
46179
+ if (!r)
46180
+ continue;
46181
+ read++;
46182
+ bytes2 += Buffer.byteLength(r.text);
46183
+ const own2 = new Set(terms(r.text));
46184
+ const matched = wanted.filter((w) => own2.has(w));
46185
+ if (!strong(matched.length, wanted.length)) {
46186
+ weaker++;
46187
+ continue;
46188
+ }
46189
+ hits.push({
46190
+ id: r.id,
46191
+ kind: r.kind,
46192
+ artifact: r.artifact,
46193
+ author: r.author_login ?? r.author_kind,
46194
+ created_at: r.created_at,
46195
+ text: r.text,
46196
+ matched,
46197
+ session: r.session_id,
46198
+ turn: r.turn_id
46199
+ });
46200
+ if (hits.length >= limit)
46201
+ break scan;
46202
+ }
46203
+ }
46204
+ if (!stopped && hits.length < limit && ranked.length > SOURCE_SCAN_MAX)
46205
+ stopped = true;
46206
+ return { hits, weaker, terms: wanted, stopped, read };
46207
+ }
46208
+
46209
+ // server/src/asked.ts
46210
+ var DECIDED_KINDS = new Set(["decision", "constraint"]);
46211
+ var DECIDED_LIFECYCLES = new Set(["active", "superseded", "withdrawn"]);
46212
+ var PER_MESSAGE = 10;
46213
+ async function askedBefore(db, projectId, q) {
46214
+ const kept = (u) => (!q.kinds?.length || q.kinds.includes(u.kind)) && (!q.lifecycles?.length || q.lifecycles.includes(u.lifecycle));
46215
+ const found = await searchSources(db, projectId, q.question, Number.POSITIVE_INFINITY, {
46216
+ notSessions: q.notSessions
46129
46217
  });
46218
+ const ids = found.hits.map((h) => h.id);
46219
+ const cited = ids.length ? await citing(db, ids) : [];
46220
+ const traced = new Set(ids.length ? (await db.selectFrom("source_processing").select("source_id").where("source_id", "in", ids).execute()).map((r) => r.source_id) : []);
46221
+ const decided = new Set(cited.filter((c) => DECIDED_KINDS.has(c.kind) && DECIDED_LIFECYCLES.has(c.lifecycle)).map((c) => c.source_id));
46222
+ const all = [];
46223
+ for (const m of found.hits) {
46224
+ const direct = unique(cited.filter((c) => c.source_id === m.id));
46225
+ const shown = direct.filter(kept);
46226
+ all.push({
46227
+ message: m,
46228
+ led: shown.map((u) => ({ ...u, now: [] })),
46229
+ hidden: direct.length - shown.length,
46230
+ context: [],
46231
+ direct: new Set(direct.map((u) => u.id)),
46232
+ decided: decided.has(m.id),
46233
+ traced: traced.has(m.id),
46234
+ reply: null
46235
+ });
46236
+ }
46237
+ const messages = all.slice(0, q.limit);
46238
+ for (const e of messages) {
46239
+ for (const u of e.led)
46240
+ if (u.lifecycle === "superseded")
46241
+ u.now = await liveSuccessors(db, u.id);
46242
+ await sameTurn(db, e, kept);
46243
+ }
46244
+ const sessions = new Map;
46245
+ for (const e of all) {
46246
+ const s = e.message.session ?? "";
46247
+ const was = sessions.get(s) ?? { decided: false, traced: true };
46248
+ sessions.set(s, { decided: was.decided || e.decided, traced: was.traced && e.traced });
46249
+ }
46250
+ const none = [...sessions.values()].every((s) => !s.decided);
46251
+ const repeats = sessions.size >= 2 && none ? {
46252
+ untraced: [...sessions].filter(([, s]) => !s.traced).map(([id]) => id),
46253
+ traced: [...sessions].filter(([, s]) => s.traced).map(([id]) => id)
46254
+ } : null;
46255
+ return {
46256
+ messages,
46257
+ repeats,
46258
+ terms: found.terms,
46259
+ weaker: found.weaker,
46260
+ stopped: found.stopped,
46261
+ read: found.read
46262
+ };
46130
46263
  }
46131
- function openReader(file2 = dbFile()) {
46132
- return kyselyOn(() => connectReader(file2));
46264
+ async function citing(db, sources) {
46265
+ const [evidence, adoption] = await Promise.all([
46266
+ db.selectFrom("unit_evidence as e").innerJoin("unit as u", "u.id", "e.unit_id").where("e.source_id", "in", sources).where("e.retracted_at", "is", null).where("u.extraction", "=", "supported").select(["e.source_id", "e.role", "u.id", "u.key", "u.kind", "u.lifecycle"]).orderBy("u.id").execute(),
46267
+ db.selectFrom("unit_adoption as a").innerJoin("unit as u", "u.id", "a.unit_id").where("a.source_id", "in", sources).where("a.retracted_at", "is", null).where("u.extraction", "=", "supported").select(["a.source_id", "u.id", "u.key", "u.kind", "u.lifecycle"]).orderBy("u.id").execute()
46268
+ ]);
46269
+ return [...evidence, ...adoption.map((a) => ({ ...a, role: "adoption" }))];
46133
46270
  }
46134
-
46135
- // server/src/export.ts
46136
- import fs5 from "node:fs";
46137
- import path5 from "node:path";
46138
-
46139
- // server/src/anchors.ts
46140
- import fs3 from "node:fs";
46141
- import path3 from "node:path";
46142
- var MAX_BYTES = 2 * 1024 * 1024;
46143
- var leaves = (rel) => rel === ".." || rel.startsWith(`..${path3.sep}`) || path3.isAbsolute(rel);
46144
- var literal3 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
46145
- function readBytes(root, rel) {
46146
- const abs = path3.join(root, rel);
46147
- if (leaves(path3.relative(root, abs)))
46148
- return;
46149
- const st = fs3.lstatSync(abs, { throwIfNoEntry: false });
46150
- if (!st)
46151
- return null;
46152
- if (!st.isFile() || st.size > MAX_BYTES)
46271
+ var unique = (xs) => [...new Map(xs.map((x) => [x.id, x])).values()];
46272
+ async function sameTurn(db, e, kept) {
46273
+ const { session, turn, id } = e.message;
46274
+ if (!session || !turn)
46153
46275
  return;
46154
- try {
46155
- const inside = path3.relative(fs3.realpathSync(root), fs3.realpathSync(abs));
46156
- if (leaves(inside))
46157
- return;
46158
- const buf = fs3.readFileSync(abs);
46159
- return buf.includes(0) ? undefined : buf;
46160
- } catch {
46276
+ const others = await db.selectFrom("source").select(["id", "author_kind"]).where("session_id", "=", session).where("turn_id", "=", turn).where("id", "<>", id).orderBy("id").execute();
46277
+ e.reply = others.findLast((o) => o.author_kind === "assistant")?.id ?? null;
46278
+ if (!others.length)
46161
46279
  return;
46280
+ const speaker = new Map(others.map((o) => [o.id, o.author_kind]));
46281
+ const seen = new Set;
46282
+ for (const c of await citing(db, others.map((o) => o.id))) {
46283
+ const k = `${c.id}\x00${c.source_id}\x00${c.role}`;
46284
+ if (e.direct.has(c.id) || !kept(c) || seen.has(k))
46285
+ continue;
46286
+ seen.add(k);
46287
+ e.context.push({
46288
+ key: c.key,
46289
+ kind: c.kind,
46290
+ lifecycle: c.lifecycle,
46291
+ speaker: speaker.get(c.source_id) ?? "",
46292
+ role: c.role
46293
+ });
46162
46294
  }
46163
46295
  }
46164
- function readText(root, rel) {
46165
- const buf = readBytes(root, rel);
46166
- return buf ? buf.toString("utf8") : buf;
46167
- }
46168
- function findSymbol(text, symbol2) {
46169
- const re = new RegExp(`(?<![\\w$])${literal3(symbol2)}(?![\\w$])`);
46170
- const lines = text.split(/\r?\n/);
46171
- return { lines, i: lines.findIndex((l) => re.test(l)) };
46172
- }
46173
- function fileState(root, rel) {
46174
- if (!root)
46175
- return "unknown";
46176
- if (leaves(path3.relative(root, path3.join(root, rel))))
46177
- return "unknown";
46178
- let realRoot;
46179
- try {
46180
- realRoot = fs3.realpathSync(root);
46181
- } catch {
46182
- return "unknown";
46183
- }
46184
- let at = root;
46185
- for (const part of rel.split("/")) {
46186
- at = path3.join(at, part);
46187
- let st;
46188
- try {
46189
- st = fs3.lstatSync(at, { throwIfNoEntry: false });
46190
- } catch (e) {
46191
- if (e.code === "ENOTDIR")
46192
- return "gone";
46193
- return "unknown";
46296
+ var UNKNOWN_SESSION = "Current session unknown; results and session counts may include its messages. Pass session to exclude it.";
46297
+ function askedText(r, known = true) {
46298
+ const parts = r.messages.map((e) => {
46299
+ const m = e.message;
46300
+ const lines = [`## s${m.id}: ${inline(m.artifact)}, ${m.created_at}`, inline(head(m.text, 600))];
46301
+ if (e.led.length) {
46302
+ lines.push("Led to:");
46303
+ for (const u of e.led.slice(0, PER_MESSAGE))
46304
+ lines.push(`- ${inline(u.key)} (${u.kind}, ${u.lifecycle}${u.now.length ? `; now ${u.now.map((n) => `${inline(n.key)} (${n.lifecycle})`).join(", ")}` : ""})`);
46194
46305
  }
46195
- if (!st)
46196
- return "gone";
46197
- if (st.isSymbolicLink()) {
46198
- try {
46199
- if (leaves(path3.relative(realRoot, fs3.realpathSync(at))))
46200
- return "unknown";
46201
- } catch {
46202
- return "unknown";
46203
- }
46306
+ if (e.led.length > PER_MESSAGE)
46307
+ lines.push(`and ${e.led.length - PER_MESSAGE} more tied records`);
46308
+ if (e.hidden)
46309
+ lines.push(`${e.hidden} tied record${e.hidden === 1 ? "" : "s"} hidden by the filters.`);
46310
+ if (!e.decided)
46311
+ lines.push(`No recorded decision.${e.traced ? "" : ` Not traced yet: run /sphica:trace ${inline(m.session ?? "")}.`}`);
46312
+ if (e.context.length) {
46313
+ lines.push("Same turn (context, not necessarily the answer):");
46314
+ for (const c of e.context.slice(0, PER_MESSAGE))
46315
+ lines.push(`- ${inline(c.key)} (${c.kind}, ${c.lifecycle}): quotes the ${c.speaker} (${c.role})`);
46316
+ if (e.context.length > PER_MESSAGE)
46317
+ lines.push(`and ${e.context.length - PER_MESSAGE} more in the same turn`);
46204
46318
  }
46319
+ if (e.reply !== null)
46320
+ lines.push(`Reply: s${e.reply} (read it with read)`);
46321
+ return lines.join(`
46322
+ `);
46323
+ });
46324
+ const out = [`${known ? "Earlier owner" : "Owner"} messages matching: ${r.terms.join(", ")}`, ...parts];
46325
+ if (r.repeats) {
46326
+ const n = r.repeats.untraced.length + r.repeats.traced.length;
46327
+ const within = r.stopped ? ` (within the first ${r.read} candidates by rank)` : "";
46328
+ out.push([
46329
+ known ? `Asked in ${n} sessions with no recorded decision${within}:` : `Matching messages in ${n} sessions with no recorded decision (current session may be included)${within}:`,
46330
+ ...r.repeats.untraced.length ? [`- not traced yet: ${r.repeats.untraced.map(inline).join(", ")} (run /sphica:trace with each)`] : [],
46331
+ ...r.repeats.traced.length ? [`- traced: ${r.repeats.traced.map(inline).join(", ")}`] : []
46332
+ ].join(`
46333
+ `));
46205
46334
  }
46206
- return "present";
46207
- }
46208
- function checkAnchor(root, a) {
46209
- if (!root)
46210
- return { state: "unknown", line: null };
46211
- const text = readText(root, a.path);
46212
- if (text === null)
46213
- return { state: "missing", line: null };
46214
- if (text === undefined)
46215
- return { state: "unknown", line: null };
46216
- if (!a.symbol)
46217
- return { state: "located", line: a.line_start };
46218
- const { i } = findSymbol(text, a.symbol);
46219
- if (i < 0)
46220
- return { state: "missing", line: null };
46221
- return {
46222
- state: a.line_start === null || a.line_start === i + 1 ? "located" : "moved",
46223
- line: i + 1
46224
- };
46335
+ if (r.stopped)
46336
+ out.push(`Stopped after ${r.read} candidates by rank; more may match.`);
46337
+ if (!known)
46338
+ out.push(UNKNOWN_SESSION);
46339
+ return out.join(`
46340
+
46341
+ `);
46225
46342
  }
46226
46343
 
46344
+ // server/src/export.ts
46345
+ import fs5 from "node:fs";
46346
+ import path5 from "node:path";
46347
+
46227
46348
  // server/src/read.ts
46228
46349
  var UNSUPPORTED = "unsupported: its owner quote was retracted or forgotten, so it is not the owner's condition";
46229
- var cut = (text, start, end) => Buffer.from(text, "utf8").subarray(start, end).toString("utf8");
46350
+ var cut = (text2, start, end) => Buffer.from(text2, "utf8").subarray(start, end).toString("utf8");
46230
46351
  var speaker = (s) => s.author_kind === "owner" ? "the owner" : s.author_kind === "assistant" ? "the assistant" : `${s.author_login ?? "someone"} (${s.author_association ?? s.author_kind})`;
46231
46352
  async function readUnit(db, projectId, ref, root, asOf) {
46232
46353
  const byId = /^u([1-9][0-9]{0,15})$/.exec(ref);
@@ -46375,8 +46496,8 @@ async function readSource(db, projectId, ref) {
46375
46496
  `);
46376
46497
  }
46377
46498
  var PART = 64 * 1024;
46378
- function part(id, text, from) {
46379
- const all = Buffer.from(text, "utf8");
46499
+ function part(id, text2, from) {
46500
+ const all = Buffer.from(text2, "utf8");
46380
46501
  let start = Math.min(from, all.length);
46381
46502
  while (start > 0 && start < all.length && ((all[start] ?? 0) & 192) === 128)
46382
46503
  start--;
@@ -46388,20 +46509,6 @@ function part(id, text, from) {
46388
46509
  // server/src/rule-files.ts
46389
46510
  import fs4 from "node:fs";
46390
46511
  import path4 from "node:path";
46391
-
46392
- // server/src/git.ts
46393
- import { execFileSync } from "node:child_process";
46394
- function cleanGit(root, args, max = 1024 * 1024) {
46395
- const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_")));
46396
- return execFileSync("git", ["-C", root, ...args], {
46397
- env,
46398
- maxBuffer: max,
46399
- stdio: ["ignore", "pipe", "ignore"],
46400
- timeout: 1e4
46401
- });
46402
- }
46403
-
46404
- // server/src/rule-files.ts
46405
46512
  var RULE_LIMITS = { files: 200, bytes: 256 * 1024, depth: 8, entries: 5000 };
46406
46513
  var RULE_NAMES = new Set(["CLAUDE.md", "AGENTS.md", "AGENTS.override.md"]);
46407
46514
  var PATHSPECS = [...RULE_NAMES, ".claude/rules/**/*.md"].map((p) => `:(glob)**/${p}`);
@@ -46422,13 +46529,13 @@ function ruleFiles(root) {
46422
46529
  out.skipped++;
46423
46530
  continue;
46424
46531
  }
46425
- const text = readBounded(root, realRoot, rel);
46426
- if (text === null)
46532
+ const text2 = readBounded(root, realRoot, rel);
46533
+ if (text2 === null)
46427
46534
  continue;
46428
- if (text === undefined)
46535
+ if (text2 === undefined)
46429
46536
  out.skipped++;
46430
46537
  else
46431
- out.files.push({ path: rel, text });
46538
+ out.files.push({ path: rel, text: text2 });
46432
46539
  }
46433
46540
  return out;
46434
46541
  }
@@ -46586,14 +46693,14 @@ async function lines(db, u) {
46586
46693
  return out;
46587
46694
  }
46588
46695
  function fenced(body) {
46589
- const text = body.join(`
46696
+ const text2 = body.join(`
46590
46697
  `);
46591
46698
  let longest = 0;
46592
- for (const m of text.matchAll(/`+/g))
46699
+ for (const m of text2.matchAll(/`+/g))
46593
46700
  longest = Math.max(longest, m[0].length);
46594
46701
  const fence = "`".repeat(Math.max(3, longest + 1));
46595
46702
  return `${fence}text
46596
- ${text}
46703
+ ${text2}
46597
46704
  ${fence}`;
46598
46705
  }
46599
46706
  async function replaced(db, from) {
@@ -46809,19 +46916,6 @@ async function fieldsText(db, projectId) {
46809
46916
  return framed(await fieldsTable(db, projectId));
46810
46917
  }
46811
46918
 
46812
- // server/src/knowledge.ts
46813
- var UNIT_KINDS = [
46814
- "decision",
46815
- "implementation",
46816
- "finding",
46817
- "dead_end",
46818
- "question",
46819
- "constraint"
46820
- ];
46821
- var LIFECYCLES = ["candidate", "active", "superseded", "withdrawn"];
46822
- var HOSTS = ["claude-code", "codex"];
46823
- var sessionId = (projectId, host, externalId) => uuidFrom(String(projectId), host, externalId);
46824
-
46825
46919
  // server/src/overview.ts
46826
46920
  import path6 from "node:path";
46827
46921
  var OVERVIEW_LIMITS = { records: 50, key: 200, text: 300, paths: 520, heading: 120 };
@@ -46955,8 +47049,8 @@ async function lookOverview(db, projectId, root) {
46955
47049
  const scan = ruleFiles(root);
46956
47050
  const found = [];
46957
47051
  for (const f of scan.files)
46958
- for (const [i, text] of f.text.split(/\r?\n/).entries())
46959
- for (const m of text.matchAll(MARKER))
47052
+ for (const [i, text2] of f.text.split(/\r?\n/).entries())
47053
+ for (const m of text2.matchAll(MARKER))
46960
47054
  found.push({ file: f.path, line: i + 1, key: m[1] ?? "" });
46961
47055
  const keys = [...new Set(found.map((f) => f.key))];
46962
47056
  const units = new Map;
@@ -47017,7 +47111,7 @@ import fs6 from "node:fs";
47017
47111
  import path7 from "node:path";
47018
47112
  var localFile = () => path7.join(sphicaHome(), "projects.json");
47019
47113
  var LOCAL_KEY = /^[a-z0-9][a-z0-9._-]*$/;
47020
- function normalizeRemote(url2) {
47114
+ function legacyRemote(url2) {
47021
47115
  const raw = String(url2 ?? "").trim();
47022
47116
  if (!raw)
47023
47117
  return null;
@@ -47034,6 +47128,15 @@ function normalizeRemote(url2) {
47034
47128
  return null;
47035
47129
  }
47036
47130
  }
47131
+ var fold = (s) => s.replace(/[A-Z]+/g, (m) => m.toLowerCase());
47132
+ function normalizeKey(key) {
47133
+ if (!key.startsWith("git:"))
47134
+ return key;
47135
+ const rest = key.slice(4);
47136
+ const cut2 = rest.indexOf("/");
47137
+ const host = fold(cut2 < 0 ? rest : rest.slice(0, cut2));
47138
+ return host === "github.com" ? fold(key) : `git:${host}${cut2 < 0 ? "" : rest.slice(cut2)}`;
47139
+ }
47037
47140
  var git = (dir, ...args) => {
47038
47141
  try {
47039
47142
  return execFileSync2("git", ["-C", dir, ...args], {
@@ -47068,14 +47171,17 @@ function identify(dir) {
47068
47171
  const given = path7.resolve(dir);
47069
47172
  const top = git(given, "rev-parse", "--show-toplevel");
47070
47173
  const root = top || given;
47071
- const remote = top ? normalizeRemote(git(root, "remote", "get-url", "origin")) : null;
47072
- if (remote)
47073
- return { key: `git:${remote}`, root, name: remote.split("/").slice(1).join("/") || remote };
47174
+ const legacy = top ? legacyRemote(git(root, "remote", "get-url", "origin")) : null;
47175
+ if (legacy) {
47176
+ const key = normalizeKey(`git:${legacy}`);
47177
+ const remote = key.slice(4);
47178
+ return { key, legacyKey: `git:${legacy}`, root, name: remote.split("/").slice(1).join("/") || remote };
47179
+ }
47074
47180
  const map3 = localMap();
47075
47181
  for (let d = root;; d = path7.dirname(d)) {
47076
47182
  const local = map3[d];
47077
47183
  if (local && LOCAL_KEY.test(local))
47078
- return { key: `local:${local}`, root: d, name: local };
47184
+ return { key: `local:${local}`, legacyKey: `local:${local}`, root: d, name: local };
47079
47185
  if (top || path7.dirname(d) === d)
47080
47186
  return null;
47081
47187
  }
@@ -47112,7 +47218,7 @@ function gitPath(token, prefix) {
47112
47218
  }
47113
47219
  return prefix && out.startsWith(prefix) ? out.slice(prefix.length) : out;
47114
47220
  }
47115
- function parseDiff(text) {
47221
+ function parseDiff(text2) {
47116
47222
  const files = [];
47117
47223
  const at = (path8) => {
47118
47224
  const found = files.find((f2) => f2.path === path8);
@@ -47132,7 +47238,7 @@ function parseDiff(text) {
47132
47238
  at(block);
47133
47239
  block = null;
47134
47240
  };
47135
- const rows = text.split(/\r?\n/);
47241
+ const rows = text2.split(/\r?\n/);
47136
47242
  for (const [i, raw] of rows.entries()) {
47137
47243
  if (cur && (oldLeft > 0 || newLeft > 0)) {
47138
47244
  if (raw.startsWith("+")) {
@@ -47331,7 +47437,7 @@ ${framed(c.work.map((w) => `- ${head(inline(w.title), 200)} (${w.status}): ${hea
47331
47437
  requireRuntime();
47332
47438
  var db = openReader();
47333
47439
  var VERSION = versionAt(ROOT);
47334
- var text = (t, isError = false) => ({
47440
+ var text2 = (t, isError = false) => ({
47335
47441
  content: [{ type: "text", text: t }],
47336
47442
  ...isError ? { isError: true } : {}
47337
47443
  });
@@ -47377,46 +47483,46 @@ var CWD = exports_external.string().optional().describe("Which project to use. P
47377
47483
  server.registerTool("status", {
47378
47484
  title: "What Sphica holds for this project",
47379
47485
  description: "Current work, and how much of this project's history is captured and extracted: sessions still waiting for trace, quarantined records, " + "and candidates without adoption. Use it to know whether an empty search means nothing was decided or nothing was extracted yet.",
47380
- inputSchema: { cwd: CWD },
47486
+ inputSchema: exports_external.object({ cwd: CWD }).strict(),
47381
47487
  annotations: READ_ONLY
47382
47488
  }, async (a) => {
47383
47489
  try {
47384
47490
  const place = identify(a.cwd ?? process.cwd());
47385
47491
  if (!place)
47386
- return text("This directory is not in a registered project (run `sphica init` there).");
47492
+ return text2("This directory is not in a registered project (run `sphica init` there).");
47387
47493
  const id = await projectId(db, place.key);
47388
47494
  const name = head(inline(place.name), 200);
47389
47495
  if (id === null)
47390
- return text(`${name} is not registered with Sphica (run \`sphica init\` there).`);
47391
- return text(await status(db, id, name));
47496
+ return text2(`${name} is not registered with Sphica (run \`sphica init\` there).`);
47497
+ return text2(await status(db, id, name));
47392
47498
  } catch (e) {
47393
- return text(`Sphica unavailable: ${head(reason(e), 300)}`, true);
47499
+ return text2(`Sphica unavailable: ${head(reason(e), 300)}`, true);
47394
47500
  }
47395
47501
  });
47396
47502
  server.registerTool("search", {
47397
47503
  title: "Search past decisions and implementation",
47398
47504
  description: "Finds records (decisions, constraints, implementations, findings, dead ends, questions) whose text holds most of the query's words, " + "active ones first. Use short queries of the subject's words (identifiers, option names, the domain terms). sources: true searches the " + "captured conversation and pull request text instead. asked: true finds the owner's earlier messages like the query in other sessions, " + "with the records that quote each and whether a decision was recorded. An empty result also says how many weaker matches were left out.",
47399
- inputSchema: {
47505
+ inputSchema: exports_external.object({
47400
47506
  query: exports_external.string().min(1).max(500).describe("Words for the subject, in Japanese or English"),
47401
47507
  cwd: CWD,
47402
47508
  kinds: exports_external.array(exports_external.enum(UNIT_KINDS)).optional().describe("Only these kinds"),
47403
47509
  lifecycles: exports_external.array(exports_external.enum(LIFECYCLES)).optional().describe("Only these states (default: all, active first)"),
47404
- path: exports_external.string().max(500).optional().describe("Only records anchored to this repository-relative path"),
47510
+ path: exports_external.string().min(1).max(500).optional().describe("Only records anchored to this repository-relative path"),
47405
47511
  sources: exports_external.boolean().optional().describe("Search captured sources instead of records"),
47406
47512
  asked: exports_external.boolean().optional().describe("Find the owner's earlier messages like the query, what they led to, and repeats with no recorded decision"),
47407
47513
  session: exports_external.string().max(200).optional().describe("With asked: this session's id, so its own messages are left out (in Codex pass CODEX_THREAD_ID from your shell; Codex gives MCP servers none)"),
47408
47514
  limit: exports_external.number().int().min(1).max(20).optional()
47409
- },
47515
+ }).strict(),
47410
47516
  annotations: READ_ONLY
47411
47517
  }, async (a) => {
47412
47518
  try {
47413
47519
  const p = await projectOf(a.cwd);
47414
47520
  if (typeof p === "string")
47415
- return text(p);
47521
+ return text2(p);
47416
47522
  const limit = a.limit ?? 8;
47417
47523
  if (a.asked) {
47418
47524
  if (a.sources || a.path !== undefined)
47419
- return text("asked cannot be combined with sources or path.");
47525
+ return text2("asked cannot be combined with sources or path.");
47420
47526
  const external2 = [a.session, process.env.CLAUDE_CODE_SESSION_ID, process.env.CODEX_THREAD_ID].filter((x) => !!x);
47421
47527
  const r2 = await askedBefore(db, p.id, {
47422
47528
  question: a.query,
@@ -47427,14 +47533,16 @@ server.registerTool("search", {
47427
47533
  });
47428
47534
  const known = external2.length > 0;
47429
47535
  if (!r2.messages.length)
47430
- return text(`No ${known ? "earlier " : ""}owner message ${r2.stopped ? `among the first ${r2.read} candidates by rank ` : ""}holds most of: ${r2.terms.join(", ") || "(no searchable words)"}. ${r2.weaker} weaker matches left out.${r2.stopped ? " Search with more specific words." : ""}${known ? "" : ` ${UNKNOWN_SESSION}`}`);
47431
- return text(framed(askedText(r2, known)));
47536
+ return text2(`No ${known ? "earlier " : ""}owner message ${r2.stopped ? `among the first ${r2.read} candidates by rank ` : ""}holds most of: ${r2.terms.join(", ") || "(no searchable words)"}. ${r2.weaker} weaker matches left out.${r2.stopped ? " Search with more specific words." : ""}${known ? "" : ` ${UNKNOWN_SESSION}`}`);
47537
+ return text2(framed(askedText(r2, known)));
47432
47538
  }
47433
47539
  if (a.sources) {
47540
+ if (a.path !== undefined)
47541
+ return text2("sources cannot be combined with path.", true);
47434
47542
  const r2 = await searchSources(db, p.id, a.query, limit);
47435
47543
  if (!r2.hits.length)
47436
- return text(`No source ${among(r2)}holds most of: ${r2.terms.join(", ") || "(no searchable words)"}. ${r2.weaker} weaker matches left out.${r2.stopped ? " Search with more specific words." : ""}`);
47437
- return text(framed(r2.hits.map((h) => `## s${h.id}: ${h.kind} ${h.artifact}, ${h.author}, ${h.created_at}
47544
+ return text2(`No source ${among(r2)}holds most of: ${r2.terms.join(", ") || "(no searchable words)"}. ${r2.weaker} weaker matches left out.${r2.stopped ? " Search with more specific words." : ""}`);
47545
+ return text2(framed(r2.hits.map((h) => `## s${h.id}: ${h.kind} ${h.artifact}, ${h.author}, ${h.created_at}
47438
47546
  ${head(h.text, 800)}
47439
47547
  Matched: ${h.matched.join(", ")}`).join(`
47440
47548
 
@@ -47447,107 +47555,109 @@ Matched: ${h.matched.join(", ")}`).join(`
47447
47555
  path: a.path,
47448
47556
  limit
47449
47557
  });
47558
+ if (r.refused)
47559
+ return text2(`Nothing was searched: ${r.refused}`, true);
47450
47560
  if (!r.hits.length)
47451
- return text(`No record ${among(r)}holds most of: ${r.terms.join(", ") || "(no searchable words)"}. ${r.weaker} weaker matches left out. ` + (r.stopped ? "Search with more specific words. " : "") + "Search again with other words or the other language, or search sources; status says whether sessions are still untraced.");
47452
- return text(framed(`${r.hits.map(hitText).join(`
47561
+ return text2(`No record ${among(r)}holds most of: ${r.terms.join(", ") || "(no searchable words)"}. ${r.weaker} weaker matches left out. ` + (r.stopped ? "Search with more specific words. " : "") + "Search again with other words or the other language, or search sources; status says whether sessions are still untraced.");
47562
+ return text2(framed(`${r.hits.map(hitText).join(`
47453
47563
 
47454
47564
  `)}${stoppedAfter(r)}
47455
47565
 
47456
47566
  Read a record by its key before relying on it.`));
47457
47567
  } catch (e) {
47458
- return text(`Sphica unavailable: ${head(reason(e), 300)}`, true);
47568
+ return text2(`Sphica unavailable: ${head(reason(e), 300)}`, true);
47459
47569
  }
47460
47570
  });
47461
47571
  server.registerTool("read", {
47462
47572
  title: "Read past records and sources in full",
47463
47573
  description: "The full record: its text, options, the exact words cited as evidence and adoption with who said them, links (supersedes, conflicts), " + "state history, and each code location checked in the working tree now. Pass keys or u<id> from search, or s<id> for a source.",
47464
- inputSchema: {
47574
+ inputSchema: exports_external.object({
47465
47575
  refs: exports_external.array(exports_external.string().min(1).max(300)).min(1).max(10).describe("Record keys, u<id>, or s<id> (s<id>@<byte> reads a long source on from that byte)"),
47466
47576
  cwd: CWD
47467
- },
47577
+ }).strict(),
47468
47578
  annotations: READ_ONLY
47469
47579
  }, async (a) => {
47470
47580
  try {
47471
47581
  const p = await projectOf(a.cwd);
47472
47582
  if (typeof p === "string")
47473
- return text(p);
47583
+ return text2(p);
47474
47584
  const parts = [];
47475
47585
  for (const ref of a.refs) {
47476
47586
  const got = /^s\d/.test(ref) ? await readSource(db, p.id, ref) : await readUnit(db, p.id, ref, p.root);
47477
47587
  parts.push(got ?? `${head(inline(ref), 200)}: not found in this project`);
47478
47588
  }
47479
- return text(framed(parts.join(`
47589
+ return text2(framed(parts.join(`
47480
47590
 
47481
47591
  `)));
47482
47592
  } catch (e) {
47483
- return text(`Sphica unavailable: ${head(reason(e), 300)}`, true);
47593
+ return text2(`Sphica unavailable: ${head(reason(e), 300)}`, true);
47484
47594
  }
47485
47595
  });
47486
47596
  server.registerTool("export", {
47487
47597
  title: "Export chosen decisions to a file",
47488
47598
  description: "Only for the export Skill, after the owner chose the decisions and the path. Builds one Markdown document of the chosen active decisions, " + "with their quotes and the older decisions each replaced, and checks that the path stays inside the repository. Returns the document to write, " + "or why nothing can be written; it never writes a file itself.",
47489
- inputSchema: {
47599
+ inputSchema: exports_external.object({
47490
47600
  records: exports_external.array(exports_external.string().min(1).max(300)).min(1).max(EXPORT_LIMITS.records).describe("Keys or u<id> of the active decisions the owner chose"),
47491
47601
  path: exports_external.string().min(1).max(500).describe("Where the owner wants the file, relative to the repository root"),
47492
47602
  cwd: CWD
47493
- },
47603
+ }).strict(),
47494
47604
  annotations: READ_ONLY
47495
47605
  }, async (a) => {
47496
47606
  try {
47497
47607
  const p = await projectOf(a.cwd);
47498
47608
  if (typeof p === "string")
47499
- return text(`Nothing was exported: ${p}`, true);
47609
+ return text2(`Nothing was exported: ${p}`, true);
47500
47610
  const where = exportPath(p.root, a.path);
47501
47611
  if ("error" in where)
47502
- return text(`Nothing was exported: ${where.error}`, true);
47612
+ return text2(`Nothing was exported: ${where.error}`, true);
47503
47613
  const built = await exportDecisions(db, p.id, p.name, a.records);
47504
47614
  if ("error" in built)
47505
- return text(built.error, true);
47506
- return text(exportReply(where, built.document));
47615
+ return text2(built.error, true);
47616
+ return text2(exportReply(where, built.document));
47507
47617
  } catch (e) {
47508
- return text(`Sphica unavailable: ${head(reason(e), 300)}`, true);
47618
+ return text2(`Sphica unavailable: ${head(reason(e), 300)}`, true);
47509
47619
  }
47510
47620
  });
47511
47621
  server.registerTool("fields", {
47512
47622
  title: "Fields this project tracks",
47513
47623
  description: "Only for the fields Skill. The fields the owner defined for this project's records, as a Markdown table: type, allowed values, " + "the record kinds each applies to, how many records carry a value, and the owner's words that defined it.",
47514
- inputSchema: { cwd: CWD },
47624
+ inputSchema: exports_external.object({ cwd: CWD }).strict(),
47515
47625
  annotations: READ_ONLY
47516
47626
  }, async (a) => {
47517
47627
  try {
47518
47628
  const p = await projectOf(a.cwd);
47519
47629
  if (typeof p === "string")
47520
- return text(p, true);
47521
- return text(await fieldsText(db, p.id));
47630
+ return text2(p, true);
47631
+ return text2(await fieldsText(db, p.id));
47522
47632
  } catch (e) {
47523
- return text(`Sphica unavailable: ${head(reason(e), 300)}`, true);
47633
+ return text2(`Sphica unavailable: ${head(reason(e), 300)}`, true);
47524
47634
  }
47525
47635
  });
47526
47636
  var DIFF = exports_external.string().min(1).max(2000000).describe("The change under review as a unified diff (git diff output)");
47527
- var notChecked = (e) => text(`Decision lane: not checked. Sphica unavailable: ${head(reason(e), 300)}. Report the decision check as not run, not as passed.`, true);
47637
+ var notChecked = (e) => text2(`Decision lane: not checked. Sphica unavailable: ${head(reason(e), 300)}. Report the decision check as not run, not as passed.`, true);
47528
47638
  server.registerTool("overview", {
47529
47639
  title: "Live decisions, and records that need a look",
47530
47640
  description: "On request, not before every change. view live lists every active decision and constraint of the project, grouped by the directory it " + "applies to, a page at a time (pass after from the previous page). view look lists live records whose code file is gone or whose symbol " + "is not found, written conditions for reconsidering an option (for you to judge, never applied), and lines in CLAUDE.md, AGENTS.md, or " + ".claude/rules marked <!-- sphica: key --> whose record was replaced or withdrawn. Read a record by its key before relying on it.",
47531
- inputSchema: {
47641
+ inputSchema: exports_external.object({
47532
47642
  view: exports_external.enum(["live", "look"]).describe("live: every active decision and constraint; look: records that need a look"),
47533
47643
  after: exports_external.number().int().min(0).optional().describe("With live: the id the previous page said to continue after"),
47534
47644
  cwd: CWD
47535
- },
47645
+ }).strict(),
47536
47646
  annotations: READ_ONLY
47537
47647
  }, async (a) => {
47538
47648
  try {
47539
47649
  const p = await projectOf(a.cwd);
47540
47650
  if (typeof p === "string")
47541
- return text(p);
47542
- return text(framed(a.view === "live" ? await liveOverview(db, p.id, a.after ?? null) : await lookOverview(db, p.id, p.root)));
47651
+ return text2(p);
47652
+ return text2(framed(a.view === "live" ? await liveOverview(db, p.id, a.after ?? null) : await lookOverview(db, p.id, p.root)));
47543
47653
  } catch (e) {
47544
- return text(`Sphica unavailable: ${head(reason(e), 300)}`, true);
47654
+ return text2(`Sphica unavailable: ${head(reason(e), 300)}`, true);
47545
47655
  }
47546
47656
  });
47547
47657
  server.registerTool("review_select", {
47548
47658
  title: "Past decisions a change touches",
47549
47659
  description: "For a code review: the active decisions, constraints, and implementation records this diff touches (records anchored to a changed path, " + "and records with no code location that forbid or defer an option an added line names). Judge each against the diff, then check the verdicts with review_check.",
47550
- inputSchema: { diff: DIFF, cwd: CWD },
47660
+ inputSchema: exports_external.object({ diff: DIFF, cwd: CWD }).strict(),
47551
47661
  annotations: READ_ONLY
47552
47662
  }, async (a) => {
47553
47663
  try {
@@ -47557,8 +47667,8 @@ server.registerTool("review_select", {
47557
47667
  const files = parseDiff(a.diff);
47558
47668
  const hits = await selectForReview(db, p.id, files);
47559
47669
  if (!hits.length)
47560
- return text(`Decision lane: checked. No active record applies to the ${files.length} changed files.`);
47561
- return text(`Decision lane: checked. ${hits.length} records apply; read each before judging it.
47670
+ return text2(`Decision lane: checked. No active record applies to the ${files.length} changed files.`);
47671
+ return text2(`Decision lane: checked. ${hits.length} records apply; read each before judging it.
47562
47672
  ${framed(hits.map((u) => `- ${u.key} (${u.kind}${u.stance ? ` ${u.stance}` : ""}): ${head(inline(u.text), 300)} [${u.because}]`).join(`
47563
47673
  `))}`);
47564
47674
  } catch (e) {
@@ -47568,11 +47678,11 @@ ${framed(hits.map((u) => `- ${u.key} (${u.kind}${u.stance ? ` ${u.stance}` : ""}
47568
47678
  server.registerTool("review_check", {
47569
47679
  title: "Check decision verdicts",
47570
47680
  description: "Checks a reviewer's verdicts on the records review_select returned. Each finding: outcome (violation, complies, unrelated, undetermined), " + "unit (the record key), reason, and for violation or complies, evidence: the changed path and an added line number (the path alone for a deleted or renamed-away file). Every record review_select returned needs one outcome. Returns the problems, or none.",
47571
- inputSchema: {
47681
+ inputSchema: exports_external.object({
47572
47682
  diff: DIFF,
47573
47683
  findings: exports_external.array(exports_external.record(exports_external.string(), exports_external.unknown())).max(50),
47574
47684
  cwd: CWD
47575
- },
47685
+ }).strict(),
47576
47686
  annotations: READ_ONLY
47577
47687
  }, async (a) => {
47578
47688
  try {
@@ -47580,7 +47690,7 @@ server.registerTool("review_check", {
47580
47690
  if (typeof p === "string")
47581
47691
  return notChecked(new Error(p));
47582
47692
  const problems = await checkFindings(db, p.id, parseDiff(a.diff), a.findings);
47583
- return text(problems.length ? `${problems.length} problems:
47693
+ return text2(problems.length ? `${problems.length} problems:
47584
47694
  ${problems.map((x) => `- ${x}`).join(`
47585
47695
  `)}` : "No problems: every verdict is backed.");
47586
47696
  } catch (e) {