throughline 0.1.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/LICENSE +21 -0
- package/README.md +329 -0
- package/bin/throughline.mjs +78 -0
- package/package.json +33 -0
- package/src/cli/doctor.mjs +98 -0
- package/src/cli/install.mjs +109 -0
- package/src/cli/status.mjs +41 -0
- package/src/constants.mjs +16 -0
- package/src/db.mjs +201 -0
- package/src/haiku-summarizer.mjs +100 -0
- package/src/resume-context.mjs +148 -0
- package/src/sc-detail.mjs +212 -0
- package/src/session-merger.mjs +127 -0
- package/src/session-merger.test.mjs +151 -0
- package/src/session-start.mjs +67 -0
- package/src/state-file.mjs +117 -0
- package/src/token-estimator.mjs +16 -0
- package/src/token-monitor.mjs +237 -0
- package/src/transcript-reader.mjs +364 -0
- package/src/transcript-reader.test.mjs +292 -0
- package/src/transcript-usage.mjs +128 -0
- package/src/turn-processor.mjs +272 -0
- package/src/turn-processor.test.mjs +155 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* throughline status — DB 統計表示
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { getDb } from '../db.mjs';
|
|
6
|
+
|
|
7
|
+
export async function run() {
|
|
8
|
+
const db = getDb();
|
|
9
|
+
|
|
10
|
+
const sessions = db.prepare('SELECT COUNT(*) as count FROM sessions').get();
|
|
11
|
+
const skeletons = db.prepare('SELECT COUNT(*) as count FROM skeletons').get();
|
|
12
|
+
let bodies = { count: 0 };
|
|
13
|
+
try {
|
|
14
|
+
bodies = db.prepare('SELECT COUNT(*) as count FROM bodies').get();
|
|
15
|
+
} catch {
|
|
16
|
+
// bodies テーブルは schema v4 以降。v3 DB では存在しない
|
|
17
|
+
}
|
|
18
|
+
const details = db.prepare('SELECT COUNT(*) as count FROM details').get();
|
|
19
|
+
|
|
20
|
+
const recentSessions = db.prepare(`
|
|
21
|
+
SELECT session_id, project_path, updated_at
|
|
22
|
+
FROM sessions
|
|
23
|
+
ORDER BY updated_at DESC
|
|
24
|
+
LIMIT 5
|
|
25
|
+
`).all();
|
|
26
|
+
|
|
27
|
+
console.log('throughline status\n');
|
|
28
|
+
console.log(` sessions : ${sessions.count}`);
|
|
29
|
+
console.log(` skeletons : ${skeletons.count} (L1)`);
|
|
30
|
+
console.log(` bodies : ${bodies.count} (L2)`);
|
|
31
|
+
console.log(` details : ${details.count} (L3)`);
|
|
32
|
+
console.log('');
|
|
33
|
+
console.log('最近のセッション:');
|
|
34
|
+
for (const s of recentSessions) {
|
|
35
|
+
const date = new Date(s.updated_at).toLocaleString('ja-JP');
|
|
36
|
+
const shortId = s.session_id.slice(0, 8);
|
|
37
|
+
const project = s.project_path.split(/[/\\]/).pop();
|
|
38
|
+
console.log(` ${shortId} ${project} ${date}`);
|
|
39
|
+
}
|
|
40
|
+
console.log('');
|
|
41
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 共有定数。
|
|
3
|
+
* - 複数モジュールが同じ文字列リテラルで分岐する値はここに集約する。
|
|
4
|
+
* - 値は SQL に書き込む列値とも一致させる(schema v5 details.kind)。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** L3 (details テーブル) の kind 列取り得る値 */
|
|
8
|
+
export const DETAIL_KIND = Object.freeze({
|
|
9
|
+
TOOL_INPUT: 'tool_input',
|
|
10
|
+
TOOL_OUTPUT: 'tool_output',
|
|
11
|
+
SYSTEM: 'system',
|
|
12
|
+
IMAGE: 'image',
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/** 上記の値すべての Set(未知値判定に使う) */
|
|
16
|
+
export const DETAIL_KIND_VALUES = new Set(Object.values(DETAIL_KIND));
|
package/src/db.mjs
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite 接続管理 — node:sqlite (Node.js v22.5+ 組み込み、依存ゼロ)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
6
|
+
import { mkdirSync } from 'fs';
|
|
7
|
+
import { homedir } from 'os';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
|
|
10
|
+
const DB_DIR = join(homedir(), '.throughline');
|
|
11
|
+
const DB_PATH = join(DB_DIR, 'throughline.db');
|
|
12
|
+
const CURRENT_VERSION = 5;
|
|
13
|
+
|
|
14
|
+
let _db = null;
|
|
15
|
+
|
|
16
|
+
function initSchema(db) {
|
|
17
|
+
const row = db.prepare('PRAGMA user_version').get();
|
|
18
|
+
const version = row.user_version ?? 0;
|
|
19
|
+
|
|
20
|
+
// v0 → v1: 全テーブル作成
|
|
21
|
+
if (version < 1) {
|
|
22
|
+
db.exec(`
|
|
23
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
24
|
+
session_id TEXT PRIMARY KEY,
|
|
25
|
+
project_path TEXT NOT NULL,
|
|
26
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
27
|
+
created_at INTEGER NOT NULL,
|
|
28
|
+
updated_at INTEGER NOT NULL
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
CREATE TABLE IF NOT EXISTS skeletons (
|
|
32
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
33
|
+
session_id TEXT NOT NULL,
|
|
34
|
+
turn_number INTEGER NOT NULL,
|
|
35
|
+
role TEXT NOT NULL,
|
|
36
|
+
summary TEXT NOT NULL,
|
|
37
|
+
created_at INTEGER NOT NULL
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
CREATE TABLE IF NOT EXISTS judgments (
|
|
41
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
42
|
+
session_id TEXT NOT NULL,
|
|
43
|
+
turn_number INTEGER NOT NULL,
|
|
44
|
+
category TEXT NOT NULL,
|
|
45
|
+
content TEXT NOT NULL,
|
|
46
|
+
content_hash TEXT NOT NULL,
|
|
47
|
+
resolved INTEGER NOT NULL DEFAULT 0,
|
|
48
|
+
created_at INTEGER NOT NULL
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
CREATE TABLE IF NOT EXISTS details (
|
|
52
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
53
|
+
session_id TEXT NOT NULL,
|
|
54
|
+
turn_number INTEGER,
|
|
55
|
+
tool_name TEXT NOT NULL,
|
|
56
|
+
input_text TEXT,
|
|
57
|
+
output_text TEXT,
|
|
58
|
+
token_count INTEGER NOT NULL DEFAULT 0,
|
|
59
|
+
created_at INTEGER NOT NULL
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
CREATE TABLE IF NOT EXISTS injection_log (
|
|
63
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
64
|
+
session_id TEXT NOT NULL,
|
|
65
|
+
event_type TEXT NOT NULL,
|
|
66
|
+
turns_injected INTEGER NOT NULL DEFAULT 0,
|
|
67
|
+
tokens_saved INTEGER NOT NULL DEFAULT 0,
|
|
68
|
+
created_at INTEGER NOT NULL
|
|
69
|
+
);
|
|
70
|
+
`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// v1 → v2: 重複排除用 UNIQUE インデックス追加
|
|
74
|
+
if (version < 2) {
|
|
75
|
+
// 先に既存の重複行を削除してからインデックスを作成
|
|
76
|
+
db.exec(`
|
|
77
|
+
DELETE FROM skeletons WHERE id NOT IN (
|
|
78
|
+
SELECT MIN(id) FROM skeletons GROUP BY session_id, turn_number, role
|
|
79
|
+
);
|
|
80
|
+
DELETE FROM judgments WHERE id NOT IN (
|
|
81
|
+
SELECT MIN(id) FROM judgments GROUP BY session_id, content_hash
|
|
82
|
+
);
|
|
83
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uq_skeletons_turn
|
|
84
|
+
ON skeletons(session_id, turn_number, role);
|
|
85
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uq_judgments_hash
|
|
86
|
+
ON judgments(session_id, content_hash);
|
|
87
|
+
`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// v2 → v3: 記憶張り替え方式のための origin_session_id / merged_into 列追加
|
|
91
|
+
if (version < 3) {
|
|
92
|
+
// origin_session_id 列追加(デフォルト NULL、後続 UPDATE で自身の session_id をセット)
|
|
93
|
+
const skeletonCols = db.prepare('PRAGMA table_info(skeletons)').all();
|
|
94
|
+
if (!skeletonCols.some((c) => c.name === 'origin_session_id')) {
|
|
95
|
+
db.exec('ALTER TABLE skeletons ADD COLUMN origin_session_id TEXT');
|
|
96
|
+
}
|
|
97
|
+
const judgmentCols = db.prepare('PRAGMA table_info(judgments)').all();
|
|
98
|
+
if (!judgmentCols.some((c) => c.name === 'origin_session_id')) {
|
|
99
|
+
db.exec('ALTER TABLE judgments ADD COLUMN origin_session_id TEXT');
|
|
100
|
+
}
|
|
101
|
+
const detailCols = db.prepare('PRAGMA table_info(details)').all();
|
|
102
|
+
if (!detailCols.some((c) => c.name === 'origin_session_id')) {
|
|
103
|
+
db.exec('ALTER TABLE details ADD COLUMN origin_session_id TEXT');
|
|
104
|
+
}
|
|
105
|
+
const sessionCols = db.prepare('PRAGMA table_info(sessions)').all();
|
|
106
|
+
if (!sessionCols.some((c) => c.name === 'merged_into')) {
|
|
107
|
+
db.exec('ALTER TABLE sessions ADD COLUMN merged_into TEXT');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 既存行の origin_session_id に自身の session_id をセット
|
|
111
|
+
db.exec(`
|
|
112
|
+
UPDATE skeletons SET origin_session_id = session_id WHERE origin_session_id IS NULL;
|
|
113
|
+
UPDATE judgments SET origin_session_id = session_id WHERE origin_session_id IS NULL;
|
|
114
|
+
UPDATE details SET origin_session_id = session_id WHERE origin_session_id IS NULL;
|
|
115
|
+
`);
|
|
116
|
+
|
|
117
|
+
// 旧 UNIQUE インデックス drop + 新 UNIQUE インデックス作成(origin_session_id を含む)
|
|
118
|
+
db.exec(`
|
|
119
|
+
DROP INDEX IF EXISTS uq_skeletons_turn;
|
|
120
|
+
DROP INDEX IF EXISTS uq_judgments_hash;
|
|
121
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uq_skeletons_turn_v3
|
|
122
|
+
ON skeletons(session_id, origin_session_id, turn_number, role);
|
|
123
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uq_judgments_hash_v3
|
|
124
|
+
ON judgments(session_id, origin_session_id, content_hash);
|
|
125
|
+
CREATE INDEX IF NOT EXISTS idx_skeletons_session
|
|
126
|
+
ON skeletons(session_id, created_at);
|
|
127
|
+
CREATE INDEX IF NOT EXISTS idx_judgments_session
|
|
128
|
+
ON judgments(session_id, resolved, created_at);
|
|
129
|
+
`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// v3 → v4: bodies テーブル追加(L2 = 会話自然言語ロスレス保存)、judgments DROP
|
|
133
|
+
if (version < 4) {
|
|
134
|
+
db.exec(`
|
|
135
|
+
CREATE TABLE IF NOT EXISTS bodies (
|
|
136
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
137
|
+
session_id TEXT NOT NULL,
|
|
138
|
+
origin_session_id TEXT NOT NULL,
|
|
139
|
+
turn_number INTEGER NOT NULL,
|
|
140
|
+
role TEXT NOT NULL,
|
|
141
|
+
text TEXT NOT NULL,
|
|
142
|
+
token_count INTEGER,
|
|
143
|
+
created_at INTEGER NOT NULL,
|
|
144
|
+
UNIQUE(session_id, origin_session_id, turn_number, role)
|
|
145
|
+
);
|
|
146
|
+
CREATE INDEX IF NOT EXISTS idx_bodies_session_created
|
|
147
|
+
ON bodies(session_id, created_at);
|
|
148
|
+
`);
|
|
149
|
+
|
|
150
|
+
// judgments テーブルと関連インデックスを DROP
|
|
151
|
+
db.exec(`
|
|
152
|
+
DROP INDEX IF EXISTS uq_judgments_hash_v3;
|
|
153
|
+
DROP INDEX IF EXISTS uq_judgments_hash;
|
|
154
|
+
DROP INDEX IF EXISTS idx_judgments_session;
|
|
155
|
+
DROP TABLE IF EXISTS judgments;
|
|
156
|
+
`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// v4 → v5: details テーブルに kind / source_id 列追加(L3 分離書き込み対応)
|
|
160
|
+
// - kind: 'tool_input' | 'tool_output' | 'system' | 'image'
|
|
161
|
+
// - source_id: transcript の一意 ID (tool_use.id, attachment.uuid 等)、冪等再処理のため
|
|
162
|
+
// - 既存行は kind='tool_input' (デフォルト)、source_id NULL
|
|
163
|
+
if (version < 5) {
|
|
164
|
+
const detailCols = db.prepare('PRAGMA table_info(details)').all();
|
|
165
|
+
if (!detailCols.some((c) => c.name === 'kind')) {
|
|
166
|
+
db.exec("ALTER TABLE details ADD COLUMN kind TEXT NOT NULL DEFAULT 'tool_input'");
|
|
167
|
+
}
|
|
168
|
+
if (!detailCols.some((c) => c.name === 'source_id')) {
|
|
169
|
+
db.exec('ALTER TABLE details ADD COLUMN source_id TEXT');
|
|
170
|
+
}
|
|
171
|
+
db.exec(`
|
|
172
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uq_details_source
|
|
173
|
+
ON details(session_id, origin_session_id, source_id)
|
|
174
|
+
WHERE source_id IS NOT NULL;
|
|
175
|
+
CREATE INDEX IF NOT EXISTS idx_details_session_kind
|
|
176
|
+
ON details(session_id, kind, created_at);
|
|
177
|
+
`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (version < CURRENT_VERSION) {
|
|
181
|
+
db.exec(`PRAGMA user_version = ${CURRENT_VERSION}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* DB インスタンスを返す(シングルトン)
|
|
187
|
+
* @returns {DatabaseSync}
|
|
188
|
+
*/
|
|
189
|
+
export function getDb() {
|
|
190
|
+
if (_db) return _db;
|
|
191
|
+
|
|
192
|
+
mkdirSync(DB_DIR, { recursive: true });
|
|
193
|
+
|
|
194
|
+
_db = new DatabaseSync(DB_PATH);
|
|
195
|
+
_db.exec('PRAGMA journal_mode = WAL');
|
|
196
|
+
_db.exec('PRAGMA foreign_keys = ON');
|
|
197
|
+
|
|
198
|
+
initSchema(_db);
|
|
199
|
+
|
|
200
|
+
return _db;
|
|
201
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* haiku-summarizer.mjs — Claude Haiku 4.5 を使った同期 L1 要約生成
|
|
3
|
+
*
|
|
4
|
+
* 呼び出し経路: Claude Max 契約前提。`claude -p --model claude-haiku-4-5-20251001`
|
|
5
|
+
* を子プロセス起動する。Anthropic API キーは使わない(Claude Code CLI が
|
|
6
|
+
* Max 契約の認証を持っている前提)。
|
|
7
|
+
*
|
|
8
|
+
* 【再帰暴走の根本対策: 隔離 cwd で spawn】
|
|
9
|
+
* 素朴に `claude -p` を spawn すると subprocess が同じ .claude/settings.json を
|
|
10
|
+
* 読んで Throughline の Stop hook を起動し、無限再帰になる。
|
|
11
|
+
*
|
|
12
|
+
* これを物理的に不可能にするため、subprocess は Throughline の project-local
|
|
13
|
+
* 設定が見つからない空ディレクトリ(~/.throughline/haiku-workdir/)を cwd に
|
|
14
|
+
* して起動する。Claude Code は cwd 起点で .claude/settings.json を探すので、
|
|
15
|
+
* project-local 設定はロードされない。global (~/.claude/settings.json) のみ
|
|
16
|
+
* 適用されるが、そこに Throughline hook は置かれない運用前提。
|
|
17
|
+
*
|
|
18
|
+
* 複数プロジェクト・複数セッションで並列実行しても互いに干渉しない(各呼び出し
|
|
19
|
+
* は独立した subprocess、ロックなし)。
|
|
20
|
+
*
|
|
21
|
+
* さらに三重防御として env var THROUGHLINE_IN_HAIKU_SUBPROCESS=1 も設定する。
|
|
22
|
+
* 万一 global に Throughline hook が紛れ込んでも turn-processor 冒頭で exit する。
|
|
23
|
+
*
|
|
24
|
+
* 失敗時のポリシー:
|
|
25
|
+
* 1. 2 回までリトライ
|
|
26
|
+
* 2. それでも失敗したら L2 全文を L1 に入れる(情報欠損ゼロ)
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { spawnSync } from 'child_process';
|
|
30
|
+
import { mkdirSync } from 'fs';
|
|
31
|
+
import { join } from 'path';
|
|
32
|
+
import { homedir } from 'os';
|
|
33
|
+
|
|
34
|
+
const MODEL = 'claude-haiku-4-5-20251001';
|
|
35
|
+
const MAX_RETRIES = 2;
|
|
36
|
+
const TIMEOUT_MS = 30_000;
|
|
37
|
+
const RECURSION_GUARD_ENV = 'THROUGHLINE_IN_HAIKU_SUBPROCESS';
|
|
38
|
+
|
|
39
|
+
// 隔離 cwd: Throughline project-local 設定が見つからない空ディレクトリ
|
|
40
|
+
const HAIKU_WORKDIR = join(homedir(), '.throughline', 'haiku-workdir');
|
|
41
|
+
|
|
42
|
+
function ensureWorkdir() {
|
|
43
|
+
try {
|
|
44
|
+
mkdirSync(HAIKU_WORKDIR, { recursive: true });
|
|
45
|
+
} catch {
|
|
46
|
+
// ignore
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* L2 本文を約 1/5 に要約する。
|
|
52
|
+
* @param {string} l2Text ターンの会話本文(user+assistant を適当な形式で結合した文字列)
|
|
53
|
+
* @returns {{ summary: string, fromFallback: boolean }}
|
|
54
|
+
*/
|
|
55
|
+
export function summarizeToL1(l2Text) {
|
|
56
|
+
if (!l2Text || !l2Text.trim()) {
|
|
57
|
+
return { summary: '(no content)', fromFallback: true };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 防御(念のため): 自分自身が Haiku subprocess 内で呼ばれていたら再帰せず即フォールバック
|
|
61
|
+
if (process.env[RECURSION_GUARD_ENV] === '1') {
|
|
62
|
+
return { summary: l2Text, fromFallback: true };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const targetChars = Math.max(20, Math.round(l2Text.length / 5));
|
|
66
|
+
const prompt =
|
|
67
|
+
`次の日本語テキストを約${targetChars}文字に要約してください。` +
|
|
68
|
+
`固有名詞・数値・因果関係を優先して残し、枝葉は落としてください。` +
|
|
69
|
+
`要約文だけを出力し、前置きや説明は不要です。`;
|
|
70
|
+
|
|
71
|
+
// child_process に渡す env: 親の env を継承しつつ再帰ガードをセット
|
|
72
|
+
const childEnv = { ...process.env, [RECURSION_GUARD_ENV]: '1' };
|
|
73
|
+
|
|
74
|
+
// 隔離 cwd を準備(project-local .claude/settings.json が見えない場所)
|
|
75
|
+
ensureWorkdir();
|
|
76
|
+
|
|
77
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
78
|
+
try {
|
|
79
|
+
const result = spawnSync('claude', ['-p', '--model', MODEL, prompt], {
|
|
80
|
+
input: l2Text,
|
|
81
|
+
encoding: 'utf8',
|
|
82
|
+
timeout: TIMEOUT_MS,
|
|
83
|
+
shell: process.platform === 'win32', // Windows は claude.cmd ラッパー
|
|
84
|
+
env: childEnv,
|
|
85
|
+
cwd: HAIKU_WORKDIR, // ← これが再帰防止の本丸
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (result.status === 0 && result.stdout) {
|
|
89
|
+
const summary = result.stdout.trim();
|
|
90
|
+
if (summary) return { summary, fromFallback: false };
|
|
91
|
+
}
|
|
92
|
+
// status != 0 や空出力は失敗とみなしてリトライ
|
|
93
|
+
} catch {
|
|
94
|
+
// spawn 失敗 (ENOENT 等) もリトライ
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 全リトライ失敗 → L2 全文をそのまま L1 に(情報欠損ゼロ)
|
|
99
|
+
return { summary: l2Text, fromFallback: true };
|
|
100
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* resume-context.mjs — L1+L2 の再注入テキストを組み立てる共有モジュール
|
|
3
|
+
*
|
|
4
|
+
* 呼び出し元:
|
|
5
|
+
* - session-start.mjs (isInheritance=true, 引き継ぎヘッダ)
|
|
6
|
+
*
|
|
7
|
+
* 新設計(schema v4):
|
|
8
|
+
* - 直近 N=20 ターンは bodies から L2 全文を注入
|
|
9
|
+
* - それ以前は skeletons から L1 要約のみ注入
|
|
10
|
+
* - 各行頭に [HH:MM:SS] 時刻プレフィックス(bodies.created_at ベース、DB 永続)
|
|
11
|
+
* - 末尾に /sc-detail <時刻> ガイドを追記
|
|
12
|
+
* - judgments セクションは廃止
|
|
13
|
+
* - 現セッションのターンは注入しない(Claude Code 本体のコンテキストに既にあるため)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const N_RECENT_L2 = 20;
|
|
17
|
+
|
|
18
|
+
const RESUME_HEADER_TEMPLATE = (turnCount) =>
|
|
19
|
+
`## Throughline: セッション記憶(${turnCount} ターン引き継ぎ)\n` +
|
|
20
|
+
`**[Throughline] 前セッションの記憶を引き継ぎました。応答の冒頭で「前の記憶を ${turnCount} ターン引き継ぎました」とユーザーに報告してください。**`;
|
|
21
|
+
|
|
22
|
+
const NORMAL_HEADER = '## Throughline: セッション記憶';
|
|
23
|
+
|
|
24
|
+
const FOOTER_GUIDE =
|
|
25
|
+
'---\n' +
|
|
26
|
+
'**[Claude 向け — 記憶の使い方]**\n' +
|
|
27
|
+
'上の L1 要約や L2 本文を読んで「具体的なコマンドやツール出力、ファイル内容を確認したい」と感じたら、' +
|
|
28
|
+
'推測せずに **Bash ツールで `throughline detail <時刻>` を実行** して L3(ツール入出力・hook 出力)を取得してください。\n' +
|
|
29
|
+
'- 単一時刻: `throughline detail 14:23:05`\n' +
|
|
30
|
+
'- 時刻範囲: `throughline detail 14:23-14:30`\n' +
|
|
31
|
+
'\n' +
|
|
32
|
+
'返る内容: 指定ターンの L2 会話本文 + L3(tool_input / tool_output / system 別にグループ化)。\n' +
|
|
33
|
+
'ユーザーに「詳細を見せて」と言われた時だけでなく、**ユーザー発言の文脈が過去ターンに依存しているのに L1/L2 だけでは情報不足だと判断した時**に、Claude 自身の判断で呼び出して構いません。';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Unix ms を HH:MM:SS 形式に変換する。
|
|
37
|
+
*/
|
|
38
|
+
function formatTime(unixMs) {
|
|
39
|
+
const d = new Date(unixMs);
|
|
40
|
+
const hh = String(d.getHours()).padStart(2, '0');
|
|
41
|
+
const mm = String(d.getMinutes()).padStart(2, '0');
|
|
42
|
+
const ss = String(d.getSeconds()).padStart(2, '0');
|
|
43
|
+
return `${hh}:${mm}:${ss}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 本文を 1 行にまとめる(改行は空白に畳む)。
|
|
48
|
+
*/
|
|
49
|
+
function flattenText(text) {
|
|
50
|
+
if (!text) return '';
|
|
51
|
+
return text.replace(/\n+/g, ' ').trim();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* L1+L2 注入テキストを組み立てる。
|
|
56
|
+
*
|
|
57
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
58
|
+
* @param {{ sessionId: string, isInheritance: boolean, excludeOriginId?: string | null }} params
|
|
59
|
+
* sessionId: 合流先 session_id (merge target)
|
|
60
|
+
* excludeOriginId: 注入対象から除外する origin_session_id(= 現セッションの origin)
|
|
61
|
+
* 指定すると「前任チェーンのターンのみ」を注入する
|
|
62
|
+
* @returns {string | null}
|
|
63
|
+
*/
|
|
64
|
+
export function buildResumeContext(db, { sessionId, isInheritance, excludeOriginId = null }) {
|
|
65
|
+
if (!sessionId) return null;
|
|
66
|
+
|
|
67
|
+
const hasExclude = Boolean(excludeOriginId);
|
|
68
|
+
|
|
69
|
+
// 直近 N 件の bodies を取得
|
|
70
|
+
const bodiesQuery = hasExclude
|
|
71
|
+
? `SELECT origin_session_id, turn_number, role, text, created_at
|
|
72
|
+
FROM bodies
|
|
73
|
+
WHERE session_id = ? AND origin_session_id != ?
|
|
74
|
+
ORDER BY created_at DESC
|
|
75
|
+
LIMIT ?`
|
|
76
|
+
: `SELECT origin_session_id, turn_number, role, text, created_at
|
|
77
|
+
FROM bodies
|
|
78
|
+
WHERE session_id = ?
|
|
79
|
+
ORDER BY created_at DESC
|
|
80
|
+
LIMIT ?`;
|
|
81
|
+
|
|
82
|
+
const limitRows = N_RECENT_L2 * 2; // user/assistant の 2 ロール分
|
|
83
|
+
|
|
84
|
+
let bodyRowsDesc = [];
|
|
85
|
+
try {
|
|
86
|
+
bodyRowsDesc = hasExclude
|
|
87
|
+
? db.prepare(bodiesQuery).all(sessionId, excludeOriginId, limitRows)
|
|
88
|
+
: db.prepare(bodiesQuery).all(sessionId, limitRows);
|
|
89
|
+
} catch {
|
|
90
|
+
// bodies テーブル未作成(v3 DB)の場合は空
|
|
91
|
+
bodyRowsDesc = [];
|
|
92
|
+
}
|
|
93
|
+
const bodyRows = bodyRowsDesc.reverse(); // ASC に戻す
|
|
94
|
+
|
|
95
|
+
// 古い側の L1(bodies に既に含まれるターンを除いたもの)を skeletons から取得
|
|
96
|
+
const bodySet = new Set(
|
|
97
|
+
bodyRows.map((r) => `${r.origin_session_id}\x00${r.turn_number}`),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const skelQuery = hasExclude
|
|
101
|
+
? `SELECT origin_session_id, turn_number, role, summary, created_at
|
|
102
|
+
FROM skeletons
|
|
103
|
+
WHERE session_id = ? AND origin_session_id != ?
|
|
104
|
+
ORDER BY created_at ASC`
|
|
105
|
+
: `SELECT origin_session_id, turn_number, role, summary, created_at
|
|
106
|
+
FROM skeletons
|
|
107
|
+
WHERE session_id = ?
|
|
108
|
+
ORDER BY created_at ASC`;
|
|
109
|
+
|
|
110
|
+
const allSkel = hasExclude
|
|
111
|
+
? db.prepare(skelQuery).all(sessionId, excludeOriginId)
|
|
112
|
+
: db.prepare(skelQuery).all(sessionId);
|
|
113
|
+
|
|
114
|
+
const l1Rows = allSkel.filter(
|
|
115
|
+
(s) => !bodySet.has(`${s.origin_session_id}\x00${s.turn_number}`),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
if (bodyRows.length === 0 && l1Rows.length === 0) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const turnCount = bodyRows.length + l1Rows.length;
|
|
123
|
+
const header = isInheritance ? RESUME_HEADER_TEMPLATE(turnCount) : NORMAL_HEADER;
|
|
124
|
+
const lines = [header];
|
|
125
|
+
|
|
126
|
+
if (l1Rows.length > 0) {
|
|
127
|
+
lines.push('');
|
|
128
|
+
lines.push('### それ以前の要約 (L1)');
|
|
129
|
+
for (const r of l1Rows) {
|
|
130
|
+
if (!r.summary || r.summary === '(no content)') continue;
|
|
131
|
+
lines.push(`[${formatTime(r.created_at)}] ${flattenText(r.summary)}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (bodyRows.length > 0) {
|
|
136
|
+
lines.push('');
|
|
137
|
+
lines.push('### 直近のターン履歴 (L2)');
|
|
138
|
+
for (const r of bodyRows) {
|
|
139
|
+
if (!r.text) continue;
|
|
140
|
+
lines.push(`[${formatTime(r.created_at)}] [${r.role}]: ${r.text}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
lines.push('');
|
|
145
|
+
lines.push(FOOTER_GUIDE);
|
|
146
|
+
|
|
147
|
+
return lines.join('\n');
|
|
148
|
+
}
|