throughline 0.4.11 → 0.4.12

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,23 @@ shipped to npm but were not individually tagged on GitHub.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.4.12] — 2026-05-17
14
+
15
+ ### Changed
16
+
17
+ - Added a 「現在地 (直前のやりとり)」 anchor at the top of the Claude
18
+ `/clear` resume context injection. The anchor re-surfaces the latest user
19
+ directive and the latest assistant turn body (each truncated to 600
20
+ characters) directly under the header, before the L1 / L2 sections.
21
+ Observed failure mode: with a long L2 window the model's attention could
22
+ fixate on the *first* L2 entry (oldest in the window) and mistake an older
23
+ plan discussion for the current state of the conversation. The anchor pins
24
+ the latest exchange at the position the model reads first, with the existing
25
+ L2-tail anchor preserved as reinforcement. The header reading instructions
26
+ now point to the new anchor as the first bullet.
27
+
28
+ ## [0.4.11] — 2026-05-10
29
+
13
30
  ### Changed
14
31
 
15
32
  - Disabled Codex automatic current-thread refresh from `UserPromptSubmit`,
@@ -23,6 +40,17 @@ shipped to npm but were not individually tagged on GitHub.
23
40
  and opens the selected host. Explicit `throughline trim --execute --host codex`
24
41
  remains available as a diagnostic current-thread rollback / inject command.
25
42
 
43
+ ### Fixed
44
+
45
+ - Codex hooks registered by `throughline install` now resolve the Node
46
+ executable through `PATH` (matching `process.execPath` by `realpath`) instead
47
+ of always hard-coding `process.execPath`. On Homebrew-installed Node on macOS,
48
+ `process.execPath` points at a Cellar-versioned binary that disappears on the
49
+ next `brew upgrade`, leaving stale absolute paths in `~/.codex/hooks.json`.
50
+ The new resolver prefers a stable `PATH` entry (e.g. `/opt/homebrew/bin/node`)
51
+ and falls back to `process.execPath` only when no PATH entry resolves to the
52
+ same binary.
53
+
26
54
  ## [0.4.10] — 2026-05-09
27
55
 
28
56
  ### Fixed
package/README.md CHANGED
@@ -216,14 +216,21 @@ this fallback. **The env var only affects the fallback**; typed `/clear` and
216
216
 
217
217
  Both paths inject the **same** curated memory:
218
218
 
219
+ - A **"現在地 (latest exchange)"** anchor (added in v0.4.12) re-surfaces the
220
+ most recent user directive and the most recent assistant turn directly under
221
+ the header, each truncated to 600 characters
219
222
  - L1 summaries (older turns, one-line)
220
223
  - L2 verbatim (most recent 20 turns, full text)
221
- - L3 references (`throughline detail <time>` retrieval commands; bodies stay in SQLite)
224
+ - L3 references (`throughline detail <time>` retrieval commands, attached
225
+ inline to each L1/L2 row; bodies stay in SQLite)
222
226
 
223
227
  The injection is reframed as **"resuming an interrupted task"** rather than
224
228
  "reading past logs". The L2 verbatim already contains the last assistant
225
229
  turn — what Claude was about to do next — so no separate memo or extended
226
- thinking section is injected.
230
+ thinking section is injected. The current-state anchor exists because, on
231
+ long L2 windows, attention can fixate on the *first* L2 entry (the oldest
232
+ turn in the window) and misread an old plan discussion as the current task;
233
+ pinning the latest exchange at the top of the injection prevents that drift.
227
234
 
228
235
  Each merged row keeps its `origin_session_id`, so repeated handoffs
229
236
  accumulate memory through chains:
@@ -95,13 +95,14 @@ on SessionStart(source, session_id, project_path):
95
95
 
96
96
  `consumeBaton` が先発なので「両方同時成立」は構造上発生しない (= baton ありなら baton 経路、無ければ source 判定)。typed `/clear` も UserPromptSubmit hook で baton を書くため、通常はほぼ常に baton path が走る。auto path は VSCode 拡張のメニュー由来 `/clear` のように UserPromptSubmit に届かない経路のためのフォールバック。
97
97
 
98
- ### 2.2 注入内容: L1 + L2 + L3 refs のみ (baton/auto どちらの経路でも同一)
98
+ ### 2.2 注入内容: 現在地アンカー + L1 + L2 + L3 refs (baton/auto どちらの経路でも同一)
99
99
 
100
- 含める:
100
+ 含める (順序):
101
101
  - ヘッダ + Reading Contract framing (= Codex 側 `renderCodexRolloutMemoryPreview` の写像)
102
+ - **現在地アンカー** (v0.4.12+): 最新 user turn と最新 assistant turn の本文をヘッダ直下に再掲 (各 600 字で truncate)
102
103
  - **L1 summaries** (古い turn の一行要約)
103
104
  - **L2 bodies** (直近 20 turn の verbatim)
104
- - **L3 references** (= `throughline detail <時刻>` の取り出しコマンド一覧、Codex 風の `- ${kind}: ${detailCommand}` フォーマット)
105
+ - **L3 references** (= `throughline detail <時刻>` の取り出しコマンド一覧、各 L1/L2 行末尾の inline suffix として集約)
105
106
  - Continuation Instruction (= 「これは過去ログではなく現在進行中の作業」と明示)
106
107
 
107
108
  含めない (= 削除):
@@ -109,7 +110,9 @@ on SessionStart(source, session_id, project_path):
109
110
  - 中断直前の thinking (extended thinking セクション)
110
111
  - 既存の Claude 向け footer の冗長な使い方説明
111
112
 
112
- 理由: L2 全文があれば最後の assistant turn 自体に「次に何をしようとしていたか」が含まれている。memo / thinking は redundant。
113
+ 理由:
114
+ - L2 末尾アンカーだけだと、L2 が長いセッションで注意が前半 (= L2 内の最古ターン) に固着し、古い計画ターンを「現在の作業」と誤認するケースがあった (実観測あり)。最新ターンをヘッダ直下にも再掲して、最初に目に入る位置で文脈を固定する。
115
+ - L2 全文があれば最後の assistant turn 自体に「次に何をしようとしていたか」が含まれている。memo / thinking は redundant。
113
116
 
114
117
  ### 2.3 `/tl` の役割: **残すが簡素化**
115
118
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.4.11",
3
+ "version": "0.4.12",
4
4
  "type": "module",
5
5
  "description": "Claude Code hooks plugin for structured context compression (/clear-safe persistent memory)",
6
6
  "keywords": [
@@ -5,8 +5,11 @@
5
5
  * - session-start.mjs (auto path / baton path どちらでも同じ注入)
6
6
  *
7
7
  * 設計 (docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md):
8
- * - 注入順: ヘッダ + 読み方 → L1 要約 → L2 本文(一番下)
9
- * - 直前の発話に Claude attention が向くよう、L2 を末尾に置く
8
+ * - 注入順: ヘッダ + 読み方 → 現在地アンカー → L1 要約 → L2 本文(一番下)
9
+ * - 「現在地」アンカーは直前の user / assistant turn をヘッダ直下に再掲して
10
+ * 最初の注意を最新ターンに固定する。L2 末尾アンカーは補強として残す。
11
+ * (L2 が長くなると末尾アンカーだけでは前半の古いターンに注意が固着し、
12
+ * 話の流れを取り違える事例があった)
10
13
  * - L3 は別セクションを設けず、対応する L1 / L2 行にインラインで
11
14
  * `[→ throughline detail HH:MM:SS (kind …)]` ヒントを付ける
12
15
  * - L2 全文があれば最後の assistant turn 自体に「次に何をしようとしていたか」が
@@ -25,12 +28,43 @@ const RESUME_HEADER_TEMPLATE = (turnCount) =>
25
28
  `## Throughline: 中断した作業の再開(${turnCount} ターン分の文脈を保持)\n` +
26
29
  `\n` +
27
30
  `**読み方:**\n` +
31
+ `- **下の「現在地」が直前のやりとりです。まずここで最新の状態と次の一手を把握してから L1/L2 を読んでください。**\n` +
28
32
  `- 直前の対話の自然な続きとして応答してください。\n` +
29
33
  '- **各ターンの詳細の取得方法**: **`Bash` ツールで `throughline detail HH:MM:SS` を実行** ' +
30
34
  `(該当ターンの本文+詳細を stdout に返します)`;
31
35
 
32
36
  const NORMAL_HEADER = '## Throughline: セッション記憶';
33
37
 
38
+ // 現在地アンカーは最新 user / assistant 本文を再掲する。長すぎると注入全体が膨らむので
39
+ // この文字数で打ち切り、全文は L2 セクション側を参照させる。
40
+ const ANCHOR_MAX_CHARS = 600;
41
+
42
+ function truncateForAnchor(text) {
43
+ const normalized = text.replace(/\n+/g, ' ').trim();
44
+ if (normalized.length <= ANCHOR_MAX_CHARS) return normalized;
45
+ return normalized.slice(0, ANCHOR_MAX_CHARS) + ' …';
46
+ }
47
+
48
+ /**
49
+ * recentBodies (古い順) から「直前のやりとり」アンカー用に
50
+ * 最新の user / assistant 行をそれぞれ 1 件ずつ拾う。
51
+ */
52
+ function pickLatestExchange(recentBodies) {
53
+ let latestUser = null;
54
+ let latestAssistant = null;
55
+ for (let i = recentBodies.length - 1; i >= 0; i -= 1) {
56
+ const r = recentBodies[i];
57
+ if (!r.text) continue;
58
+ if (r.role === 'assistant' && !latestAssistant) {
59
+ latestAssistant = r;
60
+ } else if (r.role === 'user' && !latestUser) {
61
+ latestUser = r;
62
+ }
63
+ if (latestUser && latestAssistant) break;
64
+ }
65
+ return { latestUser, latestAssistant };
66
+ }
67
+
34
68
  /**
35
69
  * L1 + L2 注入テキストを組み立てる。L3 は本文ではなく
36
70
  * 各 L1 / L2 行末尾の inline hint として付与する。
@@ -61,6 +95,28 @@ export function buildResumeContext(
61
95
 
62
96
  const l3ByTurn = groupL3ByTurn(record.references.l3);
63
97
 
98
+ // 現在地アンカー: 引き継ぎ時のみ、最新 user / assistant turn をヘッダ直下に再掲する。
99
+ // L2 末尾アンカーだけだと、長い L2 で注意が前半に固着して話の流れを取り違える事例があった。
100
+ if (isInheritance && record.memory.recentBodies.length > 0) {
101
+ const { latestUser, latestAssistant } = pickLatestExchange(record.memory.recentBodies);
102
+ const anchorLines = [];
103
+ if (latestUser) {
104
+ anchorLines.push(
105
+ `**最新ユーザー指示** [${latestUser.time}]: ${truncateForAnchor(latestUser.text)}`,
106
+ );
107
+ }
108
+ if (latestAssistant) {
109
+ anchorLines.push(
110
+ `**直前のアシスタント** [${latestAssistant.time}]: ${truncateForAnchor(latestAssistant.text)}`,
111
+ );
112
+ }
113
+ if (anchorLines.length > 0) {
114
+ lines.push('');
115
+ lines.push('### 現在地 (直前のやりとり)');
116
+ lines.push(...anchorLines);
117
+ }
118
+ }
119
+
64
120
  if (record.memory.l1Summaries.length > 0) {
65
121
  const l1Lines = [];
66
122
  for (const r of record.memory.l1Summaries) {
@@ -101,7 +101,8 @@ test('buildResumeContext: header is terse and announces the Bash invocation cont
101
101
  assert.ok(!text.includes('内訳の読み方'), 'glossary block must be gone');
102
102
  assert.ok(!text.includes('現在進行中の作業の active work context'), 'verbose framing must be gone');
103
103
 
104
- // 残るのは 2 行: 自然な続き + Bash 呼び出し方法
104
+ // 残るのは 3 行: 現在地参照案内 + 自然な続き + Bash 呼び出し方法
105
+ assert.match(text, /下の「現在地」が直前のやりとりです/);
105
106
  assert.match(text, /直前の対話の自然な続きとして応答してください/);
106
107
  assert.match(
107
108
  text,
@@ -109,6 +110,132 @@ test('buildResumeContext: header is terse and announces the Bash invocation cont
109
110
  );
110
111
  });
111
112
 
113
+ test('buildResumeContext: 現在地 anchor surfaces the latest user/assistant exchange above L1/L2', () => {
114
+ const db = makeDb();
115
+ // 25 turns to exercise an L2 window edge and ensure the anchor picks the newest.
116
+ for (let t = 1; t <= 25; t += 1) {
117
+ insertBody(db, {
118
+ session: 'new',
119
+ origin: 'old',
120
+ turn: t,
121
+ role: 'user',
122
+ text: `user turn ${t}`,
123
+ createdAt: 1000 + t * 10,
124
+ });
125
+ insertBody(db, {
126
+ session: 'new',
127
+ origin: 'old',
128
+ turn: t,
129
+ role: 'assistant',
130
+ text: `assistant turn ${t}`,
131
+ createdAt: 1000 + t * 10 + 1,
132
+ });
133
+ }
134
+
135
+ const text = buildResumeContext(db, {
136
+ sessionId: 'new',
137
+ isInheritance: true,
138
+ });
139
+
140
+ assert.ok(text);
141
+
142
+ const anchorIdx = text.indexOf('### 現在地 (直前のやりとり)');
143
+ const l2Idx = text.indexOf('### 直前の対話 (L2 / active work thread, 古い順)');
144
+ assert.ok(anchorIdx > 0, '現在地 anchor section should be present');
145
+ assert.ok(l2Idx > anchorIdx, '現在地 anchor must appear before the L2 section');
146
+
147
+ // The anchor must point to turn 25 (the latest), not any earlier turn.
148
+ assert.match(text, /\*\*最新ユーザー指示\*\* \[\d\d:\d\d:\d\d\]: user turn 25$/m);
149
+ assert.match(text, /\*\*直前のアシスタント\*\* \[\d\d:\d\d:\d\d\]: assistant turn 25$/m);
150
+ });
151
+
152
+ test('buildResumeContext: 現在地 anchor truncates long bodies but full body still appears in L2', () => {
153
+ const db = makeDb();
154
+ const longText = 'a'.repeat(1200);
155
+ insertBody(db, {
156
+ session: 'new',
157
+ origin: 'old',
158
+ turn: 1,
159
+ role: 'assistant',
160
+ text: longText,
161
+ createdAt: 1000,
162
+ });
163
+
164
+ const text = buildResumeContext(db, {
165
+ sessionId: 'new',
166
+ isInheritance: true,
167
+ });
168
+
169
+ assert.ok(text);
170
+
171
+ const anchorLine = text
172
+ .split('\n')
173
+ .find((l) => l.startsWith('**直前のアシスタント**'));
174
+ assert.ok(anchorLine, '直前のアシスタント anchor line should be present');
175
+ // Anchor must be truncated with ellipsis (originally 1200 chars > 600 cap).
176
+ assert.ok(anchorLine.endsWith(' …'), 'long anchor body must end with the ellipsis marker');
177
+ assert.ok(
178
+ anchorLine.length < longText.length,
179
+ 'anchor line should be shorter than the original body',
180
+ );
181
+
182
+ // Full body must still appear in the L2 section below.
183
+ assert.match(text, new RegExp(`\\[assistant\\]: ${longText}`));
184
+ });
185
+
186
+ test('buildResumeContext: 現在地 anchor is omitted for non-inheritance sessions', () => {
187
+ const db = makeDb();
188
+ insertBody(db, {
189
+ session: 'new',
190
+ origin: 'old',
191
+ turn: 1,
192
+ role: 'assistant',
193
+ text: 'a body',
194
+ createdAt: 1000,
195
+ });
196
+
197
+ const text = buildResumeContext(db, {
198
+ sessionId: 'new',
199
+ isInheritance: false,
200
+ });
201
+
202
+ assert.ok(text);
203
+ assert.ok(
204
+ !text.includes('現在地'),
205
+ 'normal sessions (isInheritance=false) must not include the 現在地 anchor',
206
+ );
207
+ assert.ok(
208
+ !text.includes('最新ユーザー指示'),
209
+ 'normal sessions must not surface a latest-user pointer',
210
+ );
211
+ });
212
+
213
+ test('buildResumeContext: 現在地 anchor handles a single-role recent window', () => {
214
+ const db = makeDb();
215
+ // Only user rows (no assistant) — anchor should still render with just the user line.
216
+ insertBody(db, {
217
+ session: 'new',
218
+ origin: 'old',
219
+ turn: 1,
220
+ role: 'user',
221
+ text: 'lone user message',
222
+ createdAt: 1000,
223
+ });
224
+
225
+ const text = buildResumeContext(db, {
226
+ sessionId: 'new',
227
+ isInheritance: true,
228
+ });
229
+
230
+ assert.ok(text);
231
+ assert.ok(text.includes('### 現在地 (直前のやりとり)'));
232
+ assert.match(text, /\*\*最新ユーザー指示\*\* \[\d\d:\d\d:\d\d\]: lone user message/);
233
+ assert.ok(
234
+ !text.includes('**直前のアシスタント**'),
235
+ 'no assistant body present → no 直前のアシスタント line',
236
+ );
237
+ });
238
+
112
239
  test('buildResumeContext: L2 is the very last section (anchored at bottom for attention)', () => {
113
240
  const db = makeDb();
114
241
  insertSkeleton(db, {
@@ -239,9 +366,11 @@ test('buildResumeContext: L2 entries get inline (詳細:…) suffixes with too
239
366
 
240
367
  assert.ok(text);
241
368
 
369
+ // Look for the L2 body line specifically (not the 現在地 anchor line which also
370
+ // contains the latest assistant body).
242
371
  const turnWithToolsLine = text
243
372
  .split('\n')
244
- .find((l) => l.includes('turn with tools'));
373
+ .find((l) => /^\[\d\d:\d\d:\d\d\] \[assistant\]: turn with tools/.test(l));
245
374
  assert.ok(turnWithToolsLine, 'L2 line for turn 5 should exist');
246
375
  // - tool_input + tool_output は tool 名で集約 (Bash ×2)
247
376
  // - hook 出力 (system) は suffix から除外
@@ -265,7 +394,9 @@ test('buildResumeContext: L2 entries get inline (詳細:…) suffixes with too
265
394
  'per-line should not repeat the throughline detail command (the header announces it)',
266
395
  );
267
396
 
268
- const plainLine = text.split('\n').find((l) => l.includes('plain user message'));
397
+ const plainLine = text
398
+ .split('\n')
399
+ .find((l) => /^\[\d\d:\d\d:\d\d\] \[user\]: plain user message/.test(l));
269
400
  assert.ok(plainLine, 'L2 line for turn 6 should exist');
270
401
  assert.ok(
271
402
  !plainLine.includes('詳細:'),
@@ -341,9 +472,14 @@ test('buildResumeContext: (詳細:…) suffix appears only on the last role ro
341
472
 
342
473
  assert.ok(text);
343
474
 
344
- const userTurn5 = text.split('\n').find((l) => l.includes('user side of turn 5'));
345
- const assistantTurn5 = text.split('\n').find((l) => l.includes('assistant side of turn 5'));
346
- const userTurn6 = text.split('\n').find((l) => l.includes('lone user turn'));
475
+ // Match L2 body lines specifically (`[HH:MM:SS] [role]: ...`) to avoid colliding
476
+ // with the 現在地 anchor lines (`**最新ユーザー指示** [HH:MM:SS]: ...`).
477
+ const lines = text.split('\n');
478
+ const userTurn5 = lines.find((l) => /^\[\d\d:\d\d:\d\d\] \[user\]: user side of turn 5/.test(l));
479
+ const assistantTurn5 = lines.find(
480
+ (l) => /^\[\d\d:\d\d:\d\d\] \[assistant\]: assistant side of turn 5/.test(l),
481
+ );
482
+ const userTurn6 = lines.find((l) => /^\[\d\d:\d\d:\d\d\] \[user\]: lone user turn/.test(l));
347
483
 
348
484
  assert.ok(userTurn5 && assistantTurn5 && userTurn6);
349
485
  // Turn 5: only assistant (last role of the turn) gets the suffix