@inerrata-corporation/errata 2.0.6-dev.2621 → 2.0.6-dev.2632

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.
@@ -16143,6 +16143,9 @@ CREATE INDEX IF NOT EXISTS edges_live ON edges(valid_to);
16143
16143
  -- incremental reindex can fetch just the changed files' symbols instead of
16144
16144
  -- scanning every versioned node. Partial: only live rows are ever queried.
16145
16145
  CREATE INDEX IF NOT EXISTS nodes_relpath ON nodes(json_extract(attrs_json, '$.relPath')) WHERE valid_to IS NULL;
16146
+ -- Live rows by name, for looking up the few symbols a statement mentions
16147
+ -- instead of hydrating every code symbol per minted problem.
16148
+ CREATE INDEX IF NOT EXISTS nodes_live_description ON nodes(description, label) WHERE valid_to IS NULL;
16146
16149
  `;
16147
16150
  function encodeEmbedding(emb) {
16148
16151
  if (!emb || emb.length === 0) return null;
@@ -16400,8 +16403,16 @@ var SqliteGraphStore = class {
16400
16403
  closeEdge: this.db.prepare(
16401
16404
  "UPDATE edges SET valid_to = :t WHERE id = :id AND valid_to IS NULL"
16402
16405
  ),
16406
+ // Same rows as `COALESCE(logical_id, id) = ?`, split so each branch can use
16407
+ // an index (nodes_logical, then the id primary key). The COALESCE form made
16408
+ // SQLite SCAN nodes: 0.4-0.9s per call on a 227k-node store, once per new
16409
+ // diagnostic via attributeRootCause (2026-09-24).
16403
16410
  nodeHistory: this.db.prepare(
16404
- "SELECT * FROM nodes WHERE COALESCE(logical_id, id) = ? ORDER BY version ASC"
16411
+ `SELECT * FROM (
16412
+ SELECT * FROM nodes WHERE logical_id = ?1
16413
+ UNION ALL
16414
+ SELECT * FROM nodes WHERE id = ?1 AND logical_id IS NULL
16415
+ ) ORDER BY version ASC`
16405
16416
  ),
16406
16417
  nodesAsOf: this.db.prepare(
16407
16418
  "SELECT * FROM nodes WHERE valid_from <= :t AND (valid_to IS NULL OR valid_to > :t)"
@@ -16729,6 +16740,18 @@ var SqliteGraphStore = class {
16729
16740
  ).all(params);
16730
16741
  return rows.map(rowToEdge);
16731
16742
  }
16743
+ /** Live in-degree per target over `types`, in one grouped query — the counts
16744
+ * `inEdges(id, types).length` gives, for every node at once. A per-node
16745
+ * inEdges loop over ~48k code nodes held a PreToolUse hook 12.6s when the
16746
+ * hotspots vfile materialized (2026-09-24). */
16747
+ inDegreeByType(types) {
16748
+ if (types.length === 0) return /* @__PURE__ */ new Map();
16749
+ const placeholders = types.map(() => "?").join(",");
16750
+ const rows = this.db.prepare(
16751
+ `SELECT to_id, COUNT(*) AS n FROM edges WHERE type IN (${placeholders}) AND valid_to IS NULL GROUP BY to_id`
16752
+ ).all(...types);
16753
+ return new Map(rows.map((r) => [r.to_id, Number(r.n)]));
16754
+ }
16732
16755
  neighbors(nodeId, types) {
16733
16756
  const out = [];
16734
16757
  const edges = this.outEdges(nodeId, types);
@@ -16937,6 +16960,21 @@ var SqliteGraphStore = class {
16937
16960
  /** Live nodes whose attrs.relPath is one of `relPaths`. Uses the nodes_relpath
16938
16961
  * expression index so the incremental reindex fetches only the changed files'
16939
16962
  * symbols instead of scanning every versioned node. */
16963
+ /** Live nodes of `labels` whose description is exactly one of `names`, via
16964
+ * the nodes_live_description index. Chunked under the bound-parameter cap. */
16965
+ liveNodesByDescription(names, labels) {
16966
+ if (names.length === 0 || labels.length === 0) return [];
16967
+ const out = [];
16968
+ const labelPh = labels.map(() => "?").join(",");
16969
+ for (let i = 0; i < names.length; i += 400) {
16970
+ const chunk = names.slice(i, i + 400);
16971
+ const rows = this.db.prepare(
16972
+ `SELECT * FROM nodes WHERE valid_to IS NULL AND description IN (${chunk.map(() => "?").join(",")}) AND label IN (${labelPh})`
16973
+ ).all(...chunk, ...labels);
16974
+ for (const r of rows) out.push(rowToNode(r));
16975
+ }
16976
+ return out;
16977
+ }
16940
16978
  nodesByRelPath(relPaths) {
16941
16979
  if (relPaths.length === 0) return [];
16942
16980
  const placeholders = relPaths.map(() => "?").join(",");
@@ -16991,6 +17029,19 @@ var SqliteGraphStore = class {
16991
17029
  const rows = this.stmts.nodesAsOf.all({ t });
16992
17030
  return rows.map(rowToNode);
16993
17031
  }
17032
+ /** `nodesAsOf(t).filter((n) => n.attrs[attr] === true)` with the attr test in
17033
+ * SQL, so only the flagged rows are hydrated. The filter-after form hydrated
17034
+ * the whole graph for a handful of revisit flags: 6.7s per context render on
17035
+ * a 227k-node store (2026-09-24). `attr` is interpolated into a JSON path,
17036
+ * so it must be a plain identifier. */
17037
+ nodesAsOfWhereTrue(t, attr) {
17038
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(attr)) throw new Error(`nodesAsOfWhereTrue: bad attr ${JSON.stringify(attr)}`);
17039
+ const rows = this.db.prepare(
17040
+ `SELECT * FROM nodes WHERE valid_from <= :t AND (valid_to IS NULL OR valid_to > :t)
17041
+ AND json_type(attrs_json, '$.${attr}') = 'true'`
17042
+ ).all({ t });
17043
+ return rows.map(rowToNode);
17044
+ }
16994
17045
  edgesAsOf(t) {
16995
17046
  const rows = this.stmts.edgesAsOf.all({ t });
16996
17047
  return rows.map(rowToEdge);
package/errata.mjs CHANGED
@@ -17560,6 +17560,9 @@ CREATE INDEX IF NOT EXISTS edges_live ON edges(valid_to);
17560
17560
  -- incremental reindex can fetch just the changed files' symbols instead of
17561
17561
  -- scanning every versioned node. Partial: only live rows are ever queried.
17562
17562
  CREATE INDEX IF NOT EXISTS nodes_relpath ON nodes(json_extract(attrs_json, '$.relPath')) WHERE valid_to IS NULL;
17563
+ -- Live rows by name, for looking up the few symbols a statement mentions
17564
+ -- instead of hydrating every code symbol per minted problem.
17565
+ CREATE INDEX IF NOT EXISTS nodes_live_description ON nodes(description, label) WHERE valid_to IS NULL;
17563
17566
  `;
17564
17567
  SqliteGraphStore = class {
17565
17568
  db;
@@ -17760,8 +17763,16 @@ CREATE INDEX IF NOT EXISTS nodes_relpath ON nodes(json_extract(attrs_json, '$.re
17760
17763
  closeEdge: this.db.prepare(
17761
17764
  "UPDATE edges SET valid_to = :t WHERE id = :id AND valid_to IS NULL"
17762
17765
  ),
17766
+ // Same rows as `COALESCE(logical_id, id) = ?`, split so each branch can use
17767
+ // an index (nodes_logical, then the id primary key). The COALESCE form made
17768
+ // SQLite SCAN nodes: 0.4-0.9s per call on a 227k-node store, once per new
17769
+ // diagnostic via attributeRootCause (2026-09-24).
17763
17770
  nodeHistory: this.db.prepare(
17764
- "SELECT * FROM nodes WHERE COALESCE(logical_id, id) = ? ORDER BY version ASC"
17771
+ `SELECT * FROM (
17772
+ SELECT * FROM nodes WHERE logical_id = ?1
17773
+ UNION ALL
17774
+ SELECT * FROM nodes WHERE id = ?1 AND logical_id IS NULL
17775
+ ) ORDER BY version ASC`
17765
17776
  ),
17766
17777
  nodesAsOf: this.db.prepare(
17767
17778
  "SELECT * FROM nodes WHERE valid_from <= :t AND (valid_to IS NULL OR valid_to > :t)"
@@ -18089,6 +18100,18 @@ CREATE INDEX IF NOT EXISTS nodes_relpath ON nodes(json_extract(attrs_json, '$.re
18089
18100
  ).all(params);
18090
18101
  return rows.map(rowToEdge);
18091
18102
  }
18103
+ /** Live in-degree per target over `types`, in one grouped query — the counts
18104
+ * `inEdges(id, types).length` gives, for every node at once. A per-node
18105
+ * inEdges loop over ~48k code nodes held a PreToolUse hook 12.6s when the
18106
+ * hotspots vfile materialized (2026-09-24). */
18107
+ inDegreeByType(types) {
18108
+ if (types.length === 0) return /* @__PURE__ */ new Map();
18109
+ const placeholders = types.map(() => "?").join(",");
18110
+ const rows = this.db.prepare(
18111
+ `SELECT to_id, COUNT(*) AS n FROM edges WHERE type IN (${placeholders}) AND valid_to IS NULL GROUP BY to_id`
18112
+ ).all(...types);
18113
+ return new Map(rows.map((r) => [r.to_id, Number(r.n)]));
18114
+ }
18092
18115
  neighbors(nodeId, types) {
18093
18116
  const out2 = [];
18094
18117
  const edges = this.outEdges(nodeId, types);
@@ -18297,6 +18320,21 @@ CREATE INDEX IF NOT EXISTS nodes_relpath ON nodes(json_extract(attrs_json, '$.re
18297
18320
  /** Live nodes whose attrs.relPath is one of `relPaths`. Uses the nodes_relpath
18298
18321
  * expression index so the incremental reindex fetches only the changed files'
18299
18322
  * symbols instead of scanning every versioned node. */
18323
+ /** Live nodes of `labels` whose description is exactly one of `names`, via
18324
+ * the nodes_live_description index. Chunked under the bound-parameter cap. */
18325
+ liveNodesByDescription(names, labels) {
18326
+ if (names.length === 0 || labels.length === 0) return [];
18327
+ const out2 = [];
18328
+ const labelPh = labels.map(() => "?").join(",");
18329
+ for (let i2 = 0; i2 < names.length; i2 += 400) {
18330
+ const chunk = names.slice(i2, i2 + 400);
18331
+ const rows = this.db.prepare(
18332
+ `SELECT * FROM nodes WHERE valid_to IS NULL AND description IN (${chunk.map(() => "?").join(",")}) AND label IN (${labelPh})`
18333
+ ).all(...chunk, ...labels);
18334
+ for (const r of rows) out2.push(rowToNode(r));
18335
+ }
18336
+ return out2;
18337
+ }
18300
18338
  nodesByRelPath(relPaths) {
18301
18339
  if (relPaths.length === 0) return [];
18302
18340
  const placeholders = relPaths.map(() => "?").join(",");
@@ -18351,6 +18389,19 @@ CREATE INDEX IF NOT EXISTS nodes_relpath ON nodes(json_extract(attrs_json, '$.re
18351
18389
  const rows = this.stmts.nodesAsOf.all({ t });
18352
18390
  return rows.map(rowToNode);
18353
18391
  }
18392
+ /** `nodesAsOf(t).filter((n) => n.attrs[attr] === true)` with the attr test in
18393
+ * SQL, so only the flagged rows are hydrated. The filter-after form hydrated
18394
+ * the whole graph for a handful of revisit flags: 6.7s per context render on
18395
+ * a 227k-node store (2026-09-24). `attr` is interpolated into a JSON path,
18396
+ * so it must be a plain identifier. */
18397
+ nodesAsOfWhereTrue(t, attr) {
18398
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(attr)) throw new Error(`nodesAsOfWhereTrue: bad attr ${JSON.stringify(attr)}`);
18399
+ const rows = this.db.prepare(
18400
+ `SELECT * FROM nodes WHERE valid_from <= :t AND (valid_to IS NULL OR valid_to > :t)
18401
+ AND json_type(attrs_json, '$.${attr}') = 'true'`
18402
+ ).all({ t });
18403
+ return rows.map(rowToNode);
18404
+ }
18354
18405
  edgesAsOf(t) {
18355
18406
  const rows = this.stmts.edgesAsOf.all({ t });
18356
18407
  return rows.map(rowToEdge);
@@ -18757,7 +18808,7 @@ function clearRevisit(store, nodeId, ts) {
18757
18808
  store.updateNode(nodeId, { attrs, lastUpdatedAt: ts });
18758
18809
  }
18759
18810
  function listNeedsRevisit(store, asOf) {
18760
- return store.nodesAsOf(asOf).filter((n) => n.attrs["revisit"] === true).map((n) => ({
18811
+ return store.nodesAsOfWhereTrue(asOf, "revisit").map((n) => ({
18761
18812
  id: n.id,
18762
18813
  label: n.label,
18763
18814
  description: n.description,
@@ -19270,6 +19321,33 @@ function buildSymbolIndex(store) {
19270
19321
  }
19271
19322
  return idx;
19272
19323
  }
19324
+ function symbolCandidatesInText(text) {
19325
+ const out2 = /* @__PURE__ */ new Set();
19326
+ for (const run3 of text.match(/[\w$.]+/g) ?? []) {
19327
+ const segs = run3.split(".");
19328
+ for (let i2 = 0; i2 < segs.length; i2++) {
19329
+ if (!segs[i2]) continue;
19330
+ let name2 = "";
19331
+ for (let j = i2; j < segs.length && segs[j]; j++) {
19332
+ name2 = j === i2 ? segs[j] : `${name2}.${segs[j]}`;
19333
+ if (isShapedSymbol(name2)) out2.add(name2);
19334
+ }
19335
+ }
19336
+ }
19337
+ return [...out2];
19338
+ }
19339
+ function symbolIndexForText(store, text) {
19340
+ const counts = /* @__PURE__ */ new Map();
19341
+ for (const n of store.liveNodesByDescription(symbolCandidatesInText(text), SYMBOL_LABELS)) {
19342
+ const name2 = n.description.trim();
19343
+ const list = counts.get(name2);
19344
+ if (list) list.push(n);
19345
+ else counts.set(name2, [n]);
19346
+ }
19347
+ const idx = /* @__PURE__ */ new Map();
19348
+ for (const [name2, nodes] of counts) if (nodes.length === 1) idx.set(name2, nodes[0]);
19349
+ return idx;
19350
+ }
19273
19351
  function matchSymbolsInText(text, index) {
19274
19352
  const out2 = [];
19275
19353
  for (const [name2, node2] of index) {
@@ -19282,19 +19360,9 @@ function matchSymbolsInText(text, index) {
19282
19360
  function linkProblemToSymbols(store, problemId, text, ts, index) {
19283
19361
  const p = store.getNode(problemId);
19284
19362
  if (!p || p.label !== "Problem") return [];
19285
- const idx = index ?? buildSymbolIndex(store);
19363
+ const idx = index ?? symbolIndexForText(store, text);
19286
19364
  if (idx.size === 0) return [];
19287
- let fileByPath = null;
19288
- const fileFor2 = (relPath) => {
19289
- if (!fileByPath) {
19290
- fileByPath = /* @__PURE__ */ new Map();
19291
- for (const f of store.findNodesByLabel("File")) {
19292
- const rp = String(f.attrs["relPath"] ?? f.description ?? "");
19293
- if (rp) fileByPath.set(rp, f);
19294
- }
19295
- }
19296
- return fileByPath.get(relPath) ?? null;
19297
- };
19365
+ const fileFor2 = (relPath) => store.nodesByRelPath([relPath]).find((f) => f.label === "File") ?? null;
19298
19366
  const already = new Set(store.outEdges(problemId, ["ANCHORED_AT"]).map((e) => e.to));
19299
19367
  const linked = [];
19300
19368
  for (const sym of matchSymbolsInText(text, idx)) {
@@ -24019,6 +24087,8 @@ __export(src_exports2, {
24019
24087
  stampObservedOs: () => stampObservedOs,
24020
24088
  statementIdentityKey: () => statementIdentityKey,
24021
24089
  staticConductance: () => staticConductance,
24090
+ symbolCandidatesInText: () => symbolCandidatesInText,
24091
+ symbolIndexForText: () => symbolIndexForText,
24022
24092
  toolNodeId: () => toolNodeId,
24023
24093
  toolPriorsFor: () => toolPriorsFor,
24024
24094
  triageOf: () => triageOf,
@@ -31739,16 +31809,17 @@ function diagnosticKey(relPath, d) {
31739
31809
  function anchorByLine(store, workspaceId2, relPath, line) {
31740
31810
  let best = null;
31741
31811
  let bestRow = -1;
31742
- for (const label of ["Function", "Method", "Class"]) {
31743
- for (const n of store.findNodesByLabel(label)) {
31744
- if (n.attrs["workspaceId"] !== workspaceId2) continue;
31745
- if (n.attrs["relPath"] !== relPath) continue;
31746
- const row = n.attrs["startRow"];
31747
- if (typeof row !== "number" || row > line) continue;
31748
- if (row > bestRow) {
31749
- bestRow = row;
31750
- best = n;
31751
- }
31812
+ const labelRank = { Function: 0, Method: 1, Class: 2 };
31813
+ for (const n of store.nodesByRelPath([relPath])) {
31814
+ const rank = labelRank[n.label];
31815
+ if (rank === void 0) continue;
31816
+ if (n.attrs["workspaceId"] !== workspaceId2) continue;
31817
+ if (n.attrs["relPath"] !== relPath) continue;
31818
+ const row = n.attrs["startRow"];
31819
+ if (typeof row !== "number" || row > line) continue;
31820
+ if (row > bestRow || row === bestRow && best !== null && rank < labelRank[best.label]) {
31821
+ bestRow = row;
31822
+ best = n;
31752
31823
  }
31753
31824
  }
31754
31825
  return best ? best.logicalId ?? best.id : null;
@@ -42800,7 +42871,8 @@ var init_mcp = __esm({
42800
42871
  items: dead.slice(0, limit)
42801
42872
  };
42802
42873
  }
42803
- const ranked = codeNodes.map((n) => ({ id: n.id, label: n.label, name: n.description, fanIn: store.inEdges(n.id, [...CODE_REACH_EDGES]).length })).sort((a, b) => b.fanIn - a.fanIn).slice(0, limit);
42874
+ const fanIn = store.inDegreeByType([...CODE_REACH_EDGES]);
42875
+ const ranked = codeNodes.map((n) => ({ id: n.id, label: n.label, name: n.description, fanIn: fanIn.get(n.id) ?? 0 })).sort((a, b) => b.fanIn - a.fanIn).slice(0, limit);
42804
42876
  return { kind: "fan-in", items: ranked };
42805
42877
  }
42806
42878
  },
@@ -78443,7 +78515,7 @@ function createWatchBreaker(deps) {
78443
78515
  }
78444
78516
 
78445
78517
  // src/engine.ts
78446
- var DAEMON_VERSION = true ? "2.0.6-dev.2621" : "2.0.0-alpha.0";
78518
+ var DAEMON_VERSION = true ? "2.0.6-dev.2632" : "2.0.0-alpha.0";
78447
78519
  var IGNORED_PATH = /[\\/](?:\.git|node_modules|\.errata|\.claude|\.codex|\.cursor|\.turbo|\.next|dist|coverage|test-results|playwright-report|__pycache__)(?:[\\/]|$)/;
78448
78520
  var IGNORED_NOISE = /castalia\.db|eventlog\.sqlite|turn-cursors/;
78449
78521
  var GIT_OP_MUTE_MS = 4e3;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inerrata-corporation/errata",
3
- "version": "2.0.6-dev.2621",
3
+ "version": "2.0.6-dev.2632",
4
4
  "description": "errata - local-first observation engine for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
package/pass-worker.mjs CHANGED
@@ -24525,6 +24525,9 @@ CREATE INDEX IF NOT EXISTS edges_live ON edges(valid_to);
24525
24525
  -- incremental reindex can fetch just the changed files' symbols instead of
24526
24526
  -- scanning every versioned node. Partial: only live rows are ever queried.
24527
24527
  CREATE INDEX IF NOT EXISTS nodes_relpath ON nodes(json_extract(attrs_json, '$.relPath')) WHERE valid_to IS NULL;
24528
+ -- Live rows by name, for looking up the few symbols a statement mentions
24529
+ -- instead of hydrating every code symbol per minted problem.
24530
+ CREATE INDEX IF NOT EXISTS nodes_live_description ON nodes(description, label) WHERE valid_to IS NULL;
24528
24531
  `;
24529
24532
  function encodeEmbedding(emb) {
24530
24533
  if (!emb || emb.length === 0) return null;
@@ -24782,8 +24785,16 @@ var SqliteGraphStore = class {
24782
24785
  closeEdge: this.db.prepare(
24783
24786
  "UPDATE edges SET valid_to = :t WHERE id = :id AND valid_to IS NULL"
24784
24787
  ),
24788
+ // Same rows as `COALESCE(logical_id, id) = ?`, split so each branch can use
24789
+ // an index (nodes_logical, then the id primary key). The COALESCE form made
24790
+ // SQLite SCAN nodes: 0.4-0.9s per call on a 227k-node store, once per new
24791
+ // diagnostic via attributeRootCause (2026-09-24).
24785
24792
  nodeHistory: this.db.prepare(
24786
- "SELECT * FROM nodes WHERE COALESCE(logical_id, id) = ? ORDER BY version ASC"
24793
+ `SELECT * FROM (
24794
+ SELECT * FROM nodes WHERE logical_id = ?1
24795
+ UNION ALL
24796
+ SELECT * FROM nodes WHERE id = ?1 AND logical_id IS NULL
24797
+ ) ORDER BY version ASC`
24787
24798
  ),
24788
24799
  nodesAsOf: this.db.prepare(
24789
24800
  "SELECT * FROM nodes WHERE valid_from <= :t AND (valid_to IS NULL OR valid_to > :t)"
@@ -25111,6 +25122,18 @@ var SqliteGraphStore = class {
25111
25122
  ).all(params);
25112
25123
  return rows.map(rowToEdge);
25113
25124
  }
25125
+ /** Live in-degree per target over `types`, in one grouped query — the counts
25126
+ * `inEdges(id, types).length` gives, for every node at once. A per-node
25127
+ * inEdges loop over ~48k code nodes held a PreToolUse hook 12.6s when the
25128
+ * hotspots vfile materialized (2026-09-24). */
25129
+ inDegreeByType(types) {
25130
+ if (types.length === 0) return /* @__PURE__ */ new Map();
25131
+ const placeholders = types.map(() => "?").join(",");
25132
+ const rows = this.db.prepare(
25133
+ `SELECT to_id, COUNT(*) AS n FROM edges WHERE type IN (${placeholders}) AND valid_to IS NULL GROUP BY to_id`
25134
+ ).all(...types);
25135
+ return new Map(rows.map((r) => [r.to_id, Number(r.n)]));
25136
+ }
25114
25137
  neighbors(nodeId, types) {
25115
25138
  const out2 = [];
25116
25139
  const edges = this.outEdges(nodeId, types);
@@ -25319,6 +25342,21 @@ var SqliteGraphStore = class {
25319
25342
  /** Live nodes whose attrs.relPath is one of `relPaths`. Uses the nodes_relpath
25320
25343
  * expression index so the incremental reindex fetches only the changed files'
25321
25344
  * symbols instead of scanning every versioned node. */
25345
+ /** Live nodes of `labels` whose description is exactly one of `names`, via
25346
+ * the nodes_live_description index. Chunked under the bound-parameter cap. */
25347
+ liveNodesByDescription(names, labels) {
25348
+ if (names.length === 0 || labels.length === 0) return [];
25349
+ const out2 = [];
25350
+ const labelPh = labels.map(() => "?").join(",");
25351
+ for (let i2 = 0; i2 < names.length; i2 += 400) {
25352
+ const chunk = names.slice(i2, i2 + 400);
25353
+ const rows = this.db.prepare(
25354
+ `SELECT * FROM nodes WHERE valid_to IS NULL AND description IN (${chunk.map(() => "?").join(",")}) AND label IN (${labelPh})`
25355
+ ).all(...chunk, ...labels);
25356
+ for (const r of rows) out2.push(rowToNode(r));
25357
+ }
25358
+ return out2;
25359
+ }
25322
25360
  nodesByRelPath(relPaths) {
25323
25361
  if (relPaths.length === 0) return [];
25324
25362
  const placeholders = relPaths.map(() => "?").join(",");
@@ -25373,6 +25411,19 @@ var SqliteGraphStore = class {
25373
25411
  const rows = this.stmts.nodesAsOf.all({ t });
25374
25412
  return rows.map(rowToNode);
25375
25413
  }
25414
+ /** `nodesAsOf(t).filter((n) => n.attrs[attr] === true)` with the attr test in
25415
+ * SQL, so only the flagged rows are hydrated. The filter-after form hydrated
25416
+ * the whole graph for a handful of revisit flags: 6.7s per context render on
25417
+ * a 227k-node store (2026-09-24). `attr` is interpolated into a JSON path,
25418
+ * so it must be a plain identifier. */
25419
+ nodesAsOfWhereTrue(t, attr) {
25420
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(attr)) throw new Error(`nodesAsOfWhereTrue: bad attr ${JSON.stringify(attr)}`);
25421
+ const rows = this.db.prepare(
25422
+ `SELECT * FROM nodes WHERE valid_from <= :t AND (valid_to IS NULL OR valid_to > :t)
25423
+ AND json_type(attrs_json, '$.${attr}') = 'true'`
25424
+ ).all({ t });
25425
+ return rows.map(rowToNode);
25426
+ }
25376
25427
  edgesAsOf(t) {
25377
25428
  const rows = this.stmts.edgesAsOf.all({ t });
25378
25429
  return rows.map(rowToEdge);
@@ -25488,7 +25539,7 @@ function clearRevisit(store2, nodeId, ts) {
25488
25539
  store2.updateNode(nodeId, { attrs, lastUpdatedAt: ts });
25489
25540
  }
25490
25541
  function listNeedsRevisit(store2, asOf) {
25491
- return store2.nodesAsOf(asOf).filter((n) => n.attrs["revisit"] === true).map((n) => ({
25542
+ return store2.nodesAsOfWhereTrue(asOf, "revisit").map((n) => ({
25492
25543
  id: n.id,
25493
25544
  label: n.label,
25494
25545
  description: n.description,