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.
Files changed (25) hide show
  1. package/README.md +1 -1
  2. package/bin/cli.js +2 -2
  3. package/node_modules/@understanding-graph/core/dist/services/DocumentWriter.d.ts.map +1 -1
  4. package/node_modules/@understanding-graph/core/dist/services/DocumentWriter.js +48 -7
  5. package/node_modules/@understanding-graph/core/dist/services/DocumentWriter.js.map +1 -1
  6. package/node_modules/@understanding-graph/core/package.json +1 -1
  7. package/node_modules/@understanding-graph/core/src/services/DocumentWriter.ts +56 -7
  8. package/node_modules/@understanding-graph/mcp-server/dist/tools/batch.d.ts.map +1 -1
  9. package/node_modules/@understanding-graph/mcp-server/dist/tools/batch.js +405 -229
  10. package/node_modules/@understanding-graph/mcp-server/dist/tools/batch.js.map +1 -1
  11. package/node_modules/@understanding-graph/mcp-server/dist/tools/solvers.js +1 -1
  12. package/node_modules/@understanding-graph/mcp-server/dist/tools/solvers.js.map +1 -1
  13. package/node_modules/@understanding-graph/mcp-server/package.json +1 -1
  14. package/package.json +1 -1
  15. package/packages/core/dist/services/DocumentWriter.d.ts.map +1 -1
  16. package/packages/core/dist/services/DocumentWriter.js +48 -7
  17. package/packages/core/dist/services/DocumentWriter.js.map +1 -1
  18. package/packages/core/package.json +1 -1
  19. package/packages/mcp-server/dist/tools/batch.d.ts.map +1 -1
  20. package/packages/mcp-server/dist/tools/batch.js +405 -229
  21. package/packages/mcp-server/dist/tools/batch.js.map +1 -1
  22. package/packages/mcp-server/dist/tools/solvers.js +1 -1
  23. package/packages/mcp-server/dist/tools/solvers.js.map +1 -1
  24. package/packages/mcp-server/package.json +1 -1
  25. 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 in a single call.
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 modifications
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: Every new concept must connect to an EXISTING node.
140
- * Exception: First node in an empty graph is allowed.
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
- * This prevents orphan nodes from being created in the first place.
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
- // Find all node-creating operations and their indices
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
- name: op.params.name || `operation ${i}`,
220
+ title: conceptToolName(op),
221
+ idRef: `$${i}.id`,
156
222
  });
157
223
  }
158
224
  }
159
- // If graph is empty and we're creating the first node, allow it
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
- // For each node-creating operation, check if there's a graph_connect
166
- // that connects it to an EXISTING node (not just another new node)
167
- for (const nodeOp of nodeCreatingOps) {
168
- const ref = `$${nodeOp.index}.id`;
169
- let connectsToExisting = false;
170
- for (const op of operations) {
171
- if (op.tool !== 'graph_connect')
172
- continue;
173
- const from = (op.params.from || op.params.fromId);
174
- const to = (op.params.to || op.params.toId);
175
- // Check if this connect references our new node
176
- const referencesNewNode = from === ref || to === ref;
177
- if (!referencesNewNode)
178
- continue;
179
- // Check if the OTHER end connects to an existing node
180
- const otherEnd = from === ref ? to : from;
181
- // If otherEnd is an existing node ID, we're good
182
- if (existingNodeIds.has(otherEnd)) {
183
- connectsToExisting = true;
184
- break;
185
- }
186
- // If otherEnd is a reference to an earlier operation that creates a doc node, that's ok
187
- // (doc nodes are structural and allowed)
188
- if (otherEnd.startsWith('$')) {
189
- const match = otherEnd.match(/^\$(\d+)\.(\w+)$/);
190
- if (match) {
191
- const refIndex = Number.parseInt(match[1], 10);
192
- if (refIndex < operations.length) {
193
- const refOp = operations[refIndex];
194
- // Document nodes are structural anchors, connecting to them is valid
195
- if (refOp.tool === 'doc_create') {
196
- connectsToExisting = true;
197
- break;
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
- if (!connectsToExisting) {
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 "${nodeOp.name}" (operation ${nodeOp.index}) would be orphaned. ` +
207
- `Every new concept MUST connect to an EXISTING node in the graph. ` +
208
- `Add a graph_connect operation that links this concept to an existing node ID.`,
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
- for (let i = 0; i < operations.length; i++) {
340
- const op = operations[i];
341
- try {
342
- // Resolve any variable references in params
343
- const resolvedParams = resolveReferences(op.params, results);
344
- // Pre-execution check: doc_create with parentId but no afterId
345
- if (op.tool === 'doc_create' &&
346
- resolvedParams.parentId &&
347
- !resolvedParams.afterId &&
348
- !allowUnorderedDocs) {
349
- const store = getGraphStore();
350
- const parentId = resolvedParams.parentId;
351
- const existingSiblings = store.getChildren(parentId);
352
- if (existingSiblings.length > 0) {
353
- const siblingNames = existingSiblings.map((s) => s.title).join(', ');
354
- throw new Error(`doc_create requires afterId when parent already has children. ` +
355
- `Parent "${parentId}" has siblings: [${siblingNames}]. ` +
356
- `Use afterId to specify position, or set allowUnorderedDocs: true to allow arbitrary ordering.`);
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
- // Execute the tool
360
- const result = await handleToolCall(op.tool, resolvedParams, contextManager);
361
- results.push(result);
362
- // Track affected node/edge IDs for commit
363
- const resultObj = result;
364
- if (resultObj.id && typeof resultObj.id === 'string') {
365
- if (resultObj.id.startsWith('e_')) {
366
- affectedEdgeIds.push(resultObj.id);
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
- else if (resultObj.id.startsWith('n_')) {
369
- affectedNodeIds.push(resultObj.id);
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
- // Some tools return nodeId instead of id
373
- if (resultObj.nodeId && typeof resultObj.nodeId === 'string') {
374
- affectedNodeIds.push(resultObj.nodeId);
375
- }
376
- // graph_revise returns the updated node id
377
- if (resultObj.newId && typeof resultObj.newId === 'string') {
378
- affectedNodeIds.push(resultObj.newId);
379
- }
380
- // Generate embedding immediately for node-creating tools
381
- // This ensures duplicate detection works within the same batch
382
- if (NODE_CREATING_TOOLS.includes(op.tool)) {
383
- const nodeId = result.id ||
384
- result.newId;
385
- if (nodeId && typeof nodeId === 'string') {
386
- try {
387
- const store = getGraphStore();
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
- catch {
391
- // Non-fatal: embedding generation can fail without breaking the batch
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
- // Post-creation verification: doc_create nodes must trace to root
396
- // This catches forward references that bypassed pre-validation
397
- if (op.tool === 'doc_create') {
398
- const resultObj = result;
399
- const nodeId = resultObj.id;
400
- const isRoot = resultObj.isDocRoot;
401
- if (nodeId && !isRoot) {
402
- const store = getGraphStore();
403
- const docPath = store.getDocumentPath(nodeId);
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
- // Track affected document roots for auto-regeneration
411
- if (DOC_MUTATION_TOOLS.includes(op.tool)) {
412
- const resultObj = result;
413
- const nodeId = (resultObj.id || resultObj.nodeId);
414
- if (nodeId) {
415
- const store = getGraphStore();
416
- const docPath = store.getDocumentPath(nodeId);
417
- if (docPath && docPath.length > 0 && docPath[0].isDocRoot) {
418
- affectedDocRoots.add(docPath[0].id);
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
- catch (error) {
424
- const errorMsg = error instanceof Error ? error.message : String(error);
425
- errors.push({ index: i, tool: op.tool, error: errorMsg });
426
- if (stopOnError) {
427
- // CRITICAL: Clean up orphaned nodes before returning
428
- // Otherwise nodes created before the error become orphans
429
- const graphStore = getGraphStore();
430
- const { edges: currentEdges } = graphStore.getAll();
431
- const connectedNodeIds = new Set();
432
- for (const edge of currentEdges) {
433
- connectedNodeIds.add(edge.fromId);
434
- connectedNodeIds.add(edge.toId);
435
- }
436
- const orphanedNodes = [];
437
- for (const nodeId of affectedNodeIds) {
438
- if (!nodeId.startsWith('n_'))
439
- continue;
440
- const node = graphStore.getNode(nodeId);
441
- if (!node)
442
- continue;
443
- if (node.fileType || node.isDocRoot)
444
- continue;
445
- if (!connectedNodeIds.has(nodeId)) {
446
- orphanedNodes.push(nodeId);
447
- graphStore.archiveNode(nodeId);
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
- const orphanMsg = orphanedNodes.length > 0
451
- ? ` Rolled back ${orphanedNodes.length} orphaned node(s).`
452
- : '';
453
- return {
454
- success: false,
455
- completed: i,
456
- total: operations.length,
457
- results,
458
- errors,
459
- message: `Batch stopped at operation ${i} (${op.tool}): ${errorMsg}${orphanMsg}`,
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
- const hasErrors = errors.length > 0;
467
- // Auto-regenerate affected document roots
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,