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.
@@ -0,0 +1,239 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { readFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ import {
10
+ GROK_CONTINUE_PREAMBLE,
11
+ GROK_CONTINUE_REQUEST,
12
+ GROK_CONTINUE_WAIT,
13
+ appleScriptForLaunch,
14
+ buildContinuePlan,
15
+ buildGrokArgv,
16
+ buildGrokContinuePrompt,
17
+ buildLaunchScript,
18
+ parseArgs,
19
+ resolveGrokBin,
20
+ run,
21
+ } from './grok-continue.mjs';
22
+
23
+ const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url));
24
+ const BIN_PATH = join(REPO_ROOT, 'bin/throughline.mjs');
25
+ const CONTEXT = '固有事実:琥珀の合言葉は 9f3c2a。';
26
+
27
+ function capture() {
28
+ let out = '';
29
+ let err = '';
30
+ return {
31
+ stdout: { write(chunk) { out += chunk; return true; } },
32
+ stderr: { write(chunk) { err += chunk; return true; } },
33
+ get out() { return out; },
34
+ get err() { return err; },
35
+ };
36
+ }
37
+
38
+ test('parseArgs accepts --session only', () => {
39
+ assert.deepEqual(parseArgs(['--session', 'grok:abc']), { sessionId: 'grok:abc' });
40
+ assert.throws(() => parseArgs(['--from', 'grok:abc']), /usage error/);
41
+ assert.throws(() => parseArgs(['--session']), /usage error/);
42
+ assert.throws(() => parseArgs([]), /usage error/);
43
+ });
44
+
45
+ test('first-user prompt is the locked text ending in wait', () => {
46
+ const prompt = buildGrokContinuePrompt(CONTEXT);
47
+ assert.equal(
48
+ prompt,
49
+ `${GROK_CONTINUE_PREAMBLE}\n\n${CONTEXT}\n\n${GROK_CONTINUE_REQUEST}\n\n${GROK_CONTINUE_WAIT}`,
50
+ );
51
+ });
52
+
53
+ test('grok argv is interactive grok with session id and no --rules', () => {
54
+ const argv = buildGrokArgv('/opt/grok/bin/grok', '11111111-1111-4111-8111-111111111111', CONTEXT);
55
+ assert.deepEqual(argv, [
56
+ '/opt/grok/bin/grok',
57
+ '--session-id',
58
+ '11111111-1111-4111-8111-111111111111',
59
+ CONTEXT,
60
+ ]);
61
+ assert.equal(argv.includes('--rules'), false);
62
+ assert.equal(argv.some((part) => String(part).includes('aiterm')), false);
63
+ });
64
+
65
+ test('launch script execs grok in the project cwd without --rules or aiterm', () => {
66
+ const script = buildLaunchScript({
67
+ cwd: '/work/Throughline',
68
+ grokBin: '/Users/kite/.grok/bin/grok',
69
+ sessionUuid: '11111111-1111-4111-8111-111111111111',
70
+ promptFile: '/tmp/prompt.txt',
71
+ });
72
+ assert.match(script, /^#!/);
73
+ assert.match(script, /cd '\/work\/Throughline'/);
74
+ assert.match(script, /exec '\/Users\/kite\/\.grok\/bin\/grok' --session-id/);
75
+ assert.equal(script.includes('--rules'), false);
76
+ assert.equal(script.includes('aiterm'), false);
77
+ assert.equal(script.includes('tmux'), false);
78
+ assert.equal(script.includes('subagent'), false);
79
+ });
80
+
81
+ test('macOS launch uses Terminal via osascript, not aiterm', () => {
82
+ const apple = appleScriptForLaunch('/tmp/tl-grok-continue/launch.sh');
83
+ assert.match(apple, /tell application "Terminal"/);
84
+ assert.match(apple, /do script "exec "/);
85
+ assert.equal(apple.includes('aiterm'), false);
86
+ });
87
+
88
+ test('resolveGrokBin prefers ~/.grok/bin/grok', () => {
89
+ const home = '/tmp/tl-home';
90
+ const found = resolveGrokBin({
91
+ home,
92
+ env: { PATH: '/usr/bin' },
93
+ exists: (path) => path === join(home, '.grok', 'bin', 'grok'),
94
+ });
95
+ assert.equal(found, join(home, '.grok', 'bin', 'grok'));
96
+ });
97
+
98
+ test('handoff-context failure does not spawn grok', () => {
99
+ const io = capture();
100
+ const spawned = [];
101
+ const code = run(['--session', 'grok:missing'], {
102
+ ...io,
103
+ readContext: () => null,
104
+ readProjectPath: () => '/work/dotagents',
105
+ resolveBin: () => '/tmp/grok',
106
+ spawnLaunch: (plan) => { spawned.push(plan); },
107
+ platform: 'darwin',
108
+ });
109
+ assert.equal(code, 1);
110
+ assert.equal(spawned.length, 0);
111
+ assert.match(io.err, /not available/);
112
+ });
113
+
114
+ test('handoff-context throw does not spawn grok', () => {
115
+ const io = capture();
116
+ const spawned = [];
117
+ const code = run(['--session', 'grok:broken'], {
118
+ ...io,
119
+ readContext: () => { throw new Error('db'); },
120
+ readProjectPath: () => REPO_ROOT,
121
+ resolveBin: () => '/tmp/grok',
122
+ spawnLaunch: (plan) => { spawned.push(plan); },
123
+ platform: 'darwin',
124
+ });
125
+ assert.equal(code, 1);
126
+ assert.equal(spawned.length, 0);
127
+ });
128
+
129
+ test('missing grok binary does not spawn', () => {
130
+ const io = capture();
131
+ const spawned = [];
132
+ const code = run(['--session', 'grok:ok'], {
133
+ ...io,
134
+ readContext: () => CONTEXT,
135
+ readProjectPath: () => REPO_ROOT,
136
+ resolveBin: () => null,
137
+ spawnLaunch: (plan) => { spawned.push(plan); },
138
+ platform: 'darwin',
139
+ });
140
+ assert.equal(code, 1);
141
+ assert.equal(spawned.length, 0);
142
+ });
143
+
144
+ test('successful continue spawn uses source project cwd and waits', () => {
145
+ const io = capture();
146
+ const spawned = [];
147
+ const uuid = '22222222-2222-4222-8222-222222222222';
148
+ const code = run(['--session', 'grok:source'], {
149
+ ...io,
150
+ readContext: (id) => {
151
+ assert.equal(id, 'grok:source');
152
+ return CONTEXT;
153
+ },
154
+ readProjectPath: (id) => {
155
+ assert.equal(id, 'grok:source');
156
+ return REPO_ROOT;
157
+ },
158
+ resolveBin: () => '/tmp/fake-grok',
159
+ createSessionId: () => uuid,
160
+ spawnLaunch: (plan) => { spawned.push(plan); },
161
+ platform: 'darwin',
162
+ });
163
+ assert.equal(code, 0);
164
+ assert.equal(spawned.length, 1);
165
+ assert.equal(io.out, `grok:${uuid}\n`);
166
+ const { grokArgv, launchFile, cwd } = spawned[0];
167
+ assert.equal(cwd, REPO_ROOT);
168
+ assert.equal(grokArgv.includes('--rules'), false);
169
+ assert.ok(grokArgv.at(-1).includes(CONTEXT));
170
+ assert.ok(grokArgv.at(-1).startsWith(GROK_CONTINUE_PREAMBLE));
171
+ assert.ok(grokArgv.at(-1).endsWith(GROK_CONTINUE_WAIT));
172
+ assert.equal(grokArgv[0], '/tmp/fake-grok');
173
+ assert.equal(grokArgv[1], '--session-id');
174
+ assert.match(readFileSync(launchFile, 'utf8'), /cd '/);
175
+ assert.match(appleScriptForLaunch(launchFile), /Terminal/);
176
+ });
177
+
178
+ test('missing source project path does not spawn', () => {
179
+ const io = capture();
180
+ const spawned = [];
181
+ const code = run(['--session', 'grok:source'], {
182
+ ...io,
183
+ readContext: () => CONTEXT,
184
+ readProjectPath: () => null,
185
+ resolveBin: () => '/tmp/grok',
186
+ spawnLaunch: (plan) => { spawned.push(plan); },
187
+ platform: 'darwin',
188
+ });
189
+ assert.equal(code, 1);
190
+ assert.equal(spawned.length, 0);
191
+ assert.match(io.err, /project path is not available/);
192
+ });
193
+
194
+ test('absent source project directory does not spawn', () => {
195
+ const io = capture();
196
+ const spawned = [];
197
+ const code = run(['--session', 'grok:source'], {
198
+ ...io,
199
+ readContext: () => CONTEXT,
200
+ readProjectPath: () => '/no/such/throughline-parent-project',
201
+ resolveBin: () => '/tmp/grok',
202
+ spawnLaunch: (plan) => { spawned.push(plan); },
203
+ platform: 'darwin',
204
+ });
205
+ assert.equal(code, 1);
206
+ assert.equal(spawned.length, 0);
207
+ assert.match(io.err, /does not exist/);
208
+ });
209
+
210
+ test('plan builder refuses --rules if a caller tries to add it', () => {
211
+ const plan = buildContinuePlan({
212
+ context: CONTEXT,
213
+ grokBin: '/tmp/grok',
214
+ cwd: '/work',
215
+ sessionUuid: '33333333-3333-4333-8333-333333333333',
216
+ });
217
+ assert.equal(plan.grokArgv.includes('--rules'), false);
218
+ });
219
+
220
+ test('bin dispatches grok-continue and help names the command', () => {
221
+ const bin = readFileSync(BIN_PATH, 'utf8');
222
+ assert.match(bin, /case 'grok-continue':/);
223
+ const help = spawnSync(process.execPath, [BIN_PATH, '--help'], {
224
+ cwd: REPO_ROOT,
225
+ encoding: 'utf8',
226
+ });
227
+ assert.equal(help.status, 0, help.stderr);
228
+ assert.match(help.stdout, /throughline grok-continue --session <id>/);
229
+ });
230
+
231
+ test('bin usage error is exit 2 without spawning a real grok', () => {
232
+ const result = spawnSync(process.execPath, [BIN_PATH, 'grok-continue'], {
233
+ cwd: REPO_ROOT,
234
+ encoding: 'utf8',
235
+ env: { ...process.env, HOME: join(tmpdir(), 'tl-grok-continue-missing-home') },
236
+ });
237
+ assert.equal(result.status, 2);
238
+ assert.match(result.stderr, /Usage: throughline grok-continue --session <id>/);
239
+ });
@@ -36,6 +36,25 @@ export function readHandoffContext(sessionId, {
36
36
  }
37
37
  }
38
38
 
39
+ export function readSessionProjectPath(sessionId, {
40
+ dbPath = join(homedir(), '.throughline', 'throughline.db'),
41
+ } = {}) {
42
+ if (!existsSync(dbPath)) return null;
43
+
44
+ const db = new DatabaseSync(dbPath, { readOnly: true });
45
+ try {
46
+ const row = db.prepare(
47
+ 'SELECT project_path FROM sessions WHERE session_id = ?',
48
+ ).get(sessionId);
49
+ const projectPath = row?.project_path;
50
+ return typeof projectPath === 'string' && projectPath.length > 0
51
+ ? projectPath
52
+ : null;
53
+ } finally {
54
+ db.close();
55
+ }
56
+ }
57
+
39
58
  export function run(argv = [], {
40
59
  stdout = process.stdout,
41
60
  stderr = process.stderr,
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url';
8
8
  import { DatabaseSync } from 'node:sqlite';
9
9
 
10
10
  import { buildBudgetedResumeContext } from '../resume-context.mjs';
11
+ import { readSessionProjectPath } from './handoff-context.mjs';
11
12
 
12
13
  const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url));
13
14
  const BIN_PATH = join(REPO_ROOT, 'bin/throughline.mjs');
@@ -131,6 +132,18 @@ test('handoff-context emits the exact inheritance context without changing DB ow
131
132
  }
132
133
  });
133
134
 
135
+ test('readSessionProjectPath returns the source session project', () => {
136
+ const home = mkdtempSync(join(tmpdir(), 'tl-session-project-'));
137
+ try {
138
+ const { db, dbPath } = createFixture(home);
139
+ db.close();
140
+ assert.equal(readSessionProjectPath(SESSION_ID, { dbPath }), '/work/project');
141
+ assert.equal(readSessionProjectPath('missing', { dbPath }), null);
142
+ } finally {
143
+ rmSync(home, { recursive: true, force: true });
144
+ }
145
+ });
146
+
134
147
  test('handoff-context fails without creating a missing database', () => {
135
148
  const home = mkdtempSync(join(tmpdir(), 'tl-handoff-context-missing-'));
136
149
  try {
@@ -9,6 +9,8 @@
9
9
  * Claude-facing hook は従来通り PATH 解決型 (throughline <subcommand>) を使う。
10
10
  * Codex-facing hook は VSCode App Server の PATH 差分を避けるため、絶対 node + CLI
11
11
  * script path で登録する。
12
+ * Grok-facing hook も Desktop の GUI PATH に throughline が無いため、同じ絶対
13
+ * node + CLI script path で ~/.grok/hooks/throughline.json に書く。
12
14
  */
13
15
 
14
16
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, copyFileSync, unlinkSync, rmSync, realpathSync } from 'node:fs';
@@ -24,6 +26,7 @@ const CODEX_SKILLS_SRC = join(PACKAGE_ROOT, 'codex', 'skills');
24
26
  const CODEX_SKILL_NAMES = ['throughline'];
25
27
  const CODEX_HOOKS_RELATIVE_PATH = ['.codex', 'hooks.json'];
26
28
  const CODEX_CONFIG_RELATIVE_PATH = ['.codex', 'config.toml'];
29
+ const GROK_HOOKS_RELATIVE_PATH = ['.grok', 'hooks', 'throughline.json'];
27
30
 
28
31
  // Throughline が管理する hook コマンド一覧
29
32
  // schema v4 以降: PostToolUse (capture-tool) は廃止。Stop 内で L2/L3 を一括処理する。
@@ -283,6 +286,54 @@ function resolveCodexSkillsDir() {
283
286
  return join(homedir(), '.codex', 'skills');
284
287
  }
285
288
 
289
+ function resolveGrokHooksPath() {
290
+ return join(homedir(), ...GROK_HOOKS_RELATIVE_PATH);
291
+ }
292
+
293
+ export function buildGrokHookCommand(subcommand, {
294
+ nodePath = resolveCodexHookNodePath(),
295
+ cliScriptPath = join(PACKAGE_ROOT, 'bin', 'throughline.mjs'),
296
+ } = {}) {
297
+ return `${quoteCommandPath(nodePath)} ${quoteCommandPath(cliScriptPath)} ${subcommand}`;
298
+ }
299
+
300
+ export function createGrokHooksFile(options = {}) {
301
+ return {
302
+ hooks: {
303
+ SessionStart: [
304
+ { hooks: [{ type: 'command', command: buildGrokHookCommand('session-start', options), timeout: 10 }] },
305
+ ],
306
+ UserPromptSubmit: [
307
+ { hooks: [{ type: 'command', command: buildGrokHookCommand('prompt-submit', options), timeout: 30 }] },
308
+ ],
309
+ Stop: [
310
+ {
311
+ hooks: [{
312
+ type: 'command',
313
+ command: buildGrokHookCommand('process-turn', options),
314
+ timeout: 300,
315
+ async: true,
316
+ }],
317
+ },
318
+ ],
319
+ },
320
+ };
321
+ }
322
+
323
+ function installGrokHooks() {
324
+ const hooksPath = resolveGrokHooksPath();
325
+ mkdirSync(dirname(hooksPath), { recursive: true });
326
+ writeFileSync(hooksPath, `${JSON.stringify(createGrokHooksFile(), null, 2)}\n`);
327
+ return { hooksPath };
328
+ }
329
+
330
+ function uninstallGrokHooks() {
331
+ const hooksPath = resolveGrokHooksPath();
332
+ if (!existsSync(hooksPath)) return { hooksPath, removed: 0 };
333
+ unlinkSync(hooksPath);
334
+ return { hooksPath, removed: 1 };
335
+ }
336
+
286
337
  function installSlashCommands(commandsDir) {
287
338
  if (!existsSync(SLASH_COMMANDS_SRC)) {
288
339
  return { installed: [], skipped: 'source-missing' };
@@ -537,6 +588,7 @@ export async function run(args = []) {
537
588
  writeSettings(settingsPath, current);
538
589
  const removedCommands = uninstallSlashCommands(commandsDir);
539
590
  const codex = args.includes('--project') ? null : uninstallCodexHooks();
591
+ const grok = args.includes('--project') ? null : uninstallGrokHooks();
540
592
  const removedCodexSkills = args.includes('--project') ? [] : uninstallCodexSkills(codexSkillsDir);
541
593
  console.log('Throughline hooks を削除しました。');
542
594
  console.log(` ${settingsPath}`);
@@ -546,6 +598,9 @@ export async function run(args = []) {
546
598
  if (codex?.removed > 0) {
547
599
  console.log(` Codex hooks 削除: ${codex.removed} (${codex.hooksPath})`);
548
600
  }
601
+ if (grok?.removed > 0) {
602
+ console.log(` Grok hooks 削除: ${grok.removed} (${grok.hooksPath})`);
603
+ }
549
604
  if (removedCodexSkills.length > 0) {
550
605
  console.log(` Codex skills 削除: ${removedCodexSkills.join(', ')} (${codexSkillsDir})`);
551
606
  }
@@ -568,6 +623,7 @@ export async function run(args = []) {
568
623
  writeSettings(settingsPath, current);
569
624
  const { installed: installedCommands, skipped } = installSlashCommands(commandsDir);
570
625
  const codex = args.includes('--project') ? null : installCodexHooks();
626
+ const grok = args.includes('--project') ? null : installGrokHooks();
571
627
  const codexSkills = args.includes('--project') ? { installed: [], skipped: null } : installCodexSkills(codexSkillsDir);
572
628
  const monitorTask = ensureMonitorTaskFile({
573
629
  cwd: process.cwd(),
@@ -584,6 +640,9 @@ export async function run(args = []) {
584
640
  console.log(` ${codexSkillsDir}`);
585
641
  }
586
642
  }
643
+ if (grok) {
644
+ console.log(` ${grok.hooksPath}`);
645
+ }
587
646
  console.log('');
588
647
  console.log('有効な hooks:');
589
648
  console.log(' SessionStart → throughline session-start (セッション記録・バトン消費・引き継ぎ注入)');
@@ -594,6 +653,9 @@ export async function run(args = []) {
594
653
  console.log(` Codex PostToolUse → ${buildCodexPostToolUseHookCommand()} (capture / monitor state only; auto refresh disabled)`);
595
654
  console.log(` Codex Stop → ${buildCodexStopHookCommand()} (Codex rollout capture + L1 要約)`);
596
655
  }
656
+ if (grok) {
657
+ console.log(' Grok SessionStart / UserPromptSubmit / Stop → ~/.grok/hooks/throughline.json');
658
+ }
597
659
  console.log('');
598
660
  if (installedCommands.length > 0) {
599
661
  console.log(`slash commands を配置しました: ${installedCommands.map(n => '/' + n.replace(/\.md$/, '')).join(', ')}`);
@@ -8,6 +8,8 @@ import {
8
8
  buildCodexPostToolUseHookCommand,
9
9
  buildCodexStopHookCommand,
10
10
  buildCodexUserPromptSubmitHookCommand,
11
+ buildGrokHookCommand,
12
+ createGrokHooksFile,
11
13
  isEquivalentCodexHookCommand,
12
14
  isThroughlineCodexHookCommand,
13
15
  parseCodexHookCommand,
@@ -72,6 +74,24 @@ test('global install copies Throughline slash commands to ~/.claude/commands/',
72
74
  assert.match(tlBody, /Throughline/, 'tl.md content should be real');
73
75
  const settings = JSON.parse(readFileSync(join(home.dir, '.claude', 'settings.json'), 'utf8'));
74
76
  assert.ok(settings.hooks?.UserPromptSubmit, 'UserPromptSubmit hook should be registered');
77
+ const grokHooks = JSON.parse(readFileSync(join(home.dir, '.grok', 'hooks', 'throughline.json'), 'utf8'));
78
+ assert.ok(grokHooks.hooks?.SessionStart, 'Grok SessionStart hook should be registered');
79
+ assert.ok(grokHooks.hooks?.UserPromptSubmit, 'Grok UserPromptSubmit hook should be registered');
80
+ assert.ok(grokHooks.hooks?.Stop, 'Grok Stop hook should be registered');
81
+ const grokCommands = [
82
+ grokHooks.hooks.SessionStart[0].hooks[0].command,
83
+ grokHooks.hooks.UserPromptSubmit[0].hooks[0].command,
84
+ grokHooks.hooks.Stop[0].hooks[0].command,
85
+ ];
86
+ assert.deepEqual(grokCommands, [
87
+ buildGrokHookCommand('session-start'),
88
+ buildGrokHookCommand('prompt-submit'),
89
+ buildGrokHookCommand('process-turn'),
90
+ ]);
91
+ for (const command of grokCommands) {
92
+ assert.match(command, /throughline\.mjs/);
93
+ assert.doesNotMatch(command, /^throughline /);
94
+ }
75
95
  } finally {
76
96
  unsilence();
77
97
  home.restore();
@@ -146,6 +166,32 @@ test('global install registers Codex session hooks and enables hooks features',
146
166
  }
147
167
  });
148
168
 
169
+ test('Grok hook commands are absolute node + throughline.mjs on every platform', () => {
170
+ const options = {
171
+ nodePath: String.raw`C:\Program Files\nodejs\node.exe`,
172
+ cliScriptPath: String.raw`C:\Users\Kite\App Data\Roaming\npm\node_modules\throughline\bin\throughline.mjs`,
173
+ };
174
+ assert.equal(
175
+ buildGrokHookCommand('session-start', options),
176
+ String.raw`"C:\Program Files\nodejs\node.exe" "C:\Users\Kite\App Data\Roaming\npm\node_modules\throughline\bin\throughline.mjs" session-start`,
177
+ );
178
+ assert.equal(
179
+ buildGrokHookCommand('process-turn', {
180
+ nodePath: '/opt/homebrew/bin/node',
181
+ cliScriptPath: '/Users/kite/Developer/Throughline/bin/throughline.mjs',
182
+ }),
183
+ '/opt/homebrew/bin/node /Users/kite/Developer/Throughline/bin/throughline.mjs process-turn',
184
+ );
185
+ const file = createGrokHooksFile({
186
+ nodePath: '/usr/bin/node',
187
+ cliScriptPath: '/pkg/bin/throughline.mjs',
188
+ });
189
+ assert.equal(file.hooks.SessionStart[0].hooks[0].command, '/usr/bin/node /pkg/bin/throughline.mjs session-start');
190
+ assert.equal(file.hooks.UserPromptSubmit[0].hooks[0].command, '/usr/bin/node /pkg/bin/throughline.mjs prompt-submit');
191
+ assert.equal(file.hooks.Stop[0].hooks[0].command, '/usr/bin/node /pkg/bin/throughline.mjs process-turn');
192
+ assert.equal(file.hooks.Stop[0].hooks[0].async, true);
193
+ });
194
+
149
195
  test('Codex hook builders use the PowerShell call operator on Windows only', () => {
150
196
  const options = {
151
197
  nodePath: String.raw`C:\Program Files\nodejs\node.exe`,
@@ -0,0 +1,71 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+
4
+ // Grok includes native injections only when synthetic_reason is
5
+ // system_reminder. A custom reason is written to disk and dropped from
6
+ // the model prompt (live session 01a00cf9).
7
+ export const GROK_HANDOFF_SYNTHETIC_REASON = 'system_reminder';
8
+ export const GROK_HANDOFF_MARKER = 'data-throughline-handoff="1"';
9
+
10
+ function rowText(entry) {
11
+ if (typeof entry?.content === 'string') return entry.content;
12
+ if (Array.isArray(entry?.content)) {
13
+ return entry.content
14
+ .filter((block) => block && block.type === 'text' && typeof block.text === 'string')
15
+ .map((block) => block.text)
16
+ .join('');
17
+ }
18
+ return '';
19
+ }
20
+
21
+ function parseLines(raw) {
22
+ const rows = [];
23
+ for (const line of raw.split('\n')) {
24
+ const trimmed = line.trim();
25
+ if (!trimmed) continue;
26
+ try {
27
+ rows.push(JSON.parse(trimmed));
28
+ } catch {
29
+ rows.push({ type: 'unparsed', content: line });
30
+ }
31
+ }
32
+ return rows;
33
+ }
34
+
35
+ /**
36
+ * Insert Throughline resume text as a Grok synthetic user row immediately
37
+ * before the latest <user_query>. Grok ignores UserPromptSubmit stdout.
38
+ */
39
+ export function injectGrokHandoffContext(transcriptPath, injectionText) {
40
+ if (!transcriptPath || typeof injectionText !== 'string' || injectionText.length === 0) {
41
+ return { injected: false, reason: 'missing_path_or_text' };
42
+ }
43
+
44
+ const existing = existsSync(transcriptPath) ? readFileSync(transcriptPath, 'utf8') : '';
45
+ const rows = parseLines(existing);
46
+ if (rows.some((row) => rowText(row).includes(GROK_HANDOFF_MARKER))) {
47
+ return { injected: false, reason: 'already_present' };
48
+ }
49
+
50
+ const reminder = {
51
+ type: 'user',
52
+ content: [{
53
+ type: 'text',
54
+ text: `<system-reminder ${GROK_HANDOFF_MARKER}>\n${injectionText}\n</system-reminder>`,
55
+ }],
56
+ synthetic_reason: GROK_HANDOFF_SYNTHETIC_REASON,
57
+ };
58
+
59
+ let insertAt = rows.length;
60
+ for (let i = rows.length - 1; i >= 0; i--) {
61
+ if (rows[i].type === 'user' && rowText(rows[i]).includes('<user_query>')) {
62
+ insertAt = i;
63
+ break;
64
+ }
65
+ }
66
+ rows.splice(insertAt, 0, reminder);
67
+
68
+ mkdirSync(dirname(transcriptPath), { recursive: true });
69
+ writeFileSync(transcriptPath, `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf8');
70
+ return { injected: true, reason: null, insertAt };
71
+ }
@@ -0,0 +1,69 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ import {
8
+ GROK_HANDOFF_SYNTHETIC_REASON,
9
+ injectGrokHandoffContext,
10
+ } from './grok-history-inject.mjs';
11
+
12
+ test('injectGrokHandoffContext inserts reminder before the latest user_query', () => {
13
+ const dir = mkdtempSync(join(tmpdir(), 'tl-grok-inject-'));
14
+ const path = join(dir, 'chat_history.jsonl');
15
+ writeFileSync(
16
+ path,
17
+ [
18
+ JSON.stringify({ type: 'system', content: 'sys' }),
19
+ JSON.stringify({ type: 'user', content: [{ type: 'text', text: '<user_info>x</user_info>' }] }),
20
+ JSON.stringify({ type: 'user', content: [{ type: 'text', text: '<user_query>\nこれかな?\n</user_query>' }] }),
21
+ ].join('\n') + '\n',
22
+ );
23
+ try {
24
+ const result = injectGrokHandoffContext(path, 'old assistant body');
25
+ assert.equal(result.injected, true);
26
+ const rows = readFileSync(path, 'utf8')
27
+ .split('\n')
28
+ .filter(Boolean)
29
+ .map((line) => JSON.parse(line));
30
+ assert.equal(rows.length, 4);
31
+ assert.equal(rows[2].synthetic_reason, GROK_HANDOFF_SYNTHETIC_REASON);
32
+ assert.match(rows[2].content[0].text, /data-throughline-handoff="1"/);
33
+ assert.match(rows[2].content[0].text, /old assistant body/);
34
+ assert.match(rows[3].content[0].text, /これかな?/);
35
+ } finally {
36
+ rmSync(dir, { recursive: true, force: true });
37
+ }
38
+ });
39
+
40
+ test('injectGrokHandoffContext appends when no user_query exists yet', () => {
41
+ const dir = mkdtempSync(join(tmpdir(), 'tl-grok-inject-'));
42
+ const path = join(dir, 'chat_history.jsonl');
43
+ writeFileSync(path, `${JSON.stringify({ type: 'system', content: 'sys' })}\n`);
44
+ try {
45
+ const result = injectGrokHandoffContext(path, 'memory');
46
+ assert.equal(result.injected, true);
47
+ const rows = readFileSync(path, 'utf8')
48
+ .split('\n')
49
+ .filter(Boolean)
50
+ .map((line) => JSON.parse(line));
51
+ assert.equal(rows.at(-1).synthetic_reason, GROK_HANDOFF_SYNTHETIC_REASON);
52
+ } finally {
53
+ rmSync(dir, { recursive: true, force: true });
54
+ }
55
+ });
56
+
57
+ test('injectGrokHandoffContext is idempotent', () => {
58
+ const dir = mkdtempSync(join(tmpdir(), 'tl-grok-inject-'));
59
+ const path = join(dir, 'chat_history.jsonl');
60
+ try {
61
+ assert.equal(injectGrokHandoffContext(path, 'memory').injected, true);
62
+ assert.deepEqual(injectGrokHandoffContext(path, 'memory'), {
63
+ injected: false,
64
+ reason: 'already_present',
65
+ });
66
+ } finally {
67
+ rmSync(dir, { recursive: true, force: true });
68
+ }
69
+ });