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,212 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* sc-detail — /sc-detail スラッシュコマンドの実行本体
|
|
4
|
+
*
|
|
5
|
+
* 使い方:
|
|
6
|
+
* node src/sc-detail.mjs <時刻>
|
|
7
|
+
* node src/sc-detail.mjs <開始時刻>-<終了時刻>
|
|
8
|
+
*
|
|
9
|
+
* 時刻フォーマット: HH:MM:SS または HH:MM(秒省略可)
|
|
10
|
+
* 複数ターンが同一時刻にヒットする場合は全部返す。
|
|
11
|
+
*
|
|
12
|
+
* 出力: 指定時刻のターン(または範囲内の全ターン)の L2 (bodies) + L3 (details)
|
|
13
|
+
* を人間可読なテキストで stdout に出力する。
|
|
14
|
+
*
|
|
15
|
+
* 注意:
|
|
16
|
+
* - 現在の作業ディレクトリ(cwd)のプロジェクトに属するターンのみを対象にする
|
|
17
|
+
* - session_id は merge chain 解決後の合流先(target)を使う
|
|
18
|
+
* - 複数セッションの ID を跨いで時刻で検索するので、project_path でフィルタ必須
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { getDb } from './db.mjs';
|
|
22
|
+
import { DETAIL_KIND, DETAIL_KIND_VALUES } from './constants.mjs';
|
|
23
|
+
|
|
24
|
+
function parseTimeArg(arg) {
|
|
25
|
+
const m = String(arg || '').trim().match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
|
|
26
|
+
if (!m) return null;
|
|
27
|
+
const [, hh, mm, ss] = m;
|
|
28
|
+
return {
|
|
29
|
+
hours: Number(hh),
|
|
30
|
+
minutes: Number(mm),
|
|
31
|
+
seconds: ss != null ? Number(ss) : null, // null = 秒指定なし(その分内すべて)
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 指定時刻(HH:MM:SS)の当日タイムスタンプ(ms)を返す。
|
|
37
|
+
* 秒が null の場合は start=00, end=59 の範囲を返す(呼び出し側で使い分け)。
|
|
38
|
+
*/
|
|
39
|
+
function timeToUnixRange(t, baseDate = new Date()) {
|
|
40
|
+
const y = baseDate.getFullYear();
|
|
41
|
+
const mo = baseDate.getMonth();
|
|
42
|
+
const d = baseDate.getDate();
|
|
43
|
+
const secStart = t.seconds != null ? t.seconds : 0;
|
|
44
|
+
const secEnd = t.seconds != null ? t.seconds : 59;
|
|
45
|
+
const start = new Date(y, mo, d, t.hours, t.minutes, secStart, 0).getTime();
|
|
46
|
+
const end = new Date(y, mo, d, t.hours, t.minutes, secEnd, 999).getTime();
|
|
47
|
+
return { start, end };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseRangeArg(arg) {
|
|
51
|
+
const s = String(arg || '').trim();
|
|
52
|
+
if (!s.includes('-')) {
|
|
53
|
+
const t = parseTimeArg(s);
|
|
54
|
+
if (!t) return null;
|
|
55
|
+
const r = timeToUnixRange(t);
|
|
56
|
+
return { start: r.start, end: r.end };
|
|
57
|
+
}
|
|
58
|
+
const [lo, hi] = s.split('-').map((x) => x.trim());
|
|
59
|
+
const tLo = parseTimeArg(lo);
|
|
60
|
+
const tHi = parseTimeArg(hi);
|
|
61
|
+
if (!tLo || !tHi) return null;
|
|
62
|
+
const rLo = timeToUnixRange(tLo);
|
|
63
|
+
const rHi = timeToUnixRange(tHi);
|
|
64
|
+
return { start: rLo.start, end: rHi.end };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function formatTime(unixMs) {
|
|
68
|
+
const d = new Date(unixMs);
|
|
69
|
+
const hh = String(d.getHours()).padStart(2, '0');
|
|
70
|
+
const mm = String(d.getMinutes()).padStart(2, '0');
|
|
71
|
+
const ss = String(d.getSeconds()).padStart(2, '0');
|
|
72
|
+
return `${hh}:${mm}:${ss}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* コアロジック。bin/throughline.mjs などから直接呼び出せるよう、
|
|
77
|
+
* process.argv ではなく引数配列を受け取る。
|
|
78
|
+
* @param {string[]} args
|
|
79
|
+
*/
|
|
80
|
+
export function run(args) {
|
|
81
|
+
const arg = args[0];
|
|
82
|
+
if (!arg) {
|
|
83
|
+
process.stderr.write(
|
|
84
|
+
'使い方: throughline detail <HH:MM:SS>\n' +
|
|
85
|
+
' throughline detail <HH:MM:SS>-<HH:MM:SS>\n',
|
|
86
|
+
);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const range = parseRangeArg(arg);
|
|
91
|
+
if (!range) {
|
|
92
|
+
process.stderr.write(`[sc-detail] 時刻フォーマットが無効: ${arg}\n`);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const db = getDb();
|
|
97
|
+
const projectPath = process.cwd();
|
|
98
|
+
|
|
99
|
+
// 指定時刻範囲内のターンを bodies から取得(project_path でフィルタ)
|
|
100
|
+
// bodies と sessions を JOIN して同プロジェクトに絞る
|
|
101
|
+
const bodyRows = db
|
|
102
|
+
.prepare(
|
|
103
|
+
`SELECT b.session_id, b.origin_session_id, b.turn_number, b.role, b.text, b.created_at
|
|
104
|
+
FROM bodies b
|
|
105
|
+
JOIN sessions s ON s.session_id = b.session_id
|
|
106
|
+
WHERE lower(s.project_path) = lower(?)
|
|
107
|
+
AND b.created_at BETWEEN ? AND ?
|
|
108
|
+
ORDER BY b.created_at ASC, b.role ASC`,
|
|
109
|
+
)
|
|
110
|
+
.all(projectPath, range.start, range.end);
|
|
111
|
+
|
|
112
|
+
if (bodyRows.length === 0) {
|
|
113
|
+
process.stdout.write(
|
|
114
|
+
`## Throughline /sc-detail\n\n指定時刻 ${arg} に該当するターンが見つかりませんでした。\n`,
|
|
115
|
+
);
|
|
116
|
+
process.exit(0);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ターン単位でグルーピング(同じ session_id + origin + turn_number)
|
|
120
|
+
const turnKeys = new Set();
|
|
121
|
+
for (const r of bodyRows) {
|
|
122
|
+
turnKeys.add(`${r.session_id}\x00${r.origin_session_id}\x00${r.turn_number}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const lines = [];
|
|
126
|
+
lines.push('## Throughline /sc-detail');
|
|
127
|
+
lines.push(`指定時刻: ${arg} 対象ターン数: ${turnKeys.size}`);
|
|
128
|
+
lines.push('');
|
|
129
|
+
|
|
130
|
+
// L2 を時刻順に出力
|
|
131
|
+
lines.push('### L2 (会話本文)');
|
|
132
|
+
for (const r of bodyRows) {
|
|
133
|
+
lines.push(`[${formatTime(r.created_at)}] [${r.role}]: ${r.text}`);
|
|
134
|
+
lines.push('');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 対応する L3 を details から 1 クエリで取得(N+1 回避のため row-value IN)
|
|
138
|
+
const turnTuples = [...turnKeys].map((k) => k.split('\x00'));
|
|
139
|
+
const placeholders = turnTuples.map(() => '(?, ?, ?)').join(', ');
|
|
140
|
+
const params = turnTuples.flatMap(([sid, origin, turn]) => [sid, origin, Number(turn)]);
|
|
141
|
+
const detailRows = db
|
|
142
|
+
.prepare(
|
|
143
|
+
`SELECT id, turn_number, kind, tool_name, input_text, output_text, created_at
|
|
144
|
+
FROM details
|
|
145
|
+
WHERE (session_id, origin_session_id, turn_number) IN (VALUES ${placeholders})
|
|
146
|
+
ORDER BY id ASC`,
|
|
147
|
+
)
|
|
148
|
+
.all(...params);
|
|
149
|
+
|
|
150
|
+
if (detailRows.length > 0) {
|
|
151
|
+
// 単一 pass で kind ごとに振り分け
|
|
152
|
+
const toolRows = [];
|
|
153
|
+
const systemRows = [];
|
|
154
|
+
const imageRows = [];
|
|
155
|
+
const legacyRows = [];
|
|
156
|
+
for (const d of detailRows) {
|
|
157
|
+
if (d.kind === DETAIL_KIND.TOOL_INPUT || d.kind === DETAIL_KIND.TOOL_OUTPUT) toolRows.push(d);
|
|
158
|
+
else if (d.kind === DETAIL_KIND.SYSTEM) systemRows.push(d);
|
|
159
|
+
else if (d.kind === DETAIL_KIND.IMAGE) imageRows.push(d);
|
|
160
|
+
else if (!DETAIL_KIND_VALUES.has(d.kind)) legacyRows.push(d);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (toolRows.length > 0) {
|
|
164
|
+
lines.push('### L3 (ツール入出力)');
|
|
165
|
+
for (const d of toolRows) {
|
|
166
|
+
const marker = d.kind === DETAIL_KIND.TOOL_INPUT ? 'IN ' : 'OUT';
|
|
167
|
+
lines.push(`[${formatTime(d.created_at)}] ${marker} ${d.tool_name}`);
|
|
168
|
+
if (d.input_text) {
|
|
169
|
+
lines.push(` IN: ${d.input_text.replace(/\n/g, '\n ')}`);
|
|
170
|
+
}
|
|
171
|
+
if (d.output_text) {
|
|
172
|
+
lines.push(` OUT: ${d.output_text.replace(/\n/g, '\n ')}`);
|
|
173
|
+
}
|
|
174
|
+
lines.push('');
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (systemRows.length > 0) {
|
|
179
|
+
lines.push('### L3 (システムメッセージ / hook 出力)');
|
|
180
|
+
for (const d of systemRows) {
|
|
181
|
+
lines.push(`[${formatTime(d.created_at)}] ${d.tool_name}`);
|
|
182
|
+
if (d.input_text) lines.push(` CMD: ${d.input_text}`);
|
|
183
|
+
if (d.output_text) lines.push(` OUT: ${d.output_text.replace(/\n/g, '\n ')}`);
|
|
184
|
+
lines.push('');
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (imageRows.length > 0) {
|
|
189
|
+
lines.push('### L3 (画像)');
|
|
190
|
+
for (const d of imageRows) {
|
|
191
|
+
lines.push(`[${formatTime(d.created_at)}] ${d.output_text ?? '[image]'}`);
|
|
192
|
+
}
|
|
193
|
+
lines.push('');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (legacyRows.length > 0) {
|
|
197
|
+
lines.push('### L3 (legacy)');
|
|
198
|
+
for (const d of legacyRows) {
|
|
199
|
+
lines.push(`[${formatTime(d.created_at)}] ${d.tool_name}`);
|
|
200
|
+
if (d.input_text) lines.push(` IN: ${d.input_text.replace(/\n/g, '\n ')}`);
|
|
201
|
+
if (d.output_text) lines.push(` OUT: ${d.output_text.replace(/\n/g, '\n ')}`);
|
|
202
|
+
lines.push('');
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
} else {
|
|
206
|
+
lines.push('### L3');
|
|
207
|
+
lines.push('(該当ターンに L3 レコード無し)');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
process.stdout.write(lines.join('\n') + '\n');
|
|
211
|
+
process.exit(0);
|
|
212
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-merger.mjs — 記憶張り替え + merged_into チェーン解決
|
|
3
|
+
*
|
|
4
|
+
* 用途:
|
|
5
|
+
* - SessionStart hook: mergePredecessorInto で前任セッションの L1/L2/L3 を新セッションに張り替える
|
|
6
|
+
* - Stop / PostToolUse hook: resolveMergeTarget で「入力 session_id → 実書き込み先」を解決
|
|
7
|
+
*
|
|
8
|
+
* 設計背景: docs/SESSION_LINKING_DESIGN.md
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const MAX_CHAIN_DEPTH = 10;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* merged_into チェーンを辿って最終的な書き込み先 session_id を解決する。
|
|
15
|
+
*
|
|
16
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
17
|
+
* @param {string} sessionId
|
|
18
|
+
* @returns {{ target: string, origin: string }}
|
|
19
|
+
* target: 実書き込み先 session_id(合流先)
|
|
20
|
+
* origin: 入力 session_id そのもの(INSERT 時の origin_session_id に使う)
|
|
21
|
+
*/
|
|
22
|
+
export function resolveMergeTarget(db, sessionId) {
|
|
23
|
+
const origin = sessionId;
|
|
24
|
+
const seen = new Set();
|
|
25
|
+
let current = sessionId;
|
|
26
|
+
|
|
27
|
+
for (let depth = 0; depth < MAX_CHAIN_DEPTH; depth++) {
|
|
28
|
+
if (seen.has(current)) {
|
|
29
|
+
throw new Error(`[session-merger] merge chain cycle detected at ${current}`);
|
|
30
|
+
}
|
|
31
|
+
seen.add(current);
|
|
32
|
+
|
|
33
|
+
const row = db
|
|
34
|
+
.prepare('SELECT merged_into FROM sessions WHERE session_id = ?')
|
|
35
|
+
.get(current);
|
|
36
|
+
|
|
37
|
+
if (!row || row.merged_into === null || row.merged_into === undefined) {
|
|
38
|
+
return { target: current, origin };
|
|
39
|
+
}
|
|
40
|
+
current = row.merged_into;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
throw new Error(
|
|
44
|
+
`[session-merger] merge chain depth exceeded ${MAX_CHAIN_DEPTH} from ${sessionId}`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 同一プロジェクト内の最新非合流セッションを新セッションに張り替える。
|
|
50
|
+
*
|
|
51
|
+
* 実行順序(BEGIN IMMEDIATE トランザクション内):
|
|
52
|
+
* 1. 前任候補 SELECT(同 project_path, session_id != new, merged_into IS NULL, 最新 updated_at)
|
|
53
|
+
* 2. skeletons / details / bodies の session_id を new に UPDATE
|
|
54
|
+
* (bodies は schema v4 で追加された L2 テーブル。v3 DB でも UPDATE は no-op で害なし)
|
|
55
|
+
* 3. 前任 sessions.merged_into = new
|
|
56
|
+
* 4. 新セッション sessions.updated_at = now
|
|
57
|
+
*
|
|
58
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
59
|
+
* @param {{ newSessionId: string, projectPath: string }} params
|
|
60
|
+
* @returns {{ merged: boolean, predecessorId?: string, rowCounts?: { sk: number, dt: number, bd: number } }}
|
|
61
|
+
*/
|
|
62
|
+
export function mergePredecessorInto(db, { newSessionId, projectPath }) {
|
|
63
|
+
db.exec('BEGIN IMMEDIATE');
|
|
64
|
+
try {
|
|
65
|
+
// 時系列単調制約: 前任は新セッションより created_at が古いものに限る。
|
|
66
|
+
// これにより merge chain は厳密に時系列順となり、循環参照が構造的に発生不可能になる。
|
|
67
|
+
// (同時刻に複数 SessionStart が発火しても、自分自身より新しいセッションは選ばない)
|
|
68
|
+
const pred = db
|
|
69
|
+
.prepare(
|
|
70
|
+
`SELECT session_id FROM sessions
|
|
71
|
+
WHERE lower(project_path) = lower(?)
|
|
72
|
+
AND session_id != ?
|
|
73
|
+
AND merged_into IS NULL
|
|
74
|
+
AND created_at < (SELECT created_at FROM sessions WHERE session_id = ?)
|
|
75
|
+
ORDER BY updated_at DESC
|
|
76
|
+
LIMIT 1`,
|
|
77
|
+
)
|
|
78
|
+
.get(projectPath, newSessionId, newSessionId);
|
|
79
|
+
|
|
80
|
+
if (!pred) {
|
|
81
|
+
db.exec('COMMIT');
|
|
82
|
+
return { merged: false };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const predecessorId = pred.session_id;
|
|
86
|
+
|
|
87
|
+
const sk = db
|
|
88
|
+
.prepare('UPDATE skeletons SET session_id = ? WHERE session_id = ?')
|
|
89
|
+
.run(newSessionId, predecessorId);
|
|
90
|
+
const dt = db
|
|
91
|
+
.prepare('UPDATE details SET session_id = ? WHERE session_id = ?')
|
|
92
|
+
.run(newSessionId, predecessorId);
|
|
93
|
+
// bodies は schema v4 以降のみ存在。v3 DB では 0 changes で害なし
|
|
94
|
+
let bd = { changes: 0 };
|
|
95
|
+
try {
|
|
96
|
+
bd = db
|
|
97
|
+
.prepare('UPDATE bodies SET session_id = ? WHERE session_id = ?')
|
|
98
|
+
.run(newSessionId, predecessorId);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
// bodies テーブルが未作成の場合は無視(schema v3 DB 互換)
|
|
101
|
+
if (!/no such table/i.test(err.message || '')) throw err;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
db.prepare('UPDATE sessions SET merged_into = ? WHERE session_id = ?').run(
|
|
105
|
+
newSessionId,
|
|
106
|
+
predecessorId,
|
|
107
|
+
);
|
|
108
|
+
db.prepare('UPDATE sessions SET updated_at = ? WHERE session_id = ?').run(
|
|
109
|
+
Date.now(),
|
|
110
|
+
newSessionId,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
db.exec('COMMIT');
|
|
114
|
+
return {
|
|
115
|
+
merged: true,
|
|
116
|
+
predecessorId,
|
|
117
|
+
rowCounts: { sk: sk.changes, dt: dt.changes, bd: bd.changes },
|
|
118
|
+
};
|
|
119
|
+
} catch (err) {
|
|
120
|
+
try {
|
|
121
|
+
db.exec('ROLLBACK');
|
|
122
|
+
} catch {
|
|
123
|
+
// ignore
|
|
124
|
+
}
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { resolveMergeTarget, mergePredecessorInto } from './session-merger.mjs';
|
|
5
|
+
|
|
6
|
+
function makeDb() {
|
|
7
|
+
const db = new DatabaseSync(':memory:');
|
|
8
|
+
db.exec(`
|
|
9
|
+
CREATE TABLE sessions (
|
|
10
|
+
session_id TEXT PRIMARY KEY,
|
|
11
|
+
project_path TEXT NOT NULL,
|
|
12
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
13
|
+
created_at INTEGER NOT NULL,
|
|
14
|
+
updated_at INTEGER NOT NULL,
|
|
15
|
+
merged_into TEXT
|
|
16
|
+
);
|
|
17
|
+
CREATE TABLE skeletons (
|
|
18
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
19
|
+
session_id TEXT NOT NULL,
|
|
20
|
+
origin_session_id TEXT,
|
|
21
|
+
turn_number INTEGER NOT NULL,
|
|
22
|
+
role TEXT NOT NULL,
|
|
23
|
+
summary TEXT NOT NULL,
|
|
24
|
+
created_at INTEGER NOT NULL
|
|
25
|
+
);
|
|
26
|
+
CREATE TABLE bodies (
|
|
27
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
28
|
+
session_id TEXT NOT NULL,
|
|
29
|
+
origin_session_id TEXT NOT NULL,
|
|
30
|
+
turn_number INTEGER NOT NULL,
|
|
31
|
+
role TEXT NOT NULL,
|
|
32
|
+
text TEXT NOT NULL,
|
|
33
|
+
token_count INTEGER,
|
|
34
|
+
created_at INTEGER NOT NULL
|
|
35
|
+
);
|
|
36
|
+
CREATE TABLE details (
|
|
37
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
38
|
+
session_id TEXT NOT NULL,
|
|
39
|
+
origin_session_id TEXT,
|
|
40
|
+
turn_number INTEGER,
|
|
41
|
+
tool_name TEXT NOT NULL,
|
|
42
|
+
input_text TEXT,
|
|
43
|
+
output_text TEXT,
|
|
44
|
+
token_count INTEGER NOT NULL DEFAULT 0,
|
|
45
|
+
created_at INTEGER NOT NULL
|
|
46
|
+
);
|
|
47
|
+
`);
|
|
48
|
+
return db;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function insertSession(db, id, createdAt, mergedInto = null, projectPath = '/proj') {
|
|
52
|
+
db.prepare(
|
|
53
|
+
`INSERT INTO sessions (session_id, project_path, status, created_at, updated_at, merged_into)
|
|
54
|
+
VALUES (?, ?, 'active', ?, ?, ?)`,
|
|
55
|
+
).run(id, projectPath, createdAt, createdAt, mergedInto);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
test('resolveMergeTarget: unmerged session returns itself', () => {
|
|
59
|
+
const db = makeDb();
|
|
60
|
+
insertSession(db, 'A', 1);
|
|
61
|
+
const { target, origin } = resolveMergeTarget(db, 'A');
|
|
62
|
+
assert.equal(target, 'A');
|
|
63
|
+
assert.equal(origin, 'A');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('resolveMergeTarget: follows chain to end', () => {
|
|
67
|
+
const db = makeDb();
|
|
68
|
+
insertSession(db, 'A', 1, 'B');
|
|
69
|
+
insertSession(db, 'B', 2, 'C');
|
|
70
|
+
insertSession(db, 'C', 3, null);
|
|
71
|
+
const { target, origin } = resolveMergeTarget(db, 'A');
|
|
72
|
+
assert.equal(target, 'C');
|
|
73
|
+
assert.equal(origin, 'A');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('resolveMergeTarget: detects cycle and throws', () => {
|
|
77
|
+
const db = makeDb();
|
|
78
|
+
insertSession(db, 'A', 1, 'B');
|
|
79
|
+
insertSession(db, 'B', 2, 'C');
|
|
80
|
+
insertSession(db, 'C', 3, 'A');
|
|
81
|
+
assert.throws(() => resolveMergeTarget(db, 'A'), /cycle detected/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('mergePredecessorInto: picks older predecessor and moves rows', () => {
|
|
85
|
+
const db = makeDb();
|
|
86
|
+
insertSession(db, 'old', 100);
|
|
87
|
+
db.prepare(
|
|
88
|
+
`INSERT INTO skeletons (session_id, origin_session_id, turn_number, role, summary, created_at)
|
|
89
|
+
VALUES ('old', 'old', 1, 'user', 's', 100)`,
|
|
90
|
+
).run();
|
|
91
|
+
insertSession(db, 'new', 200);
|
|
92
|
+
|
|
93
|
+
const result = mergePredecessorInto(db, { newSessionId: 'new', projectPath: '/proj' });
|
|
94
|
+
assert.equal(result.merged, true);
|
|
95
|
+
assert.equal(result.predecessorId, 'old');
|
|
96
|
+
assert.equal(result.rowCounts.sk, 1);
|
|
97
|
+
|
|
98
|
+
const skRow = db.prepare('SELECT session_id FROM skeletons').get();
|
|
99
|
+
assert.equal(skRow.session_id, 'new');
|
|
100
|
+
|
|
101
|
+
const oldRow = db.prepare('SELECT merged_into FROM sessions WHERE session_id = ?').get('old');
|
|
102
|
+
assert.equal(oldRow.merged_into, 'new');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('mergePredecessorInto: does NOT pick a session newer than self (cycle prevention)', () => {
|
|
106
|
+
const db = makeDb();
|
|
107
|
+
// new session created at t=100
|
|
108
|
+
insertSession(db, 'new', 100);
|
|
109
|
+
// another session was created LATER at t=200 (e.g. a parallel window that started after)
|
|
110
|
+
insertSession(db, 'newer', 200);
|
|
111
|
+
|
|
112
|
+
const result = mergePredecessorInto(db, { newSessionId: 'new', projectPath: '/proj' });
|
|
113
|
+
assert.equal(result.merged, false, 'should not merge a newer session into an older one');
|
|
114
|
+
|
|
115
|
+
const newerRow = db
|
|
116
|
+
.prepare('SELECT merged_into FROM sessions WHERE session_id = ?')
|
|
117
|
+
.get('newer');
|
|
118
|
+
assert.equal(newerRow.merged_into, null);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('mergePredecessorInto: chronological monotonicity prevents cycles across 3 sessions', () => {
|
|
122
|
+
const db = makeDb();
|
|
123
|
+
// Sessions created in order: A (t=100), B (t=200), C (t=300)
|
|
124
|
+
insertSession(db, 'A', 100);
|
|
125
|
+
insertSession(db, 'B', 200);
|
|
126
|
+
insertSession(db, 'C', 300);
|
|
127
|
+
|
|
128
|
+
// Simulate SessionStart firing for B first, then C, then (accidentally) A again
|
|
129
|
+
mergePredecessorInto(db, { newSessionId: 'B', projectPath: '/proj' });
|
|
130
|
+
// B should have absorbed A
|
|
131
|
+
assert.equal(
|
|
132
|
+
db.prepare('SELECT merged_into FROM sessions WHERE session_id = ?').get('A').merged_into,
|
|
133
|
+
'B',
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
mergePredecessorInto(db, { newSessionId: 'C', projectPath: '/proj' });
|
|
137
|
+
// C should have absorbed B (A is already merged, so not a candidate)
|
|
138
|
+
assert.equal(
|
|
139
|
+
db.prepare('SELECT merged_into FROM sessions WHERE session_id = ?').get('B').merged_into,
|
|
140
|
+
'C',
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
// Re-firing SessionStart for A must not create a cycle (A cannot absorb newer B or C)
|
|
144
|
+
const redundant = mergePredecessorInto(db, { newSessionId: 'A', projectPath: '/proj' });
|
|
145
|
+
assert.equal(redundant.merged, false);
|
|
146
|
+
|
|
147
|
+
// Verify no cycle: resolveMergeTarget from any node terminates at C
|
|
148
|
+
assert.equal(resolveMergeTarget(db, 'A').target, 'C');
|
|
149
|
+
assert.equal(resolveMergeTarget(db, 'B').target, 'C');
|
|
150
|
+
assert.equal(resolveMergeTarget(db, 'C').target, 'C');
|
|
151
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SessionStart hook — セッション登録 + 前任記憶の張り替え + 引き継ぎ注入
|
|
4
|
+
*
|
|
5
|
+
* stdin: { session_id, source, cwd, transcript_path, hook_event_name }
|
|
6
|
+
*
|
|
7
|
+
* 【実機確認 (2026-04-15)】
|
|
8
|
+
* SessionStart は /clear 後も source="startup" で発火する。
|
|
9
|
+
* (Windows + VSCode 拡張では source="clear" は来ないが hook 自体は発火)
|
|
10
|
+
* source に依存せず、毎回「前任の張り替え候補」を探して合流させる。
|
|
11
|
+
*
|
|
12
|
+
* 役割:
|
|
13
|
+
* 1. sessions テーブルに新セッションを INSERT OR IGNORE
|
|
14
|
+
* 2. 同プロジェクト内の最新非合流セッションを新セッションに張り替え (session-merger)
|
|
15
|
+
* 3. 合流成立なら L1+L2 を「引き継ぎヘッダ」付きで stdout 注入
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { getDb } from './db.mjs';
|
|
19
|
+
import { mergePredecessorInto } from './session-merger.mjs';
|
|
20
|
+
import { buildResumeContext } from './resume-context.mjs';
|
|
21
|
+
|
|
22
|
+
async function main() {
|
|
23
|
+
let raw = '';
|
|
24
|
+
await new Promise((resolve) => {
|
|
25
|
+
process.stdin.setEncoding('utf8');
|
|
26
|
+
process.stdin.on('data', (chunk) => {
|
|
27
|
+
raw += chunk;
|
|
28
|
+
});
|
|
29
|
+
process.stdin.on('end', resolve);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const payload = JSON.parse(raw);
|
|
33
|
+
const { session_id, cwd } = payload;
|
|
34
|
+
|
|
35
|
+
if (!session_id) throw new Error('Missing session_id in SessionStart payload');
|
|
36
|
+
|
|
37
|
+
const projectPath = cwd ?? process.cwd();
|
|
38
|
+
const db = getDb();
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
|
|
41
|
+
// 1. sessions テーブルに INSERT OR IGNORE
|
|
42
|
+
db.prepare(
|
|
43
|
+
`INSERT OR IGNORE INTO sessions (session_id, project_path, status, created_at, updated_at)
|
|
44
|
+
VALUES (?, ?, 'active', ?, ?)`,
|
|
45
|
+
).run(session_id, projectPath, now, now);
|
|
46
|
+
|
|
47
|
+
// 2. 前任の張り替え
|
|
48
|
+
const mergeResult = mergePredecessorInto(db, {
|
|
49
|
+
newSessionId: session_id,
|
|
50
|
+
projectPath,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// 3. 合流成立なら引き継ぎヘッダ付きで注入
|
|
54
|
+
if (mergeResult.merged) {
|
|
55
|
+
const text = buildResumeContext(db, { sessionId: session_id, isInheritance: true });
|
|
56
|
+
if (text) {
|
|
57
|
+
process.stdout.write(text + '\n');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
process.exit(0);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
main().catch((err) => {
|
|
65
|
+
process.stderr.write(`[session-start] error: ${err.message}\n`);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* state-file.mjs — セッション単位の状態ファイル管理(共有モジュール)
|
|
3
|
+
*
|
|
4
|
+
* パス: ~/.throughline/state/<session_id>.json
|
|
5
|
+
* 書き手: turn-processor (Stop)
|
|
6
|
+
* 読み手: token-monitor
|
|
7
|
+
*
|
|
8
|
+
* 設計判断 (docs/PUBLIC_RELEASE_PLAN.md §4.5/4.6):
|
|
9
|
+
* - ファイル単位分割で last-writer-wins 問題を解消
|
|
10
|
+
* - PID 生存チェックで stale 削除(時間窓は使わない)
|
|
11
|
+
* - projectPath は path.resolve → / → 末尾 / 除去 → Windows lowercase で正規化
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, unlinkSync, existsSync } from 'node:fs';
|
|
15
|
+
import { homedir, platform } from 'node:os';
|
|
16
|
+
import { join, resolve } from 'node:path';
|
|
17
|
+
|
|
18
|
+
const STATE_DIR = join(homedir(), '.throughline', 'state');
|
|
19
|
+
|
|
20
|
+
/** 状態ファイル保管ディレクトリを返す */
|
|
21
|
+
export function getStateDir() {
|
|
22
|
+
return STATE_DIR;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* projectPath を正規化する(書き手/読み手で同じ関数を通すのが契約)
|
|
27
|
+
* @param {string} p
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
export function normalizeProjectPath(p) {
|
|
31
|
+
if (!p) return '';
|
|
32
|
+
let result = resolve(p).replace(/\\/g, '/');
|
|
33
|
+
if (result.length > 1 && result.endsWith('/')) result = result.slice(0, -1);
|
|
34
|
+
if (platform() === 'win32') result = result.toLowerCase();
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* セッション状態ファイルを書く
|
|
40
|
+
* @param {{sessionId: string, projectPath: string, transcriptPath: string, pid: number}} data
|
|
41
|
+
*/
|
|
42
|
+
export function writeSessionState({ sessionId, projectPath, transcriptPath, pid }) {
|
|
43
|
+
if (!sessionId) throw new Error('writeSessionState: sessionId is required');
|
|
44
|
+
if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true });
|
|
45
|
+
const file = join(STATE_DIR, `${sessionId}.json`);
|
|
46
|
+
const payload = {
|
|
47
|
+
sessionId,
|
|
48
|
+
projectPath: normalizeProjectPath(projectPath),
|
|
49
|
+
transcriptPath: transcriptPath ?? null,
|
|
50
|
+
pid: pid ?? process.pid,
|
|
51
|
+
updatedAt: Date.now(),
|
|
52
|
+
};
|
|
53
|
+
writeFileSync(file, JSON.stringify(payload));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 活動タイムアウト(表示フィルタ / 削除)
|
|
57
|
+
// 背景: Windows + VSCode の hook process tree は `Claude Code → 短命 shell → node`
|
|
58
|
+
// で process.ppid は即死する shell を指すため、PID 生存チェック案は機能しない。
|
|
59
|
+
// 代替として state ファイルの updatedAt を活動信号にする。
|
|
60
|
+
export const STALE_HIDE_MS = 15 * 60 * 1000; // 15 分: 表示から隠す(次の発話で復活)
|
|
61
|
+
export const STALE_DELETE_MS = 24 * 60 * 60 * 1000; // 24 時間: ファイル自体を削除
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 全セッション状態を読む。24 時間超のファイルは削除、壊れたファイルも削除する。
|
|
65
|
+
* 15 分超のファイルは「stale」フラグを付けて返す(monitor 側で隠す判断をする)。
|
|
66
|
+
* @returns {Array<{sessionId: string, projectPath: string, transcriptPath: string|null, updatedAt: number, stale: boolean}>}
|
|
67
|
+
*/
|
|
68
|
+
export function readAllSessionStates() {
|
|
69
|
+
if (!existsSync(STATE_DIR)) return [];
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
const entries = readdirSync(STATE_DIR);
|
|
72
|
+
const results = [];
|
|
73
|
+
for (const name of entries) {
|
|
74
|
+
if (!name.endsWith('.json')) continue;
|
|
75
|
+
const file = join(STATE_DIR, name);
|
|
76
|
+
const raw = readFileSync(file, 'utf8');
|
|
77
|
+
let parsed;
|
|
78
|
+
try {
|
|
79
|
+
parsed = JSON.parse(raw);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
// JSON 破損は状態ファイル固有の冪等な状況(次ターンで再生成される)
|
|
82
|
+
process.stderr.write(`[state-file] corrupt state file ${name}, deleting: ${err.message}\n`);
|
|
83
|
+
unlinkSync(file);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const age = now - (parsed.updatedAt ?? 0);
|
|
87
|
+
if (age > STALE_DELETE_MS) {
|
|
88
|
+
// 24h 超: ハード削除(無制限蓄積防止)
|
|
89
|
+
unlinkSync(file);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
parsed.stale = age > STALE_HIDE_MS;
|
|
93
|
+
results.push(parsed);
|
|
94
|
+
}
|
|
95
|
+
return results;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* ファイル単位の mtime スナップショットを取る(差分検知用)
|
|
100
|
+
* @returns {Map<string, number>}
|
|
101
|
+
*/
|
|
102
|
+
export function snapshotStateMtimes() {
|
|
103
|
+
const result = new Map();
|
|
104
|
+
if (!existsSync(STATE_DIR)) return result;
|
|
105
|
+
for (const name of readdirSync(STATE_DIR)) {
|
|
106
|
+
if (!name.endsWith('.json')) continue;
|
|
107
|
+
// readdir と stat の間でファイルが削除される race がある。
|
|
108
|
+
// ENOENT のみ許容してそれ以外は throw(§0)
|
|
109
|
+
const file = join(STATE_DIR, name);
|
|
110
|
+
try {
|
|
111
|
+
result.set(name, statSync(file).mtimeMs);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
if (err.code !== 'ENOENT') throw err;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* テキストのトークン数を推定する。
|
|
3
|
+
*
|
|
4
|
+
* GPT-4 の平均的な比率(4文字 ≒ 1トークン)を用いたヒューリスティック実装。
|
|
5
|
+
* tiktoken への差し替えは Phase 3 で行う予定。
|
|
6
|
+
*
|
|
7
|
+
* @param {string | null | undefined} text
|
|
8
|
+
* @returns {number} 推定トークン数(整数)
|
|
9
|
+
*/
|
|
10
|
+
export function estimateTokens(text) {
|
|
11
|
+
if (text == null) {
|
|
12
|
+
return 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return Math.ceil(text.length / 4);
|
|
16
|
+
}
|