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.
- package/.claude/commands/sc-detail.md +4 -1
- package/CHANGELOG.md +50 -1
- package/README.ja.md +19 -3
- package/README.md +46 -4
- package/bin/throughline.mjs +7 -4
- package/docs/adr/0034-project-recent-sessions-context.md +73 -0
- package/docs/adr/0035-observer-cursor-prefix-only.md +49 -0
- package/package.json +1 -1
- package/src/cli/handoff-context.mjs +60 -9
- package/src/cli/handoff-context.test.mjs +193 -0
- package/src/observer-turn-feed.mjs +2 -2
- package/src/observer-turn-feed.test.mjs +52 -2
- package/src/resume-context.mjs +272 -6
- package/src/resume-context.test.mjs +278 -0
- package/src/sc-detail.mjs +85 -43
- package/src/sc-detail.test.mjs +184 -0
|
@@ -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 || '')
|
|
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
|
-
*
|
|
37
|
-
*
|
|
43
|
+
* 時刻の指定を解釈する。範囲は `<開始>-<終了>`。終了の日付を省くと開始と同じ日になる。
|
|
44
|
+
* @returns {{ from: object, to: object } | null}
|
|
38
45
|
*/
|
|
39
|
-
function
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
91
|
-
if (!
|
|
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
|
|
102
|
-
.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
+
});
|