@ai-matrx/coding-sessions 0.1.165 → 0.2.0

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.
@@ -1149,6 +1149,9 @@ var PROVIDER_LABEL = {
1149
1149
  claude: "Claude Code",
1150
1150
  codex: "Codex"
1151
1151
  };
1152
+ function chatKey(provider, providerSessionId, sessionId) {
1153
+ return providerSessionId ? `${provider}:${providerSessionId}` : `session:${sessionId ?? ""}`;
1154
+ }
1152
1155
  var TERMINAL_RUN_STATUSES = ["completed", "stopped", "failed"];
1153
1156
  function isTerminalRunStatus(status) {
1154
1157
  return TERMINAL_RUN_STATUSES.includes(status);
@@ -1463,6 +1466,78 @@ var import_promises4 = __toESM(require("fs/promises"), 1);
1463
1466
  var import_node_os3 = __toESM(require("os"), 1);
1464
1467
  var import_node_path5 = __toESM(require("path"), 1);
1465
1468
 
1469
+ // src/sessions/titles.ts
1470
+ var TITLE_MAX = 300;
1471
+ var BRIDGE_PLACEHOLDER = /^AI Matrx · [a-z-]+$/;
1472
+ var TITLE_PRECEDENCE = [
1473
+ /** The person's rename inside Matrx (Coding Sessions' preference). */
1474
+ "override",
1475
+ /** What the coding app's own sidebar shows: Claude's record `title` (renames included); Codex's thread name; Cursor's name. */
1476
+ "app",
1477
+ /** The title the app last showed, remembered by the claude-accounts engine after the record went away. */
1478
+ "app-remembered",
1479
+ /** Claude Code transcript entries (the last one of each kind that is not the bridge placeholder). */
1480
+ "custom-title",
1481
+ "ai-title",
1482
+ "agent-name",
1483
+ "summary",
1484
+ /** The person's first typed message (Codex: the thread's title / first user message). */
1485
+ "first-prompt",
1486
+ "last-prompt"
1487
+ ];
1488
+ var STAMPABLE = /* @__PURE__ */ new Set(["custom-title", "ai-title", "agent-name"]);
1489
+ function isBridgePlaceholder(value) {
1490
+ return typeof value === "string" && BRIDGE_PLACEHOLDER.test(value.trim());
1491
+ }
1492
+ function cleanTitle(value) {
1493
+ if (typeof value !== "string") return null;
1494
+ const line = value.split(/\r?\n/).map((l) => l.trim()).find((l) => l.length > 0);
1495
+ if (!line) return null;
1496
+ return line.length > TITLE_MAX ? `${line.slice(0, TITLE_MAX - 1)}\u2026` : line;
1497
+ }
1498
+ function resolveSessionTitle(sources) {
1499
+ for (const source of TITLE_PRECEDENCE) {
1500
+ const raw = sources[source];
1501
+ if (STAMPABLE.has(source) && isBridgePlaceholder(raw)) continue;
1502
+ const title = cleanTitle(raw);
1503
+ if (title) return { title, source };
1504
+ }
1505
+ return null;
1506
+ }
1507
+ function claudeTranscriptTitleSources(head, tail = []) {
1508
+ const out = {};
1509
+ const take = (source, value) => {
1510
+ if (typeof value !== "string" || !value.trim()) return;
1511
+ if (STAMPABLE.has(source) && isBridgePlaceholder(value)) return;
1512
+ out[source] = value;
1513
+ };
1514
+ const normalizer = createClaudeNormalizer();
1515
+ let firstPrompt = null;
1516
+ const scan = (entries, findPrompt) => {
1517
+ for (const entry of entries) {
1518
+ if (typeof entry !== "object" || entry === null) continue;
1519
+ const e = entry;
1520
+ if (e.type === "custom-title") take("custom-title", e.customTitle);
1521
+ else if (e.type === "ai-title") take("ai-title", e.aiTitle);
1522
+ else if (e.type === "agent-name") take("agent-name", e.agentName);
1523
+ else if (e.type === "summary") take("summary", e.summary);
1524
+ else if (e.type === "last-prompt") take("last-prompt", e.lastPrompt);
1525
+ else if (findPrompt && firstPrompt === null) {
1526
+ for (const item of normalizer.push(entry)) {
1527
+ if (item.kind === "user" && item.text.trim()) {
1528
+ firstPrompt = item.text;
1529
+ break;
1530
+ }
1531
+ }
1532
+ }
1533
+ }
1534
+ };
1535
+ scan(head, true);
1536
+ scan(tail, false);
1537
+ if (firstPrompt !== null) out["first-prompt"] = firstPrompt;
1538
+ return out;
1539
+ }
1540
+
1466
1541
  // src/engine/activity.ts
1467
1542
  var import_node_child_process2 = require("child_process");
1468
1543
  var RECENT_WRITE_MS = 2 * 60 * 1e3;
@@ -1528,11 +1603,17 @@ function describeActivity(input) {
1528
1603
  }
1529
1604
 
1530
1605
  // src/engine/files.ts
1606
+ function codexHome(dir) {
1607
+ return {
1608
+ sessions: import_node_path5.default.join(dir, "sessions"),
1609
+ archived: import_node_path5.default.join(dir, "archived_sessions"),
1610
+ index: import_node_path5.default.join(dir, "session_index.jsonl")
1611
+ };
1612
+ }
1531
1613
  function defaultProviderHomes(home = import_node_os3.default.homedir()) {
1532
1614
  return {
1533
1615
  claudeProjects: import_node_path5.default.join(home, ".claude", "projects"),
1534
- codexSessions: import_node_path5.default.join(home, ".codex", "sessions"),
1535
- codexIndex: import_node_path5.default.join(home, ".codex", "session_index.jsonl")
1616
+ codex: [codexHome(import_node_path5.default.join(home, ".codex"))]
1536
1617
  };
1537
1618
  }
1538
1619
  var UUIDISH = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;
@@ -1566,7 +1647,7 @@ async function claudeCandidates(root) {
1566
1647
  }
1567
1648
  return out;
1568
1649
  }
1569
- async function codexCandidates(root) {
1650
+ async function codexTree(root) {
1570
1651
  const out = [];
1571
1652
  for (const year of await safeReaddir(root)) {
1572
1653
  if (!year.isDirectory()) continue;
@@ -1574,17 +1655,23 @@ async function codexCandidates(root) {
1574
1655
  if (!month.isDirectory()) continue;
1575
1656
  for (const day of await safeReaddir(import_node_path5.default.join(root, year.name, month.name))) {
1576
1657
  if (!day.isDirectory()) continue;
1577
- const dir = import_node_path5.default.join(root, year.name, month.name, day.name);
1578
- const entries = await safeReaddir(dir);
1579
- const stats = await Promise.all(
1580
- entries.filter((e) => e.isFile() && e.name.startsWith("rollout-") && e.name.endsWith(".jsonl")).map((e) => statCandidate("codex", import_node_path5.default.join(dir, e.name)))
1581
- );
1582
- for (const s of stats) if (s) out.push(s);
1658
+ out.push(...await codexFlat(import_node_path5.default.join(root, year.name, month.name, day.name), false));
1583
1659
  }
1584
1660
  }
1585
1661
  }
1586
1662
  return out;
1587
1663
  }
1664
+ async function codexFlat(dir, archived) {
1665
+ const entries = await safeReaddir(dir);
1666
+ const stats = await Promise.all(
1667
+ entries.filter((e) => e.isFile() && e.name.startsWith("rollout-") && e.name.endsWith(".jsonl")).map((e) => statCandidate("codex", import_node_path5.default.join(dir, e.name)))
1668
+ );
1669
+ return stats.filter((s) => s !== null).map((s) => archived ? { ...s, archived: true } : s);
1670
+ }
1671
+ async function codexCandidates(homes) {
1672
+ const lists = await Promise.all(homes.flatMap((h) => [codexTree(h.sessions), codexFlat(h.archived, true)]));
1673
+ return lists.flat();
1674
+ }
1588
1675
  async function readSlice(file, start, length) {
1589
1676
  const handle = await import_promises4.default.open(file, "r");
1590
1677
  try {
@@ -1606,56 +1693,38 @@ function completeLines(text, droppedFirst) {
1606
1693
  }
1607
1694
  var HEAD_BYTES = 256 * 1024;
1608
1695
  var TAIL_BYTES = 128 * 1024;
1609
- function oneLine(text, max = 140) {
1610
- const flat = text.replace(/\s+/g, " ").trim();
1611
- return flat.length > max ? `${flat.slice(0, max - 1)}\u2026` : flat;
1612
- }
1613
1696
  async function describeClaude(candidate) {
1614
1697
  const head = parseJsonl(completeLines(await readSlice(candidate.file, 0, HEAD_BYTES), false));
1615
1698
  const tailStart = Math.max(0, candidate.size - TAIL_BYTES);
1616
1699
  const tail = tailStart > 0 ? parseJsonl(completeLines(await readSlice(candidate.file, tailStart, TAIL_BYTES), true)) : head;
1617
1700
  let cwd = null;
1618
- let firstPrompt = null;
1619
1701
  let entrypoint = null;
1620
- const normalizer = createClaudeNormalizer();
1621
1702
  for (const entry of head) {
1622
1703
  if (typeof entry !== "object" || entry === null) continue;
1623
1704
  const e = entry;
1624
1705
  if (!cwd && typeof e.cwd === "string") cwd = e.cwd;
1625
1706
  if (!entrypoint && typeof e.entrypoint === "string") entrypoint = e.entrypoint;
1626
- if (firstPrompt === null) {
1627
- for (const item of normalizer.push(entry)) {
1628
- if (item.kind === "user") {
1629
- firstPrompt = item.text;
1630
- break;
1631
- }
1632
- }
1633
- }
1634
- if (cwd && firstPrompt !== null) break;
1707
+ if (cwd && entrypoint) break;
1635
1708
  }
1636
- const titles = { custom: null, ai: null, agent: null, summary: null, last: null };
1637
- for (const entry of [...head, ...tail]) {
1638
- if (typeof entry !== "object" || entry === null) continue;
1639
- const e = entry;
1640
- if (e.type === "custom-title" && typeof e.customTitle === "string") titles.custom = e.customTitle;
1641
- if (e.type === "ai-title" && typeof e.aiTitle === "string") titles.ai = e.aiTitle;
1642
- if (e.type === "agent-name" && typeof e.agentName === "string") titles.agent = e.agentName;
1643
- if (e.type === "summary" && typeof e.summary === "string") titles.summary = e.summary;
1644
- if (e.type === "last-prompt" && typeof e.lastPrompt === "string") titles.last = e.lastPrompt;
1645
- }
1646
- const title = titles.custom ?? titles.ai ?? titles.agent ?? titles.summary ?? firstPrompt ?? titles.last;
1647
- if (!title) return null;
1709
+ const resolved = resolveSessionTitle(claudeTranscriptTitleSources(head, tailStart > 0 ? tail : []));
1710
+ if (!resolved) return null;
1648
1711
  return {
1649
1712
  provider: "claude",
1650
1713
  providerSessionId: candidate.id,
1651
1714
  cwd,
1652
- title: oneLine(title),
1715
+ title: resolved.title,
1716
+ titleSource: resolved.source,
1653
1717
  updatedAt: new Date(candidate.mtimeMs).toISOString(),
1654
1718
  source: entrypoint,
1655
1719
  activeElsewhere: null
1656
1720
  };
1657
1721
  }
1658
- async function readCodexIndex(file) {
1722
+ async function readCodexIndex(files) {
1723
+ const names = /* @__PURE__ */ new Map();
1724
+ for (const file of files) for (const [id, name] of await readCodexIndexFile(file)) if (!names.has(id)) names.set(id, name);
1725
+ return names;
1726
+ }
1727
+ async function readCodexIndexFile(file) {
1659
1728
  const names = /* @__PURE__ */ new Map();
1660
1729
  let text;
1661
1730
  try {
@@ -1701,13 +1770,15 @@ async function describeCodex(candidate, names) {
1701
1770
  }
1702
1771
  }
1703
1772
  if (isSubAgent) return null;
1704
- const title = names.get(candidate.id) ?? firstPrompt;
1705
- if (!title) return null;
1773
+ const resolved = resolveSessionTitle({ app: names.get(candidate.id), "first-prompt": firstPrompt });
1774
+ if (!resolved) return null;
1706
1775
  return {
1707
1776
  provider: "codex",
1708
1777
  providerSessionId: candidate.id,
1709
1778
  cwd,
1710
- title: oneLine(title),
1779
+ title: resolved.title,
1780
+ titleSource: resolved.source,
1781
+ ...candidate.archived ? { archived: true } : {},
1711
1782
  updatedAt: new Date(candidate.mtimeMs).toISOString(),
1712
1783
  source,
1713
1784
  activeElsewhere: null
@@ -1752,8 +1823,8 @@ async function discoverLocalSessions(options = {}) {
1752
1823
  const homes = options.homes ?? defaultProviderHomes();
1753
1824
  const [claude, codex, names] = await Promise.all([
1754
1825
  claudeCandidates(homes.claudeProjects),
1755
- codexCandidates(homes.codexSessions),
1756
- readCodexIndex(homes.codexIndex)
1826
+ codexCandidates(homes.codex),
1827
+ readCodexIndex(homes.codex.map((h) => h.index))
1757
1828
  ]);
1758
1829
  const candidates = [...claude, ...codex].sort((a, b) => b.mtimeMs - a.mtimeMs);
1759
1830
  const processes = await listProcesses();
@@ -1773,7 +1844,7 @@ async function discoverLocalSessions(options = {}) {
1773
1844
  }
1774
1845
  async function findSessionFile(provider, providerSessionId, homes = defaultProviderHomes()) {
1775
1846
  if (!/^[0-9a-f-]{36}$/i.test(providerSessionId)) return null;
1776
- const candidates = provider === "claude" ? await claudeCandidates(homes.claudeProjects) : await codexCandidates(homes.codexSessions);
1847
+ const candidates = provider === "claude" ? await claudeCandidates(homes.claudeProjects) : await codexCandidates(homes.codex);
1777
1848
  const hits = candidates.filter((c) => c.id === providerSessionId).sort((a, b) => b.mtimeMs - a.mtimeMs);
1778
1849
  return hits[0]?.file ?? null;
1779
1850
  }
@@ -1825,7 +1896,7 @@ async function readSessionFile(provider, file, endOffset, maxBytes = HISTORY_BUD
1825
1896
  async function listSessionFiles(homes = defaultProviderHomes()) {
1826
1897
  const [claude, codex] = await Promise.all([
1827
1898
  claudeCandidates(homes.claudeProjects),
1828
- codexCandidates(homes.codexSessions)
1899
+ codexCandidates(homes.codex)
1829
1900
  ]);
1830
1901
  return [...claude, ...codex].sort((a, b) => b.mtimeMs - a.mtimeMs);
1831
1902
  }
@@ -1868,7 +1939,7 @@ var PreferenceStore = class {
1868
1939
  if (!key || key.length > 200) throw new Error("That chat can't be found.");
1869
1940
  const next = { ...this.data[key] };
1870
1941
  if ("title" in patch) {
1871
- const title = typeof patch.title === "string" ? patch.title.replace(/\s+/g, " ").trim().slice(0, 200) : "";
1942
+ const title = typeof patch.title === "string" ? patch.title.replace(/\s+/g, " ").trim().slice(0, TITLE_MAX) : "";
1872
1943
  if (title) next.title = title;
1873
1944
  else delete next.title;
1874
1945
  }
@@ -1886,6 +1957,18 @@ var PreferenceStore = class {
1886
1957
  this.persist();
1887
1958
  return this.all();
1888
1959
  }
1960
+ /**
1961
+ * Move a preference to the chat's lasting key (`session:<id>` → `<provider>:<id>` once the
1962
+ * provider's id is known), so a rename made early is the one rename everywhere. A preference
1963
+ * already under the lasting key wins field by field.
1964
+ */
1965
+ rekey(from, to) {
1966
+ if (from === to || !this.data[from]) return false;
1967
+ this.data[to] = { ...this.data[from], ...this.data[to] };
1968
+ delete this.data[from];
1969
+ this.persist();
1970
+ return true;
1971
+ }
1889
1972
  persist() {
1890
1973
  const snapshot = JSON.stringify(this.data, null, 1);
1891
1974
  this.writing = this.writing.then(async () => {
@@ -2013,7 +2096,7 @@ async function searchLocalChats(query, options = {}) {
2013
2096
  const raw = JSON.stringify(needle).slice(1, -1);
2014
2097
  const limit = Math.max(1, Math.min(options.limit ?? 30, 100));
2015
2098
  const deadline = Date.now() + (options.budgetMs ?? 3500);
2016
- const names = await readCodexIndex(homes.codexIndex);
2099
+ const names = await readCodexIndex(homes.codex.map((h) => h.index));
2017
2100
  const hits = [];
2018
2101
  let i = cursor;
2019
2102
  for (; i < files.length && hits.length < limit && Date.now() < deadline; i += 1) {
@@ -2048,6 +2131,50 @@ async function searchLocalChats(query, options = {}) {
2048
2131
  };
2049
2132
  }
2050
2133
 
2134
+ // src/sessions/hints.ts
2135
+ function titleHintKey(provider, sessionId) {
2136
+ return `${provider}:${sessionId}`;
2137
+ }
2138
+
2139
+ // src/engine/title-hints.ts
2140
+ function overlayHint(chat, hints, expected) {
2141
+ if (!expected) return chat;
2142
+ if (hints === null) return { ...chat, titlePending: true };
2143
+ const hint = hints.get(titleHintKey(chat.provider, chat.providerSessionId));
2144
+ if (!hint) return chat;
2145
+ return {
2146
+ ...chat,
2147
+ title: hint.title,
2148
+ titleSource: hint.source,
2149
+ ...hint.archived || chat.archived ? { archived: true } : {},
2150
+ ...hint.scheduled ? { scheduled: true } : {}
2151
+ };
2152
+ }
2153
+ function unavailableFromHints(hints, covered) {
2154
+ if (!hints) return [];
2155
+ const out = [];
2156
+ for (const [key, hint] of hints) {
2157
+ if (!hint.unavailable || covered.has(key)) continue;
2158
+ const at = key.indexOf(":");
2159
+ const provider = key.slice(0, at);
2160
+ if (provider !== "claude" && provider !== "codex") continue;
2161
+ out.push({
2162
+ provider,
2163
+ providerSessionId: key.slice(at + 1),
2164
+ cwd: null,
2165
+ title: hint.title,
2166
+ titleSource: hint.source,
2167
+ updatedAt: new Date(hint.lastActiveMs ?? 0).toISOString(),
2168
+ source: null,
2169
+ activeElsewhere: null,
2170
+ unavailable: hint.unavailable,
2171
+ ...hint.archived ? { archived: true } : {},
2172
+ ...hint.scheduled ? { scheduled: true } : {}
2173
+ });
2174
+ }
2175
+ return out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
2176
+ }
2177
+
2051
2178
  // src/engine/store.ts
2052
2179
  var import_node_crypto4 = require("crypto");
2053
2180
  var import_promises6 = __toESM(require("fs/promises"), 1);
@@ -2398,6 +2525,13 @@ async function createCodingSessionsEngine(options = {}) {
2398
2525
  }
2399
2526
  }
2400
2527
  }
2528
+ const titleHints = options.titleHints;
2529
+ const hintsNow = () => titleHints?.current() ?? null;
2530
+ const unsubscribeHints = titleHints?.subscribe(() => emit({ type: "titles" })) ?? null;
2531
+ const withHintTitle = (session) => {
2532
+ const hint = session.providerSessionId ? hintsNow()?.get(titleHintKey(session.provider, session.providerSessionId)) : void 0;
2533
+ return hint ? { ...session, title: hint.title } : session;
2534
+ };
2401
2535
  function saveSession(session) {
2402
2536
  index.putSession(session);
2403
2537
  emit({ type: "session", session });
@@ -2554,7 +2688,12 @@ async function createCodingSessionsEngine(options = {}) {
2554
2688
  if (sid && sid !== knownProviderSession) {
2555
2689
  knownProviderSession = sid;
2556
2690
  const current = index.session(session.id);
2557
- if (current) saveSession({ ...current, providerSessionId: sid });
2691
+ if (current) {
2692
+ saveSession({ ...current, providerSessionId: sid });
2693
+ if (preferences.rekey(chatKey(current.provider, null, current.id), chatKey(current.provider, sid))) {
2694
+ emit({ type: "preferences", preferences: preferences.all() });
2695
+ }
2696
+ }
2558
2697
  }
2559
2698
  for (const item of items) emitItem(liveRun, item);
2560
2699
  }
@@ -2862,7 +3001,9 @@ async function createCodingSessionsEngine(options = {}) {
2862
3001
  );
2863
3002
  },
2864
3003
  async discoverSessions(opts) {
2865
- const found = await discoverLocalSessions({ limit: opts?.limit, homes, excludePids: ourPids() });
3004
+ const read = await discoverLocalSessions({ limit: opts?.limit, homes, excludePids: ourPids() });
3005
+ const hints = hintsNow();
3006
+ const found = read.map((chat) => overlayHint(chat, hints, titleHints !== void 0));
2866
3007
  if (backups) {
2867
3008
  for (const chat of found) {
2868
3009
  const key = `${chat.provider}:${chat.providerSessionId}`;
@@ -2873,7 +3014,12 @@ async function createCodingSessionsEngine(options = {}) {
2873
3014
  }
2874
3015
  }
2875
3016
  }
2876
- return found;
3017
+ const limit = opts?.limit ?? 60;
3018
+ const oldest = found.length >= limit ? found[found.length - 1]?.updatedAt ?? "" : "";
3019
+ const gone = unavailableFromHints(hints, new Set(found.map((c) => titleHintKey(c.provider, c.providerSessionId)))).filter(
3020
+ (c) => c.updatedAt >= oldest
3021
+ );
3022
+ return gone.length ? [...found, ...gone].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, limit) : found;
2877
3023
  },
2878
3024
  async recentFolders() {
2879
3025
  const seen = /* @__PURE__ */ new Map();
@@ -2894,7 +3040,7 @@ async function createCodingSessionsEngine(options = {}) {
2894
3040
  );
2895
3041
  },
2896
3042
  async listSessions() {
2897
- return index.sessions();
3043
+ return index.sessions().map(withHintTitle);
2898
3044
  },
2899
3045
  async listRuns(sessionId) {
2900
3046
  const session = index.session(sessionId);
@@ -3069,7 +3215,9 @@ async function createCodingSessionsEngine(options = {}) {
3069
3215
  return all;
3070
3216
  },
3071
3217
  async searchChats(query, opts) {
3072
- return searchLocalChats(String(query ?? ""), { homes, cursor: opts?.cursor, limit: opts?.limit });
3218
+ const result = await searchLocalChats(String(query ?? ""), { homes, cursor: opts?.cursor, limit: opts?.limit });
3219
+ const hints = hintsNow();
3220
+ return { ...result, hits: result.hits.map((hit) => ({ ...hit, title: hints?.get(titleHintKey(hit.provider, hit.providerSessionId))?.title ?? hit.title })) };
3073
3221
  },
3074
3222
  async openFolder(raw, target) {
3075
3223
  const folder = await assertFolder(String(raw ?? ""));
@@ -3099,6 +3247,7 @@ async function createCodingSessionsEngine(options = {}) {
3099
3247
  return agents.handleMcp(secret, body);
3100
3248
  },
3101
3249
  async dispose() {
3250
+ unsubscribeHints?.();
3102
3251
  for (const liveRun of live.values()) {
3103
3252
  liveRun.stopRequested = true;
3104
3253
  if (liveRun.pgid !== null) killGroup(liveRun);