@letta-ai/letta-code 0.30.26 → 0.30.28

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 (55) hide show
  1. package/dist/agent-presets.js +17 -17
  2. package/dist/agent-presets.js.map +1 -1
  3. package/dist/mcp-client.js +2 -2
  4. package/dist/mcp-client.js.map +1 -1
  5. package/dist/types/agent/turn-recovery-policy.d.ts +33 -0
  6. package/dist/types/agent/turn-recovery-policy.d.ts.map +1 -1
  7. package/dist/types/tools/impl/apply-patch.d.ts.map +1 -1
  8. package/dist/types/tools/secret-substitution.d.ts.map +1 -1
  9. package/dist/types/types/loop-status-protocol.d.ts +17 -0
  10. package/dist/types/types/loop-status-protocol.d.ts.map +1 -0
  11. package/dist/types/types/protocol_v2.d.ts +2 -19
  12. package/dist/types/types/protocol_v2.d.ts.map +1 -1
  13. package/dist/types/websocket/listener/inbound-queue.d.ts +5 -0
  14. package/dist/types/websocket/listener/inbound-queue.d.ts.map +1 -0
  15. package/dist/types/websocket/listener/protocol-outbound-routing.d.ts +9 -0
  16. package/dist/types/websocket/listener/protocol-outbound-routing.d.ts.map +1 -0
  17. package/dist/types/websocket/listener/protocol-outbound.d.ts.map +1 -1
  18. package/dist/types/websocket/listener/runtime.d.ts.map +1 -1
  19. package/dist/types/websocket/listener/turn-correlation.d.ts +10 -0
  20. package/dist/types/websocket/listener/turn-correlation.d.ts.map +1 -0
  21. package/dist/types/websocket/listener/types.d.ts +4 -0
  22. package/dist/types/websocket/listener/types.d.ts.map +1 -1
  23. package/letta.js +545 -120
  24. package/package.json +1 -1
  25. package/scripts/claude-watch/agent-watch.ts +622 -0
  26. package/scripts/claude-watch/docs-snapshot.test.ts +259 -0
  27. package/scripts/claude-watch/docs-snapshot.ts +672 -0
  28. package/scripts/claude-watch/fixtures/historical-replays.json +52 -0
  29. package/scripts/claude-watch/github.ts +137 -0
  30. package/scripts/claude-watch/release-analysis.test.ts +235 -0
  31. package/scripts/claude-watch/release-analysis.ts +297 -0
  32. package/scripts/claude-watch/release-source.test.ts +179 -0
  33. package/scripts/claude-watch/release-source.ts +369 -0
  34. package/scripts/claude-watch/runtime-observations.ts +98 -0
  35. package/scripts/claude-watch/runtime-probe.test.ts +576 -0
  36. package/scripts/claude-watch/runtime-probe.ts +911 -0
  37. package/scripts/claude-watch/runtime-sandbox.ts +170 -0
  38. package/scripts/claude-watch/state-branch.test.ts +211 -0
  39. package/scripts/claude-watch/state-branch.ts +316 -0
  40. package/scripts/claude-watch/tracker.test.ts +148 -0
  41. package/scripts/claude-watch/tracker.ts +325 -0
  42. package/scripts/claude-watch/types.ts +186 -0
  43. package/scripts/claude-watch/update-tracker.ts +201 -0
  44. package/scripts/codex-watch/agent-watch.ts +2 -2
  45. package/scripts/codex-watch/release-analysis.ts +14 -2
  46. package/scripts/codex-watch/tracker.ts +1 -3
  47. package/scripts/run-unit-tests.cjs +2 -0
  48. package/scripts/source-file-size-baseline.json +6 -5
  49. package/skills/creating-mods/references/commands.md +1 -1
  50. package/skills/creating-mods/references/ui.md +1 -1
  51. package/skills/customizing-commands/SKILL.md +1 -1
  52. package/skills/initializing-memory/SKILL.md +7 -7
  53. package/skills/self-configuration/SKILL.md +4 -4
  54. package/scripts/codex-watch/check-release.ts +0 -128
  55. package/scripts/codex-watch/render-issue.ts +0 -273
@@ -0,0 +1,672 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createTwoFilesPatch } from "diff";
3
+ import type {
4
+ ClaudeDocsDiff,
5
+ ClaudeDocsPageDiff,
6
+ ClaudeDocsPageSnapshot,
7
+ ClaudeDocsSnapshot,
8
+ ClaudeNamedChange,
9
+ } from "./types.ts";
10
+
11
+ export const DEFAULT_LLMS_URL = "https://code.claude.com/docs/llms.txt";
12
+ export const OFFICIAL_CHANGELOG_URL =
13
+ "https://raw.githubusercontent.com/anthropics/claude-code/main/CHANGELOG.md";
14
+ export const FULL_SCAN_INTERVAL_MS = 24 * 60 * 60 * 1_000;
15
+
16
+ const WATCHED_PATH_PARTS = [
17
+ "/tools",
18
+ "/cli",
19
+ "/commands",
20
+ "/changelog",
21
+ "/settings",
22
+ "/environment",
23
+ "/env",
24
+ "/permissions",
25
+ "/headless",
26
+ "/how-it-works",
27
+ "/sub-agents",
28
+ "/subagents",
29
+ "/worktrees",
30
+ "/interactive",
31
+ "/hooks",
32
+ "/agent-sdk",
33
+ "/claude-agent-sdk",
34
+ ] as const;
35
+
36
+ export type DocsFetch = (
37
+ input: string | URL | Request,
38
+ init?: RequestInit,
39
+ ) => Promise<Response>;
40
+
41
+ export interface CaptureDocsOptions {
42
+ fetch?: DocsFetch;
43
+ indexUrl?: string;
44
+ previous?: ClaudeDocsSnapshot | null;
45
+ forceFullScan?: boolean;
46
+ packageRelease?: boolean;
47
+ now?: Date | string | number;
48
+ timeoutMs?: number;
49
+ retries?: number;
50
+ retryBackoffMs?: number;
51
+ }
52
+
53
+ export interface CapturedDocs {
54
+ snapshot: ClaudeDocsSnapshot;
55
+ /** Normalized markdown, keyed by the snapshot's deterministic source_path. */
56
+ sources: Record<string, string>;
57
+ }
58
+
59
+ export interface DiffDocsOptions {
60
+ previousSources?: Record<string, string>;
61
+ currentSources?: Record<string, string>;
62
+ maxPreviewLines?: number;
63
+ maxPreviewChars?: number;
64
+ }
65
+
66
+ /** llms.txt is an index, not prose: every absolute HTTP(S) URL ending in .md is authoritative. */
67
+ export function parseLlmsTxt(text: string): string[] {
68
+ const urls = new Set<string>();
69
+ const pattern = /https?:\/\/[^\s<>"'`]+/gu;
70
+ for (const match of text.matchAll(pattern)) {
71
+ const candidate = match[0].replace(/[),.;:\]}]+$/u, "");
72
+ try {
73
+ const url = new URL(candidate);
74
+ if (url.pathname.toLowerCase().endsWith(".md")) {
75
+ url.hash = "";
76
+ urls.add(url.toString());
77
+ }
78
+ } catch {
79
+ // A malformed absolute URL cannot be fetched and is intentionally ignored.
80
+ }
81
+ }
82
+ return [...urls].sort();
83
+ }
84
+
85
+ /**
86
+ * Normalize only transport/presentation noise. In particular, leading whitespace,
87
+ * blank lines, headings, tables, code and identifier spelling remain untouched.
88
+ */
89
+ export function normalizeDocsMarkdown(input: string): string {
90
+ const lines = input
91
+ .replace(/^\uFEFF/u, "")
92
+ .replace(/\r\n?/gu, "\n")
93
+ .split("\n");
94
+ const seenBanners = new Set<string>();
95
+ const normalized: string[] = [];
96
+
97
+ for (const rawLine of lines) {
98
+ const line = rawLine.replace(/[\t ]+$/u, "");
99
+ if (isDocsBanner(line)) {
100
+ const key = line.trim().toLowerCase();
101
+ if (seenBanners.has(key)) continue;
102
+ seenBanners.add(key);
103
+ }
104
+ normalized.push(line);
105
+ }
106
+
107
+ while (normalized.length > 0 && normalized[normalized.length - 1] === "") {
108
+ normalized.pop();
109
+ }
110
+ return `${normalized.join("\n")}\n`;
111
+ }
112
+
113
+ function isDocsBanner(line: string): boolean {
114
+ const value = line
115
+ .trim()
116
+ .replace(/^#+\s*/u, "")
117
+ .replace(/^>\s*/u, "");
118
+ return /^(?:anthropic\s+)?claude(?:\s+code)?\s+docs(?:umentation)?$/iu.test(
119
+ value,
120
+ );
121
+ }
122
+
123
+ export function sha256(content: string): string {
124
+ return createHash("sha256").update(content, "utf8").digest("hex");
125
+ }
126
+
127
+ export function hashDocsMarkdown(content: string): string {
128
+ return sha256(normalizeDocsMarkdown(content));
129
+ }
130
+
131
+ export function isWatchedDocsUrl(value: string): boolean {
132
+ try {
133
+ const url = new URL(value);
134
+ const path = url.pathname.toLowerCase().replace(/\.md$/u, "");
135
+ const slugParts = path.split("/").filter(Boolean);
136
+ return (
137
+ WATCHED_PATH_PARTS.some((part) => {
138
+ const watched = part.slice(1);
139
+ return (
140
+ path.includes(`${part}/`) ||
141
+ slugParts.some(
142
+ (slug) =>
143
+ slug === watched ||
144
+ slug.startsWith(`${watched}-`) ||
145
+ slug.endsWith(`-${watched}`),
146
+ )
147
+ );
148
+ }) || slugParts.some((slug) => /^(?:how-.*-works|env-vars)$/u.test(slug))
149
+ );
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+
155
+ export function deterministicSourcePath(value: string): string {
156
+ const url = new URL(value);
157
+ const host = url.hostname.toLowerCase().replace(/[^a-z0-9.-]+/gu, "-");
158
+ const path = decodeURIComponent(url.pathname)
159
+ .replace(/^\/+|\/+$/gu, "")
160
+ .replace(/\.md$/iu, "")
161
+ .split("/")
162
+ .map((part) =>
163
+ part.replace(/[^a-zA-Z0-9._-]+/gu, "-").replace(/^-+|-+$/gu, ""),
164
+ )
165
+ .filter(Boolean)
166
+ .join("/");
167
+ const query = url.search ? `-${sha256(url.search).slice(0, 10)}` : "";
168
+ return `sources/${host}/${path || "index"}${query}.md`;
169
+ }
170
+
171
+ export function shouldFullScan(options: CaptureDocsOptions): boolean {
172
+ if (options.forceFullScan || options.packageRelease || !options.previous)
173
+ return true;
174
+ const now = toDate(options.now ?? new Date()).getTime();
175
+ const lastFullScanAt = Date.parse(options.previous.scanned_at);
176
+ return (
177
+ !Number.isFinite(lastFullScanAt) ||
178
+ now - lastFullScanAt >= FULL_SCAN_INTERVAL_MS
179
+ );
180
+ }
181
+
182
+ export async function captureDocsSnapshot(
183
+ options: CaptureDocsOptions = {},
184
+ ): Promise<CapturedDocs> {
185
+ const fetcher = options.fetch ?? globalThis.fetch;
186
+ const indexUrl = options.indexUrl ?? DEFAULT_LLMS_URL;
187
+ const allowedOrigin = new URL(indexUrl).origin;
188
+ const fixedAllowedUrls = new Set(
189
+ indexUrl === DEFAULT_LLMS_URL ? [OFFICIAL_CHANGELOG_URL] : [],
190
+ );
191
+ const now = toDate(options.now ?? new Date());
192
+ const indexText = await fetchText(
193
+ indexUrl,
194
+ fetcher,
195
+ options,
196
+ allowedOrigin,
197
+ fixedAllowedUrls,
198
+ );
199
+ const normalizedIndex = normalizeDocsMarkdown(indexText);
200
+ const urls = [
201
+ ...new Set([...parseLlmsTxt(indexText), ...fixedAllowedUrls]),
202
+ ].sort();
203
+ const fullScan = shouldFullScan(options);
204
+ const pages: Record<string, ClaudeDocsPageSnapshot> = {};
205
+ const sources: Record<string, string> = {
206
+ "sources/llms.txt": normalizedIndex,
207
+ };
208
+
209
+ const capturedPages = await mapConcurrent(urls, 8, async (url) => {
210
+ const watched = isWatchedDocsUrl(url);
211
+ const oldPage = options.previous?.pages[url];
212
+ if (!fullScan && !watched && oldPage) {
213
+ return {
214
+ page: { ...oldPage, watched: false, source_path: null },
215
+ source: null,
216
+ };
217
+ }
218
+ // A newly indexed unwatched page has no truthful hash to reuse, so fetch it
219
+ // once. Established unwatched pages above remain network-free incrementally.
220
+ const normalized = normalizeDocsMarkdown(
221
+ await fetchText(url, fetcher, options, allowedOrigin, fixedAllowedUrls),
222
+ );
223
+ const sourcePath = watched ? deterministicSourcePath(url) : null;
224
+ return {
225
+ page: {
226
+ url,
227
+ hash: sha256(normalized),
228
+ watched,
229
+ source_path: sourcePath,
230
+ },
231
+ source: sourcePath ? ([sourcePath, normalized] as const) : null,
232
+ };
233
+ });
234
+ for (const { page, source } of capturedPages) {
235
+ pages[page.url] = page;
236
+ if (source) sources[source[0]] = source[1];
237
+ }
238
+
239
+ const snapshotWithoutDigest = {
240
+ index_url: indexUrl,
241
+ index_hash: sha256(normalizedIndex),
242
+ full_scan: fullScan,
243
+ // Preserve the last full-scan clock across incremental captures so the
244
+ // 24-hour policy remains enforceable with the persisted snapshot schema.
245
+ scanned_at: fullScan
246
+ ? now.toISOString()
247
+ : (options.previous?.scanned_at ?? now.toISOString()),
248
+ pages: sortRecord(pages),
249
+ };
250
+ const digest = docsDigest(
251
+ snapshotWithoutDigest.index_hash,
252
+ snapshotWithoutDigest.pages,
253
+ );
254
+ return {
255
+ snapshot: { ...snapshotWithoutDigest, digest },
256
+ sources: sortRecord(sources),
257
+ };
258
+ }
259
+
260
+ async function mapConcurrent<T, R>(
261
+ values: readonly T[],
262
+ concurrency: number,
263
+ mapper: (value: T) => Promise<R>,
264
+ ): Promise<R[]> {
265
+ const results = new Array<R>(values.length);
266
+ let next = 0;
267
+ const workers = Array.from(
268
+ { length: Math.min(concurrency, values.length) },
269
+ async () => {
270
+ while (next < values.length) {
271
+ const index = next;
272
+ next += 1;
273
+ results[index] = await mapper(values[index] as T);
274
+ }
275
+ },
276
+ );
277
+ await Promise.all(workers);
278
+ return results;
279
+ }
280
+
281
+ function docsDigest(
282
+ indexHash: string,
283
+ pages: Record<string, ClaudeDocsPageSnapshot>,
284
+ ): string {
285
+ // scanned_at and full_scan are operational metadata, not documentation content.
286
+ return sha256(
287
+ JSON.stringify({
288
+ index_hash: indexHash,
289
+ pages: Object.keys(pages)
290
+ .sort()
291
+ .map((url) => [url, pages[url]?.hash]),
292
+ }),
293
+ );
294
+ }
295
+
296
+ async function fetchText(
297
+ url: string,
298
+ fetcher: DocsFetch,
299
+ options: CaptureDocsOptions,
300
+ allowedOrigin: string,
301
+ fixedAllowedUrls: ReadonlySet<string>,
302
+ ): Promise<string> {
303
+ const retries = options.retries ?? 2;
304
+ const timeoutMs = options.timeoutMs ?? 15_000;
305
+ const backoff = options.retryBackoffMs ?? 250;
306
+ let lastError: unknown;
307
+ let currentUrl = url;
308
+ let redirects = 0;
309
+
310
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
311
+ const controller = new AbortController();
312
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
313
+ try {
314
+ assertAllowedDocsUrl(currentUrl, allowedOrigin, fixedAllowedUrls);
315
+ const response = await fetcher(currentUrl, {
316
+ signal: controller.signal,
317
+ redirect: "manual",
318
+ });
319
+ if (response.status >= 300 && response.status < 400) {
320
+ redirects += 1;
321
+ if (redirects > 3)
322
+ throw new Error(`Too many redirects fetching ${url}`);
323
+ const location = response.headers.get("location");
324
+ if (!location)
325
+ throw new Error(`Redirect without Location from ${currentUrl}`);
326
+ const redirected = new URL(location, currentUrl).toString();
327
+ assertAllowedDocsUrl(redirected, allowedOrigin, fixedAllowedUrls);
328
+ currentUrl = redirected;
329
+ attempt -= 1;
330
+ continue;
331
+ }
332
+ if (!response.ok)
333
+ throw new Error(`HTTP ${response.status} fetching ${currentUrl}`);
334
+ return await response.text();
335
+ } catch (error) {
336
+ lastError = error;
337
+ if (attempt < retries && backoff > 0) await sleep(backoff * 2 ** attempt);
338
+ } finally {
339
+ clearTimeout(timer);
340
+ }
341
+ }
342
+ throw lastError instanceof Error
343
+ ? lastError
344
+ : new Error(`Failed to fetch ${url}`);
345
+ }
346
+
347
+ function assertAllowedDocsUrl(
348
+ value: string,
349
+ allowedOrigin: string,
350
+ fixedAllowedUrls: ReadonlySet<string>,
351
+ ): void {
352
+ const url = new URL(value);
353
+ url.hash = "";
354
+ if (
355
+ url.protocol !== "https:" ||
356
+ (url.origin !== allowedOrigin && !fixedAllowedUrls.has(url.toString())) ||
357
+ url.username ||
358
+ url.password
359
+ ) {
360
+ throw new Error(
361
+ `Refusing documentation URL outside ${allowedOrigin}: ${value}`,
362
+ );
363
+ }
364
+ }
365
+
366
+ export function diffDocsSnapshots(
367
+ previous: ClaudeDocsSnapshot | null,
368
+ current: ClaudeDocsSnapshot,
369
+ options: DiffDocsOptions = {},
370
+ ): ClaudeDocsDiff {
371
+ const previousPages = previous?.pages ?? {};
372
+ const oldUrls = new Set(Object.keys(previousPages));
373
+ const newUrls = new Set(Object.keys(current.pages));
374
+ const addedPages = [...newUrls].filter((url) => !oldUrls.has(url)).sort();
375
+ const removedPages = [...oldUrls].filter((url) => !newUrls.has(url)).sort();
376
+ const changedPages = [...newUrls]
377
+ .filter(
378
+ (url) =>
379
+ oldUrls.has(url) &&
380
+ previousPages[url]?.hash !== current.pages[url]?.hash,
381
+ )
382
+ .sort();
383
+
384
+ const watchedPageDiffs: ClaudeDocsPageDiff[] = [];
385
+ const oldNamed = emptyNamedMaps();
386
+ const newNamed = emptyNamedMaps();
387
+ const relevant = [
388
+ ...new Set([...addedPages, ...removedPages, ...changedPages]),
389
+ ].sort();
390
+ for (const url of relevant) {
391
+ const oldPage = previousPages[url];
392
+ const newPage = current.pages[url];
393
+ const sourcePath = newPage?.source_path ?? oldPage?.source_path ?? null;
394
+ if (!sourcePath) continue;
395
+ const oldText = oldPage?.source_path
396
+ ? options.previousSources?.[oldPage.source_path]
397
+ : undefined;
398
+ const newText = newPage?.source_path
399
+ ? options.currentSources?.[newPage.source_path]
400
+ : undefined;
401
+ if (oldText === undefined && newText === undefined) continue;
402
+ const preview = unifiedDiff(
403
+ oldText ?? "",
404
+ newText ?? "",
405
+ sourcePath,
406
+ options.maxPreviewLines ?? 160,
407
+ options.maxPreviewChars ?? 20_000,
408
+ );
409
+ watchedPageDiffs.push({ url, source_path: sourcePath, ...preview });
410
+ mergeNamedMaps(oldNamed, extractNamedEntries(oldText ?? ""));
411
+ mergeNamedMaps(newNamed, extractNamedEntries(newText ?? ""));
412
+ }
413
+
414
+ return {
415
+ added_pages: addedPages,
416
+ removed_pages: removedPages,
417
+ changed_pages: changedPages,
418
+ watched_page_diffs: watchedPageDiffs,
419
+ tools: compareNames(oldNamed.tools, newNamed.tools),
420
+ cli: compareNames(oldNamed.cli, newNamed.cli),
421
+ settings: compareNames(oldNamed.settings, newNamed.settings),
422
+ env_vars: compareNames(oldNamed.env_vars, newNamed.env_vars),
423
+ permission_rules: compareNames(
424
+ oldNamed.permission_rules,
425
+ newNamed.permission_rules,
426
+ ),
427
+ };
428
+ }
429
+
430
+ type NamedSets = Record<
431
+ "tools" | "cli" | "settings" | "env_vars" | "permission_rules",
432
+ Set<string>
433
+ >;
434
+
435
+ function emptyNamedCollections(): NamedSets {
436
+ return {
437
+ tools: new Set(),
438
+ cli: new Set(),
439
+ settings: new Set(),
440
+ env_vars: new Set(),
441
+ permission_rules: new Set(),
442
+ };
443
+ }
444
+
445
+ type NamedMaps = Record<keyof NamedSets, Map<string, string>>;
446
+
447
+ function emptyNamedMaps(): NamedMaps {
448
+ return {
449
+ tools: new Map(),
450
+ cli: new Map(),
451
+ settings: new Map(),
452
+ env_vars: new Map(),
453
+ permission_rules: new Map(),
454
+ };
455
+ }
456
+
457
+ function mergeNamedMaps(target: NamedMaps, source: NamedMaps): void {
458
+ for (const key of Object.keys(target) as Array<keyof NamedMaps>) {
459
+ for (const [name, signature] of source[key]) {
460
+ const existing = target[key].get(name);
461
+ target[key].set(
462
+ name,
463
+ existing ? sha256(`${existing}\n${signature}`) : signature,
464
+ );
465
+ }
466
+ }
467
+ }
468
+
469
+ /** Extract only names presented in strongly-labelled documentation contexts. */
470
+ export function extractNamedChanges(markdown: string): NamedSets {
471
+ const result = emptyNamedCollections();
472
+ const lines = normalizeDocsMarkdown(markdown).split("\n");
473
+ let heading = "";
474
+ let tableKind: keyof NamedSets | null = null;
475
+ let tableNameColumn = -1;
476
+
477
+ for (const line of lines) {
478
+ const headingMatch = /^#{1,6}\s+(.+)$/u.exec(line);
479
+ if (headingMatch?.[1]) {
480
+ heading = headingMatch[1].toLowerCase();
481
+ tableKind = null;
482
+ tableNameColumn = -1;
483
+ }
484
+
485
+ if (line.includes("|") && /^\s*\|/u.test(line)) {
486
+ const cells = tableCells(line);
487
+ const lower = cells.map((cell) => cell.toLowerCase());
488
+ if (lower.some((cell) => /^(tool|tool name)$/u.test(cell))) {
489
+ tableKind = "tools";
490
+ tableNameColumn = lower.findIndex((cell) =>
491
+ /^(tool|tool name)$/u.test(cell),
492
+ );
493
+ continue;
494
+ }
495
+ const contextualKind = headingKind(heading);
496
+ const nameColumn = lower.findIndex((cell) =>
497
+ /^(name|setting|variable|rule|command|flag)$/u.test(cell),
498
+ );
499
+ if (contextualKind && nameColumn >= 0) {
500
+ tableKind = contextualKind;
501
+ tableNameColumn = nameColumn;
502
+ continue;
503
+ }
504
+ if (tableKind && !cells.every((cell) => /^:?-{3,}:?$/u.test(cell))) {
505
+ const value = cleanName(cells[tableNameColumn] ?? "");
506
+ if (
507
+ value &&
508
+ (tableKind !== "tools" ||
509
+ /^(?:[A-Z][A-Za-z0-9_]*|mcp__[A-Za-z0-9_]+)$/u.test(value))
510
+ ) {
511
+ result[tableKind].add(value);
512
+ }
513
+ }
514
+ }
515
+
516
+ if (/\b(?:cli|command|flags?|options?)\b/iu.test(heading)) {
517
+ for (const match of line.matchAll(
518
+ /(?:^|[\s`,(])(--[a-z0-9][a-z0-9-]*)(?=$|[\s`,)=])/giu,
519
+ )) {
520
+ if (match[1]) result.cli.add(match[1]);
521
+ }
522
+ for (const match of line.matchAll(
523
+ /`(claude(?:\s+[a-z][a-z0-9-]*)+)`/giu,
524
+ )) {
525
+ if (match[1]) result.cli.add(match[1]);
526
+ }
527
+ }
528
+ if (/\b(?:settings?|configuration)\b/iu.test(heading)) {
529
+ for (const match of line.matchAll(
530
+ /`([a-z][a-zA-Z0-9]*(?:\.[a-zA-Z0-9_-]+)+)`/gu,
531
+ )) {
532
+ if (match[1]) result.settings.add(match[1]);
533
+ }
534
+ }
535
+ if (/\b(?:environment|env(?:ironment)? variables?)\b/iu.test(heading)) {
536
+ for (const match of line.matchAll(/`([A-Z][A-Z0-9_]{2,})`/gu)) {
537
+ if (match[1]) result.env_vars.add(match[1]);
538
+ }
539
+ }
540
+ if (/\bpermissions?\b/iu.test(heading)) {
541
+ for (const match of line.matchAll(
542
+ /`((?:allow|deny|ask)(?:\([^`\n]+\)|\.[a-zA-Z0-9_.-]+))`/gu,
543
+ )) {
544
+ if (match[1]) result.permission_rules.add(match[1]);
545
+ }
546
+ }
547
+ }
548
+ return result;
549
+ }
550
+
551
+ function extractNamedEntries(markdown: string): NamedMaps {
552
+ const names = extractNamedChanges(markdown);
553
+ const result = emptyNamedMaps();
554
+ const lines = normalizeDocsMarkdown(markdown).split("\n");
555
+ for (const key of Object.keys(names) as Array<keyof NamedSets>) {
556
+ for (const name of names[key]) {
557
+ // Tie a name to only the strongly-labelled line that exposed it. This
558
+ // reports table-row/flag-description edits without interpreting prose.
559
+ const evidence = lines.filter((line) => line.includes(name)).join("\n");
560
+ result[key].set(name, sha256(evidence));
561
+ }
562
+ }
563
+ return result;
564
+ }
565
+
566
+ function headingKind(heading: string): keyof NamedSets | null {
567
+ if (/\btools?\b/u.test(heading)) return "tools";
568
+ if (/\b(?:cli|commands?|flags?|options?)\b/u.test(heading)) return "cli";
569
+ if (/\b(?:settings?|configuration)\b/u.test(heading)) return "settings";
570
+ if (/\b(?:environment|env variables?)\b/u.test(heading)) return "env_vars";
571
+ if (/\bpermissions?\b/u.test(heading)) return "permission_rules";
572
+ return null;
573
+ }
574
+
575
+ function tableCells(line: string): string[] {
576
+ return line
577
+ .trim()
578
+ .replace(/^\||\|$/gu, "")
579
+ .split("|")
580
+ .map((cell) => cell.trim());
581
+ }
582
+
583
+ function cleanName(value: string): string | null {
584
+ const cleaned = value.replace(/[*_]/gu, "").replace(/^`|`$/gu, "").trim();
585
+ if (
586
+ !cleaned ||
587
+ /^:?-{3,}:?$/u.test(cleaned) ||
588
+ cleaned.length > 120 ||
589
+ /\n/u.test(cleaned)
590
+ )
591
+ return null;
592
+ return cleaned;
593
+ }
594
+
595
+ function compareNames(
596
+ oldNames: Map<string, string>,
597
+ newNames: Map<string, string>,
598
+ ): ClaudeNamedChange {
599
+ return {
600
+ added: [...newNames.keys()].filter((name) => !oldNames.has(name)).sort(),
601
+ removed: [...oldNames.keys()].filter((name) => !newNames.has(name)).sort(),
602
+ changed: [...newNames.keys()]
603
+ .filter(
604
+ (name) =>
605
+ oldNames.has(name) && oldNames.get(name) !== newNames.get(name),
606
+ )
607
+ .sort(),
608
+ };
609
+ }
610
+
611
+ export function unifiedDiff(
612
+ oldText: string,
613
+ newText: string,
614
+ sourcePath: string,
615
+ maxLines = 160,
616
+ maxChars = 20_000,
617
+ ): { preview: string; truncated: boolean } {
618
+ const oldLines = splitDiffLines(oldText);
619
+ const newLines = splitDiffLines(newText);
620
+ const patch = createTwoFilesPatch(
621
+ `a/${sourcePath}`,
622
+ `b/${sourcePath}`,
623
+ oldLines.join("\n"),
624
+ newLines.join("\n"),
625
+ "",
626
+ "",
627
+ { context: 3 },
628
+ );
629
+ const output = patch.split("\n").filter((line, index) => {
630
+ return !(index === 0 && /^=+$/u.test(line));
631
+ });
632
+ let preview = output.join("\n");
633
+ let truncated = false;
634
+ if (output.length > maxLines) {
635
+ preview = [
636
+ ...output.slice(0, Math.max(0, maxLines - 1)),
637
+ `... [diff truncated: ${output.length - maxLines + 1} lines omitted] ...`,
638
+ ].join("\n");
639
+ truncated = true;
640
+ }
641
+ if (preview.length > maxChars) {
642
+ const marker = `\n... [diff truncated: character limit ${maxChars}] ...`;
643
+ preview = `${preview.slice(0, Math.max(0, maxChars - marker.length))}${marker}`;
644
+ truncated = true;
645
+ }
646
+ return { preview, truncated };
647
+ }
648
+
649
+ function splitDiffLines(text: string): string[] {
650
+ if (!text) return [];
651
+ const lines = text.replace(/\r\n?/gu, "\n").split("\n");
652
+ if (lines[lines.length - 1] === "") lines.pop();
653
+ return lines;
654
+ }
655
+
656
+ function sortRecord<T>(record: Record<string, T>): Record<string, T> {
657
+ return Object.fromEntries(
658
+ Object.entries(record).sort(([a], [b]) => a.localeCompare(b)),
659
+ );
660
+ }
661
+
662
+ function toDate(value: Date | string | number): Date {
663
+ const date =
664
+ value instanceof Date ? new Date(value.getTime()) : new Date(value);
665
+ if (!Number.isFinite(date.getTime()))
666
+ throw new Error(`Invalid snapshot time: ${String(value)}`);
667
+ return date;
668
+ }
669
+
670
+ function sleep(milliseconds: number): Promise<void> {
671
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
672
+ }