throughline 0.7.0 → 0.8.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/CHANGELOG.md CHANGED
@@ -10,6 +10,42 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.8.0] — 2026-07-18
14
+
15
+ ### Changed (breaking behavior)
16
+
17
+ - **Injection is now push/pull, and L1 is no longer injected (ADR 0016).** The
18
+ budgeted resume context (9,500 chars) is rebuilt as: header +
19
+ current-position anchor + an always-shown retrieval-guide section as the
20
+ fixed part, then the **entire remaining budget is filled with L2 turns in
21
+ full**, newest-first, turn-atomically (a user+assistant pair goes in whole
22
+ or not at all — no fixed N, no fragment packing). L1 summaries are no longer
23
+ injected; older memory is pulled on demand instead. The guide section bakes
24
+ in the exact session id, ISO-millisecond boundary (strict less-than) and
25
+ turn counts at injection time, so the pull side never recomputes the window.
26
+
27
+ ### Added
28
+
29
+ - **`throughline recall --l2|--l1` (ADR 0016).** Read-only pull commands the
30
+ injected guide section points at. `recall --l2 --session <id> --before
31
+ <ISO ms> --last <N>` returns the N turns of full L2 bodies older than the
32
+ boundary, in the same line grammar as the injection (including L3 inline
33
+ suffixes). `recall --l1 ... --skip <N>` lists every turn older than the
34
+ `--l2` range with its L1 summary, honestly marking unsummarized turns
35
+ ("全 M ターン / 要約済み K") and always pointing at `throughline detail
36
+ <time>` for full text. The DB is opened read-only; a missing DB is an
37
+ explicit error and is never created or migrated.
38
+
39
+ ### Fixed
40
+
41
+ - **Windows ACL scripts get a 15s timeout (was 3s).** On windows-latest CI
42
+ runners a cold PowerShell start was measured at 3.0–3.2s, so the 3s
43
+ `spawnSync` cap killed the ACL apply/verify scripts of the completed-turn
44
+ receipt store and the runtime error store and surfaced as a flaky
45
+ "Windows owner-only ACL verification failed" (2 consecutive runs, including
46
+ a docs-only commit). The explicit hard-failure contract is unchanged; only
47
+ the cap was raised.
48
+
13
49
  ## [0.7.0] — 2026-07-17
14
50
 
15
51
  ### Changed (breaking behavior)
@@ -1063,7 +1099,10 @@ two attempts, instrument first instead of patching again.
1063
1099
 
1064
1100
  ---
1065
1101
 
1066
- [Unreleased]: https://github.com/kitepon-rgb/Throughline/compare/v0.6.2...HEAD
1102
+ [Unreleased]: https://github.com/kitepon-rgb/Throughline/compare/v0.8.0...HEAD
1103
+ [0.8.0]: https://github.com/kitepon-rgb/Throughline/compare/v0.7.0...v0.8.0
1104
+ [0.7.0]: https://github.com/kitepon-rgb/Throughline/compare/v0.6.3...v0.7.0
1105
+ [0.6.3]: https://github.com/kitepon-rgb/Throughline/compare/v0.6.2...v0.6.3
1067
1106
  [0.6.2]: https://github.com/kitepon-rgb/Throughline/compare/v0.6.1...v0.6.2
1068
1107
  [0.3.22]: https://github.com/kitepon-rgb/Throughline/releases/tag/v0.3.22
1069
1108
  [0.3.21]: https://github.com/kitepon-rgb/Throughline/compare/v0.3.19...v0.3.21
package/README.md CHANGED
@@ -245,15 +245,23 @@ this fallback. **The env var only affects the fallback**; typed `/clear` and
245
245
 
246
246
  ### What gets injected
247
247
 
248
- Both paths inject the **same** curated memory:
248
+ Both paths inject the **same** curated memory (push/pull design, ADR 0016):
249
249
 
250
250
  - A **"現在地 (latest exchange)"** anchor (added in v0.4.12) re-surfaces the
251
251
  most recent user directive and the most recent assistant turn directly under
252
252
  the header, each truncated to 600 characters
253
- - L1 summaries (older turns, one-line)
254
- - L2 verbatim (most recent 20 turns, full text)
253
+ - A **pull guidance section** (always present) with ready-to-run
254
+ `throughline recall` commands session id, an ISO-ms boundary, and turn
255
+ counts are baked in at injection time
256
+ - L2 verbatim: as many of the most recent turns as fit the ~9,500-char
257
+ injection budget, packed whole-turn (typically 7–8 turns; more for light
258
+ conversations). **L1 summaries are not injected** — the rest of the
259
+ 20-turn window is retrieved verbatim via `throughline recall --l2`, and
260
+ everything older via `throughline recall --l1` (summarized turns show
261
+ their L1 line; unsummarized ones are listed explicitly with a
262
+ `throughline detail` pointer)
255
263
  - L3 references (`throughline detail <time>` retrieval commands, attached
256
- inline to each L1/L2 row; bodies stay in SQLite)
264
+ inline to each L2 row; bodies stay in SQLite)
257
265
 
258
266
  The injection is reframed as **"resuming an interrupted task"** rather than
259
267
  "reading past logs". The L2 verbatim already contains the last assistant
@@ -754,6 +762,7 @@ aggregate は collection が既定OFFで、canonical dotagents config の
754
762
  | `throughline monitor [--all] [--session <id>]` | Run the multi-session token monitor |
755
763
  | `throughline monitor --diag` | Dump TTY/columns/env diagnostics (for debugging monitor render bugs) |
756
764
  | `throughline detail <time>` | Retrieve L2 body text and L3 tool I/O for a turn (see below) |
765
+ | `throughline recall --l2\|--l1 --session <id> --before <ISO> ...` | Pull older memory referenced by the injection's guidance section (read-only; the exact command is baked into each injection) |
757
766
  | `throughline observer-read --project <absolute-directory> --json` | Read one completed-turn Observer page through the JSON-only public boundary |
758
767
  | `throughline observer-wait --project <absolute-directory> --after-cursor <opaque> [--timeout-seconds 3600] --json` | Wait up to 3600 seconds for a completed-turn Observer cursor change |
759
768
  | `throughline doctor` | Check Node version, hook registration, DB writability, PATH |
@@ -9,6 +9,7 @@
9
9
  * throughline process-turn # Stop hook (Claude Code から呼ばれる)
10
10
  * throughline session-start # SessionStart hook (Claude Code から呼ばれる)
11
11
  * throughline detail <時刻> # L2+L3 詳細取得 (Claude が Bash 経由で呼ぶ想定)
12
+ * throughline recall --l2|--l1 # 注入案内から辿る pull 用 read-only 記憶取得
12
13
  * throughline handoff-preview # Codex-facing throughline_handoff JSON preview
13
14
  * throughline auditor-context --json # Read-only bounded auditor context JSON
14
15
  * throughline factory-diagnostics --json # Native factory read-only readiness JSON
@@ -66,6 +67,11 @@ switch (cmd) {
66
67
  case 'detail':
67
68
  (await import('../src/sc-detail.mjs')).run(rest);
68
69
  break;
70
+ case 'recall': {
71
+ const exitCode = (await import('../src/cli/recall.mjs')).run(rest);
72
+ process.exitCode = exitCode;
73
+ break;
74
+ }
69
75
  case 'handoff-preview':
70
76
  await (await import('../src/cli/handoff-preview.mjs')).run(rest);
71
77
  break;
@@ -17,6 +17,14 @@ A 案 (= /clear で自動引継ぎ + /tl は逃げ道として残す + /tl-trim
17
17
  > `THROUGHLINE_DISABLE_AUTO_HANDOFF=1` は **fallback path のみに作用** する
18
18
  > ようになった (typed `/clear` / `/tl` には効かない)。
19
19
 
20
+ > **2026-07-18 (ADR 0016) update**: 注入の中身を push/pull 二段に再設計した。
21
+ > push (9,500 字) はヘッダ + 現在地アンカー + 案内セクション + **L2 をターン原子で
22
+ > 入るだけ全文**(L1 は注入しない)。窓 20 ターンの残りは `throughline recall --l2`、
23
+ > それより古い全ターンは `recall --l1`(要約 or 未要約明示)で pull する。範囲・境界
24
+ > (ISO ms)・件数・session は注入時に案内コマンドへ焼き込み、recall 側は窓を再計算
25
+ > しない。正典は [ADR 0016](adr/0016-push-pull-recall-injection.md)。本書内の
26
+ > 「L1 + L2 を注入する」旧記述はこの update で読み替えること。
27
+
20
28
  ---
21
29
 
22
30
  ## 1. 確定した事実 (実機検証済み)
@@ -0,0 +1,93 @@
1
+ # ADR 0016: 注入の push/pull 再設計 — 現在地 + 入るだけ L2 の push と recall CLI による pull
2
+
3
+ - Status: accepted (2026-07-18 オーナー裁定)
4
+ - 関連: [ADR 0014](0014-two-phase-handoff-ghost-baton.md)(9,500 字注入予算)、
5
+ [ADR 0015](0015-l1-summarizer-model-effort-ratio.md)(L1 要約体制)
6
+
7
+ ## 問題
8
+
9
+ 9,500 字の注入予算(hook stdout ~10k で `<persisted-output>` file 化、ADR 0014)の中で、
10
+ 旧 `buildBudgetedResumeContext` は **L1 を先に詰めてから L2 を詰めて**いた。実測
11
+ (このMacのDB、L2 1 ターン中央値 ~800 字・平均 ~2,750 字)では L2 は 5〜10 ターンしか
12
+ 入らず、しかも L1 生成は `L2_WINDOW = 20` より古いターンにしか走らない(Stop 毎 1 件の
13
+ 遅延生成)ため、「予算落ちした 5〜20 ターン前」が **L2 も注入されず L1 も未生成**の
14
+ 記憶空白帯になっていた。省略告知は時刻列挙のみで、取り出しは 1 ターンずつ
15
+ `throughline detail` を叩くしかなかった。
16
+
17
+ ## 検討して不採用にした案
18
+
19
+ 1. **固定 N ターン + per-role 切り詰めで L2 を保証し、残りに L1 充填**:
20
+ L1 生成閾値を N に連動させる案。pull 設計に移った時点で、注入 L1 が pull の L2×残り
21
+ と同じターンを二重に運ぶ矛盾が出て破棄(会話での敵対的整理)。固定 N 自体も
22
+ 「軽い会話で予算を設計的に遊ばせる」ため破棄。
23
+ 2. **multi-hook による 10k 突破**: 実測で**可能**と確認した(下記「実測」)が、hook の
24
+ 構造的想定(1 本 = 1 context string)に無い使い方であり、attachment 順序も非決定の
25
+ ため不採用。
26
+ 3. **全文ログのファイル書き出し + Read 誘導**: DB が正本なのに派生ファイルの寿命管理が
27
+ 発生する。過去ターンのレコードは不変なので、スナップショット性は DB 直参照でも
28
+ 担保できる。CLI 経由の DB 直参照に統一して不採用。
29
+ 4. **生 SQL をモデルに案内**: schema 講義で注入が太り、origin フィルタ等の誤クエリ事故面
30
+ が開く。`detail` と同じ「DB 直参照だがモデルにはコマンド 1 発」の流儀で不採用。
31
+
32
+ ## 決定
33
+
34
+ push は「現在地」に徹し、過去は pull に出す。
35
+
36
+ - **push(注入、9,500 字内)**: ヘッダ + 現在地アンカー + 案内セクション
37
+ (固定部として最優先予約・無条件表示)+ 残り全予算に **L2 を新しい順で
38
+ 丸ごと入るターンだけ全文**(ターン単位の原子。固定 N なし・断片詰めなし・
39
+ ターン境界の自然な端数は許容)。**L1 は注入しない**。
40
+ 最新ターンが単体で予算超過する場合だけ切り詰めて入れる(従来規則の維持)。
41
+ - **pull(新 CLI `throughline recall` = read-only DB 直参照)**:
42
+ - `recall --l2 --session <id> --before <ISO8601 ms> --last <N>` —
43
+ 境界より古いターンを新しい側から N 件、L2 全文(注入と同じ行文法 + L3 suffix)
44
+ - `recall --l1 --session <id> --before <ISO8601 ms> --skip <N>` —
45
+ --l2 の担当分を飛ばした先の全ターン一覧。**L1 要約があれば要約、無ければ
46
+ 「未要約」と明示**して detail 誘導。冒頭に「全 M ターン / 要約済み K」を正直に表示
47
+ - 一点掘りは従来どおり `throughline detail <時刻>`(L2+L3)
48
+ - **`L2_WINDOW = 20` は据え置き**。20 の意味が「push(入るだけ)+ pull(残り)」の
49
+ 合計窓に再定義されるだけで、L1 要約ペース・Codex 側・schema への変更なし。
50
+
51
+ ### 間抜け防止 — 機械用境界の完全焼き込み(refuter 敵対的検証 2026-07-18)
52
+
53
+ 実装前に refuter による敵対的検証を通し、real 指摘 6 件を全て設計へ反映した:
54
+
55
+ | 指摘 | 対処 |
56
+ |---|---|
57
+ | HH:MM:SS 境界は「当日」解決(sc-detail の `timeToUnixRange`)で深夜跨ぎに壊れる | `--before` は ISO 8601 完全日時(ms 精度)。表示用 HH:MM:SS と機械用境界を分離 |
58
+ | 秒切り捨てで同秒行の境界包含が未定義 | 境界は strict less-than の ms 比較で規定 |
59
+ | 20 ターン窓はクエリ時再計算のため、新セッションのターン追記で窓がスライドし古い側が黙って欠落 | `--last <残り件数>` も注入時に焼き込み、recall 側の窓再計算を全廃 |
60
+ | L1 は遅延生成でバックログがあり「全 N ターンの要約」が虚偽になる。未要約ターンがどの取っ手からも見えない | `--l1` は全ターン一覧(要約 or 未要約明示)。件数は「全 M / 要約済み K」形式 |
61
+ | 既定 session 解決(cwd 系)は Codex 併走・複数ウィンドウで非決定 | `--session` も焼き込み。recall は既定解決を持たない |
62
+ | `getDb()` は mkdir + read-write open + migration を行い read-only 契約に反する | recall は `DatabaseSync(path, { readOnly: true })` + 存在チェック。DB を作成しない |
63
+
64
+ 併せて、予算詰めを行(role)単位からターン単位の原子に変更した(行単位だと同一ターンの
65
+ assistant 行だけ入り user 行が pull 側に半身で現れ、境界の算術が濁るため)。
66
+
67
+ ## 実測
68
+
69
+ ### 新設計のレンダリング(2026-07-18、実 DB 191 ターンセッション)
70
+
71
+ - totalChars 9,158 / 9,500、L2 9 ターン注入、残り 11 ターン、窓外 171 ターン
72
+ (要約済み 124 / 未要約 47)
73
+ - 焼き込まれた案内コマンドをそのまま実行して、`--l2` が境界ぴったりから 11 ターン
74
+ (33k 字)、`--l1` が全 171 ターン一覧(118k 字、`--last` で部分取得可)を返すことを確認
75
+
76
+ ### multi-hook 10k 突破の実測(2026-07-18、Claude Code 2.1.211 / 不採用)
77
+
78
+ - 同一 UserPromptSubmit に hook を 3 本登録 → それぞれ独立の `hook_success` attachment
79
+ になり 9,000 字 × 3 = 27,000 字が全部モデル可視 inline
80
+ - 1 本だけ 12k にすると**その 1 本だけ**が `<persisted-output>` 化(隣の 9k は無傷)
81
+ = 10k 判定は per context string
82
+ - 5 本 × 9k = 45,000 字でも全部可視。合算上限は 45k まで観測されず
83
+ - **attachment の並び順は登録順と一致しない**(並列実行のため非決定)
84
+ - 詳細は [rag/01-hooks/hook-stdout-10k-persisted-output.md](../../rag/01-hooks/hook-stdout-10k-persisted-output.md)
85
+
86
+ ## 帰結
87
+
88
+ - 「5〜20 ターン前の空白帯」は消滅: 窓内の非注入分は `recall --l2` が verbatim で、
89
+ 窓外は `recall --l1` が全ターン(未要約含む)で必ず到達可能
90
+ - push は会話密度に自動適応(中央値 7〜8 ターン、軽い会話なら 10 ターン超、重くて 2〜3)
91
+ - コンテキスト衛生は維持: pull はモデルが必要と判断した時だけ発生する
92
+ - `handoff-executor` の injection stats は
93
+ `injected_l2_turns / remaining_l2_turns / older_turns / older_summarized` に更新
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "description": "Claude Code hooks plugin for structured context compression (/clear-safe persistent memory)",
6
6
  "keywords": [
@@ -40,6 +40,23 @@ v2.1.195 より前に 10k 超を emit した実績が手元に無いため、「
40
40
  ヘッダ + アンカー常時全文、L1 → L2 を新しい側から詰め、省略は注入文へ明示)。
41
41
  詳細は [ADR 0014](../../docs/adr/0014-two-phase-handoff-ghost-baton.md)。
42
42
 
43
+ ## 追記: 10k 判定は per context string — multi-hook で突破可能(2026-07-18 実測、不採用)
44
+
45
+ 同一イベントに複数 hook を登録した場合の挙動を実測した(Claude Code 2.1.211、
46
+ 一時 project + 3〜5 本の UserPromptSubmit tracer hook + `claude -p` Haiku):
47
+
48
+ - 各 hook stdout は**独立の `hook_success` attachment** になり、10k 判定も per string。
49
+ 3 本 × 9,000 字 = 27,000 字、5 本 × 9,000 字 = 45,000 字が全部モデル可視 inline
50
+ - 1 本だけ 12k にすると**その 1 本だけ**が `<persisted-output>` 化し、隣の 9k は無傷
51
+ - hooks reference の「10,000 characters per context string」「複数 hook の
52
+ additionalContext は all of the values が届く」の文言と整合
53
+ - **罠**: attachment の並び順は登録順と一致しない(hook 並列実行のため非決定)。
54
+ multi-part 注入に使うなら各 part に自己記述ヘッダが必須
55
+
56
+ Throughline では「hook の構造的想定(1 本 = 1 context string)に無い使い方」として
57
+ **不採用**(オーナー裁定 2026-07-18)。注入は 9,500 字 push + `throughline recall` pull の
58
+ 二段構成にした([ADR 0016](../../docs/adr/0016-push-pull-recall-injection.md))。
59
+
43
60
  ## 検証手順(再現用)
44
61
 
45
62
  1. 一時 project に `.claude/settings.json` で UserPromptSubmit hook を登録し、
@@ -0,0 +1,279 @@
1
+ /**
2
+ * `throughline recall` — 注入案内から辿る pull 用の DB 直参照 CLI (read-only)
3
+ *
4
+ * 注入(push)は「現在地 + 入るだけの L2」だけを運び、残りの記憶は本コマンドで
5
+ * 必要な時だけ取得する(オーナー裁定 2026-07-18、ADR 0016):
6
+ * - `recall --l2 --session <id> --before <ISO日時> --last <N>`
7
+ * 境界(strict less-than, ms 比較)より古いターンを新しい側から N 件、
8
+ * L2 全文(注入と同じ行文法 + L3 参照 suffix)で出す。
9
+ * - `recall --l1 --session <id> --before <ISO日時> --skip <N>`
10
+ * 境界から N 件(--l2 の担当分)を飛ばした先の全ターン一覧。
11
+ * L1 要約があれば要約、無ければ「未要約」と明示して detail 誘導を出す。
12
+ *
13
+ * 契約:
14
+ * - 範囲・境界・session は注入時に案内コマンドへ焼き込まれた値だけで決まる。
15
+ * recall 側で「現在の 20 ターン窓」を再計算しない(新セッションのターン追記で
16
+ * 窓がスライドし、古い側が黙って欠落するため)。
17
+ * - DB は read-only で開く(create / migrate / write なし)。DB が無ければ
18
+ * explicit error で終了する。
19
+ * - 既定 session 解決は持たない。案内コマンドが常に `--session` を運ぶ。
20
+ */
21
+
22
+ import { existsSync } from 'node:fs';
23
+ import { homedir } from 'node:os';
24
+ import { join } from 'node:path';
25
+ import { DatabaseSync } from 'node:sqlite';
26
+ import { formatTime } from '../handoff-record.mjs';
27
+ import { groupL3ByTurn, buildPartsSummary } from '../l3-summary.mjs';
28
+
29
+ export function defaultRecallDbPath() {
30
+ return join(homedir(), '.throughline', 'throughline.db');
31
+ }
32
+
33
+ const USAGE =
34
+ 'usage: throughline recall (--l2 --last <N> | --l1 [--skip <N>] [--last <N>]) ' +
35
+ '--session <id> --before <ISO8601> [--db <path>]';
36
+
37
+ export function parseRecallArgs(argv) {
38
+ const opts = {
39
+ mode: null,
40
+ sessionId: null,
41
+ beforeMs: null,
42
+ last: null,
43
+ skip: 0,
44
+ dbPath: null,
45
+ };
46
+ for (let i = 0; i < argv.length; i += 1) {
47
+ const a = argv[i];
48
+ if (a === '--l2' || a === '--l1') {
49
+ if (opts.mode) throw new Error('recall: --l2 と --l1 は同時に指定できません');
50
+ opts.mode = a.slice(2);
51
+ } else if (a === '--session') {
52
+ opts.sessionId = argv[++i];
53
+ } else if (a === '--before') {
54
+ const raw = argv[++i];
55
+ const ms = Date.parse(raw ?? '');
56
+ if (!Number.isFinite(ms)) {
57
+ throw new Error(`recall: --before の日時を解釈できません: ${raw}(ISO 8601 を指定)`);
58
+ }
59
+ opts.beforeMs = ms;
60
+ } else if (a === '--last') {
61
+ const n = Number(argv[++i]);
62
+ if (!Number.isInteger(n) || n < 0) throw new Error('recall: --last は 0 以上の整数');
63
+ opts.last = n;
64
+ } else if (a === '--skip') {
65
+ const n = Number(argv[++i]);
66
+ if (!Number.isInteger(n) || n < 0) throw new Error('recall: --skip は 0 以上の整数');
67
+ opts.skip = n;
68
+ } else if (a === '--db') {
69
+ opts.dbPath = argv[++i];
70
+ } else {
71
+ throw new Error(`recall: 未知の引数: ${a}\n${USAGE}`);
72
+ }
73
+ }
74
+ if (!opts.mode) throw new Error(`recall: --l2 または --l1 を指定してください\n${USAGE}`);
75
+ if (!opts.sessionId) throw new Error(`recall: --session は必須です\n${USAGE}`);
76
+ if (opts.beforeMs == null) throw new Error(`recall: --before は必須です\n${USAGE}`);
77
+ if (opts.mode === 'l2' && opts.last == null) {
78
+ throw new Error(`recall: --l2 には --last <N> が必須です\n${USAGE}`);
79
+ }
80
+ return opts;
81
+ }
82
+
83
+ /**
84
+ * 境界より古い側の distinct ターンを新しい順に列挙する。
85
+ * 各要素: { originSessionId, turnNumber, turnKey, minCreatedAt }
86
+ */
87
+ function listTurnsBefore(db, { sessionId, beforeMs }) {
88
+ const rows = db
89
+ .prepare(
90
+ `SELECT origin_session_id, turn_number, MIN(created_at) AS min_ca, MAX(created_at) AS max_ca
91
+ FROM bodies
92
+ WHERE session_id = ? AND created_at < ?
93
+ GROUP BY origin_session_id, turn_number
94
+ ORDER BY min_ca DESC`,
95
+ )
96
+ .all(sessionId, beforeMs);
97
+ return rows.map((r) => ({
98
+ originSessionId: r.origin_session_id,
99
+ turnNumber: r.turn_number,
100
+ turnKey: `${r.origin_session_id}\x00${r.turn_number}`,
101
+ minCreatedAt: r.min_ca,
102
+ maxCreatedAt: r.max_ca,
103
+ }));
104
+ }
105
+
106
+ function loadL3ForTurns(db, sessionId, turns) {
107
+ if (turns.length === 0) return [];
108
+ const placeholders = turns.map(() => '(?, ?, ?)').join(', ');
109
+ const params = turns.flatMap((t) => [sessionId, t.originSessionId, Number(t.turnNumber)]);
110
+ return db
111
+ .prepare(
112
+ `SELECT kind, tool_name, origin_session_id, turn_number, created_at
113
+ FROM details
114
+ WHERE (session_id, origin_session_id, turn_number) IN (VALUES ${placeholders})
115
+ ORDER BY created_at ASC, id ASC`,
116
+ )
117
+ .all(...params)
118
+ .map((r) => ({
119
+ kind: r.kind,
120
+ toolName: r.tool_name,
121
+ originSessionId: r.origin_session_id,
122
+ turnNumber: r.turn_number,
123
+ createdAt: r.created_at,
124
+ }));
125
+ }
126
+
127
+ /**
128
+ * --l2: 境界より古いターンを新しい側から last 件、古い順の L2 全文で描画する。
129
+ */
130
+ export function renderRecallL2(db, { sessionId, beforeMs, last }) {
131
+ const turnsDesc = listTurnsBefore(db, { sessionId, beforeMs });
132
+ const selected = turnsDesc.slice(0, last).reverse(); // 古い順に戻す
133
+ const lines = [];
134
+
135
+ if (selected.length === 0) {
136
+ lines.push(`## Throughline recall (L2): 該当ターンなし(--before ${new Date(beforeMs).toISOString()} より古い L2 が DB にありません)`);
137
+ return { text: lines.join('\n'), turnCount: 0 };
138
+ }
139
+
140
+ const range = `${formatTime(selected[0].minCreatedAt)}〜${formatTime(selected[selected.length - 1].maxCreatedAt)}`;
141
+ lines.push(`## Throughline recall (L2): ${selected.length}ターン (${range}, 古い順)`);
142
+ if (selected.length < last) {
143
+ lines.push(`(--last ${last} のうち DB に存在するのは ${selected.length} ターンのみ)`);
144
+ }
145
+ lines.push('');
146
+
147
+ const l3ByTurn = groupL3ByTurn(loadL3ForTurns(db, sessionId, selected));
148
+
149
+ const bodyStmt = db.prepare(
150
+ `SELECT role, text, created_at
151
+ FROM bodies
152
+ WHERE session_id = ? AND origin_session_id = ? AND turn_number = ? AND created_at < ?
153
+ ORDER BY created_at ASC`,
154
+ );
155
+ for (const turn of selected) {
156
+ const rows = bodyStmt
157
+ .all(sessionId, turn.originSessionId, Number(turn.turnNumber), beforeMs)
158
+ .filter((r) => r.text);
159
+ for (let i = 0; i < rows.length; i += 1) {
160
+ const r = rows[i];
161
+ const isLast = i === rows.length - 1;
162
+ const partCounts = isLast ? (l3ByTurn.get(turn.turnKey)?.partCounts ?? new Map()) : new Map();
163
+ const suffix = buildPartsSummary(partCounts);
164
+ lines.push(`[${formatTime(r.created_at)}] [${r.role}]: ${r.text}${suffix}`);
165
+ }
166
+ }
167
+ return { text: lines.join('\n'), turnCount: selected.length };
168
+ }
169
+
170
+ /**
171
+ * --l1: 境界から skip 件を飛ばした先の全ターン一覧(古い順)。
172
+ * L1 要約があれば要約行、無ければ「未要約」と明示して detail 誘導を出す。
173
+ */
174
+ export function renderRecallL1(db, { sessionId, beforeMs, skip, last = null }) {
175
+ const turnsDesc = listTurnsBefore(db, { sessionId, beforeMs });
176
+ let olderDesc = turnsDesc.slice(skip);
177
+ if (last != null) olderDesc = olderDesc.slice(0, last);
178
+ const selected = [...olderDesc].reverse(); // 古い順
179
+ const lines = [];
180
+
181
+ if (selected.length === 0) {
182
+ lines.push('## Throughline recall (L1): 該当ターンなし');
183
+ return { text: lines.join('\n'), turnCount: 0, summarizedCount: 0 };
184
+ }
185
+
186
+ const skelRows = db
187
+ .prepare(
188
+ `SELECT origin_session_id, turn_number, summary, created_at
189
+ FROM skeletons
190
+ WHERE session_id = ?
191
+ ORDER BY created_at ASC`,
192
+ )
193
+ .all(sessionId);
194
+ const skelByTurn = new Map();
195
+ for (const r of skelRows) {
196
+ const key = `${r.origin_session_id}\x00${r.turn_number}`;
197
+ if (!skelByTurn.has(key)) skelByTurn.set(key, []);
198
+ skelByTurn.get(key).push(r);
199
+ }
200
+
201
+ let summarizedCount = 0;
202
+ const bodyLines = [];
203
+ for (const turn of selected) {
204
+ const time = formatTime(turn.minCreatedAt);
205
+ const skels = skelByTurn.get(turn.turnKey);
206
+ if (skels && skels.length > 0) {
207
+ summarizedCount += 1;
208
+ for (const s of skels) {
209
+ if (!s.summary || s.summary === '(no content)') continue;
210
+ const summary = s.summary.replace(/\n+/g, ' ').trim();
211
+ bodyLines.push(`[${time}] ${summary}`);
212
+ }
213
+ } else {
214
+ bodyLines.push(`[${time}] (未要約) 全文: throughline detail ${time}`);
215
+ }
216
+ }
217
+
218
+ const range = `${formatTime(selected[0].minCreatedAt)}〜${formatTime(selected[selected.length - 1].minCreatedAt)}`;
219
+ lines.push(
220
+ `## Throughline recall (L1): 全${selected.length}ターン / 要約済み ${summarizedCount} (${range}, 古い順)`,
221
+ );
222
+ lines.push('各ターンの全文・ツール入出力: `throughline detail <時刻>` で取得可');
223
+ lines.push('');
224
+ lines.push(...bodyLines);
225
+ return { text: lines.join('\n'), turnCount: selected.length, summarizedCount };
226
+ }
227
+
228
+ export function runRecall(db, opts) {
229
+ if (opts.mode === 'l2') {
230
+ return renderRecallL2(db, {
231
+ sessionId: opts.sessionId,
232
+ beforeMs: opts.beforeMs,
233
+ last: opts.last,
234
+ });
235
+ }
236
+ return renderRecallL1(db, {
237
+ sessionId: opts.sessionId,
238
+ beforeMs: opts.beforeMs,
239
+ skip: opts.skip,
240
+ last: opts.last,
241
+ });
242
+ }
243
+
244
+ export function run(argv) {
245
+ let opts;
246
+ try {
247
+ opts = parseRecallArgs(argv);
248
+ } catch (err) {
249
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
250
+ return 1;
251
+ }
252
+
253
+ const dbPath = opts.dbPath ?? defaultRecallDbPath();
254
+ if (!existsSync(dbPath)) {
255
+ process.stderr.write(`recall: DB がありません: ${dbPath}(recall は DB を作成しません)\n`);
256
+ return 1;
257
+ }
258
+
259
+ let db;
260
+ try {
261
+ db = new DatabaseSync(dbPath, { readOnly: true });
262
+ } catch (err) {
263
+ process.stderr.write(
264
+ `recall: DB を read-only で開けませんでした: ${err instanceof Error ? err.message : String(err)}\n`,
265
+ );
266
+ return 1;
267
+ }
268
+
269
+ try {
270
+ const result = runRecall(db, opts);
271
+ process.stdout.write(`${result.text}\n`);
272
+ return 0;
273
+ } catch (err) {
274
+ process.stderr.write(`recall: ${err instanceof Error ? err.message : String(err)}\n`);
275
+ return 1;
276
+ } finally {
277
+ db.close();
278
+ }
279
+ }