@tenphi/akno-core 0.4.0 → 0.6.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 (78) hide show
  1. package/dist/index/derive.d.ts.map +1 -1
  2. package/dist/index/derive.js +1 -10
  3. package/dist/index/derive.js.map +1 -1
  4. package/dist/index/indexer.d.ts +3 -0
  5. package/dist/index/indexer.d.ts.map +1 -1
  6. package/dist/index/indexer.js +33 -3
  7. package/dist/index/indexer.js.map +1 -1
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/kb/managed-lines.d.ts +7 -0
  12. package/dist/kb/managed-lines.d.ts.map +1 -0
  13. package/dist/kb/managed-lines.js +52 -0
  14. package/dist/kb/managed-lines.js.map +1 -0
  15. package/dist/maintenance/conflicts.d.ts +1 -1
  16. package/dist/ops/answer.d.ts.map +1 -1
  17. package/dist/ops/answer.js +49 -4
  18. package/dist/ops/answer.js.map +1 -1
  19. package/dist/ops/context.d.ts.map +1 -1
  20. package/dist/ops/context.js +49 -19
  21. package/dist/ops/context.js.map +1 -1
  22. package/dist/ops/read.d.ts.map +1 -1
  23. package/dist/ops/read.js +2 -1
  24. package/dist/ops/read.js.map +1 -1
  25. package/dist/ops/remember.d.ts +57 -0
  26. package/dist/ops/remember.d.ts.map +1 -1
  27. package/dist/ops/remember.js +40 -38
  28. package/dist/ops/remember.js.map +1 -1
  29. package/dist/ops/retain.d.ts.map +1 -1
  30. package/dist/ops/retain.js +281 -31
  31. package/dist/ops/retain.js.map +1 -1
  32. package/dist/ops/timeline.d.ts +2 -5
  33. package/dist/ops/timeline.d.ts.map +1 -1
  34. package/dist/ops/timeline.js +334 -82
  35. package/dist/ops/timeline.js.map +1 -1
  36. package/dist/recall/assemble.d.ts.map +1 -1
  37. package/dist/recall/assemble.js +4 -2
  38. package/dist/recall/assemble.js.map +1 -1
  39. package/dist/store/db.d.ts.map +1 -1
  40. package/dist/store/db.js +11 -1
  41. package/dist/store/db.js.map +1 -1
  42. package/dist/store/migrations.d.ts +3 -1
  43. package/dist/store/migrations.d.ts.map +1 -1
  44. package/dist/store/migrations.js +103 -1
  45. package/dist/store/migrations.js.map +1 -1
  46. package/dist/timeline/clock.d.ts +25 -0
  47. package/dist/timeline/clock.d.ts.map +1 -0
  48. package/dist/timeline/clock.js +257 -0
  49. package/dist/timeline/clock.js.map +1 -0
  50. package/dist/timeline/documents.d.ts +2 -0
  51. package/dist/timeline/documents.d.ts.map +1 -1
  52. package/dist/timeline/documents.js +28 -0
  53. package/dist/timeline/documents.js.map +1 -1
  54. package/dist/timeline/eligibility.d.ts +16 -0
  55. package/dist/timeline/eligibility.d.ts.map +1 -0
  56. package/dist/timeline/eligibility.js +31 -0
  57. package/dist/timeline/eligibility.js.map +1 -0
  58. package/dist/timeline/projection.d.ts +11 -0
  59. package/dist/timeline/projection.d.ts.map +1 -0
  60. package/dist/timeline/projection.js +77 -0
  61. package/dist/timeline/projection.js.map +1 -0
  62. package/dist/timeline/recurrence.d.ts +14 -0
  63. package/dist/timeline/recurrence.d.ts.map +1 -0
  64. package/dist/timeline/recurrence.js +238 -0
  65. package/dist/timeline/recurrence.js.map +1 -0
  66. package/dist/write/managed-memory.d.ts +8 -0
  67. package/dist/write/managed-memory.d.ts.map +1 -1
  68. package/dist/write/managed-memory.js +20 -0
  69. package/dist/write/managed-memory.js.map +1 -1
  70. package/dist/write/placement.d.ts +3 -1
  71. package/dist/write/placement.d.ts.map +1 -1
  72. package/dist/write/placement.js +4 -1
  73. package/dist/write/placement.js.map +1 -1
  74. package/dist/write/retain.d.ts +158 -30
  75. package/dist/write/retain.d.ts.map +1 -1
  76. package/dist/write/retain.js +723 -180
  77. package/dist/write/retain.js.map +1 -1
  78. package/package.json +2 -2
@@ -1,6 +1,7 @@
1
1
  import fsp from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { RetainInput, } from '@tenphi/akno-protocol';
4
+ import { ModelClient } from "../models/client.js";
4
5
  import { folderCatalog } from "../kb/folders.js";
5
6
  import { parseFrontmatter, serializeYamlString } from "../kb/frontmatter.js";
6
7
  import { parsePage } from "../kb/page.js";
@@ -10,8 +11,11 @@ import { restoreFile, writeFileAtomic } from "../write/atomic.js";
10
11
  import { detectConflict } from "../write/conflict.js";
11
12
  import { fileEntry } from "../write/journal.js";
12
13
  import { managedMemoryBlock, managedMemoryFingerprint, markerFromProvidedCandidate, parseManagedMemoryMarker, renderManagedMemoryMarker, renderManagedMemoryPayload, sameManagedMemorySemantics, } from "../write/managed-memory.js";
13
- import { appendManagedBlock, managedSourceReference } from "../write/placement.js";
14
+ import { appendManagedBlock, managedSourceReference, placeManagedItems } from "../write/placement.js";
15
+ import { modelCallReceipt, runRetain, } from "../write/retain.js";
16
+ import { resolveRememberFallback } from "../write/remember-fallback.js";
14
17
  import { reconcileRetainManagedSources } from "../write/retain-supports.js";
18
+ import { routeAutomaticCandidate } from "./remember.js";
15
19
  import { normalizeSlug } from "./write.js";
16
20
  export async function retain(ctx, rawInput) {
17
21
  const input = RetainInput.parse(rawInput);
@@ -19,7 +23,15 @@ export async function retain(ctx, rawInput) {
19
23
  for (const source of input.sources) {
20
24
  try {
21
25
  results.push('input' in source
22
- ? await retainProvidedExact(ctx, source, input.dry_run ?? false)
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
+ })
23
35
  : await retractSource(ctx, source, input.dry_run ?? false));
24
36
  }
25
37
  catch (error) {
@@ -27,43 +39,121 @@ export async function retain(ctx, rawInput) {
27
39
  source_id: source.source_id,
28
40
  revision: source.revision,
29
41
  outcome: 'held',
42
+ status: 'degraded',
43
+ degraded: ['retain_apply_failed'],
44
+ reason_code: 'apply_failed',
30
45
  candidates: [],
31
46
  note: `source apply failed: ${error instanceof Error ? error.message : String(error)}`,
32
47
  });
33
48
  }
34
49
  }
35
- const held = results.filter((result) => result.outcome === 'held' || result.outcome === 'revision_conflict').length;
50
+ const incomplete = results.some((result) => result.outcome === 'held' ||
51
+ result.outcome === 'revision_conflict' ||
52
+ result.candidates.some((candidate) => candidate.outcome === 'held' || candidate.outcome === 'not_found'));
36
53
  const effective = results.filter((result) => result.outcome === 'ok').length;
54
+ const degraded = [...new Set(results.flatMap((result) => result.degraded ?? []))];
55
+ const allEmpty = results.length > 0 && results.every((result) => result.status === 'empty');
37
56
  return {
38
- status: 'ok',
39
- outcome: held > 0 && effective > 0 ? 'partial' : effective > 0 ? 'ok' : 'noop',
57
+ status: degraded.length > 0 ? 'degraded' : allEmpty ? 'empty' : 'ok',
58
+ ...(degraded.length > 0 ? { degraded } : {}),
59
+ outcome: incomplete && effective > 0 ? 'partial' : effective > 0 ? 'ok' : 'noop',
40
60
  sources: results,
41
61
  };
42
62
  }
43
- async function retainProvidedExact(ctx, source, dryRun) {
63
+ async function retainExtracted(ctx, source, dryRun) {
64
+ if (source.retention.mode !== 'extract')
65
+ throw new Error('retain extract received a provided source');
44
66
  const sourceHash = sha256(JSON.stringify(source.input));
45
67
  const requestHash = sha256(JSON.stringify(source));
46
68
  const replay = replayResult(ctx, source.source_id, source.revision, requestHash, sourceHash);
47
69
  if (replay)
48
70
  return replay;
49
71
  const groupIssue = sourceGroupIssue(ctx, source.source_id, source.source_group);
50
- if (groupIssue) {
72
+ if (groupIssue)
51
73
  return conflictResult(source.source_id, source.revision, groupIssue);
74
+ const curator = retentionModel(ctx);
75
+ const text = 'text' in source.input ? source.input.text : source.input.items.map((item) => item.text).join('\n');
76
+ const extracted = await runRetain(text, curator, {
77
+ ...(source.mentioned_at ? { mentionedAt: source.mentioned_at } : {}),
78
+ ...(source.timezone ? { timezone: source.timezone } : {}),
79
+ ...(source.retention.mission ? { mission: source.retention.mission } : {}),
80
+ ...('items' in source.input ? { sourceItems: source.input.items } : {}),
81
+ folders: folderCatalog(ctx.config, ctx.store),
82
+ sourceId: source.source_id,
83
+ revision: source.revision,
84
+ });
85
+ if (extracted.sourceHold) {
86
+ return retainCandidates(ctx, source, [], {
87
+ dryRun,
88
+ selection: 'extracted',
89
+ placement: 'automatic',
90
+ initialResults: [],
91
+ modelUsage: { ...extracted.modelUsage, placement: [] },
92
+ sourceHold: extracted.sourceHold,
93
+ });
94
+ }
95
+ const initialResults = extracted.held.map(heldCandidateResult);
96
+ 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
+ };
52
107
  }
108
+ return retainCandidates(ctx, source, extracted.candidates, {
109
+ dryRun,
110
+ selection: 'extracted',
111
+ placement: 'automatic',
112
+ initialResults,
113
+ modelUsage: { ...extracted.modelUsage, placement: [] },
114
+ });
115
+ }
116
+ async function retainCandidates(ctx, source, suppliedCandidates, options) {
117
+ const sourceHash = sha256(JSON.stringify(source.input));
118
+ const requestHash = sha256(JSON.stringify(source));
119
+ const replay = replayResult(ctx, source.source_id, source.revision, requestHash, sourceHash);
120
+ if (replay)
121
+ return replay;
122
+ const groupIssue = sourceGroupIssue(ctx, source.source_id, source.source_group);
123
+ if (groupIssue)
124
+ return conflictResult(source.source_id, source.revision, groupIssue);
125
+ const resolved = options.placement === 'automatic'
126
+ ? await resolveAutomaticCandidates(ctx, suppliedCandidates)
127
+ : {
128
+ candidates: suppliedCandidates.filter(hasDestination),
129
+ held: suppliedCandidates
130
+ .filter((candidate) => !candidate.destination)
131
+ .map((candidate) => ({
132
+ candidate_id: candidate.candidate_id,
133
+ outcome: 'held',
134
+ reason_code: 'validation_failed',
135
+ reason: 'provided exact candidates require a destination',
136
+ })),
137
+ };
138
+ const candidates = resolved.candidates;
53
139
  const receiptFingerprint = managedMemoryFingerprint(`receipt:${source.source_id}:${source.revision}:${requestHash}`);
54
140
  const proofGroup = managedMemoryFingerprint(`proof:${source.source_group ?? source.source_id}`);
55
141
  const stages = new Map();
56
142
  const pendingSupports = [];
57
- const candidateResults = [];
58
- const candidateIds = new Set(source.retention.candidates.map((candidate) => candidate.candidate_id));
59
- const candidateOrder = new Map(source.retention.candidates.map((candidate, index) => [candidate.candidate_id, index]));
143
+ const candidateResults = [...options.initialResults, ...resolved.held];
144
+ const candidateIds = new Set(suppliedCandidates.map((candidate) => candidate.candidate_id));
145
+ const candidateOrder = new Map([
146
+ ...options.initialResults.map((result) => result.candidate_id),
147
+ ...suppliedCandidates.map((candidate) => candidate.candidate_id),
148
+ ].map((candidateId, index) => [candidateId, index]));
60
149
  const candidateMemoryIds = new Map();
61
- const ordered = dependencyOrder(source.retention.candidates);
62
- for (const candidate of source.retention.candidates) {
150
+ const ordered = dependencyOrder(candidates);
151
+ for (const candidate of candidates) {
63
152
  if (ordered.blocked.has(candidate.candidate_id)) {
64
153
  candidateResults.push({
65
154
  candidate_id: candidate.candidate_id,
66
155
  outcome: 'held',
156
+ reason_code: 'validation_failed',
67
157
  reason: 'candidate relation cycle or blocked dependency',
68
158
  });
69
159
  }
@@ -71,7 +161,12 @@ async function retainProvidedExact(ctx, source, dryRun) {
71
161
  for (const candidate of ordered.candidates) {
72
162
  const issue = candidateIssue(ctx, source, candidate, candidateIds);
73
163
  if (issue) {
74
- candidateResults.push({ candidate_id: candidate.candidate_id, outcome: 'held', reason: issue });
164
+ candidateResults.push({
165
+ candidate_id: candidate.candidate_id,
166
+ outcome: 'held',
167
+ reason_code: candidateIssueReasonCode(issue),
168
+ reason: issue,
169
+ });
75
170
  continue;
76
171
  }
77
172
  const missingTarget = (candidate.relations ?? []).find((relation) => 'candidate_id' in relation.target && !candidateMemoryIds.has(relation.target.candidate_id));
@@ -79,6 +174,7 @@ async function retainProvidedExact(ctx, source, dryRun) {
79
174
  candidateResults.push({
80
175
  candidate_id: candidate.candidate_id,
81
176
  outcome: 'held',
177
+ reason_code: 'validation_failed',
82
178
  reason: `relation target candidate ${missingTarget.target.candidate_id} was not retained`,
83
179
  });
84
180
  continue;
@@ -90,6 +186,7 @@ async function retainProvidedExact(ctx, source, dryRun) {
90
186
  candidate_id: candidate.candidate_id,
91
187
  outcome: 'held',
92
188
  slug,
189
+ reason_code: stage.issue.includes('unreadable') ? 'source_unavailable' : 'no_writable_destination',
93
190
  reason: stage.issue,
94
191
  });
95
192
  continue;
@@ -104,7 +201,7 @@ async function retainProvidedExact(ctx, source, dryRun) {
104
201
  receipt: receiptFingerprint,
105
202
  candidate: candidateFingerprint,
106
203
  proofGroup,
107
- selection: 'provided',
204
+ selection: options.selection,
108
205
  };
109
206
  const proposedId = newPrefixedId('mem');
110
207
  const marker = markerFromProvidedCandidate(proposedId, candidate, support, candidateMemoryIds);
@@ -124,6 +221,7 @@ async function retainProvidedExact(ctx, source, dryRun) {
124
221
  outcome: 'held',
125
222
  memory_id: memoryId,
126
223
  slug,
224
+ reason_code: 'support_limit',
127
225
  reason: 'support_limit',
128
226
  });
129
227
  continue;
@@ -136,13 +234,15 @@ async function retainProvidedExact(ctx, source, dryRun) {
136
234
  }
137
235
  else {
138
236
  const row = ctx.store.db.prepare('SELECT id FROM pages WHERE slug = ?').get(slug);
139
- if (stage.before !== null &&
237
+ if (options.placement === 'exact' &&
238
+ stage.before !== null &&
140
239
  candidate.destination.section &&
141
240
  countSection(stage.after, candidate.destination.section) !== 1) {
142
241
  candidateResults.push({
143
242
  candidate_id: candidate.candidate_id,
144
243
  outcome: 'held',
145
244
  slug,
245
+ reason_code: 'validation_failed',
146
246
  reason: 'exact destination section is absent or ambiguous',
147
247
  });
148
248
  continue;
@@ -162,12 +262,32 @@ async function retainProvidedExact(ctx, source, dryRun) {
162
262
  candidate_id: candidate.candidate_id,
163
263
  outcome: 'held',
164
264
  slug,
265
+ reason_code: 'conflict',
165
266
  reason: 'conflict',
166
267
  });
167
268
  continue;
168
269
  }
169
270
  }
170
- stage.after = appendBlock(stage.after, candidate.destination.section ?? 'Unsorted', managedMemoryBlock(marker, payload));
271
+ if (options.placement === 'automatic') {
272
+ const curator = retentionModel(ctx);
273
+ const placed = await placeManagedItems(stage.after, [{ id: memoryId, marker, text: payload }], curator);
274
+ stage.after = placed.content;
275
+ if (placed.modelOutcome)
276
+ options.modelUsage.placement.push(modelCallReceipt(curator, placed.modelOutcome));
277
+ if (placed.error) {
278
+ candidateResults.push({
279
+ candidate_id: candidate.candidate_id,
280
+ outcome,
281
+ memory_id: memoryId,
282
+ slug,
283
+ reason_code: 'placement_degraded',
284
+ reason: `written under Unsorted because semantic section placement degraded: ${placed.error}`,
285
+ });
286
+ }
287
+ }
288
+ else {
289
+ stage.after = appendBlock(stage.after, candidate.destination.section ?? 'Unsorted', managedMemoryBlock(marker, payload));
290
+ }
171
291
  }
172
292
  const evidence = sourceEvidence(candidate);
173
293
  pendingSupports.push({
@@ -177,31 +297,56 @@ async function retainProvidedExact(ctx, source, dryRun) {
177
297
  proof_group: proofGroup,
178
298
  memory_id: memoryId,
179
299
  slug,
180
- selection: 'provided',
300
+ selection: options.selection,
181
301
  evidence,
182
302
  evidence_hash: sha256(evidence),
183
303
  source_ref: managedSourceReference(source.locator ?? source.source_id),
184
304
  origin: sourceOrigin(candidate.attribution.source_role),
185
305
  input_hash: sourceHash,
186
306
  });
187
- candidateResults.push({
188
- candidate_id: candidate.candidate_id,
189
- outcome,
190
- memory_id: memoryId,
191
- slug,
192
- });
307
+ if (!candidateResults.some((result) => result.candidate_id === candidate.candidate_id)) {
308
+ candidateResults.push({ candidate_id: candidate.candidate_id, outcome, memory_id: memoryId, slug });
309
+ }
193
310
  candidateMemoryIds.set(candidate.candidate_id, memoryId);
194
311
  }
195
- candidateResults.sort((left, right) => candidateOrder.get(left.candidate_id) - candidateOrder.get(right.candidate_id));
312
+ candidateResults.sort((left, right) => (candidateOrder.get(left.candidate_id) ?? Number.MAX_SAFE_INTEGER) -
313
+ (candidateOrder.get(right.candidate_id) ?? Number.MAX_SAFE_INTEGER));
196
314
  const changed = [...stages.values()].filter((stage) => stage.before !== stage.after);
315
+ const placementDegraded = candidateResults.some((result) => result.reason_code === 'placement_degraded');
316
+ const degraded = placementDegraded
317
+ ? [retentionModel(ctx).available ? 'derive_failed' : 'no_derive_model']
318
+ : [];
197
319
  const preview = {
198
320
  source_id: source.source_id,
199
321
  revision: source.revision,
200
- outcome: changed.length > 0 ? 'ok' : candidateResults.some((item) => item.outcome === 'held') ? 'held' : 'noop',
322
+ outcome: options.sourceHold
323
+ ? 'held'
324
+ : changed.length > 0
325
+ ? 'ok'
326
+ : candidateResults.some((item) => item.outcome === 'held')
327
+ ? 'held'
328
+ : 'noop',
201
329
  candidates: candidateResults,
202
- ...(dryRun ? { note: 'dry run — nothing was written and no replay receipt was created' } : {}),
330
+ ...(options.sourceHold ? { reason_code: options.sourceHold.reason_code } : {}),
331
+ status: degraded.length > 0
332
+ ? 'degraded'
333
+ : options.sourceHold
334
+ ? 'ok'
335
+ : candidateResults.length === 0
336
+ ? 'empty'
337
+ : 'ok',
338
+ ...(degraded.length > 0 ? { degraded } : {}),
339
+ ...(options.placement === 'automatic' ? { model_usage: options.modelUsage } : {}),
340
+ ...(options.sourceHold ? { note: options.sourceHold.reason } : {}),
341
+ ...(options.dryRun
342
+ ? {
343
+ note: options.sourceHold
344
+ ? `${options.sourceHold.reason}; dry run — no replay receipt was created`
345
+ : 'dry run — nothing was written and no replay receipt was created',
346
+ }
347
+ : {}),
203
348
  };
204
- if (dryRun)
349
+ if (options.dryRun)
205
350
  return preview;
206
351
  const committed = await commitStages(ctx, changed, `retained ${candidateResults.filter((item) => ['written', 'support_added'].includes(item.outcome)).length} item(s) from one source revision`);
207
352
  const changeId = committed?.changeId ?? null;
@@ -212,7 +357,11 @@ async function retainProvidedExact(ctx, source, dryRun) {
212
357
  sourceHash,
213
358
  requestHash,
214
359
  receiptFingerprint,
215
- mode: 'provided_exact',
360
+ mode: options.selection === 'extracted'
361
+ ? 'extract_automatic'
362
+ : options.placement === 'automatic'
363
+ ? 'provided_automatic'
364
+ : 'provided_exact',
216
365
  result,
217
366
  changeId,
218
367
  supports: pendingSupports,
@@ -225,6 +374,67 @@ async function retainProvidedExact(ctx, source, dryRun) {
225
374
  await reindexStages(ctx, changed);
226
375
  return result;
227
376
  }
377
+ async function resolveAutomaticCandidates(ctx, candidates) {
378
+ const resolved = [];
379
+ const held = [];
380
+ const catalog = folderCatalog(ctx.config, ctx.store);
381
+ const fallback = await resolveRememberFallback(ctx, catalog);
382
+ for (const candidate of candidates) {
383
+ const suggested = candidate.destination?.slug;
384
+ const routed = await routeAutomaticCandidate(ctx, {
385
+ ...candidate,
386
+ ...(suggested ? { page: suggested } : {}),
387
+ }, { constrainToSuggestedFolder: suggested !== undefined });
388
+ let slug = routed.slug;
389
+ if (!slug && suggested && !pageExists(ctx, suggested) && admittedAutomaticSlug(catalog, suggested)) {
390
+ slug = suggested;
391
+ }
392
+ if (!slug && fallback && fallback.status !== 'unavailable')
393
+ slug = fallback.slug;
394
+ if (!slug) {
395
+ const reasonCode = routed.blocked || (!suggested && routed.nearest.length === 0)
396
+ ? 'no_writable_destination'
397
+ : 'routing_uncertain';
398
+ held.push({
399
+ candidate_id: candidate.candidate_id,
400
+ outcome: 'held',
401
+ reason_code: reasonCode,
402
+ reason: routed.blocked
403
+ ? `the strongest semantic destination is read-only: ${routed.blocked}`
404
+ : suggested && pageExists(ctx, suggested)
405
+ ? 'the suggested existing page was not judged a safe semantic destination'
406
+ : 'no admitted writable destination could be established',
407
+ });
408
+ continue;
409
+ }
410
+ resolved.push({ ...candidate, destination: { slug } });
411
+ }
412
+ return { candidates: resolved, held };
413
+ }
414
+ function hasDestination(candidate) {
415
+ return candidate.destination !== undefined;
416
+ }
417
+ function pageExists(ctx, slug) {
418
+ return ctx.store.db.prepare('SELECT 1 FROM pages WHERE slug = ?').get(slug) !== undefined;
419
+ }
420
+ function admittedAutomaticSlug(catalog, slug) {
421
+ const parent = slug.slice(0, slug.lastIndexOf('/'));
422
+ return catalog.some((folder) => folder.path === parent &&
423
+ folder.role === 'knowledge' &&
424
+ folder.remember === 'integrate' &&
425
+ folder.creatable);
426
+ }
427
+ function retentionModel(ctx) {
428
+ return ctx.config.maintenance.model ? new ModelClient(ctx.config.maintenance.model) : ctx.models.derive;
429
+ }
430
+ function heldCandidateResult(candidate) {
431
+ return {
432
+ candidate_id: candidate.candidate_id,
433
+ outcome: 'held',
434
+ reason_code: candidate.reason_code,
435
+ reason: candidate.reason,
436
+ };
437
+ }
228
438
  async function retractSource(ctx, source, dryRun) {
229
439
  const sourceHash = sha256('retraction');
230
440
  const requestHash = sha256(JSON.stringify(source));
@@ -239,6 +449,7 @@ async function retractSource(ctx, source, dryRun) {
239
449
  source_id: source.source_id,
240
450
  revision: source.revision,
241
451
  outcome: 'held',
452
+ reason_code: 'source_unavailable',
242
453
  candidates: [],
243
454
  note: 'target_revision was not retained',
244
455
  };
@@ -262,17 +473,32 @@ async function retractSource(ctx, source, dryRun) {
262
473
  }
263
474
  const stage = await existingPageStage(ctx, stages, support.slug);
264
475
  if ('issue' in stage) {
265
- results.push({ candidate_id: candidateId, outcome: 'held', reason: stage.issue });
476
+ results.push({
477
+ candidate_id: candidateId,
478
+ outcome: 'held',
479
+ reason_code: 'source_unavailable',
480
+ reason: stage.issue,
481
+ });
266
482
  continue;
267
483
  }
268
484
  const block = managedBlocks(stage.after).find((entry) => entry.marker.id === support.memory_id);
269
485
  if (!block) {
270
- results.push({ candidate_id: candidateId, outcome: 'held', reason: 'owned memory block changed' });
486
+ results.push({
487
+ candidate_id: candidateId,
488
+ outcome: 'held',
489
+ reason_code: 'conflict',
490
+ reason: 'owned memory block changed',
491
+ });
271
492
  continue;
272
493
  }
273
494
  const nextSupports = block.marker.supports.filter((entry) => !(entry.receipt === support.receipt_fingerprint && entry.candidate === support.candidate_fingerprint));
274
495
  if (nextSupports.length === block.marker.supports.length) {
275
- results.push({ candidate_id: candidateId, outcome: 'held', reason: 'support marker changed' });
496
+ results.push({
497
+ candidate_id: candidateId,
498
+ outcome: 'held',
499
+ reason_code: 'conflict',
500
+ reason: 'support marker changed',
501
+ });
276
502
  continue;
277
503
  }
278
504
  stage.after =
@@ -389,6 +615,22 @@ function candidateIssue(ctx, source, candidate, candidateIds) {
389
615
  return attributionIssue;
390
616
  if (sourceEvidence(candidate).length > 1200)
391
617
  return 'discourse frame exceeds the evidence limit';
618
+ if (RELATIVE_TIME.test(sourceEvidence(candidate))) {
619
+ const sourceItems = 'items' in source.input ? source.input.items : [];
620
+ const supportedMentionTimes = new Set([
621
+ ...(source.mentioned_at ? [source.mentioned_at] : []),
622
+ ...candidate.discourse_frame.flatMap((span) => {
623
+ const item = sourceItems.find((entry) => entry.item_id === span.item_id);
624
+ return item?.mentioned_at ? [item.mentioned_at] : [];
625
+ }),
626
+ ]);
627
+ if (!source.timezone ||
628
+ !candidate.time?.mentioned_at ||
629
+ candidate.time.timezone !== source.timezone ||
630
+ !supportedMentionTimes.has(candidate.time.mentioned_at)) {
631
+ return 'relative time is unresolved without the exact source mention time and timezone';
632
+ }
633
+ }
392
634
  if (candidate.subject_ref &&
393
635
  !ctx.store.db.prepare('SELECT 1 FROM graph_entities WHERE id = ?').get(candidate.subject_ref.entity_id)) {
394
636
  return 'subject_ref does not name an existing canonical entity';
@@ -419,6 +661,14 @@ function candidateIssue(ctx, source, candidate, candidateIds) {
419
661
  }
420
662
  return null;
421
663
  }
664
+ function candidateIssueReasonCode(issue) {
665
+ if (issue.startsWith('relative time is unresolved'))
666
+ return 'time_unresolved';
667
+ return /(?:support|discourse_frame|source|evidence).*(?:missing|ambiguous|unavailable)/i.test(issue)
668
+ ? 'source_unavailable'
669
+ : 'validation_failed';
670
+ }
671
+ const RELATIVE_TIME = /\b(today|tomorrow|yesterday|tonight|next\s+(?:day|week|month|year|monday|tuesday|wednesday|thursday|friday|saturday|sunday)|last\s+(?:night|week|month|year|monday|tuesday|wednesday|thursday|friday|saturday|sunday)|this\s+(?:morning|afternoon|evening|week|month|year))\b/i;
422
672
  function structuredAttributionIssue(source, candidate) {
423
673
  if (!('items' in source.input))
424
674
  return null;