throughline 0.3.24 → 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 +383 -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 +33 -10
  111. package/src/vscode-task.test.mjs +19 -9
@@ -0,0 +1,568 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { buildTrimPlan, renderTrimDryRunReport } from './trim-model.mjs';
8
+
9
+ function makeDb() {
10
+ const db = new DatabaseSync(':memory:');
11
+ db.exec(`
12
+ CREATE TABLE sessions (
13
+ session_id TEXT PRIMARY KEY,
14
+ project_path TEXT NOT NULL,
15
+ status TEXT NOT NULL DEFAULT 'active',
16
+ created_at INTEGER NOT NULL,
17
+ updated_at INTEGER NOT NULL,
18
+ merged_into TEXT
19
+ );
20
+ CREATE TABLE skeletons (
21
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
22
+ session_id TEXT NOT NULL,
23
+ origin_session_id TEXT,
24
+ turn_number INTEGER NOT NULL,
25
+ role TEXT NOT NULL,
26
+ summary TEXT NOT NULL,
27
+ created_at INTEGER NOT NULL
28
+ );
29
+ CREATE TABLE bodies (
30
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
31
+ session_id TEXT NOT NULL,
32
+ origin_session_id TEXT NOT NULL,
33
+ turn_number INTEGER NOT NULL,
34
+ role TEXT NOT NULL,
35
+ text TEXT NOT NULL,
36
+ token_count INTEGER,
37
+ created_at INTEGER NOT NULL
38
+ );
39
+ CREATE TABLE details (
40
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
41
+ session_id TEXT NOT NULL,
42
+ origin_session_id TEXT,
43
+ turn_number INTEGER,
44
+ tool_name TEXT NOT NULL,
45
+ input_text TEXT,
46
+ output_text TEXT,
47
+ token_count INTEGER NOT NULL DEFAULT 0,
48
+ created_at INTEGER NOT NULL,
49
+ kind TEXT,
50
+ source_id TEXT
51
+ );
52
+ `);
53
+ return db;
54
+ }
55
+
56
+ function seedTurns(db, { count = 25 } = {}) {
57
+ db.prepare(
58
+ `INSERT INTO sessions (session_id, project_path, status, created_at, updated_at)
59
+ VALUES ('sess-trim', '/repo', 'active', 1, 2)`,
60
+ ).run();
61
+
62
+ for (let turn = 1; turn <= count; turn++) {
63
+ db.prepare(
64
+ `INSERT INTO bodies
65
+ (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
66
+ VALUES ('sess-trim', 'sess-trim', ?, 'user', ?, 1, ?)`,
67
+ ).run(turn, `user body ${turn}`, turn * 1000);
68
+ db.prepare(
69
+ `INSERT INTO bodies
70
+ (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
71
+ VALUES ('sess-trim', 'sess-trim', ?, 'assistant', ?, 1, ?)`,
72
+ ).run(turn, `assistant body ${turn}`, turn * 1000 + 100);
73
+ }
74
+
75
+ db.prepare(
76
+ `INSERT INTO details
77
+ (session_id, origin_session_id, turn_number, tool_name, input_text, output_text,
78
+ token_count, created_at, kind, source_id)
79
+ VALUES ('sess-trim', 'sess-trim', 25, 'thinking', NULL, 'latest thought',
80
+ 1, 25100, 'thinking', 'thinking-25')`,
81
+ ).run();
82
+ }
83
+
84
+ function seedSkeleton(db, { turn = 1, summary = 'old L1 summary' } = {}) {
85
+ db.prepare(
86
+ `INSERT INTO skeletons
87
+ (session_id, origin_session_id, turn_number, role, summary, created_at)
88
+ VALUES ('sess-trim', 'sess-trim', ?, 'assistant', ?, ?)`,
89
+ ).run(turn, summary, turn * 1000 + 500);
90
+ }
91
+
92
+ test('buildTrimPlan: default dry-run keeps recent 20 and marks Claude as manual-only', () => {
93
+ const db = makeDb();
94
+ seedTurns(db);
95
+
96
+ const plan = buildTrimPlan(db, {
97
+ sessionId: 'sess-trim',
98
+ host: 'claude',
99
+ });
100
+
101
+ assert.equal(plan.status, 'manual-only');
102
+ assert.equal(plan.session.id, 'sess-trim');
103
+ assert.equal(plan.host.reason, 'claude_rewind_conversation_only_not_automated');
104
+ assert.equal(plan.trim.capturedTurns, 25);
105
+ assert.equal(plan.trim.keepRecent, 20);
106
+ assert.equal(plan.trim.rollbackTurns, 5);
107
+ assert.equal(plan.trim.automaticExecutionAllowed, false);
108
+ assert.equal(plan.memoryPreview.stats.recentBodies, 40);
109
+ assert.match(plan.memoryPreview.text, /assistant body 25/);
110
+ assert.match(plan.memoryPreview.text, /current-task context for continuation/);
111
+ assert.match(plan.memoryPreview.text, /Do not treat every older line as still-current truth/);
112
+ });
113
+
114
+ test('buildTrimPlan: --all plans to roll back every captured turn and enables Codex automation', () => {
115
+ const db = makeDb();
116
+ seedTurns(db, { count: 3 });
117
+
118
+ const plan = buildTrimPlan(db, {
119
+ sessionId: 'sess-trim',
120
+ host: 'codex',
121
+ trimAll: true,
122
+ });
123
+
124
+ assert.equal(plan.status, 'ready');
125
+ assert.equal(plan.host.reason, 'codex_rollback_inject_available');
126
+ assert.deepEqual(plan.hostIdentity, {
127
+ host: 'codex',
128
+ codexThreadId: null,
129
+ explicit: false,
130
+ reason: 'codex_thread_id_not_provided',
131
+ });
132
+ assert.equal(plan.trim.keepRecent, 0);
133
+ assert.equal(plan.trim.rollbackTurns, 3);
134
+ assert.equal(plan.trim.automaticExecutionAllowed, true);
135
+ assert.equal(plan.safeContinuation.status, 'fresh-thread-handoff-available');
136
+ assert.equal(plan.safeContinuation.reason, 'optional_fresh_thread_continuation');
137
+ assert.equal(
138
+ plan.safeContinuation.safetyScope,
139
+ 'fresh_thread_handoff_no_current_thread_mutation',
140
+ );
141
+ assert.equal('restartSafe' in plan.safeContinuation, false);
142
+ assert.equal(plan.safeContinuation.mutatesCurrentThread, false);
143
+ assert.equal(
144
+ plan.safeContinuation.guidedCommand,
145
+ 'throughline codex-handoff-start --session codex:<thread-id>',
146
+ );
147
+ assert.equal(
148
+ plan.safeContinuation.smokeCommand,
149
+ 'throughline codex-handoff-smoke --session codex:<thread-id>',
150
+ );
151
+ assert.equal(
152
+ plan.safeContinuation.modelSmokeDryRunCommand,
153
+ 'throughline codex-handoff-model-smoke --session codex:<thread-id> --dry-run --json',
154
+ );
155
+ assert.equal(
156
+ plan.safeContinuation.memoryCommand,
157
+ 'throughline codex-resume --session codex:<thread-id> --format handoff',
158
+ );
159
+ assert.match(plan.safeContinuation.procedure.join('\n'), /Validate the fresh-thread handoff/);
160
+ assert.match(plan.safeContinuation.procedure.join('\n'), /guided command/);
161
+ assert.match(plan.safeContinuation.procedure.join('\n'), /dry-run the model smoke command/);
162
+ assert.match(plan.safeContinuation.procedure.join('\n'), /Start a new Codex thread/);
163
+ });
164
+
165
+ test('buildTrimPlan: explicit Codex thread id is carried separately from Claude session id', () => {
166
+ const db = makeDb();
167
+ seedTurns(db, { count: 3 });
168
+
169
+ const plan = buildTrimPlan(db, {
170
+ sessionId: 'sess-trim',
171
+ host: 'codex',
172
+ codexThreadId: '019dfabf-thread',
173
+ trimAll: true,
174
+ });
175
+
176
+ assert.deepEqual(plan.hostIdentity, {
177
+ host: 'codex',
178
+ codexThreadId: '019dfabf-thread',
179
+ explicit: true,
180
+ reason: 'explicit_codex_thread_id',
181
+ });
182
+ assert.equal(plan.session.id, 'sess-trim');
183
+ assert.equal(plan.trim.automaticExecutionAllowed, true);
184
+ assert.equal(
185
+ plan.safeContinuation.memoryCommand,
186
+ 'throughline codex-resume --session codex:019dfabf-thread --format handoff',
187
+ );
188
+ assert.equal(
189
+ plan.safeContinuation.guidedCommand,
190
+ 'throughline codex-handoff-start --session codex:019dfabf-thread',
191
+ );
192
+ assert.equal(
193
+ plan.safeContinuation.smokeCommand,
194
+ 'throughline codex-handoff-smoke --session codex:019dfabf-thread',
195
+ );
196
+ assert.equal(
197
+ plan.safeContinuation.modelSmokeDryRunCommand,
198
+ 'throughline codex-handoff-model-smoke --session codex:019dfabf-thread --dry-run --json',
199
+ );
200
+ });
201
+
202
+ test('buildTrimPlan: env Codex thread id is marked non-explicit', () => {
203
+ const db = makeDb();
204
+ seedTurns(db, { count: 3 });
205
+
206
+ const plan = buildTrimPlan(db, {
207
+ sessionId: 'sess-trim',
208
+ host: 'codex',
209
+ codexThreadId: '019dfabf-thread',
210
+ codexThreadIdSource: 'env:THROUGHLINE_CODEX_THREAD_ID',
211
+ trimAll: true,
212
+ });
213
+
214
+ assert.deepEqual(plan.hostIdentity, {
215
+ host: 'codex',
216
+ codexThreadId: '019dfabf-thread',
217
+ explicit: false,
218
+ reason: 'env_codex_thread_id',
219
+ source: 'env:THROUGHLINE_CODEX_THREAD_ID',
220
+ });
221
+ });
222
+
223
+ test('buildTrimPlan: current-work memo is placed in curated memory preview', () => {
224
+ const db = makeDb();
225
+ seedTurns(db, { count: 3 });
226
+
227
+ const plan = buildTrimPlan(db, {
228
+ sessionId: 'sess-trim',
229
+ host: 'claude',
230
+ inflightMemo: '**次の一手**: keep implementing trim dry-run',
231
+ });
232
+
233
+ assert.match(plan.memoryPreview.text, /In-flight Memo/);
234
+ assert.match(plan.memoryPreview.text, /keep implementing trim dry-run/);
235
+ });
236
+
237
+ test('buildTrimPlan: external Codex rollout source can drive trim without captured DB turns', () => {
238
+ const db = makeDb();
239
+ db.prepare(
240
+ `INSERT INTO sessions (session_id, project_path, status, created_at, updated_at)
241
+ VALUES ('sess-empty', '/repo', 'active', 1, 2)`,
242
+ ).run();
243
+
244
+ const plan = buildTrimPlan(db, {
245
+ sessionId: 'sess-empty',
246
+ host: 'codex',
247
+ codexThreadId: '019dfabf-thread',
248
+ keepRecent: 20,
249
+ trimSource: {
250
+ source: 'codex-rollout',
251
+ sourceReason: 'explicit_codex_thread_rollout',
252
+ threadId: '019dfabf-thread',
253
+ projectPath: '/repo',
254
+ capturedTurns: 22,
255
+ memoryPreview: {
256
+ text: '## Throughline Trim Memory Preview\n\n### Active Work Thread (Codex Rollout)\nactive',
257
+ truncated: false,
258
+ stats: { source: 'codex-rollout' },
259
+ },
260
+ contextEstimate: {
261
+ method: 'chars_div_4',
262
+ turns: [
263
+ { turn: 1, chars: 40, estimatedTokens: 10 },
264
+ { turn: 2, chars: 80, estimatedTokens: 20 },
265
+ { turn: 3, chars: 120, estimatedTokens: 30 },
266
+ ],
267
+ },
268
+ },
269
+ });
270
+
271
+ assert.equal(plan.status, 'ready');
272
+ assert.equal(plan.trim.source, 'codex-rollout');
273
+ assert.equal(plan.trim.sourceReason, 'explicit_codex_thread_rollout');
274
+ assert.equal(plan.trim.capturedTurns, 22);
275
+ assert.equal(plan.trim.rollbackTurns, 2);
276
+ assert.equal(plan.trim.contextReductionEstimate.rollbackEstimatedTokens, 50);
277
+ assert.equal(plan.trim.contextReductionEstimate.injectedMemoryEstimatedTokens, 21);
278
+ assert.equal(plan.trim.contextReductionEstimate.netEstimatedTokens, 29);
279
+ assert.equal(plan.trim.contextReductionEstimate.reductionPct, 58);
280
+ assert.match(plan.memoryPreview.text, /Codex Rollout/);
281
+ });
282
+
283
+ test('buildTrimPlan: Codex rollout source uses Throughline DB memory when available', () => {
284
+ const db = makeDb();
285
+ seedTurns(db, { count: 25 });
286
+ seedSkeleton(db, { turn: 1, summary: 'summarized old turn 1' });
287
+
288
+ const plan = buildTrimPlan(db, {
289
+ sessionId: 'sess-trim',
290
+ host: 'codex',
291
+ codexThreadId: '019dfabf-thread',
292
+ keepRecent: 20,
293
+ trimSource: {
294
+ source: 'codex-rollout',
295
+ sourceReason: 'explicit_codex_thread_rollout',
296
+ threadId: '019dfabf-thread',
297
+ projectPath: '/repo',
298
+ capturedTurns: 25,
299
+ memoryPreview: {
300
+ text: '## Throughline Trim Memory Preview\n\n### Active Work Thread (Codex Rollout)\nwrong source',
301
+ truncated: false,
302
+ stats: { source: 'codex-rollout' },
303
+ },
304
+ contextEstimate: {
305
+ method: 'chars_div_4',
306
+ turns: [
307
+ { turn: 1, chars: 40, estimatedTokens: 10 },
308
+ { turn: 2, chars: 80, estimatedTokens: 20 },
309
+ { turn: 3, chars: 120, estimatedTokens: 30 },
310
+ { turn: 4, chars: 160, estimatedTokens: 40 },
311
+ { turn: 5, chars: 200, estimatedTokens: 50 },
312
+ ],
313
+ },
314
+ },
315
+ });
316
+
317
+ assert.equal(plan.trim.source, 'codex-rollout');
318
+ assert.equal(plan.trim.rollbackTurns, 5);
319
+ assert.equal(plan.memoryPreview.stats.source, 'throughline-db');
320
+ assert.equal(plan.memoryPreview.stats.recentBodies, 40);
321
+ assert.match(plan.memoryPreview.text, /L1 Summaries/);
322
+ assert.match(plan.memoryPreview.text, /summarized old turn 1/);
323
+ assert.match(plan.memoryPreview.text, /assistant body 25/);
324
+ assert.doesNotMatch(plan.memoryPreview.text, /Codex Rollout/);
325
+ });
326
+
327
+ test('buildTrimPlan: external Codex rollout source can stand in when DB session is absent', () => {
328
+ const db = makeDb();
329
+
330
+ const plan = buildTrimPlan(db, {
331
+ host: 'codex',
332
+ codexThreadId: '019dfabf-thread',
333
+ keepRecent: 1,
334
+ trimSource: {
335
+ source: 'codex-rollout',
336
+ threadId: '019dfabf-thread',
337
+ projectPath: '/repo',
338
+ capturedTurns: 3,
339
+ memoryPreview: {
340
+ text: 'active rollout memory',
341
+ truncated: false,
342
+ stats: { source: 'codex-rollout' },
343
+ },
344
+ },
345
+ });
346
+
347
+ assert.equal(plan.status, 'ready');
348
+ assert.equal(plan.session.id, '019dfabf-thread');
349
+ assert.equal(plan.session.status, 'external');
350
+ assert.equal(plan.session.source, 'codex-rollout');
351
+ assert.equal(plan.trim.rollbackTurns, 2);
352
+ });
353
+
354
+ test('renderTrimDryRunReport: explains host boundary and curated memory', () => {
355
+ const db = makeDb();
356
+ seedTurns(db, { count: 2 });
357
+
358
+ const plan = buildTrimPlan(db, {
359
+ sessionId: 'sess-trim',
360
+ host: 'unknown',
361
+ keepRecent: 20,
362
+ });
363
+ const report = renderTrimDryRunReport(plan);
364
+
365
+ assert.match(report, /Throughline Trim Dry-run/);
366
+ assert.match(report, /Automatic execution allowed: no/);
367
+ assert.match(report, /host_unknown/);
368
+ assert.match(report, /Curated Memory Preview/);
369
+ });
370
+
371
+ test('renderTrimDryRunReport: includes Codex context reduction estimate when available', () => {
372
+ const db = makeDb();
373
+
374
+ const plan = buildTrimPlan(db, {
375
+ host: 'codex',
376
+ codexThreadId: '019dfabf-thread',
377
+ keepRecent: 1,
378
+ trimSource: {
379
+ source: 'codex-rollout',
380
+ threadId: '019dfabf-thread',
381
+ projectPath: '/repo',
382
+ capturedTurns: 3,
383
+ memoryPreview: {
384
+ text: 'short injected memory',
385
+ truncated: false,
386
+ stats: { source: 'codex-rollout' },
387
+ },
388
+ contextEstimate: {
389
+ method: 'chars_div_4',
390
+ turns: [
391
+ { turn: 1, chars: 40, estimatedTokens: 10 },
392
+ { turn: 2, chars: 80, estimatedTokens: 20 },
393
+ { turn: 3, chars: 120, estimatedTokens: 30 },
394
+ ],
395
+ },
396
+ },
397
+ });
398
+ const report = renderTrimDryRunReport(plan);
399
+
400
+ assert.match(report, /Estimated rollback tokens: 50/);
401
+ assert.match(report, /Estimated injected memory tokens: 6/);
402
+ assert.match(report, /Estimated net token reduction: 44 \(88%, chars_div_4\)/);
403
+ assert.match(report, /Safe Continuation Path/);
404
+ assert.match(report, /fresh-thread-handoff-available/);
405
+ assert.match(report, /safety scope: fresh_thread_handoff_no_current_thread_mutation/);
406
+ assert.match(report, /throughline codex-handoff-start --session codex:019dfabf-thread/);
407
+ assert.match(report, /throughline codex-handoff-smoke --session codex:019dfabf-thread/);
408
+ assert.match(report, /throughline codex-resume --session codex:019dfabf-thread --format handoff/);
409
+ });
410
+
411
+ test('renderTrimDryRunReport: truncates text preview without truncating plan memory', () => {
412
+ const db = makeDb();
413
+ seedTurns(db, { count: 4 });
414
+
415
+ const plan = buildTrimPlan(db, {
416
+ sessionId: 'sess-trim',
417
+ host: 'codex',
418
+ codexThreadId: '019dfabf-thread',
419
+ trimAll: true,
420
+ previewMaxChars: 120,
421
+ });
422
+ const report = renderTrimDryRunReport(plan);
423
+
424
+ assert.equal(plan.memoryPreview.truncated, false);
425
+ assert.match(plan.memoryPreview.text, /assistant body 4/);
426
+ assert.match(report, /\[preview truncated to 120 chars/);
427
+ assert.match(report, /full memory remains available in JSON memoryPreview\.text/);
428
+ assert.match(report, /throughline codex-handoff-start --session codex:019dfabf-thread/);
429
+ assert.match(report, /throughline codex-resume --session codex:019dfabf-thread --format handoff/);
430
+ assert.doesNotMatch(report, /assistant body 4/);
431
+ });
432
+
433
+ test('renderTrimDryRunReport: explains Codex restore-safety risk when compacted history can restore rollback text', () => {
434
+ const db = makeDb();
435
+
436
+ const plan = buildTrimPlan(db, {
437
+ host: 'codex',
438
+ codexThreadId: '019dfabf-thread',
439
+ keepRecent: 1,
440
+ trimSource: {
441
+ source: 'codex-rollout',
442
+ threadId: '019dfabf-thread',
443
+ projectPath: '/repo',
444
+ capturedTurns: 3,
445
+ memoryPreview: {
446
+ text: 'short injected memory',
447
+ truncated: false,
448
+ stats: { source: 'codex-rollout' },
449
+ },
450
+ restoreSafety: {
451
+ status: 'risk',
452
+ compactedRows: 1,
453
+ compactedReplacementUserMessages: 2,
454
+ rolledBackUserMessages: 1,
455
+ rollbackTextRetainedInCompacted: 1,
456
+ resurrectedUserMessages: 1,
457
+ retainedTexts: [],
458
+ resurrectedTexts: [],
459
+ risks: [
460
+ {
461
+ type: 'rollback_text_retained_in_compacted_replacement_history',
462
+ count: 1,
463
+ message: 'risk',
464
+ },
465
+ ],
466
+ },
467
+ },
468
+ });
469
+ const report = renderTrimDryRunReport(plan);
470
+
471
+ assert.equal(plan.trim.restoreSafety.status, 'risk');
472
+ assert.match(report, /Restore safety: risk/);
473
+ assert.match(report, /Rollback text retained in compacted history: 1/);
474
+ assert.match(report, /Restore safety risk: rollback_text_retained_in_compacted_replacement_history \(1\)/);
475
+ });
476
+
477
+ test('renderTrimDryRunReport: explains planned Codex rollback restore-safety risk', () => {
478
+ const db = makeDb();
479
+ const dir = mkdtempSync(join(tmpdir(), 'tl-trim-model-rollout-'));
480
+ try {
481
+ const rolloutPath = writeRollout(dir, [
482
+ event('user_message', { message: 'stable request' }),
483
+ event('task_started'),
484
+ event('agent_message', { message: 'stable answer' }),
485
+ event('task_complete'),
486
+ event('user_message', { message: 'planned compacted rollback request' }),
487
+ event('task_started'),
488
+ event('agent_message', { message: 'planned compacted answer' }),
489
+ event('task_complete'),
490
+ compacted([userReplacement('planned compacted rollback request')]),
491
+ event('context_compacted'),
492
+ ]);
493
+
494
+ const plan = buildTrimPlan(db, {
495
+ host: 'codex',
496
+ codexThreadId: '019dfabf-thread',
497
+ keepRecent: 1,
498
+ trimSource: {
499
+ source: 'codex-rollout',
500
+ threadId: '019dfabf-thread',
501
+ projectPath: '/repo',
502
+ rolloutPath,
503
+ capturedTurns: 2,
504
+ memoryPreview: {
505
+ text: 'short injected memory',
506
+ truncated: false,
507
+ stats: { source: 'codex-rollout' },
508
+ },
509
+ },
510
+ });
511
+ const report = renderTrimDryRunReport(plan);
512
+
513
+ assert.equal(plan.trim.plannedRollbackRestoreSafety.status, 'risk');
514
+ assert.match(report, /Planned rollback restore safety: risk/);
515
+ assert.match(report, /Planned rollback text retained in compacted history: 1/);
516
+ assert.match(
517
+ report,
518
+ /Planned rollback restore safety risk: planned_rollback_text_retained_in_compacted_replacement_history \(1\)/,
519
+ );
520
+ } finally {
521
+ rmSync(dir, { recursive: true, force: true });
522
+ }
523
+ });
524
+
525
+ function writeRollout(dir, events) {
526
+ const path = join(dir, 'rollout.jsonl');
527
+ const rows = [
528
+ {
529
+ timestamp: '2026-05-07T00:00:00.000Z',
530
+ type: 'session_meta',
531
+ payload: {
532
+ id: '019dfabf-thread',
533
+ cwd: '/repo',
534
+ source: 'vscode',
535
+ },
536
+ },
537
+ ...events,
538
+ ];
539
+ writeFileSync(path, rows.map((row) => JSON.stringify(row)).join('\n') + '\n');
540
+ return path;
541
+ }
542
+
543
+ function event(type, payload = {}) {
544
+ return {
545
+ timestamp: '2026-05-07T00:00:01.000Z',
546
+ type: 'event_msg',
547
+ payload: { type, ...payload },
548
+ };
549
+ }
550
+
551
+ function compacted(replacementHistory) {
552
+ return {
553
+ timestamp: '2026-05-07T00:00:01.500Z',
554
+ type: 'compacted',
555
+ payload: {
556
+ message: '',
557
+ replacement_history: replacementHistory,
558
+ },
559
+ };
560
+ }
561
+
562
+ function userReplacement(text) {
563
+ return {
564
+ type: 'message',
565
+ role: 'user',
566
+ content: [{ type: 'input_text', text }],
567
+ };
568
+ }
@@ -28,9 +28,6 @@
28
28
  // 大量の node プロセスが生まれ API 500 を引き起こす。
29
29
  // haiku-summarizer が spawn 時に env.THROUGHLINE_IN_HAIKU_SUBPROCESS=1 を設定するので
30
30
  // ここで即検出して exit する。env は child_process.spawn で継承される。
31
- if (process.env.THROUGHLINE_IN_HAIKU_SUBPROCESS === '1') {
32
- process.exit(0);
33
- }
34
31
 
35
32
  import { getDb } from './db.mjs';
36
33
  import {
@@ -44,6 +41,7 @@ import { writeSessionState } from './state-file.mjs';
44
41
  import { summarizeToL1 } from './haiku-summarizer.mjs';
45
42
  import { ensureMonitorTaskFile } from './vscode-task.mjs';
46
43
  import { readLatestUsage } from './transcript-usage.mjs';
44
+ import { pathToFileURL } from 'node:url';
47
45
 
48
46
  /** 直近 N ターンは bodies を生で残し、それより古いものだけ L1 要約する。 */
49
47
  export const L2_WINDOW = 20;
@@ -106,7 +104,11 @@ function buildL2ForSummary(userTurn, assistantTurn) {
106
104
  return parts.join('\n\n');
107
105
  }
108
106
 
109
- async function main() {
107
+ export async function run() {
108
+ if (process.env.THROUGHLINE_IN_HAIKU_SUBPROCESS === '1') {
109
+ process.exit(0);
110
+ }
111
+
110
112
  let raw = '';
111
113
  await new Promise((resolve) => {
112
114
  process.stdin.setEncoding('utf8');
@@ -217,7 +219,10 @@ async function main() {
217
219
  userRow ? { content: userRow.text } : null,
218
220
  asstRow ? { content: asstRow.text } : null,
219
221
  );
220
- const { summary } = summarizeToL1(l2ForSummary);
222
+ const { summary } = summarizeToL1(l2ForSummary, {
223
+ projectPath: cwd ?? process.cwd(),
224
+ hostMode: 'claude-primary',
225
+ });
221
226
 
222
227
  db.prepare(
223
228
  `INSERT OR IGNORE INTO skeletons
@@ -297,8 +302,10 @@ async function main() {
297
302
  process.exit(0);
298
303
  }
299
304
 
300
- main().catch((err) => {
301
- const msg = err instanceof Error ? err.message : String(err);
302
- process.stderr.write(`[turn-processor] error: ${msg}\n`);
303
- process.exit(1);
304
- });
305
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
306
+ run().catch((err) => {
307
+ const msg = err instanceof Error ? err.message : String(err);
308
+ process.stderr.write(`[turn-processor] error: ${msg}\n`);
309
+ process.exit(1);
310
+ });
311
+ }