@arcfold/dsh 0.0.0-stage → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +8 -2
- package/THIRD_PARTY_NOTICES.md +11 -0
- package/cordis.patch.yml +138 -0
- package/package.json +70 -4
- package/patches/deepseek-0.2.2-rc.1-transport.json +65 -0
- package/patches/deepseek-0.2.2-rc.1-transport.patch +61 -0
- package/presets/arcfold.patch.yml +110 -0
- package/runtime/README.md +19 -0
- package/runtime/bootstrap.json +15 -0
- package/runtime/desktop-python.tar.gz +0 -0
- package/runtime/host/package.json +11 -0
- package/runtime/host/pnpm-workspace.yaml +9 -0
- package/runtime/initial-policy.json +94 -0
- package/runtime/profile/pnpm-workspace.yaml +7 -0
- package/scripts/exact-package-patch.mjs +106 -0
- package/scripts/patch-deepseek-transport.mjs +14 -0
- package/src/agent.mjs +56 -0
- package/src/browse-scope.mjs +14 -0
- package/src/budget.mjs +369 -0
- package/src/business.mjs +334 -0
- package/src/context-material.mjs +119 -0
- package/src/deepseek-budget-plugin.mjs +58 -0
- package/src/deepseek-provider.mjs +57 -0
- package/src/deepseek-receipt.mjs +97 -0
- package/src/deepseek-transport.mjs +97 -0
- package/src/desktop/adapter.mjs +16 -0
- package/src/desktop/bootstrap.mjs +84 -0
- package/src/desktop/configuration.mjs +117 -0
- package/src/desktop/provider.mjs +46 -0
- package/src/desktop/python-runtime.mjs +105 -0
- package/src/desktop/setup-service.mjs +81 -0
- package/src/desktop/vendor/LICENSE.deepseek +21 -0
- package/src/desktop/vendor/NOTICE.md +5 -0
- package/src/desktop/vendor/deepseek-0.2.2-rc.1.mjs +2919 -0
- package/src/evidence-ownership.mjs +51 -0
- package/src/evidence.mjs +35 -0
- package/src/goal-context.mjs +52 -0
- package/src/goal-execution-tool.mjs +74 -0
- package/src/goal-execution.mjs +333 -0
- package/src/goal-tool.mjs +56 -0
- package/src/goal-work-control.mjs +63 -0
- package/src/goal-work.mjs +257 -0
- package/src/goals.mjs +578 -0
- package/src/importer.mjs +298 -0
- package/src/index.mjs +53 -0
- package/src/learning-contracts.mjs +220 -0
- package/src/learning-defaults.mjs +98 -0
- package/src/learning-goals.mjs +165 -0
- package/src/learning-plugin.mjs +28 -0
- package/src/learning-runner.mjs +275 -0
- package/src/learning.mjs +534 -0
- package/src/legacy-export.mjs +332 -0
- package/src/legacy-import-source.mjs +356 -0
- package/src/metered-provider-host.mjs +101 -0
- package/src/metered-transport.mjs +435 -0
- package/src/method-context.mjs +57 -0
- package/src/method-lifecycle.mjs +34 -0
- package/src/method-tool.mjs +61 -0
- package/src/methods.mjs +425 -0
- package/src/model-selection.mjs +41 -0
- package/src/native-context-call.mjs +88 -0
- package/src/native-continuation.mjs +217 -0
- package/src/native-request-authority.mjs +90 -0
- package/src/openai-models.mjs +4 -0
- package/src/openai-receipt.mjs +98 -0
- package/src/openai-transport.mjs +167 -0
- package/src/openai-usage.mjs +194 -0
- package/src/opportunities.mjs +295 -0
- package/src/preparation-artifacts.mjs +213 -0
- package/src/preparation-plugin.mjs +290 -0
- package/src/runtime-command.mjs +264 -0
- package/src/runtime-config.mjs +417 -0
- package/src/runtime-guard.mjs +284 -0
- package/src/runtime-host.mjs +212 -0
- package/src/runtime-init.mjs +278 -0
- package/src/runtime-installation.mjs +518 -0
- package/src/runtime-lifecycle.mjs +227 -0
- package/src/runtime-model-settings.mjs +111 -0
- package/src/runtime-readiness.mjs +285 -0
- package/src/runtime-workspace.mjs +18 -0
- package/src/session-bridge.mjs +365 -0
- package/src/store.mjs +394 -0
- package/src/tree/LICENSE +21 -0
- package/src/tree/attribution.mjs +120 -0
- package/src/tree/clusters.mjs +61 -0
- package/src/tree/consolidation.mjs +103 -0
- package/src/tree/context.mjs +211 -0
- package/src/tree/contracts.mjs +142 -0
- package/src/tree/experience.mjs +140 -0
- package/src/tree/experiment-budget.mjs +84 -0
- package/src/tree/hooks.mjs +254 -0
- package/src/tree/host-context.mjs +107 -0
- package/src/tree/host-process.mjs +288 -0
- package/src/tree/host.mjs +37 -0
- package/src/tree/improvement.mjs +106 -0
- package/src/tree/items.mjs +294 -0
- package/src/tree/jobs.mjs +184 -0
- package/src/tree/learning.mjs +202 -0
- package/src/tree/metrics.mjs +203 -0
- package/src/tree/model-owner.mjs +254 -0
- package/src/tree/observations.mjs +73 -0
- package/src/tree/prompts.mjs +500 -0
- package/src/tree/reports.mjs +67 -0
- package/src/tree/repository.mjs +236 -0
- package/src/tree/runtime.mjs +745 -0
- package/src/tree/script-writer.mjs +149 -0
- package/src/tree/scripts.mjs +208 -0
- package/src/tree/signals.mjs +118 -0
- package/src/tree/snapshots.mjs +379 -0
- package/src/tree/token-counter.mjs +75 -0
- package/src/tree/token-counter.py +37 -0
- package/src/tree/tokenizer-resources/LICENSE +21 -0
- package/src/tree/tokenizer-resources/README.md +31 -0
- package/src/tree/tokenizer-resources/deepseek_recipe-0.2.0-cp310-abi3-macosx_11_0_arm64.whl +0 -0
- package/src/tree/tokenizer-resources/manifest.json +61 -0
- package/src/tree/tokenizer-resources/native.pyi +1042 -0
- package/src/tree/tokenizer-resources/tokenizer-NOTICE.md +26 -0
- package/src/tree/tokenizer-resources/tokenizer-v4-NOTICE.md +27 -0
- package/src/tree/tokenizer-resources/tokenizer-v4.json +267359 -0
- package/src/tree/tokenizer-resources/tokenizer.json +267359 -0
- package/src/tree/tools.mjs +99 -0
- package/src/tree/tree.mjs +202 -0
- package/src/tree/ui.mjs +285 -0
- package/src/tree/verification.mjs +143 -0
- package/src/understandings.mjs +309 -0
- package/src/web-budget-recovery.mjs +181 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { canonicalJson, digest, fail, shape, text, integer, array, oneOf } from './contracts.mjs';
|
|
2
|
+
import { TreeService } from './tree.mjs';
|
|
3
|
+
import { ExperienceService } from './experience.mjs';
|
|
4
|
+
import { bindTurnSnapshot } from './ui.mjs';
|
|
5
|
+
import { HarnessError } from '@deepseek-ai/dsh-llm';
|
|
6
|
+
|
|
7
|
+
const requiredString = { type: 'string', required: true };
|
|
8
|
+
const requiredVersion = { type: 'integer', required: true };
|
|
9
|
+
const strings = { type: 'array', required: true, items: { type: 'string' } };
|
|
10
|
+
export const TREE_TOOLS = Object.freeze({
|
|
11
|
+
arcfold_mark_complete: { nodeId: requiredString, expectedRevision: requiredVersion, reason: requiredString },
|
|
12
|
+
arcfold_read_history: { nodeId: requiredString, kind: { type: 'string', enum: ['summary', 'turns'], required: true }, afterTurnId: { type: 'string' }, limit: { type: 'integer' } },
|
|
13
|
+
arcfold_read_method: { methodId: requiredString, expectedVersion: { type: 'integer' } },
|
|
14
|
+
arcfold_run_method_script: { methodId: requiredString, expectedVersion: requiredVersion, scriptId: requiredString, args: { type: 'object', required: true, additionalProperties: true } },
|
|
15
|
+
arcfold_request_script: { methodId: { type: 'string' }, expectedVersion: { type: 'integer' }, purpose: requiredString, evidenceIds: strings, fileRefs: strings },
|
|
16
|
+
});
|
|
17
|
+
const descriptions = {
|
|
18
|
+
arcfold_mark_complete: '把本轮目标标为助手判断的完成,保留理由和来源;先检查对应钩子,用户仍可重新打开。',
|
|
19
|
+
arcfold_read_history: '在当前节点的上级链或直接子节点分页读取原始经历或摘要。来源已撤回时不返回原文。',
|
|
20
|
+
arcfold_read_method: '读取当前范围内一个有效方法的完整说明、准确版本和脚本逻辑编号。读取不扩大权限。',
|
|
21
|
+
arcfold_run_method_script: '通过受限执行入口运行当前有效方法脚本。自动检查版本、权限和快照;参数不接受路径、命令或权限覆盖。',
|
|
22
|
+
arcfold_request_script: '根据本轮真实证据排入专用脚本作者。新方法省略methodId/expectedVersion,不要求先有方法。evidenceIds只取本轮授权背景evidence中的id。fileRefs只取背景fileRefs中已登记的逻辑编号,不是文件名或路径;背景fileRefs为空时必须传[]。工作区文件名及读写目标写在purpose中,由作者只读工具核实;无需让用户另外上传工作区文件。purpose只描述业务输入、输出与约束,不另设计命令行开关或要求将作者私有脚本写到工作区。宿主固定以--input临时JSON参数文件调用,试跑加--dry-run;正式执行由arcfold_run_method_script的可信权限控制,不需要额外apply/write开关。返回任务状态,不自动执行;不要无限轮询。',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export async function nativeTreeCall(ctx, runtime, exec) {
|
|
26
|
+
const agent = exec.agent; exec.signal?.throwIfAborted();
|
|
27
|
+
if (!exec.signal || !agent || ctx.agents.get(agent.id) !== agent || ctx.agents.currentInitiator() !== agent || agent.status !== 'running' || runtime.mainAgents.get(agent.session.id) !== agent || exec.parent !== undefined || exec.rootCallId !== exec.callId || !exec.callId) fail('MAIN_REQUIRED', 'Only the exact direct native main call may use tree tools');
|
|
28
|
+
const events = await runtime.freezeSession(agent.session); exec.signal.throwIfAborted();
|
|
29
|
+
const start = events.findLast(e => e.type === 'turn/start'), step = events.findLast(e => e.type === 'step/start');
|
|
30
|
+
const call = events.find(e => e.type === 'tool/call' && e.data.callId === exec.callId);
|
|
31
|
+
const message = call && events.findLast(e => e.seq < call.seq && e.type === 'assistant/message');
|
|
32
|
+
const block = message?.data.message?.content?.find(b => b.type === 'tool-call' && b.id === exec.callId);
|
|
33
|
+
if (!start || !step || step.seq < start.seq || !call || call.seq < step.seq || call.data.name !== exec.name || block?.name !== exec.name || block.arguments !== call.data.arguments || message.data.message.source?.kind !== 'model' || canonicalJson(JSON.parse(call.data.arguments)) !== canonicalJson(exec.arguments) || events.some(e => e.seq > step.seq && ['step/end', 'turn/end'].includes(e.type)) || events.some(e => e.seq > call.seq && e.type === 'tool/result' && e.data.message.toolCallId === exec.callId)) fail('NATIVE_CALL_REQUIRED', 'Call must match its complete durable assistant and live step');
|
|
34
|
+
const job = runtime.mainJob(agent.session.id, start.data.turn), scope = runtime.builder.capture(agent.session.id);
|
|
35
|
+
if (!job || job.status !== 'submitted' || job.trigger.turnId !== scope.turnId || job.input.assignmentRevision !== scope.assignmentRevision) fail('MAIN_REQUIRED', 'Main call has no current owned logical turn');
|
|
36
|
+
runtime.jobs.assertCurrent(job);
|
|
37
|
+
const invocation = runtime.repo.list('tree_invocations', i => i.jobId === job.id && i.stepStartSeq === step.seq)[0];
|
|
38
|
+
const exposure = invocation && runtime.repo.list('tree_exposures', e => e.invocationId === invocation.id && !e.revoked)[0];
|
|
39
|
+
if (!exposure) fail('EXPOSURE_REQUIRED', 'The native request must have a recorded current exposure');
|
|
40
|
+
return { agent, job, scope, exposure, events, call, actor: { kind: 'model', role: 'main', id: job.id }, requestId: digest(['tree-tool', agent.session.id, exec.callId]) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function executeTreeTool(ctx, runtime, name, args, exec) {
|
|
44
|
+
const current = await nativeTreeCall(ctx, runtime, exec), { agent, job, scope, exposure, requestId, actor } = current;
|
|
45
|
+
const repo = runtime.repo, context = () => ({ actor, nodeIds: scope.nodeIds }), options = { repository: repo, resolveSource: runtime.resolveSource, context };
|
|
46
|
+
if (name === 'arcfold_mark_complete') {
|
|
47
|
+
shape(args, ['nodeId', 'expectedRevision', 'reason']); integer(args.expectedRevision, 'expectedRevision', 1); text(args.reason, 'reason', 2000);
|
|
48
|
+
if (!scope.nodeIds.includes(args.nodeId)) fail('OUT_OF_SCOPE', 'Only a directly assigned node may be marked complete');
|
|
49
|
+
const result = runtime.hooks(agent.session.id).evaluate({ sessionId: agent.session.id, moment: 'node_complete', eventKey: exec.callId, fields: { completedBy: 'model' }, signal: exec.signal });
|
|
50
|
+
if (result.decision !== 'allow') fail('HOOK_DENIED', 'Completion is blocked by a current method check');
|
|
51
|
+
const before = repo.list('tree_nodes'), receipt = new TreeService(options).setStatus({ requestId, expected: { ['tree_nodes:' + args.nodeId]: args.expectedRevision }, input: { nodeId: args.nodeId, status: 'completed', sourceRefs: job.input.sources.map(s => s.ref) } });
|
|
52
|
+
runtime.metrics.observeTree(receipt, before, { logicalTurnId: scope.turnId });
|
|
53
|
+
repo.command('recordCompletionReason', { requestId: digest([requestId, 'reason']), expected: {}, input: { nodeId: args.nodeId, reason: args.reason, callId: exec.callId, receiptId: receipt.id } }, actor, () => ({ receiptId: receipt.id }));
|
|
54
|
+
// Final consolidation is queued after the current turn has settled.
|
|
55
|
+
return { ...receipt.result, revision: repo.get('tree_nodes', args.nodeId).revision, receiptId: receipt.id };
|
|
56
|
+
}
|
|
57
|
+
if (name === 'arcfold_read_history') {
|
|
58
|
+
shape(args, ['nodeId', 'kind'], ['afterTurnId', 'limit']); oneOf(args.kind, ['summary', 'turns'], 'kind');
|
|
59
|
+
const history = new ExperienceService({ ...options, sources: runtime.sources }); history.assertReadScope(args.nodeId);
|
|
60
|
+
if (args.kind === 'turns') return history.history(args.nodeId, { afterTurnId: args.afterTurnId ?? null, limit: args.limit ?? 10 });
|
|
61
|
+
const summary = runtime.builder.summary(repo.get('tree_nodes', args.nodeId)); return { items: summary ? [summary] : [], revokedRefs: [], nextCursor: null };
|
|
62
|
+
}
|
|
63
|
+
if (name === 'arcfold_read_method') {
|
|
64
|
+
shape(args, ['methodId'], ['expectedVersion']);
|
|
65
|
+
const row = runtime.builder.activeItems(scope.ancestorPaths.flat()).find(r => r.item.id === args.methodId && r.method);
|
|
66
|
+
if (!row || args.expectedVersion !== undefined && args.expectedVersion !== row.item.activeVersion) fail('METHOD_STALE', 'Method is unavailable in the current scope/version');
|
|
67
|
+
repo.command('recordFullMethodRead', { requestId, expected: { ['tree_items:' + row.item.id]: row.item.revision }, input: { itemId: row.item.id, version: row.item.activeVersion, exposureId: exposure.id } }, actor, () => {
|
|
68
|
+
const old = repo.get('tree_exposures', exposure.id), values = old.methodReadVersions ?? [], value = { itemId: row.item.id, version: row.item.activeVersion };
|
|
69
|
+
repo.save('tree_exposures', { ...old, methodReadVersions: [...new Map([...values, value].map(v => [digest(v), v])).values()] }, old.revision); return value;
|
|
70
|
+
});
|
|
71
|
+
return { methodId: row.item.id, version: row.item.activeVersion, long: row.method.long, scopeNodeId: row.item.nodeId,
|
|
72
|
+
scripts: row.method.scripts.map(s => { const artifact = repo.list('tree_script_artifacts', a => a.scriptId === s.id && a.version === s.version && a.methodId === row.item.id)[0]; return { scriptId: s.id, usage: artifact?.usage ?? '' }; }) };
|
|
73
|
+
}
|
|
74
|
+
if (name === 'arcfold_request_script') {
|
|
75
|
+
shape(args, ['purpose', 'evidenceIds', 'fileRefs'], ['methodId', 'expectedVersion']); text(args.purpose, 'purpose', 2000); array(args.evidenceIds, 'evidenceIds', 20); array(args.fileRefs, 'fileRefs', 20);
|
|
76
|
+
if (scope.nodeIds.length !== 1 && !args.methodId) fail('SCRIPT_SCOPE_AMBIGUOUS', 'A multi-goal script request must identify its existing method');
|
|
77
|
+
const nodeId = args.methodId ? repo.get('tree_items', args.methodId)?.nodeId : scope.nodeIds[0];
|
|
78
|
+
const result = runtime.writers.request(args, { job, callId: exec.callId, nodeId, exposureId: exposure.id }); runtime.kick(); return result;
|
|
79
|
+
}
|
|
80
|
+
if (name === 'arcfold_run_method_script') {
|
|
81
|
+
shape(args, ['methodId', 'expectedVersion', 'scriptId', 'args']); integer(args.expectedVersion, 'expectedVersion', 1);
|
|
82
|
+
const resources = runtime.resourcesFor(runtime.host.workingDirectory.get(agent.session));
|
|
83
|
+
const broker = resources.brokerFor({ ...current, exec }); const result = await broker.run(args, { signal: exec.signal });
|
|
84
|
+
if (result.snapshotId) bindTurnSnapshot(repo, result.snapshotId, scope.turnId);
|
|
85
|
+
return { runId: result.id, exitCode: result.result?.code ?? null, stdout: result.result?.stdout ?? '', stderr: result.result?.stderr ?? '', truncated: result.result?.truncated ?? false, snapshotId: result.snapshotId ?? null, ...(result.error ? { denial: result.error.code } : {}) };
|
|
86
|
+
}
|
|
87
|
+
fail('TOOL_NOT_FOUND', 'Unknown tree tool');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function mountTreeTools(ctx, runtime) {
|
|
91
|
+
for (const [name, parameters] of Object.entries(TREE_TOOLS)) ctx.tools.register(runtime.defineTool({ name, description: descriptions[name], parameters,
|
|
92
|
+
output: { schema: { type: 'json' }, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }] },
|
|
93
|
+
execute: async (args, exec) => {
|
|
94
|
+
try { return { ok: true, value: await executeTreeTool(ctx, runtime, name, args, exec) }; }
|
|
95
|
+
catch (error) { if (error instanceof HarnessError) throw error; throw new HarnessError(error.message, error.code ?? 'TREE_TOOL_FAILED', { cause: error }); }
|
|
96
|
+
},
|
|
97
|
+
presentCall: () => ({ card: 'generic', title: descriptions[name], kind: name === 'arcfold_run_method_script' ? 'write' : 'read' }),
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { array, canonicalJson, digest, fail, integer, oneOf, shape, sourceRefs, text, CATEGORIES, STATUSES } from './contracts.mjs';
|
|
3
|
+
import { recordKey } from './repository.mjs';
|
|
4
|
+
|
|
5
|
+
// The adapter supplies context and source resolution. Nothing in command.input
|
|
6
|
+
// can impersonate a user, expand the current turn, or create source evidence.
|
|
7
|
+
export class DomainService {
|
|
8
|
+
constructor({ repository, context, resolveSource }) { this.repo = repository; this.context = context; this.resolveSource = resolveSource; }
|
|
9
|
+
actor(roles = [], userOnly = false) {
|
|
10
|
+
const ctx = this.context();
|
|
11
|
+
if (!ctx?.actor || userOnly && ctx.actor.kind !== 'user' || ctx.actor.kind === 'model' && !roles.includes(ctx.actor.role)) fail('FORBIDDEN', 'Operation not permitted for this trusted actor', 403);
|
|
12
|
+
return ctx.actor;
|
|
13
|
+
}
|
|
14
|
+
refs(refs, options) { return sourceRefs(refs, this.resolveSource, options); }
|
|
15
|
+
node(id) { const n = this.repo.get('tree_nodes', id); if (!n || n.deleted) fail('NODE_NOT_FOUND', 'Node not found'); return n; }
|
|
16
|
+
scoped(id) { if (this.context().actor.kind === 'model' && !(this.context().nodeIds ?? []).includes(id)) fail('OUT_OF_SCOPE', 'Node is outside this invocation', 403); }
|
|
17
|
+
candidate(id) { if (this.context().actor.kind === 'model' && !(this.context().candidateNodeIds ?? []).includes(id)) fail('OUT_OF_SCOPE', 'Node was not in the frozen attribution candidates', 403); }
|
|
18
|
+
replace(kind, row, updates) { return this.repo.save(kind, { ...row, ...updates }, row.revision); }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class TreeService extends DomainService {
|
|
22
|
+
#touchStructure() { const meta = this.repo.meta(); this.replace('tree_meta', meta, { treeRevision: meta.treeRevision + 1 }); }
|
|
23
|
+
#reactivate(id) {
|
|
24
|
+
for (const n of this.repo.ancestors(id)) if (n.status !== 'active') this.replace('tree_nodes', n, { status: 'active', statusOrigin: this.context().actor.kind === 'user' ? 'user' : 'model', cascadeFromId: null });
|
|
25
|
+
}
|
|
26
|
+
#status(id, status, refs) {
|
|
27
|
+
const node = this.node(id); oneOf(status, STATUSES, 'status');
|
|
28
|
+
if (id === this.repo.rootId() && status !== 'active') fail('ROOT_INVARIANT', 'The root stays active');
|
|
29
|
+
if (status === 'active') { this.#reactivate(id); return; }
|
|
30
|
+
this.replace('tree_nodes', node, { status, statusOrigin: this.context().actor.kind === 'user' ? 'user' : 'model', cascadeFromId: null, statusSourceRefs: refs, statusChangedAt: this.repo.now() });
|
|
31
|
+
// A terminal descendant is a boundary: its separately completed/abandoned
|
|
32
|
+
// subtree is not relabelled as a consequence of its parent's status.
|
|
33
|
+
const visit = parent => { for (const child of this.repo.list('tree_nodes', n => !n.deleted && n.parentId === parent)) {
|
|
34
|
+
if (['completed', 'abandoned'].includes(child.status)) continue;
|
|
35
|
+
this.replace('tree_nodes', child, { status, statusOrigin: 'cascade', cascadeFromId: id, statusSourceRefs: refs, statusChangedAt: this.repo.now() }); visit(child.id);
|
|
36
|
+
} }; visit(id);
|
|
37
|
+
}
|
|
38
|
+
#invalidateScope(ids, reason) {
|
|
39
|
+
for (const e of this.repo.list('tree_exposures', e => !e.revoked && e.nodeIds.some(id => ids.includes(id)))) this.replace('tree_exposures', e, { revoked: true, invalidationReason: reason });
|
|
40
|
+
for (const j of this.repo.list('tree_jobs', j => !['committed', 'failed', 'cancelled', 'unknown', 'invalid_output', 'stale'].includes(j.status) && (j.nodeIds ?? [j.scopeKey]).some(id => ids.includes(id)))) this.replace('tree_jobs', j, { status: j.status === 'submitted' ? 'submitted' : 'stale', invalidated: true, error: reason });
|
|
41
|
+
}
|
|
42
|
+
applyAttribution(command) {
|
|
43
|
+
const actor = this.actor(['attribution']);
|
|
44
|
+
return this.repo.command('applyAttribution', command, actor, () => {
|
|
45
|
+
const input = command.input;
|
|
46
|
+
shape(input, ['sessionId', 'nativeTurn', 'userSourceRefs', 'sourceRefs', 'actions', 'statusChanges'], ['failure', 'attributionRunId']);
|
|
47
|
+
text(input.sessionId, 'sessionId', 512); integer(input.nativeTurn, 'nativeTurn', 0);
|
|
48
|
+
const userRefs = this.refs(input.userSourceRefs, { user: true }), refs = this.refs(input.sourceRefs);
|
|
49
|
+
if (userRefs.some(r => !refs.some(s => canonicalJson(r) === canonicalJson(s)))) fail('INVALID_SOURCE', 'User references must be included in the turn');
|
|
50
|
+
const logicalTurnId = digest(['turn', input.sessionId, input.nativeTurn]), id = logicalTurnId;
|
|
51
|
+
if (this.repo.get('tree_turns', id)) fail('TURN_EXISTS', 'This native turn has already been attributed');
|
|
52
|
+
let nodeIds, createdNodeIds = [], fallback = null;
|
|
53
|
+
try {
|
|
54
|
+
nodeIds = this.repo.transaction(() => {
|
|
55
|
+
if (input.failure) fail('ATTRIBUTION_FAILED', text(input.failure, 'failure', 2000));
|
|
56
|
+
array(input.actions, 'actions', 8); if (!input.actions.length) fail('INVALID_FIELD', 'An attribution action is required', 400);
|
|
57
|
+
const selected = [];
|
|
58
|
+
for (const action of input.actions) {
|
|
59
|
+
oneOf(action.action, ['continue', 'switch', 'create', 'create_child'], 'action');
|
|
60
|
+
let target;
|
|
61
|
+
if (['continue', 'switch'].includes(action.action)) { shape(action, ['action', 'nodeId']); this.candidate(action.nodeId); target = this.node(action.nodeId).id; }
|
|
62
|
+
else {
|
|
63
|
+
shape(action, action.action === 'create' ? ['action', 'name'] : ['action', 'parentId', 'name']); text(action.name, 'name', 80);
|
|
64
|
+
const parentId = action.action === 'create' ? this.repo.rootId() : this.node(action.parentId).id;
|
|
65
|
+
this.candidate(parentId);
|
|
66
|
+
target = randomUUID(); this.repo.save('tree_nodes', { id: target, parentId, name: action.name, status: 'active', statusOrigin: actor.kind === 'user' ? 'user' : 'model', sourceRefs: userRefs, lastOwnTurnAt: null, lastSummaryWatermark: null }); createdNodeIds.push(target);
|
|
67
|
+
}
|
|
68
|
+
this.#reactivate(target); selected.push(target);
|
|
69
|
+
}
|
|
70
|
+
for (const change of array(input.statusChanges, 'statusChanges', 8)) {
|
|
71
|
+
shape(change, ['nodeId', 'status', 'sourceRefs']); oneOf(change.status, ['paused', 'completed', 'abandoned'], 'status'); this.refs(change.sourceRefs, { user: true });
|
|
72
|
+
this.candidate(change.nodeId);
|
|
73
|
+
this.#status(change.nodeId, change.status, change.sourceRefs);
|
|
74
|
+
}
|
|
75
|
+
return [...new Set(selected)];
|
|
76
|
+
});
|
|
77
|
+
} catch (error) {
|
|
78
|
+
// Only invalid attribution output falls back. Database/CAS/actor failures
|
|
79
|
+
// must remain explicit failures rather than masquerading as safe routing.
|
|
80
|
+
if (!['ATTRIBUTION_FAILED', 'INVALID_SHAPE', 'INVALID_FIELD', 'NODE_NOT_FOUND', 'ROOT_INVARIANT', 'INVALID_SOURCE', 'SOURCE_CHANGED', 'USER_SOURCE_REQUIRED'].includes(error.code)) throw error;
|
|
81
|
+
fallback = { code: error.code, reason: error.message, visible: true }; createdNodeIds = [];
|
|
82
|
+
const prior = this.repo.list('tree_turns', t => t.sessionId === input.sessionId && t.nativeTurn < input.nativeTurn).sort((a, b) => b.nativeTurn - a.nativeTurn)[0];
|
|
83
|
+
nodeIds = (prior?.nodeIds ?? []).filter(id => { const n = this.repo.get('tree_nodes', id); return n && !n.deleted; });
|
|
84
|
+
if (!nodeIds.length) nodeIds = [this.repo.rootId()];
|
|
85
|
+
}
|
|
86
|
+
const turn = this.repo.save('tree_turns', { id, sessionId: input.sessionId, nativeTurn: input.nativeTurn, logicalTurnId, userSourceRefs: userRefs, sourceRefs: refs, nodeIds, createdNodeIds, assignmentRevision: 1, status: 'open', startedAt: this.repo.now(), attributionRunId: input.attributionRunId ?? null, fallback });
|
|
87
|
+
for (const nodeId of nodeIds) {
|
|
88
|
+
this.repo.save('tree_links', { id: digest(['link', id, 1, nodeId]), turnId: id, nodeId, assignmentRevision: 1, active: true, sourceRefs: refs });
|
|
89
|
+
this.replace('tree_nodes', this.node(nodeId), { lastOwnTurnAt: turn.startedAt });
|
|
90
|
+
}
|
|
91
|
+
this.#touchStructure(); return { turnId: id, logicalTurnId, nodeIds, createdNodeIds, fallback };
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
moveNode(command) {
|
|
95
|
+
return this.repo.command('moveNode', command, this.actor([], true), () => {
|
|
96
|
+
shape(command.input, ['nodeId', 'parentId', 'sourceRefs']); const { nodeId, parentId } = command.input;
|
|
97
|
+
this.repo.requireExpected(command, 'tree_nodes', nodeId); const node = this.node(nodeId); this.node(parentId);
|
|
98
|
+
if (nodeId === this.repo.rootId()) fail('ROOT_INVARIANT', 'Root cannot move');
|
|
99
|
+
if (this.repo.ancestors(parentId).some(n => n.id === nodeId)) fail('TREE_CYCLE', 'A node cannot move below itself');
|
|
100
|
+
this.refs(command.input.sourceRefs, { required: false });
|
|
101
|
+
const affected = this.repo.list('tree_nodes', n => !n.deleted && this.repo.ancestors(n.id).some(a => a.id === nodeId)).map(n => n.id);
|
|
102
|
+
this.replace('tree_nodes', node, { parentId }); this.#touchStructure(); this.#invalidateScope(affected, 'node_moved'); return { nodeId, parentId };
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
setStatus(command) {
|
|
106
|
+
return this.repo.command('setStatus', command, this.actor(['main']), () => {
|
|
107
|
+
shape(command.input, ['nodeId', 'status', 'sourceRefs']); const { nodeId, status } = command.input; this.scoped(nodeId);
|
|
108
|
+
if (this.context().actor.kind === 'model' && status !== 'completed') fail('FORBIDDEN', 'Main may only mark the current node complete', 403);
|
|
109
|
+
this.repo.requireExpected(command, 'tree_nodes', nodeId); this.refs(command.input.sourceRefs, { required: false }); this.#status(nodeId, status, command.input.sourceRefs);
|
|
110
|
+
this.#touchStructure(); return { nodeId, status: this.node(nodeId).status, statusOrigin: this.node(nodeId).statusOrigin };
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
correctAssignment(command) {
|
|
114
|
+
return this.repo.command('correctAssignment', command, this.actor([], true), () => {
|
|
115
|
+
shape(command.input, ['turnId', 'nodeIds', 'sourceRefs'], ['undoCreate']); const { turnId } = command.input;
|
|
116
|
+
this.repo.requireExpected(command, 'tree_turns', turnId); const turn = this.repo.get('tree_turns', turnId); if (!turn) fail('TURN_NOT_FOUND', 'Turn not found');
|
|
117
|
+
this.refs(command.input.sourceRefs, { required: false }); const nodeIds = [...new Set(array(command.input.nodeIds, 'nodeIds', 8))];
|
|
118
|
+
if (!nodeIds.length) fail('INVALID_FIELD', 'At least one target node is required'); nodeIds.forEach(id => this.node(id));
|
|
119
|
+
const removed = turn.nodeIds.filter(id => !nodeIds.includes(id)), added = nodeIds.filter(id => !turn.nodeIds.includes(id));
|
|
120
|
+
if (!removed.length && !added.length) return { turnId, nodeIds, jobs: [], unchanged: true };
|
|
121
|
+
// Attribution predates tool/results. Preserve its immutable original refs,
|
|
122
|
+
// and include the separately settled native experience in correction.
|
|
123
|
+
const experienceRefs = [...new Map([...turn.sourceRefs, ...(turn.experienceRefs ?? [])].map(r => [canonicalJson(r), r])).values()];
|
|
124
|
+
const assignmentRevision = turn.assignmentRevision + 1;
|
|
125
|
+
for (const link of this.repo.list('tree_links', l => l.turnId === turnId && l.active)) this.replace('tree_links', link, { active: false });
|
|
126
|
+
for (const nodeId of nodeIds) this.repo.save('tree_links', { id: digest(['link', turnId, assignmentRevision, nodeId]), turnId, nodeId, assignmentRevision, active: true, sourceRefs: experienceRefs });
|
|
127
|
+
this.replace('tree_turns', turn, { nodeIds, assignmentRevision }); this.#invalidateScope(removed, 'assignment_corrected');
|
|
128
|
+
const jobs = [], matching = new Set(experienceRefs.map(canonicalJson));
|
|
129
|
+
for (const item of this.repo.list('tree_items', i => removed.includes(i.nodeId) && i.lifecycle === 'active')) {
|
|
130
|
+
const version = this.repo.get('tree_item_versions', digest(['item_version', item.id, item.activeVersion]));
|
|
131
|
+
if (!version?.sourceRefs.some(r => matching.has(canonicalJson(r)))) continue;
|
|
132
|
+
if (version.sourceRefs.every(r => matching.has(canonicalJson(r)))) {
|
|
133
|
+
this.replace('tree_items', item, { lifecycle: 'invalid', invalidationReason: 'assignment_corrected' });
|
|
134
|
+
for (const h of this.repo.list('tree_hooks', h => h.methodId === item.id && h.enabled)) this.replace('tree_hooks', h, { enabled: false });
|
|
135
|
+
for (const run of this.repo.list('tree_script_runs', r => r.methodVersion?.itemId === item.id && !['succeeded', 'failed', 'revoked'].includes(r.state))) this.replace('tree_script_runs', run, { state: 'revoked', denial: 'assignment_corrected' });
|
|
136
|
+
} else {
|
|
137
|
+
this.replace('tree_items', item, { stale: true, staleReason: 'assignment_corrected' });
|
|
138
|
+
jobs.push(this.#queue('verification', item.nodeId, { turnId, itemId: item.id, itemVersion: item.activeVersion, assignmentRevision }));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
for (const nodeId of added) jobs.push(this.#queue('learning', nodeId, { turnId, assignmentRevision, sourceRefs: experienceRefs }));
|
|
142
|
+
if (command.input.undoCreate) for (const id of turn.createdNodeIds ?? []) {
|
|
143
|
+
if (!removed.includes(id) || this.repo.list('tree_links', l => l.nodeId === id && l.active).length || this.repo.list('tree_nodes', n => !n.deleted && n.parentId === id).length) continue;
|
|
144
|
+
this.replace('tree_nodes', this.node(id), { deleted: true, deletionReason: 'undo_create_without_other_experience' });
|
|
145
|
+
}
|
|
146
|
+
this.#touchStructure(); return { turnId, nodeIds, removed, jobs };
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
#queue(kind, nodeId, input) {
|
|
150
|
+
const id = digest(['correction_job', kind, nodeId, input]);
|
|
151
|
+
this.repo.save('tree_jobs', { id, kind, scopeKey: nodeId, nodeIds: [nodeId], input, inputHash: digest(input), inputRevisionSet: { [recordKey('tree_nodes', nodeId)]: this.node(nodeId).revision }, status: 'queued' }); return id;
|
|
152
|
+
}
|
|
153
|
+
setParameter(command) {
|
|
154
|
+
return this.repo.command('setParameter', command, this.actor(['improvement']), () => {
|
|
155
|
+
shape(command.input, ['name', 'scopeNodeId', 'value', 'sourceRefs']); const { name, scopeNodeId, value } = command.input; this.node(scopeNodeId); this.scoped(scopeNodeId);
|
|
156
|
+
oneOf(name, ['retentionDays', 'leaveTimeoutHours'], 'parameter'); integer(value, name, 1, name === 'retentionDays' ? 365 : 168);
|
|
157
|
+
this.refs(command.input.sourceRefs, { required: this.context().actor.kind !== 'user' });
|
|
158
|
+
const id = digest(['parameter', name, scopeNodeId]), old = this.repo.get('tree_parameters', id);
|
|
159
|
+
if (old) { this.repo.requireExpected(command, 'tree_parameters', id); if (old.actor.kind === 'user' && this.context().actor.kind !== 'user') fail('USER_PROTECTED', 'Explicit user settings cannot be changed in background'); }
|
|
160
|
+
const saved = this.repo.save('tree_parameters', { id, name, scopeNodeId, value, sourceRefs: command.input.sourceRefs, actor: this.context().actor }, old?.revision ?? null); return { parameterId: saved.id, value };
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
parameter(name, nodeId) {
|
|
164
|
+
oneOf(name, ['retentionDays', 'leaveTimeoutHours'], 'parameter');
|
|
165
|
+
for (const n of this.repo.ancestors(nodeId).reverse()) { const p = this.repo.get('tree_parameters', digest(['parameter', name, n.id])); if (p) return p.value; }
|
|
166
|
+
return name === 'retentionDays' ? 7 : 24;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export class SignalService extends DomainService {
|
|
171
|
+
#write(input, correctionOf = null) {
|
|
172
|
+
shape(input, ['eventId', 'subjectRefs', 'dimension', 'subitem', 'category', 'polarity', 'evidenceKind', 'sourceRefs', 'nodeIds', 'confidence']);
|
|
173
|
+
text(input.eventId, 'eventId', 512); array(input.subjectRefs, 'subjects', 20).forEach(s => text(s, 'subject', 512));
|
|
174
|
+
oneOf(input.category, CATEGORIES, 'category'); oneOf(input.polarity, ['negative', 'weak', 'strong'], 'polarity'); oneOf(input.evidenceKind, ['language', 'ui', 'program'], 'evidenceKind');
|
|
175
|
+
if (input.evidenceKind === 'language') this.refs(input.sourceRefs); else this.refs(input.sourceRefs, { required: false });
|
|
176
|
+
if (input.evidenceKind === 'ui' && this.context().actor.kind !== 'user') fail('FORGED_UI', 'Only a trusted user operation is UI evidence');
|
|
177
|
+
if (this.context().actor.kind === 'model' && this.context().eventIds && !this.context().eventIds.includes(input.eventId)) fail('UNKNOWN_EVENT', 'Event was not supplied to this invocation');
|
|
178
|
+
for (const nodeId of array(input.nodeIds, 'nodeIds', 8)) this.node(nodeId);
|
|
179
|
+
if (!Number.isFinite(input.confidence) || input.confidence < 0 || input.confidence > 1) fail('INVALID_FIELD', 'Invalid confidence');
|
|
180
|
+
const subjectRefs = [...new Set(input.subjectRefs)].sort(), id = digest(['signal', input.eventId, input.category, subjectRefs, correctionOf]);
|
|
181
|
+
const old = this.repo.get('tree_signals', id);
|
|
182
|
+
if (old) {
|
|
183
|
+
const comparable = s => ({ ...s, nodeIds: [] });
|
|
184
|
+
const original = Object.fromEntries(Object.keys(input).map(k => [k, old[k]]));
|
|
185
|
+
if (canonicalJson(comparable({ ...input, subjectRefs })) !== canonicalJson(comparable(original))) fail('SIGNAL_CONFLICT', 'The same observation has conflicting evidence');
|
|
186
|
+
const nodeIds = [...new Set([...old.nodeIds, ...input.nodeIds])].sort();
|
|
187
|
+
if (canonicalJson(nodeIds) !== canonicalJson(old.nodeIds)) this.replace('tree_signals', old, { nodeIds }); return { signalId: id, duplicate: true };
|
|
188
|
+
}
|
|
189
|
+
this.repo.save('tree_signals', { ...input, subjectRefs, nodeIds: [...new Set(input.nodeIds)].sort(), id, ...(correctionOf ? { correctionOf } : {}) }); return { signalId: id, duplicate: false };
|
|
190
|
+
}
|
|
191
|
+
record(command) { return this.repo.command('recordSignal', command, this.actor(['learning', 'verification', 'consolidation']), () => this.#write(command.input)); }
|
|
192
|
+
correct(command) {
|
|
193
|
+
return this.repo.command('correctSignal', command, this.actor([], true), () => {
|
|
194
|
+
shape(command.input, ['signalId', 'replacement']); const old = this.repo.get('tree_signals', command.input.signalId); if (!old) fail('SIGNAL_NOT_FOUND', 'Signal missing');
|
|
195
|
+
this.repo.requireExpected(command, 'tree_signals', old.id); const replacement = command.input.replacement;
|
|
196
|
+
if (replacement.eventId !== old.eventId || canonicalJson([...replacement.subjectRefs].sort()) !== canonicalJson(old.subjectRefs)) fail('SIGNAL_CONFLICT', 'Correction must keep the original observation identity');
|
|
197
|
+
const result = this.#write(replacement, old.id);
|
|
198
|
+
for (const kind of ['tree_metric_windows', 'tree_clusters']) for (const row of this.repo.list(kind)) this.replace(kind, row, { needsRecheck: true });
|
|
199
|
+
return { ...result, correctionOf: old.id };
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|