understanding-graph 0.1.0 → 0.1.2
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/README.md +1 -1
- package/bin/cli.js +2 -2
- package/node_modules/@understanding-graph/core/dist/services/DocumentWriter.d.ts.map +1 -1
- package/node_modules/@understanding-graph/core/dist/services/DocumentWriter.js +48 -7
- package/node_modules/@understanding-graph/core/dist/services/DocumentWriter.js.map +1 -1
- package/node_modules/@understanding-graph/core/package.json +1 -1
- package/node_modules/@understanding-graph/core/src/services/DocumentWriter.ts +56 -7
- package/node_modules/@understanding-graph/mcp-server/dist/tools/batch.d.ts.map +1 -1
- package/node_modules/@understanding-graph/mcp-server/dist/tools/batch.js +405 -229
- package/node_modules/@understanding-graph/mcp-server/dist/tools/batch.js.map +1 -1
- package/node_modules/@understanding-graph/mcp-server/dist/tools/solvers.js +1 -1
- package/node_modules/@understanding-graph/mcp-server/dist/tools/solvers.js.map +1 -1
- package/node_modules/@understanding-graph/mcp-server/package.json +1 -1
- package/package.json +1 -1
- package/packages/core/dist/services/DocumentWriter.d.ts.map +1 -1
- package/packages/core/dist/services/DocumentWriter.js +48 -7
- package/packages/core/dist/services/DocumentWriter.js.map +1 -1
- package/packages/core/package.json +1 -1
- package/packages/mcp-server/dist/tools/batch.d.ts.map +1 -1
- package/packages/mcp-server/dist/tools/batch.js +405 -229
- package/packages/mcp-server/dist/tools/batch.js.map +1 -1
- package/packages/mcp-server/dist/tools/solvers.js +1 -1
- package/packages/mcp-server/dist/tools/solvers.js.map +1 -1
- package/packages/mcp-server/package.json +1 -1
- package/server.json +2 -2
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import { createCommit, createDocumentWriter, getGraphStore, } from '@understanding-graph/core';
|
|
2
|
+
import { createCommit, createDocumentWriter, getGraphStore, sqlite, } from '@understanding-graph/core';
|
|
3
3
|
import { handleToolCall } from './index.js';
|
|
4
|
+
// Sentinel error class used by handleBatchTools to bubble an
|
|
5
|
+
// "intentional early return with payload" out of a transactional block
|
|
6
|
+
// and force the surrounding catch handler to ROLLBACK before returning
|
|
7
|
+
// the carried payload. We can't `return` directly from inside the
|
|
8
|
+
// transaction without also calling COMMIT, so we throw and recover.
|
|
9
|
+
class BatchEarlyExit extends Error {
|
|
10
|
+
payload;
|
|
11
|
+
constructor(payload) {
|
|
12
|
+
super('__batch_early_exit__');
|
|
13
|
+
this.payload = payload;
|
|
14
|
+
this.name = 'BatchEarlyExit';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
4
17
|
// Tools that create nodes and need immediate embedding generation
|
|
5
18
|
const NODE_CREATING_TOOLS = [
|
|
6
19
|
'graph_add_concept',
|
|
@@ -21,13 +34,23 @@ const DOC_MUTATION_TOOLS = [
|
|
|
21
34
|
export const batchTools = [
|
|
22
35
|
{
|
|
23
36
|
name: 'graph_batch',
|
|
24
|
-
description: `Execute multiple graph operations
|
|
37
|
+
description: `Execute multiple graph operations as an ATOMIC COMMIT.
|
|
38
|
+
|
|
39
|
+
The entire batch is wrapped in a SQLite transaction. If any operation
|
|
40
|
+
fails, the entire batch is rolled back as if it never ran — there is no
|
|
41
|
+
half-state to clean up. This is the system's "Atomic Commit" primitive
|
|
42
|
+
from the paper: it lets you branch and revert cognitive states cleanly.
|
|
43
|
+
|
|
44
|
+
The required commit_message becomes the Origin Story attached to every
|
|
45
|
+
node and edge created in the batch. Future agents reading those nodes see
|
|
46
|
+
the intent that created them, not just the content. The commit stream is
|
|
47
|
+
the metacognitive log of how the graph evolved.
|
|
25
48
|
|
|
26
49
|
Use for:
|
|
27
50
|
- Document splitting (create children + clear parent)
|
|
28
51
|
- Creating hierarchies (root + multiple children)
|
|
29
52
|
- Bulk concept/connection creation
|
|
30
|
-
- Any multi-step
|
|
53
|
+
- Any multi-step modification that should land all-or-nothing
|
|
31
54
|
|
|
32
55
|
EDGE QUALITY REMINDER: When using graph_connect, edges should aid cognition — help future agents think better. Don't create edges for bureaucracy or "completeness". Each edge should answer: "When reasoning about X, why should I consider Y?"
|
|
33
56
|
|
|
@@ -136,76 +159,192 @@ function resolveReferences(params, results) {
|
|
|
136
159
|
}
|
|
137
160
|
const DEFAULT_DUPLICATE_THRESHOLD = 0.8;
|
|
138
161
|
/**
|
|
139
|
-
* PRE-VALIDATION:
|
|
140
|
-
*
|
|
162
|
+
* PRE-VALIDATION: every new concept created in this batch must be reachable
|
|
163
|
+
* (transitively) to a node that already exists in the graph.
|
|
164
|
+
*
|
|
165
|
+
* Three things this check has to honor that an earlier version got wrong:
|
|
166
|
+
*
|
|
167
|
+
* 1. **Title-based references work at runtime.** The graph_connect tool
|
|
168
|
+
* resolves `from`/`to` via contextManager.resolveNodeWithSuggestions,
|
|
169
|
+
* which accepts EITHER an id OR a literal title. The pre-check used
|
|
170
|
+
* to only recognize ids and `$N.id` back-refs, so a perfectly valid
|
|
171
|
+
* title-based connect was rejected as orphaning.
|
|
141
172
|
*
|
|
142
|
-
*
|
|
173
|
+
* 2. **Transitive reachability is allowed.** A common batch pattern is:
|
|
174
|
+
* op0: add concept A
|
|
175
|
+
* op1: add concept B
|
|
176
|
+
* op2: connect A → existing
|
|
177
|
+
* op3: connect B → A
|
|
178
|
+
* B is anchored to the existing graph through A. The pre-check used
|
|
179
|
+
* to require every new concept to be DIRECTLY adjacent to an existing
|
|
180
|
+
* node, which forbade this pattern.
|
|
181
|
+
*
|
|
182
|
+
* 3. **The error label needs to be the actual title.** Earlier code read
|
|
183
|
+
* params.name (which graph_add_concept doesn't define) and always fell
|
|
184
|
+
* through to "operation N", which made the error useless.
|
|
185
|
+
*
|
|
186
|
+
* Algorithm: compute the set of all nodes reachable to/from the existing
|
|
187
|
+
* graph via the union of new connect operations, then any new concept not
|
|
188
|
+
* in that reachable set is orphaned.
|
|
143
189
|
*/
|
|
144
190
|
function validateNoOrphans(operations) {
|
|
145
191
|
const store = getGraphStore();
|
|
146
192
|
const { nodes: existingNodes } = store.getAll();
|
|
147
193
|
const existingNodeIds = new Set(existingNodes.map((n) => n.id));
|
|
148
|
-
|
|
194
|
+
const existingNodeTitles = new Set(existingNodes.map((n) => n.title).filter(Boolean));
|
|
195
|
+
// Extract the title (or question) a node-creating op will produce.
|
|
196
|
+
// Different node-creating tools use different schemas:
|
|
197
|
+
// graph_add_concept -> params.title
|
|
198
|
+
// graph_question -> params.question
|
|
199
|
+
// graph_supersede -> params.new_name (the replacement concept's name)
|
|
200
|
+
// graph_serendipity -> params.title
|
|
201
|
+
// graph_answer -> params.answer (creates an answer node)
|
|
202
|
+
const conceptToolName = (op) => {
|
|
203
|
+
if (op.tool === 'graph_question')
|
|
204
|
+
return op.params.question || '';
|
|
205
|
+
if (op.tool === 'graph_supersede')
|
|
206
|
+
return op.params.new_name || '';
|
|
207
|
+
if (op.tool === 'graph_answer')
|
|
208
|
+
return op.params.answer || '';
|
|
209
|
+
return op.params.title || op.params.name || '';
|
|
210
|
+
};
|
|
211
|
+
// Collect all node-creating ops and a per-op identifier set.
|
|
212
|
+
// Each new node has at least two valid handles: its title (if present) and
|
|
213
|
+
// its `$N.id` back-ref. We canonicalize on the title, but accept either.
|
|
149
214
|
const nodeCreatingOps = [];
|
|
150
215
|
for (let i = 0; i < operations.length; i++) {
|
|
151
216
|
const op = operations[i];
|
|
152
217
|
if (NODE_CREATING_TOOLS.includes(op.tool) && op.tool !== 'doc_create') {
|
|
153
218
|
nodeCreatingOps.push({
|
|
154
219
|
index: i,
|
|
155
|
-
|
|
220
|
+
title: conceptToolName(op),
|
|
221
|
+
idRef: `$${i}.id`,
|
|
156
222
|
});
|
|
157
223
|
}
|
|
158
224
|
}
|
|
159
|
-
//
|
|
225
|
+
// Empty-graph exemption: the very first concept in a previously-empty
|
|
226
|
+
// graph is allowed to land without an edge. The post-execution sweep
|
|
227
|
+
// honors the same exemption.
|
|
160
228
|
if (existingNodes.length === 0 && nodeCreatingOps.length > 0) {
|
|
161
|
-
// Allow first node, but subsequent nodes in this batch must still connect
|
|
162
|
-
// to something (either existing or the first node via $0.id reference)
|
|
163
229
|
return { valid: true };
|
|
164
230
|
}
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
231
|
+
// Build a name → set-of-aliases map so we can normalize references.
|
|
232
|
+
// Both the title and the $N.id form refer to the same logical new node.
|
|
233
|
+
const aliasOf = new Map(); // any handle → canonical key
|
|
234
|
+
for (const op of nodeCreatingOps) {
|
|
235
|
+
const canonical = op.title || op.idRef; // prefer title, fall back to idRef
|
|
236
|
+
if (op.title)
|
|
237
|
+
aliasOf.set(op.title, canonical);
|
|
238
|
+
aliasOf.set(op.idRef, canonical);
|
|
239
|
+
}
|
|
240
|
+
const newCanonicalKeys = new Set(nodeCreatingOps.map((op) => op.title || op.idRef));
|
|
241
|
+
// Seed the reachable set with everything that already exists.
|
|
242
|
+
// Existing nodes are referenced by either id or title at the agent layer;
|
|
243
|
+
// both forms are valid anchors.
|
|
244
|
+
const reachable = new Set(); // canonical handles known to be anchored
|
|
245
|
+
const ANCHOR = '__existing__';
|
|
246
|
+
reachable.add(ANCHOR);
|
|
247
|
+
// Resolve any handle (title, id, or $N.id) into a canonical key.
|
|
248
|
+
// Returns ANCHOR for handles that point to an existing graph node, the
|
|
249
|
+
// canonical new-node key for handles that point to a new node in this
|
|
250
|
+
// batch, or null for unknown handles (treated as a forward-reference to
|
|
251
|
+
// some existing node we don't have in our pre-check view, which is rare
|
|
252
|
+
// but possible if the agent passes a literal id we haven't observed).
|
|
253
|
+
const canonicalize = (handle) => {
|
|
254
|
+
if (!handle)
|
|
255
|
+
return null;
|
|
256
|
+
if (existingNodeIds.has(handle) || existingNodeTitles.has(handle))
|
|
257
|
+
return ANCHOR;
|
|
258
|
+
if (aliasOf.has(handle))
|
|
259
|
+
return aliasOf.get(handle) ?? null;
|
|
260
|
+
// Unknown handle. Could be a doc_create $N.id back-ref to an earlier op
|
|
261
|
+
// in the same batch — those are treated as anchors (doc roots are
|
|
262
|
+
// structural).
|
|
263
|
+
if (handle.startsWith('$')) {
|
|
264
|
+
const match = handle.match(/^\$(\d+)\.(\w+)$/);
|
|
265
|
+
if (match) {
|
|
266
|
+
const refIndex = Number.parseInt(match[1], 10);
|
|
267
|
+
if (refIndex < operations.length) {
|
|
268
|
+
const refOp = operations[refIndex];
|
|
269
|
+
if (refOp.tool === 'doc_create')
|
|
270
|
+
return ANCHOR;
|
|
200
271
|
}
|
|
201
272
|
}
|
|
202
273
|
}
|
|
203
|
-
|
|
274
|
+
return null;
|
|
275
|
+
};
|
|
276
|
+
// Build the adjacency list of the connect-graph for new nodes (undirected).
|
|
277
|
+
const adjacency = new Map();
|
|
278
|
+
const addEdge = (a, b) => {
|
|
279
|
+
if (!adjacency.has(a))
|
|
280
|
+
adjacency.set(a, new Set());
|
|
281
|
+
if (!adjacency.has(b))
|
|
282
|
+
adjacency.set(b, new Set());
|
|
283
|
+
adjacency.get(a).add(b);
|
|
284
|
+
adjacency.get(b).add(a);
|
|
285
|
+
};
|
|
286
|
+
for (const op of operations) {
|
|
287
|
+
if (op.tool !== 'graph_connect')
|
|
288
|
+
continue;
|
|
289
|
+
const fromHandle = (op.params.from || op.params.fromId);
|
|
290
|
+
const toHandle = (op.params.to || op.params.toId);
|
|
291
|
+
const fromKey = canonicalize(fromHandle);
|
|
292
|
+
const toKey = canonicalize(toHandle);
|
|
293
|
+
if (fromKey && toKey)
|
|
294
|
+
addEdge(fromKey, toKey);
|
|
295
|
+
}
|
|
296
|
+
// graph_supersede creates BOTH a new node (params.new_name) AND a structural
|
|
297
|
+
// supersedes-edge from that new node to the old node (params.old). The
|
|
298
|
+
// pre-check has to model that as: the new concept is automatically anchored
|
|
299
|
+
// through the old (existing) concept it supersedes. Without this, every
|
|
300
|
+
// valid supersede call would be flagged as orphaning the new node.
|
|
301
|
+
for (const op of operations) {
|
|
302
|
+
if (op.tool !== 'graph_supersede')
|
|
303
|
+
continue;
|
|
304
|
+
const oldHandle = op.params.old || '';
|
|
305
|
+
const newHandle = op.params.new_name || '';
|
|
306
|
+
const oldKey = canonicalize(oldHandle);
|
|
307
|
+
const newKey = canonicalize(newHandle);
|
|
308
|
+
if (oldKey && newKey)
|
|
309
|
+
addEdge(oldKey, newKey);
|
|
310
|
+
}
|
|
311
|
+
// graph_answer creates an answer node connected to the question node it
|
|
312
|
+
// resolves. The connectivity is implicit, just like supersede.
|
|
313
|
+
for (const op of operations) {
|
|
314
|
+
if (op.tool !== 'graph_answer')
|
|
315
|
+
continue;
|
|
316
|
+
const questionHandle = op.params.question || '';
|
|
317
|
+
const answerHandle = op.params.answer || '';
|
|
318
|
+
const questionKey = canonicalize(questionHandle);
|
|
319
|
+
const answerKey = canonicalize(answerHandle);
|
|
320
|
+
if (questionKey && answerKey)
|
|
321
|
+
addEdge(questionKey, answerKey);
|
|
322
|
+
}
|
|
323
|
+
// BFS from ANCHOR through the adjacency list.
|
|
324
|
+
const queue = [ANCHOR];
|
|
325
|
+
while (queue.length > 0) {
|
|
326
|
+
const node = queue.shift();
|
|
327
|
+
const neighbors = adjacency.get(node);
|
|
328
|
+
if (!neighbors)
|
|
329
|
+
continue;
|
|
330
|
+
for (const next of neighbors) {
|
|
331
|
+
if (!reachable.has(next)) {
|
|
332
|
+
reachable.add(next);
|
|
333
|
+
queue.push(next);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// Any new node whose canonical key isn't in `reachable` is orphaned.
|
|
338
|
+
for (const op of nodeCreatingOps) {
|
|
339
|
+
const canonical = op.title || op.idRef;
|
|
340
|
+
if (!reachable.has(canonical)) {
|
|
341
|
+
const label = op.title || `operation ${op.index}`;
|
|
204
342
|
return {
|
|
205
343
|
valid: false,
|
|
206
|
-
error: `Concept "${
|
|
207
|
-
`Every new concept
|
|
208
|
-
`Add a graph_connect
|
|
344
|
+
error: `Concept "${label}" (operation ${op.index}) would be orphaned. ` +
|
|
345
|
+
`Every new concept must reach an EXISTING node in the graph through at least one chain of graph_connect operations in this batch. ` +
|
|
346
|
+
`Add a graph_connect that links this concept (directly or via another new concept) to an existing node — ` +
|
|
347
|
+
`you can use the existing node's title (e.g. to: "Existing Concept Title"), its ID (e.g. to: "n_abc123"), or a back-ref to an earlier op in this batch (e.g. to: "$0.id").`,
|
|
209
348
|
};
|
|
210
349
|
}
|
|
211
350
|
}
|
|
@@ -336,135 +475,238 @@ export async function handleBatchTools(name, args, contextManager) {
|
|
|
336
475
|
return false;
|
|
337
476
|
}
|
|
338
477
|
})();
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
478
|
+
// Hoisted out of the try block below so the post-commit return statement
|
|
479
|
+
// can read them. `commit` is set inside the transaction, `hasErrors` is
|
|
480
|
+
// computed after the loop but still inside the transactional section.
|
|
481
|
+
let commit;
|
|
482
|
+
let hasErrors = false;
|
|
483
|
+
// ATOMICITY: wrap the entire batch in a SQLite transaction so that a
|
|
484
|
+
// mid-batch failure (op N throws, an orphan is detected, etc.) leaves
|
|
485
|
+
// the graph in EXACTLY the state it was in before the batch ran.
|
|
486
|
+
//
|
|
487
|
+
// The paper's "Atomic Commits" framing makes this load-bearing — agents
|
|
488
|
+
// need to be able to revert a failed reasoning step cleanly. The earlier
|
|
489
|
+
// version had no transaction wrapping; ops 1..N-1 were persisted when op
|
|
490
|
+
// N failed, and the manual cleanup only handled the special case of
|
|
491
|
+
// "orphaned concept nodes". Connected nodes from prior ops survived
|
|
492
|
+
// failures, so a half-finished commit could land in the graph.
|
|
493
|
+
//
|
|
494
|
+
// Implementation notes:
|
|
495
|
+
// * better-sqlite3's `db.transaction(fn)` requires fn to be sync.
|
|
496
|
+
// Our batch loop awaits the embedding service, so we use manual
|
|
497
|
+
// BEGIN/COMMIT/ROLLBACK instead. This is safe because the MCP server
|
|
498
|
+
// handles requests serially against a single per-project connection.
|
|
499
|
+
// * Early-return cases (mid-loop error with stopOnError, orphan-sweep
|
|
500
|
+
// failure) are signalled via the BatchEarlyExit sentinel so the
|
|
501
|
+
// catch block can ROLLBACK before returning the payload.
|
|
502
|
+
// * Doc auto-regeneration writes files (not the DB), so it happens
|
|
503
|
+
// AFTER commit. Score-hint computation is read-only, also after.
|
|
504
|
+
const txnDb = sqlite.getDb();
|
|
505
|
+
txnDb.exec('BEGIN');
|
|
506
|
+
let txnCommitted = false;
|
|
507
|
+
try {
|
|
508
|
+
for (let i = 0; i < operations.length; i++) {
|
|
509
|
+
const op = operations[i];
|
|
510
|
+
try {
|
|
511
|
+
// Resolve any variable references in params
|
|
512
|
+
const resolvedParams = resolveReferences(op.params, results);
|
|
513
|
+
// Pre-execution check: doc_create with parentId but no afterId
|
|
514
|
+
if (op.tool === 'doc_create' &&
|
|
515
|
+
resolvedParams.parentId &&
|
|
516
|
+
!resolvedParams.afterId &&
|
|
517
|
+
!allowUnorderedDocs) {
|
|
518
|
+
const store = getGraphStore();
|
|
519
|
+
const parentId = resolvedParams.parentId;
|
|
520
|
+
const existingSiblings = store.getChildren(parentId);
|
|
521
|
+
if (existingSiblings.length > 0) {
|
|
522
|
+
const siblingNames = existingSiblings.map((s) => s.title).join(', ');
|
|
523
|
+
throw new Error(`doc_create requires afterId when parent already has children. ` +
|
|
524
|
+
`Parent "${parentId}" has siblings: [${siblingNames}]. ` +
|
|
525
|
+
`Use afterId to specify position, or set allowUnorderedDocs: true to allow arbitrary ordering.`);
|
|
526
|
+
}
|
|
357
527
|
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
528
|
+
// Execute the tool
|
|
529
|
+
const result = await handleToolCall(op.tool, resolvedParams, contextManager);
|
|
530
|
+
results.push(result);
|
|
531
|
+
// Track affected node/edge IDs for commit
|
|
532
|
+
const resultObj = result;
|
|
533
|
+
if (resultObj.id && typeof resultObj.id === 'string') {
|
|
534
|
+
if (resultObj.id.startsWith('e_')) {
|
|
535
|
+
affectedEdgeIds.push(resultObj.id);
|
|
536
|
+
}
|
|
537
|
+
else if (resultObj.id.startsWith('n_')) {
|
|
538
|
+
affectedNodeIds.push(resultObj.id);
|
|
539
|
+
}
|
|
367
540
|
}
|
|
368
|
-
|
|
369
|
-
|
|
541
|
+
// Some tools return nodeId instead of id
|
|
542
|
+
if (resultObj.nodeId && typeof resultObj.nodeId === 'string') {
|
|
543
|
+
affectedNodeIds.push(resultObj.nodeId);
|
|
370
544
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
await store.generateAndStoreEmbedding(nodeId);
|
|
545
|
+
// graph_revise returns the updated node id
|
|
546
|
+
if (resultObj.newId && typeof resultObj.newId === 'string') {
|
|
547
|
+
affectedNodeIds.push(resultObj.newId);
|
|
548
|
+
}
|
|
549
|
+
// Generate embedding immediately for node-creating tools
|
|
550
|
+
// This ensures duplicate detection works within the same batch
|
|
551
|
+
if (NODE_CREATING_TOOLS.includes(op.tool)) {
|
|
552
|
+
const nodeId = result.id ||
|
|
553
|
+
result.newId;
|
|
554
|
+
if (nodeId && typeof nodeId === 'string') {
|
|
555
|
+
try {
|
|
556
|
+
const store = getGraphStore();
|
|
557
|
+
await store.generateAndStoreEmbedding(nodeId);
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
// Non-fatal: embedding generation can fail without breaking the batch
|
|
561
|
+
}
|
|
389
562
|
}
|
|
390
|
-
|
|
391
|
-
|
|
563
|
+
}
|
|
564
|
+
// Post-creation verification: doc_create nodes must trace to root
|
|
565
|
+
// This catches forward references that bypassed pre-validation
|
|
566
|
+
if (op.tool === 'doc_create') {
|
|
567
|
+
const resultObj = result;
|
|
568
|
+
const nodeId = resultObj.id;
|
|
569
|
+
const isRoot = resultObj.isDocRoot;
|
|
570
|
+
if (nodeId && !isRoot) {
|
|
571
|
+
const store = getGraphStore();
|
|
572
|
+
const docPath = store.getDocumentPath(nodeId);
|
|
573
|
+
if (!docPath) {
|
|
574
|
+
throw new Error(`Created document node "${nodeId}" does not trace to a document root. ` +
|
|
575
|
+
'This may indicate a broken forward reference or missing parentId.');
|
|
576
|
+
}
|
|
392
577
|
}
|
|
393
578
|
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
if (!docPath) {
|
|
405
|
-
throw new Error(`Created document node "${nodeId}" does not trace to a document root. ` +
|
|
406
|
-
'This may indicate a broken forward reference or missing parentId.');
|
|
579
|
+
// Track affected document roots for auto-regeneration
|
|
580
|
+
if (DOC_MUTATION_TOOLS.includes(op.tool)) {
|
|
581
|
+
const resultObj = result;
|
|
582
|
+
const nodeId = (resultObj.id || resultObj.nodeId);
|
|
583
|
+
if (nodeId) {
|
|
584
|
+
const store = getGraphStore();
|
|
585
|
+
const docPath = store.getDocumentPath(nodeId);
|
|
586
|
+
if (docPath && docPath.length > 0 && docPath[0].isDocRoot) {
|
|
587
|
+
affectedDocRoots.add(docPath[0].id);
|
|
588
|
+
}
|
|
407
589
|
}
|
|
408
590
|
}
|
|
409
591
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
592
|
+
catch (error) {
|
|
593
|
+
// Don't double-wrap our own sentinel
|
|
594
|
+
if (error instanceof BatchEarlyExit)
|
|
595
|
+
throw error;
|
|
596
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
597
|
+
errors.push({ index: i, tool: op.tool, error: errorMsg });
|
|
598
|
+
if (stopOnError) {
|
|
599
|
+
// The whole batch is in a SQLite transaction, so the outer catch
|
|
600
|
+
// will ROLLBACK and undo every prior op. No need for the manual
|
|
601
|
+
// orphan cleanup that the pre-transaction version had to do.
|
|
602
|
+
throw new BatchEarlyExit({
|
|
603
|
+
success: false,
|
|
604
|
+
completed: i,
|
|
605
|
+
total: operations.length,
|
|
606
|
+
results,
|
|
607
|
+
errors,
|
|
608
|
+
message: `Batch stopped at operation ${i} (${op.tool}): ${errorMsg}. Entire batch rolled back.`,
|
|
609
|
+
});
|
|
420
610
|
}
|
|
611
|
+
// Push null result for failed operation
|
|
612
|
+
results.push({ success: false, error: errorMsg });
|
|
421
613
|
}
|
|
422
614
|
}
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
615
|
+
hasErrors = errors.length > 0;
|
|
616
|
+
// ORPHAN PREVENTION: detect any concept nodes created in this batch that
|
|
617
|
+
// have no edges. Exception: if the graph was empty when this batch started,
|
|
618
|
+
// the very first concept node is allowed to remain unconnected — by
|
|
619
|
+
// definition there's nothing for it to connect to yet. This matches
|
|
620
|
+
// validateNoOrphans's pre-check exemption.
|
|
621
|
+
//
|
|
622
|
+
// Inside the transaction. If we find orphans, throw BatchEarlyExit so
|
|
623
|
+
// the catch block ROLLBACKs the entire batch (no half-state). The
|
|
624
|
+
// pre-transaction version manually archived the orphan nodes and left
|
|
625
|
+
// everything else committed; the transactional version is cleaner.
|
|
626
|
+
const sweepStore = getGraphStore();
|
|
627
|
+
const sweepEdges = sweepStore.getAll().edges;
|
|
628
|
+
const sweepConnectedIds = new Set();
|
|
629
|
+
for (const edge of sweepEdges) {
|
|
630
|
+
sweepConnectedIds.add(edge.fromId);
|
|
631
|
+
sweepConnectedIds.add(edge.toId);
|
|
632
|
+
}
|
|
633
|
+
const orphanedNodes = [];
|
|
634
|
+
let seedNodeAllowed = graphWasEmptyAtBatchStart;
|
|
635
|
+
for (const nodeId of affectedNodeIds) {
|
|
636
|
+
if (!nodeId.startsWith('n_'))
|
|
637
|
+
continue;
|
|
638
|
+
const node = sweepStore.getNode(nodeId);
|
|
639
|
+
if (!node)
|
|
640
|
+
continue;
|
|
641
|
+
// Skip document nodes (they have structural edges managed elsewhere)
|
|
642
|
+
if (node.fileType || node.isDocRoot)
|
|
643
|
+
continue;
|
|
644
|
+
// Check if node has any edges
|
|
645
|
+
if (!sweepConnectedIds.has(nodeId)) {
|
|
646
|
+
// Allow exactly one seed node when the batch started against an empty graph.
|
|
647
|
+
if (seedNodeAllowed) {
|
|
648
|
+
seedNodeAllowed = false;
|
|
649
|
+
continue;
|
|
449
650
|
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
651
|
+
orphanedNodes.push(nodeId);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
if (orphanedNodes.length > 0) {
|
|
655
|
+
const orphanNames = orphanedNodes
|
|
656
|
+
.map((id) => {
|
|
657
|
+
const r = results.find((r) => r?.id === id);
|
|
658
|
+
return r?.name || id;
|
|
659
|
+
})
|
|
660
|
+
.join(', ');
|
|
661
|
+
throw new BatchEarlyExit({
|
|
662
|
+
success: false,
|
|
663
|
+
completed: operations.length,
|
|
664
|
+
total: operations.length,
|
|
665
|
+
results,
|
|
666
|
+
errors: [
|
|
667
|
+
{
|
|
668
|
+
index: -1,
|
|
669
|
+
tool: 'orphan_check',
|
|
670
|
+
error: `${orphanedNodes.length} concept node(s) would be orphaned (no edges): ${orphanNames}. The entire batch has been rolled back. Add graph_connect operations that link every new concept to an existing or just-created node.`,
|
|
671
|
+
},
|
|
672
|
+
],
|
|
673
|
+
message: `Batch rolled back: ${orphanedNodes.length} orphaned concept node(s) detected. No changes were persisted.`,
|
|
674
|
+
hint: 'Every concept node MUST have at least one edge. Check that graph_connect operations exist for every new concept.',
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
// Create commit record if message provided and batch had changes.
|
|
678
|
+
// Inside the transaction so a rollback also rolls back the commit row.
|
|
679
|
+
if (commitMessage &&
|
|
680
|
+
(affectedNodeIds.length > 0 || affectedEdgeIds.length > 0)) {
|
|
681
|
+
const uniqueNodeIds = [...new Set(affectedNodeIds)];
|
|
682
|
+
const uniqueEdgeIds = [...new Set(affectedEdgeIds)];
|
|
683
|
+
const createdCommit = createCommit(commitMessage, uniqueNodeIds, uniqueEdgeIds, agentName);
|
|
684
|
+
commit = { id: createdCommit.id, message: createdCommit.message };
|
|
685
|
+
}
|
|
686
|
+
// Everything inside the transaction succeeded. COMMIT.
|
|
687
|
+
txnDb.exec('COMMIT');
|
|
688
|
+
txnCommitted = true;
|
|
689
|
+
}
|
|
690
|
+
catch (err) {
|
|
691
|
+
// Roll back any uncommitted work. Either a real error from a sub-tool,
|
|
692
|
+
// or a BatchEarlyExit sentinel carrying a structured payload.
|
|
693
|
+
if (!txnCommitted) {
|
|
694
|
+
try {
|
|
695
|
+
txnDb.exec('ROLLBACK');
|
|
696
|
+
}
|
|
697
|
+
catch {
|
|
698
|
+
// Swallow — if rollback itself fails the connection is wedged
|
|
699
|
+
// and there's nothing useful we can do here.
|
|
461
700
|
}
|
|
462
|
-
// Push null result for failed operation
|
|
463
|
-
results.push({ success: false, error: errorMsg });
|
|
464
701
|
}
|
|
702
|
+
if (err instanceof BatchEarlyExit) {
|
|
703
|
+
// Intentional early exit — return its payload as the tool result.
|
|
704
|
+
return err.payload;
|
|
705
|
+
}
|
|
706
|
+
throw err;
|
|
465
707
|
}
|
|
466
|
-
|
|
467
|
-
//
|
|
708
|
+
// POST-COMMIT: doc auto-regeneration writes files (not the DB), and
|
|
709
|
+
// score-hint computation is read-only. Both are safe to do after commit.
|
|
468
710
|
const regeneratedDocs = [];
|
|
469
711
|
if (affectedDocRoots.size > 0) {
|
|
470
712
|
const projectId = contextManager.getCurrentProjectId();
|
|
@@ -521,72 +763,6 @@ Or use source_commit with type: "thinking" nodes.`;
|
|
|
521
763
|
scoreHint += `\n\n📊 ${avgEdges.toFixed(1)} avg edges per thinking (target: 2-3). Externalize more of the cognitive journey.`;
|
|
522
764
|
}
|
|
523
765
|
}
|
|
524
|
-
// ORPHAN PREVENTION: Delete any concept nodes created in this batch that have no edges.
|
|
525
|
-
// Exception: if the graph was empty when this batch started, the very first concept
|
|
526
|
-
// node is allowed to remain unconnected — by definition there's nothing for it to
|
|
527
|
-
// connect to yet. This matches validateNoOrphans's pre-check exemption.
|
|
528
|
-
const orphanedNodes = [];
|
|
529
|
-
const { edges: currentEdges } = store.getAll();
|
|
530
|
-
const connectedNodeIds = new Set();
|
|
531
|
-
for (const edge of currentEdges) {
|
|
532
|
-
connectedNodeIds.add(edge.fromId);
|
|
533
|
-
connectedNodeIds.add(edge.toId);
|
|
534
|
-
}
|
|
535
|
-
let seedNodeAllowed = graphWasEmptyAtBatchStart;
|
|
536
|
-
for (const nodeId of affectedNodeIds) {
|
|
537
|
-
if (!nodeId.startsWith('n_'))
|
|
538
|
-
continue;
|
|
539
|
-
const node = store.getNode(nodeId);
|
|
540
|
-
if (!node)
|
|
541
|
-
continue;
|
|
542
|
-
// Skip document nodes (they have structural edges managed elsewhere)
|
|
543
|
-
if (node.fileType || node.isDocRoot)
|
|
544
|
-
continue;
|
|
545
|
-
// Check if node has any edges
|
|
546
|
-
if (!connectedNodeIds.has(nodeId)) {
|
|
547
|
-
// Allow exactly one seed node when the batch started against an empty graph.
|
|
548
|
-
if (seedNodeAllowed) {
|
|
549
|
-
seedNodeAllowed = false;
|
|
550
|
-
continue;
|
|
551
|
-
}
|
|
552
|
-
orphanedNodes.push(nodeId);
|
|
553
|
-
// Delete the orphan
|
|
554
|
-
store.archiveNode(nodeId);
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
if (orphanedNodes.length > 0) {
|
|
558
|
-
const orphanNames = orphanedNodes
|
|
559
|
-
.map((id) => {
|
|
560
|
-
const r = results.find((r) => r?.id === id);
|
|
561
|
-
return r?.name || id;
|
|
562
|
-
})
|
|
563
|
-
.join(', ');
|
|
564
|
-
return {
|
|
565
|
-
success: false,
|
|
566
|
-
completed: operations.length,
|
|
567
|
-
total: operations.length,
|
|
568
|
-
results,
|
|
569
|
-
errors: [
|
|
570
|
-
{
|
|
571
|
-
index: -1,
|
|
572
|
-
tool: 'orphan_check',
|
|
573
|
-
error: `${orphanedNodes.length} concept node(s) were orphaned (no edges): ${orphanNames}. They have been deleted. Ensure graph_connect operations succeed for all new concepts.`,
|
|
574
|
-
},
|
|
575
|
-
],
|
|
576
|
-
message: `Batch failed: ${orphanedNodes.length} orphaned concept node(s) deleted. Add graph_connect calls with valid 'why' field.`,
|
|
577
|
-
hint: 'Every concept node MUST have at least one edge. Check that graph_connect calls include required fields (from, to, why).',
|
|
578
|
-
};
|
|
579
|
-
}
|
|
580
|
-
// Create commit record if message provided and batch had changes
|
|
581
|
-
let commit;
|
|
582
|
-
if (commitMessage &&
|
|
583
|
-
(affectedNodeIds.length > 0 || affectedEdgeIds.length > 0)) {
|
|
584
|
-
// Deduplicate IDs
|
|
585
|
-
const uniqueNodeIds = [...new Set(affectedNodeIds)];
|
|
586
|
-
const uniqueEdgeIds = [...new Set(affectedEdgeIds)];
|
|
587
|
-
const createdCommit = createCommit(commitMessage, uniqueNodeIds, uniqueEdgeIds, agentName);
|
|
588
|
-
commit = { id: createdCommit.id, message: createdCommit.message };
|
|
589
|
-
}
|
|
590
766
|
return {
|
|
591
767
|
success: !hasErrors,
|
|
592
768
|
completed: operations.length,
|