@tenphi/akno-core 0.10.1 → 0.12.0

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 (38) hide show
  1. package/dist/maintenance/dream.d.ts.map +1 -1
  2. package/dist/maintenance/dream.js +6 -0
  3. package/dist/maintenance/dream.js.map +1 -1
  4. package/dist/maintenance/managed-item-routing.d.ts +22 -0
  5. package/dist/maintenance/managed-item-routing.d.ts.map +1 -1
  6. package/dist/maintenance/managed-item-routing.js +329 -17
  7. package/dist/maintenance/managed-item-routing.js.map +1 -1
  8. package/dist/maintenance/managed-items.d.ts +7 -3
  9. package/dist/maintenance/managed-items.d.ts.map +1 -1
  10. package/dist/maintenance/managed-items.js +161 -70
  11. package/dist/maintenance/managed-items.js.map +1 -1
  12. package/dist/maintenance/plans.d.ts +2 -1
  13. package/dist/maintenance/plans.d.ts.map +1 -1
  14. package/dist/maintenance/plans.js +12 -1
  15. package/dist/maintenance/plans.js.map +1 -1
  16. package/dist/memory/subject-resolution.d.ts +8 -0
  17. package/dist/memory/subject-resolution.d.ts.map +1 -0
  18. package/dist/memory/subject-resolution.js +27 -0
  19. package/dist/memory/subject-resolution.js.map +1 -0
  20. package/dist/memory/temporal-destination.d.ts +11 -0
  21. package/dist/memory/temporal-destination.d.ts.map +1 -0
  22. package/dist/memory/temporal-destination.js +73 -0
  23. package/dist/memory/temporal-destination.js.map +1 -0
  24. package/dist/ops/forget.d.ts +1 -1
  25. package/dist/ops/forget.d.ts.map +1 -1
  26. package/dist/ops/forget.js +58 -1
  27. package/dist/ops/forget.js.map +1 -1
  28. package/dist/ops/remember.d.ts +11 -25
  29. package/dist/ops/remember.d.ts.map +1 -1
  30. package/dist/ops/remember.js +158 -40
  31. package/dist/ops/remember.js.map +1 -1
  32. package/dist/ops/retain.d.ts.map +1 -1
  33. package/dist/ops/retain.js +80 -23
  34. package/dist/ops/retain.js.map +1 -1
  35. package/dist/write/managed-memory.d.ts.map +1 -1
  36. package/dist/write/managed-memory.js +11 -3
  37. package/dist/write/managed-memory.js.map +1 -1
  38. package/package.json +2 -2
@@ -4,12 +4,13 @@ import { z } from 'zod';
4
4
  import { parseFrontmatter } from "../kb/frontmatter.js";
5
5
  import { parsePage, resolvePagePolicy } from "../kb/page.js";
6
6
  import { parseJsonLoose } from "../models/client.js";
7
+ import { retainedTemporalBoundary } from "../memory/temporal-destination.js";
7
8
  import { isReserved } from "../reserved.js";
8
- import { effectiveRule } from "../rules/compile.js";
9
+ import { declaringRule, effectiveRule } from "../rules/compile.js";
9
10
  import { sha256 } from "../store/ids.js";
10
11
  import { parseManagedMemoryMarker } from "../write/managed-memory.js";
11
12
  import { emptyManagedSourceMetrics, groundedManagedReplacement, managedSourceItemIds, pruneManagedSourceArchives, qualifyManagedSources, } from "./managed-item-sources.js";
12
- import { emptyManagedRoutingMetrics, hasH2, managedSectionHeading, qualifyManagedItemRouting, } from "./managed-item-routing.js";
13
+ import { emptyManagedRoutingMetrics, hasH2, managedSectionHeading, qualifyManagedItemRouting, qualifyManagedRoutingDestination, repeatsManagedRoutingEdge, } from "./managed-item-routing.js";
13
14
  const MANAGED_ITEM_FINDING_CODES = [
14
15
  'empty_marker',
15
16
  'malformed_marker',
@@ -22,6 +23,9 @@ const MANAGED_ITEM_FINDING_CODES = [
22
23
  'routing_deferred',
23
24
  'routing_uncertain',
24
25
  'routing_unavailable',
26
+ 'routing_oscillation',
27
+ 'source_vacated',
28
+ 'empty_knowledge_page',
25
29
  'wording_corrected',
26
30
  'wording_uncertain',
27
31
  'source_unavailable',
@@ -131,6 +135,12 @@ export function inspectManagedItems(content) {
131
135
  const after = prefix + lines.flatMap((line, index) => (remove.has(index) ? [] : [line])).join('\n');
132
136
  return { after, inspectedMarkers, findings, repairs, bindings };
133
137
  }
138
+ /** A frontmatter-and-heading skeleton has no knowledge of its own to preserve as a standalone page. */
139
+ function isEmptyKnowledgePageShell(content) {
140
+ const frontmatter = parseFrontmatter(content);
141
+ const body = content.slice(frontmatter.bodyOffset).replace(/<!--[^]*?-->/g, '');
142
+ return body.split(/\r?\n/).every((line) => line.trim() === '' || /^\s{0,3}#{1,6}(?:\s+.*)?$/.test(line));
143
+ }
134
144
  /** The sealed replacement must be exactly the structural repair and qualified exact-block move it declares. */
135
145
  export function managedItemRepairIssue(before, after, placements = [], corrections = []) {
136
146
  const inspection = inspectManagedItems(before);
@@ -179,6 +189,9 @@ export function managedItemOperationsIssue(replacements, placements = [], correc
179
189
  if (!applied.ok || applied.source !== source.after || applied.destination !== destination.after) {
180
190
  return 'the managed-item output is broader than its exact cross-page transfer';
181
191
  }
192
+ if (!transfer.sourceIsFallbackQueue && isEmptyKnowledgePageShell(applied.source)) {
193
+ return 'a cross-page managed-item move may not vacate a normal knowledge page';
194
+ }
182
195
  return null;
183
196
  }
184
197
  export async function planManagedItems(ctx, options = {}) {
@@ -223,19 +236,26 @@ export async function planManagedItems(ctx, options = {}) {
223
236
  report.eligiblePages += 1;
224
237
  const inspection = inspectManagedItems(before);
225
238
  report.inspectedMarkers += inspection.inspectedMarkers;
239
+ if (inspection.inspectedMarkers === 0 && isEmptyKnowledgePageShell(before)) {
240
+ addFinding(report, { code: 'empty_knowledge_page', line: page.bodyLine, outcome: 'held' }, 'held', page.slug);
241
+ }
226
242
  eligible.push({ row, before, page, inspection });
227
243
  }
228
244
  if (sourceScanComplete)
229
245
  pruneManagedSourceArchives(ctx, liveManagedSourceIds);
230
246
  const crossPageCollisions = crossPageItemCollisions(eligible);
231
- const facts = currentManagedFacts(ctx);
247
+ const memories = currentManagedMemories(ctx);
232
248
  const prepared = eligible.map((candidate) => ({
233
249
  candidate,
234
- inspection: withBindingFindings(ctx, candidate, facts, crossPageCollisions, options),
250
+ inspection: withBindingFindings(candidate, memories, crossPageCollisions, options),
235
251
  }));
252
+ const fallbackSlug = ctx.config.maintenance.retain.fallbackPage;
236
253
  const routingPages = new Map(prepared
237
254
  .filter((entry) => entry.inspection.repairs.length === 0)
238
- .map(({ candidate }) => [candidate.page.slug, managedRoutingPage(candidate)]));
255
+ .map(({ candidate }) => [
256
+ candidate.page.slug,
257
+ managedRoutingPage(ctx, candidate, candidate.page.slug === fallbackSlug),
258
+ ]));
239
259
  const occupiedPaths = new Set();
240
260
  const suppressedRoutingSources = new Set();
241
261
  const routingFindings = new Map();
@@ -248,17 +268,20 @@ export async function planManagedItems(ctx, options = {}) {
248
268
  for (const binding of inspection.bindings) {
249
269
  if (blockedLines.has(binding.markerLine))
250
270
  continue;
251
- const fact = facts.get(binding.id);
271
+ const memory = memories.get(binding.id);
252
272
  const removed = removeManagedBlock(candidate.before, binding);
253
- if (!fact || !removed)
273
+ if (!memory || !removed)
254
274
  continue;
255
- const qualified = await qualifyManagedItemRouting(ctx, managedRoutingPage(candidate), removed.content, {
275
+ const routingSource = managedRoutingPage(ctx, candidate, candidate.page.slug === fallbackSlug);
276
+ const routingItem = {
256
277
  id: binding.id,
257
278
  payload: binding.payload,
258
- subject: fact.subject,
259
- attribute: fact.attribute,
279
+ subject: memory.routingSubject,
280
+ attribute: memory.attribute || binding.currentHeading || memory.kind,
260
281
  currentHeading: binding.currentHeading,
261
- }, routingPages);
282
+ temporalBoundary: binding.temporalBoundary,
283
+ };
284
+ const qualified = await qualifyManagedItemRouting(ctx, routingSource, removed.content, routingItem, routingPages);
262
285
  addRoutingMetrics(report.routing, qualified.metrics);
263
286
  if (qualified.decision.outcome === 'keep')
264
287
  continue;
@@ -270,7 +293,7 @@ export async function planManagedItems(ctx, options = {}) {
270
293
  if (!destination ||
271
294
  destination.inspection.repairs.length > 0 ||
272
295
  occupiedPaths.has(destination.candidate.row.rel_path)) {
273
- report.routing.moved -= 1;
296
+ retractRoutingMove(report.routing, qualified.decision.createHeading === true);
274
297
  report.routing.unavailable += 1;
275
298
  setRoutingFinding(routingFindings, candidate.row.rel_path, binding.markerLine, 'routing_unavailable', 'held');
276
299
  continue;
@@ -284,15 +307,42 @@ export async function planManagedItems(ctx, options = {}) {
284
307
  destinationSlug: destination.candidate.page.slug,
285
308
  destinationHeading: qualified.decision.targetHeading,
286
309
  createDestinationHeading: qualified.decision.createHeading,
287
- destinationHeadingSource: qualified.decision.createHeading ? fact.attribute : undefined,
310
+ destinationHeadingSource: qualified.decision.createHeading
311
+ ? memory.attribute || binding.currentHeading || memory.kind
312
+ : undefined,
313
+ sourceIsFallbackQueue: candidate.page.slug === fallbackSlug,
288
314
  };
289
315
  const applied = applyManagedItemTransfer(candidate.before, destination.candidate.before, transfer);
290
316
  if (!applied.ok) {
291
- report.routing.moved -= 1;
317
+ retractRoutingMove(report.routing, qualified.decision.createHeading === true);
292
318
  report.routing.unavailable += 1;
293
319
  setRoutingFinding(routingFindings, candidate.row.rel_path, binding.markerLine, 'routing_unavailable', 'held');
294
320
  continue;
295
321
  }
322
+ if (repeatsManagedRoutingEdge(qualified.history, candidate.row.rel_path, destination.candidate.row.rel_path)) {
323
+ retractRoutingMove(report.routing, qualified.decision.createHeading === true);
324
+ report.routing.oscillationHolds += 1;
325
+ setRoutingFinding(routingFindings, candidate.row.rel_path, binding.markerLine, 'routing_oscillation', 'held');
326
+ continue;
327
+ }
328
+ if (!transfer.sourceIsFallbackQueue && isEmptyKnowledgePageShell(applied.source)) {
329
+ retractRoutingMove(report.routing, qualified.decision.createHeading === true);
330
+ report.routing.sourceVacatedHolds += 1;
331
+ setRoutingFinding(routingFindings, candidate.row.rel_path, binding.markerLine, 'source_vacated', 'held');
332
+ continue;
333
+ }
334
+ report.routing.classifierCalls += 1;
335
+ report.routing.validationCalls += 1;
336
+ const destinationValidation = await qualifyManagedRoutingDestination(ctx, routingSource, removed.content, routingItem, managedRoutingPage(ctx, destination.candidate, destination.candidate.page.slug === fallbackSlug), transfer.destinationHeading, qualified.history);
337
+ if (destinationValidation !== 'accept') {
338
+ retractRoutingMove(report.routing, qualified.decision.createHeading === true);
339
+ if (destinationValidation === 'unavailable')
340
+ report.routing.unavailable += 1;
341
+ else
342
+ report.routing.uncertain += 1;
343
+ setRoutingFinding(routingFindings, candidate.row.rel_path, binding.markerLine, destinationValidation === 'unavailable' ? 'routing_unavailable' : 'routing_uncertain', 'held');
344
+ continue;
345
+ }
296
346
  const inputHash = managedTransferInputHash(candidate.before, applied.source, destination.candidate.before, applied.destination, transfer);
297
347
  const finding = {
298
348
  code: 'misrouted_item',
@@ -347,7 +397,7 @@ export async function planManagedItems(ctx, options = {}) {
347
397
  // First seal deterministic normalization on its own. Besides keeping the move verifier small,
348
398
  // this ensures semantic qualification always sees the exact canonical bytes it is judging.
349
399
  if (inspection.repairs.length === 0) {
350
- const qualified = await qualifyManagedItemPlacement(ctx, candidate, inspection, facts);
400
+ const qualified = await qualifyManagedItemPlacement(ctx, candidate, inspection, memories);
351
401
  addPlacementMetrics(report.placement, qualified.metrics);
352
402
  const applied = applyQualifiedPlacementFindings(inspection, qualified);
353
403
  inspection = applied.inspection;
@@ -406,14 +456,23 @@ function deferPlacementUntilCanonical(inspection) {
406
456
  });
407
457
  return { inspection: { ...inspection, findings }, fragments };
408
458
  }
409
- function managedRoutingPage(candidate) {
459
+ function managedRoutingPage(ctx, candidate, fallback) {
460
+ const rule = effectiveRule(candidate.page.slug, ctx.config.rules);
461
+ const purposeRule = declaringRule(candidate.page.slug, ctx.config.rules, 'description');
410
462
  return {
411
463
  id: candidate.row.id,
412
464
  slug: candidate.page.slug,
465
+ relPath: candidate.row.rel_path,
413
466
  title: candidate.page.title,
467
+ type: candidate.page.type ?? rule.type ?? null,
468
+ about: [...new Set([...candidate.page.about, ...(rule.about ?? [])])],
469
+ folderPath: path.posix.dirname(candidate.page.slug) === '.' ? '' : path.posix.dirname(candidate.page.slug),
470
+ folderRule: purposeRule?.glob ?? null,
471
+ folderPurpose: rule.description ?? null,
414
472
  bodyHash: candidate.page.bodyHash,
415
473
  body: candidate.page.body,
416
474
  headings: uniqueManagedHeadings(candidate.before),
475
+ fallback,
417
476
  };
418
477
  }
419
478
  function setRoutingFinding(findings, relPath, line, code, outcome) {
@@ -453,12 +512,28 @@ function applyRoutingFindings(inspection, routed) {
453
512
  }
454
513
  return { ...inspection, findings };
455
514
  }
456
- function currentManagedFacts(ctx) {
515
+ function currentManagedMemories(ctx) {
457
516
  const rows = ctx.store.db
458
- .prepare(`SELECT item_id, page_id, line_start, source_line_hash, subject, attribute
459
- FROM facts WHERE item_id IS NOT NULL AND valid_to IS NULL`)
517
+ .prepare(`SELECT memory.memory_id AS item_id, memory.source_page AS page_id,
518
+ memory.payload_line AS line_start, memory.payload_hash AS source_line_hash,
519
+ COALESCE(fact.subject, entity.label, '') AS routing_subject,
520
+ COALESCE(fact.attribute, '') AS attribute, memory.kind
521
+ FROM managed_memory_entries memory
522
+ LEFT JOIN facts fact ON fact.item_id = memory.memory_id AND fact.valid_to IS NULL
523
+ LEFT JOIN graph_entities entity ON entity.id = memory.subject`)
460
524
  .all();
461
- return new Map(rows.map((row) => [row.item_id, row]));
525
+ return new Map(rows.map((row) => [
526
+ row.item_id,
527
+ {
528
+ item_id: row.item_id,
529
+ page_id: row.page_id,
530
+ line_start: row.line_start,
531
+ source_line_hash: row.source_line_hash,
532
+ routingSubject: row.routing_subject,
533
+ attribute: row.attribute,
534
+ kind: row.kind,
535
+ },
536
+ ]));
462
537
  }
463
538
  function crossPageItemCollisions(pages) {
464
539
  const occurrences = new Map();
@@ -478,7 +553,7 @@ function crossPageItemCollisions(pages) {
478
553
  }
479
554
  return collisions;
480
555
  }
481
- function withBindingFindings(ctx, candidate, facts, collisions, options) {
556
+ function withBindingFindings(candidate, memories, collisions, options) {
482
557
  const extra = new Map();
483
558
  for (const binding of candidate.inspection.bindings) {
484
559
  const collisionKey = `${candidate.row.rel_path}\0${binding.markerLine}`;
@@ -487,18 +562,14 @@ function withBindingFindings(ctx, candidate, facts, collisions, options) {
487
562
  extra.set(binding.markerLine, 'item_conflict');
488
563
  continue;
489
564
  }
490
- if (!ctx.config.index.facts || candidate.row.derived_hash !== candidate.row.body_hash) {
565
+ const memory = memories.get(binding.id);
566
+ if (!memory) {
491
567
  extra.set(binding.markerLine, 'source_unavailable');
492
568
  continue;
493
569
  }
494
- const fact = facts.get(binding.id);
495
- if (!fact) {
496
- extra.set(binding.markerLine, 'source_unavailable');
497
- continue;
498
- }
499
- if (fact.page_id !== candidate.row.id ||
500
- fact.line_start !== binding.payloadLine ||
501
- fact.source_line_hash !== sha256(binding.payload.trim())) {
570
+ if (memory.page_id !== candidate.row.id ||
571
+ memory.line_start !== binding.payloadLine ||
572
+ memory.source_line_hash !== sha256(binding.payload.trim())) {
502
573
  extra.set(binding.markerLine, 'item_conflict');
503
574
  }
504
575
  }
@@ -512,7 +583,7 @@ function withBindingFindings(ctx, candidate, facts, collisions, options) {
512
583
  }
513
584
  return { ...candidate.inspection, findings };
514
585
  }
515
- async function qualifyManagedItemPlacement(ctx, candidate, inspection, facts) {
586
+ async function qualifyManagedItemPlacement(ctx, candidate, inspection, memories) {
516
587
  const metrics = emptyPlacementMetrics();
517
588
  const blockedIds = new Set(inspection.findings
518
589
  .filter((finding) => [
@@ -520,6 +591,8 @@ async function qualifyManagedItemPlacement(ctx, candidate, inspection, facts) {
520
591
  'source_unavailable',
521
592
  'routing_uncertain',
522
593
  'routing_unavailable',
594
+ 'routing_oscillation',
595
+ 'source_vacated',
523
596
  'misrouted_item',
524
597
  'routing_deferred',
525
598
  ].includes(finding.code))
@@ -544,7 +617,8 @@ async function qualifyManagedItemPlacement(ctx, candidate, inspection, facts) {
544
617
  }
545
618
  const headings = uniqueManagedHeadings(candidate.before);
546
619
  const creatableHeadings = new Map(bindings.flatMap((binding) => {
547
- const proposed = managedSectionHeading(facts.get(binding.id)?.attribute ?? '');
620
+ const memory = memories.get(binding.id);
621
+ const proposed = managedSectionHeading(memory?.attribute || binding.currentHeading || memory?.kind || '');
548
622
  return proposed && !hasH2(candidate.page.body, proposed.heading)
549
623
  ? [[binding.id, proposed]]
550
624
  : [];
@@ -647,7 +721,9 @@ async function qualifyManagedItemPlacement(ctx, candidate, inspection, facts) {
647
721
  fromHeading: binding.currentHeading,
648
722
  toHeading: decision.targetHeading,
649
723
  createHeading: decision.createHeading,
650
- headingSource: decision.createHeading ? facts.get(binding.id)?.attribute : undefined,
724
+ headingSource: decision.createHeading
725
+ ? memories.get(binding.id)?.attribute || binding.currentHeading || memories.get(binding.id)?.kind
726
+ : undefined,
651
727
  });
652
728
  }
653
729
  }
@@ -726,6 +802,8 @@ async function qualifyManagedItemSourceBindings(ctx, candidate, inspection) {
726
802
  'source_unavailable',
727
803
  'routing_uncertain',
728
804
  'routing_unavailable',
805
+ 'routing_oscillation',
806
+ 'source_vacated',
729
807
  'misrouted_item',
730
808
  'routing_deferred',
731
809
  ].includes(finding.code))
@@ -957,6 +1035,7 @@ function managedContentKey(marker) {
957
1035
  }
958
1036
  function managedBinding(marker, bodyLine, headings) {
959
1037
  const context = headings.get(marker.markerIndex) ?? { heading: null, unique: false };
1038
+ const temporalBoundary = retainedTemporalBoundary(marker.time, marker.payload);
960
1039
  return {
961
1040
  id: marker.id,
962
1041
  origin: marker.sourceRole === 'user' || marker.sourceRole === 'assistant' ? marker.sourceRole : 'unknown',
@@ -967,6 +1046,7 @@ function managedBinding(marker, bodyLine, headings) {
967
1046
  payloadIndex: marker.payloadIndex,
968
1047
  currentHeading: context.heading,
969
1048
  currentHeadingUnique: context.unique && normalizedHeading(context.heading ?? '') !== normalizedHeading('Unsorted'),
1049
+ ...(temporalBoundary ? { temporalBoundary } : {}),
970
1050
  };
971
1051
  }
972
1052
  /** A model may judge semantic fit later; these are only the structurally provable placement failures. */
@@ -1032,7 +1112,7 @@ function uniqueManagedHeadings(content) {
1032
1112
  function normalizedHeading(value) {
1033
1113
  return value.normalize('NFKC').trim().toLowerCase();
1034
1114
  }
1035
- /** Move only the exact marker-through-payload bytes; every pre-existing surrounding byte survives. */
1115
+ /** Move the exact owned block and prune only a unique source heading left structurally empty. */
1036
1116
  export function applyManagedItemMoves(content, moves) {
1037
1117
  let current = content;
1038
1118
  for (const move of moves) {
@@ -1054,7 +1134,7 @@ export function applyManagedItemMoves(content, moves) {
1054
1134
  }
1055
1135
  return { ok: true, content: current };
1056
1136
  }
1057
- /** Move one complete owned block between existing pages, optionally adding one sealed bounded heading. */
1137
+ /** Move one owned block between pages, optionally adding or pruning one structurally bounded heading. */
1058
1138
  export function applyManagedItemTransfer(sourceContent, destinationContent, transfer) {
1059
1139
  const sourceInspection = inspectManagedItems(sourceContent);
1060
1140
  const matches = sourceInspection.bindings.filter((binding) => binding.id === transfer.itemId);
@@ -1097,43 +1177,13 @@ function applyManagedItemCorrections(content, corrections) {
1097
1177
  return { ok: true, content: current };
1098
1178
  }
1099
1179
  function moveManagedBlock(content, binding, targetHeading, createHeading = false, headingSource) {
1100
- const frontmatter = parseFrontmatter(content);
1101
- const prefix = content.slice(0, frontmatter.bodyOffset);
1102
- const body = content.slice(frontmatter.bodyOffset);
1103
- const spans = rawLineSpans(body);
1104
- const marker = spans[binding.markerIndex];
1105
- const payload = spans[binding.payloadIndex];
1106
- if (!marker || !payload || payload.contentEnd < marker.start)
1180
+ const removed = removeManagedBlock(content, binding);
1181
+ if (!removed)
1107
1182
  return null;
1108
- const block = body.slice(marker.start, payload.contentEnd);
1109
- const without = body.slice(0, marker.start) + body.slice(payload.contentEnd);
1110
1183
  if (createHeading) {
1111
- return insertManagedBlockWithNewHeading(prefix + without, block, targetHeading, headingSource);
1184
+ return insertManagedBlockWithNewHeading(removed.content, removed.block, targetHeading, headingSource);
1112
1185
  }
1113
- const targetKey = normalizedHeading(targetHeading);
1114
- const withoutSpans = rawLineSpans(without);
1115
- const targets = withoutSpans
1116
- .map((span, index) => ({
1117
- index,
1118
- span,
1119
- heading: /^\s{0,3}##(?:\s+(.+?)\s*|\s*)$/.exec(span.text)?.[1]?.trim(),
1120
- }))
1121
- .filter((entry) => entry.heading && normalizedHeading(entry.heading) === targetKey);
1122
- if (targets.length !== 1)
1123
- return null;
1124
- let insertionOffset = without.length;
1125
- for (let index = targets[0].index + 1; index < withoutSpans.length; index++) {
1126
- if (/^\s{0,3}#{1,2}(?:\s+|$)/.test(withoutSpans[index].text)) {
1127
- insertionOffset = withoutSpans[index].start;
1128
- break;
1129
- }
1130
- }
1131
- const before = without.slice(0, insertionOffset);
1132
- const after = without.slice(insertionOffset);
1133
- const eol = body.includes('\r\n') ? '\r\n' : '\n';
1134
- const beforeGap = lineGapBefore(before, eol);
1135
- const afterGap = lineGapAfter(after, eol, body.endsWith(eol));
1136
- return prefix + before + beforeGap + block + afterGap + after;
1186
+ return insertManagedBlock(removed.content, removed.block, targetHeading);
1137
1187
  }
1138
1188
  function insertManagedBlockWithNewHeading(content, block, targetHeading, headingSource) {
1139
1189
  const guarded = managedSectionHeading(headingSource ?? '');
@@ -1158,11 +1208,47 @@ function removeManagedBlock(content, binding) {
1158
1208
  const payload = spans[binding.payloadIndex];
1159
1209
  if (!marker || !payload || payload.contentEnd < marker.start)
1160
1210
  return null;
1211
+ const block = body.slice(marker.start, payload.contentEnd);
1212
+ const sourceHeadingIndex = sourceHeadingForBinding(spans, binding);
1213
+ if (sourceHeadingIndex !== null) {
1214
+ const heading = spans[sourceHeadingIndex];
1215
+ const sectionContentStart = spans[sourceHeadingIndex + 1]?.start ?? body.length;
1216
+ const sectionEnd = spans.slice(binding.payloadIndex + 1).find((span) => /^\s{0,3}#{1,2}(?:\s+|$)/.test(span.text))
1217
+ ?.start ?? body.length;
1218
+ const remainingSection = body.slice(sectionContentStart, marker.start) + body.slice(payload.contentEnd, sectionEnd);
1219
+ if (remainingSection.trim().length === 0) {
1220
+ return {
1221
+ content: prefix + body.slice(0, heading.start) + body.slice(sectionEnd),
1222
+ block,
1223
+ };
1224
+ }
1225
+ }
1161
1226
  return {
1162
1227
  content: prefix + body.slice(0, marker.start) + body.slice(payload.contentEnd),
1163
- block: body.slice(marker.start, payload.contentEnd),
1228
+ block,
1164
1229
  };
1165
1230
  }
1231
+ /** Find only the unique H2 section that structurally owns this binding. */
1232
+ function sourceHeadingForBinding(spans, binding) {
1233
+ if (!binding.currentHeading)
1234
+ return null;
1235
+ for (let index = binding.markerIndex - 1; index >= 0; index--) {
1236
+ const heading = /^\s{0,3}(#{1,2})(?:\s+(.+?)\s*|\s*)$/.exec(spans[index].text);
1237
+ if (!heading)
1238
+ continue;
1239
+ if (heading[1] === '#')
1240
+ return null;
1241
+ const key = normalizedHeading(heading[2]?.trim() ?? '');
1242
+ if (key !== normalizedHeading(binding.currentHeading))
1243
+ return null;
1244
+ const occurrences = spans.filter((span) => {
1245
+ const candidate = /^\s{0,3}##(?:\s+(.+?)\s*|\s*)$/.exec(span.text)?.[1]?.trim();
1246
+ return candidate !== undefined && normalizedHeading(candidate) === key;
1247
+ }).length;
1248
+ return occurrences === 1 ? index : null;
1249
+ }
1250
+ return null;
1251
+ }
1166
1252
  function insertManagedBlock(content, block, targetHeading) {
1167
1253
  const frontmatter = parseFrontmatter(content);
1168
1254
  const prefix = content.slice(0, frontmatter.bodyOffset);
@@ -1307,6 +1393,11 @@ function addRoutingMetrics(target, addition) {
1307
1393
  target[key] += addition[key];
1308
1394
  }
1309
1395
  }
1396
+ function retractRoutingMove(metrics, createdHeading) {
1397
+ metrics.moved = Math.max(0, metrics.moved - 1);
1398
+ if (createdHeading)
1399
+ metrics.sectionsCreated = Math.max(0, metrics.sectionsCreated - 1);
1400
+ }
1310
1401
  function addSourceMetrics(target, addition) {
1311
1402
  for (const key of Object.keys(target)) {
1312
1403
  target[key] += addition[key];