@turns/dev 0.0.1

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.
@@ -0,0 +1,84 @@
1
+ /**
2
+ * One value budget for every row this machine sends, native or live.
3
+ *
4
+ * A stored row's value has to survive the Durable Object's SQLite column limit,
5
+ * which is well under the socket's message ceiling: a value that clears the
6
+ * frame check and then fails the insert is not a dropped row, it is a batch the
7
+ * uploader retries forever. Both producers compact through here so no harness's
8
+ * multi-megabyte tool output can reach that state.
9
+ */
10
+ /** Largest value this machine ever sends for one row. */
11
+ export const MAX_NATIVE_VALUE_BYTES = 450_000;
12
+ function headUtf8(value, cap) {
13
+ const bytes = Buffer.from(value);
14
+ return bytes.byteLength <= cap ? value : bytes.subarray(0, cap).toString('utf8');
15
+ }
16
+ /** Trim long strings, deep arrays, and inline binary out of one parsed record. */
17
+ export function sanitizeNativeValue(value, stringCap, arrayCap, depth = 0) {
18
+ if (typeof value === 'string') {
19
+ const bytes = Buffer.byteLength(value);
20
+ const binary = value.includes('data:') && value.includes(';base64,');
21
+ if (!binary && bytes <= stringCap)
22
+ return value;
23
+ const head = headUtf8(value, binary ? Math.min(stringCap, 8_000) : stringCap);
24
+ return `${head}\n[turns.dev omitted ${Math.max(0, bytes - Buffer.byteLength(head)).toLocaleString()} bytes${binary ? ' of inline binary data' : ''}]`;
25
+ }
26
+ if (Array.isArray(value)) {
27
+ const held = value.slice(0, arrayCap).map((item) => sanitizeNativeValue(item, stringCap, arrayCap, depth + 1));
28
+ if (value.length > arrayCap)
29
+ held.push(`[turns.dev omitted ${value.length - arrayCap} array entries]`);
30
+ return held;
31
+ }
32
+ if (!value || typeof value !== 'object' || depth > 16)
33
+ return value;
34
+ return Object.fromEntries(Object.entries(value).map(([key, held]) => [key, sanitizeNativeValue(held, stringCap, arrayCap, depth + 1)]));
35
+ }
36
+ /** Compact one record's serialized form to fit `MAX_NATIVE_VALUE_BYTES`. */
37
+ export function compactNativeRaw(raw) {
38
+ const bytes = Buffer.byteLength(raw);
39
+ const containsBinary = raw.includes('data:') && raw.includes(';base64,');
40
+ if (bytes <= MAX_NATIVE_VALUE_BYTES && !containsBinary)
41
+ return raw;
42
+ let parsed;
43
+ try {
44
+ parsed = JSON.parse(raw);
45
+ }
46
+ catch {
47
+ return JSON.stringify({
48
+ type: 'turns.native.omitted',
49
+ text: `[turns.dev omitted an unparsable ${bytes.toLocaleString()}-byte native record]`,
50
+ });
51
+ }
52
+ let compacted = JSON.stringify(sanitizeNativeValue(parsed, 64_000, 250));
53
+ if (Buffer.byteLength(compacted) > MAX_NATIVE_VALUE_BYTES) {
54
+ compacted = JSON.stringify(sanitizeNativeValue(parsed, 4_000, 80));
55
+ }
56
+ if (Buffer.byteLength(compacted) <= MAX_NATIVE_VALUE_BYTES)
57
+ return compacted;
58
+ const record = parsed && typeof parsed === 'object' ? parsed : {};
59
+ return JSON.stringify({
60
+ type: record.type || 'turns.native.omitted',
61
+ role: record.role,
62
+ timestamp: record.timestamp || record.ts || record.created_at || record.createdAt,
63
+ text: `[turns.dev omitted an oversized ${bytes.toLocaleString()}-byte native record]`,
64
+ });
65
+ }
66
+ /**
67
+ * Compact one live frame's value. Live frames are not always JSON (stderr lines
68
+ * arrive raw), so an unparsable value keeps its readable head rather than being
69
+ * replaced wholesale the way a native record is.
70
+ */
71
+ export function compactRuntimeValue(value) {
72
+ const raw = String(value);
73
+ const bytes = Buffer.byteLength(raw);
74
+ if (bytes <= MAX_NATIVE_VALUE_BYTES && !(raw.includes('data:') && raw.includes(';base64,')))
75
+ return raw;
76
+ try {
77
+ JSON.parse(raw);
78
+ }
79
+ catch {
80
+ const head = headUtf8(raw, MAX_NATIVE_VALUE_BYTES - 200);
81
+ return `${head}\n[turns.dev omitted ${Math.max(0, bytes - Buffer.byteLength(head)).toLocaleString()} bytes of an oversized live frame]`;
82
+ }
83
+ return compactNativeRaw(raw);
84
+ }
@@ -0,0 +1,298 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ /**
5
+ * Turning the paths an agent writes into things a reader can open.
6
+ *
7
+ * An agent describes its work the way it would to a colleague sitting beside
8
+ * it — "wrote the plot to `out/chart.png`", "see ./src/app.tsx" — and every one
9
+ * of those names is meaningless on another computer. This machine is the only
10
+ * party that can say what they pointed at, so it says so on the record: each
11
+ * resolution is appended to the session's own log as its own row, carrying the
12
+ * substring, the absolute path, the size, and whether the contents came too.
13
+ *
14
+ * Only images travel eagerly, and only small ones. A transcript that mentions a
15
+ * picture is nearly always one somebody wants to see the picture in; a
16
+ * transcript that mentions a source file is one they want to know the path of.
17
+ */
18
+ /**
19
+ * Substrings that might name a file.
20
+ *
21
+ * Deliberately broad — anything with a path separator, a home-relative prefix,
22
+ * a drive letter, or a bare name carrying an extension. Breadth is safe because
23
+ * nothing is claimed until the filesystem confirms it: a candidate that names no
24
+ * readable file is dropped without ever being mentioned to the server, and one
25
+ * that no message's text contains is attached to nothing when the session is
26
+ * rendered. A path is nearly always written inside markdown, backticks, quotes
27
+ * or parentheses, and all of those fall away on their own.
28
+ *
29
+ * What is scanned is a whole JSON row, so the envelope's own words — a kind of
30
+ * `transcript.line`, a store called `session.jsonl` — look like candidates too.
31
+ * They are: they cost one `stat` each per session, resolve to nothing or to a
32
+ * file no sentence mentions, and are dropped at one of those two gates.
33
+ */
34
+ const PATH_CANDIDATE = /(?<![\w:\\/.@+-])(?:[A-Za-z]:\\|~\/|\.{1,2}\/|\/)?(?:[\w.@+-]+[/\\])*[\w.@+-]*\w\.[A-Za-z0-9]{1,12}(?![\w])/gu;
35
+ /** Extensions a browser will render inline, and the type each is served as. */
36
+ const IMAGE_CONTENT_TYPES = new Map([
37
+ ['.apng', 'image/apng'],
38
+ ['.avif', 'image/avif'],
39
+ ['.bmp', 'image/bmp'],
40
+ ['.gif', 'image/gif'],
41
+ ['.heic', 'image/heic'],
42
+ ['.ico', 'image/x-icon'],
43
+ ['.jpeg', 'image/jpeg'],
44
+ ['.jpg', 'image/jpeg'],
45
+ ['.png', 'image/png'],
46
+ ['.svg', 'image/svg+xml'],
47
+ ['.webp', 'image/webp'],
48
+ ]);
49
+ /** Kept in step with `MAX_FILE_BYTES` on the server, which rejects more. */
50
+ export const MAX_FILE_BYTES = 10 * 1024 * 1024;
51
+ /** Resolutions recorded for one session, matching the reducer's own ceiling. */
52
+ export const MAX_RESOLVED_PATHS = 256;
53
+ /** Candidates considered in one row, so a pasted log cannot become a scan. */
54
+ export const MAX_PATH_CANDIDATES_PER_ROW = 64;
55
+ export function agentImageContentType(file) {
56
+ return IMAGE_CONTENT_TYPES.get(path.extname(file).toLowerCase()) || '';
57
+ }
58
+ /**
59
+ * Destinations the reader's browser could already fetch are not ours to resolve.
60
+ *
61
+ * A single-letter prefix is not read as a scheme, so a Windows `C:\...` path
62
+ * stays a path — URL schemes are two characters or more.
63
+ */
64
+ function couldNameLocalFile(candidate) {
65
+ if (!candidate || candidate.length > 1024)
66
+ return false;
67
+ if (candidate.startsWith('#') || candidate.startsWith('//'))
68
+ return false;
69
+ return !/^[a-zA-Z][a-zA-Z0-9+.-]+:/u.test(candidate);
70
+ }
71
+ /**
72
+ * Every substring of one outbound row that might name a file.
73
+ *
74
+ * The row is the redacted JSON line, so what is scanned is escaped text: a
75
+ * candidate carrying a quote or a backslash cannot be told apart from the
76
+ * escaping around it and is skipped rather than guessed at. Windows paths are
77
+ * the notable casualty, and they are the reason this is stated rather than
78
+ * silently true.
79
+ */
80
+ export function pathCandidates(row) {
81
+ const found = [];
82
+ for (const match of String(row || '').matchAll(PATH_CANDIDATE)) {
83
+ const candidate = match[0];
84
+ if (candidate.includes('\\') || !couldNameLocalFile(candidate))
85
+ continue;
86
+ if (found.includes(candidate))
87
+ continue;
88
+ found.push(candidate);
89
+ if (found.length >= MAX_PATH_CANDIDATES_PER_ROW)
90
+ break;
91
+ }
92
+ return found;
93
+ }
94
+ /**
95
+ * Candidate filesystem paths for one substring.
96
+ *
97
+ * Markdown percent-encodes a space, so `shots/a%20b.png` and `shots/a b.png`
98
+ * are the same file written two ways and both are tried. A leading `~` is the
99
+ * agent's own shorthand for its home directory and is expanded here rather
100
+ * than being handed to the filesystem as a literal directory name.
101
+ */
102
+ export function candidateFilePaths(candidate, workingDirectory) {
103
+ const raw = String(candidate || '').trim();
104
+ if (!raw)
105
+ return [];
106
+ const forms = new Set([raw]);
107
+ try {
108
+ const decoded = decodeURIComponent(raw);
109
+ if (decoded !== raw)
110
+ forms.add(decoded);
111
+ }
112
+ catch { }
113
+ const resolved = [];
114
+ for (const form of forms) {
115
+ const expanded = form === '~' || form.startsWith('~/') ? path.join(os.homedir(), form.slice(1)) : form;
116
+ if (path.isAbsolute(expanded))
117
+ resolved.push(path.normalize(expanded));
118
+ else if (workingDirectory)
119
+ resolved.push(path.resolve(workingDirectory, expanded));
120
+ }
121
+ return [...new Set(resolved)];
122
+ }
123
+ /**
124
+ * What one substring names on this machine, or nothing.
125
+ *
126
+ * This is the filter that makes a broad regex safe: a candidate becomes a
127
+ * resolution only once the filesystem confirms a readable regular file, inside
128
+ * whatever the daemon was allowed to read. Everything else was just prose that
129
+ * happened to look like a path.
130
+ */
131
+ export function resolvePathCandidate(candidate, workingDirectory, { allowedDirectories = [] } = {}) {
132
+ for (const attempt of candidateFilePaths(candidate, workingDirectory)) {
133
+ let file;
134
+ let stat;
135
+ try {
136
+ // Canonicalise before the allowlist test: a symlink out of an allowed
137
+ // tree must not become a way to read the rest of the disk.
138
+ file = fs.realpathSync(attempt);
139
+ stat = fs.statSync(file);
140
+ }
141
+ catch {
142
+ continue;
143
+ }
144
+ if (!stat.isFile())
145
+ continue;
146
+ if (allowedDirectories.length && !insideAllowedDirectory(file, allowedDirectories))
147
+ continue;
148
+ return { file, bytes: stat.size, contentType: agentImageContentType(file) };
149
+ }
150
+ return null;
151
+ }
152
+ /** Whether the machine should send the contents alongside the name. */
153
+ export function shouldUploadEagerly(resolved) {
154
+ return Boolean(resolved.contentType) && resolved.bytes > 0 && resolved.bytes <= MAX_FILE_BYTES;
155
+ }
156
+ function insideAllowedDirectory(file, allowedDirectories) {
157
+ return allowedDirectories.some((allowed) => {
158
+ const relative = path.relative(allowed, file);
159
+ return relative !== '' && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative);
160
+ });
161
+ }
162
+ function uploadHeaders(options) {
163
+ return options.devUser
164
+ ? { 'x-turns-dev-user': options.devUser, 'x-turns-dev-role': 'uploader' }
165
+ : { authorization: `Bearer ${options.token}` };
166
+ }
167
+ /**
168
+ * Put bytes in the account's private prefix, under the path they came from.
169
+ *
170
+ * The path is the object's identity rather than a description of it, so a
171
+ * listing the machine composed for a directory is stored the same way a file
172
+ * read off the disk is: whoever fetches it addresses the same path back. It is
173
+ * addressed by machine and path alone — the session that happened to prompt the
174
+ * upload is not part of what was stored, and nothing records it.
175
+ */
176
+ export async function uploadFileBytes({ baseUrl, options, machine, absolutePath, contentType, body, fetchImpl = fetch, }) {
177
+ const url = new URL(`/api/file/${encodeURIComponent(machine)}/${encodeURIComponent(absolutePath)}`, String(baseUrl));
178
+ const response = await fetchImpl(url, {
179
+ method: 'PUT',
180
+ headers: {
181
+ ...uploadHeaders(options),
182
+ 'content-type': contentType,
183
+ 'content-length': String(body.byteLength),
184
+ },
185
+ body,
186
+ });
187
+ if (!response.ok)
188
+ throw new Error(`File upload failed (${response.status}) for ${absolutePath}.`);
189
+ return true;
190
+ }
191
+ /** Stream one on-disk file to R2 without retaining its body in the daemon. */
192
+ export async function uploadFileStream({ baseUrl, options, machine, absolutePath, contentType, bytes, fetchImpl = fetch, }) {
193
+ const url = new URL(`/api/file/${encodeURIComponent(machine)}/${encodeURIComponent(absolutePath)}`, String(baseUrl));
194
+ const body = fs.createReadStream(absolutePath);
195
+ try {
196
+ const response = await fetchImpl(url, {
197
+ method: 'PUT',
198
+ headers: {
199
+ ...uploadHeaders(options),
200
+ 'content-type': contentType,
201
+ 'content-length': String(bytes),
202
+ },
203
+ body,
204
+ // Node requires this opt-in for a streaming request body.
205
+ duplex: 'half',
206
+ });
207
+ if (!response.ok)
208
+ throw new Error(`File upload failed (${response.status}) for ${absolutePath}.`);
209
+ return true;
210
+ }
211
+ finally {
212
+ body.destroy();
213
+ }
214
+ }
215
+ /** Send one resolved file to the account's private prefix. */
216
+ export async function uploadResolvedFile({ baseUrl, options, machine, resolved, fetchImpl = fetch, }) {
217
+ const body = new Uint8Array(fs.readFileSync(resolved.file));
218
+ // The file changed under the stat. The next pass over a rewritten transcript
219
+ // resolves it again; sending a body whose length disagrees would not.
220
+ if (body.byteLength !== resolved.bytes)
221
+ return false;
222
+ return uploadFileBytes({
223
+ baseUrl,
224
+ options,
225
+ machine,
226
+ absolutePath: resolved.file,
227
+ contentType: resolved.contentType,
228
+ body,
229
+ fetchImpl,
230
+ });
231
+ }
232
+ /**
233
+ * Resolve what one session's rows mentioned, and record it.
234
+ *
235
+ * Every substring a session has already resolved is remembered, so prose quoted
236
+ * back in a later row — a compaction summary, a resumed transcript — costs a set
237
+ * lookup rather than another stat. Work runs on one chain per session so a burst
238
+ * of rows cannot open a burst of file reads, and a failure is reported once and
239
+ * dropped: rows are the product here, and a picture that did not make it must
240
+ * never hold up the transcript that named it.
241
+ */
242
+ export function createPathResolver({ options, machine, baseUrl, appendResolutions, redactor = null, fetchImpl = fetch, }) {
243
+ const seen = new Map();
244
+ const chains = new Map();
245
+ const resolveBatch = async (sessionId, workingDirectory, candidates) => {
246
+ const resolutions = [];
247
+ for (const candidate of candidates) {
248
+ const resolved = resolvePathCandidate(candidate, workingDirectory, {
249
+ allowedDirectories: options.allowedDirectories || [],
250
+ });
251
+ if (!resolved)
252
+ continue;
253
+ let uploaded = false;
254
+ if (shouldUploadEagerly(resolved)) {
255
+ uploaded = await uploadResolvedFile({ baseUrl, options, machine, resolved, fetchImpl });
256
+ }
257
+ resolutions.push({
258
+ text: candidate,
259
+ path: resolved.file,
260
+ bytes: resolved.bytes,
261
+ ...(resolved.contentType ? { contentType: resolved.contentType } : {}),
262
+ ...(uploaded ? { uploaded: true } : {}),
263
+ });
264
+ }
265
+ if (resolutions.length)
266
+ await appendResolutions(sessionId, resolutions);
267
+ };
268
+ return {
269
+ /** Offer the candidates one acknowledged batch of rows mentioned. */
270
+ enqueue(sessionId, workingDirectory, candidates) {
271
+ if (!sessionId || !candidates?.length)
272
+ return chains.get(sessionId) || Promise.resolve();
273
+ let known = seen.get(sessionId);
274
+ if (!known) {
275
+ known = new Set();
276
+ seen.set(sessionId, known);
277
+ }
278
+ const fresh = candidates.filter((candidate) => candidate && !known.has(candidate));
279
+ if (!fresh.length)
280
+ return chains.get(sessionId) || Promise.resolve();
281
+ for (const candidate of fresh) {
282
+ if (known.size >= MAX_RESOLVED_PATHS)
283
+ break;
284
+ known.add(candidate);
285
+ }
286
+ const chain = (chains.get(sessionId) || Promise.resolve()).then(() => resolveBatch(sessionId, workingDirectory, fresh).catch((error) => {
287
+ const message = error?.message || String(error);
288
+ process.stderr.write(`Path resolution failed: ${redactor ? redactor.redactText(message) : message}\n`);
289
+ }));
290
+ chains.set(sessionId, chain);
291
+ return chain;
292
+ },
293
+ /** A reset replays the whole transcript, so its substrings are offered again. */
294
+ forget(sessionId) {
295
+ seen.delete(sessionId);
296
+ },
297
+ };
298
+ }
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env node
2
+ import net from 'node:net';
3
+ import readline from 'node:readline';
4
+ const anchorSocket = process.env.TURNS_AGENT_ANCHOR_SOCKET || '';
5
+ let nextId = 1;
6
+ function send(value) {
7
+ process.stdout.write(`${JSON.stringify(value)}\n`);
8
+ }
9
+ function askAnchor(toolName, input) {
10
+ if (!anchorSocket)
11
+ return Promise.reject(new Error('The Turns agent anchor socket is unavailable.'));
12
+ return new Promise((resolve, reject) => {
13
+ const socket = net.createConnection(anchorSocket);
14
+ const id = String(nextId++);
15
+ const timer = setTimeout(() => {
16
+ socket.destroy();
17
+ reject(new Error('The question timed out.'));
18
+ }, 12 * 60 * 60_000);
19
+ timer.unref?.();
20
+ socket.once('error', (error) => {
21
+ clearTimeout(timer);
22
+ reject(error);
23
+ });
24
+ socket.once('connect', () => socket.write(`${JSON.stringify({ id, type: 'ask_interaction', toolName, input })}\n`));
25
+ readline.createInterface({ input: socket }).on('line', (line) => {
26
+ let frame;
27
+ try {
28
+ frame = JSON.parse(line);
29
+ }
30
+ catch {
31
+ return;
32
+ }
33
+ if (String(frame.id ?? '') !== id)
34
+ return;
35
+ clearTimeout(timer);
36
+ socket.end();
37
+ frame.ok ? resolve(frame.result) : reject(new Error(frame.error || 'The question was rejected.'));
38
+ });
39
+ });
40
+ }
41
+ const tools = [
42
+ {
43
+ name: 'turns_ask_user_question',
44
+ description: 'Ask the user one or more questions and wait for their interactive answer.',
45
+ inputSchema: {
46
+ type: 'object',
47
+ properties: {
48
+ question: { type: 'string' },
49
+ header: { type: 'string' },
50
+ questions: { type: 'array', items: { type: 'object' } },
51
+ options: {
52
+ type: 'array',
53
+ items: {
54
+ type: 'object',
55
+ properties: {
56
+ label: { type: 'string' },
57
+ description: { type: 'string' },
58
+ },
59
+ required: ['label'],
60
+ },
61
+ },
62
+ multiSelect: { type: 'boolean' },
63
+ allowFreeText: { type: 'boolean' },
64
+ disableFreeText: { type: 'boolean' },
65
+ disable_free_text: { type: 'boolean' },
66
+ },
67
+ },
68
+ },
69
+ {
70
+ name: 'turns_echo',
71
+ description: 'Return the supplied text unchanged. Useful for verifying that the Turns MCP bridge is connected.',
72
+ inputSchema: {
73
+ type: 'object',
74
+ properties: { text: { type: 'string' } },
75
+ required: ['text'],
76
+ },
77
+ },
78
+ ];
79
+ readline.createInterface({ input: process.stdin }).on('line', async (line) => {
80
+ let frame;
81
+ try {
82
+ frame = JSON.parse(line);
83
+ }
84
+ catch {
85
+ return;
86
+ }
87
+ if (frame.method === 'initialize')
88
+ return send({
89
+ jsonrpc: '2.0',
90
+ id: frame.id,
91
+ result: {
92
+ protocolVersion: frame.params?.protocolVersion || '2025-06-18',
93
+ capabilities: { tools: { listChanged: false } },
94
+ serverInfo: { name: 'turns', version: '1' },
95
+ },
96
+ });
97
+ if (frame.method === 'tools/list')
98
+ return send({ jsonrpc: '2.0', id: frame.id, result: { tools } });
99
+ if (frame.method === 'tools/call') {
100
+ try {
101
+ if (frame.params?.name === 'turns_echo') {
102
+ const text = String(frame.params?.arguments?.text || '');
103
+ return send({
104
+ jsonrpc: '2.0',
105
+ id: frame.id,
106
+ result: { isError: false, content: [{ type: 'text', text }] },
107
+ });
108
+ }
109
+ const response = await askAnchor(frame.params?.name, frame.params?.arguments || {});
110
+ return send({
111
+ jsonrpc: '2.0',
112
+ id: frame.id,
113
+ result: { isError: false, content: [{ type: 'text', text: JSON.stringify(response) }] },
114
+ });
115
+ }
116
+ catch (error) {
117
+ return send({
118
+ jsonrpc: '2.0',
119
+ id: frame.id,
120
+ result: { isError: true, content: [{ type: 'text', text: error?.message || String(error) }] },
121
+ });
122
+ }
123
+ }
124
+ if (frame.id !== undefined)
125
+ send({ jsonrpc: '2.0', id: frame.id, error: { code: -32601, message: 'Method not found' } });
126
+ });