ctx-switch 2.0.5 → 2.0.7
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/dist/index.mjs +350 -31
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -32,6 +32,8 @@ function parseArgs(argv) {
|
|
|
32
32
|
refine: false,
|
|
33
33
|
help: false,
|
|
34
34
|
version: false,
|
|
35
|
+
pickUser: false,
|
|
36
|
+
fromUser: null,
|
|
35
37
|
session: null,
|
|
36
38
|
model: null,
|
|
37
39
|
provider: "openrouter",
|
|
@@ -59,6 +61,12 @@ function parseArgs(argv) {
|
|
|
59
61
|
case "--refine":
|
|
60
62
|
options.refine = true;
|
|
61
63
|
break;
|
|
64
|
+
case "--pick-user":
|
|
65
|
+
options.pickUser = true;
|
|
66
|
+
break;
|
|
67
|
+
case "--from-user":
|
|
68
|
+
options.fromUser = Number(requireValue(arg, args));
|
|
69
|
+
break;
|
|
62
70
|
case "--session":
|
|
63
71
|
options.session = requireValue(arg, args);
|
|
64
72
|
break;
|
|
@@ -122,6 +130,9 @@ function parseArgs(argv) {
|
|
|
122
130
|
if (!Number.isInteger(options.limit) || options.limit <= 0) {
|
|
123
131
|
throw new Error(`Invalid limit "${options.limit}". Expected a positive integer.`);
|
|
124
132
|
}
|
|
133
|
+
if (options.fromUser !== null && (!Number.isInteger(options.fromUser) || options.fromUser <= 0)) {
|
|
134
|
+
throw new Error(`Invalid --from-user value "${options.fromUser}". Expected a positive integer.`);
|
|
135
|
+
}
|
|
125
136
|
return options;
|
|
126
137
|
}
|
|
127
138
|
function getHelpText({ name, version }) {
|
|
@@ -142,6 +153,8 @@ function getHelpText({ name, version }) {
|
|
|
142
153
|
" -o, --output <file> Write the final prompt to a file",
|
|
143
154
|
" --source <name> Session source: claude, codex, opencode (interactive if omitted)",
|
|
144
155
|
" --session <id|path> Use a specific session file or session id",
|
|
156
|
+
" --pick-user Interactively choose the starting user prompt for preserved context",
|
|
157
|
+
" --from-user <n> Start preserved context from the nth substantive user prompt",
|
|
145
158
|
" --target <name> Prompt target: generic, claude, codex, cursor, chatgpt",
|
|
146
159
|
" -n, --limit <count> Limit rows for the sessions command (default: 10)",
|
|
147
160
|
"",
|
|
@@ -163,6 +176,8 @@ function getHelpText({ name, version }) {
|
|
|
163
176
|
` ${name} --source codex --target claude`,
|
|
164
177
|
` ${name} --source codex --target codex`,
|
|
165
178
|
` ${name} --source opencode`,
|
|
179
|
+
` ${name} --pick-user`,
|
|
180
|
+
` ${name} --from-user 2`,
|
|
166
181
|
` ${name} --refine --model openrouter/free`,
|
|
167
182
|
` ${name} --output ./handoff.md`,
|
|
168
183
|
` ${name} doctor`,
|
|
@@ -829,7 +844,10 @@ import { execFileSync as execFileSync2 } from "node:child_process";
|
|
|
829
844
|
import os4 from "node:os";
|
|
830
845
|
import path6 from "node:path";
|
|
831
846
|
var OPENCODE_DB_PATH = path6.join(os4.homedir(), ".local", "share", "opencode", "opencode.db");
|
|
832
|
-
function
|
|
847
|
+
function getOpenCodeDbPath() {
|
|
848
|
+
return process.env.CTX_SWITCH_OPENCODE_DB_PATH || OPENCODE_DB_PATH;
|
|
849
|
+
}
|
|
850
|
+
function runSqlite(query, dbPath = getOpenCodeDbPath()) {
|
|
833
851
|
try {
|
|
834
852
|
const stdout = execFileSync2("sqlite3", ["-json", dbPath, query], {
|
|
835
853
|
encoding: "utf8",
|
|
@@ -882,7 +900,31 @@ function resolveSessionPath3(selection, cwd) {
|
|
|
882
900
|
const match = sessions.find((s) => s.id === selection || s.id.includes(selection));
|
|
883
901
|
return match ? match.id : null;
|
|
884
902
|
}
|
|
903
|
+
function detectToolError(tool, status, output) {
|
|
904
|
+
if (status === "error") return true;
|
|
905
|
+
if (!output) return void 0;
|
|
906
|
+
if (/Process exited with code [1-9]\d*/.test(output)) return true;
|
|
907
|
+
if (/^npm ERR!/m.test(output)) return true;
|
|
908
|
+
if (/^(Error|TypeError|ReferenceError|SyntaxError):/m.test(output)) return true;
|
|
909
|
+
if (/^(bash|sh|zsh):/m.test(output)) return true;
|
|
910
|
+
if (tool === "read" || tool === "glob" || tool === "grep") return void 0;
|
|
911
|
+
return void 0;
|
|
912
|
+
}
|
|
913
|
+
function extractDelegatedSessionId(part) {
|
|
914
|
+
const metadataSessionId = part.state?.metadata?.sessionId;
|
|
915
|
+
if (typeof metadataSessionId === "string" && metadataSessionId.trim()) {
|
|
916
|
+
return metadataSessionId.trim();
|
|
917
|
+
}
|
|
918
|
+
const output = part.state?.output;
|
|
919
|
+
if (typeof output !== "string") return null;
|
|
920
|
+
const match = output.match(/\btask_id:\s*(ses_[^\s)]+)/);
|
|
921
|
+
return match?.[1] || null;
|
|
922
|
+
}
|
|
885
923
|
function parseSession3(sessionId) {
|
|
924
|
+
return parseSessionInternal(sessionId, /* @__PURE__ */ new Set());
|
|
925
|
+
}
|
|
926
|
+
function parseSessionInternal(sessionId, seenSessions) {
|
|
927
|
+
seenSessions.add(sessionId);
|
|
886
928
|
const messages = [];
|
|
887
929
|
const meta = {
|
|
888
930
|
cwd: null,
|
|
@@ -918,6 +960,7 @@ function parseSession3(sessionId) {
|
|
|
918
960
|
if (!role || role !== "user" && role !== "assistant") continue;
|
|
919
961
|
const textParts = [];
|
|
920
962
|
const toolCalls = [];
|
|
963
|
+
const delegatedAssistantMessages = [];
|
|
921
964
|
for (const partRow of partRows) {
|
|
922
965
|
let part;
|
|
923
966
|
try {
|
|
@@ -930,9 +973,17 @@ function parseSession3(sessionId) {
|
|
|
930
973
|
} else if (part.type === "tool" && part.tool) {
|
|
931
974
|
const input = part.state?.input || {};
|
|
932
975
|
const output = part.state?.output || "";
|
|
933
|
-
const isError = part.state?.status
|
|
976
|
+
const isError = detectToolError(part.tool, part.state?.status, output);
|
|
934
977
|
const toolName = normalizeToolName(part.tool);
|
|
935
978
|
const normalizedInput = normalizeInput(part.tool, input);
|
|
979
|
+
if (toolName === "task") {
|
|
980
|
+
const delegatedSessionId = extractDelegatedSessionId(part);
|
|
981
|
+
if (delegatedSessionId && !seenSessions.has(delegatedSessionId)) {
|
|
982
|
+
normalizedInput.delegated_session_id = delegatedSessionId;
|
|
983
|
+
const delegated = parseSessionInternal(delegatedSessionId, seenSessions);
|
|
984
|
+
delegatedAssistantMessages.push(...delegated.messages.filter((message) => message.role === "assistant"));
|
|
985
|
+
}
|
|
986
|
+
}
|
|
936
987
|
toolCalls.push({
|
|
937
988
|
id: part.callID || null,
|
|
938
989
|
tool: toolName,
|
|
@@ -950,11 +1001,15 @@ function parseSession3(sessionId) {
|
|
|
950
1001
|
toolCalls,
|
|
951
1002
|
timestamp: null
|
|
952
1003
|
});
|
|
1004
|
+
if (role === "assistant" && delegatedAssistantMessages.length > 0) {
|
|
1005
|
+
messages.push(...delegatedAssistantMessages);
|
|
1006
|
+
}
|
|
953
1007
|
}
|
|
954
1008
|
return { messages, meta };
|
|
955
1009
|
}
|
|
956
1010
|
function normalizeToolName(tool) {
|
|
957
1011
|
const mapping = {
|
|
1012
|
+
apply_patch: "apply_patch",
|
|
958
1013
|
read: "read",
|
|
959
1014
|
edit: "edit",
|
|
960
1015
|
write: "write",
|
|
@@ -970,10 +1025,14 @@ function normalizeToolName(tool) {
|
|
|
970
1025
|
return mapping[tool.toLowerCase()] || tool;
|
|
971
1026
|
}
|
|
972
1027
|
function normalizeInput(tool, input) {
|
|
973
|
-
|
|
974
|
-
|
|
1028
|
+
const normalized = { ...input };
|
|
1029
|
+
if (normalized.filePath && !normalized.file_path) {
|
|
1030
|
+
normalized.file_path = normalized.filePath;
|
|
1031
|
+
}
|
|
1032
|
+
if (tool === "apply_patch" && typeof normalized.patchText === "string" && !normalized.patch) {
|
|
1033
|
+
normalized.patch = normalized.patchText;
|
|
975
1034
|
}
|
|
976
|
-
return
|
|
1035
|
+
return normalized;
|
|
977
1036
|
}
|
|
978
1037
|
|
|
979
1038
|
// src/session.ts
|
|
@@ -1040,6 +1099,23 @@ function extractParsedCommands(input) {
|
|
|
1040
1099
|
if (!Array.isArray(parsed)) return [];
|
|
1041
1100
|
return parsed.filter((entry) => Boolean(entry) && typeof entry === "object");
|
|
1042
1101
|
}
|
|
1102
|
+
function tokenizeShellCommand(command) {
|
|
1103
|
+
const matches = command.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
|
|
1104
|
+
return matches.map((token) => token.replace(/^['"]|['"]$/g, ""));
|
|
1105
|
+
}
|
|
1106
|
+
function looksLikeFilePath(token) {
|
|
1107
|
+
if (!token || token.startsWith("-")) return false;
|
|
1108
|
+
if (/[|<>;$()]/.test(token)) return false;
|
|
1109
|
+
return token.includes("/") || token.includes(".");
|
|
1110
|
+
}
|
|
1111
|
+
function extractReadPathsFromCommand(command, cwd) {
|
|
1112
|
+
const tokens = tokenizeShellCommand(command.trim());
|
|
1113
|
+
if (tokens.length === 0) return [];
|
|
1114
|
+
const base = path7.basename(tokens[0]);
|
|
1115
|
+
const readCommands = /* @__PURE__ */ new Set(["cat", "head", "tail", "sed", "nl", "wc"]);
|
|
1116
|
+
if (!readCommands.has(base)) return [];
|
|
1117
|
+
return tokens.slice(1).filter((token) => looksLikeFilePath(token)).filter((token) => !/^\d+(,\d+)?p?$/.test(token)).map((token) => resolveSessionFilePath(token, cwd));
|
|
1118
|
+
}
|
|
1043
1119
|
function buildSessionContext({
|
|
1044
1120
|
messages,
|
|
1045
1121
|
meta,
|
|
@@ -1059,6 +1135,7 @@ function buildSessionContext({
|
|
|
1059
1135
|
const command = extractCommand(toolCall.input);
|
|
1060
1136
|
const patchPaths = extractPatchFilePaths(toolCall.input, cwd);
|
|
1061
1137
|
const parsedCommands = extractParsedCommands(toolCall.input);
|
|
1138
|
+
const commandReadPaths = command ? extractReadPathsFromCommand(command, cwd) : [];
|
|
1062
1139
|
if (filePath && /(edit|write|create|multi_edit)/.test(toolName)) {
|
|
1063
1140
|
filesModified.add(resolveSessionFilePath(filePath, cwd));
|
|
1064
1141
|
} else if (filePath && /(read|grep|glob|search)/.test(toolName)) {
|
|
@@ -1071,6 +1148,9 @@ function buildSessionContext({
|
|
|
1071
1148
|
if (parsedCommand.type !== "read" || typeof parsedCommand.path !== "string") continue;
|
|
1072
1149
|
filesRead.add(resolveSessionFilePath(parsedCommand.path, cwd));
|
|
1073
1150
|
}
|
|
1151
|
+
for (const readPath of commandReadPaths) {
|
|
1152
|
+
filesRead.add(readPath);
|
|
1153
|
+
}
|
|
1074
1154
|
if (command && /(bash|command|run|exec_command)/.test(toolName)) {
|
|
1075
1155
|
commands.push(command);
|
|
1076
1156
|
}
|
|
@@ -1257,6 +1337,10 @@ function compactText(text, maxChars = 800) {
|
|
|
1257
1337
|
function unique(list) {
|
|
1258
1338
|
return [...new Set(list.filter(Boolean))];
|
|
1259
1339
|
}
|
|
1340
|
+
function isLocalCommandMarkup(text) {
|
|
1341
|
+
const trimmed = text.trim().toLowerCase();
|
|
1342
|
+
return trimmed.includes("<local-command-caveat>") || trimmed.includes("<command-name>") || trimmed.includes("<command-message>") || trimmed.includes("<command-args>") || trimmed.includes("<local-command-stdout>");
|
|
1343
|
+
}
|
|
1260
1344
|
function extractFilePath2(input) {
|
|
1261
1345
|
const value = input.file_path || input.path || input.target_file || input.filePath;
|
|
1262
1346
|
return typeof value === "string" ? value : null;
|
|
@@ -1265,10 +1349,13 @@ function extractCommand2(input) {
|
|
|
1265
1349
|
const value = input.command || input.cmd;
|
|
1266
1350
|
return typeof value === "string" ? value : null;
|
|
1267
1351
|
}
|
|
1352
|
+
function getSubstantiveUserIndexes(messages) {
|
|
1353
|
+
return messages.map((message, index) => ({ message, index })).filter(({ message }) => message.role === "user" && message.content && !isNoiseMessage(message.content)).map(({ index }) => index);
|
|
1354
|
+
}
|
|
1268
1355
|
function buildTargetGuidance(target) {
|
|
1269
1356
|
switch (target) {
|
|
1270
1357
|
case "claude":
|
|
1271
|
-
return "The next agent is Claude Code. It should read the active files first,
|
|
1358
|
+
return "The next agent is Claude Code. It should read the active files first, inspect the current workspace with git commands, and continue the implementation or debugging directly.";
|
|
1272
1359
|
case "codex":
|
|
1273
1360
|
return "The next agent is Codex. It should inspect the current files first, avoid redoing completed work, and finish any remaining implementation or verification.";
|
|
1274
1361
|
case "cursor":
|
|
@@ -1276,11 +1363,12 @@ function buildTargetGuidance(target) {
|
|
|
1276
1363
|
case "chatgpt":
|
|
1277
1364
|
return "The next agent is ChatGPT. It should reason from the current workspace state, explain what remains, and provide explicit next actions.";
|
|
1278
1365
|
default:
|
|
1279
|
-
return "The next agent should read the active files first,
|
|
1366
|
+
return "The next agent should read the active files first, inspect the current workspace with git commands, continue the interrupted work directly, and avoid redoing completed steps.";
|
|
1280
1367
|
}
|
|
1281
1368
|
}
|
|
1282
1369
|
function isNoiseMessage(text) {
|
|
1283
1370
|
const trimmed = text.trim().toLowerCase();
|
|
1371
|
+
if (isLocalCommandMarkup(trimmed)) return true;
|
|
1284
1372
|
const normalized = trimmed.replace(/[^\p{L}\p{N}\s]/gu, " ").replace(/\s+/g, " ").trim();
|
|
1285
1373
|
if (trimmed.length < 5) return true;
|
|
1286
1374
|
const noise = [
|
|
@@ -1311,17 +1399,27 @@ function isNoiseMessage(text) {
|
|
|
1311
1399
|
if (trimmed.startsWith("[request interrupted")) return true;
|
|
1312
1400
|
return false;
|
|
1313
1401
|
}
|
|
1402
|
+
function isAssistantNoiseMessage(text) {
|
|
1403
|
+
const trimmed = text.trim().toLowerCase();
|
|
1404
|
+
if (!trimmed) return true;
|
|
1405
|
+
if (isLocalCommandMarkup(trimmed)) return true;
|
|
1406
|
+
return trimmed.includes("you're out of extra usage") || trimmed.includes("resets 3:30pm") || trimmed.includes("rate limit") || trimmed.includes("login interrupted");
|
|
1407
|
+
}
|
|
1314
1408
|
function isReferentialMessage(text) {
|
|
1315
1409
|
const normalized = text.trim().toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, " ").replace(/\s+/g, " ").trim();
|
|
1316
1410
|
if (!normalized || normalized.length > 220) return false;
|
|
1317
1411
|
return /^(ok|okay|alright|now|so)\b/.test(normalized) || /\b(it|that|again|better|same|continue|still|also|another|more)\b/.test(normalized);
|
|
1318
1412
|
}
|
|
1413
|
+
function isMetaQualityAssistantMessage(text) {
|
|
1414
|
+
const lower = text.toLowerCase();
|
|
1415
|
+
return /\b(handoff|prompt)\b/.test(lower) && /\b(good|bad|better|worse|quality)\b/.test(lower);
|
|
1416
|
+
}
|
|
1319
1417
|
function filterUserMessages(messages) {
|
|
1320
|
-
const all = messages.filter((m) => m.role === "user" && m.content).map((m) => m.content.trim());
|
|
1418
|
+
const all = messages.filter((m) => m.role === "user" && m.content).map((m) => m.content.trim()).filter((msg) => !isNoiseMessage(msg));
|
|
1321
1419
|
if (all.length <= 2) return all;
|
|
1322
1420
|
const first = all[0];
|
|
1323
1421
|
const last = all[all.length - 1];
|
|
1324
|
-
const middle = all.slice(1, -1)
|
|
1422
|
+
const middle = all.slice(1, -1);
|
|
1325
1423
|
return [first, ...middle, last];
|
|
1326
1424
|
}
|
|
1327
1425
|
function extractUnresolvedErrors(messages) {
|
|
@@ -1344,21 +1442,21 @@ function extractKeyDecisions(messages) {
|
|
|
1344
1442
|
const decisions = [];
|
|
1345
1443
|
for (const msg of messages) {
|
|
1346
1444
|
if (msg.role !== "assistant" || !msg.content) continue;
|
|
1347
|
-
|
|
1348
|
-
if (/\b(handoff|prompt)\b/.test(lower) && /\b(good|bad|better|worse|quality)\b/.test(lower)) {
|
|
1445
|
+
if (isMetaQualityAssistantMessage(msg.content)) {
|
|
1349
1446
|
continue;
|
|
1350
1447
|
}
|
|
1448
|
+
const lower = msg.content.toLowerCase();
|
|
1351
1449
|
if (/\b(root cause|the issue is|the problem is|caused by|failed because|failing because|need to)\b/.test(lower) || /\b(exposed|revealed|showed)\b.*\b(gap|issue|problem|bug)\b/.test(lower) || /\bmissing\b/.test(lower)) {
|
|
1352
1450
|
decisions.push(compactText(msg.content, 300));
|
|
1353
1451
|
}
|
|
1354
1452
|
}
|
|
1355
1453
|
return decisions.slice(-5);
|
|
1356
1454
|
}
|
|
1357
|
-
function
|
|
1455
|
+
function findFocusedWindowFrom(messages, fromUserMessage) {
|
|
1358
1456
|
if (messages.length === 0) {
|
|
1359
1457
|
return { messages, sessionAppearsComplete: false };
|
|
1360
1458
|
}
|
|
1361
|
-
const substantiveUserIndexes = messages
|
|
1459
|
+
const substantiveUserIndexes = getSubstantiveUserIndexes(messages);
|
|
1362
1460
|
if (substantiveUserIndexes.length === 0) {
|
|
1363
1461
|
return { messages, sessionAppearsComplete: false };
|
|
1364
1462
|
}
|
|
@@ -1368,7 +1466,9 @@ function findFocusedWindow(messages) {
|
|
|
1368
1466
|
);
|
|
1369
1467
|
const postToolUsers = substantiveUserIndexes.filter((index) => index > lastToolIndex);
|
|
1370
1468
|
let startIndex = 0;
|
|
1371
|
-
if (
|
|
1469
|
+
if (typeof fromUserMessage === "number" && fromUserMessage > 0) {
|
|
1470
|
+
startIndex = substantiveUserIndexes[Math.min(fromUserMessage - 1, substantiveUserIndexes.length - 1)] ?? 0;
|
|
1471
|
+
} else if (postToolUsers.length > 0) {
|
|
1372
1472
|
startIndex = postToolUsers[0];
|
|
1373
1473
|
} else if (lastToolIndex >= 0) {
|
|
1374
1474
|
startIndex = substantiveUserIndexes.filter((index) => index <= lastToolIndex).at(-1) ?? 0;
|
|
@@ -1376,7 +1476,7 @@ function findFocusedWindow(messages) {
|
|
|
1376
1476
|
startIndex = substantiveUserIndexes.at(-1) ?? 0;
|
|
1377
1477
|
}
|
|
1378
1478
|
const startMessage = messages[startIndex];
|
|
1379
|
-
if (startMessage?.role === "user" && isReferentialMessage(startMessage.content)) {
|
|
1479
|
+
if ((fromUserMessage === null || typeof fromUserMessage === "undefined") && startMessage?.role === "user" && isReferentialMessage(startMessage.content)) {
|
|
1380
1480
|
const previousSubstantive = substantiveUserIndexes.filter((index) => index < startIndex).at(-1);
|
|
1381
1481
|
if (typeof previousSubstantive === "number") {
|
|
1382
1482
|
startIndex = previousSubstantive;
|
|
@@ -1384,10 +1484,25 @@ function findFocusedWindow(messages) {
|
|
|
1384
1484
|
}
|
|
1385
1485
|
const focused = messages.slice(startIndex);
|
|
1386
1486
|
const hasToolActivity = focused.some((message) => message.role === "assistant" && message.toolCalls.length > 0);
|
|
1387
|
-
const lastMessage = focused.
|
|
1487
|
+
const lastMessage = [...focused].reverse().find((message) => {
|
|
1488
|
+
if (!message.content.trim() && message.toolCalls.length === 0) return false;
|
|
1489
|
+
if (message.role === "assistant" && message.content.trim() && isAssistantNoiseMessage(message.content)) {
|
|
1490
|
+
return false;
|
|
1491
|
+
}
|
|
1492
|
+
if (message.role === "user" && message.content.trim() && isNoiseMessage(message.content)) {
|
|
1493
|
+
return false;
|
|
1494
|
+
}
|
|
1495
|
+
return true;
|
|
1496
|
+
});
|
|
1388
1497
|
const sessionAppearsComplete = Boolean(lastMessage) && lastMessage.role === "assistant" && lastMessage.toolCalls.length === 0 && !hasToolActivity;
|
|
1389
1498
|
return { messages: focused, sessionAppearsComplete };
|
|
1390
1499
|
}
|
|
1500
|
+
function listSubstantiveUserMessages(messages) {
|
|
1501
|
+
return getSubstantiveUserIndexes(messages).map((messageIndex, index) => ({
|
|
1502
|
+
index: index + 1,
|
|
1503
|
+
text: messages[messageIndex]?.content.trim() || ""
|
|
1504
|
+
}));
|
|
1505
|
+
}
|
|
1391
1506
|
function extractWorkSummary(messages) {
|
|
1392
1507
|
const filesModified = /* @__PURE__ */ new Set();
|
|
1393
1508
|
const commands = [];
|
|
@@ -1413,12 +1528,124 @@ function extractWorkSummary(messages) {
|
|
|
1413
1528
|
function extractLastAssistantAnswer(messages) {
|
|
1414
1529
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1415
1530
|
const message = messages[i];
|
|
1416
|
-
if (message.role === "assistant" && message.content.trim()) {
|
|
1531
|
+
if (message.role === "assistant" && message.content.trim() && !isAssistantNoiseMessage(message.content)) {
|
|
1417
1532
|
return compactText(message.content, 500);
|
|
1418
1533
|
}
|
|
1419
1534
|
}
|
|
1420
1535
|
return null;
|
|
1421
1536
|
}
|
|
1537
|
+
function summarizeToolCall(toolCall) {
|
|
1538
|
+
const filePath = extractFilePath2(toolCall.input);
|
|
1539
|
+
const command = extractCommand2(toolCall.input);
|
|
1540
|
+
const url = typeof toolCall.input.url === "string" ? toolCall.input.url : null;
|
|
1541
|
+
const query = typeof toolCall.input.query === "string" ? toolCall.input.query : null;
|
|
1542
|
+
const description = typeof toolCall.input.description === "string" ? toolCall.input.description : typeof toolCall.input.prompt === "string" ? toolCall.input.prompt : null;
|
|
1543
|
+
if (filePath) return `${toolCall.tool} ${filePath}`;
|
|
1544
|
+
if (command) return `${toolCall.tool}: ${summarizeCommand(command)}`;
|
|
1545
|
+
if (url) return `${toolCall.tool}: ${compactText(url, 120)}`;
|
|
1546
|
+
if (description) return `${toolCall.tool}: ${compactText(description, 120)}`;
|
|
1547
|
+
if (query) return `${toolCall.tool}: ${compactText(query, 120)}`;
|
|
1548
|
+
return toolCall.tool;
|
|
1549
|
+
}
|
|
1550
|
+
function findLastActiveAssistant(messages) {
|
|
1551
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1552
|
+
const message = messages[i];
|
|
1553
|
+
if (message.role !== "assistant") continue;
|
|
1554
|
+
if (message.content.trim() && isAssistantNoiseMessage(message.content)) continue;
|
|
1555
|
+
if (message.content.trim() || message.toolCalls.length > 0) {
|
|
1556
|
+
return message;
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
return null;
|
|
1560
|
+
}
|
|
1561
|
+
function buildCurrentStatus(messages, errors, sessionAppearsComplete) {
|
|
1562
|
+
const lastAssistant = findLastActiveAssistant(messages);
|
|
1563
|
+
const lastStep = lastAssistant?.content?.trim() ? compactText(lastAssistant.content, 400) : null;
|
|
1564
|
+
const lastToolActions = lastAssistant ? lastAssistant.toolCalls.slice(-4).map(summarizeToolCall) : [];
|
|
1565
|
+
let status = "In progress";
|
|
1566
|
+
if (sessionAppearsComplete) {
|
|
1567
|
+
status = "Latest exchange complete";
|
|
1568
|
+
} else if (errors.length > 0) {
|
|
1569
|
+
status = "Blocked by unresolved errors";
|
|
1570
|
+
} else if (lastAssistant?.toolCalls.length) {
|
|
1571
|
+
status = "Mid-task after recent tool activity";
|
|
1572
|
+
} else if (lastAssistant?.content.trim()) {
|
|
1573
|
+
status = "Awaiting the next concrete action";
|
|
1574
|
+
}
|
|
1575
|
+
return { status, lastStep, lastToolActions };
|
|
1576
|
+
}
|
|
1577
|
+
function buildRemainingWorkHints({
|
|
1578
|
+
sessionAppearsComplete,
|
|
1579
|
+
errors,
|
|
1580
|
+
work,
|
|
1581
|
+
focusFiles,
|
|
1582
|
+
recentCommands,
|
|
1583
|
+
lastAssistantAnswer
|
|
1584
|
+
}) {
|
|
1585
|
+
if (sessionAppearsComplete) return [];
|
|
1586
|
+
const hints = [];
|
|
1587
|
+
if (errors.length > 0) {
|
|
1588
|
+
hints.push("Resolve the unresolved errors above before extending the implementation.");
|
|
1589
|
+
}
|
|
1590
|
+
if (work.filesModified.length > 0) {
|
|
1591
|
+
hints.push(`Inspect the in-progress changes in ${work.filesModified.map((filePath) => `\`${filePath}\``).join(", ")} and decide what still needs to be finished or verified.`);
|
|
1592
|
+
} else if (focusFiles.length > 0) {
|
|
1593
|
+
hints.push(`Start by reading ${focusFiles.map((filePath) => `\`${filePath}\``).join(", ")} to reconstruct the current working set.`);
|
|
1594
|
+
}
|
|
1595
|
+
if (recentCommands.length > 0) {
|
|
1596
|
+
hints.push("Rerun or extend the recent checks to confirm the current state before making further changes.");
|
|
1597
|
+
}
|
|
1598
|
+
if (focusFiles.length > 0) {
|
|
1599
|
+
hints.push("Run `git diff --` on the active files to see the exact in-progress changes before editing further.");
|
|
1600
|
+
}
|
|
1601
|
+
if (hints.length === 0 && lastAssistantAnswer) {
|
|
1602
|
+
hints.push("Continue from the last meaningful assistant answer above. This session appears to have stalled after planning or approval, not after code changes.");
|
|
1603
|
+
}
|
|
1604
|
+
if (hints.length === 0) {
|
|
1605
|
+
hints.push("Inspect the active files and run `git diff` to determine the next concrete implementation step.");
|
|
1606
|
+
}
|
|
1607
|
+
return hints;
|
|
1608
|
+
}
|
|
1609
|
+
function selectSessionHistoryMessages(focusedMessages, allMessages, sessionAppearsComplete) {
|
|
1610
|
+
const sanitizeHistoryMessages = (messages) => messages.filter((message) => {
|
|
1611
|
+
if (message.role === "assistant" && message.content.trim() && isMetaQualityAssistantMessage(message.content)) {
|
|
1612
|
+
return false;
|
|
1613
|
+
}
|
|
1614
|
+
if (message.role === "assistant" && message.content.trim() && isAssistantNoiseMessage(message.content)) {
|
|
1615
|
+
return false;
|
|
1616
|
+
}
|
|
1617
|
+
if (message.role === "user" && message.content && isNoiseMessage(message.content)) {
|
|
1618
|
+
return false;
|
|
1619
|
+
}
|
|
1620
|
+
return Boolean(message.content.trim()) || message.toolCalls.length > 0;
|
|
1621
|
+
});
|
|
1622
|
+
if (sessionAppearsComplete) return sanitizeHistoryMessages(focusedMessages);
|
|
1623
|
+
const hasAssistantActivity = focusedMessages.some(
|
|
1624
|
+
(message) => message.role === "assistant" && (message.content.trim() || message.toolCalls.length > 0)
|
|
1625
|
+
);
|
|
1626
|
+
if (focusedMessages.length >= 3 && hasAssistantActivity) return sanitizeHistoryMessages(focusedMessages);
|
|
1627
|
+
const filtered = sanitizeHistoryMessages(allMessages);
|
|
1628
|
+
return filtered.slice(-8);
|
|
1629
|
+
}
|
|
1630
|
+
function buildSessionHistory(focusedMessages, allMessages, sessionAppearsComplete) {
|
|
1631
|
+
const historyMessages = selectSessionHistoryMessages(focusedMessages, allMessages, sessionAppearsComplete);
|
|
1632
|
+
const entries = historyMessages.map((message) => {
|
|
1633
|
+
const parts = [];
|
|
1634
|
+
if (message.content.trim()) {
|
|
1635
|
+
if (message.role === "assistant" && (isMetaQualityAssistantMessage(message.content) || isAssistantNoiseMessage(message.content))) {
|
|
1636
|
+
return null;
|
|
1637
|
+
}
|
|
1638
|
+
parts.push(compactText(message.content, 220));
|
|
1639
|
+
}
|
|
1640
|
+
if (message.role === "assistant" && message.toolCalls.length > 0) {
|
|
1641
|
+
parts.push(`[tools] ${message.toolCalls.slice(-4).map(summarizeToolCall).join(", ")}`);
|
|
1642
|
+
}
|
|
1643
|
+
if (parts.length === 0) return null;
|
|
1644
|
+
return `${message.role.toUpperCase()}: ${parts.join(" | ")}`;
|
|
1645
|
+
}).filter((entry) => Boolean(entry));
|
|
1646
|
+
if (entries.length <= 8) return entries;
|
|
1647
|
+
return [entries[0], "...", ...entries.slice(-6)];
|
|
1648
|
+
}
|
|
1422
1649
|
function summarizeCommand(command) {
|
|
1423
1650
|
return compactText(command.replace(/\s+/g, " ").trim(), 140);
|
|
1424
1651
|
}
|
|
@@ -1444,7 +1671,7 @@ function extractFocusFiles(ctx, work) {
|
|
|
1444
1671
|
]).slice(0, 6);
|
|
1445
1672
|
}
|
|
1446
1673
|
function buildRawPrompt(ctx, options = {}) {
|
|
1447
|
-
const focused =
|
|
1674
|
+
const focused = findFocusedWindowFrom(ctx.messages, options.fromUserMessage);
|
|
1448
1675
|
const userMessages = filterUserMessages(focused.messages);
|
|
1449
1676
|
const errors = extractUnresolvedErrors(focused.messages);
|
|
1450
1677
|
const decisions = extractKeyDecisions(focused.messages);
|
|
@@ -1452,6 +1679,16 @@ function buildRawPrompt(ctx, options = {}) {
|
|
|
1452
1679
|
const focusFiles = extractFocusFiles(ctx, work);
|
|
1453
1680
|
const recentCommands = extractRecentCommands(work.commands);
|
|
1454
1681
|
const lastAssistantAnswer = extractLastAssistantAnswer(focused.messages);
|
|
1682
|
+
const currentStatus = buildCurrentStatus(focused.messages, errors, focused.sessionAppearsComplete);
|
|
1683
|
+
const remainingWorkHints = buildRemainingWorkHints({
|
|
1684
|
+
sessionAppearsComplete: focused.sessionAppearsComplete,
|
|
1685
|
+
errors,
|
|
1686
|
+
work,
|
|
1687
|
+
focusFiles,
|
|
1688
|
+
recentCommands,
|
|
1689
|
+
lastAssistantAnswer
|
|
1690
|
+
});
|
|
1691
|
+
const sessionHistory = buildSessionHistory(focused.messages, ctx.messages, focused.sessionAppearsComplete);
|
|
1455
1692
|
let prompt = "";
|
|
1456
1693
|
prompt += "# Task\n\n";
|
|
1457
1694
|
prompt += `Project: \`${ctx.sessionCwd}\`
|
|
@@ -1479,6 +1716,26 @@ function buildRawPrompt(ctx, options = {}) {
|
|
|
1479
1716
|
|
|
1480
1717
|
`;
|
|
1481
1718
|
}
|
|
1719
|
+
prompt += "## Current Status\n\n";
|
|
1720
|
+
prompt += `- Status: ${currentStatus.status}
|
|
1721
|
+
`;
|
|
1722
|
+
if (currentStatus.lastStep) {
|
|
1723
|
+
prompt += `- Last active step: ${currentStatus.lastStep}
|
|
1724
|
+
`;
|
|
1725
|
+
}
|
|
1726
|
+
if (currentStatus.lastToolActions.length > 0) {
|
|
1727
|
+
prompt += `- Last tool actions: ${currentStatus.lastToolActions.join(", ")}
|
|
1728
|
+
`;
|
|
1729
|
+
}
|
|
1730
|
+
prompt += "\n";
|
|
1731
|
+
if (sessionHistory.length > 0) {
|
|
1732
|
+
prompt += "## Session History\n\n";
|
|
1733
|
+
for (const entry of sessionHistory) {
|
|
1734
|
+
prompt += `- ${entry}
|
|
1735
|
+
`;
|
|
1736
|
+
}
|
|
1737
|
+
prompt += "\n";
|
|
1738
|
+
}
|
|
1482
1739
|
if (errors.length > 0) {
|
|
1483
1740
|
prompt += "## DO NOT REPEAT \u2014 Unresolved Errors\n\n";
|
|
1484
1741
|
prompt += "These errors occurred and were NOT fixed. Avoid the same approaches.\n\n";
|
|
@@ -1531,6 +1788,14 @@ function buildRawPrompt(ctx, options = {}) {
|
|
|
1531
1788
|
prompt += "## Read These Files First\n\n";
|
|
1532
1789
|
for (const filePath of focusFiles) {
|
|
1533
1790
|
prompt += `- \`${filePath}\`
|
|
1791
|
+
`;
|
|
1792
|
+
}
|
|
1793
|
+
prompt += "\n";
|
|
1794
|
+
}
|
|
1795
|
+
if (remainingWorkHints.length > 0) {
|
|
1796
|
+
prompt += "## Likely Remaining Work\n\n";
|
|
1797
|
+
for (const hint of remainingWorkHints) {
|
|
1798
|
+
prompt += `- ${hint}
|
|
1534
1799
|
`;
|
|
1535
1800
|
}
|
|
1536
1801
|
prompt += "\n";
|
|
@@ -1541,12 +1806,6 @@ function buildRawPrompt(ctx, options = {}) {
|
|
|
1541
1806
|
if (git.status) {
|
|
1542
1807
|
prompt += "```\n" + git.status + "\n```\n\n";
|
|
1543
1808
|
}
|
|
1544
|
-
if (git.staged.diff) {
|
|
1545
|
-
prompt += "**Staged diff:**\n```diff\n" + git.staged.diff + "\n```\n\n";
|
|
1546
|
-
}
|
|
1547
|
-
if (git.unstaged.diff) {
|
|
1548
|
-
prompt += "**Unstaged diff:**\n```diff\n" + git.unstaged.diff + "\n```\n\n";
|
|
1549
|
-
}
|
|
1550
1809
|
if (git.untracked.length > 0) {
|
|
1551
1810
|
const shown = git.untracked.slice(0, 6);
|
|
1552
1811
|
prompt += "**Untracked files:**\n";
|
|
@@ -1577,11 +1836,13 @@ function buildRawPrompt(ctx, options = {}) {
|
|
|
1577
1836
|
if (errors.length > 0) {
|
|
1578
1837
|
prompt += "2. **Check the errors above** \u2014 do NOT repeat failed approaches. Try a different strategy.\n";
|
|
1579
1838
|
}
|
|
1580
|
-
prompt += `${errors.length > 0 ? "3" : "2"}. **
|
|
1839
|
+
prompt += `${errors.length > 0 ? "3" : "2"}. **Inspect the workspace state explicitly** \u2014 run \`git status --short\`, \`git diff --stat\`, and \`git diff -- ${focusFiles.length > 0 ? focusFiles.slice(0, 4).join(" ") : "."}\` before changing code.
|
|
1581
1840
|
`;
|
|
1582
|
-
prompt += `${errors.length > 0 ? "4" : "3"}. **
|
|
1841
|
+
prompt += `${errors.length > 0 ? "4" : "3"}. **Identify what's done vs what remains** \u2014 use the Current Status, Session History, Likely Remaining Work, recent commands, active files, and git state above as the source of truth for the current thread.
|
|
1583
1842
|
`;
|
|
1584
|
-
prompt += `${errors.length > 0 ? "5" : "4"}. **
|
|
1843
|
+
prompt += `${errors.length > 0 ? "5" : "4"}. **Continue from the last active step** \u2014 if the stop point is still ambiguous, inspect the read-first files and rerun the recent commands before changing code.
|
|
1844
|
+
`;
|
|
1845
|
+
prompt += `${errors.length > 0 ? "6" : "5"}. **Verify** \u2014 rerun or extend the relevant commands/checks above to confirm everything works.
|
|
1585
1846
|
`;
|
|
1586
1847
|
}
|
|
1587
1848
|
return prompt;
|
|
@@ -1929,6 +2190,9 @@ function formatRunSummary({
|
|
|
1929
2190
|
lines.push(` Messages: ${ctx.messages.length}`);
|
|
1930
2191
|
lines.push(` Files: ${ctx.filesModified.length} modified, ${ctx.filesRead.length} read`);
|
|
1931
2192
|
lines.push(` Git: ${summarizeGitContext(ctx.gitContext)}`);
|
|
2193
|
+
if (options.fromUser) {
|
|
2194
|
+
lines.push(` Focus: user prompt #${options.fromUser}`);
|
|
2195
|
+
}
|
|
1932
2196
|
lines.push(` Target: ${options.target}`);
|
|
1933
2197
|
lines.push(` Mode: ${mode === "raw" ? "raw" : `refined via ${options.provider}${model ? ` (${model})` : ""}`}`);
|
|
1934
2198
|
return lines.join("\n");
|
|
@@ -2080,9 +2344,50 @@ async function promptForSource() {
|
|
|
2080
2344
|
});
|
|
2081
2345
|
});
|
|
2082
2346
|
}
|
|
2347
|
+
function summarizePromptChoice(text) {
|
|
2348
|
+
return text.replace(/\s+/g, " ").trim().slice(0, 100);
|
|
2349
|
+
}
|
|
2350
|
+
async function promptForUserStart(messages) {
|
|
2351
|
+
const userPrompts = listSubstantiveUserMessages(messages);
|
|
2352
|
+
if (userPrompts.length === 0) {
|
|
2353
|
+
return null;
|
|
2354
|
+
}
|
|
2355
|
+
if (userPrompts.length === 1) {
|
|
2356
|
+
process.stderr.write("\nOnly one substantive user prompt was found. Using it automatically.\n");
|
|
2357
|
+
return 1;
|
|
2358
|
+
}
|
|
2359
|
+
const rl = readline.createInterface({
|
|
2360
|
+
input: process.stdin,
|
|
2361
|
+
output: process.stderr
|
|
2362
|
+
});
|
|
2363
|
+
process.stderr.write("\nSelect the user prompt to preserve context from:\n\n");
|
|
2364
|
+
for (const prompt of userPrompts) {
|
|
2365
|
+
process.stderr.write(` ${prompt.index}) ${summarizePromptChoice(prompt.text)}
|
|
2366
|
+
`);
|
|
2367
|
+
}
|
|
2368
|
+
process.stderr.write("\n");
|
|
2369
|
+
return new Promise((resolve) => {
|
|
2370
|
+
rl.question(`Enter choice (1-${userPrompts.length}): `, (answer) => {
|
|
2371
|
+
rl.close();
|
|
2372
|
+
const trimmed = answer.trim();
|
|
2373
|
+
if (!trimmed) {
|
|
2374
|
+
process.stderr.write("Invalid choice, using automatic focus.\n");
|
|
2375
|
+
resolve(null);
|
|
2376
|
+
return;
|
|
2377
|
+
}
|
|
2378
|
+
const idx = Number.parseInt(trimmed, 10);
|
|
2379
|
+
if (idx >= 1 && idx <= userPrompts.length) {
|
|
2380
|
+
resolve(idx);
|
|
2381
|
+
} else {
|
|
2382
|
+
process.stderr.write("Invalid choice, using automatic focus.\n");
|
|
2383
|
+
resolve(null);
|
|
2384
|
+
}
|
|
2385
|
+
});
|
|
2386
|
+
});
|
|
2387
|
+
}
|
|
2083
2388
|
async function main(argv = process.argv.slice(2)) {
|
|
2084
2389
|
const options = parseArgs(argv);
|
|
2085
|
-
const pkgInfo = { name: "ctx-switch", version: "2.0.
|
|
2390
|
+
const pkgInfo = { name: "ctx-switch", version: "2.0.7" };
|
|
2086
2391
|
const ui = createTheme(process.stderr);
|
|
2087
2392
|
if (options.help) {
|
|
2088
2393
|
process.stdout.write(`${getHelpText(pkgInfo)}
|
|
@@ -2145,6 +2450,20 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
2145
2450
|
if (messages.length === 0) {
|
|
2146
2451
|
fail(`Parsed zero usable messages from ${sessionPath}`);
|
|
2147
2452
|
}
|
|
2453
|
+
let fromUserMessage = options.fromUser;
|
|
2454
|
+
const userPrompts = listSubstantiveUserMessages(messages);
|
|
2455
|
+
if (fromUserMessage !== null && fromUserMessage > userPrompts.length) {
|
|
2456
|
+
fail(
|
|
2457
|
+
`Requested --from-user ${fromUserMessage}, but only ${userPrompts.length} substantive user prompt(s) were found.`,
|
|
2458
|
+
{
|
|
2459
|
+
suggestions: ["Run `ctx-switch --pick-user` to choose interactively.", "Omit `--from-user` to use automatic focus."]
|
|
2460
|
+
}
|
|
2461
|
+
);
|
|
2462
|
+
}
|
|
2463
|
+
if (options.pickUser && process.stdin.isTTY) {
|
|
2464
|
+
ui.step("Choosing preserved context start");
|
|
2465
|
+
fromUserMessage = await promptForUserStart(messages);
|
|
2466
|
+
}
|
|
2148
2467
|
ui.step("Capturing git context");
|
|
2149
2468
|
const gitContext = getGitContext(cwd);
|
|
2150
2469
|
const ctx = buildSessionContext({
|
|
@@ -2159,7 +2478,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
2159
2478
|
let activeModel = null;
|
|
2160
2479
|
if (!options.refine) {
|
|
2161
2480
|
ui.step("Building continuation prompt");
|
|
2162
|
-
finalPrompt = buildRawPrompt(ctx, { target: options.target });
|
|
2481
|
+
finalPrompt = buildRawPrompt(ctx, { target: options.target, fromUserMessage });
|
|
2163
2482
|
} else {
|
|
2164
2483
|
const provider = options.provider;
|
|
2165
2484
|
let apiKey = getApiKey({
|
|
@@ -2197,7 +2516,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
2197
2516
|
apiKey,
|
|
2198
2517
|
model,
|
|
2199
2518
|
systemPrompt: buildRefinementSystemPrompt(options.target),
|
|
2200
|
-
userPrompt: buildRefinementDump(ctx, { target: options.target }),
|
|
2519
|
+
userPrompt: buildRefinementDump(ctx, { target: options.target, fromUserMessage }),
|
|
2201
2520
|
timeoutMs: 0,
|
|
2202
2521
|
onStatus: (status) => {
|
|
2203
2522
|
if (!streamStarted) reporter.update(status);
|
|
@@ -2230,7 +2549,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
2230
2549
|
ui.note(`Provider detail: ${refined.rawError}`);
|
|
2231
2550
|
}
|
|
2232
2551
|
ui.note("Falling back to the raw structured prompt.");
|
|
2233
|
-
finalPrompt = buildRawPrompt(ctx, { target: options.target });
|
|
2552
|
+
finalPrompt = buildRawPrompt(ctx, { target: options.target, fromUserMessage });
|
|
2234
2553
|
}
|
|
2235
2554
|
}
|
|
2236
2555
|
}
|
package/package.json
CHANGED