throughline 0.2.0 → 0.3.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.
package/README.md CHANGED
@@ -51,17 +51,20 @@ Start any Claude Code session and your turns will begin flowing into
51
51
 
52
52
  ---
53
53
 
54
- ## Three-layer memory model (schema v6)
54
+ ## Three-layer memory model (schema v7)
55
55
 
56
- | Layer | Name | Where it lives | Content | Cost per turn |
57
- | ----- | ---------- | --------------------- | ---------------------------------------------------------- | ------------- |
58
- | **L1** | Skeleton | injected when old | one-line Haiku-generated summary of the turn | ~10 tok |
59
- | **L2** | Body | injected when recent | user text + assistant reply, verbatim | full natural |
60
- | **L3** | Detail | SQLite only | tool I/O, system messages, images (on-demand via command) | heavy, retired |
56
+ | Layer | Name | Where it lives | Content | Cost per turn |
57
+ | ----- | ---------- | --------------------- | --------------------------------------------------------------------- | ------------- |
58
+ | **L1** | Skeleton | injected when old | one-line Haiku-generated summary of the turn | ~10 tok |
59
+ | **L2** | Body | injected when recent | user text + assistant reply, verbatim | full natural |
60
+ | **L3** | Detail | SQLite only | tool I/O, system messages, images, **extended thinking** (on-demand) | heavy, retired |
61
61
 
62
62
  The layers are **complementary and disjoint** — nothing is duplicated across
63
- them. Thinking blocks are discarded entirely (not stored at either layer) to
64
- match the stock Claude Code behavior.
63
+ them. Extended thinking blocks are stored at L3 (`kind='thinking'`) so the
64
+ next session can see *what the previous Claude was thinking* at the moment it
65
+ was interrupted, not just what it said aloud. On `SessionStart` the thinking
66
+ of the **final turn** is injected inline above the L2 history; older thinking
67
+ remains retrievable via `throughline detail <time>`.
65
68
 
66
69
  On `SessionStart`, Throughline rebuilds the context from SQLite and
67
70
  injects it as plain text:
@@ -81,19 +84,32 @@ of tool inputs, tool outputs, and hook output captured at L3 for that turn.
81
84
 
82
85
  ---
83
86
 
84
- ## Explicit handoff via `/tl`
87
+ ## Explicit handoff via `/tl` (with in-flight memo)
85
88
 
86
89
  Inheritance is **opt-in**, not automatic. When you want the next session to
87
90
  pick up where this one left off, type `/tl` in the current session before you
88
91
  `/clear` or open a new chat. Without `/tl`, new sessions start fresh — no
89
92
  memory is carried over.
90
93
 
91
- The `/tl` slash command writes a **handoff baton** (the current `session_id`)
92
- into the `handoff_batons` table. On the next `SessionStart`, the hook reads the
93
- baton, and if it is less than **1 hour old**, merges that session's memory
94
- into the new session using a deterministic `UPDATE session_id = ?` inside a
95
- `BEGIN IMMEDIATE` transaction. The baton is consumed (deleted) atomically with
96
- the merge, so it cannot fire twice.
94
+ The `/tl` slash command does two things:
95
+
96
+ 1. **Writes a handoff baton** (the current `session_id`) into the
97
+ `handoff_batons` table via the `UserPromptSubmit` hook.
98
+ 2. **Asks the current Claude to write an in-flight memo.** `/tl` instructs
99
+ Claude to summarize *what it was about to do next, its current hypothesis,
100
+ open questions, and in-progress TODOs*, then pipe that Markdown into
101
+ `throughline save-inflight`, which attaches it to the baton's `memo_text`
102
+ column. This captures the "currently thinking" state that plain transcript
103
+ replay cannot preserve.
104
+
105
+ On the next `SessionStart`, the hook reads the baton, and if it is less than
106
+ **1 hour old**, merges that session's memory into the new session using a
107
+ deterministic `UPDATE session_id = ?` inside a `BEGIN IMMEDIATE` transaction.
108
+ The baton is consumed (deleted) atomically with the merge, so it cannot fire
109
+ twice. The injected resume context is reframed as **"resuming an interrupted
110
+ task"** rather than *"reading past logs"*, and the in-flight memo plus the
111
+ final turn's extended thinking appear at the top so the new Claude picks up
112
+ mid-thought.
97
113
 
98
114
  ```
99
115
  Session A (type /tl) -----------> baton written
@@ -181,6 +197,7 @@ you open the folder. Drop an equivalent config into your own project's
181
197
  | `throughline uninstall` | Remove Throughline hooks from the settings file |
182
198
  | `throughline monitor [--all] [--session <id>]` | Run the multi-session token monitor |
183
199
  | `throughline detail <time>` | Retrieve L2 body text and L3 tool I/O for a turn (see below) |
200
+ | `throughline save-inflight` | Called by `/tl` to attach an in-flight memo (stdin) to the current baton |
184
201
  | `throughline doctor` | Check Node version, hook registration, DB writability, PATH |
185
202
  | `throughline status` | Print DB statistics (sessions, skeletons, bodies, details) |
186
203
  | `throughline --version` | Print the installed version |
@@ -189,9 +206,14 @@ Slash commands (invoked by the user in Claude Code):
189
206
 
190
207
  | Command | What it does |
191
208
  | ------------- | ----------------------------------------------------------------- |
192
- | `/tl` | Write a handoff baton so the next session inherits this one |
209
+ | `/tl` | Write a handoff baton + ask Claude to save an in-flight memo for the next session |
193
210
  | `/sc-detail <time>` | Retrieve L2 body text and L3 tool I/O for a past turn |
194
211
 
212
+ > When `/tl` triggers, Claude will call `throughline save-inflight` via its
213
+ > Bash tool. Claude Code will prompt for permission the first time; add
214
+ > `Bash(throughline save-inflight:*)` to your allowlist to skip the prompt on
215
+ > subsequent `/tl` invocations.
216
+
195
217
  Hook subcommands (invoked by Claude Code, not by humans):
196
218
  `session-start` (SessionStart), `process-turn` (Stop),
197
219
  `prompt-submit` (UserPromptSubmit — detects `/tl` and writes baton).
@@ -238,13 +260,13 @@ plain `.mjs` files.
238
260
  └── <session_id>.json Per-session activity state for the monitor
239
261
  ```
240
262
 
241
- Schema v6:
263
+ Schema v7:
242
264
 
243
265
  - `sessions` — one row per `session_id`, with `project_path` and `merged_into`
244
266
  - `skeletons` — L1 one-liners, keyed by `(session_id, origin_session_id, turn, role)`
245
267
  - `bodies` — L2 verbatim text (user + assistant), same key shape
246
- - `details` — L3 records with `kind` column (`tool_input` / `tool_output` / `system` / `image`) and `source_id` for idempotent re-processing
247
- - `handoff_batons` — one row per `project_path`, holding the `session_id` that `/tl` has nominated to hand off. Consumed and deleted by the next `SessionStart` if within the 1-hour TTL.
268
+ - `details` — L3 records with `kind` column (`tool_input` / `tool_output` / `system` / `image` / `thinking`) and `source_id` for idempotent re-processing
269
+ - `handoff_batons` — one row per `project_path`, with `session_id`, `created_at`, and `memo_text` (the in-flight memo written by `save-inflight` after `/tl`). Consumed and deleted by the next `SessionStart` if within the 1-hour TTL.
248
270
  - `injection_log` — audit trail of injection events
249
271
 
250
272
  All memory tables carry an `origin_session_id` so rebonded rows keep their
@@ -316,7 +338,7 @@ unchanged here.
316
338
 
317
339
  **Database got corrupted / want a clean slate**
318
340
  Delete `~/.throughline/throughline.db` (and the `-shm` / `-wal` companion files)
319
- and `~/.throughline/state/*.json`. A fresh database with schema v6 is created on
341
+ and `~/.throughline/state/*.json`. A fresh database with schema v7 is created on
320
342
  the next hook fire.
321
343
 
322
344
  **New session didn't inherit memory from the previous one**
@@ -41,6 +41,9 @@ switch (cmd) {
41
41
  case 'detail':
42
42
  (await import('../src/sc-detail.mjs')).run(rest);
43
43
  break;
44
+ case 'save-inflight':
45
+ await (await import('../src/cli/save-inflight.mjs')).run();
46
+ break;
44
47
  case 'doctor':
45
48
  await (await import('../src/cli/doctor.mjs')).run();
46
49
  break;
@@ -66,13 +69,14 @@ async function showHelp() {
66
69
  console.log(`throughline v${version}
67
70
 
68
71
  Usage:
69
- throughline install Register hooks in ~/.claude/settings.json
70
- throughline uninstall Remove hooks
71
- throughline monitor Multi-session token monitor (use --all, --session <id>)
72
- throughline detail <time> Retrieve L2+L3 detail for a turn (e.g. 14:23:05 or 14:23-14:30)
73
- throughline doctor Check environment
74
- throughline status Show DB statistics
75
- throughline --version Show version
72
+ throughline install Register hooks in ~/.claude/settings.json
73
+ throughline uninstall Remove hooks
74
+ throughline monitor Multi-session token monitor (use --all, --session <id>)
75
+ throughline detail <time> Retrieve L2+L3 detail for a turn (e.g. 14:23:05 or 14:23-14:30)
76
+ throughline save-inflight Save in-flight memo (stdin) to the current /tl baton
77
+ throughline doctor Check environment
78
+ throughline status Show DB statistics
79
+ throughline --version Show version
76
80
 
77
81
  Hook subcommands (called by Claude Code):
78
82
  throughline session-start SessionStart hook
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Claude Code hooks plugin for structured context compression (/clear-safe persistent memory)",
6
6
  "keywords": [
package/src/baton.mjs CHANGED
@@ -24,36 +24,68 @@ export const BATON_TTL_MS = 60 * 60 * 1000; // 1 時間
24
24
 
25
25
  /**
26
26
  * 現在セッション (= /tl を発動したセッション) を次回 SessionStart で merge 対象に指名する。
27
- * 同 project_path の既存バトンは上書きされる (INSERT OR REPLACE)。最新意図のみ有効。
27
+ * 同 project_path の既存バトンがあれば session_id / created_at のみ上書き。
28
+ * v7 で追加された memo_text は保持する(連続した /tl → save-inflight の順番で
29
+ * 呼ばれた場合に、再度 /tl を打った時点で古い memo が消えないようにする)。
28
30
  *
29
31
  * @param {import('node:sqlite').DatabaseSync} db
30
32
  * @param {{ projectPath: string, sessionId: string, now?: number }} params
31
33
  */
32
34
  export function writeBaton(db, { projectPath, sessionId, now = Date.now() }) {
33
35
  db.prepare(
34
- `INSERT OR REPLACE INTO handoff_batons (project_path, session_id, created_at)
35
- VALUES (?, ?, ?)`,
36
+ `INSERT INTO handoff_batons (project_path, session_id, created_at)
37
+ VALUES (?, ?, ?)
38
+ ON CONFLICT(project_path) DO UPDATE SET
39
+ session_id = excluded.session_id,
40
+ created_at = excluded.created_at`,
36
41
  ).run(projectPath, sessionId, now);
37
42
  }
38
43
 
44
+ /**
45
+ * 既存バトンの memo_text を更新する。バトンが存在しない場合は NOOP。
46
+ * /tl 発動後、現行セッションの Claude が `throughline save-inflight` CLI 経由で
47
+ * 呼び出す。memo_text は Markdown 形式の「次の一手 / 現在の方針 / 未解決 /
48
+ * 進行中 TODO」をまとめたテキスト。
49
+ *
50
+ * Windows 互換: ドライブレター(`C:` / `c:`)やパス区切りの差異で
51
+ * /tl 書き込み時と save-inflight 呼び出し時の project_path が一致しない
52
+ * ケースがあるため、SQLite の COLLATE NOCASE で大小無視で照合する。
53
+ *
54
+ * @param {import('node:sqlite').DatabaseSync} db
55
+ * @param {{ projectPath: string, memoText: string, now?: number }} params
56
+ * @returns {{ updated: boolean }}
57
+ */
58
+ export function updateBatonMemo(db, { projectPath, memoText }) {
59
+ const result = db
60
+ .prepare(
61
+ `UPDATE handoff_batons SET memo_text = ? WHERE project_path = ? COLLATE NOCASE`,
62
+ )
63
+ .run(memoText, projectPath);
64
+ return { updated: (result.changes ?? 0) > 0 };
65
+ }
66
+
39
67
  /**
40
68
  * 同 project_path のバトンを読み出して削除する (atomic)。
41
69
  *
42
70
  * 戻り値:
43
- * - { sessionId, ageMs } : バトン存在 かつ TTL 以内
71
+ * - { sessionId, ageMs, memoText } : バトン存在 かつ TTL 以内
44
72
  * - { sessionId: null, skipReason: 'expired', ageMs } : TTL 超過で破棄
45
73
  * - { sessionId: null, skipReason: 'missing' } : バトン無し
46
74
  *
75
+ * memoText は /tl 後に save-inflight で書き込まれた in-flight メモ。
76
+ * 未保存なら null。
77
+ *
47
78
  * @param {import('node:sqlite').DatabaseSync} db
48
79
  * @param {{ projectPath: string, now?: number, ttlMs?: number }} params
49
- * @returns {{ sessionId: string | null, ageMs?: number, skipReason?: 'expired' | 'missing' }}
80
+ * @returns {{ sessionId: string | null, ageMs?: number, memoText?: string | null, skipReason?: 'expired' | 'missing' }}
50
81
  */
51
82
  export function consumeBaton(db, { projectPath, now = Date.now(), ttlMs = BATON_TTL_MS }) {
52
83
  db.exec('BEGIN IMMEDIATE');
53
84
  try {
85
+ // Windows 互換: ドライブレターの大小差を吸収するため COLLATE NOCASE
54
86
  const row = db
55
87
  .prepare(
56
- `SELECT session_id, created_at FROM handoff_batons WHERE project_path = ?`,
88
+ `SELECT session_id, created_at, memo_text FROM handoff_batons WHERE project_path = ? COLLATE NOCASE`,
57
89
  )
58
90
  .get(projectPath);
59
91
 
@@ -62,7 +94,9 @@ export function consumeBaton(db, { projectPath, now = Date.now(), ttlMs = BATON_
62
94
  return { sessionId: null, skipReason: 'missing' };
63
95
  }
64
96
 
65
- db.prepare('DELETE FROM handoff_batons WHERE project_path = ?').run(projectPath);
97
+ db.prepare('DELETE FROM handoff_batons WHERE project_path = ? COLLATE NOCASE').run(
98
+ projectPath,
99
+ );
66
100
  const ageMs = now - row.created_at;
67
101
 
68
102
  if (ageMs > ttlMs) {
@@ -71,7 +105,11 @@ export function consumeBaton(db, { projectPath, now = Date.now(), ttlMs = BATON_
71
105
  }
72
106
 
73
107
  db.exec('COMMIT');
74
- return { sessionId: row.session_id, ageMs };
108
+ return {
109
+ sessionId: row.session_id,
110
+ ageMs,
111
+ memoText: row.memo_text ?? null,
112
+ };
75
113
  } catch (err) {
76
114
  try {
77
115
  db.exec('ROLLBACK');
@@ -1,7 +1,7 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { DatabaseSync } from 'node:sqlite';
4
- import { writeBaton, consumeBaton, BATON_TTL_MS } from './baton.mjs';
4
+ import { writeBaton, consumeBaton, updateBatonMemo, BATON_TTL_MS } from './baton.mjs';
5
5
 
6
6
  function makeDb() {
7
7
  const db = new DatabaseSync(':memory:');
@@ -9,7 +9,8 @@ function makeDb() {
9
9
  CREATE TABLE handoff_batons (
10
10
  project_path TEXT PRIMARY KEY,
11
11
  session_id TEXT NOT NULL,
12
- created_at INTEGER NOT NULL
12
+ created_at INTEGER NOT NULL,
13
+ memo_text TEXT
13
14
  );
14
15
  `);
15
16
  return db;
@@ -97,3 +98,47 @@ test('consumeBaton: scopes per project_path (does not cross-consume)', () => {
97
98
  const rows = db.prepare("SELECT * FROM handoff_batons WHERE project_path = '/a'").all();
98
99
  assert.equal(rows.length, 1);
99
100
  });
101
+
102
+ test('updateBatonMemo: writes memo_text into existing baton', () => {
103
+ const db = makeDb();
104
+ writeBaton(db, { projectPath: '/proj', sessionId: 'S1', now: 1000 });
105
+ const result = updateBatonMemo(db, { projectPath: '/proj', memoText: '次の一手: X' });
106
+ assert.equal(result.updated, true);
107
+ const row = db.prepare('SELECT memo_text FROM handoff_batons').get();
108
+ assert.equal(row.memo_text, '次の一手: X');
109
+ });
110
+
111
+ test('updateBatonMemo: is a NOOP when no baton exists (no throw)', () => {
112
+ const db = makeDb();
113
+ const result = updateBatonMemo(db, { projectPath: '/missing', memoText: 'hello' });
114
+ assert.equal(result.updated, false);
115
+ });
116
+
117
+ test('writeBaton: preserves memo_text when same project_path is re-batoned', () => {
118
+ const db = makeDb();
119
+ writeBaton(db, { projectPath: '/proj', sessionId: 'S1', now: 1000 });
120
+ updateBatonMemo(db, { projectPath: '/proj', memoText: 'preserved memo' });
121
+ // 同じ project_path に再度 /tl を打っても memo は残る
122
+ writeBaton(db, { projectPath: '/proj', sessionId: 'S2', now: 2000 });
123
+ const row = db.prepare('SELECT * FROM handoff_batons').get();
124
+ assert.equal(row.session_id, 'S2');
125
+ assert.equal(row.created_at, 2000);
126
+ assert.equal(row.memo_text, 'preserved memo');
127
+ });
128
+
129
+ test('consumeBaton: returns memoText when set', () => {
130
+ const db = makeDb();
131
+ writeBaton(db, { projectPath: '/proj', sessionId: 'S1', now: 1000 });
132
+ updateBatonMemo(db, { projectPath: '/proj', memoText: '中断メモ' });
133
+ const result = consumeBaton(db, { projectPath: '/proj', now: 1000 + 1000 });
134
+ assert.equal(result.sessionId, 'S1');
135
+ assert.equal(result.memoText, '中断メモ');
136
+ });
137
+
138
+ test('consumeBaton: returns memoText=null when not set', () => {
139
+ const db = makeDb();
140
+ writeBaton(db, { projectPath: '/proj', sessionId: 'S1', now: 1000 });
141
+ const result = consumeBaton(db, { projectPath: '/proj', now: 1000 + 1000 });
142
+ assert.equal(result.sessionId, 'S1');
143
+ assert.equal(result.memoText, null);
144
+ });
@@ -0,0 +1,81 @@
1
+ /**
2
+ * throughline save-inflight — /tl 発動後、現行 Claude が中断地点の in-flight メモを
3
+ * stdin 経由で書き込む CLI。
4
+ *
5
+ * 使い方 (Claude Code の Bash ツールから):
6
+ * throughline save-inflight <<'EOF'
7
+ * **次の一手**: ...
8
+ * **現在の方針**: ...
9
+ * **未解決の疑問**: ...
10
+ * **進行中 TODO**: ...
11
+ * EOF
12
+ *
13
+ * 動作:
14
+ * 1. stdin を UTF-8 で全部読む
15
+ * 2. 空なら exit 1 (§0 フォールバック禁止 — サイレント成功しない)
16
+ * 3. cwd に対応する handoff_batons 行の memo_text を UPDATE
17
+ * 4. バトン未登録なら updated=false で警告して exit 1
18
+ *
19
+ * 呼び出し元: [.claude/commands/tl.md] が Claude に実行させる
20
+ */
21
+
22
+ import { getDb } from '../db.mjs';
23
+ import { updateBatonMemo } from '../baton.mjs';
24
+ import { readFileSync, appendFileSync, mkdirSync } from 'node:fs';
25
+ import { join, dirname } from 'node:path';
26
+ import { homedir } from 'node:os';
27
+
28
+ function logInflight(entry) {
29
+ const path = join(homedir(), '.throughline', 'logs', 'inflight-memo.log');
30
+ try {
31
+ mkdirSync(dirname(path), { recursive: true });
32
+ appendFileSync(path, JSON.stringify(entry) + '\n', 'utf8');
33
+ } catch (err) {
34
+ const msg = err instanceof Error ? err.message : 'unknown';
35
+ process.stderr.write(`[save-inflight:log] ${msg}\n`);
36
+ }
37
+ }
38
+
39
+ export async function run() {
40
+ let memoText;
41
+ try {
42
+ memoText = readFileSync(0, 'utf8').trim();
43
+ } catch (err) {
44
+ const msg = err instanceof Error ? err.message : 'unknown';
45
+ process.stderr.write(`[save-inflight] failed to read stdin: ${msg}\n`);
46
+ process.exit(1);
47
+ return;
48
+ }
49
+
50
+ if (!memoText) {
51
+ process.stderr.write(
52
+ '[save-inflight] stdin was empty. Provide the in-flight memo via stdin (here-doc or pipe).\n',
53
+ );
54
+ process.exit(1);
55
+ return;
56
+ }
57
+
58
+ const projectPath = process.cwd();
59
+ const db = getDb();
60
+ const { updated } = updateBatonMemo(db, { projectPath, memoText });
61
+
62
+ logInflight({
63
+ ts: new Date().toISOString(),
64
+ project_path: projectPath,
65
+ memo_length: memoText.length,
66
+ baton_updated: updated,
67
+ });
68
+
69
+ if (!updated) {
70
+ process.stderr.write(
71
+ `[save-inflight] no baton found for ${projectPath}. ` +
72
+ `Run /tl first so the baton exists, then save-inflight can attach the memo.\n`,
73
+ );
74
+ process.exit(1);
75
+ return;
76
+ }
77
+
78
+ process.stdout.write(
79
+ `[throughline] in-flight memo saved (${memoText.length} chars) for next session\n`,
80
+ );
81
+ }
package/src/constants.mjs CHANGED
@@ -10,6 +10,7 @@ export const DETAIL_KIND = Object.freeze({
10
10
  TOOL_OUTPUT: 'tool_output',
11
11
  SYSTEM: 'system',
12
12
  IMAGE: 'image',
13
+ THINKING: 'thinking',
13
14
  });
14
15
 
15
16
  /** 上記の値すべての Set(未知値判定に使う) */
package/src/db.mjs CHANGED
@@ -9,7 +9,7 @@ import { join } from 'path';
9
9
 
10
10
  const DB_DIR = join(homedir(), '.throughline');
11
11
  const DB_PATH = join(DB_DIR, 'throughline.db');
12
- const CURRENT_VERSION = 6;
12
+ const CURRENT_VERSION = 7;
13
13
 
14
14
  let _db = null;
15
15
 
@@ -191,6 +191,17 @@ function initSchema(db) {
191
191
  `);
192
192
  }
193
193
 
194
+ // v6 → v7: handoff_batons に memo_text 列追加(/tl 発動時に現行 Claude 自身が
195
+ // 書き込む in-flight メモ。「次の一手」「現在の方針」「未解決」「進行中 TODO」
196
+ // の短い Markdown テキスト。次セッションの SessionStart が resume-context の
197
+ // 先頭に注入して「中断地点からの再開」感を復元する)
198
+ if (version < 7) {
199
+ const batonCols = db.prepare('PRAGMA table_info(handoff_batons)').all();
200
+ if (!batonCols.some((c) => c.name === 'memo_text')) {
201
+ db.exec('ALTER TABLE handoff_batons ADD COLUMN memo_text TEXT');
202
+ }
203
+ }
204
+
194
205
  if (version < CURRENT_VERSION) {
195
206
  db.exec(`PRAGMA user_version = ${CURRENT_VERSION}`);
196
207
  }
@@ -1,23 +1,34 @@
1
1
  /**
2
- * resume-context.mjs — L1+L2 の再注入テキストを組み立てる共有モジュール
2
+ * resume-context.mjs — 中断地点からの再開注入テキストを組み立てる共有モジュール
3
3
  *
4
4
  * 呼び出し元:
5
5
  * - session-start.mjs (isInheritance=true, 引き継ぎヘッダ)
6
6
  *
7
- * 新設計(schema v4):
7
+ * 設計(schema v7 対応):
8
+ * - 注入順: ヘッダ → [in-flight メモ] → [中断直前の思考] → L1 要約 → L2 本文 → フッタ
9
+ * - in-flight メモ: /tl 発動時に現行 Claude が書いた「次の一手 / 方針 / 未解決 / TODO」
10
+ * - 中断直前の思考: 最終ターンの assistant extended thinking (details kind='thinking')
8
11
  * - 直近 N=20 ターンは bodies から L2 全文を注入
9
12
  * - それ以前は skeletons から L1 要約のみ注入
10
- * - 各行頭に [HH:MM:SS] 時刻プレフィックス(bodies.created_at ベース、DB 永続)
13
+ * - 各行頭に [HH:MM:SS] 時刻プレフィックス(created_at ベース、DB 永続)
11
14
  * - 末尾に /sc-detail <時刻> ガイドを追記
12
- * - judgments セクションは廃止
13
15
  * - 現セッションのターンは注入しない(Claude Code 本体のコンテキストに既にあるため)
16
+ * - フレーミングを「過去の記憶」から「中断した作業の再開」に変更 (B 案)
14
17
  */
15
18
 
16
19
  const N_RECENT_L2 = 20;
17
20
 
18
21
  const RESUME_HEADER_TEMPLATE = (turnCount) =>
19
- `## Throughline: セッション記憶(${turnCount} ターン引き継ぎ)\n` +
20
- `**[Throughline] 前セッションの記憶を引き継ぎました。応答の冒頭で「前の記憶を ${turnCount} ターン引き継ぎました」とユーザーに報告してください。**`;
22
+ `## Throughline: 中断した作業の再開(${turnCount} ターン分の文脈を保持)\n` +
23
+ `\n` +
24
+ `**前セッションで進行中だった作業を、この新セッションで引き継いでいます。以下が中断時点の状態です:**\n` +
25
+ `- 中断直前の in-flight メモ(前セッション末尾で Claude 自身が書いた「次の一手・方針・未解決・TODO」)\n` +
26
+ `- 中断直前の思考 (最終ターンの extended thinking)\n` +
27
+ `- 直近 ${N_RECENT_L2} ターンの会話本文 (L2)\n` +
28
+ `- それ以前の要約 (L1)\n` +
29
+ `\n` +
30
+ `応答の冒頭でユーザーに「前の作業を ${turnCount} ターン分引き継ぎました」と報告してください。` +
31
+ `作業方針は前セッションのものを踏襲し、中断地点から自然に続行してください。`;
21
32
 
22
33
  const NORMAL_HEADER = '## Throughline: セッション記憶';
23
34
 
@@ -25,11 +36,11 @@ const FOOTER_GUIDE =
25
36
  '---\n' +
26
37
  '**[Claude 向け — 記憶の使い方]**\n' +
27
38
  '上の L1 要約や L2 本文を読んで「具体的なコマンドやツール出力、ファイル内容を確認したい」と感じたら、' +
28
- '推測せずに **Bash ツールで `throughline detail <時刻>` を実行** して L3(ツール入出力・hook 出力)を取得してください。\n' +
39
+ '推測せずに **Bash ツールで `throughline detail <時刻>` を実行** して L3(ツール入出力・hook 出力・thinking)を取得してください。\n' +
29
40
  '- 単一時刻: `throughline detail 14:23:05`\n' +
30
41
  '- 時刻範囲: `throughline detail 14:23-14:30`\n' +
31
42
  '\n' +
32
- '返る内容: 指定ターンの L2 会話本文 + L3(tool_input / tool_output / system 別にグループ化)。\n' +
43
+ '返る内容: 指定ターンの L2 会話本文 + L3(tool_input / tool_output / system / thinking 別にグループ化)。\n' +
33
44
  'ユーザーに「詳細を見せて」と言われた時だけでなく、**ユーザー発言の文脈が過去ターンに依存しているのに L1/L2 だけでは情報不足だと判断した時**に、Claude 自身の判断で呼び出して構いません。';
34
45
 
35
46
  /**
@@ -44,29 +55,75 @@ function formatTime(unixMs) {
44
55
  }
45
56
 
46
57
  /**
47
- * 本文を 1 行にまとめる(改行は空白に畳む)。
58
+ * 最新ターン番号 (= 中断直前) の thinking ブロックを details から取り出す。
59
+ * origin 除外がある場合はそれも考慮する。
60
+ *
61
+ * @param {import('node:sqlite').DatabaseSync} db
62
+ * @param {string} sessionId
63
+ * @param {string | null} excludeOriginId
64
+ * @returns {Array<{ output_text: string, created_at: number }>}
48
65
  */
49
- function flattenText(text) {
50
- if (!text) return '';
51
- return text.replace(/\n+/g, ' ').trim();
66
+ function loadLatestThinking(db, sessionId, excludeOriginId) {
67
+ const hasExclude = Boolean(excludeOriginId);
68
+
69
+ // 最新 (origin_session_id, turn_number) を bodies から特定
70
+ const latestQuery = hasExclude
71
+ ? `SELECT origin_session_id, turn_number, created_at
72
+ FROM bodies
73
+ WHERE session_id = ? AND origin_session_id != ? AND role = 'assistant'
74
+ ORDER BY created_at DESC
75
+ LIMIT 1`
76
+ : `SELECT origin_session_id, turn_number, created_at
77
+ FROM bodies
78
+ WHERE session_id = ? AND role = 'assistant'
79
+ ORDER BY created_at DESC
80
+ LIMIT 1`;
81
+
82
+ let latest;
83
+ try {
84
+ latest = hasExclude
85
+ ? db.prepare(latestQuery).get(sessionId, excludeOriginId)
86
+ : db.prepare(latestQuery).get(sessionId);
87
+ } catch {
88
+ return [];
89
+ }
90
+ if (!latest) return [];
91
+
92
+ // その (origin_session_id, turn_number) に紐づく kind='thinking' を取り出す
93
+ try {
94
+ const rows = db
95
+ .prepare(
96
+ `SELECT output_text, created_at FROM details
97
+ WHERE session_id = ? AND origin_session_id = ? AND turn_number = ? AND kind = 'thinking'
98
+ ORDER BY created_at ASC`,
99
+ )
100
+ .all(sessionId, latest.origin_session_id, latest.turn_number);
101
+ return rows.filter((r) => typeof r.output_text === 'string' && r.output_text.length > 0);
102
+ } catch {
103
+ return [];
104
+ }
52
105
  }
53
106
 
54
107
  /**
55
108
  * L1+L2 注入テキストを組み立てる。
56
109
  *
57
110
  * @param {import('node:sqlite').DatabaseSync} db
58
- * @param {{ sessionId: string, isInheritance: boolean, excludeOriginId?: string | null }} params
59
- * sessionId: 合流先 session_id (merge target)
60
- * excludeOriginId: 注入対象から除外する origin_session_id(= 現セッションの origin)
61
- * 指定すると「前任チェーンのターンのみ」を注入する
111
+ * @param {{
112
+ * sessionId: string,
113
+ * isInheritance: boolean,
114
+ * excludeOriginId?: string | null,
115
+ * inflightMemo?: string | null,
116
+ * }} params
62
117
  * @returns {string | null}
63
118
  */
64
- export function buildResumeContext(db, { sessionId, isInheritance, excludeOriginId = null }) {
119
+ export function buildResumeContext(
120
+ db,
121
+ { sessionId, isInheritance, excludeOriginId = null, inflightMemo = null },
122
+ ) {
65
123
  if (!sessionId) return null;
66
124
 
67
125
  const hasExclude = Boolean(excludeOriginId);
68
126
 
69
- // 直近 N 件の bodies を取得
70
127
  const bodiesQuery = hasExclude
71
128
  ? `SELECT origin_session_id, turn_number, role, text, created_at
72
129
  FROM bodies
@@ -92,7 +149,7 @@ export function buildResumeContext(db, { sessionId, isInheritance, excludeOrigin
92
149
  }
93
150
  const bodyRows = bodyRowsDesc.reverse(); // ASC に戻す
94
151
 
95
- // 古い側の L1(bodies に既に含まれるターンを除いたもの)を skeletons から取得
152
+ // 古い側の L1(bodies に既に含まれるターンを除いたもの)
96
153
  const bodySet = new Set(
97
154
  bodyRows.map((r) => `${r.origin_session_id}\x00${r.turn_number}`),
98
155
  );
@@ -115,7 +172,14 @@ export function buildResumeContext(db, { sessionId, isInheritance, excludeOrigin
115
172
  (s) => !bodySet.has(`${s.origin_session_id}\x00${s.turn_number}`),
116
173
  );
117
174
 
118
- if (bodyRows.length === 0 && l1Rows.length === 0) {
175
+ const thinkingRows = loadLatestThinking(db, sessionId, excludeOriginId);
176
+
177
+ if (
178
+ bodyRows.length === 0 &&
179
+ l1Rows.length === 0 &&
180
+ thinkingRows.length === 0 &&
181
+ !inflightMemo
182
+ ) {
119
183
  return null;
120
184
  }
121
185
 
@@ -123,12 +187,26 @@ export function buildResumeContext(db, { sessionId, isInheritance, excludeOrigin
123
187
  const header = isInheritance ? RESUME_HEADER_TEMPLATE(turnCount) : NORMAL_HEADER;
124
188
  const lines = [header];
125
189
 
190
+ if (inflightMemo && inflightMemo.trim().length > 0) {
191
+ lines.push('');
192
+ lines.push('### 中断直前の in-flight メモ(前セッションの Claude 自身による要約)');
193
+ lines.push(inflightMemo.trim());
194
+ }
195
+
196
+ if (thinkingRows.length > 0) {
197
+ lines.push('');
198
+ lines.push('### 中断直前の思考 (最終ターンの extended thinking)');
199
+ for (const r of thinkingRows) {
200
+ lines.push(`[${formatTime(r.created_at)}] ${r.output_text}`);
201
+ }
202
+ }
203
+
126
204
  if (l1Rows.length > 0) {
127
205
  lines.push('');
128
206
  lines.push('### それ以前の要約 (L1)');
129
207
  for (const r of l1Rows) {
130
208
  if (!r.summary || r.summary === '(no content)') continue;
131
- lines.push(`[${formatTime(r.created_at)}] ${flattenText(r.summary)}`);
209
+ lines.push(`[${formatTime(r.created_at)}] ${r.summary.replace(/\n+/g, ' ').trim()}`);
132
210
  }
133
211
  }
134
212
 
@@ -83,14 +83,20 @@ async function main() {
83
83
  baton_session_id: baton.sessionId ?? null,
84
84
  baton_age_ms: baton.ageMs ?? null,
85
85
  baton_skip_reason: baton.skipReason ?? null,
86
+ baton_has_memo: Boolean(baton.memoText),
86
87
  merged: mergeResult.merged,
87
88
  merge_skip_reason: mergeResult.skipReason ?? null,
88
89
  predecessor_id: mergeResult.predecessorId ?? null,
89
90
  });
90
91
 
91
92
  // 3. 合流成立なら引き継ぎヘッダ付きで注入
93
+ // バトンに付いていた in-flight メモも併せて先頭セクションに注入する
92
94
  if (mergeResult.merged) {
93
- const text = buildResumeContext(db, { sessionId: session_id, isInheritance: true });
95
+ const text = buildResumeContext(db, {
96
+ sessionId: session_id,
97
+ isInheritance: true,
98
+ inflightMemo: baton.memoText ?? null,
99
+ });
94
100
  if (text) {
95
101
  process.stdout.write(text + '\n');
96
102
  }
@@ -197,7 +197,7 @@ export function sliceCurrentTurnEntries(entries) {
197
197
  * 分類ルール:
198
198
  * - assistant の tool_use ブロック → tool_input (name, input を JSON 化して input_text に)
199
199
  * - user の tool_result ブロック → tool_output (content を output_text に、ANSI 剥離)
200
- * - assistant/user の thinking ブロック → 破棄
200
+ * - assistant の thinking ブロック → thinking (b.thinking を output_text に)
201
201
  * - assistant/user の text ブロック → 扱わない(L2 bodies 側の責務)
202
202
  * - attachment entry (hook_success) → system (hookName + content を出力に)
203
203
  * - system entry (stop_hook_summary) → skip(hook タイミング情報で意味なし)
@@ -215,7 +215,8 @@ export function extractDetailBlocks(turnEntries) {
215
215
  if (e.type === 'assistant') {
216
216
  const blocks = e.message?.content;
217
217
  if (!Array.isArray(blocks)) continue;
218
- for (const b of blocks) {
218
+ for (let i = 0; i < blocks.length; i++) {
219
+ const b = blocks[i];
219
220
  if (!b || !b.type) continue;
220
221
  if (b.type === 'tool_use' && typeof b.id === 'string') {
221
222
  toolNameById.set(b.id, b.name ?? 'unknown');
@@ -226,6 +227,16 @@ export function extractDetailBlocks(turnEntries) {
226
227
  input_text: JSON.stringify(b.input ?? null),
227
228
  output_text: null,
228
229
  });
230
+ } else if (b.type === 'thinking' && typeof b.thinking === 'string') {
231
+ // 固有 id が無いため entry uuid + block index で冪等キーを合成
232
+ const sourceId = e.uuid ? `${e.uuid}:thinking:${i}` : null;
233
+ out.push({
234
+ kind: DETAIL_KIND.THINKING,
235
+ tool_name: 'thinking',
236
+ source_id: sourceId,
237
+ input_text: null,
238
+ output_text: b.thinking,
239
+ });
229
240
  } else if (b.type === 'image') {
230
241
  out.push({
231
242
  kind: DETAIL_KIND.IMAGE,
@@ -235,7 +246,7 @@ export function extractDetailBlocks(turnEntries) {
235
246
  output_text: '[image]',
236
247
  });
237
248
  }
238
- // text / thinking は扱わない
249
+ // text は扱わない
239
250
  }
240
251
  } else if (e.type === 'user') {
241
252
  const blocks = e.message?.content;
@@ -114,22 +114,80 @@ test('extractDetailBlocks: tool_use と tool_result をペアで抽出', () => {
114
114
  assert.equal(output.output_text, 'hi\n');
115
115
  });
116
116
 
117
- test('extractDetailBlocks: thinking / text ブロックは L3 に入れない', () => {
117
+ test('extractDetailBlocks: assistant の thinking ブロックを kind=thinking で抽出、text は無視', () => {
118
118
  const entries = [
119
119
  userEntry('prompt'),
120
120
  {
121
121
  type: 'assistant',
122
+ uuid: 'asst-1',
122
123
  message: {
123
124
  role: 'assistant',
124
125
  content: [
125
- { type: 'thinking', thinking: 'internal thoughts' },
126
+ { type: 'thinking', thinking: 'internal thoughts', signature: 'sig' },
126
127
  { type: 'text', text: 'response' },
127
128
  ],
128
129
  },
129
130
  },
130
131
  ];
131
132
  const details = extractDetailBlocks(entries);
132
- assert.equal(details.length, 0);
133
+ assert.equal(details.length, 1);
134
+ assert.equal(details[0].kind, DETAIL_KIND.THINKING);
135
+ assert.equal(details[0].tool_name, 'thinking');
136
+ assert.equal(details[0].source_id, 'asst-1:thinking:0');
137
+ assert.equal(details[0].input_text, null);
138
+ assert.equal(details[0].output_text, 'internal thoughts');
139
+ });
140
+
141
+ test('extractDetailBlocks: 同 entry 内で thinking + tool_use + image が混在しても全て抽出', () => {
142
+ const entries = [
143
+ userEntry('prompt'),
144
+ {
145
+ type: 'assistant',
146
+ uuid: 'asst-2',
147
+ message: {
148
+ role: 'assistant',
149
+ content: [
150
+ { type: 'thinking', thinking: 'first thought' },
151
+ { type: 'tool_use', id: 'toolu_1', name: 'Read', input: { path: '/x' } },
152
+ { type: 'thinking', thinking: 'second thought' },
153
+ { type: 'image', source: {} },
154
+ { type: 'text', text: 'done' },
155
+ ],
156
+ },
157
+ },
158
+ asstTextEntry('wrap'),
159
+ ];
160
+ const details = extractDetailBlocks(entries);
161
+ // thinking x2, tool_input x1, image x1 = 4
162
+ assert.equal(details.length, 4);
163
+ const thinkings = details.filter((d) => d.kind === DETAIL_KIND.THINKING);
164
+ assert.equal(thinkings.length, 2);
165
+ assert.equal(thinkings[0].source_id, 'asst-2:thinking:0');
166
+ assert.equal(thinkings[1].source_id, 'asst-2:thinking:2');
167
+ assert.equal(thinkings[0].output_text, 'first thought');
168
+ assert.equal(thinkings[1].output_text, 'second thought');
169
+ });
170
+
171
+ test('extractDetailBlocks: thinking エントリに uuid が無くても source_id=null で通過する', () => {
172
+ const entries = [
173
+ userEntry('prompt'),
174
+ {
175
+ type: 'assistant',
176
+ // uuid 欠損
177
+ message: {
178
+ role: 'assistant',
179
+ content: [
180
+ { type: 'thinking', thinking: 'thought without uuid' },
181
+ { type: 'text', text: 'reply' },
182
+ ],
183
+ },
184
+ },
185
+ ];
186
+ const details = extractDetailBlocks(entries);
187
+ const thinking = details.find((d) => d.kind === DETAIL_KIND.THINKING);
188
+ assert.ok(thinking);
189
+ assert.equal(thinking.source_id, null);
190
+ assert.equal(thinking.output_text, 'thought without uuid');
133
191
  });
134
192
 
135
193
  test('extractDetailBlocks: attachment (hook_success) を system として抽出', () => {