throughline 0.10.2 → 0.10.5
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 +88 -27
- package/README.ja.md +83 -49
- package/README.md +106 -78
- package/bin/throughline.mjs +32 -13
- package/docs/00_overview.md +56 -42
- package/docs/01_l1_l2_l3_redesign.md +1 -1
- package/docs/02_clear_auto_handoff_plan.md +39 -333
- package/docs/04_public_release_plan.md +73 -190
- package/docs/05_codex_first_roadmap.md +4 -4
- package/docs/06_codex_trim_rollback_fix_plan.md +1 -1
- package/docs/08_codex_dual_support.md +1 -1
- package/docs/09_rollback_context_trim_insight.md +1 -1
- package/docs/12_desktop_clear_handoff_plan.md +6 -213
- package/docs/15_windows_ci_release_latency_plan.md +6 -87
- package/docs/16_readonly_handoff_context_plan.md +7 -38
- package/docs/adr/0005-observer-read-pagination.md +1 -1
- package/docs/adr/0014-two-phase-handoff-ghost-baton.md +1 -1
- package/docs/adr/0019-product-owned-database-migration-acceptance.md +1 -1
- package/docs/adr/0021-grok-host-capture.md +1 -1
- package/docs/adr/0022-cursor-host-capture.md +39 -0
- package/docs/archive/02_clear_auto_handoff_plan.md +350 -0
- package/docs/{03_inheritance_on_clear_only.md → archive/03_inheritance_on_clear_only.md} +22 -22
- package/docs/{07_codex_trim_implementation_plan.md → archive/07_codex_trim_implementation_plan.md} +8 -8
- package/docs/{10_transcript_injection_plan.md → archive/10_transcript_injection_plan.md} +12 -12
- package/docs/archive/12_desktop_clear_handoff_plan.md +218 -0
- package/docs/{14_observer_completed_turn_feed_plan.md → archive/14_observer_completed_turn_feed_plan.md} +7 -7
- package/docs/archive/15_windows_ci_release_latency_plan.md +89 -0
- package/docs/archive/16_readonly_handoff_context_plan.md +40 -0
- package/docs/archive/README.md +28 -15
- package/docs/archive/plan_grok-successor-launch.md +99 -0
- package/docs/archive/room-log_throughline_20260830-155052.md +285 -0
- package/docs/plan_grok-successor-launch.md +6 -97
- package/package.json +19 -11
- package/rag/INDEX.md +2 -2
- package/src/baton.mjs +11 -9
- package/src/cli/handoff-context.test.mjs +36 -0
- package/src/cli/help.test.mjs +5 -0
- package/src/cli/install.mjs +91 -0
- package/src/cli/install.test.mjs +57 -0
- package/src/cli/runtime-errors.mjs +9 -3
- package/src/cli/runtime-errors.test.mjs +13 -13
- package/src/cli/self-update.mjs +402 -0
- package/src/cli/self-update.test.mjs +525 -0
- package/src/db.mjs +1 -1
- package/src/docs-contract.test.mjs +153 -0
- package/src/hosts/claude.mjs +1 -0
- package/src/hosts/codex.mjs +1 -0
- package/src/hosts/cursor.mjs +128 -0
- package/src/hosts/cursor.test.mjs +104 -0
- package/src/hosts/grok.mjs +1 -0
- package/src/hosts/identity.mjs +19 -2
- package/src/hosts/identity.test.mjs +27 -4
- package/src/hosts/index.mjs +11 -2
- package/src/product-ci-contract.test.mjs +14 -0
- package/src/prompt-submit.mjs +8 -10
- package/src/resume-context.mjs +4 -4
- package/src/runtime-error-hook.test.mjs +4 -6
- package/src/runtime-error-store.mjs +40 -18
- package/src/runtime-error-store.test.mjs +53 -26
- package/src/session-merger.mjs +16 -7
- package/src/session-merger.test.mjs +27 -0
- package/src/session-start.mjs +24 -1
- package/src/spike-transcript-writer.mjs +1 -1
- package/src/transcript-reader-cursor.test.mjs +43 -0
- package/src/transcript-reader.mjs +14 -7
- /package/docs/{11_codex_monitor_implementation_plan.md → archive/11_codex_monitor_implementation_plan.md} +0 -0
- /package/docs/{13_native_factory_diagnostics_plan.md → archive/13_native_factory_diagnostics_plan.md} +0 -0
- /package/docs/{BUGHUB_RUNTIME_ERROR_STORE_PLAN.md → archive/BUGHUB_RUNTIME_ERROR_STORE_PLAN.md} +0 -0
|
@@ -8,13 +8,14 @@ import { dirname, join } from 'node:path';
|
|
|
8
8
|
import {
|
|
9
9
|
acknowledgeRuntimeErrors,
|
|
10
10
|
compactRuntimeErrors,
|
|
11
|
-
|
|
11
|
+
defaultRuntimeErrorConfigPath,
|
|
12
12
|
defaultRuntimeErrorStorePath,
|
|
13
13
|
getRuntimeErrorDiagnostics,
|
|
14
14
|
observeRuntimeError,
|
|
15
15
|
readRuntimeErrorSnapshot,
|
|
16
16
|
reopenRuntimeError,
|
|
17
17
|
resolveRuntimeError,
|
|
18
|
+
setRuntimeErrorCollectionEnabled,
|
|
18
19
|
} from './runtime-error-store.mjs';
|
|
19
20
|
import { applyWindowsPrivateAcl, verifyWindowsPrivateAcl } from './os/windows-acl-test-helper.mjs';
|
|
20
21
|
|
|
@@ -29,18 +30,16 @@ function sandbox() {
|
|
|
29
30
|
XDG_CONFIG_HOME: join(root, 'config'),
|
|
30
31
|
XDG_STATE_HOME: join(root, 'state'),
|
|
31
32
|
};
|
|
32
|
-
const configPath =
|
|
33
|
+
const configPath = defaultRuntimeErrorConfigPath(env);
|
|
33
34
|
const storePath = defaultRuntimeErrorStorePath(env);
|
|
34
35
|
return { root, env, configPath, storePath };
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
function enableCollection(box
|
|
38
|
+
function enableCollection(box) {
|
|
38
39
|
mkdirSync(dirname(box.configPath), { recursive: true });
|
|
39
40
|
writeFileSync(box.configPath, JSON.stringify({
|
|
40
|
-
|
|
41
|
-
host: { id: 'test-host', profile: process.platform === 'win32' ? 'windows-native' : 'mac' },
|
|
41
|
+
schema: 'throughline.runtime_error_config.v1',
|
|
42
42
|
collection: { enabled: true },
|
|
43
|
-
reporting,
|
|
44
43
|
}));
|
|
45
44
|
applyWindowsPrivateAcl(box.configPath);
|
|
46
45
|
}
|
|
@@ -49,8 +48,8 @@ test('runtime error store: missing/false/malformed config is fail-closed and cre
|
|
|
49
48
|
for (const config of [
|
|
50
49
|
null,
|
|
51
50
|
{ collection: { enabled: true } },
|
|
52
|
-
{ collection: { enabled: false } },
|
|
53
|
-
{ collection: { enabled: 'true' } },
|
|
51
|
+
{ schema: 'throughline.runtime_error_config.v1', collection: { enabled: false } },
|
|
52
|
+
{ schema: 'throughline.runtime_error_config.v1', collection: { enabled: 'true' } },
|
|
54
53
|
'{malformed',
|
|
55
54
|
]) {
|
|
56
55
|
const box = sandbox();
|
|
@@ -67,15 +66,15 @@ test('runtime error store: missing/false/malformed config is fail-closed and cre
|
|
|
67
66
|
}
|
|
68
67
|
});
|
|
69
68
|
|
|
70
|
-
test('runtime error store: Windows native uses
|
|
69
|
+
test('runtime error store: Windows native uses product-owned LocalAppData paths', () => {
|
|
71
70
|
const env = {
|
|
72
71
|
OS: 'Windows_NT',
|
|
73
72
|
USERPROFILE: 'C:\\Users\\kite_',
|
|
74
73
|
LOCALAPPDATA: 'C:\\Users\\kite_\\AppData\\Local',
|
|
75
74
|
};
|
|
76
75
|
assert.equal(
|
|
77
|
-
|
|
78
|
-
join(env.LOCALAPPDATA, '
|
|
76
|
+
defaultRuntimeErrorConfigPath(env),
|
|
77
|
+
join(env.LOCALAPPDATA, 'throughline', 'runtime-errors.config.json'),
|
|
79
78
|
);
|
|
80
79
|
assert.equal(
|
|
81
80
|
defaultRuntimeErrorStorePath(env),
|
|
@@ -83,6 +82,34 @@ test('runtime error store: Windows native uses the canonical LocalAppData paths'
|
|
|
83
82
|
);
|
|
84
83
|
});
|
|
85
84
|
|
|
85
|
+
test('runtime error store: product-owned config enables and disables collection', () => {
|
|
86
|
+
const box = sandbox();
|
|
87
|
+
assert.deepEqual(setRuntimeErrorCollectionEnabled(true, { env: box.env }), {
|
|
88
|
+
schema: 'throughline.runtime_error_config.v1',
|
|
89
|
+
collection: { enabled: true },
|
|
90
|
+
});
|
|
91
|
+
assert.equal(observeRuntimeError({ code: 'HOOK_PROCESS_TURN_FAILED' }, { env: box.env }).status, 'recorded');
|
|
92
|
+
assert.deepEqual(setRuntimeErrorCollectionEnabled(false, { env: box.env }), {
|
|
93
|
+
schema: 'throughline.runtime_error_config.v1',
|
|
94
|
+
collection: { enabled: false },
|
|
95
|
+
});
|
|
96
|
+
assert.equal(getRuntimeErrorDiagnostics({ env: box.env }).collection, 'disabled');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('runtime error store: legacy dotagents config cannot control collection', () => {
|
|
100
|
+
const box = sandbox();
|
|
101
|
+
const legacyPath = join(box.env.XDG_CONFIG_HOME, 'dotagents', 'factory-reporter.json');
|
|
102
|
+
mkdirSync(dirname(legacyPath), { recursive: true });
|
|
103
|
+
writeFileSync(legacyPath, JSON.stringify({
|
|
104
|
+
schema_version: '1.0',
|
|
105
|
+
host: { id: 'test-host', profile: 'mac' },
|
|
106
|
+
collection: { enabled: true },
|
|
107
|
+
reporting: { enabled: false },
|
|
108
|
+
}));
|
|
109
|
+
assert.equal(observeRuntimeError({ code: 'HOOK_PROCESS_TURN_FAILED' }, { env: box.env }).status, 'disabled');
|
|
110
|
+
assert.throws(() => statSync(box.storePath), { code: 'ENOENT' });
|
|
111
|
+
});
|
|
112
|
+
|
|
86
113
|
test('runtime error store: one Windows mutation spends ACL processes only on distinct state transitions', (t) => {
|
|
87
114
|
const box = sandbox();
|
|
88
115
|
box.env.OS = 'Windows_NT';
|
|
@@ -127,20 +154,26 @@ test('runtime error store: Windows temporary ACL failure leaves the previous ato
|
|
|
127
154
|
assert.deepEqual(readdirSync(dirname(box.storePath)).filter((name) => name.endsWith('.tmp')), []);
|
|
128
155
|
});
|
|
129
156
|
|
|
130
|
-
test('runtime error store: reporting
|
|
157
|
+
test('runtime error store: factory reporting fields cannot enable product collection', () => {
|
|
131
158
|
const box = sandbox();
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
159
|
+
mkdirSync(dirname(box.configPath), { recursive: true });
|
|
160
|
+
writeFileSync(box.configPath, JSON.stringify({
|
|
161
|
+
schema_version: '1.0',
|
|
162
|
+
host: { id: 'test-host', profile: 'mac' },
|
|
163
|
+
collection: { enabled: true },
|
|
164
|
+
reporting: {
|
|
165
|
+
enabled: true,
|
|
166
|
+
endpoint: 'https://should-never-be-read.invalid/private',
|
|
167
|
+
credential_file: '/private/token',
|
|
168
|
+
},
|
|
169
|
+
}));
|
|
170
|
+
applyWindowsPrivateAcl(box.configPath);
|
|
137
171
|
const result = observeRuntimeError(
|
|
138
172
|
{ code: 'HOOK_PROCESS_TURN_FAILED', now: '2026-07-13T00:00:00.000Z' },
|
|
139
173
|
{ env: box.env, version: '0.6.1', platform: TEST_PLATFORM, arch: 'arm64' },
|
|
140
174
|
);
|
|
141
|
-
assert.equal(result.status, '
|
|
142
|
-
|
|
143
|
-
assert.doesNotMatch(bytes, /should-never-be-read|private\/token|endpoint|credential/i);
|
|
175
|
+
assert.equal(result.status, 'disabled');
|
|
176
|
+
assert.throws(() => statSync(box.storePath), { code: 'ENOENT' });
|
|
144
177
|
});
|
|
145
178
|
|
|
146
179
|
test('runtime error store: observation API rejects raw or arbitrary fields', () => {
|
|
@@ -294,12 +327,6 @@ test('runtime error store: atomic private store has owner-only modes and bounded
|
|
|
294
327
|
assert.doesNotMatch(JSON.stringify(diagnostics), /Users|\\|\.throughline|runtime-errors\.json/);
|
|
295
328
|
});
|
|
296
329
|
|
|
297
|
-
test('runtime error store: non-canonical reporting values keep collection fail-closed', () => {
|
|
298
|
-
const box = sandbox();
|
|
299
|
-
enableCollection(box, { enabled: true, endpoint: 'ftp://invalid', credential_file: '' });
|
|
300
|
-
assert.deepEqual(observeRuntimeError({ code: 'HOOK_CODEX_FAILED' }, { env: box.env }), { status: 'disabled' });
|
|
301
|
-
});
|
|
302
|
-
|
|
303
330
|
test('runtime error store: unknown top-level fields and future ack are rejected before compaction', () => {
|
|
304
331
|
const box = sandbox();
|
|
305
332
|
enableCollection(box);
|
package/src/session-merger.mjs
CHANGED
|
@@ -2,15 +2,19 @@
|
|
|
2
2
|
* session-merger.mjs — 記憶張り替え + merged_into チェーン解決
|
|
3
3
|
*
|
|
4
4
|
* 用途:
|
|
5
|
-
* -
|
|
5
|
+
* - 初回 UserPromptSubmit: baton または source='clear' で凍結した旧セッションを
|
|
6
|
+
* mergeSpecificPredecessor で新セッションに張り替え
|
|
6
7
|
* - Stop hook: resolveMergeTarget で「入力 session_id → 実書き込み先」を解決
|
|
7
8
|
*
|
|
8
|
-
* 設計背景: docs/archive/SESSION_LINKING_DESIGN.md, docs/03_inheritance_on_clear_only.md (バトン方式採用)
|
|
9
|
+
* 設計背景: docs/archive/SESSION_LINKING_DESIGN.md, docs/archive/03_inheritance_on_clear_only.md (バトン方式採用)
|
|
9
10
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
11
|
+
* 旧実装の無条件な時間差ヒューリスティックは撤去済み。
|
|
12
|
+
* 引き継ぎは `/tl` baton、または VS Code `/clear` の `source='clear'` で
|
|
13
|
+
* SessionStart 時に凍結した transcript-backed predecessor からだけ発火する。
|
|
12
14
|
*/
|
|
13
15
|
|
|
16
|
+
import { sameProjectPath } from './project-path.mjs';
|
|
17
|
+
|
|
14
18
|
const MAX_CHAIN_DEPTH = 10;
|
|
15
19
|
|
|
16
20
|
/**
|
|
@@ -61,7 +65,7 @@ export function resolveMergeTarget(db, sessionId) {
|
|
|
61
65
|
* merged: boolean,
|
|
62
66
|
* predecessorId?: string,
|
|
63
67
|
* rowCounts?: { sk: number, dt: number, bd: number },
|
|
64
|
-
* skipReason?: 'self_handoff' | 'predecessor_not_found' | 'already_merged' | 'predecessor_not_older',
|
|
68
|
+
* skipReason?: 'self_handoff' | 'predecessor_not_found' | 'already_merged' | 'project_mismatch' | 'predecessor_not_older',
|
|
65
69
|
* }}
|
|
66
70
|
*/
|
|
67
71
|
export function mergeSpecificPredecessor(db, { newSessionId, predecessorId, now = Date.now() }) {
|
|
@@ -72,7 +76,7 @@ export function mergeSpecificPredecessor(db, { newSessionId, predecessorId, now
|
|
|
72
76
|
db.exec('BEGIN IMMEDIATE');
|
|
73
77
|
try {
|
|
74
78
|
const pred = db
|
|
75
|
-
.prepare('SELECT session_id, created_at, merged_into FROM sessions WHERE session_id = ?')
|
|
79
|
+
.prepare('SELECT session_id, project_path, created_at, merged_into FROM sessions WHERE session_id = ?')
|
|
76
80
|
.get(predecessorId);
|
|
77
81
|
|
|
78
82
|
if (!pred) {
|
|
@@ -85,9 +89,14 @@ export function mergeSpecificPredecessor(db, { newSessionId, predecessorId, now
|
|
|
85
89
|
}
|
|
86
90
|
|
|
87
91
|
const self = db
|
|
88
|
-
.prepare('SELECT created_at FROM sessions WHERE session_id = ?')
|
|
92
|
+
.prepare('SELECT project_path, created_at FROM sessions WHERE session_id = ?')
|
|
89
93
|
.get(newSessionId);
|
|
90
94
|
|
|
95
|
+
if (self && !sameProjectPath(pred.project_path, self.project_path)) {
|
|
96
|
+
db.exec('COMMIT');
|
|
97
|
+
return { merged: false, skipReason: 'project_mismatch' };
|
|
98
|
+
}
|
|
99
|
+
|
|
91
100
|
// 時系列単調制約: 前任は新セッションより created_at が古いこと。
|
|
92
101
|
// バトンが自分より新しい session を指していたら(異常データ)merge しない。
|
|
93
102
|
if (self && pred.created_at >= self.created_at) {
|
|
@@ -151,6 +151,33 @@ test('mergeSpecificPredecessor: predecessor already merged into third session',
|
|
|
151
151
|
assert.equal(result.skipReason, 'already_merged');
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
+
test('mergeSpecificPredecessor: refuses to move memory across projects', () => {
|
|
155
|
+
const db = makeDb();
|
|
156
|
+
insertSession(db, 'bot-a-old', 100, null, '/bots/bot-a');
|
|
157
|
+
insertSession(db, 'bot-b-new', 200, null, '/bots/bot-b');
|
|
158
|
+
db.prepare(
|
|
159
|
+
`INSERT INTO bodies
|
|
160
|
+
(session_id, origin_session_id, turn_number, role, text, created_at)
|
|
161
|
+
VALUES ('bot-a-old', 'bot-a-old', 1, 'user', 'A_PRIVATE_MEMORY', 100)`,
|
|
162
|
+
).run();
|
|
163
|
+
|
|
164
|
+
const result = mergeSpecificPredecessor(db, {
|
|
165
|
+
newSessionId: 'bot-b-new',
|
|
166
|
+
predecessorId: 'bot-a-old',
|
|
167
|
+
now: 200,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
assert.deepEqual(result, { merged: false, skipReason: 'project_mismatch' });
|
|
171
|
+
assert.equal(
|
|
172
|
+
db.prepare('SELECT session_id FROM bodies WHERE text = ?').get('A_PRIVATE_MEMORY').session_id,
|
|
173
|
+
'bot-a-old',
|
|
174
|
+
);
|
|
175
|
+
assert.equal(
|
|
176
|
+
db.prepare('SELECT merged_into FROM sessions WHERE session_id = ?').get('bot-a-old').merged_into,
|
|
177
|
+
null,
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
154
181
|
test('mergeSpecificPredecessor: refuses predecessor with created_at >= self', () => {
|
|
155
182
|
const db = makeDb();
|
|
156
183
|
insertSession(db, 'new', 100);
|
package/src/session-start.mjs
CHANGED
|
@@ -32,7 +32,8 @@ 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 { NON_CLAUDE_SESSION_PREFIXES, normalizeHookPayload } from './hosts/index.mjs';
|
|
35
|
+
import { hostAdapterForSessionId, NON_CLAUDE_SESSION_PREFIXES, normalizeHookPayload } from './hosts/index.mjs';
|
|
36
|
+
import { executeFirstPromptHandoff } from './handoff-executor.mjs';
|
|
36
37
|
|
|
37
38
|
const ENV_DISABLE_AUTO_HANDOFF = 'THROUGHLINE_DISABLE_AUTO_HANDOFF';
|
|
38
39
|
|
|
@@ -161,6 +162,28 @@ export async function run() {
|
|
|
161
162
|
pending_registered: true,
|
|
162
163
|
});
|
|
163
164
|
|
|
165
|
+
// Cursor は beforeSubmitPrompt が additional_context を持たない。
|
|
166
|
+
// 新規 composer 会話の sessionStart が公式の注入口なので、ここで第二相を実行する。
|
|
167
|
+
// Claude の幽霊 SessionStart 問題はこの host では sessionStart = 会話作成のため適用しない。
|
|
168
|
+
// クラウド / background agent は baton を奪わない。
|
|
169
|
+
const hostAdapter = hostAdapterForSessionId(session_id);
|
|
170
|
+
if (hostAdapter.consumesHandoffAtSessionStart === true && payload.is_background_agent !== true) {
|
|
171
|
+
const handoff = executeFirstPromptHandoff(db, {
|
|
172
|
+
sessionId: session_id,
|
|
173
|
+
projectPath,
|
|
174
|
+
now,
|
|
175
|
+
});
|
|
176
|
+
if (handoff.attempted && handoff.injectionText) {
|
|
177
|
+
const delivery = hostAdapter.deliverHandoffInjection({
|
|
178
|
+
payload,
|
|
179
|
+
text: handoff.injectionText,
|
|
180
|
+
});
|
|
181
|
+
if (!delivery.delivered) {
|
|
182
|
+
process.stderr.write(`[session-start] ${delivery.reason}\n`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
164
187
|
process.exit(0);
|
|
165
188
|
}
|
|
166
189
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SPIKE ONLY — Phase 0-2 / 0-4 検証用。本実装ではない。
|
|
3
3
|
*
|
|
4
|
-
* docs/10_transcript_injection_plan.md Phase 0-2 で
|
|
4
|
+
* docs/archive/10_transcript_injection_plan.md Phase 0-2 で
|
|
5
5
|
* 「`/clear` 直後の SessionStart hook 内で transcript_path に L2 を user/assistant
|
|
6
6
|
* role 付きで append すると、Claude が次の short prompt の文脈として読むか」を実機検証する。
|
|
7
7
|
*
|
|
@@ -0,0 +1,43 @@
|
|
|
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, sliceCurrentTurnEntries, readRawEntries } from './transcript-reader.mjs';
|
|
8
|
+
|
|
9
|
+
test('readTranscript accepts Cursor agent-transcripts role-first jsonl', () => {
|
|
10
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-cursor-transcript-'));
|
|
11
|
+
const path = join(dir, '7face712.jsonl');
|
|
12
|
+
writeFileSync(
|
|
13
|
+
path,
|
|
14
|
+
[
|
|
15
|
+
JSON.stringify({
|
|
16
|
+
role: 'user',
|
|
17
|
+
message: { content: [{ type: 'text', text: 'hello cursor' }] },
|
|
18
|
+
}),
|
|
19
|
+
JSON.stringify({
|
|
20
|
+
role: 'assistant',
|
|
21
|
+
message: { content: [{ type: 'text', text: 'captured reply' }] },
|
|
22
|
+
}),
|
|
23
|
+
].join('\n'),
|
|
24
|
+
);
|
|
25
|
+
try {
|
|
26
|
+
const turns = readTranscript(path);
|
|
27
|
+
assert.deepEqual(
|
|
28
|
+
turns.map((t) => ({ role: t.role, content: t.content })),
|
|
29
|
+
[
|
|
30
|
+
{ role: 'user', content: 'hello cursor' },
|
|
31
|
+
{ role: 'assistant', content: 'captured reply' },
|
|
32
|
+
],
|
|
33
|
+
);
|
|
34
|
+
const groups = getLogicalTurnGroups(path);
|
|
35
|
+
assert.equal(groups.length, 1);
|
|
36
|
+
assert.equal(groups[0].user.content, 'hello cursor');
|
|
37
|
+
assert.equal(groups[0].representative.content, 'captured reply');
|
|
38
|
+
const sliced = sliceCurrentTurnEntries(readRawEntries(path));
|
|
39
|
+
assert.equal(sliced.length, 2);
|
|
40
|
+
} finally {
|
|
41
|
+
rmSync(dir, { recursive: true, force: true });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
import { readFileSync, existsSync } from 'fs';
|
|
12
12
|
import { DETAIL_KIND } from './constants.mjs';
|
|
13
13
|
|
|
14
|
+
function entryKind(entry) {
|
|
15
|
+
if (typeof entry?.type === 'string' && entry.type.length > 0) return entry.type;
|
|
16
|
+
if (typeof entry?.role === 'string' && entry.role.length > 0) return entry.role;
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
14
20
|
/**
|
|
15
21
|
* content 配列からテキスト部分だけを結合する。
|
|
16
22
|
* thinking ブロックは除外。
|
|
@@ -51,8 +57,9 @@ export function readTranscript(transcriptPath) {
|
|
|
51
57
|
continue;
|
|
52
58
|
}
|
|
53
59
|
|
|
54
|
-
// user / assistant
|
|
55
|
-
|
|
60
|
+
// user / assistant エントリのみ対象。Claude は type、Cursor jsonl は role。
|
|
61
|
+
const kind = entryKind(entry);
|
|
62
|
+
if (kind !== 'user' && kind !== 'assistant') continue;
|
|
56
63
|
|
|
57
64
|
// subagent の sidechain エントリは主会話ではないので除外。現行 CC は主 transcript に
|
|
58
65
|
// 書かない(400 transcript 実測ゼロ件)が、将来変更への安価な防御 (docs/12 B-1)
|
|
@@ -60,7 +67,7 @@ export function readTranscript(transcriptPath) {
|
|
|
60
67
|
|
|
61
68
|
const msg = entry.message;
|
|
62
69
|
const grokContent = entry.content;
|
|
63
|
-
const role = msg?.role ??
|
|
70
|
+
const role = msg?.role ?? kind;
|
|
64
71
|
const rawContent = msg?.content ?? grokContent;
|
|
65
72
|
if (!role || rawContent == null) continue;
|
|
66
73
|
|
|
@@ -258,7 +265,7 @@ export function sliceCurrentTurnEntries(entries) {
|
|
|
258
265
|
let lastAssistantTextIdx = -1;
|
|
259
266
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
260
267
|
const e = entries[i];
|
|
261
|
-
if (e
|
|
268
|
+
if (entryKind(e) !== 'assistant') continue;
|
|
262
269
|
const blocks = e.message?.content;
|
|
263
270
|
if (!Array.isArray(blocks)) continue;
|
|
264
271
|
if (blocks.some((b) => b && b.type === 'text' && typeof b.text === 'string' && b.text.length > 0)) {
|
|
@@ -272,7 +279,7 @@ export function sliceCurrentTurnEntries(entries) {
|
|
|
272
279
|
let userTextIdx = -1;
|
|
273
280
|
for (let i = lastAssistantTextIdx - 1; i >= 0; i--) {
|
|
274
281
|
const e = entries[i];
|
|
275
|
-
if (e
|
|
282
|
+
if (entryKind(e) !== 'user') continue;
|
|
276
283
|
const blocks = e.message?.content;
|
|
277
284
|
if (Array.isArray(blocks)) {
|
|
278
285
|
if (blocks.some((b) => b && b.type === 'text' && typeof b.text === 'string' && b.text.length > 0)) {
|
|
@@ -319,7 +326,7 @@ export function extractDetailBlocks(turnEntries) {
|
|
|
319
326
|
const toolNameById = new Map();
|
|
320
327
|
|
|
321
328
|
for (const e of turnEntries) {
|
|
322
|
-
if (e
|
|
329
|
+
if (entryKind(e) === 'assistant') {
|
|
323
330
|
const blocks = e.message?.content;
|
|
324
331
|
if (!Array.isArray(blocks)) continue;
|
|
325
332
|
for (let i = 0; i < blocks.length; i++) {
|
|
@@ -355,7 +362,7 @@ export function extractDetailBlocks(turnEntries) {
|
|
|
355
362
|
}
|
|
356
363
|
// text は扱わない
|
|
357
364
|
}
|
|
358
|
-
} else if (e
|
|
365
|
+
} else if (entryKind(e) === 'user') {
|
|
359
366
|
const blocks = e.message?.content;
|
|
360
367
|
if (!Array.isArray(blocks)) continue;
|
|
361
368
|
for (const b of blocks) {
|
|
File without changes
|
|
File without changes
|
/package/docs/{BUGHUB_RUNTIME_ERROR_STORE_PLAN.md → archive/BUGHUB_RUNTIME_ERROR_STORE_PLAN.md}
RENAMED
|
File without changes
|