throughline 0.3.24 → 0.3.25
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/.claude/commands/tl-trim.md +42 -0
- package/.codex-sidecar.yml +62 -0
- package/CHANGELOG.md +583 -0
- package/README.ja.md +42 -5
- package/README.md +383 -23
- package/bin/throughline.mjs +168 -4
- package/codex/skills/throughline/SKILL.md +157 -0
- package/codex/skills/throughline/agents/openai.yaml +7 -0
- package/docs/INHERITANCE_ON_CLEAR_ONLY.md +146 -0
- package/docs/L1_L2_L3_REDESIGN.md +415 -0
- package/docs/PUBLIC_RELEASE_PLAN.md +184 -0
- package/docs/THROUGHLINE_CODEX_DUAL_SUPPORT.md +249 -0
- package/docs/THROUGHLINE_CODEX_FIRST_ROADMAP.md +555 -0
- package/docs/THROUGHLINE_CODEX_MONITOR_IMPLEMENTATION_PLAN.md +220 -0
- package/docs/THROUGHLINE_CODEX_TRIM_IMPLEMENTATION_PLAN.md +528 -0
- package/docs/THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md +672 -0
- package/docs/archive/CONCEPT.md +476 -0
- package/docs/archive/EXPERIMENT.md +371 -0
- package/docs/archive/README.md +22 -0
- package/docs/archive/SESSION_LINKING_DESIGN.md +231 -0
- package/docs/archive/THROUGHLINE_NEXT_STEPS.md +134 -0
- package/docs/throughline-codex-trim-rollback-incident-report.md +306 -0
- package/docs/throughline-handoff-context.example.json +57 -0
- package/docs/throughline-rollback-context-trim-insight.md +455 -0
- package/package.json +6 -2
- package/src/cli/codex-capture.mjs +95 -0
- package/src/cli/codex-handoff-model-smoke.mjs +292 -0
- package/src/cli/codex-handoff-model-smoke.test.mjs +262 -0
- package/src/cli/codex-handoff-smoke.mjs +163 -0
- package/src/cli/codex-handoff-smoke.test.mjs +149 -0
- package/src/cli/codex-handoff-start.mjs +291 -0
- package/src/cli/codex-handoff-start.test.mjs +194 -0
- package/src/cli/codex-hook.mjs +276 -0
- package/src/cli/codex-hook.test.mjs +293 -0
- package/src/cli/codex-host-primitive-audit.mjs +110 -0
- package/src/cli/codex-host-primitive-audit.test.mjs +75 -0
- package/src/cli/codex-restore-smoke.mjs +357 -0
- package/src/cli/codex-restore-source-audit.mjs +304 -0
- package/src/cli/codex-resume.mjs +138 -0
- package/src/cli/codex-rollback-model-visible-smoke.mjs +373 -0
- package/src/cli/codex-rollback-model-visible-smoke.test.mjs +255 -0
- package/src/cli/codex-sidecar-diagnostics.mjs +48 -0
- package/src/cli/codex-sidecar-dry-run.mjs +85 -0
- package/src/cli/codex-summarize.mjs +224 -0
- package/src/cli/codex-threads.mjs +89 -0
- package/src/cli/codex-visibility-smoke.mjs +196 -0
- package/src/cli/codex-vscode-restore-smoke.mjs +226 -0
- package/src/cli/codex-vscode-rollback-smoke.mjs +114 -0
- package/src/cli/doctor.mjs +503 -1
- package/src/cli/doctor.test.mjs +542 -3
- package/src/cli/handoff-preview.mjs +78 -0
- package/src/cli/help.test.mjs +64 -0
- package/src/cli/install.mjs +227 -4
- package/src/cli/install.test.mjs +207 -4
- package/src/cli/trim.mjs +564 -0
- package/src/codex-app-server.mjs +1816 -0
- package/src/codex-app-server.test.mjs +512 -0
- package/src/codex-auto-refresh.mjs +194 -0
- package/src/codex-auto-refresh.test.mjs +182 -0
- package/src/codex-capture.mjs +235 -0
- package/src/codex-capture.test.mjs +393 -0
- package/src/codex-handoff-model-smoke.mjs +114 -0
- package/src/codex-handoff-model-smoke.test.mjs +89 -0
- package/src/codex-handoff-smoke.mjs +124 -0
- package/src/codex-handoff-smoke.test.mjs +103 -0
- package/src/codex-handoff.mjs +331 -0
- package/src/codex-handoff.test.mjs +220 -0
- package/src/codex-host-primitive-audit.mjs +374 -0
- package/src/codex-host-primitive-audit.test.mjs +208 -0
- package/src/codex-restore-smoke.test.mjs +639 -0
- package/src/codex-restore-source-audit.mjs +1348 -0
- package/src/codex-restore-source-audit.test.mjs +623 -0
- package/src/codex-resume.test.mjs +242 -0
- package/src/codex-rollout-memory.mjs +711 -0
- package/src/codex-rollout-memory.test.mjs +610 -0
- package/src/codex-sidecar-cli.test.mjs +75 -0
- package/src/codex-sidecar.mjs +246 -0
- package/src/codex-sidecar.test.mjs +172 -0
- package/src/codex-summarize.test.mjs +143 -0
- package/src/codex-thread-identity.mjs +23 -0
- package/src/codex-thread-index.mjs +173 -0
- package/src/codex-thread-index.test.mjs +164 -0
- package/src/codex-usage.mjs +110 -0
- package/src/codex-usage.test.mjs +140 -0
- package/src/codex-visibility-smoke.test.mjs +222 -0
- package/src/codex-vscode-restore-smoke.mjs +206 -0
- package/src/codex-vscode-restore-smoke.test.mjs +325 -0
- package/src/codex-vscode-rollback-smoke.mjs +90 -0
- package/src/codex-vscode-rollback-smoke.test.mjs +290 -0
- package/src/db-schema.test.mjs +97 -0
- package/src/haiku-summarizer.mjs +267 -26
- package/src/haiku-summarizer.test.mjs +282 -0
- package/src/handoff-preview.test.mjs +108 -0
- package/src/handoff-record.mjs +294 -0
- package/src/handoff-record.test.mjs +226 -0
- package/src/hook-entrypoints.test.mjs +326 -0
- package/src/package-files.test.mjs +19 -0
- package/src/prompt-submit.mjs +9 -6
- package/src/resume-context.mjs +44 -140
- package/src/resume-context.test.mjs +172 -0
- package/src/session-start.mjs +8 -5
- package/src/state-file.mjs +50 -6
- package/src/state-file.test.mjs +50 -0
- package/src/token-monitor.mjs +14 -10
- package/src/token-monitor.test.mjs +27 -0
- package/src/trim-cli.test.mjs +1584 -0
- package/src/trim-model.mjs +584 -0
- package/src/trim-model.test.mjs +568 -0
- package/src/turn-processor.mjs +17 -10
- package/src/vscode-task.mjs +33 -10
- package/src/vscode-task.test.mjs +19 -9
package/src/resume-context.mjs
CHANGED
|
@@ -13,10 +13,12 @@
|
|
|
13
13
|
* - 各行頭に [HH:MM:SS] 時刻プレフィックス(created_at ベース、DB 永続)
|
|
14
14
|
* - 末尾に /sc-detail <時刻> ガイドを追記
|
|
15
15
|
* - 現セッションのターンは注入しない(Claude Code 本体のコンテキストに既にあるため)
|
|
16
|
-
* -
|
|
16
|
+
* - フレーミングを「過去の記憶」から「中断した作業の再開」に変更する。
|
|
17
|
+
* 冒頭と末尾の両方に current-work instruction を置き、長文 context 内でも
|
|
18
|
+
* L1/L2 を現在タスク用の作業文脈として読むよう誘導する。
|
|
17
19
|
*/
|
|
18
20
|
|
|
19
|
-
|
|
21
|
+
import { buildHandoffRecord, N_RECENT_L2 } from './handoff-record.mjs';
|
|
20
22
|
|
|
21
23
|
const RESUME_HEADER_TEMPLATE = (turnCount) =>
|
|
22
24
|
`## Throughline: 中断した作業の再開(${turnCount} ターン分の文脈を保持)\n` +
|
|
@@ -30,6 +32,19 @@ const RESUME_HEADER_TEMPLATE = (turnCount) =>
|
|
|
30
32
|
`応答の冒頭でユーザーに「前の作業を ${turnCount} ターン分引き継ぎました」と報告してください。` +
|
|
31
33
|
`作業方針は前セッションのものを踏襲し、中断地点から自然に続行してください。`;
|
|
32
34
|
|
|
35
|
+
const ACTIVE_WORK_READING_CONTRACT =
|
|
36
|
+
`\n` +
|
|
37
|
+
`**読み方の契約:**\n` +
|
|
38
|
+
`- これは単なる過去ログではなく、現在進行中の作業を再開するための active work context です。\n` +
|
|
39
|
+
`- L2 は古い順に並んだ作業履歴です。後の発言・判断・TODO は前の仮説や作業方針を上書きし得ます。\n` +
|
|
40
|
+
`- すべての L2 行を現在も正しい事実として扱わず、最新の L2、in-flight メモ、最終ターン thinking を優先して現在状態を推定してください。\n` +
|
|
41
|
+
`- 不足している tool output / 詳細根拠が必要なときだけ、末尾の \`throughline detail <時刻>\` を使って L3 を取得してください。`;
|
|
42
|
+
|
|
43
|
+
const CONTINUATION_REMINDER =
|
|
44
|
+
'**再開指示:** 上記の L1 / L2 / thinking / in-flight メモを、現在タスクに使う作業コンテキストとして扱ってください。' +
|
|
45
|
+
'最新の L2、in-flight メモ、最終ターン thinking から次の一手を決め、中断地点から続行してください。' +
|
|
46
|
+
'古い仮説や未完了 TODO は、後続の判断で上書きされ得ます。';
|
|
47
|
+
|
|
33
48
|
const NORMAL_HEADER = '## Throughline: セッション記憶';
|
|
34
49
|
|
|
35
50
|
const FOOTER_GUIDE =
|
|
@@ -43,67 +58,6 @@ const FOOTER_GUIDE =
|
|
|
43
58
|
'返る内容: 指定ターンの L2 会話本文 + L3(tool_input / tool_output / system / thinking 別にグループ化)。\n' +
|
|
44
59
|
'ユーザーに「詳細を見せて」と言われた時だけでなく、**ユーザー発言の文脈が過去ターンに依存しているのに L1/L2 だけでは情報不足だと判断した時**に、Claude 自身の判断で呼び出して構いません。';
|
|
45
60
|
|
|
46
|
-
/**
|
|
47
|
-
* Unix ms を HH:MM:SS 形式に変換する。
|
|
48
|
-
*/
|
|
49
|
-
function formatTime(unixMs) {
|
|
50
|
-
const d = new Date(unixMs);
|
|
51
|
-
const hh = String(d.getHours()).padStart(2, '0');
|
|
52
|
-
const mm = String(d.getMinutes()).padStart(2, '0');
|
|
53
|
-
const ss = String(d.getSeconds()).padStart(2, '0');
|
|
54
|
-
return `${hh}:${mm}:${ss}`;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
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 }>}
|
|
65
|
-
*/
|
|
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
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
61
|
/**
|
|
108
62
|
* L1+L2 注入テキストを組み立てる。
|
|
109
63
|
*
|
|
@@ -120,107 +74,57 @@ export function buildResumeContext(
|
|
|
120
74
|
db,
|
|
121
75
|
{ sessionId, isInheritance, excludeOriginId = null, inflightMemo = null },
|
|
122
76
|
) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
WHERE session_id = ?
|
|
136
|
-
ORDER BY created_at DESC
|
|
137
|
-
LIMIT ?`;
|
|
138
|
-
|
|
139
|
-
const limitRows = N_RECENT_L2 * 2; // user/assistant の 2 ロール分
|
|
140
|
-
|
|
141
|
-
let bodyRowsDesc = [];
|
|
142
|
-
try {
|
|
143
|
-
bodyRowsDesc = hasExclude
|
|
144
|
-
? db.prepare(bodiesQuery).all(sessionId, excludeOriginId, limitRows)
|
|
145
|
-
: db.prepare(bodiesQuery).all(sessionId, limitRows);
|
|
146
|
-
} catch {
|
|
147
|
-
// bodies テーブル未作成(v3 DB)の場合は空
|
|
148
|
-
bodyRowsDesc = [];
|
|
149
|
-
}
|
|
150
|
-
const bodyRows = bodyRowsDesc.reverse(); // ASC に戻す
|
|
151
|
-
|
|
152
|
-
// 古い側の L1(bodies に既に含まれるターンを除いたもの)
|
|
153
|
-
const bodySet = new Set(
|
|
154
|
-
bodyRows.map((r) => `${r.origin_session_id}\x00${r.turn_number}`),
|
|
155
|
-
);
|
|
156
|
-
|
|
157
|
-
const skelQuery = hasExclude
|
|
158
|
-
? `SELECT origin_session_id, turn_number, role, summary, created_at
|
|
159
|
-
FROM skeletons
|
|
160
|
-
WHERE session_id = ? AND origin_session_id != ?
|
|
161
|
-
ORDER BY created_at ASC`
|
|
162
|
-
: `SELECT origin_session_id, turn_number, role, summary, created_at
|
|
163
|
-
FROM skeletons
|
|
164
|
-
WHERE session_id = ?
|
|
165
|
-
ORDER BY created_at ASC`;
|
|
166
|
-
|
|
167
|
-
const allSkel = hasExclude
|
|
168
|
-
? db.prepare(skelQuery).all(sessionId, excludeOriginId)
|
|
169
|
-
: db.prepare(skelQuery).all(sessionId);
|
|
170
|
-
|
|
171
|
-
const l1Rows = allSkel.filter(
|
|
172
|
-
(s) => !bodySet.has(`${s.origin_session_id}\x00${s.turn_number}`),
|
|
173
|
-
);
|
|
174
|
-
|
|
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
|
-
) {
|
|
183
|
-
return null;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const turnCount = bodyRows.length + l1Rows.length;
|
|
187
|
-
const header = isInheritance ? RESUME_HEADER_TEMPLATE(turnCount) : NORMAL_HEADER;
|
|
77
|
+
const record = buildHandoffRecord(db, {
|
|
78
|
+
sessionId,
|
|
79
|
+
isInheritance,
|
|
80
|
+
excludeOriginId,
|
|
81
|
+
inflightMemo,
|
|
82
|
+
});
|
|
83
|
+
if (!record) return null;
|
|
84
|
+
|
|
85
|
+
const turnCount = record.stats.preservedContextRows;
|
|
86
|
+
const header = isInheritance
|
|
87
|
+
? RESUME_HEADER_TEMPLATE(turnCount) + ACTIVE_WORK_READING_CONTRACT
|
|
88
|
+
: NORMAL_HEADER;
|
|
188
89
|
const lines = [header];
|
|
189
90
|
|
|
190
|
-
if (
|
|
91
|
+
if (record.memory.inflightMemo) {
|
|
191
92
|
lines.push('');
|
|
192
93
|
lines.push('### 中断直前の in-flight メモ(前セッションの Claude 自身による要約)');
|
|
193
|
-
lines.push(inflightMemo
|
|
94
|
+
lines.push(record.memory.inflightMemo);
|
|
194
95
|
}
|
|
195
96
|
|
|
196
|
-
if (
|
|
97
|
+
if (record.memory.latestThinking.length > 0) {
|
|
197
98
|
lines.push('');
|
|
198
99
|
lines.push('### 中断直前の思考 (最終ターンの extended thinking)');
|
|
199
|
-
for (const r of
|
|
200
|
-
lines.push(`[${
|
|
100
|
+
for (const r of record.memory.latestThinking) {
|
|
101
|
+
lines.push(`[${r.time}] ${r.text}`);
|
|
201
102
|
}
|
|
202
103
|
}
|
|
203
104
|
|
|
204
|
-
if (
|
|
105
|
+
if (record.memory.l1Summaries.length > 0) {
|
|
205
106
|
lines.push('');
|
|
206
107
|
lines.push('### それ以前の要約 (L1)');
|
|
207
|
-
for (const r of
|
|
108
|
+
for (const r of record.memory.l1Summaries) {
|
|
208
109
|
if (!r.summary || r.summary === '(no content)') continue;
|
|
209
|
-
lines.push(`[${
|
|
110
|
+
lines.push(`[${r.time}] ${r.summary.replace(/\n+/g, ' ').trim()}`);
|
|
210
111
|
}
|
|
211
112
|
}
|
|
212
113
|
|
|
213
|
-
if (
|
|
114
|
+
if (record.memory.recentBodies.length > 0) {
|
|
214
115
|
lines.push('');
|
|
215
|
-
lines.push('###
|
|
216
|
-
|
|
116
|
+
lines.push('### 現在進行中の作業履歴 (L2 / active work thread)');
|
|
117
|
+
lines.push('以下は古い順です。後の行ほど現在状態に近く、前の仮説を上書きし得ます。');
|
|
118
|
+
for (const r of record.memory.recentBodies) {
|
|
217
119
|
if (!r.text) continue;
|
|
218
|
-
lines.push(`[${
|
|
120
|
+
lines.push(`[${r.time}] [${r.role}]: ${r.text}`);
|
|
219
121
|
}
|
|
220
122
|
}
|
|
221
123
|
|
|
222
124
|
lines.push('');
|
|
223
125
|
lines.push(FOOTER_GUIDE);
|
|
126
|
+
lines.push('');
|
|
127
|
+
lines.push(CONTINUATION_REMINDER);
|
|
224
128
|
|
|
225
129
|
return lines.join('\n');
|
|
226
130
|
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { buildResumeContext } from './resume-context.mjs';
|
|
5
|
+
|
|
6
|
+
function makeDb() {
|
|
7
|
+
const db = new DatabaseSync(':memory:');
|
|
8
|
+
db.exec(`
|
|
9
|
+
CREATE TABLE skeletons (
|
|
10
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
11
|
+
session_id TEXT NOT NULL,
|
|
12
|
+
origin_session_id TEXT,
|
|
13
|
+
turn_number INTEGER NOT NULL,
|
|
14
|
+
role TEXT NOT NULL,
|
|
15
|
+
summary TEXT NOT NULL,
|
|
16
|
+
created_at INTEGER NOT NULL
|
|
17
|
+
);
|
|
18
|
+
CREATE TABLE bodies (
|
|
19
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
20
|
+
session_id TEXT NOT NULL,
|
|
21
|
+
origin_session_id TEXT NOT NULL,
|
|
22
|
+
turn_number INTEGER NOT NULL,
|
|
23
|
+
role TEXT NOT NULL,
|
|
24
|
+
text TEXT NOT NULL,
|
|
25
|
+
token_count INTEGER,
|
|
26
|
+
created_at INTEGER NOT NULL
|
|
27
|
+
);
|
|
28
|
+
CREATE TABLE details (
|
|
29
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
30
|
+
session_id TEXT NOT NULL,
|
|
31
|
+
origin_session_id TEXT,
|
|
32
|
+
turn_number INTEGER,
|
|
33
|
+
tool_name TEXT NOT NULL,
|
|
34
|
+
input_text TEXT,
|
|
35
|
+
output_text TEXT,
|
|
36
|
+
token_count INTEGER NOT NULL DEFAULT 0,
|
|
37
|
+
created_at INTEGER NOT NULL,
|
|
38
|
+
kind TEXT,
|
|
39
|
+
source_id TEXT
|
|
40
|
+
);
|
|
41
|
+
`);
|
|
42
|
+
return db;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function insertSkeleton(db, row) {
|
|
46
|
+
db.prepare(
|
|
47
|
+
`INSERT INTO skeletons (session_id, origin_session_id, turn_number, role, summary, created_at)
|
|
48
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
49
|
+
).run(row.session, row.origin, row.turn, row.role, row.summary, row.createdAt);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function insertBody(db, row) {
|
|
53
|
+
db.prepare(
|
|
54
|
+
`INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
|
|
55
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
56
|
+
).run(row.session, row.origin, row.turn, row.role, row.text, 1, row.createdAt);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function insertThinking(db, row) {
|
|
60
|
+
db.prepare(
|
|
61
|
+
`INSERT INTO details
|
|
62
|
+
(session_id, origin_session_id, turn_number, tool_name, input_text, output_text,
|
|
63
|
+
token_count, created_at, kind, source_id)
|
|
64
|
+
VALUES (?, ?, ?, 'thinking', NULL, ?, 1, ?, 'thinking', ?)`,
|
|
65
|
+
).run(row.session, row.origin, row.turn, row.text, row.createdAt, row.sourceId);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
test('buildResumeContext: inheritance output order is memo -> thinking -> L1 -> L2 -> footer', () => {
|
|
69
|
+
const db = makeDb();
|
|
70
|
+
|
|
71
|
+
insertSkeleton(db, {
|
|
72
|
+
session: 'new',
|
|
73
|
+
origin: 'old',
|
|
74
|
+
turn: 1,
|
|
75
|
+
role: 'assistant',
|
|
76
|
+
summary: 'older L1 summary',
|
|
77
|
+
createdAt: 1000,
|
|
78
|
+
});
|
|
79
|
+
insertBody(db, {
|
|
80
|
+
session: 'new',
|
|
81
|
+
origin: 'old',
|
|
82
|
+
turn: 2,
|
|
83
|
+
role: 'user',
|
|
84
|
+
text: 'recent user body',
|
|
85
|
+
createdAt: 2000,
|
|
86
|
+
});
|
|
87
|
+
insertBody(db, {
|
|
88
|
+
session: 'new',
|
|
89
|
+
origin: 'old',
|
|
90
|
+
turn: 2,
|
|
91
|
+
role: 'assistant',
|
|
92
|
+
text: 'recent assistant body',
|
|
93
|
+
createdAt: 2100,
|
|
94
|
+
});
|
|
95
|
+
insertThinking(db, {
|
|
96
|
+
session: 'new',
|
|
97
|
+
origin: 'old',
|
|
98
|
+
turn: 2,
|
|
99
|
+
text: 'latest thinking block',
|
|
100
|
+
createdAt: 2200,
|
|
101
|
+
sourceId: 'asst:thinking:0',
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const text = buildResumeContext(db, {
|
|
105
|
+
sessionId: 'new',
|
|
106
|
+
isInheritance: true,
|
|
107
|
+
inflightMemo: '**Next**: keep going',
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
assert.ok(text);
|
|
111
|
+
assert.match(text, /^## Throughline: 中断した作業の再開/);
|
|
112
|
+
|
|
113
|
+
const memoIdx = text.indexOf('### 中断直前の in-flight メモ');
|
|
114
|
+
const thinkingIdx = text.indexOf('### 中断直前の思考');
|
|
115
|
+
const l1Idx = text.indexOf('### それ以前の要約 (L1)');
|
|
116
|
+
const l2Idx = text.indexOf('### 現在進行中の作業履歴 (L2 / active work thread)');
|
|
117
|
+
const footerIdx = text.indexOf('**[Claude 向け — 記憶の使い方]**');
|
|
118
|
+
|
|
119
|
+
assert.ok(memoIdx > 0, 'in-flight memo section should be present');
|
|
120
|
+
assert.ok(thinkingIdx > memoIdx, 'thinking should follow in-flight memo');
|
|
121
|
+
assert.ok(l1Idx > thinkingIdx, 'L1 should follow thinking');
|
|
122
|
+
assert.ok(l2Idx > l1Idx, 'L2 should follow L1');
|
|
123
|
+
assert.ok(footerIdx > l2Idx, 'footer should follow L2');
|
|
124
|
+
|
|
125
|
+
assert.ok(text.includes('**Next**: keep going'));
|
|
126
|
+
assert.ok(text.includes('latest thinking block'));
|
|
127
|
+
assert.ok(text.includes('older L1 summary'));
|
|
128
|
+
assert.ok(text.includes('[user]: recent user body'));
|
|
129
|
+
assert.ok(text.includes('[assistant]: recent assistant body'));
|
|
130
|
+
assert.match(text, /単なる過去ログではなく、現在進行中の作業/);
|
|
131
|
+
assert.match(text, /後の行ほど現在状態に近く/);
|
|
132
|
+
assert.match(text, /上記の L1 \/ L2 \/ thinking \/ in-flight メモを、現在タスクに使う作業コンテキスト/);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('buildResumeContext: returns null when no memory rows or inflight memo exist', () => {
|
|
136
|
+
const db = makeDb();
|
|
137
|
+
assert.equal(
|
|
138
|
+
buildResumeContext(db, { sessionId: 'empty', isInheritance: true }),
|
|
139
|
+
null,
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('buildResumeContext: excludeOriginId omits rows from the current origin', () => {
|
|
144
|
+
const db = makeDb();
|
|
145
|
+
|
|
146
|
+
insertBody(db, {
|
|
147
|
+
session: 'new',
|
|
148
|
+
origin: 'old',
|
|
149
|
+
turn: 1,
|
|
150
|
+
role: 'assistant',
|
|
151
|
+
text: 'old origin body',
|
|
152
|
+
createdAt: 1000,
|
|
153
|
+
});
|
|
154
|
+
insertBody(db, {
|
|
155
|
+
session: 'new',
|
|
156
|
+
origin: 'new',
|
|
157
|
+
turn: 1,
|
|
158
|
+
role: 'assistant',
|
|
159
|
+
text: 'current origin body',
|
|
160
|
+
createdAt: 2000,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const text = buildResumeContext(db, {
|
|
164
|
+
sessionId: 'new',
|
|
165
|
+
isInheritance: false,
|
|
166
|
+
excludeOriginId: 'new',
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
assert.ok(text);
|
|
170
|
+
assert.ok(text.includes('old origin body'));
|
|
171
|
+
assert.ok(!text.includes('current origin body'));
|
|
172
|
+
});
|
package/src/session-start.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import { ensureMonitorTaskFile } from './vscode-task.mjs';
|
|
|
25
25
|
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
26
26
|
import { join, dirname } from 'node:path';
|
|
27
27
|
import { homedir } from 'node:os';
|
|
28
|
+
import { pathToFileURL } from 'node:url';
|
|
28
29
|
|
|
29
30
|
function logDecision(entry) {
|
|
30
31
|
const path = join(homedir(), '.throughline', 'logs', 'inheritance-decision.log');
|
|
@@ -37,7 +38,7 @@ function logDecision(entry) {
|
|
|
37
38
|
}
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
async function
|
|
41
|
+
export async function run() {
|
|
41
42
|
let raw = '';
|
|
42
43
|
await new Promise((resolve) => {
|
|
43
44
|
process.stdin.setEncoding('utf8');
|
|
@@ -118,7 +119,9 @@ async function main() {
|
|
|
118
119
|
process.exit(0);
|
|
119
120
|
}
|
|
120
121
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
122
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
123
|
+
run().catch((err) => {
|
|
124
|
+
process.stderr.write(`[session-start] error: ${err.message}\n`);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
});
|
|
127
|
+
}
|
package/src/state-file.mjs
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
* state-file.mjs — セッション単位の状態ファイル管理(共有モジュール)
|
|
3
3
|
*
|
|
4
4
|
* パス: ~/.throughline/state/<session_id>.json
|
|
5
|
-
* 書き手: turn-processor (Stop)
|
|
5
|
+
* 書き手: turn-processor (Claude Stop), codex-hook (Codex Stop)
|
|
6
6
|
* 読み手: token-monitor
|
|
7
7
|
*
|
|
8
8
|
* 設計判断 (docs/PUBLIC_RELEASE_PLAN.md §4.5/4.6):
|
|
9
9
|
* - ファイル単位分割で last-writer-wins 問題を解消
|
|
10
|
-
* -
|
|
10
|
+
* - updatedAt ベースで stale 判定(短命 hook process の PID には依存しない)
|
|
11
11
|
* - projectPath は path.resolve → / → 末尾 / 除去 → Windows lowercase で正規化
|
|
12
12
|
*/
|
|
13
13
|
|
|
@@ -37,21 +37,43 @@ export function normalizeProjectPath(p) {
|
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
39
|
* セッション状態ファイルを書く
|
|
40
|
-
* @param {{
|
|
40
|
+
* @param {{
|
|
41
|
+
* sessionId: string,
|
|
42
|
+
* projectPath: string,
|
|
43
|
+
* transcriptPath?: string|null,
|
|
44
|
+
* rolloutPath?: string|null,
|
|
45
|
+
* pid?: number,
|
|
46
|
+
* usage?: object|null,
|
|
47
|
+
* host?: 'claude'|'codex',
|
|
48
|
+
* }} data
|
|
41
49
|
*
|
|
42
50
|
* usage: monitor が表示する tokens/model/contextWindowSize をここに固定保存する。
|
|
43
51
|
* Stop hook が readLatestUsage の結果を載せることで、monitor 側が毎フレーム JSONL を
|
|
44
52
|
* 再スキャンする必要がなくなる。旧バージョン互換のため optional (無ければ monitor が
|
|
45
53
|
* transcriptPath を読んでフォールバック)。
|
|
46
54
|
*/
|
|
47
|
-
export function writeSessionState({
|
|
55
|
+
export function writeSessionState({
|
|
56
|
+
sessionId,
|
|
57
|
+
projectPath,
|
|
58
|
+
transcriptPath,
|
|
59
|
+
rolloutPath,
|
|
60
|
+
pid,
|
|
61
|
+
usage,
|
|
62
|
+
host,
|
|
63
|
+
}) {
|
|
48
64
|
if (!sessionId) throw new Error('writeSessionState: sessionId is required');
|
|
65
|
+
const normalizedHost = normalizeHost(host);
|
|
66
|
+
if (host && normalizedHost === 'unknown') {
|
|
67
|
+
throw new Error(`writeSessionState: unsupported host ${host}`);
|
|
68
|
+
}
|
|
49
69
|
if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true });
|
|
50
|
-
const file = join(STATE_DIR,
|
|
70
|
+
const file = join(STATE_DIR, stateFilename(sessionId));
|
|
51
71
|
const payload = {
|
|
52
72
|
sessionId,
|
|
73
|
+
host: normalizedHost === 'unknown' ? 'claude' : normalizedHost,
|
|
53
74
|
projectPath: normalizeProjectPath(projectPath),
|
|
54
75
|
transcriptPath: transcriptPath ?? null,
|
|
76
|
+
rolloutPath: rolloutPath ?? null,
|
|
55
77
|
pid: pid ?? process.pid,
|
|
56
78
|
updatedAt: Date.now(),
|
|
57
79
|
};
|
|
@@ -69,7 +91,7 @@ export const STALE_DELETE_MS = 24 * 60 * 60 * 1000; // 24 時間: ファイル
|
|
|
69
91
|
/**
|
|
70
92
|
* 全セッション状態を読む。24 時間超のファイルは削除、壊れたファイルも削除する。
|
|
71
93
|
* 15 分超のファイルは「stale」フラグを付けて返す(monitor 側で隠す判断をする)。
|
|
72
|
-
* @returns {Array<{sessionId: string, projectPath: string, transcriptPath: string|null, updatedAt: number, stale: boolean}>}
|
|
94
|
+
* @returns {Array<{sessionId: string, host: string, projectPath: string, transcriptPath: string|null, rolloutPath: string|null, updatedAt: number, stale: boolean}>}
|
|
73
95
|
*/
|
|
74
96
|
export function readAllSessionStates() {
|
|
75
97
|
if (!existsSync(STATE_DIR)) return [];
|
|
@@ -112,6 +134,7 @@ export function readAllSessionStates() {
|
|
|
112
134
|
}
|
|
113
135
|
continue;
|
|
114
136
|
}
|
|
137
|
+
parsed = normalizeState(parsed);
|
|
115
138
|
const age = now - (parsed.updatedAt ?? 0);
|
|
116
139
|
if (age > STALE_DELETE_MS) {
|
|
117
140
|
// 24h 超: ハード削除(無制限蓄積防止)
|
|
@@ -128,6 +151,27 @@ export function readAllSessionStates() {
|
|
|
128
151
|
return results;
|
|
129
152
|
}
|
|
130
153
|
|
|
154
|
+
function stateFilename(sessionId) {
|
|
155
|
+
return `${encodeURIComponent(sessionId)}.json`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function normalizeHost(host) {
|
|
159
|
+
if (host === undefined || host === null || host === '') return 'claude';
|
|
160
|
+
if (host === 'claude' || host === 'codex') return host;
|
|
161
|
+
return 'unknown';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function normalizeState(parsed) {
|
|
165
|
+
const host = normalizeHost(parsed?.host);
|
|
166
|
+
return {
|
|
167
|
+
...parsed,
|
|
168
|
+
host,
|
|
169
|
+
projectPath: normalizeProjectPath(parsed?.projectPath ?? ''),
|
|
170
|
+
transcriptPath: parsed?.transcriptPath ?? null,
|
|
171
|
+
rolloutPath: parsed?.rolloutPath ?? null,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
131
175
|
/**
|
|
132
176
|
* ファイル単位の mtime スナップショットを取る(差分検知用)
|
|
133
177
|
* @returns {Map<string, number>}
|
package/src/state-file.test.mjs
CHANGED
|
@@ -146,11 +146,58 @@ test('writeSessionState: usage 付きで書くと JSON に含まれる', async (
|
|
|
146
146
|
const results = mod.readAllSessionStates();
|
|
147
147
|
assert.equal(results.length, 1);
|
|
148
148
|
assert.ok(results[0].usage);
|
|
149
|
+
assert.equal(results[0].host, 'claude');
|
|
150
|
+
assert.equal(results[0].rolloutPath, null);
|
|
149
151
|
assert.equal(results[0].usage.tokens, 123);
|
|
150
152
|
assert.equal(results[0].usage.model, 'claude-opus-4-6');
|
|
151
153
|
});
|
|
152
154
|
});
|
|
153
155
|
|
|
156
|
+
test('writeSessionState: Codex state は host と rolloutPath を保持しファイル名を encode する', async () => {
|
|
157
|
+
await withIsolatedStateDir(async ({ stateDir, mod }) => {
|
|
158
|
+
mod.writeSessionState({
|
|
159
|
+
sessionId: 'codex:019dfaba-thread',
|
|
160
|
+
host: 'codex',
|
|
161
|
+
projectPath: '/tmp/x',
|
|
162
|
+
transcriptPath: null,
|
|
163
|
+
rolloutPath: '/tmp/codex/rollout.jsonl',
|
|
164
|
+
pid: 1,
|
|
165
|
+
usage: {
|
|
166
|
+
tokens: 123,
|
|
167
|
+
model: 'codex',
|
|
168
|
+
contextWindowSize: 258400,
|
|
169
|
+
contextWindowEstimated: false,
|
|
170
|
+
outputTokens: 10,
|
|
171
|
+
estimated: false,
|
|
172
|
+
source: 'codex-rollout-token-count',
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
assert.deepEqual(readdirSync(stateDir), ['codex%3A019dfaba-thread.json']);
|
|
177
|
+
const results = mod.readAllSessionStates();
|
|
178
|
+
assert.equal(results.length, 1);
|
|
179
|
+
assert.equal(results[0].sessionId, 'codex:019dfaba-thread');
|
|
180
|
+
assert.equal(results[0].host, 'codex');
|
|
181
|
+
assert.equal(results[0].transcriptPath, null);
|
|
182
|
+
assert.equal(results[0].rolloutPath, '/tmp/codex/rollout.jsonl');
|
|
183
|
+
assert.equal(results[0].usage.source, 'codex-rollout-token-count');
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('writeSessionState: unsupported host は throw する', async () => {
|
|
188
|
+
await withIsolatedStateDir(async ({ mod }) => {
|
|
189
|
+
assert.throws(
|
|
190
|
+
() =>
|
|
191
|
+
mod.writeSessionState({
|
|
192
|
+
sessionId: 'sess-bad-host',
|
|
193
|
+
host: 'unknown-host',
|
|
194
|
+
projectPath: '/tmp/x',
|
|
195
|
+
}),
|
|
196
|
+
/unsupported host/,
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
154
201
|
test('writeSessionState: usage 無しで書いたらフィールド自体が無い (旧フォーマット互換)', async () => {
|
|
155
202
|
await withIsolatedStateDir(async ({ stateDir, mod }) => {
|
|
156
203
|
mod.writeSessionState({
|
|
@@ -162,6 +209,7 @@ test('writeSessionState: usage 無しで書いたらフィールド自体が無
|
|
|
162
209
|
const results = mod.readAllSessionStates();
|
|
163
210
|
assert.equal(results.length, 1);
|
|
164
211
|
assert.equal(results[0].usage, undefined);
|
|
212
|
+
assert.equal(results[0].host, 'claude');
|
|
165
213
|
});
|
|
166
214
|
});
|
|
167
215
|
|
|
@@ -178,6 +226,8 @@ test('readAllSessionStates: 旧バージョンが書いた usage 無しの state
|
|
|
178
226
|
}));
|
|
179
227
|
const results = mod.readAllSessionStates();
|
|
180
228
|
assert.equal(results.length, 1);
|
|
229
|
+
assert.equal(results[0].host, 'claude');
|
|
230
|
+
assert.equal(results[0].rolloutPath, null);
|
|
181
231
|
assert.equal(results[0].usage, undefined);
|
|
182
232
|
// usage 無しで読めること自体が互換性の証明
|
|
183
233
|
});
|