@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.
- package/README.md +4 -0
- package/dist/cli/commandCatalog.js +24 -3
- package/dist/cli/commandDiscovery.js +4 -1
- package/dist/cli.js +23 -3078
- package/dist/commands/taskCommands.js +79 -21
- package/dist/commands/taskIntegrationCommands.js +3 -1
- package/dist/commands/taskUpstreamCommands.js +3 -1
- package/dist/context/runContextPack.js +132 -5
- package/dist/context/sourceRunContext.js +4 -2
- package/dist/context/taskContext.js +6 -1
- package/dist/controlPlaneCli.js +3092 -0
- package/dist/controller/fileSchedulerStoreAdapter.js +19 -6
- package/dist/controller/jobControl.js +21 -0
- package/dist/executor/agentExecutor.js +1 -1
- package/dist/executor/effectiveLaunch.js +18 -5
- package/dist/executor/fileRoleLaunchPlanner.js +15 -19
- package/dist/integration/gitIntegrationService.js +50 -15
- package/dist/message/messageContinuation.js +7 -2
- package/dist/nativeAgent/agent.js +176 -74
- package/dist/nativeAgent/cliDemo.js +37 -0
- package/dist/nativeAgent/codingTools.js +11 -0
- package/dist/nativeAgent/commandTool.js +215 -0
- package/dist/nativeAgent/compactionDemo.js +158 -0
- package/dist/nativeAgent/composition.js +71 -0
- package/dist/nativeAgent/context/budget.js +44 -0
- package/dist/nativeAgent/context/index.js +339 -0
- package/dist/nativeAgent/context/providerCompressor.js +103 -0
- package/dist/nativeAgent/demo.js +12 -1
- package/dist/nativeAgent/evaluation/cases.js +38 -0
- package/dist/nativeAgent/evaluation/checks.js +91 -0
- package/dist/nativeAgent/evaluation/demo.js +19 -0
- package/dist/nativeAgent/evaluation/files.js +54 -0
- package/dist/nativeAgent/evaluation/fixture.js +36 -0
- package/dist/nativeAgent/evaluation/index.js +239 -0
- package/dist/nativeAgent/executionOwner.js +209 -0
- package/dist/nativeAgent/filePatterns.js +170 -0
- package/dist/nativeAgent/fileToolsSupport.js +202 -0
- package/dist/nativeAgent/index.js +13 -0
- package/dist/nativeAgent/interaction/cli.js +358 -0
- package/dist/nativeAgent/interaction/contracts.js +1 -0
- package/dist/nativeAgent/interaction/index.js +3 -0
- package/dist/nativeAgent/interaction/memoryDemo.js +115 -0
- package/dist/nativeAgent/interaction/renderer.js +34 -0
- package/dist/nativeAgent/localSafety.js +258 -0
- package/dist/nativeAgent/model/anthropicMessages.js +204 -0
- package/dist/nativeAgent/model/chatCompletions.js +210 -0
- package/dist/nativeAgent/model/errors.js +47 -0
- package/dist/nativeAgent/model/gateway.js +423 -0
- package/dist/nativeAgent/model/index.js +7 -0
- package/dist/nativeAgent/model/observationAdapter.js +21 -0
- package/dist/nativeAgent/model/protocols.js +19 -0
- package/dist/nativeAgent/model/responses.js +263 -0
- package/dist/nativeAgent/model/types.js +1 -0
- package/dist/nativeAgent/model/wire.js +73 -0
- package/dist/nativeAgent/observability/index.js +220 -0
- package/dist/nativeAgent/product/catalog.js +82 -0
- package/dist/nativeAgent/product/config.js +295 -0
- package/dist/nativeAgent/product/facts.js +30 -0
- package/dist/nativeAgent/product/index.js +62 -0
- package/dist/nativeAgent/product/location.js +44 -0
- package/dist/nativeAgent/product/runtime.js +276 -0
- package/dist/nativeAgent/product/storage.js +49 -0
- package/dist/nativeAgent/product/tools.js +47 -0
- package/dist/nativeAgent/product/transport.js +54 -0
- package/dist/nativeAgent/projectGuidance/index.js +425 -0
- package/dist/nativeAgent/searchTools.js +305 -0
- package/dist/nativeAgent/session/backends.js +293 -0
- package/dist/nativeAgent/session/catalog.js +97 -0
- package/dist/nativeAgent/session/catalogDemo.js +87 -0
- package/dist/nativeAgent/session/contracts.js +1 -0
- package/dist/nativeAgent/session/format.js +269 -0
- package/dist/nativeAgent/session/index.js +5 -0
- package/dist/nativeAgent/session/location.js +36 -0
- package/dist/nativeAgent/session/sqliteFormat.js +134 -0
- package/dist/nativeAgent/session/store.js +248 -0
- package/dist/nativeAgent/textTools.js +270 -133
- package/dist/nativeAgent/toolManager/executor.js +290 -0
- package/dist/nativeAgent/toolManager/index.js +4 -0
- package/dist/nativeAgent/validation.js +2 -2
- package/dist/task/taskAuthority.js +56 -0
- package/dist/web/assets/client/app.js +64 -3
- package/dist/web/assets/client/detail.js +6 -6
- package/dist/web/assets/client/i18n.js +4 -0
- package/dist/web/assets/client/overview.js +12 -9
- package/dist/web/assets/client/sidebar.js +31 -2
- package/dist/web/assets/shell.js +2 -1
- package/dist/web/assets/styles/components.js +2 -0
- package/dist/web/assets/styles/layout.js +11 -4
- package/dist/web/assets/styles/responsive.js +12 -4
- package/dist/web/assets/styles/views.js +16 -13
- package/docs/agent-result-consumption.md +16 -0
- package/docs/agent-result-consumption.zh-CN.md +13 -0
- package/docs/examples/agent-offline.mjs +194 -0
- package/docs/native-agent.md +283 -0
- package/docs/release-workflow.md +47 -9
- package/docs/release-workflow.zh-CN.md +36 -6
- package/docs/roles-and-configuration.md +32 -0
- package/docs/roles-and-configuration.zh-CN.md +26 -0
- package/package.json +1 -1
- package/skills/yui-leader/SKILL.md +9 -0
- package/skills/yui-reviewer/SKILL.md +5 -0
- package/skills/yui-runtime/SKILL.md +7 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { lstat, opendir } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { FileFault, bounded, workspaceRoot, validPath, checkPath, inspect, readSnapshot, digest, fits, cursor, readCursor, lines, isBinary } from './fileToolsSupport.js';
|
|
5
|
+
import { glob, regex } from './filePatterns.js';
|
|
6
|
+
const fail = (code, message) => ({ code, message, effect: 'none' });
|
|
7
|
+
const limit = () => new FileFault('limit_exceeded', 'Scan incomplete: entry/depth/byte budget exceeded; narrow path or query');
|
|
8
|
+
const defaults = { maxEntries: 10_000, maxResults: 200, maxOutputBytes: 64 * 1024,
|
|
9
|
+
maxFileBytes: 8 * 1024 * 1024, maxTotalBytes: 32 * 1024 * 1024, maxDepth: 32, maxPatternWork: 2_000_000 };
|
|
10
|
+
/** Bounded rescans, no index/daemon, no OS sandbox. .git is always excluded. */
|
|
11
|
+
export function createSearchTools(options) {
|
|
12
|
+
const root = workspaceRoot(options.root);
|
|
13
|
+
const limits = { ...defaults };
|
|
14
|
+
for (const key of Object.keys(defaults))
|
|
15
|
+
limits[key] = bounded(options[key], defaults[key], key);
|
|
16
|
+
const validate = (args, name) => {
|
|
17
|
+
if (!args || typeof args !== 'object' || Array.isArray(args))
|
|
18
|
+
return fail('invalid_arguments', 'Expected an argument object');
|
|
19
|
+
if (!validPath(args.path))
|
|
20
|
+
return fail(typeof args.path === 'string' && (path.isAbsolute(args.path)
|
|
21
|
+
|| args.path.split(/[\\/]/).includes('..')) ? 'path_out_of_scope' : 'invalid_arguments', 'Expected a bounded relative path');
|
|
22
|
+
if (Object.keys(args).some(key => !['path', 'query', 'mode', 'cursor', 'ignore', 'generated', 'hidden', 'exclude'].includes(key))
|
|
23
|
+
|| (name === 'list' && (args.query !== undefined || args.mode !== undefined))
|
|
24
|
+
|| (name !== 'list' && (typeof args.query !== 'string' || !args.query || args.query.length > 4096 || /[\0\r\n]/.test(args.query)))
|
|
25
|
+
|| (args.mode !== undefined && !['literal', 'glob', 'regex'].includes(args.mode))
|
|
26
|
+
|| (args.mode === 'regex' && name !== 'search') || (args.mode === 'glob' && name !== 'find')
|
|
27
|
+
|| ['ignore', 'generated', 'hidden'].some(key => args[key] !== undefined && typeof args[key] !== 'boolean')
|
|
28
|
+
|| (args.cursor !== undefined && (typeof args.cursor !== 'string' || !args.cursor || args.cursor.length > 2048))
|
|
29
|
+
|| (args.exclude !== undefined && (!Array.isArray(args.exclude) || args.exclude.length > 16
|
|
30
|
+
|| args.exclude.some(item => typeof item !== 'string')))) {
|
|
31
|
+
return fail('invalid_arguments', 'Expected query mode, optional cursor and explicit discovery policy');
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
};
|
|
35
|
+
return ['list', 'find', 'search'].map(name => ({
|
|
36
|
+
definition: {
|
|
37
|
+
name,
|
|
38
|
+
description: name === 'list' ? 'List immediate entries with bounded pages and explicit discovery coverage'
|
|
39
|
+
: name === 'find' ? 'Find literal basename substrings (default) or root-relative glob patterns; bounded pages bind scan/policy. '
|
|
40
|
+
+ 'Glob (max 256 characters): *, ?, [] ranges/negation, backslash literal escapes outside []; ** only as a whole path segment. '
|
|
41
|
+
+ 'No braces or extglob (@(), +(), ?(), !(), *()). Example: mode="glob", query="**/*.ts".'
|
|
42
|
+
: 'Search literal substrings (default) or bounded single-line regex; matching line snippets, real line numbers, explicit binary/policy coverage. '
|
|
43
|
+
+ 'Regex (max 256 characters): literals, ., [] ranges/^ negation, ^/$ anchors, *+?, \\d/\\w/\\s, escaped metacharacters. '
|
|
44
|
+
+ 'No groups, alternation, counted repeats, flags, lookaround, backreferences or escapes inside []. '
|
|
45
|
+
+ 'Example: mode="regex", query="^TODO\\\\s+.+$" (JSON-escaped).',
|
|
46
|
+
inputSchema: { type: 'object', properties: { path: { type: 'string' },
|
|
47
|
+
...(name !== 'list' ? { query: { type: 'string' }, mode: { type: 'string', enum: name === 'find'
|
|
48
|
+
? ['literal', 'glob'] : ['literal', 'regex'] } } : {}),
|
|
49
|
+
cursor: { type: 'string' }, ignore: { type: 'boolean' }, generated: { type: 'boolean' },
|
|
50
|
+
hidden: { type: 'boolean' }, exclude: { type: 'array', maxItems: 16, items: { type: 'string' } } },
|
|
51
|
+
required: name === 'list' ? ['path'] : ['path', 'query'], additionalProperties: false },
|
|
52
|
+
},
|
|
53
|
+
validate: args => validate(args, name),
|
|
54
|
+
async execute(args, scope, signal) {
|
|
55
|
+
const invalid = validate(args, name);
|
|
56
|
+
if (invalid)
|
|
57
|
+
return { ok: false, error: invalid };
|
|
58
|
+
const request = args;
|
|
59
|
+
try {
|
|
60
|
+
signal.throwIfAborted();
|
|
61
|
+
const start = await checkPath(root, request.path);
|
|
62
|
+
const startInfo = await inspect(start);
|
|
63
|
+
if (path.relative(root, start).split(path.sep).includes('.git')) {
|
|
64
|
+
throw new FileFault('git_metadata_denied', 'Discovery does not enter .git metadata, including explicit paths');
|
|
65
|
+
}
|
|
66
|
+
const budget = { remaining: limits.maxPatternWork };
|
|
67
|
+
const match = request.mode === 'glob' ? glob(request.query) : request.mode === 'regex' ? regex(request.query) : undefined;
|
|
68
|
+
const excludes = (request.exclude ?? []).map(pattern => ({ pattern, match: glob(pattern) }));
|
|
69
|
+
const policy = { ignore: request.ignore ?? true, generated: request.generated ?? true,
|
|
70
|
+
hidden: request.hidden ?? true, exclude: request.exclude ?? [], gitMetadata: false };
|
|
71
|
+
const binding = digest(JSON.stringify([name, request.path, request.query ?? null, request.mode ?? 'literal', policy, limits]));
|
|
72
|
+
// Decode request binding before the scan; fingerprint is checked after the bounded full rescan.
|
|
73
|
+
let offset = 0;
|
|
74
|
+
let previousFingerprint;
|
|
75
|
+
if (request.cursor) {
|
|
76
|
+
let parsed;
|
|
77
|
+
try {
|
|
78
|
+
parsed = JSON.parse(Buffer.from(request.cursor, 'base64url').toString('utf8'));
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
throw new FileFault('invalid_cursor', 'Cursor cannot be decoded');
|
|
82
|
+
}
|
|
83
|
+
if (!parsed || parsed.binding !== binding || typeof parsed.fingerprint !== 'string') {
|
|
84
|
+
throw new FileFault('invalid_cursor', 'Cursor does not match request');
|
|
85
|
+
}
|
|
86
|
+
offset = readCursor(request.cursor, binding, parsed.fingerprint, Number.MAX_SAFE_INTEGER);
|
|
87
|
+
previousFingerprint = parsed.fingerprint;
|
|
88
|
+
}
|
|
89
|
+
const fingerprint = createHash('sha256');
|
|
90
|
+
let scanned = 0;
|
|
91
|
+
let totalBytes = 0;
|
|
92
|
+
let ruleCount = 0;
|
|
93
|
+
let count = 0;
|
|
94
|
+
const skipped = { gitMetadata: 0, ignored: 0, generated: 0, hidden: 0, excluded: 0, binary: 0 };
|
|
95
|
+
const results = [];
|
|
96
|
+
const record = (target, info) => {
|
|
97
|
+
fingerprint.update(JSON.stringify([path.relative(root, target), info.dev, info.ino, info.size, info.mtimeMs, info.ctimeMs]));
|
|
98
|
+
};
|
|
99
|
+
const append = (result) => {
|
|
100
|
+
if (count >= offset && results.length <= limits.maxResults)
|
|
101
|
+
results.push(result);
|
|
102
|
+
count++;
|
|
103
|
+
};
|
|
104
|
+
const loadRules = async (directory, inherited) => {
|
|
105
|
+
let rules = inherited;
|
|
106
|
+
if (directory !== root) {
|
|
107
|
+
try {
|
|
108
|
+
const git = await lstat(path.join(directory, '.git'));
|
|
109
|
+
if (git.isDirectory() || git.isFile())
|
|
110
|
+
rules = []; // Nested repository starts a new ignore scope.
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (error.code !== 'ENOENT')
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (!policy.ignore)
|
|
118
|
+
return rules;
|
|
119
|
+
const ignorePath = path.join(directory, '.gitignore');
|
|
120
|
+
let content;
|
|
121
|
+
try {
|
|
122
|
+
await inspect(ignorePath);
|
|
123
|
+
content = await readSnapshot(ignorePath, Math.min(64 * 1024, limits.maxFileBytes), signal);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
if (error.code === 'ENOENT')
|
|
127
|
+
return rules;
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
totalBytes += content.bytes;
|
|
131
|
+
if (totalBytes > limits.maxTotalBytes)
|
|
132
|
+
throw limit();
|
|
133
|
+
fingerprint.update(ignorePath + content.sha256 + content.identity);
|
|
134
|
+
const base = path.relative(root, directory).split(path.sep).join('/');
|
|
135
|
+
const added = [];
|
|
136
|
+
for (let pattern of content.text.split(/\r?\n/)) {
|
|
137
|
+
if (!pattern || pattern.startsWith('#'))
|
|
138
|
+
continue;
|
|
139
|
+
if (++ruleCount > 1024)
|
|
140
|
+
throw new FileFault('pattern_limit', 'Too many ignore rules');
|
|
141
|
+
const negate = pattern.startsWith('!');
|
|
142
|
+
if (negate)
|
|
143
|
+
pattern = pattern.slice(1);
|
|
144
|
+
const directoryOnly = pattern.endsWith('/');
|
|
145
|
+
if (directoryOnly)
|
|
146
|
+
pattern = pattern.slice(0, -1);
|
|
147
|
+
const anchored = pattern.startsWith('/');
|
|
148
|
+
if (anchored)
|
|
149
|
+
pattern = pattern.slice(1);
|
|
150
|
+
added.push({ base, directory: directoryOnly, negate, basename: !anchored && !pattern.includes('/'), match: glob(pattern) });
|
|
151
|
+
}
|
|
152
|
+
return [...rules, ...added];
|
|
153
|
+
};
|
|
154
|
+
const omitted = (display, basename, directory, rules) => {
|
|
155
|
+
if (basename === '.git') {
|
|
156
|
+
skipped.gitMetadata++;
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
if (!policy.hidden && basename.startsWith('.')) {
|
|
160
|
+
skipped.hidden++;
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
if (policy.generated && directory && ['node_modules', 'dist', 'build', 'coverage'].includes(basename)) {
|
|
164
|
+
skipped.generated++;
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
if (excludes.some(rule => rule.match(rule.pattern.includes('/') ? display : basename, budget))) {
|
|
168
|
+
skipped.excluded++;
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
let ignored = false;
|
|
172
|
+
for (const rule of rules) {
|
|
173
|
+
if (rule.directory && !directory)
|
|
174
|
+
continue;
|
|
175
|
+
if (rule.base && !display.startsWith(rule.base + '/'))
|
|
176
|
+
continue;
|
|
177
|
+
const local = rule.base ? display.slice(rule.base.length + 1) : display;
|
|
178
|
+
if (rule.match(rule.basename ? basename : local, budget))
|
|
179
|
+
ignored = !rule.negate;
|
|
180
|
+
}
|
|
181
|
+
if (ignored)
|
|
182
|
+
skipped.ignored++;
|
|
183
|
+
return ignored;
|
|
184
|
+
};
|
|
185
|
+
const fileResult = async (target, display) => {
|
|
186
|
+
const info = await inspect(target);
|
|
187
|
+
record(target, info);
|
|
188
|
+
if (name === 'find') {
|
|
189
|
+
if (match ? match(display, budget) : path.basename(target).includes(request.query))
|
|
190
|
+
append({ path: display });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (name === 'list') {
|
|
194
|
+
append({ path: display, type: 'file' });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (info.size > limits.maxFileBytes || info.size + totalBytes > limits.maxTotalBytes)
|
|
198
|
+
throw limit();
|
|
199
|
+
const snapshot = await readSnapshot(target, limits.maxFileBytes, signal);
|
|
200
|
+
totalBytes += snapshot.bytes;
|
|
201
|
+
if (totalBytes > limits.maxTotalBytes)
|
|
202
|
+
throw limit();
|
|
203
|
+
fingerprint.update(snapshot.sha256 + snapshot.identity);
|
|
204
|
+
if (isBinary(snapshot.text)) {
|
|
205
|
+
skipped.binary++;
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
let line = 0;
|
|
209
|
+
let byteStart = 0;
|
|
210
|
+
for (const raw of lines(snapshot.text)) {
|
|
211
|
+
signal.throwIfAborted();
|
|
212
|
+
line++;
|
|
213
|
+
const text = raw.endsWith('\n') ? raw.slice(0, raw.endsWith('\r\n') ? -2 : -1) : raw;
|
|
214
|
+
const matches = match ? match(text, budget) : text.includes(request.query);
|
|
215
|
+
if (matches) {
|
|
216
|
+
// Long matching lines are previews, not silently complete text.
|
|
217
|
+
let end = Math.min(text.length, Math.max(1, Math.floor(limits.maxOutputBytes / 8)));
|
|
218
|
+
if (/[\uDC00-\uDFFF]/.test(text[end] ?? ''))
|
|
219
|
+
end--;
|
|
220
|
+
const snippet = text.slice(0, end);
|
|
221
|
+
append(end === text.length ? { path: display, line, text } : { path: display, line, text: snippet,
|
|
222
|
+
textTruncated: true, lineBytes: Buffer.byteLength(text), byteStart, sha256: snapshot.sha256 });
|
|
223
|
+
}
|
|
224
|
+
byteStart += Buffer.byteLength(raw);
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
const scan = async (directory, depth, inherited) => {
|
|
228
|
+
signal.throwIfAborted();
|
|
229
|
+
if (depth > limits.maxDepth)
|
|
230
|
+
throw limit();
|
|
231
|
+
const before = await inspect(directory);
|
|
232
|
+
record(directory, before);
|
|
233
|
+
const rules = await loadRules(directory, inherited);
|
|
234
|
+
const names = [];
|
|
235
|
+
const dir = await opendir(directory);
|
|
236
|
+
for await (const entry of dir) {
|
|
237
|
+
if (++scanned > limits.maxEntries)
|
|
238
|
+
throw limit();
|
|
239
|
+
names.push(entry.name);
|
|
240
|
+
}
|
|
241
|
+
names.sort();
|
|
242
|
+
for (const basename of names) {
|
|
243
|
+
signal.throwIfAborted();
|
|
244
|
+
const target = path.join(directory, basename);
|
|
245
|
+
const info = await lstat(target);
|
|
246
|
+
const display = path.relative(root, target).split(path.sep).join('/');
|
|
247
|
+
record(target, info);
|
|
248
|
+
if (omitted(display, basename, info.isDirectory(), rules))
|
|
249
|
+
continue;
|
|
250
|
+
await inspect(target);
|
|
251
|
+
if (info.isDirectory()) {
|
|
252
|
+
if (name === 'list')
|
|
253
|
+
append({ path: display, type: 'directory' });
|
|
254
|
+
else
|
|
255
|
+
await scan(target, depth + 1, rules);
|
|
256
|
+
}
|
|
257
|
+
else
|
|
258
|
+
await fileResult(target, display);
|
|
259
|
+
}
|
|
260
|
+
const after = await inspect(directory);
|
|
261
|
+
if (before.ino !== after.ino || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) {
|
|
262
|
+
throw new FileFault('edit_conflict', 'Directory changed during scan');
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
let inherited = [];
|
|
266
|
+
const relativeParts = path.relative(root, start).split(path.sep).filter(Boolean);
|
|
267
|
+
let ancestor = root;
|
|
268
|
+
for (const part of startInfo.isDirectory() ? relativeParts : []) {
|
|
269
|
+
inherited = await loadRules(ancestor, inherited);
|
|
270
|
+
ancestor = path.join(ancestor, part);
|
|
271
|
+
}
|
|
272
|
+
if (startInfo.isDirectory())
|
|
273
|
+
await scan(start, 0, inherited);
|
|
274
|
+
else
|
|
275
|
+
await fileResult(start, path.relative(root, start).split(path.sep).join('/')); // Explicit files bypass discovery filters.
|
|
276
|
+
signal.throwIfAborted();
|
|
277
|
+
const stamp = fingerprint.digest('hex');
|
|
278
|
+
if (previousFingerprint !== undefined && previousFingerprint !== stamp)
|
|
279
|
+
throw new FileFault('stale_cursor', 'Scan changed; restart query');
|
|
280
|
+
if (offset > count)
|
|
281
|
+
throw new FileFault('invalid_cursor', 'Cursor exceeds results');
|
|
282
|
+
const page = results.slice(0, limits.maxResults);
|
|
283
|
+
const make = () => {
|
|
284
|
+
const complete = offset + page.length === count;
|
|
285
|
+
return { ok: true, content: JSON.stringify({ results: page, complete, scanComplete: true,
|
|
286
|
+
nextCursor: complete ? null : cursor(binding, stamp, offset + page.length),
|
|
287
|
+
coverage: { complete: Object.values(skipped).every(value => value === 0), policy },
|
|
288
|
+
skipped, budgets: { entries: scanned, bytes: totalBytes, patternWork: limits.maxPatternWork - budget.remaining },
|
|
289
|
+
truncationReason: complete ? null : page.length < limits.maxResults ? 'output_bytes' : 'result_limit' }) };
|
|
290
|
+
};
|
|
291
|
+
while (page.length && !fits(make(), limits.maxOutputBytes, name, scope.toolCallId))
|
|
292
|
+
page.pop();
|
|
293
|
+
if ((offset < count && !page.length) || !fits(make(), limits.maxOutputBytes, name, scope.toolCallId)) {
|
|
294
|
+
throw new FileFault('limit_exceeded', 'Output cannot hold one result plus coverage metadata; increase budget or narrow path');
|
|
295
|
+
}
|
|
296
|
+
return make();
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
const code = error instanceof FileFault ? error.code === 'too_large' ? 'limit_exceeded' : error.code
|
|
300
|
+
: signal.aborted ? 'cancelled' : error.code === 'ENOENT' ? 'not_found' : 'io_error';
|
|
301
|
+
return { ok: false, error: fail(code, error instanceof FileFault ? error.message : `Scan incomplete (${code})`) };
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
}));
|
|
305
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
4
|
+
import { existsSync, lstatSync } from 'node:fs';
|
|
5
|
+
import { copyEvent, encode, identity, inspectDocument, revision, SessionError, sessionLimits } from './format.js';
|
|
6
|
+
import { currentCursor, historyRecords, increment, metadataRevision, nextCursor, normalizeTitle, pageLimit, queryBounds, readCursor, } from './catalog.js';
|
|
7
|
+
import { decode, digest, hash, initializeSessionDatabase } from './sqliteFormat.js';
|
|
8
|
+
import { copyLocation, decodeLocation } from './location.js';
|
|
9
|
+
export { digest } from './sqliteFormat.js';
|
|
10
|
+
function transition(previous, next, expected) {
|
|
11
|
+
if (expected === null ? previous !== null : !previous || revision(previous) !== expected) {
|
|
12
|
+
throw new SessionError('revision_conflict', 'Session revision conflict; reload, do not replay uncertain effects');
|
|
13
|
+
}
|
|
14
|
+
if (expected === null ? next.events.length !== 0
|
|
15
|
+
: next.events.length !== expected + 1
|
|
16
|
+
|| JSON.stringify(next.events.slice(0, -1)) !== JSON.stringify(previous.events)) {
|
|
17
|
+
throw new SessionError('invalid_append', 'Write must create an empty Session or append exactly one immutable fact');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function missing() { throw new SessionError('not_found', 'Session not found'); }
|
|
21
|
+
function compareIds(a, b) { return Buffer.compare(Buffer.from(a), Buffer.from(b)); }
|
|
22
|
+
function seek(ids, after) {
|
|
23
|
+
let lo = 0, hi = ids.length;
|
|
24
|
+
while (lo < hi) {
|
|
25
|
+
const mid = (lo + hi) >>> 1;
|
|
26
|
+
if (compareIds(ids[mid], after) <= 0)
|
|
27
|
+
lo = mid + 1;
|
|
28
|
+
else
|
|
29
|
+
hi = mid;
|
|
30
|
+
}
|
|
31
|
+
return lo;
|
|
32
|
+
}
|
|
33
|
+
export function createMemorySessionBackend() {
|
|
34
|
+
const source = Object.freeze({ id: `memory:${randomUUID()}`, durability: 'volatile' });
|
|
35
|
+
const storeId = randomUUID();
|
|
36
|
+
const documents = new Map();
|
|
37
|
+
const metadata = new Map();
|
|
38
|
+
const ids = [];
|
|
39
|
+
let catalogRevision = 0;
|
|
40
|
+
let closed = false;
|
|
41
|
+
const check = () => { if (closed)
|
|
42
|
+
throw new SessionError('closed', 'Session backend is closed'); };
|
|
43
|
+
const info = (sessionId) => {
|
|
44
|
+
check();
|
|
45
|
+
identity(sessionId);
|
|
46
|
+
const stored = documents.get(sessionId) ?? missing();
|
|
47
|
+
return { ...metadata.get(sessionId), revision: revision(stored.document), digest: stored.digest, source, storeId,
|
|
48
|
+
location: stored.location === null ? null : { ...stored.location } };
|
|
49
|
+
};
|
|
50
|
+
const query = (sessionId, after, limit) => {
|
|
51
|
+
queryBounds(after, limit);
|
|
52
|
+
const detail = info(sessionId);
|
|
53
|
+
if (after > detail.revision)
|
|
54
|
+
throw new SessionError('invalid_query', 'Cursor exceeds saved revision');
|
|
55
|
+
const records = historyRecords(after, detail.revision, limit, at => ({
|
|
56
|
+
revision: at, event: structuredClone(documents.get(sessionId).document.events[at - 1]),
|
|
57
|
+
}));
|
|
58
|
+
const end = records.at(-1)?.revision ?? after;
|
|
59
|
+
return { sessionId, revision: detail.revision, digest: detail.digest, source, records,
|
|
60
|
+
nextCursor: end < detail.revision ? end : null };
|
|
61
|
+
};
|
|
62
|
+
const write = (document, expectedRevision, location = null) => {
|
|
63
|
+
check();
|
|
64
|
+
const next = inspectDocument(document).document;
|
|
65
|
+
const previous = documents.get(next.sessionId);
|
|
66
|
+
transition(previous?.document ?? null, next, expectedRevision);
|
|
67
|
+
const nextDigest = digest(next);
|
|
68
|
+
if (expectedRevision === null) {
|
|
69
|
+
const nextRevision = increment(catalogRevision);
|
|
70
|
+
metadata.set(next.sessionId, { sessionId: next.sessionId, title: null, metadataRevision: 0 });
|
|
71
|
+
ids.splice(seek(ids, next.sessionId), 0, next.sessionId);
|
|
72
|
+
catalogRevision = nextRevision;
|
|
73
|
+
}
|
|
74
|
+
documents.set(next.sessionId, { document: next, digest: nextDigest, location: previous?.location ?? location });
|
|
75
|
+
};
|
|
76
|
+
return {
|
|
77
|
+
source,
|
|
78
|
+
catalog: {
|
|
79
|
+
async getSessionInfo(sessionId) { return info(sessionId); },
|
|
80
|
+
async listSessions(options = {}) {
|
|
81
|
+
check();
|
|
82
|
+
const limit = pageLimit(options);
|
|
83
|
+
const cursor = readCursor(options, storeId, 'sessions');
|
|
84
|
+
currentCursor(cursor, catalogRevision);
|
|
85
|
+
if (cursor && !metadata.has(cursor.after)) {
|
|
86
|
+
throw new SessionError('invalid_cursor', 'Cursor does not identify a saved Session');
|
|
87
|
+
}
|
|
88
|
+
const start = cursor ? seek(ids, cursor.after) : 0;
|
|
89
|
+
const selected = ids.slice(start, start + limit);
|
|
90
|
+
return { storeId, catalogRevision, items: selected.map(id => ({ ...metadata.get(id) })),
|
|
91
|
+
nextCursor: start + selected.length < ids.length
|
|
92
|
+
? nextCursor(storeId, 'sessions', limit, catalogRevision, selected.at(-1)) : null };
|
|
93
|
+
},
|
|
94
|
+
async renameSession(sessionId, title, expected) {
|
|
95
|
+
const detail = info(sessionId);
|
|
96
|
+
const nextTitle = normalizeTitle(title);
|
|
97
|
+
metadataRevision(expected);
|
|
98
|
+
if (detail.metadataRevision !== expected)
|
|
99
|
+
throw new SessionError('revision_conflict', 'Metadata revision conflict; read current title');
|
|
100
|
+
if (detail.title !== nextTitle) {
|
|
101
|
+
const nextRevision = increment(catalogRevision);
|
|
102
|
+
metadata.set(sessionId, { sessionId, title: nextTitle, metadataRevision: increment(expected) });
|
|
103
|
+
catalogRevision = nextRevision;
|
|
104
|
+
}
|
|
105
|
+
return info(sessionId);
|
|
106
|
+
},
|
|
107
|
+
async readHistory(sessionId, options = {}) {
|
|
108
|
+
check();
|
|
109
|
+
identity(sessionId);
|
|
110
|
+
const limit = pageLimit(options);
|
|
111
|
+
const cursor = readCursor(options, storeId, 'history', sessionId);
|
|
112
|
+
const detail = info(sessionId);
|
|
113
|
+
currentCursor(cursor, detail.revision);
|
|
114
|
+
const page = query(sessionId, cursor ? cursor.after : 0, limit);
|
|
115
|
+
return { ...page, storeId, nextCursor: page.nextCursor === null ? null
|
|
116
|
+
: nextCursor(storeId, 'history', limit, detail.revision, page.nextCursor, sessionId) };
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
async query(sessionId, options) { check(); return query(sessionId, options.after, options.limit); },
|
|
120
|
+
async read(sessionId) {
|
|
121
|
+
check();
|
|
122
|
+
identity(sessionId);
|
|
123
|
+
return structuredClone(documents.get(sessionId)?.document ?? null);
|
|
124
|
+
},
|
|
125
|
+
async write(document, expectedRevision) {
|
|
126
|
+
write(document, expectedRevision);
|
|
127
|
+
},
|
|
128
|
+
async createWithLocation(document, location) { write(document, null, copyLocation(location)); },
|
|
129
|
+
async close() { closed = true; documents.clear(); metadata.clear(); ids.length = 0; },
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/** Caller-selected local file; no Yui Home discovery, global connection or daemon. */
|
|
133
|
+
export function createSqliteSessionBackend(filename) {
|
|
134
|
+
if (!isAbsolute(filename) || (existsSync(filename)
|
|
135
|
+
&& (lstatSync(filename).isSymbolicLink() || !lstatSync(filename).isFile()))) {
|
|
136
|
+
throw new SessionError('invalid_path', 'An absolute controlled regular-file path is required');
|
|
137
|
+
}
|
|
138
|
+
const db = new Database(filename, { timeout: 1000 });
|
|
139
|
+
try {
|
|
140
|
+
db.pragma('synchronous = FULL');
|
|
141
|
+
const journal = db.pragma('journal_mode', { simple: true });
|
|
142
|
+
if (journal !== 'delete')
|
|
143
|
+
throw new SessionError('unsupported_format', 'Session database requires DELETE journal mode');
|
|
144
|
+
initializeSessionDatabase(db);
|
|
145
|
+
}
|
|
146
|
+
catch (cause) {
|
|
147
|
+
db.close();
|
|
148
|
+
throw cause;
|
|
149
|
+
}
|
|
150
|
+
let closed = false;
|
|
151
|
+
const check = () => { if (closed)
|
|
152
|
+
throw new SessionError('closed', 'Session backend is closed'); };
|
|
153
|
+
const source = Object.freeze({ id: `sqlite:${resolve(filename)}`, durability: 'persistent' });
|
|
154
|
+
const catalogRow = db.prepare('SELECT id, revision FROM session_catalog WHERE singleton = 1');
|
|
155
|
+
const catalogState = () => {
|
|
156
|
+
const state = catalogRow.get();
|
|
157
|
+
if (!state || !Number.isSafeInteger(state.revision) || state.revision < 0) {
|
|
158
|
+
throw new SessionError('corrupt_session', 'Invalid catalog state');
|
|
159
|
+
}
|
|
160
|
+
return state;
|
|
161
|
+
};
|
|
162
|
+
const storeId = catalogState().id;
|
|
163
|
+
const readInfo = db.prepare('SELECT id, revision, digest, title, metadata_revision, location FROM sessions INDEXED BY session_info WHERE id = ?');
|
|
164
|
+
const detail = (row) => {
|
|
165
|
+
if (!Number.isSafeInteger(row.revision) || row.revision < 0 || row.revision > sessionLimits.events
|
|
166
|
+
|| !Number.isSafeInteger(row.metadata_revision) || row.metadata_revision < 0
|
|
167
|
+
|| normalizeTitle(row.title) !== row.title || !/^[0-9a-f]{64}$/.test(row.digest)) {
|
|
168
|
+
throw new SessionError('corrupt_session', 'Malformed Session summary');
|
|
169
|
+
}
|
|
170
|
+
return { sessionId: row.id, revision: row.revision, digest: row.digest, title: row.title,
|
|
171
|
+
metadataRevision: row.metadata_revision, storeId, source, location: decodeLocation(row.location) };
|
|
172
|
+
};
|
|
173
|
+
const info = (id) => { identity(id); return detail(readInfo.get(id) ?? missing()); };
|
|
174
|
+
const readRow = db.prepare('SELECT id, revision, document, digest FROM sessions WHERE id = ?');
|
|
175
|
+
const readEvent = db.prepare('SELECT event, digest FROM session_events WHERE session_id = ? AND revision = ?');
|
|
176
|
+
const query = (sessionId, after, limit) => {
|
|
177
|
+
queryBounds(after, limit);
|
|
178
|
+
const saved = info(sessionId);
|
|
179
|
+
if (after > saved.revision)
|
|
180
|
+
throw new SessionError('invalid_query', 'Cursor exceeds the saved revision');
|
|
181
|
+
const records = historyRecords(after, saved.revision, limit, at => {
|
|
182
|
+
const row = readEvent.get(sessionId, at);
|
|
183
|
+
if (!row || Buffer.byteLength(row.event) > sessionLimits.eventBytes || hash(row.event) !== row.digest) {
|
|
184
|
+
throw new SessionError('corrupt_session', 'Missing or corrupt history projection; preserve the file');
|
|
185
|
+
}
|
|
186
|
+
const event = copyEvent(JSON.parse(row.event));
|
|
187
|
+
if (event.sessionId !== sessionId)
|
|
188
|
+
throw new SessionError('corrupt_session', 'History identity mismatch');
|
|
189
|
+
return { revision: at, event };
|
|
190
|
+
});
|
|
191
|
+
const end = records.at(-1)?.revision ?? after;
|
|
192
|
+
return { sessionId, revision: saved.revision, digest: saved.digest, source, records,
|
|
193
|
+
nextCursor: end < saved.revision ? end : null };
|
|
194
|
+
};
|
|
195
|
+
const listFirst = db.prepare('SELECT id, title, metadata_revision FROM sessions INDEXED BY session_info ORDER BY id COLLATE BINARY LIMIT ?');
|
|
196
|
+
const listNext = db.prepare('SELECT id, title, metadata_revision FROM sessions INDEXED BY session_info WHERE id > ? COLLATE BINARY ORDER BY id COLLATE BINARY LIMIT ?');
|
|
197
|
+
const bumpCatalog = () => db.prepare('UPDATE session_catalog SET revision = ? WHERE singleton = 1').run(increment(catalogState().revision));
|
|
198
|
+
const insert = db.prepare('INSERT INTO sessions VALUES (?, ?, ?, ?, NULL, 0, ?)');
|
|
199
|
+
const update = db.prepare('UPDATE sessions SET revision = ?, document = ?, digest = ? WHERE id = ?');
|
|
200
|
+
const insertEvent = db.prepare('INSERT INTO session_events VALUES (?, ?, ?, ?)');
|
|
201
|
+
const write = db.transaction((next, expected, location = null) => {
|
|
202
|
+
const row = readRow.get(next.sessionId);
|
|
203
|
+
transition(row ? decode(row) : null, next, expected);
|
|
204
|
+
if (row) {
|
|
205
|
+
update.run(revision(next), encode(next), digest(next), next.sessionId);
|
|
206
|
+
const text = JSON.stringify(next.events.at(-1));
|
|
207
|
+
insertEvent.run(next.sessionId, revision(next), text, hash(text));
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
insert.run(next.sessionId, revision(next), encode(next), digest(next), location === null ? null : JSON.stringify(location));
|
|
211
|
+
bumpCatalog();
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
const catalog = {
|
|
215
|
+
async getSessionInfo(sessionId) { check(); return info(sessionId); },
|
|
216
|
+
async listSessions(options = {}) {
|
|
217
|
+
check();
|
|
218
|
+
return db.transaction(() => {
|
|
219
|
+
const limit = pageLimit(options);
|
|
220
|
+
const state = catalogState();
|
|
221
|
+
const cursor = readCursor(options, storeId, 'sessions');
|
|
222
|
+
currentCursor(cursor, state.revision);
|
|
223
|
+
if (cursor && !readInfo.get(cursor.after)) {
|
|
224
|
+
throw new SessionError('invalid_cursor', 'Cursor does not identify a saved Session');
|
|
225
|
+
}
|
|
226
|
+
const rows = (cursor ? listNext.all(cursor.after, limit + 1) : listFirst.all(limit + 1));
|
|
227
|
+
const selected = rows.slice(0, limit);
|
|
228
|
+
const items = selected.map(row => {
|
|
229
|
+
identity(row.id);
|
|
230
|
+
metadataRevision(row.metadata_revision);
|
|
231
|
+
if (normalizeTitle(row.title) !== row.title)
|
|
232
|
+
throw new SessionError('corrupt_session', 'Invalid stored title');
|
|
233
|
+
return { sessionId: row.id, title: row.title, metadataRevision: row.metadata_revision };
|
|
234
|
+
});
|
|
235
|
+
return { storeId, catalogRevision: state.revision, items,
|
|
236
|
+
nextCursor: rows.length > limit ? nextCursor(storeId, 'sessions', limit, state.revision, items.at(-1).sessionId) : null };
|
|
237
|
+
})();
|
|
238
|
+
},
|
|
239
|
+
async renameSession(sessionId, title, expected) {
|
|
240
|
+
check();
|
|
241
|
+
identity(sessionId);
|
|
242
|
+
const nextTitle = normalizeTitle(title);
|
|
243
|
+
metadataRevision(expected);
|
|
244
|
+
return db.transaction(() => {
|
|
245
|
+
const current = info(sessionId);
|
|
246
|
+
if (current.metadataRevision !== expected)
|
|
247
|
+
throw new SessionError('revision_conflict', 'Metadata revision conflict; read current title');
|
|
248
|
+
if (current.title !== nextTitle) {
|
|
249
|
+
db.prepare('UPDATE sessions SET title = ?, metadata_revision = ? WHERE id = ?')
|
|
250
|
+
.run(nextTitle, increment(expected), sessionId);
|
|
251
|
+
bumpCatalog();
|
|
252
|
+
}
|
|
253
|
+
return info(sessionId);
|
|
254
|
+
}).immediate();
|
|
255
|
+
},
|
|
256
|
+
async readHistory(sessionId, options = {}) {
|
|
257
|
+
check();
|
|
258
|
+
identity(sessionId);
|
|
259
|
+
return db.transaction(() => {
|
|
260
|
+
const limit = pageLimit(options);
|
|
261
|
+
const cursor = readCursor(options, storeId, 'history', sessionId);
|
|
262
|
+
const saved = info(sessionId);
|
|
263
|
+
currentCursor(cursor, saved.revision);
|
|
264
|
+
const page = query(sessionId, cursor ? cursor.after : 0, limit);
|
|
265
|
+
return { ...page, storeId, nextCursor: page.nextCursor === null ? null
|
|
266
|
+
: nextCursor(storeId, 'history', limit, saved.revision, page.nextCursor, sessionId) };
|
|
267
|
+
})();
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
return {
|
|
271
|
+
source, catalog,
|
|
272
|
+
async query(sessionId, options) { check(); return db.transaction(() => query(sessionId, options.after, options.limit))(); },
|
|
273
|
+
async read(sessionId) {
|
|
274
|
+
check();
|
|
275
|
+
identity(sessionId);
|
|
276
|
+
const row = readRow.get(sessionId);
|
|
277
|
+
return row ? decode(row) : null;
|
|
278
|
+
},
|
|
279
|
+
async write(document, expectedRevision) {
|
|
280
|
+
check();
|
|
281
|
+
write.immediate(inspectDocument(document).document, expectedRevision);
|
|
282
|
+
},
|
|
283
|
+
async createWithLocation(document, location) {
|
|
284
|
+
check();
|
|
285
|
+
const selected = copyLocation(location);
|
|
286
|
+
write.immediate(inspectDocument(document).document, null, selected);
|
|
287
|
+
},
|
|
288
|
+
async close() { if (!closed) {
|
|
289
|
+
db.close();
|
|
290
|
+
closed = true;
|
|
291
|
+
} },
|
|
292
|
+
};
|
|
293
|
+
}
|