@vincemakes/kiso-tools-node 0.1.26 → 0.1.27

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/index.d.ts CHANGED
@@ -10,6 +10,11 @@
10
10
  * only medium). No shell flags the kernel doesn't need: `shell` carries an
11
11
  * explicit timeout and an output cap so a runaway command cannot flood the
12
12
  * context.
13
+ *
14
+ * Token 轮: reads are RANGEABLE (read_file offset/limit, default head 200
15
+ * lines) and search/list are capped (50 / 200) — every truncation carries
16
+ * an actionable continuation note (deterministic per file state), so the
17
+ * model always has a path to the full content.
13
18
  */
14
19
  import { type Tool, type ToolResult } from "@vincemakes/kiso-core";
15
20
  /**
@@ -54,6 +59,8 @@ export interface WorkspaceToolsOptions {
54
59
  }
55
60
  export declare function readFileTool(opts: WorkspaceToolsOptions): Tool<{
56
61
  path: string;
62
+ offset?: number;
63
+ limit?: number;
57
64
  }>;
58
65
  export declare function listDirTool(opts: WorkspaceToolsOptions): Tool<{
59
66
  path?: string;
package/dist/index.js CHANGED
@@ -10,6 +10,11 @@
10
10
  * only medium). No shell flags the kernel doesn't need: `shell` carries an
11
11
  * explicit timeout and an output cap so a runaway command cannot flood the
12
12
  * context.
13
+ *
14
+ * Token 轮: reads are RANGEABLE (read_file offset/limit, default head 200
15
+ * lines) and search/list are capped (50 / 200) — every truncation carries
16
+ * an actionable continuation note (deterministic per file state), so the
17
+ * model always has a path to the full content.
13
18
  */
14
19
  import { execFileSync, spawn } from "node:child_process";
15
20
  import { chmodSync, existsSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
@@ -17,6 +22,14 @@ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:pat
17
22
  import { defineTool } from "@vincemakes/kiso-core";
18
23
  const OUTPUT_CAP = 100_000; // chars of output a tool result may carry
19
24
  const DEFAULT_SHELL_TIMEOUT_MS = 30_000;
25
+ // Token 轮: the scoped-read defaults — read_file shows the head 200 lines
26
+ // of a large file (with an actionable continuation note, never a silent
27
+ // drop), search_text caps at 50 excerpts, list_dir at 200 entries. The
28
+ // red line: every truncation names its continuation — the model always
29
+ // has a path to the full content.
30
+ const DEFAULT_READ_LINES = 200;
31
+ const MAX_SEARCH_MATCHES = 50;
32
+ const MAX_DIR_ENTRIES = 200;
20
33
  function cap(text) {
21
34
  return text.length > OUTPUT_CAP ? `${text.slice(0, OUTPUT_CAP)}\n…[truncated]` : text;
22
35
  }
@@ -156,24 +169,92 @@ function inodeReadPolicy(root, full) {
156
169
  }
157
170
  return null;
158
171
  }
172
+ /** The "… N more lines" note — the actionable continuation: the exact
173
+ * line the next read must start at, so the model can always reach the
174
+ * full content in ranges (the red line). */
175
+ function moreLinesNote(nextOffset, remaining) {
176
+ return `\n… ${remaining} more ${remaining === 1 ? "line" : "lines"} (call again with offset=${nextOffset})`;
177
+ }
159
178
  export function readFileTool(opts) {
160
179
  return defineTool({
161
180
  name: "read_file",
162
- description: "Read a file's content from disk. Relative to the workspace root.",
181
+ description: "Read a file's content from disk. Relative to the workspace root. Returns the first 200 lines by default; a file with more lines appends a note with the exact count and the offset to continue from. Pass offset (1-based first line) and/or limit (line count) to read a range.",
163
182
  parameters: {
164
183
  type: "object",
165
- properties: { path: { type: "string", description: "Workspace-relative path of the file to read" } },
184
+ properties: {
185
+ path: { type: "string", description: "Workspace-relative path of the file to read" },
186
+ offset: { type: "number", description: "1-based first line to read (default: 1)" },
187
+ limit: { type: "number", description: "Maximum number of lines to read (default: to the end of the file)" },
188
+ },
166
189
  required: ["path"],
167
190
  },
168
191
  idempotent: true,
169
- execute: async ({ path }) => {
192
+ execute: async ({ path, offset, limit }) => {
170
193
  try {
171
194
  const full = resolveWithinRoot(opts.workspaceRoot, path);
172
195
  const denied = inodeReadPolicy(opts.workspaceRoot, full);
173
196
  if (denied !== null)
174
197
  return escapeResult(denied);
175
198
  const content = readFileSync(full, "utf8");
176
- return { content: cap(content), isError: false };
199
+ // The lines the file DISPLAYS: a trailing newline's empty split
200
+ // element is not a line. Line k = split[k-1], 1-based.
201
+ const parts = content.split("\n");
202
+ const total = content.endsWith("\n") ? parts.length - 1 : parts.length;
203
+ const badCount = (v, name) => typeof v !== "number" || !Number.isInteger(v) || v < 1
204
+ ? `read_file: ${name} must be a positive integer (got ${JSON.stringify(v)})`
205
+ : undefined;
206
+ if (offset !== undefined) {
207
+ const bad = badCount(offset, "offset");
208
+ if (bad !== undefined)
209
+ return { content: bad, isError: true, errorKind: "invalid_input" };
210
+ }
211
+ if (limit !== undefined) {
212
+ const bad = badCount(limit, "limit");
213
+ if (bad !== undefined)
214
+ return { content: bad, isError: true, errorKind: "invalid_input" };
215
+ }
216
+ const start = offset ?? 1;
217
+ if (start > total) {
218
+ return {
219
+ content: `read_file: offset=${start} is past the end of ${path} (${total} lines)`,
220
+ isError: true,
221
+ errorKind: "invalid_input",
222
+ };
223
+ }
224
+ const end = limit === undefined ? total : Math.min(start + limit - 1, total);
225
+ // DEFAULT: the head 200 lines; a larger file ends with the
226
+ // honest continuation note (small files ≤ 200 lines are
227
+ // byte-identical to the pre-token-round behavior).
228
+ let text;
229
+ let note = "";
230
+ if (offset === undefined && limit === undefined) {
231
+ text = total <= DEFAULT_READ_LINES ? content : parts.slice(0, DEFAULT_READ_LINES).join("\n");
232
+ if (total > DEFAULT_READ_LINES)
233
+ note = moreLinesNote(DEFAULT_READ_LINES + 1, total - DEFAULT_READ_LINES);
234
+ }
235
+ else {
236
+ text = parts.slice(start - 1, end).join("\n");
237
+ if (end < total)
238
+ note = moreLinesNote(end + 1, total - end);
239
+ }
240
+ // The output cap's cut must STAY actionable: cut at a line
241
+ // boundary and name the exact next offset (the generic cap()
242
+ // would leave the model blind mid-file).
243
+ if (text.length > OUTPUT_CAP) {
244
+ const cut = text.lastIndexOf("\n", OUTPUT_CAP);
245
+ if (cut > 0) {
246
+ text = text.slice(0, cut);
247
+ note = `\n… [output capped at ${OUTPUT_CAP} chars — continue with offset=${start + text.split("\n").length}]` + note;
248
+ }
249
+ else {
250
+ // The first 100000 chars are one line (or a blank line
251
+ // then one): offset ranges cannot split it — shell is
252
+ // the only honest path.
253
+ text = text.slice(0, OUTPUT_CAP);
254
+ note = `\n… [a line near ${path}:${start} exceeds the ${OUTPUT_CAP}-char cap — slice it with shell]` + note;
255
+ }
256
+ }
257
+ return { content: `${text}${note}`, isError: false };
177
258
  }
178
259
  catch (err) {
179
260
  if (err instanceof PathEscapeError)
@@ -186,7 +267,7 @@ export function readFileTool(opts) {
186
267
  export function listDirTool(opts) {
187
268
  return defineTool({
188
269
  name: "list_dir",
189
- description: "List the entries of a directory. Omit path to list the workspace root.",
270
+ description: "List the entries of a directory. Omit path to list the workspace root. Capped at 200 entries with an overflow note (narrow to a subdirectory for more).",
190
271
  parameters: {
191
272
  type: "object",
192
273
  properties: { path: { type: "string", description: "Workspace-relative directory to list" } },
@@ -199,7 +280,11 @@ export function listDirTool(opts) {
199
280
  const isDir = e.isDirectory();
200
281
  return `${isDir ? "dir " : "file"} ${e.name}${isDir ? "/" : ""}`;
201
282
  });
202
- return { content: entries.length ? cap(entries.join("\n")) : "(empty directory)", isError: false };
283
+ let content = entries.length ? cap(entries.slice(0, MAX_DIR_ENTRIES).join("\n")) : "(empty directory)";
284
+ if (entries.length > MAX_DIR_ENTRIES) {
285
+ content += `\n… +${entries.length - MAX_DIR_ENTRIES} more entries (narrow to a subdirectory)`;
286
+ }
287
+ return { content, isError: false };
203
288
  }
204
289
  catch (err) {
205
290
  if (err instanceof PathEscapeError)
@@ -212,7 +297,7 @@ export function listDirTool(opts) {
212
297
  export function searchTextTool(opts) {
213
298
  return defineTool({
214
299
  name: "search_text",
215
- description: "Search files under a workspace directory (recursive) for a regular expression. Returns matching file:line excerpts, capped.",
300
+ description: "Search files under a workspace directory (recursive) for a regular expression. Returns matching file:line excerpts, capped at 50 — an overflow note states the count of further matches (narrow the pattern to see them).",
216
301
  parameters: {
217
302
  type: "object",
218
303
  properties: {
@@ -233,9 +318,13 @@ export function searchTextTool(opts) {
233
318
  throw err;
234
319
  }
235
320
  const regex = new RegExp(pattern, "i");
321
+ // The walk NEVER early-aborts on the cap: the overflow note's count
322
+ // must be the file-true total, not a bound (the red line). The
323
+ // depth cap and the node_modules/dotfile skip stay.
236
324
  const matches = [];
325
+ let totalMatches = 0;
237
326
  const walk = (dir, depth) => {
238
- if (depth > 8 || matches.length > 200)
327
+ if (depth > 8)
239
328
  return;
240
329
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
241
330
  if (entry.name.startsWith(".") || entry.name === "node_modules")
@@ -257,9 +346,10 @@ export function searchTextTool(opts) {
257
346
  const text = readFileSync(full, "utf8");
258
347
  for (const [i, line] of text.split("\n").entries()) {
259
348
  if (regex.test(line)) {
260
- matches.push(`${full}:${i + 1}: ${line.trim().slice(0, 160)}`);
261
- if (matches.length >= 200)
262
- return;
349
+ totalMatches += 1;
350
+ if (matches.length < MAX_SEARCH_MATCHES) {
351
+ matches.push(`${full}:${i + 1}: ${line.trim().slice(0, 160)}`);
352
+ }
263
353
  }
264
354
  }
265
355
  }
@@ -275,7 +365,10 @@ export function searchTextTool(opts) {
275
365
  catch (err) {
276
366
  return { content: `search_text failed: ${err.message}`, isError: true, errorKind: "fatal" };
277
367
  }
278
- return { content: matches.length ? cap(matches.join("\n")) : "(no matches)", isError: false };
368
+ let content = matches.length ? cap(matches.join("\n")) : "(no matches)";
369
+ if (totalMatches > matches.length)
370
+ content += `\n… +${totalMatches - matches.length} more matches (narrow the pattern)`;
371
+ return { content, isError: false };
279
372
  },
280
373
  });
281
374
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tools-node",
3
- "version": "0.1.26",
4
- "description": "kiso coding tools for Node hosts \u2014 read file, list directory, search text, write/edit file, shell command.",
3
+ "version": "0.1.27",
4
+ "description": "kiso coding tools for Node hosts read file, list directory, search text, write/edit file, shell command.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "exports": {
@@ -21,7 +21,7 @@
21
21
  "test": "vitest run"
22
22
  },
23
23
  "dependencies": {
24
- "@vincemakes/kiso-core": "0.1.26"
24
+ "@vincemakes/kiso-core": "0.1.27"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^26.1.2",