throughline 0.9.1 → 0.10.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 +28 -0
- package/README.ja.md +40 -2
- package/README.md +42 -3
- package/bin/throughline.mjs +12 -1
- package/docs/00_overview.md +2 -0
- package/docs/02_clear_auto_handoff_plan.md +6 -0
- package/docs/04_public_release_plan.md +2 -2
- package/docs/adr/0021-grok-host-capture.md +60 -0
- package/docs/plan_grok-successor-launch.md +99 -0
- package/package.json +4 -2
- package/src/cli/grok-continue.mjs +197 -0
- package/src/cli/grok-continue.test.mjs +239 -0
- package/src/cli/handoff-context.mjs +19 -0
- package/src/cli/handoff-context.test.mjs +13 -0
- package/src/cli/install.mjs +62 -0
- package/src/cli/install.test.mjs +46 -0
- package/src/grok-history-inject.mjs +71 -0
- package/src/grok-history-inject.test.mjs +69 -0
- package/src/hook-entrypoints.test.mjs +257 -44
- package/src/hook-envelope.mjs +43 -4
- package/src/hook-envelope.test.mjs +79 -0
- package/src/prompt-submit.mjs +85 -18
- package/src/prompt-submit.test.mjs +58 -1
- package/src/session-start.mjs +3 -3
- package/src/transcript-reader-grok.test.mjs +37 -0
- package/src/transcript-reader.mjs +7 -3
- package/src/turn-processor.mjs +8 -7
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
commandTextFromPrompt,
|
|
5
|
+
isBatonCommand,
|
|
6
|
+
isClearCommand,
|
|
7
|
+
launchGrokContinueAfterTl,
|
|
8
|
+
shouldLaunchGrokContinue,
|
|
9
|
+
} from './prompt-submit.mjs';
|
|
4
10
|
|
|
5
11
|
test('isBatonCommand: bare /tl', () => {
|
|
6
12
|
assert.equal(isBatonCommand('/tl'), true);
|
|
@@ -64,3 +70,54 @@ test('isClearCommand: rejects empty / non-string', () => {
|
|
|
64
70
|
assert.equal(isClearCommand(undefined), false);
|
|
65
71
|
assert.equal(isClearCommand(42), false);
|
|
66
72
|
});
|
|
73
|
+
|
|
74
|
+
test('commandTextFromPrompt unwraps Grok user_query and leaves bare Claude text', () => {
|
|
75
|
+
assert.equal(commandTextFromPrompt('/tl'), '/tl');
|
|
76
|
+
assert.equal(
|
|
77
|
+
commandTextFromPrompt('<user_query>\n/tl\n</user_query>\n<skill_information>saved</skill_information>'),
|
|
78
|
+
'/tl',
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('shouldLaunchGrokContinue is only Grok /tl', () => {
|
|
83
|
+
assert.equal(shouldLaunchGrokContinue({ sessionId: 'grok:abc', trigger: 'tl' }), true);
|
|
84
|
+
assert.equal(shouldLaunchGrokContinue({ sessionId: 'grok:abc', trigger: 'clear' }), false);
|
|
85
|
+
assert.equal(shouldLaunchGrokContinue({ sessionId: 'old-session', trigger: 'tl' }), false);
|
|
86
|
+
assert.equal(shouldLaunchGrokContinue({ sessionId: 'codex:thread', trigger: 'tl' }), false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('launchGrokContinueAfterTl calls grok-continue with the source session', () => {
|
|
90
|
+
const calls = [];
|
|
91
|
+
const code = launchGrokContinueAfterTl({
|
|
92
|
+
sessionId: 'grok:abc',
|
|
93
|
+
cwd: '/work/Throughline',
|
|
94
|
+
continueRun: (argv, opts) => {
|
|
95
|
+
calls.push({ argv, opts });
|
|
96
|
+
return 0;
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
assert.equal(code, 0);
|
|
100
|
+
assert.deepEqual(calls, [{
|
|
101
|
+
argv: ['--session', 'grok:abc'],
|
|
102
|
+
opts: { cwd: '/work/Throughline' },
|
|
103
|
+
}]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('isBatonCommand: Grok user_query wrap around /tl', () => {
|
|
107
|
+
const wrapped =
|
|
108
|
+
'<user_query>\n/tl\n</user_query>\n<skill_information>\nThroughline saved the baton.\n</skill_information>';
|
|
109
|
+
assert.equal(isBatonCommand(wrapped), true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('isBatonCommand: Grok wrap does not treat skill body /tl as the command', () => {
|
|
113
|
+
const wrapped =
|
|
114
|
+
'<user_query>\n続けてくれ\n</user_query>\n<skill_information>\nType /tl to hand off.\n</skill_information>';
|
|
115
|
+
assert.equal(isBatonCommand(wrapped), false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('isClearCommand: Grok user_query wrap around /clear and /new', () => {
|
|
119
|
+
assert.equal(isClearCommand('<user_query>\n/clear\n</user_query>'), true);
|
|
120
|
+
assert.equal(isClearCommand('<user_query>\n/new\n</user_query>'), true);
|
|
121
|
+
assert.equal(isClearCommand('/new'), true);
|
|
122
|
+
assert.equal(isClearCommand('/newest'), false);
|
|
123
|
+
});
|
package/src/session-start.mjs
CHANGED
|
@@ -32,7 +32,7 @@ import { logDecision } from './decision-log.mjs';
|
|
|
32
32
|
import { existsSync } from 'node:fs';
|
|
33
33
|
import { pathToFileURL } from 'node:url';
|
|
34
34
|
import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
|
|
35
|
-
import {
|
|
35
|
+
import { normalizeHookPayload } from './hook-envelope.mjs';
|
|
36
36
|
|
|
37
37
|
const ENV_DISABLE_AUTO_HANDOFF = 'THROUGHLINE_DISABLE_AUTO_HANDOFF';
|
|
38
38
|
|
|
@@ -62,6 +62,7 @@ function findLatestClaudePredecessor(db, projectPath, currentSessionId) {
|
|
|
62
62
|
AND merged_into IS NULL
|
|
63
63
|
AND session_id != ?
|
|
64
64
|
AND session_id NOT LIKE 'codex:%'
|
|
65
|
+
AND session_id NOT LIKE 'grok:%'
|
|
65
66
|
ORDER BY updated_at DESC
|
|
66
67
|
LIMIT 5`,
|
|
67
68
|
)
|
|
@@ -91,8 +92,7 @@ export async function run() {
|
|
|
91
92
|
process.stdin.on('end', resolve);
|
|
92
93
|
});
|
|
93
94
|
|
|
94
|
-
const payload = JSON.parse(raw);
|
|
95
|
-
if (isUnsupportedNonClaudeEnvelope(payload)) return;
|
|
95
|
+
const payload = normalizeHookPayload(JSON.parse(raw));
|
|
96
96
|
const { session_id, cwd, source, transcript_path } = payload;
|
|
97
97
|
|
|
98
98
|
if (!session_id) throw new Error('Missing session_id in SessionStart payload');
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { getLogicalTurnGroups, readTranscript } from './transcript-reader.mjs';
|
|
8
|
+
|
|
9
|
+
test('readTranscript accepts Grok chat_history.jsonl user/assistant rows', () => {
|
|
10
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-grok-transcript-'));
|
|
11
|
+
const path = join(dir, 'chat_history.jsonl');
|
|
12
|
+
writeFileSync(
|
|
13
|
+
path,
|
|
14
|
+
[
|
|
15
|
+
JSON.stringify({ type: 'system', content: 'ignore' }),
|
|
16
|
+
JSON.stringify({ type: 'user', content: [{ type: 'text', text: 'hello grok' }] }),
|
|
17
|
+
JSON.stringify({ type: 'assistant', content: 'captured reply' }),
|
|
18
|
+
JSON.stringify({ type: 'tool_result', content: 'not a turn' }),
|
|
19
|
+
].join('\n'),
|
|
20
|
+
);
|
|
21
|
+
try {
|
|
22
|
+
const turns = readTranscript(path);
|
|
23
|
+
assert.deepEqual(
|
|
24
|
+
turns.map((t) => ({ role: t.role, content: t.content })),
|
|
25
|
+
[
|
|
26
|
+
{ role: 'user', content: 'hello grok' },
|
|
27
|
+
{ role: 'assistant', content: 'captured reply' },
|
|
28
|
+
],
|
|
29
|
+
);
|
|
30
|
+
const groups = getLogicalTurnGroups(path);
|
|
31
|
+
assert.equal(groups.length, 1);
|
|
32
|
+
assert.equal(groups[0].user.content, 'hello grok');
|
|
33
|
+
assert.equal(groups[0].representative.content, 'captured reply');
|
|
34
|
+
} finally {
|
|
35
|
+
rmSync(dir, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
});
|
|
@@ -59,14 +59,18 @@ export function readTranscript(transcriptPath) {
|
|
|
59
59
|
if (entry.isSidechain === true) continue;
|
|
60
60
|
|
|
61
61
|
const msg = entry.message;
|
|
62
|
-
|
|
62
|
+
const grokContent = entry.content;
|
|
63
|
+
const role = msg?.role ?? entry.type;
|
|
64
|
+
const rawContent = msg?.content ?? grokContent;
|
|
65
|
+
if (!role || rawContent == null) continue;
|
|
63
66
|
|
|
64
|
-
const text = extractText(
|
|
67
|
+
const text = extractText(rawContent);
|
|
65
68
|
if (!text) continue;
|
|
66
69
|
|
|
67
70
|
const ts = typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : NaN;
|
|
71
|
+
// grok chat_history.jsonl は Claude の message 包みを持たず type/content 直置き。
|
|
68
72
|
turns.push({
|
|
69
|
-
role
|
|
73
|
+
role,
|
|
70
74
|
content: text,
|
|
71
75
|
turn_number: turns.length,
|
|
72
76
|
timestamp: Number.isNaN(ts) ? null : ts,
|
package/src/turn-processor.mjs
CHANGED
|
@@ -46,7 +46,7 @@ import { readLatestUsage } from './transcript-usage.mjs';
|
|
|
46
46
|
import { pathToFileURL } from 'node:url';
|
|
47
47
|
import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
|
|
48
48
|
import { writeCompletedTurnReceipt } from './completed-turn-receipts.mjs';
|
|
49
|
-
import {
|
|
49
|
+
import { normalizeHookPayload } from './hook-envelope.mjs';
|
|
50
50
|
|
|
51
51
|
/** 直近 N ターンは bodies を生で残し、それより古いものだけ L1 要約する。 */
|
|
52
52
|
export const L2_WINDOW = 20;
|
|
@@ -191,8 +191,7 @@ export async function run() {
|
|
|
191
191
|
process.stdin.on('end', resolve);
|
|
192
192
|
});
|
|
193
193
|
|
|
194
|
-
const payload = JSON.parse(raw || '{}');
|
|
195
|
-
if (isUnsupportedNonClaudeEnvelope(payload)) return;
|
|
194
|
+
const payload = normalizeHookPayload(JSON.parse(raw || '{}'));
|
|
196
195
|
const { session_id, transcript_path, cwd, last_assistant_message } = payload;
|
|
197
196
|
if (!session_id) throw new Error('Missing session_id in Stop payload');
|
|
198
197
|
|
|
@@ -206,10 +205,12 @@ export async function run() {
|
|
|
206
205
|
process.stderr.write(`[vscode-task] ${msg}\n`);
|
|
207
206
|
}
|
|
208
207
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
208
|
+
if (!session_id.startsWith('grok:')) {
|
|
209
|
+
await waitForClaudeStopTranscriptFlush({
|
|
210
|
+
transcriptPath: transcript_path,
|
|
211
|
+
lastAssistantMessage: last_assistant_message,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
213
214
|
|
|
214
215
|
// Stop hook 時点で state ファイルを更新 → token-monitor の「アクティブ行」判定が
|
|
215
216
|
// アシスタント応答終了時刻まで追従する
|