throughline 0.3.24 → 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 +383 -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 +33 -10
- package/src/vscode-task.test.mjs +19 -9
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
import { defaultCodexHome, findCodexThreadCandidate } from './codex-thread-index.mjs';
|
|
4
|
+
import { estimateTokens } from './token-estimator.mjs';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_PREVIEW_MAX_CHARS = 8_000;
|
|
7
|
+
const MAX_ENTRY_CHARS = 900;
|
|
8
|
+
const MAX_RECENT_ENTRIES = 40;
|
|
9
|
+
|
|
10
|
+
export function buildCodexRolloutTrimSource({
|
|
11
|
+
threadId,
|
|
12
|
+
codexHome = defaultCodexHome(),
|
|
13
|
+
projectPath = process.cwd(),
|
|
14
|
+
previewMaxChars = DEFAULT_PREVIEW_MAX_CHARS,
|
|
15
|
+
sourceReason = 'explicit_codex_thread_rollout',
|
|
16
|
+
} = {}) {
|
|
17
|
+
if (typeof threadId !== 'string' || threadId.length === 0) {
|
|
18
|
+
throw new Error('threadId is required');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const candidate = findCodexThreadCandidate({
|
|
22
|
+
threadId,
|
|
23
|
+
codexHome,
|
|
24
|
+
projectPath,
|
|
25
|
+
requireProjectMatch: true,
|
|
26
|
+
});
|
|
27
|
+
if (!candidate) return null;
|
|
28
|
+
|
|
29
|
+
const parsed = parseCodexRolloutFile(candidate.rolloutPath, { includeInFlightTurn: false });
|
|
30
|
+
const memoryPreview = renderCodexRolloutMemoryPreview({
|
|
31
|
+
candidate,
|
|
32
|
+
parsed,
|
|
33
|
+
previewMaxChars,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
source: 'codex-rollout',
|
|
38
|
+
sourceReason,
|
|
39
|
+
threadId,
|
|
40
|
+
rolloutPath: candidate.rolloutPath,
|
|
41
|
+
projectPath: candidate.cwd ?? projectPath,
|
|
42
|
+
capturedTurns: parsed.activeTurnCount,
|
|
43
|
+
memoryPreview,
|
|
44
|
+
stats: parsed.stats,
|
|
45
|
+
restoreSafety: parsed.restoreSafety,
|
|
46
|
+
contextEstimate: buildCodexContextEstimate(parsed),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function parseCodexRolloutFile(
|
|
51
|
+
path,
|
|
52
|
+
{ includeRestoreIndex = false, includeInFlightTurn = true } = {},
|
|
53
|
+
) {
|
|
54
|
+
const activeTurns = [];
|
|
55
|
+
let openTurn = null;
|
|
56
|
+
let postRollbackTurn = null;
|
|
57
|
+
let afterRollback = false;
|
|
58
|
+
let pendingMessages = [];
|
|
59
|
+
let pendingDetails = [];
|
|
60
|
+
const toolNameByCallId = new Map();
|
|
61
|
+
const compactedReplacementUserTexts = new Map();
|
|
62
|
+
const rolledBackUserTexts = new Map();
|
|
63
|
+
const resurrectedUserTexts = new Map();
|
|
64
|
+
const stats = {
|
|
65
|
+
parsedRows: 0,
|
|
66
|
+
corruptRows: 0,
|
|
67
|
+
compactedRows: 0,
|
|
68
|
+
compactedReplacementUserMessages: 0,
|
|
69
|
+
taskStarted: 0,
|
|
70
|
+
taskComplete: 0,
|
|
71
|
+
rollbackEvents: 0,
|
|
72
|
+
rolledBackTurns: 0,
|
|
73
|
+
rolledBackUserMessages: 0,
|
|
74
|
+
userMessagesAfterRollback: 0,
|
|
75
|
+
latestRollbackAt: null,
|
|
76
|
+
rollbackTextRetainedInCompacted: 0,
|
|
77
|
+
resurrectedUserMessages: 0,
|
|
78
|
+
injectedDeveloperMessages: 0,
|
|
79
|
+
syntheticContinuationTurns: 0,
|
|
80
|
+
toolInputs: 0,
|
|
81
|
+
toolOutputs: 0,
|
|
82
|
+
skippedMessages: 0,
|
|
83
|
+
inFlightTurnsExcluded: 0,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
87
|
+
if (!line.trim()) continue;
|
|
88
|
+
let row;
|
|
89
|
+
try {
|
|
90
|
+
row = JSON.parse(line);
|
|
91
|
+
stats.parsedRows++;
|
|
92
|
+
} catch {
|
|
93
|
+
stats.corruptRows++;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const payload = row?.payload;
|
|
98
|
+
if (row?.type === 'compacted') {
|
|
99
|
+
stats.compactedRows++;
|
|
100
|
+
const userMessages = compactedPayloadToUserMessages(payload, row.timestamp);
|
|
101
|
+
stats.compactedReplacementUserMessages += userMessages.length;
|
|
102
|
+
for (const message of userMessages) {
|
|
103
|
+
incrementTextMap(compactedReplacementUserTexts, message.text);
|
|
104
|
+
}
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (row?.type === 'response_item') {
|
|
109
|
+
const responseUserMessage = responseItemToUserMessage(payload, row.timestamp);
|
|
110
|
+
if (responseUserMessage) {
|
|
111
|
+
if (stats.rollbackEvents > 0) {
|
|
112
|
+
stats.userMessagesAfterRollback++;
|
|
113
|
+
}
|
|
114
|
+
notePotentialResurrectedUserMessage({
|
|
115
|
+
message: responseUserMessage,
|
|
116
|
+
compactedReplacementUserTexts,
|
|
117
|
+
rolledBackUserTexts,
|
|
118
|
+
resurrectedUserTexts,
|
|
119
|
+
stats,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
const injectedMessage = responseItemToMemoryMessage(payload, row.timestamp);
|
|
123
|
+
if (injectedMessage) {
|
|
124
|
+
stats.injectedDeveloperMessages++;
|
|
125
|
+
}
|
|
126
|
+
const detail = responseItemToDetail(payload, row.timestamp, toolNameByCallId);
|
|
127
|
+
if (detail) {
|
|
128
|
+
if (detail.kind === 'tool_input') stats.toolInputs++;
|
|
129
|
+
if (detail.kind === 'tool_output') stats.toolOutputs++;
|
|
130
|
+
if (openTurn) {
|
|
131
|
+
openTurn.details.push(detail);
|
|
132
|
+
} else if (afterRollback) {
|
|
133
|
+
if (!postRollbackTurn) {
|
|
134
|
+
postRollbackTurn = {
|
|
135
|
+
number: `rollout-${stats.parsedRows}`,
|
|
136
|
+
messages: [],
|
|
137
|
+
details: [],
|
|
138
|
+
};
|
|
139
|
+
activeTurns.push(postRollbackTurn);
|
|
140
|
+
}
|
|
141
|
+
postRollbackTurn.details.push(detail);
|
|
142
|
+
} else {
|
|
143
|
+
pendingDetails.push(detail);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (row?.type !== 'event_msg' || !payload?.type) continue;
|
|
150
|
+
|
|
151
|
+
if (payload.type === 'task_started') {
|
|
152
|
+
const pendingSplit = splitPendingMessagesForTaskStart({
|
|
153
|
+
messages: pendingMessages,
|
|
154
|
+
details: pendingDetails,
|
|
155
|
+
syntheticNumber: `rollout-${stats.parsedRows}`,
|
|
156
|
+
});
|
|
157
|
+
if (pendingSplit.syntheticTurn) {
|
|
158
|
+
activeTurns.push(pendingSplit.syntheticTurn);
|
|
159
|
+
stats.syntheticContinuationTurns++;
|
|
160
|
+
}
|
|
161
|
+
const turn = {
|
|
162
|
+
number: stats.taskStarted + 1,
|
|
163
|
+
messages: pendingSplit.currentMessages,
|
|
164
|
+
details: pendingSplit.currentDetails,
|
|
165
|
+
};
|
|
166
|
+
pendingMessages = [];
|
|
167
|
+
pendingDetails = [];
|
|
168
|
+
activeTurns.push(turn);
|
|
169
|
+
openTurn = turn;
|
|
170
|
+
postRollbackTurn = null;
|
|
171
|
+
afterRollback = false;
|
|
172
|
+
stats.taskStarted++;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (payload.type === 'task_complete') {
|
|
177
|
+
stats.taskComplete++;
|
|
178
|
+
openTurn = null;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (payload.type === 'thread_rolled_back') {
|
|
183
|
+
const count = Math.max(0, Number(payload.num_turns) || 0);
|
|
184
|
+
stats.rollbackEvents++;
|
|
185
|
+
stats.rolledBackTurns += count;
|
|
186
|
+
stats.latestRollbackAt = row.timestamp ?? null;
|
|
187
|
+
const removedTurns = removeRolledBackTurns(activeTurns, count);
|
|
188
|
+
for (const message of removedTurns.flatMap((turn) => turn.messages)) {
|
|
189
|
+
if (message.role !== 'user') continue;
|
|
190
|
+
stats.rolledBackUserMessages++;
|
|
191
|
+
incrementTextMap(rolledBackUserTexts, message.text);
|
|
192
|
+
}
|
|
193
|
+
if (openTurn && !activeTurns.includes(openTurn)) {
|
|
194
|
+
openTurn = null;
|
|
195
|
+
}
|
|
196
|
+
if (postRollbackTurn && !activeTurns.includes(postRollbackTurn)) {
|
|
197
|
+
postRollbackTurn = null;
|
|
198
|
+
}
|
|
199
|
+
pendingMessages = [];
|
|
200
|
+
pendingDetails = [];
|
|
201
|
+
afterRollback = true;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const message = eventPayloadToMemoryMessage(payload, row.timestamp);
|
|
206
|
+
if (!message) {
|
|
207
|
+
if (payload.type === 'user_message' || payload.type === 'agent_message') {
|
|
208
|
+
stats.skippedMessages++;
|
|
209
|
+
}
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (message.role === 'user') {
|
|
213
|
+
if (stats.rollbackEvents > 0) {
|
|
214
|
+
stats.userMessagesAfterRollback++;
|
|
215
|
+
}
|
|
216
|
+
notePotentialResurrectedUserMessage({
|
|
217
|
+
message,
|
|
218
|
+
compactedReplacementUserTexts,
|
|
219
|
+
rolledBackUserTexts,
|
|
220
|
+
resurrectedUserTexts,
|
|
221
|
+
stats,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (openTurn) {
|
|
226
|
+
openTurn.messages.push(message);
|
|
227
|
+
} else if (afterRollback && message.role === 'assistant') {
|
|
228
|
+
if (!postRollbackTurn) {
|
|
229
|
+
postRollbackTurn = {
|
|
230
|
+
number: `rollout-${stats.parsedRows}`,
|
|
231
|
+
messages: [],
|
|
232
|
+
details: [],
|
|
233
|
+
};
|
|
234
|
+
activeTurns.push(postRollbackTurn);
|
|
235
|
+
}
|
|
236
|
+
postRollbackTurn.messages.push(message);
|
|
237
|
+
} else {
|
|
238
|
+
pendingMessages.push(message);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (!includeInFlightTurn && openTurn) {
|
|
243
|
+
const index = activeTurns.indexOf(openTurn);
|
|
244
|
+
if (index >= 0) {
|
|
245
|
+
activeTurns.splice(index, 1);
|
|
246
|
+
stats.inFlightTurnsExcluded++;
|
|
247
|
+
}
|
|
248
|
+
openTurn = null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (!includeInFlightTurn && afterRollback && postRollbackTurn) {
|
|
252
|
+
const index = activeTurns.indexOf(postRollbackTurn);
|
|
253
|
+
if (index >= 0) {
|
|
254
|
+
activeTurns.splice(index, 1);
|
|
255
|
+
stats.inFlightTurnsExcluded++;
|
|
256
|
+
}
|
|
257
|
+
postRollbackTurn = null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (pendingMessages.length > 0 || pendingDetails.length > 0) {
|
|
261
|
+
activeTurns.push({
|
|
262
|
+
number: `rollout-${stats.parsedRows}`,
|
|
263
|
+
messages: pendingMessages,
|
|
264
|
+
details: pendingDetails,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const restoreSafety = buildRestoreSafetyDiagnostics({
|
|
269
|
+
compactedReplacementUserTexts,
|
|
270
|
+
rolledBackUserTexts,
|
|
271
|
+
resurrectedUserTexts,
|
|
272
|
+
stats,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
return compactNullish({
|
|
276
|
+
activeTurnCount: activeTurns.length,
|
|
277
|
+
activeTurns,
|
|
278
|
+
entries: activeTurns.flatMap((turn) =>
|
|
279
|
+
turn.messages.map((message) => ({
|
|
280
|
+
...message,
|
|
281
|
+
turn: turn.number,
|
|
282
|
+
})),
|
|
283
|
+
),
|
|
284
|
+
stats,
|
|
285
|
+
restoreSafety,
|
|
286
|
+
_restoreIndex: includeRestoreIndex
|
|
287
|
+
? {
|
|
288
|
+
compactedReplacementUserTexts,
|
|
289
|
+
}
|
|
290
|
+
: null,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function inspectCodexPlannedRollbackRestoreSafety({ rolloutPath, rollbackTurns } = {}) {
|
|
295
|
+
if (typeof rolloutPath !== 'string' || rolloutPath.length === 0) {
|
|
296
|
+
throw new Error('rolloutPath is required');
|
|
297
|
+
}
|
|
298
|
+
if (!Number.isInteger(rollbackTurns) || rollbackTurns < 0) {
|
|
299
|
+
throw new Error('rollbackTurns must be a non-negative integer');
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const parsed = parseCodexRolloutFile(rolloutPath, {
|
|
303
|
+
includeRestoreIndex: true,
|
|
304
|
+
includeInFlightTurn: false,
|
|
305
|
+
});
|
|
306
|
+
const compactedReplacementUserTexts =
|
|
307
|
+
parsed._restoreIndex?.compactedReplacementUserTexts ?? new Map();
|
|
308
|
+
const targetTurns = parsed.activeTurns.slice(
|
|
309
|
+
Math.max(0, parsed.activeTurns.length - rollbackTurns),
|
|
310
|
+
);
|
|
311
|
+
const plannedUserTexts = new Map();
|
|
312
|
+
for (const message of targetTurns.flatMap((turn) => turn.messages)) {
|
|
313
|
+
if (message.role !== 'user') continue;
|
|
314
|
+
incrementTextMap(plannedUserTexts, message.text);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const retainedTexts = [];
|
|
318
|
+
for (const [text, planned] of plannedUserTexts) {
|
|
319
|
+
const compacted = compactedReplacementUserTexts.get(text);
|
|
320
|
+
if (!compacted) continue;
|
|
321
|
+
retainedTexts.push({
|
|
322
|
+
textPreview: clipDiagnosticText(text),
|
|
323
|
+
plannedRollbackCount: planned.count,
|
|
324
|
+
compactedReplacementCount: compacted.count,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const risks =
|
|
329
|
+
retainedTexts.length > 0
|
|
330
|
+
? [
|
|
331
|
+
{
|
|
332
|
+
type: 'planned_rollback_text_retained_in_compacted_replacement_history',
|
|
333
|
+
count: retainedTexts.length,
|
|
334
|
+
message:
|
|
335
|
+
'User text targeted by the planned rollback is already present in compacted.replacement_history and may be restored later.',
|
|
336
|
+
},
|
|
337
|
+
]
|
|
338
|
+
: [];
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
status: risks.length > 0 ? 'risk' : 'ok',
|
|
342
|
+
rolloutPath,
|
|
343
|
+
plannedRollbackTurns: rollbackTurns,
|
|
344
|
+
activeTurnCount: parsed.activeTurnCount,
|
|
345
|
+
compactedRows: parsed.stats.compactedRows,
|
|
346
|
+
compactedReplacementUserMessages: parsed.stats.compactedReplacementUserMessages,
|
|
347
|
+
plannedRollbackUserMessages: [...plannedUserTexts.values()].reduce(
|
|
348
|
+
(sum, entry) => sum + entry.count,
|
|
349
|
+
0,
|
|
350
|
+
),
|
|
351
|
+
rollbackTextRetainedInCompacted: retainedTexts.length,
|
|
352
|
+
retainedTexts,
|
|
353
|
+
risks,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function compactedPayloadToUserMessages(payload, timestamp) {
|
|
358
|
+
if (!Array.isArray(payload?.replacement_history)) return [];
|
|
359
|
+
return payload.replacement_history
|
|
360
|
+
.filter((item) => item?.type === 'message' && item.role === 'user')
|
|
361
|
+
.map((item) => ({
|
|
362
|
+
time: timestamp ?? null,
|
|
363
|
+
role: 'user',
|
|
364
|
+
text: normalizeMessageText(messageContentToText(item.content)),
|
|
365
|
+
}))
|
|
366
|
+
.filter((message) => message.text.length > 0);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function responseItemToDetail(payload, timestamp, toolNameByCallId) {
|
|
370
|
+
if (payload?.type === 'function_call') {
|
|
371
|
+
const callId = typeof payload.call_id === 'string' ? payload.call_id : null;
|
|
372
|
+
const toolName = [payload.namespace, payload.name].filter((v) => typeof v === 'string' && v).join('.');
|
|
373
|
+
const name = toolName || 'function_call';
|
|
374
|
+
if (callId) toolNameByCallId.set(callId, name);
|
|
375
|
+
return {
|
|
376
|
+
time: timestamp ?? null,
|
|
377
|
+
kind: 'tool_input',
|
|
378
|
+
tool_name: name,
|
|
379
|
+
source_id: callId,
|
|
380
|
+
input_text: stringifyToolArguments(payload.arguments),
|
|
381
|
+
output_text: null,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (payload?.type === 'function_call_output') {
|
|
386
|
+
const callId = typeof payload.call_id === 'string' ? payload.call_id : null;
|
|
387
|
+
return {
|
|
388
|
+
time: timestamp ?? null,
|
|
389
|
+
kind: 'tool_output',
|
|
390
|
+
tool_name: callId ? (toolNameByCallId.get(callId) ?? 'function_call') : 'function_call',
|
|
391
|
+
source_id: callId ? `${callId}:output` : null,
|
|
392
|
+
input_text: null,
|
|
393
|
+
output_text: typeof payload.output === 'string' ? payload.output : JSON.stringify(payload.output ?? null),
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function stringifyToolArguments(value) {
|
|
401
|
+
if (typeof value === 'string') return value;
|
|
402
|
+
return JSON.stringify(value ?? null);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function splitPendingMessagesForTaskStart({ messages, details, syntheticNumber }) {
|
|
406
|
+
const firstCurrentMessageIndex = messages.findIndex((message) => message.role !== 'assistant');
|
|
407
|
+
if (firstCurrentMessageIndex === -1 && messages.length > 0) {
|
|
408
|
+
return {
|
|
409
|
+
syntheticTurn: {
|
|
410
|
+
number: syntheticNumber,
|
|
411
|
+
messages,
|
|
412
|
+
details,
|
|
413
|
+
},
|
|
414
|
+
currentMessages: [],
|
|
415
|
+
currentDetails: [],
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (firstCurrentMessageIndex <= 0) {
|
|
420
|
+
return {
|
|
421
|
+
syntheticTurn: null,
|
|
422
|
+
currentMessages: messages,
|
|
423
|
+
currentDetails: details,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const syntheticMessages = messages.slice(0, firstCurrentMessageIndex);
|
|
428
|
+
const currentMessages = messages.slice(firstCurrentMessageIndex);
|
|
429
|
+
const boundaryTime = currentMessages[0]?.time ?? null;
|
|
430
|
+
const syntheticDetails = [];
|
|
431
|
+
const currentDetails = [];
|
|
432
|
+
|
|
433
|
+
for (const detail of details) {
|
|
434
|
+
if (boundaryTime && detail?.time && detail.time < boundaryTime) {
|
|
435
|
+
syntheticDetails.push(detail);
|
|
436
|
+
} else {
|
|
437
|
+
currentDetails.push(detail);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
syntheticTurn: {
|
|
443
|
+
number: syntheticNumber,
|
|
444
|
+
messages: syntheticMessages,
|
|
445
|
+
details: syntheticDetails,
|
|
446
|
+
},
|
|
447
|
+
currentMessages,
|
|
448
|
+
currentDetails,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function removeRolledBackTurns(activeTurns, count) {
|
|
453
|
+
return activeTurns.splice(Math.max(0, activeTurns.length - count), count);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function eventPayloadToMemoryMessage(payload, timestamp) {
|
|
457
|
+
if (payload.type === 'user_message') {
|
|
458
|
+
const text = normalizeMessageText(payload.message);
|
|
459
|
+
if (!text || shouldSkipUserMessage(text)) return null;
|
|
460
|
+
return {
|
|
461
|
+
time: timestamp ?? null,
|
|
462
|
+
role: 'user',
|
|
463
|
+
text,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (payload.type === 'agent_message') {
|
|
468
|
+
const text = normalizeMessageText(payload.message);
|
|
469
|
+
if (!text) return null;
|
|
470
|
+
return {
|
|
471
|
+
time: timestamp ?? null,
|
|
472
|
+
role: 'assistant',
|
|
473
|
+
text,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function responseItemToMemoryMessage(payload, timestamp) {
|
|
481
|
+
if (payload?.type !== 'message' || payload.role !== 'developer') return null;
|
|
482
|
+
const text = normalizeMessageText(messageContentToText(payload.content));
|
|
483
|
+
if (!isThroughlineInjectedDeveloperMemory(text)) return null;
|
|
484
|
+
if (!text) return null;
|
|
485
|
+
return {
|
|
486
|
+
time: timestamp ?? null,
|
|
487
|
+
role: 'developer',
|
|
488
|
+
text,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function responseItemToUserMessage(payload, timestamp) {
|
|
493
|
+
if (payload?.type !== 'message' || payload.role !== 'user') return null;
|
|
494
|
+
const text = normalizeMessageText(messageContentToText(payload.content));
|
|
495
|
+
if (!text || shouldSkipUserMessage(text)) return null;
|
|
496
|
+
return {
|
|
497
|
+
time: timestamp ?? null,
|
|
498
|
+
role: 'user',
|
|
499
|
+
text,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function isThroughlineInjectedDeveloperMemory(text) {
|
|
504
|
+
return text.startsWith('## Throughline: Active Work Context');
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function messageContentToText(content) {
|
|
508
|
+
if (!Array.isArray(content)) return '';
|
|
509
|
+
return content
|
|
510
|
+
.map((item) => {
|
|
511
|
+
if (typeof item?.text === 'string') return item.text;
|
|
512
|
+
if (typeof item?.input_text === 'string') return item.input_text;
|
|
513
|
+
if (typeof item?.output_text === 'string') return item.output_text;
|
|
514
|
+
return '';
|
|
515
|
+
})
|
|
516
|
+
.filter(Boolean)
|
|
517
|
+
.join('\n');
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function shouldSkipUserMessage(text) {
|
|
521
|
+
return text.startsWith('# AGENTS.md instructions') || text.startsWith('<hook_prompt');
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function normalizeMessageText(value) {
|
|
525
|
+
if (typeof value !== 'string') return '';
|
|
526
|
+
return value.replace(/\s+/g, ' ').trim();
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function incrementTextMap(map, text) {
|
|
530
|
+
if (!text) return;
|
|
531
|
+
const existing = map.get(text);
|
|
532
|
+
if (existing) {
|
|
533
|
+
existing.count++;
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
map.set(text, { text, count: 1 });
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function compactNullish(object) {
|
|
540
|
+
return Object.fromEntries(Object.entries(object).filter(([, value]) => value !== null && value !== undefined));
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function notePotentialResurrectedUserMessage({
|
|
544
|
+
message,
|
|
545
|
+
compactedReplacementUserTexts,
|
|
546
|
+
rolledBackUserTexts,
|
|
547
|
+
resurrectedUserTexts,
|
|
548
|
+
stats,
|
|
549
|
+
}) {
|
|
550
|
+
if (stats.rollbackEvents < 1) return;
|
|
551
|
+
if (!rolledBackUserTexts.has(message.text)) return;
|
|
552
|
+
if (!compactedReplacementUserTexts.has(message.text)) return;
|
|
553
|
+
stats.resurrectedUserMessages++;
|
|
554
|
+
incrementTextMap(resurrectedUserTexts, message.text);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function buildRestoreSafetyDiagnostics({
|
|
558
|
+
compactedReplacementUserTexts,
|
|
559
|
+
rolledBackUserTexts,
|
|
560
|
+
resurrectedUserTexts,
|
|
561
|
+
stats,
|
|
562
|
+
}) {
|
|
563
|
+
const retainedTexts = [];
|
|
564
|
+
const rolledBackTexts = [...rolledBackUserTexts.values()].map((entry) => ({
|
|
565
|
+
textPreview: clipDiagnosticText(entry.text),
|
|
566
|
+
count: entry.count,
|
|
567
|
+
}));
|
|
568
|
+
|
|
569
|
+
for (const [text, rolledBack] of rolledBackUserTexts) {
|
|
570
|
+
const compacted = compactedReplacementUserTexts.get(text);
|
|
571
|
+
if (!compacted) continue;
|
|
572
|
+
retainedTexts.push({
|
|
573
|
+
textPreview: clipDiagnosticText(text),
|
|
574
|
+
rolledBackCount: rolledBack.count,
|
|
575
|
+
compactedReplacementCount: compacted.count,
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const resurrectedTexts = [...resurrectedUserTexts.values()].map((entry) => ({
|
|
580
|
+
textPreview: clipDiagnosticText(entry.text),
|
|
581
|
+
count: entry.count,
|
|
582
|
+
}));
|
|
583
|
+
|
|
584
|
+
stats.rollbackTextRetainedInCompacted = retainedTexts.length;
|
|
585
|
+
stats.resurrectedUserMessages = resurrectedTexts.reduce((sum, entry) => sum + entry.count, 0);
|
|
586
|
+
|
|
587
|
+
const risks = [];
|
|
588
|
+
if (retainedTexts.length > 0) {
|
|
589
|
+
risks.push({
|
|
590
|
+
type: 'rollback_text_retained_in_compacted_replacement_history',
|
|
591
|
+
count: retainedTexts.length,
|
|
592
|
+
message:
|
|
593
|
+
'Rollback-targeted user text is still present in compacted.replacement_history and may be restored later.',
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
if (resurrectedTexts.length > 0) {
|
|
597
|
+
risks.push({
|
|
598
|
+
type: 'rolled_back_user_text_reappeared_after_rollback',
|
|
599
|
+
count: resurrectedTexts.length,
|
|
600
|
+
message: 'A user message matching rolled-back compacted history reappeared after rollback.',
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
return {
|
|
605
|
+
status: risks.length > 0 ? 'risk' : 'ok',
|
|
606
|
+
compactedRows: stats.compactedRows,
|
|
607
|
+
compactedReplacementUserMessages: stats.compactedReplacementUserMessages,
|
|
608
|
+
rolledBackUserMessages: stats.rolledBackUserMessages,
|
|
609
|
+
userMessagesAfterRollback: stats.userMessagesAfterRollback,
|
|
610
|
+
latestRollbackAt: stats.latestRollbackAt,
|
|
611
|
+
rollbackTextRetainedInCompacted: retainedTexts.length,
|
|
612
|
+
resurrectedUserMessages: stats.resurrectedUserMessages,
|
|
613
|
+
rolledBackTexts,
|
|
614
|
+
retainedTexts,
|
|
615
|
+
resurrectedTexts,
|
|
616
|
+
risks,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function clipDiagnosticText(text) {
|
|
621
|
+
if (text.length <= 180) return text;
|
|
622
|
+
return `${text.slice(0, 180).trimEnd()} [truncated]`;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function renderCodexRolloutMemoryPreview({ candidate, parsed, previewMaxChars }) {
|
|
626
|
+
const lines = [];
|
|
627
|
+
lines.push('## Throughline Trim Memory Preview');
|
|
628
|
+
lines.push('');
|
|
629
|
+
lines.push('Intent: Continue the active Codex work thread after rollback.');
|
|
630
|
+
lines.push('');
|
|
631
|
+
lines.push('### Reading Contract');
|
|
632
|
+
lines.push(
|
|
633
|
+
'This preview is current-task context for continuation, not a passive archive. ' +
|
|
634
|
+
'Entries are oldest-to-newest; later entries may supersede earlier hypotheses.',
|
|
635
|
+
);
|
|
636
|
+
lines.push(
|
|
637
|
+
'The source is the active Codex rollout after applying thread_rolled_back events; ' +
|
|
638
|
+
'rolled-back tail turns are not included as current work.',
|
|
639
|
+
);
|
|
640
|
+
lines.push('');
|
|
641
|
+
lines.push('### Source');
|
|
642
|
+
lines.push(`Codex thread: ${candidate.id}`);
|
|
643
|
+
lines.push(`Project: ${candidate.cwd ?? 'unknown'}`);
|
|
644
|
+
lines.push(`Rollout: ${candidate.rolloutPath}`);
|
|
645
|
+
lines.push(`Active turns: ${parsed.activeTurnCount}`);
|
|
646
|
+
|
|
647
|
+
const recentEntries = parsed.entries.slice(-MAX_RECENT_ENTRIES);
|
|
648
|
+
if (recentEntries.length > 0) {
|
|
649
|
+
lines.push('');
|
|
650
|
+
lines.push('### Active Work Thread (Codex Rollout)');
|
|
651
|
+
lines.push('Entries are oldest-to-newest; later entries may supersede earlier hypotheses.');
|
|
652
|
+
for (const entry of recentEntries) {
|
|
653
|
+
const time = entry.time ?? 'unknown-time';
|
|
654
|
+
lines.push(`[${time}] [turn ${entry.turn}] [${entry.role}] ${clipEntry(entry.text)}`);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
lines.push('');
|
|
659
|
+
lines.push('### Continuation Instruction');
|
|
660
|
+
lines.push(
|
|
661
|
+
'Use these Codex rollout entries as the active work thread. Continue from the latest actionable state, ' +
|
|
662
|
+
'and do not resurrect rolled-back turns or obsolete earlier hypotheses.',
|
|
663
|
+
);
|
|
664
|
+
|
|
665
|
+
const fullText = lines.join('\n');
|
|
666
|
+
const truncated = fullText.length > previewMaxChars;
|
|
667
|
+
return {
|
|
668
|
+
text: truncated
|
|
669
|
+
? `${fullText.slice(0, previewMaxChars).trimEnd()}\n\n[truncated for dry-run preview]`
|
|
670
|
+
: fullText,
|
|
671
|
+
truncated,
|
|
672
|
+
stats: {
|
|
673
|
+
source: 'codex-rollout',
|
|
674
|
+
activeTurns: parsed.activeTurnCount,
|
|
675
|
+
recentEntries: parsed.entries.length,
|
|
676
|
+
rollbackEvents: parsed.stats.rollbackEvents,
|
|
677
|
+
rolledBackTurns: parsed.stats.rolledBackTurns,
|
|
678
|
+
injectedDeveloperMessages: parsed.stats.injectedDeveloperMessages,
|
|
679
|
+
skippedMessages: parsed.stats.skippedMessages,
|
|
680
|
+
restoreSafety: parsed.restoreSafety,
|
|
681
|
+
},
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function buildCodexContextEstimate(parsed) {
|
|
686
|
+
const turns = parsed.activeTurns.map((turn) => {
|
|
687
|
+
const text = [
|
|
688
|
+
...turn.messages.map((message) => message.text),
|
|
689
|
+
...turn.details.flatMap((detail) => [detail.input_text, detail.output_text]),
|
|
690
|
+
]
|
|
691
|
+
.filter((value) => typeof value === 'string' && value.length > 0)
|
|
692
|
+
.join('\n');
|
|
693
|
+
return {
|
|
694
|
+
turn: turn.number,
|
|
695
|
+
chars: text.length,
|
|
696
|
+
estimatedTokens: estimateTokens(text),
|
|
697
|
+
};
|
|
698
|
+
});
|
|
699
|
+
return {
|
|
700
|
+
method: 'chars_div_4',
|
|
701
|
+
activeTurns: turns.length,
|
|
702
|
+
activeChars: turns.reduce((sum, row) => sum + row.chars, 0),
|
|
703
|
+
activeEstimatedTokens: turns.reduce((sum, row) => sum + row.estimatedTokens, 0),
|
|
704
|
+
turns,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function clipEntry(text) {
|
|
709
|
+
if (text.length <= MAX_ENTRY_CHARS) return text;
|
|
710
|
+
return `${text.slice(0, MAX_ENTRY_CHARS).trimEnd()} [entry truncated]`;
|
|
711
|
+
}
|