throughline 0.14.3 → 0.15.1

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.
@@ -11,6 +11,7 @@ import { buildBudgetedResumeContext, INJECTION_BUDGET_CHARS } from '../resume-co
11
11
  import {
12
12
  parseArgs,
13
13
  readLatestProjectHandoffContext,
14
+ readRecentProjectHandoffContext,
14
15
  readSessionProjectPath,
15
16
  } from './handoff-context.mjs';
16
17
 
@@ -355,6 +356,7 @@ test('handoff-context accepts only the documented supplement argument shape', ()
355
356
  projectPath: null,
356
357
  supplementFile: null,
357
358
  handoffDisclosure: 'visible',
359
+ projectSessions: 'latest',
358
360
  });
359
361
  assert.deepEqual(parseArgs([
360
362
  '--session', 's', '--json', '--supplement-file', '/tmp/memory.json',
@@ -363,6 +365,7 @@ test('handoff-context accepts only the documented supplement argument shape', ()
363
365
  projectPath: null,
364
366
  supplementFile: '/tmp/memory.json',
365
367
  handoffDisclosure: 'visible',
368
+ projectSessions: 'latest',
366
369
  });
367
370
  assert.deepEqual(parseArgs([
368
371
  '--project', '.', '--json', '--disclosure', 'silent',
@@ -371,7 +374,23 @@ test('handoff-context accepts only the documented supplement argument shape', ()
371
374
  projectPath: process.cwd(),
372
375
  supplementFile: null,
373
376
  handoffDisclosure: 'silent',
377
+ projectSessions: 'latest',
374
378
  });
379
+ assert.deepEqual(parseArgs([
380
+ '--project', '.', '--json', '--disclosure', 'silent', '--sessions', 'recent',
381
+ ]), {
382
+ sessionId: null,
383
+ projectPath: process.cwd(),
384
+ supplementFile: null,
385
+ handoffDisclosure: 'silent',
386
+ projectSessions: 'recent',
387
+ });
388
+ assert.equal(parseArgs(['--project', '.', '--json', '--sessions', 'latest']).projectSessions, 'latest');
389
+ // 複数 session をまたぐのは --project だけ。--session の意味は変えない
390
+ assert.throws(() => parseArgs(['--session', 's', '--json', '--sessions', 'recent']));
391
+ assert.throws(() => parseArgs(['--session', 's', '--json', '--sessions', 'latest']));
392
+ assert.throws(() => parseArgs(['--project', '.', '--json', '--sessions', 'all']));
393
+ assert.throws(() => parseArgs(['--project', '.', '--json', '--sessions', 'recent', '--sessions', 'recent']));
375
394
  assert.throws(() => parseArgs(['--session', 's', '--supplement-file', '/tmp/memory.json', '--json']));
376
395
  assert.throws(() => parseArgs(['--project', '.', '--json', '--supplement-file', '/tmp/memory.json']));
377
396
  assert.throws(() => parseArgs(['--session', 's', '--json', '--disclosure', 'hidden']));
@@ -439,3 +458,177 @@ test('handoff-context fails without creating a missing database', () => {
439
458
  rmSync(home, { recursive: true, force: true });
440
459
  }
441
460
  });
461
+
462
+ function insertProjectSession(db, { sessionId, projectPath = '/work/project', updatedAt, turns }) {
463
+ db.prepare(
464
+ `INSERT INTO sessions
465
+ (session_id, project_path, status, created_at, updated_at, merged_into)
466
+ VALUES (?, ?, 'active', ?, ?, NULL)`,
467
+ ).run(sessionId, projectPath, updatedAt - 1000, updatedAt);
468
+ const insertBody = db.prepare(
469
+ `INSERT INTO bodies
470
+ (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
471
+ VALUES (?, ?, ?, ?, ?, 4, ?)`,
472
+ );
473
+ turns.forEach(([user, assistant, at], index) => {
474
+ insertBody.run(sessionId, sessionId, index + 1, 'user', user, at);
475
+ insertBody.run(sessionId, sessionId, index + 1, 'assistant', assistant, at + 500);
476
+ });
477
+ }
478
+
479
+ const PROJECT_ARGS = ['handoff-context', '--project', '/work/project', '--json', '--disclosure', 'silent'];
480
+
481
+ test('handoff-context --sessions recent adds earlier sessions of the same project only', () => {
482
+ const home = mkdtempSync(join(tmpdir(), 'tl-handoff-recent-'));
483
+ try {
484
+ const { db, dbPath } = createFixture(home);
485
+ // 5 ターンの作業の会話が、最新の 1 ターンの会話より前にある
486
+ insertProjectSession(db, {
487
+ sessionId: 'earlier-work',
488
+ updatedAt: 1_699_999_990_000,
489
+ turns: [1, 2, 3, 4, 5].map((n) => [
490
+ `EARLIER_REQUEST_${n}`, `EARLIER_RESULT_${n}`, 1_699_999_900_000 + n * 10_000,
491
+ ]),
492
+ });
493
+ insertProjectSession(db, {
494
+ sessionId: 'other-project-work',
495
+ projectPath: '/work/other',
496
+ updatedAt: 1_700_000_009_000,
497
+ turns: [['OTHER_PROJECT_PRIVATE', 'OTHER_PROJECT_ANSWER', 1_700_000_008_000]],
498
+ });
499
+ const before = ownershipSnapshot(db);
500
+ db.close();
501
+
502
+ // 引数を付けない時と --sessions latest は、今までと同じ出力
503
+ const latest = runCli(home, PROJECT_ARGS);
504
+ assert.equal(latest.status, 0, latest.stderr);
505
+ const latestPayload = JSON.parse(latest.stdout);
506
+ assert.deepEqual(Object.keys(latestPayload), ['schema', 'status', 'sessionId', 'context']);
507
+ assert.doesNotMatch(latestPayload.context, /EARLIER_REQUEST/);
508
+ assert.equal(runCli(home, [...PROJECT_ARGS, '--sessions', 'latest']).stdout, latest.stdout);
509
+
510
+ const recent = runCli(home, [...PROJECT_ARGS, '--sessions', 'recent']);
511
+ assert.equal(recent.status, 0, recent.stderr);
512
+ const payload = JSON.parse(recent.stdout);
513
+ assert.deepEqual(Object.keys(payload), ['schema', 'status', 'sessionId', 'context', 'sessions']);
514
+ assert.equal(payload.schema, 'throughline.handoff_context.v1');
515
+ assert.equal(payload.status, 'ready');
516
+ assert.equal(payload.sessionId, SESSION_ID);
517
+ assert.ok(payload.context.startsWith(`${latestPayload.context}\n\n## Throughline: このprojectで記録された過去の会話`));
518
+ assert.ok(payload.context.length <= INJECTION_BUDGET_CHARS);
519
+ for (let n = 1; n <= 5; n += 1) {
520
+ assert.ok(payload.context.includes(`EARLIER_REQUEST_${n}`));
521
+ assert.ok(payload.context.includes(`EARLIER_RESULT_${n}`));
522
+ }
523
+ assert.match(payload.context, /(5ターン \/ session earlier-work)/);
524
+ assert.doesNotMatch(payload.context, /OTHER_PROJECT_PRIVATE|OTHER_PROJECT_ANSWER|other-project-work/);
525
+ assert.deepEqual(payload.sessions, [
526
+ {
527
+ sessionId: SESSION_ID,
528
+ role: 'current',
529
+ firstTurnAt: new Date(1_700_000_002_000).toISOString(),
530
+ lastTurnAt: new Date(1_700_000_003_000).toISOString(),
531
+ turns: 1,
532
+ includedTurns: 1,
533
+ },
534
+ {
535
+ sessionId: 'earlier-work',
536
+ role: 'past',
537
+ firstTurnAt: new Date(1_699_999_910_000).toISOString(),
538
+ lastTurnAt: new Date(1_699_999_950_500).toISOString(),
539
+ turns: 5,
540
+ includedTurns: 5,
541
+ },
542
+ ]);
543
+
544
+ const selected = readRecentProjectHandoffContext('/work/project', {
545
+ dbPath,
546
+ handoffDisclosure: 'silent',
547
+ });
548
+ assert.equal(selected.context, payload.context);
549
+
550
+ const verify = new DatabaseSync(dbPath, { readOnly: true });
551
+ assert.deepEqual(ownershipSnapshot(verify), before);
552
+ verify.close();
553
+ } finally {
554
+ rmSync(home, { recursive: true, force: true });
555
+ }
556
+ });
557
+
558
+ test('handoff-context --sessions recent matches the default when the project has one session', () => {
559
+ const home = mkdtempSync(join(tmpdir(), 'tl-handoff-recent-single-'));
560
+ try {
561
+ const { db } = createFixture(home);
562
+ // 本文の無い session と別 project の session は、過去の会話に数えない
563
+ db.prepare(
564
+ `INSERT INTO sessions
565
+ (session_id, project_path, status, created_at, updated_at, merged_into)
566
+ VALUES ('newest-empty', '/work/project', 'active', ?, ?, NULL)`,
567
+ ).run(1_700_000_005_000, 1_700_000_006_000);
568
+ insertProjectSession(db, {
569
+ sessionId: 'other-project-work',
570
+ projectPath: '/work/other',
571
+ updatedAt: 1_699_999_990_000,
572
+ turns: [['OTHER_PROJECT_PRIVATE', 'OTHER_PROJECT_ANSWER', 1_699_999_900_000]],
573
+ });
574
+ db.close();
575
+
576
+ for (const disclosure of ['silent', 'visible']) {
577
+ const args = ['handoff-context', '--project', '/work/project', '--json', '--disclosure', disclosure];
578
+ const latest = runCli(home, args);
579
+ const recent = runCli(home, [...args, '--sessions', 'recent']);
580
+ assert.equal(latest.status, 0, latest.stderr);
581
+ assert.equal(recent.status, 0, recent.stderr);
582
+ const latestPayload = JSON.parse(latest.stdout);
583
+ const { sessions, ...rest } = JSON.parse(recent.stdout);
584
+ assert.deepEqual(rest, latestPayload);
585
+ assert.deepEqual(sessions.map((s) => [s.sessionId, s.role, s.turns, s.includedTurns]), [
586
+ [SESSION_ID, 'current', 1, 1],
587
+ ]);
588
+ }
589
+ } finally {
590
+ rmSync(home, { recursive: true, force: true });
591
+ }
592
+ });
593
+
594
+ test('handoff-context --sessions recent returns empty with an empty session list', () => {
595
+ const home = mkdtempSync(join(tmpdir(), 'tl-handoff-recent-empty-'));
596
+ try {
597
+ const { db } = createFixture(home);
598
+ db.exec('DELETE FROM details; DELETE FROM bodies; DELETE FROM skeletons;');
599
+ db.close();
600
+
601
+ const result = runCli(home, [...PROJECT_ARGS, '--sessions', 'recent']);
602
+ assert.equal(result.status, 0, result.stderr);
603
+ assert.deepEqual(JSON.parse(result.stdout), {
604
+ schema: 'throughline.handoff_context.v1',
605
+ status: 'empty',
606
+ sessionId: null,
607
+ context: '',
608
+ sessions: [],
609
+ });
610
+ } finally {
611
+ rmSync(home, { recursive: true, force: true });
612
+ }
613
+ });
614
+
615
+ test('handoff-context rejects --sessions together with --session', () => {
616
+ const home = mkdtempSync(join(tmpdir(), 'tl-handoff-recent-usage-'));
617
+ try {
618
+ const { db } = createFixture(home);
619
+ db.close();
620
+
621
+ for (const args of [
622
+ ['handoff-context', '--session', SESSION_ID, '--json', '--sessions', 'recent'],
623
+ [...PROJECT_ARGS, '--sessions', 'everything'],
624
+ ]) {
625
+ const result = runCli(home, args);
626
+ assert.equal(result.status, 2);
627
+ assert.equal(result.stdout, '');
628
+ assert.match(result.stderr, /^Usage: throughline handoff-context /);
629
+ assert.match(result.stderr, /\[--sessions latest\|recent\]/);
630
+ }
631
+ } finally {
632
+ rmSync(home, { recursive: true, force: true });
633
+ }
634
+ });
@@ -47,7 +47,7 @@ export function resolveObserverTurnFeed({ projectPath, cursor = null, codexHome,
47
47
  ? emptyCandidate()
48
48
  : candidates.find((item) => item.host === prior.host && item.threadHash === prior.thread_sha256);
49
49
  if (!priorCandidate || prior.host !== null &&
50
- (prior.history_floor < priorCandidate.historyFloor || prior.length > priorCandidate.chain.length ||
50
+ (prior.length > priorCandidate.chain.length ||
51
51
  prefixDigest(priorCandidate.chain.slice(0, prior.length)) !== prior.prefix_sha256)) {
52
52
  return { schema: OBSERVER_CURSOR_SCHEMA, status: 'resync_required', afterCursor: cursor, throughCursor: null };
53
53
  }
@@ -254,7 +254,7 @@ function decodeCursorForRead(token, projectSha256) {
254
254
  function validateCursorCandidate(cursor, candidates) {
255
255
  if (cursor.host === null) return emptyCandidate();
256
256
  const current = candidates.find((item) => item.host === cursor.host && item.threadHash === cursor.thread_sha256);
257
- if (!current || cursor.history_floor < current.historyFloor || cursor.length > current.chain.length ||
257
+ if (!current || cursor.length > current.chain.length ||
258
258
  prefixDigest(current.chain.slice(0, cursor.length)) !== cursor.prefix_sha256) return null;
259
259
  return current;
260
260
  }
@@ -126,11 +126,16 @@ test('observer feed: Claude history floor, host/thread switch, cross-host tie, a
126
126
  try {
127
127
  const options = box.receiptOptions;
128
128
  seedCompletedTurnReceiptStore({ projectPath: box.project, receiptOptions: options, targetSessionId: 'claude-session' });
129
+ const full = resolveObserverTurnFeed({ projectPath: box.project, receiptOptions: options, codexHome: box.home });
129
130
  writeCompletedTurnReceipt({ projectPath: box.project, targetSessionId: 'claude-session', originSessionId: 'o257', userBody: 'user-body-257', assistantBody: 'assistant-body-257', completedAt: 257 }, options);
131
+ // 同じ会話の先頭の控えが落ちた時は、先頭からの照合が合わなくなるので resync(ADR 0035)
132
+ assert.equal(resolveObserverTurnFeed({ projectPath: box.project, cursor: full.throughCursor, receiptOptions: options, codexHome: box.home }).status, 'resync_required');
130
133
  const latest = resolveObserverTurnFeed({ projectPath: box.project, receiptOptions: options, codexHome: box.home });
131
134
  const decoded = decodeObserverCursor(latest.throughCursor);
132
- const beforeFloor = encodeObserverCursor({ ...decoded, history_floor: 1 });
133
- assert.equal(resolveObserverTurnFeed({ projectPath: box.project, cursor: beforeFloor, receiptOptions: options, codexHome: box.home }).status, 'resync_required');
135
+ assert.equal(decoded.history_floor, 2);
136
+ // 下限の番号は位置に入れたままだが、検証には使わない。先頭からの照合が合えば有効
137
+ const olderFloor = encodeObserverCursor({ ...decoded, history_floor: 1 });
138
+ assert.equal(resolveObserverTurnFeed({ projectPath: box.project, cursor: olderFloor, receiptOptions: options, codexHome: box.home }).status, 'unchanged');
134
139
  assert.equal(resolveObserverTurnFeed({ projectPath: box.project, cursor: `${latest.throughCursor}x`, receiptOptions: options, codexHome: box.home }).status, 'resync_required');
135
140
  const oldVersion = `tlc1.${Buffer.from(JSON.stringify({ ...decoded, schema: 'throughline.observer_cursor.v0' })).toString('base64url')}`;
136
141
  assert.equal(resolveObserverTurnFeed({ projectPath: box.project, cursor: oldVersion, receiptOptions: options, codexHome: box.home }).status, 'resync_required');
@@ -157,6 +162,51 @@ test('observer feed: Claude history floor, host/thread switch, cross-host tie, a
157
162
  } finally { rmSync(box.root, { recursive: true, force: true }); }
158
163
  });
159
164
 
165
+ test('observer feed: 控えが上限に達して別の古い会話の控えが落ちても、今の会話の位置は有効なまま', () => {
166
+ const box = fixture();
167
+ const dbPath = join(box.root, 'throughline.db');
168
+ let db;
169
+ try {
170
+ // 古い会話の控えで上限まで埋まっている席で、新しい会話が始まる
171
+ seedCompletedTurnReceiptStore({ projectPath: box.project, receiptOptions: box.receiptOptions, targetSessionId: 'old-session' });
172
+ db = createProjectionDb(dbPath, box.project, 'live-session');
173
+ const read = (afterCursor) => readObserverTurnPage({
174
+ projectPath: box.project, afterCursor, codexHome: box.home, receiptOptions: box.receiptOptions, dbPath,
175
+ });
176
+ const floorOf = (cursor) => decodeObserverCursor(cursor).history_floor;
177
+
178
+ addClaudeTurn(box, db, { sessionId: 'live-session', origin: 'live-1', turn: 1, user: 'user-1', assistant: 'assistant-1', at: 1001 });
179
+ let cursor = read(null).throughCursor;
180
+ assert.equal(floorOf(cursor), 2);
181
+ for (let turn = 2; turn <= 5; turn++) {
182
+ // 1 ターンごとに古い会話の控えが1件落ち、控え全体の下限が1上がる
183
+ addClaudeTurn(box, db, { sessionId: 'live-session', origin: `live-${turn}`, turn, user: `user-${turn}`, assistant: `assistant-${turn}`, at: 1000 + turn });
184
+ assert.equal(resolveObserverTurnFeed({
185
+ projectPath: box.project, cursor, codexHome: box.home, receiptOptions: box.receiptOptions,
186
+ }).status, 'append');
187
+ const page = read(cursor);
188
+ assert.equal(page.status, 'delta');
189
+ assert.deepEqual(page.turns.map((item) => item.user), [`user-${turn}`]);
190
+ assert.equal(page.page.complete, true);
191
+ assert.equal(floorOf(page.throughCursor), turn + 1);
192
+ cursor = page.throughCursor;
193
+ }
194
+ // 追いついた位置は、次のターンが来るまで unchanged
195
+ assert.equal(resolveObserverTurnFeed({
196
+ projectPath: box.project, cursor, codexHome: box.home, receiptOptions: box.receiptOptions,
197
+ }).status, 'unchanged');
198
+ // 読み遅れた位置(2 ターン前)からも、残りを続きとして読める
199
+ addClaudeTurn(box, db, { sessionId: 'live-session', origin: 'live-6', turn: 6, user: 'user-6', assistant: 'assistant-6', at: 1006 });
200
+ addClaudeTurn(box, db, { sessionId: 'live-session', origin: 'live-7', turn: 7, user: 'user-7', assistant: 'assistant-7', at: 1007 });
201
+ const late = read(cursor);
202
+ assert.equal(late.status, 'delta');
203
+ assert.deepEqual(late.turns.map((item) => item.user), ['user-6', 'user-7']);
204
+ } finally {
205
+ db?.close();
206
+ rmSync(box.root, { recursive: true, force: true });
207
+ }
208
+ });
209
+
160
210
  test('observer feed: Codex pair hashes reuse DB capture role aggregation', () => {
161
211
  const box = fixture();
162
212
  try {
@@ -211,8 +211,10 @@ function buildResumeSections(
211
211
  const isLastOfTurn = lastIdxPerTurn.get(key) === i;
212
212
  const partCounts = isLastOfTurn ? (l3ByTurn.get(key)?.partCounts ?? new Map()) : new Map();
213
213
  const suffix = buildPartsSummary(partCounts);
214
+ const rest = `[${r.role}]: ${r.text}${suffix}`;
214
215
  l2Lines.push({
215
- text: `[${r.time}] [${r.role}]: ${r.text}${suffix}`,
216
+ text: `[${r.time}] ${rest}`,
217
+ rest,
216
218
  time: r.time,
217
219
  role: r.role,
218
220
  turnKey: key,
@@ -426,14 +428,13 @@ export function buildBudgetedResumeContext(
426
428
  return renderBudgetedSections(db, sections, { sessionId, excludeOriginId, maxChars });
427
429
  }
428
430
 
429
- /** 組み立て済みのセクションを、予算内の注入テキストにする(/clear の引き継ぎと自動圧縮後の継続が共有)。 */
430
- function renderBudgetedSections(db, sections, { sessionId, excludeOriginId, maxChars }) {
431
- const lineCost = (s) => s.length + 1; // join('\n') 分
431
+ const lineCost = (s) => s.length + 1; // join('\n') 分
432
432
 
433
- // L2 行をターン単位のグループにまとめる(元の古い順を保つ)
433
+ /** L2 行をターン単位のグループにまとめる(元の古い順を保つ)。 */
434
+ function groupL2Turns(l2Lines) {
434
435
  const turns = [];
435
436
  const turnIndex = new Map();
436
- for (const line of sections.l2Lines) {
437
+ for (const line of l2Lines) {
437
438
  let group = turnIndex.get(line.turnKey);
438
439
  if (!group) {
439
440
  group = { turnKey: line.turnKey, lines: [], minCreatedAt: Infinity, maxCreatedAt: -Infinity };
@@ -444,6 +445,12 @@ function renderBudgetedSections(db, sections, { sessionId, excludeOriginId, maxC
444
445
  if (line.createdAt < group.minCreatedAt) group.minCreatedAt = line.createdAt;
445
446
  if (line.createdAt > group.maxCreatedAt) group.maxCreatedAt = line.createdAt;
446
447
  }
448
+ return turns;
449
+ }
450
+
451
+ /** 組み立て済みのセクションを、予算内の注入テキストにする(/clear の引き継ぎと自動圧縮後の継続が共有)。 */
452
+ function renderBudgetedSections(db, sections, { sessionId, excludeOriginId, maxChars }) {
453
+ const turns = groupL2Turns(sections.l2Lines);
447
454
 
448
455
  // 固定部 (ヘッダ + アンカー + セクション見出し + 案内予約) のコスト
449
456
  const fixedCost =
@@ -544,6 +551,265 @@ function renderBudgetedSections(db, sections, { sessionId, excludeOriginId, maxC
544
551
  };
545
552
  }
546
553
 
554
+ // ---- 同じ project の過去の会話(handoff-context --project --sessions recent、ADR 0034)----
555
+
556
+ // 本文を載せなかった過去の会話の一覧に載せる件数の上限。
557
+ const PAST_INDEX_MAX = 5;
558
+ // 過去の会話の本文を足す時に、それより古い会話の一覧のために残しておく件数と、1行の見積もり
559
+ // (session id 約 42 字 + ISO 24 字 + 日時の範囲 + 定型文で 190 字前後)。
560
+ const PAST_INDEX_RESERVED_LINES = 2;
561
+ const PAST_INDEX_LINE_RESERVE = 240;
562
+ // recall の案内に焼き込む --last の上限。長い会話を1回の recall で全部引かせない。
563
+ const PAST_RECALL_LAST_MAX = 10;
564
+
565
+ const PAST_HEADER_LINES = [
566
+ '## Throughline: このprojectで記録された過去の会話(新しい順)',
567
+ '',
568
+ '**読み方:**',
569
+ '- 上の「現在地」より前に、同じprojectで行われた会話の記録です。次のユーザー入力は、ここへの返事ではありません。' +
570
+ 'ここに載っている依頼は、あらためて頼まれた時だけ実行してください。',
571
+ '- 新しい順に、予算へ入ったターンだけを載せています。残りの本文は各行の `throughline recall` で、' +
572
+ '道具の入出力は `throughline detail <YYYY-MM-DD>T<HH:MM:SS>` で取得できます。',
573
+ ];
574
+ const PAST_INDEX_TITLE = '### 本文を載せていない過去の会話';
575
+ const PAST_INDEX_MORE = '- これより古い会話は、この一覧に載せていません。';
576
+
577
+ function formatLocalDate(unixMs) {
578
+ const d = new Date(unixMs);
579
+ const mo = String(d.getMonth() + 1).padStart(2, '0');
580
+ const day = String(d.getDate()).padStart(2, '0');
581
+ return `${d.getFullYear()}-${mo}-${day}`;
582
+ }
583
+
584
+ /** `throughline detail` にそのまま渡せる日付つきの時刻。 */
585
+ function formatLocalDateTime(unixMs) {
586
+ return `${formatLocalDate(unixMs)}T${formatClock(unixMs)}`;
587
+ }
588
+
589
+ function formatLocalRange(minMs, maxMs) {
590
+ const minute = (unixMs) => formatClock(unixMs).slice(0, 5);
591
+ const from = `${formatLocalDate(minMs)} ${minute(minMs)}`;
592
+ return formatLocalDate(minMs) === formatLocalDate(maxMs)
593
+ ? `${from}〜${minute(maxMs)}`
594
+ : `${from}〜${formatLocalDate(maxMs)} ${minute(maxMs)}`;
595
+ }
596
+
597
+ /** 会話本文を持つターンの一覧(古い順)。 */
598
+ function loadSessionTurnSpans(db, sessionId) {
599
+ return db
600
+ .prepare(
601
+ `SELECT origin_session_id, turn_number, MIN(created_at) AS min_ca, MAX(created_at) AS max_ca
602
+ FROM bodies
603
+ WHERE session_id = ? AND text != ''
604
+ GROUP BY origin_session_id, turn_number
605
+ ORDER BY min_ca ASC`,
606
+ )
607
+ .all(sessionId)
608
+ .map((r) => ({
609
+ turnKey: `${r.origin_session_id}\x00${r.turn_number}`,
610
+ minCreatedAt: r.min_ca,
611
+ maxCreatedAt: r.max_ca,
612
+ }));
613
+ }
614
+
615
+ function describeSession(sessionId, role, spans, includedTurns) {
616
+ const first = spans.length > 0 ? spans[0].minCreatedAt : null;
617
+ const last = spans.reduce((max, t) => (max == null || t.maxCreatedAt > max ? t.maxCreatedAt : max), null);
618
+ return {
619
+ sessionId,
620
+ role,
621
+ firstTurnAt: first == null ? null : new Date(first).toISOString(),
622
+ lastTurnAt: last == null ? null : new Date(last).toISOString(),
623
+ turns: spans.length,
624
+ includedTurns,
625
+ };
626
+ }
627
+
628
+ function recallHint(sessionId, beforeMs, turns) {
629
+ const last = Math.min(turns, PAST_RECALL_LAST_MAX);
630
+ return (
631
+ (last < turns ? `新しい${last}ターンの本文は ` : '本文は ') +
632
+ `\`throughline recall --l2 --session ${sessionId} --before ${new Date(beforeMs).toISOString()} --last ${last}\``
633
+ );
634
+ }
635
+
636
+ /** 過去の会話1つ分の行。keptTurns はその会話の新しい側から続いたターン(古い順)。 */
637
+ function renderPastBlock(candidate, keptTurns) {
638
+ const rest = candidate.turns - keptTurns.length;
639
+ const count = rest > 0
640
+ ? `${candidate.turns}ターンのうち新しい${keptTurns.length}ターン`
641
+ : `${candidate.turns}ターン`;
642
+ const lines = [
643
+ '',
644
+ `### 過去の会話 ${formatLocalRange(candidate.minCreatedAt, candidate.maxCreatedAt)}(${count} / session ${candidate.sessionId})`,
645
+ ];
646
+ if (rest > 0) {
647
+ lines.push(
648
+ `- これより前の${rest}ターン: ${recallHint(candidate.sessionId, keptTurns[0].minCreatedAt, rest)}`,
649
+ );
650
+ }
651
+ for (const turn of keptTurns) {
652
+ for (const line of turn.lines) lines.push(`[${formatLocalDateTime(line.createdAt)}] ${line.rest}`);
653
+ }
654
+ return lines;
655
+ }
656
+
657
+ function renderPastIndexLine(candidate) {
658
+ return (
659
+ `- ${formatLocalRange(candidate.minCreatedAt, candidate.maxCreatedAt)}(${candidate.turns}ターン): ` +
660
+ recallHint(candidate.sessionId, candidate.maxCreatedAt + 1, candidate.turns)
661
+ );
662
+ }
663
+
664
+ /**
665
+ * 同じ project の直近の会話から、予算付きの注入テキストを作る (ADR 0034)。
666
+ *
667
+ * 現在の会話(文脈を持つ最新の session)は、buildBudgetedResumeContext と同じ文を先頭に置く。
668
+ * 過去の会話は、その後ろの余った予算にだけ入れる。現在の会話の文は、過去の会話の有無で変わらない。
669
+ * 現在の会話のターンが全部入っている時だけ、過去の会話を新しい順にターン単位で足す。
670
+ * 入らないターンが出たらそこで止め、それより古い会話は一覧(日時・ターン数・recall)だけにする。
671
+ * 新しいターンを飛ばして古いターンを載せない。同じ (origin, turn) は新しい会話の1回だけ載せる。
672
+ *
673
+ * @param {import('node:sqlite').DatabaseSync} db
674
+ * @param {{
675
+ * sessionIds: string[], 同じ project の session を新しい順に並べたもの(選ぶのは呼び出し側)
676
+ * handoffDisclosure?: 'visible' | 'silent',
677
+ * maxChars?: number,
678
+ * }} params
679
+ * @returns {{
680
+ * text: string,
681
+ * sessionId: string, 現在の会話
682
+ * sessions: Array<{
683
+ * sessionId: string,
684
+ * role: 'current' | 'past',
685
+ * firstTurnAt: string | null,
686
+ * lastTurnAt: string | null,
687
+ * turns: number,
688
+ * includedTurns: number, 0 は一覧にだけ載せた会話
689
+ * }>,
690
+ * } | null}
691
+ */
692
+ export function buildRecentSessionsContext(
693
+ db,
694
+ { sessionIds, handoffDisclosure = 'visible', maxChars = INJECTION_BUDGET_CHARS },
695
+ ) {
696
+ let currentIndex = -1;
697
+ let currentSections = null;
698
+ for (let i = 0; i < sessionIds.length; i += 1) {
699
+ currentSections = buildResumeSections(db, {
700
+ sessionId: sessionIds[i],
701
+ isInheritance: true,
702
+ handoffDisclosure,
703
+ });
704
+ if (currentSections) {
705
+ currentIndex = i;
706
+ break;
707
+ }
708
+ }
709
+ if (!currentSections) return null;
710
+
711
+ const currentId = sessionIds[currentIndex];
712
+ const currentSpans = loadSessionTurnSpans(db, currentId);
713
+ const current = renderBudgetedSections(db, currentSections, {
714
+ sessionId: currentId,
715
+ excludeOriginId: null,
716
+ maxChars,
717
+ });
718
+ const sessions = [describeSession(currentId, 'current', currentSpans, current.injectedL2Turns)];
719
+ const onlyCurrent = () => ({ text: current.text, sessionId: currentId, sessions: sessions.slice(0, 1) });
720
+
721
+ // 過去の会話の候補を、新しい順に必要な分だけ読む。先に読んだ(新しい)会話のターンは後の会話から外す。
722
+ const seenTurns = new Set(currentSpans.map((t) => t.turnKey));
723
+ const candidates = [];
724
+ let cursor = currentIndex + 1;
725
+ const candidateAt = (n) => {
726
+ while (candidates.length <= n && cursor < sessionIds.length) {
727
+ const sessionId = sessionIds[cursor];
728
+ cursor += 1;
729
+ const spans = loadSessionTurnSpans(db, sessionId).filter((t) => !seenTurns.has(t.turnKey));
730
+ if (spans.length === 0) continue;
731
+ for (const t of spans) seenTurns.add(t.turnKey);
732
+ candidates.push({
733
+ sessionId,
734
+ spans,
735
+ turns: spans.length,
736
+ minCreatedAt: spans[0].minCreatedAt,
737
+ maxCreatedAt: spans.reduce((max, t) => Math.max(max, t.maxCreatedAt), -Infinity),
738
+ });
739
+ }
740
+ return candidates[n] ?? null;
741
+ };
742
+ const countFrom = (n) => [candidateAt(n), candidateAt(n + 1)].filter(Boolean).length;
743
+ if (countFrom(0) === 0) return onlyCurrent();
744
+
745
+ const cost = (lines) => lines.reduce((sum, line) => sum + lineCost(line), 0);
746
+ const headerCost = cost(['', ...PAST_HEADER_LINES]);
747
+ const indexTitleCost = cost(['', PAST_INDEX_TITLE]);
748
+ const indexReserve = (count) =>
749
+ count > 0 ? indexTitleCost + PAST_INDEX_LINE_RESERVE * Math.min(PAST_INDEX_RESERVED_LINES, count) : 0;
750
+
751
+ // 過去の会話に使えるのは、現在の会話を今と同じ文で置いた後の余りだけ。
752
+ let remaining = maxChars - current.totalChars - headerCost;
753
+ const pastLines = [];
754
+ let next = 0;
755
+
756
+ // 本文: 現在の会話に載せ残しが無い時だけ、新しい会話から順にターンを足す。
757
+ let fillBodies =
758
+ current.remainingL2Turns === 0 && current.olderTurns === 0 && !current.truncatedNewestL2;
759
+ while (fillBodies) {
760
+ const candidate = candidateAt(next);
761
+ if (!candidate) break;
762
+ const available = remaining - indexReserve(countFrom(next + 1));
763
+ const keys = new Set(candidate.spans.map((t) => t.turnKey));
764
+ const stored = buildResumeSections(db, {
765
+ sessionId: candidate.sessionId,
766
+ isInheritance: true,
767
+ handoffDisclosure: 'silent',
768
+ });
769
+ const turns = groupL2Turns((stored?.l2Lines ?? []).filter((line) => keys.has(line.turnKey)));
770
+ let block = null;
771
+ for (let kept = 1; kept <= turns.length; kept += 1) {
772
+ const lines = renderPastBlock(candidate, turns.slice(turns.length - kept));
773
+ const blockCost = cost(lines);
774
+ if (blockCost <= available) block = { lines, blockCost, kept };
775
+ }
776
+ if (!block) break;
777
+ pastLines.push(...block.lines);
778
+ remaining -= block.blockCost;
779
+ sessions.push(describeSession(candidate.sessionId, 'past', candidate.spans, block.kept));
780
+ next += 1;
781
+ // 途中までしか入らなかった会話より古い会話は、一覧だけにする。
782
+ fillBodies = block.kept === candidate.turns;
783
+ }
784
+
785
+ // 一覧: 本文を載せなかった会話を、新しい順に載せる。
786
+ const indexLines = [];
787
+ while (indexLines.length < PAST_INDEX_MAX) {
788
+ const candidate = candidateAt(next);
789
+ if (!candidate) break;
790
+ const line = renderPastIndexLine(candidate);
791
+ const lineTotal = lineCost(line) + (indexLines.length === 0 ? indexTitleCost : 0);
792
+ if (lineTotal > remaining) break;
793
+ indexLines.push(line);
794
+ remaining -= lineTotal;
795
+ sessions.push(describeSession(candidate.sessionId, 'past', candidate.spans, 0));
796
+ next += 1;
797
+ }
798
+ if (indexLines.length > 0) {
799
+ pastLines.push('', PAST_INDEX_TITLE, ...indexLines);
800
+ if (candidateAt(next) && lineCost(PAST_INDEX_MORE) <= remaining) pastLines.push(PAST_INDEX_MORE);
801
+ }
802
+
803
+ // 余りに1行も入らなかった時は、節の見出しも付けない。
804
+ if (pastLines.length === 0) return onlyCurrent();
805
+
806
+ return {
807
+ text: [current.text, '', ...PAST_HEADER_LINES, ...pastLines].join('\n'),
808
+ sessionId: currentId,
809
+ sessions,
810
+ };
811
+ }
812
+
547
813
  // 長い依頼は、指示が先頭にも末尾にも書かれる。先頭 3/4 と末尾 1/4 を残し、間を抜いたことを明示する。
548
814
  function truncateInFlight(text, maxChars) {
549
815
  const trimmed = text.trim();