throughline 0.5.0 → 0.6.0

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 (43) hide show
  1. package/.codex-sidecar.yml +5 -0
  2. package/CHANGELOG.md +47 -2
  3. package/README.ja.md +37 -21
  4. package/README.md +47 -26
  5. package/docs/00_overview.md +34 -0
  6. package/docs/{L1_L2_L3_REDESIGN.md → 01_l1_l2_l3_redesign.md} +3 -3
  7. package/docs/{THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md → 02_clear_auto_handoff_plan.md} +6 -6
  8. package/docs/{INHERITANCE_ON_CLEAR_ONLY.md → 03_inheritance_on_clear_only.md} +3 -3
  9. package/docs/{PUBLIC_RELEASE_PLAN.md → 04_public_release_plan.md} +3 -3
  10. package/docs/{THROUGHLINE_CODEX_FIRST_ROADMAP.md → 05_codex_first_roadmap.md} +9 -9
  11. package/docs/{THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md → 06_codex_trim_rollback_fix_plan.md} +6 -6
  12. package/docs/{THROUGHLINE_CODEX_TRIM_IMPLEMENTATION_PLAN.md → 07_codex_trim_implementation_plan.md} +10 -10
  13. package/docs/{THROUGHLINE_CODEX_DUAL_SUPPORT.md → 08_codex_dual_support.md} +8 -8
  14. package/docs/{throughline-rollback-context-trim-insight.md → 09_rollback_context_trim_insight.md} +5 -5
  15. package/docs/{THROUGHLINE_TRANSCRIPT_INJECTION_PLAN.md → 10_transcript_injection_plan.md} +6 -6
  16. package/docs/{THROUGHLINE_CODEX_MONITOR_IMPLEMENTATION_PLAN.md → 11_codex_monitor_implementation_plan.md} +1 -1
  17. package/docs/12_desktop_clear_handoff_plan.md +215 -0
  18. package/docs/adr/0001-claude-primary-codex-adapter.md +22 -0
  19. package/docs/archive/README.md +3 -3
  20. package/docs/archive/THROUGHLINE_NEXT_STEPS.md +3 -3
  21. package/package.json +2 -1
  22. package/rag/01-hooks/raw/session-end-reasons.md +21 -0
  23. package/{docs/RAG → rag}/INDEX.md +20 -16
  24. package/src/baton.mjs +2 -2
  25. package/src/db.mjs +2 -2
  26. package/src/hook-entrypoints.test.mjs +102 -0
  27. package/src/package-files.test.mjs +1 -0
  28. package/src/prompt-submit.mjs +2 -2
  29. package/src/resume-context.mjs +1 -1
  30. package/src/session-merger.mjs +1 -1
  31. package/src/session-start.mjs +62 -3
  32. package/src/spike-transcript-writer.mjs +1 -1
  33. package/src/state-file.mjs +1 -1
  34. package/src/token-monitor.mjs +1 -1
  35. package/src/transcript-reader.mjs +71 -0
  36. package/src/turn-backfill.mjs +131 -0
  37. package/src/turn-backfill.test.mjs +213 -0
  38. package/src/turn-processor.mjs +28 -40
  39. /package/docs/{throughline-codex-trim-rollback-incident-report.md → audit-2026-05/codex-trim-rollback-incident-report.md} +0 -0
  40. /package/{docs/RAG/_raw/01-hooks → rag/01-hooks/raw}/hooks-reference-extract.md +0 -0
  41. /package/{docs/RAG/_raw/02-messages-api → rag/02-messages-api/raw}/messages-api-extract.md +0 -0
  42. /package/{docs/RAG/_raw/03-settings → rag/03-settings/raw}/sessions-extract.md +0 -0
  43. /package/{docs/RAG/_raw/04-skills → rag/04-skills/raw}/initialUserMessage-investigation.md +0 -0
@@ -0,0 +1,213 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir, homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { DatabaseSync } from 'node:sqlite';
7
+ import { backfillBodies, deriveTranscriptPath } from './turn-backfill.mjs';
8
+
9
+ function makeDb() {
10
+ const db = new DatabaseSync(':memory:');
11
+ db.exec(`
12
+ CREATE TABLE bodies (
13
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
14
+ session_id TEXT NOT NULL,
15
+ origin_session_id TEXT NOT NULL,
16
+ turn_number INTEGER NOT NULL,
17
+ role TEXT NOT NULL,
18
+ text TEXT NOT NULL,
19
+ token_count INTEGER,
20
+ created_at INTEGER NOT NULL,
21
+ UNIQUE(session_id, origin_session_id, turn_number, role)
22
+ );
23
+ `);
24
+ return db;
25
+ }
26
+
27
+ function withData(entries, fn) {
28
+ const dir = mkdtempSync(join(tmpdir(), 'tl-backfill-'));
29
+ const path = join(dir, 'data.jsonl');
30
+ writeFileSync(path, entries.map((entry) => JSON.stringify(entry)).join('\n'), 'utf8');
31
+ try {
32
+ return fn(path);
33
+ } finally {
34
+ rmSync(dir, { recursive: true, force: true });
35
+ }
36
+ }
37
+
38
+ function entry(role, text, timestamp, extra = {}) {
39
+ return {
40
+ type: role,
41
+ ...(timestamp === undefined ? {} : { timestamp }),
42
+ ...extra,
43
+ message: { role, content: [{ type: 'text', text }] },
44
+ };
45
+ }
46
+
47
+ test('backfillBodies: multi-fragment group uses last fragment index for both body rows', () => {
48
+ withData(
49
+ [
50
+ entry('user', 'question one'),
51
+ entry('assistant', 'first fragment'),
52
+ entry('assistant', 'last fragment'),
53
+ entry('user', 'question two'),
54
+ entry('assistant', 'second answer'),
55
+ ],
56
+ (path) => {
57
+ const db = makeDb();
58
+ const result = backfillBodies(db, {
59
+ targetSessionId: 'target', originSessionId: 'origin', transcriptPath: path, now: 999,
60
+ });
61
+ assert.deepEqual(result, { groups: 2, insertedTurns: 2, skippedExisting: 0, lastTurnNumber: 4 });
62
+ assert.deepEqual(
63
+ db.prepare('SELECT turn_number, role, text FROM bodies ORDER BY turn_number, role').all().map((row) => ({ ...row })),
64
+ [
65
+ { turn_number: 2, role: 'assistant', text: 'last fragment' },
66
+ { turn_number: 2, role: 'user', text: 'question one' },
67
+ { turn_number: 4, role: 'assistant', text: 'second answer' },
68
+ { turn_number: 4, role: 'user', text: 'question two' },
69
+ ],
70
+ );
71
+ },
72
+ );
73
+ });
74
+
75
+ test('backfillBodies: junk final fragment falls back and all-junk group is dropped', () => {
76
+ withData(
77
+ [
78
+ entry('user', 'keep this'),
79
+ entry('assistant', 'real answer'),
80
+ entry('assistant', "You've hit your session limit. Please try again later."),
81
+ entry('user', 'drop this'),
82
+ entry('assistant', 'API Error: unavailable'),
83
+ ],
84
+ (path) => {
85
+ const db = makeDb();
86
+ const result = backfillBodies(db, {
87
+ targetSessionId: 'target', originSessionId: 'origin', transcriptPath: path, now: 999,
88
+ });
89
+ assert.equal(result.groups, 1);
90
+ assert.equal(result.insertedTurns, 1);
91
+ assert.deepEqual(
92
+ db.prepare("SELECT turn_number, text FROM bodies WHERE role = 'assistant'").all().map((row) => ({ ...row })),
93
+ [{ turn_number: 1, text: 'real answer' }],
94
+ );
95
+ },
96
+ );
97
+ });
98
+
99
+ test('backfillBodies: user-only group is dropped', () => {
100
+ withData([entry('user', 'unanswered')], (path) => {
101
+ const db = makeDb();
102
+ assert.deepEqual(
103
+ backfillBodies(db, {
104
+ targetSessionId: 'target', originSessionId: 'origin', transcriptPath: path, now: 999,
105
+ }),
106
+ { groups: 0, insertedTurns: 0, skippedExisting: 0, lastTurnNumber: null },
107
+ );
108
+ });
109
+ });
110
+
111
+ test('backfillBodies: a single pre-seeded fragment skips its whole group while other groups insert', () => {
112
+ withData(
113
+ [
114
+ entry('user', 'first question'),
115
+ entry('assistant', 'first fragment'),
116
+ entry('assistant', 'last first fragment'),
117
+ entry('user', 'second question'),
118
+ entry('assistant', 'second answer'),
119
+ ],
120
+ (path) => {
121
+ const db = makeDb();
122
+ db.prepare(
123
+ `INSERT INTO bodies
124
+ (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
125
+ VALUES ('existing-target', 'origin', 1, 'assistant', 'existing fragment', 1, 1)`,
126
+ ).run();
127
+ const result = backfillBodies(db, {
128
+ targetSessionId: 'target', originSessionId: 'origin', transcriptPath: path, now: 999,
129
+ });
130
+ assert.equal(result.insertedTurns, 1);
131
+ assert.equal(result.skippedExisting, 1);
132
+ assert.equal(
133
+ db.prepare("SELECT COUNT(*) AS count FROM bodies WHERE text = 'last first fragment'").get().count,
134
+ 0,
135
+ );
136
+ assert.equal(
137
+ db.prepare("SELECT COUNT(*) AS count FROM bodies WHERE turn_number = 4").get().count,
138
+ 2,
139
+ );
140
+ },
141
+ );
142
+ });
143
+
144
+ test('backfillBodies: second run is idempotent', () => {
145
+ withData([entry('user', 'question'), entry('assistant', 'answer')], (path) => {
146
+ const db = makeDb();
147
+ backfillBodies(db, { targetSessionId: 'target', originSessionId: 'origin', transcriptPath: path, now: 999 });
148
+ assert.equal(
149
+ backfillBodies(db, {
150
+ targetSessionId: 'target', originSessionId: 'origin', transcriptPath: path, now: 999,
151
+ }).insertedTurns,
152
+ 0,
153
+ );
154
+ });
155
+ });
156
+
157
+ test('backfillBodies: transcript timestamps are retained and absent timestamps use now', () => {
158
+ withData(
159
+ [
160
+ entry('user', 'dated question', '2026-01-02T03:04:05.000Z'),
161
+ entry('assistant', 'dated answer', '2026-01-02T03:04:06.000Z'),
162
+ entry('user', 'undated question'),
163
+ entry('assistant', 'undated answer'),
164
+ ],
165
+ (path) => {
166
+ const db = makeDb();
167
+ backfillBodies(db, { targetSessionId: 'target', originSessionId: 'origin', transcriptPath: path, now: 777 });
168
+ assert.deepEqual(
169
+ db.prepare('SELECT turn_number, role, created_at FROM bodies ORDER BY turn_number, role').all().map((row) => ({ ...row })),
170
+ [
171
+ { turn_number: 1, role: 'assistant', created_at: Date.parse('2026-01-02T03:04:06.000Z') },
172
+ { turn_number: 1, role: 'user', created_at: Date.parse('2026-01-02T03:04:05.000Z') },
173
+ { turn_number: 3, role: 'assistant', created_at: 777 },
174
+ { turn_number: 3, role: 'user', created_at: 777 },
175
+ ],
176
+ );
177
+ },
178
+ );
179
+ });
180
+
181
+ test('backfillBodies: sidechain entries and missing or empty paths produce no groups', () => {
182
+ withData(
183
+ [entry('user', 'side question', undefined, { isSidechain: true }), entry('assistant', 'side answer', undefined, { isSidechain: true })],
184
+ (path) => {
185
+ const db = makeDb();
186
+ assert.equal(
187
+ backfillBodies(db, {
188
+ targetSessionId: 'target', originSessionId: 'origin', transcriptPath: path, now: 999,
189
+ }).groups,
190
+ 0,
191
+ );
192
+ assert.deepEqual(
193
+ backfillBodies(db, {
194
+ targetSessionId: 'target', originSessionId: 'origin', transcriptPath: null, now: 999,
195
+ }),
196
+ { groups: 0, insertedTurns: 0, skippedExisting: 0, lastTurnNumber: null },
197
+ );
198
+ assert.deepEqual(
199
+ backfillBodies(db, {
200
+ targetSessionId: 'target', originSessionId: 'origin', transcriptPath: '', now: 999,
201
+ }),
202
+ { groups: 0, insertedTurns: 0, skippedExisting: 0, lastTurnNumber: null },
203
+ );
204
+ },
205
+ );
206
+ });
207
+
208
+ test('deriveTranscriptPath munges slash and dot characters with one leading dash', () => {
209
+ assert.equal(
210
+ deriveTranscriptPath('/Users/kite/Developer/Through.line', 'session-id'),
211
+ join(homedir(), '.claude', 'projects', '-Users-kite-Developer-Through-line', 'session-id.jsonl'),
212
+ );
213
+ });
@@ -8,8 +8,9 @@
8
8
  * (Haiku 要約用の claude -p subprocess 内で自分自身の Stop hook として起動された場合)
9
9
  * 1. resolveMergeTarget で「実書き込み先 (target) / origin」を解決
10
10
  * (input session が別セッションに合流済みなら合流先に書く)
11
- * 2. 最後の assistant ターン + 直前の user ターンのペアを取得
12
- * 3. L2 本文 (bodies) に user / assistant 2 行を INSERT
11
+ * 2. transcript 全体を論理ターン群で走査し、未捕捉の完了ターンを bodies
12
+ * 一括回収する (turn-backfill.mjs、docs/12 B-1)。回収実績は backfill.log に記録
13
+ * 3. (旧: 最後の 1 ペアのみ保存 — Stop 空振りで永久穴が生じたため全走査化)
13
14
  * 4. 【遅延要約】target 配下の bodies ターン数 (distinct origin×turn) が
14
15
  * WINDOW (=20) を超えていたら、最古の未要約ターンを 1 件だけ
15
16
  * Haiku 4.5 で要約 → skeletons (L1) に INSERT。
@@ -31,11 +32,11 @@
31
32
 
32
33
  import { getDb } from './db.mjs';
33
34
  import {
34
- getLastTurnPair,
35
35
  readRawEntries,
36
36
  sliceCurrentTurnEntries,
37
37
  extractDetailBlocks,
38
38
  } from './transcript-reader.mjs';
39
+ import { backfillBodies, logBackfill } from './turn-backfill.mjs';
39
40
  import { resolveMergeTarget } from './session-merger.mjs';
40
41
  import { writeSessionState } from './state-file.mjs';
41
42
  import { summarizeToL1 } from './haiku-summarizer.mjs';
@@ -160,46 +161,33 @@ export async function run() {
160
161
  db.prepare('UPDATE sessions SET updated_at = ? WHERE session_id = ?').run(now, target);
161
162
  }
162
163
 
163
- // 最後の assistant ターン + 直前の user ターンを取得
164
- const { user: userTurn, assistant: assistantTurn } = getLastTurnPair(transcript_path);
165
- if (!assistantTurn) {
166
- // /clear 直後などでトランスクリプトが空の場合は何もしない
164
+ // L2 = transcript 全体を論理ターン群で走査し、未捕捉の完了ターンを一括回収する。
165
+ // 従来の「最後の 1 ペアのみ保存」は Stop の空振り・不発が永久穴になった (docs/12 B-1)
166
+ // user / assistant は「1 往復 = 1 ターン」として同じ turn_number(= 代表 assistant
167
+ // 断片の index)でペアリングされ、bodies と skeletons が同じ turn_number で突合できる。
168
+ const backfill = backfillBodies(db, {
169
+ targetSessionId: target,
170
+ originSessionId: origin,
171
+ transcriptPath: transcript_path,
172
+ now,
173
+ });
174
+ logBackfill({
175
+ ts: new Date(now).toISOString(),
176
+ hook: 'stop',
177
+ session_id,
178
+ target,
179
+ origin,
180
+ transcript_path: transcript_path ?? null,
181
+ groups: backfill.groups,
182
+ inserted_turns: backfill.insertedTurns,
183
+ skipped_existing: backfill.skippedExisting,
184
+ });
185
+ if (backfill.lastTurnNumber === null) {
186
+ // /clear 直後などでトランスクリプトに完了ターンが無い場合は何もしない
167
187
  process.exit(0);
168
188
  }
169
189
 
170
- const turnNumber = assistantTurn.turn_number;
171
-
172
- // L2 = bodies に user / assistant を個別行で保存
173
- const insertBody = db.prepare(
174
- `INSERT OR IGNORE INTO bodies
175
- (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
176
- VALUES (?, ?, ?, ?, ?, ?, ?)`,
177
- );
178
- // user / assistant を「1 往復 = 1 ターン」として扱うため、同じ turn_number
179
- // (= assistant 側の turn_number)でペアリングして保存する。
180
- // これにより bodies と skeletons が同じ turn_number で突合できる。
181
- if (userTurn?.content) {
182
- insertBody.run(
183
- target,
184
- origin,
185
- turnNumber,
186
- 'user',
187
- userTurn.content,
188
- Math.round(userTurn.content.length / 4),
189
- now,
190
- );
191
- }
192
- if (assistantTurn?.content) {
193
- insertBody.run(
194
- target,
195
- origin,
196
- turnNumber,
197
- 'assistant',
198
- assistantTurn.content,
199
- Math.round(assistantTurn.content.length / 4),
200
- now,
201
- );
202
- }
190
+ const turnNumber = backfill.lastTurnNumber;
203
191
 
204
192
  // L1 = 遅延要約。target 配下の bodies ターン数 (distinct origin×turn) が
205
193
  // WINDOW を超えていたら、最古の未要約ターンを 1 件だけ要約する。