@intentius/chant-lexicon-aws 0.44.10 → 0.44.13
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.
- package/dist/agentcore/trace-fetch.d.ts +332 -0
- package/dist/agentcore/trace-fetch.d.ts.map +1 -0
- package/dist/agentcore/trace-render.d.ts +256 -0
- package/dist/agentcore/trace-render.d.ts.map +1 -0
- package/dist/api/read-client.d.ts +16 -0
- package/dist/api/read-client.d.ts.map +1 -1
- package/dist/identity-observe.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/op/activities/index.d.ts +10 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/agentcore/trace-fetch.test.ts +588 -0
- package/src/agentcore/trace-fetch.ts +754 -0
- package/src/agentcore/trace-render.test.ts +366 -0
- package/src/agentcore/trace-render.ts +589 -0
- package/src/api/read-client.ts +7 -1
- package/src/identity-observe.test.ts +44 -2
- package/src/identity-observe.ts +25 -2
- package/src/op/activities/index.ts +15 -0
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `awsAgentCoreFetchTrace` — pull a Bedrock AgentCore session history and
|
|
3
|
+
* render it as dogwood replay-trace text (#1685, follow-on from #1682).
|
|
4
|
+
*
|
|
5
|
+
* Contributed the way the fly lexicon contributes `flyApply` and the cedar
|
|
6
|
+
* lexicon contributes `dogwoodReplay`: a plain exported async function taking
|
|
7
|
+
* one args object, re-exported from `src/op/activities/index.ts`, resolved **by
|
|
8
|
+
* name** by core's activity registry when a project lists the `aws` lexicon.
|
|
9
|
+
* No Temporal import beneath it, so the local executor runs it unchanged and a
|
|
10
|
+
* Temporal worker registers the same function. Transport is injectable through
|
|
11
|
+
* the same `AwsReadHttp` seam `src/api/read-client.ts` already uses, so tests
|
|
12
|
+
* never touch the network and `endpoint` retargets the whole thing.
|
|
13
|
+
*
|
|
14
|
+
* The output is text. The cedar lexicon's `PolicyReplayOp` reads a trace from
|
|
15
|
+
* `tracePath`, so `outPath` here is the handoff — and it is the *only* handoff.
|
|
16
|
+
* Neither package imports the other; the decoupling contract is the grammar.
|
|
17
|
+
*
|
|
18
|
+
* ## Which AgentCore surface actually carries decision history
|
|
19
|
+
*
|
|
20
|
+
* Investigated before building, because the answer changes what can be shipped.
|
|
21
|
+
* Verified against the live API reference and devguide on 2026-08-10:
|
|
22
|
+
*
|
|
23
|
+
* - **There is no `GetTrace`, no `GetSession`, and no `ListRuntimeSessions`.**
|
|
24
|
+
* The `bedrock-agentcore` data plane (API version 2024-02-28) publishes
|
|
25
|
+
* `StopRuntimeSession` with no read counterpart — a runtime session can be
|
|
26
|
+
* killed but not enumerated. `bedrock-agentcore-control` (2023-06-05) is
|
|
27
|
+
* resource CRUD only.
|
|
28
|
+
* <https://docs.aws.amazon.com/bedrock-agentcore/latest/APIReference/API_Operations.html>
|
|
29
|
+
* - **AgentCore Memory is the one fetchable session history**, and it is what
|
|
30
|
+
* this module reads: `ListSessions`
|
|
31
|
+
* (`POST /memories/{memoryId}/actor/{actorId}/sessions`) and `ListEvents`
|
|
32
|
+
* (`POST /memories/{memoryId}/actor/{actorId}/sessions/{sessionId}`), both
|
|
33
|
+
* paginated, with a `payload[]` of `conversational` (roles `ASSISTANT`,
|
|
34
|
+
* `USER`, `TOOL`, `OTHER`) or `blob` members.
|
|
35
|
+
* <https://docs.aws.amazon.com/bedrock-agentcore/latest/APIReference/API_ListEvents.html>
|
|
36
|
+
* <https://docs.aws.amazon.com/bedrock-agentcore/latest/APIReference/API_PayloadType.html>
|
|
37
|
+
* The caveat is load-bearing and is repeated in {@link AgentCoreMemorySource}:
|
|
38
|
+
* Memory holds what the agent *wrote* via `CreateEvent`. It is a store, not a
|
|
39
|
+
* service-side audit. An agent that never writes leaves nothing to replay.
|
|
40
|
+
* - **The service's own per-tool-call and per-decision records are CloudWatch
|
|
41
|
+
* Logs, not an API.** Gateway `tools/call` bodies land in
|
|
42
|
+
* `/aws/vendedlogs/bedrock-agentcore/gateway/APPLICATION_LOGS/{gateway_id}`;
|
|
43
|
+
* the allow/deny itself lives in `aws/spans` span attributes
|
|
44
|
+
* (`aws.agentcore.policy.authorization_decision` = `ALLOW`|`DENY`,
|
|
45
|
+
* `…determining_policies`, `…effects`, `…temporal.event_timestamp_ns`).
|
|
46
|
+
* <https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/observability-gateway-metrics.html>
|
|
47
|
+
* <https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/observability-policy-metrics.html>
|
|
48
|
+
* Both are reachable only through CloudWatch Logs Insights / X-Ray
|
|
49
|
+
* Transaction Search, and the gateway record's `requestBody` is a Java map
|
|
50
|
+
* `toString` (`{id=1, jsonrpc=2.0, method=tools/call, params={…}}`) rather
|
|
51
|
+
* than JSON. Those two sources are therefore *named and refused* here rather
|
|
52
|
+
* than guessed at — see {@link AgentCoreTraceUnavailableError}. An honest
|
|
53
|
+
* partial beats an invented API.
|
|
54
|
+
* - **No emulator serves any of it.** Floci's service index lists no
|
|
55
|
+
* `bedrock-agentcore` at all (its only Bedrock entry is a `bedrock-runtime`
|
|
56
|
+
* stub), and MiniStack implements twelve control-plane runtime/endpoint
|
|
57
|
+
* operations and states that Memory, Gateway, Identity and the rest are not
|
|
58
|
+
* implemented. A control-plane emulator is structurally incapable of serving
|
|
59
|
+
* decision history anyway, since every per-call record lives in CloudWatch.
|
|
60
|
+
* So the testable path today is the injected transport, and the mocked
|
|
61
|
+
* fixtures in `./trace-fetch.test.ts` are the contract.
|
|
62
|
+
*
|
|
63
|
+
* ## Signing
|
|
64
|
+
*
|
|
65
|
+
* SigV4 through `read-client.ts`'s `requestHeaders`, which is the same seam its
|
|
66
|
+
* own CloudFormation and Cloud Control calls use (#1686). Signed when
|
|
67
|
+
* credentials resolve and the target is real AWS; scope-only against an
|
|
68
|
+
* endpoint override, since an emulator does not verify signatures and requiring
|
|
69
|
+
* credentials to read a local lane would be a tax with nothing behind it.
|
|
70
|
+
* `signEndpointOverride: true` opts back in for an override that *is* real AWS.
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
74
|
+
import { dirname, resolve } from "node:path";
|
|
75
|
+
import {
|
|
76
|
+
AwsReadError,
|
|
77
|
+
requestHeaders,
|
|
78
|
+
serviceUrl,
|
|
79
|
+
type AwsCredentialSource,
|
|
80
|
+
type AwsReadHttp,
|
|
81
|
+
} from "../api/read-client";
|
|
82
|
+
import {
|
|
83
|
+
agentCoreDecimal,
|
|
84
|
+
renderAgentCoreTrace,
|
|
85
|
+
AgentCoreTraceError,
|
|
86
|
+
type AgentCoreFields,
|
|
87
|
+
type AgentCoreSessionEvent,
|
|
88
|
+
type AgentCoreTimeOrigin,
|
|
89
|
+
type AgentCoreTraceIssue,
|
|
90
|
+
type AgentCoreTraceIssueKind,
|
|
91
|
+
type AgentCoreTraceValue,
|
|
92
|
+
} from "./trace-render";
|
|
93
|
+
|
|
94
|
+
const SERVICE = "bedrock-agentcore";
|
|
95
|
+
const DEFAULT_MAX_EVENTS = 1_000;
|
|
96
|
+
/** `ListEvents` bounds `maxResults` at 100. */
|
|
97
|
+
const PAGE_SIZE = 100;
|
|
98
|
+
|
|
99
|
+
/* ── Sources ──────────────────────────────────────────────────────────────── */
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Where a history is read from.
|
|
103
|
+
*
|
|
104
|
+
* Only `memory` is implemented, and the reason the other two are named rather
|
|
105
|
+
* than omitted is that "which surface carries this" is the question #1685 asked
|
|
106
|
+
* first. A caller who reaches for `spans` gets the finding, not a 404.
|
|
107
|
+
*/
|
|
108
|
+
export type AgentCoreTraceSource =
|
|
109
|
+
/** AgentCore Memory `ListEvents`. The one fetchable session history. */
|
|
110
|
+
| "memory"
|
|
111
|
+
/** Gateway vended `tools/call` logs. CloudWatch-only, and not JSON. */
|
|
112
|
+
| "gateway-logs"
|
|
113
|
+
/** `aws/spans` policy-decision span attributes. CloudWatch/X-Ray-only. */
|
|
114
|
+
| "spans";
|
|
115
|
+
|
|
116
|
+
/** Marker type for the documented Memory caveat. See the module header. */
|
|
117
|
+
export type AgentCoreMemorySource = Extract<AgentCoreTraceSource, "memory">;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A source that exists in AWS but is not reachable the way this module reads.
|
|
121
|
+
*
|
|
122
|
+
* The message names the finding and the surface, so a caller who hits it knows
|
|
123
|
+
* whether they are blocked on chant or on AWS. Both current cases are the
|
|
124
|
+
* latter: the record is real, it is in CloudWatch Logs, and getting at it is a
|
|
125
|
+
* Logs Insights integration rather than an AgentCore API call.
|
|
126
|
+
*/
|
|
127
|
+
export class AgentCoreTraceUnavailableError extends Error {
|
|
128
|
+
constructor(
|
|
129
|
+
readonly source: AgentCoreTraceSource,
|
|
130
|
+
message: string,
|
|
131
|
+
) {
|
|
132
|
+
super(message);
|
|
133
|
+
this.name = "AgentCoreTraceUnavailableError";
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const UNAVAILABLE: Record<Exclude<AgentCoreTraceSource, "memory">, string> = {
|
|
138
|
+
"gateway-logs":
|
|
139
|
+
"AgentCore Gateway does not publish an API that returns per-invocation tool calls. " +
|
|
140
|
+
"The record exists, vended to CloudWatch Logs at " +
|
|
141
|
+
"/aws/vendedlogs/bedrock-agentcore/gateway/APPLICATION_LOGS/{gateway_id}, but its requestBody " +
|
|
142
|
+
"field is a Java map toString ({id=1, jsonrpc=2.0, method=tools/call, params={…}}) rather than " +
|
|
143
|
+
"JSON, so reading it is a CloudWatch Logs Insights integration with its own parser, not a fetch. " +
|
|
144
|
+
'Use source: "memory" (AgentCore Memory ListEvents), or file the Logs Insights source as its own issue. ' +
|
|
145
|
+
"See https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/observability-gateway-metrics.html",
|
|
146
|
+
spans:
|
|
147
|
+
"AgentCore policy decisions are not returned by any published API. AuthorizeAction and " +
|
|
148
|
+
"PartiallyAuthorizeActions appear only as a CloudWatch metric dimension; the per-call allow/deny " +
|
|
149
|
+
"lives in aws.agentcore.policy.authorization_decision on spans in the aws/spans log group, " +
|
|
150
|
+
"readable through CloudWatch Logs Insights or xray:StartTraceRetrieval and requiring Transaction " +
|
|
151
|
+
'Search. Use source: "memory" for the tool-call history a replay actually needs — a replay ' +
|
|
152
|
+
"recomputes the verdict from the policy set, so the recorded decision is a comparison input, not " +
|
|
153
|
+
"a trace input. See https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/observability-policy-metrics.html",
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/* ── The Memory wire shapes ───────────────────────────────────────────────── */
|
|
157
|
+
|
|
158
|
+
/** One `payload[]` member, as `ListEvents` returns it. */
|
|
159
|
+
export interface MemoryPayloadMember {
|
|
160
|
+
readonly conversational?: { readonly role?: string; readonly content?: unknown };
|
|
161
|
+
readonly blob?: unknown;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** One event, as `ListEvents` returns it. Fields this module does not read are ignored. */
|
|
165
|
+
export interface MemoryEvent {
|
|
166
|
+
readonly eventId?: string;
|
|
167
|
+
readonly sessionId?: string;
|
|
168
|
+
readonly actorId?: string;
|
|
169
|
+
readonly memoryId?: string;
|
|
170
|
+
/** restJson1 timestamp: epoch seconds (possibly fractional) or an ISO-8601 string. */
|
|
171
|
+
readonly eventTimestamp?: number | string;
|
|
172
|
+
readonly metadata?: unknown;
|
|
173
|
+
readonly payload?: readonly MemoryPayloadMember[];
|
|
174
|
+
readonly branch?: { readonly name?: string; readonly rootEventId?: string };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
178
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/* ── Time ─────────────────────────────────────────────────────────────────── */
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Epoch milliseconds from whatever the wire carried.
|
|
185
|
+
*
|
|
186
|
+
* restJson1 renders a `timestamp` shape as epoch **seconds**, possibly
|
|
187
|
+
* fractional, but the docs' examples and several SDK paths show ISO-8601, so
|
|
188
|
+
* both are accepted. A bare number is disambiguated by magnitude: anything at
|
|
189
|
+
* or beyond 1e12 is already milliseconds (1e12 ms is 2001; 1e12 s is the year
|
|
190
|
+
* 33658), so the heuristic has no realistic collision. Anything else throws
|
|
191
|
+
* rather than being coerced — a guessed timestamp puts every temporal window in
|
|
192
|
+
* the replay in the wrong place.
|
|
193
|
+
*/
|
|
194
|
+
export function toEpochMs(value: unknown, what: string): number {
|
|
195
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
196
|
+
return value >= 1e12 ? Math.round(value) : Math.round(value * 1000);
|
|
197
|
+
}
|
|
198
|
+
if (typeof value === "string" && value.length > 0) {
|
|
199
|
+
const parsed = Date.parse(value);
|
|
200
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
201
|
+
}
|
|
202
|
+
throw new AgentCoreTraceError(
|
|
203
|
+
`agentcore trace: ${what} is not a usable timestamp (${JSON.stringify(value)}) — the history is malformed, and a guessed timepoint moves every temporal window in the replay`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/* ── JSON → trace values ──────────────────────────────────────────────────── */
|
|
208
|
+
|
|
209
|
+
/** What to do with a payload key the trace grammar cannot spell. */
|
|
210
|
+
export type NonIdentifierFieldPolicy = "rename" | "fail";
|
|
211
|
+
|
|
212
|
+
const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
213
|
+
|
|
214
|
+
/** `tool-name` → `tool_name`, `2fa` → `f_2fa`. Deterministic, so goldens hold. */
|
|
215
|
+
export function identifierFor(key: string): string {
|
|
216
|
+
const cleaned = key.replace(/[^A-Za-z0-9_]/g, "_");
|
|
217
|
+
return IDENT.test(cleaned) ? cleaned : `f_${cleaned}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** A key the coercion had to rewrite, reported so a rename is never silent. */
|
|
221
|
+
export interface FieldRename {
|
|
222
|
+
readonly path: string;
|
|
223
|
+
readonly from: string;
|
|
224
|
+
readonly to: string;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* A non-integer JS number as a decimal literal the Cedar surface accepts.
|
|
229
|
+
*
|
|
230
|
+
* `String(1e-7)` is `"1e-7"`, which is not a decimal literal, so a legitimate
|
|
231
|
+
* small payload number would otherwise abort the whole fetch. `toFixed(20)` is
|
|
232
|
+
* the widest non-exponential rendering JS offers, and the trailing zeros it
|
|
233
|
+
* pads with come back off.
|
|
234
|
+
*
|
|
235
|
+
* A value too small for even that rounds to `0.0`, and a trace that says a
|
|
236
|
+
* policy compared against zero when the agent saw something else is the whole
|
|
237
|
+
* failure class this module exists to prevent — so it throws instead, naming
|
|
238
|
+
* the field.
|
|
239
|
+
*/
|
|
240
|
+
export function decimalText(value: number, context?: string): string {
|
|
241
|
+
const plain = String(value);
|
|
242
|
+
if (!/e/i.test(plain)) return plain;
|
|
243
|
+
|
|
244
|
+
const fixed = value.toFixed(20).replace(/(\.\d*?)0+$/, "$1");
|
|
245
|
+
const text = fixed.endsWith(".") ? `${fixed}0` : fixed;
|
|
246
|
+
if (Number(text) !== value) {
|
|
247
|
+
const where = context ? ` at ${context}` : "";
|
|
248
|
+
throw new AgentCoreTraceError(
|
|
249
|
+
`agentcore trace: ${plain}${where} has no exact decimal spelling in the trace grammar — it would render as ${text}, and a policy comparing against that would be comparing against a number the agent never saw. Carry it as a string.`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
return text;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Coerce arbitrary JSON into trace values.
|
|
257
|
+
*
|
|
258
|
+
* A non-integer number becomes a Cedar decimal rather than being rounded,
|
|
259
|
+
* because the scale is the thing a policy compares against. `null` and
|
|
260
|
+
* `undefined` are dropped from *records*, because Cedar has no null and an
|
|
261
|
+
* invented sentinel would be matched by a predicate that meant something else.
|
|
262
|
+
* And a key the grammar cannot spell is renamed — every rename is returned, so
|
|
263
|
+
* a caller can see it, and `onNonIdentifierField: "fail"` refuses instead.
|
|
264
|
+
*
|
|
265
|
+
* Two things throw rather than being smoothed over, and both are the same
|
|
266
|
+
* failure this module refuses everywhere else — data that changes meaning
|
|
267
|
+
* without saying so. A `null` *inside an array* would shift every later index,
|
|
268
|
+
* so a predicate on `args[1]` would start matching `args[0]`'s value. And two
|
|
269
|
+
* payload keys that rewrite to the same identifier (`tool-name` and `tool.name`
|
|
270
|
+
* both become `tool_name`) would silently drop one of them.
|
|
271
|
+
*/
|
|
272
|
+
export function coerceFields(
|
|
273
|
+
value: unknown,
|
|
274
|
+
options: { onNonIdentifierField?: NonIdentifierFieldPolicy } = {},
|
|
275
|
+
path = "",
|
|
276
|
+
renames: FieldRename[] = [],
|
|
277
|
+
): { fields: AgentCoreFields; renames: FieldRename[] } {
|
|
278
|
+
const fields: Record<string, AgentCoreTraceValue> = {};
|
|
279
|
+
if (!isRecord(value)) return { fields, renames };
|
|
280
|
+
|
|
281
|
+
const sources = new Map<string, string>();
|
|
282
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
283
|
+
const here = path ? `${path}.${key}` : key;
|
|
284
|
+
const coerced = coerceValue(raw, options, here, renames);
|
|
285
|
+
if (coerced === undefined) continue;
|
|
286
|
+
let name = key;
|
|
287
|
+
if (!IDENT.test(key)) {
|
|
288
|
+
if (options.onNonIdentifierField === "fail") {
|
|
289
|
+
throw new AgentCoreTraceError(
|
|
290
|
+
`agentcore trace: the payload field "${here}" is not an identifier and the dogwood trace grammar cannot spell it — rename it at the source, or accept the rewrite by leaving onNonIdentifierField unset`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
name = identifierFor(key);
|
|
294
|
+
renames.push({ path: here, from: key, to: name });
|
|
295
|
+
}
|
|
296
|
+
const taken = sources.get(name);
|
|
297
|
+
if (taken !== undefined) {
|
|
298
|
+
throw new AgentCoreTraceError(
|
|
299
|
+
`agentcore trace: the payload fields "${taken}" and "${key}" both spell "${name}" in the trace grammar, so one would overwrite the other and a predicate on it would read the wrong value — rename one at the source`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
sources.set(name, key);
|
|
303
|
+
fields[name] = coerced;
|
|
304
|
+
}
|
|
305
|
+
return { fields, renames };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function coerceValue(
|
|
309
|
+
value: unknown,
|
|
310
|
+
options: { onNonIdentifierField?: NonIdentifierFieldPolicy },
|
|
311
|
+
path: string,
|
|
312
|
+
renames: FieldRename[],
|
|
313
|
+
): AgentCoreTraceValue | undefined {
|
|
314
|
+
if (value === null || value === undefined) return undefined;
|
|
315
|
+
if (typeof value === "string" || typeof value === "boolean") return value;
|
|
316
|
+
if (typeof value === "number") {
|
|
317
|
+
if (!Number.isFinite(value)) return undefined;
|
|
318
|
+
return Number.isInteger(value) ? value : agentCoreDecimal(decimalText(value, path), path);
|
|
319
|
+
}
|
|
320
|
+
if (Array.isArray(value)) {
|
|
321
|
+
return value.map((item, index) => {
|
|
322
|
+
const here = `${path}[${index}]`;
|
|
323
|
+
const coerced = coerceValue(item, options, here, renames);
|
|
324
|
+
if (coerced === undefined) {
|
|
325
|
+
throw new AgentCoreTraceError(
|
|
326
|
+
`agentcore trace: ${here} has no value the trace grammar can carry (${JSON.stringify(item) ?? "undefined"}), and dropping it would shift every later element — so a predicate written against a position in this array would read its neighbour's value. Fix it at the source.`,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
return coerced;
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
if (isRecord(value)) return coerceFields(value, options, path, renames).fields;
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* One payload group from a `blob` member.
|
|
338
|
+
*
|
|
339
|
+
* A blob is arbitrary JSON, so `input` is not necessarily a record —
|
|
340
|
+
* `input: "the prompt text"` is a perfectly ordinary thing for an agent to
|
|
341
|
+
* write. A non-record is wrapped under `value` the same way a non-record
|
|
342
|
+
* `conversational.content` is, rather than being dropped for not being the
|
|
343
|
+
* shape this module hoped for.
|
|
344
|
+
*/
|
|
345
|
+
function coerceGroup(
|
|
346
|
+
value: unknown,
|
|
347
|
+
options: { onNonIdentifierField?: NonIdentifierFieldPolicy },
|
|
348
|
+
path: string,
|
|
349
|
+
renames: FieldRename[],
|
|
350
|
+
): AgentCoreFields {
|
|
351
|
+
if (value === undefined || value === null) return {};
|
|
352
|
+
return coerceFields(isRecord(value) ? value : { value }, options, path, renames).fields;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/* ── Memory → normalized events ───────────────────────────────────────────── */
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* How a `conversational` role becomes an action and an event kind.
|
|
359
|
+
*
|
|
360
|
+
* The default is a convention, not a contract AWS publishes — `Conversational`
|
|
361
|
+
* carries a role and content and nothing else, so the tool name, if there is
|
|
362
|
+
* one, is inside the content the agent wrote. Override it per project.
|
|
363
|
+
*/
|
|
364
|
+
export interface RoleMapping {
|
|
365
|
+
readonly action: string;
|
|
366
|
+
readonly kind: string;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Default role mapping. `TOOL` is a decision-kind `request`: it is the call being authorized. */
|
|
370
|
+
export const DEFAULT_ROLE_MAPPING: Readonly<Record<string, RoleMapping>> = {
|
|
371
|
+
USER: { action: "Prompt", kind: "request" },
|
|
372
|
+
ASSISTANT: { action: "Respond", kind: "response" },
|
|
373
|
+
TOOL: { action: "InvokeTool", kind: "request" },
|
|
374
|
+
OTHER: { action: "Event", kind: "request" },
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The `blob` convention this module reads.
|
|
379
|
+
*
|
|
380
|
+
* A `blob` payload is arbitrary JSON, so there is nothing to parse until
|
|
381
|
+
* someone agrees on a shape. This is that shape, and it is the one worth
|
|
382
|
+
* writing from `CreateEvent`: `action` and `kind` decide the trace's grammar
|
|
383
|
+
* slots, `input`/`output`/`error` become the payload groups that land in both
|
|
384
|
+
* bags, and everything else becomes `attributes`. Every key is optional; an
|
|
385
|
+
* absent `action` falls back to the role mapping's `OTHER`.
|
|
386
|
+
*/
|
|
387
|
+
export const BLOB_KEYS = ["action", "kind", "input", "output", "error"] as const;
|
|
388
|
+
|
|
389
|
+
/** Options shared by the normalizer and the activity. */
|
|
390
|
+
export interface AgentCoreNormalizeOptions {
|
|
391
|
+
/** Overrides merged over {@link DEFAULT_ROLE_MAPPING}. */
|
|
392
|
+
readonly roles?: Readonly<Record<string, RoleMapping>>;
|
|
393
|
+
/** Cedar resource id for every event. Default: the event's `memoryId`. */
|
|
394
|
+
readonly target?: string;
|
|
395
|
+
/** Default `"rename"`. */
|
|
396
|
+
readonly onNonIdentifierField?: NonIdentifierFieldPolicy;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** A normalized history plus whatever the coercion had to rewrite. */
|
|
400
|
+
export interface NormalizedHistory {
|
|
401
|
+
readonly events: readonly AgentCoreSessionEvent[];
|
|
402
|
+
readonly renames: readonly FieldRename[];
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* `ListEvents` output → the renderer's normalized events. Pure: no transport.
|
|
407
|
+
*
|
|
408
|
+
* One event with N payload members becomes N normalized events sharing the
|
|
409
|
+
* event's timestamp, with `#0`, `#1`, … appended to the id — the trace's
|
|
410
|
+
* `requestId` has to identify a decision point, and a two-member event is two
|
|
411
|
+
* decision points. A member that is neither `conversational` nor `blob` is a
|
|
412
|
+
* shape this module does not know, and it throws rather than being skipped:
|
|
413
|
+
* a silently dropped tool call is a temporal predicate that silently misses.
|
|
414
|
+
*/
|
|
415
|
+
export function normalizeMemoryEvents(
|
|
416
|
+
events: readonly MemoryEvent[],
|
|
417
|
+
options: AgentCoreNormalizeOptions = {},
|
|
418
|
+
): NormalizedHistory {
|
|
419
|
+
const roles = { ...DEFAULT_ROLE_MAPPING, ...(options.roles ?? {}) };
|
|
420
|
+
const renames: FieldRename[] = [];
|
|
421
|
+
const out: AgentCoreSessionEvent[] = [];
|
|
422
|
+
|
|
423
|
+
events.forEach((event, index) => {
|
|
424
|
+
const where = `event ${index}${event.eventId ? ` (${event.eventId})` : ""}`;
|
|
425
|
+
const timeMs = toEpochMs(event.eventTimestamp, `${where}'s eventTimestamp`);
|
|
426
|
+
const members = event.payload ?? [];
|
|
427
|
+
if (members.length === 0) {
|
|
428
|
+
throw new AgentCoreTraceError(
|
|
429
|
+
`agentcore trace: ${where} has an empty payload — either includePayloads was false or the history is truncated, and a payload-less trace replays green while proving nothing`,
|
|
430
|
+
index,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
members.forEach((member, slot) => {
|
|
435
|
+
const suffix = members.length > 1 ? `#${slot}` : "";
|
|
436
|
+
const base = {
|
|
437
|
+
timeMs,
|
|
438
|
+
sessionId: event.sessionId ?? "",
|
|
439
|
+
eventId: `${event.eventId ?? ""}${suffix}`,
|
|
440
|
+
actor: event.actorId ?? "",
|
|
441
|
+
target: options.target ?? event.memoryId ?? "",
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
if (member.conversational) {
|
|
445
|
+
const role = member.conversational.role ?? "OTHER";
|
|
446
|
+
const mapping = roles[role] ?? roles.OTHER!;
|
|
447
|
+
const content = coerceGroup(
|
|
448
|
+
isRecord(member.conversational.content)
|
|
449
|
+
? member.conversational.content
|
|
450
|
+
: { text: member.conversational.content },
|
|
451
|
+
options,
|
|
452
|
+
`${where}.payload[${slot}].conversational.content`,
|
|
453
|
+
renames,
|
|
454
|
+
);
|
|
455
|
+
out.push({
|
|
456
|
+
...base,
|
|
457
|
+
action: mapping.action,
|
|
458
|
+
kind: mapping.kind,
|
|
459
|
+
...(Object.keys(content).length > 0 ? { input: content } : {}),
|
|
460
|
+
attributes: { role },
|
|
461
|
+
});
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (member.blob !== undefined) {
|
|
466
|
+
const blob = isRecord(member.blob) ? member.blob : { value: member.blob };
|
|
467
|
+
const prefix = `${where}.payload[${slot}].blob`;
|
|
468
|
+
const rest: Record<string, unknown> = {};
|
|
469
|
+
for (const [key, value] of Object.entries(blob)) {
|
|
470
|
+
if (!(BLOB_KEYS as readonly string[]).includes(key)) rest[key] = value;
|
|
471
|
+
}
|
|
472
|
+
const groups = {
|
|
473
|
+
input: coerceGroup(blob.input, options, `${prefix}.input`, renames),
|
|
474
|
+
output: coerceGroup(blob.output, options, `${prefix}.output`, renames),
|
|
475
|
+
error: coerceGroup(blob.error, options, `${prefix}.error`, renames),
|
|
476
|
+
attributes: coerceFields(rest, options, prefix, renames).fields,
|
|
477
|
+
};
|
|
478
|
+
out.push({
|
|
479
|
+
...base,
|
|
480
|
+
action: typeof blob.action === "string" && blob.action.length > 0 ? blob.action : roles.OTHER!.action,
|
|
481
|
+
kind: typeof blob.kind === "string" && blob.kind.length > 0 ? blob.kind : roles.OTHER!.kind,
|
|
482
|
+
...(Object.keys(groups.input).length > 0 ? { input: groups.input } : {}),
|
|
483
|
+
...(Object.keys(groups.output).length > 0 ? { output: groups.output } : {}),
|
|
484
|
+
...(Object.keys(groups.error).length > 0 ? { error: groups.error } : {}),
|
|
485
|
+
...(Object.keys(groups.attributes).length > 0 ? { attributes: groups.attributes } : {}),
|
|
486
|
+
});
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
throw new AgentCoreTraceError(
|
|
491
|
+
`agentcore trace: ${where}.payload[${slot}] is neither conversational nor blob — PayloadType is a union of exactly those two, so this is a shape change or a corrupt record, and skipping it would drop a decision point from the replay`,
|
|
492
|
+
index,
|
|
493
|
+
);
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
return { events: out, renames };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/* ── Transport ────────────────────────────────────────────────────────────── */
|
|
501
|
+
|
|
502
|
+
const defaultHttp: AwsReadHttp = async (url, init, signal) => {
|
|
503
|
+
const res = await fetch(url, { method: "POST", headers: init.headers, body: init.body, signal });
|
|
504
|
+
return { status: res.status, text: await res.text() };
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
/** Where to read from, how to reach it, and what to sign with. */
|
|
508
|
+
export interface AgentCoreReadOptions {
|
|
509
|
+
/** Endpoint override. Omit for the real regional host. */
|
|
510
|
+
readonly endpoint?: string;
|
|
511
|
+
/** Default `us-east-1`. */
|
|
512
|
+
readonly region?: string;
|
|
513
|
+
/**
|
|
514
|
+
* What to sign with: literal credentials, or a resolver that decides.
|
|
515
|
+
* Omitted, the environment answers; with nothing there, the request goes out
|
|
516
|
+
* carrying the credential scope and no signature.
|
|
517
|
+
*/
|
|
518
|
+
readonly credentials?: AwsCredentialSource;
|
|
519
|
+
/** Environment the credential fallback reads. Defaults to `process.env`; injectable for tests. */
|
|
520
|
+
readonly env?: Record<string, string | undefined>;
|
|
521
|
+
/** Sign even against an endpoint override — for an override that is real AWS. */
|
|
522
|
+
readonly signEndpointOverride?: boolean;
|
|
523
|
+
/** Signing clock. Injected by tests so a signature is reproducible. */
|
|
524
|
+
readonly now?: Date;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function agentCorePost(
|
|
528
|
+
path: string,
|
|
529
|
+
body: Record<string, unknown>,
|
|
530
|
+
options: AgentCoreReadOptions,
|
|
531
|
+
http: AwsReadHttp,
|
|
532
|
+
signal?: AbortSignal,
|
|
533
|
+
): Promise<Record<string, unknown>> {
|
|
534
|
+
const url = `${serviceUrl(SERVICE, options.endpoint, options.region)}${path.replace(/^\//, "")}`;
|
|
535
|
+
const wire = JSON.stringify(body);
|
|
536
|
+
const res = await http(
|
|
537
|
+
url,
|
|
538
|
+
{
|
|
539
|
+
headers: requestHeaders(SERVICE, url, wire, { "content-type": "application/json" }, options),
|
|
540
|
+
body: wire,
|
|
541
|
+
},
|
|
542
|
+
signal,
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
let parsed: unknown;
|
|
546
|
+
try {
|
|
547
|
+
parsed = JSON.parse(res.text);
|
|
548
|
+
} catch {
|
|
549
|
+
throw new AwsReadError(`unparseable ${SERVICE} response for ${path}`, res.status);
|
|
550
|
+
}
|
|
551
|
+
const payload = isRecord(parsed) ? parsed : {};
|
|
552
|
+
const type = typeof payload.__type === "string" ? payload.__type.split("#").pop() : undefined;
|
|
553
|
+
if (type || res.status >= 400) {
|
|
554
|
+
const message = typeof payload.message === "string" ? payload.message : `${path} failed with HTTP ${res.status}`;
|
|
555
|
+
throw new AwsReadError(message, res.status, type);
|
|
556
|
+
}
|
|
557
|
+
return payload;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Path-segment encoding for the Memory REST routes. */
|
|
561
|
+
function segment(value: string, what: string): string {
|
|
562
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
563
|
+
throw new AgentCoreTraceError(`agentcore trace: ${what} is required to read a session history`);
|
|
564
|
+
}
|
|
565
|
+
return encodeURIComponent(value);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** What {@link listMemoryEvents} takes. */
|
|
569
|
+
export interface ListMemoryEventsArgs extends AgentCoreReadOptions {
|
|
570
|
+
readonly memoryId: string;
|
|
571
|
+
readonly actorId: string;
|
|
572
|
+
readonly sessionId: string;
|
|
573
|
+
/** Stop after this many events. Default 1000 — a runaway session is not a trace. */
|
|
574
|
+
readonly maxEvents?: number;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* `ListEvents` for one session, paginated to exhaustion or to `maxEvents`.
|
|
579
|
+
*
|
|
580
|
+
* `includePayloads` is always true: without it the response carries event ids
|
|
581
|
+
* and timestamps and nothing to match a predicate against, which is a trace
|
|
582
|
+
* that replays green and proves nothing.
|
|
583
|
+
*/
|
|
584
|
+
export async function listMemoryEvents(
|
|
585
|
+
args: ListMemoryEventsArgs,
|
|
586
|
+
signal?: AbortSignal,
|
|
587
|
+
http: AwsReadHttp = defaultHttp,
|
|
588
|
+
): Promise<MemoryEvent[]> {
|
|
589
|
+
const path =
|
|
590
|
+
`memories/${segment(args.memoryId, "memoryId")}` +
|
|
591
|
+
`/actor/${segment(args.actorId, "actorId")}` +
|
|
592
|
+
`/sessions/${segment(args.sessionId, "sessionId")}`;
|
|
593
|
+
const cap = args.maxEvents ?? DEFAULT_MAX_EVENTS;
|
|
594
|
+
// `ListEvents` bounds maxResults at 1..100, so a cap of 0 would go out as a
|
|
595
|
+
// request the service rejects rather than as an empty result.
|
|
596
|
+
if (!Number.isInteger(cap) || cap < 1) {
|
|
597
|
+
throw new AgentCoreTraceError(`agentcore trace: maxEvents must be a positive integer — got ${String(cap)}`);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const out: MemoryEvent[] = [];
|
|
601
|
+
let nextToken: string | undefined;
|
|
602
|
+
do {
|
|
603
|
+
const body = await agentCorePost(
|
|
604
|
+
path,
|
|
605
|
+
{
|
|
606
|
+
includePayloads: true,
|
|
607
|
+
maxResults: Math.min(PAGE_SIZE, cap - out.length),
|
|
608
|
+
...(nextToken ? { nextToken } : {}),
|
|
609
|
+
},
|
|
610
|
+
args,
|
|
611
|
+
http,
|
|
612
|
+
signal,
|
|
613
|
+
);
|
|
614
|
+
const events = Array.isArray(body.events) ? (body.events as MemoryEvent[]) : [];
|
|
615
|
+
out.push(...events);
|
|
616
|
+
nextToken = typeof body.nextToken === "string" && body.nextToken.length > 0 ? body.nextToken : undefined;
|
|
617
|
+
if (events.length === 0) break;
|
|
618
|
+
} while (nextToken && out.length < cap);
|
|
619
|
+
|
|
620
|
+
return out.slice(0, cap);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/* ── The activity ─────────────────────────────────────────────────────────── */
|
|
624
|
+
|
|
625
|
+
/** What {@link awsAgentCoreFetchTrace} takes. */
|
|
626
|
+
export interface AwsAgentCoreFetchTraceArgs extends AgentCoreReadOptions, AgentCoreNormalizeOptions {
|
|
627
|
+
/** Default `"memory"`. The other two are named and refused — see the module header. */
|
|
628
|
+
readonly source?: AgentCoreTraceSource;
|
|
629
|
+
/** AgentCore Memory resource id. */
|
|
630
|
+
readonly memoryId: string;
|
|
631
|
+
/** Actor whose sessions are being read. */
|
|
632
|
+
readonly actorId: string;
|
|
633
|
+
/** The session to replay. */
|
|
634
|
+
readonly sessionId: string;
|
|
635
|
+
/**
|
|
636
|
+
* Window start, inclusive. Read by {@link toEpochMs}, the same function the
|
|
637
|
+
* event timestamps go through — so epoch seconds, epoch milliseconds, or
|
|
638
|
+
* anything `Date.parse` reads, and a bound is never on a different scale
|
|
639
|
+
* from the events it is being compared against.
|
|
640
|
+
*/
|
|
641
|
+
readonly since?: number | string;
|
|
642
|
+
/** Window end, inclusive. Read the same way as {@link since}. */
|
|
643
|
+
readonly until?: number | string;
|
|
644
|
+
/** Cap on events fetched before the window filter. Default 1000. */
|
|
645
|
+
readonly maxEvents?: number;
|
|
646
|
+
|
|
647
|
+
/** Cedar namespace for actions and entity types. Default `"AgentCore"`. */
|
|
648
|
+
readonly namespace?: string;
|
|
649
|
+
/** Entity type for the actor. Default `"Actor"`. */
|
|
650
|
+
readonly principalType?: string;
|
|
651
|
+
/** Entity type for the target. Default `"Runtime"`. */
|
|
652
|
+
readonly resourceType?: string;
|
|
653
|
+
/** Default `"epoch-seconds"`. */
|
|
654
|
+
readonly origin?: AgentCoreTimeOrigin;
|
|
655
|
+
/** Kinds that build a Cedar request. Default `["request"]`. */
|
|
656
|
+
readonly decisionKinds?: readonly string[];
|
|
657
|
+
/** Weakenings to tolerate. Empty by default. */
|
|
658
|
+
readonly allow?: readonly AgentCoreTraceIssueKind[];
|
|
659
|
+
|
|
660
|
+
/** Write the rendered trace here — what `PolicyReplayOp`'s `tracePath` reads. */
|
|
661
|
+
readonly outPath?: string;
|
|
662
|
+
/** Throw when the window produced no events. Default `true`. */
|
|
663
|
+
readonly requireEvents?: boolean;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** What {@link awsAgentCoreFetchTrace} returns. */
|
|
667
|
+
export interface AwsAgentCoreFetchTraceResult {
|
|
668
|
+
readonly source: AgentCoreTraceSource;
|
|
669
|
+
readonly sessionId: string;
|
|
670
|
+
/** The rendered trace, one event per line, newline-terminated. */
|
|
671
|
+
readonly text: string;
|
|
672
|
+
/** Lines in the trace — decision points plus history-only events. */
|
|
673
|
+
readonly lineCount: number;
|
|
674
|
+
/** Events `ListEvents` returned before the window filter. */
|
|
675
|
+
readonly fetched: number;
|
|
676
|
+
/** Absolute path written, when `outPath` was given. */
|
|
677
|
+
readonly outPath?: string;
|
|
678
|
+
/** Payload keys the trace grammar could not spell, rewritten and reported. */
|
|
679
|
+
readonly renames: readonly FieldRename[];
|
|
680
|
+
/** Weakenings the caller allowed. Empty unless `allow` named something. */
|
|
681
|
+
readonly issues: readonly AgentCoreTraceIssue[];
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function windowBound(value: number | string | undefined, what: string): number | undefined {
|
|
685
|
+
if (value === undefined) return undefined;
|
|
686
|
+
return toEpochMs(value, what);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Fetch an AgentCore session history and render it as dogwood trace text.
|
|
691
|
+
*
|
|
692
|
+
* The window is applied client-side because `ListEvents`' `filter` takes a
|
|
693
|
+
* branch and event metadata and has no time predicate, so a server-side window
|
|
694
|
+
* is not on offer. `maxEvents` bounds the fetch; `since`/`until` bound what is
|
|
695
|
+
* normalized and rendered.
|
|
696
|
+
*/
|
|
697
|
+
export async function awsAgentCoreFetchTrace(
|
|
698
|
+
args: AwsAgentCoreFetchTraceArgs,
|
|
699
|
+
signal?: AbortSignal,
|
|
700
|
+
http: AwsReadHttp = defaultHttp,
|
|
701
|
+
): Promise<AwsAgentCoreFetchTraceResult> {
|
|
702
|
+
const source = args.source ?? "memory";
|
|
703
|
+
if (source !== "memory") {
|
|
704
|
+
const detail = UNAVAILABLE[source];
|
|
705
|
+
if (!detail) {
|
|
706
|
+
throw new AgentCoreTraceUnavailableError(source, `agentcore trace: unknown source "${String(source)}"`);
|
|
707
|
+
}
|
|
708
|
+
throw new AgentCoreTraceUnavailableError(source, `agentcore trace: ${detail}`);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const raw = await listMemoryEvents(args, signal, http);
|
|
712
|
+
const since = windowBound(args.since, "the window's `since`");
|
|
713
|
+
const until = windowBound(args.until, "the window's `until`");
|
|
714
|
+
|
|
715
|
+
// The window is applied to the *fetched* events, before normalizing, so an
|
|
716
|
+
// event the caller deliberately excluded cannot fail the run. Normalizing
|
|
717
|
+
// first would let one payload-less record anywhere in the fetched range abort
|
|
718
|
+
// a narrowed window with no way to recover short of shrinking maxEvents.
|
|
719
|
+
const inWindow = raw.filter((event, index) => {
|
|
720
|
+
const at = toEpochMs(event.eventTimestamp, `event ${index}${event.eventId ? ` (${event.eventId})` : ""}'s eventTimestamp`);
|
|
721
|
+
return (since === undefined || at >= since) && (until === undefined || at <= until);
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
const { events: windowed, renames } = normalizeMemoryEvents(inWindow, args);
|
|
725
|
+
|
|
726
|
+
if (windowed.length === 0 && (args.requireEvents ?? true)) {
|
|
727
|
+
const window = since !== undefined || until !== undefined ? " in the requested window" : "";
|
|
728
|
+
throw new AgentCoreTraceError(
|
|
729
|
+
`agentcore trace: session ${args.sessionId} has no events${window} (${raw.length} fetched). ` +
|
|
730
|
+
"AgentCore Memory holds what the agent wrote through CreateEvent, not a service-side audit, so an " +
|
|
731
|
+
"agent that never writes leaves nothing to replay. Pass requireEvents: false to accept an empty trace.",
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const { text, lines, issues } = renderAgentCoreTrace(windowed, args);
|
|
736
|
+
|
|
737
|
+
let written: string | undefined;
|
|
738
|
+
if (args.outPath) {
|
|
739
|
+
written = resolve(args.outPath);
|
|
740
|
+
await mkdir(dirname(written), { recursive: true });
|
|
741
|
+
await writeFile(written, text, "utf8");
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
return {
|
|
745
|
+
source,
|
|
746
|
+
sessionId: args.sessionId,
|
|
747
|
+
text,
|
|
748
|
+
lineCount: lines.length,
|
|
749
|
+
fetched: raw.length,
|
|
750
|
+
...(written ? { outPath: written } : {}),
|
|
751
|
+
renames,
|
|
752
|
+
issues,
|
|
753
|
+
};
|
|
754
|
+
}
|