@arcfold/dsh 0.0.0-stage → 0.4.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/LICENSE +21 -0
- package/README.md +8 -2
- package/THIRD_PARTY_NOTICES.md +11 -0
- package/cordis.patch.yml +138 -0
- package/package.json +70 -4
- package/patches/deepseek-0.2.2-rc.1-transport.json +65 -0
- package/patches/deepseek-0.2.2-rc.1-transport.patch +61 -0
- package/presets/arcfold.patch.yml +110 -0
- package/runtime/README.md +19 -0
- package/runtime/bootstrap.json +15 -0
- package/runtime/desktop-python.tar.gz +0 -0
- package/runtime/host/package.json +11 -0
- package/runtime/host/pnpm-workspace.yaml +9 -0
- package/runtime/initial-policy.json +94 -0
- package/runtime/profile/pnpm-workspace.yaml +7 -0
- package/scripts/exact-package-patch.mjs +106 -0
- package/scripts/patch-deepseek-transport.mjs +14 -0
- package/src/agent.mjs +56 -0
- package/src/browse-scope.mjs +14 -0
- package/src/budget.mjs +369 -0
- package/src/business.mjs +334 -0
- package/src/context-material.mjs +119 -0
- package/src/deepseek-budget-plugin.mjs +58 -0
- package/src/deepseek-provider.mjs +57 -0
- package/src/deepseek-receipt.mjs +97 -0
- package/src/deepseek-transport.mjs +97 -0
- package/src/desktop/adapter.mjs +16 -0
- package/src/desktop/bootstrap.mjs +84 -0
- package/src/desktop/configuration.mjs +117 -0
- package/src/desktop/provider.mjs +46 -0
- package/src/desktop/python-runtime.mjs +105 -0
- package/src/desktop/setup-service.mjs +81 -0
- package/src/desktop/vendor/LICENSE.deepseek +21 -0
- package/src/desktop/vendor/NOTICE.md +5 -0
- package/src/desktop/vendor/deepseek-0.2.2-rc.1.mjs +2919 -0
- package/src/evidence-ownership.mjs +51 -0
- package/src/evidence.mjs +35 -0
- package/src/goal-context.mjs +52 -0
- package/src/goal-execution-tool.mjs +74 -0
- package/src/goal-execution.mjs +333 -0
- package/src/goal-tool.mjs +56 -0
- package/src/goal-work-control.mjs +63 -0
- package/src/goal-work.mjs +257 -0
- package/src/goals.mjs +578 -0
- package/src/importer.mjs +298 -0
- package/src/index.mjs +53 -0
- package/src/learning-contracts.mjs +220 -0
- package/src/learning-defaults.mjs +98 -0
- package/src/learning-goals.mjs +165 -0
- package/src/learning-plugin.mjs +28 -0
- package/src/learning-runner.mjs +275 -0
- package/src/learning.mjs +534 -0
- package/src/legacy-export.mjs +332 -0
- package/src/legacy-import-source.mjs +356 -0
- package/src/metered-provider-host.mjs +101 -0
- package/src/metered-transport.mjs +435 -0
- package/src/method-context.mjs +57 -0
- package/src/method-lifecycle.mjs +34 -0
- package/src/method-tool.mjs +61 -0
- package/src/methods.mjs +425 -0
- package/src/model-selection.mjs +41 -0
- package/src/native-context-call.mjs +88 -0
- package/src/native-continuation.mjs +217 -0
- package/src/native-request-authority.mjs +90 -0
- package/src/openai-models.mjs +4 -0
- package/src/openai-receipt.mjs +98 -0
- package/src/openai-transport.mjs +167 -0
- package/src/openai-usage.mjs +194 -0
- package/src/opportunities.mjs +295 -0
- package/src/preparation-artifacts.mjs +213 -0
- package/src/preparation-plugin.mjs +290 -0
- package/src/runtime-command.mjs +264 -0
- package/src/runtime-config.mjs +417 -0
- package/src/runtime-guard.mjs +284 -0
- package/src/runtime-host.mjs +212 -0
- package/src/runtime-init.mjs +278 -0
- package/src/runtime-installation.mjs +518 -0
- package/src/runtime-lifecycle.mjs +227 -0
- package/src/runtime-model-settings.mjs +111 -0
- package/src/runtime-readiness.mjs +285 -0
- package/src/runtime-workspace.mjs +18 -0
- package/src/session-bridge.mjs +365 -0
- package/src/store.mjs +394 -0
- package/src/tree/LICENSE +21 -0
- package/src/tree/attribution.mjs +120 -0
- package/src/tree/clusters.mjs +61 -0
- package/src/tree/consolidation.mjs +103 -0
- package/src/tree/context.mjs +211 -0
- package/src/tree/contracts.mjs +142 -0
- package/src/tree/experience.mjs +140 -0
- package/src/tree/experiment-budget.mjs +84 -0
- package/src/tree/hooks.mjs +254 -0
- package/src/tree/host-context.mjs +107 -0
- package/src/tree/host-process.mjs +288 -0
- package/src/tree/host.mjs +37 -0
- package/src/tree/improvement.mjs +106 -0
- package/src/tree/items.mjs +294 -0
- package/src/tree/jobs.mjs +184 -0
- package/src/tree/learning.mjs +202 -0
- package/src/tree/metrics.mjs +203 -0
- package/src/tree/model-owner.mjs +254 -0
- package/src/tree/observations.mjs +73 -0
- package/src/tree/prompts.mjs +500 -0
- package/src/tree/reports.mjs +67 -0
- package/src/tree/repository.mjs +236 -0
- package/src/tree/runtime.mjs +745 -0
- package/src/tree/script-writer.mjs +149 -0
- package/src/tree/scripts.mjs +208 -0
- package/src/tree/signals.mjs +118 -0
- package/src/tree/snapshots.mjs +379 -0
- package/src/tree/token-counter.mjs +75 -0
- package/src/tree/token-counter.py +37 -0
- package/src/tree/tokenizer-resources/LICENSE +21 -0
- package/src/tree/tokenizer-resources/README.md +31 -0
- package/src/tree/tokenizer-resources/deepseek_recipe-0.2.0-cp310-abi3-macosx_11_0_arm64.whl +0 -0
- package/src/tree/tokenizer-resources/manifest.json +61 -0
- package/src/tree/tokenizer-resources/native.pyi +1042 -0
- package/src/tree/tokenizer-resources/tokenizer-NOTICE.md +26 -0
- package/src/tree/tokenizer-resources/tokenizer-v4-NOTICE.md +27 -0
- package/src/tree/tokenizer-resources/tokenizer-v4.json +267359 -0
- package/src/tree/tokenizer-resources/tokenizer.json +267359 -0
- package/src/tree/tools.mjs +99 -0
- package/src/tree/tree.mjs +202 -0
- package/src/tree/ui.mjs +285 -0
- package/src/tree/verification.mjs +143 -0
- package/src/understandings.mjs +309 -0
- package/src/web-budget-recovery.mjs +181 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { CONTRACT, canonicalJson, digest, fail, integer, jsonClone } from './contracts.mjs';
|
|
2
|
+
import { validateOutput } from './prompts.mjs';
|
|
3
|
+
|
|
4
|
+
export class ConsolidationService {
|
|
5
|
+
constructor({ repository, experience, contextBuilder, jobs }) { Object.assign(this, { repo: repository, experience, builder: contextBuilder, jobs }); }
|
|
6
|
+
plan(nodeId, reason) {
|
|
7
|
+
if (!['threshold', 'leave_timeout', 'node_leave', 'node_complete'].includes(reason)) fail('SUMMARY_TRIGGER', 'Unknown consolidation trigger');
|
|
8
|
+
const node = this.builder.node(nodeId), turns = this.experience.turns(nodeId), session = this.repo.list('tree_learning_sessions', s => s.nodeId === nodeId)[0];
|
|
9
|
+
// A hidden/revoked version still owns its immutable version number.
|
|
10
|
+
const previous = node.currentSummaryId && this.repo.get('tree_summaries', node.currentSummaryId), threshold = reason === 'threshold', history = session?.history ?? [];
|
|
11
|
+
const coveredJobs = threshold ? history.slice(0, -3) : history;
|
|
12
|
+
const selected = threshold ? turns.filter(t => coveredJobs.some(h => h.turnId === t.id)) : turns;
|
|
13
|
+
if (!selected.length) return null;
|
|
14
|
+
const throughTurnIds = selected.map(t => t.id), assignmentVersions = Object.fromEntries(selected.map(t => [t.id, t.assignmentRevision]));
|
|
15
|
+
if (!threshold && canonicalJson(previous?.throughTurnIds ?? []) === canonicalJson(throughTurnIds) && canonicalJson(previous?.assignmentVersions ?? {}) === canonicalJson(assignmentVersions)) return null;
|
|
16
|
+
const material = selected.map(t => this.experience.material(t));
|
|
17
|
+
if (material.some(m => m.revokedRefs.length)) fail('SUMMARY_SOURCE_REVOKED', 'Do not silently summarize missing original evidence');
|
|
18
|
+
const sourceWatermark = digest([throughTurnIds, assignmentVersions, material.map(m => m.nativeHash)]);
|
|
19
|
+
const learningRecords = coveredJobs.map(entry => {
|
|
20
|
+
const job = this.jobs.get(entry.jobId), input = jsonClone(job.input); delete input.epochSeed;
|
|
21
|
+
if (job.status !== 'committed') fail('SUMMARY_LEARNING_RECEIPT', 'Only committed learning can enter a stable summary');
|
|
22
|
+
const turn = job.trigger.turnId && this.repo.get('tree_turns', job.trigger.turnId);
|
|
23
|
+
if (job.invalidated || turn && !turn.nodeIds.includes(nodeId) || job.input.sources.some(s => !this.experience.sources.resolve(s.ref))) return { jobId: job.id, invalidated: true, reason: '来源已撤回或归属已纠正;旧输入和输出不再作为本节点整理依据。', inputHash: job.inputHash, resultHash: job.resultHash };
|
|
24
|
+
return { jobId: job.id, input, output: job.validatedResult, receipt: job.resultRef, nativeLogRef: job.nativeLogRef, invalidated: job.invalidated === true };
|
|
25
|
+
});
|
|
26
|
+
return { nodeId, reason, throughTurnIds, assignmentVersions, material, sourceWatermark,
|
|
27
|
+
learningRecords,
|
|
28
|
+
previousSummaryId: previous?.id ?? null, previousVersion: previous?.version ?? null, nodeRevision: node.revision,
|
|
29
|
+
coveredJobIds: coveredJobs.map(h => h.jobId), recentJobIds: history.slice(-3).map(h => h.jobId), contextEpoch: session?.contextEpoch ?? null };
|
|
30
|
+
}
|
|
31
|
+
freeze(plan) {
|
|
32
|
+
const context = this.builder.build([plan.nodeId], 'consolidation'), own = this.builder.node(plan.nodeId);
|
|
33
|
+
const siblings = this.repo.list('tree_nodes', n => !n.deleted && n.parentId === own.parentId && n.id !== own.id).map(n => n.id);
|
|
34
|
+
const items = this.builder.activeItems([...new Set([...context.ancestorPaths.flat(), ...siblings])], 'consolidation');
|
|
35
|
+
const sources = [...new Map(plan.material.flatMap(m => m.sources).map(s => [s.id, s])).values()];
|
|
36
|
+
const knownItems = items.map(({ item, version, method }) => ({ id: item.id, version: item.activeVersion, nodeId: item.nodeId, kind: item.kind, sourceKind: version.sourceKind, userProtected: item.userProtected, content: method ? { short: method.short, long: method.long } : version.content }));
|
|
37
|
+
const { material: _material, learningRecords, ...summaryPlan } = plan;
|
|
38
|
+
const learningHistory = learningRecords.map(record => {
|
|
39
|
+
if (!record.input) return record;
|
|
40
|
+
const { sources: originalSources, ...input } = record.input;
|
|
41
|
+
return { ...record, input: { ...input, sourceIds: originalSources.map(s => s.id) } };
|
|
42
|
+
});
|
|
43
|
+
const input = { inputVersion: CONTRACT, role: 'consolidation', scope: { nodeIds: [plan.nodeId], ancestorPaths: context.ancestorPaths, treeRevision: context.treeRevision }, sources, knownItems,
|
|
44
|
+
allowedIds: { nodeIds: [...new Set(context.ancestorPaths.flat())], fileIds: [], subjectIds: [] }, trigger: { reason: plan.reason }, changeReceipts: [], summaryPlan, learningHistory, constraints: context.segments };
|
|
45
|
+
const revisions = { [`tree_nodes:${plan.nodeId}`]: plan.nodeRevision };
|
|
46
|
+
for (const turnId of plan.throughTurnIds) revisions[`tree_turns:${turnId}`] = this.repo.get('tree_turns', turnId).revision;
|
|
47
|
+
for (const { item } of items) revisions[`tree_items:${item.id}`] = item.revision;
|
|
48
|
+
return { input, revisions };
|
|
49
|
+
}
|
|
50
|
+
applyResult(result, { job, requestId }) {
|
|
51
|
+
const output = validateOutput('consolidation', result, job.input), plan = job.input.summaryPlan, current = this.plan(plan.nodeId, plan.reason);
|
|
52
|
+
if (!current || current.sourceWatermark !== plan.sourceWatermark || current.previousSummaryId !== plan.previousSummaryId) fail('JOB_STALE', 'Summary source assignment or previous version changed');
|
|
53
|
+
const node = this.builder.node(plan.nodeId), version = (plan.previousVersion ?? 0) + 1, id = digest(['summary', node.id, version]);
|
|
54
|
+
const sourceRefs = [...new Map(job.input.sources.map(s => [canonicalJson(s.ref), s.ref])).values()];
|
|
55
|
+
return this.repo.command('commitTreeSummary', { requestId, expected: job.inputRevisionSet, input: { nodeId: node.id, jobId: job.id, sourceWatermark: plan.sourceWatermark, sourceRefs } }, { kind: 'model', id: job.id, role: 'consolidation' }, () => {
|
|
56
|
+
const { oneLine, ...body } = output.summary;
|
|
57
|
+
this.repo.save('tree_summaries', { id, nodeId: node.id, version, oneLine, body, throughTurnIds: plan.throughTurnIds, sourceRefs, previousVersion: plan.previousVersion,
|
|
58
|
+
sourceWatermark: plan.sourceWatermark, assignmentVersions: plan.assignmentVersions, coveredJobIds: plan.coveredJobIds, recentJobIds: plan.recentJobIds, reason: plan.reason, jobId: job.id });
|
|
59
|
+
this.repo.save('tree_nodes', { ...node, currentSummaryId: id, lastSummaryWatermark: plan.sourceWatermark }, node.revision);
|
|
60
|
+
// Broaden/retrospect remain versioned proposals for B5; this service never
|
|
61
|
+
// silently grants them item/method activation authority.
|
|
62
|
+
return { summaryId: id, version, nodeId: node.id, proposals: { broaden: output.broaden, retrospect: output.retrospect }, epochPending: plan.reason === 'threshold' };
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
finishEpoch(jobId) {
|
|
66
|
+
const job = this.jobs.get(jobId); if (job.status !== 'committed') fail('SUMMARY_NOT_COMMITTED', 'Only a committed durable summary can replace context');
|
|
67
|
+
const plan = job.input.summaryPlan; if (plan.reason !== 'threshold') return null;
|
|
68
|
+
const summary = this.repo.list('tree_summaries', s => s.jobId === jobId)[0];
|
|
69
|
+
if (!summary || summary.sourceRefs.some(ref => !this.experience.sources.resolve(ref))) fail('SUMMARY_SOURCE_REVOKED', 'Summary evidence is no longer current');
|
|
70
|
+
const session = this.repo.list('tree_learning_sessions', s => s.nodeId === plan.nodeId)[0];
|
|
71
|
+
if (session?.summaryId === summary.id && session.contextEpoch === plan.contextEpoch + 1) return session;
|
|
72
|
+
if (session?.contextEpoch !== plan.contextEpoch) fail('JOB_STALE', 'A different learning epoch already advanced');
|
|
73
|
+
return this.jobs.replaceEpoch(plan.nodeId, { summaryId: summary.id, summaryHash: digest(summary), sourceWatermark: summary.sourceWatermark, recentJobIds: plan.recentJobIds });
|
|
74
|
+
}
|
|
75
|
+
learningSeed(nodeId) {
|
|
76
|
+
const session = this.repo.list('tree_learning_sessions', s => s.nodeId === nodeId)[0];
|
|
77
|
+
if (!session || session.contextEpoch === 1 || session.appendSeq > 0) return null;
|
|
78
|
+
const summary = this.repo.get('tree_summaries', session.summaryId);
|
|
79
|
+
if (!summary || digest(summary) !== session.summaryHash || !this.builder.summary({ ...this.builder.node(nodeId), currentSummaryId: summary.id })) fail('EPOCH_COVERAGE', 'The new epoch requires its exact verified summary');
|
|
80
|
+
const recent = session.recentJobIds.map(id => {
|
|
81
|
+
const job = this.jobs.get(id); if (job.status !== 'committed' || job.invalidated || job.nodeIds[0] !== nodeId || job.input.sources.some(s => !this.experience.sources.resolve(s.ref))) fail('EPOCH_COVERAGE', 'Recent learning receipt or source is no longer valid');
|
|
82
|
+
const input = jsonClone(job.input); delete input.epochSeed;
|
|
83
|
+
return { jobId: job.id, input, output: job.validatedResult, receipt: job.resultRef, nativeLogRef: job.nativeLogRef };
|
|
84
|
+
});
|
|
85
|
+
return { contextEpoch: session.contextEpoch, summary: { id: summary.id, version: summary.version, oneLine: summary.oneLine, body: summary.body, sourceWatermark: summary.sourceWatermark }, recent, constraints: this.builder.build([nodeId], 'learning').segments };
|
|
86
|
+
}
|
|
87
|
+
pressure({ fullRequest, measure, contextWindow, maxOutputTokens }) {
|
|
88
|
+
integer(contextWindow, 'model context window'); integer(maxOutputTokens, 'output tokens');
|
|
89
|
+
const reading = measure(fullRequest);
|
|
90
|
+
if (reading?.then || reading?.kind !== 'exact' || reading.inputHash !== digest(fullRequest) || !Number.isSafeInteger(reading.tokens) || reading.tokens < 0 || !reading.tokenizerIdentity) fail('EXACT_TOKEN_COUNT_REQUIRED', 'Full prompt/messages/tools need a source-bound exact count; the host character heuristic is not proof');
|
|
91
|
+
const limit = Math.floor(Math.min(128000, contextWindow * 0.8, contextWindow - maxOutputTokens - 65536));
|
|
92
|
+
if (limit <= 0) fail('CONTEXT_CAPACITY', 'Model leaves no safe learning capacity');
|
|
93
|
+
return { tokens: reading.tokens, limit, needsSummary: reading.tokens > limit, tokenizerIdentity: reading.tokenizerIdentity, inputHash: reading.inputHash };
|
|
94
|
+
}
|
|
95
|
+
due(at = this.repo.now()) {
|
|
96
|
+
const now = Date.parse(at), result = [];
|
|
97
|
+
for (const node of this.repo.list('tree_nodes', n => !n.deleted && n.lastOwnTurnAt)) {
|
|
98
|
+
const timeout = this.builder.repo.ancestors(node.id).reverse().map(n => this.repo.get('tree_parameters', digest(['parameter', 'leaveTimeoutHours', n.id]))).find(Boolean)?.value ?? 24;
|
|
99
|
+
if (now - Date.parse(node.lastOwnTurnAt) >= timeout * 3600000) { const plan = this.plan(node.id, 'leave_timeout'); if (plan) result.push(plan); }
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { canonicalJson, digest, fail, jsonClone } from './contracts.mjs';
|
|
2
|
+
import { DomainService } from './tree.mjs';
|
|
3
|
+
import { TREE_CONTEXT_SOURCE, assertOwnedCompaction } from './host-context.mjs';
|
|
4
|
+
|
|
5
|
+
const actor = { kind: 'program', id: 'tree-context' };
|
|
6
|
+
const versionId = (id, version) => digest(['item_version', id, version]);
|
|
7
|
+
const retired = 'arcfold/tree-context-retired';
|
|
8
|
+
|
|
9
|
+
export class ContextBuilder extends DomainService {
|
|
10
|
+
constructor(options) { super(options); this.sources = options.sources; this.mounted = new Map(); }
|
|
11
|
+
activeItems(nodeIds, role = 'main') {
|
|
12
|
+
return this.repo.listByNodeIds('tree_items', nodeIds, i => i.lifecycle === 'active' && !i.stale && i.activeVersion).flatMap(item => {
|
|
13
|
+
const version = this.repo.get('tree_item_versions', versionId(item.id, item.activeVersion));
|
|
14
|
+
if (!version || version.sourceRefs.some(ref => !this.resolveSource(ref))) return [];
|
|
15
|
+
if (item.kind === 'supplement') {
|
|
16
|
+
const supplement = this.repo.get('tree_supplements', digest(['supplement', item.id, item.activeVersion]));
|
|
17
|
+
if (!supplement || supplement.target !== role) return [];
|
|
18
|
+
return [{ item, version, supplement }];
|
|
19
|
+
}
|
|
20
|
+
if (item.kind === 'method') {
|
|
21
|
+
const method = this.repo.get('tree_methods', digest(['method', item.id, item.activeVersion]));
|
|
22
|
+
if (!method || method.pendingScriptRequests.length) return [];
|
|
23
|
+
return [{ item, version, method }];
|
|
24
|
+
}
|
|
25
|
+
return [{ item, version }];
|
|
26
|
+
}).sort((a, b) => nodeIds.indexOf(a.item.nodeId) - nodeIds.indexOf(b.item.nodeId) || a.item.id.localeCompare(b.item.id));
|
|
27
|
+
}
|
|
28
|
+
summary(node) {
|
|
29
|
+
const summary = node.currentSummaryId && this.repo.get('tree_summaries', node.currentSummaryId);
|
|
30
|
+
if (!summary || summary.sourceRefs.some(ref => !this.resolveSource(ref)) || summary.throughTurnIds.some(id => {
|
|
31
|
+
const turn = this.repo.get('tree_turns', id); return !turn?.nodeIds.includes(node.id) || turn.assignmentRevision !== summary.assignmentVersions[id];
|
|
32
|
+
})) return null;
|
|
33
|
+
return summary;
|
|
34
|
+
}
|
|
35
|
+
reusableComponent(source) {
|
|
36
|
+
if (!Array.isArray(source.sourceRefs)) return false;
|
|
37
|
+
let available;
|
|
38
|
+
try {
|
|
39
|
+
available = source.componentKey?.startsWith('cross-session:') && this.sources?.resolveMany
|
|
40
|
+
? this.sources.resolveMany(source.sourceRefs).every(Boolean) : source.sourceRefs.every(ref => this.resolveSource(ref));
|
|
41
|
+
} catch { return false; }
|
|
42
|
+
return available &&
|
|
43
|
+
(source.itemVersions ?? []).every(v => { const item = this.repo.get('tree_items', v.itemId); return item && !item.stale && item.lifecycle !== 'invalid'; }) &&
|
|
44
|
+
(source.turnAssignments ?? []).every(v => { const turn = this.repo.get('tree_turns', v.turnId); return turn?.status === 'settled' && turn.assignmentRevision === v.assignmentRevision && turn.nodeIds.includes(v.nodeId); }) &&
|
|
45
|
+
(source.summaryVersions ?? []).every(v => Boolean(this.summary({ id: v.nodeId, currentSummaryId: digest(['summary', v.nodeId, v.version]) })));
|
|
46
|
+
}
|
|
47
|
+
crossSessionSegments(nodeIds, sessionId, currentTurn) {
|
|
48
|
+
const selected = new Set(nodeIds);
|
|
49
|
+
const turns = this.repo.list('tree_turns', turn => turn.status === 'settled' && turn.settledAt < currentTurn.startedAt && turn.sessionId !== sessionId && turn.nodeIds.some(id => selected.has(id)))
|
|
50
|
+
.sort((a, b) => a.startedAt.localeCompare(b.startedAt) || a.sessionId.localeCompare(b.sessionId) || a.nativeTurn - b.nativeTurn || a.id.localeCompare(b.id));
|
|
51
|
+
const recent = turns.slice(-6), entries = [], assignments = [], refs = [];
|
|
52
|
+
let resolved = null;
|
|
53
|
+
if (this.sources?.resolveMany) {
|
|
54
|
+
try {
|
|
55
|
+
const allRefs = recent.flatMap(turn => turn.experienceRefs ?? turn.sourceRefs);
|
|
56
|
+
const allSources = this.sources.resolveMany(allRefs);
|
|
57
|
+
resolved = new Map(allRefs.map((ref, index) => [digest(ref), allSources[index]]));
|
|
58
|
+
} catch { /* Individual turns below retain the conservative fallback. */ }
|
|
59
|
+
}
|
|
60
|
+
let usedBytes = 0, omitted = turns.length - recent.length;
|
|
61
|
+
for (const turn of recent.toReversed()) {
|
|
62
|
+
// A mixed-goal turn can contain private information from another branch.
|
|
63
|
+
// Do not send its unsplit original into a narrower current scope.
|
|
64
|
+
if (turn.nodeIds.some(id => !selected.has(id))) { omitted++; continue; }
|
|
65
|
+
let sources;
|
|
66
|
+
try { sources = resolved ? (turn.experienceRefs ?? turn.sourceRefs).map(ref => resolved.get(digest(ref))) : this.sources?.resolveMany ? this.sources.resolveMany(turn.experienceRefs ?? turn.sourceRefs) : (turn.experienceRefs ?? turn.sourceRefs).map(ref => this.resolveSource(ref)); }
|
|
67
|
+
catch { omitted++; continue; }
|
|
68
|
+
if (sources.some(source => !source)) { omitted++; continue; }
|
|
69
|
+
const visible = sources.filter(source => source.role === 'user' && source.origin === 'user' || source.role === 'assistant' && source.origin === 'model');
|
|
70
|
+
const messages = visible.map(source => ({ role: source.role, text: source.text, sourceId: source.id }));
|
|
71
|
+
const entry = { turnId: turn.id, sessionId: turn.sessionId, messages };
|
|
72
|
+
const bytes = Buffer.byteLength(canonicalJson(entry));
|
|
73
|
+
if (usedBytes + bytes > 48 * 1024) { omitted++; continue; }
|
|
74
|
+
usedBytes += bytes; entries.unshift(entry); refs.push(...visible.map(source => source.ref));
|
|
75
|
+
assignments.push({ turnId: turn.id, nodeId: turn.nodeIds.find(id => selected.has(id)), assignmentRevision: turn.assignmentRevision });
|
|
76
|
+
}
|
|
77
|
+
if (!turns.length) return [];
|
|
78
|
+
const value = { recentOtherSessions: entries, omittedTurns: omitted,
|
|
79
|
+
instruction: '这些是同一目标在其他聊天的原话和答复,不代表用户已采纳助手建议。若未覆盖所需经历,用历史工具查证。' };
|
|
80
|
+
return [{ key: 'cross-session:' + digest([...selected].sort()), nodeId: null, version: digest([value, assignments]),
|
|
81
|
+
text: canonicalJson(value), sourceRefs: refs, itemVersions: [], summaryVersions: [], turnAssignments: assignments }];
|
|
82
|
+
}
|
|
83
|
+
build(nodeIds, role = 'main') {
|
|
84
|
+
const paths = nodeIds.map(id => this.repo.ancestors(id)), ordered = [...new Map(paths.flat().map(n => [n.id, n])).values()];
|
|
85
|
+
const items = this.activeItems(ordered.map(n => n.id), role), itemVersions = items.map(({ item }) => ({ itemId: item.id, version: item.activeVersion }));
|
|
86
|
+
const summaries = ordered.map(n => this.summary(n)).filter(Boolean), summaryVersions = summaries.map(s => ({ nodeId: s.nodeId, version: s.version }));
|
|
87
|
+
const segments = ordered.map(node => {
|
|
88
|
+
const rows = items.filter(i => i.item.nodeId === node.id), summary = summaries.find(s => s.nodeId === node.id), own = nodeIds.includes(node.id);
|
|
89
|
+
const value = { target: { id: node.id, name: node.name, status: node.status, statusOrigin: node.statusOrigin },
|
|
90
|
+
understandings: rows.filter(r => r.item.kind === 'understanding').map(({ item, version }) => ({ id: item.id, version: item.activeVersion, source: version.sourceKind, validation: item.validation, content: version.content })),
|
|
91
|
+
rules: rows.filter(r => r.method?.short).map(({ item, method }) => ({ id: item.id, version: item.activeVersion, text: method.short })),
|
|
92
|
+
methods: rows.filter(r => r.method?.long).map(({ item, method }) => ({ id: item.id, version: item.activeVersion, name: method.long.name, description: method.long.description })),
|
|
93
|
+
supplements: rows.filter(r => r.supplement).map(({ item, version }) => ({ id: item.id, version: item.activeVersion, text: version.content })),
|
|
94
|
+
experience: summary ? { version: summary.version, oneLine: summary.oneLine, ...(own ? { body: summary.body } : {}) } : null,
|
|
95
|
+
children: own ? this.repo.list('tree_nodes', n => !n.deleted && n.parentId === node.id).map(n => ({ id: n.id, name: n.name, status: n.status, oneLine: this.summary(n)?.oneLine ?? '' })).sort((a, b) => a.id.localeCompare(b.id)) : [],
|
|
96
|
+
probes: own ? this.repo.list('tree_items', i => i.nodeId === node.id && i.lifecycle === 'active' && i.probe).map(i => ({ itemId: i.id, version: i.activeVersion, what: i.probe.what })) : [] };
|
|
97
|
+
const text = canonicalJson({ ...value, instruction: '同一节点以最后一份背景为准;旧版中没有继续列出的内容已撤销。' });
|
|
98
|
+
return { key: `node:${node.id}`, nodeId: node.id, version: digest(value), text,
|
|
99
|
+
sourceRefs: [...rows.flatMap(r => r.version.sourceRefs), ...(summary?.sourceRefs ?? [])],
|
|
100
|
+
itemVersions: rows.map(r => ({ itemId: r.item.id, version: r.item.activeVersion })), summaryVersions: summary ? [{ nodeId: node.id, version: summary.version }] : [] };
|
|
101
|
+
});
|
|
102
|
+
// Names/status/oneLine are the explicitly permitted global discovery view.
|
|
103
|
+
// No sibling understanding, method, source text or summary body enters it.
|
|
104
|
+
const overview = this.repo.list('tree_nodes', n => !n.deleted && n.status === 'active').map(n => ({ id: n.id, name: n.name, status: n.status, oneLine: this.summary(n)?.oneLine ?? '' })).sort((a, b) => a.id.localeCompare(b.id));
|
|
105
|
+
const text = canonicalJson({ globalOverview: overview });
|
|
106
|
+
segments.unshift({ key: 'global-overview', nodeId: null, version: digest(overview), text,
|
|
107
|
+
sourceRefs: this.repo.list('tree_nodes', n => !n.deleted && n.status === 'active').flatMap(n => this.summary(n)?.sourceRefs ?? []), itemVersions: [],
|
|
108
|
+
summaryVersions: this.repo.list('tree_nodes', n => !n.deleted && n.status === 'active').flatMap(n => { const summary = this.summary(n); return summary ? [{ nodeId: n.id, version: summary.version }] : []; }) });
|
|
109
|
+
return { role, nodeIds: [...nodeIds], ancestorPaths: paths.map(p => p.map(n => n.id)), segments, itemVersions, summaryVersions,
|
|
110
|
+
treeRevision: this.repo.meta().treeRevision, renderedHash: digest(segments), bytes: Buffer.byteLength(segments.map(s => s.text).join('\n')) };
|
|
111
|
+
}
|
|
112
|
+
scope(sessionId) { return this.repo.meta().contextSessions?.[sessionId] ?? null; }
|
|
113
|
+
async activate(sessionId, turnId, { cancelPrevious, expectedEpoch } = {}) {
|
|
114
|
+
const turn = this.repo.get('tree_turns', turnId);
|
|
115
|
+
if (!turn || turn.sessionId !== sessionId) fail('TURN_SCOPE', 'Only this native turn may choose its session scope');
|
|
116
|
+
const token = digest(['scope-transition', sessionId, turnId, turn.assignmentRevision]), previous = this.scope(sessionId);
|
|
117
|
+
if (previous?.token === token && previous.phase === 'active') return previous;
|
|
118
|
+
if (expectedEpoch !== undefined && previous?.epoch !== expectedEpoch) fail('SCOPE_STALE', 'Scope changed before transition');
|
|
119
|
+
if (previous && typeof cancelPrevious !== 'function') fail('SCOPE_CANCELLER_REQUIRED', 'An owned cancellation callback must stop previous scope execution');
|
|
120
|
+
const beginId = digest(['begin-scope', token]);
|
|
121
|
+
if (previous?.token !== token) this.repo.command('leaveTreeScope', { requestId: beginId, expected: { [`tree_turns:${turnId}`]: turn.revision }, input: { sessionId, turnId, token, sourceRefs: turn.userSourceRefs } }, actor, () => {
|
|
122
|
+
const meta = this.repo.meta(), old = meta.contextSessions?.[sessionId];
|
|
123
|
+
const scope = { token, phase: 'leaving', epoch: (old?.epoch ?? 0) + 1, nodeIds: turn.nodeIds, turnId, assignmentRevision: turn.assignmentRevision,
|
|
124
|
+
previousNodeIds: old?.nodeIds ?? [], leaveEvents: (old?.nodeIds ?? []).filter(id => !turn.nodeIds.includes(id)).map(nodeId => ({ type: 'node_leave', nodeId })), sourceRefs: turn.userSourceRefs };
|
|
125
|
+
this.repo.save('tree_meta', { ...meta, contextSessions: { ...meta.contextSessions, [sessionId]: scope } }, meta.revision); return scope;
|
|
126
|
+
});
|
|
127
|
+
const pending = this.scope(sessionId);
|
|
128
|
+
if (pending?.token !== token) fail('SCOPE_STALE', 'A later scope owns cancellation; do not cancel it from this old transition');
|
|
129
|
+
if (pending.previousNodeIds.length) await cancelPrevious(jsonClone(pending));
|
|
130
|
+
return this.repo.command('enterTreeScope', { requestId: digest(['finish-scope', token]), expected: {}, input: { sessionId, token, sourceRefs: turn.userSourceRefs } }, actor, () => {
|
|
131
|
+
const meta = this.repo.meta(), current = meta.contextSessions?.[sessionId], latest = this.repo.get('tree_turns', turnId);
|
|
132
|
+
if (current?.token !== token || latest.assignmentRevision !== turn.assignmentRevision) fail('SCOPE_STALE', 'A later turn/correction superseded this transition');
|
|
133
|
+
const scope = { ...current, phase: 'active', enteredAt: this.repo.now(), enterEvents: current.nodeIds.filter(id => !current.previousNodeIds.includes(id)).map(nodeId => ({ type: 'node_enter', nodeId })) };
|
|
134
|
+
this.repo.save('tree_meta', { ...meta, contextSessions: { ...meta.contextSessions, [sessionId]: scope } }, meta.revision); return scope;
|
|
135
|
+
}).result;
|
|
136
|
+
}
|
|
137
|
+
capture(sessionId, role = 'main') {
|
|
138
|
+
const scope = this.scope(sessionId), turn = scope && this.repo.get('tree_turns', scope.turnId);
|
|
139
|
+
if (!scope || scope.phase !== 'active' || scope.assignmentRevision !== turn?.assignmentRevision || canonicalJson(scope.nodeIds) !== canonicalJson(turn.nodeIds)) fail('SCOPE_STALE', 'No active current assignment scope');
|
|
140
|
+
const value = { ...this.build(scope.nodeIds, role), scopeEpoch: scope.epoch, turnId: scope.turnId, assignmentRevision: scope.assignmentRevision };
|
|
141
|
+
if (role === 'main') {
|
|
142
|
+
value.segments.push(...this.crossSessionSegments(scope.nodeIds, sessionId, turn));
|
|
143
|
+
value.renderedHash = digest(value.segments);
|
|
144
|
+
value.bytes = Buffer.byteLength(value.segments.map(s => s.text).join('\n'));
|
|
145
|
+
}
|
|
146
|
+
return { ...value, key: digest(value) };
|
|
147
|
+
}
|
|
148
|
+
assertCurrent(sessionId, snapshot) { if (this.capture(sessionId, snapshot.role).key !== snapshot.key) fail('CONTEXT_STALE', 'Scope, versions or source availability changed before dispatch'); return true; }
|
|
149
|
+
expose(sessionId, invocationId, boundaryIndex, snapshot) {
|
|
150
|
+
this.assertCurrent(sessionId, snapshot);
|
|
151
|
+
const mounted = this.mounted.get(sessionId);
|
|
152
|
+
if (mounted && (mounted.snapshotKey !== snapshot.key || mounted.messages.some(m => !this.reusableComponent(m.source)))) fail('CONTEXT_STALE', 'Native projection must be refreshed before exposure');
|
|
153
|
+
const messages = mounted?.messages, visibleVersions = messages ? [...new Map(messages.flatMap(m => m.source.itemVersions ?? []).map(v => [canonicalJson(v), v])).values()] : snapshot.itemVersions;
|
|
154
|
+
const id = digest(['exposure', invocationId]), existing = this.repo.get('tree_exposures', id);
|
|
155
|
+
if (existing) { if (existing.contextKey !== snapshot.key || existing.revoked) fail('EXPOSURE_CHANGED', 'An invocation cannot change exposure'); return existing; }
|
|
156
|
+
this.repo.command('recordTreeExposure', { requestId: id, expected: {}, input: { invocationId, contextKey: snapshot.key } }, actor, () => {
|
|
157
|
+
this.repo.save('tree_exposures', { id, invocationId, role: snapshot.role, nodeIds: snapshot.nodeIds, itemVersions: snapshot.itemVersions, visibleItemVersions: visibleVersions, summaryVersions: snapshot.summaryVersions,
|
|
158
|
+
renderedHash: messages ? digest(messages) : snapshot.renderedHash, contextKey: snapshot.key, treeRevision: snapshot.treeRevision, modelSessionId: sessionId, boundaryIndex, scopeEpoch: snapshot.scopeEpoch,
|
|
159
|
+
bytes: messages ? Buffer.byteLength(canonicalJson(messages)) : snapshot.bytes }); return { exposureId: id };
|
|
160
|
+
}); return this.repo.get('tree_exposures', id);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Per-component native projection: unchanged ancestors keep their position. */
|
|
165
|
+
export function mountTreeContext(ctx, { builder, sessionId, createUserMessage }) {
|
|
166
|
+
let session; const disposers = [];
|
|
167
|
+
disposers.push(ctx.on('agent/pre-step', async (payload, next) => {
|
|
168
|
+
const decision = await next(); if (decision.kind === 'reject') return decision;
|
|
169
|
+
payload.signal.throwIfAborted(); session = payload.agent.session;
|
|
170
|
+
if (session.id !== sessionId) fail('CONTEXT_SESSION', 'Context owner cannot mount another native session');
|
|
171
|
+
assertOwnedCompaction(session); const snapshot = builder.capture(sessionId), current = new Map(snapshot.segments.map(s => [s.key, s])), retainedMessages = [], latest = new Map();
|
|
172
|
+
for (const seq of [...session.surface.nodes]) {
|
|
173
|
+
const event = session.eventAt(seq); if (event.type !== 'user/message' || event.data.source?.kind !== TREE_CONTEXT_SOURCE) continue;
|
|
174
|
+
const source = event.data.source, segment = current.get(source.componentKey);
|
|
175
|
+
// Preserve earlier same-scope versions for a stable prefix, but never
|
|
176
|
+
// retain an out-of-scope component or withdrawn original evidence.
|
|
177
|
+
if (segment && (!segment.key.startsWith('cross-session:') || source.componentVersion === segment.version) && builder.reusableComponent(source)) {
|
|
178
|
+
retainedMessages.push(event.data); latest.set(segment.key, event.data); continue;
|
|
179
|
+
}
|
|
180
|
+
session.append('user/message', createUserMessage({ source: { kind: retired }, content: [{ type: 'text', text: '[previous Arcfold component retired]' }] }), { surfaceOp: { op: 'replace', startSeq: seq, endSeq: seq }, sourceEventSeqs: [seq] });
|
|
181
|
+
}
|
|
182
|
+
if (decision.messages.some(m => [TREE_CONTEXT_SOURCE, retired].includes(m.source?.kind))) fail('CONTEXT_OWNER', 'Only the scoped context owner may inject its messages');
|
|
183
|
+
const additions = [];
|
|
184
|
+
for (const segment of snapshot.segments) {
|
|
185
|
+
const previous = latest.get(segment.key); if (previous?.source.componentVersion === segment.version) continue;
|
|
186
|
+
let text = segment.text, overview;
|
|
187
|
+
if (segment.key === 'global-overview') {
|
|
188
|
+
overview = JSON.parse(segment.text).globalOverview;
|
|
189
|
+
if (previous?.source.overview) {
|
|
190
|
+
const old = new Map(previous.source.overview.map(row => [row.id, row])), now = new Set(overview.map(row => row.id));
|
|
191
|
+
const changes = [...overview.filter(row => !old.has(row.id) || canonicalJson(row) !== canonicalJson(old.get(row.id))), ...[...old.values()].filter(row => !now.has(row.id)).map(row => ({ id: row.id, removed: true }))];
|
|
192
|
+
text = canonicalJson({ globalOverviewChanges: changes, instruction: '只更新这些节点;同一编号以最后一行为准,removed 表示不再列入进行中概貌。' });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
additions.push(createUserMessage({ source: { kind: TREE_CONTEXT_SOURCE, componentKey: segment.key, componentVersion: segment.version, sourceRefs: segment.sourceRefs, itemVersions: segment.itemVersions, summaryVersions: segment.summaryVersions, ...(segment.turnAssignments ? { turnAssignments: segment.turnAssignments } : {}), ...(overview ? { overview } : {}) }, content: [{ type: 'text', text }] }));
|
|
196
|
+
}
|
|
197
|
+
builder.mounted.set(sessionId, { snapshotKey: snapshot.key, messages: [...retainedMessages, ...additions] });
|
|
198
|
+
return { ...decision, messages: [...decision.messages, ...additions] };
|
|
199
|
+
}));
|
|
200
|
+
disposers.push(ctx.on('llm/stream', (options, next) => {
|
|
201
|
+
if (!session || options.sessionId !== sessionId) return next();
|
|
202
|
+
return (async function* () {
|
|
203
|
+
options.signal?.throwIfAborted(); assertOwnedCompaction(session);
|
|
204
|
+
const snapshot = builder.capture(sessionId), actual = options.messages.filter(m => m.source?.kind === TREE_CONTEXT_SOURCE);
|
|
205
|
+
const mounted = builder.mounted.get(sessionId);
|
|
206
|
+
if (mounted?.snapshotKey !== snapshot.key || actual.some(m => !builder.reusableComponent(m.source)) || canonicalJson(actual) !== canonicalJson(mounted.messages)) fail('CONTEXT_STALE', 'Actual native input differs from the prepared current-scope projection');
|
|
207
|
+
yield* next();
|
|
208
|
+
})();
|
|
209
|
+
}));
|
|
210
|
+
return async () => { builder.mounted.delete(sessionId); for (const dispose of disposers.reverse()) await dispose(); };
|
|
211
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { DomainError, canonicalJson, jsonClone } from '../store.mjs';
|
|
3
|
+
export { canonicalJson, jsonClone };
|
|
4
|
+
export const SCHEMA = 'arcfold.goal-tree/v3';
|
|
5
|
+
export const SCHEMA_VERSION = 1;
|
|
6
|
+
export const CONTRACT = 'arcfold.tree.v3.1';
|
|
7
|
+
export const digest = value => createHash('sha256').update(canonicalJson(value)).digest('hex');
|
|
8
|
+
export const fail = (code, message, status = 409) => { throw new DomainError(code, message, status); };
|
|
9
|
+
export const count = text => [...text].length;
|
|
10
|
+
export function text(value, field, max = 2000, empty = false) {
|
|
11
|
+
if (typeof value !== 'string' || value.includes('\0') || (!empty && !value.trim()) || count(value) > max) fail('INVALID_FIELD', `Invalid ${field}`, 400);
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
export function array(value, field, max = 2000) {
|
|
15
|
+
if (!Array.isArray(value) || value.length > max) fail('INVALID_FIELD', `Invalid ${field}`, 400); return value;
|
|
16
|
+
}
|
|
17
|
+
export function oneOf(value, choices, field) { if (!choices.includes(value)) fail('INVALID_FIELD', `Invalid ${field}`, 400); return value; }
|
|
18
|
+
export function integer(value, field, min = 1, max = Number.MAX_SAFE_INTEGER) {
|
|
19
|
+
if (!Number.isSafeInteger(value) || value < min || value > max) fail('INVALID_FIELD', `Invalid ${field}`, 400); return value;
|
|
20
|
+
}
|
|
21
|
+
export function shape(value, required, optional = []) {
|
|
22
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || required.some(k => !Object.hasOwn(value, k)) ||
|
|
23
|
+
Object.keys(value).some(k => !required.includes(k) && !optional.includes(k))) fail('INVALID_SHAPE', 'Missing or unknown fields', 400);
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
export const STATUSES = ['active', 'paused', 'completed', 'abandoned'];
|
|
27
|
+
export const CATEGORIES = ['not_understood', 'misread', 'restart', 'repeated', 'overdone', 'omitted', 'misreported', 'redo', 'proceeded', 'approved', 'adopted_proposal', 'confirmed_inference'];
|
|
28
|
+
export const ROLES = ['main', 'attribution', 'learning', 'consolidation', 'verification', 'script_writer', 'improvement'];
|
|
29
|
+
export const IMMUTABLE_KINDS = new Set(['tree_item_versions', 'tree_summaries', 'tree_change_events', 'tree_receipts', 'tree_methods', 'tree_supplements']);
|
|
30
|
+
|
|
31
|
+
// Minimum persisted contracts. Role-specific output schemas live in B2 and
|
|
32
|
+
// cannot add authority fields to model output. Stored extension fields are host-owned.
|
|
33
|
+
export const RECORD_FIELDS = Object.freeze({
|
|
34
|
+
tree_meta: 'schemaVersion userId rootNodeId treeRevision lastEventOrdinal',
|
|
35
|
+
tree_nodes: 'parentId name status statusOrigin sourceRefs lastOwnTurnAt lastSummaryWatermark',
|
|
36
|
+
tree_turns: 'sessionId nativeTurn logicalTurnId userSourceRefs sourceRefs nodeIds status startedAt',
|
|
37
|
+
tree_links: 'turnId nodeId assignmentRevision active sourceRefs',
|
|
38
|
+
tree_summaries: 'nodeId version oneLine body throughTurnIds sourceRefs previousVersion',
|
|
39
|
+
tree_items: 'kind nodeId activeVersion lifecycle validation userProtected originNodeId',
|
|
40
|
+
tree_item_versions: 'itemId version content sourceKind sourceRefs reason',
|
|
41
|
+
tree_methods: 'itemId version short long scripts hooks expectation evaluation',
|
|
42
|
+
tree_supplements: 'itemId version target scopeNodeId traitEvidence universalStopgap',
|
|
43
|
+
tree_parameters: 'name scopeNodeId value sourceRefs actor',
|
|
44
|
+
tree_exposures: 'invocationId role nodeIds itemVersions summaryVersions renderedHash treeRevision modelSessionId boundaryIndex',
|
|
45
|
+
tree_learning_sessions: 'nodeId sessionId contextEpoch appendSeq sourceWatermark appliedChangeOrdinal estimateTokens',
|
|
46
|
+
tree_jobs: 'kind scopeKey inputHash inputRevisionSet status',
|
|
47
|
+
tree_invocations: 'jobId role nativeSessionId ordinal modelConfigHash promptHash inputHash allowedTools state',
|
|
48
|
+
tree_signals: 'eventId subjectRefs dimension subitem category polarity evidenceKind sourceRefs nodeIds confidence',
|
|
49
|
+
tree_episodes: 'signalIds beforeRefs reactionRefs resolutionRefs nodeIds state start',
|
|
50
|
+
tree_clusters: 'category polarity epoch episodeIds status methodIds reviewWatermark recurrenceCount',
|
|
51
|
+
tree_repairs: 'producerRole stage type sourceSignalIds nodeIds reason requestId',
|
|
52
|
+
tree_change_events: 'ordinal commandType actor entityIds beforeVersions afterVersions sourceRefs receiptId',
|
|
53
|
+
tree_hooks: 'hookId methodId version scopeNodeId moment conditions action message enabled',
|
|
54
|
+
tree_hook_runs: 'logicalTurnId eventKey hookId version scopeEpoch inputHash result continuationCycle observedAt',
|
|
55
|
+
tree_script_artifacts: 'scriptId methodId version language entry files argSchema dryRunSupport buildRunId activationState',
|
|
56
|
+
tree_script_runs: 'runId methodVersion nodeEpoch workspaceIdentity policyHash snapshotId argvHash state',
|
|
57
|
+
tree_snapshots: 'snapshotId workspaceIdentity manifestHash commitId reason coverage restoreState',
|
|
58
|
+
tree_reports: 'category fingerprint step problem evidenceRefs whyUniversal stopgapIds suggestedFix status exportAudit',
|
|
59
|
+
tree_metric_windows: 'scope fromOrdinal toOrdinal observations numerators denominators missing algorithmVersion',
|
|
60
|
+
tree_receipts: 'requestId inputHash result revisions eventIds',
|
|
61
|
+
});
|
|
62
|
+
export function validateRef(ref) {
|
|
63
|
+
shape(ref, ['sessionId', 'seq', 'sha256'], ['messageId']);
|
|
64
|
+
text(ref.sessionId, 'sessionId', 512); integer(ref.seq, 'seq', 0);
|
|
65
|
+
if (!/^[a-f0-9]{64}$/.test(ref.sha256)) fail('INVALID_SOURCE', 'Source must have an exact SHA-256');
|
|
66
|
+
if (ref.messageId !== undefined) text(ref.messageId, 'messageId', 512);
|
|
67
|
+
}
|
|
68
|
+
export function sourceRefs(refs, resolveSource, { user = false, required = true } = {}) {
|
|
69
|
+
array(refs, 'sourceRefs', 20); if (required && !refs.length) fail('SOURCE_REQUIRED', 'Immutable source evidence required');
|
|
70
|
+
let human = false;
|
|
71
|
+
for (const ref of refs) {
|
|
72
|
+
validateRef(ref); const source = resolveSource(ref);
|
|
73
|
+
if (!source || source.then || source.sha256 !== ref.sha256) fail('SOURCE_CHANGED', 'Source cannot be verified synchronously');
|
|
74
|
+
human ||= source.role === 'user' && source.origin === 'user';
|
|
75
|
+
}
|
|
76
|
+
if (user && !human) fail('USER_SOURCE_REQUIRED', 'User-expressed content requires an actual human source, not a program message');
|
|
77
|
+
return jsonClone(refs);
|
|
78
|
+
}
|
|
79
|
+
export function validateRecord(kind, record) {
|
|
80
|
+
if (!Object.hasOwn(RECORD_FIELDS, kind)) fail('UNKNOWN_KIND', `Unknown tree record kind ${kind}`);
|
|
81
|
+
if (RECORD_FIELDS[kind].split(' ').some(k => !Object.hasOwn(record, k))) fail('INVALID_RECORD', `Incomplete ${kind}`);
|
|
82
|
+
text(record.id, 'id', 512);
|
|
83
|
+
const lists = {
|
|
84
|
+
tree_turns: ['nodeIds'], tree_summaries: ['throughTurnIds'], tree_methods: ['scripts', 'hooks'], tree_supplements: ['traitEvidence'],
|
|
85
|
+
tree_exposures: ['nodeIds', 'itemVersions', 'summaryVersions'], tree_invocations: ['allowedTools'], tree_signals: ['subjectRefs', 'nodeIds'],
|
|
86
|
+
tree_episodes: ['signalIds', 'beforeRefs', 'reactionRefs', 'resolutionRefs', 'nodeIds'], tree_clusters: ['episodeIds', 'methodIds'],
|
|
87
|
+
tree_repairs: ['sourceSignalIds', 'nodeIds'], tree_change_events: ['entityIds', 'beforeVersions', 'afterVersions'], tree_hooks: ['conditions'],
|
|
88
|
+
tree_script_artifacts: ['files'], tree_reports: ['evidenceRefs', 'stopgapIds', 'exportAudit'], tree_receipts: ['eventIds'],
|
|
89
|
+
}[kind] ?? [];
|
|
90
|
+
lists.forEach(k => array(record[k], k, 100000));
|
|
91
|
+
const objects = {
|
|
92
|
+
tree_summaries: ['body'], tree_methods: ['expectation', 'evaluation'], tree_parameters: ['actor'], tree_jobs: ['inputRevisionSet'],
|
|
93
|
+
tree_change_events: ['actor'], tree_script_artifacts: ['argSchema'], tree_snapshots: ['coverage'], tree_metric_windows: ['numerators', 'denominators'], tree_receipts: ['result', 'revisions'],
|
|
94
|
+
}[kind] ?? [];
|
|
95
|
+
for (const k of objects) if (!record[k] || typeof record[k] !== 'object' || Array.isArray(record[k])) fail('INVALID_RECORD', `Invalid ${kind}.${k}`);
|
|
96
|
+
const identities = {
|
|
97
|
+
tree_meta: ['userId', 'rootNodeId'], tree_turns: ['sessionId', 'logicalTurnId'], tree_links: ['turnId', 'nodeId'], tree_summaries: ['nodeId'],
|
|
98
|
+
tree_items: ['nodeId', 'originNodeId'], tree_item_versions: ['itemId', 'reason'], tree_methods: ['itemId'], tree_supplements: ['itemId', 'scopeNodeId'],
|
|
99
|
+
tree_parameters: ['scopeNodeId'], tree_exposures: ['invocationId', 'role', 'renderedHash', 'modelSessionId'], tree_learning_sessions: ['nodeId', 'sessionId'],
|
|
100
|
+
tree_jobs: ['kind', 'scopeKey', 'inputHash', 'status'], tree_invocations: ['jobId', 'role', 'nativeSessionId', 'modelConfigHash', 'promptHash', 'inputHash', 'state'],
|
|
101
|
+
tree_signals: ['eventId', 'dimension', 'subitem'], tree_episodes: ['state', 'start'], tree_clusters: ['category', 'polarity', 'status'],
|
|
102
|
+
tree_repairs: ['producerRole', 'stage', 'type', 'reason', 'requestId'], tree_change_events: ['commandType', 'receiptId'],
|
|
103
|
+
tree_hooks: ['hookId', 'methodId', 'scopeNodeId', 'moment', 'action'], tree_hook_runs: ['logicalTurnId', 'eventKey', 'hookId', 'inputHash', 'observedAt'],
|
|
104
|
+
tree_script_artifacts: ['scriptId', 'methodId', 'language', 'entry', 'buildRunId', 'activationState'], tree_script_runs: ['runId', 'policyHash', 'snapshotId', 'argvHash', 'state'],
|
|
105
|
+
tree_snapshots: ['snapshotId', 'manifestHash', 'commitId', 'reason'], tree_reports: ['category', 'fingerprint', 'step', 'problem', 'whyUniversal', 'suggestedFix', 'status'],
|
|
106
|
+
tree_metric_windows: ['algorithmVersion'], tree_receipts: ['requestId', 'inputHash'],
|
|
107
|
+
}[kind] ?? [];
|
|
108
|
+
identities.forEach(k => text(record[k], k, k === 'problem' || k === 'whyUniversal' || k === 'suggestedFix' || k === 'reason' ? 20000 : 512));
|
|
109
|
+
if (kind === 'tree_turns') { integer(record.nativeTurn, 'nativeTurn', 0); oneOf(record.status, ['open', 'settled', 'interrupted', 'failed'], 'turn status'); }
|
|
110
|
+
if (kind === 'tree_links') { integer(record.assignmentRevision, 'assignmentRevision'); if (typeof record.active !== 'boolean') fail('INVALID_RECORD', 'Link active must be boolean'); }
|
|
111
|
+
if (kind === 'tree_jobs') oneOf(record.status, ['queued', 'prepared', 'submitted', 'result_received', 'validated', 'committed', 'failed', 'unknown', 'invalid_output', 'stale', 'cancelled'], 'job status');
|
|
112
|
+
if (kind === 'tree_exposures') {
|
|
113
|
+
oneOf(record.role, ROLES, 'role'); integer(record.treeRevision, 'treeRevision'); integer(record.boundaryIndex, 'boundaryIndex', 0);
|
|
114
|
+
for (const ref of record.itemVersions) { shape(ref, ['itemId', 'version']); text(ref.itemId, 'itemId', 512); integer(ref.version, 'item version'); }
|
|
115
|
+
for (const ref of record.summaryVersions) { shape(ref, ['nodeId', 'version']); text(ref.nodeId, 'nodeId', 512); integer(ref.version, 'summary version'); }
|
|
116
|
+
}
|
|
117
|
+
if (kind === 'tree_invocations') oneOf(record.role, ROLES, 'role');
|
|
118
|
+
if (kind === 'tree_hooks' && typeof record.enabled !== 'boolean') fail('INVALID_RECORD', 'Hook enabled must be boolean');
|
|
119
|
+
if (kind === 'tree_meta') { integer(record.schemaVersion, 'schemaVersion'); integer(record.treeRevision, 'treeRevision'); integer(record.lastEventOrdinal, 'lastEventOrdinal', 0); }
|
|
120
|
+
if (kind === 'tree_change_events') integer(record.ordinal, 'ordinal');
|
|
121
|
+
for (const field of ['sourceRefs', 'userSourceRefs']) if (record[field] !== undefined) array(record[field], field).forEach(validateRef);
|
|
122
|
+
if (kind === 'tree_nodes') { text(record.name, 'name', 200); oneOf(record.status, STATUSES, 'status'); oneOf(record.statusOrigin, ['user', 'model', 'cascade'], 'statusOrigin'); }
|
|
123
|
+
if (kind === 'tree_items') { oneOf(record.kind, ['understanding', 'method', 'supplement'], 'item kind'); oneOf(record.lifecycle, ['draft', 'active', 'withdrawn', 'changed', 'invalid'], 'lifecycle'); oneOf(record.validation, ['unverified', 'supported', 'refuted'], 'validation'); if (typeof record.userProtected !== 'boolean') fail('INVALID_RECORD', 'User protection must be boolean'); if (record.activeVersion !== null) integer(record.activeVersion, 'activeVersion'); if (record.lifecycle === 'active' && record.activeVersion === null) fail('INVALID_RECORD', 'Active items need a version'); }
|
|
124
|
+
if (kind === 'tree_item_versions') oneOf(record.sourceKind, ['user_expressed', 'assistant_inferred', 'unknown'], 'sourceKind');
|
|
125
|
+
if (kind === 'tree_supplements') oneOf(record.target, ['learning', 'attribution', 'consolidation'], 'supplement target');
|
|
126
|
+
if (kind === 'tree_parameters') { oneOf(record.name, ['retentionDays', 'leaveTimeoutHours'], 'parameter'); integer(record.value, record.name, 1, record.name === 'retentionDays' ? 365 : 168); }
|
|
127
|
+
if (kind === 'tree_signals') { oneOf(record.category, CATEGORIES, 'category'); oneOf(record.polarity, ['negative', 'weak', 'strong'], 'polarity'); oneOf(record.evidenceKind, ['language', 'ui', 'program'], 'evidenceKind'); }
|
|
128
|
+
if (record.version !== undefined) integer(record.version, 'version');
|
|
129
|
+
return record;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function validateShort(value) {
|
|
133
|
+
shape(value, ['situation', 'action', 'basis', 'notApplicable']);
|
|
134
|
+
for (const key of Object.keys(value)) text(value[key], key, 200, true);
|
|
135
|
+
if (count(Object.values(value).join('')) > 200) fail('CONTENT_LIMIT', 'Short text exceeds 200 codepoints');
|
|
136
|
+
}
|
|
137
|
+
export function validateLong(value) {
|
|
138
|
+
shape(value, ['name', 'description', 'whenToUse', 'steps', 'pitfalls', 'verify']);
|
|
139
|
+
text(value.name, 'name', 80); text(value.description, 'description', 60);
|
|
140
|
+
for (const key of ['whenToUse', 'steps', 'pitfalls', 'verify']) text(value[key], key, 2000, true);
|
|
141
|
+
if (count(value.whenToUse + value.steps + value.pitfalls + value.verify) > 2000) fail('CONTENT_LIMIT', 'Long text exceeds 2000 codepoints');
|
|
142
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { canonicalJson, digest, fail, integer, jsonClone, validateRef } from './contracts.mjs';
|
|
2
|
+
import { DomainService } from './tree.mjs';
|
|
3
|
+
|
|
4
|
+
const messageOf = (event, messageId) => event.type === 'agent/inbox/spliced' ? event.data.inserted?.find(m => m.id === messageId) : event.type === 'user/message' ? event.data : event.data?.message;
|
|
5
|
+
const visibleBlocks = message => (message?.content ?? []).filter(b => b.type !== 'reasoning');
|
|
6
|
+
|
|
7
|
+
/** Reads a host-frozen durable native log. Source labels never come from text. */
|
|
8
|
+
export class NativeSources {
|
|
9
|
+
constructor({ readSession, isRevoked = () => false }) { Object.assign(this, { readSession, isRevoked }); }
|
|
10
|
+
events(sessionId) {
|
|
11
|
+
const packet = this.readSession(sessionId);
|
|
12
|
+
if (packet?.then || packet?.durable !== true || packet.sessionId !== sessionId || !Array.isArray(packet.events)) fail('DURABLE_SOURCE_REQUIRED', 'A synchronously frozen durable native log is required');
|
|
13
|
+
const events = jsonClone(packet.events);
|
|
14
|
+
for (const [seq, event] of events.entries()) if (event.seq !== seq || typeof event.type !== 'string' || !event.data) fail('NATIVE_LOG_INVALID', 'Native original log must retain its complete event sequence');
|
|
15
|
+
return events;
|
|
16
|
+
}
|
|
17
|
+
ref(sessionId, event, messageId) {
|
|
18
|
+
const message = messageOf(event, messageId);
|
|
19
|
+
return { sessionId, seq: event.seq, ...(message?.id ? { messageId: message.id } : {}), sha256: digest(event) };
|
|
20
|
+
}
|
|
21
|
+
project(sessionId, event, messageId) {
|
|
22
|
+
let role, origin, content;
|
|
23
|
+
if (['user/message', 'agent/inbox/spliced'].includes(event.type) && messageOf(event, messageId)?.source?.kind === 'user') { role = 'user'; origin = 'user'; content = visibleBlocks(messageOf(event, messageId)); }
|
|
24
|
+
else if (event.type === 'assistant/message' && event.data.message?.source?.kind === 'model') {
|
|
25
|
+
role = 'assistant'; origin = 'model'; content = visibleBlocks(event.data.message).filter(b => b.type !== 'tool-call');
|
|
26
|
+
} else if (event.type === 'tool/call') { role = 'assistant'; origin = 'tool-call'; content = [{ type: 'tool-call', id: event.data.callId, name: event.data.name, arguments: event.data.arguments }]; }
|
|
27
|
+
else if (event.type === 'tool/result' && event.data.message?.role === 'tool') { role = 'tool'; origin = 'tool'; content = visibleBlocks(event.data.message); }
|
|
28
|
+
else return null;
|
|
29
|
+
if (!content.length) return null;
|
|
30
|
+
const ref = this.ref(sessionId, event, messageId);
|
|
31
|
+
return { id: digest(['native-source', ref]), role, origin, content, text: content.map(b => b.type === 'text' ? b.text : canonicalJson(b)).join('\n'), ref, sha256: ref.sha256, nativeType: event.type, at: new Date(event.time).toISOString(), ...(event.type === 'tool/result' ? { toolCallId: event.data.message.toolCallId, isError: event.data.message.isError === true } : {}) };
|
|
32
|
+
}
|
|
33
|
+
resolve(ref) {
|
|
34
|
+
validateRef(ref); if (this.isRevoked(ref)) return null;
|
|
35
|
+
const event = this.events(ref.sessionId)[ref.seq];
|
|
36
|
+
if (!event || digest(event) !== ref.sha256 || ref.messageId && messageOf(event, ref.messageId)?.id !== ref.messageId) return null;
|
|
37
|
+
return this.project(ref.sessionId, event, ref.messageId);
|
|
38
|
+
}
|
|
39
|
+
resolveMany(refs) {
|
|
40
|
+
const eventsBySession = new Map();
|
|
41
|
+
return refs.map(ref => {
|
|
42
|
+
validateRef(ref);
|
|
43
|
+
if (this.isRevoked(ref)) return null;
|
|
44
|
+
if (!eventsBySession.has(ref.sessionId)) eventsBySession.set(ref.sessionId, this.events(ref.sessionId));
|
|
45
|
+
const event = eventsBySession.get(ref.sessionId)[ref.seq];
|
|
46
|
+
if (!event || digest(event) !== ref.sha256 || ref.messageId && messageOf(event, ref.messageId)?.id !== ref.messageId) return null;
|
|
47
|
+
return this.project(ref.sessionId, event, ref.messageId);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
claimed(sessionId, messages) {
|
|
51
|
+
const events = this.events(sessionId);
|
|
52
|
+
return messages.filter(m => m.source?.kind === 'user').map(message => {
|
|
53
|
+
const original = events.find(e => e.type === 'agent/inbox/spliced' && e.data.inserted?.some(m => m.id === message.id && canonicalJson(m) === canonicalJson(message)));
|
|
54
|
+
if (!original) fail('USER_INBOX_SOURCE', 'Claimed user input must match its durable native inbox original');
|
|
55
|
+
return this.project(sessionId, original, message.id);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
turn(sessionId, nativeTurn, { requireEnd = true } = {}) {
|
|
59
|
+
integer(nativeTurn, 'native turn', 0); const events = this.events(sessionId);
|
|
60
|
+
const start = events.find(e => e.type === 'turn/start' && e.data.turn === nativeTurn);
|
|
61
|
+
if (!start) fail('TURN_SOURCE_MISSING', 'Native turn start missing');
|
|
62
|
+
const end = events.find(e => e.type === 'turn/end' && e.seq > start.seq && e.data.turn === nativeTurn);
|
|
63
|
+
if (requireEnd && !end) fail('TURN_NOT_SETTLED', 'Native turn has not durably ended');
|
|
64
|
+
const next = events.find(e => e.type === 'turn/start' && e.seq > start.seq), through = end?.seq ?? (next ? next.seq - 1 : events.at(-1).seq);
|
|
65
|
+
const ownEvents = events.filter(e => e.seq >= start.seq && e.seq <= through), sources = ownEvents.map(e => this.project(sessionId, e)).filter(Boolean);
|
|
66
|
+
const userSources = sources.filter(s => s.role === 'user' && s.origin === 'user');
|
|
67
|
+
if (!userSources.length) fail('PROGRAM_NOT_USER', 'Program-only input is not a logical human turn');
|
|
68
|
+
return { sessionId, nativeTurn, startSeq: start.seq, endSeq: end?.seq ?? null, nativeHash: digest(ownEvents), endReason: end?.data.reason ?? null, sources, userSources };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class ExperienceService extends DomainService {
|
|
73
|
+
constructor(options) { super(options); this.sources = options.sources; }
|
|
74
|
+
settle(turnId) {
|
|
75
|
+
const actor = this.actor([]); if (actor.kind !== 'program') fail('HOST_REQUIRED', 'Only durable host turn/end settles experience');
|
|
76
|
+
const turn = this.repo.get('tree_turns', turnId); if (!turn) fail('TURN_NOT_FOUND', 'Attribution must exist before settlement');
|
|
77
|
+
const captured = this.sources.turn(turn.sessionId, turn.nativeTurn), requestId = digest(['settle-experience', turnId]), receiptId = digest(['receipt', requestId]);
|
|
78
|
+
const prior = this.repo.get('tree_receipts', receiptId);
|
|
79
|
+
if (prior) { if (turn.experienceHash !== captured.nativeHash) fail('SOURCE_CHANGED', 'Settled native turn changed'); return prior; }
|
|
80
|
+
for (const ref of turn.userSourceRefs) {
|
|
81
|
+
const original = this.sources.resolve(ref);
|
|
82
|
+
if (!original || !captured.userSources.some(s => s.ref.messageId === ref.messageId && canonicalJson(s.content) === canonicalJson(original.content))) fail('SOURCE_CHANGED', 'Attribution user source differs from the durable turn');
|
|
83
|
+
}
|
|
84
|
+
const refs = [...turn.userSourceRefs, ...captured.sources.filter(s => s.role !== 'user' || !turn.userSourceRefs.some(r => r.messageId === s.ref.messageId)).map(s => s.ref)], status = captured.endReason.kind === 'completed' ? 'settled' : captured.endReason.kind === 'error' ? 'failed' : 'interrupted';
|
|
85
|
+
return this.repo.command('settleExperience', { requestId, expected: { [`tree_turns:${turnId}`]: turn.revision }, input: { turnId, nativeHash: captured.nativeHash, endSeq: captured.endSeq, sourceRefs: refs } }, actor, () => {
|
|
86
|
+
this.replace('tree_turns', turn, { status, settledAt: this.repo.now(), experienceRefs: refs, experienceHash: captured.nativeHash, nativeEndSeq: captured.endSeq, nativeEndReason: captured.endReason, settlementReceiptId: receiptId });
|
|
87
|
+
const jobs = [];
|
|
88
|
+
for (const nodeId of turn.nodeIds) {
|
|
89
|
+
const node = this.node(nodeId); this.replace('tree_nodes', node, { lastOwnTurnAt: this.repo.now(), lastOwnTurnId: turnId });
|
|
90
|
+
if (status !== 'settled') continue;
|
|
91
|
+
const id = digest(['settled-learning', turnId, nodeId, turn.assignmentRevision]);
|
|
92
|
+
const input = { turnId, assignmentRevision: turn.assignmentRevision, sourceRefs: refs };
|
|
93
|
+
this.repo.save('tree_jobs', { id, kind: 'learning', scopeKey: nodeId, nodeIds: [nodeId], input, inputHash: digest(input), inputRevisionSet: { [`tree_turns:${turnId}`]: turn.revision + 1 }, status: 'queued', triggerKind: 'settled_main_turn' }); jobs.push(id);
|
|
94
|
+
}
|
|
95
|
+
return { turnId, status, nodeIds: turn.nodeIds, sourceRefs: refs, learningIntents: jobs };
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
turns(nodeId, { includeInterrupted = true } = {}) {
|
|
99
|
+
this.node(nodeId);
|
|
100
|
+
const ids = new Set(this.repo.list('tree_links', l => l.nodeId === nodeId && l.active).map(l => l.turnId));
|
|
101
|
+
return this.repo.list('tree_turns', t => ids.has(t.id) && (t.status === 'settled' || includeInterrupted && ['interrupted', 'failed'].includes(t.status)))
|
|
102
|
+
.sort((a, b) => a.startedAt.localeCompare(b.startedAt) || a.sessionId.localeCompare(b.sessionId) || a.nativeTurn - b.nativeTurn || a.id.localeCompare(b.id));
|
|
103
|
+
}
|
|
104
|
+
material(turn) {
|
|
105
|
+
const sources = [], revokedRefs = [];
|
|
106
|
+
for (const ref of turn.experienceRefs ?? turn.sourceRefs) { const value = this.sources.resolve(ref); if (value) sources.push(value); else revokedRefs.push(ref); }
|
|
107
|
+
return { turnId: turn.id, status: turn.status, assignmentRevision: turn.assignmentRevision, sources, revokedRefs, nativeHash: turn.experienceHash ?? null };
|
|
108
|
+
}
|
|
109
|
+
assertReadScope(nodeId) {
|
|
110
|
+
const ctx = this.context(); if (ctx.actor.kind === 'user') return;
|
|
111
|
+
if (!(ctx.nodeIds ?? []).some(id => this.repo.ancestors(id).some(n => n.id === nodeId) || this.node(nodeId).parentId === id)) fail('OUT_OF_SCOPE', 'History is limited to ancestors and direct children');
|
|
112
|
+
}
|
|
113
|
+
history(nodeId, { afterTurnId = null, limit = 10, byteLimit = 65536 } = {}) {
|
|
114
|
+
this.assertReadScope(nodeId); integer(limit, 'limit', 1, 50); integer(byteLimit, 'byte limit', 1024, 65536);
|
|
115
|
+
const all = this.turns(nodeId); let index = 0, offset = 0, cursor;
|
|
116
|
+
if (afterTurnId !== null) {
|
|
117
|
+
if (typeof afterTurnId !== 'string') fail('CURSOR_STALE', 'History cursor must be a string');
|
|
118
|
+
if (afterTurnId.startsWith('page:')) {
|
|
119
|
+
try { cursor = JSON.parse(Buffer.from(afterTurnId.slice(5), 'base64url').toString()); } catch { fail('CURSOR_STALE', 'Malformed history cursor'); }
|
|
120
|
+
if (!cursor || cursor.nodeId !== nodeId || !Number.isSafeInteger(cursor.offset) || cursor.offset < 0) fail('CURSOR_STALE', 'History cursor belongs to a different scope');
|
|
121
|
+
index = all.findIndex(t => t.id === cursor.turnId); offset = cursor.offset;
|
|
122
|
+
} else { const prior = all.findIndex(t => t.id === afterTurnId); if (prior < 0) fail('CURSOR_STALE', 'Unknown previous turn'); index = prior + 1; }
|
|
123
|
+
}
|
|
124
|
+
if (index < 0) fail('CURSOR_STALE', 'History cursor no longer belongs to this node');
|
|
125
|
+
const encode = (turnId, offset, hash) => 'page:' + Buffer.from(canonicalJson({ nodeId, turnId, offset, hash })).toString('base64url');
|
|
126
|
+
const items = [], revokedRefs = []; let bytes = 0;
|
|
127
|
+
for (let i = index; i < all.length && items.length < limit; i++) {
|
|
128
|
+
const material = this.material(all[i]); revokedRefs.push(...material.revokedRefs);
|
|
129
|
+
const projected = canonicalJson(material), from = i === index ? offset : 0, points = [...projected];
|
|
130
|
+
if (i === index && cursor && cursor.hash !== digest(material)) fail('CURSOR_STALE', 'Source withdrawal or assignment changed this page; restart from its turn');
|
|
131
|
+
if (from > points.length) fail('CURSOR_STALE', 'History offset is outside this turn');
|
|
132
|
+
let to = from;
|
|
133
|
+
while (to < points.length && bytes + Buffer.byteLength(points[to]) <= byteLimit) bytes += Buffer.byteLength(points[to++]);
|
|
134
|
+
if (to > from) items.push({ turnId: all[i].id, status: all[i].status, text: points.slice(from, to).join(''), offset: from, complete: from === 0 && to === points.length });
|
|
135
|
+
if (to < points.length) return { items, revokedRefs, nextCursor: encode(all[i].id, to, digest(material)) };
|
|
136
|
+
if (items.length === limit && i + 1 < all.length) return { items, revokedRefs, nextCursor: all[i].id };
|
|
137
|
+
}
|
|
138
|
+
return { items, revokedRefs, nextCursor: null };
|
|
139
|
+
}
|
|
140
|
+
}
|