throughline 0.7.0 → 0.8.1
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 +50 -1
- package/README.ja.md +74 -42
- package/README.md +40 -25
- package/bin/throughline.mjs +6 -0
- package/docs/02_clear_auto_handoff_plan.md +8 -0
- package/docs/adr/0016-push-pull-recall-injection.md +105 -0
- package/package.json +1 -1
- package/rag/01-hooks/hook-stdout-10k-persisted-output.md +17 -0
- package/src/cli/factory-diagnostics.test.mjs +10 -1
- package/src/cli/recall.mjs +279 -0
- package/src/cli/recall.test.mjs +269 -0
- package/src/completed-turn-receipts.mjs +2 -1
- package/src/factory-diagnostics.mjs +3 -1
- package/src/factory-diagnostics.test.mjs +17 -0
- package/src/handoff-executor.mjs +4 -2
- package/src/resume-context.mjs +195 -82
- package/src/resume-context.test.mjs +112 -18
- package/src/runtime-error-store.mjs +2 -1
- package/src/runtime-error-store.test.mjs +1 -1
- package/src/turn-processor.test.mjs +4 -3
- package/src/windows-acl-test-helper.mjs +2 -2
|
@@ -5,6 +5,7 @@ import { existsSync, mkdtempSync, rmSync, statSync } from 'node:fs';
|
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
|
|
8
|
+
import { CURRENT_VERSION } from '../db.mjs';
|
|
8
9
|
import {
|
|
9
10
|
collectFactoryDiagnostics,
|
|
10
11
|
inspectFactoryDatabase,
|
|
@@ -19,7 +20,12 @@ test('factory-diagnostics CLI: JSON-only contract and schema fixture', () => {
|
|
|
19
20
|
stdout: { write(value) { output.push(value); } },
|
|
20
21
|
version: '0.6.1',
|
|
21
22
|
inspectThread: () => ({ status: 'ready', rolloutAvailable: true }),
|
|
22
|
-
inspectDatabase: () => ({
|
|
23
|
+
inspectDatabase: () => ({
|
|
24
|
+
status: 'ready',
|
|
25
|
+
schemaVersion: CURRENT_VERSION,
|
|
26
|
+
supportedSchemaVersion: CURRENT_VERSION,
|
|
27
|
+
handoffMemory: true,
|
|
28
|
+
}),
|
|
23
29
|
inspectHooks: () => ({
|
|
24
30
|
status: 'ready',
|
|
25
31
|
claudeStatus: 'ready',
|
|
@@ -32,6 +38,9 @@ test('factory-diagnostics CLI: JSON-only contract and schema fixture', () => {
|
|
|
32
38
|
const parsed = JSON.parse(output[0]);
|
|
33
39
|
assert.equal(parsed.schema, 'throughline.native_factory_diagnostics.v1');
|
|
34
40
|
assert.equal(parsed.version, '0.6.1');
|
|
41
|
+
assert.equal(parsed.databaseSchema.schema, `throughline.database.v${CURRENT_VERSION}`);
|
|
42
|
+
assert.equal(parsed.databaseSchema.databaseSchemaVersion, CURRENT_VERSION);
|
|
43
|
+
assert.equal(parsed.databaseSchema.supportedDatabaseSchemaVersion, CURRENT_VERSION);
|
|
35
44
|
assert.equal(parsed.readiness.restore.status, 'ready');
|
|
36
45
|
assert.equal(parsed.evidence.restoreSmoke.status, 'unverified');
|
|
37
46
|
});
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `throughline recall` — 注入案内から辿る pull 用の DB 直参照 CLI (read-only)
|
|
3
|
+
*
|
|
4
|
+
* 注入(push)は「現在地 + 入るだけの L2」だけを運び、残りの記憶は本コマンドで
|
|
5
|
+
* 必要な時だけ取得する(オーナー裁定 2026-07-18、ADR 0016):
|
|
6
|
+
* - `recall --l2 --session <id> --before <ISO日時> --last <N>`
|
|
7
|
+
* 境界(strict less-than, ms 比較)より古いターンを新しい側から N 件、
|
|
8
|
+
* L2 全文(注入と同じ行文法 + L3 参照 suffix)で出す。
|
|
9
|
+
* - `recall --l1 --session <id> --before <ISO日時> --skip <N>`
|
|
10
|
+
* 境界から N 件(--l2 の担当分)を飛ばした先の全ターン一覧。
|
|
11
|
+
* L1 要約があれば要約、無ければ「未要約」と明示して detail 誘導を出す。
|
|
12
|
+
*
|
|
13
|
+
* 契約:
|
|
14
|
+
* - 範囲・境界・session は注入時に案内コマンドへ焼き込まれた値だけで決まる。
|
|
15
|
+
* recall 側で「現在の 20 ターン窓」を再計算しない(新セッションのターン追記で
|
|
16
|
+
* 窓がスライドし、古い側が黙って欠落するため)。
|
|
17
|
+
* - DB は read-only で開く(create / migrate / write なし)。DB が無ければ
|
|
18
|
+
* explicit error で終了する。
|
|
19
|
+
* - 既定 session 解決は持たない。案内コマンドが常に `--session` を運ぶ。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync } from 'node:fs';
|
|
23
|
+
import { homedir } from 'node:os';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
26
|
+
import { formatTime } from '../handoff-record.mjs';
|
|
27
|
+
import { groupL3ByTurn, buildPartsSummary } from '../l3-summary.mjs';
|
|
28
|
+
|
|
29
|
+
export function defaultRecallDbPath() {
|
|
30
|
+
return join(homedir(), '.throughline', 'throughline.db');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const USAGE =
|
|
34
|
+
'usage: throughline recall (--l2 --last <N> | --l1 [--skip <N>] [--last <N>]) ' +
|
|
35
|
+
'--session <id> --before <ISO8601> [--db <path>]';
|
|
36
|
+
|
|
37
|
+
export function parseRecallArgs(argv) {
|
|
38
|
+
const opts = {
|
|
39
|
+
mode: null,
|
|
40
|
+
sessionId: null,
|
|
41
|
+
beforeMs: null,
|
|
42
|
+
last: null,
|
|
43
|
+
skip: 0,
|
|
44
|
+
dbPath: null,
|
|
45
|
+
};
|
|
46
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
47
|
+
const a = argv[i];
|
|
48
|
+
if (a === '--l2' || a === '--l1') {
|
|
49
|
+
if (opts.mode) throw new Error('recall: --l2 と --l1 は同時に指定できません');
|
|
50
|
+
opts.mode = a.slice(2);
|
|
51
|
+
} else if (a === '--session') {
|
|
52
|
+
opts.sessionId = argv[++i];
|
|
53
|
+
} else if (a === '--before') {
|
|
54
|
+
const raw = argv[++i];
|
|
55
|
+
const ms = Date.parse(raw ?? '');
|
|
56
|
+
if (!Number.isFinite(ms)) {
|
|
57
|
+
throw new Error(`recall: --before の日時を解釈できません: ${raw}(ISO 8601 を指定)`);
|
|
58
|
+
}
|
|
59
|
+
opts.beforeMs = ms;
|
|
60
|
+
} else if (a === '--last') {
|
|
61
|
+
const n = Number(argv[++i]);
|
|
62
|
+
if (!Number.isInteger(n) || n < 0) throw new Error('recall: --last は 0 以上の整数');
|
|
63
|
+
opts.last = n;
|
|
64
|
+
} else if (a === '--skip') {
|
|
65
|
+
const n = Number(argv[++i]);
|
|
66
|
+
if (!Number.isInteger(n) || n < 0) throw new Error('recall: --skip は 0 以上の整数');
|
|
67
|
+
opts.skip = n;
|
|
68
|
+
} else if (a === '--db') {
|
|
69
|
+
opts.dbPath = argv[++i];
|
|
70
|
+
} else {
|
|
71
|
+
throw new Error(`recall: 未知の引数: ${a}\n${USAGE}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (!opts.mode) throw new Error(`recall: --l2 または --l1 を指定してください\n${USAGE}`);
|
|
75
|
+
if (!opts.sessionId) throw new Error(`recall: --session は必須です\n${USAGE}`);
|
|
76
|
+
if (opts.beforeMs == null) throw new Error(`recall: --before は必須です\n${USAGE}`);
|
|
77
|
+
if (opts.mode === 'l2' && opts.last == null) {
|
|
78
|
+
throw new Error(`recall: --l2 には --last <N> が必須です\n${USAGE}`);
|
|
79
|
+
}
|
|
80
|
+
return opts;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 境界より古い側の distinct ターンを新しい順に列挙する。
|
|
85
|
+
* 各要素: { originSessionId, turnNumber, turnKey, minCreatedAt }
|
|
86
|
+
*/
|
|
87
|
+
function listTurnsBefore(db, { sessionId, beforeMs }) {
|
|
88
|
+
const rows = db
|
|
89
|
+
.prepare(
|
|
90
|
+
`SELECT origin_session_id, turn_number, MIN(created_at) AS min_ca, MAX(created_at) AS max_ca
|
|
91
|
+
FROM bodies
|
|
92
|
+
WHERE session_id = ? AND created_at < ?
|
|
93
|
+
GROUP BY origin_session_id, turn_number
|
|
94
|
+
ORDER BY min_ca DESC`,
|
|
95
|
+
)
|
|
96
|
+
.all(sessionId, beforeMs);
|
|
97
|
+
return rows.map((r) => ({
|
|
98
|
+
originSessionId: r.origin_session_id,
|
|
99
|
+
turnNumber: r.turn_number,
|
|
100
|
+
turnKey: `${r.origin_session_id}\x00${r.turn_number}`,
|
|
101
|
+
minCreatedAt: r.min_ca,
|
|
102
|
+
maxCreatedAt: r.max_ca,
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function loadL3ForTurns(db, sessionId, turns) {
|
|
107
|
+
if (turns.length === 0) return [];
|
|
108
|
+
const placeholders = turns.map(() => '(?, ?, ?)').join(', ');
|
|
109
|
+
const params = turns.flatMap((t) => [sessionId, t.originSessionId, Number(t.turnNumber)]);
|
|
110
|
+
return db
|
|
111
|
+
.prepare(
|
|
112
|
+
`SELECT kind, tool_name, origin_session_id, turn_number, created_at
|
|
113
|
+
FROM details
|
|
114
|
+
WHERE (session_id, origin_session_id, turn_number) IN (VALUES ${placeholders})
|
|
115
|
+
ORDER BY created_at ASC, id ASC`,
|
|
116
|
+
)
|
|
117
|
+
.all(...params)
|
|
118
|
+
.map((r) => ({
|
|
119
|
+
kind: r.kind,
|
|
120
|
+
toolName: r.tool_name,
|
|
121
|
+
originSessionId: r.origin_session_id,
|
|
122
|
+
turnNumber: r.turn_number,
|
|
123
|
+
createdAt: r.created_at,
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* --l2: 境界より古いターンを新しい側から last 件、古い順の L2 全文で描画する。
|
|
129
|
+
*/
|
|
130
|
+
export function renderRecallL2(db, { sessionId, beforeMs, last }) {
|
|
131
|
+
const turnsDesc = listTurnsBefore(db, { sessionId, beforeMs });
|
|
132
|
+
const selected = turnsDesc.slice(0, last).reverse(); // 古い順に戻す
|
|
133
|
+
const lines = [];
|
|
134
|
+
|
|
135
|
+
if (selected.length === 0) {
|
|
136
|
+
lines.push(`## Throughline recall (L2): 該当ターンなし(--before ${new Date(beforeMs).toISOString()} より古い L2 が DB にありません)`);
|
|
137
|
+
return { text: lines.join('\n'), turnCount: 0 };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const range = `${formatTime(selected[0].minCreatedAt)}〜${formatTime(selected[selected.length - 1].maxCreatedAt)}`;
|
|
141
|
+
lines.push(`## Throughline recall (L2): ${selected.length}ターン (${range}, 古い順)`);
|
|
142
|
+
if (selected.length < last) {
|
|
143
|
+
lines.push(`(--last ${last} のうち DB に存在するのは ${selected.length} ターンのみ)`);
|
|
144
|
+
}
|
|
145
|
+
lines.push('');
|
|
146
|
+
|
|
147
|
+
const l3ByTurn = groupL3ByTurn(loadL3ForTurns(db, sessionId, selected));
|
|
148
|
+
|
|
149
|
+
const bodyStmt = db.prepare(
|
|
150
|
+
`SELECT role, text, created_at
|
|
151
|
+
FROM bodies
|
|
152
|
+
WHERE session_id = ? AND origin_session_id = ? AND turn_number = ? AND created_at < ?
|
|
153
|
+
ORDER BY created_at ASC`,
|
|
154
|
+
);
|
|
155
|
+
for (const turn of selected) {
|
|
156
|
+
const rows = bodyStmt
|
|
157
|
+
.all(sessionId, turn.originSessionId, Number(turn.turnNumber), beforeMs)
|
|
158
|
+
.filter((r) => r.text);
|
|
159
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
160
|
+
const r = rows[i];
|
|
161
|
+
const isLast = i === rows.length - 1;
|
|
162
|
+
const partCounts = isLast ? (l3ByTurn.get(turn.turnKey)?.partCounts ?? new Map()) : new Map();
|
|
163
|
+
const suffix = buildPartsSummary(partCounts);
|
|
164
|
+
lines.push(`[${formatTime(r.created_at)}] [${r.role}]: ${r.text}${suffix}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { text: lines.join('\n'), turnCount: selected.length };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* --l1: 境界から skip 件を飛ばした先の全ターン一覧(古い順)。
|
|
172
|
+
* L1 要約があれば要約行、無ければ「未要約」と明示して detail 誘導を出す。
|
|
173
|
+
*/
|
|
174
|
+
export function renderRecallL1(db, { sessionId, beforeMs, skip, last = null }) {
|
|
175
|
+
const turnsDesc = listTurnsBefore(db, { sessionId, beforeMs });
|
|
176
|
+
let olderDesc = turnsDesc.slice(skip);
|
|
177
|
+
if (last != null) olderDesc = olderDesc.slice(0, last);
|
|
178
|
+
const selected = [...olderDesc].reverse(); // 古い順
|
|
179
|
+
const lines = [];
|
|
180
|
+
|
|
181
|
+
if (selected.length === 0) {
|
|
182
|
+
lines.push('## Throughline recall (L1): 該当ターンなし');
|
|
183
|
+
return { text: lines.join('\n'), turnCount: 0, summarizedCount: 0 };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const skelRows = db
|
|
187
|
+
.prepare(
|
|
188
|
+
`SELECT origin_session_id, turn_number, summary, created_at
|
|
189
|
+
FROM skeletons
|
|
190
|
+
WHERE session_id = ?
|
|
191
|
+
ORDER BY created_at ASC`,
|
|
192
|
+
)
|
|
193
|
+
.all(sessionId);
|
|
194
|
+
const skelByTurn = new Map();
|
|
195
|
+
for (const r of skelRows) {
|
|
196
|
+
const key = `${r.origin_session_id}\x00${r.turn_number}`;
|
|
197
|
+
if (!skelByTurn.has(key)) skelByTurn.set(key, []);
|
|
198
|
+
skelByTurn.get(key).push(r);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let summarizedCount = 0;
|
|
202
|
+
const bodyLines = [];
|
|
203
|
+
for (const turn of selected) {
|
|
204
|
+
const time = formatTime(turn.minCreatedAt);
|
|
205
|
+
const skels = skelByTurn.get(turn.turnKey);
|
|
206
|
+
if (skels && skels.length > 0) {
|
|
207
|
+
summarizedCount += 1;
|
|
208
|
+
for (const s of skels) {
|
|
209
|
+
if (!s.summary || s.summary === '(no content)') continue;
|
|
210
|
+
const summary = s.summary.replace(/\n+/g, ' ').trim();
|
|
211
|
+
bodyLines.push(`[${time}] ${summary}`);
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
bodyLines.push(`[${time}] (未要約) 全文: throughline detail ${time}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const range = `${formatTime(selected[0].minCreatedAt)}〜${formatTime(selected[selected.length - 1].minCreatedAt)}`;
|
|
219
|
+
lines.push(
|
|
220
|
+
`## Throughline recall (L1): 全${selected.length}ターン / 要約済み ${summarizedCount} (${range}, 古い順)`,
|
|
221
|
+
);
|
|
222
|
+
lines.push('各ターンの全文・ツール入出力: `throughline detail <時刻>` で取得可');
|
|
223
|
+
lines.push('');
|
|
224
|
+
lines.push(...bodyLines);
|
|
225
|
+
return { text: lines.join('\n'), turnCount: selected.length, summarizedCount };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function runRecall(db, opts) {
|
|
229
|
+
if (opts.mode === 'l2') {
|
|
230
|
+
return renderRecallL2(db, {
|
|
231
|
+
sessionId: opts.sessionId,
|
|
232
|
+
beforeMs: opts.beforeMs,
|
|
233
|
+
last: opts.last,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return renderRecallL1(db, {
|
|
237
|
+
sessionId: opts.sessionId,
|
|
238
|
+
beforeMs: opts.beforeMs,
|
|
239
|
+
skip: opts.skip,
|
|
240
|
+
last: opts.last,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function run(argv) {
|
|
245
|
+
let opts;
|
|
246
|
+
try {
|
|
247
|
+
opts = parseRecallArgs(argv);
|
|
248
|
+
} catch (err) {
|
|
249
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
250
|
+
return 1;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const dbPath = opts.dbPath ?? defaultRecallDbPath();
|
|
254
|
+
if (!existsSync(dbPath)) {
|
|
255
|
+
process.stderr.write(`recall: DB がありません: ${dbPath}(recall は DB を作成しません)\n`);
|
|
256
|
+
return 1;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let db;
|
|
260
|
+
try {
|
|
261
|
+
db = new DatabaseSync(dbPath, { readOnly: true });
|
|
262
|
+
} catch (err) {
|
|
263
|
+
process.stderr.write(
|
|
264
|
+
`recall: DB を read-only で開けませんでした: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
265
|
+
);
|
|
266
|
+
return 1;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
const result = runRecall(db, opts);
|
|
271
|
+
process.stdout.write(`${result.text}\n`);
|
|
272
|
+
return 0;
|
|
273
|
+
} catch (err) {
|
|
274
|
+
process.stderr.write(`recall: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
275
|
+
return 1;
|
|
276
|
+
} finally {
|
|
277
|
+
db.close();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
9
|
+
import { parseRecallArgs, renderRecallL2, renderRecallL1 } from './recall.mjs';
|
|
10
|
+
|
|
11
|
+
const BIN = fileURLToPath(new URL('../../bin/throughline.mjs', import.meta.url));
|
|
12
|
+
|
|
13
|
+
function makeDb(path = ':memory:') {
|
|
14
|
+
const db = new DatabaseSync(path);
|
|
15
|
+
db.exec(`
|
|
16
|
+
CREATE TABLE skeletons (
|
|
17
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
18
|
+
session_id TEXT NOT NULL,
|
|
19
|
+
origin_session_id TEXT,
|
|
20
|
+
turn_number INTEGER NOT NULL,
|
|
21
|
+
role TEXT NOT NULL,
|
|
22
|
+
summary TEXT NOT NULL,
|
|
23
|
+
created_at INTEGER NOT NULL
|
|
24
|
+
);
|
|
25
|
+
CREATE TABLE bodies (
|
|
26
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
27
|
+
session_id TEXT NOT NULL,
|
|
28
|
+
origin_session_id TEXT NOT NULL,
|
|
29
|
+
turn_number INTEGER NOT NULL,
|
|
30
|
+
role TEXT NOT NULL,
|
|
31
|
+
text TEXT NOT NULL,
|
|
32
|
+
token_count INTEGER,
|
|
33
|
+
created_at INTEGER NOT NULL
|
|
34
|
+
);
|
|
35
|
+
CREATE TABLE details (
|
|
36
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
37
|
+
session_id TEXT NOT NULL,
|
|
38
|
+
origin_session_id TEXT,
|
|
39
|
+
turn_number INTEGER,
|
|
40
|
+
tool_name TEXT NOT NULL,
|
|
41
|
+
input_text TEXT,
|
|
42
|
+
output_text TEXT,
|
|
43
|
+
token_count INTEGER NOT NULL DEFAULT 0,
|
|
44
|
+
created_at INTEGER NOT NULL,
|
|
45
|
+
kind TEXT,
|
|
46
|
+
source_id TEXT
|
|
47
|
+
);
|
|
48
|
+
`);
|
|
49
|
+
return db;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function insertBody(db, { session, origin, turn, role, text, createdAt }) {
|
|
53
|
+
db.prepare(
|
|
54
|
+
`INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
|
|
55
|
+
VALUES (?, ?, ?, ?, ?, 1, ?)`,
|
|
56
|
+
).run(session, origin, turn, role, text, createdAt);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function insertSkeleton(db, { session, origin, turn, role, summary, createdAt }) {
|
|
60
|
+
db.prepare(
|
|
61
|
+
`INSERT INTO skeletons (session_id, origin_session_id, turn_number, role, summary, created_at)
|
|
62
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
63
|
+
).run(session, origin, turn, role, summary, createdAt);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// day: 2026-07-17T23:50:00Z 起点の ms を返す (深夜跨ぎテスト用)
|
|
67
|
+
const BASE = Date.parse('2026-07-17T23:50:00.000Z');
|
|
68
|
+
|
|
69
|
+
function seedTurns(db, { session = 's1', origin = 'o1', turns = 10, stepMs = 60_000 } = {}) {
|
|
70
|
+
for (let t = 1; t <= turns; t += 1) {
|
|
71
|
+
insertBody(db, {
|
|
72
|
+
session, origin, turn: t, role: 'user',
|
|
73
|
+
text: `q-${String(t).padStart(2, '0')}`, createdAt: BASE + t * stepMs,
|
|
74
|
+
});
|
|
75
|
+
insertBody(db, {
|
|
76
|
+
session, origin, turn: t, role: 'assistant',
|
|
77
|
+
text: `a-${String(t).padStart(2, '0')}`, createdAt: BASE + t * stepMs + 1_000,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
test('parseRecallArgs: mode/session/before are required, --l2 requires --last', () => {
|
|
83
|
+
assert.throws(() => parseRecallArgs([]), /--l2 または --l1/);
|
|
84
|
+
assert.throws(() => parseRecallArgs(['--l2', '--session', 's', '--last', '3']), /--before は必須/);
|
|
85
|
+
assert.throws(() => parseRecallArgs(['--l2', '--before', '2026-07-18T00:00:00Z', '--last', '3']), /--session は必須/);
|
|
86
|
+
assert.throws(
|
|
87
|
+
() => parseRecallArgs(['--l2', '--session', 's', '--before', '2026-07-18T00:00:00Z']),
|
|
88
|
+
/--last <N> が必須/,
|
|
89
|
+
);
|
|
90
|
+
assert.throws(() => parseRecallArgs(['--l2', '--l1']), /同時に指定できません/);
|
|
91
|
+
assert.throws(
|
|
92
|
+
() => parseRecallArgs(['--l2', '--session', 's', '--before', '14:02:11', '--last', '3']),
|
|
93
|
+
/解釈できません/,
|
|
94
|
+
'HH:MM:SS 単独は日付が無く受け付けない (深夜跨ぎで壊れるため ISO 必須)',
|
|
95
|
+
);
|
|
96
|
+
const ok = parseRecallArgs([
|
|
97
|
+
'--l1', '--session', 's', '--before', '2026-07-18T00:00:00.482Z', '--skip', '7',
|
|
98
|
+
]);
|
|
99
|
+
assert.equal(ok.mode, 'l1');
|
|
100
|
+
assert.equal(ok.beforeMs, Date.parse('2026-07-18T00:00:00.482Z'));
|
|
101
|
+
assert.equal(ok.skip, 7);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('recall --l2: strict less-than ms boundary, newest-first selection, oldest-first output', () => {
|
|
105
|
+
const db = makeDb();
|
|
106
|
+
seedTurns(db, { turns: 10 });
|
|
107
|
+
// 境界 = turn 8 の min(created_at) → turn 7 以前だけが対象 (turn 8 自身は含まない)
|
|
108
|
+
const boundary = BASE + 8 * 60_000;
|
|
109
|
+
|
|
110
|
+
const { text, turnCount } = renderRecallL2(db, { sessionId: 's1', beforeMs: boundary, last: 3 });
|
|
111
|
+
|
|
112
|
+
assert.equal(turnCount, 3);
|
|
113
|
+
assert.ok(!text.includes('a-08'), 'boundary turn itself must be excluded (strict less-than)');
|
|
114
|
+
assert.ok(text.includes('q-05') && text.includes('a-07'), 'turns 5..7 must be returned');
|
|
115
|
+
assert.ok(!text.includes('q-04 '), 'turns older than --last window are not returned');
|
|
116
|
+
// 古い順: q-05 が a-07 より先
|
|
117
|
+
assert.ok(text.indexOf('q-05') < text.indexOf('a-07'));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('recall --l2: same-second sibling rows are split exactly by ms boundary (no gap, no overlap)', () => {
|
|
121
|
+
const db = makeDb();
|
|
122
|
+
const sec = Date.parse('2026-07-18T01:02:03.000Z');
|
|
123
|
+
// 同一秒内に 2 ターン (ms 差のみ)
|
|
124
|
+
insertBody(db, { session: 's1', origin: 'o1', turn: 1, role: 'assistant', text: 'ms-100', createdAt: sec + 100 });
|
|
125
|
+
insertBody(db, { session: 's1', origin: 'o1', turn: 2, role: 'assistant', text: 'ms-482', createdAt: sec + 482 });
|
|
126
|
+
|
|
127
|
+
const { text } = renderRecallL2(db, { sessionId: 's1', beforeMs: sec + 482, last: 10 });
|
|
128
|
+
assert.ok(text.includes('ms-100'), 'older same-second row must be included');
|
|
129
|
+
assert.ok(!text.includes('ms-482'), 'boundary row itself must be excluded');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('recall --l2: ISO boundary works across midnight (no today-anchored resolution)', () => {
|
|
133
|
+
const db = makeDb();
|
|
134
|
+
seedTurns(db, { turns: 10 }); // BASE=23:50Z 起点、turn 10 は翌日 00:30Z 台
|
|
135
|
+
const boundary = BASE + 10 * 60_000; // 日付跨ぎ後の turn 10 の min
|
|
136
|
+
|
|
137
|
+
const { text, turnCount } = renderRecallL2(db, { sessionId: 's1', beforeMs: boundary, last: 20 });
|
|
138
|
+
assert.equal(turnCount, 9, 'all 9 turns before the post-midnight boundary must be found');
|
|
139
|
+
assert.ok(text.includes('q-01'));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('recall --l2: results do not shift when the new session appends turns (no window recomputation)', () => {
|
|
143
|
+
const db = makeDb();
|
|
144
|
+
seedTurns(db, { turns: 10 });
|
|
145
|
+
const boundary = BASE + 8 * 60_000;
|
|
146
|
+
const before = renderRecallL2(db, { sessionId: 's1', beforeMs: boundary, last: 3 });
|
|
147
|
+
|
|
148
|
+
// 新セッションのターンが同じ session_id へ大量に追記されても (merge 後の走行)、
|
|
149
|
+
// recall の結果は焼き込まれた境界だけで決まり不変
|
|
150
|
+
for (let t = 100; t < 130; t += 1) {
|
|
151
|
+
insertBody(db, {
|
|
152
|
+
session: 's1', origin: 'newborn', turn: t, role: 'assistant',
|
|
153
|
+
text: `new-${t}`, createdAt: BASE + t * 60_000,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
const after = renderRecallL2(db, { sessionId: 's1', beforeMs: boundary, last: 3 });
|
|
157
|
+
assert.equal(after.text, before.text);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('recall --l2: fewer rows than --last is announced, not silent', () => {
|
|
161
|
+
const db = makeDb();
|
|
162
|
+
seedTurns(db, { turns: 3 });
|
|
163
|
+
const { text, turnCount } = renderRecallL2(db, {
|
|
164
|
+
sessionId: 's1', beforeMs: BASE + 3 * 60_000, last: 13,
|
|
165
|
+
});
|
|
166
|
+
assert.equal(turnCount, 2);
|
|
167
|
+
assert.match(text, /--last 13 のうち DB に存在するのは 2 ターンのみ/);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('recall --l2: other sessions are not mixed in', () => {
|
|
171
|
+
const db = makeDb();
|
|
172
|
+
seedTurns(db, { turns: 3 });
|
|
173
|
+
insertBody(db, {
|
|
174
|
+
session: 'codex:zzz', origin: 'codex:zzz', turn: 1, role: 'assistant',
|
|
175
|
+
text: 'codex-row', createdAt: BASE + 60_000,
|
|
176
|
+
});
|
|
177
|
+
const { text } = renderRecallL2(db, { sessionId: 's1', beforeMs: BASE + 10 * 60_000, last: 20 });
|
|
178
|
+
assert.ok(!text.includes('codex-row'));
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('recall --l2: L3 suffix appears on the last row of each turn', () => {
|
|
182
|
+
const db = makeDb();
|
|
183
|
+
seedTurns(db, { turns: 3 });
|
|
184
|
+
db.prepare(
|
|
185
|
+
`INSERT INTO details (session_id, origin_session_id, turn_number, tool_name, input_text, output_text, token_count, created_at, kind, source_id)
|
|
186
|
+
VALUES ('s1', 'o1', 1, 'Bash', 'ls', 'ok', 1, ?, 'tool_input', 'tu-1')`,
|
|
187
|
+
).run(BASE + 60_000 + 500);
|
|
188
|
+
const { text } = renderRecallL2(db, { sessionId: 's1', beforeMs: BASE + 10 * 60_000, last: 20 });
|
|
189
|
+
assert.match(text, /a-01 \(詳細:Bash\)/);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('recall --l1: skip jumps over the --l2 range; summarized and unsummarized turns are both listed', () => {
|
|
193
|
+
const db = makeDb();
|
|
194
|
+
seedTurns(db, { turns: 10 });
|
|
195
|
+
// turn 1, 2 だけ要約済み
|
|
196
|
+
insertSkeleton(db, { session: 's1', origin: 'o1', turn: 1, role: 'assistant', summary: 'sum-1', createdAt: BASE });
|
|
197
|
+
insertSkeleton(db, { session: 's1', origin: 'o1', turn: 2, role: 'assistant', summary: 'sum-2', createdAt: BASE });
|
|
198
|
+
|
|
199
|
+
// 境界 = turn 8 の min。--skip 3 で turn 7..5 を飛ばし、turn 4 以前が対象
|
|
200
|
+
const boundary = BASE + 8 * 60_000;
|
|
201
|
+
const { text, turnCount, summarizedCount } = renderRecallL1(db, {
|
|
202
|
+
sessionId: 's1', beforeMs: boundary, skip: 3,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
assert.equal(turnCount, 4, 'turns 1..4 must be listed');
|
|
206
|
+
assert.equal(summarizedCount, 2);
|
|
207
|
+
assert.match(text, /全4ターン \/ 要約済み 2/);
|
|
208
|
+
assert.ok(text.includes('sum-1') && text.includes('sum-2'));
|
|
209
|
+
assert.match(text, /\(未要約\) 全文: throughline detail \d{2}:\d{2}:\d{2}/);
|
|
210
|
+
assert.ok(!text.includes('q-05'), 'skipped turns (--l2 range) must not appear');
|
|
211
|
+
// 古い順: sum-1 が未要約 turn 4 より先
|
|
212
|
+
assert.ok(text.indexOf('sum-1') < text.indexOf('(未要約)'));
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('recall --l1: empty result is explicit', () => {
|
|
216
|
+
const db = makeDb();
|
|
217
|
+
seedTurns(db, { turns: 2 });
|
|
218
|
+
const { text, turnCount } = renderRecallL1(db, {
|
|
219
|
+
sessionId: 's1', beforeMs: BASE + 2 * 60_000, skip: 5,
|
|
220
|
+
});
|
|
221
|
+
assert.equal(turnCount, 0);
|
|
222
|
+
assert.match(text, /該当ターンなし/);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('recall CLI: read-only contract — missing DB is an explicit error and is not created', () => {
|
|
226
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-recall-'));
|
|
227
|
+
const dbPath = join(dir, 'nope', 'throughline.db');
|
|
228
|
+
try {
|
|
229
|
+
let failed = false;
|
|
230
|
+
try {
|
|
231
|
+
execFileSync(
|
|
232
|
+
process.execPath,
|
|
233
|
+
[BIN, 'recall', '--l2', '--session', 's1',
|
|
234
|
+
'--before', '2026-07-18T00:00:00Z', '--last', '3', '--db', dbPath],
|
|
235
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
|
|
236
|
+
);
|
|
237
|
+
} catch (err) {
|
|
238
|
+
failed = true;
|
|
239
|
+
assert.match(String(err.stderr), /DB がありません/);
|
|
240
|
+
}
|
|
241
|
+
assert.ok(failed, 'missing DB must exit non-zero');
|
|
242
|
+
assert.ok(!existsSync(dbPath), 'recall must not create the DB');
|
|
243
|
+
} finally {
|
|
244
|
+
rmSync(dir, { recursive: true, force: true });
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('recall CLI: end-to-end via bin dispatcher against a real DB file', () => {
|
|
249
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-recall-'));
|
|
250
|
+
const dbPath = join(dir, 'throughline.db');
|
|
251
|
+
try {
|
|
252
|
+
const db = makeDb(dbPath);
|
|
253
|
+
seedTurns(db, { turns: 5 });
|
|
254
|
+
db.close();
|
|
255
|
+
|
|
256
|
+
const boundaryIso = new Date(BASE + 4 * 60_000).toISOString();
|
|
257
|
+
const out = execFileSync(
|
|
258
|
+
process.execPath,
|
|
259
|
+
[BIN, 'recall', '--l2', '--session', 's1',
|
|
260
|
+
'--before', boundaryIso, '--last', '2', '--db', dbPath],
|
|
261
|
+
{ encoding: 'utf8' },
|
|
262
|
+
);
|
|
263
|
+
assert.match(out, /## Throughline recall \(L2\): 2ターン/);
|
|
264
|
+
assert.ok(out.includes('a-03') && out.includes('q-02'));
|
|
265
|
+
assert.ok(!out.includes('a-04'), 'boundary turn must be excluded');
|
|
266
|
+
} finally {
|
|
267
|
+
rmSync(dir, { recursive: true, force: true });
|
|
268
|
+
}
|
|
269
|
+
});
|
|
@@ -20,7 +20,8 @@ export const COMPLETED_TURN_RECEIPT_SCHEMA_VERSION = '1.0';
|
|
|
20
20
|
export const COMPLETED_TURN_RECEIPT_LIMIT = 256;
|
|
21
21
|
|
|
22
22
|
const PRIVATE_DIRECTORY_CAPABILITY = Symbol('throughline.completed-turn-receipt-directory');
|
|
23
|
-
|
|
23
|
+
// CI実測でPowerShellコールドスタートが3.0〜3.2秒に達しflakeしたため15秒 (run 29586852389 / 29628634501)
|
|
24
|
+
const WINDOWS_ACL_TIMEOUT_MS = 15_000;
|
|
24
25
|
|
|
25
26
|
export function defaultCompletedTurnReceiptStorePath(projectSha256, env = process.env) {
|
|
26
27
|
if (!isSha256(projectSha256)) throw new TypeError('projectSha256 must be a SHA-256 digest');
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { CURRENT_VERSION } from './db.mjs';
|
|
2
|
+
|
|
1
3
|
export const FACTORY_DIAGNOSTICS_SCHEMA = 'throughline.native_factory_diagnostics.v1';
|
|
2
|
-
export const DATABASE_SCHEMA =
|
|
4
|
+
export const DATABASE_SCHEMA = `throughline.database.v${CURRENT_VERSION}`;
|
|
3
5
|
|
|
4
6
|
const STATUSES = new Set(['ready', 'not_ready', 'not_applicable', 'unverified']);
|
|
5
7
|
|
|
@@ -2,9 +2,26 @@ import { test } from 'node:test';
|
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
|
+
DATABASE_SCHEMA,
|
|
5
6
|
FACTORY_DIAGNOSTICS_SCHEMA,
|
|
6
7
|
buildFactoryDiagnostics,
|
|
7
8
|
} from './factory-diagnostics.mjs';
|
|
9
|
+
import { CURRENT_VERSION } from './db.mjs';
|
|
10
|
+
|
|
11
|
+
test('factory diagnostics: database label は DB 実体・対応 version と一致する', () => {
|
|
12
|
+
const result = buildFactoryDiagnostics({
|
|
13
|
+
database: {
|
|
14
|
+
status: 'ready',
|
|
15
|
+
schemaVersion: CURRENT_VERSION,
|
|
16
|
+
supportedSchemaVersion: CURRENT_VERSION,
|
|
17
|
+
handoffMemory: true,
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
assert.equal(DATABASE_SCHEMA, `throughline.database.v${CURRENT_VERSION}`);
|
|
22
|
+
assert.equal(result.databaseSchema.schema, `throughline.database.v${result.databaseSchema.databaseSchemaVersion}`);
|
|
23
|
+
assert.equal(result.databaseSchema.databaseSchemaVersion, result.databaseSchema.supportedDatabaseSchemaVersion);
|
|
24
|
+
});
|
|
8
25
|
|
|
9
26
|
test('factory diagnostics: 未設定の native factory は not_applicable を成功へ丸めない', () => {
|
|
10
27
|
const result = buildFactoryDiagnostics({
|
package/src/handoff-executor.mjs
CHANGED
|
@@ -140,8 +140,10 @@ export function executeFirstPromptHandoff(db, { sessionId, projectPath, now = Da
|
|
|
140
140
|
injectionText = budgeted.text;
|
|
141
141
|
injectionStats = {
|
|
142
142
|
total_chars: budgeted.totalChars,
|
|
143
|
-
|
|
144
|
-
|
|
143
|
+
injected_l2_turns: budgeted.injectedL2Turns,
|
|
144
|
+
remaining_l2_turns: budgeted.remainingL2Turns,
|
|
145
|
+
older_turns: budgeted.olderTurns,
|
|
146
|
+
older_summarized: budgeted.olderSummarized,
|
|
145
147
|
truncated_newest_l2: budgeted.truncatedNewestL2,
|
|
146
148
|
};
|
|
147
149
|
}
|