throughline 0.2.0 → 0.3.1
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 +42 -20
- package/bin/throughline.mjs +12 -8
- package/package.json +1 -1
- package/src/baton.mjs +46 -8
- package/src/baton.test.mjs +47 -2
- package/src/cli/save-inflight.mjs +81 -0
- package/src/constants.mjs +1 -0
- package/src/db.mjs +12 -1
- package/src/resume-context.mjs +99 -21
- package/src/session-start.mjs +7 -1
- package/src/state-file.mjs +44 -7
- package/src/state-file.test.mjs +135 -0
- package/src/token-monitor.mjs +280 -62
- package/src/token-monitor.test.mjs +331 -0
- package/src/transcript-reader.mjs +14 -3
- package/src/transcript-reader.test.mjs +61 -3
- package/src/transcript-usage.mjs +27 -6
- package/src/transcript-usage.test.mjs +159 -0
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
|
|
54
|
+
## Three-layer memory model (schema v7)
|
|
55
55
|
|
|
56
|
-
| Layer | Name | Where it lives | Content
|
|
57
|
-
| ----- | ---------- | --------------------- |
|
|
58
|
-
| **L1** | Skeleton | injected when old | one-line Haiku-generated summary of the turn
|
|
59
|
-
| **L2** | Body | injected when recent | user text + assistant reply, verbatim
|
|
60
|
-
| **L3** | Detail | SQLite only | tool I/O, system messages, images (on-demand
|
|
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.
|
|
64
|
-
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
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
|
|
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`,
|
|
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
|
|
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**
|
package/bin/throughline.mjs
CHANGED
|
@@ -36,11 +36,14 @@ switch (cmd) {
|
|
|
36
36
|
await import('../src/prompt-submit.mjs');
|
|
37
37
|
break;
|
|
38
38
|
case 'monitor':
|
|
39
|
-
await import('../src/token-monitor.mjs');
|
|
39
|
+
(await import('../src/token-monitor.mjs')).main();
|
|
40
40
|
break;
|
|
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
|
|
70
|
-
throughline uninstall
|
|
71
|
-
throughline monitor
|
|
72
|
-
throughline detail <time>
|
|
73
|
-
throughline
|
|
74
|
-
throughline
|
|
75
|
-
throughline
|
|
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
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
|
|
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
|
|
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(
|
|
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 {
|
|
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');
|
package/src/baton.test.mjs
CHANGED
|
@@ -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
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 =
|
|
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
|
}
|
package/src/resume-context.mjs
CHANGED
|
@@ -1,23 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* resume-context.mjs —
|
|
2
|
+
* resume-context.mjs — 中断地点からの再開注入テキストを組み立てる共有モジュール
|
|
3
3
|
*
|
|
4
4
|
* 呼び出し元:
|
|
5
5
|
* - session-start.mjs (isInheritance=true, 引き継ぎヘッダ)
|
|
6
6
|
*
|
|
7
|
-
*
|
|
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] 時刻プレフィックス(
|
|
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:
|
|
20
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
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 {{
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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(
|
|
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
|
|
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
|
-
|
|
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)}] ${
|
|
209
|
+
lines.push(`[${formatTime(r.created_at)}] ${r.summary.replace(/\n+/g, ' ').trim()}`);
|
|
132
210
|
}
|
|
133
211
|
}
|
|
134
212
|
|
package/src/session-start.mjs
CHANGED
|
@@ -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, {
|
|
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
|
}
|