@zq-silk/yui 2.0.0 → 2.2.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 (138) hide show
  1. package/README.md +4 -0
  2. package/dist/cli/commandCatalog.js +318 -289
  3. package/dist/cli/commandDiscovery.js +72 -0
  4. package/dist/cli/completion.js +22 -227
  5. package/dist/cli/dynamicCompletion.js +12 -6
  6. package/dist/cli/invocationAuthority.js +2 -2
  7. package/dist/cli/invocationRouter.js +18 -7
  8. package/dist/cli.js +23 -3030
  9. package/dist/commands/capabilityCommands.js +5 -5
  10. package/dist/commands/globalRoleCommands.js +2 -1
  11. package/dist/commands/taskCommands.js +90 -21
  12. package/dist/commands/taskIntegrationCommands.js +3 -1
  13. package/dist/commands/taskUpstreamCommands.js +3 -1
  14. package/dist/context/runContextPack.js +132 -5
  15. package/dist/context/sourceRunContext.js +4 -2
  16. package/dist/context/taskContext.js +12 -3
  17. package/dist/controlPlaneCli.js +3092 -0
  18. package/dist/controller/fileSchedulerStoreAdapter.js +19 -6
  19. package/dist/controller/jobControl.js +21 -0
  20. package/dist/executor/agentExecutor.js +1 -1
  21. package/dist/executor/effectiveLaunch.js +18 -5
  22. package/dist/executor/fileRoleLaunchPlanner.js +15 -19
  23. package/dist/integration/gitIntegrationService.js +50 -15
  24. package/dist/kernel/accessAssessment.js +1 -0
  25. package/dist/kernel/builtinCapabilities.js +28 -1
  26. package/dist/kernel/capabilityRegistry.js +64 -19
  27. package/dist/message/messageContinuation.js +7 -2
  28. package/dist/nativeAgent/agent.js +275 -0
  29. package/dist/nativeAgent/cliDemo.js +37 -0
  30. package/dist/nativeAgent/codingTools.js +11 -0
  31. package/dist/nativeAgent/commandTool.js +215 -0
  32. package/dist/nativeAgent/compactionDemo.js +158 -0
  33. package/dist/nativeAgent/composition.js +71 -0
  34. package/dist/nativeAgent/context/budget.js +44 -0
  35. package/dist/nativeAgent/context/index.js +339 -0
  36. package/dist/nativeAgent/context/providerCompressor.js +103 -0
  37. package/dist/nativeAgent/contracts.js +1 -0
  38. package/dist/nativeAgent/demo.js +40 -0
  39. package/dist/nativeAgent/evaluation/cases.js +38 -0
  40. package/dist/nativeAgent/evaluation/checks.js +91 -0
  41. package/dist/nativeAgent/evaluation/demo.js +19 -0
  42. package/dist/nativeAgent/evaluation/files.js +54 -0
  43. package/dist/nativeAgent/evaluation/fixture.js +36 -0
  44. package/dist/nativeAgent/evaluation/index.js +239 -0
  45. package/dist/nativeAgent/executionOwner.js +209 -0
  46. package/dist/nativeAgent/filePatterns.js +170 -0
  47. package/dist/nativeAgent/fileToolsSupport.js +202 -0
  48. package/dist/nativeAgent/index.js +16 -0
  49. package/dist/nativeAgent/interaction/cli.js +358 -0
  50. package/dist/nativeAgent/interaction/contracts.js +1 -0
  51. package/dist/nativeAgent/interaction/index.js +3 -0
  52. package/dist/nativeAgent/interaction/memoryDemo.js +115 -0
  53. package/dist/nativeAgent/interaction/renderer.js +34 -0
  54. package/dist/nativeAgent/localSafety.js +258 -0
  55. package/dist/nativeAgent/mockProvider.js +43 -0
  56. package/dist/nativeAgent/model/anthropicMessages.js +204 -0
  57. package/dist/nativeAgent/model/chatCompletions.js +210 -0
  58. package/dist/nativeAgent/model/errors.js +47 -0
  59. package/dist/nativeAgent/model/gateway.js +423 -0
  60. package/dist/nativeAgent/model/index.js +7 -0
  61. package/dist/nativeAgent/model/observationAdapter.js +21 -0
  62. package/dist/nativeAgent/model/protocols.js +19 -0
  63. package/dist/nativeAgent/model/responses.js +263 -0
  64. package/dist/nativeAgent/model/types.js +1 -0
  65. package/dist/nativeAgent/model/wire.js +73 -0
  66. package/dist/nativeAgent/observability/index.js +220 -0
  67. package/dist/nativeAgent/product/catalog.js +82 -0
  68. package/dist/nativeAgent/product/config.js +295 -0
  69. package/dist/nativeAgent/product/facts.js +30 -0
  70. package/dist/nativeAgent/product/index.js +62 -0
  71. package/dist/nativeAgent/product/location.js +44 -0
  72. package/dist/nativeAgent/product/runtime.js +276 -0
  73. package/dist/nativeAgent/product/storage.js +49 -0
  74. package/dist/nativeAgent/product/tools.js +47 -0
  75. package/dist/nativeAgent/product/transport.js +54 -0
  76. package/dist/nativeAgent/projectGuidance/index.js +425 -0
  77. package/dist/nativeAgent/searchTools.js +305 -0
  78. package/dist/nativeAgent/session/backends.js +293 -0
  79. package/dist/nativeAgent/session/catalog.js +97 -0
  80. package/dist/nativeAgent/session/catalogDemo.js +87 -0
  81. package/dist/nativeAgent/session/contracts.js +1 -0
  82. package/dist/nativeAgent/session/format.js +269 -0
  83. package/dist/nativeAgent/session/index.js +5 -0
  84. package/dist/nativeAgent/session/location.js +36 -0
  85. package/dist/nativeAgent/session/sqliteFormat.js +134 -0
  86. package/dist/nativeAgent/session/store.js +248 -0
  87. package/dist/nativeAgent/textTools.js +293 -0
  88. package/dist/nativeAgent/toolManager/executor.js +290 -0
  89. package/dist/nativeAgent/toolManager/index.js +4 -0
  90. package/dist/nativeAgent/validation.js +95 -0
  91. package/dist/plugins/pluginService.js +98 -8
  92. package/dist/resources/projectResourceService.js +23 -1
  93. package/dist/runtime/managedIdentity.js +6 -0
  94. package/dist/surface/surfaceContributions.js +3 -0
  95. package/dist/task/taskAuthority.js +61 -10
  96. package/dist/web/assets/assetManifest.js +38 -22
  97. package/dist/web/assets/client/api.js +115 -0
  98. package/dist/web/assets/client/app.js +615 -862
  99. package/dist/web/assets/client/components.js +323 -897
  100. package/dist/web/assets/client/detail.js +333 -0
  101. package/dist/web/assets/client/dock.js +225 -0
  102. package/dist/web/assets/client/dom.js +55 -3
  103. package/dist/web/assets/client/evidence.js +172 -0
  104. package/dist/web/assets/client/format.js +25 -14
  105. package/dist/web/assets/client/forms.js +230 -0
  106. package/dist/web/assets/client/i18n.js +1024 -791
  107. package/dist/web/assets/client/overview.js +118 -0
  108. package/dist/web/assets/client/records.js +63 -0
  109. package/dist/web/assets/client/sections.js +313 -0
  110. package/dist/web/assets/client/sidebar.js +146 -0
  111. package/dist/web/assets/client/theme.js +61 -22
  112. package/dist/web/assets/icons.js +39 -0
  113. package/dist/web/assets/shell.js +125 -118
  114. package/dist/web/assets/styles/base.js +41 -0
  115. package/dist/web/assets/styles/components.js +169 -0
  116. package/dist/web/assets/styles/layout.js +56 -77
  117. package/dist/web/assets/styles/markdown.js +16 -24
  118. package/dist/web/assets/styles/responsive.js +41 -44
  119. package/dist/web/assets/styles/tokens.js +65 -89
  120. package/dist/web/assets/styles/views.js +292 -0
  121. package/dist/web/webServer.js +2 -1
  122. package/docs/agent-result-consumption.md +16 -0
  123. package/docs/agent-result-consumption.zh-CN.md +13 -0
  124. package/docs/examples/agent-offline.mjs +194 -0
  125. package/docs/native-agent.md +283 -0
  126. package/docs/release-workflow.md +61 -0
  127. package/docs/release-workflow.zh-CN.md +46 -0
  128. package/docs/roles-and-configuration.md +32 -0
  129. package/docs/roles-and-configuration.zh-CN.md +26 -0
  130. package/package.json +1 -1
  131. package/skills/yui-leader/SKILL.md +9 -0
  132. package/skills/yui-reviewer/SKILL.md +5 -0
  133. package/skills/yui-runtime/SKILL.md +33 -0
  134. package/dist/web/assets/client/taskSummary.js +0 -350
  135. package/dist/web/assets/client/taskSurface.js +0 -616
  136. package/dist/web/assets/client/view.js +0 -608
  137. package/dist/web/assets/styles/cards.js +0 -247
  138. package/dist/web/assets/styles/widgets.js +0 -168
@@ -0,0 +1,158 @@
1
+ import assert from 'node:assert/strict';
2
+ import { createHash } from 'node:crypto';
3
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { createAgent, createContextBuilder, createProviderCompressor, createModelGateway, createSessionStore, createSqliteSessionBackend, createExecutionOwner, createTextTools } from './index.js';
8
+ const digest = (v) => createHash('sha256').update(JSON.stringify(v)).digest('hex');
9
+ /** Only model responses are fixtures. Real gateway, kernel, tools, SQLite and owner remain in the path. */
10
+ export async function runCompactionDemo() {
11
+ const root = await mkdtemp(path.join(tmpdir(), 'agent-compaction-demo-'));
12
+ let store, owner;
13
+ let summaryCalls = 0, codingCalls = 0, compressedRequests = 0;
14
+ const observedSummaryBytes = [];
15
+ const observedSourceBytes = [];
16
+ const codingProjections = [];
17
+ const sessionId = 'long-session';
18
+ const verifySources = (serialized, report, full) => {
19
+ const summary = JSON.parse(serialized).contextSummary;
20
+ const selected = report.entries.filter(entry => entry.action === 'summarized');
21
+ assert.equal(summary.historyDigest, report.historyDigest);
22
+ assert.equal(summary.digest, digest(selected.map(entry => entry.digest)));
23
+ assert.equal(summary.sources.provenanceDigest, digest(selected.map(({ action: _action, reason: _reason, ...entry }) => entry)));
24
+ const span = summary.sources.history;
25
+ assert.equal(span.rangeKind, 'enclosing');
26
+ assert.equal(span.digest, digest(full.slice(...span.historyRange)));
27
+ const outcomes = selected.flatMap(entry => entry.toolOutcomes ?? []);
28
+ assert.deepEqual(summary.sources.toolSettlements, {
29
+ representation: 'aggregate-only', succeeded: outcomes.filter(outcome => outcome.ok).length,
30
+ failedWithoutEffect: outcomes.filter(outcome => !outcome.ok && outcome.effect === 'none').length,
31
+ digest: digest(outcomes),
32
+ });
33
+ const bytes = Buffer.byteLength(JSON.stringify(summary.sources));
34
+ assert.ok(bytes < 1500);
35
+ observedSourceBytes.push(bytes);
36
+ };
37
+ const verifyTurn = (before, result) => {
38
+ for (const { report } of result.contextReports) {
39
+ assert.ok(report.estimatedInput <= report.availableInput);
40
+ assert.equal(report.baseReceipt.digest, before.digest);
41
+ const full = [...before.messages, ...result.messages].slice(0, Math.max(...report.entries.filter(e => e.historyRange).map(e => e.historyRange[1])));
42
+ assert.equal(report.historyDigest, digest(full));
43
+ for (const entry of report.entries)
44
+ if (entry.historyRange)
45
+ assert.equal(entry.digest, digest(full.slice(...entry.historyRange)));
46
+ const serialized = codingProjections.shift();
47
+ if (serialized)
48
+ verifySources(serialized, report, full);
49
+ else
50
+ assert.ok(report.entries.every(entry => entry.action !== 'summarized'));
51
+ }
52
+ };
53
+ try {
54
+ await writeFile(path.join(root, 'input.txt'), 'Selected workspace evidence.\n'.repeat(30));
55
+ const transport = async (_endpoint, init) => {
56
+ const wire = JSON.parse(init.body);
57
+ const isSummary = wire.messages[0]?.content.startsWith('Summarize coding-session data');
58
+ let content, toolCalls;
59
+ if (isSummary) {
60
+ summaryCalls++;
61
+ assert.equal(wire.tools?.length ?? 0, 0);
62
+ // The adapter adds its own wire overhead, which the JSON-byte fixture does not call tokens.
63
+ observedSummaryBytes.push(Buffer.byteLength(init.body));
64
+ content = 'Workspace reads confirmed. Preserve API; never publish. Continue coding from saved original facts.';
65
+ }
66
+ else {
67
+ codingCalls++;
68
+ codingProjections.push(wire.messages.find((message) => typeof message.content === 'string' && message.content.includes('"contextSummary":'))?.content);
69
+ assert.ok(wire.messages.some((m) => m.content.includes('Goal: preserve API; never publish')));
70
+ assert.ok(wire.messages.some((m) => m.role === 'system' && m.content.includes('Trusted project guidance')));
71
+ for (const message of wire.messages) {
72
+ if (typeof message.content !== 'string' || !message.content.includes('"contextSummary":'))
73
+ continue;
74
+ const summary = JSON.parse(message.content).contextSummary;
75
+ assert.equal(summary.trust, 'data');
76
+ compressedRequests++;
77
+ }
78
+ const last = wire.messages.at(-1);
79
+ if (last.role === 'user') {
80
+ content = 'Read the explicitly selected file.';
81
+ toolCalls = [{ id: `read-${codingCalls}`, type: 'function',
82
+ function: { name: 'read', arguments: '{"path":"input.txt"}' } }];
83
+ }
84
+ else {
85
+ assert.equal(last.role, 'tool');
86
+ assert.equal(JSON.parse(last.content).ok, true);
87
+ content = 'Read confirmed; detailed intermediate progress. '.repeat(65);
88
+ }
89
+ }
90
+ return new Response(JSON.stringify({ choices: [{ index: 0,
91
+ finish_reason: toolCalls ? 'tool_calls' : 'stop',
92
+ message: { role: 'assistant', content, ...(toolCalls ? { tool_calls: toolCalls } : {}) },
93
+ }] }));
94
+ };
95
+ const gateway = createModelGateway({ endpoint: 'https://offline.invalid/chat', model: 'fixture',
96
+ account: { kind: 'none' }, transport });
97
+ const compressor = createProviderCompressor({ id: 'provider-summary-v1', provider: gateway,
98
+ budget: { capacity: 6500, reserveOutput: 500 }, maxSummaryBytes: 500, maxCalls: 32 });
99
+ const context = createContextBuilder({ compressor, sources: [{ id: 'explicit-guide', async load() {
100
+ return [{ id: 'guide', kind: 'guidance', content: 'Trusted project guidance: bounded edits only.',
101
+ source: 'demo-guide', revision: 'v1', required: true }];
102
+ } }] });
103
+ const tools = createTextTools({ root }).filter(t => t.definition.name === 'read');
104
+ const budget = { capacity: 14_000, reserveOutput: 1000, reserveTools: 500 };
105
+ const filename = path.join(root, 'sessions.sqlite');
106
+ store = createSessionStore(createSqliteSessionBackend(filename));
107
+ await store.create(sessionId);
108
+ owner = createExecutionOwner({ store, maxSteps: 2,
109
+ context: { builder: context, budget, tools: tools.map(t => t.definition) },
110
+ agent: recording => createAgent({ tools, recorder: recording, provider: gateway,
111
+ contextBuilder: context, contextBudget: budget }),
112
+ });
113
+ for (let i = 0; i < 8; i++) {
114
+ const before = await store.load(sessionId);
115
+ await owner.submit(sessionId, i === 0 ? 'Goal: preserve API; never publish' : `Continue coding: ${i}`);
116
+ const result = (await owner.settle(sessionId)).result;
117
+ assert.equal(result.reason, 'completed', result.error?.message);
118
+ const saved = await store.load(sessionId);
119
+ assert.deepEqual(saved.document.events.slice(0, before.document.events.length), before.document.events);
120
+ assert.deepEqual(saved.messages.slice(0, before.messages.length), before.messages);
121
+ assert.equal(saved.recovery.disposition, 'ready');
122
+ verifyTurn(before, result);
123
+ }
124
+ const beforeManual = await store.load(sessionId);
125
+ const manual = await owner.compact(sessionId);
126
+ assert.ok(manual.report.entries.some(e => e.action === 'summarized'));
127
+ const manualSummary = manual.request.messages.flatMap(message => 'content' in message && message.content.includes('"contextSummary":') ? [message.content] : [])[0];
128
+ assert.ok(manualSummary);
129
+ verifySources(manualSummary, manual.report, beforeManual.messages);
130
+ assert.deepEqual(await store.load(sessionId), beforeManual);
131
+ await owner.submit(sessionId, 'Continue after manual compact');
132
+ const continued = (await owner.settle(sessionId)).result;
133
+ assert.equal(continued.reason, 'completed');
134
+ verifyTurn(beforeManual, continued);
135
+ assert.equal(codingProjections.length, 0);
136
+ const saved = await store.load(sessionId);
137
+ assert.equal(saved.recovery.calls.length, 9);
138
+ assert.ok(saved.recovery.calls.every(c => c.status === 'settled' && c.outcome?.ok));
139
+ assert.ok(summaryCalls >= 2);
140
+ assert.ok(compressedRequests >= 2);
141
+ await owner.close();
142
+ await store.close();
143
+ store = createSessionStore(createSqliteSessionBackend(filename));
144
+ assert.equal((await store.load(sessionId)).digest, saved.digest);
145
+ return { turns: 9, summaryCalls, compressedRequests, codingCalls, verifiedToolPairs: 9,
146
+ immutableHistory: true, provenanceVerified: true, sqliteRestartVerified: true,
147
+ largestSummaryWireBytes: Math.max(...observedSummaryBytes),
148
+ largestSourceMetadataBytes: Math.max(...observedSourceBytes),
149
+ validation: 'offline fixture responses; not real-model summary quality' };
150
+ }
151
+ finally {
152
+ await owner?.close();
153
+ await store?.close();
154
+ await rm(root, { recursive: true, force: true });
155
+ }
156
+ }
157
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url))
158
+ console.log(JSON.stringify(await runCompactionDemo(), null, 2));
@@ -0,0 +1,71 @@
1
+ import { createModelObservationAdapter } from './model/index.js';
2
+ /** Live display fanout with no retained body or durable cursor. UI consumers
3
+ * own bounded buffering; final messages always come from the Session store. */
4
+ export function createInteractionProgress() {
5
+ const listeners = new Set();
6
+ return {
7
+ observe(event) {
8
+ if (event.data.type !== 'text_delta')
9
+ return;
10
+ for (const listener of listeners) {
11
+ try {
12
+ void Promise.resolve(listener({ sessionId: event.sessionId, turnId: event.turnId, text: event.data.text })).catch(() => { });
13
+ }
14
+ catch { /* Display cannot reject model execution. */ }
15
+ }
16
+ },
17
+ subscribe(listener) {
18
+ if (listeners.size >= 32)
19
+ throw new Error('Progress subscriber limit reached');
20
+ listeners.add(listener);
21
+ return () => { listeners.delete(listener); };
22
+ },
23
+ };
24
+ }
25
+ /** Connect actual model evidence; never synthesize starts, usage or AgentEvent sequences. */
26
+ export function connectModelObservations(observer, display) {
27
+ return createModelObservationAdapter({
28
+ display(event) {
29
+ if (event.data.type === 'text_delta')
30
+ observer.observeStream({ ...event, text: event.data.text }, event.source);
31
+ display?.(event);
32
+ },
33
+ diagnostics(event) {
34
+ const { sessionId, turnId, step, requestId, attempt, source, data } = event;
35
+ const identity = { sessionId, turnId, step, requestId, attempt };
36
+ if (data.type === 'retry') {
37
+ observer.observeModel({ ...identity, phase: 'retry', retryAfterMs: data.delayMs }, source);
38
+ }
39
+ else {
40
+ const r = data.record;
41
+ observer.observeModel({
42
+ ...identity, phase: 'ended',
43
+ status: r.outcome === 'success' ? 'completed' : r.outcome === 'cancelled' ? 'cancelled'
44
+ : r.effect === 'unknown' ? 'unknown' : 'error',
45
+ ...(r.effect !== 'completed' ? { effect: r.effect } : {}),
46
+ ...(r.outcome !== 'success' ? { errorCode: r.outcome } : {}),
47
+ ...(r.usage ? { usage: r.usage } : {}),
48
+ clientRequestId: r.clientRequestId,
49
+ ...(r.providerRequestId ? { providerRequestId: r.providerRequestId } : {}),
50
+ ...(r.status === undefined ? {} : { httpStatus: r.status }),
51
+ elapsedMs: r.elapsedMs,
52
+ }, source);
53
+ }
54
+ },
55
+ });
56
+ }
57
+ /** UI offsets are opaque observation cursors, never message or session revisions. */
58
+ export function createInteractionDiagnostics(observer) {
59
+ return {
60
+ async query(sessionId, after, limit) {
61
+ const page = observer.query({ sessionId, after, limit });
62
+ const metadata = { gap: page.gap, evicted: page.evicted, closed: page.closed };
63
+ const lines = page.records.map((record, i) => JSON.stringify(i ? record : { ...record, ...metadata }));
64
+ if (!lines.length)
65
+ lines.push(JSON.stringify(metadata));
66
+ return { lines, nextOffset: page.nextCursor < page.throughCursor ? page.nextCursor : null };
67
+ },
68
+ async health() { return JSON.stringify(observer.health()); },
69
+ subscribe(changed) { return observer.subscribe({ export() { changed(); } }); },
70
+ };
71
+ }
@@ -0,0 +1,44 @@
1
+ import { ContextBuildError } from './index.js';
2
+ export function validCount(n) {
3
+ return typeof n === 'number' && Number.isSafeInteger(n) && n >= 0;
4
+ }
5
+ const units = ['tokens', 'bytes', 'custom'];
6
+ const text = (v) => typeof v === 'string' && v.trim().length > 0;
7
+ export function measure(counter, request, capacity) {
8
+ let measurement;
9
+ try {
10
+ measurement = structuredClone(counter.count(request));
11
+ }
12
+ catch {
13
+ throw new ContextBuildError('estimation_failed', `Counter failed: ${counter.id}`);
14
+ }
15
+ if (!measurement || measurement.accuracy === 'unknown')
16
+ throw new ContextBuildError('count_unknown', 'Request count is unknown; no model request admitted');
17
+ if (!units.includes(measurement.unit) || !text(measurement.source)
18
+ || !validCount(measurement.value)
19
+ || !['exact', 'estimated'].includes(measurement.accuracy)
20
+ || !validCount(measurement.uncertainty)
21
+ || (measurement.accuracy === 'exact' && measurement.uncertainty !== 0))
22
+ throw new ContextBuildError('invalid_estimate', 'Count requires units, source, accuracy and explicit uncertainty');
23
+ if (capacity && (capacity.unit !== measurement.unit || capacity.counterId !== counter.id))
24
+ throw new ContextBuildError('capacity_mismatch', 'Model capacity and counter units/identity differ');
25
+ const upper = measurement.value + measurement.uncertainty;
26
+ if (!validCount(upper))
27
+ throw new ContextBuildError('invalid_estimate', 'Count plus uncertainty overflows');
28
+ return { measurement, upper };
29
+ }
30
+ export function budgetBounds(budget, model) {
31
+ if (!budget || typeof budget !== 'object')
32
+ throw new ContextBuildError('invalid_budget', 'An explicit capacity and reserve budget is required');
33
+ const capacity = model?.contextWindow ?? budget.capacity;
34
+ const reserveTools = budget.reserveTools ?? 0, safetyMargin = budget.safetyMargin ?? 0;
35
+ const reserve = budget.reserveOutput + reserveTools + safetyMargin;
36
+ if (![capacity, budget.capacity, budget.reserveOutput, reserveTools, safetyMargin, reserve].every(validCount)
37
+ || capacity === 0 || reserve > capacity)
38
+ throw new ContextBuildError('invalid_budget', 'Invalid capacity or output/tool/safety reservations');
39
+ if (model && (!text(model.model) || !text(model.revision) || !text(model.counterId)
40
+ || !units.includes(model.unit) || (model.maxOutput !== undefined
41
+ && (!validCount(model.maxOutput) || budget.reserveOutput > model.maxOutput))))
42
+ throw new ContextBuildError('capacity_mismatch', 'Invalid model capability or output reservation exceeds supported output');
43
+ return { capacity, reserveOutput: budget.reserveOutput, reserveTools, safetyMargin, availableInput: capacity - reserve };
44
+ }
@@ -0,0 +1,339 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { history, limits, size } from '../validation.js';
3
+ import { budgetBounds, measure, validCount } from './budget.js';
4
+ export { createProviderCompressor } from './providerCompressor.js';
5
+ export class ContextBuildError extends Error {
6
+ code;
7
+ report;
8
+ recovery;
9
+ constructor(code, message, report) {
10
+ const recovery = code === 'cancelled' ? 'Wait for started summarization to settle; retry explicitly from saved history.'
11
+ : ['storage_failed', 'session_recovery_required', 'history_changed'].includes(code)
12
+ ? 'Inspect the exact SessionStore receipt, unsettled effects and original failure; reload/reconcile saved facts without replaying tools.'
13
+ : code === 'byte_budget_exceeded'
14
+ ? 'Reduce the projected request or use a smaller summary; the independent kernel byte ceiling cannot be raised by model capacity.'
15
+ : ['count_unknown', 'capacity_mismatch', 'capacity_changed', 'invalid_estimate', 'estimation_failed', 'capacity_failed'].includes(code)
16
+ ? 'Refresh model capability and supply a matching complete-request counter with explicit uncertainty; rebuild.'
17
+ : ['budget_exceeded', 'summary_input_exceeded', 'summary_budget_exhausted', 'compression_no_gain'].includes(code)
18
+ ? 'Inspect retained anchors and tool groups; choose a larger authorized capacity or a bounded summary strategy. Do not truncate or replay tools.'
19
+ : 'Inspect the failed source/compressor and authoritative history; correct it and explicitly rebuild. No projection or tool replay was installed.';
20
+ super(`${code}: ${message}. ${recovery}`);
21
+ this.code = code;
22
+ this.report = report;
23
+ this.recovery = recovery;
24
+ }
25
+ }
26
+ function frozen(value) {
27
+ const copy = structuredClone(value);
28
+ function freeze(item) {
29
+ if (item && typeof item === 'object') {
30
+ Object.values(item).forEach(freeze);
31
+ Object.freeze(item);
32
+ }
33
+ }
34
+ freeze(copy);
35
+ return copy;
36
+ }
37
+ function fail(code, message) { throw new ContextBuildError(code, message); }
38
+ function cancelled(signal) {
39
+ if (signal.aborted)
40
+ fail('cancelled', 'Context construction cancelled; no request produced');
41
+ }
42
+ const count = validCount;
43
+ function nonempty(value) { return typeof value === 'string' && value.trim().length > 0; }
44
+ const digest = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
45
+ /** Deterministic approximation, not a provider tokenizer. Budgets are UTF-8 JSON bytes. */
46
+ export const jsonByteEstimator = Object.freeze({
47
+ id: 'utf8-json-bytes',
48
+ estimate: (request) => Buffer.byteLength(JSON.stringify(request), 'utf8'),
49
+ });
50
+ function historyUnits(request, keep, ranges) {
51
+ const messages = request.messages;
52
+ try {
53
+ history(messages, Infinity);
54
+ }
55
+ catch {
56
+ fail('invalid_history', 'Source history must contain valid, bounded messages and complete tool batches');
57
+ }
58
+ const historyRevision = `sha256:${digest(messages)}`;
59
+ const units = [];
60
+ const used = new Set();
61
+ let latestUser = -1;
62
+ let firstUser = -1;
63
+ for (let i = 0; i < messages.length; i++)
64
+ if (messages[i].role === 'user') {
65
+ latestUser = i;
66
+ if (firstUser < 0)
67
+ firstUser = i;
68
+ }
69
+ for (const range of ranges ?? [])
70
+ if (range.length !== 2 || !count(range[0]) || !count(range[1])
71
+ || range[0] >= range[1] || range[1] > messages.length)
72
+ fail('invalid_history', 'Protected history ranges must be valid original [start,end) bounds');
73
+ for (let i = 0; i < messages.length;) {
74
+ const start = i;
75
+ const m = messages[i++];
76
+ if (!m || !['system', 'user', 'assistant'].includes(m.role) || !('content' in m)
77
+ || typeof m.content !== 'string')
78
+ fail('invalid_history', 'Unexpected or unpaired history message');
79
+ if (m.role === 'assistant') {
80
+ if (!Array.isArray(m.toolCalls))
81
+ fail('invalid_history', 'Missing assistant tool calls');
82
+ const pending = new Map();
83
+ for (const call of m.toolCalls) {
84
+ if (!nonempty(call.id) || !nonempty(call.name) || used.has(call.id))
85
+ fail('invalid_history', 'Duplicate or invalid tool call identity');
86
+ used.add(call.id);
87
+ pending.set(call.id, call.name);
88
+ }
89
+ while (pending.size) {
90
+ const result = messages[i++];
91
+ if (!result || result.role !== 'tool' || !pending.has(result.toolCallId)
92
+ || pending.get(result.toolCallId) !== result.name || !result.outcome
93
+ || (result.outcome.ok !== true && result.outcome.ok !== false))
94
+ fail('invalid_history', 'Tool batch requires exactly one matching result per call');
95
+ pending.delete(result.toolCallId);
96
+ if (!result.outcome.ok && result.outcome.error.effect === 'unknown')
97
+ fail('unresolved_effect', 'Unknown tool effects must be reconciled in authoritative history before projection');
98
+ }
99
+ }
100
+ const required = m.role === 'system' || start === firstUser || (latestUser >= 0 && start >= latestUser)
101
+ || (ranges ?? []).some(([a, b]) => a < i && b > start);
102
+ units.push({ messages: messages.slice(start, i), required, entry: {
103
+ source: `session:${request.sessionId}`, revision: historyRevision,
104
+ digest: digest(messages.slice(start, i)),
105
+ ...(m.role === 'assistant' && m.toolCalls.length ? { toolOutcomes: messages.slice(start + 1, i).map(result => {
106
+ const tool = result;
107
+ return { toolCallId: tool.toolCallId, name: tool.name, ok: tool.outcome.ok,
108
+ ...(!tool.outcome.ok ? { errorCode: tool.outcome.error.code, effect: tool.outcome.error.effect } : {}) };
109
+ }) } : {}),
110
+ historyRange: [start, i], action: 'retained', reason: required ? 'system-or-current-input' : 'within-budget',
111
+ } });
112
+ }
113
+ for (const unit of units.slice(Math.max(0, units.length - keep))) {
114
+ unit.required = true;
115
+ unit.entry.reason = 'recent-group';
116
+ }
117
+ return units;
118
+ }
119
+ /** Model-facing commitments, not a second history or an enumeration of old groups.
120
+ * Exact selected ranges/material identities/outcomes remain in this build's report. */
121
+ function summarySources(entries, messages) {
122
+ const provenance = entries.map(({ action: _action, reason: _reason, ...entry }) => entry);
123
+ const historical = entries.filter(entry => entry.historyRange);
124
+ const materials = provenance.filter(entry => !entry.historyRange);
125
+ const outcomes = entries.flatMap(entry => entry.toolOutcomes ?? []);
126
+ const historyRange = historical.length
127
+ ? [historical[0].historyRange[0], historical.at(-1).historyRange[1]] : undefined;
128
+ return {
129
+ representation: 'aggregate', details: 'report.entries', groups: entries.length,
130
+ provenanceDigest: digest(provenance),
131
+ ...(historyRange ? { history: {
132
+ // Protected groups can leave holes: this commits the enclosing original
133
+ // span, while provenanceDigest binds the exact selected groups in report.
134
+ rangeKind: 'enclosing', historyRange, digest: digest(messages.slice(...historyRange)),
135
+ groups: historical.length,
136
+ } } : {}),
137
+ ...(materials.length ? { materials: { count: materials.length, digest: digest(materials) } } : {}),
138
+ ...(outcomes.length ? { toolSettlements: {
139
+ representation: 'aggregate-only',
140
+ succeeded: outcomes.filter(outcome => outcome.ok).length,
141
+ failedWithoutEffect: outcomes.filter(outcome => !outcome.ok && outcome.effect === 'none').length,
142
+ digest: digest(outcomes),
143
+ } } : {}),
144
+ };
145
+ }
146
+ export function createContextBuilder(options = {}) {
147
+ const sources = [...(options.sources ?? [])];
148
+ const estimator = options.estimator ?? jsonByteEstimator;
149
+ const compressor = options.compressor;
150
+ const counter = options.counter ?? {
151
+ id: estimator.id, count: request => ({ value: estimator.estimate(request),
152
+ unit: estimator.id === jsonByteEstimator.id ? 'bytes' : 'custom',
153
+ source: estimator.id, accuracy: estimator.id === jsonByteEstimator.id ? 'exact' : 'estimated', uncertainty: 0 }),
154
+ };
155
+ if (options.counter && options.estimator)
156
+ fail('invalid_options', 'Select a counter or a legacy estimator, not both');
157
+ // One bounded, disposable projection. Never a transcript, persistent fact or shared Session authority.
158
+ let cached;
159
+ if (!nonempty(estimator.id) || !nonempty(counter.id) || (compressor && !nonempty(compressor.id))
160
+ || (options.capacity && !nonempty(options.capacity.id))
161
+ || sources.some(s => !nonempty(s.id)) || new Set(sources.map(s => s.id)).size !== sources.length)
162
+ fail('invalid_options', 'Extensions require nonempty unique source identities');
163
+ return {
164
+ discard(sessionId) { if (cached?.sessionId === sessionId)
165
+ cached = undefined; },
166
+ async build(input, signal) {
167
+ cancelled(signal);
168
+ input = frozen(input);
169
+ const keep = input.keepRecentGroups ?? 2;
170
+ if (!count(keep) || (input.mode !== undefined && !['auto', 'manual'].includes(input.mode)))
171
+ fail('invalid_budget', 'Invalid retention count or projection mode');
172
+ // Snapshot before the first await; extensions never receive caller-owned history.
173
+ const request = frozen(input.request);
174
+ const units = historyUnits(request, keep, input.protectedHistoryRanges);
175
+ const scope = frozen({ sessionId: request.sessionId, turnId: request.turnId, step: request.step });
176
+ const baseReceipt = input.baseReceipt && frozen(input.baseReceipt);
177
+ if (baseReceipt && (baseReceipt.sessionId !== request.sessionId || !count(baseReceipt.revision)
178
+ || !count(baseReceipt.messageCount) || baseReceipt.messageCount > request.messages.length
179
+ || !/^[a-f0-9]{64}$/.test(baseReceipt.digest)))
180
+ fail('invalid_history', 'Invalid stored-prefix receipt');
181
+ let model;
182
+ if (options.capacity) {
183
+ try {
184
+ model = frozen(await options.capacity.resolve(scope, signal));
185
+ }
186
+ catch {
187
+ cancelled(signal);
188
+ fail('capacity_failed', `Capacity source failed: ${options.capacity.id}`);
189
+ }
190
+ cancelled(signal);
191
+ if (!model)
192
+ fail('capacity_mismatch', 'Capacity source returned no model capability');
193
+ }
194
+ const bounds = budgetBounds(input.budget, model);
195
+ const capability = digest({ model, counter: counter.id });
196
+ const materials = [];
197
+ for (const source of sources) {
198
+ cancelled(signal);
199
+ let loaded;
200
+ try {
201
+ loaded = frozen(await source.load(scope, signal));
202
+ }
203
+ catch {
204
+ cancelled(signal);
205
+ fail('source_failed', `Context source failed: ${source.id}`);
206
+ }
207
+ cancelled(signal);
208
+ if (!Array.isArray(loaded))
209
+ fail('invalid_material', `Invalid source output: ${source.id}`);
210
+ const ids = new Set();
211
+ for (const material of loaded) {
212
+ if (!material || !nonempty(material.id) || ids.has(material.id) || !nonempty(material.source)
213
+ || !nonempty(material.revision) || typeof material.content !== 'string'
214
+ || typeof material.required !== 'boolean' || !['guidance', 'file', 'data'].includes(material.kind))
215
+ fail('invalid_material', `Invalid material from source: ${source.id}`);
216
+ ids.add(material.id);
217
+ materials.push({
218
+ messages: [{ role: material.kind === 'guidance' ? 'system' : 'user',
219
+ content: JSON.stringify({ contextMaterial: { ...material, loader: source.id } }) }],
220
+ required: material.required || material.kind === 'guidance',
221
+ entry: { source: material.source, revision: material.revision, sourceId: source.id,
222
+ digest: digest(material),
223
+ materialId: material.id, action: 'retained',
224
+ reason: material.required || material.kind === 'guidance' ? 'required-material' : 'within-budget' },
225
+ });
226
+ }
227
+ }
228
+ // Required material is not summarized, but its changes still invalidate derived
229
+ // history summaries. Include selection and order, not only compressible units.
230
+ const sourceSnapshot = digest({
231
+ sources: sources.map(source => source.id),
232
+ materials: materials.map(unit => ({ entry: unit.entry, required: unit.required })),
233
+ });
234
+ if (cached?.sessionId === request.sessionId && cached.sourceSnapshot !== sourceSnapshot)
235
+ cached = undefined;
236
+ // Material order is explicit; history order remains unchanged.
237
+ const all = [...materials, ...units];
238
+ const makeRequest = () => frozen({ ...request, messages: all.flatMap(u => [...u.messages]) });
239
+ let measurement;
240
+ const estimate = () => {
241
+ cancelled(signal);
242
+ const counted = measure(counter, makeRequest(), model);
243
+ measurement = counted.measurement;
244
+ cancelled(signal);
245
+ return counted.upper;
246
+ };
247
+ const { availableInput } = bounds;
248
+ let estimatedInput = estimate();
249
+ const historyDigest = digest(request.messages);
250
+ const report = () => frozen({ estimator: counter.id,
251
+ ...(compressor ? { compressor: compressor.id } : {}), ...bounds, estimatedInput, measurement,
252
+ ...(model ? { model, capacitySource: options.capacity.id } : {}), historyDigest, byteInput: size(makeRequest()),
253
+ ...(baseReceipt ? { baseReceipt } : {}), entries: all.map(u => u.entry) });
254
+ const candidates = all.filter(u => !u.required);
255
+ const keys = candidates.map(u => digest({ source: u.entry.source, sourceId: u.entry.sourceId,
256
+ materialId: u.entry.materialId, revision: u.entry.sourceId ? u.entry.revision : undefined, digest: u.entry.digest }));
257
+ const reusable = cached?.sessionId === request.sessionId && cached.capability === capability
258
+ && cached.keys.length <= keys.length && cached.keys.every((key, i) => keys[i] === key);
259
+ const install = (summary, selected) => {
260
+ const originals = selected.map(u => u.entry);
261
+ const summaryDigest = digest(originals.map(e => e.digest));
262
+ selected[0].messages = [{ role: 'user', content: JSON.stringify({ contextSummary: {
263
+ trust: 'data', sessionId: request.sessionId, historyDigest, digest: summaryDigest,
264
+ compressor: compressor.id, sources: summarySources(originals, request.messages),
265
+ summary,
266
+ } }) }];
267
+ for (const u of selected.slice(1))
268
+ u.messages = [];
269
+ for (const u of selected) {
270
+ u.entry.action = 'summarized';
271
+ u.entry.reason = 'budget-compression';
272
+ }
273
+ estimatedInput = estimate();
274
+ };
275
+ const originalEstimate = estimatedInput;
276
+ let usedCache = 0;
277
+ let nextCache;
278
+ if (compressor && reusable && cached.keys.length) {
279
+ usedCache = cached.keys.length;
280
+ install(cached.summary, candidates.slice(0, usedCache));
281
+ }
282
+ if (estimatedInput > availableInput || (input.mode === 'manual' && candidates.length > usedCache)) {
283
+ if (compressor && candidates.length) {
284
+ cancelled(signal);
285
+ let summary;
286
+ const groups = candidates.filter(u => u.messages.length).map(u => u.messages);
287
+ try {
288
+ summary = await compressor.summarize(frozen({
289
+ entry: { source: `session:${request.sessionId}`, revision: `sha256:${historyDigest}`,
290
+ digest: digest(candidates.map(u => u.entry.digest)), action: 'retained', reason: 'summary-input' },
291
+ messages: groups.flatMap(group => [...group]), groups,
292
+ }), signal);
293
+ }
294
+ catch (error) {
295
+ cancelled(signal);
296
+ if (error instanceof ContextBuildError)
297
+ throw error;
298
+ const failure = new ContextBuildError('compression_failed', `Context compressor failed: ${compressor.id}`);
299
+ failure.cause = error;
300
+ throw failure;
301
+ }
302
+ cancelled(signal);
303
+ if (!nonempty(summary))
304
+ fail('invalid_summary', 'Compressor must return nonempty plain text');
305
+ if (Buffer.byteLength(summary, 'utf8') > 128 * 1024)
306
+ fail('invalid_summary', 'Summary exceeds the bounded projection text limit');
307
+ install(summary, candidates);
308
+ if (estimatedInput >= originalEstimate)
309
+ throw new ContextBuildError('compression_no_gain', 'Summary did not reduce the full request', report());
310
+ if (estimatedInput <= availableInput)
311
+ nextCache = { sessionId: request.sessionId, capability, sourceSnapshot, keys, summary };
312
+ }
313
+ }
314
+ if (estimatedInput > availableInput)
315
+ throw new ContextBuildError('budget_exceeded', 'Preserved context and request overhead exceed available input budget', report());
316
+ if (size(makeRequest()) > limits.historyBytes || makeRequest().messages.some(m => size(m) > limits.messageBytes))
317
+ throw new ContextBuildError('byte_budget_exceeded', 'Projection exceeds the independent request/message byte ceiling', report());
318
+ // Sources/summarization can await long enough for model selection to change. Do not
319
+ // publish an old budget conclusion or install its cache under a new capability.
320
+ if (options.capacity) {
321
+ let current;
322
+ try {
323
+ current = frozen(await options.capacity.resolve(scope, signal));
324
+ }
325
+ catch {
326
+ cancelled(signal);
327
+ fail('capacity_failed', `Capacity refresh failed: ${options.capacity.id}`);
328
+ }
329
+ cancelled(signal);
330
+ if (digest(current) !== digest(model))
331
+ throw new ContextBuildError('capacity_changed', 'Model capability changed while projecting; no request admitted', report());
332
+ }
333
+ cancelled(signal);
334
+ if (nextCache)
335
+ cached = nextCache;
336
+ return frozen({ request: makeRequest(), report: report() });
337
+ },
338
+ };
339
+ }