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 +28 -0
- package/README.md +9 -2
- package/docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md +7 -4
- package/package.json +1 -1
- package/src/resume-context.mjs +58 -2
- package/src/resume-context.test.mjs +142 -6
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
|
|
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
|
|
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 <時刻>`
|
|
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
|
-
理由:
|
|
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
package/src/resume-context.mjs
CHANGED
|
@@ -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
|
-
* -
|
|
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
|
-
// 残るのは
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
const
|
|
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
|