throughline 0.3.23 → 0.3.25

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 (111) hide show
  1. package/.claude/commands/tl-trim.md +42 -0
  2. package/.codex-sidecar.yml +62 -0
  3. package/CHANGELOG.md +583 -0
  4. package/README.ja.md +42 -5
  5. package/README.md +400 -23
  6. package/bin/throughline.mjs +168 -4
  7. package/codex/skills/throughline/SKILL.md +157 -0
  8. package/codex/skills/throughline/agents/openai.yaml +7 -0
  9. package/docs/INHERITANCE_ON_CLEAR_ONLY.md +146 -0
  10. package/docs/L1_L2_L3_REDESIGN.md +415 -0
  11. package/docs/PUBLIC_RELEASE_PLAN.md +184 -0
  12. package/docs/THROUGHLINE_CODEX_DUAL_SUPPORT.md +249 -0
  13. package/docs/THROUGHLINE_CODEX_FIRST_ROADMAP.md +555 -0
  14. package/docs/THROUGHLINE_CODEX_MONITOR_IMPLEMENTATION_PLAN.md +220 -0
  15. package/docs/THROUGHLINE_CODEX_TRIM_IMPLEMENTATION_PLAN.md +528 -0
  16. package/docs/THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md +672 -0
  17. package/docs/archive/CONCEPT.md +476 -0
  18. package/docs/archive/EXPERIMENT.md +371 -0
  19. package/docs/archive/README.md +22 -0
  20. package/docs/archive/SESSION_LINKING_DESIGN.md +231 -0
  21. package/docs/archive/THROUGHLINE_NEXT_STEPS.md +134 -0
  22. package/docs/throughline-codex-trim-rollback-incident-report.md +306 -0
  23. package/docs/throughline-handoff-context.example.json +57 -0
  24. package/docs/throughline-rollback-context-trim-insight.md +455 -0
  25. package/package.json +6 -2
  26. package/src/cli/codex-capture.mjs +95 -0
  27. package/src/cli/codex-handoff-model-smoke.mjs +292 -0
  28. package/src/cli/codex-handoff-model-smoke.test.mjs +262 -0
  29. package/src/cli/codex-handoff-smoke.mjs +163 -0
  30. package/src/cli/codex-handoff-smoke.test.mjs +149 -0
  31. package/src/cli/codex-handoff-start.mjs +291 -0
  32. package/src/cli/codex-handoff-start.test.mjs +194 -0
  33. package/src/cli/codex-hook.mjs +276 -0
  34. package/src/cli/codex-hook.test.mjs +293 -0
  35. package/src/cli/codex-host-primitive-audit.mjs +110 -0
  36. package/src/cli/codex-host-primitive-audit.test.mjs +75 -0
  37. package/src/cli/codex-restore-smoke.mjs +357 -0
  38. package/src/cli/codex-restore-source-audit.mjs +304 -0
  39. package/src/cli/codex-resume.mjs +138 -0
  40. package/src/cli/codex-rollback-model-visible-smoke.mjs +373 -0
  41. package/src/cli/codex-rollback-model-visible-smoke.test.mjs +255 -0
  42. package/src/cli/codex-sidecar-diagnostics.mjs +48 -0
  43. package/src/cli/codex-sidecar-dry-run.mjs +85 -0
  44. package/src/cli/codex-summarize.mjs +224 -0
  45. package/src/cli/codex-threads.mjs +89 -0
  46. package/src/cli/codex-visibility-smoke.mjs +196 -0
  47. package/src/cli/codex-vscode-restore-smoke.mjs +226 -0
  48. package/src/cli/codex-vscode-rollback-smoke.mjs +114 -0
  49. package/src/cli/doctor.mjs +503 -1
  50. package/src/cli/doctor.test.mjs +542 -3
  51. package/src/cli/handoff-preview.mjs +78 -0
  52. package/src/cli/help.test.mjs +64 -0
  53. package/src/cli/install.mjs +227 -4
  54. package/src/cli/install.test.mjs +207 -4
  55. package/src/cli/trim.mjs +564 -0
  56. package/src/codex-app-server.mjs +1816 -0
  57. package/src/codex-app-server.test.mjs +512 -0
  58. package/src/codex-auto-refresh.mjs +194 -0
  59. package/src/codex-auto-refresh.test.mjs +182 -0
  60. package/src/codex-capture.mjs +235 -0
  61. package/src/codex-capture.test.mjs +393 -0
  62. package/src/codex-handoff-model-smoke.mjs +114 -0
  63. package/src/codex-handoff-model-smoke.test.mjs +89 -0
  64. package/src/codex-handoff-smoke.mjs +124 -0
  65. package/src/codex-handoff-smoke.test.mjs +103 -0
  66. package/src/codex-handoff.mjs +331 -0
  67. package/src/codex-handoff.test.mjs +220 -0
  68. package/src/codex-host-primitive-audit.mjs +374 -0
  69. package/src/codex-host-primitive-audit.test.mjs +208 -0
  70. package/src/codex-restore-smoke.test.mjs +639 -0
  71. package/src/codex-restore-source-audit.mjs +1348 -0
  72. package/src/codex-restore-source-audit.test.mjs +623 -0
  73. package/src/codex-resume.test.mjs +242 -0
  74. package/src/codex-rollout-memory.mjs +711 -0
  75. package/src/codex-rollout-memory.test.mjs +610 -0
  76. package/src/codex-sidecar-cli.test.mjs +75 -0
  77. package/src/codex-sidecar.mjs +246 -0
  78. package/src/codex-sidecar.test.mjs +172 -0
  79. package/src/codex-summarize.test.mjs +143 -0
  80. package/src/codex-thread-identity.mjs +23 -0
  81. package/src/codex-thread-index.mjs +173 -0
  82. package/src/codex-thread-index.test.mjs +164 -0
  83. package/src/codex-usage.mjs +110 -0
  84. package/src/codex-usage.test.mjs +140 -0
  85. package/src/codex-visibility-smoke.test.mjs +222 -0
  86. package/src/codex-vscode-restore-smoke.mjs +206 -0
  87. package/src/codex-vscode-restore-smoke.test.mjs +325 -0
  88. package/src/codex-vscode-rollback-smoke.mjs +90 -0
  89. package/src/codex-vscode-rollback-smoke.test.mjs +290 -0
  90. package/src/db-schema.test.mjs +97 -0
  91. package/src/haiku-summarizer.mjs +267 -26
  92. package/src/haiku-summarizer.test.mjs +282 -0
  93. package/src/handoff-preview.test.mjs +108 -0
  94. package/src/handoff-record.mjs +294 -0
  95. package/src/handoff-record.test.mjs +226 -0
  96. package/src/hook-entrypoints.test.mjs +326 -0
  97. package/src/package-files.test.mjs +19 -0
  98. package/src/prompt-submit.mjs +9 -6
  99. package/src/resume-context.mjs +44 -140
  100. package/src/resume-context.test.mjs +172 -0
  101. package/src/session-start.mjs +8 -5
  102. package/src/state-file.mjs +50 -6
  103. package/src/state-file.test.mjs +50 -0
  104. package/src/token-monitor.mjs +14 -10
  105. package/src/token-monitor.test.mjs +27 -0
  106. package/src/trim-cli.test.mjs +1584 -0
  107. package/src/trim-model.mjs +584 -0
  108. package/src/trim-model.test.mjs +568 -0
  109. package/src/turn-processor.mjs +17 -10
  110. package/src/vscode-task.mjs +94 -6
  111. package/src/vscode-task.test.mjs +186 -6
@@ -0,0 +1,222 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
10
+
11
+ function makeTempHome() {
12
+ return mkdtempSync(join(tmpdir(), 'tl-codex-visible-home-'));
13
+ }
14
+
15
+ function makeTempProject() {
16
+ return mkdtempSync(join(tmpdir(), 'tl-codex-visible-project-'));
17
+ }
18
+
19
+ function makeFakeCodexAppServer(dir) {
20
+ const script = join(dir, 'fake-codex-app-server.mjs');
21
+ writeFileSync(
22
+ script,
23
+ `#!/usr/bin/env node
24
+ import { createInterface } from 'node:readline';
25
+ const rl = createInterface({ input: process.stdin });
26
+ let injectedText = '';
27
+ let resumeAfterInject = false;
28
+ function send(message) { process.stdout.write(JSON.stringify(message) + '\\n'); }
29
+ rl.on('line', (line) => {
30
+ const msg = JSON.parse(line);
31
+ if (msg.method === 'initialized') return;
32
+ if (msg.method === 'initialize') {
33
+ send({ id: msg.id, result: { userAgent: 'fake-codex' } });
34
+ } else if (msg.method === 'thread/read' || msg.method === 'thread/resume') {
35
+ if (msg.method === 'thread/resume' && injectedText) resumeAfterInject = true;
36
+ send({ id: msg.id, result: { thread: { id: msg.params.threadId, turns: [{ id: 'turn-1' }] } } });
37
+ } else if (msg.method === 'thread/inject_items') {
38
+ injectedText = JSON.stringify(msg.params);
39
+ send({ id: msg.id, result: { thread: { id: msg.params.threadId, turns: [{ id: 'turn-1' }, { id: 'memory' }] } } });
40
+ } else if (msg.method === 'turn/start') {
41
+ const marker = resumeAfterInject ? 'TL_CLI_RESUME_AFTER_INJECT' : injectedText.includes('Codex memo smoke') ? 'TL_CLI_MEMO' : 'TL_CLI_VISIBLE';
42
+ send({ method: 'item/agentMessage/delta', params: { threadId: msg.params.threadId, turnId: 'turn-2', itemId: 'item-1', delta: marker } });
43
+ send({ id: msg.id, result: { turn: { id: 'turn-2' } } });
44
+ } else {
45
+ send({ id: msg.id, error: { code: -32601, message: 'unknown method' } });
46
+ }
47
+ });
48
+ `,
49
+ );
50
+ chmodSync(script, 0o755);
51
+ return script;
52
+ }
53
+
54
+ async function seedDb(home, project) {
55
+ const originalHome = process.env.HOME;
56
+ const originalUserProfile = process.env.USERPROFILE;
57
+ process.env.HOME = home;
58
+ process.env.USERPROFILE = home;
59
+ try {
60
+ const mod = await import(`./db.mjs?codexVisible=${Date.now()}-${Math.random()}`);
61
+ const db = mod.getDb();
62
+ db.prepare(
63
+ `INSERT INTO sessions (session_id, project_path, status, created_at, updated_at)
64
+ VALUES ('codex:thread-visible', ?, 'active', 1, 2)`,
65
+ ).run(project);
66
+ db.prepare(
67
+ `INSERT INTO bodies
68
+ (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
69
+ VALUES ('codex:thread-visible', 'codex:thread-visible', 1, 'assistant',
70
+ 'visible smoke body', 3, 1000)`,
71
+ ).run();
72
+ db.close();
73
+ } finally {
74
+ if (originalHome === undefined) delete process.env.HOME;
75
+ else process.env.HOME = originalHome;
76
+ if (originalUserProfile === undefined) delete process.env.USERPROFILE;
77
+ else process.env.USERPROFILE = originalUserProfile;
78
+ }
79
+ }
80
+
81
+ function runSmoke(home, project, args = [], extraEnv = {}, input = undefined) {
82
+ return spawnSync(
83
+ process.execPath,
84
+ [join(REPO_ROOT, 'bin/throughline.mjs'), 'codex-visibility-smoke', ...args],
85
+ {
86
+ cwd: project,
87
+ env: {
88
+ ...process.env,
89
+ HOME: home,
90
+ USERPROFILE: home,
91
+ ...extraEnv,
92
+ },
93
+ encoding: 'utf8',
94
+ input,
95
+ },
96
+ );
97
+ }
98
+
99
+ test('codex-visibility-smoke refuses without explicit experimental env', async () => {
100
+ const home = makeTempHome();
101
+ const project = makeTempProject();
102
+ try {
103
+ await seedDb(home, project);
104
+ const result = runSmoke(home, project, [
105
+ '--session',
106
+ 'codex:thread-visible',
107
+ '--marker',
108
+ 'TL_CLI_VISIBLE',
109
+ '--json',
110
+ ]);
111
+
112
+ assert.equal(result.status, 1);
113
+ const payload = JSON.parse(result.stdout);
114
+ assert.equal(payload.status, 'refused');
115
+ assert.equal(payload.reason, 'experimental_env_required');
116
+ } finally {
117
+ rmSync(project, { recursive: true, force: true });
118
+ rmSync(home, { recursive: true, force: true });
119
+ }
120
+ });
121
+
122
+ test('codex-visibility-smoke runs marker smoke with fake app-server', async () => {
123
+ const home = makeTempHome();
124
+ const project = makeTempProject();
125
+ try {
126
+ await seedDb(home, project);
127
+ const fake = makeFakeCodexAppServer(project);
128
+ const result = runSmoke(
129
+ home,
130
+ project,
131
+ [
132
+ '--session',
133
+ 'codex:thread-visible',
134
+ '--marker',
135
+ 'TL_CLI_VISIBLE',
136
+ '--codex-app-server-bin',
137
+ fake,
138
+ '--json',
139
+ ],
140
+ { THROUGHLINE_EXPERIMENTAL_CODEX_MODEL_VISIBLE_SMOKE: '1' },
141
+ );
142
+
143
+ assert.equal(result.status, 0, result.stderr);
144
+ const payload = JSON.parse(result.stdout);
145
+ assert.equal(payload.status, 'visible');
146
+ assert.equal(payload.reason, 'marker_found_in_agent_message');
147
+ assert.equal(payload.threadId, 'thread-visible');
148
+ assert.equal(payload.injectSent, true);
149
+ assert.equal(payload.turnStartSent, true);
150
+ assert.match(payload.agentText, /TL_CLI_VISIBLE/);
151
+ } finally {
152
+ rmSync(project, { recursive: true, force: true });
153
+ rmSync(home, { recursive: true, force: true });
154
+ }
155
+ });
156
+
157
+ test('codex-visibility-smoke includes Codex-primary memo stdin in injected memory', async () => {
158
+ const home = makeTempHome();
159
+ const project = makeTempProject();
160
+ try {
161
+ await seedDb(home, project);
162
+ const fake = makeFakeCodexAppServer(project);
163
+ const result = runSmoke(
164
+ home,
165
+ project,
166
+ [
167
+ '--session',
168
+ 'codex:thread-visible',
169
+ '--marker',
170
+ 'TL_CLI_MEMO',
171
+ '--codex-app-server-bin',
172
+ fake,
173
+ '--memo-stdin',
174
+ '--json',
175
+ ],
176
+ { THROUGHLINE_EXPERIMENTAL_CODEX_MODEL_VISIBLE_SMOKE: '1' },
177
+ 'Codex memo smoke: continue from the explicit memo.',
178
+ );
179
+
180
+ assert.equal(result.status, 0, result.stderr);
181
+ const payload = JSON.parse(result.stdout);
182
+ assert.equal(payload.status, 'visible');
183
+ assert.match(payload.agentText, /TL_CLI_MEMO/);
184
+ } finally {
185
+ rmSync(project, { recursive: true, force: true });
186
+ rmSync(home, { recursive: true, force: true });
187
+ }
188
+ });
189
+
190
+ test('codex-visibility-smoke can resume after inject before marker turn', async () => {
191
+ const home = makeTempHome();
192
+ const project = makeTempProject();
193
+ try {
194
+ await seedDb(home, project);
195
+ const fake = makeFakeCodexAppServer(project);
196
+ const result = runSmoke(
197
+ home,
198
+ project,
199
+ [
200
+ '--session',
201
+ 'codex:thread-visible',
202
+ '--marker',
203
+ 'TL_CLI_RESUME_AFTER_INJECT',
204
+ '--codex-app-server-bin',
205
+ fake,
206
+ '--resume-after-inject',
207
+ '--json',
208
+ ],
209
+ { THROUGHLINE_EXPERIMENTAL_CODEX_MODEL_VISIBLE_SMOKE: '1' },
210
+ );
211
+
212
+ assert.equal(result.status, 0, result.stderr);
213
+ const payload = JSON.parse(result.stdout);
214
+ assert.equal(payload.status, 'visible');
215
+ assert.equal(payload.resumeAfterInject, true);
216
+ assert.equal(payload.postInjectResumedTurns, 1);
217
+ assert.match(payload.agentText, /TL_CLI_RESUME_AFTER_INJECT/);
218
+ } finally {
219
+ rmSync(project, { recursive: true, force: true });
220
+ rmSync(home, { recursive: true, force: true });
221
+ }
222
+ });
@@ -0,0 +1,206 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+
4
+ import { findCodexThreadCandidate, defaultCodexHome } from './codex-thread-index.mjs';
5
+
6
+ export function makeCodexVsCodeRestoreSmokeMarker() {
7
+ return `TL_CODEX_VSCODE_RESTORE_${randomUUID().slice(0, 8).toUpperCase()}`;
8
+ }
9
+
10
+ export function buildCodexVsCodeRestoreSmokeMemory({ marker }) {
11
+ assertNonEmptyString(marker, 'marker');
12
+ return [
13
+ '## Throughline: Active Work Context',
14
+ '',
15
+ '### VS Code Restore Smoke',
16
+ 'When the user asks for the Throughline VS Code restore smoke marker after a VS Code reload or reconnect, reply exactly:',
17
+ marker,
18
+ ].join('\n');
19
+ }
20
+
21
+ export function buildCodexVsCodeRestoreSmokePrompt() {
22
+ return [
23
+ 'Throughline VS Code restore smoke:',
24
+ 'Read the marker from your developer memory and reply with exactly that marker and nothing else.',
25
+ ].join(' ');
26
+ }
27
+
28
+ export function inspectCodexVsCodeRestoreSmokeRollout({
29
+ threadId,
30
+ marker,
31
+ codexHome = defaultCodexHome(),
32
+ projectPath = process.cwd(),
33
+ preparedAt = null,
34
+ afterVsCodeRestart = false,
35
+ } = {}) {
36
+ assertNonEmptyString(threadId, 'threadId');
37
+ assertNonEmptyString(marker, 'marker');
38
+ if (preparedAt !== null && Number.isNaN(Date.parse(preparedAt))) {
39
+ throw new Error('preparedAt must be an ISO timestamp when provided');
40
+ }
41
+
42
+ const candidate = findCodexThreadCandidate({
43
+ threadId,
44
+ codexHome,
45
+ projectPath,
46
+ requireProjectMatch: true,
47
+ });
48
+ if (!candidate) {
49
+ return {
50
+ status: 'refused',
51
+ reason: 'codex_rollout_source_required',
52
+ proofScope: 'none',
53
+ restartSafe: false,
54
+ threadId,
55
+ marker,
56
+ };
57
+ }
58
+
59
+ const inspected = inspectRolloutRows({
60
+ path: candidate.rolloutPath,
61
+ marker,
62
+ preparedAt,
63
+ });
64
+ const promptObserved = inspected.promptMatches.length > 0;
65
+ const assistantMarkerVisible = inspected.assistantMarkerMatches.length > 0;
66
+ const markerLeakedInUserPrompt = inspected.userMarkerMatches.length > 0;
67
+ let status = 'pending';
68
+ let reason = 'marker_not_found_in_rollout';
69
+ let restartSafe = false;
70
+ let proofScope = 'codex_rollout_marker_search_only';
71
+
72
+ if (markerLeakedInUserPrompt) {
73
+ status = 'invalid';
74
+ reason = 'marker_leaked_in_user_prompt';
75
+ } else if (assistantMarkerVisible && !promptObserved) {
76
+ status = 'invalid';
77
+ reason = 'marker_answer_without_smoke_prompt';
78
+ } else if (assistantMarkerVisible && afterVsCodeRestart) {
79
+ status = 'vscode-restart-visible';
80
+ reason = 'assistant_marker_found_after_restart_ack';
81
+ restartSafe = true;
82
+ proofScope = 'manual_vscode_reload_plus_rollout_marker';
83
+ } else if (assistantMarkerVisible) {
84
+ status = 'marker-visible-restart-unacknowledged';
85
+ reason = 'assistant_marker_found_without_restart_ack';
86
+ }
87
+
88
+ return {
89
+ status,
90
+ reason,
91
+ proofScope,
92
+ restartSafe,
93
+ threadId,
94
+ marker,
95
+ rolloutPath: candidate.rolloutPath,
96
+ preparedAt,
97
+ afterVsCodeRestart: Boolean(afterVsCodeRestart),
98
+ ...inspected,
99
+ };
100
+ }
101
+
102
+ function inspectRolloutRows({ path, marker, preparedAt }) {
103
+ const preparedTime = preparedAt ? Date.parse(preparedAt) : null;
104
+ const rows = {
105
+ parsedRows: 0,
106
+ corruptRows: 0,
107
+ rowsAfterPreparedAt: 0,
108
+ promptMatches: [],
109
+ userMarkerMatches: [],
110
+ assistantMarkerMatches: [],
111
+ assistantMarkerMentions: [],
112
+ };
113
+
114
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
115
+ if (!line.trim()) continue;
116
+ let row;
117
+ try {
118
+ row = JSON.parse(line);
119
+ rows.parsedRows++;
120
+ } catch {
121
+ rows.corruptRows++;
122
+ continue;
123
+ }
124
+ if (!isAfterPreparedAt(row.timestamp, preparedTime)) continue;
125
+ rows.rowsAfterPreparedAt++;
126
+
127
+ const message = rowToMessage(row);
128
+ if (!message) continue;
129
+ const entry = {
130
+ row: rows.parsedRows,
131
+ timestamp: row.timestamp ?? null,
132
+ role: message.role,
133
+ textPreview: preview(message.text),
134
+ };
135
+ if (message.role === 'user') {
136
+ if (message.text.includes('Throughline VS Code restore smoke')) {
137
+ rows.promptMatches.push(entry);
138
+ }
139
+ if (message.text.includes(marker)) {
140
+ rows.userMarkerMatches.push(entry);
141
+ }
142
+ } else if (message.role === 'assistant' && message.text.includes(marker)) {
143
+ rows.assistantMarkerMentions.push(entry);
144
+ if (message.text.trim() === marker) {
145
+ rows.assistantMarkerMatches.push(entry);
146
+ }
147
+ }
148
+ }
149
+
150
+ return rows;
151
+ }
152
+
153
+ function rowToMessage(row) {
154
+ const payload = row?.payload;
155
+ if (row?.type === 'event_msg') {
156
+ if (payload?.type === 'user_message') {
157
+ return { role: 'user', text: normalizeText(payload.message) };
158
+ }
159
+ if (payload?.type === 'agent_message') {
160
+ return { role: 'assistant', text: normalizeText(payload.message) };
161
+ }
162
+ }
163
+
164
+ if (row?.type === 'response_item' && payload?.type === 'message') {
165
+ const role = payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : null;
166
+ if (!role) return null;
167
+ return { role, text: normalizeText(messageContentToText(payload.content)) };
168
+ }
169
+
170
+ return null;
171
+ }
172
+
173
+ function messageContentToText(content) {
174
+ if (typeof content === 'string') return content;
175
+ if (!Array.isArray(content)) return '';
176
+ return content
177
+ .map((part) => {
178
+ if (typeof part === 'string') return part;
179
+ if (typeof part?.text === 'string') return part.text;
180
+ if (typeof part?.input_text === 'string') return part.input_text;
181
+ return '';
182
+ })
183
+ .filter(Boolean)
184
+ .join('\n');
185
+ }
186
+
187
+ function isAfterPreparedAt(timestamp, preparedTime) {
188
+ if (preparedTime === null) return true;
189
+ const time = Date.parse(timestamp ?? '');
190
+ return !Number.isNaN(time) && time >= preparedTime;
191
+ }
192
+
193
+ function normalizeText(value) {
194
+ return typeof value === 'string' ? value : JSON.stringify(value ?? '');
195
+ }
196
+
197
+ function preview(value) {
198
+ const text = normalizeText(value).replace(/\s+/g, ' ').trim();
199
+ return text.length > 240 ? `${text.slice(0, 240)} [truncated]` : text;
200
+ }
201
+
202
+ function assertNonEmptyString(value, name) {
203
+ if (typeof value !== 'string' || value.length === 0) {
204
+ throw new Error(`${name} must be a non-empty string`);
205
+ }
206
+ }