@punktechnologies/sdk 0.1.0 → 0.1.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.
package/src/index.ts ADDED
@@ -0,0 +1,1559 @@
1
+ /**
2
+ * @punktechnologies/sdk — developer-facing client for the Punk gateway.
3
+ *
4
+ * Point your agent's gateway traffic at Punk, wrap your tools with
5
+ * `traceTool`, and the runtime observes, caches, learns and (after replay +
6
+ * shadow proof) routes repeated work through deterministic artifacts.
7
+ */
8
+
9
+ import type {
10
+ Artifact,
11
+ ArtifactEvaluation,
12
+ McpServerRecord,
13
+ McpTestResult,
14
+ Pattern,
15
+ PolicyVerdict,
16
+ PromotionGateStatus,
17
+ Run,
18
+ SavingsSummary,
19
+ SideEffectLevel,
20
+ SideEffectRecord,
21
+ SomDiff,
22
+ SomSnapshot,
23
+ TraceEvent,
24
+ TraceEventType,
25
+ TrustLane,
26
+ WebActionIntent,
27
+ WebActionResult,
28
+ } from "./types";
29
+
30
+ // Local mirrors of the @punk/trace-schema contracts (keeps the published SDK
31
+ // dependency-free). Re-exported so consumers can type their own code.
32
+ export type * from "./types";
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Public types
36
+ // ---------------------------------------------------------------------------
37
+
38
+ export interface PunkOptions {
39
+ /** Gateway base URL. Default: http://localhost:4100 */
40
+ baseUrl?: string;
41
+ /** Bearer token (only needed when the gateway sets PUNK_API_KEY). */
42
+ apiKey?: string;
43
+ /** Logical application name, sent as X-Punk-App. Default: "default-app". */
44
+ app?: string;
45
+ /** Agent identity, sent as X-Punk-Agent. */
46
+ agent?: string;
47
+ /** Pseudonymous end-user identifier, sent as X-Punk-Subject. */
48
+ subject?: string;
49
+ }
50
+
51
+ export const PUNK_CHORUS_MODEL = "punk/chorus" as const;
52
+ /** @deprecated Use PUNK_CHORUS_MODEL. */
53
+ export const PUNK_MODEL = PUNK_CHORUS_MODEL;
54
+ const DEFAULT_BASE_URL = "http://localhost:4100";
55
+ const MAX_TOOL_CACHE_TTL_SECONDS = 24 * 60 * 60;
56
+ const MAX_TOOL_CACHE_DIMENSION_CHARS = 120;
57
+
58
+ export type PunkLatencyMode =
59
+ | "fast"
60
+ | "balanced"
61
+ | "deep"
62
+ | "maximum_quality"
63
+ | (string & {});
64
+ export type PunkQualityMode =
65
+ | "economy"
66
+ | "balanced"
67
+ | "frontier_optional"
68
+ | "maximum_quality"
69
+ | (string & {});
70
+ export type PunkReceiptMode =
71
+ | "off"
72
+ | "none"
73
+ | "summary"
74
+ | "full"
75
+ | (string & {});
76
+ export type PunkCircuitMode =
77
+ | "off"
78
+ | "reuse"
79
+ | "learn"
80
+ | (string & {});
81
+ export type PunkAuditLevel =
82
+ | "minimal"
83
+ | "standard"
84
+ | "full"
85
+ | (string & {});
86
+ export type PunkResearchMode =
87
+ | "off"
88
+ | "som"
89
+ | "plasmate"
90
+ | "deep"
91
+ | (string & {});
92
+ export type PunkModelClass =
93
+ | "commercial"
94
+ | "frontier"
95
+ | "open_weight"
96
+ | "local"
97
+ | (string & {});
98
+
99
+ export interface PunkChorusMetadata {
100
+ requestId?: string;
101
+ workflowId?: string;
102
+ tenantHint?: string;
103
+ labels?: string[];
104
+ [key: string]: unknown;
105
+ }
106
+
107
+ export interface PunkChorusOptions {
108
+ budget_limit_usd?: number;
109
+ latency_mode?: PunkLatencyMode;
110
+ quality_mode?: PunkQualityMode;
111
+ policy_profile?: string;
112
+ receipt_mode?: PunkReceiptMode;
113
+ circuit_mode?: PunkCircuitMode;
114
+ shadow_mode?: boolean;
115
+ audit_level?: PunkAuditLevel;
116
+ /** Enable the governed SOTA mix preset for maximum-quality Chorus runs. */
117
+ sota_mix?: boolean;
118
+ research_mode?: PunkResearchMode;
119
+ research_max_queries?: number;
120
+ research_max_sources?: number;
121
+ research_context_chars?: number;
122
+ live_panel_models?: string[] | string;
123
+ live_synthesis_model?: string;
124
+ live_synthesis_required?: boolean;
125
+ live_synthesis_max_tokens?: number;
126
+ /** Compatibility alias for live_synthesis_model. */
127
+ answer_model?: string;
128
+ /** Compatibility alias for live_synthesis_max_tokens. */
129
+ answer_max_tokens?: number;
130
+ local_only?: boolean;
131
+ allowed_model_classes?: PunkModelClass[];
132
+ blocked_providers?: string[];
133
+ /** Punk Chorus metadata forwarded in the OpenAI-compatible request body. */
134
+ chorus?: PunkChorusMetadata;
135
+ }
136
+
137
+ export interface ChatParams extends PunkChorusOptions {
138
+ model: string;
139
+ messages: Array<{ role: string; content: string }>;
140
+ temperature?: number;
141
+ max_tokens?: number;
142
+ max_completion_tokens?: number;
143
+ top_p?: number;
144
+ response_format?: unknown;
145
+ tools?: ChatTool[];
146
+ tool_choice?: unknown;
147
+ }
148
+
149
+ export interface ChatTool {
150
+ type?: "function";
151
+ function?: {
152
+ name: string;
153
+ description?: string;
154
+ parameters?: Record<string, unknown>;
155
+ sideEffectLevel?: SideEffectLevel;
156
+ ttlSeconds?: number;
157
+ };
158
+ name?: string;
159
+ description?: string;
160
+ parameters?: Record<string, unknown>;
161
+ sideEffectLevel?: SideEffectLevel;
162
+ ttlSeconds?: number;
163
+ }
164
+
165
+ export interface ChatToolCall {
166
+ id: string;
167
+ type: "function";
168
+ function: {
169
+ name: string;
170
+ arguments: string;
171
+ };
172
+ }
173
+
174
+ function stringifyToolArguments(value: unknown): string {
175
+ if (typeof value === "string") return value;
176
+ try {
177
+ return JSON.stringify(value ?? {}) ?? "{}";
178
+ } catch {
179
+ return "{}";
180
+ }
181
+ }
182
+
183
+ function normalizeChatToolCalls(value: unknown): ChatToolCall[] {
184
+ if (!Array.isArray(value)) return [];
185
+ return value
186
+ .map((tc: any, index: number): ChatToolCall | null => {
187
+ const name = tc?.function?.name;
188
+ if (typeof name !== "string" || name.length === 0) return null;
189
+ return {
190
+ id: typeof tc?.id === "string" && tc.id.length > 0 ? tc.id : `call_${index}`,
191
+ type: "function",
192
+ function: {
193
+ name,
194
+ arguments: stringifyToolArguments(tc.function.arguments),
195
+ },
196
+ };
197
+ })
198
+ .filter((tc): tc is ChatToolCall => tc !== null);
199
+ }
200
+
201
+ function malformedChatResponse(method: string, path: string, reason: string): Error {
202
+ return new Error(`Punk API ${method} ${path} returned malformed chat response: ${reason}`);
203
+ }
204
+
205
+ function malformedApiResponse(method: string, path: string, reason: string): Error {
206
+ return new Error(`Punk API ${method} ${path} returned malformed response: ${reason}`);
207
+ }
208
+
209
+ function isRecord(value: unknown): value is Record<string, unknown> {
210
+ return value !== null && typeof value === "object" && !Array.isArray(value);
211
+ }
212
+
213
+ function requireArrayProperty<T>(
214
+ value: unknown,
215
+ key: string,
216
+ method: string,
217
+ path: string,
218
+ ): T[] {
219
+ if (!isRecord(value) || !Array.isArray(value[key])) {
220
+ throw malformedApiResponse(method, path, `missing ${key} array`);
221
+ }
222
+ return value[key] as T[];
223
+ }
224
+
225
+ function requireObjectProperty<T>(
226
+ value: unknown,
227
+ key: string,
228
+ method: string,
229
+ path: string,
230
+ ): T {
231
+ if (!isRecord(value) || !isRecord(value[key])) {
232
+ throw malformedApiResponse(method, path, `missing ${key} object`);
233
+ }
234
+ return value[key] as T;
235
+ }
236
+
237
+ function requireWebFetchResult(value: unknown, method: string, path: string): WebFetchResult {
238
+ if (!isRecord(value)) throw malformedApiResponse(method, path, "missing response object");
239
+ if (!isRecord(value.som)) throw malformedApiResponse(method, path, "missing som object");
240
+ if (typeof value.source !== "string" || value.source.length === 0) {
241
+ throw malformedApiResponse(method, path, "missing source string");
242
+ }
243
+ if (typeof value.url !== "string" || value.url.length === 0) {
244
+ throw malformedApiResponse(method, path, "missing url string");
245
+ }
246
+ if (typeof value.requestedUrl !== "string" || value.requestedUrl.length === 0) {
247
+ throw malformedApiResponse(method, path, "missing requestedUrl string");
248
+ }
249
+ if (typeof value.cached !== "boolean") {
250
+ throw malformedApiResponse(method, path, "missing cached boolean");
251
+ }
252
+ if (typeof value.context !== "string") {
253
+ throw malformedApiResponse(method, path, "missing context string");
254
+ }
255
+ for (const key of ["htmlBytes", "somBytes", "tokensSavedEstimate"] as const) {
256
+ if (typeof value[key] !== "number" || !Number.isFinite(value[key])) {
257
+ throw malformedApiResponse(method, path, `missing finite ${key}`);
258
+ }
259
+ }
260
+ return value as unknown as WebFetchResult;
261
+ }
262
+
263
+ function requireWebSessionOpenResult(value: unknown, method: string, path: string): WebSessionOpenResult {
264
+ if (!isRecord(value)) throw malformedApiResponse(method, path, "missing response object");
265
+ if (typeof value.sessionId !== "string" || value.sessionId.length === 0) {
266
+ throw malformedApiResponse(method, path, "missing sessionId string");
267
+ }
268
+ if (!isRecord(value.som)) throw malformedApiResponse(method, path, "missing som object");
269
+ if (typeof value.source !== "string" || value.source.length === 0) {
270
+ throw malformedApiResponse(method, path, "missing source string");
271
+ }
272
+ if (typeof value.context !== "string") {
273
+ throw malformedApiResponse(method, path, "missing context string");
274
+ }
275
+ const session = requireWebSessionSummary(value.session, method, path, "session");
276
+ if (session.id !== value.sessionId) {
277
+ throw malformedApiResponse(method, path, "session.id does not match sessionId");
278
+ }
279
+ return value as unknown as WebSessionOpenResult;
280
+ }
281
+
282
+ function requireWebActResult(
283
+ value: unknown,
284
+ method: string,
285
+ path: string,
286
+ expectedSessionId?: string,
287
+ ): WebActResult {
288
+ if (!isRecord(value)) throw malformedApiResponse(method, path, "missing response object");
289
+ if (!isRecord(value.result)) throw malformedApiResponse(method, path, "missing result object");
290
+ requireWebActionResult(value.result, method, path);
291
+ if (!isRecord(value.som)) throw malformedApiResponse(method, path, "missing som object");
292
+ if (value.diff !== undefined && !isRecord(value.diff)) {
293
+ throw malformedApiResponse(method, path, "diff must be an object when present");
294
+ }
295
+ const session = requireWebSessionSummary(value.session, method, path, "session");
296
+ if (typeof value.context !== "string") {
297
+ throw malformedApiResponse(method, path, "missing context string");
298
+ }
299
+ if (expectedSessionId !== undefined && session.id !== expectedSessionId) {
300
+ throw malformedApiResponse(method, path, "session.id does not match requested session");
301
+ }
302
+ return value as unknown as WebActResult;
303
+ }
304
+
305
+ function requireWebActionResult(value: Record<string, unknown>, method: string, path: string): void {
306
+ if (typeof value.ok !== "boolean") {
307
+ throw malformedApiResponse(method, path, "result.ok must be boolean");
308
+ }
309
+ if (typeof value.action !== "string" || !["click", "type", "select", "submit"].includes(value.action)) {
310
+ throw malformedApiResponse(method, path, "result.action must be click|type|select|submit");
311
+ }
312
+ if (typeof value.target !== "string" || value.target.length === 0) {
313
+ throw malformedApiResponse(method, path, "result.target must be a non-empty string");
314
+ }
315
+ if (typeof value.url !== "string" || value.url.length === 0) {
316
+ throw malformedApiResponse(method, path, "result.url must be a non-empty string");
317
+ }
318
+ if (value.resolved !== undefined && typeof value.resolved !== "string") {
319
+ throw malformedApiResponse(method, path, "result.resolved must be a string when present");
320
+ }
321
+ if (value.navigated !== undefined && typeof value.navigated !== "boolean") {
322
+ throw malformedApiResponse(method, path, "result.navigated must be boolean when present");
323
+ }
324
+ if (value.requestedUrl !== undefined && typeof value.requestedUrl !== "string") {
325
+ throw malformedApiResponse(method, path, "result.requestedUrl must be a string when present");
326
+ }
327
+ if (value.error !== undefined && typeof value.error !== "string") {
328
+ throw malformedApiResponse(method, path, "result.error must be a string when present");
329
+ }
330
+ if (value.posted !== undefined) {
331
+ if (!isRecord(value.posted)) {
332
+ throw malformedApiResponse(method, path, "result.posted must be an object when present");
333
+ }
334
+ for (const [key, postedValue] of Object.entries(value.posted)) {
335
+ if (typeof postedValue !== "string") {
336
+ throw malformedApiResponse(method, path, `result.posted.${key} must be a string`);
337
+ }
338
+ }
339
+ }
340
+ }
341
+
342
+ function requireWebSessionSummary(
343
+ value: unknown,
344
+ method: string,
345
+ path: string,
346
+ label: string,
347
+ ): WebSessionSummary {
348
+ if (!isRecord(value)) throw malformedApiResponse(method, path, `missing ${label} object`);
349
+ if (typeof value.id !== "string" || value.id.length === 0) {
350
+ throw malformedApiResponse(method, path, `${label}.id must be a non-empty string`);
351
+ }
352
+ if (typeof value.url !== "string" || value.url.length === 0) {
353
+ throw malformedApiResponse(method, path, `${label}.url must be a non-empty string`);
354
+ }
355
+ if (typeof value.source !== "string" || value.source.length === 0) {
356
+ throw malformedApiResponse(method, path, `${label}.source must be a non-empty string`);
357
+ }
358
+ if (typeof value.openedAt !== "number" || !Number.isFinite(value.openedAt)) {
359
+ throw malformedApiResponse(method, path, `${label}.openedAt must be finite`);
360
+ }
361
+ if (typeof value.ageMs !== "number" || !Number.isFinite(value.ageMs) || value.ageMs < 0) {
362
+ throw malformedApiResponse(method, path, `${label}.ageMs must be a non-negative finite number`);
363
+ }
364
+ return value as unknown as WebSessionSummary;
365
+ }
366
+
367
+ function requireWebSessionsListResult(
368
+ value: unknown,
369
+ method: string,
370
+ path: string,
371
+ ): { sessions: WebSessionSummary[] } {
372
+ const sessions = requireArrayProperty<Record<string, unknown>>(value, "sessions", method, path);
373
+ for (let i = 0; i < sessions.length; i++) {
374
+ requireWebSessionSummary(sessions[i]!, method, path, `sessions[${i}]`);
375
+ }
376
+ return value as { sessions: WebSessionSummary[] };
377
+ }
378
+
379
+ function requireOkResult(value: unknown, method: string, path: string): { ok: true } {
380
+ if (!isRecord(value) || value.ok !== true) {
381
+ throw malformedApiResponse(method, path, "missing ok true");
382
+ }
383
+ return { ok: true };
384
+ }
385
+
386
+ function requireNonNegativeIntegerProperty(
387
+ value: Record<string, unknown>,
388
+ key: string,
389
+ method: string,
390
+ path: string,
391
+ label = key,
392
+ ): number {
393
+ const raw = value[key];
394
+ if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw < 0) {
395
+ throw malformedApiResponse(method, path, `${label} must be a non-negative integer`);
396
+ }
397
+ return raw;
398
+ }
399
+
400
+ function requireNonEmptyStringProperty(
401
+ value: Record<string, unknown>,
402
+ key: string,
403
+ method: string,
404
+ path: string,
405
+ label = key,
406
+ ): string {
407
+ const raw = value[key];
408
+ if (typeof raw !== "string" || raw.trim().length === 0) {
409
+ throw malformedApiResponse(method, path, `${label} must be a non-empty string`);
410
+ }
411
+ return raw;
412
+ }
413
+
414
+ function requireFiniteNonNegativeNumberProperty(
415
+ value: Record<string, unknown>,
416
+ key: string,
417
+ method: string,
418
+ path: string,
419
+ label = key,
420
+ ): number {
421
+ const raw = value[key];
422
+ if (typeof raw !== "number" || !Number.isFinite(raw) || raw < 0) {
423
+ throw malformedApiResponse(method, path, `${label} must be a non-negative finite number`);
424
+ }
425
+ return raw;
426
+ }
427
+
428
+ function requireOptionalFiniteNonNegativeNumberProperty(
429
+ value: Record<string, unknown>,
430
+ key: string,
431
+ method: string,
432
+ path: string,
433
+ label = key,
434
+ ): void {
435
+ if (value[key] === undefined) return;
436
+ requireFiniteNonNegativeNumberProperty(value, key, method, path, label);
437
+ }
438
+
439
+ function requireUnitRateProperty(
440
+ value: Record<string, unknown>,
441
+ key: string,
442
+ method: string,
443
+ path: string,
444
+ label = key,
445
+ ): number {
446
+ const raw = requireFiniteNonNegativeNumberProperty(value, key, method, path, label);
447
+ if (raw > 1) {
448
+ throw malformedApiResponse(method, path, `${label} must be between 0 and 1`);
449
+ }
450
+ return raw;
451
+ }
452
+
453
+ function requireSavingsSummary(value: unknown, method: string, path: string): SavingsSummary {
454
+ if (!isRecord(value)) throw malformedApiResponse(method, path, "missing response object");
455
+ requireNonEmptyStringProperty(value, "tenantId", method, path);
456
+ for (const key of ["totalRuns", "liveRuns", "optimizedRuns", "blockedRuns", "cacheHits", "artifactHits", "somTokensSaved"] as const) {
457
+ requireNonNegativeIntegerProperty(value, key, method, path);
458
+ }
459
+ for (const key of ["totalCostUsd", "totalSavedUsd", "ghostSavedUsd", "totalSavedMs"] as const) {
460
+ requireFiniteNonNegativeNumberProperty(value, key, method, path);
461
+ }
462
+ requireUnitRateProperty(value, "cacheHitRate", method, path);
463
+ requireUnitRateProperty(value, "artifactHitRate", method, path);
464
+ const totalRuns = value.totalRuns as number;
465
+ const accountedRuns = (value.liveRuns as number) + (value.optimizedRuns as number) + (value.blockedRuns as number);
466
+ if (accountedRuns !== totalRuns) {
467
+ throw malformedApiResponse(method, path, "run counters must add up to totalRuns");
468
+ }
469
+ if ((value.cacheHits as number) > (value.optimizedRuns as number)) {
470
+ throw malformedApiResponse(method, path, "cacheHits cannot exceed optimizedRuns");
471
+ }
472
+ if ((value.artifactHits as number) > (value.optimizedRuns as number)) {
473
+ throw malformedApiResponse(method, path, "artifactHits cannot exceed optimizedRuns");
474
+ }
475
+ return value as unknown as SavingsSummary;
476
+ }
477
+
478
+ const RUN_STATUSES = new Set(["running", "completed", "failed", "blocked"]);
479
+ const ARTIFACT_STATES = new Set(["candidate", "replay_failed", "replay_passed", "shadowing", "shadow_failed", "approved", "promoted", "canary", "stable", "degraded", "quarantined", "retired"]);
480
+ const ARTIFACT_TYPES = new Set(["static_response", "template", "deterministic_transform", "ruleset_classifier", "tool_plan", "web_extraction", "hybrid"]);
481
+
482
+ function requireRunRecord(value: unknown, method: string, path: string, label = "run"): Run {
483
+ if (!isRecord(value)) throw malformedApiResponse(method, path, `missing ${label} object`);
484
+ for (const key of ["id", "tenantId", "appId", "requestHash"] as const) {
485
+ requireNonEmptyStringProperty(value, key, method, path, `${label}.${key}`);
486
+ }
487
+ const status = requireNonEmptyStringProperty(value, "status", method, path, `${label}.status`);
488
+ if (!RUN_STATUSES.has(status)) {
489
+ throw malformedApiResponse(method, path, `${label}.status must be running|completed|failed|blocked`);
490
+ }
491
+ for (const key of ["inputTokens", "outputTokens"] as const) {
492
+ requireNonNegativeIntegerProperty(value, key, method, path, `${label}.${key}`);
493
+ }
494
+ for (const key of ["costUsd", "savedUsd", "latencyMs", "startedAt"] as const) {
495
+ requireFiniteNonNegativeNumberProperty(value, key, method, path, `${label}.${key}`);
496
+ }
497
+ requireOptionalFiniteNonNegativeNumberProperty(value, "ghostSavedUsd", method, path, `${label}.ghostSavedUsd`);
498
+ requireOptionalFiniteNonNegativeNumberProperty(value, "completedAt", method, path, `${label}.completedAt`);
499
+ if (value.route !== undefined && (typeof value.route !== "string" || value.route.trim().length === 0)) {
500
+ throw malformedApiResponse(method, path, `${label}.route must be a non-empty string when present`);
501
+ }
502
+ return value as unknown as Run;
503
+ }
504
+
505
+ function requireTraceEventRecord(value: unknown, method: string, path: string, label: string): TraceEvent {
506
+ if (!isRecord(value)) throw malformedApiResponse(method, path, `${label} must be an object`);
507
+ for (const key of ["id", "runId", "tenantId", "type"] as const) {
508
+ requireNonEmptyStringProperty(value, key, method, path, `${label}.${key}`);
509
+ }
510
+ requireFiniteNonNegativeNumberProperty(value, "ts", method, path, `${label}.ts`);
511
+ if (!isRecord(value.payload)) {
512
+ throw malformedApiResponse(method, path, `${label}.payload must be an object`);
513
+ }
514
+ return value as unknown as TraceEvent;
515
+ }
516
+
517
+ function requireSideEffectRecord(value: unknown, method: string, path: string, label: string): SideEffectRecord {
518
+ if (!isRecord(value)) throw malformedApiResponse(method, path, `${label} must be an object`);
519
+ for (const key of ["id", "runId", "tenantId", "toolName", "status"] as const) {
520
+ requireNonEmptyStringProperty(value, key, method, path, `${label}.${key}`);
521
+ }
522
+ const level = requireNonNegativeIntegerProperty(value, "level", method, path, `${label}.level`);
523
+ if (level > 4) throw malformedApiResponse(method, path, `${label}.level must be between 0 and 4`);
524
+ requireFiniteNonNegativeNumberProperty(value, "createdAt", method, path, `${label}.createdAt`);
525
+ return value as unknown as SideEffectRecord;
526
+ }
527
+
528
+ function requireRunDetailResult(value: unknown, method: string, path: string): RunDetail {
529
+ if (!isRecord(value)) throw malformedApiResponse(method, path, "missing response object");
530
+ requireRunRecord(value.run, method, path);
531
+ const events = requireArrayProperty<unknown>(value, "events", method, path);
532
+ events.forEach((event, index) => requireTraceEventRecord(event, method, path, `events[${index}]`));
533
+ const sideEffects = requireArrayProperty<unknown>(value, "sideEffects", method, path);
534
+ sideEffects.forEach((sideEffect, index) => requireSideEffectRecord(sideEffect, method, path, `sideEffects[${index}]`));
535
+ return value as unknown as RunDetail;
536
+ }
537
+
538
+ function requireArtifactRecord(value: unknown, method: string, path: string, label = "artifact"): Artifact {
539
+ if (!isRecord(value)) throw malformedApiResponse(method, path, `missing ${label} object`);
540
+ for (const key of ["id", "tenantId", "patternId", "state", "type"] as const) {
541
+ requireNonEmptyStringProperty(value, key, method, path, `${label}.${key}`);
542
+ }
543
+ if (!ARTIFACT_STATES.has(String(value.state))) {
544
+ throw malformedApiResponse(method, path, `${label}.state is not a known artifact state`);
545
+ }
546
+ if (!ARTIFACT_TYPES.has(String(value.type))) {
547
+ throw malformedApiResponse(method, path, `${label}.type is not a known artifact type`);
548
+ }
549
+ if (!isRecord(value.representation)) throw malformedApiResponse(method, path, `${label}.representation must be an object`);
550
+ if (!isRecord(value.provenance)) throw malformedApiResponse(method, path, `${label}.provenance must be an object`);
551
+ requireNonNegativeIntegerProperty(value, "version", method, path, `${label}.version`);
552
+ const sideEffectLevel = requireNonNegativeIntegerProperty(value, "sideEffectLevel", method, path, `${label}.sideEffectLevel`);
553
+ if (sideEffectLevel > 4) throw malformedApiResponse(method, path, `${label}.sideEffectLevel must be between 0 and 4`);
554
+ for (const key of ["replayPasses", "replayFails", "shadowPasses", "shadowFails", "livePasses", "liveFails"] as const) {
555
+ requireNonNegativeIntegerProperty(value, key, method, path, `${label}.${key}`);
556
+ }
557
+ for (const key of ["alpha", "beta", "confidence", "estCostUsd", "estLatencyMs", "createdAt"] as const) {
558
+ requireFiniteNonNegativeNumberProperty(value, key, method, path, `${label}.${key}`);
559
+ }
560
+ if ((value.confidence as number) > 1) throw malformedApiResponse(method, path, `${label}.confidence must be between 0 and 1`);
561
+ requireOptionalFiniteNonNegativeNumberProperty(value, "promotedAt", method, path, `${label}.promotedAt`);
562
+ requireOptionalFiniteNonNegativeNumberProperty(value, "lastEvaluatedAt", method, path, `${label}.lastEvaluatedAt`);
563
+ return value as unknown as Artifact;
564
+ }
565
+
566
+ function requireArtifactEvaluationRecord(value: unknown, method: string, path: string, label: string): ArtifactEvaluation {
567
+ if (!isRecord(value)) throw malformedApiResponse(method, path, `${label} must be an object`);
568
+ for (const key of ["id", "artifactId", "tenantId", "kind"] as const) {
569
+ requireNonEmptyStringProperty(value, key, method, path, `${label}.${key}`);
570
+ }
571
+ if (!["replay", "shadow", "live"].includes(String(value.kind))) {
572
+ throw malformedApiResponse(method, path, `${label}.kind must be replay|shadow|live`);
573
+ }
574
+ for (const key of ["pass", "structuralPass", "sideEffectPass"] as const) {
575
+ if (typeof value[key] !== "boolean") throw malformedApiResponse(method, path, `${label}.${key} must be boolean`);
576
+ }
577
+ requireUnitRateProperty(value, "semanticScore", method, path, `${label}.semanticScore`);
578
+ requireFiniteNonNegativeNumberProperty(value, "createdAt", method, path, `${label}.createdAt`);
579
+ return value as unknown as ArtifactEvaluation;
580
+ }
581
+
582
+ function requirePromotionGateStatus(value: unknown, method: string, path: string): PromotionGateStatus {
583
+ if (!isRecord(value)) throw malformedApiResponse(method, path, "promotionGate must be an object");
584
+ if (typeof value.eligible !== "boolean") throw malformedApiResponse(method, path, "promotionGate.eligible must be boolean");
585
+ if (typeof value.autoPromotable !== "boolean") throw malformedApiResponse(method, path, "promotionGate.autoPromotable must be boolean");
586
+ if (!Array.isArray(value.reasons) || value.reasons.some((reason) => typeof reason !== "string")) {
587
+ throw malformedApiResponse(method, path, "promotionGate.reasons must be a string array");
588
+ }
589
+ return value as unknown as PromotionGateStatus;
590
+ }
591
+
592
+ function requireArtifactDetailResult(value: unknown, method: string, path: string): ArtifactDetail {
593
+ if (!isRecord(value)) throw malformedApiResponse(method, path, "missing response object");
594
+ requireArtifactRecord(value.artifact, method, path);
595
+ const evaluations = requireArrayProperty<unknown>(value, "evaluations", method, path);
596
+ evaluations.forEach((evaluation, index) => requireArtifactEvaluationRecord(evaluation, method, path, `evaluations[${index}]`));
597
+ if (value.pattern !== null && value.pattern !== undefined && !isRecord(value.pattern)) {
598
+ throw malformedApiResponse(method, path, "pattern must be an object or null");
599
+ }
600
+ if (value.promotionGate !== undefined) requirePromotionGateStatus(value.promotionGate, method, path);
601
+ return value as unknown as ArtifactDetail;
602
+ }
603
+
604
+ function requireReceiptResult(value: unknown, method: string, path: string): PunkReceipt {
605
+ if (!isRecord(value)) throw malformedApiResponse(method, path, "missing response object");
606
+ if (value.punkReceipt !== 1) throw malformedApiResponse(method, path, "missing punkReceipt marker");
607
+ requireNonEmptyStringProperty(value, "runId", method, path);
608
+ if (!isRecord(value.receipt)) throw malformedApiResponse(method, path, "missing receipt object");
609
+ if (!Array.isArray(value.ledger)) throw malformedApiResponse(method, path, "missing ledger array");
610
+ return value as PunkReceipt;
611
+ }
612
+
613
+ function requireEvidencePacketResult(value: unknown, method: string, path: string): EvidencePacket {
614
+ if (!isRecord(value)) throw malformedApiResponse(method, path, "missing response object");
615
+ if (value.punkEvidencePacket !== 1) throw malformedApiResponse(method, path, "missing punkEvidencePacket marker");
616
+ if (!isRecord(value.summary)) throw malformedApiResponse(method, path, "missing summary object");
617
+ requireRunRecord(value.run, method, path);
618
+ for (const key of ["events", "sideEffects", "auditEvents"] as const) {
619
+ if (!Array.isArray(value[key])) throw malformedApiResponse(method, path, `missing ${key} array`);
620
+ }
621
+ if (!isRecord(value.integrity)) throw malformedApiResponse(method, path, "missing integrity object");
622
+ if (!isRecord(value.replay)) throw malformedApiResponse(method, path, "missing replay object");
623
+ return value as EvidencePacket;
624
+ }
625
+
626
+ function requireLearningReport(value: unknown, method: string, path: string): LearningReport {
627
+ if (!isRecord(value)) throw malformedApiResponse(method, path, "missing response object");
628
+ requireNonNegativeIntegerProperty(value, "artifactsSynthesized", method, path);
629
+ if (!Array.isArray(value.promotionsEligible)) {
630
+ throw malformedApiResponse(method, path, "missing promotionsEligible array");
631
+ }
632
+ if (!Array.isArray(value.autoPromoted)) {
633
+ throw malformedApiResponse(method, path, "missing autoPromoted array");
634
+ }
635
+ return value as unknown as LearningReport;
636
+ }
637
+
638
+ function requireCacheStatsResult(value: unknown, method: string, path: string): CacheStats {
639
+ const stats = requireArrayProperty<Record<string, unknown>>(value, "stats", method, path);
640
+ for (let i = 0; i < stats.length; i++) {
641
+ const stat = stats[i]!;
642
+ if (!isRecord(stat)) {
643
+ throw malformedApiResponse(method, path, `stats[${i}] must be an object`);
644
+ }
645
+ if (typeof stat.cacheType !== "string" || stat.cacheType.trim().length === 0) {
646
+ throw malformedApiResponse(method, path, `stats[${i}].cacheType must be a non-empty string`);
647
+ }
648
+ requireNonNegativeIntegerProperty(stat, "entries", method, path, `stats[${i}].entries`);
649
+ requireNonNegativeIntegerProperty(stat, "hits", method, path, `stats[${i}].hits`);
650
+ }
651
+ return value as CacheStats;
652
+ }
653
+
654
+ function requireMcpServerRecord(value: unknown, method: string, path: string, label: string): McpServerRecord {
655
+ if (!isRecord(value)) throw malformedApiResponse(method, path, `${label} must be an object`);
656
+ if (typeof value.id !== "string" || value.id.length === 0) {
657
+ throw malformedApiResponse(method, path, `${label}.id must be a non-empty string`);
658
+ }
659
+ if (typeof value.name !== "string" || value.name.length === 0) {
660
+ throw malformedApiResponse(method, path, `${label}.name must be a non-empty string`);
661
+ }
662
+ if (typeof value.transport !== "string" || !["stdio", "http"].includes(value.transport)) {
663
+ throw malformedApiResponse(method, path, `${label}.transport must be stdio|http`);
664
+ }
665
+ return value as unknown as McpServerRecord;
666
+ }
667
+
668
+ function requireMcpServerList(value: unknown, method: string, path: string): McpServerRecord[] {
669
+ const servers = requireArrayProperty<unknown>(value, "servers", method, path);
670
+ return servers.map((server, index) => requireMcpServerRecord(server, method, path, `servers[${index}]`));
671
+ }
672
+
673
+ function requireMcpServerCreateResult(value: unknown, method: string, path: string): McpServerRecord {
674
+ return requireMcpServerRecord(requireObjectProperty<unknown>(value, "server", method, path), method, path, "server");
675
+ }
676
+
677
+ function cleanPathId(label: string, value: string): string {
678
+ if (typeof value !== "string") {
679
+ throw new Error(`Punk SDK requires a string ${label}`);
680
+ }
681
+ const trimmed = value.trim();
682
+ if (!trimmed) {
683
+ throw new Error(`Punk SDK requires a non-empty ${label}`);
684
+ }
685
+ return trimmed;
686
+ }
687
+
688
+ function normalizeBaseUrl(value: string | undefined): string {
689
+ const trimmed = typeof value === "string" ? value.trim() : "";
690
+ return (trimmed || DEFAULT_BASE_URL).replace(/\/+$/, "");
691
+ }
692
+
693
+ function firstChatMessage(raw: unknown, method: string, path: string): unknown {
694
+ if (raw === null || typeof raw !== "object" || !Array.isArray((raw as any).choices)) {
695
+ throw malformedChatResponse(method, path, "missing choices array");
696
+ }
697
+ const choice = (raw as any).choices[0];
698
+ if (choice === null || typeof choice !== "object" || Array.isArray(choice)) {
699
+ throw malformedChatResponse(method, path, "missing choices[0]");
700
+ }
701
+ const message = choice.message;
702
+ if (message === null || typeof message !== "object" || Array.isArray(message)) {
703
+ throw malformedChatResponse(method, path, "missing choices[0].message");
704
+ }
705
+ const content = (message as any).content;
706
+ const hasToolCalls = normalizeChatToolCalls((message as any).tool_calls).length > 0;
707
+ const hasRefusal = typeof (message as any).refusal === "string";
708
+ if ((content === undefined || content === null) && !hasToolCalls && !hasRefusal) {
709
+ throw malformedChatResponse(method, path, "choices[0].message has no content or tool_calls");
710
+ }
711
+ return message;
712
+ }
713
+
714
+ function chatMessageContentToString(value: unknown): string {
715
+ if (value === null || value === undefined) return "";
716
+ if (typeof value === "string") return value;
717
+ if (!Array.isArray(value)) {
718
+ throw malformedChatResponse("POST", "/v1/chat/completions", "message.content was not a string, null, or text parts array");
719
+ }
720
+ return value
721
+ .map((part: any) => {
722
+ if (typeof part === "string") return part;
723
+ if (typeof part?.text === "string") return part.text;
724
+ return "";
725
+ })
726
+ .join("");
727
+ }
728
+
729
+ function chatMessageText(message: any): string {
730
+ if ((message?.content === null || message?.content === undefined) && typeof message?.refusal === "string") {
731
+ return message.refusal;
732
+ }
733
+ return chatMessageContentToString(message?.content);
734
+ }
735
+
736
+ const TOOL_LEVEL_4 = new Set([
737
+ "delete", "remove", "drop", "pay", "charge", "refund", "purchase", "subscribe",
738
+ "unsubscribe", "transfer", "withdraw", "deposit", "revoke", "grant", "invite",
739
+ "promote", "demote", "admin", "owner", "role", "permission", "permissions",
740
+ "password", "secret", "token", "credential", "credentials", "terminate",
741
+ "destroy", "disable", "deactivate", "suspend", "ban", "cancel", "deploy", "release",
742
+ "rollback",
743
+ ]);
744
+ const TOOL_LEVEL_3 = new Set([
745
+ "send", "post", "create", "write", "notify", "email", "slack", "ticket",
746
+ "message", "sms", "comment", "reply", "publish", "submit", "approve", "merge",
747
+ "webhook", "alert",
748
+ ]);
749
+ const TOOL_LEVEL_2 = new Set(["update", "upsert", "tag", "label"]);
750
+ const TOOL_LEVEL_1 = new Set([
751
+ "get", "list", "read", "search", "fetch", "lookup", "query",
752
+ ]);
753
+ const TOOL_LEVEL_0 = new Set(["format", "parse", "transform", "calc", "calculate"]);
754
+
755
+ function toolNameTokens(toolName: string): string[] {
756
+ return toolName
757
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
758
+ .split(/[\s._\-:/]+/)
759
+ .map((token) => token.toLowerCase())
760
+ .filter(Boolean);
761
+ }
762
+
763
+ function normalizeSideEffectLevelValue(value: unknown): SideEffectLevel | undefined {
764
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 4
765
+ ? (value as SideEffectLevel)
766
+ : undefined;
767
+ }
768
+
769
+ function inferSideEffectLevel(toolName: string): { level: SideEffectLevel; matched: boolean } {
770
+ const tokens = toolNameTokens(toolName);
771
+ if (
772
+ tokens.includes("key") &&
773
+ tokens.some((token) => token === "api" || token === "auth" || token === "secret" || token === "credential")
774
+ ) {
775
+ return { level: 4, matched: true };
776
+ }
777
+ if (tokens.some((token) => TOOL_LEVEL_4.has(token))) return { level: 4, matched: true };
778
+ if (tokens.some((token) => TOOL_LEVEL_3.has(token))) return { level: 3, matched: true };
779
+ if (tokens.some((token) => TOOL_LEVEL_2.has(token))) return { level: 2, matched: true };
780
+ if (tokens.some((token) => TOOL_LEVEL_1.has(token))) return { level: 1, matched: true };
781
+ if (tokens.some((token) => TOOL_LEVEL_0.has(token))) return { level: 0, matched: true };
782
+ return { level: 3, matched: false };
783
+ }
784
+
785
+ function classifySdkToolSideEffect(toolName: string, declared: unknown): SideEffectLevel {
786
+ const inferred = inferSideEffectLevel(toolName);
787
+ const validDeclared = normalizeSideEffectLevelValue(declared);
788
+ if (validDeclared === undefined) return Math.max(3, inferred.level) as SideEffectLevel;
789
+ return (inferred.matched ? Math.max(validDeclared, inferred.level) : validDeclared) as SideEffectLevel;
790
+ }
791
+
792
+ function normalizeTtlSeconds(value: unknown): number | undefined {
793
+ if (value === undefined) return undefined;
794
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
795
+ throw new Error("Punk traceTool requires 'ttlSeconds' to be a positive integer when provided");
796
+ }
797
+ if (value > MAX_TOOL_CACHE_TTL_SECONDS) {
798
+ throw new Error(`Punk traceTool requires 'ttlSeconds' to be ${MAX_TOOL_CACHE_TTL_SECONDS} or fewer`);
799
+ }
800
+ return value;
801
+ }
802
+
803
+ function cleanIdentityValue(value: string | undefined): string | undefined {
804
+ if (typeof value !== "string") return undefined;
805
+ const trimmed = value.trim();
806
+ return trimmed.length > 0 ? trimmed : undefined;
807
+ }
808
+
809
+ function cleanOptionalCacheDimension(label: string, value: string | undefined): string | undefined {
810
+ if (typeof value !== "string") return undefined;
811
+ const trimmed = value.trim();
812
+ if (trimmed.length === 0 || trimmed !== value || trimmed.length > MAX_TOOL_CACHE_DIMENSION_CHARS) {
813
+ throw new Error(`Punk traceTool requires '${label}' to be a non-empty trimmed string ${MAX_TOOL_CACHE_DIMENSION_CHARS} characters or fewer`);
814
+ }
815
+ return trimmed;
816
+ }
817
+
818
+ function requireCleanToolName(value: unknown): string {
819
+ if (typeof value !== "string") {
820
+ throw new Error("Punk traceTool requires a string tool name");
821
+ }
822
+ const trimmed = value.trim();
823
+ if (!trimmed) {
824
+ throw new Error("Punk traceTool requires a non-empty tool name");
825
+ }
826
+ if (trimmed.length > MAX_TOOL_CACHE_DIMENSION_CHARS) {
827
+ throw new Error(`Punk traceTool requires a tool name ${MAX_TOOL_CACHE_DIMENSION_CHARS} characters or fewer`);
828
+ }
829
+ return trimmed;
830
+ }
831
+
832
+ function toolErrorPayload(err: unknown): Record<string, unknown> {
833
+ if (err instanceof Error) {
834
+ return {
835
+ name: err.name,
836
+ message: err.message,
837
+ };
838
+ }
839
+ return { message: String(err) };
840
+ }
841
+
842
+ function toolCacheHitHasResult(hit: { hit: boolean; result?: unknown }): hit is { hit: true; result: unknown } {
843
+ return hit.hit === true && Object.prototype.hasOwnProperty.call(hit, "result") && hit.result !== undefined;
844
+ }
845
+
846
+ function normalizeTraceDecision(value: unknown): PolicyVerdict | null {
847
+ if (!isRecord(value)) return null;
848
+ const decision = value.decision;
849
+ if (decision !== "allow" && decision !== "deny" && decision !== "approval_required") return null;
850
+ return {
851
+ ...((value as unknown) as Omit<PolicyVerdict, "decision" | "reason">),
852
+ decision,
853
+ reason: typeof value.reason === "string" ? value.reason : "",
854
+ };
855
+ }
856
+
857
+ export interface ChatResult {
858
+ content: string;
859
+ toolCalls: ChatToolCall[];
860
+ runId: string;
861
+ route: string;
862
+ raw: any;
863
+ }
864
+
865
+ export interface PunkReceipt {
866
+ id?: string;
867
+ runId?: string;
868
+ route?: string;
869
+ chorus?: PunkChorusMetadata;
870
+ metadata?: Record<string, unknown>;
871
+ [key: string]: unknown;
872
+ }
873
+
874
+ export interface EvidencePacket {
875
+ punkEvidencePacket?: number;
876
+ summary?: Record<string, unknown>;
877
+ routeExplanation?: unknown;
878
+ events?: unknown[];
879
+ chorus?: PunkChorusMetadata;
880
+ metadata?: Record<string, unknown>;
881
+ [key: string]: unknown;
882
+ }
883
+
884
+ export interface WebFetchResult {
885
+ som: SomSnapshot;
886
+ source: string; // "plasmate" | "builtin" | "cache"
887
+ cached: boolean;
888
+ /** Canonical observed URL after redirects and fragment normalization. */
889
+ url: string;
890
+ /** Caller-supplied URL after server-side trim/canonicalization, before redirects. */
891
+ requestedUrl: string;
892
+ htmlBytes: number;
893
+ somBytes: number;
894
+ tokensSavedEstimate: number;
895
+ diff?: SomDiff;
896
+ context: string;
897
+ }
898
+
899
+ export interface WebSessionSummary {
900
+ id: string;
901
+ url: string;
902
+ source: string; // "plasmate" | "builtin"
903
+ openedAt: number;
904
+ ageMs: number;
905
+ }
906
+
907
+ export interface WebSessionOpenResult {
908
+ sessionId: string;
909
+ som: SomSnapshot;
910
+ source: string; // "plasmate" | "builtin"
911
+ session: WebSessionSummary;
912
+ context: string;
913
+ }
914
+
915
+ export interface WebActResult {
916
+ result: WebActionResult;
917
+ som: SomSnapshot;
918
+ diff?: SomDiff;
919
+ session: WebSessionSummary;
920
+ context: string;
921
+ }
922
+
923
+ export interface LearningReport {
924
+ artifactsSynthesized: number;
925
+ promotionsEligible: string[];
926
+ autoPromoted: string[];
927
+ synthesisReports?: Array<Record<string, unknown>>;
928
+ [key: string]: unknown;
929
+ }
930
+
931
+ export interface RunDetail {
932
+ run: Run;
933
+ events: TraceEvent[];
934
+ sideEffects: SideEffectRecord[];
935
+ }
936
+
937
+ export interface ArtifactDetail {
938
+ artifact: Artifact;
939
+ evaluations: ArtifactEvaluation[];
940
+ pattern: Pattern | null;
941
+ promotionGate?: PromotionGateStatus;
942
+ }
943
+
944
+ export interface CacheStats {
945
+ stats: Array<{ cacheType: string; entries: number; hits: number }>;
946
+ }
947
+
948
+ export interface ToolDefinition<TArgs, TResult> {
949
+ name: string;
950
+ /** 0 pure … 4 high-impact. Undeclared tools are treated as level 3 (conservative). */
951
+ sideEffectLevel?: SideEffectLevel;
952
+ /** Read-only tools (level <= 1) with a TTL participate in the tool-result cache. */
953
+ ttlSeconds?: number;
954
+ /** Optional schema/version fingerprint used to isolate tool-result cache entries across contract changes. */
955
+ schemaFp?: string;
956
+ execute: (args: TArgs) => Promise<TResult> | TResult;
957
+ }
958
+
959
+ export type TracedTool<TArgs, TResult> = (
960
+ args: TArgs,
961
+ ctx?: { runId?: string },
962
+ ) => Promise<TResult>;
963
+
964
+ export function punkChorusChat(
965
+ params: Omit<ChatParams, "model"> & { model?: typeof PUNK_CHORUS_MODEL },
966
+ ): ChatParams {
967
+ return { ...params, model: PUNK_CHORUS_MODEL };
968
+ }
969
+
970
+ /** @deprecated Use punkChorusChat. */
971
+ export const punkModelChat = punkChorusChat;
972
+
973
+ // ---------------------------------------------------------------------------
974
+ // Client
975
+ // ---------------------------------------------------------------------------
976
+
977
+ export class Punk {
978
+ readonly baseUrl: string;
979
+ readonly app: string;
980
+ readonly agent?: string;
981
+ readonly subject?: string;
982
+ private readonly apiKey?: string;
983
+
984
+ constructor(opts: PunkOptions = {}) {
985
+ this.baseUrl = normalizeBaseUrl(opts.baseUrl);
986
+ this.apiKey = cleanIdentityValue(opts.apiKey);
987
+ this.app = cleanIdentityValue(opts.app) ?? "default-app";
988
+ this.agent = cleanIdentityValue(opts.agent);
989
+ this.subject = cleanIdentityValue(opts.subject);
990
+ }
991
+
992
+ // -- chat -----------------------------------------------------------------
993
+
994
+ /** Send an OpenAI-compatible chat completion through the gateway. */
995
+ async chat(params: ChatParams): Promise<ChatResult> {
996
+ let res: Response;
997
+ try {
998
+ res = await fetch(`${this.baseUrl}/v1/chat/completions`, {
999
+ method: "POST",
1000
+ headers: this.headers({
1001
+ "X-Punk-App": this.app,
1002
+ "X-Punk-Agent": this.agent,
1003
+ "X-Punk-Subject": this.subject,
1004
+ }),
1005
+ body: JSON.stringify({ ...params, stream: false }),
1006
+ });
1007
+ } catch (err) {
1008
+ throw this.toTransportError("POST", "/v1/chat/completions", err);
1009
+ }
1010
+ const path = "/v1/chat/completions";
1011
+ const method = "POST";
1012
+ const raw = await this.parseJsonResponse<any>(method, path, res);
1013
+ const message = firstChatMessage(raw, method, path) as any;
1014
+ const toolCalls = normalizeChatToolCalls(message?.tool_calls);
1015
+ return {
1016
+ content: chatMessageText(message),
1017
+ toolCalls,
1018
+ runId: res.headers.get("x-punk-run-id") ?? "",
1019
+ route: res.headers.get("x-punk-route") ?? "unknown",
1020
+ raw,
1021
+ };
1022
+ }
1023
+
1024
+ // -- tool tracing ---------------------------------------------------------
1025
+
1026
+ /**
1027
+ * Wrap a tool so every invocation is traced into the run it belongs to, and
1028
+ * explicitly declared read-only results (level <= 1 with a TTL) flow
1029
+ * through the tool-result cache. Tracing requires `ctx.runId`; without it
1030
+ * the tool still executes, silently untraced and uncached.
1031
+ */
1032
+ traceTool<TArgs, TResult>(
1033
+ def: ToolDefinition<TArgs, TResult>,
1034
+ ): TracedTool<TArgs, TResult> {
1035
+ const toolName = requireCleanToolName(def.name);
1036
+ const level = classifySdkToolSideEffect(toolName, def.sideEffectLevel);
1037
+ const ttlSeconds = normalizeTtlSeconds(def.ttlSeconds);
1038
+ const schemaFp = cleanOptionalCacheDimension("schemaFp", def.schemaFp);
1039
+
1040
+ return async (args: TArgs, ctx?: { runId?: string }): Promise<TResult> => {
1041
+ const runId = cleanIdentityValue(ctx?.runId);
1042
+ const cacheable = level <= 1 && ttlSeconds !== undefined && runId !== undefined && this.subject !== undefined;
1043
+
1044
+ if (runId) {
1045
+ const decision = await this.tryTraceDecision(runId, "tool.called", {
1046
+ name: toolName,
1047
+ args,
1048
+ sideEffectLevel: level,
1049
+ ...(schemaFp ? { schemaFp } : {}),
1050
+ });
1051
+ if (decision && decision.decision !== "allow") {
1052
+ const message = `Punk policy ${decision.decision}: ${decision.reason || "tool execution is not allowed"}`;
1053
+ if (level >= 2) {
1054
+ await this.tryTrace(runId, "side_effect.suppressed", {
1055
+ toolName,
1056
+ level,
1057
+ payload: args,
1058
+ decision: decision.decision,
1059
+ reason: decision.reason,
1060
+ });
1061
+ }
1062
+ await this.tryTrace(runId, "tool.completed", {
1063
+ name: toolName,
1064
+ sideEffectLevel: level,
1065
+ error: {
1066
+ name: "PunkPolicyError",
1067
+ message,
1068
+ decision: decision.decision,
1069
+ reason: decision.reason,
1070
+ },
1071
+ });
1072
+ throw new Error(message);
1073
+ }
1074
+ }
1075
+
1076
+ if (cacheable) {
1077
+ const cached = await this.toolCacheCheck(toolName, args, level, schemaFp);
1078
+ if (toolCacheHitHasResult(cached)) {
1079
+ if (runId) {
1080
+ await this.tryTrace(runId, "tool.completed", {
1081
+ name: toolName,
1082
+ sideEffectLevel: level,
1083
+ result: cached.result,
1084
+ cached: true,
1085
+ ...(schemaFp ? { schemaFp } : {}),
1086
+ });
1087
+ }
1088
+ return cached.result as TResult;
1089
+ }
1090
+ }
1091
+
1092
+ if (runId) {
1093
+ if (level >= 2) {
1094
+ await this.tryTrace(runId, "side_effect.planned", {
1095
+ toolName,
1096
+ level,
1097
+ payload: args,
1098
+ });
1099
+ }
1100
+ }
1101
+
1102
+ let result: TResult;
1103
+ try {
1104
+ result = await def.execute(args);
1105
+ } catch (err) {
1106
+ if (runId) {
1107
+ await this.tryTrace(runId, "tool.completed", {
1108
+ name: toolName,
1109
+ sideEffectLevel: level,
1110
+ error: toolErrorPayload(err),
1111
+ ...(schemaFp ? { schemaFp } : {}),
1112
+ });
1113
+ }
1114
+ throw err;
1115
+ }
1116
+
1117
+ if (runId) {
1118
+ if (level >= 2) {
1119
+ await this.tryTrace(runId, "side_effect.executed", {
1120
+ toolName,
1121
+ level,
1122
+ payload: args,
1123
+ });
1124
+ }
1125
+ await this.tryTrace(runId, "tool.completed", {
1126
+ name: toolName,
1127
+ sideEffectLevel: level,
1128
+ result,
1129
+ ...(schemaFp ? { schemaFp } : {}),
1130
+ });
1131
+ }
1132
+ if (cacheable) {
1133
+ await this.toolCacheStore(toolName, args, result, ttlSeconds, level, schemaFp);
1134
+ }
1135
+ return result;
1136
+ };
1137
+ }
1138
+
1139
+ /** Append a trace event to a run. */
1140
+ async trace(
1141
+ runId: string,
1142
+ type: TraceEventType | string,
1143
+ payload: Record<string, unknown>,
1144
+ ): Promise<void> {
1145
+ const id = cleanPathId("run id", runId);
1146
+ await this.request<{ ok: boolean }>("POST", "/api/v1/trace", {
1147
+ runId: id,
1148
+ type,
1149
+ payload,
1150
+ });
1151
+ }
1152
+
1153
+ // -- feedback ---------------------------------------------------------------
1154
+
1155
+ async feedback(
1156
+ runId: string,
1157
+ rating: 1 | -1,
1158
+ correction?: string,
1159
+ ): Promise<void> {
1160
+ const id = cleanPathId("run id", runId);
1161
+ const path = `/api/v1/runs/${encodeURIComponent(id)}/feedback`;
1162
+ const trimmedCorrection = typeof correction === "string" ? correction.trim() : "";
1163
+ const body = trimmedCorrection
1164
+ ? { type: "correction", correction: trimmedCorrection }
1165
+ : { type: "rating", rating };
1166
+ requireOkResult(await this.request<unknown>("POST", path, body), "POST", path);
1167
+ }
1168
+
1169
+ // -- semantic web (Plasmate SOM) --------------------------------------------
1170
+
1171
+ async fetchSom(
1172
+ url: string,
1173
+ opts?: { bypassCache?: boolean },
1174
+ ): Promise<WebFetchResult> {
1175
+ const path = "/api/v1/web/fetch";
1176
+ return requireWebFetchResult(await this.request<unknown>("POST", path, {
1177
+ url,
1178
+ ...(opts?.bypassCache ? { bypassCache: true } : {}),
1179
+ }), "POST", path);
1180
+ }
1181
+
1182
+ /**
1183
+ * Web sessions & actions — the perception→action loop. Open a session,
1184
+ * act on SOM element ids (click/type/select/submit), close it. Actions are
1185
+ * policy-governed server-side: type/select and read-only clicks are
1186
+ * "read:web" (side-effect level 0–1); submit and submit-button clicks are
1187
+ * "write:web" at level 3 and can be denied/held by policy (403).
1188
+ * Observe-mode keys cannot perform web writes.
1189
+ */
1190
+ readonly web = {
1191
+ openSession: async (url: string): Promise<WebSessionOpenResult> => {
1192
+ const path = "/api/v1/web/sessions";
1193
+ return requireWebSessionOpenResult(await this.request<unknown>("POST", path, { url }), "POST", path);
1194
+ },
1195
+
1196
+ act: async (sessionId: string, intent: WebActionIntent): Promise<WebActResult> => {
1197
+ const id = cleanPathId("web session id", sessionId);
1198
+ const path = `/api/v1/web/sessions/${encodeURIComponent(id)}/act`;
1199
+ return requireWebActResult(await this.request<unknown>("POST", path, { intent }), "POST", path, id);
1200
+ },
1201
+
1202
+ closeSession: async (sessionId: string): Promise<{ ok: true }> => {
1203
+ const id = cleanPathId("web session id", sessionId);
1204
+ const path = `/api/v1/web/sessions/${encodeURIComponent(id)}`;
1205
+ return requireOkResult(await this.request<unknown>("DELETE", path), "DELETE", path);
1206
+ },
1207
+
1208
+ listSessions: async (): Promise<{
1209
+ sessions: WebSessionSummary[];
1210
+ }> => {
1211
+ const path = "/api/v1/web/sessions";
1212
+ return requireWebSessionsListResult(await this.request<unknown>("GET", path), "GET", path);
1213
+ },
1214
+ };
1215
+
1216
+ // -- external MCP servers (registry + connection testing) -------------------
1217
+
1218
+ /**
1219
+ * The gateway's external-MCP registry: register servers (admin), test
1220
+ * connections (connect + list_tools), and let workflow tool_call nodes use
1221
+ * them. Note: stdio servers execute their command on the GATEWAY host —
1222
+ * registration is an operator-level action.
1223
+ */
1224
+ readonly mcp = {
1225
+ listServers: async (): Promise<McpServerRecord[]> => {
1226
+ const path = "/api/v1/mcp/servers";
1227
+ return requireMcpServerList(await this.request<unknown>("GET", path), "GET", path);
1228
+ },
1229
+
1230
+ createServer: async (server: {
1231
+ name: string;
1232
+ transport: "stdio" | "http";
1233
+ command?: string;
1234
+ args?: string[];
1235
+ url?: string;
1236
+ /** Values may be cred:<credentialId> references (resolved at connect time). */
1237
+ env?: Record<string, string>;
1238
+ headers?: Record<string, string>;
1239
+ enabled?: boolean;
1240
+ }): Promise<McpServerRecord> => {
1241
+ const path = "/api/v1/mcp/servers";
1242
+ return requireMcpServerCreateResult(await this.request<unknown>("POST", path, server), "POST", path);
1243
+ },
1244
+
1245
+ /** Connect + list_tools (throwaway connection); persists the server's status. */
1246
+ testServer: (id: string): Promise<McpTestResult> =>
1247
+ this.request<McpTestResult>(
1248
+ "POST",
1249
+ `/api/v1/mcp/servers/${encodeURIComponent(cleanPathId("MCP server id", id))}/test`,
1250
+ ),
1251
+ };
1252
+
1253
+ // -- memory quarantine (trust-lane influence declaration) -------------------
1254
+
1255
+ /**
1256
+ * Declare what memory/context influenced a run, tagged with its trust lane.
1257
+ * Recording is ALWAYS allowed (cheap, useful telemetry). When the tenant
1258
+ * enables memory quarantine, low-trust influence (untrusted/observed) on a
1259
+ * run gates that run's high-impact (side-effect ≥ threshold) actions to
1260
+ * approval_required — "untrusted web content can't trigger a payment".
1261
+ */
1262
+ readonly memory = {
1263
+ recordInfluence: (
1264
+ runId: string,
1265
+ influence: { source: string; trustLane: TrustLane; contentHash?: string },
1266
+ ): Promise<{ ok: boolean; influence: { id: string; runId: string; source: string; trustLane: TrustLane } }> =>
1267
+ this.request(
1268
+ "POST",
1269
+ `/api/v1/runs/${encodeURIComponent(cleanPathId("run id", runId))}/memory`,
1270
+ influence,
1271
+ ),
1272
+ };
1273
+
1274
+ // -- prompt side-loading (Punk Runtime Engine ingest) -----------------------
1275
+
1276
+ /**
1277
+ * Ingest an externally-handled prompt (Claude Code / Claude app / any
1278
+ * client) as a completed OBSERVED run: $0 cost, model "external", redaction
1279
+ * honored. Returns the created run id.
1280
+ */
1281
+ async ingestPrompt(
1282
+ source: string,
1283
+ prompt: string,
1284
+ opts?: { sessionId?: string; metadata?: Record<string, unknown> },
1285
+ ): Promise<{ runId: string }> {
1286
+ return this.request<{ runId: string }>("POST", "/api/v1/ingest/prompt", {
1287
+ source,
1288
+ prompt,
1289
+ ...(opts?.sessionId ? { sessionId: opts.sessionId } : {}),
1290
+ ...(opts?.metadata ? { metadata: opts.metadata } : {}),
1291
+ });
1292
+ }
1293
+
1294
+ // -- read APIs ---------------------------------------------------------------
1295
+
1296
+ async savings(): Promise<SavingsSummary> {
1297
+ const path = "/api/v1/savings";
1298
+ return requireSavingsSummary(await this.request<unknown>("GET", path), "GET", path);
1299
+ }
1300
+
1301
+ async patterns(): Promise<Pattern[]> {
1302
+ const path = "/api/v1/patterns";
1303
+ const body = await this.request<unknown>(
1304
+ "GET",
1305
+ path,
1306
+ );
1307
+ return requireArrayProperty<Pattern>(body, "patterns", "GET", path);
1308
+ }
1309
+
1310
+ async artifacts(): Promise<Artifact[]> {
1311
+ const path = "/api/v1/artifacts";
1312
+ const body = await this.request<unknown>(
1313
+ "GET",
1314
+ path,
1315
+ );
1316
+ return requireArrayProperty<Artifact>(body, "artifacts", "GET", path);
1317
+ }
1318
+
1319
+ async artifactDetail(id: string): Promise<ArtifactDetail> {
1320
+ const path = `/api/v1/artifacts/${encodeURIComponent(cleanPathId("artifact id", id))}`;
1321
+ return requireArtifactDetailResult(await this.request<unknown>("GET", path), "GET", path);
1322
+ }
1323
+
1324
+ async runDetail(id: string): Promise<RunDetail> {
1325
+ const path = `/api/v1/runs/${encodeURIComponent(cleanPathId("run id", id))}`;
1326
+ return requireRunDetailResult(await this.request<unknown>("GET", path), "GET", path);
1327
+ }
1328
+
1329
+ async receipt(id: string): Promise<PunkReceipt> {
1330
+ const path = `/api/v1/receipts/${encodeURIComponent(cleanPathId("receipt id", id))}`;
1331
+ return requireReceiptResult(await this.request<unknown>("GET", path), "GET", path);
1332
+ }
1333
+
1334
+ async evidencePacket(runId: string): Promise<EvidencePacket> {
1335
+ const path = `/api/v1/runs/${encodeURIComponent(cleanPathId("run id", runId))}/evidence-packet`;
1336
+ return requireEvidencePacketResult(await this.request<unknown>("GET", path), "GET", path);
1337
+ }
1338
+
1339
+ async cacheStats(): Promise<CacheStats> {
1340
+ const path = "/api/v1/cache/stats";
1341
+ const body = await this.request<unknown>("GET", path);
1342
+ return requireCacheStatsResult(body, "GET", path);
1343
+ }
1344
+
1345
+ // -- learning lifecycle -------------------------------------------------------
1346
+
1347
+ async learningTick(): Promise<LearningReport> {
1348
+ const path = "/api/v1/learning/tick";
1349
+ return requireLearningReport(await this.request<unknown>("POST", path), "POST", path);
1350
+ }
1351
+
1352
+ async promoteArtifact(id: string): Promise<Artifact> {
1353
+ const path = `/api/v1/artifacts/${encodeURIComponent(cleanPathId("artifact id", id))}/promote`;
1354
+ const body = await this.request<{ artifact: Artifact }>(
1355
+ "POST",
1356
+ path,
1357
+ );
1358
+ return requireObjectProperty<Artifact>(body, "artifact", "POST", path);
1359
+ }
1360
+
1361
+ // -- tool-result cache ----------------------------------------------------
1362
+
1363
+ /** Best-effort cache lookup; network failures degrade to a miss. */
1364
+ async toolCacheCheck(
1365
+ toolName: string,
1366
+ args: unknown,
1367
+ sideEffectLevel?: SideEffectLevel,
1368
+ schemaFp?: string,
1369
+ ): Promise<{ hit: boolean; result?: unknown }> {
1370
+ try {
1371
+ return await this.request<{ hit: boolean; result?: unknown }>(
1372
+ "POST",
1373
+ "/api/v1/tool-cache/check",
1374
+ { toolName, appId: this.app, subject: this.subject, args, sideEffectLevel, schemaFp },
1375
+ );
1376
+ } catch {
1377
+ return { hit: false };
1378
+ }
1379
+ }
1380
+
1381
+ /** Best-effort cache store; failures are swallowed. */
1382
+ async toolCacheStore(
1383
+ toolName: string,
1384
+ args: unknown,
1385
+ result: unknown,
1386
+ ttlSeconds?: number,
1387
+ sideEffectLevel?: SideEffectLevel,
1388
+ schemaFp?: string,
1389
+ ): Promise<void> {
1390
+ try {
1391
+ await this.request<{ ok: boolean }>("POST", "/api/v1/tool-cache/store", {
1392
+ toolName,
1393
+ appId: this.app,
1394
+ subject: this.subject,
1395
+ args,
1396
+ result,
1397
+ ttlSeconds,
1398
+ sideEffectLevel,
1399
+ schemaFp,
1400
+ });
1401
+ } catch {
1402
+ // caching is an optimization, never a failure mode
1403
+ }
1404
+ }
1405
+
1406
+ // -- internals --------------------------------------------------------------
1407
+
1408
+ private headers(
1409
+ extra?: Record<string, string | undefined>,
1410
+ ): Record<string, string> {
1411
+ const headers: Record<string, string> = {
1412
+ "Content-Type": "application/json",
1413
+ };
1414
+ if (this.apiKey) headers["Authorization"] = `Bearer ${this.apiKey}`;
1415
+ if (extra) {
1416
+ for (const [key, value] of Object.entries(extra)) {
1417
+ if (value !== undefined && value !== "") headers[key] = value;
1418
+ }
1419
+ }
1420
+ return headers;
1421
+ }
1422
+
1423
+ private async request<T>(
1424
+ method: string,
1425
+ path: string,
1426
+ body?: unknown,
1427
+ ): Promise<T> {
1428
+ let res: Response;
1429
+ try {
1430
+ res = await fetch(`${this.baseUrl}${path}`, {
1431
+ method,
1432
+ headers: this.headers(),
1433
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
1434
+ });
1435
+ } catch (err) {
1436
+ throw this.toTransportError(method, path, err);
1437
+ }
1438
+ return this.parseJsonResponse<T>(method, path, res);
1439
+ }
1440
+
1441
+ private async parseJsonResponse<T>(
1442
+ method: string,
1443
+ path: string,
1444
+ res: Response,
1445
+ ): Promise<T> {
1446
+ const text = await res.text();
1447
+ if (!res.ok) {
1448
+ throw this.toError(method, path, res, text);
1449
+ }
1450
+ try {
1451
+ return JSON.parse(text) as T;
1452
+ } catch {
1453
+ throw new Error(
1454
+ `Punk API ${method} ${path} returned non-JSON response: ${res.status} ${res.statusText}` +
1455
+ (text ? ` — ${text.slice(0, 500)}` : ""),
1456
+ );
1457
+ }
1458
+ }
1459
+
1460
+ private toError(
1461
+ method: string,
1462
+ path: string,
1463
+ res: Response,
1464
+ text: string,
1465
+ ): Error {
1466
+ const message = this.responseErrorMessage(text);
1467
+ const runId = res.headers.get("x-punk-run-id");
1468
+ const route = res.headers.get("x-punk-route");
1469
+ const evidence = [
1470
+ runId ? `run ${runId}` : "",
1471
+ route ? `route ${route}` : "",
1472
+ ].filter(Boolean).join(", ");
1473
+ return new Error(
1474
+ `Punk API ${method} ${path} failed: ${res.status} ${res.statusText}` +
1475
+ (evidence ? ` (${evidence})` : "") +
1476
+ (message ? ` — ${message}` : ""),
1477
+ );
1478
+ }
1479
+
1480
+ private toTransportError(method: string, path: string, err: unknown): Error {
1481
+ const reason = err instanceof Error ? err.message : String(err);
1482
+ return new Error(
1483
+ `Punk API ${method} ${path} could not reach ${this.baseUrl}: ${reason}`,
1484
+ );
1485
+ }
1486
+
1487
+ private responseErrorMessage(text: string): string {
1488
+ if (!text) return "";
1489
+ let data: any = null;
1490
+ try {
1491
+ data = JSON.parse(text);
1492
+ } catch {
1493
+ return text.slice(0, 500);
1494
+ }
1495
+ const error = data?.error;
1496
+ let message = "";
1497
+ if (error && typeof error === "object") {
1498
+ if (typeof error.message === "string" && error.message.trim()) message = error.message.trim();
1499
+ else if (typeof error.code === "string" && error.code.trim()) message = error.code.trim();
1500
+ else {
1501
+ try {
1502
+ message = JSON.stringify(error);
1503
+ } catch {
1504
+ message = String(error);
1505
+ }
1506
+ }
1507
+ } else if (typeof data?.message === "string" && data.message.trim()) {
1508
+ message = data.message.trim();
1509
+ if (typeof error === "string" && error.trim() && error.trim() !== message) {
1510
+ message += ` (${error.trim()})`;
1511
+ }
1512
+ } else if (typeof error === "string" && error.trim()) {
1513
+ message = error.trim();
1514
+ } else {
1515
+ message = text.slice(0, 500);
1516
+ }
1517
+ if (Array.isArray(data?.validation) && data.validation.length > 0) {
1518
+ message += `: ${data.validation.slice(0, 3).map((v: unknown) => String(v)).join("; ")}`;
1519
+ }
1520
+ return message.slice(0, 500);
1521
+ }
1522
+
1523
+ /** Tracing is observability, not control flow — failures are swallowed. */
1524
+ private async tryTrace(
1525
+ runId: string,
1526
+ type: TraceEventType | string,
1527
+ payload: Record<string, unknown>,
1528
+ ): Promise<void> {
1529
+ try {
1530
+ await this.trace(runId, type, payload);
1531
+ } catch {
1532
+ // never let telemetry break the tool call
1533
+ }
1534
+ }
1535
+
1536
+ /**
1537
+ * Trace a tool call and return Punk's governance decision when one is
1538
+ * provided. Transport/API failures still fail open so telemetry outages do
1539
+ * not break tools; explicit deny/approval decisions remain control flow.
1540
+ */
1541
+ private async tryTraceDecision(
1542
+ runId: string,
1543
+ type: TraceEventType | string,
1544
+ payload: Record<string, unknown>,
1545
+ ): Promise<PolicyVerdict | null> {
1546
+ try {
1547
+ const res = await this.request<{ ok: boolean; decision?: PolicyVerdict }>("POST", "/api/v1/trace", {
1548
+ runId,
1549
+ type,
1550
+ payload,
1551
+ });
1552
+ return normalizeTraceDecision(res.decision);
1553
+ } catch {
1554
+ return null;
1555
+ }
1556
+ }
1557
+ }
1558
+
1559
+ export default Punk;