@tenphi/akno-core 0.8.0 → 0.10.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 (96) hide show
  1. package/README.md +4 -0
  2. package/config/default.jsonc +11 -4
  3. package/dist/config/load.js +2 -0
  4. package/dist/config/load.js.map +1 -1
  5. package/dist/config/schema.d.ts +4 -0
  6. package/dist/config/schema.d.ts.map +1 -1
  7. package/dist/config/schema.js +18 -0
  8. package/dist/config/schema.js.map +1 -1
  9. package/dist/context.d.ts.map +1 -1
  10. package/dist/context.js +3 -0
  11. package/dist/context.js.map +1 -1
  12. package/dist/doctor.d.ts +6 -0
  13. package/dist/doctor.d.ts.map +1 -1
  14. package/dist/doctor.js +14 -3
  15. package/dist/doctor.js.map +1 -1
  16. package/dist/index/graph.d.ts.map +1 -1
  17. package/dist/index/graph.js +8 -3
  18. package/dist/index/graph.js.map +1 -1
  19. package/dist/index/indexer.d.ts +8 -0
  20. package/dist/index/indexer.d.ts.map +1 -1
  21. package/dist/index/indexer.js +261 -14
  22. package/dist/index/indexer.js.map +1 -1
  23. package/dist/index/page-quarantine.d.ts +40 -0
  24. package/dist/index/page-quarantine.d.ts.map +1 -0
  25. package/dist/index/page-quarantine.js +160 -0
  26. package/dist/index/page-quarantine.js.map +1 -0
  27. package/dist/index.d.ts +1 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +1 -0
  30. package/dist/index.js.map +1 -1
  31. package/dist/maintenance/curate.js +1 -1
  32. package/dist/maintenance/dream.d.ts +3 -0
  33. package/dist/maintenance/dream.d.ts.map +1 -1
  34. package/dist/maintenance/dream.js +5 -0
  35. package/dist/maintenance/dream.js.map +1 -1
  36. package/dist/maintenance/log.d.ts.map +1 -1
  37. package/dist/maintenance/log.js +1 -0
  38. package/dist/maintenance/log.js.map +1 -1
  39. package/dist/maintenance/managed-items.js +1 -1
  40. package/dist/maintenance/managed-items.js.map +1 -1
  41. package/dist/maintenance/plans.d.ts.map +1 -1
  42. package/dist/maintenance/plans.js +14 -0
  43. package/dist/maintenance/plans.js.map +1 -1
  44. package/dist/maintenance/runs.js +1 -0
  45. package/dist/maintenance/runs.js.map +1 -1
  46. package/dist/ops/context.d.ts.map +1 -1
  47. package/dist/ops/context.js +5 -1
  48. package/dist/ops/context.js.map +1 -1
  49. package/dist/ops/forget.d.ts.map +1 -1
  50. package/dist/ops/forget.js +6 -0
  51. package/dist/ops/forget.js.map +1 -1
  52. package/dist/ops/graph.d.ts.map +1 -1
  53. package/dist/ops/graph.js +11 -2
  54. package/dist/ops/graph.js.map +1 -1
  55. package/dist/ops/ingest.d.ts.map +1 -1
  56. package/dist/ops/ingest.js +9 -3
  57. package/dist/ops/ingest.js.map +1 -1
  58. package/dist/ops/move.d.ts.map +1 -1
  59. package/dist/ops/move.js +11 -0
  60. package/dist/ops/move.js.map +1 -1
  61. package/dist/ops/read.d.ts.map +1 -1
  62. package/dist/ops/read.js +17 -1
  63. package/dist/ops/read.js.map +1 -1
  64. package/dist/ops/recall.d.ts.map +1 -1
  65. package/dist/ops/recall.js +15 -1
  66. package/dist/ops/recall.js.map +1 -1
  67. package/dist/ops/remember.d.ts.map +1 -1
  68. package/dist/ops/remember.js +68 -180
  69. package/dist/ops/remember.js.map +1 -1
  70. package/dist/ops/retain.d.ts +31 -1
  71. package/dist/ops/retain.d.ts.map +1 -1
  72. package/dist/ops/retain.js +498 -70
  73. package/dist/ops/retain.js.map +1 -1
  74. package/dist/ops/timeline.d.ts.map +1 -1
  75. package/dist/ops/timeline.js +8 -0
  76. package/dist/ops/timeline.js.map +1 -1
  77. package/dist/ops/write.d.ts.map +1 -1
  78. package/dist/ops/write.js +28 -1
  79. package/dist/ops/write.js.map +1 -1
  80. package/dist/recall/assemble.js +1 -1
  81. package/dist/recall/assemble.js.map +1 -1
  82. package/dist/store/db.d.ts.map +1 -1
  83. package/dist/store/db.js +7 -1
  84. package/dist/store/db.js.map +1 -1
  85. package/dist/store/migrations.d.ts +3 -1
  86. package/dist/store/migrations.d.ts.map +1 -1
  87. package/dist/store/migrations.js +49 -1
  88. package/dist/store/migrations.js.map +1 -1
  89. package/dist/write/gate.d.ts.map +1 -1
  90. package/dist/write/gate.js +3 -3
  91. package/dist/write/gate.js.map +1 -1
  92. package/dist/write/retain-supports.d.ts +16 -0
  93. package/dist/write/retain-supports.d.ts.map +1 -1
  94. package/dist/write/retain-supports.js +65 -0
  95. package/dist/write/retain-supports.js.map +1 -1
  96. package/package.json +2 -2
@@ -1,11 +1,14 @@
1
1
  import fsp from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { RetainInput, } from '@tenphi/akno-protocol';
3
+ import { AknoError, RetainInput, } from '@tenphi/akno-protocol';
4
4
  import { ModelClient } from "../models/client.js";
5
5
  import { folderCatalog } from "../kb/folders.js";
6
6
  import { parseFrontmatter, serializeYamlString } from "../kb/frontmatter.js";
7
7
  import { parsePage } from "../kb/page.js";
8
+ import { contentWords } from "../kb/words.js";
9
+ import { declaringRule, effectiveRule } from "../rules/compile.js";
8
10
  import { isReserved } from "../reserved.js";
11
+ import { hasInlineMergeConflict, matchesConflictPath, quarantineReasonsForPath, } from "../index/page-quarantine.js";
9
12
  import { newPrefixedId, sha256 } from "../store/ids.js";
10
13
  import { restoreFile, writeFileAtomic } from "../write/atomic.js";
11
14
  import { detectConflict } from "../write/conflict.js";
@@ -14,25 +17,33 @@ import { managedMemoryBlock, managedMemoryFingerprint, markerFromProvidedCandida
14
17
  import { appendManagedBlock, managedSourceReference, placeManagedItems } from "../write/placement.js";
15
18
  import { modelCallReceipt, runRetain, } from "../write/retain.js";
16
19
  import { resolveRememberFallback } from "../write/remember-fallback.js";
17
- import { reconcileRetainManagedSources } from "../write/retain-supports.js";
20
+ import { pruneRetainEvidence, reconcileRetainManagedSources } from "../write/retain-supports.js";
18
21
  import { routeAutomaticCandidate } from "./remember.js";
19
- import { normalizeSlug } from "./write.js";
22
+ import { read } from "./read.js";
23
+ import { normalizeSlug, titleFromSlug } from "./write.js";
20
24
  export async function retain(ctx, rawInput) {
21
25
  const input = RetainInput.parse(rawInput);
22
26
  const results = [];
23
27
  for (const source of input.sources) {
24
28
  try {
25
- results.push('input' in source
26
- ? source.retention.mode === 'extract'
27
- ? await retainExtracted(ctx, source, input.dry_run ?? false)
28
- : await retainCandidates(ctx, source, source.retention.candidates, {
29
- dryRun: input.dry_run ?? false,
30
- selection: 'provided',
31
- placement: source.retention.placement,
32
- initialResults: [],
33
- modelUsage: { extraction: null, verification: null, placement: [] },
34
- })
35
- : await retractSource(ctx, source, input.dry_run ?? false));
29
+ if (!('input' in source)) {
30
+ results.push(await retractSource(ctx, source, input.dry_run ?? false));
31
+ continue;
32
+ }
33
+ const resolved = await resolveRetainSource(ctx, source);
34
+ if ('result' in resolved) {
35
+ results.push(resolved.result);
36
+ continue;
37
+ }
38
+ results.push(source.retention.mode === 'extract'
39
+ ? await retainExtracted(ctx, resolved, input.dry_run ?? false)
40
+ : await retainCandidates(ctx, resolved, source.retention.candidates, {
41
+ dryRun: input.dry_run ?? false,
42
+ selection: 'provided',
43
+ placement: source.retention.placement,
44
+ initialResults: [],
45
+ modelUsage: { extraction: null, verification: null, placement: [] },
46
+ }));
36
47
  }
37
48
  catch (error) {
38
49
  results.push({
@@ -53,24 +64,137 @@ export async function retain(ctx, rawInput) {
53
64
  const effective = results.filter((result) => result.outcome === 'ok').length;
54
65
  const degraded = [...new Set(results.flatMap((result) => result.degraded ?? []))];
55
66
  const allEmpty = results.length > 0 && results.every((result) => result.status === 'empty');
67
+ const allUnavailable = results.length > 0 && results.every((result) => result.status === 'unavailable');
56
68
  return {
57
- status: degraded.length > 0 ? 'degraded' : allEmpty ? 'empty' : 'ok',
69
+ status: allUnavailable ? 'unavailable' : degraded.length > 0 ? 'degraded' : allEmpty ? 'empty' : 'ok',
58
70
  ...(degraded.length > 0 ? { degraded } : {}),
59
71
  outcome: incomplete && effective > 0 ? 'partial' : effective > 0 ? 'ok' : 'noop',
60
72
  sources: results,
61
73
  };
62
74
  }
63
- async function retainExtracted(ctx, source, dryRun) {
75
+ async function resolveRetainSource(ctx, requested) {
76
+ const input = requested.input;
77
+ if ('text' in input || 'items' in input) {
78
+ const kind = 'text' in input ? 'text' : 'items';
79
+ return {
80
+ source: { ...requested, input },
81
+ requested,
82
+ sourceHash: sha256(JSON.stringify(input)),
83
+ sourceRef: requested.locator ?? requested.source_id,
84
+ binding: {
85
+ kind,
86
+ availability: 'available',
87
+ reextractable: requested.preserve_source !== undefined,
88
+ ...(requested.preserve_source
89
+ ? { preservedSlug: normalizeSlug(requested.preserve_source.slug) }
90
+ : {}),
91
+ },
92
+ degraded: [],
93
+ };
94
+ }
95
+ if ('page_slug' in input) {
96
+ const slug = normalizeSlug(input.page_slug);
97
+ const row = ctx.store.db.prepare('SELECT rel_path, role FROM pages WHERE slug = ?').get(slug);
98
+ if (!row)
99
+ return { result: unavailableSourceResult(requested, 'page', slug, 'source page is not indexed') };
100
+ if (row.role !== 'source') {
101
+ return {
102
+ result: unavailableSourceResult(requested, 'page', slug, 'page_slug accepts only a page whose effective role is source', 'validation_failed'),
103
+ };
104
+ }
105
+ const bytes = await fsp.readFile(path.join(ctx.config.aknoPath, row.rel_path), 'utf8').catch(() => null);
106
+ if (bytes === null) {
107
+ return { result: unavailableSourceResult(requested, 'page', slug, 'source page is unreadable') };
108
+ }
109
+ const sourceText = parsePage(row.rel_path, bytes).body;
110
+ if (!sourceText.trim()) {
111
+ return { result: unavailableSourceResult(requested, 'page', slug, 'source page has no readable body') };
112
+ }
113
+ return {
114
+ source: { ...requested, input: { text: sourceText } },
115
+ requested,
116
+ sourceHash: sha256(bytes),
117
+ sourceRef: requested.locator ?? slug,
118
+ binding: { kind: 'page', availability: 'available', reextractable: true, reference: slug },
119
+ degraded: [],
120
+ };
121
+ }
122
+ try {
123
+ const output = await read(ctx, { document: input.document_id });
124
+ if (!output.document) {
125
+ return {
126
+ result: unavailableSourceResult(requested, 'document', input.document_id, 'document reference did not resolve to readable document state'),
127
+ };
128
+ }
129
+ const document = output.document;
130
+ if (!document.text) {
131
+ return {
132
+ result: unavailableSourceResult(requested, 'document', document.id, output.note ?? 'document has no readable extraction or rendition'),
133
+ };
134
+ }
135
+ const degraded = output.status === 'ok' ? [] : (output.degraded ?? ['no_document_text']);
136
+ const documentParts = ctx.store.db
137
+ .prepare(`SELECT rel_path, sha256 FROM documents
138
+ WHERE group_key = (
139
+ SELECT group_key FROM documents WHERE id = ?
140
+ ) AND renders IS NULL
141
+ ORDER BY part`)
142
+ .all(document.id);
143
+ return {
144
+ source: { ...requested, input: { text: document.text } },
145
+ requested,
146
+ sourceHash: sha256(JSON.stringify({ id: document.id, parts: documentParts, text: document.text })),
147
+ sourceRef: requested.locator ?? document.rel_path,
148
+ binding: {
149
+ kind: 'document',
150
+ availability: output.status === 'ok' ? 'available' : 'degraded',
151
+ reextractable: true,
152
+ reference: document.id,
153
+ },
154
+ degraded,
155
+ };
156
+ }
157
+ catch (error) {
158
+ const note = error instanceof AknoError ? error.message : `document source failed: ${String(error)}`;
159
+ return { result: unavailableSourceResult(requested, 'document', input.document_id, note) };
160
+ }
161
+ }
162
+ function unavailableSourceResult(source, kind, reference, note, reasonCode = 'source_unavailable') {
163
+ return {
164
+ source_id: source.source_id,
165
+ revision: source.revision,
166
+ outcome: 'held',
167
+ status: 'unavailable',
168
+ reason_code: reasonCode,
169
+ candidates: [],
170
+ source: { kind, availability: 'unavailable', reextractable: true, reference },
171
+ note,
172
+ };
173
+ }
174
+ function sourceResultBinding(binding) {
175
+ return {
176
+ kind: binding.kind,
177
+ availability: binding.availability,
178
+ reextractable: binding.reextractable,
179
+ ...(binding.reference ? { reference: binding.reference } : {}),
180
+ ...(binding.preservedSlug ? { preserved_slug: binding.preservedSlug } : {}),
181
+ };
182
+ }
183
+ async function retainExtracted(ctx, resolved, dryRun) {
184
+ const source = resolved.source;
64
185
  if (source.retention.mode !== 'extract')
65
186
  throw new Error('retain extract received a provided source');
66
- const sourceHash = sha256(JSON.stringify(source.input));
67
- const requestHash = sha256(JSON.stringify(source));
187
+ const sourceHash = resolved.sourceHash;
188
+ const requestHash = sha256(JSON.stringify(resolved.requested));
68
189
  const replay = replayResult(ctx, source.source_id, source.revision, requestHash, sourceHash);
69
190
  if (replay)
70
191
  return replay;
71
192
  const groupIssue = sourceGroupIssue(ctx, source.source_id, source.source_group);
72
193
  if (groupIssue)
73
194
  return conflictResult(source.source_id, source.revision, groupIssue);
195
+ const correctionIssue = validateCorrectionTarget(ctx, source);
196
+ if (correctionIssue)
197
+ return correctionHoldResult(resolved, correctionIssue);
74
198
  const curator = retentionModel(ctx);
75
199
  const text = 'text' in source.input ? source.input.text : source.input.items.map((item) => item.text).join('\n');
76
200
  const extracted = await runRetain(text, curator, {
@@ -83,7 +207,7 @@ async function retainExtracted(ctx, source, dryRun) {
83
207
  revision: source.revision,
84
208
  });
85
209
  if (extracted.sourceHold) {
86
- return retainCandidates(ctx, source, [], {
210
+ return retainCandidates(ctx, resolved, [], {
87
211
  dryRun,
88
212
  selection: 'extracted',
89
213
  placement: 'automatic',
@@ -94,18 +218,20 @@ async function retainExtracted(ctx, source, dryRun) {
94
218
  }
95
219
  const initialResults = extracted.held.map(heldCandidateResult);
96
220
  if (extracted.error) {
97
- return {
98
- source_id: source.source_id,
99
- revision: source.revision,
100
- outcome: 'held',
101
- status: 'degraded',
102
- degraded: [extracted.degradedReason ?? 'derive_failed'],
103
- candidates: initialResults,
104
- model_usage: { ...extracted.modelUsage, placement: [] },
105
- note: `automatic retention could not complete: ${extracted.error}`,
106
- };
221
+ return retainCandidates(ctx, resolved, [], {
222
+ dryRun,
223
+ selection: 'extracted',
224
+ placement: 'automatic',
225
+ initialResults,
226
+ modelUsage: { ...extracted.modelUsage, placement: [] },
227
+ additionalDegraded: [extracted.degradedReason ?? 'derive_failed'],
228
+ sourceHold: {
229
+ reason_code: initialResults[0]?.reason_code ?? 'apply_failed',
230
+ reason: `automatic retention could not complete: ${extracted.error}`,
231
+ },
232
+ });
107
233
  }
108
- return retainCandidates(ctx, source, extracted.candidates, {
234
+ return retainCandidates(ctx, resolved, extracted.candidates, {
109
235
  dryRun,
110
236
  selection: 'extracted',
111
237
  placement: 'automatic',
@@ -113,16 +239,20 @@ async function retainExtracted(ctx, source, dryRun) {
113
239
  modelUsage: { ...extracted.modelUsage, placement: [] },
114
240
  });
115
241
  }
116
- async function retainCandidates(ctx, source, suppliedCandidates, options) {
117
- const sourceHash = sha256(JSON.stringify(source.input));
118
- const requestHash = sha256(JSON.stringify(source));
242
+ async function retainCandidates(ctx, resolvedSource, suppliedCandidates, options) {
243
+ const source = resolvedSource.source;
244
+ const sourceHash = resolvedSource.sourceHash;
245
+ const requestHash = sha256(JSON.stringify(resolvedSource.requested));
119
246
  const replay = replayResult(ctx, source.source_id, source.revision, requestHash, sourceHash);
120
247
  if (replay)
121
248
  return replay;
122
249
  const groupIssue = sourceGroupIssue(ctx, source.source_id, source.source_group);
123
250
  if (groupIssue)
124
251
  return conflictResult(source.source_id, source.revision, groupIssue);
125
- const resolved = options.placement === 'automatic'
252
+ const correctionIssue = validateCorrectionTarget(ctx, source);
253
+ if (correctionIssue)
254
+ return correctionHoldResult(resolvedSource, correctionIssue);
255
+ const resolved = options.placement === 'automatic' && !options.skipAutomaticRouting
126
256
  ? await resolveAutomaticCandidates(ctx, suppliedCandidates)
127
257
  : {
128
258
  candidates: suppliedCandidates.filter(hasDestination),
@@ -139,9 +269,74 @@ async function retainCandidates(ctx, source, suppliedCandidates, options) {
139
269
  const receiptFingerprint = managedMemoryFingerprint(`receipt:${source.source_id}:${source.revision}:${requestHash}`);
140
270
  const proofGroup = managedMemoryFingerprint(`proof:${source.source_group ?? source.source_id}`);
141
271
  const stages = new Map();
272
+ const effectiveBinding = options.sourceHold && resolvedSource.binding.preservedSlug
273
+ ? {
274
+ kind: resolvedSource.binding.kind,
275
+ availability: resolvedSource.binding.availability,
276
+ reextractable: false,
277
+ ...(resolvedSource.binding.reference ? { reference: resolvedSource.binding.reference } : {}),
278
+ }
279
+ : resolvedSource.binding;
280
+ if (effectiveBinding.preservedSlug) {
281
+ if (candidates.some((candidate) => normalizeSlug(candidate.destination.slug) === effectiveBinding.preservedSlug)) {
282
+ return {
283
+ source_id: source.source_id,
284
+ revision: source.revision,
285
+ outcome: 'held',
286
+ status: 'ok',
287
+ reason_code: 'validation_failed',
288
+ candidates: [],
289
+ source: sourceResultBinding({
290
+ kind: effectiveBinding.kind,
291
+ availability: effectiveBinding.availability,
292
+ reextractable: false,
293
+ ...(effectiveBinding.reference ? { reference: effectiveBinding.reference } : {}),
294
+ }),
295
+ note: 'a preserved source page cannot also be a retained-memory destination',
296
+ };
297
+ }
298
+ const preserved = await preservedSourceStage(ctx, stages, effectiveBinding.preservedSlug, resolvedSource.requested.input);
299
+ if ('issue' in preserved) {
300
+ return {
301
+ source_id: source.source_id,
302
+ revision: source.revision,
303
+ outcome: 'held',
304
+ status: 'ok',
305
+ reason_code: preserved.reasonCode,
306
+ candidates: [],
307
+ source: sourceResultBinding({
308
+ kind: resolvedSource.binding.kind,
309
+ availability: resolvedSource.binding.availability,
310
+ reextractable: false,
311
+ ...(resolvedSource.binding.reference ? { reference: resolvedSource.binding.reference } : {}),
312
+ }),
313
+ note: preserved.issue,
314
+ };
315
+ }
316
+ }
317
+ const correction = source.retracts
318
+ ? await stageRetractions(ctx, source.source_id, source.retracts.target_revision, source.retracts.candidate_ids, stages)
319
+ : { results: [], removed: [], valid: true };
320
+ if (!correction.valid) {
321
+ return {
322
+ source_id: source.source_id,
323
+ revision: source.revision,
324
+ outcome: 'held',
325
+ status: 'ok',
326
+ reason_code: 'conflict',
327
+ candidates: correction.results,
328
+ source: sourceResultBinding(effectiveBinding),
329
+ note: 'correction targets no longer match their owned memory blocks; nothing was changed',
330
+ };
331
+ }
142
332
  const pendingSupports = [];
143
- const candidateResults = [...options.initialResults, ...resolved.held];
144
- const candidateIds = new Set(suppliedCandidates.map((candidate) => candidate.candidate_id));
333
+ const suppliedCandidateIds = new Set(suppliedCandidates.map((candidate) => candidate.candidate_id));
334
+ const candidateResults = [
335
+ ...correction.results.filter((result) => !suppliedCandidateIds.has(result.candidate_id)),
336
+ ...options.initialResults,
337
+ ...resolved.held,
338
+ ];
339
+ const candidateIds = suppliedCandidateIds;
145
340
  const candidateOrder = new Map([
146
341
  ...options.initialResults.map((result) => result.candidate_id),
147
342
  ...suppliedCandidates.map((candidate) => candidate.candidate_id),
@@ -263,7 +458,7 @@ async function retainCandidates(ctx, source, suppliedCandidates, options) {
263
458
  outcome: 'held',
264
459
  slug,
265
460
  reason_code: 'conflict',
266
- reason: 'conflict',
461
+ reason: conflict.existing,
267
462
  });
268
463
  continue;
269
464
  }
@@ -300,7 +495,7 @@ async function retainCandidates(ctx, source, suppliedCandidates, options) {
300
495
  selection: options.selection,
301
496
  evidence,
302
497
  evidence_hash: sha256(evidence),
303
- source_ref: managedSourceReference(source.locator ?? source.source_id),
498
+ source_ref: managedSourceReference(resolvedSource.sourceRef),
304
499
  origin: sourceOrigin(candidate.attribution.source_role),
305
500
  input_hash: sourceHash,
306
501
  });
@@ -313,9 +508,42 @@ async function retainCandidates(ctx, source, suppliedCandidates, options) {
313
508
  (candidateOrder.get(right.candidate_id) ?? Number.MAX_SAFE_INTEGER));
314
509
  const changed = [...stages.values()].filter((stage) => stage.before !== stage.after);
315
510
  const placementDegraded = candidateResults.some((result) => result.reason_code === 'placement_degraded');
316
- const degraded = placementDegraded
317
- ? [retentionModel(ctx).available ? 'derive_failed' : 'no_derive_model']
318
- : [];
511
+ const degraded = [
512
+ ...resolvedSource.degraded,
513
+ ...(options.additionalDegraded ?? []),
514
+ ...(placementDegraded
515
+ ? [retentionModel(ctx).available ? 'derive_failed' : 'no_derive_model']
516
+ : []),
517
+ ];
518
+ const acceptedCandidateIds = new Set(candidateResults
519
+ .filter((result) => ['written', 'support_added', 'duplicate'].includes(result.outcome))
520
+ .map((result) => result.candidate_id));
521
+ const correctionAdmissionFailed = source.retracts !== undefined &&
522
+ (suppliedCandidateIds.size === 0 ||
523
+ [...suppliedCandidateIds].some((candidateId) => !acceptedCandidateIds.has(candidateId)) ||
524
+ candidateResults.some((result) => result.outcome === 'held' || result.outcome === 'not_found'));
525
+ if (correctionAdmissionFailed) {
526
+ return {
527
+ source_id: source.source_id,
528
+ revision: source.revision,
529
+ outcome: 'held',
530
+ status: degraded.length > 0 ? 'degraded' : 'ok',
531
+ reason_code: 'validation_failed',
532
+ candidates: candidateResults.map((result) => result.outcome === 'held' || result.outcome === 'not_found'
533
+ ? result
534
+ : {
535
+ candidate_id: result.candidate_id,
536
+ outcome: 'held',
537
+ ...(result.memory_id ? { memory_id: result.memory_id } : {}),
538
+ ...(result.slug ? { slug: result.slug } : {}),
539
+ reason_code: 'validation_failed',
540
+ reason: 'not applied because the compound correction was not fully admissible',
541
+ }),
542
+ source: sourceResultBinding(effectiveBinding),
543
+ ...(degraded.length > 0 ? { degraded } : {}),
544
+ note: 'compound correction was not fully admissible; neither removal nor replacement was written',
545
+ };
546
+ }
319
547
  const preview = {
320
548
  source_id: source.source_id,
321
549
  revision: source.revision,
@@ -327,6 +555,7 @@ async function retainCandidates(ctx, source, suppliedCandidates, options) {
327
555
  ? 'held'
328
556
  : 'noop',
329
557
  candidates: candidateResults,
558
+ source: sourceResultBinding(effectiveBinding),
330
559
  ...(options.sourceHold ? { reason_code: options.sourceHold.reason_code } : {}),
331
560
  status: degraded.length > 0
332
561
  ? 'degraded'
@@ -348,7 +577,7 @@ async function retainCandidates(ctx, source, suppliedCandidates, options) {
348
577
  };
349
578
  if (options.dryRun)
350
579
  return preview;
351
- const committed = await commitStages(ctx, changed, `retained ${candidateResults.filter((item) => ['written', 'support_added'].includes(item.outcome)).length} item(s) from one source revision`);
580
+ const committed = await commitStages(ctx, changed, `${options.journalOp === 'remember' ? 'remembered' : 'retained'} ${candidateResults.filter((item) => ['written', 'support_added'].includes(item.outcome)).length} item(s) from one source revision`, options.journalOp);
352
581
  const changeId = committed?.changeId ?? null;
353
582
  const result = { ...preview, ...(changeId ? { change_id: changeId } : {}) };
354
583
  try {
@@ -365,15 +594,58 @@ async function retainCandidates(ctx, source, suppliedCandidates, options) {
365
594
  result,
366
595
  changeId,
367
596
  supports: pendingSupports,
368
- retracted: [],
597
+ retracted: correction.removed,
598
+ binding: effectiveBinding,
369
599
  });
370
600
  }
371
601
  catch (error) {
372
602
  await rollbackCommittedStages(ctx, committed, error);
373
603
  }
374
- await reindexStages(ctx, changed);
604
+ options.onIndexed?.(await reindexStages(ctx, changed));
375
605
  return result;
376
606
  }
607
+ /**
608
+ * Compatibility adapter entrypoint: remember keeps its public routing/action response, while
609
+ * validation, owned-block staging, source archives, journaling, receipts, and indexing execute
610
+ * through the same engine as keyed retain.
611
+ */
612
+ export async function retainRememberCandidates(ctx, input) {
613
+ const inputHash = sha256(input.text);
614
+ const sourceId = `remember:${newPrefixedId('src')}`;
615
+ const requested = {
616
+ source_id: sourceId,
617
+ revision: '1',
618
+ source_group: `remember:${managedMemoryFingerprint(`${input.source ?? 'remember'}:${inputHash}`)}`,
619
+ source_kind: 'conversation',
620
+ ...(input.mentionedAt ? { mentioned_at: input.mentionedAt } : {}),
621
+ ...(input.timezone ? { timezone: input.timezone } : {}),
622
+ ...(input.source ? { locator: input.source } : {}),
623
+ input: { text: input.text },
624
+ retention: { mode: 'extract', ...(input.mission ? { mission: input.mission } : {}) },
625
+ };
626
+ const resolved = {
627
+ source: requested,
628
+ requested,
629
+ sourceHash: inputHash,
630
+ sourceRef: input.source ?? 'remember',
631
+ binding: { kind: 'text', availability: 'available', reextractable: false },
632
+ degraded: [],
633
+ };
634
+ let factsAdded = 0;
635
+ const result = await retainCandidates(ctx, resolved, input.candidates, {
636
+ dryRun: input.dryRun,
637
+ selection: 'extracted',
638
+ placement: 'automatic',
639
+ skipAutomaticRouting: true,
640
+ journalOp: 'remember',
641
+ onIndexed: (factsDerived) => {
642
+ factsAdded = factsDerived;
643
+ },
644
+ initialResults: [...input.held],
645
+ modelUsage: { ...input.modelUsage, placement: [] },
646
+ });
647
+ return { result, factsAdded };
648
+ }
377
649
  async function resolveAutomaticCandidates(ctx, candidates) {
378
650
  const resolved = [];
379
651
  const held = [];
@@ -435,34 +707,45 @@ function heldCandidateResult(candidate) {
435
707
  reason: candidate.reason,
436
708
  };
437
709
  }
438
- async function retractSource(ctx, source, dryRun) {
439
- const sourceHash = sha256('retraction');
440
- const requestHash = sha256(JSON.stringify(source));
441
- const replay = replayResult(ctx, source.source_id, source.revision, requestHash, sourceHash);
442
- if (replay)
443
- return replay;
710
+ function validateCorrectionTarget(ctx, source) {
711
+ if (!source.retracts)
712
+ return null;
713
+ const target = ctx.store.db
714
+ .prepare('SELECT receipt_fingerprint FROM retain_receipts WHERE source_id = ? AND revision = ?')
715
+ .get(source.source_id, source.retracts.target_revision);
716
+ if (!target)
717
+ return 'correction target_revision was not retained for this source';
718
+ const active = new Set(ctx.store.db
719
+ .prepare(`SELECT candidate_id FROM retain_supports
720
+ WHERE receipt_fingerprint = ? AND retracted_by IS NULL AND forgotten_by IS NULL`)
721
+ .all(target.receipt_fingerprint).map((row) => row.candidate_id));
722
+ const missing = source.retracts.candidate_ids.find((candidateId) => !active.has(candidateId));
723
+ return missing ? `correction target candidate ${missing} is not active` : null;
724
+ }
725
+ function correctionHoldResult(source, note) {
726
+ return {
727
+ source_id: source.source.source_id,
728
+ revision: source.source.revision,
729
+ outcome: 'held',
730
+ status: 'ok',
731
+ reason_code: 'conflict',
732
+ candidates: [],
733
+ source: sourceResultBinding(source.binding),
734
+ note,
735
+ };
736
+ }
737
+ async function stageRetractions(ctx, sourceId, targetRevision, candidateIds, stages) {
444
738
  const target = ctx.store.db
445
739
  .prepare('SELECT * FROM retain_receipts WHERE source_id = ? AND revision = ?')
446
- .get(source.source_id, source.retention.target_revision);
447
- if (!target) {
448
- return {
449
- source_id: source.source_id,
450
- revision: source.revision,
451
- outcome: 'held',
452
- reason_code: 'source_unavailable',
453
- candidates: [],
454
- note: 'target_revision was not retained',
455
- };
456
- }
740
+ .get(sourceId, targetRevision);
741
+ if (!target)
742
+ return { results: [], removed: [], valid: false };
457
743
  const selected = ctx.store.db
458
744
  .prepare(`SELECT * FROM retain_supports
459
745
  WHERE receipt_fingerprint = ? AND retracted_by IS NULL AND forgotten_by IS NULL
460
746
  ORDER BY candidate_id`)
461
747
  .all(target.receipt_fingerprint);
462
- const wanted = source.retention.candidate_ids
463
- ? new Set(source.retention.candidate_ids)
464
- : new Set(selected.map((row) => row.candidate_id));
465
- const stages = new Map();
748
+ const wanted = candidateIds ?? selected.map((row) => row.candidate_id);
466
749
  const results = [];
467
750
  const removed = [];
468
751
  for (const candidateId of wanted) {
@@ -513,6 +796,46 @@ async function retractSource(ctx, source, dryRun) {
513
796
  slug: support.slug,
514
797
  });
515
798
  }
799
+ return {
800
+ results,
801
+ removed,
802
+ valid: results.length === wanted.length && results.every((result) => result.outcome === 'retracted'),
803
+ };
804
+ }
805
+ async function retractSource(ctx, source, dryRun) {
806
+ const sourceHash = sha256('retraction');
807
+ const requestHash = sha256(JSON.stringify(source));
808
+ const replay = replayResult(ctx, source.source_id, source.revision, requestHash, sourceHash);
809
+ if (replay)
810
+ return replay;
811
+ const targetExists = ctx.store.db
812
+ .prepare('SELECT 1 FROM retain_receipts WHERE source_id = ? AND revision = ?')
813
+ .get(source.source_id, source.retention.target_revision);
814
+ if (!targetExists) {
815
+ return {
816
+ source_id: source.source_id,
817
+ revision: source.revision,
818
+ outcome: 'held',
819
+ reason_code: 'source_unavailable',
820
+ candidates: [],
821
+ note: 'target_revision was not retained',
822
+ };
823
+ }
824
+ const stages = new Map();
825
+ const staged = await stageRetractions(ctx, source.source_id, source.retention.target_revision, source.retention.candidate_ids, stages);
826
+ const results = staged.results;
827
+ const removed = staged.removed;
828
+ if (!staged.valid) {
829
+ return {
830
+ source_id: source.source_id,
831
+ revision: source.revision,
832
+ outcome: 'held',
833
+ status: 'ok',
834
+ reason_code: 'conflict',
835
+ candidates: results,
836
+ note: 'one or more retraction targets no longer match owned memory; nothing was changed',
837
+ };
838
+ }
516
839
  const changed = [...stages.values()].filter((stage) => stage.before !== stage.after);
517
840
  const preview = {
518
841
  source_id: source.source_id,
@@ -758,7 +1081,7 @@ async function pageStage(ctx, stages, slug, subject) {
758
1081
  if (!row && (await fsp.stat(absolute).catch(() => null))) {
759
1082
  return { issue: 'unindexed_page_exists' };
760
1083
  }
761
- const title = safeTitle(subject) || slugTitle(slug);
1084
+ const title = managedPageTitle(subject, slug);
762
1085
  const stage = {
763
1086
  slug,
764
1087
  relPath,
@@ -768,6 +1091,80 @@ async function pageStage(ctx, stages, slug, subject) {
768
1091
  stages.set(slug, stage);
769
1092
  return stage;
770
1093
  }
1094
+ async function preservedSourceStage(ctx, stages, slug, input) {
1095
+ if (!('text' in input || 'items' in input)) {
1096
+ return { issue: 'only inline source input can be preserved', reasonCode: 'validation_failed' };
1097
+ }
1098
+ if (isReserved(slug, ctx.config)) {
1099
+ return { issue: 'preserved source destination is reserved', reasonCode: 'no_writable_destination' };
1100
+ }
1101
+ const content = serializePreservedSource(slug, input);
1102
+ const row = ctx.store.db
1103
+ .prepare('SELECT rel_path, role, remember_management FROM pages WHERE slug = ?')
1104
+ .get(slug);
1105
+ if (row && (row.role !== 'source' || row.remember_management !== 'deny')) {
1106
+ return {
1107
+ issue: 'preserved source destination must be source + remember: deny',
1108
+ reasonCode: 'no_writable_destination',
1109
+ };
1110
+ }
1111
+ const relPath = row?.rel_path ?? `${slug}.md`;
1112
+ const absolute = path.join(ctx.config.aknoPath, relPath);
1113
+ if (row) {
1114
+ const before = await fsp.readFile(absolute, 'utf8').catch(() => null);
1115
+ if (before === null) {
1116
+ return { issue: 'preserved source destination is unreadable', reasonCode: 'source_unavailable' };
1117
+ }
1118
+ if (before !== content) {
1119
+ return {
1120
+ issue: 'preserved source destination already contains different bytes',
1121
+ reasonCode: 'conflict',
1122
+ };
1123
+ }
1124
+ const stage = { slug, relPath, before, after: before };
1125
+ stages.set(slug, stage);
1126
+ return stage;
1127
+ }
1128
+ if (await fsp.stat(absolute).catch(() => null)) {
1129
+ return { issue: 'an unindexed page exists at the preserved source destination', reasonCode: 'conflict' };
1130
+ }
1131
+ const policy = effectiveRule(slug, ctx.config.rules);
1132
+ const roleRule = declaringRule(slug, ctx.config.rules, 'role');
1133
+ const rememberRule = declaringRule(slug, ctx.config.rules, 'remember');
1134
+ if (policy.role !== 'source' || policy.remember !== 'deny' || !roleRule || !rememberRule) {
1135
+ return {
1136
+ issue: 'preserved source folder must explicitly declare role: source and remember: deny',
1137
+ reasonCode: 'no_writable_destination',
1138
+ };
1139
+ }
1140
+ const gate = ctx.gate.check(slug, ctx.actor);
1141
+ if (!gate.allowed) {
1142
+ return {
1143
+ issue: `preserved source folder is not declared: ${gate.requiresFolder.folder}`,
1144
+ reasonCode: 'no_writable_destination',
1145
+ };
1146
+ }
1147
+ const stage = { slug, relPath, before: null, after: content };
1148
+ stages.set(slug, stage);
1149
+ return stage;
1150
+ }
1151
+ function serializePreservedSource(slug, input) {
1152
+ const title = slugTitle(slug);
1153
+ const body = 'text' in input
1154
+ ? input.text
1155
+ : input.items
1156
+ .map((item) => {
1157
+ const attributes = [
1158
+ `id=${encodeURIComponent(item.item_id)}`,
1159
+ ...(item.role ? [`role=${item.role}`] : []),
1160
+ ...(item.speaker ? [`speaker=${encodeURIComponent(item.speaker)}`] : []),
1161
+ ...(item.mentioned_at ? [`mentioned-at=${encodeURIComponent(item.mentioned_at)}`] : []),
1162
+ ];
1163
+ return `<!-- akno:source-item ${attributes.join(' ')} -->\n${item.text}`;
1164
+ })
1165
+ .join('\n\n');
1166
+ return `---\ntitle: ${serializeYamlString(title, 'title')}\nakno:\n role: source\n management:\n remember: deny\n---\n\n# ${title}\n\n## Source\n\n${body}\n`;
1167
+ }
771
1168
  async function existingPageStage(ctx, stages, slug) {
772
1169
  const cached = stages.get(slug);
773
1170
  if (cached)
@@ -833,15 +1230,24 @@ function removeBlock(content, markerIndex, payloadIndex) {
833
1230
  lines.splice(markerIndex, payloadIndex - markerIndex + 1);
834
1231
  return prefix + lines.join('\n').replace(/\n{3,}/g, '\n\n');
835
1232
  }
836
- async function commitStages(ctx, stages, summary) {
1233
+ async function commitStages(ctx, stages, summary, op = 'retain') {
837
1234
  if (stages.length === 0)
838
1235
  return null;
1236
+ for (const stage of stages) {
1237
+ if (quarantineReasonsForPath(ctx.store, stage.relPath).length > 0 ||
1238
+ matchesConflictPath(stage.relPath, ctx.config.index.conflictPathPatterns) ||
1239
+ hasInlineMergeConflict(stage.after)) {
1240
+ throw new AknoError('conflict', 'retain destination is quarantined by Markdown conflict', {
1241
+ reason: 'source_conflict',
1242
+ });
1243
+ }
1244
+ }
839
1245
  const files = [];
840
1246
  try {
841
1247
  for (const stage of stages) {
842
1248
  files.push(fileEntry(await writeFileAtomic(ctx.config.aknoPath, stage.relPath, stage.after)));
843
1249
  }
844
- const changeId = ctx.journal.record({ actor: ctx.actor, op: 'retain', summary, files });
1250
+ const changeId = ctx.journal.record({ actor: ctx.actor, op, summary, files });
845
1251
  return { changeId, files };
846
1252
  }
847
1253
  catch (error) {
@@ -881,10 +1287,11 @@ async function rollbackCommittedStages(ctx, committed, cause) {
881
1287
  }
882
1288
  async function reindexStages(ctx, stages) {
883
1289
  if (stages.length === 0)
884
- return;
1290
+ return 0;
885
1291
  const paths = stages.map((stage) => stage.relPath);
886
- await ctx.indexer.runForeground({ only: paths, modelPaths: [] });
1292
+ const report = await ctx.indexer.runForeground({ only: paths, modelPaths: [] });
887
1293
  ctx.derive.schedule(paths);
1294
+ return report.factsDerived;
888
1295
  }
889
1296
  function persistReceipt(ctx, input) {
890
1297
  const now = new Date().toISOString();
@@ -908,11 +1315,21 @@ function persistReceipt(ctx, input) {
908
1315
  for (const support of input.retracted) {
909
1316
  retract.run(input.receiptFingerprint, support.receipt_fingerprint, support.candidate_id);
910
1317
  }
1318
+ if (input.binding) {
1319
+ ctx.store.db
1320
+ .prepare(`INSERT INTO retain_source_bindings(
1321
+ receipt_fingerprint, input_kind, input_ref, preserved_slug,
1322
+ availability, reextractable, created_at
1323
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`)
1324
+ .run(input.receiptFingerprint, input.binding.kind, input.binding.reference ?? null, input.binding.preservedSlug ?? null, input.binding.availability, input.binding.reextractable ? 1 : 0, now);
1325
+ }
911
1326
  });
912
1327
  reconcileRetainManagedSources(ctx, [
913
1328
  ...input.supports.map((support) => support.memory_id),
914
1329
  ...input.retracted.map((support) => support.memory_id),
915
1330
  ]);
1331
+ if (input.retracted.length > 0)
1332
+ pruneRetainEvidence(ctx, { apply: true });
916
1333
  }
917
1334
  function sourceEvidence(candidate) {
918
1335
  return candidate.discourse_frame.map((span) => span.quote).join('\n…\n');
@@ -975,6 +1392,17 @@ function slugTitle(slug) {
975
1392
  const name = slug.slice(slug.lastIndexOf('/') + 1).replaceAll('-', ' ');
976
1393
  return name.replace(/\b\w/g, (letter) => letter.toUpperCase());
977
1394
  }
1395
+ function managedPageTitle(subject, slug) {
1396
+ const cleaned = safeTitle(subject);
1397
+ if (!cleaned)
1398
+ return titleFromSlug(slug);
1399
+ const named = contentWords(slug.slice(slug.lastIndexOf('/') + 1));
1400
+ for (const word of contentWords(cleaned)) {
1401
+ if (named.has(word))
1402
+ return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
1403
+ }
1404
+ return titleFromSlug(slug);
1405
+ }
978
1406
  function safeTitle(value) {
979
1407
  return value
980
1408
  .replaceAll('\r', ' ')