@vincemakes/kiso-tools-node 0.47.3 → 0.48.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/dist/corpus.d.ts +12 -0
- package/dist/corpus.js +51 -21
- package/dist/index.d.ts +4 -0
- package/dist/index.js +8 -1
- package/dist/search-worker.js +51 -34
- package/dist/task-runner.js +28 -1
- package/package.json +2 -2
package/dist/corpus.d.ts
CHANGED
|
@@ -101,6 +101,18 @@ export declare function corpusSkips(declared: boolean, layers: readonly Layer[],
|
|
|
101
101
|
/** The layers in force inside `dir`, given its parent's. */
|
|
102
102
|
export declare function layersEntering(dir: string, parent: readonly Layer[]): readonly Layer[];
|
|
103
103
|
export declare function walkCorpus(opts: CorpusOptions): CorpusWalk;
|
|
104
|
+
/** Directory entries by name, in UTF-16 code-unit order — the same on
|
|
105
|
+
* every platform, unlike readdir's own order (name order on APFS and
|
|
106
|
+
* NTFS, hash order on ext4). */
|
|
107
|
+
export declare function byName(a: {
|
|
108
|
+
name: string;
|
|
109
|
+
}, b: {
|
|
110
|
+
name: string;
|
|
111
|
+
}): number;
|
|
112
|
+
/** Workspace-relative POSIX paths by segment, each by code unit: `a/x`
|
|
113
|
+
* before `a-b` before `b`, which is the order a depth-first walk by name
|
|
114
|
+
* lists them in. */
|
|
115
|
+
export declare function bySegments(a: string, b: string): number;
|
|
104
116
|
/** A minimal glob → RegExp, anchored, over workspace-relative POSIX paths.
|
|
105
117
|
*
|
|
106
118
|
* `*` any run except `/`, `?` one character except `/`, `**` any run
|
package/dist/corpus.js
CHANGED
|
@@ -147,24 +147,43 @@ export function walkCorpus(opts) {
|
|
|
147
147
|
let cutByDeadline = false;
|
|
148
148
|
let visited = 0;
|
|
149
149
|
const stopped = () => cutByCap || cutByVisits || cutByDeadline;
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
150
|
+
const start = opts.walkFrom ?? root;
|
|
151
|
+
// entering a subtree, the layers between root and it still apply
|
|
152
|
+
let startLayers = rootLayer === null ? [] : [rootLayer];
|
|
153
|
+
if (start !== root) {
|
|
154
|
+
let cur = root;
|
|
155
|
+
for (const part of relative(root, start).split(sep).filter(Boolean)) {
|
|
156
|
+
cur = join(cur, part);
|
|
157
|
+
startLayers = layersEntering(cur, startLayers);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// 0.48.0 (finding 0472-F2) — BREADTH-FIRST, files before directories.
|
|
161
|
+
// The walk was depth-first in readdir order, so under a budget a deep
|
|
162
|
+
// sibling directory spent it before a file one level up was looked at:
|
|
163
|
+
// `Desktop/devv/` sorts before `Desktop/generations.ts`, and the owner's
|
|
164
|
+
// glob for that file stopped inside devv. Level by level, each
|
|
165
|
+
// directory's entries in name order and its files first, the budget cuts
|
|
166
|
+
// the deepest part of the tree and never a file nearer the root.
|
|
167
|
+
const queue = [{ dir: start, depth: 0, layers: startLayers }];
|
|
168
|
+
for (let next = 0; next < queue.length && !stopped(); next += 1) {
|
|
169
|
+
const { dir, depth, layers } = queue[next];
|
|
153
170
|
if (Date.now() > deadline) {
|
|
154
171
|
cutByDeadline = true;
|
|
155
|
-
|
|
172
|
+
break;
|
|
156
173
|
}
|
|
157
174
|
let entries;
|
|
158
175
|
try {
|
|
159
176
|
entries = readdirSync(dir, { withFileTypes: true });
|
|
160
177
|
}
|
|
161
178
|
catch {
|
|
162
|
-
|
|
179
|
+
continue; // unreadable directory: skipped, as before
|
|
163
180
|
}
|
|
181
|
+
entries.sort(byName);
|
|
164
182
|
const here = depth === 0 ? layers : layersEntering(dir, layers);
|
|
183
|
+
const subdirs = [];
|
|
165
184
|
for (const entry of entries) {
|
|
166
185
|
if (stopped())
|
|
167
|
-
|
|
186
|
+
break;
|
|
168
187
|
const name = entry.name;
|
|
169
188
|
const full = join(dir, name);
|
|
170
189
|
const isDir = entry.isDirectory();
|
|
@@ -177,42 +196,53 @@ export function walkCorpus(opts) {
|
|
|
177
196
|
cutByDepth = true;
|
|
178
197
|
continue;
|
|
179
198
|
}
|
|
180
|
-
|
|
199
|
+
subdirs.push({ dir: full, depth: depth + 1, layers: here });
|
|
181
200
|
continue;
|
|
182
201
|
}
|
|
183
202
|
if (visited >= maxVisited) {
|
|
184
203
|
cutByVisits = true;
|
|
185
|
-
|
|
204
|
+
break;
|
|
186
205
|
}
|
|
187
206
|
visited += 1;
|
|
188
207
|
// the clock is read every 256 files, not per file
|
|
189
208
|
if ((visited & 255) === 0 && Date.now() > deadline) {
|
|
190
209
|
cutByDeadline = true;
|
|
191
|
-
|
|
210
|
+
break;
|
|
192
211
|
}
|
|
193
212
|
const rel = relative(root, full).split(sep).join("/");
|
|
194
213
|
if (opts.accept !== undefined && !opts.accept(rel))
|
|
195
214
|
continue;
|
|
196
215
|
if (files.length >= maxEntries) {
|
|
197
216
|
cutByCap = true;
|
|
198
|
-
|
|
217
|
+
break;
|
|
199
218
|
}
|
|
200
219
|
files.push(rel);
|
|
201
220
|
}
|
|
202
|
-
|
|
203
|
-
const start = opts.walkFrom ?? root;
|
|
204
|
-
// entering a subtree, the layers between root and it still apply
|
|
205
|
-
let startLayers = rootLayer === null ? [] : [rootLayer];
|
|
206
|
-
if (start !== root) {
|
|
207
|
-
let cur = root;
|
|
208
|
-
for (const part of relative(root, start).split(sep).filter(Boolean)) {
|
|
209
|
-
cur = join(cur, part);
|
|
210
|
-
startLayers = layersEntering(cur, startLayers);
|
|
211
|
-
}
|
|
221
|
+
queue.push(...subdirs);
|
|
212
222
|
}
|
|
213
|
-
|
|
223
|
+
// what was walked, in an order that does not depend on the filesystem's
|
|
224
|
+
// readdir: by path segments, which is a depth-first listing by name
|
|
225
|
+
files.sort(bySegments);
|
|
214
226
|
return { files, cutByDepth, cutByCap, cutByVisits, cutByDeadline, visited };
|
|
215
227
|
}
|
|
228
|
+
/** Directory entries by name, in UTF-16 code-unit order — the same on
|
|
229
|
+
* every platform, unlike readdir's own order (name order on APFS and
|
|
230
|
+
* NTFS, hash order on ext4). */
|
|
231
|
+
export function byName(a, b) {
|
|
232
|
+
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
233
|
+
}
|
|
234
|
+
/** Workspace-relative POSIX paths by segment, each by code unit: `a/x`
|
|
235
|
+
* before `a-b` before `b`, which is the order a depth-first walk by name
|
|
236
|
+
* lists them in. */
|
|
237
|
+
export function bySegments(a, b) {
|
|
238
|
+
const x = a.split("/");
|
|
239
|
+
const y = b.split("/");
|
|
240
|
+
for (let i = 0; i < Math.min(x.length, y.length); i += 1) {
|
|
241
|
+
if (x[i] !== y[i])
|
|
242
|
+
return x[i] < y[i] ? -1 : 1;
|
|
243
|
+
}
|
|
244
|
+
return x.length - y.length;
|
|
245
|
+
}
|
|
216
246
|
/** A minimal glob → RegExp, anchored, over workspace-relative POSIX paths.
|
|
217
247
|
*
|
|
218
248
|
* `*` any run except `/`, `?` one character except `/`, `**` any run
|
package/dist/index.d.ts
CHANGED
|
@@ -156,6 +156,10 @@ export interface WorkspaceToolsOptions {
|
|
|
156
156
|
readonly searchMaxMs?: number;
|
|
157
157
|
/** read_file: refuse a file larger than this (default 64 MiB). */
|
|
158
158
|
readonly readMaxFileBytes?: number;
|
|
159
|
+
/** shell, with tasks wired: the least foreground wait (default 60s,
|
|
160
|
+
* the default wait). A shorter `foregroundMs` is raised to it; 0
|
|
161
|
+
* keeps the model's value as asked (ADR-0058 Amendment 10). */
|
|
162
|
+
readonly minForegroundMs?: number;
|
|
159
163
|
};
|
|
160
164
|
}
|
|
161
165
|
export declare function readFileTool(opts: WorkspaceToolsOptions): Tool<{
|
package/dist/index.js
CHANGED
|
@@ -1367,7 +1367,14 @@ export function shellTool(opts) {
|
|
|
1367
1367
|
execute: async ({ command, timeoutMs, foregroundMs, background, readyWhen }, ctx) => {
|
|
1368
1368
|
// the session's tasks, when wired and this call has a session
|
|
1369
1369
|
const tasks = opts.tasks?.(ctx.sessionId);
|
|
1370
|
-
|
|
1370
|
+
// ADR-0058 Amendment 10: with tasks wired a wait shorter than the
|
|
1371
|
+
// floor only moves the command to the background sooner, so the
|
|
1372
|
+
// model may lengthen the wait, never shorten it. Without tasks the
|
|
1373
|
+
// wait is a kill timeout and is kept as asked.
|
|
1374
|
+
const asked = [foregroundMs, timeoutMs].find((v) => typeof v === "number" && Number.isFinite(v));
|
|
1375
|
+
const timeout = tasks !== undefined
|
|
1376
|
+
? Math.max(asked ?? DEFAULT_FOREGROUND_MS, opts.limits?.minForegroundMs ?? DEFAULT_FOREGROUND_MS)
|
|
1377
|
+
: (asked ?? DEFAULT_SHELL_TIMEOUT_MS);
|
|
1371
1378
|
// E group: a PRE-aborted signal never spawns the command.
|
|
1372
1379
|
if (ctx.signal.aborted) {
|
|
1373
1380
|
return { content: "shell aborted before start", isError: true, errorKind: "fatal" };
|
package/dist/search-worker.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { open, readdir } from "node:fs/promises";
|
|
19
19
|
import { basename, join, relative, sep } from "node:path";
|
|
20
|
-
import { corpusSkips, layersEntering, readLayer, walkCorpus } from "./corpus.js";
|
|
20
|
+
import { byName, bySegments, corpusSkips, layersEntering, readLayer, walkCorpus } from "./corpus.js";
|
|
21
21
|
import { isMainThread, parentPort } from "node:worker_threads";
|
|
22
22
|
import { diskPath, isProtectedDiskPath } from "./protected.js";
|
|
23
23
|
/** A scanned file is protected by inode (the store itself, under any name
|
|
@@ -74,7 +74,7 @@ export function runGlob(req) {
|
|
|
74
74
|
}
|
|
75
75
|
export async function runSearch(req) {
|
|
76
76
|
const regex = new RegExp(req.pattern, req.flags);
|
|
77
|
-
const
|
|
77
|
+
const found = [];
|
|
78
78
|
let totalMatches = 0;
|
|
79
79
|
let filesSeen = 0;
|
|
80
80
|
let skippedFiles = 0;
|
|
@@ -136,8 +136,8 @@ export async function runSearch(req) {
|
|
|
136
136
|
// WORKSPACE-RELATIVE, not absolute: `read_file` refuses an
|
|
137
137
|
// absolute path, so an absolute hit here is a result the
|
|
138
138
|
// model cannot feed back without rewriting it by hand.
|
|
139
|
-
if (
|
|
140
|
-
|
|
139
|
+
if (found.length < req.maxMatches)
|
|
140
|
+
found.push({ path: relative(req.workspaceRoot, full) || basename(full), line: i + 1, text: excerptAround(line, regex) });
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
143
|
}
|
|
@@ -149,50 +149,67 @@ export async function runSearch(req) {
|
|
|
149
149
|
// workspace root — not by `.git`, and the walk never goes up.
|
|
150
150
|
const rootLayer = readLayer(req.root);
|
|
151
151
|
const declared = rootLayer !== null;
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const code = err.code;
|
|
161
|
-
if (code === "EACCES" || code === "EPERM") {
|
|
162
|
-
unreadableDirs += 1;
|
|
163
|
-
return;
|
|
164
|
-
}
|
|
165
|
-
throw err;
|
|
166
|
-
}
|
|
167
|
-
const here = depth === 0 ? layers : layersEntering(dir, layers);
|
|
168
|
-
for (const entry of entries) {
|
|
152
|
+
// 0.48.0 (finding 0472-F2) — the same order as `walkCorpus`: breadth-
|
|
153
|
+
// first, each directory's entries by name and its files before any
|
|
154
|
+
// subdirectory, so the budget cuts the deepest part of the tree and
|
|
155
|
+
// never a file nearer the root. Depth-first, a deep sibling directory
|
|
156
|
+
// spent the budget before a shallow file was read.
|
|
157
|
+
const walk = async (start, startLayers) => {
|
|
158
|
+
const queue = [{ dir: start, depth: 0, layers: startLayers }];
|
|
159
|
+
for (let next = 0; next < queue.length; next += 1) {
|
|
169
160
|
if (outOfBudget())
|
|
170
161
|
return;
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
162
|
+
const { dir, depth, layers } = queue[next];
|
|
163
|
+
let entries;
|
|
164
|
+
try {
|
|
165
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
const code = err.code;
|
|
169
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
170
|
+
unreadableDirs += 1;
|
|
178
171
|
continue;
|
|
179
172
|
}
|
|
180
|
-
|
|
173
|
+
throw err;
|
|
174
|
+
}
|
|
175
|
+
entries.sort(byName);
|
|
176
|
+
const here = depth === 0 ? layers : layersEntering(dir, layers);
|
|
177
|
+
for (const entry of entries) {
|
|
178
|
+
if (outOfBudget())
|
|
179
|
+
return;
|
|
180
|
+
const full = join(dir, entry.name);
|
|
181
|
+
const isDir = entry.isDirectory();
|
|
182
|
+
if (corpusSkips(declared, here, full, entry.name, isDir))
|
|
183
|
+
continue;
|
|
184
|
+
if (isDir) {
|
|
185
|
+
if (isExcluded(full)) {
|
|
186
|
+
excludedDirs += 1;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (depth + 1 <= 8)
|
|
190
|
+
queue.push({ dir: full, depth: depth + 1, layers: here });
|
|
191
|
+
}
|
|
192
|
+
else if (entry.isFile())
|
|
193
|
+
await scanFile(full);
|
|
181
194
|
}
|
|
182
|
-
else if (entry.isFile())
|
|
183
|
-
await scanFile(full);
|
|
184
195
|
}
|
|
185
196
|
};
|
|
186
197
|
try {
|
|
187
198
|
if (req.single !== null)
|
|
188
199
|
await scanFile(req.single);
|
|
189
200
|
else
|
|
190
|
-
await walk(req.root,
|
|
201
|
+
await walk(req.root, rootLayer === null ? [] : [rootLayer]);
|
|
191
202
|
}
|
|
192
203
|
catch (err) {
|
|
193
|
-
return { token: req.token, matches, totalMatches, filesSeen, skippedFiles, multiLink, unreadableDirs, excludedDirs, stopped, stoppedAt, error: err.message };
|
|
204
|
+
return { token: req.token, matches: listed(found), totalMatches, filesSeen, skippedFiles, multiLink, unreadableDirs, excludedDirs, stopped, stoppedAt, error: err.message };
|
|
194
205
|
}
|
|
195
|
-
return { token: req.token, matches, totalMatches, filesSeen, skippedFiles, multiLink, unreadableDirs, excludedDirs, stopped, stoppedAt };
|
|
206
|
+
return { token: req.token, matches: listed(found), totalMatches, filesSeen, skippedFiles, multiLink, unreadableDirs, excludedDirs, stopped, stoppedAt };
|
|
207
|
+
}
|
|
208
|
+
/** The matches as the tool prints them: by path segments, then by line —
|
|
209
|
+
* a listing that does not depend on the order the walk read them in. */
|
|
210
|
+
function listed(found) {
|
|
211
|
+
const key = (path) => path.split(sep).join("/");
|
|
212
|
+
return [...found].sort((a, b) => bySegments(key(a.path), key(b.path)) || a.line - b.line).map((m) => `${m.path}:${m.line}: ${m.text}`);
|
|
196
213
|
}
|
|
197
214
|
if (!isMainThread && parentPort !== null) {
|
|
198
215
|
parentPort.once("message", (req) => {
|
package/dist/task-runner.js
CHANGED
|
@@ -69,17 +69,40 @@ function plannedOf(journal) {
|
|
|
69
69
|
throw new Error(`no planned record in ${journal}`);
|
|
70
70
|
}
|
|
71
71
|
function main(dir) {
|
|
72
|
+
// The runner's FIRST act: TERM is handled from here on, by one handler
|
|
73
|
+
// that is never removed. The task reads running the moment
|
|
74
|
+
// command_started is written, so a stop may arrive before the command and
|
|
75
|
+
// its stop exist; without a handler, TERM's default disposition killed
|
|
76
|
+
// the runner with no record, and the task was lost. The handler is
|
|
77
|
+
// dispatched from the event loop, after this synchronous main returns,
|
|
78
|
+
// by which time `stopNow` is set. `heldTerm` is a guard, only reachable
|
|
79
|
+
// if main() ever awaits before the stop exists: such a TERM is held and
|
|
80
|
+
// performed as soon as the stop exists (below). The handler is never
|
|
81
|
+
// swapped: dropping the last listener between two moments would drop a
|
|
82
|
+
// pending signal with it.
|
|
83
|
+
let stopNow = null;
|
|
84
|
+
let heldTerm = false;
|
|
85
|
+
process.on("SIGTERM", () => {
|
|
86
|
+
if (stopNow !== null)
|
|
87
|
+
stopNow();
|
|
88
|
+
else
|
|
89
|
+
heldTerm = true;
|
|
90
|
+
});
|
|
72
91
|
const journal = join(dir, "journal.jsonl");
|
|
73
92
|
const planned = plannedOf(journal);
|
|
74
93
|
// test-only crash points: the process dies right after a record, before
|
|
75
94
|
// the step that record gates
|
|
76
95
|
const dieAfter = process.env.KISO_TASK_RUNNER_DIE_AFTER;
|
|
96
|
+
// test-only: hold the runner right after a record, so a stop can land in
|
|
97
|
+
// the window the record opens
|
|
98
|
+
const pauseAfter = process.env.KISO_TASK_RUNNER_PAUSE_AFTER;
|
|
77
99
|
// test-only: report a stop as unconfirmed (a survivor the platform could
|
|
78
100
|
// not kill cannot be made on purpose)
|
|
79
101
|
const forceUnconfirmed = process.env.KISO_TASK_RUNNER_STOP_UNCONFIRMED === "1";
|
|
80
102
|
const cap = Number(process.env.KISO_TASK_OUTPUT_CAP ?? OUTPUT_CAP_DEFAULT);
|
|
81
103
|
const env = { ...process.env };
|
|
82
104
|
delete env.KISO_TASK_RUNNER_DIE_AFTER;
|
|
105
|
+
delete env.KISO_TASK_RUNNER_PAUSE_AFTER;
|
|
83
106
|
delete env.KISO_TASK_RUNNER_STOP_UNCONFIRMED;
|
|
84
107
|
delete env.KISO_TASK_OUTPUT_CAP;
|
|
85
108
|
// "" when the start time cannot be read: the identity is then the pid
|
|
@@ -91,6 +114,8 @@ function main(dir) {
|
|
|
91
114
|
append(journal, { type: "command_started", ts: Date.now() });
|
|
92
115
|
if (dieAfter === "command_started")
|
|
93
116
|
process.exit(99);
|
|
117
|
+
if (pauseAfter === "command_started")
|
|
118
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 600);
|
|
94
119
|
const out = new RotatingOutput(join(dir, "output.log"), cap);
|
|
95
120
|
let finished = false;
|
|
96
121
|
/** The last record; the output closes first, and nothing is written after. */
|
|
@@ -163,7 +188,9 @@ function main(dir) {
|
|
|
163
188
|
finish({ type: "terminal", ts: Date.now(), exitCode: e?.code ?? null, signal: e?.signal ?? null });
|
|
164
189
|
});
|
|
165
190
|
};
|
|
166
|
-
|
|
191
|
+
stopNow = stop;
|
|
192
|
+
if (heldTerm)
|
|
193
|
+
stop();
|
|
167
194
|
// the journal is the stop channel: re-read only when it grew
|
|
168
195
|
let journalSize = -1;
|
|
169
196
|
const watch = setInterval(() => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincemakes/kiso-tools-node",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.48.0",
|
|
4
4
|
"description": "kiso coding tools for Node hosts \u2014 read file, list directory, search text, write/edit file, shell command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"test": "vitest run"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@vincemakes/kiso-core": "0.
|
|
28
|
+
"@vincemakes/kiso-core": "0.48.0",
|
|
29
29
|
"ignore": "^7.0.9"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|