ctx-switch 2.0.6 → 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.
Files changed (2) hide show
  1. package/dist/index.mjs +142 -22
  2. 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`,
@@ -1322,6 +1337,10 @@ function compactText(text, maxChars = 800) {
1322
1337
  function unique(list) {
1323
1338
  return [...new Set(list.filter(Boolean))];
1324
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
+ }
1325
1344
  function extractFilePath2(input) {
1326
1345
  const value = input.file_path || input.path || input.target_file || input.filePath;
1327
1346
  return typeof value === "string" ? value : null;
@@ -1330,6 +1349,9 @@ function extractCommand2(input) {
1330
1349
  const value = input.command || input.cmd;
1331
1350
  return typeof value === "string" ? value : null;
1332
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
+ }
1333
1355
  function buildTargetGuidance(target) {
1334
1356
  switch (target) {
1335
1357
  case "claude":
@@ -1346,6 +1368,7 @@ function buildTargetGuidance(target) {
1346
1368
  }
1347
1369
  function isNoiseMessage(text) {
1348
1370
  const trimmed = text.trim().toLowerCase();
1371
+ if (isLocalCommandMarkup(trimmed)) return true;
1349
1372
  const normalized = trimmed.replace(/[^\p{L}\p{N}\s]/gu, " ").replace(/\s+/g, " ").trim();
1350
1373
  if (trimmed.length < 5) return true;
1351
1374
  const noise = [
@@ -1376,6 +1399,12 @@ function isNoiseMessage(text) {
1376
1399
  if (trimmed.startsWith("[request interrupted")) return true;
1377
1400
  return false;
1378
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
+ }
1379
1408
  function isReferentialMessage(text) {
1380
1409
  const normalized = text.trim().toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, " ").replace(/\s+/g, " ").trim();
1381
1410
  if (!normalized || normalized.length > 220) return false;
@@ -1386,11 +1415,11 @@ function isMetaQualityAssistantMessage(text) {
1386
1415
  return /\b(handoff|prompt)\b/.test(lower) && /\b(good|bad|better|worse|quality)\b/.test(lower);
1387
1416
  }
1388
1417
  function filterUserMessages(messages) {
1389
- 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));
1390
1419
  if (all.length <= 2) return all;
1391
1420
  const first = all[0];
1392
1421
  const last = all[all.length - 1];
1393
- const middle = all.slice(1, -1).filter((msg) => !isNoiseMessage(msg));
1422
+ const middle = all.slice(1, -1);
1394
1423
  return [first, ...middle, last];
1395
1424
  }
1396
1425
  function extractUnresolvedErrors(messages) {
@@ -1423,11 +1452,11 @@ function extractKeyDecisions(messages) {
1423
1452
  }
1424
1453
  return decisions.slice(-5);
1425
1454
  }
1426
- function findFocusedWindow(messages) {
1455
+ function findFocusedWindowFrom(messages, fromUserMessage) {
1427
1456
  if (messages.length === 0) {
1428
1457
  return { messages, sessionAppearsComplete: false };
1429
1458
  }
1430
- const substantiveUserIndexes = messages.map((message, index) => ({ message, index })).filter(({ message }) => message.role === "user" && message.content && !isNoiseMessage(message.content)).map(({ index }) => index);
1459
+ const substantiveUserIndexes = getSubstantiveUserIndexes(messages);
1431
1460
  if (substantiveUserIndexes.length === 0) {
1432
1461
  return { messages, sessionAppearsComplete: false };
1433
1462
  }
@@ -1437,7 +1466,9 @@ function findFocusedWindow(messages) {
1437
1466
  );
1438
1467
  const postToolUsers = substantiveUserIndexes.filter((index) => index > lastToolIndex);
1439
1468
  let startIndex = 0;
1440
- if (postToolUsers.length > 0) {
1469
+ if (typeof fromUserMessage === "number" && fromUserMessage > 0) {
1470
+ startIndex = substantiveUserIndexes[Math.min(fromUserMessage - 1, substantiveUserIndexes.length - 1)] ?? 0;
1471
+ } else if (postToolUsers.length > 0) {
1441
1472
  startIndex = postToolUsers[0];
1442
1473
  } else if (lastToolIndex >= 0) {
1443
1474
  startIndex = substantiveUserIndexes.filter((index) => index <= lastToolIndex).at(-1) ?? 0;
@@ -1445,7 +1476,7 @@ function findFocusedWindow(messages) {
1445
1476
  startIndex = substantiveUserIndexes.at(-1) ?? 0;
1446
1477
  }
1447
1478
  const startMessage = messages[startIndex];
1448
- if (startMessage?.role === "user" && isReferentialMessage(startMessage.content)) {
1479
+ if ((fromUserMessage === null || typeof fromUserMessage === "undefined") && startMessage?.role === "user" && isReferentialMessage(startMessage.content)) {
1449
1480
  const previousSubstantive = substantiveUserIndexes.filter((index) => index < startIndex).at(-1);
1450
1481
  if (typeof previousSubstantive === "number") {
1451
1482
  startIndex = previousSubstantive;
@@ -1453,10 +1484,25 @@ function findFocusedWindow(messages) {
1453
1484
  }
1454
1485
  const focused = messages.slice(startIndex);
1455
1486
  const hasToolActivity = focused.some((message) => message.role === "assistant" && message.toolCalls.length > 0);
1456
- const lastMessage = focused.at(-1);
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
+ });
1457
1497
  const sessionAppearsComplete = Boolean(lastMessage) && lastMessage.role === "assistant" && lastMessage.toolCalls.length === 0 && !hasToolActivity;
1458
1498
  return { messages: focused, sessionAppearsComplete };
1459
1499
  }
1500
+ function listSubstantiveUserMessages(messages) {
1501
+ return getSubstantiveUserIndexes(messages).map((messageIndex, index) => ({
1502
+ index: index + 1,
1503
+ text: messages[messageIndex]?.content.trim() || ""
1504
+ }));
1505
+ }
1460
1506
  function extractWorkSummary(messages) {
1461
1507
  const filesModified = /* @__PURE__ */ new Set();
1462
1508
  const commands = [];
@@ -1482,7 +1528,7 @@ function extractWorkSummary(messages) {
1482
1528
  function extractLastAssistantAnswer(messages) {
1483
1529
  for (let i = messages.length - 1; i >= 0; i--) {
1484
1530
  const message = messages[i];
1485
- if (message.role === "assistant" && message.content.trim()) {
1531
+ if (message.role === "assistant" && message.content.trim() && !isAssistantNoiseMessage(message.content)) {
1486
1532
  return compactText(message.content, 500);
1487
1533
  }
1488
1534
  }
@@ -1491,14 +1537,21 @@ function extractLastAssistantAnswer(messages) {
1491
1537
  function summarizeToolCall(toolCall) {
1492
1538
  const filePath = extractFilePath2(toolCall.input);
1493
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;
1494
1543
  if (filePath) return `${toolCall.tool} ${filePath}`;
1495
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)}`;
1496
1548
  return toolCall.tool;
1497
1549
  }
1498
1550
  function findLastActiveAssistant(messages) {
1499
1551
  for (let i = messages.length - 1; i >= 0; i--) {
1500
1552
  const message = messages[i];
1501
1553
  if (message.role !== "assistant") continue;
1554
+ if (message.content.trim() && isAssistantNoiseMessage(message.content)) continue;
1502
1555
  if (message.content.trim() || message.toolCalls.length > 0) {
1503
1556
  return message;
1504
1557
  }
@@ -1526,7 +1579,8 @@ function buildRemainingWorkHints({
1526
1579
  errors,
1527
1580
  work,
1528
1581
  focusFiles,
1529
- recentCommands
1582
+ recentCommands,
1583
+ lastAssistantAnswer
1530
1584
  }) {
1531
1585
  if (sessionAppearsComplete) return [];
1532
1586
  const hints = [];
@@ -1544,26 +1598,33 @@ function buildRemainingWorkHints({
1544
1598
  if (focusFiles.length > 0) {
1545
1599
  hints.push("Run `git diff --` on the active files to see the exact in-progress changes before editing further.");
1546
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
+ }
1547
1604
  if (hints.length === 0) {
1548
1605
  hints.push("Inspect the active files and run `git diff` to determine the next concrete implementation step.");
1549
1606
  }
1550
1607
  return hints;
1551
1608
  }
1552
1609
  function selectSessionHistoryMessages(focusedMessages, allMessages, sessionAppearsComplete) {
1553
- if (sessionAppearsComplete) return focusedMessages;
1554
- const hasAssistantActivity = focusedMessages.some(
1555
- (message) => message.role === "assistant" && (message.content.trim() || message.toolCalls.length > 0)
1556
- );
1557
- if (focusedMessages.length >= 3 && hasAssistantActivity) return focusedMessages;
1558
- const filtered = allMessages.filter((message) => {
1610
+ const sanitizeHistoryMessages = (messages) => messages.filter((message) => {
1559
1611
  if (message.role === "assistant" && message.content.trim() && isMetaQualityAssistantMessage(message.content)) {
1560
1612
  return false;
1561
1613
  }
1614
+ if (message.role === "assistant" && message.content.trim() && isAssistantNoiseMessage(message.content)) {
1615
+ return false;
1616
+ }
1562
1617
  if (message.role === "user" && message.content && isNoiseMessage(message.content)) {
1563
1618
  return false;
1564
1619
  }
1565
1620
  return Boolean(message.content.trim()) || message.toolCalls.length > 0;
1566
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);
1567
1628
  return filtered.slice(-8);
1568
1629
  }
1569
1630
  function buildSessionHistory(focusedMessages, allMessages, sessionAppearsComplete) {
@@ -1571,7 +1632,7 @@ function buildSessionHistory(focusedMessages, allMessages, sessionAppearsComplet
1571
1632
  const entries = historyMessages.map((message) => {
1572
1633
  const parts = [];
1573
1634
  if (message.content.trim()) {
1574
- if (message.role === "assistant" && isMetaQualityAssistantMessage(message.content)) {
1635
+ if (message.role === "assistant" && (isMetaQualityAssistantMessage(message.content) || isAssistantNoiseMessage(message.content))) {
1575
1636
  return null;
1576
1637
  }
1577
1638
  parts.push(compactText(message.content, 220));
@@ -1610,7 +1671,7 @@ function extractFocusFiles(ctx, work) {
1610
1671
  ]).slice(0, 6);
1611
1672
  }
1612
1673
  function buildRawPrompt(ctx, options = {}) {
1613
- const focused = findFocusedWindow(ctx.messages);
1674
+ const focused = findFocusedWindowFrom(ctx.messages, options.fromUserMessage);
1614
1675
  const userMessages = filterUserMessages(focused.messages);
1615
1676
  const errors = extractUnresolvedErrors(focused.messages);
1616
1677
  const decisions = extractKeyDecisions(focused.messages);
@@ -1624,7 +1685,8 @@ function buildRawPrompt(ctx, options = {}) {
1624
1685
  errors,
1625
1686
  work,
1626
1687
  focusFiles,
1627
- recentCommands
1688
+ recentCommands,
1689
+ lastAssistantAnswer
1628
1690
  });
1629
1691
  const sessionHistory = buildSessionHistory(focused.messages, ctx.messages, focused.sessionAppearsComplete);
1630
1692
  let prompt = "";
@@ -2128,6 +2190,9 @@ function formatRunSummary({
2128
2190
  lines.push(` Messages: ${ctx.messages.length}`);
2129
2191
  lines.push(` Files: ${ctx.filesModified.length} modified, ${ctx.filesRead.length} read`);
2130
2192
  lines.push(` Git: ${summarizeGitContext(ctx.gitContext)}`);
2193
+ if (options.fromUser) {
2194
+ lines.push(` Focus: user prompt #${options.fromUser}`);
2195
+ }
2131
2196
  lines.push(` Target: ${options.target}`);
2132
2197
  lines.push(` Mode: ${mode === "raw" ? "raw" : `refined via ${options.provider}${model ? ` (${model})` : ""}`}`);
2133
2198
  return lines.join("\n");
@@ -2279,9 +2344,50 @@ async function promptForSource() {
2279
2344
  });
2280
2345
  });
2281
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
+ }
2282
2388
  async function main(argv = process.argv.slice(2)) {
2283
2389
  const options = parseArgs(argv);
2284
- const pkgInfo = { name: "ctx-switch", version: "2.0.6" };
2390
+ const pkgInfo = { name: "ctx-switch", version: "2.0.7" };
2285
2391
  const ui = createTheme(process.stderr);
2286
2392
  if (options.help) {
2287
2393
  process.stdout.write(`${getHelpText(pkgInfo)}
@@ -2344,6 +2450,20 @@ async function main(argv = process.argv.slice(2)) {
2344
2450
  if (messages.length === 0) {
2345
2451
  fail(`Parsed zero usable messages from ${sessionPath}`);
2346
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
+ }
2347
2467
  ui.step("Capturing git context");
2348
2468
  const gitContext = getGitContext(cwd);
2349
2469
  const ctx = buildSessionContext({
@@ -2358,7 +2478,7 @@ async function main(argv = process.argv.slice(2)) {
2358
2478
  let activeModel = null;
2359
2479
  if (!options.refine) {
2360
2480
  ui.step("Building continuation prompt");
2361
- finalPrompt = buildRawPrompt(ctx, { target: options.target });
2481
+ finalPrompt = buildRawPrompt(ctx, { target: options.target, fromUserMessage });
2362
2482
  } else {
2363
2483
  const provider = options.provider;
2364
2484
  let apiKey = getApiKey({
@@ -2396,7 +2516,7 @@ async function main(argv = process.argv.slice(2)) {
2396
2516
  apiKey,
2397
2517
  model,
2398
2518
  systemPrompt: buildRefinementSystemPrompt(options.target),
2399
- userPrompt: buildRefinementDump(ctx, { target: options.target }),
2519
+ userPrompt: buildRefinementDump(ctx, { target: options.target, fromUserMessage }),
2400
2520
  timeoutMs: 0,
2401
2521
  onStatus: (status) => {
2402
2522
  if (!streamStarted) reporter.update(status);
@@ -2429,7 +2549,7 @@ async function main(argv = process.argv.slice(2)) {
2429
2549
  ui.note(`Provider detail: ${refined.rawError}`);
2430
2550
  }
2431
2551
  ui.note("Falling back to the raw structured prompt.");
2432
- finalPrompt = buildRawPrompt(ctx, { target: options.target });
2552
+ finalPrompt = buildRawPrompt(ctx, { target: options.target, fromUserMessage });
2433
2553
  }
2434
2554
  }
2435
2555
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ctx-switch",
3
- "version": "2.0.6",
3
+ "version": "2.0.7",
4
4
  "description": "Switch coding agents without losing context. Generate handoff prompts across Claude Code, Codex, and OpenCode.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",