tinker-agent 2.9.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +41 -1
  2. package/README.md +17 -1
  3. package/package.json +2 -1
  4. package/src/agent/runtime-hosted-session.ts +443 -0
  5. package/src/cli/command-line.ts +26 -2
  6. package/src/cli/connect-runner.tsx +26 -0
  7. package/src/cli/main.ts +26 -0
  8. package/src/cli/output.ts +1 -1
  9. package/src/cli/public-cli-contract.ts +18 -0
  10. package/src/cli/public-config-contract.ts +1 -1
  11. package/src/cli/serve-runner.ts +45 -0
  12. package/src/cli/serve-runtime.ts +100 -0
  13. package/src/context/context-swap-renderer.ts +14 -0
  14. package/src/observation/observation-builder.ts +87 -37
  15. package/src/remote/client.ts +350 -0
  16. package/src/remote/config.ts +95 -0
  17. package/src/remote/http-server.ts +240 -0
  18. package/src/remote/protocol.ts +228 -0
  19. package/src/remote/service-store.ts +175 -0
  20. package/src/remote/service.ts +219 -0
  21. package/src/remote/sync-hub.ts +95 -0
  22. package/src/session/remote-history-reader.ts +143 -0
  23. package/src/tools/bash-task.ts +26 -16
  24. package/src/tools/bash.ts +44 -2
  25. package/src/tools/glob.ts +107 -19
  26. package/src/tools/grep-output.ts +130 -0
  27. package/src/tools/grep-pagination.ts +73 -0
  28. package/src/tools/grep-path.ts +11 -0
  29. package/src/tools/grep-snippets.ts +111 -0
  30. package/src/tools/grep.ts +139 -154
  31. package/src/tools/read.ts +0 -9
  32. package/src/tools/ripgrep.ts +19 -26
  33. package/src/tools/shell-process.ts +30 -4
  34. package/src/tools/task-stop.ts +2 -1
  35. package/src/tools/terminal-screen.ts +11 -2
  36. package/src/tools/types.ts +30 -2
  37. package/src/tui/event-store.ts +15 -2
  38. package/src/tui/remote-app.tsx +210 -0
@@ -0,0 +1,130 @@
1
+ import path from "node:path";
2
+ import { toDisplayPath } from "./path-safety";
3
+ import type { GrepOutputMode } from "./types";
4
+ import { excerptGrepLines } from "./grep-snippets";
5
+
6
+ export type GrepContentRecord = {
7
+ kind: "match" | "context";
8
+ filePath: string;
9
+ lineNumber: number;
10
+ /** Keep each JSON event intact so pagination cannot split a multiline match. */
11
+ lines: string[];
12
+ };
13
+
14
+ export type GrepRecord =
15
+ | { kind: "file"; filePath: string }
16
+ | { kind: "count"; filePath: string; count: number }
17
+ | GrepContentRecord;
18
+
19
+ /** Decode complete protocol records only. A truncated tail is never a record. */
20
+ export function parseGrepOutput(
21
+ stdout: string,
22
+ mode: GrepOutputMode,
23
+ workspaceRoot: string,
24
+ truncated: boolean,
25
+ searchCwd: string = workspaceRoot,
26
+ ): GrepRecord[] {
27
+ if (mode === "content") {
28
+ return parseJsonOutput(stdout, workspaceRoot, truncated, searchCwd);
29
+ }
30
+ const records: GrepRecord[] = [];
31
+ let start = 0;
32
+ while (start < stdout.length) {
33
+ const nul = stdout.indexOf("\0", start);
34
+ if (nul === -1) {
35
+ if (truncated) break;
36
+ throw new Error("Missing NUL path delimiter.");
37
+ }
38
+ const reportedPath = stdout.slice(start, nul);
39
+ if (reportedPath === "") throw new Error("Empty path in ripgrep output.");
40
+ const filePath = toDisplayPath(
41
+ workspaceRoot,
42
+ path.resolve(searchCwd, reportedPath),
43
+ );
44
+ if (mode === "files_with_matches") {
45
+ records.push({ kind: "file", filePath });
46
+ start = nul + 1;
47
+ continue;
48
+ }
49
+ const end = stdout.indexOf("\n", nul + 1);
50
+ if (end === -1) {
51
+ if (truncated) break;
52
+ throw new Error("Unterminated count record.");
53
+ }
54
+ const value = stdout.slice(nul + 1, end);
55
+ if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value))) {
56
+ throw new Error("Invalid count in ripgrep output.");
57
+ }
58
+ records.push({ kind: "count", filePath, count: Number(value) });
59
+ start = end + 1;
60
+ }
61
+ return records;
62
+ }
63
+
64
+ function parseJsonOutput(
65
+ stdout: string,
66
+ workspaceRoot: string,
67
+ truncated: boolean,
68
+ searchCwd: string,
69
+ ): GrepRecord[] {
70
+ const records: GrepRecord[] = [];
71
+ let start = 0;
72
+ while (start < stdout.length) {
73
+ const end = stdout.indexOf("\n", start);
74
+ if (end === -1) {
75
+ if (truncated) break;
76
+ throw new Error("Unterminated JSON event.");
77
+ }
78
+ const event: unknown = JSON.parse(stdout.slice(start, end));
79
+ start = end + 1;
80
+ if (!isRecord(event) || typeof event.type !== "string" || !isRecord(event.data)) {
81
+ throw new Error("Invalid ripgrep JSON event.");
82
+ }
83
+ if (["begin", "end", "summary"].includes(event.type)) continue;
84
+ if (event.type !== "match" && event.type !== "context") {
85
+ throw new Error("Unexpected ripgrep JSON event type.");
86
+ }
87
+ const data = event.data;
88
+ const reportedPath = decodeText(data.path, true);
89
+ if (reportedPath === "") throw new Error("Empty path in ripgrep JSON event.");
90
+ if (
91
+ typeof data.line_number !== "number" ||
92
+ !Number.isSafeInteger(data.line_number) ||
93
+ data.line_number < 1
94
+ ) {
95
+ throw new Error("Invalid line number in ripgrep JSON event.");
96
+ }
97
+ const filePath = toDisplayPath(
98
+ workspaceRoot,
99
+ path.resolve(searchCwd, reportedPath),
100
+ );
101
+ records.push({
102
+ kind: event.type,
103
+ filePath,
104
+ lineNumber: data.line_number,
105
+ lines: excerptGrepLines(decodeBytes(data.lines), data.submatches),
106
+ });
107
+ }
108
+ return records;
109
+ }
110
+
111
+ function decodeText(value: unknown, isPath: boolean): string {
112
+ if (isRecord(value) && typeof value.text === "string") return value.text;
113
+ return new TextDecoder("utf-8", { fatal: isPath }).decode(decodeBytes(value));
114
+ }
115
+
116
+ function decodeBytes(value: unknown): Buffer {
117
+ if (!isRecord(value)) throw new Error("Invalid text field in ripgrep JSON event.");
118
+ if (typeof value.text === "string") return Buffer.from(value.text, "utf8");
119
+ if (
120
+ typeof value.bytes === "string" &&
121
+ /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value.bytes)
122
+ ) {
123
+ return Buffer.from(value.bytes, "base64");
124
+ }
125
+ throw new Error("Invalid text encoding in ripgrep JSON event.");
126
+ }
127
+
128
+ function isRecord(value: unknown): value is Record<string, unknown> {
129
+ return typeof value === "object" && value !== null && !Array.isArray(value);
130
+ }
@@ -0,0 +1,73 @@
1
+ import type { GrepContentRecord, GrepRecord } from "./grep-output";
2
+
3
+ const defaultHeadLimit = 250;
4
+
5
+ export function applyHeadLimit<T>(items: T[], limit: number | undefined, offset = 0) {
6
+ const effectiveLimit = limit ?? defaultHeadLimit;
7
+ const selected =
8
+ effectiveLimit === 0
9
+ ? items.slice(offset)
10
+ : items.slice(offset, offset + effectiveLimit);
11
+ const hasMore = offset + selected.length < items.length;
12
+ return {
13
+ items: selected,
14
+ totalResults: items.length,
15
+ returnedResults: selected.length,
16
+ hasMore,
17
+ nextOffset: hasMore ? offset + selected.length : undefined,
18
+ appliedLimit: hasMore ? effectiveLimit : undefined,
19
+ };
20
+ }
21
+
22
+ /** Match events select windows; matches encountered inside a window never expand it. */
23
+ export function applyContentHeadLimit(
24
+ records: GrepRecord[],
25
+ limit: number | undefined,
26
+ offset: number,
27
+ context: { before: number; after: number },
28
+ ) {
29
+ const matches = records.filter(
30
+ (record): record is GrepContentRecord => record.kind === "match",
31
+ );
32
+ const page = applyHeadLimit(matches, limit, offset);
33
+ const windows = new Map<string, { start: number; end: number }[]>();
34
+ for (const match of page.items) {
35
+ const ranges = windows.get(match.filePath) ?? [];
36
+ const start = Math.max(1, match.lineNumber - context.before);
37
+ const end = match.lineNumber + match.lines.length - 1 + context.after;
38
+ const last = ranges.at(-1);
39
+ if (last !== undefined && start <= last.end + 1) {
40
+ last.end = Math.max(last.end, end);
41
+ } else {
42
+ ranges.push({ start, end });
43
+ }
44
+ windows.set(match.filePath, ranges);
45
+ }
46
+
47
+ const items: GrepContentRecord[] = [];
48
+ // rg emits each physical line once, in file/line order. Walk merged windows
49
+ // linearly rather than rescanning all records for every selected match.
50
+ const cursors = new Map<string, number>();
51
+ for (const record of records) {
52
+ if (record.kind !== "match" && record.kind !== "context") continue;
53
+ const ranges = windows.get(record.filePath);
54
+ if (ranges === undefined) continue;
55
+ let cursor = cursors.get(record.filePath) ?? 0;
56
+ for (const [index, text] of record.lines.entries()) {
57
+ const lineNumber = record.lineNumber + index;
58
+ while (cursor < ranges.length && ranges[cursor].end < lineNumber) cursor++;
59
+ const range = ranges[cursor];
60
+ if (range === undefined) break;
61
+ if (lineNumber >= range.start) {
62
+ items.push({
63
+ kind: record.kind,
64
+ filePath: record.filePath,
65
+ lineNumber,
66
+ lines: [text],
67
+ });
68
+ }
69
+ }
70
+ cursors.set(record.filePath, cursor);
71
+ }
72
+ return { ...page, items };
73
+ }
@@ -0,0 +1,11 @@
1
+ // Quote only paths that need escaping; ordinary paths remain easy to scan/copy.
2
+ export function formatGrepPath(filePath: string): string {
3
+ return /[\p{Cc}\p{Cf}"\\\u2028\u2029]/u.test(filePath)
4
+ ? JSON.stringify(filePath).replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, (character) =>
5
+ character
6
+ .split("")
7
+ .map((unit) => `\\u${unit.charCodeAt(0).toString(16).padStart(4, "0")}`)
8
+ .join(""),
9
+ )
10
+ : filePath;
11
+ }
@@ -0,0 +1,111 @@
1
+ const maxLineCodePoints = 500;
2
+ const matchContextCodePoints = 100;
3
+
4
+ type MatchRange = { start: number; end: number };
5
+
6
+ /** rg submatch offsets address the original bytes of the entire JSON event. */
7
+ export function excerptGrepLines(bytes: Buffer, submatches: unknown): string[] {
8
+ const matches = parseSubmatches(submatches, bytes.length);
9
+ const lines: string[] = [];
10
+ let start = 0;
11
+ let matchIndex = 0;
12
+ while (start < bytes.length) {
13
+ const newline = bytes.indexOf(10, start);
14
+ const end = newline === -1 ? bytes.length : newline;
15
+ const textEnd = end > start && bytes[end - 1] === 13 ? end - 1 : end;
16
+ while (matchIndex < matches.length && matches[matchIndex].end < start) {
17
+ matchIndex++;
18
+ }
19
+ lines.push(excerptLine(bytes.subarray(start, textEnd), matches, matchIndex, start));
20
+ start = end + 1;
21
+ }
22
+ return lines;
23
+ }
24
+
25
+ function excerptLine(
26
+ bytes: Buffer,
27
+ matches: MatchRange[],
28
+ matchIndex: number,
29
+ lineStart: number,
30
+ ): string {
31
+ const points = [...bytes.toString("utf8")];
32
+ if (points.length <= maxLineCodePoints) return points.join("");
33
+
34
+ const windows: MatchRange[] = [];
35
+ const codePointOffset = createCodePointOffsetReader(bytes);
36
+ let remaining = maxLineCodePoints;
37
+ for (let index = matchIndex; index < matches.length && remaining > 0; index++) {
38
+ const match = matches[index];
39
+ if (match.start > lineStart + bytes.length) break;
40
+ if (match.end === lineStart && match.start < lineStart) continue;
41
+ const matchStart = codePointOffset(match.start - lineStart);
42
+ const matchEnd = codePointOffset(match.end - lineStart);
43
+ const previous = windows.at(-1);
44
+ // Reserve room for the match even when earlier windows used most of the budget.
45
+ const before = Math.min(matchContextCodePoints, Math.floor(remaining / 2));
46
+ const start = Math.max(0, matchStart - before);
47
+ const end = Math.min(points.length, matchEnd + matchContextCodePoints);
48
+ if (previous !== undefined && start <= previous.end) {
49
+ const extension = Math.min(remaining, Math.max(0, end - previous.end));
50
+ previous.end += extension;
51
+ remaining -= extension;
52
+ } else {
53
+ const keptEnd = Math.min(end, start + remaining);
54
+ windows.push({ start, end: keptEnd });
55
+ remaining -= keptEnd - start;
56
+ }
57
+ }
58
+ // Context events (and legacy fixtures without submatches) still retain useful text.
59
+ if (windows.length === 0) windows.push({ start: 0, end: maxLineCodePoints });
60
+
61
+ const parts: string[] = [];
62
+ let cursor = 0;
63
+ for (const window of windows) {
64
+ if (window.start > cursor) parts.push(omission(window.start - cursor));
65
+ parts.push(points.slice(window.start, window.end).join(""));
66
+ cursor = window.end;
67
+ }
68
+ if (cursor < points.length) parts.push(omission(points.length - cursor));
69
+ return parts.join("");
70
+ }
71
+
72
+ function createCodePointOffsetReader(bytes: Buffer): (offset: number) => number {
73
+ let byteCursor = 0;
74
+ let pointCursor = 0;
75
+ // Submatches are ordered and non-overlapping; scan each prefix only once.
76
+ return (offset) => {
77
+ const end = Math.max(0, Math.min(offset, bytes.length));
78
+ pointCursor += [...bytes.subarray(byteCursor, end).toString("utf8")].length;
79
+ byteCursor = end;
80
+ return pointCursor;
81
+ };
82
+ }
83
+
84
+ function omission(count: number): string {
85
+ return `[... ${count} code points omitted ...]`;
86
+ }
87
+
88
+ function parseSubmatches(value: unknown, byteLength: number): MatchRange[] {
89
+ if (value === undefined) return [];
90
+ if (!Array.isArray(value)) throw new Error("Invalid ripgrep submatches.");
91
+ let previousEnd = 0;
92
+ return value.map((item: unknown) => {
93
+ if (
94
+ typeof item !== "object" ||
95
+ item === null ||
96
+ !("start" in item) ||
97
+ !("end" in item) ||
98
+ typeof item.start !== "number" ||
99
+ typeof item.end !== "number" ||
100
+ !Number.isSafeInteger(item.start) ||
101
+ !Number.isSafeInteger(item.end) ||
102
+ item.start < previousEnd ||
103
+ item.end < item.start ||
104
+ item.end > byteLength
105
+ ) {
106
+ throw new Error("Invalid ripgrep submatch offsets.");
107
+ }
108
+ previousEnd = item.end;
109
+ return { start: item.start, end: item.end };
110
+ });
111
+ }