tinker-agent 1.0.65

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 (110) hide show
  1. package/README.md +173 -0
  2. package/package.json +78 -0
  3. package/patches/markdansi@0.3.2.patch +37 -0
  4. package/src/agent/context-builder.ts +43 -0
  5. package/src/agent/context-meter.ts +310 -0
  6. package/src/agent/loop.ts +525 -0
  7. package/src/agent/runtime-session.ts +1212 -0
  8. package/src/agent/session-ledger.ts +828 -0
  9. package/src/agent/turn-cancellation.ts +44 -0
  10. package/src/agent/types.ts +77 -0
  11. package/src/cli/config.ts +283 -0
  12. package/src/cli/index.ts +29 -0
  13. package/src/cli/model-profiles.ts +289 -0
  14. package/src/cli/run-runner.ts +107 -0
  15. package/src/cli/tui-runner.tsx +290 -0
  16. package/src/context/compiled-context-hash.ts +138 -0
  17. package/src/context/compiled-context-validator.ts +209 -0
  18. package/src/context/context-manager.ts +362 -0
  19. package/src/context/context-policy.ts +8 -0
  20. package/src/context/context-protocol-validator.ts +463 -0
  21. package/src/context/context-revision-compiler.ts +281 -0
  22. package/src/context/context-revision.ts +111 -0
  23. package/src/context/context-source.ts +30 -0
  24. package/src/context/context-swap-renderer.ts +272 -0
  25. package/src/context/protocol-frame.ts +240 -0
  26. package/src/context/swap-planner.ts +725 -0
  27. package/src/events/append-private-file.ts +16 -0
  28. package/src/events/bash-result-detail.ts +70 -0
  29. package/src/events/composite-event-sink.ts +82 -0
  30. package/src/events/event-sink.ts +16 -0
  31. package/src/events/jsonl-event-log.ts +13 -0
  32. package/src/events/observation-text-log.ts +195 -0
  33. package/src/events/stdout-event-printer.ts +396 -0
  34. package/src/events/types.ts +263 -0
  35. package/src/ids/runtime-id.ts +68 -0
  36. package/src/ids/uuid-v7.ts +5 -0
  37. package/src/instructions/project-instructions.ts +242 -0
  38. package/src/mcp/mcp-config.ts +144 -0
  39. package/src/mcp/mcp-manager.ts +216 -0
  40. package/src/mcp/mcp-tool-executor.ts +178 -0
  41. package/src/model/committed-prefix-auditor.ts +68 -0
  42. package/src/model/fake-model-client.ts +280 -0
  43. package/src/model/model-client.ts +64 -0
  44. package/src/model/model-context-profile.ts +134 -0
  45. package/src/model/model-request-preflight.ts +120 -0
  46. package/src/model/openai-chat-mapping.ts +444 -0
  47. package/src/model/openai-chat-model-client.ts +190 -0
  48. package/src/model/prompt-prefix-hash.ts +47 -0
  49. package/src/model/token-estimator.ts +148 -0
  50. package/src/observation/observation-builder.ts +481 -0
  51. package/src/session/resume-projection.ts +616 -0
  52. package/src/session/session-catalog.ts +270 -0
  53. package/src/session/session-errors.ts +121 -0
  54. package/src/session/session-history-reader.ts +535 -0
  55. package/src/session/session-lock.ts +291 -0
  56. package/src/session/session-schema.ts +741 -0
  57. package/src/session/session-store.ts +3067 -0
  58. package/src/session/sqlite-session-ledger.ts +153 -0
  59. package/src/tools/bash-task.ts +617 -0
  60. package/src/tools/bash.ts +450 -0
  61. package/src/tools/cwd-state.ts +22 -0
  62. package/src/tools/edit.ts +428 -0
  63. package/src/tools/file-diff.ts +116 -0
  64. package/src/tools/glob.ts +202 -0
  65. package/src/tools/grep.ts +550 -0
  66. package/src/tools/hash.ts +9 -0
  67. package/src/tools/path-safety.ts +33 -0
  68. package/src/tools/read.ts +319 -0
  69. package/src/tools/recall.ts +400 -0
  70. package/src/tools/registry.ts +213 -0
  71. package/src/tools/ripgrep.ts +220 -0
  72. package/src/tools/task-list.ts +59 -0
  73. package/src/tools/task-output-snapshot.ts +47 -0
  74. package/src/tools/task-output-tool.ts +62 -0
  75. package/src/tools/task-output.ts +159 -0
  76. package/src/tools/task-stop.ts +59 -0
  77. package/src/tools/task-tool-args.ts +29 -0
  78. package/src/tools/types.ts +330 -0
  79. package/src/tools/web-fetch/backend.ts +27 -0
  80. package/src/tools/web-fetch/browser-backend.ts +126 -0
  81. package/src/tools/web-fetch/exa-backend.ts +172 -0
  82. package/src/tools/web-fetch/index.ts +298 -0
  83. package/src/tools/web-fetch/local-backend.ts +267 -0
  84. package/src/tools/web-fetch/refiner.ts +78 -0
  85. package/src/tools/web-fetch/route.ts +95 -0
  86. package/src/tools/web-search.ts +300 -0
  87. package/src/tools/write.ts +244 -0
  88. package/src/tui/app.tsx +497 -0
  89. package/src/tui/components/assistant-markdown.tsx +47 -0
  90. package/src/tui/components/background-tasks.tsx +92 -0
  91. package/src/tui/components/bash-result-view.tsx +47 -0
  92. package/src/tui/components/context-status.tsx +127 -0
  93. package/src/tui/components/diff-view.tsx +151 -0
  94. package/src/tui/components/file-viewer.tsx +212 -0
  95. package/src/tui/components/footer.tsx +60 -0
  96. package/src/tui/components/header.tsx +21 -0
  97. package/src/tui/components/model-picker.tsx +142 -0
  98. package/src/tui/components/prompt-input.tsx +432 -0
  99. package/src/tui/components/resume-session-picker.tsx +273 -0
  100. package/src/tui/components/timeline.tsx +121 -0
  101. package/src/tui/context-format.ts +24 -0
  102. package/src/tui/event-store.ts +865 -0
  103. package/src/tui/git-branch.ts +23 -0
  104. package/src/tui/line-editor.ts +157 -0
  105. package/src/tui/prompt-history.ts +94 -0
  106. package/src/tui/slash-commands.ts +126 -0
  107. package/src/tui/tui-projection-policy.ts +35 -0
  108. package/src/tui/tui-projection-store.ts +123 -0
  109. package/src/tui/tui-session-controller.ts +170 -0
  110. package/src/tui/view-file.ts +122 -0
@@ -0,0 +1,550 @@
1
+ import path from "node:path";
2
+ import { stat } from "node:fs/promises";
3
+ import { throwIfTurnCancelled } from "../agent/turn-cancellation";
4
+ import { isWorkspaceLocalCwd, type CwdState } from "./cwd-state";
5
+ import { resolveWorkspacePath, toDisplayPath } from "./path-safety";
6
+ import { ripGrep } from "./ripgrep";
7
+ import { defineToolExecutor } from "./types";
8
+ import type {
9
+ GrepOutputMode,
10
+ GrepRawResult,
11
+ ToolExecutionContext,
12
+ ToolExecutor,
13
+ } from "./types";
14
+
15
+ type GrepArgs = {
16
+ pattern: string;
17
+ path?: string;
18
+ glob?: string;
19
+ output_mode?: GrepOutputMode;
20
+ before?: number;
21
+ after?: number;
22
+ contextAlias?: number;
23
+ context?: number;
24
+ lineNumbers?: boolean;
25
+ caseInsensitive?: boolean;
26
+ type?: string;
27
+ head_limit?: number;
28
+ offset?: number;
29
+ multiline?: boolean;
30
+ };
31
+
32
+ export type GrepToolOptions = {
33
+ workspaceRoot: string;
34
+ cwdState: CwdState;
35
+ };
36
+
37
+ const ignoredDirectories = [
38
+ ".git",
39
+ ".svn",
40
+ ".hg",
41
+ ".bzr",
42
+ ".jj",
43
+ ".sl",
44
+ "node_modules",
45
+ ".tinker",
46
+ ];
47
+
48
+ const defaultHeadLimit = 250;
49
+
50
+ export function createGrepToolExecutor(options: GrepToolOptions): ToolExecutor {
51
+ return defineToolExecutor("grep", {
52
+ definition: {
53
+ name: "Grep",
54
+ description: "Search file contents with ripgrep.",
55
+ parameters: {
56
+ type: "object",
57
+ additionalProperties: false,
58
+ properties: {
59
+ pattern: {
60
+ type: "string",
61
+ description:
62
+ "The regular expression pattern to search for in file contents.",
63
+ },
64
+ path: {
65
+ type: "string",
66
+ description:
67
+ "Optional workspace-relative or absolute file or directory to search in. Defaults to the current workspace-local cwd.",
68
+ },
69
+ glob: {
70
+ type: "string",
71
+ description: 'Glob pattern to filter files, such as "*.js" or "**/*.tsx".',
72
+ },
73
+ output_mode: {
74
+ type: "string",
75
+ enum: ["content", "files_with_matches", "count"],
76
+ description: 'Output mode. Defaults to "files_with_matches".',
77
+ },
78
+ "-B": {
79
+ type: "integer",
80
+ minimum: 0,
81
+ description:
82
+ 'Number of lines to show before each match. Only applies to output_mode="content".',
83
+ },
84
+ "-A": {
85
+ type: "integer",
86
+ minimum: 0,
87
+ description:
88
+ 'Number of lines to show after each match. Only applies to output_mode="content".',
89
+ },
90
+ "-C": {
91
+ type: "integer",
92
+ minimum: 0,
93
+ description: "Alias for context.",
94
+ },
95
+ context: {
96
+ type: "integer",
97
+ minimum: 0,
98
+ description:
99
+ 'Number of lines to show before and after each match. Only applies to output_mode="content".',
100
+ },
101
+ "-n": {
102
+ type: "boolean",
103
+ description: "Show line numbers in content output. Defaults to true.",
104
+ },
105
+ "-i": {
106
+ type: "boolean",
107
+ description: "Case insensitive search.",
108
+ },
109
+ type: {
110
+ type: "string",
111
+ description: "File type to search, such as js, ts, py, rust, go, or java.",
112
+ },
113
+ head_limit: {
114
+ type: "integer",
115
+ minimum: 0,
116
+ description:
117
+ "Limit output to first N lines or entries. Defaults to 250. Pass 0 for unlimited.",
118
+ },
119
+ offset: {
120
+ type: "integer",
121
+ minimum: 0,
122
+ description:
123
+ "Skip first N lines or entries before applying head_limit. Defaults to 0.",
124
+ },
125
+ multiline: {
126
+ type: "boolean",
127
+ description:
128
+ "Enable multiline mode where dot matches newlines. Defaults to false.",
129
+ },
130
+ },
131
+ required: ["pattern"],
132
+ },
133
+ },
134
+ async execute(args, _call, context: ToolExecutionContext): Promise<GrepRawResult> {
135
+ throwIfTurnCancelled(context.signal);
136
+ const parsed = parseGrepArgs(args);
137
+
138
+ if (!parsed.ok) {
139
+ return grepFailure({
140
+ pattern: parsed.pattern ?? "",
141
+ searchPath: ".",
142
+ mode: parsed.mode ?? "files_with_matches",
143
+ error: parsed.error,
144
+ });
145
+ }
146
+
147
+ const input = parsed.value;
148
+ const mode = input.output_mode ?? "files_with_matches";
149
+
150
+ let absoluteSearchPath: string;
151
+ try {
152
+ absoluteSearchPath = resolveSearchPath(options, input.path);
153
+ } catch (error) {
154
+ return grepFailure({
155
+ pattern: input.pattern,
156
+ searchPath: input.path ?? ".",
157
+ mode,
158
+ error: errorMessage(error),
159
+ });
160
+ }
161
+
162
+ const searchPath = toDisplayPath(options.workspaceRoot, absoluteSearchPath);
163
+
164
+ const pathCheck = await ensureFileOrDirectory(absoluteSearchPath);
165
+ throwIfTurnCancelled(context.signal);
166
+ if (!pathCheck.ok) {
167
+ return grepFailure({
168
+ pattern: input.pattern,
169
+ searchPath,
170
+ absoluteSearchPath,
171
+ mode,
172
+ error: pathCheck.error,
173
+ });
174
+ }
175
+
176
+ const rgArgs = buildRipgrepArgs(input, mode, absoluteSearchPath);
177
+ const rg = await ripGrep(rgArgs, { signal: context.signal });
178
+
179
+ if (!rg.ok) {
180
+ return grepFailure({
181
+ pattern: input.pattern,
182
+ searchPath,
183
+ absoluteSearchPath,
184
+ mode,
185
+ truncated: rg.truncated ? true : undefined,
186
+ error: rg.error ?? "ripgrep failed.",
187
+ });
188
+ }
189
+
190
+ const base = {
191
+ ok: true as const,
192
+ pattern: input.pattern,
193
+ searchPath,
194
+ absoluteSearchPath,
195
+ mode,
196
+ ignored: ignoredDirectories,
197
+ };
198
+ const partialWarning = rg.truncated ? rg.error : undefined;
199
+
200
+ if (mode === "files_with_matches") {
201
+ const sorted = await sortFilesForOutput([...new Set(rg.lines)]);
202
+ const page = applyHeadLimit(sorted, input.head_limit, input.offset ?? 0);
203
+ const filenames = page.items.map((file) =>
204
+ toDisplayPath(options.workspaceRoot, file),
205
+ );
206
+
207
+ return {
208
+ ...base,
209
+ filenames,
210
+ numFiles: filenames.length,
211
+ appliedLimit: page.appliedLimit,
212
+ appliedOffset: appliedOffset(input.offset),
213
+ truncated: rg.truncated || page.appliedLimit !== undefined || undefined,
214
+ error: partialWarning,
215
+ };
216
+ }
217
+
218
+ if (mode === "count") {
219
+ const sorted = [...rg.lines].sort((left, right) => left.localeCompare(right));
220
+ const page = applyHeadLimit(sorted, input.head_limit, input.offset ?? 0);
221
+ const entries = page.items.map((line) =>
222
+ parseCountLine(line, options.workspaceRoot),
223
+ );
224
+ const filenames = entries.map((entry) => entry.filePath);
225
+
226
+ return {
227
+ ...base,
228
+ filenames,
229
+ numFiles: filenames.length,
230
+ content: entries
231
+ .map((entry) => `${entry.filePath}:${entry.count}`)
232
+ .join("\n"),
233
+ numMatches: entries.reduce((total, entry) => total + entry.count, 0),
234
+ appliedLimit: page.appliedLimit,
235
+ appliedOffset: appliedOffset(input.offset),
236
+ truncated: rg.truncated || page.appliedLimit !== undefined || undefined,
237
+ error: partialWarning,
238
+ };
239
+ }
240
+
241
+ const page = applyHeadLimit(rg.lines, input.head_limit, input.offset ?? 0);
242
+ const contentLines = page.items.map((line) =>
243
+ relativizeContentLine(line, options.workspaceRoot),
244
+ );
245
+ const filenames = extractContentFilenames(contentLines);
246
+
247
+ return {
248
+ ...base,
249
+ filenames,
250
+ numFiles: filenames.length,
251
+ content: contentLines.join("\n"),
252
+ numLines: contentLines.length,
253
+ appliedLimit: page.appliedLimit,
254
+ appliedOffset: appliedOffset(input.offset),
255
+ truncated: rg.truncated || page.appliedLimit !== undefined || undefined,
256
+ error: partialWarning,
257
+ };
258
+ },
259
+ });
260
+ }
261
+
262
+ export function buildRipgrepArgs(
263
+ input: GrepArgs,
264
+ mode: GrepOutputMode,
265
+ absoluteSearchPath: string,
266
+ ): string[] {
267
+ const args = ["--hidden", "--max-columns", "500"];
268
+
269
+ for (const directory of ignoredDirectories) {
270
+ args.push("--glob", `!${directory}`);
271
+ }
272
+
273
+ if (mode === "files_with_matches") {
274
+ args.push("-l");
275
+ } else if (mode === "count") {
276
+ args.push("-c", "--with-filename");
277
+ } else {
278
+ args.push("--with-filename");
279
+ if (input.lineNumbers !== false) {
280
+ args.push("-n");
281
+ }
282
+
283
+ if (input.context !== undefined) {
284
+ args.push("-C", String(input.context));
285
+ } else if (input.contextAlias !== undefined) {
286
+ args.push("-C", String(input.contextAlias));
287
+ } else {
288
+ if (input.before !== undefined) {
289
+ args.push("-B", String(input.before));
290
+ }
291
+ if (input.after !== undefined) {
292
+ args.push("-A", String(input.after));
293
+ }
294
+ }
295
+ }
296
+
297
+ if (input.multiline === true) {
298
+ args.push("-U", "--multiline-dotall");
299
+ }
300
+
301
+ if (input.caseInsensitive === true) {
302
+ args.push("-i");
303
+ }
304
+
305
+ if (input.type !== undefined) {
306
+ args.push("--type", input.type);
307
+ }
308
+
309
+ if (input.glob !== undefined) {
310
+ for (const pattern of input.glob.split(/[,\s]+/).filter((part) => part !== "")) {
311
+ args.push("--glob", pattern);
312
+ }
313
+ }
314
+
315
+ args.push("-e", input.pattern, absoluteSearchPath);
316
+ return args;
317
+ }
318
+
319
+ export function applyHeadLimit<T>(
320
+ items: T[],
321
+ limit: number | undefined,
322
+ offset = 0,
323
+ ): { items: T[]; appliedLimit?: number } {
324
+ if (limit === 0) {
325
+ return { items: items.slice(offset) };
326
+ }
327
+
328
+ const effectiveLimit = limit ?? defaultHeadLimit;
329
+ const itemsPage = items.slice(offset, offset + effectiveLimit);
330
+ const wasTruncated = items.length - offset > effectiveLimit;
331
+
332
+ return {
333
+ items: itemsPage,
334
+ appliedLimit: wasTruncated ? effectiveLimit : undefined,
335
+ };
336
+ }
337
+
338
+ type ParsedGrepArgs =
339
+ | { ok: true; value: GrepArgs }
340
+ | { ok: false; error: string; pattern?: string; mode?: GrepOutputMode };
341
+
342
+ function parseGrepArgs(args: unknown): ParsedGrepArgs {
343
+ if (!isRecord(args)) {
344
+ return { ok: false, error: "Grep arguments must be an object." };
345
+ }
346
+
347
+ const pattern = typeof args.pattern === "string" ? args.pattern : undefined;
348
+
349
+ if (pattern === undefined || pattern === "") {
350
+ return { ok: false, error: "Grep.pattern must be a non-empty string." };
351
+ }
352
+
353
+ if (args.path !== undefined && typeof args.path !== "string") {
354
+ return { ok: false, pattern, error: "Grep.path must be a string." };
355
+ }
356
+
357
+ if (args.glob !== undefined && typeof args.glob !== "string") {
358
+ return { ok: false, pattern, error: "Grep.glob must be a string." };
359
+ }
360
+
361
+ if (
362
+ args.output_mode !== undefined &&
363
+ args.output_mode !== "content" &&
364
+ args.output_mode !== "files_with_matches" &&
365
+ args.output_mode !== "count"
366
+ ) {
367
+ return {
368
+ ok: false,
369
+ pattern,
370
+ error:
371
+ 'Grep.output_mode must be one of "content", "files_with_matches", or "count".',
372
+ };
373
+ }
374
+
375
+ const mode = args.output_mode;
376
+
377
+ for (const name of ["-B", "-A", "-C", "context", "head_limit", "offset"]) {
378
+ const value = args[name];
379
+ if (value !== undefined && !isNonNegativeInteger(value)) {
380
+ return {
381
+ ok: false,
382
+ pattern,
383
+ mode,
384
+ error: `Grep.${name} must be a non-negative integer.`,
385
+ };
386
+ }
387
+ }
388
+
389
+ for (const name of ["-n", "-i", "multiline"]) {
390
+ const value = args[name];
391
+ if (value !== undefined && typeof value !== "boolean") {
392
+ return {
393
+ ok: false,
394
+ pattern,
395
+ mode,
396
+ error: `Grep.${name} must be a boolean.`,
397
+ };
398
+ }
399
+ }
400
+
401
+ if (args.type !== undefined && (typeof args.type !== "string" || args.type === "")) {
402
+ return {
403
+ ok: false,
404
+ pattern,
405
+ mode,
406
+ error: "Grep.type must be a non-empty string.",
407
+ };
408
+ }
409
+
410
+ return {
411
+ ok: true,
412
+ value: {
413
+ pattern,
414
+ path: args.path,
415
+ glob: args.glob,
416
+ output_mode: mode,
417
+ before: args["-B"] as number | undefined,
418
+ after: args["-A"] as number | undefined,
419
+ contextAlias: args["-C"] as number | undefined,
420
+ context: args.context as number | undefined,
421
+ lineNumbers: args["-n"] as boolean | undefined,
422
+ caseInsensitive: args["-i"] as boolean | undefined,
423
+ type: args.type,
424
+ head_limit: args.head_limit as number | undefined,
425
+ offset: args.offset as number | undefined,
426
+ multiline: args.multiline as boolean | undefined,
427
+ },
428
+ };
429
+ }
430
+
431
+ function resolveSearchPath(options: GrepToolOptions, inputPath?: string): string {
432
+ if (inputPath !== undefined) {
433
+ return resolveWorkspacePath(options.workspaceRoot, inputPath);
434
+ }
435
+
436
+ const cwd = options.cwdState.cwd;
437
+ if (!isWorkspaceLocalCwd(options.workspaceRoot, cwd)) {
438
+ throw new Error("Current cwd is outside the workspace. Pass an explicit path.");
439
+ }
440
+
441
+ return path.resolve(cwd);
442
+ }
443
+
444
+ async function ensureFileOrDirectory(
445
+ targetPath: string,
446
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
447
+ try {
448
+ const info = await stat(targetPath);
449
+ return info.isFile() || info.isDirectory()
450
+ ? { ok: true }
451
+ : { ok: false, error: "Grep.path must be a file or directory." };
452
+ } catch {
453
+ return { ok: false, error: "Grep.path does not exist." };
454
+ }
455
+ }
456
+
457
+ async function sortFilesForOutput(files: string[]): Promise<string[]> {
458
+ if (process.env.NODE_ENV === "test") {
459
+ return [...files].sort((left, right) => left.localeCompare(right));
460
+ }
461
+
462
+ const withMtime = await Promise.all(
463
+ files.map(async (file) => {
464
+ try {
465
+ const info = await stat(file);
466
+ return { file, mtimeMs: info.mtimeMs };
467
+ } catch {
468
+ return { file, mtimeMs: 0 };
469
+ }
470
+ }),
471
+ );
472
+
473
+ return withMtime
474
+ .sort((left, right) =>
475
+ right.mtimeMs !== left.mtimeMs
476
+ ? right.mtimeMs - left.mtimeMs
477
+ : left.file.localeCompare(right.file),
478
+ )
479
+ .map((entry) => entry.file);
480
+ }
481
+
482
+ function parseCountLine(
483
+ line: string,
484
+ workspaceRoot: string,
485
+ ): { filePath: string; count: number } {
486
+ const separator = line.lastIndexOf(":");
487
+ const absolutePath = separator === -1 ? line : line.slice(0, separator);
488
+ const count = separator === -1 ? 0 : Number(line.slice(separator + 1));
489
+
490
+ return {
491
+ filePath: toDisplayPath(workspaceRoot, absolutePath),
492
+ count: Number.isFinite(count) ? count : 0,
493
+ };
494
+ }
495
+
496
+ function relativizeContentLine(line: string, workspaceRoot: string): string {
497
+ const prefix = path.resolve(workspaceRoot) + path.sep;
498
+ return line.startsWith(prefix) ? line.slice(prefix.length) : line;
499
+ }
500
+
501
+ function extractContentFilenames(lines: string[]): string[] {
502
+ const filenames = new Set<string>();
503
+
504
+ for (const line of lines) {
505
+ const match = /^(.+?):\d+[:-]/.exec(line);
506
+ if (match?.[1] !== undefined) {
507
+ filenames.add(match[1]);
508
+ }
509
+ }
510
+
511
+ return [...filenames];
512
+ }
513
+
514
+ function appliedOffset(offset: number | undefined): number | undefined {
515
+ return offset !== undefined && offset > 0 ? offset : undefined;
516
+ }
517
+
518
+ function grepFailure(input: {
519
+ pattern: string;
520
+ searchPath: string;
521
+ absoluteSearchPath?: string;
522
+ mode: GrepOutputMode;
523
+ truncated?: boolean;
524
+ error: string;
525
+ }): GrepRawResult {
526
+ return {
527
+ ok: false,
528
+ pattern: input.pattern,
529
+ searchPath: input.searchPath,
530
+ absoluteSearchPath: input.absoluteSearchPath,
531
+ mode: input.mode,
532
+ filenames: [],
533
+ numFiles: 0,
534
+ ignored: ignoredDirectories,
535
+ truncated: input.truncated,
536
+ error: input.error,
537
+ };
538
+ }
539
+
540
+ function isRecord(value: unknown): value is Record<string, unknown> {
541
+ return typeof value === "object" && value !== null && !Array.isArray(value);
542
+ }
543
+
544
+ function isNonNegativeInteger(value: unknown): value is number {
545
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
546
+ }
547
+
548
+ function errorMessage(error: unknown): string {
549
+ return error instanceof Error ? error.message : String(error);
550
+ }
@@ -0,0 +1,9 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export function sha256Bytes(bytes: Uint8Array): string {
4
+ return createHash("sha256").update(bytes).digest("hex");
5
+ }
6
+
7
+ export function sha256Text(content: string): string {
8
+ return sha256Bytes(Buffer.from(content, "utf8"));
9
+ }
@@ -0,0 +1,33 @@
1
+ import path from "node:path";
2
+
3
+ export function resolveWorkspacePath(workspaceRoot: string, inputPath: string): string {
4
+ if (inputPath.trim() === "") {
5
+ throw new Error("Path is required.");
6
+ }
7
+
8
+ if (path.isAbsolute(inputPath)) {
9
+ return path.resolve(inputPath);
10
+ }
11
+
12
+ const root = path.resolve(workspaceRoot);
13
+ const resolved = path.resolve(root, inputPath);
14
+
15
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) {
16
+ throw new Error("Path escapes workspace.");
17
+ }
18
+
19
+ return resolved;
20
+ }
21
+
22
+ export function toDisplayPath(workspaceRoot: string, absolutePath: string): string {
23
+ const root = path.resolve(workspaceRoot);
24
+ const resolved = path.resolve(absolutePath);
25
+
26
+ if (resolved === root) {
27
+ return ".";
28
+ }
29
+
30
+ return resolved.startsWith(root + path.sep)
31
+ ? path.relative(root, resolved)
32
+ : resolved;
33
+ }