throughline 0.4.11 → 0.5.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/CHANGELOG.md +89 -0
- package/README.md +9 -2
- package/docs/RAG/INDEX.md +160 -0
- package/docs/RAG/_raw/01-hooks/hooks-reference-extract.md +250 -0
- package/docs/RAG/_raw/02-messages-api/messages-api-extract.md +126 -0
- package/docs/RAG/_raw/03-settings/sessions-extract.md +64 -0
- package/docs/RAG/_raw/04-skills/initialUserMessage-investigation.md +101 -0
- package/docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md +7 -4
- package/docs/THROUGHLINE_TRANSCRIPT_INJECTION_PLAN.md +446 -0
- package/package.json +1 -1
- package/src/hook-entrypoints.test.mjs +288 -0
- package/src/prompt-submit.mjs +131 -4
- package/src/resume-context.mjs +79 -6
- package/src/resume-context.test.mjs +161 -9
- package/src/session-start.mjs +95 -13
- package/src/spike-transcript-writer.mjs +196 -0
- package/src/spike-transcript-writer.test.mjs +298 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { spikeInject, generateSpikeTracer } from './spike-transcript-writer.mjs';
|
|
8
|
+
|
|
9
|
+
function makeDb() {
|
|
10
|
+
const db = new DatabaseSync(':memory:');
|
|
11
|
+
db.exec(`
|
|
12
|
+
CREATE TABLE sessions (
|
|
13
|
+
session_id TEXT PRIMARY KEY,
|
|
14
|
+
project_path TEXT NOT NULL,
|
|
15
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
16
|
+
created_at INTEGER NOT NULL,
|
|
17
|
+
updated_at INTEGER NOT NULL,
|
|
18
|
+
merged_into TEXT
|
|
19
|
+
);
|
|
20
|
+
CREATE TABLE skeletons (
|
|
21
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
22
|
+
session_id TEXT NOT NULL,
|
|
23
|
+
origin_session_id TEXT,
|
|
24
|
+
turn_number INTEGER NOT NULL,
|
|
25
|
+
role TEXT NOT NULL,
|
|
26
|
+
summary TEXT NOT NULL,
|
|
27
|
+
created_at INTEGER NOT NULL
|
|
28
|
+
);
|
|
29
|
+
CREATE TABLE bodies (
|
|
30
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
31
|
+
session_id TEXT NOT NULL,
|
|
32
|
+
origin_session_id TEXT NOT NULL,
|
|
33
|
+
turn_number INTEGER NOT NULL,
|
|
34
|
+
role TEXT NOT NULL,
|
|
35
|
+
text TEXT NOT NULL,
|
|
36
|
+
token_count INTEGER,
|
|
37
|
+
created_at INTEGER NOT NULL
|
|
38
|
+
);
|
|
39
|
+
CREATE TABLE details (
|
|
40
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
41
|
+
session_id TEXT NOT NULL,
|
|
42
|
+
origin_session_id TEXT,
|
|
43
|
+
turn_number INTEGER,
|
|
44
|
+
tool_name TEXT NOT NULL,
|
|
45
|
+
input_text TEXT,
|
|
46
|
+
output_text TEXT,
|
|
47
|
+
token_count INTEGER NOT NULL DEFAULT 0,
|
|
48
|
+
created_at INTEGER NOT NULL,
|
|
49
|
+
kind TEXT,
|
|
50
|
+
source_id TEXT
|
|
51
|
+
);
|
|
52
|
+
`);
|
|
53
|
+
return db;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function seedSession(db, { newSessionId = 'NEW', originId = 'ORIG' } = {}) {
|
|
57
|
+
db.prepare(
|
|
58
|
+
`INSERT INTO sessions (session_id, project_path, status, created_at, updated_at)
|
|
59
|
+
VALUES (?, '/repo', 'active', 1, 2)`,
|
|
60
|
+
).run(newSessionId);
|
|
61
|
+
// 2 user / 2 assistant turn を新 session 配下に origin_session_id=ORIG で配置
|
|
62
|
+
// (handoff-record は inheritance mode で origin != current を recent bodies に拾う)
|
|
63
|
+
const rows = [
|
|
64
|
+
{ turn: 1, role: 'user', text: 'こんにちは。tracer 検証だ', createdAt: 1000 },
|
|
65
|
+
{ turn: 2, role: 'assistant', text: '了解です。テスト用ターン 1', createdAt: 1100 },
|
|
66
|
+
{ turn: 3, role: 'user', text: '続きを書け', createdAt: 1200 },
|
|
67
|
+
{ turn: 4, role: 'assistant', text: '最後のアシスタント発話 (tracer 受け取り側)', createdAt: 1300 },
|
|
68
|
+
];
|
|
69
|
+
for (const r of rows) {
|
|
70
|
+
db.prepare(
|
|
71
|
+
`INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
|
|
72
|
+
VALUES (?, ?, ?, ?, ?, 1, ?)`,
|
|
73
|
+
).run(newSessionId, originId, r.turn, r.role, r.text, r.createdAt);
|
|
74
|
+
}
|
|
75
|
+
return rows;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function makeTempJsonl() {
|
|
79
|
+
const dir = mkdtempSync(join(tmpdir(), 'spike-writer-test-'));
|
|
80
|
+
return join(dir, 'session.jsonl');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
test('generateSpikeTracer returns 8 hex chars', () => {
|
|
84
|
+
const t = generateSpikeTracer();
|
|
85
|
+
assert.match(t, /^[0-9a-f]{8}$/);
|
|
86
|
+
assert.notEqual(t, generateSpikeTracer()); // 衝突しない (実質)
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('spikeInject: returns skipReason when no recentBodies', () => {
|
|
90
|
+
const db = makeDb();
|
|
91
|
+
db.prepare(
|
|
92
|
+
`INSERT INTO sessions (session_id, project_path, status, created_at, updated_at)
|
|
93
|
+
VALUES ('NEW', '/repo', 'active', 1, 2)`,
|
|
94
|
+
).run();
|
|
95
|
+
const target = makeTempJsonl();
|
|
96
|
+
const result = spikeInject({
|
|
97
|
+
db,
|
|
98
|
+
targetJsonlPath: target,
|
|
99
|
+
newSessionId: 'NEW',
|
|
100
|
+
cwd: '/repo',
|
|
101
|
+
version: '2.1.x',
|
|
102
|
+
gitBranch: 'main',
|
|
103
|
+
tracer: 'abcd1234',
|
|
104
|
+
});
|
|
105
|
+
assert.equal(result.appended, 0);
|
|
106
|
+
assert.equal(result.skipReason, 'no_record_or_empty_l2');
|
|
107
|
+
assert.equal(result.tracer, null);
|
|
108
|
+
assert.equal(result.tracerAppendedAt, null);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('spikeInject: appends bodies, embeds tracer in last assistant only', () => {
|
|
112
|
+
const db = makeDb();
|
|
113
|
+
seedSession(db);
|
|
114
|
+
const target = makeTempJsonl();
|
|
115
|
+
const tracer = 'deadbeef';
|
|
116
|
+
|
|
117
|
+
const result = spikeInject({
|
|
118
|
+
db,
|
|
119
|
+
targetJsonlPath: target,
|
|
120
|
+
newSessionId: 'NEW',
|
|
121
|
+
cwd: '/repo',
|
|
122
|
+
version: '2.1.x',
|
|
123
|
+
gitBranch: 'main',
|
|
124
|
+
tracer,
|
|
125
|
+
});
|
|
126
|
+
assert.equal(result.appended, 4);
|
|
127
|
+
assert.equal(result.parentUuidStart, null); // empty file 起点
|
|
128
|
+
assert.equal(result.tracer, tracer);
|
|
129
|
+
assert.equal(result.tracerAppendedAt, 3); // index of last assistant
|
|
130
|
+
|
|
131
|
+
const lines = readFileSync(target, 'utf8').split('\n').filter((l) => l.trim());
|
|
132
|
+
assert.equal(lines.length, 4);
|
|
133
|
+
|
|
134
|
+
const parsed = lines.map((l) => JSON.parse(l));
|
|
135
|
+
// 順序 (古い順): user → assistant → user → assistant
|
|
136
|
+
assert.deepEqual(parsed.map((o) => o.type), ['user', 'assistant', 'user', 'assistant']);
|
|
137
|
+
// tracer は最後の assistant のみ
|
|
138
|
+
const lastAssistant = parsed[3];
|
|
139
|
+
assert.match(lastAssistant.message.content[0].text, /\[spike-tracer: deadbeef\]$/);
|
|
140
|
+
// 中間の assistant には tracer が無い
|
|
141
|
+
const midAssistant = parsed[1];
|
|
142
|
+
assert.doesNotMatch(midAssistant.message.content[0].text, /spike-tracer/);
|
|
143
|
+
// user 行にも無い
|
|
144
|
+
for (const u of [parsed[0], parsed[2]]) {
|
|
145
|
+
assert.doesNotMatch(u.message.content[0].text, /spike-tracer/);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('spikeInject: omits tracer entirely when tracer=null', () => {
|
|
150
|
+
const db = makeDb();
|
|
151
|
+
seedSession(db);
|
|
152
|
+
const target = makeTempJsonl();
|
|
153
|
+
|
|
154
|
+
const result = spikeInject({
|
|
155
|
+
db,
|
|
156
|
+
targetJsonlPath: target,
|
|
157
|
+
newSessionId: 'NEW',
|
|
158
|
+
cwd: '/repo',
|
|
159
|
+
version: '2.1.x',
|
|
160
|
+
gitBranch: 'main',
|
|
161
|
+
tracer: null,
|
|
162
|
+
});
|
|
163
|
+
assert.equal(result.appended, 4);
|
|
164
|
+
assert.equal(result.tracer, null);
|
|
165
|
+
assert.equal(result.tracerAppendedAt, null);
|
|
166
|
+
|
|
167
|
+
const content = readFileSync(target, 'utf8');
|
|
168
|
+
assert.doesNotMatch(content, /spike-tracer/);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('spikeInject: chains from last uuid when target has preexisting lines', () => {
|
|
172
|
+
const db = makeDb();
|
|
173
|
+
seedSession(db);
|
|
174
|
+
const target = makeTempJsonl();
|
|
175
|
+
const preexisting = { type: 'attachment', uuid: 'pre-attachment-uuid', parentUuid: null };
|
|
176
|
+
writeFileSync(target, JSON.stringify(preexisting) + '\n', 'utf8');
|
|
177
|
+
|
|
178
|
+
const result = spikeInject({
|
|
179
|
+
db,
|
|
180
|
+
targetJsonlPath: target,
|
|
181
|
+
newSessionId: 'NEW',
|
|
182
|
+
cwd: '/repo',
|
|
183
|
+
version: '2.1.x',
|
|
184
|
+
gitBranch: 'main',
|
|
185
|
+
tracer: 'cafef00d',
|
|
186
|
+
});
|
|
187
|
+
assert.equal(result.parentUuidStart, 'pre-attachment-uuid');
|
|
188
|
+
|
|
189
|
+
const lines = readFileSync(target, 'utf8').split('\n').filter((l) => l.trim());
|
|
190
|
+
// 1 件目 (preexisting) は preserve、2 件目が最初の spike user
|
|
191
|
+
const firstSpike = JSON.parse(lines[1]);
|
|
192
|
+
assert.equal(firstSpike.parentUuid, 'pre-attachment-uuid');
|
|
193
|
+
// chain は spike 内で繋がる
|
|
194
|
+
const secondSpike = JSON.parse(lines[2]);
|
|
195
|
+
assert.equal(secondSpike.parentUuid, firstSpike.uuid);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('spikeInject: defaults assistant model to a real Claude model name', () => {
|
|
199
|
+
const db = makeDb();
|
|
200
|
+
seedSession(db);
|
|
201
|
+
const target = makeTempJsonl();
|
|
202
|
+
spikeInject({
|
|
203
|
+
db,
|
|
204
|
+
targetJsonlPath: target,
|
|
205
|
+
newSessionId: 'NEW',
|
|
206
|
+
cwd: '/repo',
|
|
207
|
+
version: '2.1.x',
|
|
208
|
+
gitBranch: 'main',
|
|
209
|
+
tracer: 'aabbccdd',
|
|
210
|
+
});
|
|
211
|
+
const lines = readFileSync(target, 'utf8').split('\n').filter((l) => l.trim());
|
|
212
|
+
const firstAssistant = JSON.parse(lines[1]); // index 1 = first assistant in [user, assistant, user, assistant]
|
|
213
|
+
assert.equal(firstAssistant.message.model, 'claude-opus-4-7', 'Phase 0-5 retry: real model name not fake');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('spikeInject: assistantModel override is honored', () => {
|
|
217
|
+
const db = makeDb();
|
|
218
|
+
seedSession(db);
|
|
219
|
+
const target = makeTempJsonl();
|
|
220
|
+
spikeInject({
|
|
221
|
+
db,
|
|
222
|
+
targetJsonlPath: target,
|
|
223
|
+
newSessionId: 'NEW',
|
|
224
|
+
cwd: '/repo',
|
|
225
|
+
version: '2.1.x',
|
|
226
|
+
gitBranch: 'main',
|
|
227
|
+
tracer: '11223344',
|
|
228
|
+
assistantModel: 'claude-overridden-model',
|
|
229
|
+
});
|
|
230
|
+
const lines = readFileSync(target, 'utf8').split('\n').filter((l) => l.trim());
|
|
231
|
+
const firstAssistant = JSON.parse(lines[1]);
|
|
232
|
+
assert.equal(firstAssistant.message.model, 'claude-overridden-model');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('spikeInject: writes Claude Code-compatible shape (top-level fields)', () => {
|
|
236
|
+
const db = makeDb();
|
|
237
|
+
seedSession(db, { newSessionId: 'NEW-SESSION' });
|
|
238
|
+
const target = makeTempJsonl();
|
|
239
|
+
|
|
240
|
+
spikeInject({
|
|
241
|
+
db,
|
|
242
|
+
targetJsonlPath: target,
|
|
243
|
+
newSessionId: 'NEW-SESSION',
|
|
244
|
+
cwd: '/repo',
|
|
245
|
+
version: '2.1.145',
|
|
246
|
+
gitBranch: 'main',
|
|
247
|
+
tracer: '00000000',
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const lines = readFileSync(target, 'utf8').split('\n').filter((l) => l.trim());
|
|
251
|
+
const u = JSON.parse(lines[0]);
|
|
252
|
+
// user turn の必須 14 フィールド (Plan §6 リバース結果より)
|
|
253
|
+
for (const k of [
|
|
254
|
+
'parentUuid',
|
|
255
|
+
'isSidechain',
|
|
256
|
+
'promptId',
|
|
257
|
+
'type',
|
|
258
|
+
'message',
|
|
259
|
+
'uuid',
|
|
260
|
+
'timestamp',
|
|
261
|
+
'permissionMode',
|
|
262
|
+
'userType',
|
|
263
|
+
'entrypoint',
|
|
264
|
+
'cwd',
|
|
265
|
+
'sessionId',
|
|
266
|
+
'version',
|
|
267
|
+
'gitBranch',
|
|
268
|
+
]) {
|
|
269
|
+
assert.ok(k in u, `user line missing field: ${k}`);
|
|
270
|
+
}
|
|
271
|
+
assert.equal(u.type, 'user');
|
|
272
|
+
assert.equal(u.sessionId, 'NEW-SESSION');
|
|
273
|
+
assert.equal(u.version, '2.1.145');
|
|
274
|
+
assert.equal(u.message.role, 'user');
|
|
275
|
+
|
|
276
|
+
const a = JSON.parse(lines[1]);
|
|
277
|
+
// assistant turn の必須 13 フィールド
|
|
278
|
+
for (const k of [
|
|
279
|
+
'parentUuid',
|
|
280
|
+
'isSidechain',
|
|
281
|
+
'message',
|
|
282
|
+
'requestId',
|
|
283
|
+
'type',
|
|
284
|
+
'uuid',
|
|
285
|
+
'timestamp',
|
|
286
|
+
'userType',
|
|
287
|
+
'entrypoint',
|
|
288
|
+
'cwd',
|
|
289
|
+
'sessionId',
|
|
290
|
+
'version',
|
|
291
|
+
'gitBranch',
|
|
292
|
+
]) {
|
|
293
|
+
assert.ok(k in a, `assistant line missing field: ${k}`);
|
|
294
|
+
}
|
|
295
|
+
assert.equal(a.type, 'assistant');
|
|
296
|
+
assert.equal(a.message.role, 'assistant');
|
|
297
|
+
assert.ok(a.message.usage);
|
|
298
|
+
});
|