@letta-ai/letta-code 0.31.6 → 0.31.8

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.
Files changed (56) hide show
  1. package/dist/mcp-client.js +8 -8
  2. package/dist/mcp-client.js.map +3 -3
  3. package/dist/memory-constraints.js +18 -0
  4. package/dist/memory-constraints.js.map +10 -0
  5. package/dist/types/agent/memory-constraints.d.ts +1 -1
  6. package/dist/types/agent/memory-constraints.d.ts.map +1 -1
  7. package/dist/types/agent/subagents/index.d.ts +0 -2
  8. package/dist/types/agent/subagents/index.d.ts.map +1 -1
  9. package/dist/types/agent/subagents/subagent-model.d.ts +20 -0
  10. package/dist/types/agent/subagents/subagent-model.d.ts.map +1 -1
  11. package/dist/types/{utils → agent}/system-prompt-size.d.ts +8 -4
  12. package/dist/types/agent/system-prompt-size.d.ts.map +1 -0
  13. package/dist/types/backend/backend.d.ts +7 -0
  14. package/dist/types/backend/backend.d.ts.map +1 -1
  15. package/dist/types/cli/helpers/system-prompt-warning.d.ts +2 -1
  16. package/dist/types/cli/helpers/system-prompt-warning.d.ts.map +1 -1
  17. package/dist/types/mcp-client.d.ts +6 -0
  18. package/dist/types/mcp-client.d.ts.map +1 -1
  19. package/dist/types/memory-constraints.d.ts +15 -0
  20. package/dist/types/memory-constraints.d.ts.map +1 -0
  21. package/dist/types/permissions/shell-analysis.d.ts.map +1 -1
  22. package/dist/types/providers/openai-codex-provider.d.ts +2 -10
  23. package/dist/types/providers/openai-codex-provider.d.ts.map +1 -1
  24. package/dist/types/tools/impl/task.d.ts +20 -2
  25. package/dist/types/tools/impl/task.d.ts.map +1 -1
  26. package/dist/types/types/chatgpt-oauth.d.ts +9 -0
  27. package/dist/types/types/chatgpt-oauth.d.ts.map +1 -0
  28. package/dist/types/types/protocol_v2.d.ts +3 -3
  29. package/dist/types/types/protocol_v2.d.ts.map +1 -1
  30. package/dist/types/utils/secrets-store.d.ts +4 -0
  31. package/dist/types/utils/secrets-store.d.ts.map +1 -1
  32. package/letta.js +29378 -28407
  33. package/package.json +11 -1
  34. package/scripts/agent-watch/verify-pr-identity.test.ts +97 -0
  35. package/scripts/agent-watch/verify-pr-identity.ts +119 -0
  36. package/scripts/builtin-skills-watch/aggregate-results.ts +13 -8
  37. package/scripts/builtin-skills-watch/evidence.test.ts +61 -17
  38. package/scripts/builtin-skills-watch/evidence.ts +66 -38
  39. package/scripts/builtin-skills-watch/finalize-result.test.ts +125 -0
  40. package/scripts/builtin-skills-watch/finalize-result.ts +44 -0
  41. package/scripts/builtin-skills-watch/reconcile-results.test.ts +58 -0
  42. package/scripts/builtin-skills-watch/reconcile-results.ts +311 -0
  43. package/scripts/builtin-skills-watch/result-artifacts.test.ts +83 -0
  44. package/scripts/builtin-skills-watch/result-artifacts.ts +160 -0
  45. package/scripts/builtin-skills-watch/update-tracker.test.ts +17 -1
  46. package/scripts/builtin-skills-watch/update-tracker.ts +89 -15
  47. package/scripts/claude-watch/update-tracker.test.ts +30 -0
  48. package/scripts/claude-watch/update-tracker.ts +19 -5
  49. package/scripts/codex-watch/update-tracker.test.ts +28 -0
  50. package/scripts/codex-watch/update-tracker.ts +59 -7
  51. package/scripts/source-file-size-baseline.json +2 -1
  52. package/skills/browser-use/SKILL.md +363 -0
  53. package/skills/browser-use/references/recording.md +60 -0
  54. package/skills/letta-guide/SKILL.md +6 -2
  55. package/skills/self-configuration/SKILL.md +3 -1
  56. package/dist/types/utils/system-prompt-size.d.ts.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letta-ai/letta-code",
3
- "version": "0.31.6",
3
+ "version": "0.31.8",
4
4
  "description": "Letta Code is a CLI tool for interacting with stateful Letta agents from the terminal.",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1.3.10",
@@ -22,6 +22,8 @@
22
22
  "dist/app-server-client.cjs.map",
23
23
  "dist/memory-confinement.js",
24
24
  "dist/memory-confinement.js.map",
25
+ "dist/memory-constraints.js",
26
+ "dist/memory-constraints.js.map",
25
27
  "dist/mcp-client.js",
26
28
  "dist/mcp-client.js.map",
27
29
  "dist/agent-presets.js",
@@ -58,6 +60,11 @@
58
60
  "import": "./dist/memory-confinement.js",
59
61
  "default": "./dist/memory-confinement.js"
60
62
  },
63
+ "./memory-constraints": {
64
+ "types": "./dist/types/memory-constraints.d.ts",
65
+ "import": "./dist/memory-constraints.js",
66
+ "default": "./dist/memory-constraints.js"
67
+ },
61
68
  "./mcp-client": {
62
69
  "types": "./dist/types/mcp-client.d.ts",
63
70
  "import": "./dist/mcp-client.js",
@@ -211,6 +218,9 @@
211
218
  "memory-confinement": [
212
219
  "./dist/types/memory-confinement.d.ts"
213
220
  ],
221
+ "memory-constraints": [
222
+ "./dist/types/memory-constraints.d.ts"
223
+ ],
214
224
  "mcp-client": [
215
225
  "./dist/types/mcp-client.d.ts"
216
226
  ],
@@ -0,0 +1,97 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ enforceWatcherPrIdentity,
4
+ parseArgs,
5
+ type WatcherPullRequest,
6
+ } from "./verify-pr-identity.ts";
7
+
8
+ const marker = "Pi-ai-watch: 1.0.0...1.0.1";
9
+
10
+ function pullRequest(
11
+ overrides: Partial<WatcherPullRequest> = {},
12
+ ): WatcherPullRequest {
13
+ return {
14
+ author: { login: "amelia-letta" },
15
+ body: marker,
16
+ isDraft: true,
17
+ number: 123,
18
+ url: "https://github.com/letta-ai/letta-code/pull/123",
19
+ ...overrides,
20
+ };
21
+ }
22
+
23
+ function enforce(
24
+ pullRequests: WatcherPullRequest[],
25
+ closed: string[] = [],
26
+ ): WatcherPullRequest | null {
27
+ return enforceWatcherPrIdentity({
28
+ repo: "letta-ai/letta-code",
29
+ marker,
30
+ expectedLogin: "amelia-letta",
31
+ pullRequests,
32
+ closePullRequest: (candidate) => closed.push(candidate.url),
33
+ });
34
+ }
35
+
36
+ describe("enforceWatcherPrIdentity", () => {
37
+ test("returns the matching Amelia draft PR", () => {
38
+ expect(enforce([pullRequest()])?.number).toBe(123);
39
+ });
40
+
41
+ test("ignores unrelated open PRs", () => {
42
+ expect(enforce([pullRequest({ body: "unrelated" })])).toBeNull();
43
+ });
44
+
45
+ test("closes and rejects a PR from the wrong author", () => {
46
+ const closed: string[] = [];
47
+ expect(() =>
48
+ enforce([pullRequest({ author: { login: "carenthomas" } })], closed),
49
+ ).toThrow("author=carenthomas");
50
+ expect(closed).toEqual(["https://github.com/letta-ai/letta-code/pull/123"]);
51
+ });
52
+
53
+ test("closes and rejects a ready PR", () => {
54
+ const closed: string[] = [];
55
+ expect(() => enforce([pullRequest({ isDraft: false })], closed)).toThrow(
56
+ "draft=false",
57
+ );
58
+ expect(closed).toHaveLength(1);
59
+ });
60
+
61
+ test("rejects duplicate valid PRs", () => {
62
+ expect(() =>
63
+ enforce([
64
+ pullRequest(),
65
+ pullRequest({
66
+ number: 124,
67
+ url: "https://github.com/letta-ai/letta-code/pull/124",
68
+ }),
69
+ ]),
70
+ ).toThrow("Multiple open watcher PRs");
71
+ });
72
+ });
73
+
74
+ describe("parseArgs", () => {
75
+ test("requires every identity input", () => {
76
+ expect(() => parseArgs(["--repo", "letta-ai/letta-code"])).toThrow(
77
+ "--marker is required",
78
+ );
79
+ });
80
+
81
+ test("parses a complete invocation", () => {
82
+ expect(
83
+ parseArgs([
84
+ "--repo",
85
+ "letta-ai/letta-code",
86
+ "--marker",
87
+ marker,
88
+ "--expected-login",
89
+ "amelia-letta",
90
+ ]),
91
+ ).toEqual({
92
+ repo: "letta-ai/letta-code",
93
+ marker,
94
+ expectedLogin: "amelia-letta",
95
+ });
96
+ });
97
+ });
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { spawnSync } from "node:child_process";
4
+
5
+ export interface WatcherPullRequest {
6
+ author: { login: string } | null;
7
+ body: string | null;
8
+ isDraft: boolean;
9
+ number: number;
10
+ url: string;
11
+ }
12
+
13
+ interface Args {
14
+ expectedLogin: string;
15
+ marker: string;
16
+ repo: string;
17
+ }
18
+
19
+ interface EnforceOptions extends Args {
20
+ closePullRequest: (pullRequest: WatcherPullRequest) => void;
21
+ pullRequests: WatcherPullRequest[];
22
+ }
23
+
24
+ export function enforceWatcherPrIdentity(
25
+ options: EnforceOptions,
26
+ ): WatcherPullRequest | null {
27
+ const matches = options.pullRequests.filter((pullRequest) =>
28
+ pullRequest.body?.includes(options.marker),
29
+ );
30
+ const invalid = matches.filter(
31
+ (pullRequest) =>
32
+ pullRequest.author?.login !== options.expectedLogin ||
33
+ !pullRequest.isDraft,
34
+ );
35
+
36
+ for (const pullRequest of invalid) options.closePullRequest(pullRequest);
37
+
38
+ if (invalid.length > 0) {
39
+ const details = invalid
40
+ .map(
41
+ (pullRequest) =>
42
+ `${pullRequest.url} (author=${pullRequest.author?.login ?? "unknown"}, draft=${pullRequest.isDraft})`,
43
+ )
44
+ .join(", ");
45
+ throw new Error(
46
+ `Closed watcher PRs that did not match author=${options.expectedLogin} and draft=true: ${details}`,
47
+ );
48
+ }
49
+ if (matches.length > 1) {
50
+ throw new Error(
51
+ `Multiple open watcher PRs contain marker ${JSON.stringify(options.marker)}`,
52
+ );
53
+ }
54
+ return matches[0] ?? null;
55
+ }
56
+
57
+ export function parseArgs(argv: string[]): Args {
58
+ let repo = "";
59
+ let marker = "";
60
+ let expectedLogin = "";
61
+ for (let index = 0; index < argv.length; index += 1) {
62
+ const argument = argv[index];
63
+ if (argument === "--repo") repo = argv[++index] ?? "";
64
+ else if (argument === "--marker") marker = argv[++index] ?? "";
65
+ else if (argument === "--expected-login") {
66
+ expectedLogin = argv[++index] ?? "";
67
+ } else {
68
+ throw new Error(`Unknown argument: ${argument}`);
69
+ }
70
+ }
71
+ if (!/^[^/]+\/[^/]+$/.test(repo)) throw new Error("--repo is required");
72
+ if (!marker) throw new Error("--marker is required");
73
+ if (!expectedLogin) throw new Error("--expected-login is required");
74
+ return { repo, marker, expectedLogin };
75
+ }
76
+
77
+ function runGh(args: string[]): string {
78
+ const result = spawnSync("gh", args, {
79
+ encoding: "utf8",
80
+ maxBuffer: 50 * 1024 * 1024,
81
+ stdio: ["ignore", "pipe", "pipe"],
82
+ });
83
+ if (result.status !== 0) {
84
+ throw new Error(`gh ${args.join(" ")} failed:\n${result.stderr}`);
85
+ }
86
+ return result.stdout;
87
+ }
88
+
89
+ function main(): void {
90
+ const args = parseArgs(process.argv.slice(2));
91
+ const pullRequests = JSON.parse(
92
+ runGh([
93
+ "pr",
94
+ "list",
95
+ "--repo",
96
+ args.repo,
97
+ "--state",
98
+ "open",
99
+ "--limit",
100
+ "1000",
101
+ "--json",
102
+ "author,body,isDraft,number,url",
103
+ ]),
104
+ ) as WatcherPullRequest[];
105
+ const verified = enforceWatcherPrIdentity({
106
+ ...args,
107
+ pullRequests,
108
+ closePullRequest: (pullRequest) => {
109
+ runGh(["pr", "close", pullRequest.url, "--repo", args.repo]);
110
+ },
111
+ });
112
+ console.log(
113
+ verified
114
+ ? `Verified Amelia draft PR: ${verified.url}`
115
+ : `No open PR contains marker ${JSON.stringify(args.marker)}`,
116
+ );
117
+ }
118
+
119
+ if (import.meta.main) main();
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  /** Validates a daily skill-review batch and updates the tracker once. */
3
3
 
4
- import { existsSync, readFileSync } from "node:fs";
4
+ import { readFileSync } from "node:fs";
5
5
  import { join } from "node:path";
6
6
  import {
7
7
  type BuiltinSkillWatchAnalysis,
@@ -9,6 +9,10 @@ import {
9
9
  DEFAULT_TARGET_REPO,
10
10
  } from "./analysis.ts";
11
11
  import { createIssueComment, editIssueBody } from "./github.ts";
12
+ import {
13
+ discoverReviewArtifacts,
14
+ formatFailureReceipt,
15
+ } from "./result-artifacts.ts";
12
16
  import {
13
17
  hasTerminalCandidate,
14
18
  parseTrackerState,
@@ -103,6 +107,7 @@ function main(): void {
103
107
  let state = parseTrackerState(original.body);
104
108
  let failed = false;
105
109
  const outcomes: ValidatedOutcome[] = [];
110
+ const artifacts = discoverReviewArtifacts(args.resultsDir as string);
106
111
 
107
112
  for (const candidate of manifest.candidates) {
108
113
  const analysisPath = join(
@@ -130,14 +135,14 @@ function main(): void {
130
135
  analysis.workflow_run_url = workflowRunUrl(pending.workflow_run_id);
131
136
  assertAnalysisIdentity(received, analysis);
132
137
 
133
- const resultPath = join(
134
- args.resultsDir as string,
135
- `builtin-skill-result-${received.skill}`,
136
- "result.json",
137
- );
138
+ const resultPath = artifacts.results.get(received.candidate_id);
138
139
  try {
139
- if (!existsSync(resultPath)) {
140
- throw new Error("Amelia result artifact is missing");
140
+ if (!resultPath) {
141
+ const receipt = artifacts.failures.get(received.candidate_id);
142
+ if (receipt) throw new Error(formatFailureReceipt(receipt));
143
+ throw new Error(
144
+ `Amelia result artifact is missing for ${received.candidate_id}`,
145
+ );
141
146
  }
142
147
  const result = readReviewResult(resultPath, analysis);
143
148
  if (result.pr_url) {
@@ -29,6 +29,27 @@ describe("review evidence", () => {
29
29
  expect(digestReviewEvidence(evidence)).toMatch(/^[a-f0-9]{64}$/);
30
30
  });
31
31
 
32
+ test("accepts RFC 3339 UTC timestamps without milliseconds", () => {
33
+ expect(() =>
34
+ parseReviewEvidence({
35
+ schema_version: 1,
36
+ candidate_id: "candidate",
37
+ skill: "creating-skills",
38
+ sources: [
39
+ {
40
+ locator: "src/skills/builtin/creating-skills/SKILL.md",
41
+ revision: "a".repeat(40),
42
+ content_digest: null,
43
+ retrieved_at: "2026-08-26T00:00:00Z",
44
+ excerpt: "current source",
45
+ claims: ["checked current source"],
46
+ },
47
+ ],
48
+ probes: [],
49
+ }),
50
+ ).not.toThrow();
51
+ });
52
+
32
53
  test("requires at least one source", () => {
33
54
  expect(() =>
34
55
  parseReviewEvidence({
@@ -38,7 +59,7 @@ describe("review evidence", () => {
38
59
  sources: [],
39
60
  probes: [],
40
61
  }),
41
- ).toThrow("sources are invalid");
62
+ ).toThrow("between 1 and 5 sources");
42
63
  });
43
64
 
44
65
  test("rejects malformed digests and timestamps", () => {
@@ -59,28 +80,51 @@ describe("review evidence", () => {
59
80
  ],
60
81
  probes: [],
61
82
  }),
62
- ).toThrow("sources are invalid");
83
+ ).toThrow("content_digest is invalid");
63
84
  });
64
85
 
65
- test("rejects evidence too large for the tracker", () => {
86
+ test("accepts production-sized evidence larger than the old tracker limit", () => {
87
+ const evidence = parseReviewEvidence({
88
+ schema_version: 1,
89
+ candidate_id: "candidate",
90
+ skill: "creating-skills",
91
+ sources: Array.from({ length: 5 }, (_, index) => ({
92
+ locator: `https://docs.letta.com/source-${index}`,
93
+ revision: "a".repeat(40),
94
+ content_digest: "b".repeat(64),
95
+ retrieved_at: "2026-08-26T00:00:00.000Z",
96
+ excerpt: "q".repeat(300),
97
+ claims: Array.from({ length: 5 }, () => "z".repeat(300)),
98
+ })),
99
+ probes: [],
100
+ });
101
+
102
+ expect(Buffer.byteLength(JSON.stringify(evidence), "utf8")).toBeGreaterThan(
103
+ 650,
104
+ );
105
+ });
106
+
107
+ test("still rejects evidence larger than an issue comment can safely hold", () => {
66
108
  expect(() =>
67
109
  parseReviewEvidence({
68
110
  schema_version: 1,
69
111
  candidate_id: "candidate",
70
112
  skill: "creating-skills",
71
- sources: [
72
- {
73
- locator: "x".repeat(500),
74
- revision: "y".repeat(300),
75
- content_digest: null,
76
- retrieved_at: "2026-08-26T00:00:00.000Z",
77
- excerpt: "q".repeat(300),
78
- claims: ["z".repeat(500)],
79
- },
80
- ],
81
- probes: [],
113
+ sources: Array.from({ length: 5 }, (_, index) => ({
114
+ locator: `https://docs.letta.com/${"x".repeat(450)}-${index}`,
115
+ revision: "y".repeat(300),
116
+ content_digest: null,
117
+ retrieved_at: "2026-08-26T00:00:00.000Z",
118
+ excerpt: "q".repeat(300),
119
+ claims: Array.from({ length: 5 }, () => "z".repeat(500)),
120
+ })),
121
+ probes: Array.from({ length: 5 }, () => ({
122
+ command: "c".repeat(500),
123
+ result_digest: "d".repeat(64),
124
+ summary: "s".repeat(500),
125
+ })),
82
126
  }),
83
- ).toThrow("exceeds 650 bytes");
127
+ ).toThrow("exceeds 16384 bytes");
84
128
  });
85
129
 
86
130
  test("requires a revision or digest and rejects unknown fields", () => {
@@ -100,7 +144,7 @@ describe("review evidence", () => {
100
144
  sources: [source],
101
145
  probes: [],
102
146
  }),
103
- ).toThrow("sources are invalid");
147
+ ).toThrow("requires a revision or content_digest");
104
148
  expect(() =>
105
149
  parseReviewEvidence({
106
150
  schema_version: 1,
@@ -109,6 +153,6 @@ describe("review evidence", () => {
109
153
  sources: [{ ...source, content_digest: "a".repeat(64), secret: "no" }],
110
154
  probes: [],
111
155
  }),
112
- ).toThrow("sources are invalid");
156
+ ).toThrow("unknown or missing fields");
113
157
  });
114
158
  });
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
3
3
  const MAX_SOURCES = 5;
4
4
  const MAX_PROBES = 5;
5
5
  const MAX_CLAIMS_PER_SOURCE = 5;
6
- const MAX_SERIALIZED_BYTES = 650;
6
+ const MAX_SERIALIZED_BYTES = 16 * 1024;
7
7
 
8
8
  export interface EvidenceSource {
9
9
  locator: string;
@@ -51,29 +51,24 @@ export function parseReviewEvidence(value: unknown): ReviewEvidence {
51
51
  if (
52
52
  !Array.isArray(value.sources) ||
53
53
  value.sources.length === 0 ||
54
- value.sources.length > MAX_SOURCES ||
55
- !value.sources.every(isEvidenceSource)
54
+ value.sources.length > MAX_SOURCES
56
55
  ) {
57
- throw new TypeError("review evidence sources are invalid");
56
+ throw new TypeError("review evidence must include between 1 and 5 sources");
58
57
  }
59
- if (
60
- !Array.isArray(value.probes) ||
61
- value.probes.length > MAX_PROBES ||
62
- !value.probes.every(isEvidenceProbe)
63
- ) {
64
- throw new TypeError("review evidence probes are invalid");
58
+ if (!Array.isArray(value.probes) || value.probes.length > MAX_PROBES) {
59
+ throw new TypeError("review evidence must include at most 5 probes");
65
60
  }
66
61
  const evidence: ReviewEvidence = {
67
62
  schema_version: 1,
68
63
  candidate_id: value.candidate_id,
69
64
  skill: value.skill,
70
- sources: value.sources,
71
- probes: value.probes,
65
+ sources: value.sources.map(parseEvidenceSource),
66
+ probes: value.probes.map(parseEvidenceProbe),
72
67
  };
73
68
  if (
74
69
  Buffer.byteLength(JSON.stringify(evidence), "utf8") > MAX_SERIALIZED_BYTES
75
70
  ) {
76
- throw new TypeError("review evidence exceeds 650 bytes");
71
+ throw new TypeError("review evidence exceeds 16384 bytes");
77
72
  }
78
73
  return evidence;
79
74
  }
@@ -82,44 +77,77 @@ export function digestReviewEvidence(evidence: ReviewEvidence): string {
82
77
  return createHash("sha256").update(JSON.stringify(evidence)).digest("hex");
83
78
  }
84
79
 
85
- function isEvidenceSource(value: unknown): value is EvidenceSource {
86
- return (
87
- isRecord(value) &&
88
- hasExactKeys(value, [
80
+ function parseEvidenceSource(value: unknown, index: number): EvidenceSource {
81
+ const description = `review evidence source ${index + 1}`;
82
+ if (
83
+ !isRecord(value) ||
84
+ !hasExactKeys(value, [
89
85
  "locator",
90
86
  "revision",
91
87
  "content_digest",
92
88
  "retrieved_at",
93
89
  "excerpt",
94
90
  "claims",
95
- ]) &&
96
- isBoundedString(value.locator, 500) &&
97
- (value.revision === null || isBoundedString(value.revision, 300)) &&
98
- (value.content_digest === null || isDigest(value.content_digest)) &&
99
- (value.revision !== null || value.content_digest !== null) &&
100
- isIsoTimestamp(value.retrieved_at) &&
101
- isBoundedString(value.excerpt, 300) &&
102
- Array.isArray(value.claims) &&
103
- value.claims.length > 0 &&
104
- value.claims.length <= MAX_CLAIMS_PER_SOURCE &&
105
- value.claims.every((claim) => isBoundedString(claim, 500))
106
- );
91
+ ])
92
+ ) {
93
+ throw new TypeError(`${description} has unknown or missing fields`);
94
+ }
95
+ if (!isBoundedString(value.locator, 500)) {
96
+ throw new TypeError(`${description} locator is invalid`);
97
+ }
98
+ if (value.revision !== null && !isBoundedString(value.revision, 300)) {
99
+ throw new TypeError(`${description} revision is invalid`);
100
+ }
101
+ if (value.content_digest !== null && !isDigest(value.content_digest)) {
102
+ throw new TypeError(`${description} content_digest is invalid`);
103
+ }
104
+ if (value.revision === null && value.content_digest === null) {
105
+ throw new TypeError(`${description} requires a revision or content_digest`);
106
+ }
107
+ if (!isIsoTimestamp(value.retrieved_at)) {
108
+ throw new TypeError(`${description} retrieved_at is not an ISO timestamp`);
109
+ }
110
+ if (!isBoundedString(value.excerpt, 300)) {
111
+ throw new TypeError(`${description} excerpt is invalid`);
112
+ }
113
+ if (
114
+ !Array.isArray(value.claims) ||
115
+ value.claims.length === 0 ||
116
+ value.claims.length > MAX_CLAIMS_PER_SOURCE ||
117
+ !value.claims.every((claim) => isBoundedString(claim, 500))
118
+ ) {
119
+ throw new TypeError(`${description} claims are invalid`);
120
+ }
121
+ return value as unknown as EvidenceSource;
107
122
  }
108
123
 
109
- function isEvidenceProbe(value: unknown): value is EvidenceProbe {
110
- return (
111
- isRecord(value) &&
112
- hasExactKeys(value, ["command", "result_digest", "summary"]) &&
113
- isBoundedString(value.command, 500) &&
114
- isDigest(value.result_digest) &&
115
- isBoundedString(value.summary, 500)
116
- );
124
+ function parseEvidenceProbe(value: unknown, index: number): EvidenceProbe {
125
+ const description = `review evidence probe ${index + 1}`;
126
+ if (
127
+ !isRecord(value) ||
128
+ !hasExactKeys(value, ["command", "result_digest", "summary"])
129
+ ) {
130
+ throw new TypeError(`${description} has unknown or missing fields`);
131
+ }
132
+ if (!isBoundedString(value.command, 500)) {
133
+ throw new TypeError(`${description} command is invalid`);
134
+ }
135
+ if (!isDigest(value.result_digest)) {
136
+ throw new TypeError(`${description} result_digest is invalid`);
137
+ }
138
+ if (!isBoundedString(value.summary, 500)) {
139
+ throw new TypeError(`${description} summary is invalid`);
140
+ }
141
+ return value as unknown as EvidenceProbe;
117
142
  }
118
143
 
119
144
  function isIsoTimestamp(value: unknown): value is string {
120
145
  if (typeof value !== "string") return false;
121
146
  const parsed = new Date(value);
122
- return !Number.isNaN(parsed.getTime()) && parsed.toISOString() === value;
147
+ return (
148
+ !Number.isNaN(parsed.getTime()) &&
149
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(value)
150
+ );
123
151
  }
124
152
 
125
153
  function isDigest(value: unknown): value is string {