@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.
- package/README.md +4 -0
- package/config/default.jsonc +11 -4
- package/dist/config/load.js +2 -0
- package/dist/config/load.js.map +1 -1
- package/dist/config/schema.d.ts +4 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +18 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +3 -0
- package/dist/context.js.map +1 -1
- package/dist/doctor.d.ts +6 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +14 -3
- package/dist/doctor.js.map +1 -1
- package/dist/index/graph.d.ts.map +1 -1
- package/dist/index/graph.js +8 -3
- package/dist/index/graph.js.map +1 -1
- package/dist/index/indexer.d.ts +8 -0
- package/dist/index/indexer.d.ts.map +1 -1
- package/dist/index/indexer.js +261 -14
- package/dist/index/indexer.js.map +1 -1
- package/dist/index/page-quarantine.d.ts +40 -0
- package/dist/index/page-quarantine.d.ts.map +1 -0
- package/dist/index/page-quarantine.js +160 -0
- package/dist/index/page-quarantine.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/maintenance/curate.js +1 -1
- package/dist/maintenance/dream.d.ts +3 -0
- package/dist/maintenance/dream.d.ts.map +1 -1
- package/dist/maintenance/dream.js +5 -0
- package/dist/maintenance/dream.js.map +1 -1
- package/dist/maintenance/log.d.ts.map +1 -1
- package/dist/maintenance/log.js +1 -0
- package/dist/maintenance/log.js.map +1 -1
- package/dist/maintenance/managed-items.js +1 -1
- package/dist/maintenance/managed-items.js.map +1 -1
- package/dist/maintenance/plans.d.ts.map +1 -1
- package/dist/maintenance/plans.js +14 -0
- package/dist/maintenance/plans.js.map +1 -1
- package/dist/maintenance/runs.js +1 -0
- package/dist/maintenance/runs.js.map +1 -1
- package/dist/ops/context.d.ts.map +1 -1
- package/dist/ops/context.js +5 -1
- package/dist/ops/context.js.map +1 -1
- package/dist/ops/forget.d.ts.map +1 -1
- package/dist/ops/forget.js +6 -0
- package/dist/ops/forget.js.map +1 -1
- package/dist/ops/graph.d.ts.map +1 -1
- package/dist/ops/graph.js +11 -2
- package/dist/ops/graph.js.map +1 -1
- package/dist/ops/ingest.d.ts.map +1 -1
- package/dist/ops/ingest.js +9 -3
- package/dist/ops/ingest.js.map +1 -1
- package/dist/ops/move.d.ts.map +1 -1
- package/dist/ops/move.js +11 -0
- package/dist/ops/move.js.map +1 -1
- package/dist/ops/read.d.ts.map +1 -1
- package/dist/ops/read.js +17 -1
- package/dist/ops/read.js.map +1 -1
- package/dist/ops/recall.d.ts.map +1 -1
- package/dist/ops/recall.js +15 -1
- package/dist/ops/recall.js.map +1 -1
- package/dist/ops/remember.d.ts.map +1 -1
- package/dist/ops/remember.js +68 -180
- package/dist/ops/remember.js.map +1 -1
- package/dist/ops/retain.d.ts +31 -1
- package/dist/ops/retain.d.ts.map +1 -1
- package/dist/ops/retain.js +498 -70
- package/dist/ops/retain.js.map +1 -1
- package/dist/ops/timeline.d.ts.map +1 -1
- package/dist/ops/timeline.js +8 -0
- package/dist/ops/timeline.js.map +1 -1
- package/dist/ops/write.d.ts.map +1 -1
- package/dist/ops/write.js +28 -1
- package/dist/ops/write.js.map +1 -1
- package/dist/recall/assemble.js +1 -1
- package/dist/recall/assemble.js.map +1 -1
- package/dist/store/db.d.ts.map +1 -1
- package/dist/store/db.js +7 -1
- package/dist/store/db.js.map +1 -1
- package/dist/store/migrations.d.ts +3 -1
- package/dist/store/migrations.d.ts.map +1 -1
- package/dist/store/migrations.js +49 -1
- package/dist/store/migrations.js.map +1 -1
- package/dist/write/gate.d.ts.map +1 -1
- package/dist/write/gate.js +3 -3
- package/dist/write/gate.js.map +1 -1
- package/dist/write/retain-supports.d.ts +16 -0
- package/dist/write/retain-supports.d.ts.map +1 -1
- package/dist/write/retain-supports.js +65 -0
- package/dist/write/retain-supports.js.map +1 -1
- package/package.json +2 -2
package/dist/ops/retain.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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 =
|
|
67
|
-
const requestHash = sha256(JSON.stringify(
|
|
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,
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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,
|
|
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,
|
|
117
|
-
const
|
|
118
|
-
const
|
|
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
|
|
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
|
|
144
|
-
const
|
|
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:
|
|
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(
|
|
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 =
|
|
317
|
-
|
|
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,
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
|
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', ' ')
|