@zq-silk/yui 2.1.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 (102) hide show
  1. package/README.md +4 -0
  2. package/dist/cli/commandCatalog.js +24 -3
  3. package/dist/cli/commandDiscovery.js +4 -1
  4. package/dist/cli.js +23 -3078
  5. package/dist/commands/taskCommands.js +79 -21
  6. package/dist/commands/taskIntegrationCommands.js +3 -1
  7. package/dist/commands/taskUpstreamCommands.js +3 -1
  8. package/dist/context/runContextPack.js +132 -5
  9. package/dist/context/sourceRunContext.js +4 -2
  10. package/dist/context/taskContext.js +6 -1
  11. package/dist/controlPlaneCli.js +3092 -0
  12. package/dist/controller/fileSchedulerStoreAdapter.js +19 -6
  13. package/dist/controller/jobControl.js +21 -0
  14. package/dist/executor/agentExecutor.js +1 -1
  15. package/dist/executor/effectiveLaunch.js +18 -5
  16. package/dist/executor/fileRoleLaunchPlanner.js +15 -19
  17. package/dist/integration/gitIntegrationService.js +50 -15
  18. package/dist/message/messageContinuation.js +7 -2
  19. package/dist/nativeAgent/agent.js +176 -74
  20. package/dist/nativeAgent/cliDemo.js +37 -0
  21. package/dist/nativeAgent/codingTools.js +11 -0
  22. package/dist/nativeAgent/commandTool.js +215 -0
  23. package/dist/nativeAgent/compactionDemo.js +158 -0
  24. package/dist/nativeAgent/composition.js +71 -0
  25. package/dist/nativeAgent/context/budget.js +44 -0
  26. package/dist/nativeAgent/context/index.js +339 -0
  27. package/dist/nativeAgent/context/providerCompressor.js +103 -0
  28. package/dist/nativeAgent/demo.js +12 -1
  29. package/dist/nativeAgent/evaluation/cases.js +38 -0
  30. package/dist/nativeAgent/evaluation/checks.js +91 -0
  31. package/dist/nativeAgent/evaluation/demo.js +19 -0
  32. package/dist/nativeAgent/evaluation/files.js +54 -0
  33. package/dist/nativeAgent/evaluation/fixture.js +36 -0
  34. package/dist/nativeAgent/evaluation/index.js +239 -0
  35. package/dist/nativeAgent/executionOwner.js +209 -0
  36. package/dist/nativeAgent/filePatterns.js +170 -0
  37. package/dist/nativeAgent/fileToolsSupport.js +202 -0
  38. package/dist/nativeAgent/index.js +13 -0
  39. package/dist/nativeAgent/interaction/cli.js +358 -0
  40. package/dist/nativeAgent/interaction/contracts.js +1 -0
  41. package/dist/nativeAgent/interaction/index.js +3 -0
  42. package/dist/nativeAgent/interaction/memoryDemo.js +115 -0
  43. package/dist/nativeAgent/interaction/renderer.js +34 -0
  44. package/dist/nativeAgent/localSafety.js +258 -0
  45. package/dist/nativeAgent/model/anthropicMessages.js +204 -0
  46. package/dist/nativeAgent/model/chatCompletions.js +210 -0
  47. package/dist/nativeAgent/model/errors.js +47 -0
  48. package/dist/nativeAgent/model/gateway.js +423 -0
  49. package/dist/nativeAgent/model/index.js +7 -0
  50. package/dist/nativeAgent/model/observationAdapter.js +21 -0
  51. package/dist/nativeAgent/model/protocols.js +19 -0
  52. package/dist/nativeAgent/model/responses.js +263 -0
  53. package/dist/nativeAgent/model/types.js +1 -0
  54. package/dist/nativeAgent/model/wire.js +73 -0
  55. package/dist/nativeAgent/observability/index.js +220 -0
  56. package/dist/nativeAgent/product/catalog.js +82 -0
  57. package/dist/nativeAgent/product/config.js +295 -0
  58. package/dist/nativeAgent/product/facts.js +30 -0
  59. package/dist/nativeAgent/product/index.js +62 -0
  60. package/dist/nativeAgent/product/location.js +44 -0
  61. package/dist/nativeAgent/product/runtime.js +276 -0
  62. package/dist/nativeAgent/product/storage.js +49 -0
  63. package/dist/nativeAgent/product/tools.js +47 -0
  64. package/dist/nativeAgent/product/transport.js +54 -0
  65. package/dist/nativeAgent/projectGuidance/index.js +425 -0
  66. package/dist/nativeAgent/searchTools.js +305 -0
  67. package/dist/nativeAgent/session/backends.js +293 -0
  68. package/dist/nativeAgent/session/catalog.js +97 -0
  69. package/dist/nativeAgent/session/catalogDemo.js +87 -0
  70. package/dist/nativeAgent/session/contracts.js +1 -0
  71. package/dist/nativeAgent/session/format.js +269 -0
  72. package/dist/nativeAgent/session/index.js +5 -0
  73. package/dist/nativeAgent/session/location.js +36 -0
  74. package/dist/nativeAgent/session/sqliteFormat.js +134 -0
  75. package/dist/nativeAgent/session/store.js +248 -0
  76. package/dist/nativeAgent/textTools.js +270 -133
  77. package/dist/nativeAgent/toolManager/executor.js +290 -0
  78. package/dist/nativeAgent/toolManager/index.js +4 -0
  79. package/dist/nativeAgent/validation.js +2 -2
  80. package/dist/task/taskAuthority.js +56 -0
  81. package/dist/web/assets/client/app.js +64 -3
  82. package/dist/web/assets/client/detail.js +6 -6
  83. package/dist/web/assets/client/i18n.js +4 -0
  84. package/dist/web/assets/client/overview.js +12 -9
  85. package/dist/web/assets/client/sidebar.js +31 -2
  86. package/dist/web/assets/shell.js +2 -1
  87. package/dist/web/assets/styles/components.js +2 -0
  88. package/dist/web/assets/styles/layout.js +11 -4
  89. package/dist/web/assets/styles/responsive.js +12 -4
  90. package/dist/web/assets/styles/views.js +16 -13
  91. package/docs/agent-result-consumption.md +16 -0
  92. package/docs/agent-result-consumption.zh-CN.md +13 -0
  93. package/docs/examples/agent-offline.mjs +194 -0
  94. package/docs/native-agent.md +283 -0
  95. package/docs/release-workflow.md +47 -9
  96. package/docs/release-workflow.zh-CN.md +36 -6
  97. package/docs/roles-and-configuration.md +32 -0
  98. package/docs/roles-and-configuration.zh-CN.md +26 -0
  99. package/package.json +1 -1
  100. package/skills/yui-leader/SKILL.md +9 -0
  101. package/skills/yui-reviewer/SKILL.md +5 -0
  102. package/skills/yui-runtime/SKILL.md +7 -0
@@ -0,0 +1,97 @@
1
+ import { SessionError, sessionLimits } from './format.js';
2
+ export const catalogLimits = Object.freeze({
3
+ defaultPageSize: 20, pageSize: 100, titleCharacters: 200, cursorBytes: 4096,
4
+ // Every legal event (<=1 MiB) fits with its record wrapper and page envelope.
5
+ historyBytes: 2 * 1024 * 1024,
6
+ });
7
+ const invalid = () => { throw new SessionError('invalid_cursor', 'Invalid cursor for this store, target or page size'); };
8
+ export function pageLimit(options) {
9
+ if (!options || typeof options !== 'object' || Array.isArray(options)
10
+ || Object.keys(options).some(key => !['limit', 'cursor'].includes(key))) {
11
+ throw new SessionError('invalid_query', 'Expected page options containing only limit and cursor');
12
+ }
13
+ const limit = options.limit === undefined ? catalogLimits.defaultPageSize : options.limit;
14
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > catalogLimits.pageSize) {
15
+ throw new SessionError('invalid_query', 'Page limit must be an integer in 1..100');
16
+ }
17
+ return limit;
18
+ }
19
+ export function readCursor(options, store, kind, session) {
20
+ if (options.cursor === undefined)
21
+ return undefined;
22
+ const text = options.cursor;
23
+ if (typeof text !== 'string' || !text.length || Buffer.byteLength(text) > catalogLimits.cursorBytes
24
+ || !/^[A-Za-z0-9_-]+$/.test(text))
25
+ return invalid();
26
+ try {
27
+ const raw = Buffer.from(text, 'base64url');
28
+ if (raw.toString('base64url') !== text)
29
+ return invalid();
30
+ const c = JSON.parse(raw.toString('utf8'));
31
+ if (!c || Array.isArray(c) || c.v !== 1 || c.store !== store || c.kind !== kind
32
+ || c.limit !== pageLimit(options) || !Number.isSafeInteger(c.revision) || c.revision < 0
33
+ || Object.keys(c).some(key => !['v', 'store', 'kind', 'limit', 'revision', 'after', 'session'].includes(key))
34
+ || (kind === 'sessions' ? c.session !== undefined || typeof c.after !== 'string'
35
+ || !c.after.trim() || c.after.length > 256
36
+ : c.session !== session || !Number.isSafeInteger(c.after) || Number(c.after) < 1
37
+ || Number(c.after) >= c.revision))
38
+ return invalid();
39
+ return c;
40
+ }
41
+ catch {
42
+ return invalid();
43
+ }
44
+ }
45
+ export function nextCursor(store, kind, limit, revision, after, session) {
46
+ return Buffer.from(JSON.stringify({ v: 1, store, kind, limit, revision, after,
47
+ ...(session === undefined ? {} : { session }) })).toString('base64url');
48
+ }
49
+ export function currentCursor(cursor, revision) {
50
+ if (cursor && cursor.revision !== revision) {
51
+ throw new SessionError('cursor_stale', 'Saved data changed; restart pagination from the first page');
52
+ }
53
+ }
54
+ export function normalizeTitle(title) {
55
+ if (title === null)
56
+ return null;
57
+ if (typeof title !== 'string')
58
+ throw new SessionError('invalid_title', 'Title must be text or null');
59
+ const text = title.trim();
60
+ if (!text || [...text].length > catalogLimits.titleCharacters || /[\p{Cc}\p{Cs}]/u.test(text)) {
61
+ throw new SessionError('invalid_title', 'Title must contain 1..200 Unicode characters without controls or lone surrogates');
62
+ }
63
+ return text;
64
+ }
65
+ export function metadataRevision(expected) {
66
+ if (!Number.isSafeInteger(expected) || expected < 0) {
67
+ throw new SessionError('revision_conflict', 'Expected metadata revision must be a nonnegative integer');
68
+ }
69
+ }
70
+ export function increment(value) {
71
+ if (!Number.isSafeInteger(value) || value < 0 || value === Number.MAX_SAFE_INTEGER) {
72
+ throw new SessionError('revision_exhausted', 'Stored revision cannot be incremented safely');
73
+ }
74
+ return value + 1;
75
+ }
76
+ export function queryBounds(after, limit) {
77
+ if (!Number.isSafeInteger(after) || after < 0 || !Number.isSafeInteger(limit)
78
+ || limit < 1 || limit > sessionLimits.pageSize) {
79
+ throw new SessionError('invalid_query', 'Cursor must be nonnegative; page limit must be 1..100');
80
+ }
81
+ }
82
+ /** Reads at most limit + 1 individual bounded records, never a whole log.
83
+ * Reserve 64 KiB for escaped identities, file source and the <=4 KiB cursor;
84
+ * this also leaves every legal <=1 MiB event readable in a 2 MiB page. */
85
+ export function historyRecords(after, revision, limit, read) {
86
+ const records = [];
87
+ let bytes = 64 * 1024;
88
+ for (let at = after + 1; at <= revision && records.length < limit; at++) {
89
+ const item = read(at);
90
+ const size = Buffer.byteLength(JSON.stringify(item));
91
+ if (bytes + size > catalogLimits.historyBytes)
92
+ break;
93
+ bytes += size;
94
+ records.push(item);
95
+ }
96
+ return records;
97
+ }
@@ -0,0 +1,87 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { createAgent, createExecutionOwner, createSessionStore, createSqliteSessionBackend, } from '../index.js';
6
+ // Standalone storage/owner evidence, not task-80's product CLI or a live model.
7
+ const directory = await mkdtemp(join(tmpdir(), 'native-catalog-demo-'));
8
+ const filename = join(directory, 'sessions.sqlite');
9
+ let store;
10
+ let owner;
11
+ let modelCalls = 0;
12
+ const openOwner = (sessions) => createExecutionOwner({
13
+ store: sessions, maxSteps: 1,
14
+ agent: recorder => createAgent({
15
+ recorder, tools: [],
16
+ provider: { async complete(request) {
17
+ modelCalls++;
18
+ if (modelCalls === 2)
19
+ assert.deepEqual(request.messages.map(m => 'content' in m ? m.content : ''), ['hello', 'saved answer', 'again']);
20
+ return { kind: 'final', content: modelCalls === 1 ? 'saved answer' : 'continued after restart' };
21
+ } },
22
+ }),
23
+ });
24
+ try {
25
+ store = createSessionStore(createSqliteSessionBackend(filename));
26
+ for (const id of ['c', 'a'])
27
+ await store.create(id); // Old calls explicitly lack location.
28
+ const location = { root: directory, cwd: directory };
29
+ await store.create('b', location); // One atomic creation, never a second metadata write.
30
+ await store.renameSession('a', 'Same title', 0);
31
+ await store.renameSession('b', 'Same title', 0);
32
+ await store.renameSession('c', 'Temporary', 0);
33
+ await store.renameSession('c', null, 1); // Explicitly clear, no history replacement.
34
+ owner = openOwner(store);
35
+ await owner.submit('b', 'hello');
36
+ const evidence = await owner.settle('b');
37
+ assert.equal(evidence?.result?.reason, 'completed');
38
+ await owner.close();
39
+ owner = undefined;
40
+ await store.close();
41
+ store = undefined; // No old database connections survive.
42
+ store = createSessionStore(createSqliteSessionBackend(filename));
43
+ const discovered = [];
44
+ let cursor;
45
+ do {
46
+ const page = await store.listSessions({ limit: 1, cursor });
47
+ discovered.push(...page.items);
48
+ cursor = page.nextCursor ?? undefined;
49
+ } while (cursor);
50
+ assert.deepEqual(discovered.map(s => s.sessionId), ['a', 'b', 'c']);
51
+ assert.equal(discovered.filter(s => s.title === 'Same title').length, 2);
52
+ // Titles are not keys: the caller chooses the displayed exact ID.
53
+ const selected = discovered.find(s => s.sessionId === 'b');
54
+ const detail = await store.getSessionInfo(selected.sessionId);
55
+ assert.deepEqual(detail.location, location);
56
+ assert.equal((await store.getSessionInfo('a')).location, null);
57
+ // Consumer boundary: missing location must not be guessed. Before real execution,
58
+ // task-80 checks filesystem/exact explicit-location conflicts and rebuilds 82/84
59
+ // with fresh authority; these storage reads grant none of that authority.
60
+ assert.equal(detail.digest, evidence?.receipt?.digest);
61
+ const renamed = await store.renameSession(selected.sessionId, 'Chosen session', detail.metadataRevision);
62
+ assert.equal(renamed.revision, detail.revision);
63
+ let historyPages = 0, historyRecords = 0;
64
+ do {
65
+ const page = await store.readHistory(selected.sessionId, { limit: 2, cursor });
66
+ historyPages++;
67
+ historyRecords += page.records.length;
68
+ cursor = page.nextCursor ?? undefined;
69
+ } while (cursor);
70
+ assert.ok(historyPages >= 3);
71
+ assert.equal(historyRecords, detail.revision);
72
+ assert.equal(modelCalls, 1); // Discovery/selection/history never start execution.
73
+ owner = openOwner(store);
74
+ await owner.submit(selected.sessionId, 'again');
75
+ assert.equal((await owner.settle(selected.sessionId))?.result?.reason, 'completed');
76
+ console.log(JSON.stringify({
77
+ discovered, selected: renamed.sessionId, persistedTitle: renamed.title,
78
+ historyPages, historyRecords, receiptPreserved: true,
79
+ allConnectionsClosedBeforeReopen: true, continuedThroughExistingOwner: true, modelCalls,
80
+ persistedLocation: detail.location, legacyLocationExplicitlyMissing: true,
81
+ }, null, 2));
82
+ }
83
+ finally {
84
+ await owner?.close();
85
+ await store?.close();
86
+ await rm(directory, { recursive: true, force: true });
87
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,269 @@
1
+ export const sessionLimits = Object.freeze({
2
+ documentBytes: 16 * 1024 * 1024, eventBytes: 1024 * 1024,
3
+ events: 10_000, pageSize: 100, subscribers: 64,
4
+ });
5
+ export class SessionError extends Error {
6
+ code;
7
+ constructor(code, message, options) {
8
+ super(message, options);
9
+ this.code = code;
10
+ this.name = 'SessionError';
11
+ }
12
+ }
13
+ function requireFact(condition, message) {
14
+ if (!condition)
15
+ throw new SessionError('invalid_session', message);
16
+ }
17
+ export function identity(value) {
18
+ requireFact(typeof value === 'string' && value.trim().length > 0 && value.length <= 256, 'Session, Turn and call identities must be nonempty and at most 256 characters');
19
+ }
20
+ function object(value) {
21
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
22
+ }
23
+ function fields(value, names) {
24
+ requireFact(object(value) && Object.keys(value).every(key => names.includes(key)), 'Invalid record fields');
25
+ }
26
+ function json(value, depth = 0) {
27
+ requireFact(depth <= 64, 'JSON nesting limit exceeded');
28
+ if (value === null || typeof value === 'string' || typeof value === 'boolean')
29
+ return;
30
+ if (typeof value === 'number') {
31
+ requireFact(Number.isFinite(value), 'Nonfinite JSON number');
32
+ return;
33
+ }
34
+ if (Array.isArray(value)) {
35
+ for (const entry of value)
36
+ json(entry, depth + 1);
37
+ return;
38
+ }
39
+ requireFact(object(value) && Object.getPrototypeOf(value) === Object.prototype, 'Expected plain JSON');
40
+ for (const entry of Object.values(value))
41
+ json(entry, depth + 1);
42
+ }
43
+ export function immutable(value) {
44
+ const freeze = (item) => {
45
+ if (item && typeof item === 'object') {
46
+ for (const child of Object.values(item))
47
+ freeze(child);
48
+ Object.freeze(item);
49
+ }
50
+ };
51
+ freeze(value);
52
+ return value;
53
+ }
54
+ export function encode(document) { return JSON.stringify(document); }
55
+ export function revision(document) { return document.events.length; }
56
+ export function copyEvent(value) {
57
+ // The public skeleton emits the optional step as undefined before a Step.
58
+ const candidate = value && object(value.data) && value.data.type === 'message_appended'
59
+ && value.data.step === undefined
60
+ ? { ...value, data: Object.fromEntries(Object.entries(value.data).filter(([key]) => key !== 'step')) }
61
+ : value;
62
+ json(candidate);
63
+ const text = JSON.stringify(candidate);
64
+ requireFact(Buffer.byteLength(text) <= sessionLimits.eventBytes, 'Event byte limit exceeded');
65
+ return JSON.parse(text);
66
+ }
67
+ function outcome(value) {
68
+ requireFact(object(value), 'Invalid tool outcome');
69
+ if (value.ok === true) {
70
+ fields(value, ['ok', 'content']);
71
+ requireFact(typeof value.content === 'string', 'Missing tool content');
72
+ }
73
+ else {
74
+ fields(value, ['ok', 'error']);
75
+ requireFact(value.ok === false, 'Invalid tool outcome status');
76
+ fields(value.error, ['code', 'message', 'effect']);
77
+ identity(value.error.code);
78
+ requireFact(typeof value.error.message === 'string'
79
+ && ['none', 'unknown'].includes(value.error.effect), 'Invalid tool error');
80
+ }
81
+ }
82
+ function message(value) {
83
+ requireFact(object(value), 'Invalid message');
84
+ if (value.role === 'tool') {
85
+ fields(value, ['role', 'toolCallId', 'name', 'outcome']);
86
+ identity(value.toolCallId);
87
+ identity(value.name);
88
+ outcome(value.outcome);
89
+ return;
90
+ }
91
+ requireFact(typeof value.content === 'string', 'Missing message content');
92
+ if (value.role === 'assistant') {
93
+ fields(value, ['role', 'content', 'toolCalls']);
94
+ requireFact(Array.isArray(value.toolCalls) && value.toolCalls.length <= 8, 'Invalid tool batch');
95
+ for (const call of value.toolCalls) {
96
+ fields(call, ['id', 'name', 'arguments']);
97
+ identity(call.id);
98
+ identity(call.name);
99
+ requireFact('arguments' in call && Buffer.byteLength(JSON.stringify(call.arguments)) <= 64 * 1024, 'Missing or oversized tool arguments');
100
+ }
101
+ }
102
+ else {
103
+ fields(value, ['role', 'content']);
104
+ requireFact(value.role === 'system' || value.role === 'user', 'Invalid message role');
105
+ }
106
+ }
107
+ /**
108
+ * Central current-format admission and projection. No heuristic normalization,
109
+ * synthesized results or historical-shape fallback. Future versions migrate here.
110
+ */
111
+ function inspectVersion(value, version) {
112
+ json(value);
113
+ fields(value, ['schemaVersion', 'sessionId', 'events']);
114
+ requireFact(value.schemaVersion === version, `Unsupported Session format; expected version ${version}`);
115
+ identity(value.sessionId);
116
+ requireFact(Array.isArray(value.events) && value.events.length <= sessionLimits.events, 'Event count limit exceeded');
117
+ requireFact(Buffer.byteLength(JSON.stringify(value)) <= sessionLimits.documentBytes, 'Session byte limit exceeded');
118
+ // Detached input protects validation and async backend calls from caller mutation.
119
+ const document = JSON.parse(JSON.stringify(value));
120
+ const messages = [];
121
+ const turns = [];
122
+ const calls = [];
123
+ const turnIds = new Set();
124
+ const callIds = new Set();
125
+ let active;
126
+ let pending = [];
127
+ let userSeen = false;
128
+ let responseSeen = false;
129
+ let finalSeen = false;
130
+ let uncertain = false;
131
+ let cleanupRequired = false;
132
+ for (const [index, event] of document.events.entries()) {
133
+ fields(event, ['sessionId', 'turnId', 'seq', 'data']);
134
+ identity(event.turnId);
135
+ requireFact(event.sessionId === document.sessionId && Number.isSafeInteger(event.seq), 'Event identity or sequence mismatch');
136
+ requireFact(Buffer.byteLength(JSON.stringify(event)) <= sessionLimits.eventBytes, 'Event byte limit exceeded');
137
+ const data = event.data;
138
+ requireFact(object(data), 'Missing event data');
139
+ const at = index + 1;
140
+ if (data.type === 'turn_started') {
141
+ fields(data, ['type']);
142
+ requireFact(!active && !turnIds.has(event.turnId) && event.seq === 1 && !uncertain && !cleanupRequired, 'Cannot start Turn: active, repeated, noninitial sequence or unknown effects require recovery');
143
+ active = { turnId: event.turnId, lastSequence: 1, lastStep: 0 };
144
+ turns.push(active);
145
+ turnIds.add(event.turnId);
146
+ userSeen = false;
147
+ finalSeen = false;
148
+ continue;
149
+ }
150
+ requireFact(active && active.turnId === event.turnId && event.seq === active.lastSequence + 1, 'Fact must extend the active Turn sequence');
151
+ active.lastSequence = event.seq;
152
+ switch (data.type) {
153
+ case 'step_started':
154
+ fields(data, ['type', 'step']);
155
+ requireFact(userSeen && !finalSeen && !uncertain && !cleanupRequired && active.openStep === undefined && !pending.length
156
+ && data.step === active.lastStep + 1, 'Invalid Step start or unsettled calls');
157
+ active.lastStep = data.step;
158
+ active.openStep = data.step;
159
+ responseSeen = false;
160
+ break;
161
+ case 'message_appended': {
162
+ fields(data, version === 1 ? ['type', 'step', 'message'] : ['type', 'step', 'message', 'settlement']);
163
+ message(data.message);
164
+ const msg = data.message;
165
+ requireFact(data.settlement === undefined || msg.role === 'tool', 'Settlement requires a tool result');
166
+ if (msg.role === 'system' || msg.role === 'user') {
167
+ requireFact(data.step === undefined && !active.lastStep && !userSeen, 'Input messages must precede Steps and occur only once per Turn');
168
+ if (msg.role === 'user')
169
+ userSeen = true;
170
+ }
171
+ else {
172
+ requireFact(active.openStep !== undefined && data.step === active.openStep, 'Model/tool message must belong to the open Step');
173
+ if (msg.role === 'assistant') {
174
+ requireFact(!responseSeen && !pending.length, 'Repeated model response or unsettled calls');
175
+ responseSeen = true;
176
+ finalSeen = msg.toolCalls.length === 0;
177
+ for (const call of msg.toolCalls) {
178
+ requireFact(!callIds.has(call.id), 'Repeated tool call identity');
179
+ callIds.add(call.id);
180
+ const record = {
181
+ turnId: event.turnId, step: data.step, call: call,
182
+ callRevision: at, status: 'not-started',
183
+ };
184
+ calls.push(record);
185
+ pending.push(record);
186
+ }
187
+ }
188
+ else if (msg.role === 'tool') {
189
+ const call = pending[0];
190
+ requireFact(call && call.call.id === msg.toolCallId && call.call.name === msg.name, 'Tool result must pair with exactly the next pending call');
191
+ requireFact((!msg.outcome.ok && msg.outcome.error.effect === 'none') || call.startRevision !== undefined, 'Effectful result requires a confirmed write-ahead start');
192
+ requireFact(!uncertain || (!msg.outcome.ok && msg.outcome.error.effect === 'none'
193
+ && call.startRevision === undefined), 'Cannot report new effects after uncertainty');
194
+ call.resultRevision = at;
195
+ call.outcome = msg.outcome;
196
+ if (data.settlement !== undefined) {
197
+ const s = data.settlement;
198
+ fields(s, ['started', 'status', 'cancellationRequested', 'cleanup']);
199
+ requireFact(typeof s.started === 'boolean' && typeof s.cancellationRequested === 'boolean', 'Invalid settlement flags');
200
+ const expected = !s.started ? 'not_executed' : msg.outcome.ok ? 'succeeded'
201
+ : msg.outcome.error.effect === 'unknown' ? 'unknown'
202
+ : msg.outcome.error.code === 'cancelled' ? 'cancelled' : 'failed';
203
+ requireFact(s.status === expected && (!s.started || call.startRevision !== undefined)
204
+ && (s.started || (!msg.outcome.ok && msg.outcome.error.effect === 'none')), 'Settlement contradicts outcome or intent');
205
+ fields(s.cleanup, ['status', 'error']);
206
+ requireFact(['not_acquired', 'released', 'failed', 'acquire_failed'].includes(s.cleanup.status), 'Invalid cleanup status');
207
+ if (s.cleanup.status === 'failed' || s.cleanup.status === 'acquire_failed') {
208
+ fields(s.cleanup.error, ['code', 'message']);
209
+ identity(s.cleanup.error.code);
210
+ requireFact(typeof s.cleanup.error.message === 'string', 'Missing cleanup diagnostic');
211
+ cleanupRequired = true;
212
+ }
213
+ else
214
+ requireFact(!('error' in s.cleanup), 'Unexpected cleanup diagnostic');
215
+ requireFact(!s.started || ['released', 'failed'].includes(s.cleanup.status), 'Started tool must retain acquired-resource settlement');
216
+ call.settlement = data.settlement;
217
+ }
218
+ call.status = !msg.outcome.ok && msg.outcome.error.effect === 'unknown' ? 'unknown' : 'settled';
219
+ uncertain ||= call.status === 'unknown';
220
+ pending.shift();
221
+ }
222
+ }
223
+ messages.push(msg);
224
+ break;
225
+ }
226
+ case 'tool_started': {
227
+ fields(data, ['type', 'step', 'toolCallId', 'name']);
228
+ const call = pending[0];
229
+ requireFact(!uncertain && !cleanupRequired && active.openStep !== undefined && data.step === active.openStep && call
230
+ && call.call.id === data.toolCallId && call.call.name === data.name && call.startRevision === undefined, 'Tool start must match the next unstarted call in the open Step');
231
+ call.startRevision = at;
232
+ call.status = 'unknown';
233
+ break;
234
+ }
235
+ case 'step_ended':
236
+ fields(data, ['type', 'step']);
237
+ requireFact(active.openStep !== undefined && data.step === active.openStep && !pending.length, 'Cannot end Step with unsettled calls or without an open Step');
238
+ delete active.openStep;
239
+ break;
240
+ case 'turn_ended':
241
+ fields(data, ['type', 'reason', 'errorCode']);
242
+ requireFact(active.openStep === undefined && !pending.length, 'Cannot end Turn with open Step or unsettled calls');
243
+ requireFact(['completed', 'cancelled', 'budget_exhausted', 'error'].includes(data.reason)
244
+ && (data.errorCode === undefined || typeof data.errorCode === 'string'), 'Invalid terminal');
245
+ requireFact(data.reason !== 'completed' || finalSeen, 'Completed Turn requires final model response');
246
+ requireFact(!uncertain || data.reason === 'error', 'Unknown effects require error terminal');
247
+ requireFact(!cleanupRequired || data.reason === 'error', 'Unreleased resources require error terminal');
248
+ active.terminal = { reason: data.reason, revision: at,
249
+ ...(data.errorCode === undefined ? {} : { errorCode: data.errorCode }) };
250
+ active = undefined;
251
+ break;
252
+ default:
253
+ throw new SessionError('invalid_session', 'Unsupported event type');
254
+ }
255
+ }
256
+ return { document, messages, recovery: {
257
+ disposition: calls.some(c => c.status === 'unknown') ? 'unknown-effects'
258
+ : cleanupRequired ? 'cleanup-required' : active ? 'interrupted' : 'ready',
259
+ turns, calls,
260
+ } };
261
+ }
262
+ /** Current readers never reinterpret old shapes or repair malformed evidence. */
263
+ export function inspectDocument(value) { return inspectVersion(value, 2); }
264
+ /** The one declared v1 → v2 transition. All original events remain byte-equivalent
265
+ * JSON values; absent historical settlement evidence is never invented. */
266
+ export function migrateSessionDocument(value) {
267
+ inspectVersion(value, 1);
268
+ return inspectDocument({ ...value, schemaVersion: 2 }).document;
269
+ }
@@ -0,0 +1,5 @@
1
+ export { createSessionStore, SessionSaveError, SessionMetadataSaveError } from './store.js';
2
+ export { createMemorySessionBackend, createSqliteSessionBackend } from './backends.js';
3
+ export { SessionError, sessionLimits } from './format.js';
4
+ export { catalogLimits } from './catalog.js';
5
+ export { locationLimits } from './location.js';
@@ -0,0 +1,36 @@
1
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
2
+ import { SessionError } from './format.js';
3
+ export const locationLimits = Object.freeze({ pathBytes: 4096 });
4
+ /** Pure lexical admission. No filesystem lookup, normalization or permission. */
5
+ export function copyLocation(value) {
6
+ const invalid = () => {
7
+ throw new SessionError('invalid_location', 'Expected only resolved absolute root/cwd within root, each at most 4096 UTF-8 bytes');
8
+ };
9
+ if (!value || typeof value !== 'object' || Object.getPrototypeOf(value) !== Object.prototype
10
+ || Reflect.ownKeys(value).length !== 2
11
+ || !Object.hasOwn(value, 'root') || !Object.hasOwn(value, 'cwd'))
12
+ return invalid();
13
+ const { root, cwd } = value;
14
+ for (const path of [root, cwd]) {
15
+ if (typeof path !== 'string' || Buffer.byteLength(path) > locationLimits.pathBytes
16
+ || /[\p{Cc}\p{Cs}]/u.test(path) || !isAbsolute(path) || resolve(path) !== path)
17
+ return invalid();
18
+ }
19
+ const within = relative(root, cwd);
20
+ if (within === '..' || within.startsWith(`..${sep}`) || isAbsolute(within))
21
+ return invalid();
22
+ return { root: root, cwd: cwd };
23
+ }
24
+ export function decodeLocation(text) {
25
+ if (text === null)
26
+ return null;
27
+ try {
28
+ if (typeof text !== 'string' || Buffer.byteLength(text) > 6 * locationLimits.pathBytes + 64) {
29
+ throw new Error('Oversized or non-text location');
30
+ }
31
+ return copyLocation(JSON.parse(text));
32
+ }
33
+ catch (cause) {
34
+ throw new SessionError('corrupt_session', 'Malformed stored location; do not guess a working directory', { cause });
35
+ }
36
+ }
@@ -0,0 +1,134 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { encode, inspectDocument, migrateSessionDocument, revision, SessionError, sessionLimits } from './format.js';
3
+ import { metadataRevision, normalizeTitle } from './catalog.js';
4
+ export const applicationId = 0x4e415331; // NAS1, independent of Yui Home.
5
+ export const oldSchema = 'CREATE TABLE sessions (id TEXT PRIMARY KEY, revision INTEGER NOT NULL, document TEXT NOT NULL, digest TEXT NOT NULL)';
6
+ const layout3 = [
7
+ 'CREATE TABLE sessions (id TEXT PRIMARY KEY, revision INTEGER NOT NULL, document TEXT NOT NULL, digest TEXT NOT NULL, title TEXT, metadata_revision INTEGER NOT NULL)',
8
+ 'CREATE TABLE session_events (session_id TEXT NOT NULL REFERENCES sessions(id), revision INTEGER NOT NULL, event TEXT NOT NULL, digest TEXT NOT NULL, PRIMARY KEY (session_id, revision))',
9
+ 'CREATE TABLE session_catalog (singleton INTEGER PRIMARY KEY CHECK (singleton = 1), id TEXT NOT NULL, revision INTEGER NOT NULL)',
10
+ // Cover every lightweight read without traversing the document's overflow
11
+ // pages. SQLite maintains this index; it is not a separately writable catalog.
12
+ 'CREATE INDEX session_info ON sessions (id, title, metadata_revision, revision, digest)',
13
+ ];
14
+ const tables = [
15
+ 'CREATE TABLE sessions (id TEXT PRIMARY KEY, revision INTEGER NOT NULL, document TEXT NOT NULL, digest TEXT NOT NULL, title TEXT, metadata_revision INTEGER NOT NULL, location TEXT)',
16
+ ...layout3.slice(1, 3),
17
+ 'CREATE INDEX session_info ON sessions (id, title, metadata_revision, revision, digest, location)',
18
+ ];
19
+ export function hash(text) { return createHash('sha256').update(text).digest('hex'); }
20
+ export function digest(document) { return hash(encode(document)); }
21
+ export function decode(row) {
22
+ try {
23
+ if (Buffer.byteLength(row.document) > sessionLimits.documentBytes)
24
+ throw new Error('Oversized document');
25
+ const document = inspectDocument(JSON.parse(row.document)).document;
26
+ if (row.id !== document.sessionId || row.revision !== revision(document) || row.digest !== digest(document)) {
27
+ throw new Error('Stored identity, revision or digest mismatch');
28
+ }
29
+ return document;
30
+ }
31
+ catch (cause) {
32
+ throw new SessionError('corrupt_session', 'Stored Session is malformed; preserve the file for diagnosis', { cause });
33
+ }
34
+ }
35
+ /** One centralized, atomic layout chain: v1 document -> v2 settlement format ->
36
+ * v3 catalog/projection -> v4 immutable creation location.
37
+ * Document v2 and its existing receipts stay intact.
38
+ * This is called only on the caller-selected independent database. */
39
+ export function initializeSessionDatabase(db) {
40
+ db.transaction(() => {
41
+ const id = db.pragma('application_id', { simple: true });
42
+ const version = db.pragma('user_version', { simple: true });
43
+ const objects = db.prepare("SELECT sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' ORDER BY sql").all();
44
+ const sql = objects.map(x => x.sql);
45
+ const unsupported = () => { throw new SessionError('unsupported_format', 'Not a supported Session database layout/version; no migration applied'); };
46
+ const create = () => {
47
+ for (const table of tables)
48
+ db.exec(table);
49
+ db.prepare('INSERT INTO session_catalog VALUES (1, ?, 0)').run(randomUUID());
50
+ };
51
+ if (id === 0 && version === 0 && !objects.length) {
52
+ create();
53
+ db.pragma(`application_id = ${applicationId}`);
54
+ db.pragma('user_version = 4');
55
+ return;
56
+ }
57
+ if (id !== applicationId || ![1, 2, 3, 4].includes(version))
58
+ unsupported();
59
+ if (version === 3 || version === 4) {
60
+ if (JSON.stringify(sql) !== JSON.stringify([...(version === 3 ? layout3 : tables)].sort()))
61
+ unsupported();
62
+ const rows = db.prepare('SELECT id, revision FROM session_catalog').all();
63
+ if (rows.length !== 1 || !/^[0-9a-f-]{36}$/.test(rows[0].id)
64
+ || !Number.isSafeInteger(rows[0].revision) || rows[0].revision < 0)
65
+ unsupported();
66
+ if (version === 4)
67
+ return;
68
+ // No document rewrites: add only missing location, preserve catalog UUID,
69
+ // title CAS, events, document bytes and all existing save receipts.
70
+ db.exec('ALTER TABLE sessions ADD COLUMN location TEXT');
71
+ db.exec('DROP INDEX session_info');
72
+ db.exec(tables[3]);
73
+ const events = db.prepare('SELECT revision, event, digest FROM session_events WHERE session_id = ? ORDER BY revision');
74
+ const count = db.prepare('SELECT count(*) AS n FROM session_events WHERE session_id = ?');
75
+ const next = db.prepare('SELECT * FROM sessions WHERE id > ? COLLATE BINARY ORDER BY id COLLATE BINARY LIMIT 1');
76
+ let row = db.prepare('SELECT * FROM sessions ORDER BY id COLLATE BINARY LIMIT 1').get();
77
+ while (row) {
78
+ const document = decode(row);
79
+ metadataRevision(row.metadata_revision);
80
+ if (normalizeTitle(row.title) !== row.title)
81
+ throw new SessionError('corrupt_session', 'Malformed migrated title');
82
+ if (count.get(row.id).n !== row.revision) {
83
+ throw new SessionError('corrupt_session', 'Malformed migrated history projection');
84
+ }
85
+ let at = 0;
86
+ for (const projected of events.iterate(row.id)) {
87
+ const original = JSON.stringify(document.events[at++]);
88
+ if (projected.revision !== at || projected.event !== original || projected.digest !== hash(original)) {
89
+ throw new SessionError('corrupt_session', 'Migrated history differs from its authoritative document');
90
+ }
91
+ }
92
+ row = next.get(row.id);
93
+ }
94
+ if (db.prepare('SELECT 1 FROM session_events WHERE NOT EXISTS (SELECT 1 FROM sessions WHERE id = session_id) LIMIT 1').get()) {
95
+ throw new SessionError('corrupt_session', 'Orphaned migrated history projection');
96
+ }
97
+ db.pragma('user_version = 4');
98
+ return;
99
+ }
100
+ if (objects.length !== 1 || sql[0] !== oldSchema)
101
+ unsupported();
102
+ // DDL and all data transformations share this transaction. A rejected row or
103
+ // failed insert rolls back even the table rename and the version transition.
104
+ db.exec('ALTER TABLE sessions RENAME TO source_sessions');
105
+ create();
106
+ const insert = db.prepare('INSERT INTO sessions VALUES (?, ?, ?, ?, NULL, 0, NULL)');
107
+ const eventInsert = db.prepare('INSERT INTO session_events VALUES (?, ?, ?, ?)');
108
+ const next = db.prepare('SELECT id, revision, document, digest FROM source_sessions WHERE id > ? COLLATE BINARY ORDER BY id COLLATE BINARY LIMIT 1');
109
+ let row = db.prepare('SELECT id, revision, document, digest FROM source_sessions ORDER BY id COLLATE BINARY LIMIT 1').get();
110
+ while (row) {
111
+ let document;
112
+ if (version === 1) {
113
+ if (Buffer.byteLength(row.document) > sessionLimits.documentBytes)
114
+ throw new SessionError('corrupt_session', 'Oversized version-1 document');
115
+ const original = JSON.parse(row.document);
116
+ if (row.id !== original.sessionId || row.revision !== original.events?.length || row.digest !== hash(JSON.stringify(original))) {
117
+ throw new SessionError('corrupt_session', 'Version-1 identity, revision or digest mismatch');
118
+ }
119
+ document = migrateSessionDocument(original);
120
+ row = { ...row, document: encode(document), digest: digest(document) };
121
+ }
122
+ else
123
+ document = decode(row);
124
+ insert.run(row.id, row.revision, row.document, row.digest);
125
+ for (const [index, event] of document.events.entries()) {
126
+ const text = JSON.stringify(event);
127
+ eventInsert.run(row.id, index + 1, text, hash(text));
128
+ }
129
+ row = next.get(row.id);
130
+ }
131
+ db.exec('DROP TABLE source_sessions');
132
+ db.pragma('user_version = 4');
133
+ }).immediate();
134
+ }