@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,84 @@
1
+ import { basename, isAbsolute } from 'node:path';
2
+ import { BudgetLedger } from '../budget.mjs';
3
+ import { canonicalJson, digest, fail, integer, jsonClone, shape, text } from './contracts.mjs';
4
+
5
+ export const TREE_TEST_SCOPE = 'arcfold-tree-v3-150cny';
6
+ export const TEST_STAGE_CNY = Object.freeze({ smoke: 5, roles: 20, learning: 35, scripts: 30, heldout: 40, repair: 20 });
7
+ const frozen = value => { if (value && typeof value === 'object') { Object.values(value).forEach(frozen); Object.freeze(value); } return value; };
8
+
9
+ // Runtime experiment policy: preserve finite ledger arithmetic and settlement.
10
+ // Preserve the existing finite ledger's dispatch/unknown/settlement
11
+ // and schema. Its physical SQL bucket remains "limited"; a sealed manifest maps
12
+ // this one independent database to the externally visible tree experiment scope.
13
+ export class TreeTestLedger {
14
+ constructor({ filename, authorizationRef, manifest, create = false }) {
15
+ if (!isAbsolute(filename) || basename(filename) !== 'test-ledger.sqlite') fail('TEST_LEDGER_PATH', 'The shared experiment requires an explicit test-ledger.sqlite');
16
+ shape(manifest, ['budgetScope', 'ceilingCny', 'model', 'modelVersion', 'prices', 'inputBound', 'outputCap', 'verification']);
17
+ if (manifest.budgetScope !== TREE_TEST_SCOPE || manifest.ceilingCny !== 150 || manifest.inputBound !== 1000000) fail('TEST_BUDGET', 'One 150 CNY experiment and conservative 1M input reservation required');
18
+ integer(manifest.outputCap, 'output cap', 1, 8192); text(manifest.model, 'model'); text(manifest.modelVersion, 'model version');
19
+ shape(manifest.prices, ['input', 'output', 'cachedInput']); for (const p of Object.values(manifest.prices)) if (!Number.isFinite(p) || p < 0) fail('TEST_PRICE', 'Explicit nonnegative peak prices required');
20
+ shape(manifest.verification, ['at', 'urls', 'contentHashes', 'adapterVersion']); text(manifest.verification.at, 'price verification time');
21
+ if (!Array.isArray(manifest.verification.urls) || !manifest.verification.urls.length || !Array.isArray(manifest.verification.contentHashes) || manifest.verification.contentHashes.some(h => !/^[a-f0-9]{64}$/.test(h)) || manifest.verification.urls.length !== manifest.verification.contentHashes.length) fail('TEST_PRICE', 'Official verification references and hashes required');
22
+ this.base = new BudgetLedger(filename, { ceilingCny: 150, authorizationRef, scope: 'limited', create });
23
+ Object.defineProperties(this, { filename: { value: filename, enumerable: true }, scope: { value: TREE_TEST_SCOPE, enumerable: true }, manifest: { value: frozen(jsonClone(manifest)), enumerable: true }, manifestHash: { value: digest(manifest), enumerable: true } });
24
+ try {
25
+ this.base.store.transaction(() => {
26
+ const id = 'arcfold-tree-test-budget-manifest', old = this.base.store.get('experiment_budget_manifest', id);
27
+ if (old && old.hash !== this.manifestHash) fail('TEST_MANIFEST_CONFLICT', 'Do not silently change model, price or scope within this experiment');
28
+ if (!old) {
29
+ if (this.base.list().length || !create) fail('TEST_LEDGER_UNOWNED', 'Cannot adopt another experiment ledger');
30
+ this.base.store.save('experiment_budget_manifest', { id, hash: this.manifestHash, manifest: this.manifest, authorizationRef });
31
+ }
32
+ });
33
+ } catch (e) { this.base.close(); throw e; }
34
+ }
35
+ assertCurrentAuthorization() {
36
+ if (this.base.scope !== 'limited' || this.base.authorization.ceilingMicroCny !== 150000000 || this.base.summary().ceilingMicroCny !== 150000000 || digest(this.manifest) !== this.manifestHash) fail('TEST_BUDGET', 'Experiment cannot change its finite 150 CNY cap');
37
+ this.base.assertCurrentAuthorization(); const record = this.base.store.get('experiment_budget_manifest', 'arcfold-tree-test-budget-manifest');
38
+ if (record?.hash !== this.manifestHash || digest(record.manifest) !== this.manifestHash) fail('TEST_MANIFEST_CONFLICT', 'Frozen experiment authorization changed');
39
+ }
40
+ reserve(spec) {
41
+ const m = this.manifest;
42
+ if (spec.model !== m.model || spec.inputTokenCeiling !== m.inputBound || spec.maxOutputTokens > m.outputCap || canonicalJson(spec.prices) !== canonicalJson(m.prices)) fail('TEST_RESERVATION', 'Request differs from the frozen peak reservation policy');
43
+ const metadata = spec.metadata ?? {}, stage = metadata.stage;
44
+ if (!Object.hasOwn(TEST_STAGE_CNY, stage) || metadata.budgetScope !== this.scope || !metadata.treeJobId || !metadata.treeInvocationId || !metadata.role) fail('TEST_OWNER', 'Every paid request requires a role, job, invocation and experiment stage');
45
+ // Transport priceVersion is its own policy evidence; do not let that value
46
+ // overwrite and hide the job's independently frozen price identity.
47
+ if (metadata.modelVersion !== m.modelVersion || metadata.treePriceVersion !== this.manifestHash || metadata.priceVersion !== this.manifestHash) fail('TEST_RESERVATION', 'Job model/version and ledger price manifest must agree');
48
+ if (stage === 'repair' && (!metadata.failureId || !metadata.repairCommit || !metadata.retestPlanHash)) fail('TEST_REPAIR', 'Repair allowance requires a located failure, commit and frozen retest plan');
49
+ return this.base.store.transaction(() => {
50
+ this.assertCurrentAuthorization(); const prior = this.base.get(spec.requestId);
51
+ if (prior && ['stage', 'treeJobId', 'treeInvocationId', 'role', 'budgetScope'].some(k => prior.metadata[k] !== metadata[k])) fail('TEST_OWNER', 'Request cannot switch role/stage/owner');
52
+ const reserved = this.base.reserve(spec); // Reuses integer monetary arithmetic and the total hard cap.
53
+ const stageMicro = this.base.list().filter(r => r.metadata?.stage === stage).reduce((n, r) => n + r.spentMicroCny + r.reservedMicroCny, 0);
54
+ if (stageMicro > TEST_STAGE_CNY[stage] * 1000000) fail('TEST_STAGE_EXCEEDED', 'Stage spent + reserved + unknown exceeds its allowance');
55
+ return reserved;
56
+ });
57
+ }
58
+ get(id) { return this.base.get(id); }
59
+ list() { return this.base.list(); }
60
+ assertScope(id) {
61
+ this.assertCurrentAuthorization(); this.base.assertScope(id);
62
+ const record = this.get(id);
63
+ if (record && (record.model !== this.manifest.model || record.metadata?.budgetScope !== this.scope || record.metadata?.treePriceVersion !== this.manifestHash ||
64
+ record.metadata?.modelVersion !== this.manifest.modelVersion || !Object.hasOwn(TEST_STAGE_CNY, record.metadata?.stage) || !record.metadata?.treeJobId || !record.metadata?.treeInvocationId || !record.metadata?.role)) fail('TEST_OWNER', 'Saved receipt does not belong to this frozen experiment owner');
65
+ }
66
+ markDispatched(id) {
67
+ return this.base.store.transaction(() => {
68
+ this.assertCurrentAuthorization(); const record = this.get(id), active = this.list().filter(r => r.status === 'dispatched');
69
+ if (active.length >= 2 || record?.metadata.role === 'script_writer' && active.some(r => r.metadata.role === 'script_writer')) fail('TEST_CONCURRENCY', 'At most two test requests and one writer may be in flight across all roles');
70
+ return this.base.markDispatched(id);
71
+ });
72
+ }
73
+ markUnknown(id, reason) { return this.base.markUnknown(id, reason); }
74
+ releaseUnsent(id, reason) { return this.base.releaseUnsent(id, reason); }
75
+ settle(id, usage) { return this.base.settle(id, usage); }
76
+ summary() {
77
+ const base = this.base.summary(), records = this.list();
78
+ return { ...base, scope: this.scope, physicalScope: 'limited', manifestHash: this.manifestHash,
79
+ unknownMicroCny: records.filter(r => r.status === 'unknown').reduce((n, r) => n + r.reservedMicroCny, 0),
80
+ stages: Object.fromEntries(Object.entries(TEST_STAGE_CNY).map(([stage, ceilingCny]) => [stage, { ceilingCny, spentMicroCny: records.filter(r => r.metadata?.stage === stage).reduce((n, r) => n + r.spentMicroCny, 0), reservedMicroCny: records.filter(r => r.metadata?.stage === stage).reduce((n, r) => n + r.reservedMicroCny, 0) }])) };
81
+ }
82
+ modelPolicy() { return { contextWindow: this.manifest.inputBound, maxOutputTokens: this.manifest.outputCap, requestMaxTokens: this.manifest.outputCap, prices: jsonClone(this.manifest.prices), priceVersion: this.manifestHash, reasoningEfforts: ['off', 'low', 'high', 'max'] }; }
83
+ close() { this.base.close(); }
84
+ }
@@ -0,0 +1,254 @@
1
+ import { isAbsolute, resolve } from 'node:path';
2
+ import { canonicalJson, digest, fail, jsonClone } from './contracts.mjs';
3
+ import { HOOK_RULES, validateHook } from './items.mjs';
4
+ import { beneath, canonicalPath } from './host-process.mjs';
5
+ import { assertOwnedCompaction } from './host-context.mjs';
6
+
7
+ const actor = { kind: 'program', id: 'tree-hooks' };
8
+ const pathFields = new Set(['filePath', 'earlierFilesInTurn', 'filesWritten', 'filesWrittenInNode']);
9
+ const negative = new Set(['not_contains']);
10
+ const methodKey = (id, version) => digest(['method', id, version]);
11
+ const itemKey = (id, version) => digest(['item_version', id, version]);
12
+
13
+ /** Unknown host effects remain unknown; text matching never grants authority. */
14
+ export function matchCondition(condition, fields, { workspace, fileEffects = 'unknown' } = {}) {
15
+ const { field, op, value } = condition;
16
+ if (!Object.hasOwn(fields, field) || fields[field] === undefined || fields[field] === null) return { matched: null, diagnostic: 'missing_field:' + field };
17
+ const actual = fields[field], values = Array.isArray(actual) ? actual : [actual];
18
+ const incomplete = pathFields.has(field) && Array.isArray(actual) && fileEffects !== 'complete';
19
+ if (incomplete && negative.has(op)) return { matched: null, diagnostic: 'incomplete_file_effects' };
20
+ if (values.some(v => !['string', 'boolean'].includes(typeof v))) return { matched: null, diagnostic: 'unsupported_host_field:' + field };
21
+ const compare = entry => {
22
+ if (typeof value === 'boolean' || typeof entry === 'boolean') return op === 'equals' && entry === value;
23
+ if (pathFields.has(field)) {
24
+ if (!workspace) return null;
25
+ const a = canonicalPath(isAbsolute(entry) ? entry : resolve(workspace, entry)), b = canonicalPath(isAbsolute(value) ? value : resolve(workspace, value));
26
+ if (op === 'equals') return a === b;
27
+ if (op === 'ends_with') {
28
+ // A relative suffix matches complete canonical path components. An
29
+ // absolute suffix names the entire path, never a string fragment.
30
+ if (isAbsolute(value)) return a === b;
31
+ const base = canonicalPath(workspace); if (!beneath(b, base) || b === base) return false;
32
+ return a.endsWith('/' + b.slice(base.length + 1));
33
+ }
34
+ // A directory condition compares canonical path components, never a
35
+ // lexical prefix such as /source matching /source-evil or a symlink.
36
+ return beneath(a, b);
37
+ }
38
+ if (op === 'equals') return entry === value;
39
+ if (op === 'starts_with') return entry.startsWith(value);
40
+ if (op === 'ends_with') return entry.endsWith(value);
41
+ return entry.includes(value);
42
+ };
43
+ const matches = values.map(compare);
44
+ if (matches.some(v => v === null)) return { matched: null, diagnostic: 'path_context_missing' };
45
+ if (incomplete && !matches.some(Boolean)) return { matched: null, diagnostic: 'incomplete_file_effects' };
46
+ return { matched: op === 'not_contains' ? matches.every(v => !v) : matches.some(Boolean), diagnostic: null };
47
+ }
48
+
49
+ export class HookService {
50
+ constructor({ repository, readScope, resolveSource, workspace }) {
51
+ if (typeof readScope !== 'function' || typeof resolveSource !== 'function') fail('HOOK_HOST_REQUIRED', 'Hooks require trusted scope and original-source services');
52
+ Object.assign(this, { repo: repository, readScope, resolveSource, workspace });
53
+ }
54
+ snapshot(sessionId, moment) {
55
+ if (!HOOK_RULES[moment]) fail('INVALID_HOOK', 'Unknown hook event');
56
+ const scope = this.readScope(sessionId);
57
+ if (!scope || !(scope.phase === 'active' || moment === 'node_leave' && scope.phase === 'leaving')) fail('SCOPE_STALE', 'Hook scope is not admitted');
58
+ // After a user undoes a created goal, the old scope may contain both
59
+ // surviving and deleted nodes. Keep checking hooks on surviving paths.
60
+ const ids = moment === 'node_leave' && scope.phase === 'leaving'
61
+ ? scope.previousNodeIds.filter(id => { const node = this.repo.get('tree_nodes', id); return node && !node.deleted; })
62
+ : scope.nodeIds;
63
+ const paths = ids.map(id => this.repo.ancestors(id));
64
+ const byId = new Map(), depths = new Map();
65
+ for (const path of paths) for (const [depth, node] of path.entries()) {
66
+ byId.set(node.id, node);
67
+ depths.set(node.id, depth);
68
+ }
69
+ const nodes = [...byId.values()].sort((a, b) => depths.get(a.id) - depths.get(b.id) || a.id.localeCompare(b.id));
70
+ const relevant = new Set(byId.keys());
71
+ const itemsByNode = new Map();
72
+ for (const item of this.repo.listByNodeIds('tree_items', [...relevant], i => i.kind === 'method' && i.lifecycle === 'active' && !i.stale)) {
73
+ if (!itemsByNode.has(item.nodeId)) itemsByNode.set(item.nodeId, []);
74
+ itemsByNode.get(item.nodeId).push(item);
75
+ }
76
+ const hooks = [];
77
+ for (const node of nodes) for (const item of (itemsByNode.get(node.id) ?? []).sort((a, b) => a.id.localeCompare(b.id))) {
78
+ const bundle = this.repo.get('tree_methods', methodKey(item.id, item.activeVersion)), version = this.repo.get('tree_item_versions', itemKey(item.id, item.activeVersion));
79
+ if (!bundle || !version || bundle.pendingScriptRequests.length || version.sourceRefs.some(ref => !this.resolveSource(ref))) continue;
80
+ for (const ref of bundle.hooks) {
81
+ const hook = this.repo.get('tree_hooks', digest(['hook_version', ref.id, ref.version]));
82
+ if (!hook?.enabled || hook.moment !== moment) continue;
83
+ validateHook({ moment: hook.moment, conditions: hook.conditions, action: hook.action, message: hook.message });
84
+ hooks.push({ ...hook, itemRevision: item.revision, hookRevision: hook.revision, sourceRefs: version.sourceRefs });
85
+ }
86
+ }
87
+ if (hooks.length > 100) fail('HOOK_CAPACITY', 'This event cannot activate more than 100 hooks; no hooks were silently dropped');
88
+ const turn = this.repo.get('tree_turns', scope.turnId);
89
+ if (!turn || turn.assignmentRevision !== scope.assignmentRevision) fail('SCOPE_STALE', 'Assignment changed before hook admission');
90
+ const value = { sessionId, moment, scopeEpoch: scope.epoch, scopeToken: scope.token, logicalTurnId: turn.logicalTurnId, turnId: turn.id, nodeIds: ids, hooks };
91
+ return { ...value, key: digest(value) };
92
+ }
93
+ assertCurrent(snapshot) {
94
+ if (this.snapshot(snapshot.sessionId, snapshot.moment).key !== snapshot.key) fail('HOOK_STALE', 'Scope, method, hook or source changed');
95
+ }
96
+ evaluate({ sessionId, moment, eventKey, fields, origin = 'program', fileEffects = 'unknown', signal, nodeId = null }) {
97
+ signal?.throwIfAborted();
98
+ if (typeof eventKey !== 'string' || !eventKey || typeof fields !== 'object' || !fields || Array.isArray(fields)) fail('HOOK_EVENT', 'A stable native event key and host-projected fields are required');
99
+ if (Object.keys(fields).some(key => !HOOK_RULES[moment]?.fields.includes(key))) fail('HOOK_EVENT', 'Only the registered host projection is accepted');
100
+ if (moment === 'user_message' && origin !== 'user') return { ignored: 'program_not_user', runs: [], contexts: [], reminders: [], notes: [], decision: 'allow', continuation: null };
101
+ const snapshot = this.snapshot(sessionId, moment);
102
+ if (['node_leave', 'node_enter'].includes(moment)) {
103
+ nodeId ??= fields.nodeId ?? (snapshot.nodeIds.length === 1 ? snapshot.nodeIds[0] : null);
104
+ if (!snapshot.nodeIds.includes(nodeId)) fail('HOOK_EVENT', 'A node lifecycle event must identify one admitted node');
105
+ }
106
+ const input = { sessionId, moment, eventKey, fields: jsonClone(fields), origin, fileEffects, scopeKey: snapshot.key, nodeId };
107
+ const inputHash = digest(input), id = digest(['hook-event', sessionId, eventKey, moment]);
108
+ const receipt = this.repo.command('evaluateTreeHooks', { requestId: id, expected: {}, input }, actor, () => {
109
+ this.assertCurrent(snapshot); signal?.throwIfAborted();
110
+ const history = this.repo.list('tree_hook_runs', row => row.logicalTurnId === snapshot.logicalTurnId);
111
+ const cycle = Math.max(0, ...history.map(row => row.continuationCycle)), runs = [], contexts = [], reminders = [], notes = [], continuing = [];
112
+ let decision = 'allow';
113
+ for (const hook of snapshot.hooks) {
114
+ let result;
115
+ try {
116
+ const conditions = hook.conditions.map(c => matchCondition(c, fields, { workspace: this.workspace, fileEffects }));
117
+ const matched = conditions.some(c => c.matched === false) ? false : conditions.some(c => c.matched === null) ? null : true;
118
+ result = { status: matched === true ? 'matched' : matched === null ? 'unknown' : 'pass', diagnostics: conditions.map(c => c.diagnostic).filter(Boolean), action: null, message: hook.message };
119
+ if (matched === true) {
120
+ result.action = hook.action;
121
+ if (hook.action === 'block') {
122
+ if (moment === 'node_complete' && fields.completedBy === 'user') { result.action = 'remind_user'; reminders.push(hook.message); }
123
+ else decision = 'deny';
124
+ } else if (hook.action === 'ask_user') { if (decision !== 'deny') decision = 'ask'; }
125
+ else if (hook.action === 'add_context') contexts.push(hook.message);
126
+ else if (hook.action === 'remind_user') reminders.push(hook.message);
127
+ else if (hook.action === 'leave_note') notes.push({ hookId: hook.hookId, methodId: hook.methodId, version: hook.version, nodeId, message: hook.message });
128
+ else if (hook.action === 'continue') {
129
+ const earlier = history.filter(row => row.hookId === hook.hookId && row.version === hook.version).sort((a, b) => b.eventOrdinal - a.eventOrdinal);
130
+ const consecutive = (earlier[0]?.result.status === 'needs_action' ? earlier[0].result.consecutive ?? 1 : 0) + 1;
131
+ result = { ...result, status: 'needs_action', consecutive, exhausted: consecutive >= 2 || cycle >= 3 };
132
+ if (result.exhausted) reminders.push(hook.message); else continuing.push(hook);
133
+ }
134
+ }
135
+ } catch (error) {
136
+ // Only unexpected condition execution errors fail open. Scope,
137
+ // source, budget and process gates are outside this catch.
138
+ result = { status: 'error', action: null, message: hook.message, diagnostics: [{ code: error.code ?? 'HOOK_ERROR', message: error.message }], failOpen: true };
139
+ }
140
+ runs.push({ id: digest(['hook-run', sessionId, eventKey, moment, hook.hookId, hook.version]), logicalTurnId: snapshot.logicalTurnId,
141
+ eventKey: digest([sessionId, eventKey, moment]), hookId: hook.hookId, methodId: hook.methodId, version: hook.version, scopeEpoch: snapshot.scopeEpoch, inputHash,
142
+ result, continuationCycle: cycle + Number(continuing.length > 0), observedAt: this.repo.now(), eventOrdinal: this.repo.meta().lastEventOrdinal + 1 });
143
+ }
144
+ const nextCycle = cycle + Number(continuing.length > 0);
145
+ for (const run of runs) this.repo.save('tree_hook_runs', { ...run, continuationCycle: nextCycle });
146
+ if (notes.length || moment === 'node_enter') {
147
+ const meta = this.repo.meta(), saved = { ...(meta.hookNotes ?? {}) };
148
+ for (const note of notes) { const runId = runs.find(run => run.hookId === note.hookId).id, noteId = digest(['hook-note', runId, note.nodeId]); saved[noteId] = { ...note, id: noteId, runId, consumedBy: null }; }
149
+ if (moment === 'node_enter') for (const note of Object.values(saved)) {
150
+ const item = this.repo.get('tree_items', note.methodId), hook = this.repo.get('tree_hooks', digest(['hook_version', note.hookId, note.version]));
151
+ const version = item && this.repo.get('tree_item_versions', itemKey(item.id, item.activeVersion));
152
+ if (note.nodeId === nodeId && !note.consumedBy && item?.lifecycle === 'active' && !item.stale && item.activeVersion === note.version && hook?.enabled && version?.sourceRefs.every(ref => this.resolveSource(ref))) {
153
+ contexts.push(note.message); saved[note.id] = { ...note, consumedBy: id };
154
+ const run = { id: digest(['hook-note-entry', note.id, id]), logicalTurnId: snapshot.logicalTurnId, eventKey: digest([sessionId, eventKey, moment]), hookId: note.hookId, methodId: note.methodId, version: note.version, scopeEpoch: snapshot.scopeEpoch, inputHash,
155
+ result: { status: 'matched', action: 'add_context', message: note.message, priorRunId: note.runId }, continuationCycle: nextCycle, observedAt: this.repo.now(), eventOrdinal: this.repo.meta().lastEventOrdinal + 1 };
156
+ if (!runs.some(existing => existing.hookId === run.hookId && existing.version === run.version)) { runs.push(run); this.repo.save('tree_hook_runs', run); }
157
+ }
158
+ }
159
+ this.repo.save('tree_meta', { ...meta, hookNotes: saved }, meta.revision);
160
+ }
161
+ const continuation = continuing.length ? { source: { kind: 'arcfold/hook', hookRunIds: runs.filter(r => continuing.some(h => h.hookId === r.hookId)).map(r => r.id), versions: continuing.map(h => ({ hookId: h.hookId, version: h.version })), logicalTurnId: snapshot.logicalTurnId, scopeEpoch: snapshot.scopeEpoch }, text: [...new Set(continuing.map(h => h.message))].join('\n'), cycle: nextCycle } : null;
162
+ return { snapshot, inputHash, runs: runs.map(r => r.id), decision, contexts: [...new Set(contexts)], reminders: [...new Set(reminders)], notes, continuation,
163
+ signals: runs.filter(r => r.result.exhausted).map(r => ({ kind: 'hook_continue_exhausted', hookRunId: r.id, logicalTurnId: r.logicalTurnId })) };
164
+ });
165
+ return receipt.result;
166
+ }
167
+ /** Approval and native permission are supplied by separate trusted host paths. */
168
+ async beforeTool(event, { baseDecision, requestApproval, execute }) {
169
+ if (!['allow', 'ask', 'deny'].includes(baseDecision)) fail('HOST_PERMISSION_REQUIRED', 'A native permission decision is required');
170
+ const result = this.evaluate({ ...event, moment: 'before_tool' });
171
+ if (baseDecision !== 'allow') return { ...result, decision: baseDecision, executed: false };
172
+ if (result.decision === 'deny') return { ...result, executed: false };
173
+ if (result.decision === 'ask') {
174
+ if (typeof requestApproval !== 'function') return { ...result, decision: 'deny', denial: 'approval_missing', executed: false };
175
+ const approval = await requestApproval({ sessionId: event.sessionId, eventKey: event.eventKey, inputHash: result.inputHash, scopeEpoch: result.snapshot.scopeEpoch });
176
+ if (approval?.approved !== true || approval.sessionId !== event.sessionId || approval.eventKey !== event.eventKey || approval.inputHash !== result.inputHash || approval.scopeEpoch !== result.snapshot.scopeEpoch) return { ...result, decision: 'deny', denial: 'approval_missing', executed: false };
177
+ }
178
+ event.signal?.throwIfAborted(); this.assertCurrent(result.snapshot);
179
+ if (typeof execute !== 'function') return { ...result, decision: 'allow', executed: false };
180
+ const executionId = digest(['hook-tool-execution', event.sessionId, event.eventKey]);
181
+ const old = this.repo.meta().hookToolExecutions?.[executionId];
182
+ if (old) return { ...result, decision: 'deny', executed: false, denial: 'already_admitted_no_replay', previousState: old.state };
183
+ const record = (state, extra = {}) => this.repo.command('recordHookToolExecution', { requestId: digest([executionId, state]), expected: {}, input: { executionId, state, inputHash: result.inputHash } }, actor, () => {
184
+ const meta = this.repo.meta(), executions = meta.hookToolExecutions ?? {};
185
+ if (state === 'started' && executions[executionId]) fail('EXECUTION_ALREADY_ADMITTED', 'Tool execution already has an owner');
186
+ this.repo.save('tree_meta', { ...meta, hookToolExecutions: { ...executions, [executionId]: { state, inputHash: result.inputHash, scopeEpoch: result.snapshot.scopeEpoch, at: this.repo.now(), ...extra } } }, meta.revision);
187
+ return { executionId, state };
188
+ });
189
+ record('started');
190
+ try { event.signal?.throwIfAborted(); this.assertCurrent(result.snapshot); const value = await execute(); record('completed'); return { ...result, decision: 'allow', executed: true, value }; }
191
+ catch (error) { record('failed', { error: { code: error.code ?? 'TOOL_FAILED', message: error.message } }); throw error; }
192
+ }
193
+ assertContinuation(result, signal) {
194
+ signal?.throwIfAborted(); this.assertCurrent(result.snapshot); if (!result.continuation) fail('NO_CONTINUATION', 'No admitted hook continuation');
195
+ const ordinal = Math.max(...result.runs.map(id => this.repo.get('tree_hook_runs', id)?.eventOrdinal ?? -1));
196
+ if (this.repo.list('tree_hook_runs', row => row.logicalTurnId === result.snapshot.logicalTurnId && row.result.action === 'continue').some(row => row.eventOrdinal > ordinal)) fail('CONTINUATION_STALE', 'A later stop check superseded this pending continuation');
197
+ return result.continuation;
198
+ }
199
+ claimContinuation(result, nativeMessageId, signal) {
200
+ this.assertContinuation(result, signal);
201
+ if (typeof nativeMessageId !== 'string' || !nativeMessageId) fail('HOOK_EVENT', 'Persist the actual native steering message identity before admission');
202
+ const id = digest(['hook-steer', result.snapshot.logicalTurnId, result.continuation.cycle]);
203
+ if (this.repo.meta().hookContinuations?.[id]) return { claimed: false, state: this.repo.meta().hookContinuations[id] };
204
+ return this.repo.command('claimHookContinuation', { requestId: id, expected: {}, input: { nativeMessageId, runIds: result.runs, scopeEpoch: result.snapshot.scopeEpoch } }, actor, () => {
205
+ this.assertContinuation(result, signal); const meta = this.repo.meta();
206
+ if (meta.hookContinuations?.[id]) fail('CONTINUATION_ALREADY_CLAIMED', 'A persisted send intent already owns this cycle');
207
+ const state = { nativeMessageId, runIds: result.runs, scopeEpoch: result.snapshot.scopeEpoch, state: 'send_intent', at: this.repo.now() };
208
+ this.repo.save('tree_meta', { ...meta, hookContinuations: { ...meta.hookContinuations, [id]: state } }, meta.revision);
209
+ return { claimed: true, state };
210
+ }).result;
211
+ }
212
+ message(result, runIds = result.runs.filter(id => this.repo.get('tree_hook_runs', id)?.result.action === 'add_context' || result.continuation?.source.hookRunIds.includes(id))) {
213
+ const runs = runIds.map(id => this.repo.get('tree_hook_runs', id)).filter(run => run && ['add_context', 'continue'].includes(run.result.action));
214
+ if (!runs.length) return null;
215
+ return { source: { kind: 'arcfold/hook', hookRunIds: runs.map(r => r.id), scopeEpoch: result.snapshot.scopeEpoch, logicalTurnId: result.snapshot.logicalTurnId }, content: [{ type: 'text', text: [...new Set(runs.map(r => r.result.message))].join('\n') }] };
216
+ }
217
+ validMessage(sessionId, message) {
218
+ const source = message.source, scope = this.readScope(sessionId), turn = scope && this.repo.get('tree_turns', scope.turnId);
219
+ if (message.role !== 'user' || source?.kind !== 'arcfold/hook' || !Array.isArray(source.hookRunIds) || !source.hookRunIds.length || scope?.phase !== 'active' || scope.epoch !== source.scopeEpoch || turn?.assignmentRevision !== scope.assignmentRevision || turn?.logicalTurnId !== source.logicalTurnId) return false;
220
+ const runs = source.hookRunIds.map(id => this.repo.get('tree_hook_runs', id));
221
+ for (const run of runs) {
222
+ if (!run || run.scopeEpoch !== scope.epoch || run.logicalTurnId !== turn.logicalTurnId || !['add_context', 'continue'].includes(run.result.action)) return false;
223
+ const item = this.repo.get('tree_items', run.methodId), hook = this.repo.get('tree_hooks', digest(['hook_version', run.hookId, run.version]));
224
+ if (!item || item.lifecycle !== 'active' || item.stale || item.activeVersion !== run.version || !hook?.enabled || !scope.nodeIds.some(id => this.repo.ancestors(id).some(node => node.id === item.nodeId))) return false;
225
+ const version = this.repo.get('tree_item_versions', itemKey(item.id, item.activeVersion)); if (!version || version.sourceRefs.some(ref => !this.resolveSource(ref))) return false;
226
+ }
227
+ return canonicalJson(message.content) === canonicalJson([{ type: 'text', text: [...new Set(runs.map(r => r.result.message))].join('\n') }]);
228
+ }
229
+ }
230
+
231
+ /** The native loop still owns delivery. This adapter only retires stale owned text. */
232
+ export function mountHookContext(ctx, { service, sessionId, createUserMessage }) {
233
+ let session;
234
+ const offPrepare = ctx.on('agent/pre-step', async (payload, next) => {
235
+ const decision = await next(); if (decision.kind === 'reject') return decision; payload.signal.throwIfAborted();
236
+ session = payload.agent.session; if (session.id !== sessionId) fail('HOOK_SESSION', 'Hook context cannot mount another native session');
237
+ assertOwnedCompaction(session);
238
+ for (const seq of [...session.surface.nodes]) {
239
+ const event = session.eventAt(seq); if (event.type !== 'user/message' || event.data.source?.kind !== 'arcfold/hook' || service.validMessage(sessionId, event.data)) continue;
240
+ session.append('user/message', createUserMessage({ source: { kind: 'arcfold/tree-context-retired' }, content: [{ type: 'text', text: '[previous Arcfold hook retired]' }] }), { surfaceOp: { op: 'replace', startSeq: seq, endSeq: seq }, sourceEventSeqs: [seq] });
241
+ }
242
+ for (const message of decision.messages) if (message.source?.kind === 'arcfold/hook' && !service.validMessage(sessionId, message)) fail('HOOK_STALE', 'Pending hook text no longer belongs to this scope');
243
+ return decision;
244
+ });
245
+ const offStream = ctx.on('llm/stream', (options, next) => {
246
+ if (!session || options.sessionId !== sessionId) return next();
247
+ return (async function* () {
248
+ options.signal?.throwIfAborted(); assertOwnedCompaction(session);
249
+ if (options.messages.some(m => m.source?.kind === 'arcfold/hook' && !service.validMessage(sessionId, m))) fail('HOOK_STALE', 'Hook version or scope changed before dispatch');
250
+ yield* next();
251
+ })();
252
+ });
253
+ return async () => { await offStream(); await offPrepare(); };
254
+ }
@@ -0,0 +1,107 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ export const TREE_CONTEXT_SOURCE = 'arcfold/tree-context';
4
+ export const TREE_COMPACTION_RECORD = 'plugin:arcfold/tree-compaction';
5
+ const retired = 'arcfold/tree-context-retired';
6
+ const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
7
+ export const TREE_OWNED_KINDS = Object.freeze([TREE_CONTEXT_SOURCE, retired, 'arcfold/hook']);
8
+ const owned = message => message.role === 'user' && TREE_OWNED_KINDS.includes(message.source?.kind);
9
+ const fail = message => { throw Object.assign(new Error(message), { code: 'TREE_CONTEXT_UNSAFE' }); };
10
+
11
+ function currentSnapshot(read) {
12
+ const value = structuredClone(read());
13
+ if (!value || !Number.isSafeInteger(value.scopeEpoch) || value.scopeEpoch < 1 ||
14
+ !Array.isArray(value.nodeIds) || !value.nodeIds.length ||
15
+ value.nodeIds.some(id => typeof id !== 'string' || !id) ||
16
+ !Number.isSafeInteger(value.version) || value.version < 1 || typeof value.text !== 'string')
17
+ fail('Current context must have a durable scope epoch, nodes and content version');
18
+ return { value, key: digest(value) };
19
+ }
20
+
21
+ /** Unknown/legacy mixed summaries are refused, never guessed apart or deleted. */
22
+ export function assertOwnedCompaction(session) {
23
+ const events = session.snapshotEvents();
24
+ for (const seq of session.surface.nodes) {
25
+ const event = session.eventAt(seq);
26
+ if (event.type !== 'user/message' || event.data.source?.kind !== 'compact-checkpoint') continue;
27
+ const refs = event.sourceEventSeqs ?? [];
28
+ const summary = refs.map(n => session.eventAt(n)).find(e => e.type === 'compaction/summary');
29
+ const receipt = summary && events.find(e => e.type === TREE_COMPACTION_RECORD &&
30
+ e.data.compactionId === summary.data.compactionId && e.seq < summary.seq &&
31
+ e.data.summaryHash === digest(summary.data.summary) && (
32
+ e.data.contract === 'owned-excluded-v2' && Array.isArray(e.data.ownedKinds) && digest(e.data.ownedKinds) === digest(TREE_OWNED_KINDS) ||
33
+ e.data.contract === 'owned-excluded-v1' && !events.some(old => old.seq < summary.seq && old.type === 'user/message' && old.data.source?.kind === 'arcfold/hook')));
34
+ if (!receipt) fail('Checkpoint lacks durable evidence that owned context was excluded before summarization');
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Subclass the actual host transaction; only its documented summarizer seam is
40
+ * replaced. summarize must use B2's owned, metered job path, never Base.summarize
41
+ * (which makes a purpose=compaction call outside that ownership contract).
42
+ */
43
+ export function ownedCompactionClass(Base, { summarize, appendPluginRecord }) {
44
+ if (typeof summarize !== 'function' || typeof appendPluginRecord !== 'function') fail('Owned summarizer and durable recorder required');
45
+ return class OwnedCompaction extends Base {
46
+ async summarize(input, agent, signal) {
47
+ signal?.throwIfAborted();
48
+ assertOwnedCompaction(agent.session);
49
+ const messages = input.messages.filter(message => !owned(message));
50
+ const clean = { ...input, messages };
51
+ const start = agent.session.snapshotEvents().findLast(e => e.type === 'compaction/start');
52
+ if (!start) fail('No native compaction transaction');
53
+ const result = await summarize(clean, agent, signal);
54
+ signal?.throwIfAborted();
55
+ appendPluginRecord(agent.session, TREE_COMPACTION_RECORD, {
56
+ contract: 'owned-excluded-v2', ownedKinds: TREE_OWNED_KINDS, compactionId: start.data.compactionId,
57
+ inputHash: digest(clean), summaryHash: digest(result.summary),
58
+ excluded: input.messages.filter(owned).map(message => ({ id: message.id, source: message.source })),
59
+ });
60
+ return result;
61
+ }
62
+ };
63
+ }
64
+
65
+ /** Scope-owned mounting; does not replace the host loop or mutate human text. */
66
+ export function mountOwnedContext(ctx, { readCurrent, createUserMessage }) {
67
+ let session;
68
+ const disposers = [];
69
+ disposers.push(ctx.on('agent/pre-step', async (payload, next) => {
70
+ const decision = await next();
71
+ if (decision.kind === 'reject') return decision;
72
+ payload.signal.throwIfAborted();
73
+ session = payload.agent.session;
74
+ assertOwnedCompaction(session);
75
+ const snapshot = currentSnapshot(readCurrent);
76
+ let present = false;
77
+ for (const seq of [...session.surface.nodes]) {
78
+ const event = session.eventAt(seq);
79
+ if (event.type !== 'user/message' || event.data.source?.kind !== TREE_CONTEXT_SOURCE) continue;
80
+ if (event.data.source.snapshotKey === snapshot.key && !present) { present = true; continue; }
81
+ session.append('user/message', createUserMessage({ source: { kind: retired },
82
+ content: [{ type: 'text', text: '[previous Arcfold context retired]' }] }),
83
+ { surfaceOp: { op: 'replace', startSeq: seq, endSeq: seq }, sourceEventSeqs: [seq] });
84
+ }
85
+ if (decision.messages.some(owned)) fail('Only the context owner may inject scoped context');
86
+ if (present) return decision;
87
+ const { scopeEpoch, nodeIds, version, text } = snapshot.value;
88
+ return { ...decision, messages: [...decision.messages, createUserMessage({
89
+ source: { kind: TREE_CONTEXT_SOURCE, scopeEpoch, nodeIds, version, snapshotKey: snapshot.key },
90
+ content: [{ type: 'text', text }],
91
+ })] };
92
+ }));
93
+ disposers.push(ctx.on('llm/stream', (options, next) => {
94
+ if (!session || options.sessionId !== session.id) return next();
95
+ return (async function* () {
96
+ options.signal?.throwIfAborted();
97
+ assertOwnedCompaction(session);
98
+ const snapshot = currentSnapshot(readCurrent);
99
+ const active = options.messages.filter(m => m.source?.kind === TREE_CONTEXT_SOURCE);
100
+ if (active.length !== 1 || active[0].source.snapshotKey !== snapshot.key ||
101
+ digest(active[0].content) !== digest([{ type: 'text', text: snapshot.value.text }]))
102
+ fail('Scoped context changed before model dispatch');
103
+ yield* next();
104
+ })();
105
+ }));
106
+ return async () => { for (const dispose of disposers.reverse()) await dispose(); };
107
+ }