autodev-cli 1.4.97 → 1.4.101
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/media/profile/00-identity.md +8 -20
- package/media/profile/01-learning.md +12 -19
- package/media/profile/02-memory-mcp.md +4 -44
- package/media/profile/03-living-docs.md +10 -13
- package/media/profile/04-skill-files.md +3 -5
- package/media/profile/05-skill-creation.md +3 -14
- package/media/profile/06-core-rules.md +10 -47
- package/media/profile/07-core-loop.md +8 -25
- package/media/profile/08-thinking.md +10 -23
- package/media/profile/09-parallel-panel.md +9 -24
- package/media/profile/10-codebase-verification.md +7 -16
- package/media/profile/11-git-debug-security.md +5 -10
- package/media/profile/12-todo-format.md +3 -16
- package/media/profile/13-workflow-principles.md +3 -7
- package/media/profile/14-contracts.md +9 -12
- package/media/profile/15-soul.md +6 -8
- package/media/profile/16-journal.md +5 -32
- package/media/profile/17-issue-tracking.md +9 -14
- package/media/profile/18-knowledgebase.md +6 -9
- package/media/profile/19-subagent-context-management.md +11 -496
- package/media/profile/20-project-graph.md +23 -49
- package/out/commands/mcpOperate.js +150 -32
- package/out/commands/mcpOperate.js.map +1 -1
- package/out/core/controlSpec.d.ts +56 -0
- package/out/core/controlSpec.js +267 -0
- package/out/core/controlSpec.js.map +1 -0
- package/out/dispatcher.js +3 -1
- package/out/dispatcher.js.map +1 -1
- package/out/graphRender.d.ts +87 -0
- package/out/graphRender.js +279 -0
- package/out/graphRender.js.map +1 -0
- package/out/graphStore.d.ts +377 -2
- package/out/graphStore.js +1240 -64
- package/out/graphStore.js.map +1 -1
- package/out/journal.d.ts +35 -0
- package/out/journal.js +167 -0
- package/out/journal.js.map +1 -0
- package/out/messageBuilder.d.ts +1 -1
- package/out/messageBuilder.js +29 -29
- package/out/messageBuilder.js.map +1 -1
- package/out/periodicActions.d.ts +18 -0
- package/out/periodicActions.js +49 -0
- package/out/periodicActions.js.map +1 -1
- package/out/profileBuilder.d.ts +1 -1
- package/out/profileBuilder.js +31 -4
- package/out/profileBuilder.js.map +1 -1
- package/out/taskLoop.d.ts +19 -0
- package/out/taskLoop.js +214 -4
- package/out/taskLoop.js.map +1 -1
- package/package.json +2 -2
package/out/graphStore.js
CHANGED
|
@@ -23,6 +23,14 @@
|
|
|
23
23
|
* 3. Every `evaluation` identifies a `rubric`.
|
|
24
24
|
* 4. Superseded nodes remain addressable — supersede never deletes; it links a
|
|
25
25
|
* new version with a `supersedes` edge and flags the old one.
|
|
26
|
+
*
|
|
27
|
+
* MATERIALISATION (Tier 0). The log is append-only, so reload is incremental: we
|
|
28
|
+
* remember a byte offset and apply only the newly-appended tail on top of the
|
|
29
|
+
* live maps (apply is idempotent last-writer-wins) — a full clear+rebuild only
|
|
30
|
+
* happens when the file shrank (a rewrite). Two derived indexes are kept in step:
|
|
31
|
+
* an adjacency index (nodeId → incident edges) and an entity canonical-key index
|
|
32
|
+
* (name → entity id) so `UsageService` / `usage-service` / `usage service` all
|
|
33
|
+
* resolve to ONE entity.
|
|
26
34
|
*/
|
|
27
35
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
28
36
|
if (k2 === undefined) k2 = k;
|
|
@@ -59,6 +67,10 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
59
67
|
})();
|
|
60
68
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
61
69
|
exports.GraphStore = exports.GraphInvariantError = exports.EDGE_TYPES = exports.NODE_TYPES = void 0;
|
|
70
|
+
exports.tokenize = tokenize;
|
|
71
|
+
exports.canonicalKey = canonicalKey;
|
|
72
|
+
exports.estimateTokens = estimateTokens;
|
|
73
|
+
exports.parseSourcePath = parseSourcePath;
|
|
62
74
|
const fs = __importStar(require("fs"));
|
|
63
75
|
const path = __importStar(require("path"));
|
|
64
76
|
const crypto = __importStar(require("crypto"));
|
|
@@ -75,22 +87,192 @@ exports.EDGE_TYPES = [
|
|
|
75
87
|
class GraphInvariantError extends Error {
|
|
76
88
|
}
|
|
77
89
|
exports.GraphInvariantError = GraphInvariantError;
|
|
90
|
+
/**
|
|
91
|
+
* Legacy display slug — kept for backward-compatible id resolution of graphs
|
|
92
|
+
* written before the canonical-key dedup landed. New entity ids use
|
|
93
|
+
* {@link canonicalKey}. `slug("UsageService")` and `slug("usage-service")`
|
|
94
|
+
* DIVERGE, which is exactly the bug canonicalKey fixes; slug survives only so
|
|
95
|
+
* old `entity:usageservice`-style ids still resolve via the fallback path.
|
|
96
|
+
*/
|
|
78
97
|
function slug(s) {
|
|
79
98
|
return s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'x';
|
|
80
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Split a label into normalised word tokens: break camelCase / PascalCase and
|
|
102
|
+
* acronym boundaries into words, lowercase, and split on any non-alphanumeric.
|
|
103
|
+
* `"UsageService"` → `["usage","service"]`, `"usage-service"` → `["usage","service"]`,
|
|
104
|
+
* `"HTTPServer v2"` → `["http","server","v2"]`.
|
|
105
|
+
*/
|
|
106
|
+
function tokenize(s) {
|
|
107
|
+
return String(s || '')
|
|
108
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2') // camelCase boundary
|
|
109
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') // ACRONYMWord → ACRONYM Word
|
|
110
|
+
.toLowerCase()
|
|
111
|
+
.split(/[^a-z0-9]+/)
|
|
112
|
+
.filter(Boolean);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Canonical dedup key for an entity name: a **sorted, de-duplicated token set**.
|
|
116
|
+
* This collapses camelCase, kebab-case and spaced spellings AND word order to a
|
|
117
|
+
* single key, so `UsageService`, `usage-service` and `usage service` map to one
|
|
118
|
+
* `entity:` id. Sorting means `"Service Usage"` also collapses onto the same key.
|
|
119
|
+
*/
|
|
120
|
+
function canonicalKey(s) {
|
|
121
|
+
const toks = Array.from(new Set(tokenize(s))).sort();
|
|
122
|
+
return toks.join('-').slice(0, 60) || 'x';
|
|
123
|
+
}
|
|
81
124
|
function shortId() { return crypto.randomBytes(5).toString('hex'); }
|
|
125
|
+
/** Rough token estimate for budgeting — ~4 chars/token. */
|
|
126
|
+
function estimateTokens(s) {
|
|
127
|
+
return Math.ceil((s ? s.length : 0) / 4);
|
|
128
|
+
}
|
|
129
|
+
// ── Tier 2 constants ─────────────────────────────────────────────────────────
|
|
130
|
+
/** Node types whose `file:line` source triggers the parent_of auto-scaffold (item 1). */
|
|
131
|
+
const SCAFFOLD_SOURCE_TYPES = new Set(['claim', 'artifact', 'decision', 'note']);
|
|
132
|
+
/** Regenerate the materialised snapshot when the uncovered JSONL tail exceeds this many bytes. */
|
|
133
|
+
const SNAPSHOT_TAIL_BYTES = 256 * 1024;
|
|
134
|
+
/** …or this many ops applied since the last snapshot (item 3). */
|
|
135
|
+
const SNAPSHOT_TAIL_OPS = 2000;
|
|
136
|
+
/**
|
|
137
|
+
* Parse a `file:line`-style source into its cumulative path segments — the spine
|
|
138
|
+
* an auto-scaffold builds (item 1). `"src/foo/bar.ts:12"` → `["src","src/foo",
|
|
139
|
+
* "src/foo/bar.ts"]`; `"pay.ts:10"` → `["pay.ts"]`. Returns null for a URL, a
|
|
140
|
+
* source-node id (`source:ab12`), or anything that doesn't look like a path — the
|
|
141
|
+
* scaffold is deterministic and conservative, never a guess.
|
|
142
|
+
*/
|
|
143
|
+
function parseSourcePath(source) {
|
|
144
|
+
let s = String(source || '').trim();
|
|
145
|
+
if (!s) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) {
|
|
149
|
+
return null;
|
|
150
|
+
} // url (http://, file://, …)
|
|
151
|
+
s = s.replace(/:\d+(:\d+)?$/, ''); // strip :line(:col)
|
|
152
|
+
s = s.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '');
|
|
153
|
+
if (!s || s.includes('..')) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
// must look like a file path: has a '/' or a dotted extension (rules out `source:ab12`)
|
|
157
|
+
const looksPath = s.includes('/') || /\.[a-z0-9]+$/i.test(s);
|
|
158
|
+
if (!looksPath) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
const parts = s.split('/').filter((x) => x && x !== '.');
|
|
162
|
+
if (!parts.length || parts.length > 20) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
const cumulative = [];
|
|
166
|
+
let acc = '';
|
|
167
|
+
for (const p of parts) {
|
|
168
|
+
acc = acc ? `${acc}/${p}` : p;
|
|
169
|
+
cumulative.push(acc);
|
|
170
|
+
}
|
|
171
|
+
return cumulative;
|
|
172
|
+
}
|
|
173
|
+
// atomicity ceiling for a lock-free appendFileSync: writes at or below PIPE_BUF
|
|
174
|
+
// (4096 on Linux) can't interleave with a concurrent writer. We warn a little
|
|
175
|
+
// below it so a serialized op line that risks a torn interleave is never silent.
|
|
176
|
+
const PIPE_BUF_SAFE = 4000;
|
|
177
|
+
const EDGE_ONTOLOGY = {
|
|
178
|
+
produced: [{ from: ['agent_run'], to: ['artifact'] }],
|
|
179
|
+
evaluates: [{ from: ['evaluation'], to: ['artifact', 'agent_run'] }],
|
|
180
|
+
supports: [{ from: '*', to: ['claim'] }],
|
|
181
|
+
contradicts: [{ from: '*', to: ['claim'] }],
|
|
182
|
+
resolved_to: [{ from: '*', to: ['entity'] }],
|
|
183
|
+
derived_from: [{ from: '*', to: '*' }],
|
|
184
|
+
parent_of: [{ from: '*', to: '*' }],
|
|
185
|
+
depends_on: [{ from: '*', to: '*' }],
|
|
186
|
+
mentions: [{ from: '*', to: '*' }],
|
|
187
|
+
relates_to: [{ from: '*', to: '*' }],
|
|
188
|
+
revises: [{ from: '*', to: '*' }],
|
|
189
|
+
supersedes: [{ from: '*', to: '*' }],
|
|
190
|
+
};
|
|
191
|
+
function describeRule(r) {
|
|
192
|
+
const f = r.from === '*' ? '*' : r.from.join('|');
|
|
193
|
+
const t = r.to === '*' ? '*' : r.to.join('|');
|
|
194
|
+
return `(${f})→(${t})`;
|
|
195
|
+
}
|
|
196
|
+
/** @returns true if the triple is allowed, else a human-readable rejection reason. */
|
|
197
|
+
function edgeAllowed(rel, fromType, toType) {
|
|
198
|
+
const rules = EDGE_ONTOLOGY[rel];
|
|
199
|
+
if (!rules) {
|
|
200
|
+
return true;
|
|
201
|
+
} // unknown relation types are validated earlier; stay permissive here
|
|
202
|
+
for (const r of rules) {
|
|
203
|
+
const okFrom = r.from === '*' || r.from.includes(fromType);
|
|
204
|
+
const okTo = r.to === '*' || r.to.includes(toType);
|
|
205
|
+
if (okFrom && okTo) {
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return `edge "${rel}" expects ${rules.map(describeRule).join(' or ')}, but got (${fromType})→(${toType})`;
|
|
210
|
+
}
|
|
211
|
+
// ── Relevance ranking (Tier 1, item 1) ──────────────────────────────────────
|
|
212
|
+
// A deliberately VECTORLESS scorer: field-weighted TF·IDF over the resident node
|
|
213
|
+
// set, lifted by recency + degree. Honours PageIndex's "similarity ≠ relevance" —
|
|
214
|
+
// no embeddings, fully explainable. Field weights: name > summary ≈ aliases > body.
|
|
215
|
+
const FIELD_WEIGHTS = [
|
|
216
|
+
{ field: 'name', weight: 3 },
|
|
217
|
+
{ field: 'summary', weight: 2 },
|
|
218
|
+
{ field: 'aliases', weight: 2 },
|
|
219
|
+
{ field: 'body', weight: 1 },
|
|
220
|
+
];
|
|
221
|
+
/** Weight of the recency lift in finalScore = textScore·(1 + wRecency·decay + wDegree·log1p(deg)). */
|
|
222
|
+
const W_RECENCY = 0.5;
|
|
223
|
+
/** Weight of the connectivity (degree) lift. */
|
|
224
|
+
const W_DEGREE = 0.3;
|
|
225
|
+
/** Recency half-life-ish constant in days: exp(-ageDays/RECENCY_TAU). */
|
|
226
|
+
const RECENCY_TAU_DAYS = 30;
|
|
227
|
+
/** Guaranteed-inclusion floor for an exact-substring hit that scored 0 on tokens
|
|
228
|
+
* (keeps the old substring behaviour a strict subset of ranked results). */
|
|
229
|
+
const SUBSTR_FLOOR = 0.001;
|
|
230
|
+
/** Provenance strength for best-first expansion: strong provenance edges outrank weak ones. */
|
|
231
|
+
const EDGE_STRENGTH = {
|
|
232
|
+
supports: 3, derived_from: 3, produced: 3, evaluates: 3, depends_on: 3, parent_of: 3,
|
|
233
|
+
supersedes: 2, revises: 2, resolved_to: 2, contradicts: 2,
|
|
234
|
+
mentions: 1, relates_to: 1,
|
|
235
|
+
};
|
|
82
236
|
class GraphStore {
|
|
83
237
|
identity;
|
|
84
238
|
dir;
|
|
85
239
|
file;
|
|
240
|
+
/** Disposable materialised cache for cold-start (item 3). JSONL stays the sole source of truth. */
|
|
241
|
+
snapshotFile;
|
|
242
|
+
snapshotEnabled;
|
|
243
|
+
/** Byte offset the on-disk snapshot covers (0 = none loaded/written this session). */
|
|
244
|
+
snapshotCoversBytes = 0;
|
|
245
|
+
/** Ops applied since the last snapshot was written/loaded — the regeneration trigger. */
|
|
246
|
+
opsSinceSnapshot = 0;
|
|
86
247
|
nodes = new Map();
|
|
87
248
|
edges = new Map();
|
|
249
|
+
/** nodeId → incident edges (both directions). Rebuilt on clear, kept in step in apply(). */
|
|
250
|
+
adjacency = new Map();
|
|
251
|
+
/** entity canonical-key → entity node id — the dedup + legacy-id resolution index. */
|
|
252
|
+
entityIndex = new Map();
|
|
88
253
|
lastSize = -1;
|
|
89
254
|
lastMtimeMs = -1;
|
|
90
|
-
|
|
255
|
+
/** byte offset up to (and including) the last COMPLETE log line we've applied. */
|
|
256
|
+
lastOffset = 0;
|
|
257
|
+
/** total log ops applied since the last full rebuild (for dead-weight telemetry). */
|
|
258
|
+
appliedOps = 0;
|
|
259
|
+
/** complete-but-corrupt (JSON-unparseable) MIDDLE lines seen; a torn FINAL line is NOT counted. */
|
|
260
|
+
parseFailures = 0;
|
|
261
|
+
/** wall-clock ms of the last (incremental or full) reload. */
|
|
262
|
+
lastReplayMs = 0;
|
|
263
|
+
/** Monotonic node-set version bumped by apply() — the invalidation signal reload uses. */
|
|
264
|
+
mutationSeq = 0;
|
|
265
|
+
/** IDF over the resident node set, memoised until mutationSeq changes (item 1). */
|
|
266
|
+
idfCache = null;
|
|
267
|
+
idfCacheSeq = -1;
|
|
268
|
+
constructor(workspaceRoot, identity, opts) {
|
|
91
269
|
this.identity = identity;
|
|
92
270
|
this.dir = path.join(workspaceRoot, '.autodev', 'graph');
|
|
93
271
|
this.file = path.join(this.dir, 'graph.jsonl');
|
|
272
|
+
this.snapshotFile = path.join(this.dir, 'graph.snapshot.json');
|
|
273
|
+
// Snapshot is on by default; pass { snapshot: false } for a pure full-replay store
|
|
274
|
+
// (used by tests to prove snapshot+tail == full replay).
|
|
275
|
+
this.snapshotEnabled = opts?.snapshot !== false;
|
|
94
276
|
this.reloadIfChanged();
|
|
95
277
|
}
|
|
96
278
|
/** Where the graph lives (for messages). */
|
|
@@ -101,7 +283,27 @@ class GraphStore {
|
|
|
101
283
|
fs.mkdirSync(this.dir, { recursive: true });
|
|
102
284
|
}
|
|
103
285
|
}
|
|
104
|
-
|
|
286
|
+
clear() {
|
|
287
|
+
this.nodes.clear();
|
|
288
|
+
this.edges.clear();
|
|
289
|
+
this.adjacency.clear();
|
|
290
|
+
this.entityIndex.clear();
|
|
291
|
+
this.appliedOps = 0;
|
|
292
|
+
this.parseFailures = 0;
|
|
293
|
+
this.lastOffset = 0;
|
|
294
|
+
this.idfCache = null;
|
|
295
|
+
this.snapshotCoversBytes = 0;
|
|
296
|
+
this.opsSinceSnapshot = 0;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Re-materialise from disk when the log changed under us (another agent wrote).
|
|
300
|
+
* INCREMENTAL: since the log is append-only we treat `lastOffset` as a byte
|
|
301
|
+
* cursor and apply only the newly-appended tail on top of the live maps (apply
|
|
302
|
+
* is idempotent). A full clear+rebuild happens only when the file shrank below
|
|
303
|
+
* our cursor (a rewrite). A torn final line is left unconsumed and picked up on
|
|
304
|
+
* a later tick once the writer finishes it. This is provably identical to a
|
|
305
|
+
* full replay because apply() is last-writer-wins and the log is append-only.
|
|
306
|
+
*/
|
|
105
307
|
reloadIfChanged() {
|
|
106
308
|
let st = null;
|
|
107
309
|
try {
|
|
@@ -111,21 +313,195 @@ class GraphStore {
|
|
|
111
313
|
st = null;
|
|
112
314
|
}
|
|
113
315
|
if (!st) {
|
|
114
|
-
if (this.
|
|
115
|
-
this.
|
|
116
|
-
this.edges.clear();
|
|
117
|
-
this.lastSize = 0;
|
|
118
|
-
this.lastMtimeMs = 0;
|
|
316
|
+
if (this.nodes.size || this.edges.size || this.lastOffset !== 0) {
|
|
317
|
+
this.clear();
|
|
119
318
|
}
|
|
319
|
+
this.lastSize = 0;
|
|
320
|
+
this.lastMtimeMs = 0;
|
|
321
|
+
this.lastOffset = 0;
|
|
120
322
|
return;
|
|
121
323
|
}
|
|
122
324
|
if (st.size === this.lastSize && st.mtimeMs === this.lastMtimeMs) {
|
|
123
325
|
return;
|
|
124
326
|
}
|
|
125
|
-
|
|
126
|
-
this.
|
|
127
|
-
|
|
128
|
-
|
|
327
|
+
const t0 = Date.now();
|
|
328
|
+
const firstLoad = this.lastSize < 0;
|
|
329
|
+
if (st.size < this.lastOffset) {
|
|
330
|
+
// the file was truncated/rewritten under us — rebuild from scratch.
|
|
331
|
+
this.clear();
|
|
332
|
+
this.loadFromScratch(st.size);
|
|
333
|
+
}
|
|
334
|
+
else if (firstLoad) {
|
|
335
|
+
// cold start — try the materialised snapshot, else full replay.
|
|
336
|
+
this.loadFromScratch(st.size);
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
// append-only growth — fold just the new tail onto the current state.
|
|
340
|
+
this.replayRange(this.lastOffset, st.size);
|
|
341
|
+
}
|
|
342
|
+
this.lastReplayMs = Date.now() - t0;
|
|
343
|
+
this.lastSize = st.size;
|
|
344
|
+
this.lastMtimeMs = st.mtimeMs;
|
|
345
|
+
if (this.snapshotEnabled) {
|
|
346
|
+
this.maybeWriteSnapshot(st.size);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// ── materialised snapshot (Tier 2, item 3) ─────────────────────────────────
|
|
350
|
+
/**
|
|
351
|
+
* Cold-start load: if a valid snapshot covers a prefix of the current log, adopt
|
|
352
|
+
* it and tail-replay only the uncovered bytes; otherwise full-replay. The
|
|
353
|
+
* snapshot is a DISPOSABLE cache — a missing / stale / corrupt one simply falls
|
|
354
|
+
* back to full replay, and the JSONL is never touched.
|
|
355
|
+
*/
|
|
356
|
+
loadFromScratch(size) {
|
|
357
|
+
if (this.snapshotEnabled && this.tryLoadSnapshot(size)) {
|
|
358
|
+
this.replayRange(this.lastOffset, size); // fold the tail beyond the snapshot
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
this.clear();
|
|
362
|
+
this.replayRange(0, size);
|
|
363
|
+
}
|
|
364
|
+
/** sha256 of the first min(4096, covers) bytes of the log — the cheap rewrite guard. */
|
|
365
|
+
headSig(covers) {
|
|
366
|
+
const n = Math.min(4096, covers);
|
|
367
|
+
const fd = fs.openSync(this.file, 'r');
|
|
368
|
+
try {
|
|
369
|
+
const b = Buffer.allocUnsafe(n);
|
|
370
|
+
let read = 0;
|
|
371
|
+
while (read < n) {
|
|
372
|
+
const k = fs.readSync(fd, b, read, n - read, read);
|
|
373
|
+
if (k <= 0) {
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
read += k;
|
|
377
|
+
}
|
|
378
|
+
return crypto.createHash('sha256').update(b.subarray(0, read)).digest('hex');
|
|
379
|
+
}
|
|
380
|
+
finally {
|
|
381
|
+
fs.closeSync(fd);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Adopt the on-disk snapshot iff it is a valid prefix of the current log:
|
|
386
|
+
* `coversBytes` in (0, size] AND the head-signature still matches (guards against
|
|
387
|
+
* a rewritten/truncated log whose prefix changed). Rebuilds the derived indexes
|
|
388
|
+
* from the resident node/edge set rather than trusting serialized indexes.
|
|
389
|
+
*/
|
|
390
|
+
tryLoadSnapshot(size) {
|
|
391
|
+
try {
|
|
392
|
+
if (!fs.existsSync(this.snapshotFile)) {
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
const snap = JSON.parse(fs.readFileSync(this.snapshotFile, 'utf8'));
|
|
396
|
+
const covers = Number(snap.coversBytes);
|
|
397
|
+
if (!Number.isFinite(covers) || covers <= 0 || covers > size) {
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
if (!Array.isArray(snap.nodes) || !Array.isArray(snap.edges)) {
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
if (typeof snap.headSig !== 'string' || this.headSig(covers) !== snap.headSig) {
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
// valid → adopt. Populate the maps and rebuild adjacency + entity indexes.
|
|
407
|
+
this.clear();
|
|
408
|
+
for (const n of snap.nodes) {
|
|
409
|
+
this.nodes.set(n.id, n);
|
|
410
|
+
this.indexEntity(n);
|
|
411
|
+
}
|
|
412
|
+
for (const e of snap.edges) {
|
|
413
|
+
this.edges.set(e.id, e);
|
|
414
|
+
this.indexEdge(e);
|
|
415
|
+
}
|
|
416
|
+
this.appliedOps = this.nodes.size + this.edges.size;
|
|
417
|
+
this.mutationSeq++;
|
|
418
|
+
this.idfCache = null;
|
|
419
|
+
this.lastOffset = covers;
|
|
420
|
+
this.snapshotCoversBytes = covers;
|
|
421
|
+
this.opsSinceSnapshot = 0;
|
|
422
|
+
return true;
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
/** Regenerate the snapshot when the uncovered tail grew past the byte/op threshold. */
|
|
429
|
+
maybeWriteSnapshot(size) {
|
|
430
|
+
const uncoveredBytes = size - this.snapshotCoversBytes;
|
|
431
|
+
if (uncoveredBytes >= SNAPSHOT_TAIL_BYTES || this.opsSinceSnapshot >= SNAPSHOT_TAIL_OPS) {
|
|
432
|
+
this.writeSnapshot();
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Write the snapshot atomically (temp file + rename) so a partial write is never
|
|
437
|
+
* visible and concurrent multi-agent regenerations can't corrupt it — the loser
|
|
438
|
+
* of a rename race simply leaves a consistent, self-describing file behind. Snaps
|
|
439
|
+
* only the byte prefix we've fully applied (`lastOffset`), so a torn final line is
|
|
440
|
+
* never captured.
|
|
441
|
+
*/
|
|
442
|
+
writeSnapshot() {
|
|
443
|
+
const covers = this.lastOffset;
|
|
444
|
+
if (covers <= 0) {
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
try {
|
|
448
|
+
this.ensureDir();
|
|
449
|
+
const snap = {
|
|
450
|
+
coversBytes: covers,
|
|
451
|
+
generatedAt: new Date().toISOString(),
|
|
452
|
+
generatedBy: this.identity.agentId,
|
|
453
|
+
headSig: this.headSig(covers),
|
|
454
|
+
nodes: [...this.nodes.values()],
|
|
455
|
+
edges: [...this.edges.values()],
|
|
456
|
+
};
|
|
457
|
+
const tmp = `${this.snapshotFile}.${process.pid}.${shortId()}.tmp`;
|
|
458
|
+
fs.writeFileSync(tmp, JSON.stringify(snap));
|
|
459
|
+
fs.renameSync(tmp, this.snapshotFile);
|
|
460
|
+
this.snapshotCoversBytes = covers;
|
|
461
|
+
this.opsSinceSnapshot = 0;
|
|
462
|
+
return true;
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
/** Force a snapshot now (test hook / explicit checkpoint). No-op when snapshots are disabled. */
|
|
469
|
+
materializeSnapshot() {
|
|
470
|
+
this.reloadIfChanged();
|
|
471
|
+
return this.snapshotEnabled ? this.writeSnapshot() : false;
|
|
472
|
+
}
|
|
473
|
+
/** Read bytes [start,end), apply every COMPLETE line, and advance lastOffset past them. */
|
|
474
|
+
replayRange(start, end) {
|
|
475
|
+
if (end <= start) {
|
|
476
|
+
this.lastOffset = end;
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
let buf;
|
|
480
|
+
const fd = fs.openSync(this.file, 'r');
|
|
481
|
+
try {
|
|
482
|
+
const len = end - start;
|
|
483
|
+
const b = Buffer.allocUnsafe(len);
|
|
484
|
+
let read = 0;
|
|
485
|
+
while (read < len) {
|
|
486
|
+
const n = fs.readSync(fd, b, read, len - read, start + read);
|
|
487
|
+
if (n <= 0) {
|
|
488
|
+
break;
|
|
489
|
+
}
|
|
490
|
+
read += n;
|
|
491
|
+
}
|
|
492
|
+
buf = read === len ? b : b.subarray(0, read);
|
|
493
|
+
}
|
|
494
|
+
finally {
|
|
495
|
+
fs.closeSync(fd);
|
|
496
|
+
}
|
|
497
|
+
// Only apply up to the last newline; the trailing bytes (if any) are a torn
|
|
498
|
+
// final line — leave the cursor before them so the next tick re-reads them.
|
|
499
|
+
const lastNl = buf.lastIndexOf(0x0A);
|
|
500
|
+
if (lastNl < 0) {
|
|
501
|
+
return;
|
|
502
|
+
} // no complete line yet
|
|
503
|
+
const complete = buf.subarray(0, lastNl + 1).toString('utf8');
|
|
504
|
+
for (const line of complete.split('\n')) {
|
|
129
505
|
const t = line.trim();
|
|
130
506
|
if (!t) {
|
|
131
507
|
continue;
|
|
@@ -135,21 +511,30 @@ class GraphStore {
|
|
|
135
511
|
op = JSON.parse(t);
|
|
136
512
|
}
|
|
137
513
|
catch {
|
|
514
|
+
this.parseFailures++;
|
|
138
515
|
continue;
|
|
139
|
-
} //
|
|
516
|
+
} // a complete but corrupt MIDDLE line — surfaced in stats
|
|
140
517
|
this.apply(op);
|
|
141
518
|
}
|
|
142
|
-
this.
|
|
143
|
-
this.lastMtimeMs = st.mtimeMs;
|
|
519
|
+
this.lastOffset = start + lastNl + 1;
|
|
144
520
|
}
|
|
145
521
|
apply(op) {
|
|
522
|
+
this.appliedOps++;
|
|
523
|
+
this.opsSinceSnapshot++;
|
|
524
|
+
this.mutationSeq++; // invalidates the IDF cache (same signal reload rides)
|
|
146
525
|
if (op.op === 'node') {
|
|
147
526
|
const { op: _o, ...node } = op;
|
|
148
527
|
this.nodes.set(node.id, node);
|
|
528
|
+
this.indexEntity(node);
|
|
149
529
|
}
|
|
150
530
|
else if (op.op === 'edge') {
|
|
151
531
|
const { op: _o, ...edge } = op;
|
|
532
|
+
const prev = this.edges.get(edge.id);
|
|
533
|
+
if (prev) {
|
|
534
|
+
this.unindexEdge(prev);
|
|
535
|
+
}
|
|
152
536
|
this.edges.set(edge.id, edge);
|
|
537
|
+
this.indexEdge(edge);
|
|
153
538
|
}
|
|
154
539
|
else if (op.op === 'supersede') {
|
|
155
540
|
const old = this.nodes.get(op.id);
|
|
@@ -159,19 +544,79 @@ class GraphStore {
|
|
|
159
544
|
}
|
|
160
545
|
}
|
|
161
546
|
}
|
|
547
|
+
indexEntity(n) {
|
|
548
|
+
if (n.type !== 'entity') {
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
// name wins; aliases fill gaps so they can resolve the same entity.
|
|
552
|
+
this.entityIndex.set(canonicalKey(n.name), n.id);
|
|
553
|
+
for (const a of n.aliases || []) {
|
|
554
|
+
const k = canonicalKey(a);
|
|
555
|
+
if (!this.entityIndex.has(k)) {
|
|
556
|
+
this.entityIndex.set(k, n.id);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
indexEdge(e) {
|
|
561
|
+
this.pushAdj(e.from, e);
|
|
562
|
+
if (e.to !== e.from) {
|
|
563
|
+
this.pushAdj(e.to, e);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
pushAdj(nid, e) {
|
|
567
|
+
const list = this.adjacency.get(nid);
|
|
568
|
+
if (list) {
|
|
569
|
+
list.push(e);
|
|
570
|
+
}
|
|
571
|
+
else {
|
|
572
|
+
this.adjacency.set(nid, [e]);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
unindexEdge(e) {
|
|
576
|
+
for (const nid of new Set([e.from, e.to])) {
|
|
577
|
+
const list = this.adjacency.get(nid);
|
|
578
|
+
if (!list) {
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
const i = list.findIndex((x) => x.id === e.id);
|
|
582
|
+
if (i >= 0) {
|
|
583
|
+
list.splice(i, 1);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
162
587
|
append(op) {
|
|
163
588
|
this.ensureDir();
|
|
164
|
-
|
|
589
|
+
const line = JSON.stringify(op) + '\n';
|
|
590
|
+
const bytes = Buffer.byteLength(line, 'utf8');
|
|
591
|
+
// SAFETY (Tier 0, item 3): appendFileSync is only atomic below PIPE_BUF; a
|
|
592
|
+
// larger line can interleave with a concurrent agent's append in this
|
|
593
|
+
// lock-free model. Never silent — warn on stderr with a pointer to the cause.
|
|
594
|
+
if (bytes > PIPE_BUF_SAFE) {
|
|
595
|
+
const id = op.id ?? op.op;
|
|
596
|
+
process.stderr.write(`[graph] warning: op ${id} serialises to ${bytes} bytes (> ${PIPE_BUF_SAFE}); ` +
|
|
597
|
+
`appendFileSync is not atomic above PIPE_BUF and may interleave with a concurrent writer — ` +
|
|
598
|
+
`consider shortening its body/props.\n`);
|
|
599
|
+
}
|
|
600
|
+
fs.appendFileSync(this.file, line);
|
|
601
|
+
this.apply(op);
|
|
165
602
|
try {
|
|
166
603
|
const st = fs.statSync(this.file);
|
|
167
604
|
this.lastSize = st.size;
|
|
168
605
|
this.lastMtimeMs = st.mtimeMs;
|
|
606
|
+
this.lastOffset = st.size; // our own write is fully consumed
|
|
169
607
|
}
|
|
170
608
|
catch { /* stat best-effort */ }
|
|
171
|
-
this.apply(op);
|
|
172
609
|
}
|
|
173
610
|
// ── writes ───────────────────────────────────────────────────────────────
|
|
174
611
|
addNode(input) {
|
|
612
|
+
return this.addNodeInternal(input, { scaffold: true });
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* The write path. `scaffold:true` (the public {@link addNode}) additionally runs
|
|
616
|
+
* the deterministic parent_of auto-scaffold (item 1); the scaffold's own entity
|
|
617
|
+
* writes pass `scaffold:false` so they never recurse.
|
|
618
|
+
*/
|
|
619
|
+
addNodeInternal(input, opts) {
|
|
175
620
|
this.reloadIfChanged();
|
|
176
621
|
const type = String(input.type || '').toLowerCase().trim();
|
|
177
622
|
if (!exports.NODE_TYPES.includes(type)) {
|
|
@@ -193,16 +638,25 @@ class GraphStore {
|
|
|
193
638
|
if (type === 'evaluation' && !String(input.rubric || '').trim()) {
|
|
194
639
|
throw new GraphInvariantError('an "evaluation" needs a rubric (pass rubric:"what criteria were checked")');
|
|
195
640
|
}
|
|
196
|
-
// Stable ids: entities dedupe by
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
|
|
641
|
+
// Stable ids: entities dedupe by a sorted-token-set canonical key so
|
|
642
|
+
// UsageService / usage-service / "usage service" resolve to ONE id — and old
|
|
643
|
+
// graphs stay addressable because the key index is seeded from stored names.
|
|
644
|
+
// Everything else gets a fresh id unless the caller pins one (upsert).
|
|
645
|
+
const id = String(input.id || '').trim() || this.mintId(type, name, input.aliases);
|
|
200
646
|
const now = new Date().toISOString();
|
|
201
647
|
const existing = this.nodes.get(id);
|
|
648
|
+
// Stamp when the summary was (re)written so staleness — gaining children or being
|
|
649
|
+
// superseded AFTER the summary — is detectable without a separate op format.
|
|
650
|
+
const summaryProvided = input.summary != null && String(input.summary).trim() !== '';
|
|
651
|
+
let props = input.props ?? existing?.props;
|
|
652
|
+
if (summaryProvided) {
|
|
653
|
+
props = { ...(props || {}), summaryAt: now };
|
|
654
|
+
}
|
|
202
655
|
const node = {
|
|
203
656
|
id, type, name,
|
|
204
657
|
body: input.body ?? existing?.body,
|
|
205
|
-
|
|
658
|
+
summary: summaryProvided ? String(input.summary).trim() : existing?.summary,
|
|
659
|
+
props,
|
|
206
660
|
source: input.source ?? existing?.source,
|
|
207
661
|
inference: input.inference ?? existing?.inference,
|
|
208
662
|
version: input.version ?? existing?.version,
|
|
@@ -215,8 +669,92 @@ class GraphStore {
|
|
|
215
669
|
updatedAt: now,
|
|
216
670
|
};
|
|
217
671
|
this.append({ op: 'node', ...node });
|
|
672
|
+
if (opts.scaffold) {
|
|
673
|
+
this.scaffold(node);
|
|
674
|
+
}
|
|
218
675
|
return { node, created: !existing };
|
|
219
676
|
}
|
|
677
|
+
// ── parent_of auto-scaffold from source paths / area tags (Tier 2, item 1) ──
|
|
678
|
+
/**
|
|
679
|
+
* Deterministically fill the parent_of tree from a fact's provenance — NO LLM.
|
|
680
|
+
* A `file:line` source on a claim/artifact/decision/note upserts `entity` nodes
|
|
681
|
+
* for the file and its ancestor dirs and links a `dir → file → fact` spine; a
|
|
682
|
+
* `props.area` tag upserts an area entity and parents the fact under it. Every
|
|
683
|
+
* derived node/edge carries this run's provenance and a `props.autoScaffold=true`
|
|
684
|
+
* marker, and is idempotent (deterministic ids + edge dedup) so re-adds and
|
|
685
|
+
* concurrent writers converge instead of duplicating.
|
|
686
|
+
*/
|
|
687
|
+
scaffold(node) {
|
|
688
|
+
try {
|
|
689
|
+
if (SCAFFOLD_SOURCE_TYPES.has(node.type) && node.source) {
|
|
690
|
+
this.scaffoldFromSource(node);
|
|
691
|
+
}
|
|
692
|
+
const area = node.props?.area;
|
|
693
|
+
if (typeof area === 'string' && area.trim()) {
|
|
694
|
+
this.scaffoldFromArea(node, area.trim());
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
catch { /* scaffolding is best-effort — it must never fail the primary write */ }
|
|
698
|
+
}
|
|
699
|
+
scaffoldFromSource(node) {
|
|
700
|
+
const segs = parseSourcePath(node.source);
|
|
701
|
+
if (!segs || !segs.length) {
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
let prevId = null;
|
|
705
|
+
for (let i = 0; i < segs.length; i++) {
|
|
706
|
+
const p = segs[i];
|
|
707
|
+
const isFile = i === segs.length - 1;
|
|
708
|
+
const id = `entity:path:${p}`;
|
|
709
|
+
this.addNodeInternal({ type: 'entity', name: p, id, props: { autoScaffold: true, kind: isFile ? 'file' : 'dir', path: p } }, { scaffold: false });
|
|
710
|
+
if (prevId) {
|
|
711
|
+
this.linkParentOf(prevId, id, node.source);
|
|
712
|
+
}
|
|
713
|
+
prevId = id;
|
|
714
|
+
}
|
|
715
|
+
if (prevId) {
|
|
716
|
+
this.linkParentOf(prevId, node.id, node.source);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
scaffoldFromArea(node, area) {
|
|
720
|
+
const id = `entity:area:${slug(area)}`;
|
|
721
|
+
this.addNodeInternal({ type: 'entity', name: area, id, props: { autoScaffold: true, kind: 'area', area } }, { scaffold: false });
|
|
722
|
+
this.linkParentOf(id, node.id);
|
|
723
|
+
}
|
|
724
|
+
/** Is there already an edge of `type` from→to? (idempotency for scaffold/rollup edges). */
|
|
725
|
+
hasEdge(type, from, to) {
|
|
726
|
+
for (const e of this.adjacency.get(from) || []) {
|
|
727
|
+
if (e.type === type && e.from === from && e.to === to) {
|
|
728
|
+
return true;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
return false;
|
|
732
|
+
}
|
|
733
|
+
/** Add a parent_of edge (marked autoScaffold) unless it already exists. */
|
|
734
|
+
linkParentOf(from, to, source) {
|
|
735
|
+
if (from === to || this.hasEdge('parent_of', from, to)) {
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
this.addEdge({ type: 'parent_of', from, to, source, props: { autoScaffold: true } });
|
|
739
|
+
}
|
|
740
|
+
/** Deterministic id for a new node: entities dedupe by canonical key (+ aliases), others are fresh. */
|
|
741
|
+
mintId(type, name, aliases) {
|
|
742
|
+
if (type !== 'entity') {
|
|
743
|
+
return `${type}:${shortId()}`;
|
|
744
|
+
}
|
|
745
|
+
const key = canonicalKey(name);
|
|
746
|
+
let existingId = this.entityIndex.get(key);
|
|
747
|
+
if (!existingId) {
|
|
748
|
+
for (const a of aliases || []) {
|
|
749
|
+
const hit = this.entityIndex.get(canonicalKey(a));
|
|
750
|
+
if (hit) {
|
|
751
|
+
existingId = hit;
|
|
752
|
+
break;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
return existingId || `entity:${key}`;
|
|
757
|
+
}
|
|
220
758
|
addEdge(input) {
|
|
221
759
|
this.reloadIfChanged();
|
|
222
760
|
const type = String(input.type || '').toLowerCase().trim();
|
|
@@ -228,12 +766,20 @@ class GraphStore {
|
|
|
228
766
|
if (!from || !to) {
|
|
229
767
|
throw new GraphInvariantError('edge needs both "from" and "to" node ids');
|
|
230
768
|
}
|
|
231
|
-
|
|
769
|
+
const fromNode = this.nodes.get(from);
|
|
770
|
+
const toNode = this.nodes.get(to);
|
|
771
|
+
if (!fromNode) {
|
|
232
772
|
throw new GraphInvariantError(`edge "from" node not found: ${from}`);
|
|
233
773
|
}
|
|
234
|
-
if (!
|
|
774
|
+
if (!toNode) {
|
|
235
775
|
throw new GraphInvariantError(`edge "to" node not found: ${to}`);
|
|
236
776
|
}
|
|
777
|
+
// SAFETY (Tier 0, item 6): reject an obviously-wrong (fromType, relation,
|
|
778
|
+
// targetType) triple — e.g. evaluates: commit→task — with a helpful message.
|
|
779
|
+
const verdict = edgeAllowed(type, fromNode.type, toNode.type);
|
|
780
|
+
if (verdict !== true) {
|
|
781
|
+
throw new GraphInvariantError(verdict);
|
|
782
|
+
}
|
|
237
783
|
const edge = {
|
|
238
784
|
id: `edge:${shortId()}`, type, from, to,
|
|
239
785
|
props: input.props, source: input.source,
|
|
@@ -269,25 +815,122 @@ class GraphStore {
|
|
|
269
815
|
return this.nodes.get(idOrName);
|
|
270
816
|
}
|
|
271
817
|
const needle = idOrName.toLowerCase();
|
|
272
|
-
// exact name match first, then alias, then the entity-
|
|
818
|
+
// exact name match first, then alias, then the entity canonical-key index,
|
|
819
|
+
// then the legacy slug convention (so pre-canonical graphs still resolve).
|
|
273
820
|
for (const n of this.nodes.values()) {
|
|
274
821
|
if (n.name.toLowerCase() === needle) {
|
|
275
822
|
return n;
|
|
276
823
|
}
|
|
277
824
|
}
|
|
278
825
|
for (const n of this.nodes.values()) {
|
|
279
|
-
if ((n.aliases || []).some(a => a.toLowerCase() === needle)) {
|
|
826
|
+
if ((n.aliases || []).some((a) => a.toLowerCase() === needle)) {
|
|
280
827
|
return n;
|
|
281
828
|
}
|
|
282
829
|
}
|
|
830
|
+
const eid = this.entityIndex.get(canonicalKey(idOrName));
|
|
831
|
+
if (eid && this.nodes.has(eid)) {
|
|
832
|
+
return this.nodes.get(eid);
|
|
833
|
+
}
|
|
283
834
|
return this.nodes.get(`entity:${slug(idOrName)}`);
|
|
284
835
|
}
|
|
285
|
-
|
|
836
|
+
/** Incident `contradicts` edges of a node, resolved to {id,name} of the other endpoint. */
|
|
837
|
+
contradictionsFor(id) {
|
|
838
|
+
const out = [];
|
|
839
|
+
for (const e of this.adjacency.get(id) || []) {
|
|
840
|
+
if (e.type !== 'contradicts') {
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
const otherId = e.from === id ? e.to : e.from;
|
|
844
|
+
out.push({ id: otherId, name: this.nodes.get(otherId)?.name ?? otherId });
|
|
845
|
+
}
|
|
846
|
+
return out;
|
|
847
|
+
}
|
|
848
|
+
/** Node degree (incident edge count) — shared ranking infra for Tier 1. */
|
|
849
|
+
degree(id) {
|
|
850
|
+
return (this.adjacency.get(id) || []).length;
|
|
851
|
+
}
|
|
852
|
+
// ── relevance ranking (Tier 1, item 1) ────────────────────────────────────
|
|
853
|
+
/** IDF over the resident node set, memoised until the node set mutates. */
|
|
854
|
+
idf() {
|
|
855
|
+
if (this.idfCache && this.idfCacheSeq === this.mutationSeq) {
|
|
856
|
+
return this.idfCache;
|
|
857
|
+
}
|
|
858
|
+
const df = new Map();
|
|
859
|
+
const N = this.nodes.size || 1;
|
|
860
|
+
for (const n of this.nodes.values()) {
|
|
861
|
+
const toks = new Set([
|
|
862
|
+
...tokenize(n.name),
|
|
863
|
+
...tokenize(n.summary || ''),
|
|
864
|
+
...tokenize((n.aliases || []).join(' ')),
|
|
865
|
+
...tokenize(n.body || ''),
|
|
866
|
+
]);
|
|
867
|
+
for (const t of toks) {
|
|
868
|
+
df.set(t, (df.get(t) || 0) + 1);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
const idf = new Map();
|
|
872
|
+
for (const [t, d] of df) {
|
|
873
|
+
idf.set(t, Math.log(1 + N / (1 + d)));
|
|
874
|
+
}
|
|
875
|
+
this.idfCache = idf;
|
|
876
|
+
this.idfCacheSeq = this.mutationSeq;
|
|
877
|
+
return idf;
|
|
878
|
+
}
|
|
879
|
+
/** Exponential recency lift in [0,1]: 1 at now, decaying with age (τ≈30d). */
|
|
880
|
+
recencyDecay(ts) {
|
|
881
|
+
const ms = Date.parse(ts || '');
|
|
882
|
+
if (!isFinite(ms)) {
|
|
883
|
+
return 0;
|
|
884
|
+
}
|
|
885
|
+
const days = Math.max(0, (Date.now() - ms) / 86400000);
|
|
886
|
+
return Math.exp(-days / RECENCY_TAU_DAYS);
|
|
887
|
+
}
|
|
888
|
+
/** Field-weighted TF·IDF text score for a node against a set of query terms. */
|
|
889
|
+
scoreNode(n, terms, idf) {
|
|
890
|
+
if (!terms.length) {
|
|
891
|
+
return { score: 0, matched: [] };
|
|
892
|
+
}
|
|
893
|
+
const fieldTokens = {
|
|
894
|
+
name: tokenize(n.name),
|
|
895
|
+
summary: tokenize(n.summary || ''),
|
|
896
|
+
aliases: tokenize((n.aliases || []).join(' ')),
|
|
897
|
+
body: tokenize(n.body || ''),
|
|
898
|
+
};
|
|
899
|
+
let score = 0;
|
|
900
|
+
const matched = [];
|
|
901
|
+
for (const term of terms) {
|
|
902
|
+
let tf = 0;
|
|
903
|
+
for (const { field, weight } of FIELD_WEIGHTS) {
|
|
904
|
+
for (const tok of fieldTokens[field]) {
|
|
905
|
+
if (tok === term) {
|
|
906
|
+
tf += weight;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
if (tf > 0) {
|
|
911
|
+
score += tf * (idf.get(term) || 0);
|
|
912
|
+
matched.push(term);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
return { score, matched };
|
|
916
|
+
}
|
|
917
|
+
/**
|
|
918
|
+
* Relevance-ranked query (Tier 1, item 1). OR-semantics: a node is a hit if it
|
|
919
|
+
* matches ≥1 query term OR contains the intent as an exact substring (the old
|
|
920
|
+
* behaviour, kept as a guaranteed-inclusion signal → strict superset). Ranked by
|
|
921
|
+
* `textScore·(1 + wRecency·decay + wDegree·log1p(degree))`. `mode:'recent'` keeps
|
|
922
|
+
* the pure recency order; superseded nodes hidden unless includeSuperseded.
|
|
923
|
+
*/
|
|
924
|
+
scoredQuery(opts) {
|
|
286
925
|
this.reloadIfChanged();
|
|
287
926
|
const limit = Math.max(1, Math.min(200, opts.limit ?? 30));
|
|
288
927
|
const type = opts.type ? String(opts.type).toLowerCase() : undefined;
|
|
289
|
-
const text = opts.text ? String(opts.text)
|
|
290
|
-
const
|
|
928
|
+
const text = opts.text ? String(opts.text) : undefined;
|
|
929
|
+
const textLower = text ? text.toLowerCase() : undefined;
|
|
930
|
+
const terms = text ? Array.from(new Set(tokenize(text))) : [];
|
|
931
|
+
const idf = terms.length ? this.idf() : null;
|
|
932
|
+
const mode = opts.mode ?? 'relevance';
|
|
933
|
+
const rows = [];
|
|
291
934
|
for (const n of this.nodes.values()) {
|
|
292
935
|
if (opts.id && n.id !== opts.id) {
|
|
293
936
|
continue;
|
|
@@ -298,22 +941,55 @@ class GraphStore {
|
|
|
298
941
|
if (!opts.includeSuperseded && n.supersededBy) {
|
|
299
942
|
continue;
|
|
300
943
|
}
|
|
301
|
-
if (
|
|
302
|
-
const
|
|
303
|
-
|
|
944
|
+
if (textLower) {
|
|
945
|
+
const { score: ts, matched } = this.scoreNode(n, terms, idf);
|
|
946
|
+
const hay = `${n.name}\n${n.summary ?? ''}\n${n.body ?? ''}\n${(n.aliases || []).join(' ')}`.toLowerCase();
|
|
947
|
+
const substring = hay.includes(textLower);
|
|
948
|
+
let textScore = ts;
|
|
949
|
+
if (substring && textScore <= 0) {
|
|
950
|
+
textScore = SUBSTR_FLOOR;
|
|
951
|
+
} // guaranteed inclusion
|
|
952
|
+
if (textScore <= 0) {
|
|
304
953
|
continue;
|
|
305
|
-
}
|
|
954
|
+
} // no term hit and no substring — a real miss
|
|
955
|
+
const final = textScore * (1 + W_RECENCY * this.recencyDecay(n.updatedAt) + W_DEGREE * Math.log1p(this.degree(n.id)));
|
|
956
|
+
rows.push({ node: n, score: final, textScore, matched, substring });
|
|
957
|
+
}
|
|
958
|
+
else {
|
|
959
|
+
rows.push({ node: n, score: this.recencyDecay(n.updatedAt), textScore: 0, matched: [], substring: false });
|
|
306
960
|
}
|
|
307
|
-
out.push(n);
|
|
308
961
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
962
|
+
rows.sort((a, b) => {
|
|
963
|
+
if (mode === 'recent' || !textLower) {
|
|
964
|
+
const r = (b.node.updatedAt || '').localeCompare(a.node.updatedAt || '');
|
|
965
|
+
if (r !== 0) {
|
|
966
|
+
return r;
|
|
967
|
+
}
|
|
968
|
+
return b.score - a.score;
|
|
969
|
+
}
|
|
970
|
+
if (b.score !== a.score) {
|
|
971
|
+
return b.score - a.score;
|
|
972
|
+
}
|
|
973
|
+
return (b.node.updatedAt || '').localeCompare(a.node.updatedAt || '');
|
|
974
|
+
});
|
|
975
|
+
return rows.slice(0, limit);
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Search nodes by type and/or text. Thin wrapper over {@link scoredQuery}: with
|
|
979
|
+
* `text` it returns relevance-ranked hits (item 1); without, recency order. The
|
|
980
|
+
* old pure-substring behaviour survives as a guaranteed-inclusion subset.
|
|
981
|
+
*/
|
|
982
|
+
query(opts) {
|
|
983
|
+
return this.scoredQuery(opts).map((r) => r.node);
|
|
312
984
|
}
|
|
313
985
|
/**
|
|
314
986
|
* Bounded neighbourhood around a node — the playbook's "context construction
|
|
315
987
|
* from a graph": resolve the entity, expand a hop or two over allowed edge
|
|
316
988
|
* types, and return a small, citable subgraph rather than the whole graph.
|
|
989
|
+
* Uses the adjacency index (O(visited degree), not O(E·hops)) and returns the
|
|
990
|
+
* BFS hop-distance per node so the caller can rank/token-budget the result.
|
|
991
|
+
* `includeConflicts` (default true) always follows `contradicts` edges so a
|
|
992
|
+
* conflicting claim can never be hidden by an `edgeTypes` filter.
|
|
317
993
|
*/
|
|
318
994
|
neighbors(opts) {
|
|
319
995
|
this.reloadIfChanged();
|
|
@@ -324,57 +1000,529 @@ class GraphStore {
|
|
|
324
1000
|
const hops = Math.max(1, Math.min(3, opts.hops ?? 1));
|
|
325
1001
|
const edgeLimit = Math.max(1, Math.min(200, opts.limit ?? 40));
|
|
326
1002
|
const allow = opts.edgeTypes && opts.edgeTypes.length
|
|
327
|
-
? new Set(opts.edgeTypes.map(e => e.toLowerCase())) : null;
|
|
328
|
-
|
|
1003
|
+
? new Set(opts.edgeTypes.map((e) => e.toLowerCase())) : null;
|
|
1004
|
+
if (allow && opts.includeConflicts !== false) {
|
|
1005
|
+
allow.add('contradicts');
|
|
1006
|
+
}
|
|
1007
|
+
const dist = new Map([[center.id, 0]]);
|
|
329
1008
|
const nodeOut = new Map([[center.id, center]]);
|
|
330
1009
|
const edgeOut = [];
|
|
1010
|
+
const edgeSeen = new Set();
|
|
331
1011
|
let frontier = [center.id];
|
|
332
1012
|
for (let h = 0; h < hops && edgeOut.length < edgeLimit; h++) {
|
|
333
1013
|
const next = [];
|
|
334
|
-
for (const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
const touchesFrontier = frontier.includes(e.from) || frontier.includes(e.to);
|
|
339
|
-
if (!touchesFrontier) {
|
|
340
|
-
continue;
|
|
341
|
-
}
|
|
342
|
-
if (!edgeOut.find(x => x.id === e.id)) {
|
|
343
|
-
edgeOut.push(e);
|
|
344
|
-
if (edgeOut.length >= edgeLimit) {
|
|
345
|
-
break;
|
|
1014
|
+
for (const nid of frontier) {
|
|
1015
|
+
for (const e of this.adjacency.get(nid) || []) {
|
|
1016
|
+
if (allow && !allow.has(e.type)) {
|
|
1017
|
+
continue;
|
|
346
1018
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
1019
|
+
if (!edgeSeen.has(e.id)) {
|
|
1020
|
+
edgeSeen.add(e.id);
|
|
1021
|
+
edgeOut.push(e);
|
|
1022
|
+
if (edgeOut.length >= edgeLimit) {
|
|
1023
|
+
break;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
const other = e.from === nid ? e.to : e.from;
|
|
1027
|
+
if (!nodeOut.has(other)) {
|
|
1028
|
+
const nn = this.nodes.get(other);
|
|
352
1029
|
if (nn) {
|
|
353
|
-
nodeOut.set(
|
|
354
|
-
|
|
1030
|
+
nodeOut.set(other, nn);
|
|
1031
|
+
dist.set(other, h + 1);
|
|
1032
|
+
next.push(other);
|
|
355
1033
|
}
|
|
356
1034
|
}
|
|
357
1035
|
}
|
|
1036
|
+
if (edgeOut.length >= edgeLimit) {
|
|
1037
|
+
break;
|
|
1038
|
+
}
|
|
358
1039
|
}
|
|
359
1040
|
frontier = next;
|
|
360
1041
|
if (!frontier.length) {
|
|
361
1042
|
break;
|
|
362
1043
|
}
|
|
363
1044
|
}
|
|
364
|
-
return { center, nodes: [...nodeOut.values()], edges: edgeOut };
|
|
1045
|
+
return { center, nodes: [...nodeOut.values()], edges: edgeOut, dist: Object.fromEntries(dist) };
|
|
1046
|
+
}
|
|
1047
|
+
// ── summaries (Tier 1, item 3) ────────────────────────────────────────────
|
|
1048
|
+
/** Distinct neighbour nodes of a node (via the adjacency index). */
|
|
1049
|
+
neighborNodes(id) {
|
|
1050
|
+
const out = [];
|
|
1051
|
+
const seen = new Set();
|
|
1052
|
+
for (const e of this.adjacency.get(id) || []) {
|
|
1053
|
+
const other = e.from === id ? e.to : e.from;
|
|
1054
|
+
if (seen.has(other)) {
|
|
1055
|
+
continue;
|
|
1056
|
+
}
|
|
1057
|
+
seen.add(other);
|
|
1058
|
+
const nn = this.nodes.get(other);
|
|
1059
|
+
if (nn) {
|
|
1060
|
+
out.push(nn);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
return out;
|
|
1064
|
+
}
|
|
1065
|
+
/** `parent_of` children of a node (this node as the parent). */
|
|
1066
|
+
childrenOf(id) {
|
|
1067
|
+
const out = [];
|
|
1068
|
+
for (const e of this.adjacency.get(id) || []) {
|
|
1069
|
+
if (e.type === 'parent_of' && e.from === id) {
|
|
1070
|
+
const c = this.nodes.get(e.to);
|
|
1071
|
+
if (c) {
|
|
1072
|
+
out.push(c);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
return out;
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* DETERMINISTIC one-line rollup for an interior/hub node with NO authored
|
|
1080
|
+
* summary — NO LLM call. Synthesised from structure: child (or, absent
|
|
1081
|
+
* `parent_of` children, neighbour) type counts + the top child names by degree.
|
|
1082
|
+
* Returns undefined for a leaf/low-degree node (nothing to summarise).
|
|
1083
|
+
*/
|
|
1084
|
+
rollupSummary(id) {
|
|
1085
|
+
let kids = this.childrenOf(id);
|
|
1086
|
+
if (!kids.length) {
|
|
1087
|
+
if (this.degree(id) < 3) {
|
|
1088
|
+
return undefined;
|
|
1089
|
+
} // a leaf — no structure to roll up
|
|
1090
|
+
kids = this.neighborNodes(id);
|
|
1091
|
+
}
|
|
1092
|
+
if (!kids.length) {
|
|
1093
|
+
return undefined;
|
|
1094
|
+
}
|
|
1095
|
+
const counts = new Map();
|
|
1096
|
+
for (const k of kids) {
|
|
1097
|
+
counts.set(k.type, (counts.get(k.type) || 0) + 1);
|
|
1098
|
+
}
|
|
1099
|
+
const countStr = [...counts.entries()]
|
|
1100
|
+
.sort((a, b) => b[1] - a[1])
|
|
1101
|
+
.map(([t, c]) => `${c} ${t}`)
|
|
1102
|
+
.join(', ');
|
|
1103
|
+
const top = [...kids]
|
|
1104
|
+
.sort((a, b) => this.degree(b.id) - this.degree(a.id))
|
|
1105
|
+
.slice(0, 3)
|
|
1106
|
+
.map((k) => k.name);
|
|
1107
|
+
return `${countStr} · top: ${top.join(', ')}`;
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* The summary to SHOW for a node: the authored one if present, else the
|
|
1111
|
+
* deterministic structural rollup (flagged synthesized), else nothing.
|
|
1112
|
+
*/
|
|
1113
|
+
effectiveSummary(node) {
|
|
1114
|
+
if (node.summary && node.summary.trim()) {
|
|
1115
|
+
return { text: node.summary.trim(), synthesized: false };
|
|
1116
|
+
}
|
|
1117
|
+
const roll = this.rollupSummary(node.id);
|
|
1118
|
+
return roll ? { text: roll, synthesized: true } : undefined;
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* An AUTHORED summary is stale when the node has been superseded, or gained a
|
|
1122
|
+
* `parent_of` child AFTER the summary was written (`props.summaryAt`). Nodes
|
|
1123
|
+
* with no authored summary are "unsummarized", not stale.
|
|
1124
|
+
*/
|
|
1125
|
+
summaryStale(node) {
|
|
1126
|
+
if (!node.summary || !node.summary.trim()) {
|
|
1127
|
+
return false;
|
|
1128
|
+
}
|
|
1129
|
+
if (node.supersededBy) {
|
|
1130
|
+
return true;
|
|
1131
|
+
}
|
|
1132
|
+
const at = Date.parse(String(node.props?.summaryAt ?? node.updatedAt));
|
|
1133
|
+
if (!isFinite(at)) {
|
|
1134
|
+
return false;
|
|
1135
|
+
}
|
|
1136
|
+
for (const e of this.adjacency.get(node.id) || []) {
|
|
1137
|
+
if (e.type === 'parent_of' && e.from === node.id && Date.parse(e.createdAt) > at) {
|
|
1138
|
+
return true;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
return false;
|
|
1142
|
+
}
|
|
1143
|
+
mapEntry(node) {
|
|
1144
|
+
const es = this.effectiveSummary(node);
|
|
1145
|
+
return {
|
|
1146
|
+
id: node.id, type: node.type, name: node.name,
|
|
1147
|
+
summary: es?.text, synthesized: es?.synthesized,
|
|
1148
|
+
stale: this.summaryStale(node) || undefined,
|
|
1149
|
+
degree: this.degree(node.id),
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
// ── cold-start anchor index (Tier 1, item 2) ───────────────────────────────
|
|
1153
|
+
/**
|
|
1154
|
+
* "Where do I start" for an agent with no id/name in hand. Ranks entities (and
|
|
1155
|
+
* any node when `focusType` unset) by degree() over the adjacency index, and
|
|
1156
|
+
* bundles the open `question`s, freshest `decision`s, and live `contradicts`
|
|
1157
|
+
* pairs it already knows how to find. Purely read-side; render via graphRender.
|
|
1158
|
+
*/
|
|
1159
|
+
map(opts) {
|
|
1160
|
+
this.reloadIfChanged();
|
|
1161
|
+
const topK = Math.max(1, Math.min(50, opts?.maxChildren ?? 10));
|
|
1162
|
+
const focus = opts?.focusType ? String(opts.focusType).toLowerCase() : undefined;
|
|
1163
|
+
const live = [...this.nodes.values()].filter((n) => !n.supersededBy);
|
|
1164
|
+
const hubs = live
|
|
1165
|
+
.filter((n) => this.degree(n.id) > 0 && (!focus || n.type === focus))
|
|
1166
|
+
.sort((a, b) => this.degree(b.id) - this.degree(a.id) || (b.updatedAt || '').localeCompare(a.updatedAt || ''))
|
|
1167
|
+
.slice(0, topK)
|
|
1168
|
+
.map((n) => this.mapEntry(n));
|
|
1169
|
+
const freshest = (t) => live
|
|
1170
|
+
.filter((n) => n.type === t)
|
|
1171
|
+
.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || ''))
|
|
1172
|
+
.slice(0, topK)
|
|
1173
|
+
.map((n) => this.mapEntry(n));
|
|
1174
|
+
// live (unsuperseded on both ends) contradicts pairs, deduped
|
|
1175
|
+
const seenPair = new Set();
|
|
1176
|
+
const contradictions = [];
|
|
1177
|
+
for (const e of this.edges.values()) {
|
|
1178
|
+
if (e.type !== 'contradicts') {
|
|
1179
|
+
continue;
|
|
1180
|
+
}
|
|
1181
|
+
const a = this.nodes.get(e.from);
|
|
1182
|
+
const b = this.nodes.get(e.to);
|
|
1183
|
+
if (!a || !b || a.supersededBy || b.supersededBy) {
|
|
1184
|
+
continue;
|
|
1185
|
+
}
|
|
1186
|
+
const key = [a.id, b.id].sort().join('|');
|
|
1187
|
+
if (seenPair.has(key)) {
|
|
1188
|
+
continue;
|
|
1189
|
+
}
|
|
1190
|
+
seenPair.add(key);
|
|
1191
|
+
contradictions.push({ a: { id: a.id, name: a.name }, b: { id: b.id, name: b.name } });
|
|
1192
|
+
if (contradictions.length >= topK) {
|
|
1193
|
+
break;
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
const pinned = this.pinnedNodes().slice(0, topK).map((n) => this.mapEntry(n));
|
|
1197
|
+
return { pinned, hubs, questions: freshest('question'), decisions: freshest('decision'), contradictions };
|
|
1198
|
+
}
|
|
1199
|
+
// ── pinned "core" tier (Tier 2, item 4) ────────────────────────────────────
|
|
1200
|
+
/** A node is pinned by `props.pinned===true` or by the reserved `props.area==='core'`. */
|
|
1201
|
+
isPinnedNode(n) {
|
|
1202
|
+
const p = (n.props || {});
|
|
1203
|
+
return p.pinned === true || p.area === 'core';
|
|
1204
|
+
}
|
|
1205
|
+
/** Live pinned nodes (project invariants) — always prepended within budget. */
|
|
1206
|
+
pinnedNodes() {
|
|
1207
|
+
this.reloadIfChanged();
|
|
1208
|
+
return [...this.nodes.values()]
|
|
1209
|
+
.filter((n) => !n.supersededBy && this.isPinnedNode(n))
|
|
1210
|
+
.sort((a, b) => this.degree(b.id) - this.degree(a.id) || (b.updatedAt || '').localeCompare(a.updatedAt || ''));
|
|
1211
|
+
}
|
|
1212
|
+
/** Set/clear the pin flag on a node (upsert; the node stays otherwise unchanged). */
|
|
1213
|
+
pin(idOrName, pinned = true) {
|
|
1214
|
+
this.reloadIfChanged();
|
|
1215
|
+
const n = this.getNode(idOrName);
|
|
1216
|
+
if (!n) {
|
|
1217
|
+
throw new GraphInvariantError(`${pinned ? 'pin' : 'unpin'}: node not found: ${idOrName}`);
|
|
1218
|
+
}
|
|
1219
|
+
const { node } = this.addNodeInternal({ type: n.type, name: n.name, id: n.id, props: { ...(n.props || {}), pinned } }, { scaffold: false });
|
|
1220
|
+
return node;
|
|
1221
|
+
}
|
|
1222
|
+
// ── reasoning-based best-first retrieval (Tier 1, item 4) ──────────────────
|
|
1223
|
+
/**
|
|
1224
|
+
* Vectorless, explainable, CITABLE retrieval — the PageIndex centrepiece done
|
|
1225
|
+
* without embeddings and without a server-side model (the calling agent is the
|
|
1226
|
+
* reasoner). Seed with the item-1 ranked query on `intent`, then best-first
|
|
1227
|
+
* expand a relevance-priority frontier: pop the best node, expand 1 hop
|
|
1228
|
+
* (strong provenance edges — supports/derived_from/produced/evaluates/
|
|
1229
|
+
* depends_on/parent_of — before weak relates_to/mentions), score newly-found
|
|
1230
|
+
* nodes against the intent, push. Stop at node_budget. Each returned row cites
|
|
1231
|
+
* `[id]` with the matched terms and the edge-path from its seed.
|
|
1232
|
+
*/
|
|
1233
|
+
search(opts) {
|
|
1234
|
+
this.reloadIfChanged();
|
|
1235
|
+
const budget = Math.max(1, Math.min(60, opts.nodeBudget ?? 12));
|
|
1236
|
+
const hops = Math.max(1, Math.min(4, opts.hops ?? 2));
|
|
1237
|
+
const intent = String(opts.intent || '');
|
|
1238
|
+
const terms = Array.from(new Set(tokenize(intent)));
|
|
1239
|
+
const idf = this.idf();
|
|
1240
|
+
const rows = new Map();
|
|
1241
|
+
const frontier = [];
|
|
1242
|
+
const seedK = Math.min(budget, Math.max(3, Math.ceil(budget / 2)));
|
|
1243
|
+
for (const s of this.scoredQuery({ text: intent, mode: 'relevance', limit: seedK })) {
|
|
1244
|
+
rows.set(s.node.id, { node: s.node, score: s.score, matched: s.matched, seedId: s.node.id, depth: 0, path: [] });
|
|
1245
|
+
frontier.push({ id: s.node.id, score: s.score, depth: 0 });
|
|
1246
|
+
}
|
|
1247
|
+
while (frontier.length && rows.size < budget) {
|
|
1248
|
+
frontier.sort((a, b) => b.score - a.score);
|
|
1249
|
+
const cur = frontier.shift();
|
|
1250
|
+
if (cur.depth >= hops) {
|
|
1251
|
+
continue;
|
|
1252
|
+
}
|
|
1253
|
+
const curRec = rows.get(cur.id);
|
|
1254
|
+
if (!curRec) {
|
|
1255
|
+
continue;
|
|
1256
|
+
}
|
|
1257
|
+
const incident = [...(this.adjacency.get(cur.id) || [])]
|
|
1258
|
+
.sort((x, y) => (EDGE_STRENGTH[y.type] || 0) - (EDGE_STRENGTH[x.type] || 0));
|
|
1259
|
+
for (const e of incident) {
|
|
1260
|
+
if (rows.size >= budget) {
|
|
1261
|
+
break;
|
|
1262
|
+
}
|
|
1263
|
+
const otherId = e.from === cur.id ? e.to : e.from;
|
|
1264
|
+
if (rows.has(otherId)) {
|
|
1265
|
+
continue;
|
|
1266
|
+
}
|
|
1267
|
+
const other = this.nodes.get(otherId);
|
|
1268
|
+
if (!other || other.supersededBy) {
|
|
1269
|
+
continue;
|
|
1270
|
+
} // superseded hidden by default
|
|
1271
|
+
const { score: ts, matched } = this.scoreNode(other, terms, idf);
|
|
1272
|
+
const textScore = ts > 0 ? ts : SUBSTR_FLOOR * 0.5; // reachable-but-off-intent nodes stay citable, ranked low
|
|
1273
|
+
const final = textScore * (1 + W_RECENCY * this.recencyDecay(other.updatedAt) + W_DEGREE * Math.log1p(this.degree(otherId)));
|
|
1274
|
+
const dir = e.from === cur.id ? 'out' : 'in';
|
|
1275
|
+
const path = [...curRec.path, `${e.type}${dir === 'out' ? '→' : '←'} ${other.type}:${other.id}`];
|
|
1276
|
+
rows.set(otherId, { node: other, score: final, matched, seedId: curRec.seedId, depth: cur.depth + 1, path, via: { edge: e.type, fromId: cur.id, dir } });
|
|
1277
|
+
frontier.push({ id: otherId, score: final, depth: cur.depth + 1 });
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
const out = [...rows.values()]
|
|
1281
|
+
.sort((a, b) => b.score - a.score)
|
|
1282
|
+
.map((r) => {
|
|
1283
|
+
const es = this.effectiveSummary(r.node);
|
|
1284
|
+
return {
|
|
1285
|
+
id: r.node.id, type: r.node.type, name: r.node.name,
|
|
1286
|
+
score: r.score, matched: r.matched,
|
|
1287
|
+
summary: es?.text, synthesized: es?.synthesized,
|
|
1288
|
+
inference: r.node.inference,
|
|
1289
|
+
seedId: r.seedId, via: r.via, path: r.path,
|
|
1290
|
+
conflicts: this.contradictionsFor(r.node.id),
|
|
1291
|
+
};
|
|
1292
|
+
});
|
|
1293
|
+
return { intent, terms, budget, hops, rows: out };
|
|
1294
|
+
}
|
|
1295
|
+
// ── navigable table-of-contents (Tier 2, item 2) ───────────────────────────
|
|
1296
|
+
parentOfChildIds(id) {
|
|
1297
|
+
const out = [];
|
|
1298
|
+
for (const e of this.adjacency.get(id) || []) {
|
|
1299
|
+
if (e.type === 'parent_of' && e.from === id) {
|
|
1300
|
+
out.push(e.to);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
return out;
|
|
1304
|
+
}
|
|
1305
|
+
hasParentOfParent(id) {
|
|
1306
|
+
return (this.adjacency.get(id) || []).some((e) => e.type === 'parent_of' && e.to === id);
|
|
1307
|
+
}
|
|
1308
|
+
hasParentOfChild(id) {
|
|
1309
|
+
return (this.adjacency.get(id) || []).some((e) => e.type === 'parent_of' && e.from === id);
|
|
1310
|
+
}
|
|
1311
|
+
/** Connected components of `pool` over the given (undirected) edge types. */
|
|
1312
|
+
components(pool, edgeTypes) {
|
|
1313
|
+
const idset = new Set(pool.map((n) => n.id));
|
|
1314
|
+
const parent = new Map();
|
|
1315
|
+
const find = (x) => { while (parent.get(x) !== x) {
|
|
1316
|
+
parent.set(x, parent.get(parent.get(x)));
|
|
1317
|
+
x = parent.get(x);
|
|
1318
|
+
} return x; };
|
|
1319
|
+
for (const n of pool) {
|
|
1320
|
+
parent.set(n.id, n.id);
|
|
1321
|
+
}
|
|
1322
|
+
const allow = new Set(edgeTypes);
|
|
1323
|
+
for (const e of this.edges.values()) {
|
|
1324
|
+
if (!allow.has(e.type) || !idset.has(e.from) || !idset.has(e.to)) {
|
|
1325
|
+
continue;
|
|
1326
|
+
}
|
|
1327
|
+
const ra = find(e.from), rb = find(e.to);
|
|
1328
|
+
if (ra !== rb) {
|
|
1329
|
+
parent.set(ra, rb);
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
const groups = new Map();
|
|
1333
|
+
for (const n of pool) {
|
|
1334
|
+
const r = find(n.id);
|
|
1335
|
+
const g = groups.get(r);
|
|
1336
|
+
if (g) {
|
|
1337
|
+
g.push(n);
|
|
1338
|
+
}
|
|
1339
|
+
else {
|
|
1340
|
+
groups.set(r, [n]);
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
return [...groups.values()];
|
|
1344
|
+
}
|
|
1345
|
+
/**
|
|
1346
|
+
* Depth-bounded, summarized hierarchy (PageIndex-style "text-stripped overview →
|
|
1347
|
+
* drill"). The spine is `parent_of` (roots = entities with no incoming parent_of);
|
|
1348
|
+
* where that spine is sparse the uncovered remainder falls back to deterministic
|
|
1349
|
+
* clustering (by `props.area`, else connected components over relates_to/
|
|
1350
|
+
* depends_on). Each level shows the top `maxChildren` by degree and collapses the
|
|
1351
|
+
* rest to `+K more`. Summaries reuse {@link effectiveSummary}. Pass `root` to
|
|
1352
|
+
* expand one branch.
|
|
1353
|
+
*/
|
|
1354
|
+
toc(opts) {
|
|
1355
|
+
this.reloadIfChanged();
|
|
1356
|
+
const depth = Math.max(1, Math.min(6, opts?.depth ?? 2));
|
|
1357
|
+
const maxChildren = Math.max(1, Math.min(50, opts?.maxChildren ?? 8));
|
|
1358
|
+
const focus = opts?.focusType ? String(opts.focusType).toLowerCase() : undefined;
|
|
1359
|
+
const build = (n, d, seen) => {
|
|
1360
|
+
seen.add(n.id);
|
|
1361
|
+
let kids = this.parentOfChildIds(n.id)
|
|
1362
|
+
.map((id) => this.nodes.get(id))
|
|
1363
|
+
.filter((c) => !!c && !c.supersededBy && !seen.has(c.id));
|
|
1364
|
+
if (focus) {
|
|
1365
|
+
kids = kids.filter((c) => c.type === focus);
|
|
1366
|
+
}
|
|
1367
|
+
kids.sort((a, b) => this.degree(b.id) - this.degree(a.id) || (b.updatedAt || '').localeCompare(a.updatedAt || ''));
|
|
1368
|
+
const childCount = kids.length;
|
|
1369
|
+
const cap = d > 1 ? maxChildren : 0;
|
|
1370
|
+
const shown = kids.slice(0, cap);
|
|
1371
|
+
const children = shown.map((c) => build(c, d - 1, seen));
|
|
1372
|
+
const es = this.effectiveSummary(n);
|
|
1373
|
+
const more = childCount - shown.length;
|
|
1374
|
+
return { id: n.id, type: n.type, name: n.name, summary: es?.text, synthesized: es?.synthesized, childCount, children, moreChildren: more > 0 ? more : undefined };
|
|
1375
|
+
};
|
|
1376
|
+
if (opts?.root) {
|
|
1377
|
+
const r = this.getNode(opts.root);
|
|
1378
|
+
return r ? { roots: [build(r, depth, new Set())] } : { roots: [] };
|
|
1379
|
+
}
|
|
1380
|
+
const seen = new Set();
|
|
1381
|
+
const live = [...this.nodes.values()].filter((n) => !n.supersededBy);
|
|
1382
|
+
const spineRoots = live
|
|
1383
|
+
.filter((n) => this.hasParentOfChild(n.id) && !this.hasParentOfParent(n.id))
|
|
1384
|
+
.sort((a, b) => this.degree(b.id) - this.degree(a.id) || (b.updatedAt || '').localeCompare(a.updatedAt || ''));
|
|
1385
|
+
const roots = spineRoots.map((r) => build(r, depth, seen));
|
|
1386
|
+
// Fall back to clustering for significant nodes the spine didn't cover.
|
|
1387
|
+
const synthCluster = (id, label, members) => {
|
|
1388
|
+
const sorted = [...members].sort((a, b) => this.degree(b.id) - this.degree(a.id));
|
|
1389
|
+
const shown = sorted.slice(0, maxChildren);
|
|
1390
|
+
const children = shown.map((m) => build(m, 1, seen));
|
|
1391
|
+
const more = members.length - shown.length;
|
|
1392
|
+
return { id, type: 'cluster', name: label, summary: `${members.length} node(s)`, childCount: members.length, children, moreChildren: more > 0 ? more : undefined };
|
|
1393
|
+
};
|
|
1394
|
+
const remaining = live.filter((n) => !seen.has(n.id) && this.degree(n.id) > 0);
|
|
1395
|
+
const clusterRoots = [];
|
|
1396
|
+
if (remaining.length) {
|
|
1397
|
+
const byArea = new Map();
|
|
1398
|
+
const rest = [];
|
|
1399
|
+
for (const n of remaining) {
|
|
1400
|
+
const area = typeof n.props?.area === 'string' ? n.props.area : undefined;
|
|
1401
|
+
if (area) {
|
|
1402
|
+
const g = byArea.get(area);
|
|
1403
|
+
if (g) {
|
|
1404
|
+
g.push(n);
|
|
1405
|
+
}
|
|
1406
|
+
else {
|
|
1407
|
+
byArea.set(area, [n]);
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
else {
|
|
1411
|
+
rest.push(n);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
for (const [area, members] of byArea) {
|
|
1415
|
+
clusterRoots.push(synthCluster(`cluster:area:${slug(area)}`, `area: ${area}`, members));
|
|
1416
|
+
}
|
|
1417
|
+
for (const comp of this.components(rest, ['relates_to', 'depends_on'])) {
|
|
1418
|
+
if (comp.length === 1) {
|
|
1419
|
+
continue;
|
|
1420
|
+
} // a lone node with only foreign edges — not a cluster
|
|
1421
|
+
const rep = [...comp].sort((a, b) => this.degree(b.id) - this.degree(a.id))[0];
|
|
1422
|
+
clusterRoots.push(synthCluster(`cluster:${rep.id}`, `cluster: ${rep.name}`, comp));
|
|
1423
|
+
}
|
|
1424
|
+
clusterRoots.sort((a, b) => b.childCount - a.childCount);
|
|
1425
|
+
}
|
|
1426
|
+
return { roots: [...roots, ...clusterRoots], clustered: roots.length === 0 && clusterRoots.length > 0 };
|
|
1427
|
+
}
|
|
1428
|
+
// ── hierarchical rollup / summary nodes (Tier 2, item 5) ────────────────────
|
|
1429
|
+
/** Deterministic id for a cluster's rollup node, so concurrent rollups upsert not duplicate. */
|
|
1430
|
+
rollupId(center) {
|
|
1431
|
+
const key = center.id.startsWith('entity:') ? center.id.slice('entity:'.length) : center.id.replace(/[^a-z0-9]+/gi, '-');
|
|
1432
|
+
return `summary:${key || 'x'}`;
|
|
1433
|
+
}
|
|
1434
|
+
/** parent_of descendants of a node (transitive), excluding itself. */
|
|
1435
|
+
descendants(id) {
|
|
1436
|
+
const out = new Set();
|
|
1437
|
+
const stack = [id];
|
|
1438
|
+
while (stack.length) {
|
|
1439
|
+
const cur = stack.pop();
|
|
1440
|
+
for (const cid of this.parentOfChildIds(cur)) {
|
|
1441
|
+
if (cid !== id && !out.has(cid)) {
|
|
1442
|
+
out.add(cid);
|
|
1443
|
+
stack.push(cid);
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
return [...out];
|
|
1448
|
+
}
|
|
1449
|
+
rollupFromMembers(memberIds) {
|
|
1450
|
+
const counts = new Map();
|
|
1451
|
+
for (const mid of memberIds) {
|
|
1452
|
+
const n = this.nodes.get(mid);
|
|
1453
|
+
if (n) {
|
|
1454
|
+
counts.set(n.type, (counts.get(n.type) || 0) + 1);
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
const countStr = [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([t, c]) => `${c} ${t}`).join(', ');
|
|
1458
|
+
const top = memberIds.map((id) => this.nodes.get(id)).filter((n) => !!n)
|
|
1459
|
+
.sort((a, b) => this.degree(b.id) - this.degree(a.id)).slice(0, 3).map((n) => n.name);
|
|
1460
|
+
return `${memberIds.length} member(s): ${countStr}${top.length ? ` · top: ${top.join(', ')}` : ''}`;
|
|
1461
|
+
}
|
|
1462
|
+
/** The rollup/summary node for a cluster centre, if one has been created. */
|
|
1463
|
+
rollupNodeFor(idOrName) {
|
|
1464
|
+
this.reloadIfChanged();
|
|
1465
|
+
const c = this.getNode(idOrName);
|
|
1466
|
+
if (!c) {
|
|
1467
|
+
return undefined;
|
|
1468
|
+
}
|
|
1469
|
+
return this.nodes.get(this.rollupId(c));
|
|
1470
|
+
}
|
|
1471
|
+
/**
|
|
1472
|
+
* Create or upsert a hierarchical rollup for a cluster (item 5). The cluster is a
|
|
1473
|
+
* centre entity's `parent_of` descendants, or — absent a spine — its N-hop
|
|
1474
|
+
* neighbourhood. The rollup is a `note` with `props.rollup=true` and a
|
|
1475
|
+
* DETERMINISTIC id (`summary:<centre-key>`) so concurrent rollups of the same
|
|
1476
|
+
* cluster upsert rather than duplicate; it links `derived_from → each member`
|
|
1477
|
+
* (members stay fully addressable in `props.members`) and hangs under the centre
|
|
1478
|
+
* via `parent_of` so it's discoverable in the TOC. Summary text is agent-supplied
|
|
1479
|
+
* or the deterministic Tier-1 rollup (no LLM on the default path).
|
|
1480
|
+
*/
|
|
1481
|
+
rollup(opts) {
|
|
1482
|
+
this.reloadIfChanged();
|
|
1483
|
+
const center = opts.idOrName ? this.getNode(opts.idOrName) : undefined;
|
|
1484
|
+
if (!center) {
|
|
1485
|
+
throw new GraphInvariantError(`graph_rollup: centre node not found: ${opts.idOrName ?? ''}`);
|
|
1486
|
+
}
|
|
1487
|
+
const id = this.rollupId(center);
|
|
1488
|
+
let members = this.descendants(center.id);
|
|
1489
|
+
if (!members.length) {
|
|
1490
|
+
const hops = Math.max(1, Math.min(3, opts.hops ?? 1));
|
|
1491
|
+
const res = this.neighbors({ idOrName: center.id, hops, limit: 500 });
|
|
1492
|
+
members = (res ? res.nodes : []).map((n) => n.id).filter((mid) => mid !== center.id);
|
|
1493
|
+
}
|
|
1494
|
+
members = members.filter((mid) => mid !== id && !mid.startsWith('summary:'));
|
|
1495
|
+
const summaryText = (opts.summary && opts.summary.trim())
|
|
1496
|
+
? opts.summary.trim()
|
|
1497
|
+
: (this.rollupSummary(center.id) || this.rollupFromMembers(members));
|
|
1498
|
+
const { node, created } = this.addNodeInternal({
|
|
1499
|
+
type: 'note', id, name: `Rollup: ${center.name}`,
|
|
1500
|
+
summary: summaryText,
|
|
1501
|
+
props: { rollup: true, of: center.id, members },
|
|
1502
|
+
}, { scaffold: false });
|
|
1503
|
+
this.linkParentOf(center.id, id);
|
|
1504
|
+
for (const m of members) {
|
|
1505
|
+
if (m === id || !this.nodes.has(m)) {
|
|
1506
|
+
continue;
|
|
1507
|
+
}
|
|
1508
|
+
if (!this.hasEdge('derived_from', id, m)) {
|
|
1509
|
+
this.addEdge({ type: 'derived_from', from: id, to: m });
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
return { node, members, created };
|
|
365
1513
|
}
|
|
366
1514
|
stats() {
|
|
367
1515
|
this.reloadIfChanged();
|
|
368
1516
|
const nodesByType = {};
|
|
369
1517
|
const edgesByType = {};
|
|
370
|
-
let superseded = 0, openQuestions = 0;
|
|
371
|
-
const touched = new Set();
|
|
1518
|
+
let superseded = 0, openQuestions = 0, estTokens = 0;
|
|
372
1519
|
for (const e of this.edges.values()) {
|
|
373
1520
|
edgesByType[e.type] = (edgesByType[e.type] || 0) + 1;
|
|
374
|
-
|
|
375
|
-
touched.add(e.to);
|
|
1521
|
+
estTokens += estimateTokens(`${e.type}${e.from}${e.to}${e.source ?? ''}`);
|
|
376
1522
|
}
|
|
377
|
-
|
|
1523
|
+
// Types that benefit most from a summary (item 3).
|
|
1524
|
+
const SUMMARY_TYPES = new Set(['entity', 'decision', 'question', 'artifact', 'agent_run']);
|
|
1525
|
+
let isolated = 0, unsummarized = 0, staleSummaries = 0, spinelessHubs = 0, pinned = 0;
|
|
378
1526
|
for (const n of this.nodes.values()) {
|
|
379
1527
|
nodesByType[n.type] = (nodesByType[n.type] || 0) + 1;
|
|
380
1528
|
if (n.supersededBy) {
|
|
@@ -383,14 +1531,42 @@ class GraphStore {
|
|
|
383
1531
|
if (n.type === 'question' && !n.supersededBy) {
|
|
384
1532
|
openQuestions++;
|
|
385
1533
|
}
|
|
386
|
-
if (
|
|
1534
|
+
if (this.degree(n.id) === 0 && !n.supersededBy) {
|
|
387
1535
|
isolated++;
|
|
388
1536
|
}
|
|
1537
|
+
const hasSummary = !!(n.summary && n.summary.trim());
|
|
1538
|
+
if (!n.supersededBy && SUMMARY_TYPES.has(n.type) && !hasSummary) {
|
|
1539
|
+
unsummarized++;
|
|
1540
|
+
}
|
|
1541
|
+
if (hasSummary && this.summaryStale(n)) {
|
|
1542
|
+
staleSummaries++;
|
|
1543
|
+
}
|
|
1544
|
+
if (!n.supersededBy && this.isPinnedNode(n)) {
|
|
1545
|
+
pinned++;
|
|
1546
|
+
}
|
|
1547
|
+
// A hub with children (degree≥3) but NO parent_of spine — nudge to scaffold the tree (item 1).
|
|
1548
|
+
if (!n.supersededBy && n.type === 'entity' && this.degree(n.id) >= 3
|
|
1549
|
+
&& !this.hasParentOfChild(n.id) && !this.hasParentOfParent(n.id)) {
|
|
1550
|
+
spinelessHubs++;
|
|
1551
|
+
}
|
|
1552
|
+
estTokens += estimateTokens(`${n.name}${n.summary ?? ''}${n.body ?? ''}`);
|
|
389
1553
|
}
|
|
390
1554
|
const contradictions = edgesByType['contradicts'] || 0;
|
|
1555
|
+
const live = this.nodes.size + this.edges.size;
|
|
1556
|
+
const deadWeight = Math.max(0, this.appliedOps - live);
|
|
391
1557
|
return {
|
|
392
1558
|
nodeCount: this.nodes.size, edgeCount: this.edges.size,
|
|
393
1559
|
nodesByType, edgesByType, superseded, isolated, contradictions, openQuestions,
|
|
1560
|
+
unsummarized, staleSummaries,
|
|
1561
|
+
spinelessHubs, pinned,
|
|
1562
|
+
fileBytes: this.lastSize < 0 ? 0 : this.lastSize,
|
|
1563
|
+
totalOps: this.appliedOps,
|
|
1564
|
+
deadWeight,
|
|
1565
|
+
deadWeightRatio: this.appliedOps ? deadWeight / this.appliedOps : 0,
|
|
1566
|
+
lastReplayMs: this.lastReplayMs,
|
|
1567
|
+
estTokens,
|
|
1568
|
+
parseFailures: this.parseFailures,
|
|
1569
|
+
tornFinalLine: this.lastOffset < this.lastSize,
|
|
394
1570
|
};
|
|
395
1571
|
}
|
|
396
1572
|
}
|