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