@hue-run/sdk 0.17.0 → 0.18.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,24 @@
1
+ /**
2
+ * Edits to recorded JSON text that keep every byte they do not change. Placing a file in a value
3
+ * read from JSON text (an attribute, or a tool call's JSON-encoded arguments) replaces only the
4
+ * file's own value, or the members of the part that held it; the rest of the text, its key order,
5
+ * number spellings and whitespace included, is kept as it was recorded. Mirrors the Python SDK's
6
+ * `_json_edits.py`, so both SDKs write the same bytes.
7
+ */
8
+ /** One change at a JSON pointer of a document: its value replaced by `value` (JSON text), or,
9
+ * for an object, the member `remove` dropped and each `set` member's value replaced in place or
10
+ * appended. */
11
+ export type JsonEdit = {
12
+ pointer: string;
13
+ value: string;
14
+ } | {
15
+ pointer: string;
16
+ remove?: string;
17
+ set?: readonly [string, string][];
18
+ };
19
+ /**
20
+ * `text` with `edits` applied, every byte outside the changed values kept. Each object edit
21
+ * keeps the object's other members, the separators between them and its whitespace; appended
22
+ * members are written compactly (`,"key":value`) before the closing brace.
23
+ */
24
+ export declare function applyJsonEdits(text: string, edits: readonly JsonEdit[]): string;
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Edits to recorded JSON text that keep every byte they do not change. Placing a file in a value
3
+ * read from JSON text (an attribute, or a tool call's JSON-encoded arguments) replaces only the
4
+ * file's own value, or the members of the part that held it; the rest of the text, its key order,
5
+ * number spellings and whitespace included, is kept as it was recorded. Mirrors the Python SDK's
6
+ * `_json_edits.py`, so both SDKs write the same bytes.
7
+ */
8
+ const segment = (key) => key.replaceAll("~", "~0").replaceAll("/", "~1");
9
+ /** The spans of the values at `wanted` pointers of valid JSON `text`, with the members of each
10
+ * object among them. A duplicate key's last member is the one a pointer names. */
11
+ function locate(text, wanted) {
12
+ const prefixes = new Set();
13
+ for (const pointer of wanted)
14
+ for (let at = pointer.length; at >= 0; at = pointer.lastIndexOf("/", at - 1)) {
15
+ prefixes.add(pointer.slice(0, at));
16
+ if (at === 0)
17
+ break;
18
+ }
19
+ const found = new Map();
20
+ const space = (index) => {
21
+ let at = index;
22
+ for (;;) {
23
+ const code = text.charCodeAt(at);
24
+ if (code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0d)
25
+ at++;
26
+ else
27
+ return at;
28
+ }
29
+ };
30
+ const stringEnd = (index) => {
31
+ let at = index + 1;
32
+ for (;;) {
33
+ const code = text.charCodeAt(at);
34
+ if (code === 0x22)
35
+ return at + 1;
36
+ at += code === 0x5c ? 2 : 1;
37
+ }
38
+ };
39
+ /** The end of the value starting at `index`, and an object's members when asked. */
40
+ const valueEnd = (index, members) => {
41
+ const code = text.charCodeAt(index);
42
+ if (code === 0x22)
43
+ return stringEnd(index);
44
+ if (code === 0x7b || code === 0x5b) {
45
+ const object = code === 0x7b;
46
+ let at = space(index + 1);
47
+ if (text.charCodeAt(at) === (object ? 0x7d : 0x5d))
48
+ return at + 1;
49
+ for (;;) {
50
+ let key = "";
51
+ let start = at;
52
+ if (object) {
53
+ const keyEnd = stringEnd(at);
54
+ if (members)
55
+ key = JSON.parse(text.slice(at, keyEnd));
56
+ at = space(space(keyEnd) + 1);
57
+ }
58
+ const valueStart = at;
59
+ const end = valueEnd(at);
60
+ if (members)
61
+ members.push({ key, start, valueStart, end });
62
+ at = space(end);
63
+ if (text.charCodeAt(at) === 0x2c) {
64
+ at = space(at + 1);
65
+ start = at;
66
+ continue;
67
+ }
68
+ return at + 1;
69
+ }
70
+ }
71
+ let at = index;
72
+ for (;;) {
73
+ const code = text.charCodeAt(at);
74
+ // A number's characters, or a literal's letters.
75
+ if ((code >= 0x30 && code <= 0x39) ||
76
+ code === 0x2d ||
77
+ code === 0x2b ||
78
+ code === 0x2e ||
79
+ (code >= 0x61 && code <= 0x7a) ||
80
+ code === 0x45)
81
+ at++;
82
+ else
83
+ return at;
84
+ }
85
+ };
86
+ const visit = (index, pointer) => {
87
+ const code = text.charCodeAt(index);
88
+ const descend = code === 0x7b || code === 0x5b;
89
+ const members = descend ? [] : undefined;
90
+ const end = valueEnd(index, members);
91
+ if (wanted.has(pointer))
92
+ found.set(pointer, code === 0x7b ? { start: index, end, members } : { start: index, end });
93
+ if (!members)
94
+ return;
95
+ const last = new Map();
96
+ members.forEach((member, position) => last.set(code === 0x7b ? member.key : String(position), member));
97
+ for (const [key, member] of last) {
98
+ const child = `${pointer}/${segment(key)}`;
99
+ if (prefixes.has(child))
100
+ visit(member.valueStart, child);
101
+ }
102
+ };
103
+ visit(space(0), "");
104
+ return found;
105
+ }
106
+ /**
107
+ * `text` with `edits` applied, every byte outside the changed values kept. Each object edit
108
+ * keeps the object's other members, the separators between them and its whitespace; appended
109
+ * members are written compactly (`,"key":value`) before the closing brace.
110
+ */
111
+ export function applyJsonEdits(text, edits) {
112
+ if (!edits.length)
113
+ return text;
114
+ const values = new Map();
115
+ const objects = new Map();
116
+ for (const edit of edits) {
117
+ if ("value" in edit)
118
+ values.set(edit.pointer, edit.value);
119
+ else {
120
+ const object = objects.get(edit.pointer) ?? { remove: new Set(), set: new Map() };
121
+ if (edit.remove !== undefined)
122
+ object.remove.add(edit.remove);
123
+ for (const [key, value] of edit.set ?? [])
124
+ object.set.set(key, value);
125
+ objects.set(edit.pointer, object);
126
+ }
127
+ }
128
+ const located = locate(text, new Set([...values.keys(), ...objects.keys()]));
129
+ const pieces = [];
130
+ for (const [pointer, value] of values) {
131
+ const span = located.get(pointer);
132
+ if (span)
133
+ pieces.push({ start: span.start, end: span.end, render: () => value });
134
+ }
135
+ for (const [pointer, edit] of objects) {
136
+ const span = located.get(pointer);
137
+ if (!span?.members)
138
+ continue;
139
+ const members = span.members;
140
+ // The last member of each key set in place keeps its position; the others are appended.
141
+ const lastOf = new Map();
142
+ for (const member of members)
143
+ lastOf.set(member.key, member);
144
+ for (const [key, value] of edit.set) {
145
+ const member = lastOf.get(key);
146
+ if (member && !edit.remove.has(key))
147
+ pieces.push({ start: member.valueStart, end: member.end, render: () => value });
148
+ }
149
+ pieces.push({
150
+ start: span.start,
151
+ end: span.end,
152
+ render: () => {
153
+ const kept = members.filter((member) => !edit.remove.has(member.key));
154
+ const first = members[0]?.start ?? span.end - 1;
155
+ const close = members.length ? members.at(-1).end : span.end - 1;
156
+ let out = text.slice(span.start, first);
157
+ kept.forEach((member, position) => {
158
+ const index = members.indexOf(member);
159
+ // The separator that preceded this member, comma included, unless it now comes first.
160
+ if (position > 0)
161
+ out += text.slice(members[index - 1].end, member.start);
162
+ out += render(member.start, member.end);
163
+ });
164
+ let appended = "";
165
+ for (const [key, value] of edit.set)
166
+ if (!lastOf.has(key) || edit.remove.has(key))
167
+ appended += `${kept.length || appended ? "," : ""}${JSON.stringify(key)}:${value}`;
168
+ return out + appended + text.slice(close, span.end);
169
+ },
170
+ });
171
+ }
172
+ pieces.sort((a, b) => a.start - b.start || b.end - a.end);
173
+ /** The text from `start` to `end` with the pieces within it rendered; a piece inside another
174
+ * is rendered by the outer one. */
175
+ const render = (start, end) => {
176
+ let out = "";
177
+ let at = start;
178
+ for (const piece of pieces) {
179
+ if (piece.start < at || piece.end > end)
180
+ continue;
181
+ out += text.slice(at, piece.start) + piece.render();
182
+ at = piece.end;
183
+ }
184
+ return out + text.slice(at, end);
185
+ };
186
+ return render(0, text.length);
187
+ }
package/dist/privacy.d.ts CHANGED
@@ -44,6 +44,19 @@ export interface RedactionLimits {
44
44
  */
45
45
  offload?: OffloadCollector;
46
46
  }
47
+ /**
48
+ * A log record's body, placed by the upload step, cut as {@link redactLog} cuts one: each string
49
+ * over the cap cut to it and bytes over it replaced by the receiver's marker, and a structured
50
+ * body still over the cap replaced by the marker. Whether anything was cut, for `hue.truncated`.
51
+ */
52
+ export declare function capLogBody(body: unknown, valueBytes: number): {
53
+ body: unknown;
54
+ cut: boolean;
55
+ };
56
+ /** Whether a log record is a GenAI event, whose structured body carries messages. */
57
+ export declare function isGenAiLog(log: ReadableLogRecord): boolean;
58
+ /** Whether a log body is structured (an object or array), so a pointer can address inside it. */
59
+ export declare function isStructuredBody(body: unknown): boolean;
47
60
  export type ResourceCache = WeakMap<Resource, Resource>;
48
61
  export declare function redactSpan(span: ReadableSpan, options: HueOptions, cache: ResourceCache, limits?: RedactionLimits): ReadableSpan;
49
62
  export declare function redactLog(log: ReadableLogRecord, options: HueOptions, cache: ResourceCache, limits?: RedactionLimits): ReadableLogRecord;
package/dist/privacy.js CHANGED
@@ -2,7 +2,7 @@ import { SpanStatusCode } from "@opentelemetry/api";
2
2
  import { resourceFromAttributes } from "@opentelemetry/resources";
3
3
  import { MAX_CONTENT_BYTES, MAX_RECORD_NODES, REDACTION_CONTEXT_UNITS } from "./config.js";
4
4
  import { scrubToolCredentials, withToolCatalogSummary } from "./tool-definitions.js";
5
- import { hashInlineFiles, mayInlineFiles } from "./inline-files.js";
5
+ import { hashInlineFiles, isMediaKey, mayInlineFiles } from "./inline-files.js";
6
6
  import { truncateUtf8 } from "./safety.js";
7
7
  export { truncateUtf8 };
8
8
  /** Attribute keys (and their dotted children) removed in metadata-only mode. */
@@ -152,6 +152,10 @@ const NOT_HELD = Symbol("not held");
152
152
  */
153
153
  function offloadValue(key, value, path, options, budget) {
154
154
  const collector = budget.offload;
155
+ // A log record uploads only the media in its inspected attributes: anything else is redacted
156
+ // and cut as before.
157
+ if (collector.log && (typeof value !== "string" || !isMediaKey(key) || budget.cut?.has(value)))
158
+ return NOT_HELD;
155
159
  if (value instanceof Uint8Array) {
156
160
  if (value.byteLength <= budget.valueBytes)
157
161
  return NOT_HELD;
@@ -178,6 +182,13 @@ function offloadValue(key, value, path, options, budget) {
178
182
  if (++budget.nodes > MAX_RECORD_NODES)
179
183
  throw new Error("Telemetry value exceeds the supported nesting limit");
180
184
  const result = redactedText(scrubbed, path, options, budget.valueBytes);
185
+ if (collector.log && !mayInlineFiles(key, result)) {
186
+ if (Buffer.byteLength(result) <= budget.valueBytes)
187
+ return result;
188
+ if (!budget.truncated.includes(key))
189
+ budget.truncated.push(key);
190
+ return truncateUtf8(result, budget.valueBytes);
191
+ }
181
192
  if (mayInlineFiles(key, result) ||
182
193
  (result.length * 3 > budget.valueBytes && Buffer.byteLength(result) > budget.valueBytes))
183
194
  collector.candidates.push({ key, value: result });
@@ -190,18 +201,19 @@ function offloadValue(key, value, path, options, budget) {
190
201
  * is exported whole with the rest of its evidence, and a reader knows the one value is partial.
191
202
  * Dropping the record for a large value lost every call, result and reply it recorded.
192
203
  */
193
- function redactValue(value, path, options, budget, depth = 0, listAs = path) {
204
+ function redactValue(value, path, options, budget, depth = 0, listAs = path, whole = false) {
194
205
  if (++budget.nodes > MAX_RECORD_NODES || depth > 32)
195
206
  throw new Error("Telemetry value exceeds the supported nesting limit");
196
207
  if (typeof value === "string") {
197
- const cap = budget.valueBytes;
208
+ // `whole`: a log record's body left for the upload step, which cuts what it does not upload.
209
+ const cap = whole && budget.cut?.has(value) !== true ? Infinity : budget.valueBytes;
198
210
  // The redactor sees the recorded text past the cap, so a secret that crosses the cap is still
199
211
  // its to recognize; what it returns is cut to the cap afterwards. Text the queue cut when the
200
212
  // record was admitted (far past the cap) reached the redactor without its continuation, so
201
213
  // the end of the answer, which may hold the start of a secret it could not recognize, is
202
214
  // never exported.
203
215
  const cutWhenQueued = budget.cut?.has(value) === true;
204
- let result = redactedText(value, path, options, cap);
216
+ let result = redactedText(value, path, options, Math.min(cap, budget.valueBytes));
205
217
  if (cutWhenQueued && options.redact)
206
218
  result = withoutTail(result, REDACTION_CONTEXT_UNITS);
207
219
  const bytes = Buffer.byteLength(result);
@@ -214,7 +226,11 @@ function redactValue(value, path, options, budget, depth = 0, listAs = path) {
214
226
  if (!budget.truncated.includes(listAs))
215
227
  budget.truncated.push(listAs);
216
228
  budget.bytes += cap;
217
- return truncateUtf8(result, cap);
229
+ const cut = truncateUtf8(result, cap);
230
+ // A log body's string the queue cut is no whole file: the upload step leaves it as cut.
231
+ if (whole && cutWhenQueued && budget.offload)
232
+ (budget.offload.bodyCut ??= new Set()).add(cut);
233
+ return cut;
218
234
  }
219
235
  // A value the queue replaced by the receiver's marker (a tool definition or recorded request
220
236
  // too long to scrub when it was queued) is listed as cut.
@@ -226,7 +242,7 @@ function redactValue(value, path, options, budget, depth = 0, listAs = path) {
226
242
  if (Array.isArray(value)) {
227
243
  if (value.length > MAX_RECORD_NODES)
228
244
  throw new Error("Telemetry array exceeds the complexity limit");
229
- return value.map((item, index) => redactValue(item, `${path}.${index}`, options, budget, depth + 1, listAs));
245
+ return value.map((item, index) => redactValue(item, `${path}.${index}`, options, budget, depth + 1, listAs, whole));
230
246
  }
231
247
  if (value instanceof Uint8Array) {
232
248
  if (value.byteLength <= budget.valueBytes) {
@@ -241,10 +257,51 @@ function redactValue(value, path, options, budget, depth = 0, listAs = path) {
241
257
  if (value !== null && typeof value === "object")
242
258
  return Object.fromEntries(Object.entries(value).map(([key, item]) => [
243
259
  key,
244
- redactValue(item, `${path}.${key}`, options, budget, depth + 1, listAs),
260
+ redactValue(item, `${path}.${key}`, options, budget, depth + 1, listAs, whole),
245
261
  ]));
246
262
  return value;
247
263
  }
264
+ /**
265
+ * A log record's body, placed by the upload step, cut as {@link redactLog} cuts one: each string
266
+ * over the cap cut to it and bytes over it replaced by the receiver's marker, and a structured
267
+ * body still over the cap replaced by the marker. Whether anything was cut, for `hue.truncated`.
268
+ */
269
+ export function capLogBody(body, valueBytes) {
270
+ let cut = false;
271
+ const visit = (value) => {
272
+ if (typeof value === "string") {
273
+ if (Buffer.byteLength(value) <= valueBytes)
274
+ return value;
275
+ cut = true;
276
+ return truncateUtf8(value, valueBytes);
277
+ }
278
+ if (value instanceof Uint8Array) {
279
+ if (value.byteLength <= valueBytes)
280
+ return value;
281
+ cut = true;
282
+ return truncatedMarker(value.byteLength);
283
+ }
284
+ if (Array.isArray(value))
285
+ return value.map(visit);
286
+ if (value !== null && typeof value === "object")
287
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, visit(item)]));
288
+ return value;
289
+ };
290
+ const size = structuredBytes(body);
291
+ const result = visit(body);
292
+ if (result !== null && typeof result === "object" && structuredBytes(result) > valueBytes)
293
+ return { body: truncatedMarker(size), cut: true };
294
+ return { body: result, cut };
295
+ }
296
+ /** Whether a log record is a GenAI event, whose structured body carries messages. */
297
+ export function isGenAiLog(log) {
298
+ const name = log.eventName ?? log.attributes["event.name"];
299
+ return typeof name === "string" && name.startsWith("gen_ai.");
300
+ }
301
+ /** Whether a log body is structured (an object or array), so a pointer can address inside it. */
302
+ export function isStructuredBody(body) {
303
+ return body !== null && typeof body === "object" && !(body instanceof Uint8Array);
304
+ }
248
305
  /**
249
306
  * A record's attributes through the redactor. `listPrefix` is how a cut value of these
250
307
  * attributes is listed under the record's `hue.truncated`: the key alone for the record's own
@@ -265,7 +322,7 @@ function attributes(source, options, path, budget, listPrefix = "", own = false)
265
322
  return [
266
323
  [
267
324
  key,
268
- redactValue(scrubToolCredentials(key, own ? hashInlineFiles(key, value) : value), `${path}.${key}`, options, budget, 0, `${listPrefix}${key}`),
325
+ redactValue(scrubToolCredentials(key, own === true ? hashInlineFiles(key, value) : value), `${path}.${key}`, options, budget, 0, `${listPrefix}${key}`),
269
326
  ],
270
327
  ];
271
328
  }));
@@ -371,17 +428,27 @@ export function redactSpan(span, options, cache, limits = {}) {
371
428
  }
372
429
  export function redactLog(log, options, cache, limits = {}) {
373
430
  const budget = budgetOf(limits);
374
- let body = options.captureContent ? redactValue(log.body, "body", options, budget) : undefined;
375
- if (body !== undefined && typeof body !== "string") {
376
- // A structured body over the cap is replaced by the receiver's marker, as the receiver would
377
- // replace it; the marker names the body's size as recorded, before any text part was cut.
378
- if (Buffer.byteLength(JSON.stringify(body)) > budget.valueBytes) {
379
- body = truncatedMarker(structuredBytes(log.body));
380
- if (!budget.truncated.includes("body"))
381
- budget.truncated.push("body");
431
+ const offload = limits.offload;
432
+ let body;
433
+ if (offload && options.captureContent && isGenAiLog(log) && isStructuredBody(log.body)) {
434
+ // Left whole for the upload step, which uploads its media and cuts what is left over the cap.
435
+ body = redactValue(log.body, "body", options, budget, 0, "body", true);
436
+ offload.body = { value: body };
437
+ }
438
+ else {
439
+ body = options.captureContent ? redactValue(log.body, "body", options, budget) : undefined;
440
+ if (body !== undefined && typeof body !== "string") {
441
+ // A structured body over the cap is replaced by the receiver's marker, as the receiver
442
+ // would replace it; the marker names the body's size as recorded, before any text part
443
+ // was cut.
444
+ if (Buffer.byteLength(JSON.stringify(body)) > budget.valueBytes) {
445
+ body = truncatedMarker(structuredBytes(log.body));
446
+ if (!budget.truncated.includes("body"))
447
+ budget.truncated.push("body");
448
+ }
382
449
  }
383
450
  }
384
- const redactedAttributes = attributes(log.attributes, options, "attributes", budget);
451
+ const redactedAttributes = attributes(log.attributes, options, "attributes", budget, "", offload ? "log" : false);
385
452
  const resource = redactResource(log.resource, options, cache, budget);
386
453
  const scopeAttributes = log.instrumentationScope.attributes
387
454
  ? attributes(log.instrumentationScope.attributes, options, "scope.attributes", budget, "scope.")
package/dist/safety.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type Span } from "@opentelemetry/api";
2
+ import { Attachment } from "./attachment.js";
2
3
  export declare function noopSpan(): Span;
3
4
  export declare function safeSpan(source: Span, failed: () => void): Span;
4
5
  export declare function spanSource(span: Span): Span;
@@ -10,8 +11,11 @@ export interface EncodeLimits {
10
11
  }
11
12
  /** One content attribute's bounds under a receiver's value cap of `valueBytes`. */
12
13
  export declare function contentLimitsOf(valueBytes: number): EncodeLimits;
14
+ /** Writes an {@link Attachment} met at `pointer`, `depth` levels deep: the JSON value written in
15
+ * its place. Without one, an attachment is no JSON data. */
16
+ export type PlaceAttachment = (attachment: Attachment, pointer: string, depth: number) => unknown;
13
17
  /** Validate a bounded data tree without invoking toJSON or property getters. */
14
- export declare function encodeContent(value: unknown, limits?: EncodeLimits): string;
18
+ export declare function encodeContent(value: unknown, limits?: EncodeLimits, place?: PlaceAttachment): string;
15
19
  /**
16
20
  * The value as JSON text within `limits.bytes`, and whether it was cut to fit. A value over the
17
21
  * cap is cut to a UTF-8 prefix of its JSON text at the cap, as Hue's receiver cuts a value over
@@ -22,7 +26,7 @@ export declare function encodeContent(value: unknown, limits?: EncodeLimits): st
22
26
  * among its members is never a shorter document that reads whole: the prefix ends at the cap,
23
27
  * inside whatever member the cap fell in.
24
28
  */
25
- export declare function encodeBoundedContent(value: unknown, limits?: EncodeLimits, cut?: boolean): {
29
+ export declare function encodeBoundedContent(value: unknown, limits?: EncodeLimits, cut?: boolean, place?: PlaceAttachment): {
26
30
  text: string;
27
31
  truncated: boolean;
28
32
  };
package/dist/safety.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { INVALID_SPAN_CONTEXT, trace } from "@opentelemetry/api";
2
2
  import { types as utilTypes } from "node:util";
3
+ import { Attachment, pointerSegment } from "./attachment.js";
3
4
  import { MAX_CONTENT_BYTES, MAX_RECORD_NODES } from "./config.js";
4
5
  export function noopSpan() {
5
6
  return trace.wrapSpanContext(INVALID_SPAN_CONTEXT);
@@ -114,8 +115,8 @@ export function contentLimitsOf(valueBytes) {
114
115
  return { ...contentLimits, bytes: valueBytes };
115
116
  }
116
117
  /** Validate a bounded data tree without invoking toJSON or property getters. */
117
- export function encodeContent(value, limits = contentLimits) {
118
- const { text, truncated } = encodeBoundedContent(value, limits, false);
118
+ export function encodeContent(value, limits = contentLimits, place) {
119
+ const { text, truncated } = encodeBoundedContent(value, limits, false, place);
119
120
  if (truncated)
120
121
  throw new RangeError("Content limit exceeded");
121
122
  return text;
@@ -130,12 +131,14 @@ export function encodeContent(value, limits = contentLimits) {
130
131
  * among its members is never a shorter document that reads whole: the prefix ends at the cap,
131
132
  * inside whatever member the cap fell in.
132
133
  */
133
- export function encodeBoundedContent(value, limits = contentLimits, cut = true) {
134
+ export function encodeBoundedContent(value, limits = contentLimits, cut = true, place) {
134
135
  let nodes = 0;
135
136
  let bytes = 0;
136
137
  let truncated = false;
137
138
  const parts = [];
138
139
  const ancestors = new Set();
140
+ // The keys from the root to the value being written, kept only to place attachments.
141
+ const path = [];
139
142
  const write = (text) => {
140
143
  parts.push(text);
141
144
  bytes += Buffer.byteLength(text);
@@ -172,6 +175,11 @@ export function encodeBoundedContent(value, limits = contentLimits, cut = true)
172
175
  // The native check also handles revoked proxies without invoking their traps.
173
176
  if (utilTypes.isProxy(item))
174
177
  throw new TypeError("JSON proxies are unsupported");
178
+ if (place && item instanceof Attachment) {
179
+ const pointer = path.length ? `/${path.map(pointerSegment).join("/")}` : "";
180
+ write(JSON.stringify(place(item, pointer, depth) ?? null));
181
+ return;
182
+ }
175
183
  const array = Array.isArray(item);
176
184
  if (!array && ![Object.prototype, null].includes(Object.getPrototypeOf(item)))
177
185
  throw new TypeError("Expected JSON data");
@@ -193,7 +201,11 @@ export function encodeBoundedContent(value, limits = contentLimits, cut = true)
193
201
  throw new TypeError("Expected JSON data property");
194
202
  write(`${first ? "" : ","}${array ? "" : `${JSON.stringify(key)}:`}`);
195
203
  first = false;
204
+ if (place)
205
+ path.push(key);
196
206
  visit(descriptor.value, depth + 1);
207
+ if (place)
208
+ path.pop();
197
209
  }
198
210
  write(array ? "]" : "}");
199
211
  ancestors.delete(item);
package/dist/setup/cli.js CHANGED
@@ -24,6 +24,10 @@ const extensions = new Map([
24
24
  "mcp",
25
25
  async () => (await loadCommand("mcp", () => import("../cli/mcp.js"))).runMcpCommand(process.argv.slice(3)),
26
26
  ],
27
+ [
28
+ "import",
29
+ async () => (await loadCommand("import", () => import("../cli/import.js"))).runImportCommand(process.argv.slice(3)),
30
+ ],
27
31
  [
28
32
  "listen",
29
33
  async () => (await loadCommand("listen", () => import("../cli/listen.js"))).runListenCommand(process.argv.slice(3)),
@@ -70,7 +74,7 @@ async function main() {
70
74
  }
71
75
  catch {
72
76
  if (!agentRequested) {
73
- process.stderr.write("Usage: hue <setup|resume|status|claim|login|eval|mcp|listen> [--agent|--format human|plain|jsonl] [--project PATH] [--origin URL] [--restart]\n");
77
+ process.stderr.write("Usage: hue <setup|resume|status|claim|login|eval|mcp|listen|import> [--agent|--format human|plain|jsonl] [--project PATH] [--origin URL] [--restart]\n");
74
78
  return 2;
75
79
  }
76
80
  const event = {
@@ -101,7 +105,7 @@ async function main() {
101
105
  process.stdout.write(`${renderJsonlEvent(event)}\n`);
102
106
  return 2;
103
107
  }
104
- process.stdout.write("Usage: hue <setup|resume|status|claim|login|eval|mcp|listen> [--agent|--format human|plain|jsonl] [--project PATH] [--origin URL] [--restart]\n");
108
+ process.stdout.write("Usage: hue <setup|resume|status|claim|login|eval|mcp|listen|import> [--agent|--format human|plain|jsonl] [--project PATH] [--origin URL] [--restart]\n");
105
109
  return 0;
106
110
  }
107
111
  const command = parsed.positionals[0];
@@ -133,7 +137,7 @@ async function main() {
133
137
  process.stdout.write(`${renderJsonlEvent(event)}\n`);
134
138
  }
135
139
  else
136
- process.stderr.write("Usage: hue <setup|resume|status|claim|login|eval|mcp|listen> [--agent|--format human|plain|jsonl] [--project PATH] [--origin URL] [--restart]\n");
140
+ process.stderr.write("Usage: hue <setup|resume|status|claim|login|eval|mcp|listen|import> [--agent|--format human|plain|jsonl] [--project PATH] [--origin URL] [--restart]\n");
137
141
  return 2;
138
142
  }
139
143
  const mode = selectSetupOutputMode({
@@ -34,4 +34,4 @@ export interface RecordSnapshot<T> {
34
34
  inspected: ReadonlySet<string>;
35
35
  }
36
36
  export declare function snapshotSpan(source: ReadableSpan, limit: number, captureContent?: boolean, valueUnits?: number, hold?: HoldLimits, memo?: MediaDigestMemo): RecordSnapshot<ReadableSpan>;
37
- export declare function snapshotLog(source: ReadableLogRecord, limit: number, captureContent?: boolean, valueUnits?: number): RecordSnapshot<ReadWriteLogRecord>;
37
+ export declare function snapshotLog(source: ReadableLogRecord, limit: number, captureContent?: boolean, valueUnits?: number, hold?: HoldLimits, memo?: MediaDigestMemo): RecordSnapshot<ReadWriteLogRecord>;