throughline 0.4.10 → 0.4.12
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 +49 -3
- package/README.ja.md +27 -0
- package/README.md +66 -35
- package/bin/throughline.mjs +4 -2
- package/codex/skills/throughline/SKILL.md +30 -29
- package/codex/skills/throughline/agents/openai.yaml +2 -2
- package/docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md +7 -4
- package/docs/THROUGHLINE_CODEX_FIRST_ROADMAP.md +10 -10
- package/docs/THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md +19 -16
- package/docs/throughline-rollback-context-trim-insight.md +11 -7
- package/package.json +2 -2
- package/src/cli/codex-handoff-model-smoke.mjs +7 -7
- package/src/cli/codex-handoff-smoke.mjs +7 -7
- package/src/cli/codex-handoff-start.mjs +184 -10
- package/src/cli/codex-handoff-start.test.mjs +80 -1
- package/src/cli/codex-hook.mjs +37 -110
- package/src/cli/codex-hook.test.mjs +147 -32
- package/src/cli/codex-resume.mjs +7 -7
- package/src/cli/codex-summarize.mjs +7 -7
- package/src/cli/handoff-preview.mjs +6 -6
- package/src/cli/help.test.mjs +3 -1
- package/src/cli/install.mjs +42 -6
- package/src/cli/install.test.mjs +39 -5
- package/src/codex-app-server.mjs +156 -0
- package/src/codex-app-server.test.mjs +8 -0
- package/src/codex-auto-refresh.mjs +361 -29
- package/src/codex-auto-refresh.test.mjs +357 -0
- package/src/codex-thread-index.mjs +8 -2
- package/src/project-path.mjs +20 -0
- package/src/resume-context.mjs +58 -2
- package/src/resume-context.test.mjs +142 -6
- package/src/test-env.mjs +4 -0
- package/src/trim-model.mjs +6 -6
|
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
|
|
2
2
|
import { test } from 'node:test';
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
|
+
buildCodexAutoRefreshFingerprint,
|
|
5
6
|
CODEX_AUTO_REFRESH_THRESHOLD,
|
|
6
7
|
evaluateCodexAutoRefreshUsage,
|
|
7
8
|
runCodexAutoRefresh,
|
|
@@ -48,12 +49,38 @@ test('evaluateCodexAutoRefreshUsage: estimate does not trigger mutation', () =>
|
|
|
48
49
|
assert.equal(estimatedWindow.reason, 'estimated_context_window_not_allowed');
|
|
49
50
|
});
|
|
50
51
|
|
|
52
|
+
test('runCodexAutoRefresh: disabled by default and does not inspect trim source', async () => {
|
|
53
|
+
let buildTrimSourceCalled = false;
|
|
54
|
+
const result = await runCodexAutoRefresh({
|
|
55
|
+
db: {},
|
|
56
|
+
threadId: '019dfaba-thread',
|
|
57
|
+
projectPath: '/repo',
|
|
58
|
+
usage: {
|
|
59
|
+
tokens: 240_000,
|
|
60
|
+
contextWindowSize: 258_400,
|
|
61
|
+
estimated: false,
|
|
62
|
+
contextWindowEstimated: false,
|
|
63
|
+
},
|
|
64
|
+
deps: {
|
|
65
|
+
buildTrimSource: () => {
|
|
66
|
+
buildTrimSourceCalled = true;
|
|
67
|
+
return null;
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
assert.equal(result.status, 'skipped');
|
|
73
|
+
assert.equal(result.reason, 'codex_auto_refresh_disabled');
|
|
74
|
+
assert.equal(buildTrimSourceCalled, false);
|
|
75
|
+
});
|
|
76
|
+
|
|
51
77
|
test('runCodexAutoRefresh: below threshold skips before building trim source', async () => {
|
|
52
78
|
let buildTrimSourceCalled = false;
|
|
53
79
|
const result = await runCodexAutoRefresh({
|
|
54
80
|
db: {},
|
|
55
81
|
threadId: '019dfaba-thread',
|
|
56
82
|
projectPath: '/repo',
|
|
83
|
+
enabled: true,
|
|
57
84
|
usage: {
|
|
58
85
|
tokens: 100_000,
|
|
59
86
|
contextWindowSize: 258_400,
|
|
@@ -82,6 +109,7 @@ test('runCodexAutoRefresh: threshold reached rolls back and injects Throughline
|
|
|
82
109
|
codexThreadIdSource: 'payload:session_id',
|
|
83
110
|
projectPath: '/repo',
|
|
84
111
|
sessionId: 'codex:019dfaba-thread',
|
|
112
|
+
enabled: true,
|
|
85
113
|
usage: {
|
|
86
114
|
tokens: 240_000,
|
|
87
115
|
contextWindowSize: 258_400,
|
|
@@ -137,12 +165,159 @@ test('runCodexAutoRefresh: threshold reached rolls back and injects Throughline
|
|
|
137
165
|
assert.match(runTrimExecutionArgs.memoryText, /Throughline: Active Work Context/);
|
|
138
166
|
});
|
|
139
167
|
|
|
168
|
+
test('runCodexAutoRefresh: suppresses repeated execution for the same rollout and usage epoch', async () => {
|
|
169
|
+
let executionCalls = 0;
|
|
170
|
+
const stateStore = makeMemoryAutoRefreshStateStore();
|
|
171
|
+
const baseArgs = {
|
|
172
|
+
db: {},
|
|
173
|
+
threadId: '019dfaba-thread',
|
|
174
|
+
codexThreadIdSource: 'payload:session_id',
|
|
175
|
+
projectPath: '/repo',
|
|
176
|
+
sessionId: 'codex:019dfaba-thread',
|
|
177
|
+
enabled: true,
|
|
178
|
+
usage: {
|
|
179
|
+
tokens: 240_000,
|
|
180
|
+
contextWindowSize: 258_400,
|
|
181
|
+
estimated: false,
|
|
182
|
+
contextWindowEstimated: false,
|
|
183
|
+
},
|
|
184
|
+
autoRefreshStateStore: stateStore,
|
|
185
|
+
deps: {
|
|
186
|
+
buildTrimSource: () => ({
|
|
187
|
+
source: 'codex-rollout',
|
|
188
|
+
capturedTurns: 4,
|
|
189
|
+
memoryPreview: { text: 'rollout preview' },
|
|
190
|
+
stats: {
|
|
191
|
+
rollbackEvents: 0,
|
|
192
|
+
injectedDeveloperMessages: 0,
|
|
193
|
+
userMessagesAfterRollback: 0,
|
|
194
|
+
},
|
|
195
|
+
}),
|
|
196
|
+
buildTrimPlan: () => ({
|
|
197
|
+
status: 'ready',
|
|
198
|
+
trim: {
|
|
199
|
+
source: 'codex-rollout',
|
|
200
|
+
capturedTurns: 4,
|
|
201
|
+
rollbackTurns: 4,
|
|
202
|
+
rolloutPath: '/repo/rollout.jsonl',
|
|
203
|
+
rolloutStats: {
|
|
204
|
+
rollbackEvents: 0,
|
|
205
|
+
injectedDeveloperMessages: 0,
|
|
206
|
+
userMessagesAfterRollback: 0,
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
memoryPreview: {
|
|
210
|
+
text: '## Throughline: Active Work Context\n\nrecent work',
|
|
211
|
+
stats: { source: 'throughline-db' },
|
|
212
|
+
},
|
|
213
|
+
}),
|
|
214
|
+
runTrimExecution: async () => {
|
|
215
|
+
executionCalls++;
|
|
216
|
+
return {
|
|
217
|
+
rollbackSent: true,
|
|
218
|
+
injectSent: true,
|
|
219
|
+
postInjectVisibilityCheck: { status: 'match' },
|
|
220
|
+
};
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const first = await runCodexAutoRefresh(baseArgs);
|
|
226
|
+
const second = await runCodexAutoRefresh(baseArgs);
|
|
227
|
+
|
|
228
|
+
assert.equal(first.status, 'refreshed-live');
|
|
229
|
+
assert.equal(second.status, 'skipped');
|
|
230
|
+
assert.equal(second.reason, 'auto_refresh_backoff');
|
|
231
|
+
assert.equal(executionCalls, 1);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('runCodexAutoRefresh: durable success keeps the thread quiet after injection until a new user turn', async () => {
|
|
235
|
+
let executionCalls = 0;
|
|
236
|
+
const stateStore = makeMemoryAutoRefreshStateStore();
|
|
237
|
+
const makeArgs = ({ tokens, rollbackEvents, injectedDeveloperMessages, userMessagesAfterRollback }) => ({
|
|
238
|
+
db: {},
|
|
239
|
+
threadId: '019dfaba-thread',
|
|
240
|
+
projectPath: '/repo',
|
|
241
|
+
sessionId: 'codex:019dfaba-thread',
|
|
242
|
+
enabled: true,
|
|
243
|
+
usage: {
|
|
244
|
+
tokens,
|
|
245
|
+
contextWindowSize: 258_400,
|
|
246
|
+
estimated: false,
|
|
247
|
+
contextWindowEstimated: false,
|
|
248
|
+
},
|
|
249
|
+
autoRefreshStateStore: stateStore,
|
|
250
|
+
deps: {
|
|
251
|
+
buildTrimSource: () => ({
|
|
252
|
+
source: 'codex-rollout',
|
|
253
|
+
capturedTurns: 1,
|
|
254
|
+
memoryPreview: { text: 'rollout preview' },
|
|
255
|
+
stats: { rollbackEvents, injectedDeveloperMessages, userMessagesAfterRollback },
|
|
256
|
+
}),
|
|
257
|
+
buildTrimPlan: () => ({
|
|
258
|
+
status: 'ready',
|
|
259
|
+
trim: {
|
|
260
|
+
source: 'codex-rollout',
|
|
261
|
+
capturedTurns: 1,
|
|
262
|
+
rollbackTurns: 1,
|
|
263
|
+
rolloutPath: '/repo/rollout.jsonl',
|
|
264
|
+
rolloutStats: { rollbackEvents, injectedDeveloperMessages, userMessagesAfterRollback },
|
|
265
|
+
},
|
|
266
|
+
memoryPreview: {
|
|
267
|
+
text: '## Throughline: Active Work Context\n\nrecent work',
|
|
268
|
+
stats: { source: 'throughline-db' },
|
|
269
|
+
},
|
|
270
|
+
}),
|
|
271
|
+
runTrimExecution: async () => {
|
|
272
|
+
executionCalls++;
|
|
273
|
+
return {
|
|
274
|
+
rollbackSent: true,
|
|
275
|
+
injectSent: true,
|
|
276
|
+
postInjectVisibilityCheck: { status: 'match' },
|
|
277
|
+
};
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const first = await runCodexAutoRefresh(
|
|
283
|
+
makeArgs({
|
|
284
|
+
tokens: 240_000,
|
|
285
|
+
rollbackEvents: 0,
|
|
286
|
+
injectedDeveloperMessages: 0,
|
|
287
|
+
userMessagesAfterRollback: 0,
|
|
288
|
+
}),
|
|
289
|
+
);
|
|
290
|
+
const afterInjection = await runCodexAutoRefresh(
|
|
291
|
+
makeArgs({
|
|
292
|
+
tokens: 252_000,
|
|
293
|
+
rollbackEvents: 1,
|
|
294
|
+
injectedDeveloperMessages: 1,
|
|
295
|
+
userMessagesAfterRollback: 0,
|
|
296
|
+
}),
|
|
297
|
+
);
|
|
298
|
+
const afterNewUserTurn = await runCodexAutoRefresh(
|
|
299
|
+
makeArgs({
|
|
300
|
+
tokens: 252_000,
|
|
301
|
+
rollbackEvents: 1,
|
|
302
|
+
injectedDeveloperMessages: 1,
|
|
303
|
+
userMessagesAfterRollback: 1,
|
|
304
|
+
}),
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
assert.equal(first.status, 'refreshed-live');
|
|
308
|
+
assert.equal(afterInjection.status, 'skipped');
|
|
309
|
+
assert.equal(afterInjection.reason, 'auto_refresh_backoff');
|
|
310
|
+
assert.equal(afterNewUserTurn.status, 'refreshed-live');
|
|
311
|
+
assert.equal(executionCalls, 2);
|
|
312
|
+
});
|
|
313
|
+
|
|
140
314
|
test('runCodexAutoRefresh: threshold reached skips when injectable DB memory is missing', async () => {
|
|
141
315
|
let runTrimExecutionCalled = false;
|
|
142
316
|
const result = await runCodexAutoRefresh({
|
|
143
317
|
db: {},
|
|
144
318
|
threadId: '019dfaba-thread',
|
|
145
319
|
projectPath: '/repo',
|
|
320
|
+
enabled: true,
|
|
146
321
|
usage: {
|
|
147
322
|
tokens: 240_000,
|
|
148
323
|
contextWindowSize: 258_400,
|
|
@@ -180,3 +355,185 @@ test('runCodexAutoRefresh: threshold reached skips when injectable DB memory is
|
|
|
180
355
|
assert.equal(result.reason, 'injectable_memory_required');
|
|
181
356
|
assert.equal(runTrimExecutionCalled, false);
|
|
182
357
|
});
|
|
358
|
+
|
|
359
|
+
test('runCodexAutoRefresh: noop and missing injectable memory are backed off for the same state', async () => {
|
|
360
|
+
const cases = [
|
|
361
|
+
{
|
|
362
|
+
name: 'nothing_to_trim',
|
|
363
|
+
plan: {
|
|
364
|
+
status: 'ready',
|
|
365
|
+
trim: {
|
|
366
|
+
source: 'codex-rollout',
|
|
367
|
+
capturedTurns: 2,
|
|
368
|
+
rollbackTurns: 0,
|
|
369
|
+
rolloutPath: '/repo/rollout.jsonl',
|
|
370
|
+
rolloutStats: { rollbackEvents: 0, injectedDeveloperMessages: 0, userMessagesAfterRollback: 0 },
|
|
371
|
+
},
|
|
372
|
+
memoryPreview: {
|
|
373
|
+
text: '## Throughline: Active Work Context\n\nrecent work',
|
|
374
|
+
stats: { source: 'throughline-db' },
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
name: 'injectable_memory_required',
|
|
380
|
+
plan: {
|
|
381
|
+
status: 'ready',
|
|
382
|
+
trim: {
|
|
383
|
+
source: 'codex-rollout',
|
|
384
|
+
capturedTurns: 2,
|
|
385
|
+
rollbackTurns: 2,
|
|
386
|
+
rolloutPath: '/repo/rollout.jsonl',
|
|
387
|
+
rolloutStats: { rollbackEvents: 0, injectedDeveloperMessages: 0, userMessagesAfterRollback: 0 },
|
|
388
|
+
},
|
|
389
|
+
memoryPreview: {
|
|
390
|
+
text: 'rollout preview only',
|
|
391
|
+
stats: { source: 'codex-rollout' },
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
];
|
|
396
|
+
|
|
397
|
+
for (const fixture of cases) {
|
|
398
|
+
const stateStore = makeMemoryAutoRefreshStateStore();
|
|
399
|
+
const args = {
|
|
400
|
+
db: {},
|
|
401
|
+
threadId: `019dfaba-thread-${fixture.name}`,
|
|
402
|
+
projectPath: '/repo',
|
|
403
|
+
enabled: true,
|
|
404
|
+
usage: {
|
|
405
|
+
tokens: 240_000,
|
|
406
|
+
contextWindowSize: 258_400,
|
|
407
|
+
estimated: false,
|
|
408
|
+
contextWindowEstimated: false,
|
|
409
|
+
},
|
|
410
|
+
autoRefreshStateStore: stateStore,
|
|
411
|
+
deps: {
|
|
412
|
+
buildTrimSource: () => ({
|
|
413
|
+
source: 'codex-rollout',
|
|
414
|
+
capturedTurns: 2,
|
|
415
|
+
memoryPreview: { text: 'rollout preview' },
|
|
416
|
+
stats: { rollbackEvents: 0, injectedDeveloperMessages: 0, userMessagesAfterRollback: 0 },
|
|
417
|
+
}),
|
|
418
|
+
buildTrimPlan: () => fixture.plan,
|
|
419
|
+
runTrimExecution: async () => {
|
|
420
|
+
throw new Error('runTrimExecution should not be called');
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
const first = await runCodexAutoRefresh(args);
|
|
426
|
+
const second = await runCodexAutoRefresh(args);
|
|
427
|
+
|
|
428
|
+
assert.equal(first.status, 'skipped', fixture.name);
|
|
429
|
+
assert.equal(first.reason, fixture.name);
|
|
430
|
+
assert.equal(second.status, 'skipped', fixture.name);
|
|
431
|
+
assert.equal(second.reason, 'auto_refresh_backoff');
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test('runCodexAutoRefresh: backoff still allows a new usage epoch or thread', async () => {
|
|
436
|
+
const stateStore = makeMemoryAutoRefreshStateStore();
|
|
437
|
+
const makeArgs = ({ threadId, tokens }) => ({
|
|
438
|
+
db: {},
|
|
439
|
+
threadId,
|
|
440
|
+
projectPath: '/repo',
|
|
441
|
+
enabled: true,
|
|
442
|
+
usage: {
|
|
443
|
+
tokens,
|
|
444
|
+
contextWindowSize: 258_400,
|
|
445
|
+
estimated: false,
|
|
446
|
+
contextWindowEstimated: false,
|
|
447
|
+
},
|
|
448
|
+
autoRefreshStateStore: stateStore,
|
|
449
|
+
deps: {
|
|
450
|
+
buildTrimSource: () => ({
|
|
451
|
+
source: 'codex-rollout',
|
|
452
|
+
capturedTurns: 2,
|
|
453
|
+
memoryPreview: { text: 'rollout preview' },
|
|
454
|
+
stats: { rollbackEvents: 0, injectedDeveloperMessages: 0, userMessagesAfterRollback: 0 },
|
|
455
|
+
}),
|
|
456
|
+
buildTrimPlan: () => ({
|
|
457
|
+
status: 'ready',
|
|
458
|
+
trim: {
|
|
459
|
+
source: 'codex-rollout',
|
|
460
|
+
capturedTurns: 2,
|
|
461
|
+
rollbackTurns: 2,
|
|
462
|
+
rolloutPath: '/repo/rollout.jsonl',
|
|
463
|
+
rolloutStats: { rollbackEvents: 0, injectedDeveloperMessages: 0, userMessagesAfterRollback: 0 },
|
|
464
|
+
},
|
|
465
|
+
memoryPreview: {
|
|
466
|
+
text: 'rollout preview only',
|
|
467
|
+
stats: { source: 'codex-rollout' },
|
|
468
|
+
},
|
|
469
|
+
}),
|
|
470
|
+
runTrimExecution: async () => {
|
|
471
|
+
throw new Error('runTrimExecution should not be called');
|
|
472
|
+
},
|
|
473
|
+
},
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
const first = await runCodexAutoRefresh(makeArgs({ threadId: 'thread-a', tokens: 240_000 }));
|
|
477
|
+
const repeated = await runCodexAutoRefresh(makeArgs({ threadId: 'thread-a', tokens: 240_000 }));
|
|
478
|
+
const usageAdvanced = await runCodexAutoRefresh(makeArgs({ threadId: 'thread-a', tokens: 252_000 }));
|
|
479
|
+
const newThread = await runCodexAutoRefresh(makeArgs({ threadId: 'thread-b', tokens: 240_000 }));
|
|
480
|
+
|
|
481
|
+
assert.equal(first.reason, 'injectable_memory_required');
|
|
482
|
+
assert.equal(repeated.reason, 'auto_refresh_backoff');
|
|
483
|
+
assert.equal(usageAdvanced.reason, 'injectable_memory_required');
|
|
484
|
+
assert.equal(newThread.reason, 'injectable_memory_required');
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test('buildCodexAutoRefreshFingerprint: ignores live tool-loop row churn within a usage epoch', () => {
|
|
488
|
+
const base = {
|
|
489
|
+
threadId: 'thread-a',
|
|
490
|
+
projectPath: '/repo',
|
|
491
|
+
usage: {
|
|
492
|
+
tokens: 196_000,
|
|
493
|
+
contextWindowSize: 258_400,
|
|
494
|
+
estimated: false,
|
|
495
|
+
contextWindowEstimated: false,
|
|
496
|
+
source: 'codex-rollout-token-count-live-turn',
|
|
497
|
+
},
|
|
498
|
+
rolloutState: {
|
|
499
|
+
rolloutPath: '/repo/rollout.jsonl',
|
|
500
|
+
capturedTurns: 1,
|
|
501
|
+
rollbackTurns: null,
|
|
502
|
+
capturedRows: 2,
|
|
503
|
+
capturedDetails: 60,
|
|
504
|
+
rollbackEvents: 1,
|
|
505
|
+
rolledBackTurns: 2,
|
|
506
|
+
injectedDeveloperMessages: 1,
|
|
507
|
+
userMessagesAfterRollback: 0,
|
|
508
|
+
},
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
assert.equal(
|
|
512
|
+
buildCodexAutoRefreshFingerprint(base),
|
|
513
|
+
buildCodexAutoRefreshFingerprint({
|
|
514
|
+
...base,
|
|
515
|
+
usage: {
|
|
516
|
+
...base.usage,
|
|
517
|
+
tokens: 199_000,
|
|
518
|
+
},
|
|
519
|
+
rolloutState: {
|
|
520
|
+
...base.rolloutState,
|
|
521
|
+
capturedRows: 3,
|
|
522
|
+
capturedDetails: 65,
|
|
523
|
+
},
|
|
524
|
+
}),
|
|
525
|
+
);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
function makeMemoryAutoRefreshStateStore() {
|
|
529
|
+
const states = new Map();
|
|
530
|
+
return {
|
|
531
|
+
read(threadId) {
|
|
532
|
+
const state = states.get(threadId);
|
|
533
|
+
return state ? structuredClone(state) : null;
|
|
534
|
+
},
|
|
535
|
+
write(threadId, state) {
|
|
536
|
+
states.set(threadId, structuredClone(state));
|
|
537
|
+
},
|
|
538
|
+
};
|
|
539
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, statSync } from 'node:fs';
|
|
1
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join, resolve, sep } from 'node:path';
|
|
4
4
|
|
|
@@ -169,5 +169,11 @@ function compareCandidates(a, b) {
|
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
function normalizePath(value) {
|
|
172
|
-
|
|
172
|
+
let resolved = resolve(value);
|
|
173
|
+
try {
|
|
174
|
+
if (existsSync(resolved)) resolved = realpathSync.native(resolved);
|
|
175
|
+
} catch {
|
|
176
|
+
// Keep the lexical path when it cannot be resolved.
|
|
177
|
+
}
|
|
178
|
+
return resolved.split(sep).join('/').replace(/\/+$/, '').toLowerCase();
|
|
173
179
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { platform } from 'node:os';
|
|
3
|
+
import { resolve, sep } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export function normalizeProjectPathForCompare(value) {
|
|
6
|
+
if (!value) return '';
|
|
7
|
+
let resolved = resolve(String(value));
|
|
8
|
+
try {
|
|
9
|
+
if (existsSync(resolved)) resolved = realpathSync.native(resolved);
|
|
10
|
+
} catch {
|
|
11
|
+
// Fall back to the lexical path when the filesystem cannot resolve it.
|
|
12
|
+
}
|
|
13
|
+
let normalized = resolved.split(sep).join('/').replace(/\/+$/, '');
|
|
14
|
+
if (platform() === 'win32') normalized = normalized.toLowerCase();
|
|
15
|
+
return normalized;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function sameProjectPath(a, b) {
|
|
19
|
+
return normalizeProjectPathForCompare(a) === normalizeProjectPathForCompare(b);
|
|
20
|
+
}
|
package/src/resume-context.mjs
CHANGED
|
@@ -5,8 +5,11 @@
|
|
|
5
5
|
* - session-start.mjs (auto path / baton path どちらでも同じ注入)
|
|
6
6
|
*
|
|
7
7
|
* 設計 (docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md):
|
|
8
|
-
* - 注入順: ヘッダ + 読み方 → L1 要約 → L2 本文(一番下)
|
|
9
|
-
* -
|
|
8
|
+
* - 注入順: ヘッダ + 読み方 → 現在地アンカー → L1 要約 → L2 本文(一番下)
|
|
9
|
+
* - 「現在地」アンカーは直前の user / assistant turn をヘッダ直下に再掲して
|
|
10
|
+
* 最初の注意を最新ターンに固定する。L2 末尾アンカーは補強として残す。
|
|
11
|
+
* (L2 が長くなると末尾アンカーだけでは前半の古いターンに注意が固着し、
|
|
12
|
+
* 話の流れを取り違える事例があった)
|
|
10
13
|
* - L3 は別セクションを設けず、対応する L1 / L2 行にインラインで
|
|
11
14
|
* `[→ throughline detail HH:MM:SS (kind …)]` ヒントを付ける
|
|
12
15
|
* - L2 全文があれば最後の assistant turn 自体に「次に何をしようとしていたか」が
|
|
@@ -25,12 +28,43 @@ const RESUME_HEADER_TEMPLATE = (turnCount) =>
|
|
|
25
28
|
`## Throughline: 中断した作業の再開(${turnCount} ターン分の文脈を保持)\n` +
|
|
26
29
|
`\n` +
|
|
27
30
|
`**読み方:**\n` +
|
|
31
|
+
`- **下の「現在地」が直前のやりとりです。まずここで最新の状態と次の一手を把握してから L1/L2 を読んでください。**\n` +
|
|
28
32
|
`- 直前の対話の自然な続きとして応答してください。\n` +
|
|
29
33
|
'- **各ターンの詳細の取得方法**: **`Bash` ツールで `throughline detail HH:MM:SS` を実行** ' +
|
|
30
34
|
`(該当ターンの本文+詳細を stdout に返します)`;
|
|
31
35
|
|
|
32
36
|
const NORMAL_HEADER = '## Throughline: セッション記憶';
|
|
33
37
|
|
|
38
|
+
// 現在地アンカーは最新 user / assistant 本文を再掲する。長すぎると注入全体が膨らむので
|
|
39
|
+
// この文字数で打ち切り、全文は L2 セクション側を参照させる。
|
|
40
|
+
const ANCHOR_MAX_CHARS = 600;
|
|
41
|
+
|
|
42
|
+
function truncateForAnchor(text) {
|
|
43
|
+
const normalized = text.replace(/\n+/g, ' ').trim();
|
|
44
|
+
if (normalized.length <= ANCHOR_MAX_CHARS) return normalized;
|
|
45
|
+
return normalized.slice(0, ANCHOR_MAX_CHARS) + ' …';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* recentBodies (古い順) から「直前のやりとり」アンカー用に
|
|
50
|
+
* 最新の user / assistant 行をそれぞれ 1 件ずつ拾う。
|
|
51
|
+
*/
|
|
52
|
+
function pickLatestExchange(recentBodies) {
|
|
53
|
+
let latestUser = null;
|
|
54
|
+
let latestAssistant = null;
|
|
55
|
+
for (let i = recentBodies.length - 1; i >= 0; i -= 1) {
|
|
56
|
+
const r = recentBodies[i];
|
|
57
|
+
if (!r.text) continue;
|
|
58
|
+
if (r.role === 'assistant' && !latestAssistant) {
|
|
59
|
+
latestAssistant = r;
|
|
60
|
+
} else if (r.role === 'user' && !latestUser) {
|
|
61
|
+
latestUser = r;
|
|
62
|
+
}
|
|
63
|
+
if (latestUser && latestAssistant) break;
|
|
64
|
+
}
|
|
65
|
+
return { latestUser, latestAssistant };
|
|
66
|
+
}
|
|
67
|
+
|
|
34
68
|
/**
|
|
35
69
|
* L1 + L2 注入テキストを組み立てる。L3 は本文ではなく
|
|
36
70
|
* 各 L1 / L2 行末尾の inline hint として付与する。
|
|
@@ -61,6 +95,28 @@ export function buildResumeContext(
|
|
|
61
95
|
|
|
62
96
|
const l3ByTurn = groupL3ByTurn(record.references.l3);
|
|
63
97
|
|
|
98
|
+
// 現在地アンカー: 引き継ぎ時のみ、最新 user / assistant turn をヘッダ直下に再掲する。
|
|
99
|
+
// L2 末尾アンカーだけだと、長い L2 で注意が前半に固着して話の流れを取り違える事例があった。
|
|
100
|
+
if (isInheritance && record.memory.recentBodies.length > 0) {
|
|
101
|
+
const { latestUser, latestAssistant } = pickLatestExchange(record.memory.recentBodies);
|
|
102
|
+
const anchorLines = [];
|
|
103
|
+
if (latestUser) {
|
|
104
|
+
anchorLines.push(
|
|
105
|
+
`**最新ユーザー指示** [${latestUser.time}]: ${truncateForAnchor(latestUser.text)}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
if (latestAssistant) {
|
|
109
|
+
anchorLines.push(
|
|
110
|
+
`**直前のアシスタント** [${latestAssistant.time}]: ${truncateForAnchor(latestAssistant.text)}`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
if (anchorLines.length > 0) {
|
|
114
|
+
lines.push('');
|
|
115
|
+
lines.push('### 現在地 (直前のやりとり)');
|
|
116
|
+
lines.push(...anchorLines);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
64
120
|
if (record.memory.l1Summaries.length > 0) {
|
|
65
121
|
const l1Lines = [];
|
|
66
122
|
for (const r of record.memory.l1Summaries) {
|