@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.
Files changed (127) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +8 -2
  3. package/THIRD_PARTY_NOTICES.md +11 -0
  4. package/cordis.patch.yml +138 -0
  5. package/package.json +70 -4
  6. package/patches/deepseek-0.2.2-rc.1-transport.json +65 -0
  7. package/patches/deepseek-0.2.2-rc.1-transport.patch +61 -0
  8. package/presets/arcfold.patch.yml +110 -0
  9. package/runtime/README.md +19 -0
  10. package/runtime/bootstrap.json +15 -0
  11. package/runtime/desktop-python.tar.gz +0 -0
  12. package/runtime/host/package.json +11 -0
  13. package/runtime/host/pnpm-workspace.yaml +9 -0
  14. package/runtime/initial-policy.json +94 -0
  15. package/runtime/profile/pnpm-workspace.yaml +7 -0
  16. package/scripts/exact-package-patch.mjs +106 -0
  17. package/scripts/patch-deepseek-transport.mjs +14 -0
  18. package/src/agent.mjs +56 -0
  19. package/src/browse-scope.mjs +14 -0
  20. package/src/budget.mjs +369 -0
  21. package/src/business.mjs +334 -0
  22. package/src/context-material.mjs +119 -0
  23. package/src/deepseek-budget-plugin.mjs +58 -0
  24. package/src/deepseek-provider.mjs +57 -0
  25. package/src/deepseek-receipt.mjs +97 -0
  26. package/src/deepseek-transport.mjs +97 -0
  27. package/src/desktop/adapter.mjs +16 -0
  28. package/src/desktop/bootstrap.mjs +84 -0
  29. package/src/desktop/configuration.mjs +117 -0
  30. package/src/desktop/provider.mjs +46 -0
  31. package/src/desktop/python-runtime.mjs +105 -0
  32. package/src/desktop/setup-service.mjs +81 -0
  33. package/src/desktop/vendor/LICENSE.deepseek +21 -0
  34. package/src/desktop/vendor/NOTICE.md +5 -0
  35. package/src/desktop/vendor/deepseek-0.2.2-rc.1.mjs +2919 -0
  36. package/src/evidence-ownership.mjs +51 -0
  37. package/src/evidence.mjs +35 -0
  38. package/src/goal-context.mjs +52 -0
  39. package/src/goal-execution-tool.mjs +74 -0
  40. package/src/goal-execution.mjs +333 -0
  41. package/src/goal-tool.mjs +56 -0
  42. package/src/goal-work-control.mjs +63 -0
  43. package/src/goal-work.mjs +257 -0
  44. package/src/goals.mjs +578 -0
  45. package/src/importer.mjs +298 -0
  46. package/src/index.mjs +53 -0
  47. package/src/learning-contracts.mjs +220 -0
  48. package/src/learning-defaults.mjs +98 -0
  49. package/src/learning-goals.mjs +165 -0
  50. package/src/learning-plugin.mjs +28 -0
  51. package/src/learning-runner.mjs +275 -0
  52. package/src/learning.mjs +534 -0
  53. package/src/legacy-export.mjs +332 -0
  54. package/src/legacy-import-source.mjs +356 -0
  55. package/src/metered-provider-host.mjs +101 -0
  56. package/src/metered-transport.mjs +435 -0
  57. package/src/method-context.mjs +57 -0
  58. package/src/method-lifecycle.mjs +34 -0
  59. package/src/method-tool.mjs +61 -0
  60. package/src/methods.mjs +425 -0
  61. package/src/model-selection.mjs +41 -0
  62. package/src/native-context-call.mjs +88 -0
  63. package/src/native-continuation.mjs +217 -0
  64. package/src/native-request-authority.mjs +90 -0
  65. package/src/openai-models.mjs +4 -0
  66. package/src/openai-receipt.mjs +98 -0
  67. package/src/openai-transport.mjs +167 -0
  68. package/src/openai-usage.mjs +194 -0
  69. package/src/opportunities.mjs +295 -0
  70. package/src/preparation-artifacts.mjs +213 -0
  71. package/src/preparation-plugin.mjs +290 -0
  72. package/src/runtime-command.mjs +264 -0
  73. package/src/runtime-config.mjs +417 -0
  74. package/src/runtime-guard.mjs +284 -0
  75. package/src/runtime-host.mjs +212 -0
  76. package/src/runtime-init.mjs +278 -0
  77. package/src/runtime-installation.mjs +518 -0
  78. package/src/runtime-lifecycle.mjs +227 -0
  79. package/src/runtime-model-settings.mjs +111 -0
  80. package/src/runtime-readiness.mjs +285 -0
  81. package/src/runtime-workspace.mjs +18 -0
  82. package/src/session-bridge.mjs +365 -0
  83. package/src/store.mjs +394 -0
  84. package/src/tree/LICENSE +21 -0
  85. package/src/tree/attribution.mjs +120 -0
  86. package/src/tree/clusters.mjs +61 -0
  87. package/src/tree/consolidation.mjs +103 -0
  88. package/src/tree/context.mjs +211 -0
  89. package/src/tree/contracts.mjs +142 -0
  90. package/src/tree/experience.mjs +140 -0
  91. package/src/tree/experiment-budget.mjs +84 -0
  92. package/src/tree/hooks.mjs +254 -0
  93. package/src/tree/host-context.mjs +107 -0
  94. package/src/tree/host-process.mjs +288 -0
  95. package/src/tree/host.mjs +37 -0
  96. package/src/tree/improvement.mjs +106 -0
  97. package/src/tree/items.mjs +294 -0
  98. package/src/tree/jobs.mjs +184 -0
  99. package/src/tree/learning.mjs +202 -0
  100. package/src/tree/metrics.mjs +203 -0
  101. package/src/tree/model-owner.mjs +254 -0
  102. package/src/tree/observations.mjs +73 -0
  103. package/src/tree/prompts.mjs +500 -0
  104. package/src/tree/reports.mjs +67 -0
  105. package/src/tree/repository.mjs +236 -0
  106. package/src/tree/runtime.mjs +745 -0
  107. package/src/tree/script-writer.mjs +149 -0
  108. package/src/tree/scripts.mjs +208 -0
  109. package/src/tree/signals.mjs +118 -0
  110. package/src/tree/snapshots.mjs +379 -0
  111. package/src/tree/token-counter.mjs +75 -0
  112. package/src/tree/token-counter.py +37 -0
  113. package/src/tree/tokenizer-resources/LICENSE +21 -0
  114. package/src/tree/tokenizer-resources/README.md +31 -0
  115. package/src/tree/tokenizer-resources/deepseek_recipe-0.2.0-cp310-abi3-macosx_11_0_arm64.whl +0 -0
  116. package/src/tree/tokenizer-resources/manifest.json +61 -0
  117. package/src/tree/tokenizer-resources/native.pyi +1042 -0
  118. package/src/tree/tokenizer-resources/tokenizer-NOTICE.md +26 -0
  119. package/src/tree/tokenizer-resources/tokenizer-v4-NOTICE.md +27 -0
  120. package/src/tree/tokenizer-resources/tokenizer-v4.json +267359 -0
  121. package/src/tree/tokenizer-resources/tokenizer.json +267359 -0
  122. package/src/tree/tools.mjs +99 -0
  123. package/src/tree/tree.mjs +202 -0
  124. package/src/tree/ui.mjs +285 -0
  125. package/src/tree/verification.mjs +143 -0
  126. package/src/understandings.mjs +309 -0
  127. package/src/web-budget-recovery.mjs +181 -0
@@ -0,0 +1,285 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { canonicalJson, digest, fail, shape, text, integer, array, oneOf, validateShort, validateLong, STATUSES, CATEGORIES } from './contracts.mjs';
3
+ import { TreeService, SignalService } from './tree.mjs';
4
+ import { ItemService } from './items.mjs';
5
+ import { evidenceResolver, effectiveSignals, signalPolarity, EpisodeSignals } from './signals.mjs';
6
+ import { contentHash } from './host-process.mjs';
7
+
8
+ export const TREE_ENDPOINTS = ['session.current', 'session.selectBranch', 'tree.list', 'node.details', 'turn.details', 'source.read', 'node.rename', 'node.move', 'node.status', 'turn.correct', 'turn.undoCreate', 'turn.undoStatus', 'item.confirm', 'item.edit', 'item.withdraw', 'item.undoCorrection', 'hook.disable', 'signal.correct', 'snapshots.list', 'snapshots.preview', 'snapshots.restore', 'metrics.read', 'reports.list', 'reports.details', 'reports.export', 'parameters.set', 'receipts.read'];
9
+ const readEndpoints = new Set(['session.current', 'tree.list', 'node.details', 'turn.details', 'source.read', 'snapshots.list', 'metrics.read', 'reports.list', 'reports.details', 'receipts.read']);
10
+ const versionId = (id, version) => digest(['item_version', id, version]);
11
+ const methodId = (id, version) => digest(['method', id, version]);
12
+
13
+ // Called by the trusted turn adapter immediately after capture, never by RPC.
14
+ export function bindTurnSnapshot(repository, snapshotId, logicalTurnId) {
15
+ const snapshot = repository.get('tree_snapshots', snapshotId), turn = repository.get('tree_turns', logicalTurnId);
16
+ if (!snapshot || !turn || snapshot.logicalTurnId && snapshot.logicalTurnId !== turn.id) fail('SNAPSHOT_BINDING', 'Snapshot requires its exact originating turn');
17
+ return repository.command('bindTurnSnapshot', { requestId: digest(['snapshot-turn', snapshotId, logicalTurnId]), expected: {}, input: { snapshotId, logicalTurnId } }, { kind: 'program', id: 'tree-turn-adapter' }, () => {
18
+ repository.save('tree_snapshots', { ...snapshot, logicalTurnId }, snapshot.revision); return { snapshotId, logicalTurnId };
19
+ });
20
+ }
21
+
22
+ /** Host-owned domain adapter. Browser data never supplies the actor or scope. */
23
+ export class TreeUIService {
24
+ constructor({ repository, resolveSource, metrics, reports, snapshotsFor, browserScope, attribution, selectBranch }) {
25
+ if (typeof browserScope !== 'function') fail('BROWSER_SCOPE_REQUIRED', 'A host-owned browser scope is required');
26
+ Object.assign(this, { repo: repository, metrics, reports, snapshotsFor, browserScope, attribution, selectBranch }); this.resolveSource = evidenceResolver(repository, resolveSource);
27
+ }
28
+ authorize(context) {
29
+ if (context.actor?.kind !== 'user' || context.actor.id !== this.repo.userId || context.signal?.aborted !== false) fail('FORBIDDEN', 'Current authenticated owner required', 403);
30
+ const scope = this.browserScope(context); if (!scope || scope.then || !Array.isArray(scope.nodeIds) || new Set(scope.nodeIds).size !== scope.nodeIds.length || scope.nodeIds.some(id => { const n = this.repo.get('tree_nodes', id); return !n || n.deleted; })) fail('BROWSE_SCOPE', 'Browser scope is unavailable', 403);
31
+ return scope;
32
+ }
33
+ scoped(id, scope) { if (!scope.nodeIds.includes(id)) fail('BROWSE_SCOPE', 'This node is outside the current browser scope', 403); const node = this.repo.get('tree_nodes', id); if (!node || node.deleted) fail('NODE_NOT_FOUND', 'Node not found'); return node; }
34
+ turnJobs(turn) {
35
+ const rows = this.repo.list('tree_jobs', j => j.input?.turnId === turn.id || j.trigger?.turnId === turn.id);
36
+ const assignment = j => j.input?.currentTurn?.assignmentRevision ?? j.input?.assignmentRevision;
37
+ const learning = rows.filter(j => j.role === 'learning').sort((a, b) => b.enqueuedOrder - a.enqueuedOrder || b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id));
38
+ const owner = j => learning.find(other => other.nodeIds[0] === j.nodeIds[0] && assignment(other) === assignment(j));
39
+ // Scheduling intents are not a second worker. Old intents remain queued
40
+ // after rejected/unknown output; project their actual owner's state without
41
+ // rewriting evidence or dispatching another request from a read endpoint.
42
+ const correctionOwner = intent => rows.find(j => j.role === 'verification' && j.trigger?.intentId === intent.id);
43
+ return rows.filter(j => j.role || (j.kind !== 'learning' || !owner(j)) && (j.kind !== 'verification' || !correctionOwner(j))).map(j => ({ id: j.id, role: j.role ?? j.kind, status: j.status,
44
+ current: (assignment(j) === undefined || assignment(j) === turn.assignmentRevision) && (j.role !== 'learning' || owner(j)?.id === j.id), error: j.error?.code ?? j.error ?? null,
45
+ rejectedItems: j.rejectedItems ?? [] }));
46
+ }
47
+ page(rows, payload, key) {
48
+ const limit = payload.limit ?? 50; integer(limit, 'limit', 1, 100);
49
+ const sorted = rows.toSorted((a, b) => a.id.localeCompare(b.id)), version = digest(sorted.map(r => [r.id, r.revision]));
50
+ let after = '';
51
+ if (payload.cursor) { text(payload.cursor, 'cursor', 2048); let cursor; try { cursor = JSON.parse(Buffer.from(payload.cursor, 'base64url').toString()); } catch { fail('CURSOR_INVALID', 'Invalid page cursor'); } if (cursor.key !== key || cursor.version !== version || typeof cursor.after !== 'string') fail('REVISION_CONFLICT', 'List changed; refresh from the first page'); after = cursor.after; }
52
+ const rest = sorted.filter(r => r.id > after), selected = rest.slice(0, limit);
53
+ return { rows: selected, nextCursor: rest.length > limit ? Buffer.from(canonicalJson({ key, version, after: selected.at(-1).id })).toString('base64url') : null };
54
+ }
55
+ read(endpoint, payload, context, scope) {
56
+ if (endpoint === 'session.current') { shape(payload, []); const turn = this.repo.list('tree_turns', t => t.sessionId === context.sessionId).sort((a, b) => b.nativeTurn - a.nativeTurn)[0];
57
+ if (turn && turn.nodeIds.some(id => !scope.nodeIds.includes(id))) fail('BROWSE_SCOPE', 'Current turn outside scope', 403);
58
+ const nodeIds = turn?.nodeIds ?? [this.repo.rootId()]; return { attribution: this.attribution && scope.nodeIds.length === this.repo.list('tree_nodes', n => !n.deleted).length ? this.attribution.capacity(context.sessionId) : null, turnId: turn?.id ?? null, paths: nodeIds.map(id => this.repo.ancestors(id).filter(n => scope.nodeIds.includes(n.id))), turnIds: this.repo.list('tree_turns', t => t.sessionId === context.sessionId && t.nodeIds.every(id => scope.nodeIds.includes(id))).map(t => ({ nativeTurn: t.nativeTurn, turnId: t.id })) }; }
59
+ if (endpoint === 'tree.list') {
60
+ shape(payload, [], ['statusFilter', 'cursor', 'limit']); const statuses = payload.statusFilter ?? STATUSES; array(statuses, 'statusFilter', 4).forEach(s => oneOf(s, STATUSES, 'status'));
61
+ const page = this.page(this.repo.list('tree_nodes', n => !n.deleted && scope.nodeIds.includes(n.id) && statuses.includes(n.status)), payload, 'tree:' + digest(scope.nodeIds));
62
+ return { nodes: page.rows.map(n => ({ ...n, oneLine: n.currentSummaryId ? this.repo.get('tree_summaries', n.currentSummaryId)?.oneLine ?? '' : '' })), nextCursor: page.nextCursor, treeRevision: this.repo.meta().treeRevision };
63
+ }
64
+ if (endpoint === 'node.details') {
65
+ shape(payload, ['nodeId']); const node = this.scoped(payload.nodeId, scope), items = this.repo.list('tree_items', i => i.nodeId === node.id).map(item => ({ ...item, current: this.repo.get('tree_item_versions', versionId(item.id, item.activeVersion ?? item.draftVersion)),
66
+ history: this.repo.list('tree_item_versions', v => v.itemId === item.id), method: item.kind === 'method' ? this.repo.get('tree_methods', methodId(item.id, item.activeVersion ?? item.draftVersion)) : null,
67
+ hooks: this.repo.list('tree_hooks', h => h.methodId === item.id && h.version === item.activeVersion), scripts: item.kind !== 'method' ? [] : (this.repo.get('tree_methods', methodId(item.id, item.activeVersion ?? item.draftVersion))?.scripts ?? []).map(ref => { const artifact = this.repo.list('tree_script_artifacts', a => a.scriptId === ref.id && a.version === ref.version)[0]; return { id: ref.id, version: ref.version, usage: artifact?.usage ?? null, readOnlyProbeCount: artifact?.probeIds?.length ?? 0, notVerified: artifact?.notVerified ?? [] }; }), supplement: item.kind === 'supplement' ? this.repo.get('tree_supplements', digest(['supplement', item.id, item.activeVersion])) : null }));
68
+ return { node, path: this.repo.ancestors(node.id).filter(n => scope.nodeIds.includes(n.id)), summary: node.currentSummaryId ? this.repo.get('tree_summaries', node.currentSummaryId) : null, items,
69
+ children: this.repo.list('tree_nodes', n => !n.deleted && n.parentId === node.id && scope.nodeIds.includes(n.id)), promotionHistory: items.filter(i => i.originChain?.length > 1).map(i => ({ itemId: i.id, originChain: i.originChain, reason: i.promotionReason })),
70
+ parameters: ['retentionDays', 'leaveTimeoutHours'].map(name => { const service = new TreeService({ repository: this.repo }); return { name, value: service.parameter(name, node.id), local: this.repo.get('tree_parameters', digest(['parameter', name, node.id])) }; }),
71
+ revisionSet: { ['tree_nodes:' + node.id]: node.revision, ...Object.fromEntries(items.map(i => ['tree_items:' + i.id, i.revision])) } };
72
+ }
73
+ if (endpoint === 'turn.details') {
74
+ shape(payload, ['turnId']); const turn = this.repo.get('tree_turns', payload.turnId); if (!turn || turn.nodeIds.some(id => !scope.nodeIds.includes(id))) fail('BROWSE_SCOPE', 'Turn is outside browser scope', 403);
75
+ return { turn, nodes: turn.nodeIds.map(id => this.scoped(id, scope)), signals: effectiveSignals(this.repo, this.resolveSource).filter(s => s.sourceRefs.some(r => turn.userSourceRefs.some(ref => digest(ref) === digest(r)))),
76
+ statusChanges: Object.values(this.repo.meta().treeObservations ?? {}).flat().filter(o => !o.reversalOf && o.logicalTurnId === turn.id && o.type === 'status' && o.origin !== 'cascade' && scope.nodeIds.includes(o.nodeId)).map(o => { const group = Object.values(this.repo.meta().treeObservations).flat().filter(other => other.commandReceiptId === o.commandReceiptId && other.type === 'status'); return { ...o, nodeName: this.repo.get('tree_nodes', o.nodeId)?.name, expectedRevisions: Object.fromEntries(group.filter(other => scope.nodeIds.includes(other.nodeId)).map(other => [other.nodeId, this.repo.get('tree_nodes', other.nodeId)?.revision])), reversed: Boolean(this.repo.meta().reversedTreeChanges?.[o.commandReceiptId]) }; }),
77
+ hookRuns: this.repo.list('tree_hook_runs', r => r.logicalTurnId === turn.logicalTurnId), jobs: this.turnJobs(turn),
78
+ snapshots: this.repo.list('tree_snapshots', s => s.logicalTurnId === turn.logicalTurnId).map(s => ({ id: s.id, coverage: s.coverage, restoreState: s.restoreState })) };
79
+ }
80
+ if (endpoint === 'metrics.read') { shape(payload, [], ['scopeNodeId', 'range']); if (payload.scopeNodeId) this.scoped(payload.scopeNodeId, scope); else if (!scope.nodeIds.includes(this.repo.rootId()) || scope.nodeIds.length !== this.repo.list('tree_nodes', n => !n.deleted).length) fail('BROWSE_SCOPE', 'Global metrics require the whole tree scope', 403); shape(payload.range ?? {}, [], ['afterOrdinal', 'throughOrdinal']);
81
+ const current = this.metrics.freeze(payload.scopeNodeId ?? null, payload.range), windows = this.repo.list('tree_metric_windows', w => !w.needsRecheck && w.scope === current.scope && w.toOrdinal <= current.toOrdinal).sort((a, b) => a.toOrdinal - b.toOrdinal).slice(-2);
82
+ return { ...current, windows, comparison: this.metrics.compare(windows[0], windows[1]) }; }
83
+ if (endpoint === 'reports.list') {
84
+ shape(payload, [], ['cursor', 'limit']); this.requireReports(scope); const page = this.page(this.repo.list('tree_reports'), payload, 'reports'); return { reports: page.rows.map(r => this.reports.preview(r.id, { actor: context.actor })), nextCursor: page.nextCursor };
85
+ }
86
+ if (endpoint === 'reports.details') { shape(payload, ['reportId'], ['includeEvidence']); this.requireReports(scope); if (payload.includeEvidence !== undefined && typeof payload.includeEvidence !== 'boolean') fail('INVALID_FIELD', 'Explicit evidence choice required'); return this.reports.preview(payload.reportId, { actor: context.actor, includeEvidence: payload.includeEvidence ?? false }); }
87
+ if (endpoint === 'source.read') { shape(payload, ['itemId','version','sourceIndex']); const item = this.repo.get('tree_items', payload.itemId); if (!item) fail('ITEM_NOT_FOUND', 'Item missing'); this.scoped(item.nodeId, scope); const version = this.repo.get('tree_item_versions', versionId(item.id, payload.version)); integer(payload.sourceIndex, 'sourceIndex', 0, 1999); const ref = version?.sourceRefs[payload.sourceIndex], source = ref && this.resolveSource(ref); if (!source) fail('SOURCE_CHANGED', 'Evidence is no longer available'); return { role: source.role, origin: source.origin, text: source.text, ref }; }
88
+ if (endpoint === 'snapshots.list') { shape(payload, ['logicalTurnId']); const turn = this.repo.get('tree_turns', payload.logicalTurnId); if (!turn || turn.nodeIds.some(id => !scope.nodeIds.includes(id))) fail('BROWSE_SCOPE', 'Snapshot turn is outside scope', 403); return { snapshots: this.repo.list('tree_snapshots', s => s.logicalTurnId === turn.id).map(s => ({ id: s.id, coverage: s.coverage, restoreState: s.restoreState, capturedAt: s.capturedAt })) }; }
89
+ if (endpoint === 'receipts.read') {
90
+ shape(payload, ['requestId']); const value = this.repo.meta().uiRequests?.[this.key(context.actor, payload.requestId)]; if (!value) return { state: 'not_found' }; if (value.sessionId && value.sessionId !== context.sessionId) fail('BROWSE_SCOPE', '操作属于另一段对话', 403); value.nodeIds.forEach(id => { if (!scope.nodeIds.includes(id)) fail('BROWSE_SCOPE', 'Receipt belongs to another browse scope', 403); });
91
+ if (value.endpoint === 'snapshots.restore' && ['pending', 'unknown'].includes(value.state)) { const intent = this.repo.meta().restoreIntents?.[digest(['restore', value.domainRequestId])]; if (intent) return { ...value, state: intent.state === 'completed' ? 'success' : intent.state, value: intent }; }
92
+ return value;
93
+ }
94
+ fail('ENDPOINT_NOT_FOUND', 'Unknown tree endpoint', 404);
95
+ }
96
+ requireReports(scope) { if (!scope.nodeIds.includes(this.repo.rootId()) || scope.nodeIds.length !== this.repo.list('tree_nodes', n => !n.deleted).length) fail('BROWSE_SCOPE', 'Reports combine evidence across the tree', 403); }
97
+ key(actor, requestId) { text(requestId, 'requestId', 512); return digest(['tree-ui', actor.id, requestId]); }
98
+ recordUI(key, value) {
99
+ return this.repo.command('recordTreeUIReceipt', { requestId: digest([key, value.state, digest(value)]), expected: {}, input: { key, state: value.state } }, { kind: 'program', id: 'tree-ui' }, () => { const meta = this.repo.meta(); this.repo.save('tree_meta', { ...meta, uiRequests: { ...meta.uiRequests, [key]: value } }, meta.revision); return value; }).result;
100
+ }
101
+ async dispatch(endpoint, payload, context) {
102
+ const scope = this.authorize(context); if (!TREE_ENDPOINTS.includes(endpoint)) fail('ENDPOINT_NOT_FOUND', 'Unknown tree endpoint', 404);
103
+ if (readEndpoints.has(endpoint)) return this.read(endpoint, payload, context, scope);
104
+ text(payload.requestId, 'requestId', 512); const key = this.key(context.actor, payload.requestId), hash = digest([endpoint, payload, ...(endpoint === 'session.selectBranch' ? [context.sessionId] : [])]), prior = this.repo.meta().uiRequests?.[key];
105
+ if (prior) { if (prior.hash !== hash) fail('IDEMPOTENCY_CONFLICT', 'This request identity has different input'); prior.nodeIds.forEach(id => { if (!scope.nodeIds.includes(id)) fail('BROWSE_SCOPE', 'Receipt outside browser scope', 403); }); return prior; }
106
+ const state = { endpoint, hash, nodeIds: [...scope.nodeIds], state: 'pending', domainRequestId: key, requestId: payload.requestId };
107
+ if (endpoint === 'session.selectBranch') {
108
+ shape(payload, ['requestId', 'nodeId', 'expectedTreeRevision', 'expectedSelectionRevision', 'pendingId']);
109
+ this.requireReports(scope);
110
+ if (!this.selectBranch) fail('ENDPOINT_UNAVAILABLE', '当前服务尚未启用目标范围选择');
111
+ if (payload.nodeId !== null) this.scoped(payload.nodeId, scope);
112
+ integer(payload.expectedTreeRevision, 'expectedTreeRevision'); integer(payload.expectedSelectionRevision, 'expectedSelectionRevision', 0);
113
+ if (payload.pendingId !== null) text(payload.pendingId, 'pendingId', 128);
114
+ state.sessionId = context.sessionId; this.recordUI(key, state);
115
+ try {
116
+ const value = await this.selectBranch(payload, context, key);
117
+ return this.recordUI(key, { ...state, state: 'success', value });
118
+ } catch (error) {
119
+ const pending = payload.pendingId && this.repo.meta().attributionPending?.[payload.pendingId];
120
+ this.recordUI(key, { ...state, state: pending?.continuationRequestId === key ? 'unknown' : 'failed', error: { code: error.code ?? 'HOST_ERROR' } }); throw error;
121
+ }
122
+ }
123
+ if (endpoint.startsWith('snapshots.')) {
124
+ if (!this.snapshotsFor) fail('SNAPSHOT_UNAVAILABLE', 'Workspace snapshot service is not mounted');
125
+ shape(payload, endpoint === 'snapshots.preview' ? ['requestId', 'snapshotId', 'paths'] : ['requestId', 'previewId', 'expectedHashes']);
126
+ const preview = endpoint === 'snapshots.restore' && this.repo.meta().restorePreviews?.[payload.previewId], snapshot = this.repo.get('tree_snapshots', payload.snapshotId ?? preview?.snapshotId);
127
+ const turn = snapshot && this.repo.get('tree_turns', snapshot.logicalTurnId); if (!turn || turn.nodeIds.some(id => !scope.nodeIds.includes(id))) fail('BROWSE_SCOPE', 'Snapshot is outside the admitted turn scope', 403);
128
+ state.nodeIds = [...turn.nodeIds]; this.recordUI(key, state); const snapshots = this.snapshotsFor(context);
129
+ try { const value = endpoint === 'snapshots.preview' ? await snapshots.preview({ snapshotId: snapshot.id, paths: payload.paths, requestId: key }) : await snapshots.apply({ previewId: payload.previewId, expectedCurrentHashes: payload.expectedHashes, requestId: key, signal: context.signal });
130
+ if (endpoint === 'snapshots.preview') { value.diffs = await this.previewDiffs(value, snapshots, context.signal); this.authorize(context); }
131
+ return this.recordUI(key, { ...state, state: endpoint === 'snapshots.preview' || value.state === 'completed' ? 'success' : value.state ?? 'unknown', value });
132
+ } catch (error) { this.recordUI(key, { ...state, state: 'unknown', error: { code: error.code ?? 'SNAPSHOT_UNKNOWN' } }); throw error; }
133
+ }
134
+ return this.repo.transaction(() => {
135
+ const beforeNodes = this.repo.list('tree_nodes'), result = this.mutate(endpoint, payload, context, scope, key), receipt = result.receipt ?? result;
136
+ this.metrics.observeTree(receipt, beforeNodes);
137
+ if (result.repair) this.recordRepair(receipt, result.repair);
138
+ if (endpoint !== 'reports.export') state.nodeIds = uniqueIds([...(result.repair?.nodeIds ?? []), ...receipt.eventIds.flatMap(id => this.repo.get('tree_change_events', id).entityIds).flatMap(key => {
139
+ const split = key.indexOf(':'), kind = key.slice(0, split), row = this.repo.get(kind, key.slice(split + 1));
140
+ return kind === 'tree_nodes' ? [row.id] : kind === 'tree_turns' ? row.nodeIds : row?.nodeId ? [row.nodeId] : row?.scopeNodeId ? [row.scopeNodeId] : [];
141
+ })]).filter(id => !this.repo.get('tree_nodes', id)?.deleted);
142
+ return this.recordUI(key, { ...state, state: 'success', value: receipt });
143
+ });
144
+ }
145
+ mutate(endpoint, p, context, scope, requestId) {
146
+ const opts = { repository: this.repo, resolveSource: this.resolveSource, context: () => ({ actor: context.actor }) }, tree = new TreeService(opts), items = new ItemService(opts), command = (input, expected = {}) => ({ requestId, input, expected });
147
+ const node = id => this.scoped(id, scope), expected = (kind, id, value) => { integer(value, 'expectedRevision'); return { [kind + ':' + id]: value }; };
148
+ if (endpoint === 'item.undoCorrection') {
149
+ shape(p, ['requestId', 'itemId', 'expectedRevision', 'replacementId', 'expectedReplacementRevision']);
150
+ const original = items.item(p.itemId), replacement = items.item(p.replacementId);
151
+ node(original.nodeId); node(replacement.nodeId);
152
+ if (original.kind !== 'understanding' || !['invalid', 'changed'].includes(original.lifecycle) || original.replacedBy !== replacement.id ||
153
+ original.nodeId !== replacement.nodeId || replacement.lifecycle !== 'active' || replacement.kind !== 'understanding' ||
154
+ replacement.activeVersion !== 1 || replacement.userProtected || replacement.producerRole !== 'learning') {
155
+ fail('CORRECTION_CHANGED', '更正后的内容已经变化,请先查看历史,不能直接撤销');
156
+ }
157
+ const old = items.version(original.id, original.activeVersion);
158
+ for (const ref of old.sourceRefs) {
159
+ if (!this.resolveSource(ref)) fail('SOURCE_CHANGED', '原理解的来源已不可用,不能直接恢复');
160
+ const owners = this.repo.list('tree_turns', turn => (turn.experienceRefs ?? turn.sourceRefs).some(source => digest(source) === digest(ref)));
161
+ if (owners.length && !owners.some(turn => turn.status === 'settled' && turn.nodeIds.includes(original.nodeId))) fail('SOURCE_CHANGED', '原理解的来源已不属于此事项');
162
+ }
163
+ const revisions = { ...expected('tree_items', original.id, p.expectedRevision), ...expected('tree_items', replacement.id, p.expectedReplacementRevision) };
164
+ return this.repo.command('undoLearningCorrection', command({ itemId: original.id, replacementId: replacement.id }, revisions), context.actor, () => {
165
+ this.repo.save('tree_items', { ...original, lifecycle: 'active', replacedBy: null,
166
+ correctionUndoHistory: [...(original.correctionUndoHistory ?? []), { replacementId: replacement.id, correctionSourceRefs: original.correctionSourceRefs ?? [], requestId }] }, original.revision);
167
+ this.repo.save('tree_items', { ...replacement, lifecycle: 'withdrawn', withdrawalReason: '用户撤销自动更正' }, replacement.revision);
168
+ for (const exposure of this.repo.list('tree_exposures', value => !value.revoked && value.itemVersions.some(version => version.itemId === replacement.id))) {
169
+ this.repo.save('tree_exposures', { ...exposure, revoked: true, invalidationReason: 'user_undid_correction' }, exposure.revision);
170
+ }
171
+ return { itemId: original.id, replacementId: replacement.id };
172
+ });
173
+ }
174
+ if (endpoint === 'turn.undoStatus') {
175
+ shape(p, ['requestId', 'turnId', 'observationId', 'expectedRevisions']); const turn = this.repo.get('tree_turns', p.turnId); if (!turn) fail('TURN_NOT_FOUND', 'Turn not found'); turn.nodeIds.forEach(node);
176
+ const all = Object.values(this.repo.meta().treeObservations ?? {}).flat(), original = all.find(o => o.id === p.observationId && o.logicalTurnId === turn.id && o.type === 'status' && o.origin !== 'cascade');
177
+ if (!original || this.repo.meta().reversedTreeChanges?.[original.commandReceiptId]) fail('REVISION_CONFLICT', 'Status change is unavailable or already reversed');
178
+ const group = all.filter(o => o.commandReceiptId === original.commandReceiptId && o.type === 'status'); shape(p.expectedRevisions, group.map(o => o.nodeId));
179
+ for (const o of group) { const current = node(o.nodeId); integer(p.expectedRevisions[o.nodeId], 'expectedRevision'); if (current.revision !== p.expectedRevisions[o.nodeId] || current.status !== o.after || current.deleted || all.some(later => later.ordinal > o.ordinal && later.nodeId === o.nodeId && ['status', 'parentId', 'deleted'].includes(later.type))) fail('REVISION_CONFLICT', 'This status has changed since the original action'); }
180
+ for (const o of group.filter(o => o.before !== 'active')) if (this.repo.list('tree_nodes', n => !n.deleted && !group.some(g => g.nodeId === n.id) && ['active', 'paused'].includes(n.status) && this.repo.ancestors(n.id).some(a => a.id === o.nodeId)).length) fail('REVISION_CONFLICT', 'Later descendant activity prevents restoring this old status');
181
+ const receipt = this.repo.command('undoTurnStatus', command({ turnId: turn.id, observationId: original.id }, Object.fromEntries(group.map(o => ['tree_nodes:' + o.nodeId, p.expectedRevisions[o.nodeId]]))), context.actor, () => {
182
+ for (const o of group) { const current = node(o.nodeId); this.repo.save('tree_nodes', { ...current, status: o.before, statusOrigin: o.beforeStatusOrigin, cascadeFromId: o.beforeCascadeFromId, statusChangedAt: this.repo.now(), statusSourceRefs: [] }, current.revision); }
183
+ const meta = this.repo.meta(); this.repo.save('tree_meta', { ...meta, treeRevision: meta.treeRevision + 1, reversedTreeChanges: { ...meta.reversedTreeChanges, [original.commandReceiptId]: requestId } }, meta.revision); return { turnId: turn.id, nodeIds: group.map(o => o.nodeId), reversalOf: original.commandReceiptId };
184
+ });
185
+ return { receipt, repair: { producerRole: original.origin === 'attribution' ? 'attribution' : 'main', producerJobId: null, nodeIds: group.map(o => o.nodeId), type: 'user_undo_status', reason: '用户撤销本轮状态判断;等待诊断是否属于原误判', eligible: original.origin !== 'user' } };
186
+ }
187
+ if (endpoint === 'node.rename') { shape(p, ['requestId','nodeId','name','expectedRevision']); node(p.nodeId); text(p.name, 'name', 80);
188
+ const before = node(p.nodeId), receipt = this.repo.command('renameTreeNode', command({ nodeId: p.nodeId, name: p.name }, expected('tree_nodes', p.nodeId, p.expectedRevision)), context.actor, () => { const n = node(p.nodeId); this.repo.save('tree_nodes', { ...n, name: p.name, nameUserProtected: true }, n.revision); const meta = this.repo.meta(); this.repo.save('tree_meta', { ...meta, treeRevision: meta.treeRevision + 1 }, meta.revision); return { nodeId: n.id }; });
189
+ return { receipt, repair: { producerRole: 'attribution', producerJobId: null, nodeIds: [p.nodeId], type: 'user_rename', reason: '用户修改事项名称;仍需判断是原误判还是新的意图', eligible: !before.nameUserProtected && before.name !== p.name } }; }
190
+ if (endpoint === 'node.move') { shape(p, ['requestId','nodeId','parentId','expectedTreeRevision']); const n = node(p.nodeId); node(p.parentId); this.requireSubtree(n.id, scope); if (this.repo.meta().treeRevision !== p.expectedTreeRevision) fail('REVISION_CONFLICT', 'Tree changed'); return tree.moveNode(command({ nodeId: n.id, parentId: p.parentId, sourceRefs: [] }, expected('tree_nodes', n.id, n.revision))); }
191
+ if (endpoint === 'node.status') { shape(p, ['requestId','nodeId','status','expectedRevision']); const before = node(p.nodeId); this.requireSubtree(p.nodeId, scope); if (p.status === 'active') this.repo.ancestors(p.nodeId).forEach(n => node(n.id));
192
+ const receipt = tree.setStatus(command({ nodeId: p.nodeId, status: p.status, sourceRefs: [] }, expected('tree_nodes', p.nodeId, p.expectedRevision)));
193
+ return { receipt, repair: { producerRole: before.statusOrigin === 'model' ? 'main' : 'attribution', producerJobId: null, nodeIds: [p.nodeId], type: 'user_status_change', reason: '用户改变事项状态;重新打开助手标完事项需要回看依据', eligible: before.statusOrigin !== 'user' && before.status !== p.status } }; }
194
+ if (['turn.correct', 'turn.undoCreate'].includes(endpoint)) {
195
+ shape(p, endpoint === 'turn.correct' ? ['requestId','turnId','nodeIds','expectedAssignmentRevision'] : ['requestId','turnId','nodeId','expectedRevision','expectedAssignmentRevision'], endpoint === 'turn.correct' ? ['create'] : []);
196
+ const turn = this.repo.get('tree_turns', p.turnId); if (!turn || turn.nodeIds.some(id => !scope.nodeIds.includes(id))) fail('BROWSE_SCOPE', 'Turn outside scope', 403); if (turn.assignmentRevision !== p.expectedAssignmentRevision) fail('REVISION_CONFLICT', 'Assignment changed');
197
+ let targetIds = p.nodeIds;
198
+ if (endpoint === 'turn.undoCreate') { const n = node(p.nodeId); if (n.revision !== p.expectedRevision || !turn.createdNodeIds.includes(n.id)) fail('REVISION_CONFLICT', 'Created node changed');
199
+ if (this.repo.list('tree_links', l => l.nodeId === n.id && l.active && l.turnId !== turn.id).length || this.repo.list('tree_nodes', c => c.parentId === n.id && !c.deleted).length) fail('NEED_ASSIGNMENT_CORRECTION', 'This node has later experience; correct this turn instead');
200
+ targetIds = turn.nodeIds.filter(id => id !== n.id); if (!targetIds.length) targetIds = [n.parentId]; }
201
+ array(targetIds, 'nodeIds', p.create ? 7 : 8).forEach(node);
202
+ if (p.create) {
203
+ shape(p.create, ['name', 'parentId', 'expectedTreeRevision']); text(p.create.name, 'name', 80); node(p.create.parentId);
204
+ if (p.create.expectedTreeRevision !== this.repo.meta().treeRevision) fail('REVISION_CONFLICT', 'Tree changed');
205
+ this.repo.ancestors(p.create.parentId).forEach(n => node(n.id)); const id = randomUUID(), beforeCreate = this.repo.list('tree_nodes');
206
+ const created = this.repo.command('createCorrectionTarget', { requestId: digest([requestId, 'create']), expected: {}, input: p.create }, context.actor, () => {
207
+ this.repo.save('tree_nodes', { id, parentId: p.create.parentId, name: p.create.name, nameUserProtected: true, status: 'active', statusOrigin: 'user', sourceRefs: [], lastOwnTurnAt: turn.startedAt, lastSummaryWatermark: null });
208
+ for (const ancestor of this.repo.ancestors(p.create.parentId)) if (ancestor.status !== 'active') this.repo.save('tree_nodes', { ...ancestor, status: 'active', statusOrigin: 'user', cascadeFromId: null }, ancestor.revision);
209
+ const meta = this.repo.meta(); this.repo.save('tree_meta', { ...meta, treeRevision: meta.treeRevision + 1 }, meta.revision); return { nodeId: id };
210
+ });
211
+ this.metrics.observeTree(created, beforeCreate); targetIds = [...targetIds, id];
212
+ }
213
+ const receipt = tree.correctAssignment(command({ turnId: turn.id, nodeIds: targetIds, sourceRefs: [], ...(endpoint === 'turn.undoCreate' ? { undoCreate: true } : {}) }, expected('tree_turns', turn.id, turn.revision)));
214
+ return { receipt, repair: { producerRole: 'attribution', producerJobId: turn.attributionRunId, nodeIds: uniqueIds([...turn.nodeIds, ...targetIds]), type: 'assignment_correction', reason: '用户纠正本轮目标归属', eligible: !receipt.result.unchanged } };
215
+ }
216
+ if (endpoint.startsWith('item.')) {
217
+ shape(p, endpoint === 'item.edit' ? ['requestId','itemId','expectedVersion','replacement','reason'] : endpoint === 'item.withdraw' ? ['requestId','itemId','expectedVersion','reason'] : ['requestId','itemId','expectedVersion']);
218
+ const item = items.item(p.itemId); node(item.nodeId); if ((item.activeVersion ?? item.draftVersion) !== p.expectedVersion) fail('REVISION_CONFLICT', 'Item version changed');
219
+ const old = items.version(item.id, p.expectedVersion), exp = expected('tree_items', item.id, item.revision); let receipt;
220
+ if (endpoint === 'item.confirm') {
221
+ if (item.stale) fail('ITEM_RECHECK_REQUIRED', '归属已变化,请核对并保存当前内容后再使用');
222
+ if (item.kind !== 'understanding' || old.sourceKind !== 'assistant_inferred') fail('CONFIRM_INFERENCE_ONLY', 'Only an assistant inference can be confirmed');
223
+ receipt = items.confirm(command({ itemId: item.id, version: p.expectedVersion }, exp));
224
+ new EpisodeSignals({ repository: this.repo, resolveSource: this.resolveSource }).captureConfirmation(receipt.id); return receipt;
225
+ }
226
+ if (endpoint === 'item.withdraw') receipt = items.withdraw(command({ itemId: item.id, version: p.expectedVersion, reason: p.reason }, exp));
227
+ else receipt = this.editItem(command({ itemId: item.id, version: p.expectedVersion, replacement: p.replacement, reason: p.reason }, exp), item, old, context.actor);
228
+ return { receipt, repair: { producerRole: item.producerRole ?? 'learning', producerJobId: item.producerJobId ?? null, itemId: item.id, itemVersion: p.expectedVersion, nodeIds: [item.nodeId], type: endpoint === 'item.edit' ? 'user_edit' : 'user_withdraw', reason: p.reason, eligible: !item.stale && !item.userProtected && old.sourceKind === 'assistant_inferred' } };
229
+ }
230
+ if (endpoint === 'hook.disable') { shape(p, ['requestId','methodId','hookId','expectedVersion']); const item = items.item(p.methodId); node(item.nodeId); if (item.activeVersion !== p.expectedVersion) fail('REVISION_CONFLICT', 'Method changed');
231
+ const hook = this.repo.get('tree_hooks', digest(['hook_version', p.hookId, p.expectedVersion])); if (!hook || hook.methodId !== item.id) fail('HOOK_NOT_FOUND', 'Hook is not part of the current method');
232
+ const receipt = this.repo.command('disableTreeHook', command({ methodId: item.id, hookId: p.hookId, version: p.expectedVersion }, expected('tree_hooks', hook.id, hook.revision)), context.actor, () => { this.repo.save('tree_hooks', { ...hook, enabled: false, manuallyDisabled: true }, hook.revision); return { methodId: item.id, hookId: hook.hookId, enabled: false }; });
233
+ return { receipt, repair: { producerRole: item.producerRole ?? 'learning', producerJobId: item.producerJobId ?? null, nodeIds: [item.nodeId], itemId: item.id, itemVersion: item.activeVersion, type: 'user_disable_hook', reason: '用户停用方法提醒;尚未诊断原因', eligible: hook.enabled && !item.userProtected } }; }
234
+ if (endpoint === 'signal.correct') { shape(p, ['requestId','signalId','category','expectedRevision']); oneOf(p.category, CATEGORIES, 'category'); const old = this.repo.get('tree_signals', p.signalId); if (!old || !effectiveSignals(this.repo, this.resolveSource).some(s => s.id === old.id)) fail('REVISION_CONFLICT', 'Signal has already been corrected'); old.nodeIds.forEach(node);
235
+ const replacement = Object.fromEntries(['eventId','subjectRefs','dimension','subitem','category','polarity','evidenceKind','sourceRefs','nodeIds','confidence'].map(k => [k, old[k]]));
236
+ const receipt = new SignalService(opts).correct(command({ signalId: old.id, replacement: { ...replacement, category: p.category, subitem: p.category, polarity: signalPolarity(p.category) } }, expected('tree_signals', old.id, p.expectedRevision)));
237
+ return { receipt, repair: { producerRole: 'learning', producerJobId: null, nodeIds: old.nodeIds, type: 'user_category_correction', reason: '用户纠正反馈类别', eligible: !old.correctionOf && old.category !== p.category } }; }
238
+ if (endpoint === 'parameters.set') { shape(p, ['requestId','name','nodeId','value','expectedRevision']); node(p.nodeId); const id = digest(['parameter', p.name, p.nodeId]), old = this.repo.get('tree_parameters', id); if ((old?.revision ?? 0) !== p.expectedRevision) fail('REVISION_CONFLICT', 'Parameter changed'); return tree.setParameter(command({ name: p.name, scopeNodeId: p.nodeId, value: p.value, sourceRefs: [] }, old ? expected('tree_parameters', id, p.expectedRevision) : {})); }
239
+ if (endpoint === 'reports.export') { shape(p, ['requestId','reportIds','includeEvidence','expectedRevisions']); this.requireReports(scope); const exp = {}; for (const id of array(p.reportIds, 'reportIds', 100)) Object.assign(exp, expected('tree_reports', id, p.expectedRevisions[id])); return this.reports.export(command({ reportIds: p.reportIds, includeEvidence: p.includeEvidence }, exp), context.actor); }
240
+ fail('ENDPOINT_NOT_FOUND', 'Unknown tree mutation', 404);
241
+ }
242
+ editItem(command, item, old, actor) {
243
+ if (item.lifecycle !== 'active') fail('VERSION_CONFLICT', 'Only active content may be edited; unfinished drafts can be withdrawn');
244
+ text(command.input.reason, 'reason'); const replacement = command.input.replacement;
245
+ if (item.kind === 'understanding') text(replacement, 'understanding', 2000);
246
+ else if (item.kind === 'supplement') validateShort(replacement);
247
+ else { shape(replacement, ['short', 'long']); if (!replacement.short && !replacement.long) fail('TEXT_REQUIRED', 'A method needs text'); if (replacement.short) validateShort(replacement.short); if (replacement.long) validateLong(replacement.long); }
248
+ return this.repo.command('editTreeItem', command, actor, () => {
249
+ const sourceRefs = item.stale ? old.sourceRefs.filter(ref => {
250
+ const owners = this.repo.list('tree_turns', t => (t.experienceRefs ?? t.sourceRefs).some(r => digest(r) === digest(ref)));
251
+ return !owners.length || owners.some(t => t.nodeIds.includes(item.nodeId) && t.status === 'settled');
252
+ }) : old.sourceRefs;
253
+ const version = old.version + 1, content = item.kind === 'method' ? { ...old.content, ...replacement, sourceRefs } : replacement;
254
+ this.repo.save('tree_item_versions', { ...old, id: versionId(item.id, version), version, content, sourceRefs, sourceKind: 'user_expressed', reason: command.input.reason, supersedes: old.version, userEditRef: digest(['change', command.requestId]) });
255
+ if (item.kind === 'method') {
256
+ const bundle = this.repo.get('tree_methods', methodId(item.id, old.version)), hooks = bundle.hooks.map(ref => { const row = this.repo.get('tree_hooks', digest(['hook_version', ref.id, ref.version])); this.repo.save('tree_hooks', { ...row, enabled: false }, row.revision); this.repo.save('tree_hooks', { ...row, id: digest(['hook_version', ref.id, version]), version }); return { id: ref.id, version }; });
257
+ this.repo.save('tree_methods', { ...bundle, id: methodId(item.id, version), version, ...replacement, hooks, evaluation: { status: 'unverified', windowId: null, evidenceIds: [] } });
258
+ } else if (item.kind === 'supplement') { const bundle = this.repo.get('tree_supplements', digest(['supplement', item.id, old.version])); this.repo.save('tree_supplements', { ...bundle, id: digest(['supplement', item.id, version]), version, ...(item.stale ? { traitEvidence: bundle.traitEvidence.filter(ref => sourceRefs.some(r => digest(r) === digest(ref))) } : {}) }); }
259
+ this.repo.save('tree_items', { ...item, activeVersion: version, validation: 'unverified', userProtected: true, producerRole: 'user', producerJobId: null,
260
+ ...(item.stale ? { stale: false, staleReason: null, sourceReview: { ...item.sourceReview, status: 'resolved_by_user', userEditRef: digest(['change', command.requestId]) } } : {}) }, item.revision);
261
+ for (const e of this.repo.list('tree_exposures', e => !e.revoked && e.itemVersions.some(v => v.itemId === item.id))) this.repo.save('tree_exposures', { ...e, revoked: true, invalidationReason: 'user_edit' }, e.revision);
262
+ return { itemId: item.id, version };
263
+ });
264
+ }
265
+ recordRepair(receipt, value) {
266
+ const event = this.repo.get('tree_change_events', receipt.eventIds[0]), ref = { sessionId: 'arcfold-ui:' + this.repo.rootId(), seq: event.ordinal, sha256: digest(event) };
267
+ const id = digest(['ui-repair', receipt.id]);
268
+ this.repo.command('recordUserCorrectionObservation', { requestId: id, expected: {}, input: { receiptId: receipt.id, sourceRefs: [ref] } }, { kind: 'program', id: 'tree-ui' }, () => {
269
+ this.repo.save('tree_repairs', { id, producerRole: value.producerRole, producerJobId: value.producerJobId, itemId: value.itemId ?? null, itemVersion: value.itemVersion ?? null, stage: 'generate', type: value.type, sourceSignalIds: [], nodeIds: value.nodeIds, reason: value.reason, requestId: receipt.requestId, evidenceRefs: [ref], countsAsError: value.eligible ? null : false, eligibleForDiagnosis: value.eligible, attribution: 'source_bound_observation' }); return { repairId: id };
270
+ });
271
+ }
272
+ requireSubtree(id, scope) { for (const n of this.repo.list('tree_nodes', n => !n.deleted && this.repo.ancestors(n.id).some(a => a.id === id))) this.scoped(n.id, scope); }
273
+ async previewDiffs(preview, snapshots, signal) {
274
+ const scan = await snapshots.io.scan({ copy: true, signal });
275
+ const render = bytes => bytes === null ? null : { sha256: contentHash(bytes), size: bytes.length, binary: bytes.includes(0), truncated: bytes.length > 65536, text: bytes.includes(0) ? null : bytes.subarray(0, 65536).toString('utf8') };
276
+ return preview.changes.map(change => {
277
+ const current = scan.manifest.files.find(f => f.path === change.path), fingerprint = current ? { sha256: current.sha256, mode: current.mode } : null;
278
+ if (canonicalJson(fingerprint) !== canonicalJson(change.current)) fail('RESTORE_CONFLICT', 'Files changed while preparing the review');
279
+ const target = change.target ? snapshots.git(['cat-file', 'blob', change.target.blob], undefined, true) : null;
280
+ if (target && contentHash(target) !== change.target.sha256) fail('SNAPSHOT_HASH', 'Saved content changed');
281
+ return { path: change.path, kind: change.kind, current: render(scan.contents.get(change.path) ?? null), restoreTo: render(target) };
282
+ });
283
+ }
284
+ }
285
+ const uniqueIds = values => [...new Set(values)];
@@ -0,0 +1,143 @@
1
+ import { CONTRACT, canonicalJson, digest, fail, jsonClone } from './contracts.mjs';
2
+ import { validateOutput, resolveSourceIds } from './prompts.mjs';
3
+ import { effectiveSignals, EpisodeSignals } from './signals.mjs';
4
+ import { itemInput } from './learning.mjs';
5
+ import { LocalReports } from './reports.mjs';
6
+
7
+ const unique = values => [...new Map(values.map(v => [canonicalJson(v), v])).values()];
8
+ const versionKey = (id, version) => digest(['item_version', id, version]);
9
+
10
+ export class VerificationService {
11
+ constructor({ repository, experience, contextBuilder, jobs, modelConfig, clusters, writerRouter }) {
12
+ Object.assign(this, { repo: repository, experience, builder: contextBuilder, jobs, config: modelConfig, clusters, writers: writerRouter }); this.resolveSource = contextBuilder.resolveSource; this.reports = new LocalReports({ repository, resolveSource: this.resolveSource });
13
+ }
14
+ exposureTurn(exposure) {
15
+ const invocation = this.repo.get('tree_invocations', exposure.invocationId) ?? this.repo.list('tree_invocations', i => i.ledgerRequestId === exposure.invocationId)[0];
16
+ if (!invocation || invocation.role !== 'main' || invocation.nativeSessionId !== exposure.modelSessionId) return null;
17
+ return this.repo.list('tree_turns', t => t.sessionId === invocation.nativeSessionId && t.nativeTurn === invocation.nativeTurn && t.status === 'settled')[0] ?? null;
18
+ }
19
+ freeze(nodeId, { reason = 'window', summaryId = null, clusterIds = [], afterTurnId = null } = {}) {
20
+ if (!['window', 'cluster', 'stagnation'].includes(reason)) fail('VERIFICATION_TRIGGER', 'Unsupported reflection trigger');
21
+ const node = this.builder.node(nodeId);
22
+ const clusterViews = clusterIds.map(id => { const row = this.repo.get('tree_clusters', id); if (!row || row.nodeId !== nodeId) fail('CLUSTER_SCOPE', 'Cluster must be reviewed at its common ancestor'); return this.clusters.freeze(row); });
23
+ const clusterRefs = clusterViews.flatMap(c => c.episodes.flatMap(e => [...e.beforeRefs, ...e.reactionRefs, ...e.resolutionRefs]));
24
+ const clusterTurns = this.repo.list('tree_turns', t => t.status === 'settled' && (t.experienceRefs ?? t.sourceRefs).some(r => clusterRefs.some(ref => digest(ref) === digest(r))));
25
+ const ownTurns = [...new Map([...this.experience.turns(nodeId, { includeInterrupted: false }), ...clusterTurns].map(t => [t.id, t])).values()].sort((a, b) => a.startedAt.localeCompare(b.startedAt) || a.sessionId.localeCompare(b.sessionId) || a.nativeTurn - b.nativeTurn);
26
+ const start = afterTurnId === null ? 0 : ownTurns.findIndex(t => t.id === afterTurnId) + 1;
27
+ if (afterTurnId !== null && start === 0) fail('WINDOW_BOUNDARY', 'Unknown prior window boundary');
28
+ const turns = ownTurns.slice(start), ids = new Set(turns.map(t => t.id));
29
+ const exposures = this.repo.list('tree_exposures', e => e.role === 'main').filter(e => { const turn = this.exposureTurn(e); return turn && ids.has(turn.id); });
30
+ const signals = effectiveSignals(this.repo, this.resolveSource), allTurns = this.repo.list('tree_turns');
31
+ const feedbackScopes = new Set([nodeId, ...clusterViews.flatMap(c => c.episodes.flatMap(e => e.nodeIds))]);
32
+ const effects = turns.map(turn => {
33
+ const material = this.experience.material(turn); if (material.revokedRefs.length) fail('SOURCE_CHANGED', 'Verification cannot silently omit withdrawn experience');
34
+ const next = allTurns.filter(t => t.sessionId === turn.sessionId && t.nativeTurn > turn.nativeTurn).sort((a, b) => a.nativeTurn - b.nativeTurn)[0];
35
+ const feedback = (next?.nodeIds.some(id => feedbackScopes.has(id)) ? next.userSourceRefs : []).map(this.resolveSource).filter(Boolean);
36
+ const observations = exposures.filter(e => this.exposureTurn(e).id === turn.id);
37
+ return { turnId: turn.id, assignmentRevision: turn.assignmentRevision, exposureIds: observations.map(e => e.id), itemVersions: unique(observations.flatMap(e => e.visibleItemVersions ?? e.itemVersions)),
38
+ input: material.sources.filter(s => s.role === 'user' && s.origin === 'user'), answer: material.sources.filter(s => s.role === 'assistant' && s.origin === 'model'), feedback,
39
+ signals: signals.filter(s => s.sourceRefs.some(ref => feedback.some(source => digest(ref) === digest(source.ref)))),
40
+ uiActions: this.repo.list('tree_change_events', e => e.actor.kind === 'user' && e.entityIds.some(key => key === 'tree_turns:' + turn.id)) };
41
+ });
42
+ const summary = summaryId ? this.repo.get('tree_summaries', summaryId) : null;
43
+ if (summaryId && (!summary || summary.nodeId !== nodeId)) fail('SUMMARY_SCOPE', 'Window needs its own exact summary');
44
+ const before = summary?.previousVersion ? this.repo.get('tree_summaries', digest(['summary', nodeId, summary.previousVersion])) : null;
45
+ const context = this.builder.build([nodeId], 'verification'), exposedIds = new Set(exposures.flatMap(e => e.itemVersions.map(v => v.itemId)));
46
+ const reviewNodeIds = [...new Set([nodeId, ...clusterViews.flatMap(c => c.episodes.flatMap(e => e.nodeIds))])];
47
+ const extraRows = clusterViews.length ? this.builder.activeItems([...new Set(exposures.flatMap(e => e.nodeIds))], 'verification').filter(r => exposedIds.has(r.item.id)) : [];
48
+ const rows = [...new Map([...this.builder.activeItems(context.ancestorPaths.flat(), 'verification'), ...extraRows].filter(r => ['understanding', 'method'].includes(r.item.kind)).map(r => [r.item.id, r])).values()];
49
+ const sources = unique([...effects.flatMap(e => [...e.input, ...e.answer, ...e.feedback]), ...clusterViews.flatMap(c => [...c.episodes, ...c.controls].flatMap(e => e.sources))]);
50
+ const knownItems = rows.map(itemInput), trace = knownItems.map(item => ({ ...item, usedTurns: effects.filter(e => e.itemVersions.some(v => v.itemId === item.id && v.version === item.version)).map(e => e.turnId),
51
+ expectation: item.method?.expectation ?? null, inputMode: item.method ? item.method.short ? 'short_rule' : exposures.some(e => e.methodReadVersions?.some(v => v.itemId === item.id && v.version === item.version)) ? 'full_method_read' : 'directory_only' : 'understanding',
52
+ categoryCount: new Set(effects.filter(e => e.itemVersions.some(v => v.itemId === item.id && v.version === item.version)).flatMap(e => e.signals.filter(s => s.category === item.method?.expectation?.category).map(s => s.eventId))).size }));
53
+ const window = { nodeId, reason, summaryId, summaryVersion: summary?.version ?? null, throughTurnIds: turns.map(t => t.id), effectsHash: digest(effects), clusterVersions: clusterViews.map(c => [c.id, c.revision]) };
54
+ window.consolidationWindow = summary ? digest(['consolidation-window', nodeId, summary.sourceWatermark ?? [summary.throughTurnIds, summary.assignmentVersions]]) : null;
55
+ const windowId = digest(['verification-window', window]);
56
+ const input = { inputVersion: CONTRACT, role: 'verification', scope: { nodeIds: [nodeId], ancestorPaths: unique([...context.ancestorPaths, ...reviewNodeIds.map(id => this.repo.ancestors(id).map(n => n.id))]), treeRevision: context.treeRevision }, sources, knownItems,
57
+ allowedIds: { nodeIds: [...new Set([...context.ancestorPaths.flat(), ...exposures.flatMap(e => e.nodeIds), ...knownItems.flatMap(i => i.originChain ?? [])])], subjectIds: knownItems.map(i => i.id), fileIds: [],
58
+ opportunitySourceIds: unique(effects.filter(e => e.exposureIds.length).flatMap(e => e.input.map(s => s.id))), clusters: clusterViews.map(c => ({ id: c.id, version: c.revision })),
59
+ nodeVersions: [{ id: nodeId, version: node.revision }], summaryVersions: summary ? [{ id: nodeId, version: summary.version }] : [] },
60
+ changeReceipts: [], window: { ...window, id: windowId }, effects, trace, exposures, reviewNodeIds, clusters: clusterViews, summaryBefore: before, summaryAfter: summary,
61
+ hookObservations: new EpisodeSignals({ repository: this.repo, resolveSource: this.resolveSource }).hookCandidates().filter(h => ids.has(h.logicalTurnId)),
62
+ constraints: context.segments.filter(s => s.nodeId !== null) };
63
+ const revisions = { ['tree_nodes:' + nodeId]: node.revision };
64
+ for (const id of reviewNodeIds) revisions['tree_nodes:' + id] = this.repo.get('tree_nodes', id).revision;
65
+ for (const turn of turns) revisions['tree_turns:' + turn.id] = turn.revision;
66
+ for (const row of rows) revisions['tree_items:' + row.item.id] = row.item.revision;
67
+ for (const row of exposures) revisions['tree_exposures:' + row.id] = row.revision;
68
+ for (const row of clusterViews) revisions['tree_clusters:' + row.id] = row.revision;
69
+ return { input, revisions };
70
+ }
71
+ enqueue(nodeId, options) {
72
+ const frozen = this.freeze(nodeId, options), key = frozen.input.window.id;
73
+ const existing = this.repo.get('tree_jobs', digest(['model_job', 'verification', key])); if (existing) return existing;
74
+ return this.jobs.enqueue({ role: 'verification', key, nodeIds: [nodeId], input: frozen.input, revisionSet: frozen.revisions, config: this.config('verification', { parentJobId: options?.parentJobId, nodeIds: [nodeId] }), trigger: { kind: options?.reason ?? 'window', producerRole: 'program', windowId: key, ...(options?.parentJobId ? { parentJobId: options.parentJobId } : {}) } });
75
+ }
76
+ enqueueCorrection(intent) {
77
+ const key = digest(['assignment-source-recheck', intent.id]);
78
+ const existing = this.repo.get('tree_jobs', digest(['model_job', 'verification', key]));
79
+ // A failed or unknown request remains this intent's owner. Never recreate
80
+ // it with a newer prompt, model selection or item revision on restart.
81
+ if (existing) return existing;
82
+ const item = this.repo.get('tree_items', intent.input.itemId), turn = this.repo.get('tree_turns', intent.input.turnId);
83
+ if (!item || item.lifecycle !== 'active' || !item.stale || item.staleReason !== 'assignment_corrected' || item.activeVersion !== intent.input.itemVersion ||
84
+ turn?.assignmentRevision !== intent.input.assignmentRevision || turn.nodeIds.includes(item.nodeId)) fail('JOB_STALE', 'Correction target or assignment has changed');
85
+ const version = this.repo.get('tree_item_versions', versionKey(item.id, item.activeVersion));
86
+ const originalRefs = new Set(version.sourceRefs.map(canonicalJson));
87
+ const turns = this.repo.list('tree_turns', t => (t.experienceRefs ?? t.sourceRefs).some(r => originalRefs.has(canonicalJson(r))));
88
+ const retained = version.sourceRefs.filter(ref => {
89
+ const owners = turns.filter(t => (t.experienceRefs ?? t.sourceRefs).some(r => canonicalJson(r) === canonicalJson(ref)));
90
+ return !owners.length || owners.some(t => t.nodeIds.includes(item.nodeId) && t.status === 'settled');
91
+ });
92
+ const sources = unique(retained.map(ref => { const source = this.resolveSource(ref); if (!source) fail('SOURCE_CHANGED', 'A remaining source is unavailable'); return source; }));
93
+ if (!sources.length) fail('RECHECK_EVIDENCE_MISSING', 'No remaining evidence can support a model recheck');
94
+ const node = this.builder.node(item.nodeId), path = this.repo.ancestors(node.id).map(n => n.id);
95
+ const known = itemInput({ item, version, method: item.kind === 'method' ? this.repo.get('tree_methods', digest(['method', item.id, item.activeVersion])) : null });
96
+ // The old claim is the review target, not new evidence. Removed raw refs
97
+ // stay in the historical version and never enter this frozen request.
98
+ if (item.kind === 'method') { known.content = { ...known.content }; delete known.content.sourceRefs; }
99
+ const supplement = item.kind === 'supplement' ? this.repo.get('tree_supplements', digest(['supplement', item.id, item.activeVersion])) : null;
100
+ const input = { inputVersion: CONTRACT, role: 'verification', scope: { nodeIds: [node.id], ancestorPaths: [path], treeRevision: this.repo.meta().treeRevision },
101
+ sources, knownItems: [known], allowedIds: { nodeIds: path, subjectIds: [item.id], fileIds: [], opportunitySourceIds: [], clusters: [], nodeVersions: [], summaryVersions: [] }, changeReceipts: [],
102
+ sourceRecheck: { intentId: intent.id, itemId: item.id, itemVersion: item.activeVersion, removedSourceCount: version.sourceRefs.length - retained.length,
103
+ ...(supplement ? { universalStopgap: supplement.universalStopgap, retainedTraitSourceIds: sources.filter(s => supplement.traitEvidence.some(ref => digest(ref) === digest(s.ref))).map(s => s.id) } : {}) },
104
+ assignmentRevision: turn.assignmentRevision, exposures: [], effects: [], trace: [], reviewNodeIds: [node.id], clusters: [],
105
+ window: { id: key, nodeId: node.id, reason: 'assignment_correction' } };
106
+ const revisionSet = { ['tree_items:' + item.id]: item.revision, ['tree_nodes:' + node.id]: node.revision };
107
+ for (const row of turns) revisionSet['tree_turns:' + row.id] = row.revision;
108
+ return this.jobs.enqueue({ role: 'verification', key, nodeIds: [node.id], input, revisionSet, config: this.config('verification', { turnId: turn.id, nodeIds: [node.id] }),
109
+ trigger: { kind: 'assignment_correction', producerRole: 'program', intentId: intent.id, turnId: turn.id, assignmentRevision: turn.assignmentRevision } });
110
+ }
111
+ applyResult(result, { job, requestId }) {
112
+ const output = validateOutput('verification', result, job.input);
113
+ return this.repo.transaction(() => {
114
+ this.jobs.assertCurrent(job);
115
+ const report = this.reports.recordReview(output, { job, requestId: digest([requestId, 'report']) });
116
+ if (job.input.sourceRecheck) {
117
+ const intentId = job.input.sourceRecheck.intentId;
118
+ this.repo.command('completeSourceRecheckReport', { requestId: digest([requestId, 'intent']), expected: {}, input: { intentId, jobId: job.id, reportId: report.reportId } },
119
+ { kind: 'program', id: 'source-recheck' }, () => {
120
+ const intent = this.repo.get('tree_jobs', intentId);
121
+ if (!intent || intent.status !== 'queued') fail('JOB_STALE', 'Source recheck intent changed');
122
+ this.repo.save('tree_jobs', { ...intent, status: 'committed', ownedJobId: job.id, reportId: report.reportId }, intent.revision);
123
+ return { intentId, reportId: report.reportId };
124
+ });
125
+ }
126
+ return { reportId: report.reportId, reportOnly: true };
127
+ });
128
+ }
129
+ }
130
+
131
+ /** B3 owns summaries; B5 applies its already-validated relocation/proposals. */
132
+ export class ConsolidationReflection {
133
+ constructor({ repository, consolidation, resolveSource, writerRouter }) { Object.assign(this, { repo: repository, consolidation, resolveSource, writers: writerRouter }); }
134
+ applyResult(result, context) {
135
+ const { job } = context;
136
+ const output = validateOutput('consolidation', result, job.input);
137
+ if (job.trigger.reason === 'node_complete' && this.repo.get('tree_nodes', job.nodeIds[0]).status !== 'completed') fail('JOB_STALE', 'Node is no longer completed');
138
+ // Consolidation maintains the experience summary; proposed learning and
139
+ // promotion remain visible in the job output and are not applied.
140
+ const summary = this.consolidation.applyResult(output, context);
141
+ return { ...summary.result, moved: [], retrospect: { status: 'report_only' } };
142
+ }
143
+ }