throughline 0.3.23 → 0.3.25
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/.claude/commands/tl-trim.md +42 -0
- package/.codex-sidecar.yml +62 -0
- package/CHANGELOG.md +583 -0
- package/README.ja.md +42 -5
- package/README.md +400 -23
- package/bin/throughline.mjs +168 -4
- package/codex/skills/throughline/SKILL.md +157 -0
- package/codex/skills/throughline/agents/openai.yaml +7 -0
- package/docs/INHERITANCE_ON_CLEAR_ONLY.md +146 -0
- package/docs/L1_L2_L3_REDESIGN.md +415 -0
- package/docs/PUBLIC_RELEASE_PLAN.md +184 -0
- package/docs/THROUGHLINE_CODEX_DUAL_SUPPORT.md +249 -0
- package/docs/THROUGHLINE_CODEX_FIRST_ROADMAP.md +555 -0
- package/docs/THROUGHLINE_CODEX_MONITOR_IMPLEMENTATION_PLAN.md +220 -0
- package/docs/THROUGHLINE_CODEX_TRIM_IMPLEMENTATION_PLAN.md +528 -0
- package/docs/THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md +672 -0
- package/docs/archive/CONCEPT.md +476 -0
- package/docs/archive/EXPERIMENT.md +371 -0
- package/docs/archive/README.md +22 -0
- package/docs/archive/SESSION_LINKING_DESIGN.md +231 -0
- package/docs/archive/THROUGHLINE_NEXT_STEPS.md +134 -0
- package/docs/throughline-codex-trim-rollback-incident-report.md +306 -0
- package/docs/throughline-handoff-context.example.json +57 -0
- package/docs/throughline-rollback-context-trim-insight.md +455 -0
- package/package.json +6 -2
- package/src/cli/codex-capture.mjs +95 -0
- package/src/cli/codex-handoff-model-smoke.mjs +292 -0
- package/src/cli/codex-handoff-model-smoke.test.mjs +262 -0
- package/src/cli/codex-handoff-smoke.mjs +163 -0
- package/src/cli/codex-handoff-smoke.test.mjs +149 -0
- package/src/cli/codex-handoff-start.mjs +291 -0
- package/src/cli/codex-handoff-start.test.mjs +194 -0
- package/src/cli/codex-hook.mjs +276 -0
- package/src/cli/codex-hook.test.mjs +293 -0
- package/src/cli/codex-host-primitive-audit.mjs +110 -0
- package/src/cli/codex-host-primitive-audit.test.mjs +75 -0
- package/src/cli/codex-restore-smoke.mjs +357 -0
- package/src/cli/codex-restore-source-audit.mjs +304 -0
- package/src/cli/codex-resume.mjs +138 -0
- package/src/cli/codex-rollback-model-visible-smoke.mjs +373 -0
- package/src/cli/codex-rollback-model-visible-smoke.test.mjs +255 -0
- package/src/cli/codex-sidecar-diagnostics.mjs +48 -0
- package/src/cli/codex-sidecar-dry-run.mjs +85 -0
- package/src/cli/codex-summarize.mjs +224 -0
- package/src/cli/codex-threads.mjs +89 -0
- package/src/cli/codex-visibility-smoke.mjs +196 -0
- package/src/cli/codex-vscode-restore-smoke.mjs +226 -0
- package/src/cli/codex-vscode-rollback-smoke.mjs +114 -0
- package/src/cli/doctor.mjs +503 -1
- package/src/cli/doctor.test.mjs +542 -3
- package/src/cli/handoff-preview.mjs +78 -0
- package/src/cli/help.test.mjs +64 -0
- package/src/cli/install.mjs +227 -4
- package/src/cli/install.test.mjs +207 -4
- package/src/cli/trim.mjs +564 -0
- package/src/codex-app-server.mjs +1816 -0
- package/src/codex-app-server.test.mjs +512 -0
- package/src/codex-auto-refresh.mjs +194 -0
- package/src/codex-auto-refresh.test.mjs +182 -0
- package/src/codex-capture.mjs +235 -0
- package/src/codex-capture.test.mjs +393 -0
- package/src/codex-handoff-model-smoke.mjs +114 -0
- package/src/codex-handoff-model-smoke.test.mjs +89 -0
- package/src/codex-handoff-smoke.mjs +124 -0
- package/src/codex-handoff-smoke.test.mjs +103 -0
- package/src/codex-handoff.mjs +331 -0
- package/src/codex-handoff.test.mjs +220 -0
- package/src/codex-host-primitive-audit.mjs +374 -0
- package/src/codex-host-primitive-audit.test.mjs +208 -0
- package/src/codex-restore-smoke.test.mjs +639 -0
- package/src/codex-restore-source-audit.mjs +1348 -0
- package/src/codex-restore-source-audit.test.mjs +623 -0
- package/src/codex-resume.test.mjs +242 -0
- package/src/codex-rollout-memory.mjs +711 -0
- package/src/codex-rollout-memory.test.mjs +610 -0
- package/src/codex-sidecar-cli.test.mjs +75 -0
- package/src/codex-sidecar.mjs +246 -0
- package/src/codex-sidecar.test.mjs +172 -0
- package/src/codex-summarize.test.mjs +143 -0
- package/src/codex-thread-identity.mjs +23 -0
- package/src/codex-thread-index.mjs +173 -0
- package/src/codex-thread-index.test.mjs +164 -0
- package/src/codex-usage.mjs +110 -0
- package/src/codex-usage.test.mjs +140 -0
- package/src/codex-visibility-smoke.test.mjs +222 -0
- package/src/codex-vscode-restore-smoke.mjs +206 -0
- package/src/codex-vscode-restore-smoke.test.mjs +325 -0
- package/src/codex-vscode-rollback-smoke.mjs +90 -0
- package/src/codex-vscode-rollback-smoke.test.mjs +290 -0
- package/src/db-schema.test.mjs +97 -0
- package/src/haiku-summarizer.mjs +267 -26
- package/src/haiku-summarizer.test.mjs +282 -0
- package/src/handoff-preview.test.mjs +108 -0
- package/src/handoff-record.mjs +294 -0
- package/src/handoff-record.test.mjs +226 -0
- package/src/hook-entrypoints.test.mjs +326 -0
- package/src/package-files.test.mjs +19 -0
- package/src/prompt-submit.mjs +9 -6
- package/src/resume-context.mjs +44 -140
- package/src/resume-context.test.mjs +172 -0
- package/src/session-start.mjs +8 -5
- package/src/state-file.mjs +50 -6
- package/src/state-file.test.mjs +50 -0
- package/src/token-monitor.mjs +14 -10
- package/src/token-monitor.test.mjs +27 -0
- package/src/trim-cli.test.mjs +1584 -0
- package/src/trim-model.mjs +584 -0
- package/src/trim-model.test.mjs +568 -0
- package/src/turn-processor.mjs +17 -10
- package/src/vscode-task.mjs +94 -6
- package/src/vscode-task.test.mjs +186 -6
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
CODEX_AUTO_REFRESH_THRESHOLD,
|
|
6
|
+
evaluateCodexAutoRefreshUsage,
|
|
7
|
+
runCodexAutoRefresh,
|
|
8
|
+
} from './codex-auto-refresh.mjs';
|
|
9
|
+
|
|
10
|
+
test('evaluateCodexAutoRefreshUsage: default threshold is 90%', () => {
|
|
11
|
+
assert.equal(CODEX_AUTO_REFRESH_THRESHOLD, 0.9);
|
|
12
|
+
const below = evaluateCodexAutoRefreshUsage({
|
|
13
|
+
tokens: 232_559,
|
|
14
|
+
contextWindowSize: 258_400,
|
|
15
|
+
estimated: false,
|
|
16
|
+
contextWindowEstimated: false,
|
|
17
|
+
});
|
|
18
|
+
assert.equal(below.shouldRefresh, false);
|
|
19
|
+
assert.equal(below.reason, 'below_threshold');
|
|
20
|
+
|
|
21
|
+
const atThreshold = evaluateCodexAutoRefreshUsage({
|
|
22
|
+
tokens: 232_560,
|
|
23
|
+
contextWindowSize: 258_400,
|
|
24
|
+
estimated: false,
|
|
25
|
+
contextWindowEstimated: false,
|
|
26
|
+
});
|
|
27
|
+
assert.equal(atThreshold.shouldRefresh, true);
|
|
28
|
+
assert.equal(atThreshold.reason, 'threshold_reached');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('evaluateCodexAutoRefreshUsage: estimate does not trigger mutation', () => {
|
|
32
|
+
const estimatedUsage = evaluateCodexAutoRefreshUsage({
|
|
33
|
+
tokens: 250_000,
|
|
34
|
+
contextWindowSize: 258_400,
|
|
35
|
+
estimated: true,
|
|
36
|
+
contextWindowEstimated: false,
|
|
37
|
+
});
|
|
38
|
+
assert.equal(estimatedUsage.shouldRefresh, false);
|
|
39
|
+
assert.equal(estimatedUsage.reason, 'estimated_usage_not_allowed');
|
|
40
|
+
|
|
41
|
+
const estimatedWindow = evaluateCodexAutoRefreshUsage({
|
|
42
|
+
tokens: 250_000,
|
|
43
|
+
contextWindowSize: 258_400,
|
|
44
|
+
estimated: false,
|
|
45
|
+
contextWindowEstimated: true,
|
|
46
|
+
});
|
|
47
|
+
assert.equal(estimatedWindow.shouldRefresh, false);
|
|
48
|
+
assert.equal(estimatedWindow.reason, 'estimated_context_window_not_allowed');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('runCodexAutoRefresh: below threshold skips before building trim source', async () => {
|
|
52
|
+
let buildTrimSourceCalled = false;
|
|
53
|
+
const result = await runCodexAutoRefresh({
|
|
54
|
+
db: {},
|
|
55
|
+
threadId: '019dfaba-thread',
|
|
56
|
+
projectPath: '/repo',
|
|
57
|
+
usage: {
|
|
58
|
+
tokens: 100_000,
|
|
59
|
+
contextWindowSize: 258_400,
|
|
60
|
+
estimated: false,
|
|
61
|
+
contextWindowEstimated: false,
|
|
62
|
+
},
|
|
63
|
+
deps: {
|
|
64
|
+
buildTrimSource: () => {
|
|
65
|
+
buildTrimSourceCalled = true;
|
|
66
|
+
return null;
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
assert.equal(result.status, 'skipped');
|
|
72
|
+
assert.equal(result.reason, 'below_threshold');
|
|
73
|
+
assert.equal(buildTrimSourceCalled, false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('runCodexAutoRefresh: threshold reached rolls back and injects Throughline memory', async () => {
|
|
77
|
+
let buildTrimSourceCalled = false;
|
|
78
|
+
let runTrimExecutionArgs = null;
|
|
79
|
+
const result = await runCodexAutoRefresh({
|
|
80
|
+
db: {},
|
|
81
|
+
threadId: '019dfaba-thread',
|
|
82
|
+
codexThreadIdSource: 'payload:session_id',
|
|
83
|
+
projectPath: '/repo',
|
|
84
|
+
sessionId: 'codex:019dfaba-thread',
|
|
85
|
+
usage: {
|
|
86
|
+
tokens: 240_000,
|
|
87
|
+
contextWindowSize: 258_400,
|
|
88
|
+
estimated: false,
|
|
89
|
+
contextWindowEstimated: false,
|
|
90
|
+
},
|
|
91
|
+
deps: {
|
|
92
|
+
buildTrimSource: (args) => {
|
|
93
|
+
buildTrimSourceCalled = true;
|
|
94
|
+
assert.equal(args.threadId, '019dfaba-thread');
|
|
95
|
+
assert.equal(args.sourceReason, 'payload_codex_thread_rollout');
|
|
96
|
+
return {
|
|
97
|
+
source: 'codex-rollout',
|
|
98
|
+
capturedTurns: 2,
|
|
99
|
+
memoryPreview: { text: 'rollout preview' },
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
buildTrimPlan: (db, args) => {
|
|
103
|
+
assert.equal(args.sessionId, 'codex:019dfaba-thread');
|
|
104
|
+
assert.equal(args.trimAll, true);
|
|
105
|
+
assert.equal(args.host, 'codex');
|
|
106
|
+
return {
|
|
107
|
+
status: 'ready',
|
|
108
|
+
trim: {
|
|
109
|
+
source: 'codex-rollout',
|
|
110
|
+
capturedTurns: 2,
|
|
111
|
+
rollbackTurns: 2,
|
|
112
|
+
},
|
|
113
|
+
memoryPreview: {
|
|
114
|
+
text: '## Throughline: Active Work Context\n\nrecent work',
|
|
115
|
+
stats: { source: 'throughline-db' },
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
runTrimExecution: async (args) => {
|
|
120
|
+
runTrimExecutionArgs = args;
|
|
121
|
+
return {
|
|
122
|
+
rollbackSent: true,
|
|
123
|
+
injectSent: true,
|
|
124
|
+
postInjectVisibilityCheck: { status: 'match' },
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
assert.equal(result.status, 'refreshed-live');
|
|
131
|
+
assert.equal(result.reason, 'rollback_and_inject_sent_live');
|
|
132
|
+
assert.equal(buildTrimSourceCalled, true);
|
|
133
|
+
assert.equal(runTrimExecutionArgs.threadId, '019dfaba-thread');
|
|
134
|
+
assert.equal(runTrimExecutionArgs.cwd, '/repo');
|
|
135
|
+
assert.equal(runTrimExecutionArgs.rollbackTurns, 2);
|
|
136
|
+
assert.equal(runTrimExecutionArgs.expectedTurns, 2);
|
|
137
|
+
assert.match(runTrimExecutionArgs.memoryText, /Throughline: Active Work Context/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('runCodexAutoRefresh: threshold reached skips when injectable DB memory is missing', async () => {
|
|
141
|
+
let runTrimExecutionCalled = false;
|
|
142
|
+
const result = await runCodexAutoRefresh({
|
|
143
|
+
db: {},
|
|
144
|
+
threadId: '019dfaba-thread',
|
|
145
|
+
projectPath: '/repo',
|
|
146
|
+
usage: {
|
|
147
|
+
tokens: 240_000,
|
|
148
|
+
contextWindowSize: 258_400,
|
|
149
|
+
estimated: false,
|
|
150
|
+
contextWindowEstimated: false,
|
|
151
|
+
},
|
|
152
|
+
deps: {
|
|
153
|
+
buildTrimSource: () => ({
|
|
154
|
+
source: 'codex-rollout',
|
|
155
|
+
capturedTurns: 2,
|
|
156
|
+
memoryPreview: { text: 'rollout preview' },
|
|
157
|
+
}),
|
|
158
|
+
buildTrimPlan: () => {
|
|
159
|
+
return {
|
|
160
|
+
status: 'ready',
|
|
161
|
+
trim: {
|
|
162
|
+
source: 'codex-rollout',
|
|
163
|
+
capturedTurns: 2,
|
|
164
|
+
rollbackTurns: 2,
|
|
165
|
+
},
|
|
166
|
+
memoryPreview: {
|
|
167
|
+
text: 'rollout preview only',
|
|
168
|
+
stats: { source: 'codex-rollout' },
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
},
|
|
172
|
+
runTrimExecution: async () => {
|
|
173
|
+
runTrimExecutionCalled = true;
|
|
174
|
+
return {};
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
assert.equal(result.status, 'skipped');
|
|
180
|
+
assert.equal(result.reason, 'injectable_memory_required');
|
|
181
|
+
assert.equal(runTrimExecutionCalled, false);
|
|
182
|
+
});
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { parseCodexRolloutFile } from './codex-rollout-memory.mjs';
|
|
2
|
+
import { defaultCodexHome, findCodexThreadCandidate } from './codex-thread-index.mjs';
|
|
3
|
+
|
|
4
|
+
export const CODEX_SESSION_PREFIX = 'codex:';
|
|
5
|
+
|
|
6
|
+
export function buildCodexThroughlineSessionId(threadId) {
|
|
7
|
+
if (typeof threadId !== 'string' || threadId.trim().length === 0) {
|
|
8
|
+
throw new Error('threadId is required');
|
|
9
|
+
}
|
|
10
|
+
return `${CODEX_SESSION_PREFIX}${threadId.trim()}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isCodexThroughlineSessionId(sessionId) {
|
|
14
|
+
return typeof sessionId === 'string' && sessionId.startsWith(CODEX_SESSION_PREFIX);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function codexSessionIdToThreadId(sessionId) {
|
|
18
|
+
if (!isCodexThroughlineSessionId(sessionId)) return null;
|
|
19
|
+
return sessionId.slice(CODEX_SESSION_PREFIX.length) || null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function captureCodexRolloutToDb(
|
|
23
|
+
db,
|
|
24
|
+
{
|
|
25
|
+
threadId,
|
|
26
|
+
codexHome = defaultCodexHome(),
|
|
27
|
+
projectPath = process.cwd(),
|
|
28
|
+
now = Date.now(),
|
|
29
|
+
} = {},
|
|
30
|
+
) {
|
|
31
|
+
if (!db) throw new Error('db is required');
|
|
32
|
+
if (typeof threadId !== 'string' || threadId.trim().length === 0) {
|
|
33
|
+
throw new Error('threadId is required');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const candidate = findCodexThreadCandidate({
|
|
37
|
+
threadId: threadId.trim(),
|
|
38
|
+
codexHome,
|
|
39
|
+
projectPath,
|
|
40
|
+
requireProjectMatch: true,
|
|
41
|
+
});
|
|
42
|
+
if (!candidate) {
|
|
43
|
+
return {
|
|
44
|
+
status: 'unavailable',
|
|
45
|
+
reason: 'codex_rollout_not_found_for_project',
|
|
46
|
+
threadId: threadId.trim(),
|
|
47
|
+
sessionId: buildCodexThroughlineSessionId(threadId),
|
|
48
|
+
projectPath,
|
|
49
|
+
capturedTurns: 0,
|
|
50
|
+
capturedRows: 0,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const parsed = parseCodexRolloutFile(candidate.rolloutPath);
|
|
55
|
+
const sessionId = buildCodexThroughlineSessionId(candidate.id);
|
|
56
|
+
const rows = buildBodyRowsFromActiveTurns(parsed.activeTurns, {
|
|
57
|
+
sessionId,
|
|
58
|
+
now,
|
|
59
|
+
});
|
|
60
|
+
const detailRows = buildDetailRowsFromActiveTurns(parsed.activeTurns, {
|
|
61
|
+
sessionId,
|
|
62
|
+
now,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
db.exec('BEGIN IMMEDIATE');
|
|
66
|
+
try {
|
|
67
|
+
db.prepare(
|
|
68
|
+
`INSERT INTO sessions (session_id, project_path, status, created_at, updated_at)
|
|
69
|
+
VALUES (?, ?, 'active', ?, ?)
|
|
70
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
71
|
+
project_path = excluded.project_path,
|
|
72
|
+
status = 'active',
|
|
73
|
+
updated_at = excluded.updated_at`,
|
|
74
|
+
).run(sessionId, candidate.cwd ?? projectPath, now, now);
|
|
75
|
+
|
|
76
|
+
// Codex rollout is the source of truth for this namespaced session. Rebuild
|
|
77
|
+
// it so rolled-back tail turns from a previous capture cannot survive.
|
|
78
|
+
db.prepare('DELETE FROM skeletons WHERE session_id = ?').run(sessionId);
|
|
79
|
+
db.prepare('DELETE FROM bodies WHERE session_id = ?').run(sessionId);
|
|
80
|
+
db.prepare('DELETE FROM details WHERE session_id = ?').run(sessionId);
|
|
81
|
+
|
|
82
|
+
const insertBody = db.prepare(
|
|
83
|
+
`INSERT INTO bodies
|
|
84
|
+
(session_id, origin_session_id, turn_number, role, text, token_count, created_at)
|
|
85
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
86
|
+
);
|
|
87
|
+
const insertDetail = db.prepare(
|
|
88
|
+
`INSERT OR IGNORE INTO details
|
|
89
|
+
(session_id, origin_session_id, turn_number, tool_name, input_text, output_text,
|
|
90
|
+
token_count, created_at, kind, source_id)
|
|
91
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
for (const row of rows) {
|
|
95
|
+
insertBody.run(
|
|
96
|
+
row.sessionId,
|
|
97
|
+
row.originSessionId,
|
|
98
|
+
row.turnNumber,
|
|
99
|
+
row.role,
|
|
100
|
+
row.text,
|
|
101
|
+
row.tokenCount,
|
|
102
|
+
row.createdAt,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
for (const row of detailRows) {
|
|
106
|
+
insertDetail.run(
|
|
107
|
+
row.sessionId,
|
|
108
|
+
row.originSessionId,
|
|
109
|
+
row.turnNumber,
|
|
110
|
+
row.toolName,
|
|
111
|
+
row.inputText,
|
|
112
|
+
row.outputText,
|
|
113
|
+
row.tokenCount,
|
|
114
|
+
row.createdAt,
|
|
115
|
+
row.kind,
|
|
116
|
+
row.sourceId,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
db.exec('COMMIT');
|
|
121
|
+
} catch (err) {
|
|
122
|
+
db.exec('ROLLBACK');
|
|
123
|
+
throw err;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
status: 'captured',
|
|
128
|
+
source: 'codex-rollout',
|
|
129
|
+
sourceAgent: 'codex',
|
|
130
|
+
threadId: candidate.id,
|
|
131
|
+
sessionId,
|
|
132
|
+
projectPath: candidate.cwd ?? projectPath,
|
|
133
|
+
rolloutPath: candidate.rolloutPath,
|
|
134
|
+
capturedTurns: parsed.activeTurnCount,
|
|
135
|
+
capturedRows: rows.length,
|
|
136
|
+
capturedDetails: detailRows.length,
|
|
137
|
+
stats: parsed.stats,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function buildBodyRowsFromActiveTurns(activeTurns, { sessionId, now = Date.now() } = {}) {
|
|
142
|
+
if (!isCodexThroughlineSessionId(sessionId)) {
|
|
143
|
+
throw new Error('Codex capture requires a codex:<thread_id> session id');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const rows = [];
|
|
147
|
+
let turnNumber = 0;
|
|
148
|
+
for (const turn of activeTurns ?? []) {
|
|
149
|
+
const grouped = groupMessagesByRole(turn.messages ?? []);
|
|
150
|
+
const details = turn.details ?? [];
|
|
151
|
+
if (grouped.length === 0 && details.length === 0) continue;
|
|
152
|
+
|
|
153
|
+
turnNumber++;
|
|
154
|
+
const createdAt = pickTurnCreatedAt(turn.messages ?? [], now);
|
|
155
|
+
for (const [role, text] of grouped) {
|
|
156
|
+
rows.push({
|
|
157
|
+
sessionId,
|
|
158
|
+
originSessionId: sessionId,
|
|
159
|
+
turnNumber,
|
|
160
|
+
role,
|
|
161
|
+
text,
|
|
162
|
+
tokenCount: Math.round(text.length / 4),
|
|
163
|
+
createdAt,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return rows;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function buildDetailRowsFromActiveTurns(activeTurns, { sessionId, now = Date.now() } = {}) {
|
|
171
|
+
if (!isCodexThroughlineSessionId(sessionId)) {
|
|
172
|
+
throw new Error('Codex capture requires a codex:<thread_id> session id');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const rows = [];
|
|
176
|
+
let turnNumber = 0;
|
|
177
|
+
for (const turn of activeTurns ?? []) {
|
|
178
|
+
const grouped = groupMessagesByRole(turn.messages ?? []);
|
|
179
|
+
const details = turn.details ?? [];
|
|
180
|
+
if (grouped.length === 0 && details.length === 0) continue;
|
|
181
|
+
|
|
182
|
+
turnNumber++;
|
|
183
|
+
for (const detail of details) {
|
|
184
|
+
if (!detail?.kind || !detail?.tool_name) continue;
|
|
185
|
+
const inputText = detail.input_text ?? null;
|
|
186
|
+
const outputText = detail.output_text ?? null;
|
|
187
|
+
rows.push({
|
|
188
|
+
sessionId,
|
|
189
|
+
originSessionId: sessionId,
|
|
190
|
+
turnNumber,
|
|
191
|
+
toolName: String(detail.tool_name),
|
|
192
|
+
inputText,
|
|
193
|
+
outputText,
|
|
194
|
+
tokenCount: Math.round(((inputText?.length ?? 0) + (outputText?.length ?? 0)) / 4),
|
|
195
|
+
createdAt: pickDetailCreatedAt(detail, now),
|
|
196
|
+
kind: String(detail.kind),
|
|
197
|
+
sourceId: detail.source_id ?? null,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return rows;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function groupMessagesByRole(messages) {
|
|
205
|
+
const grouped = new Map();
|
|
206
|
+
for (const message of messages) {
|
|
207
|
+
if (!message?.role || !message?.text) continue;
|
|
208
|
+
const role = String(message.role);
|
|
209
|
+
const existing = grouped.get(role);
|
|
210
|
+
grouped.set(role, existing ? `${existing}\n\n${message.text}` : message.text);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const preferred = ['user', 'assistant', 'developer'];
|
|
214
|
+
return [...grouped.entries()].sort(([a], [b]) => {
|
|
215
|
+
const ai = preferred.indexOf(a);
|
|
216
|
+
const bi = preferred.indexOf(b);
|
|
217
|
+
if (ai !== -1 || bi !== -1) {
|
|
218
|
+
return (ai === -1 ? preferred.length : ai) - (bi === -1 ? preferred.length : bi);
|
|
219
|
+
}
|
|
220
|
+
return a.localeCompare(b);
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function pickTurnCreatedAt(messages, fallback) {
|
|
225
|
+
const times = messages
|
|
226
|
+
.map((message) => Date.parse(message.time ?? ''))
|
|
227
|
+
.filter((time) => Number.isFinite(time));
|
|
228
|
+
if (times.length === 0) return fallback;
|
|
229
|
+
return Math.min(...times);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function pickDetailCreatedAt(detail, fallback) {
|
|
233
|
+
const time = Date.parse(detail?.time ?? '');
|
|
234
|
+
return Number.isFinite(time) ? time : fallback;
|
|
235
|
+
}
|