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.
@@ -5,6 +5,7 @@ import {
5
5
  buildResumeContext,
6
6
  buildBudgetedResumeContext,
7
7
  buildAutoContinuationContext,
8
+ buildRecentSessionsContext,
8
9
  INJECTION_BUDGET_CHARS,
9
10
  } from './resume-context.mjs';
10
11
 
@@ -1034,3 +1035,280 @@ test('buildAutoContinuationContext: 改行のある直前の発言は全文を
1034
1035
  assert.match(result.text, /- 道具: Read \/work\/file-080-x+\.mjs\n\*\*止める直前のあなたの発言\*\* \[\d\d:\d\d:\d\d\]: 方針:\n1\. 型を直す\n2\. 試験を足す\n\*\*止める直前に呼ぼうとして、実行されなかった道具\*\*: Edit \/work\/types\.mjs\n/);
1035
1036
  });
1036
1037
 
1038
+
1039
+ // ---- 同じ project の過去の会話 (ADR 0034) ----
1040
+
1041
+ const RECENT_BASE = 1_700_000_000_000;
1042
+ const RECENT_MINUTE = 60_000;
1043
+
1044
+ /** 1 ターン (user + assistant) を入れる。at は user 行の時刻で、assistant 行は 30 秒後。 */
1045
+ function insertTurn(db, { session, origin = session, turn, user, assistant, at }) {
1046
+ insertBody(db, { session, origin, turn, role: 'user', text: user, createdAt: at });
1047
+ insertBody(db, { session, origin, turn, role: 'assistant', text: assistant, createdAt: at + 30_000 });
1048
+ }
1049
+
1050
+ function escapeRegExp(text) {
1051
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1052
+ }
1053
+
1054
+ function countOccurrences(text, needle) {
1055
+ return text.split(needle).length - 1;
1056
+ }
1057
+
1058
+ test('recent: 過去の会話が無い時は buildBudgetedResumeContext と同じ文になる', () => {
1059
+ const db = makeDb();
1060
+ insertTurn(db, { session: 'only', turn: 1, user: 'ひとつだけの依頼', assistant: 'ひとつだけの回答', at: RECENT_BASE });
1061
+
1062
+ const expected = buildBudgetedResumeContext(db, {
1063
+ sessionId: 'only',
1064
+ isInheritance: true,
1065
+ handoffDisclosure: 'silent',
1066
+ });
1067
+ // 本文の無い新しい session は飛ばし、現在の会話に選ばない
1068
+ const recent = buildRecentSessionsContext(db, {
1069
+ sessionIds: ['newer-empty', 'only', 'older-empty'],
1070
+ handoffDisclosure: 'silent',
1071
+ });
1072
+
1073
+ assert.equal(recent.text, expected.text);
1074
+ assert.equal(recent.sessionId, 'only');
1075
+ assert.deepEqual(recent.sessions, [
1076
+ {
1077
+ sessionId: 'only',
1078
+ role: 'current',
1079
+ firstTurnAt: new Date(RECENT_BASE).toISOString(),
1080
+ lastTurnAt: new Date(RECENT_BASE + 30_000).toISOString(),
1081
+ turns: 1,
1082
+ includedTurns: 1,
1083
+ },
1084
+ ]);
1085
+ assert.equal(buildRecentSessionsContext(db, { sessionIds: ['newer-empty'] }), null);
1086
+ });
1087
+
1088
+ test('recent: 短い最新の会話の後ろに、前の作業の会話が入る', () => {
1089
+ const db = makeDb();
1090
+ // 6 ターンの作業 → 1 ターンの連絡(最新)
1091
+ for (let turn = 1; turn <= 6; turn += 1) {
1092
+ insertTurn(db, {
1093
+ session: 'work',
1094
+ turn,
1095
+ user: `作業の依頼${turn}`,
1096
+ assistant: `作業の結果${turn}`,
1097
+ at: RECENT_BASE + turn * RECENT_MINUTE,
1098
+ });
1099
+ }
1100
+ insertDetail(db, {
1101
+ session: 'work', origin: 'work', turn: 6, kind: 'tool_input', toolName: 'Bash', createdAt: RECENT_BASE + 6 * RECENT_MINUTE + 1000,
1102
+ });
1103
+ insertTurn(db, { session: 'note', turn: 1, user: '短い連絡', assistant: '連絡への返事', at: RECENT_BASE + 60 * RECENT_MINUTE });
1104
+
1105
+ const latestOnly = buildBudgetedResumeContext(db, {
1106
+ sessionId: 'note',
1107
+ isInheritance: true,
1108
+ handoffDisclosure: 'silent',
1109
+ });
1110
+ const recent = buildRecentSessionsContext(db, { sessionIds: ['note', 'work'], handoffDisclosure: 'silent' });
1111
+
1112
+ // 現在の会話の部分は今と同じ文で、その後ろに過去の会話が続く
1113
+ assert.ok(recent.text.startsWith(`${latestOnly.text}\n\n## Throughline: このprojectで記録された過去の会話(新しい順)\n`));
1114
+ assert.ok(recent.text.length <= INJECTION_BUDGET_CHARS);
1115
+ assert.equal(recent.sessionId, 'note');
1116
+ // 「直前の会話」「短い返事は GO」の案内は現在の会話の1回だけ
1117
+ assert.equal(countOccurrences(recent.text, '短文/相槌の判定'), 1);
1118
+ assert.equal(countOccurrences(recent.text, '### 現在地 (直前のやりとり)'), 1);
1119
+ assert.match(recent.text, /次のユーザー入力は、ここへの返事ではありません/);
1120
+
1121
+ const past = recent.text.slice(latestOnly.text.length);
1122
+ assert.match(past, /### 過去の会話 \d{4}-\d{2}-\d{2} \d{2}:\d{2}〜\d{2}:\d{2}(6ターン \/ session work)/);
1123
+ for (let turn = 1; turn <= 6; turn += 1) {
1124
+ assert.match(past, new RegExp(`\\[\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\] \\[user\\]: 作業の依頼${turn}\\n`));
1125
+ }
1126
+ // ターンは古い順に並び、道具の案内は今と同じくターンの最後の行に付く
1127
+ assert.ok(past.indexOf('作業の依頼1') < past.indexOf('作業の結果1'));
1128
+ assert.ok(past.indexOf('作業の結果1') < past.indexOf('作業の依頼2'));
1129
+ assert.match(past, /\[assistant\]: 作業の結果6 \(詳細:Bash\)/);
1130
+ // 全部入ったので、recall の案内と一覧は付かない
1131
+ assert.ok(!past.includes('throughline recall --l2 --session work'));
1132
+ assert.ok(!past.includes('### 本文を載せていない過去の会話'));
1133
+
1134
+ assert.deepEqual(
1135
+ recent.sessions.map((s) => [s.sessionId, s.role, s.turns, s.includedTurns]),
1136
+ [['note', 'current', 1, 1], ['work', 'past', 6, 6]],
1137
+ );
1138
+ assert.equal(recent.sessions[1].firstTurnAt, new Date(RECENT_BASE + RECENT_MINUTE).toISOString());
1139
+ assert.equal(recent.sessions[1].lastTurnAt, new Date(RECENT_BASE + 6 * RECENT_MINUTE + 30_000).toISOString());
1140
+ });
1141
+
1142
+ test('recent: 入らないターンから先は載せず、recall の案内と一覧にする', () => {
1143
+ const db = makeDb();
1144
+ insertTurn(db, { session: 'oldest', turn: 1, user: 'OLDEST_REQUEST', assistant: 'OLDEST_ANSWER', at: RECENT_BASE });
1145
+ insertTurn(db, { session: 'oldest', turn: 2, user: 'OLDEST_REQUEST_2', assistant: 'OLDEST_ANSWER_2', at: RECENT_BASE + RECENT_MINUTE });
1146
+ for (let turn = 1; turn <= 14; turn += 1) {
1147
+ insertTurn(db, {
1148
+ session: 'work',
1149
+ turn,
1150
+ user: `work-q-${String(turn).padStart(2, '0')}`,
1151
+ assistant: `work-a-${String(turn).padStart(2, '0')} ` + 'x'.repeat(600),
1152
+ at: RECENT_BASE + (10 + turn) * RECENT_MINUTE,
1153
+ });
1154
+ }
1155
+ insertTurn(db, { session: 'note', turn: 1, user: '短い連絡', assistant: '連絡への返事', at: RECENT_BASE + 60 * RECENT_MINUTE });
1156
+
1157
+ const recent = buildRecentSessionsContext(db, {
1158
+ sessionIds: ['note', 'work', 'oldest'],
1159
+ handoffDisclosure: 'silent',
1160
+ maxChars: 6000,
1161
+ });
1162
+
1163
+ assert.ok(recent.text.length <= 6000, `length ${recent.text.length} must fit budget`);
1164
+ const work = recent.sessions.find((s) => s.sessionId === 'work');
1165
+ assert.ok(work.includedTurns > 0 && work.includedTurns < 14, `includedTurns ${work.includedTurns}`);
1166
+ assert.equal(work.turns, 14);
1167
+ // 新しい側から続けて入り、ターンは user 行と assistant 行が揃う。それより古いターンは1行も入らない
1168
+ for (let turn = 1; turn <= 14; turn += 1) {
1169
+ const tag = String(turn).padStart(2, '0');
1170
+ const included = turn > 14 - work.includedTurns;
1171
+ assert.equal(recent.text.includes(`work-q-${tag}`), included, `user row of turn ${tag}`);
1172
+ assert.equal(recent.text.includes(`work-a-${tag} `), included, `assistant row of turn ${tag}`);
1173
+ }
1174
+ // 残りは recall で引ける。境界は載せた最古ターンの時刻、件数は 10 まで
1175
+ const rest = 14 - work.includedTurns;
1176
+ const oldestIncluded = 14 - work.includedTurns + 1;
1177
+ const boundary = new Date(RECENT_BASE + (10 + oldestIncluded) * RECENT_MINUTE).toISOString();
1178
+ assert.ok(recent.text.includes(`(14ターンのうち新しい${work.includedTurns}ターン / session work)`));
1179
+ assert.ok(
1180
+ recent.text.includes(
1181
+ `- これより前の${rest}ターン: ${rest > 10 ? '新しい10ターンの本文は ' : '本文は '}` +
1182
+ `\`throughline recall --l2 --session work --before ${boundary} --last ${Math.min(rest, 10)}\``,
1183
+ ),
1184
+ recent.text,
1185
+ );
1186
+ // 途中で止まった会話より古い会話は、本文を載せず一覧にする
1187
+ assert.ok(!recent.text.includes('OLDEST_REQUEST'));
1188
+ const afterLast = new Date(RECENT_BASE + RECENT_MINUTE + 30_000 + 1).toISOString();
1189
+ assert.match(
1190
+ recent.text,
1191
+ new RegExp(
1192
+ '### 本文を載せていない過去の会話\\n' +
1193
+ '- \\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}〜\\d{2}:\\d{2}(2ターン): 本文は ' +
1194
+ escapeRegExp(`\`throughline recall --l2 --session oldest --before ${afterLast} --last 2\``) +
1195
+ '$',
1196
+ ),
1197
+ );
1198
+ assert.deepEqual(
1199
+ recent.sessions.map((s) => [s.sessionId, s.role, s.includedTurns]),
1200
+ [['note', 'current', 1], ['work', 'past', work.includedTurns], ['oldest', 'past', 0]],
1201
+ );
1202
+ });
1203
+
1204
+ test('recent: 現在の会話に載せ残しがある時は、その文を変えず、余りに一覧だけ付ける', () => {
1205
+ const db = makeDb();
1206
+ // 一覧の1行が長くなる session id(余りに入る場合と入らない場合の両方を作る)
1207
+ const pastId = `past-${'p'.repeat(195)}`;
1208
+ insertTurn(db, { session: pastId, turn: 1, user: 'PAST_SHORT_REQUEST', assistant: 'PAST_SHORT_ANSWER', at: RECENT_BASE });
1209
+ for (let turn = 1; turn <= 10; turn += 1) {
1210
+ insertTurn(db, {
1211
+ session: 'long',
1212
+ turn,
1213
+ user: `long-q-${String(turn).padStart(2, '0')}`,
1214
+ assistant: `long-a-${String(turn).padStart(2, '0')} ` + 'x'.repeat(800),
1215
+ at: RECENT_BASE + (10 + turn) * RECENT_MINUTE,
1216
+ });
1217
+ }
1218
+
1219
+ // 現在の会話の文は、過去の会話の有無でも予算でも変わらない
1220
+ const listed = new Set();
1221
+ for (const maxChars of [4600, 4800, 4900, 5000, 5200, 5400, 5600, 5700]) {
1222
+ const latestOnly = buildBudgetedResumeContext(db, {
1223
+ sessionId: 'long',
1224
+ isInheritance: true,
1225
+ handoffDisclosure: 'silent',
1226
+ maxChars,
1227
+ });
1228
+ const recent = buildRecentSessionsContext(db, {
1229
+ sessionIds: ['long', pastId],
1230
+ handoffDisclosure: 'silent',
1231
+ maxChars,
1232
+ });
1233
+ listed.add(recent.sessions.length === 2);
1234
+
1235
+ assert.ok(latestOnly.remainingL2Turns > 0, 'the current session must have turns left for pull');
1236
+ assert.ok(recent.text.length <= maxChars, `length ${recent.text.length} must fit budget ${maxChars}`);
1237
+ assert.equal(recent.sessions[0].includedTurns, latestOnly.injectedL2Turns);
1238
+ // 新しいターンを飛ばして古い会話の本文を載せない
1239
+ assert.ok(!recent.text.includes('PAST_SHORT_REQUEST'));
1240
+ assert.ok(!recent.text.includes('### 過去の会話 '));
1241
+ if (recent.sessions.length === 1) {
1242
+ // 余りに一覧の1行も入らない時は、今と同じ文
1243
+ assert.equal(recent.text, latestOnly.text);
1244
+ continue;
1245
+ }
1246
+ assert.ok(recent.text.startsWith(`${latestOnly.text}\n\n## Throughline: このprojectで記録された過去の会話(新しい順)\n`));
1247
+ assert.ok(recent.text.includes('### 本文を載せていない過去の会話\n'));
1248
+ assert.ok(recent.text.includes(`(1ターン): 本文は \`throughline recall --l2 --session ${pastId} --before `));
1249
+ assert.deepEqual(recent.sessions.map((s) => [s.sessionId, s.includedTurns]), [
1250
+ ['long', latestOnly.injectedL2Turns],
1251
+ [pastId, 0],
1252
+ ]);
1253
+ }
1254
+ assert.deepEqual([...listed].sort(), [false, true], '一覧が入る予算と入らない予算の両方を確かめる');
1255
+ });
1256
+
1257
+ test('recent: 同じ origin と turn は新しい会話の1回だけ載せる', () => {
1258
+ const db = makeDb();
1259
+ // 後継の会話 (current) が、前任 (shared) のターンを引き継いで持っている
1260
+ insertTurn(db, { session: 'current', origin: 'shared', turn: 1, user: 'SHARED_REQUEST', assistant: 'SHARED_ANSWER', at: RECENT_BASE });
1261
+ insertTurn(db, { session: 'current', turn: 1, user: '最新の依頼', assistant: '最新の回答', at: RECENT_BASE + 30 * RECENT_MINUTE });
1262
+ // 同じターンの行が別の session にも残っている
1263
+ insertTurn(db, { session: 'copy', origin: 'shared', turn: 1, user: 'SHARED_REQUEST', assistant: 'SHARED_ANSWER', at: RECENT_BASE });
1264
+ insertTurn(db, { session: 'mixed', origin: 'shared', turn: 1, user: 'SHARED_REQUEST', assistant: 'SHARED_ANSWER', at: RECENT_BASE });
1265
+ insertTurn(db, { session: 'mixed', turn: 1, user: 'MIXED_OWN_REQUEST', assistant: 'MIXED_OWN_ANSWER', at: RECENT_BASE - 10 * RECENT_MINUTE });
1266
+
1267
+ const recent = buildRecentSessionsContext(db, {
1268
+ sessionIds: ['current', 'copy', 'mixed'],
1269
+ handoffDisclosure: 'silent',
1270
+ });
1271
+
1272
+ const past = recent.text.slice(recent.text.indexOf('## Throughline: このprojectで記録された過去の会話'));
1273
+ assert.ok(!past.includes('SHARED_REQUEST'));
1274
+ assert.ok(past.includes('MIXED_OWN_REQUEST'));
1275
+ assert.match(past, /(1ターン \/ session mixed)/);
1276
+ // 重なるターンしか持たない会話は、過去の会話に数えない
1277
+ assert.deepEqual(
1278
+ recent.sessions.map((s) => [s.sessionId, s.turns, s.includedTurns]),
1279
+ [['current', 2, 2], ['mixed', 1, 1]],
1280
+ );
1281
+ });
1282
+
1283
+ test('recent: 一覧は5件までで、それより古い会話があることを書く', () => {
1284
+ const db = makeDb();
1285
+ insertTurn(db, { session: 'current', turn: 1, user: '最新の依頼', assistant: '最新の回答', at: RECENT_BASE + 100 * RECENT_MINUTE });
1286
+ const sessionIds = ['current'];
1287
+ for (let n = 1; n <= 7; n += 1) {
1288
+ // どの会話も最新のターンが予算に入らない
1289
+ insertTurn(db, {
1290
+ session: `big-${n}`,
1291
+ turn: 1,
1292
+ user: `BIG_REQUEST_${n}`,
1293
+ assistant: 'y'.repeat(INJECTION_BUDGET_CHARS),
1294
+ at: RECENT_BASE + (50 - n) * RECENT_MINUTE,
1295
+ });
1296
+ sessionIds.push(`big-${n}`);
1297
+ }
1298
+
1299
+ const recent = buildRecentSessionsContext(db, { sessionIds, handoffDisclosure: 'silent' });
1300
+
1301
+ assert.ok(recent.text.length <= INJECTION_BUDGET_CHARS);
1302
+ assert.ok(!recent.text.includes('BIG_REQUEST_'));
1303
+ const index = recent.text.slice(recent.text.indexOf('### 本文を載せていない過去の会話\n')).split('\n');
1304
+ assert.equal(index.length, 7);
1305
+ for (let n = 1; n <= 5; n += 1) {
1306
+ assert.ok(index[n].includes(`--session big-${n} `), index[n]);
1307
+ assert.ok(index[n].endsWith('--last 1`'), index[n]);
1308
+ }
1309
+ assert.equal(index[6], '- これより古い会話は、この一覧に載せていません。');
1310
+ assert.deepEqual(
1311
+ recent.sessions.map((s) => [s.sessionId, s.includedTurns]),
1312
+ [['current', 1], ['big-1', 0], ['big-2', 0], ['big-3', 0], ['big-4', 0], ['big-5', 0]],
1313
+ );
1314
+ });
package/src/sc-detail.mjs CHANGED
@@ -5,8 +5,11 @@
5
5
  * 使い方:
6
6
  * node src/sc-detail.mjs <時刻>
7
7
  * node src/sc-detail.mjs <開始時刻>-<終了時刻>
8
+ * node src/sc-detail.mjs <日付>T<時刻>
8
9
  *
9
- * 時刻フォーマット: HH:MM:SS または HH:MM(秒省略可)
10
+ * 時刻フォーマット: HH:MM:SS または HH:MM(秒省略可)。日付は YYYY-MM-DD。
11
+ * 日付を省いた時は、今日から1日ずつ遡り、その時刻のターンがある最も新しい日を対象にする
12
+ * (注入文の [HH:MM:SS] は過去の時刻で、前日以前のことがある)。
10
13
  * 複数ターンが同一時刻にヒットする場合は全部返す。
11
14
  *
12
15
  * 出力: 指定時刻のターン(または範囲内の全ターン)の L2 (bodies) + L3 (details)
@@ -22,10 +25,14 @@ import { getDb } from './db.mjs';
22
25
  import { DETAIL_KIND, DETAIL_KIND_VALUES } from './constants.mjs';
23
26
 
24
27
  function parseTimeArg(arg) {
25
- const m = String(arg || '').trim().match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
28
+ const m = String(arg || '')
29
+ .trim()
30
+ .match(/^(?:(\d{4})-(\d{2})-(\d{2})[T ])?(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
26
31
  if (!m) return null;
27
- const [, hh, mm, ss] = m;
32
+ const [, year, month, day, hh, mm, ss] = m;
28
33
  return {
34
+ // null = 日付指定なし(今日から遡って探す)
35
+ date: year != null ? { year: Number(year), month: Number(month), day: Number(day) } : null,
29
36
  hours: Number(hh),
30
37
  minutes: Number(mm),
31
38
  seconds: ss != null ? Number(ss) : null, // null = 秒指定なし(その分内すべて)
@@ -33,35 +40,42 @@ function parseTimeArg(arg) {
33
40
  }
34
41
 
35
42
  /**
36
- * 指定時刻(HH:MM:SS)の当日タイムスタンプ(ms)を返す。
37
- * 秒が null の場合は start=00, end=59 の範囲を返す(呼び出し側で使い分け)。
43
+ * 時刻の指定を解釈する。範囲は `<開始>-<終了>`。終了の日付を省くと開始と同じ日になる。
44
+ * @returns {{ from: object, to: object } | null}
38
45
  */
39
- function timeToUnixRange(t, baseDate = new Date()) {
40
- const y = baseDate.getFullYear();
41
- const mo = baseDate.getMonth();
42
- const d = baseDate.getDate();
43
- const secStart = t.seconds != null ? t.seconds : 0;
44
- const secEnd = t.seconds != null ? t.seconds : 59;
45
- const start = new Date(y, mo, d, t.hours, t.minutes, secStart, 0).getTime();
46
- const end = new Date(y, mo, d, t.hours, t.minutes, secEnd, 999).getTime();
47
- return { start, end };
46
+ export function parseDetailArg(arg) {
47
+ const s = String(arg || '').trim();
48
+ const single = parseTimeArg(s);
49
+ if (single) return { from: single, to: single };
50
+ // 日付にも `-` があるので、時刻の直後の `-` で開始と終了を分ける
51
+ const m = s.match(/^(.*?\d:\d{2}(?::\d{2})?)\s*-\s*(.+)$/);
52
+ if (!m) return null;
53
+ const from = parseTimeArg(m[1]);
54
+ const to = parseTimeArg(m[2]);
55
+ if (!from || !to) return null;
56
+ return { from, to: to.date ? to : { ...to, date: from.date } };
48
57
  }
49
58
 
50
- function parseRangeArg(arg) {
51
- const s = String(arg || '').trim();
52
- if (!s.includes('-')) {
53
- const t = parseTimeArg(s);
54
- if (!t) return null;
55
- const r = timeToUnixRange(t);
56
- return { start: r.start, end: r.end };
57
- }
58
- const [lo, hi] = s.split('-').map((x) => x.trim());
59
- const tLo = parseTimeArg(lo);
60
- const tHi = parseTimeArg(hi);
61
- if (!tLo || !tHi) return null;
62
- const rLo = timeToUnixRange(tLo);
63
- const rHi = timeToUnixRange(tHi);
64
- return { start: rLo.start, end: rHi.end };
59
+ /**
60
+ * 時刻の指定を、日付 base(年月日だけを使う)の上のタイムスタンプ範囲(ms)にする。
61
+ * 秒が null の場合は、開始は 00 秒、終了は 59 秒までを含める。
62
+ */
63
+ export function detailRangeOn(spec, base) {
64
+ const at = (t, endOfUnit) => {
65
+ const date = t.date ?? { year: base.getFullYear(), month: base.getMonth() + 1, day: base.getDate() };
66
+ const seconds = t.seconds != null ? t.seconds : endOfUnit ? 59 : 0;
67
+ return new Date(
68
+ date.year, date.month - 1, date.day, t.hours, t.minutes, seconds, endOfUnit ? 999 : 0,
69
+ ).getTime();
70
+ };
71
+ return { start: at(spec.from, false), end: at(spec.to, true) };
72
+ }
73
+
74
+ function formatDate(unixMs) {
75
+ const d = new Date(unixMs);
76
+ const mo = String(d.getMonth() + 1).padStart(2, '0');
77
+ const day = String(d.getDate()).padStart(2, '0');
78
+ return `${d.getFullYear()}-${mo}-${day}`;
65
79
  }
66
80
 
67
81
  function formatTime(unixMs) {
@@ -82,13 +96,15 @@ export function run(args) {
82
96
  if (!arg) {
83
97
  process.stderr.write(
84
98
  '使い方: throughline detail <HH:MM:SS>\n' +
85
- ' throughline detail <HH:MM:SS>-<HH:MM:SS>\n',
99
+ ' throughline detail <HH:MM:SS>-<HH:MM:SS>\n' +
100
+ ' throughline detail <YYYY-MM-DD>T<HH:MM:SS>\n' +
101
+ '日付を省くと、今日から遡って、その時刻のターンがある最も新しい日を探します。\n',
86
102
  );
87
103
  process.exit(1);
88
104
  }
89
105
 
90
- const range = parseRangeArg(arg);
91
- if (!range) {
106
+ const spec = parseDetailArg(arg);
107
+ if (!spec) {
92
108
  process.stderr.write(`[sc-detail] 時刻フォーマットが無効: ${arg}\n`);
93
109
  process.exit(1);
94
110
  }
@@ -98,16 +114,39 @@ export function run(args) {
98
114
 
99
115
  // 指定時刻範囲内のターンを bodies から取得(project_path でフィルタ)
100
116
  // bodies と sessions を JOIN して同プロジェクトに絞る
101
- const bodyRows = db
102
- .prepare(
103
- `SELECT b.session_id, b.origin_session_id, b.turn_number, b.role, b.text, b.created_at
104
- FROM bodies b
105
- JOIN sessions s ON s.session_id = b.session_id
106
- WHERE lower(s.project_path) = lower(?)
107
- AND b.created_at BETWEEN ? AND ?
108
- ORDER BY b.created_at ASC, b.role ASC`,
109
- )
110
- .all(projectPath, range.start, range.end);
117
+ const bodyStmt = db.prepare(
118
+ `SELECT b.session_id, b.origin_session_id, b.turn_number, b.role, b.text, b.created_at
119
+ FROM bodies b
120
+ JOIN sessions s ON s.session_id = b.session_id
121
+ WHERE lower(s.project_path) = lower(?)
122
+ AND b.created_at BETWEEN ? AND ?
123
+ ORDER BY b.created_at ASC, b.role ASC`,
124
+ );
125
+
126
+ let bodyRows = [];
127
+ if (spec.from.date) {
128
+ const range = detailRangeOn(spec, new Date());
129
+ bodyRows = bodyStmt.all(projectPath, range.start, range.end);
130
+ } else {
131
+ // 日付の指定が無い時は、今日から1日ずつ遡り、最初にターンが見つかった日を対象にする。
132
+ // この project の最古の本文より前までは探さない。
133
+ const oldest = db
134
+ .prepare(
135
+ `SELECT MIN(b.created_at) AS oldest
136
+ FROM bodies b
137
+ JOIN sessions s ON s.session_id = b.session_id
138
+ WHERE lower(s.project_path) = lower(?)`,
139
+ )
140
+ .get(projectPath)?.oldest;
141
+ const today = new Date();
142
+ for (let back = 0; oldest != null; back += 1) {
143
+ const day = new Date(today.getFullYear(), today.getMonth(), today.getDate() - back);
144
+ const range = detailRangeOn(spec, day);
145
+ if (range.end < oldest) break;
146
+ bodyRows = bodyStmt.all(projectPath, range.start, range.end);
147
+ if (bodyRows.length > 0) break;
148
+ }
149
+ }
111
150
 
112
151
  if (bodyRows.length === 0) {
113
152
  process.stdout.write(
@@ -115,6 +154,9 @@ export function run(args) {
115
154
  );
116
155
  process.exit(0);
117
156
  }
157
+ // 今日以外の日のターンを返す時は、どの日のターンかを明示する
158
+ const foundDate = formatDate(bodyRows[0].created_at);
159
+ const dateNote = !spec.from.date && foundDate !== formatDate(Date.now()) ? `(${foundDate})` : '';
118
160
 
119
161
  // ターン単位でグルーピング(同じ session_id + origin + turn_number)
120
162
  const turnKeys = new Set();
@@ -124,7 +166,7 @@ export function run(args) {
124
166
 
125
167
  const lines = [];
126
168
  lines.push('## Throughline /sc-detail');
127
- lines.push(`指定時刻: ${arg} 対象ターン数: ${turnKeys.size}`);
169
+ lines.push(`指定時刻: ${arg}${dateNote} 対象ターン数: ${turnKeys.size}`);
128
170
  lines.push('');
129
171
 
130
172
  // L2 を時刻順に出力
@@ -0,0 +1,184 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { DatabaseSync } from 'node:sqlite';
9
+
10
+ import { detailRangeOn, parseDetailArg } from './sc-detail.mjs';
11
+
12
+ const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url));
13
+ const BIN_PATH = join(REPO_ROOT, 'bin/throughline.mjs');
14
+
15
+ function pad(n) {
16
+ return String(n).padStart(2, '0');
17
+ }
18
+
19
+ function clock(date) {
20
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
21
+ }
22
+
23
+ function ymd(date) {
24
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
25
+ }
26
+
27
+ /** 今日から daysAgo 日前の、指定した時刻。 */
28
+ function daysAgoAt(daysAgo, hours, minutes, seconds) {
29
+ const now = new Date();
30
+ return new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysAgo, hours, minutes, seconds);
31
+ }
32
+
33
+ function createBox() {
34
+ const home = mkdtempSync(join(tmpdir(), 'tl-sc-detail-'));
35
+ const project = join(home, 'project');
36
+ mkdirSync(project);
37
+ const env = {
38
+ ...process.env,
39
+ HOME: home,
40
+ USERPROFILE: home,
41
+ XDG_CONFIG_HOME: join(home, '.config'),
42
+ XDG_STATE_HOME: join(home, '.local', 'state'),
43
+ };
44
+ const run = (args) =>
45
+ spawnSync(process.execPath, [BIN_PATH, 'detail', ...args], { cwd: project, env, encoding: 'utf8' });
46
+ // detail は cwd の project だけを対象にする。子 process から見える cwd をそのまま記録する
47
+ const cwd = spawnSync(process.execPath, ['-e', 'process.stdout.write(process.cwd())'], {
48
+ cwd: project, env, encoding: 'utf8',
49
+ }).stdout;
50
+ // 最初の実行で製品の schema が作られる
51
+ assert.equal(run(['00:00']).status, 0);
52
+ const db = new DatabaseSync(join(home, '.throughline', 'throughline.db'));
53
+ db.prepare(
54
+ `INSERT INTO sessions (session_id, project_path, status, created_at, updated_at)
55
+ VALUES ('detail-session', ?, 'active', 1, 1)`,
56
+ ).run(cwd);
57
+ const insertBody = db.prepare(
58
+ `INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
59
+ VALUES ('detail-session', 'detail-session', ?, ?, ?, 1, ?)`,
60
+ );
61
+ const insertDetail = db.prepare(
62
+ `INSERT INTO details
63
+ (session_id, origin_session_id, turn_number, tool_name, input_text, output_text, token_count, created_at, kind, source_id)
64
+ VALUES ('detail-session', 'detail-session', ?, 'Bash', ?, NULL, 1, ?, 'tool_input', ?)`,
65
+ );
66
+ let turn = 0;
67
+ const addTurn = (date, label) => {
68
+ turn += 1;
69
+ insertBody.run(turn, 'user', `${label}_REQUEST`, date.getTime());
70
+ insertBody.run(turn, 'assistant', `${label}_ANSWER`, date.getTime() + 400);
71
+ insertDetail.run(turn, `${label}_COMMAND`, date.getTime() + 200, `tool-${turn}`);
72
+ };
73
+ return { home, run, addTurn, close: () => db.close() };
74
+ }
75
+
76
+ test('parseDetailArg: 時刻だけ、日付つき、範囲を解釈する', () => {
77
+ assert.deepEqual(parseDetailArg('19:20:25'), {
78
+ from: { date: null, hours: 19, minutes: 20, seconds: 25 },
79
+ to: { date: null, hours: 19, minutes: 20, seconds: 25 },
80
+ });
81
+ assert.deepEqual(parseDetailArg('9:05').from, { date: null, hours: 9, minutes: 5, seconds: null });
82
+ const date = { year: 2026, month: 10, day: 4 };
83
+ assert.deepEqual(parseDetailArg('2026-10-04T19:20:25').from, { date, hours: 19, minutes: 20, seconds: 25 });
84
+ assert.deepEqual(parseDetailArg('2026-10-04 19:20').from, { date, hours: 19, minutes: 20, seconds: null });
85
+ // 範囲: 終了の日付を省くと開始と同じ日
86
+ assert.deepEqual(parseDetailArg('19:20:25-19:30:00'), {
87
+ from: { date: null, hours: 19, minutes: 20, seconds: 25 },
88
+ to: { date: null, hours: 19, minutes: 30, seconds: 0 },
89
+ });
90
+ assert.deepEqual(parseDetailArg('2026-10-04T23:50:00-23:59:59'), {
91
+ from: { date, hours: 23, minutes: 50, seconds: 0 },
92
+ to: { date, hours: 23, minutes: 59, seconds: 59 },
93
+ });
94
+ assert.deepEqual(parseDetailArg('2026-10-04T23:50:00-2026-10-05T00:10:00').to, {
95
+ date: { year: 2026, month: 10, day: 5 }, hours: 0, minutes: 10, seconds: 0,
96
+ });
97
+ for (const invalid of ['', 'abc', '19', '2026-10-04', '2026-10-04T', '19:20:25-', '19:20:25-abc', '10-04T19:20:25']) {
98
+ assert.equal(parseDetailArg(invalid), null, invalid);
99
+ }
100
+ });
101
+
102
+ test('detailRangeOn: 日付の無い時刻は渡した日に、日付つきはその日に置く', () => {
103
+ const base = new Date(2026, 9, 5, 3, 0, 0);
104
+ assert.deepEqual(detailRangeOn(parseDetailArg('19:20:25'), base), {
105
+ start: new Date(2026, 9, 5, 19, 20, 25, 0).getTime(),
106
+ end: new Date(2026, 9, 5, 19, 20, 25, 999).getTime(),
107
+ });
108
+ // 秒を省くと、その分の全部
109
+ assert.deepEqual(detailRangeOn(parseDetailArg('19:20'), base), {
110
+ start: new Date(2026, 9, 5, 19, 20, 0, 0).getTime(),
111
+ end: new Date(2026, 9, 5, 19, 20, 59, 999).getTime(),
112
+ });
113
+ assert.deepEqual(detailRangeOn(parseDetailArg('2026-10-04T19:20:25-19:30'), base), {
114
+ start: new Date(2026, 9, 4, 19, 20, 25, 0).getTime(),
115
+ end: new Date(2026, 9, 4, 19, 30, 59, 999).getTime(),
116
+ });
117
+ });
118
+
119
+ test('detail: 日付を省いた時刻は、今日に無ければ遡って最も新しい日のターンを返す', () => {
120
+ const box = createBox();
121
+ try {
122
+ const yesterday = daysAgoAt(1, 19, 20, 25);
123
+ const older = daysAgoAt(3, 19, 20, 25);
124
+ box.addTurn(older, 'OLDER');
125
+ box.addTurn(yesterday, 'YESTERDAY');
126
+ box.close();
127
+
128
+ const bare = box.run([clock(yesterday)]);
129
+ assert.equal(bare.status, 0, bare.stderr);
130
+ assert.match(bare.stdout, new RegExp(`指定時刻: 19:20:25(${ymd(yesterday)}) 対象ターン数: 1\\n`));
131
+ assert.match(bare.stdout, /\[19:20:25\] \[user\]: YESTERDAY_REQUEST/);
132
+ assert.match(bare.stdout, /YESTERDAY_ANSWER/);
133
+ assert.match(bare.stdout, /IN {2}Bash\n {2}IN: {2}YESTERDAY_COMMAND/);
134
+ // 同じ時刻のもっと古い日のターンは混ぜない
135
+ assert.doesNotMatch(bare.stdout, /OLDER_/);
136
+
137
+ // 日付を付ければ、その日のターンを引ける
138
+ const dated = box.run([`${ymd(older)}T19:20:25`]);
139
+ assert.equal(dated.status, 0, dated.stderr);
140
+ assert.match(dated.stdout, new RegExp(`指定時刻: ${ymd(older)}T19:20:25 対象ターン数: 1\\n`));
141
+ assert.match(dated.stdout, /OLDER_REQUEST/);
142
+ assert.match(dated.stdout, /OLDER_COMMAND/);
143
+ assert.doesNotMatch(dated.stdout, /YESTERDAY_/);
144
+
145
+ const range = box.run([`${ymd(older)}T19:20-19:21`]);
146
+ assert.equal(range.status, 0, range.stderr);
147
+ assert.match(range.stdout, /OLDER_REQUEST/);
148
+ assert.doesNotMatch(range.stdout, /YESTERDAY_/);
149
+ } finally {
150
+ rmSync(box.home, { recursive: true, force: true });
151
+ }
152
+ });
153
+
154
+ test('detail: 今日のターンはそのまま返し、無い時刻と無効な書き方は今までどおり知らせる', () => {
155
+ const box = createBox();
156
+ try {
157
+ const earlier = daysAgoAt(2, 8, 15, 0);
158
+ // 今日の 00:00:00 は、いつ実行しても過去の時刻
159
+ const today = daysAgoAt(0, 0, 0, 0);
160
+ box.addTurn(earlier, 'EARLIER');
161
+ box.addTurn(today, 'TODAY');
162
+ box.close();
163
+
164
+ const found = box.run(['00:00:00']);
165
+ assert.equal(found.status, 0, found.stderr);
166
+ assert.match(found.stdout, /指定時刻: 00:00:00 {2}対象ターン数: 1\n/);
167
+ assert.match(found.stdout, /TODAY_REQUEST/);
168
+
169
+ // この project の記録に無い時刻
170
+ const missing = box.run(['13:13:13']);
171
+ assert.equal(missing.status, 0, missing.stderr);
172
+ assert.match(missing.stdout, /指定時刻 13:13:13 に該当するターンが見つかりませんでした。/);
173
+ // 日付を付けた時は、その日だけを見る
174
+ const wrongDay = box.run([`${ymd(daysAgoAt(1, 0, 0, 0))}T08:15:00`]);
175
+ assert.equal(wrongDay.status, 0, wrongDay.stderr);
176
+ assert.match(wrongDay.stdout, /に該当するターンが見つかりませんでした。/);
177
+
178
+ const invalid = box.run(['yesterday']);
179
+ assert.equal(invalid.status, 1);
180
+ assert.match(invalid.stderr, /時刻フォーマットが無効: yesterday/);
181
+ } finally {
182
+ rmSync(box.home, { recursive: true, force: true });
183
+ }
184
+ });