@shardflux/sdk 0.6.1 → 0.7.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.
@@ -0,0 +1,481 @@
1
+ /**
2
+ * Output encoding for tool-call capture (docs/decisions/0006-tool-call-capture.md, "Output encoding"; golden cases in
3
+ * packages/contracts/schemas/examples/tool-call-capture/cases.json). Pure functions: a value in, the files to store and
4
+ * the index fields out. Serialization never throws: a value that cannot be converted becomes `{"$type","$repr"}`, and
5
+ * a failure of the whole conversion is reported as `dropped: "serialize_failed"`.
6
+ */
7
+ import { createHash } from 'node:crypto';
8
+ import { inspect } from 'node:util';
9
+ export const DEFAULT_MAX_OUTPUT_BYTES = 32 * 1024 * 1024;
10
+ const REPR_MAX = 4096;
11
+ const encoder = new TextEncoder();
12
+ export const sha256Hex = (data) => createHash('sha256').update(data).digest('hex');
13
+ /**
14
+ * The tool name as used in file names: every Unicode code point outside `[A-Za-z0-9._-]` becomes `_`, cut to 64
15
+ * characters; an empty result becomes `tool`.
16
+ */
17
+ export function sanitizeToolName(name) {
18
+ let out = '';
19
+ for (const ch of name) {
20
+ out += /^[A-Za-z0-9._-]$/.test(ch) ? ch : '_';
21
+ if (out.length >= 64)
22
+ break;
23
+ }
24
+ return out.length === 0 ? 'tool' : out;
25
+ }
26
+ /** Sequence numbers in file names: zero-padded to 6 digits (more digits past 999999). */
27
+ export const padSeq = (seq) => String(seq).padStart(6, '0');
28
+ // ---- bytes and magic numbers -------------------------------------------------------------------
29
+ /** Raw bytes as the capture stores them: Uint8Array (and Buffer), ArrayBuffer, DataView. Other typed arrays are JSON. */
30
+ export function asBytes(value) {
31
+ if (value instanceof Uint8Array)
32
+ return value;
33
+ if (value instanceof ArrayBuffer)
34
+ return new Uint8Array(value);
35
+ if (typeof SharedArrayBuffer !== 'undefined' && value instanceof SharedArrayBuffer)
36
+ return new Uint8Array(value);
37
+ if (value instanceof DataView)
38
+ return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
39
+ return null;
40
+ }
41
+ const startsWith = (b, sig, at = 0) => b.length >= at + sig.length && sig.every((v, i) => b[at + i] === v);
42
+ const ascii = (s) => [...s].map((c) => c.charCodeAt(0));
43
+ const MAGIC = [
44
+ { test: (b) => startsWith(b, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), ext: '.png', type: 'image/png' },
45
+ { test: (b) => startsWith(b, [0xff, 0xd8, 0xff]), ext: '.jpg', type: 'image/jpeg' },
46
+ { test: (b) => startsWith(b, ascii('GIF87a')) || startsWith(b, ascii('GIF89a')), ext: '.gif', type: 'image/gif' },
47
+ { test: (b) => startsWith(b, ascii('RIFF')) && startsWith(b, ascii('WEBP'), 8), ext: '.webp', type: 'image/webp' },
48
+ { test: (b) => startsWith(b, ascii('%PDF-')), ext: '.pdf', type: 'application/pdf' },
49
+ { test: (b) => startsWith(b, [0x50, 0x4b, 0x03, 0x04]) || startsWith(b, [0x50, 0x4b, 0x05, 0x06]) || startsWith(b, [0x50, 0x4b, 0x07, 0x08]), ext: '.zip', type: 'application/zip' },
50
+ { test: (b) => startsWith(b, [0x1f, 0x8b]), ext: '.gz', type: 'application/gzip' },
51
+ { test: (b) => startsWith(b, ascii('PAR1')), ext: '.parquet', type: 'application/vnd.apache.parquet' },
52
+ ];
53
+ /** Extension and content type from magic bytes; `.bin` `application/octet-stream` when none matches. */
54
+ export function sniff(bytes) {
55
+ for (const m of MAGIC)
56
+ if (m.test(bytes))
57
+ return { ext: m.ext, contentType: m.type };
58
+ return { ext: '.bin', contentType: 'application/octet-stream' };
59
+ }
60
+ /** MIME types with a known extension: the magic-byte table plus the text types of rule 4. */
61
+ const MIME_EXT = {
62
+ ...Object.fromEntries(MAGIC.map((m) => [m.type, m.ext])),
63
+ 'text/plain': '.txt',
64
+ 'text/csv': '.csv',
65
+ 'text/html': '.html',
66
+ 'text/markdown': '.md',
67
+ 'application/json': '.json',
68
+ };
69
+ const TEXT_TYPES = {
70
+ '.txt': 'text/plain; charset=utf-8',
71
+ '.csv': 'text/csv; charset=utf-8',
72
+ '.html': 'text/html; charset=utf-8',
73
+ '.md': 'text/markdown; charset=utf-8',
74
+ };
75
+ const mimeBase = (mime) => (mime.split(';')[0] ?? '').trim().toLowerCase();
76
+ // ---- streams ------------------------------------------------------------------------------------
77
+ /**
78
+ * Values capture never reads (reading would consume them): Response, ReadableStream, Node streams, Blob, generator
79
+ * objects and other async iterables (a wrapped tool's async generator is teed instead; see capture.ts).
80
+ */
81
+ export function isUnreadable(value) {
82
+ if (typeof value !== 'object' || value === null)
83
+ return false;
84
+ if (typeof value[Symbol.asyncIterator] === 'function')
85
+ return true;
86
+ if (Object.prototype.toString.call(value) === '[object Generator]')
87
+ return true;
88
+ if (typeof Response !== 'undefined' && value instanceof Response)
89
+ return true;
90
+ if (typeof ReadableStream !== 'undefined' && value instanceof ReadableStream)
91
+ return true;
92
+ if (typeof Blob !== 'undefined' && value instanceof Blob)
93
+ return true;
94
+ const v = value;
95
+ if (typeof v.pipe === 'function' && typeof v.on === 'function')
96
+ return true; // Node stream
97
+ return typeof v.getReader === 'function' && typeof v.cancel === 'function'; // a ReadableStream from another realm
98
+ }
99
+ // ---- JSON-safe conversion (rule 5) --------------------------------------------------------------
100
+ function repr(value) {
101
+ let type = typeof value;
102
+ try {
103
+ if (typeof value === 'object' && value !== null)
104
+ type = value.constructor?.name || 'Object';
105
+ else if (typeof value === 'function')
106
+ type = 'function';
107
+ }
108
+ catch {
109
+ // a hostile constructor getter
110
+ }
111
+ let text;
112
+ try {
113
+ text = typeof value === 'function' ? `[Function ${value.name || 'anonymous'}]` : inspect(value, { depth: 2, breakLength: Infinity });
114
+ }
115
+ catch {
116
+ text = `[${type}]`;
117
+ }
118
+ return { $type: type, $repr: text.length > REPR_MAX ? text.slice(0, REPR_MAX) : text };
119
+ }
120
+ const b64 = (bytes) => Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64');
121
+ /** Parses a JSON arguments string (OpenAI); anything else is kept as it is. An empty string is `{}`. */
122
+ export function parseArguments(v) {
123
+ if (typeof v !== 'string')
124
+ return v;
125
+ if (v.trim() === '')
126
+ return {};
127
+ try {
128
+ return JSON.parse(v);
129
+ }
130
+ catch {
131
+ return v;
132
+ }
133
+ }
134
+ /** An error as the index line and outputs carry it: `{type, message}`. */
135
+ export function errorInfo(err) {
136
+ if (err instanceof Error)
137
+ return { type: err.name || err.constructor?.name || 'Error', message: String(err.message) };
138
+ if (typeof err === 'object' && err !== null) {
139
+ const e = err;
140
+ const type = typeof e.name === 'string' ? e.name : typeof e.type === 'string' ? e.type : 'Error';
141
+ if (typeof e.message === 'string')
142
+ return { type, message: e.message };
143
+ return { type, message: safeStringify(err) };
144
+ }
145
+ return { type: typeof err === 'string' ? 'Error' : typeof err, message: String(err) };
146
+ }
147
+ /**
148
+ * Converts any value to plain JSON data (objects, arrays, strings, finite numbers, booleans, null): `toJSON`, Date →
149
+ * ISO string, Map → object, Set → array, BigInt → string, NaN/Infinity → null, errors → `{type, message}`, nested bytes
150
+ * → `{"$base64"}`, cycles → `"[Circular]"`, unconvertible → `{"$type","$repr"}`. Functions and symbols inside objects
151
+ * are left out and inside arrays become null, as in JSON.stringify. Never throws. `undefined` at the top stays
152
+ * undefined.
153
+ */
154
+ export function toJsonSafe(value) {
155
+ const ancestors = new Set();
156
+ const walk = (v, key, inArray) => {
157
+ switch (typeof v) {
158
+ case 'string':
159
+ case 'boolean':
160
+ return v;
161
+ case 'number':
162
+ return Number.isFinite(v) ? v : null;
163
+ case 'bigint':
164
+ return v.toString();
165
+ case 'undefined':
166
+ return inArray ? null : undefined;
167
+ case 'function':
168
+ case 'symbol':
169
+ return inArray ? null : undefined;
170
+ }
171
+ if (v === null)
172
+ return null;
173
+ const obj = v;
174
+ const bytes = asBytes(obj);
175
+ if (bytes)
176
+ return { $base64: b64(bytes) };
177
+ if (ancestors.has(obj))
178
+ return '[Circular]';
179
+ try {
180
+ if (obj instanceof Error)
181
+ return errorInfo(obj);
182
+ if (typeof obj.toJSON === 'function') {
183
+ let j;
184
+ try {
185
+ j = obj.toJSON(key);
186
+ }
187
+ catch {
188
+ return repr(obj);
189
+ }
190
+ if (j === obj)
191
+ return repr(obj);
192
+ ancestors.add(obj);
193
+ try {
194
+ return walk(j, key, inArray);
195
+ }
196
+ finally {
197
+ ancestors.delete(obj);
198
+ }
199
+ }
200
+ ancestors.add(obj);
201
+ try {
202
+ if (Array.isArray(obj))
203
+ return obj.map((x, i) => walk(x, String(i), true));
204
+ if (ArrayBuffer.isView(obj))
205
+ return Array.from(obj, (x) => walk(x, '', true));
206
+ if (obj instanceof Map) {
207
+ const out = {};
208
+ for (const [k, x] of obj) {
209
+ const w = walk(x, String(k), false);
210
+ if (w !== undefined)
211
+ out[typeof k === 'string' ? k : String(typeof k === 'object' && k !== null ? safeStringify(k) : k)] = w;
212
+ }
213
+ return out;
214
+ }
215
+ if (obj instanceof Set)
216
+ return [...obj].map((x, i) => walk(x, String(i), true));
217
+ if (obj instanceof Promise || obj instanceof WeakMap || obj instanceof WeakSet || obj instanceof RegExp)
218
+ return repr(obj);
219
+ const out = {};
220
+ for (const k of Object.keys(obj)) {
221
+ let x;
222
+ try {
223
+ x = obj[k];
224
+ }
225
+ catch (e) {
226
+ x = repr(e);
227
+ }
228
+ const w = walk(x, k, false);
229
+ if (w !== undefined)
230
+ out[k] = w;
231
+ }
232
+ return out;
233
+ }
234
+ finally {
235
+ ancestors.delete(obj);
236
+ }
237
+ }
238
+ catch {
239
+ return repr(obj);
240
+ }
241
+ };
242
+ if (typeof value === 'function' || typeof value === 'symbol')
243
+ return repr(value);
244
+ return walk(value, '', false);
245
+ }
246
+ /** Compact JSON of any value (via toJsonSafe); never throws. Undefined becomes `null`. */
247
+ export function safeStringify(value) {
248
+ try {
249
+ return JSON.stringify(toJsonSafe(value)) ?? 'null';
250
+ }
251
+ catch {
252
+ return JSON.stringify(repr(value));
253
+ }
254
+ }
255
+ // ---- strings (rule 3) ---------------------------------------------------------------------------
256
+ /** A JSON object or array in strict JSON (no NaN/Infinity), surrounded only by JSON whitespace. */
257
+ export function isJsonContainerText(s) {
258
+ let i = 0;
259
+ while (i < s.length && (s[i] === ' ' || s[i] === '\t' || s[i] === '\n' || s[i] === '\r'))
260
+ i += 1;
261
+ const c = s[i];
262
+ if (c !== '{' && c !== '[')
263
+ return false;
264
+ try {
265
+ const v = JSON.parse(s);
266
+ return typeof v === 'object' && v !== null;
267
+ }
268
+ catch {
269
+ return false;
270
+ }
271
+ }
272
+ const HTML_START = /^\s*(<!doctype html|<html)/i;
273
+ function textFile(text) {
274
+ if (isJsonContainerText(text))
275
+ return { ext: '.json', contentType: 'application/json' };
276
+ if (HTML_START.test(text))
277
+ return { ext: '.html', contentType: 'text/html; charset=utf-8' };
278
+ return { ext: '.txt', contentType: 'text/plain; charset=utf-8' };
279
+ }
280
+ /** The longest prefix of at most `max` bytes that ends on a UTF-8 character boundary. */
281
+ export function cutUtf8(bytes, max) {
282
+ if (bytes.length <= max)
283
+ return bytes;
284
+ let cut = Math.max(0, max);
285
+ while (cut > 0 && ((bytes[cut] ?? 0) & 0xc0) === 0x80)
286
+ cut -= 1;
287
+ return bytes.subarray(0, cut);
288
+ }
289
+ function file(path, data, contentType) {
290
+ return { path, data, contentType, sha256: sha256Hex(data) };
291
+ }
292
+ function single(base, data, ext, contentType, max, textual) {
293
+ if (data.length > max) {
294
+ if (!textual)
295
+ return dropped('too_large');
296
+ const cut = cutUtf8(data, max);
297
+ const f = file(`${base}${ext}.part`, cut, contentType);
298
+ return { files: [f], outputPath: f.path, contentType, bytes: cut.length, sha256: f.sha256, truncated: true };
299
+ }
300
+ const f = file(`${base}${ext}`, data, contentType);
301
+ return { files: [f], outputPath: f.path, contentType, bytes: data.length, sha256: f.sha256, truncated: false };
302
+ }
303
+ export function dropped(reason, note) {
304
+ return { files: [], outputPath: null, contentType: null, bytes: 0, sha256: null, truncated: false, dropped: reason, ...(note ? { note } : {}) };
305
+ }
306
+ const NONE = Object.freeze({ files: [], outputPath: null, contentType: null, bytes: 0, sha256: null, truncated: false });
307
+ // ---- multi-part (rule 4) ------------------------------------------------------------------------
308
+ const BLOCK_TYPES = new Set(['text', 'image', 'document', 'resource', 'resource_link', 'audio', 'search_result']);
309
+ const MCP_KEYS = new Set(['content', 'isError', 'is_error', 'structuredContent', 'structured_content', '_meta', 'meta', 'resultType', 'result_type']);
310
+ const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
311
+ /** An MCP-like result: `content` a non-empty array of objects with a string `type`, other keys from the MCP set. */
312
+ export function isMcpLike(v) {
313
+ if (!isObj(v))
314
+ return false;
315
+ const content = v.content;
316
+ if (!Array.isArray(content) || content.length === 0)
317
+ return false;
318
+ if (!content.every((b) => isObj(b) && typeof b.type === 'string'))
319
+ return false;
320
+ return Object.keys(v).every((k) => MCP_KEYS.has(k));
321
+ }
322
+ /** A content-block array: non-empty, every item an object whose `type` is a known block type. */
323
+ export function isBlockArray(v) {
324
+ return Array.isArray(v) && v.length > 0 && v.every((b) => isObj(b) && typeof b.type === 'string' && BLOCK_TYPES.has(b.type));
325
+ }
326
+ const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/;
327
+ const decodeBase64 = (s) => (s.length % 4 === 0 && BASE64.test(s) ? new Uint8Array(Buffer.from(s, 'base64')) : null);
328
+ /** The payloads of one block, in order (at most one per block). */
329
+ function payloadOf(block) {
330
+ const str = (v) => (typeof v === 'string' ? v : null);
331
+ const type = block.type;
332
+ if (type === 'text' && typeof block.text === 'string') {
333
+ return { kind: 'text', value: block.text, mime: str(block.mimeType) ?? str(block.mime_type), set: (r) => (block.text = r) };
334
+ }
335
+ if ((type === 'image' || type === 'audio') && typeof block.data === 'string') {
336
+ return { kind: 'base64', value: block.data, mime: str(block.mimeType) ?? str(block.mime_type), set: (r) => (block.data = r) };
337
+ }
338
+ if (type === 'resource' && isObj(block.resource)) {
339
+ const res = block.resource;
340
+ if (typeof res.text === 'string')
341
+ return { kind: 'text', value: res.text, mime: str(res.mimeType) ?? str(res.mime_type), set: (r) => (res.text = r) };
342
+ if (typeof res.blob === 'string')
343
+ return { kind: 'base64', value: res.blob, mime: str(res.mimeType) ?? str(res.mime_type), set: (r) => (res.blob = r) };
344
+ }
345
+ if (isObj(block.source)) {
346
+ const src = block.source;
347
+ if (src.type === 'text' && typeof src.data === 'string')
348
+ return { kind: 'text', value: src.data, mime: str(src.media_type), set: (r) => (src.data = r) };
349
+ if (src.type === 'base64' && typeof src.data === 'string')
350
+ return { kind: 'base64', value: src.data, mime: str(src.media_type), set: (r) => (src.data = r) };
351
+ }
352
+ return null;
353
+ }
354
+ function multipart(root, blocks, base, max) {
355
+ const files = [];
356
+ let i = 0;
357
+ for (const block of blocks) {
358
+ const p = payloadOf(block);
359
+ if (!p)
360
+ continue;
361
+ let data;
362
+ let ext;
363
+ let contentType;
364
+ if (p.kind === 'text') {
365
+ data = encoder.encode(p.value);
366
+ if (isJsonContainerText(p.value)) {
367
+ ext = '.json';
368
+ contentType = 'application/json';
369
+ }
370
+ else {
371
+ const m = p.mime ? mimeBase(p.mime) : '';
372
+ ext = m === 'text/csv' ? '.csv' : m === 'text/html' ? '.html' : m === 'text/markdown' ? '.md' : '.txt';
373
+ contentType = TEXT_TYPES[ext];
374
+ }
375
+ }
376
+ else {
377
+ const decoded = decodeBase64(p.value);
378
+ if (!decoded)
379
+ continue; // not base64: left in result.json as it is
380
+ data = decoded;
381
+ const sniffed = sniff(data);
382
+ const m = p.mime ? mimeBase(p.mime) : '';
383
+ contentType = p.mime ? p.mime.toLowerCase() : sniffed.contentType;
384
+ ext = MIME_EXT[m] ?? sniffed.ext;
385
+ }
386
+ i += 1;
387
+ const name = `part-${i}${ext}`;
388
+ files.push(file(`${base}/${name}`, data, contentType));
389
+ p.set({ $part: name });
390
+ }
391
+ const result = file(`${base}/result.json`, encoder.encode(JSON.stringify(root)), 'application/json');
392
+ files.push(result);
393
+ const total = files.reduce((n, f) => n + f.data.length, 0);
394
+ if (total > max)
395
+ return dropped('too_large');
396
+ return {
397
+ files,
398
+ outputPath: `${base}/`,
399
+ contentType: 'multipart/mixed',
400
+ bytes: total,
401
+ sha256: result.sha256,
402
+ truncated: false,
403
+ parts: files.map((f) => ({ path: f.path, content_type: f.contentType, bytes: f.data.length, sha256: f.sha256 })),
404
+ };
405
+ }
406
+ // ---- the whole output ---------------------------------------------------------------------------
407
+ /**
408
+ * Plans how `value` is stored. `base` is `<seq>-<tool>` (zero-padded seq, sanitized tool name). Never throws.
409
+ */
410
+ export function planOutput(value, base, maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES) {
411
+ try {
412
+ if (value === undefined || value === null)
413
+ return NONE;
414
+ if (isUnreadable(value))
415
+ return { ...NONE, note: 'stream_not_captured' };
416
+ const bytes = asBytes(value);
417
+ if (bytes) {
418
+ const s = sniff(bytes);
419
+ return single(base, bytes, s.ext, s.contentType, maxOutputBytes, false);
420
+ }
421
+ if (typeof value === 'string') {
422
+ const t = textFile(value);
423
+ return single(base, encoder.encode(value), t.ext, t.contentType, maxOutputBytes, true);
424
+ }
425
+ const plain = toJsonSafe(value);
426
+ if (isMcpLike(plain))
427
+ return multipart(plain, plain.content, base, maxOutputBytes);
428
+ if (isBlockArray(plain))
429
+ return multipart(plain, plain, base, maxOutputBytes);
430
+ return single(base, encoder.encode(JSON.stringify(plain) ?? 'null'), '.json', 'application/json', maxOutputBytes, true);
431
+ }
432
+ catch {
433
+ return dropped('serialize_failed');
434
+ }
435
+ }
436
+ /** Items of an async iterable as `.jsonl` (rule 7): one compact JSON line per item, cut at `max` bytes. */
437
+ export class JsonlCollector {
438
+ #max;
439
+ #chunks = [];
440
+ #size = 0;
441
+ truncated = false;
442
+ count = 0;
443
+ constructor(max) {
444
+ this.#max = max;
445
+ }
446
+ push(item) {
447
+ this.count += 1;
448
+ if (this.truncated)
449
+ return;
450
+ const line = encoder.encode(`${safeStringify(item)}\n`);
451
+ if (this.#size + line.length > this.#max) {
452
+ this.#chunks.push(cutUtf8(line, this.#max - this.#size));
453
+ this.#size = this.#max;
454
+ this.truncated = true;
455
+ return;
456
+ }
457
+ this.#chunks.push(line);
458
+ this.#size += line.length;
459
+ }
460
+ /** The items collected so far, parsed back (a cut last line is left out). */
461
+ values() {
462
+ const text = Buffer.concat(this.#chunks).toString('utf8');
463
+ const out = [];
464
+ for (const line of text.split('\n')) {
465
+ if (line.length === 0)
466
+ continue;
467
+ try {
468
+ out.push(JSON.parse(line));
469
+ }
470
+ catch {
471
+ // the line cut at the size limit
472
+ }
473
+ }
474
+ return out;
475
+ }
476
+ plan(base) {
477
+ const data = Buffer.concat(this.#chunks);
478
+ const f = file(`${base}.jsonl${this.truncated ? '.part' : ''}`, new Uint8Array(data.buffer, data.byteOffset, data.byteLength), 'application/x-ndjson');
479
+ return { files: [f], outputPath: f.path, contentType: 'application/x-ndjson', bytes: data.length, sha256: f.sha256, truncated: this.truncated };
480
+ }
481
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The texts tool-call capture writes or returns, embedded because the package publishes only dist/. They are copies of
3
+ * packages/contracts/capture/workspace-readme.md and prompt-hint.md (test/capture.test.ts asserts they are equal).
4
+ * Placeholders: {{dir}} (the capture directory) and {{run_dir}} (the run directory).
5
+ */
6
+ /** <dir>/README.md, written once per capture. */
7
+ export declare const WORKSPACE_README = "# Tool calls\n\nEvery tool call made by the agent's harness is saved here by Shardflux tool-call capture. Each run (one capture,\nusually one process or one conversation turn) has its own directory, named by its start time.\n\n {{dir}}/<run>/index.jsonl one JSON line per call, in completion order\n {{dir}}/<run>/000007-web_search.json the full output of call 7 (.json, .txt, .html, .png, .pdf, ...)\n {{dir}}/<run>/000010-search/ an output with several parts: part-1.txt, part-2.png, result.json\n {{dir}}/<run>/000011-sql.input.json an input too large for the index line\n *.part an output cut at the size limit (\"truncated\": true)\n\nIndex fields: seq, call_id, tool, status (ok, error, cancelled, incomplete, retry), error, started_at, duration_ms,\ninput (or input_path), output_path (relative to the run directory), content_type, bytes, truncated.\n\nRead the index with `fromjson?` so a partially written line is skipped:\n\n cat {{dir}}/*/index.jsonl | jq -cR 'fromjson? // empty'\n cat {{dir}}/*/index.jsonl | jq -cR 'fromjson? // empty | select(.call_id == \"toolu_...\")'\n cat {{dir}}/*/index.jsonl | jq -cR 'fromjson? // empty | select(.tool == \"web_search\") | \"\\(.run)/\\(.output_path)\"'\n\nIn Python:\n\n import json, pathlib\n\n def calls(root=\"{{dir}}\"):\n for index in sorted(pathlib.Path(root).glob(\"*/index.jsonl\")):\n for line in index.read_text().splitlines():\n try:\n yield json.loads(line)\n except ValueError:\n pass # a partially written line\n";
8
+ /** The paragraph promptHint() returns (never injected into a prompt by the SDK). */
9
+ export declare const PROMPT_HINT = "Every tool call you make is also saved as files in this workspace. {{run_dir}}/index.jsonl has one JSON line per call (call_id, tool, status, input, output_path), and the full output of each call is at {{run_dir}}/<output_path>. Earlier runs are in the other directories under {{dir}}, described in {{dir}}/README.md. To analyse or transform a tool result with code, read it from these files (for example with jq or Python) rather than copying it from the conversation.\n";
10
+ /** Fills {{dir}} and {{run_dir}}. */
11
+ export declare function fillTemplate(text: string, vars: {
12
+ dir: string;
13
+ run_dir: string;
14
+ }): string;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The texts tool-call capture writes or returns, embedded because the package publishes only dist/. They are copies of
3
+ * packages/contracts/capture/workspace-readme.md and prompt-hint.md (test/capture.test.ts asserts they are equal).
4
+ * Placeholders: {{dir}} (the capture directory) and {{run_dir}} (the run directory).
5
+ */
6
+ /** <dir>/README.md, written once per capture. */
7
+ export const WORKSPACE_README = "# Tool calls\n\nEvery tool call made by the agent's harness is saved here by Shardflux tool-call capture. Each run (one capture,\nusually one process or one conversation turn) has its own directory, named by its start time.\n\n {{dir}}/<run>/index.jsonl one JSON line per call, in completion order\n {{dir}}/<run>/000007-web_search.json the full output of call 7 (.json, .txt, .html, .png, .pdf, ...)\n {{dir}}/<run>/000010-search/ an output with several parts: part-1.txt, part-2.png, result.json\n {{dir}}/<run>/000011-sql.input.json an input too large for the index line\n *.part an output cut at the size limit (\"truncated\": true)\n\nIndex fields: seq, call_id, tool, status (ok, error, cancelled, incomplete, retry), error, started_at, duration_ms,\ninput (or input_path), output_path (relative to the run directory), content_type, bytes, truncated.\n\nRead the index with `fromjson?` so a partially written line is skipped:\n\n cat {{dir}}/*/index.jsonl | jq -cR 'fromjson? // empty'\n cat {{dir}}/*/index.jsonl | jq -cR 'fromjson? // empty | select(.call_id == \"toolu_...\")'\n cat {{dir}}/*/index.jsonl | jq -cR 'fromjson? // empty | select(.tool == \"web_search\") | \"\\(.run)/\\(.output_path)\"'\n\nIn Python:\n\n import json, pathlib\n\n def calls(root=\"{{dir}}\"):\n for index in sorted(pathlib.Path(root).glob(\"*/index.jsonl\")):\n for line in index.read_text().splitlines():\n try:\n yield json.loads(line)\n except ValueError:\n pass # a partially written line\n";
8
+ /** The paragraph promptHint() returns (never injected into a prompt by the SDK). */
9
+ export const PROMPT_HINT = "Every tool call you make is also saved as files in this workspace. {{run_dir}}/index.jsonl has one JSON line per call (call_id, tool, status, input, output_path), and the full output of each call is at {{run_dir}}/<output_path>. Earlier runs are in the other directories under {{dir}}, described in {{dir}}/README.md. To analyse or transform a tool result with code, read it from these files (for example with jq or Python) rather than copying it from the conversation.\n";
10
+ /** Fills {{dir}} and {{run_dir}}. */
11
+ export function fillTemplate(text, vars) {
12
+ return text.replaceAll("{{run_dir}}", vars.run_dir).replaceAll("{{dir}}", vars.dir);
13
+ }