@jacques-ai/core 0.0.7-alpha.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.
Files changed (341) hide show
  1. package/dist/archive/archive-store.d.ts +166 -0
  2. package/dist/archive/archive-store.d.ts.map +1 -0
  3. package/dist/archive/archive-store.js +612 -0
  4. package/dist/archive/archive-store.js.map +1 -0
  5. package/dist/archive/bulk-archive.d.ts +63 -0
  6. package/dist/archive/bulk-archive.d.ts.map +1 -0
  7. package/dist/archive/bulk-archive.js +315 -0
  8. package/dist/archive/bulk-archive.js.map +1 -0
  9. package/dist/archive/filename-utils.d.ts +39 -0
  10. package/dist/archive/filename-utils.d.ts.map +1 -0
  11. package/dist/archive/filename-utils.js +78 -0
  12. package/dist/archive/filename-utils.js.map +1 -0
  13. package/dist/archive/index.d.ts +21 -0
  14. package/dist/archive/index.d.ts.map +1 -0
  15. package/dist/archive/index.js +45 -0
  16. package/dist/archive/index.js.map +1 -0
  17. package/dist/archive/manifest-extractor.d.ts +40 -0
  18. package/dist/archive/manifest-extractor.d.ts.map +1 -0
  19. package/dist/archive/manifest-extractor.js +456 -0
  20. package/dist/archive/manifest-extractor.js.map +1 -0
  21. package/dist/archive/migration.d.ts +59 -0
  22. package/dist/archive/migration.d.ts.map +1 -0
  23. package/dist/archive/migration.js +172 -0
  24. package/dist/archive/migration.js.map +1 -0
  25. package/dist/archive/plan-cataloger.d.ts +24 -0
  26. package/dist/archive/plan-cataloger.d.ts.map +1 -0
  27. package/dist/archive/plan-cataloger.js +100 -0
  28. package/dist/archive/plan-cataloger.js.map +1 -0
  29. package/dist/archive/plan-extractor.d.ts +84 -0
  30. package/dist/archive/plan-extractor.d.ts.map +1 -0
  31. package/dist/archive/plan-extractor.js +371 -0
  32. package/dist/archive/plan-extractor.js.map +1 -0
  33. package/dist/archive/search-indexer.d.ts +50 -0
  34. package/dist/archive/search-indexer.d.ts.map +1 -0
  35. package/dist/archive/search-indexer.js +294 -0
  36. package/dist/archive/search-indexer.js.map +1 -0
  37. package/dist/archive/subagent-store.d.ts +113 -0
  38. package/dist/archive/subagent-store.d.ts.map +1 -0
  39. package/dist/archive/subagent-store.js +173 -0
  40. package/dist/archive/subagent-store.js.map +1 -0
  41. package/dist/archive/types.d.ts +236 -0
  42. package/dist/archive/types.d.ts.map +1 -0
  43. package/dist/archive/types.js +30 -0
  44. package/dist/archive/types.js.map +1 -0
  45. package/dist/branding.d.ts +9 -0
  46. package/dist/branding.d.ts.map +1 -0
  47. package/dist/branding.js +50 -0
  48. package/dist/branding.js.map +1 -0
  49. package/dist/cache/git-utils.d.ts +36 -0
  50. package/dist/cache/git-utils.d.ts.map +1 -0
  51. package/dist/cache/git-utils.js +160 -0
  52. package/dist/cache/git-utils.js.map +1 -0
  53. package/dist/cache/hidden-projects.d.ts +19 -0
  54. package/dist/cache/hidden-projects.d.ts.map +1 -0
  55. package/dist/cache/hidden-projects.js +48 -0
  56. package/dist/cache/hidden-projects.js.map +1 -0
  57. package/dist/cache/index.d.ts +15 -0
  58. package/dist/cache/index.d.ts.map +1 -0
  59. package/dist/cache/index.js +20 -0
  60. package/dist/cache/index.js.map +1 -0
  61. package/dist/cache/metadata-extractor.d.ts +62 -0
  62. package/dist/cache/metadata-extractor.d.ts.map +1 -0
  63. package/dist/cache/metadata-extractor.js +574 -0
  64. package/dist/cache/metadata-extractor.js.map +1 -0
  65. package/dist/cache/mode-detector.d.ts +19 -0
  66. package/dist/cache/mode-detector.d.ts.map +1 -0
  67. package/dist/cache/mode-detector.js +161 -0
  68. package/dist/cache/mode-detector.js.map +1 -0
  69. package/dist/cache/persistence.d.ts +39 -0
  70. package/dist/cache/persistence.d.ts.map +1 -0
  71. package/dist/cache/persistence.js +98 -0
  72. package/dist/cache/persistence.js.map +1 -0
  73. package/dist/cache/project-discovery.d.ts +41 -0
  74. package/dist/cache/project-discovery.d.ts.map +1 -0
  75. package/dist/cache/project-discovery.js +212 -0
  76. package/dist/cache/project-discovery.js.map +1 -0
  77. package/dist/cache/session-index.d.ts +258 -0
  78. package/dist/cache/session-index.d.ts.map +1 -0
  79. package/dist/cache/session-index.js +1030 -0
  80. package/dist/cache/session-index.js.map +1 -0
  81. package/dist/cache/types.d.ts +159 -0
  82. package/dist/cache/types.d.ts.map +1 -0
  83. package/dist/cache/types.js +29 -0
  84. package/dist/cache/types.js.map +1 -0
  85. package/dist/catalog/bulk-extractor.d.ts +18 -0
  86. package/dist/catalog/bulk-extractor.d.ts.map +1 -0
  87. package/dist/catalog/bulk-extractor.js +150 -0
  88. package/dist/catalog/bulk-extractor.js.map +1 -0
  89. package/dist/catalog/extractor.d.ts +53 -0
  90. package/dist/catalog/extractor.d.ts.map +1 -0
  91. package/dist/catalog/extractor.js +522 -0
  92. package/dist/catalog/extractor.js.map +1 -0
  93. package/dist/catalog/index.d.ts +10 -0
  94. package/dist/catalog/index.d.ts.map +1 -0
  95. package/dist/catalog/index.js +11 -0
  96. package/dist/catalog/index.js.map +1 -0
  97. package/dist/catalog/types.d.ts +134 -0
  98. package/dist/catalog/types.d.ts.map +1 -0
  99. package/dist/catalog/types.js +8 -0
  100. package/dist/catalog/types.js.map +1 -0
  101. package/dist/client/index.d.ts +6 -0
  102. package/dist/client/index.d.ts.map +1 -0
  103. package/dist/client/index.js +5 -0
  104. package/dist/client/index.js.map +1 -0
  105. package/dist/client/websocket-client.d.ts +96 -0
  106. package/dist/client/websocket-client.d.ts.map +1 -0
  107. package/dist/client/websocket-client.js +222 -0
  108. package/dist/client/websocket-client.js.map +1 -0
  109. package/dist/context/index.d.ts +13 -0
  110. package/dist/context/index.d.ts.map +1 -0
  111. package/dist/context/index.js +26 -0
  112. package/dist/context/index.js.map +1 -0
  113. package/dist/context/indexer.d.ts +73 -0
  114. package/dist/context/indexer.d.ts.map +1 -0
  115. package/dist/context/indexer.js +233 -0
  116. package/dist/context/indexer.js.map +1 -0
  117. package/dist/context/manager.d.ts +66 -0
  118. package/dist/context/manager.d.ts.map +1 -0
  119. package/dist/context/manager.js +310 -0
  120. package/dist/context/manager.js.map +1 -0
  121. package/dist/context/types.d.ts +149 -0
  122. package/dist/context/types.d.ts.map +1 -0
  123. package/dist/context/types.js +36 -0
  124. package/dist/context/types.js.map +1 -0
  125. package/dist/handoff/catalog.d.ts +54 -0
  126. package/dist/handoff/catalog.d.ts.map +1 -0
  127. package/dist/handoff/catalog.js +121 -0
  128. package/dist/handoff/catalog.js.map +1 -0
  129. package/dist/handoff/generator.d.ts +107 -0
  130. package/dist/handoff/generator.d.ts.map +1 -0
  131. package/dist/handoff/generator.js +603 -0
  132. package/dist/handoff/generator.js.map +1 -0
  133. package/dist/handoff/index.d.ts +13 -0
  134. package/dist/handoff/index.d.ts.map +1 -0
  135. package/dist/handoff/index.js +12 -0
  136. package/dist/handoff/index.js.map +1 -0
  137. package/dist/handoff/llm-generator.d.ts +77 -0
  138. package/dist/handoff/llm-generator.d.ts.map +1 -0
  139. package/dist/handoff/llm-generator.js +513 -0
  140. package/dist/handoff/llm-generator.js.map +1 -0
  141. package/dist/handoff/prompts.d.ts +18 -0
  142. package/dist/handoff/prompts.d.ts.map +1 -0
  143. package/dist/handoff/prompts.js +22 -0
  144. package/dist/handoff/prompts.js.map +1 -0
  145. package/dist/handoff/types.d.ts +28 -0
  146. package/dist/handoff/types.d.ts.map +1 -0
  147. package/dist/handoff/types.js +7 -0
  148. package/dist/handoff/types.js.map +1 -0
  149. package/dist/index.d.ts +56 -0
  150. package/dist/index.d.ts.map +1 -0
  151. package/dist/index.js +132 -0
  152. package/dist/index.js.map +1 -0
  153. package/dist/logging/claude-operations.d.ts +111 -0
  154. package/dist/logging/claude-operations.d.ts.map +1 -0
  155. package/dist/logging/claude-operations.js +132 -0
  156. package/dist/logging/claude-operations.js.map +1 -0
  157. package/dist/logging/error-utils.d.ts +18 -0
  158. package/dist/logging/error-utils.d.ts.map +1 -0
  159. package/dist/logging/error-utils.js +37 -0
  160. package/dist/logging/error-utils.js.map +1 -0
  161. package/dist/logging/index.d.ts +11 -0
  162. package/dist/logging/index.d.ts.map +1 -0
  163. package/dist/logging/index.js +10 -0
  164. package/dist/logging/index.js.map +1 -0
  165. package/dist/logging/logger.d.ts +25 -0
  166. package/dist/logging/logger.d.ts.map +1 -0
  167. package/dist/logging/logger.js +39 -0
  168. package/dist/logging/logger.js.map +1 -0
  169. package/dist/notifications/constants.d.ts +16 -0
  170. package/dist/notifications/constants.d.ts.map +1 -0
  171. package/dist/notifications/constants.js +49 -0
  172. package/dist/notifications/constants.js.map +1 -0
  173. package/dist/notifications/index.d.ts +9 -0
  174. package/dist/notifications/index.d.ts.map +1 -0
  175. package/dist/notifications/index.js +8 -0
  176. package/dist/notifications/index.js.map +1 -0
  177. package/dist/notifications/types.d.ts +28 -0
  178. package/dist/notifications/types.d.ts.map +1 -0
  179. package/dist/notifications/types.js +7 -0
  180. package/dist/notifications/types.js.map +1 -0
  181. package/dist/notifications/utils.d.ts +20 -0
  182. package/dist/notifications/utils.d.ts.map +1 -0
  183. package/dist/notifications/utils.js +37 -0
  184. package/dist/notifications/utils.js.map +1 -0
  185. package/dist/plan/index.d.ts +12 -0
  186. package/dist/plan/index.d.ts.map +1 -0
  187. package/dist/plan/index.js +15 -0
  188. package/dist/plan/index.js.map +1 -0
  189. package/dist/plan/plan-parser.d.ts +33 -0
  190. package/dist/plan/plan-parser.d.ts.map +1 -0
  191. package/dist/plan/plan-parser.js +189 -0
  192. package/dist/plan/plan-parser.js.map +1 -0
  193. package/dist/plan/progress-computer.d.ts +34 -0
  194. package/dist/plan/progress-computer.d.ts.map +1 -0
  195. package/dist/plan/progress-computer.js +211 -0
  196. package/dist/plan/progress-computer.js.map +1 -0
  197. package/dist/plan/progress-matcher.d.ts +34 -0
  198. package/dist/plan/progress-matcher.d.ts.map +1 -0
  199. package/dist/plan/progress-matcher.js +297 -0
  200. package/dist/plan/progress-matcher.js.map +1 -0
  201. package/dist/plan/task-extractor.d.ts +30 -0
  202. package/dist/plan/task-extractor.d.ts.map +1 -0
  203. package/dist/plan/task-extractor.js +435 -0
  204. package/dist/plan/task-extractor.js.map +1 -0
  205. package/dist/plan/types.d.ts +131 -0
  206. package/dist/plan/types.d.ts.map +1 -0
  207. package/dist/plan/types.js +8 -0
  208. package/dist/plan/types.js.map +1 -0
  209. package/dist/project/aggregator.d.ts +43 -0
  210. package/dist/project/aggregator.d.ts.map +1 -0
  211. package/dist/project/aggregator.js +218 -0
  212. package/dist/project/aggregator.js.map +1 -0
  213. package/dist/project/index.d.ts +9 -0
  214. package/dist/project/index.d.ts.map +1 -0
  215. package/dist/project/index.js +9 -0
  216. package/dist/project/index.js.map +1 -0
  217. package/dist/project/types.d.ts +65 -0
  218. package/dist/project/types.d.ts.map +1 -0
  219. package/dist/project/types.js +27 -0
  220. package/dist/project/types.js.map +1 -0
  221. package/dist/session/detector.d.ts +113 -0
  222. package/dist/session/detector.d.ts.map +1 -0
  223. package/dist/session/detector.js +333 -0
  224. package/dist/session/detector.js.map +1 -0
  225. package/dist/session/filters.d.ts +32 -0
  226. package/dist/session/filters.d.ts.map +1 -0
  227. package/dist/session/filters.js +100 -0
  228. package/dist/session/filters.js.map +1 -0
  229. package/dist/session/format-title.d.ts +16 -0
  230. package/dist/session/format-title.d.ts.map +1 -0
  231. package/dist/session/format-title.js +54 -0
  232. package/dist/session/format-title.js.map +1 -0
  233. package/dist/session/index.d.ts +16 -0
  234. package/dist/session/index.d.ts.map +1 -0
  235. package/dist/session/index.js +10 -0
  236. package/dist/session/index.js.map +1 -0
  237. package/dist/session/parser.d.ts +264 -0
  238. package/dist/session/parser.d.ts.map +1 -0
  239. package/dist/session/parser.js +588 -0
  240. package/dist/session/parser.js.map +1 -0
  241. package/dist/session/token-estimator.d.ts +32 -0
  242. package/dist/session/token-estimator.d.ts.map +1 -0
  243. package/dist/session/token-estimator.js +139 -0
  244. package/dist/session/token-estimator.js.map +1 -0
  245. package/dist/session/transformer.d.ts +126 -0
  246. package/dist/session/transformer.d.ts.map +1 -0
  247. package/dist/session/transformer.js +158 -0
  248. package/dist/session/transformer.js.map +1 -0
  249. package/dist/setup/hooks-config.d.ts +35 -0
  250. package/dist/setup/hooks-config.d.ts.map +1 -0
  251. package/dist/setup/hooks-config.js +107 -0
  252. package/dist/setup/hooks-config.js.map +1 -0
  253. package/dist/setup/hooks-symlink.d.ts +17 -0
  254. package/dist/setup/hooks-symlink.d.ts.map +1 -0
  255. package/dist/setup/hooks-symlink.js +89 -0
  256. package/dist/setup/hooks-symlink.js.map +1 -0
  257. package/dist/setup/index.d.ts +13 -0
  258. package/dist/setup/index.d.ts.map +1 -0
  259. package/dist/setup/index.js +12 -0
  260. package/dist/setup/index.js.map +1 -0
  261. package/dist/setup/prerequisites.d.ts +11 -0
  262. package/dist/setup/prerequisites.d.ts.map +1 -0
  263. package/dist/setup/prerequisites.js +72 -0
  264. package/dist/setup/prerequisites.js.map +1 -0
  265. package/dist/setup/settings-merge.d.ts +33 -0
  266. package/dist/setup/settings-merge.d.ts.map +1 -0
  267. package/dist/setup/settings-merge.js +131 -0
  268. package/dist/setup/settings-merge.js.map +1 -0
  269. package/dist/setup/skills-install.d.ts +17 -0
  270. package/dist/setup/skills-install.d.ts.map +1 -0
  271. package/dist/setup/skills-install.js +60 -0
  272. package/dist/setup/skills-install.js.map +1 -0
  273. package/dist/setup/types.d.ts +39 -0
  274. package/dist/setup/types.d.ts.map +1 -0
  275. package/dist/setup/types.js +7 -0
  276. package/dist/setup/types.js.map +1 -0
  277. package/dist/setup/verification.d.ts +9 -0
  278. package/dist/setup/verification.d.ts.map +1 -0
  279. package/dist/setup/verification.js +91 -0
  280. package/dist/setup/verification.js.map +1 -0
  281. package/dist/shortcuts/index.d.ts +8 -0
  282. package/dist/shortcuts/index.d.ts.map +1 -0
  283. package/dist/shortcuts/index.js +6 -0
  284. package/dist/shortcuts/index.js.map +1 -0
  285. package/dist/shortcuts/key-utils.d.ts +54 -0
  286. package/dist/shortcuts/key-utils.d.ts.map +1 -0
  287. package/dist/shortcuts/key-utils.js +129 -0
  288. package/dist/shortcuts/key-utils.js.map +1 -0
  289. package/dist/shortcuts/shortcut-registry.d.ts +37 -0
  290. package/dist/shortcuts/shortcut-registry.d.ts.map +1 -0
  291. package/dist/shortcuts/shortcut-registry.js +322 -0
  292. package/dist/shortcuts/shortcut-registry.js.map +1 -0
  293. package/dist/sources/config.d.ts +91 -0
  294. package/dist/sources/config.d.ts.map +1 -0
  295. package/dist/sources/config.js +229 -0
  296. package/dist/sources/config.js.map +1 -0
  297. package/dist/sources/googledocs.d.ts +43 -0
  298. package/dist/sources/googledocs.d.ts.map +1 -0
  299. package/dist/sources/googledocs.js +298 -0
  300. package/dist/sources/googledocs.js.map +1 -0
  301. package/dist/sources/index.d.ts +14 -0
  302. package/dist/sources/index.d.ts.map +1 -0
  303. package/dist/sources/index.js +19 -0
  304. package/dist/sources/index.js.map +1 -0
  305. package/dist/sources/notion.d.ts +35 -0
  306. package/dist/sources/notion.d.ts.map +1 -0
  307. package/dist/sources/notion.js +352 -0
  308. package/dist/sources/notion.js.map +1 -0
  309. package/dist/sources/obsidian.d.ts +38 -0
  310. package/dist/sources/obsidian.d.ts.map +1 -0
  311. package/dist/sources/obsidian.js +228 -0
  312. package/dist/sources/obsidian.js.map +1 -0
  313. package/dist/sources/types.d.ts +133 -0
  314. package/dist/sources/types.d.ts.map +1 -0
  315. package/dist/sources/types.js +19 -0
  316. package/dist/sources/types.js.map +1 -0
  317. package/dist/storage/index.d.ts +6 -0
  318. package/dist/storage/index.d.ts.map +1 -0
  319. package/dist/storage/index.js +5 -0
  320. package/dist/storage/index.js.map +1 -0
  321. package/dist/storage/writer.d.ts +86 -0
  322. package/dist/storage/writer.d.ts.map +1 -0
  323. package/dist/storage/writer.js +137 -0
  324. package/dist/storage/writer.js.map +1 -0
  325. package/dist/types.d.ts +203 -0
  326. package/dist/types.d.ts.map +1 -0
  327. package/dist/types.js +8 -0
  328. package/dist/types.js.map +1 -0
  329. package/dist/utils/claude-token.d.ts +49 -0
  330. package/dist/utils/claude-token.d.ts.map +1 -0
  331. package/dist/utils/claude-token.js +169 -0
  332. package/dist/utils/claude-token.js.map +1 -0
  333. package/dist/utils/index.d.ts +7 -0
  334. package/dist/utils/index.d.ts.map +1 -0
  335. package/dist/utils/index.js +13 -0
  336. package/dist/utils/index.js.map +1 -0
  337. package/dist/utils/settings.d.ts +100 -0
  338. package/dist/utils/settings.d.ts.map +1 -0
  339. package/dist/utils/settings.js +206 -0
  340. package/dist/utils/settings.js.map +1 -0
  341. package/package.json +54 -0
@@ -0,0 +1,1030 @@
1
+ /**
2
+ * Session Index
3
+ *
4
+ * Lightweight index for fast session listing and search.
5
+ * Reads directly from Claude Code JSONL files - no content copying.
6
+ *
7
+ * Architecture:
8
+ * ~/.claude/projects/... (SOURCE OF TRUTH)
9
+ * ↓ read directly
10
+ * GUI Viewer
11
+ * ↑
12
+ * ~/.jacques/cache/
13
+ * └── sessions-index.json (~5KB, metadata only)
14
+ */
15
+ import { promises as fs } from "fs";
16
+ import * as path from "path";
17
+ import { homedir } from "os";
18
+ import { execSync } from "child_process";
19
+ import { parseJSONL, getEntryStatistics } from "../session/parser.js";
20
+ import { listSubagentFiles, decodeProjectPath, getClaudeProjectsDir } from "../session/detector.js";
21
+ import { PLAN_TRIGGER_PATTERNS, extractPlanTitle } from "../archive/plan-extractor.js";
22
+ import { readProjectIndex } from "../context/indexer.js";
23
+ /** Claude projects directory (resolved via config/env) */
24
+ const CLAUDE_PROJECTS_PATH = getClaudeProjectsDir();
25
+ /** Jacques cache directory */
26
+ const JACQUES_CACHE_PATH = path.join(homedir(), ".jacques", "cache");
27
+ /** Session index filename */
28
+ const SESSION_INDEX_FILE = "sessions-index.json";
29
+ /**
30
+ * Get default empty session index
31
+ */
32
+ export function getDefaultSessionIndex() {
33
+ return {
34
+ version: "2.0.0",
35
+ lastScanned: new Date().toISOString(),
36
+ sessions: [],
37
+ };
38
+ }
39
+ // Re-export for backwards compatibility (cache/index.ts exports this)
40
+ export { decodeProjectPath };
41
+ /**
42
+ * Get the cache directory path
43
+ */
44
+ export function getCacheDir() {
45
+ return JACQUES_CACHE_PATH;
46
+ }
47
+ /**
48
+ * Get the session index file path
49
+ */
50
+ export function getIndexPath() {
51
+ return path.join(JACQUES_CACHE_PATH, SESSION_INDEX_FILE);
52
+ }
53
+ /**
54
+ * Ensure cache directory exists
55
+ */
56
+ export async function ensureCacheDir() {
57
+ await fs.mkdir(JACQUES_CACHE_PATH, { recursive: true });
58
+ }
59
+ /**
60
+ * Read the session index from disk
61
+ */
62
+ export async function readSessionIndex() {
63
+ try {
64
+ const indexPath = getIndexPath();
65
+ const content = await fs.readFile(indexPath, "utf-8");
66
+ return JSON.parse(content);
67
+ }
68
+ catch {
69
+ return getDefaultSessionIndex();
70
+ }
71
+ }
72
+ /**
73
+ * Write the session index to disk
74
+ */
75
+ export async function writeSessionIndex(index) {
76
+ await ensureCacheDir();
77
+ const indexPath = getIndexPath();
78
+ await fs.writeFile(indexPath, JSON.stringify(index, null, 2), "utf-8");
79
+ }
80
+ /**
81
+ * Extract session title from parsed JSONL entries.
82
+ * Priority:
83
+ * 1. Summary entry (Claude's auto-generated title)
84
+ * 2. First real user message (skips internal command messages)
85
+ */
86
+ function extractTitle(entries) {
87
+ // Try summary first
88
+ const summaryEntry = entries.find((e) => e.type === "summary" && e.content.summary);
89
+ if (summaryEntry?.content.summary) {
90
+ return summaryEntry.content.summary;
91
+ }
92
+ // Fallback to first real user message (skip internal command messages)
93
+ const userMessage = entries.find((e) => {
94
+ if (e.type !== "user_message" || !e.content.text)
95
+ return false;
96
+ const text = e.content.text.trim();
97
+ // Skip internal Claude Code messages
98
+ if (text.startsWith("<local-command"))
99
+ return false;
100
+ if (text.startsWith("<command-"))
101
+ return false;
102
+ if (text.length === 0)
103
+ return false;
104
+ return true;
105
+ });
106
+ if (userMessage?.content.text) {
107
+ // Truncate long messages
108
+ const text = userMessage.content.text.trim();
109
+ if (text.length > 100) {
110
+ return text.slice(0, 97) + "...";
111
+ }
112
+ return text;
113
+ }
114
+ return "Untitled Session";
115
+ }
116
+ /**
117
+ * Extract timestamps from entries
118
+ */
119
+ function extractTimestamps(entries) {
120
+ if (entries.length === 0) {
121
+ const now = new Date().toISOString();
122
+ return { startedAt: now, endedAt: now };
123
+ }
124
+ // Find earliest and latest timestamps
125
+ let startedAt = entries[0].timestamp;
126
+ let endedAt = entries[0].timestamp;
127
+ for (const entry of entries) {
128
+ if (entry.timestamp < startedAt) {
129
+ startedAt = entry.timestamp;
130
+ }
131
+ if (entry.timestamp > endedAt) {
132
+ endedAt = entry.timestamp;
133
+ }
134
+ }
135
+ return { startedAt, endedAt };
136
+ }
137
+ /**
138
+ * Detect session mode (planning vs execution) and extract plan references.
139
+ *
140
+ * - Planning mode: EnterPlanMode tool was called during session
141
+ * - Execution mode: First user message contains plan trigger pattern
142
+ */
143
+ export function detectModeAndPlans(entries) {
144
+ let mode = null;
145
+ const planRefs = [];
146
+ // Track if EnterPlanMode was called (planning mode)
147
+ let hasEnterPlanMode = false;
148
+ // Track first real user message for execution mode detection
149
+ let firstUserMessageChecked = false;
150
+ entries.forEach((entry, index) => {
151
+ // Check for EnterPlanMode tool call (planning mode)
152
+ if (entry.type === 'tool_call' && entry.content.toolName === 'EnterPlanMode') {
153
+ hasEnterPlanMode = true;
154
+ }
155
+ // Check first user message for execution mode
156
+ if (entry.type === 'user_message' && entry.content.text && !firstUserMessageChecked) {
157
+ const text = entry.content.text.trim();
158
+ // Skip internal command messages
159
+ if (text.startsWith('<local-command') ||
160
+ text.startsWith('<command-') ||
161
+ text.length === 0) {
162
+ return;
163
+ }
164
+ firstUserMessageChecked = true;
165
+ // Check if first message matches plan trigger patterns
166
+ for (const pattern of PLAN_TRIGGER_PATTERNS) {
167
+ if (pattern.test(text)) {
168
+ mode = 'execution';
169
+ // Extract plan content and title
170
+ const match = text.match(pattern);
171
+ if (match) {
172
+ const planContent = text.substring(match[0].length).trim();
173
+ // Only count as plan if it has content with markdown heading
174
+ if (planContent.length >= 100 && planContent.includes('#')) {
175
+ const title = extractPlanTitle(planContent);
176
+ planRefs.push({
177
+ title,
178
+ source: 'embedded',
179
+ messageIndex: index,
180
+ });
181
+ }
182
+ }
183
+ break;
184
+ }
185
+ }
186
+ }
187
+ // Check for embedded plans in other user messages (not just first)
188
+ if (entry.type === 'user_message' && entry.content.text && firstUserMessageChecked) {
189
+ const text = entry.content.text.trim();
190
+ // Skip internal command messages
191
+ if (text.startsWith('<local-command') ||
192
+ text.startsWith('<command-') ||
193
+ text.length === 0) {
194
+ return;
195
+ }
196
+ // Check for plan trigger patterns in subsequent messages
197
+ for (const pattern of PLAN_TRIGGER_PATTERNS) {
198
+ if (pattern.test(text)) {
199
+ const match = text.match(pattern);
200
+ if (match) {
201
+ const planContent = text.substring(match[0].length).trim();
202
+ if (planContent.length >= 100 && planContent.includes('#')) {
203
+ const title = extractPlanTitle(planContent);
204
+ // Avoid duplicate entries for the same message
205
+ if (!planRefs.some(r => r.messageIndex === index)) {
206
+ planRefs.push({
207
+ title,
208
+ source: 'embedded',
209
+ messageIndex: index,
210
+ });
211
+ }
212
+ }
213
+ }
214
+ break;
215
+ }
216
+ }
217
+ }
218
+ // Check for Plan agent responses from agent_progress entries
219
+ if (entry.type === 'agent_progress' && entry.content.agentType === 'Plan') {
220
+ const agentId = entry.content.agentId;
221
+ if (agentId && !planRefs.some(r => r.source === 'agent' && r.agentId === agentId)) {
222
+ planRefs.push({
223
+ title: entry.content.agentDescription || 'Agent-Generated Plan',
224
+ source: 'agent',
225
+ messageIndex: index,
226
+ agentId,
227
+ });
228
+ }
229
+ }
230
+ // Check for Write tool calls to plan files
231
+ if (entry.type === 'tool_call' && entry.content.toolName === 'Write') {
232
+ const input = entry.content.toolInput;
233
+ const filePath = input?.file_path || '';
234
+ const content = input?.content || '';
235
+ // Skip code files - they're not plans even if "plan" is in the name
236
+ const codeExtensions = [
237
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
238
+ '.py', '.rb', '.go', '.rs', '.java', '.kt', '.swift',
239
+ '.c', '.cpp', '.h', '.hpp', '.cs', '.php',
240
+ '.vue', '.svelte', '.astro',
241
+ '.css', '.scss', '.less', '.sass',
242
+ '.html', '.htm', '.xml', '.svg',
243
+ '.json', '.yaml', '.yml', '.toml',
244
+ '.sh', '.bash', '.zsh', '.fish',
245
+ '.sql', '.graphql', '.prisma',
246
+ ];
247
+ const isCodeFile = codeExtensions.some(ext => filePath.toLowerCase().endsWith(ext));
248
+ if (isCodeFile) {
249
+ return;
250
+ }
251
+ // Check if path looks like a plan file
252
+ const pathLooksLikePlan = filePath.toLowerCase().includes('plan') ||
253
+ filePath.endsWith('.plan.md') ||
254
+ filePath.includes('.jacques/plans/');
255
+ // Check if content looks like markdown plan (not code)
256
+ const hasHeading = /^#+\s+.+/m.test(content);
257
+ const hasListOrParagraph = /^[-*]\s+.+/m.test(content) || content.split('\n\n').length > 1;
258
+ const firstLine = content.split('\n').find(line => line.trim().length > 0) || '';
259
+ const codePatterns = [
260
+ /^import\s+/,
261
+ /^export\s+/,
262
+ /^const\s+/,
263
+ /^function\s+/,
264
+ /^class\s+/,
265
+ /^interface\s+/,
266
+ /^type\s+/,
267
+ ];
268
+ const looksLikeCode = codePatterns.some(p => p.test(firstLine.trim()));
269
+ const looksLikeMarkdown = hasHeading && hasListOrParagraph && !looksLikeCode;
270
+ if (pathLooksLikePlan && looksLikeMarkdown) {
271
+ const title = extractPlanTitle(content);
272
+ planRefs.push({
273
+ title,
274
+ source: 'write',
275
+ messageIndex: index,
276
+ filePath,
277
+ });
278
+ }
279
+ }
280
+ });
281
+ // Planning mode takes precedence if EnterPlanMode was called
282
+ if (hasEnterPlanMode) {
283
+ mode = 'planning';
284
+ }
285
+ return { mode, planRefs };
286
+ }
287
+ /**
288
+ * Extract explore agents and web searches from entries.
289
+ * For explore agents, computes token cost from their subagent JSONL files.
290
+ */
291
+ async function extractAgentsAndSearches(entries, subagentFiles) {
292
+ const exploreAgents = [];
293
+ const webSearches = [];
294
+ const seenAgentIds = new Set();
295
+ const seenQueries = new Set();
296
+ // Build a map of agentId -> subagent file for quick lookup
297
+ const subagentFileMap = new Map();
298
+ for (const f of subagentFiles) {
299
+ subagentFileMap.set(f.agentId, f);
300
+ }
301
+ for (const entry of entries) {
302
+ // Extract explore agents from agent_progress entries
303
+ if (entry.type === 'agent_progress' && entry.content.agentType === 'Explore') {
304
+ const agentId = entry.content.agentId;
305
+ if (agentId && !seenAgentIds.has(agentId)) {
306
+ seenAgentIds.add(agentId);
307
+ exploreAgents.push({
308
+ id: agentId,
309
+ description: entry.content.agentDescription || 'Explore codebase',
310
+ timestamp: entry.timestamp,
311
+ });
312
+ }
313
+ }
314
+ // Extract web searches from web_search entries with results
315
+ if (entry.type === 'web_search' && entry.content.searchType === 'results') {
316
+ const query = entry.content.searchQuery;
317
+ if (query && !seenQueries.has(query)) {
318
+ seenQueries.add(query);
319
+ webSearches.push({
320
+ query,
321
+ resultCount: entry.content.searchResultCount || 0,
322
+ timestamp: entry.timestamp,
323
+ });
324
+ }
325
+ }
326
+ }
327
+ // Compute token costs for explore agents from their subagent JSONL files
328
+ for (const agent of exploreAgents) {
329
+ const subagentFile = subagentFileMap.get(agent.id);
330
+ if (subagentFile) {
331
+ try {
332
+ const subEntries = await parseJSONL(subagentFile.filePath);
333
+ if (subEntries.length > 0) {
334
+ const subStats = getEntryStatistics(subEntries);
335
+ // Total cost = last turn's context window size + estimated output
336
+ const inputCost = subStats.lastInputTokens + subStats.lastCacheRead;
337
+ const outputCost = subStats.totalOutputTokensEstimated;
338
+ agent.tokenCost = inputCost + outputCost;
339
+ }
340
+ }
341
+ catch {
342
+ // Subagent file couldn't be parsed, leave tokenCost undefined
343
+ }
344
+ }
345
+ }
346
+ return { exploreAgents, webSearches };
347
+ }
348
+ /**
349
+ * Detect git info for a project path: repo root, branch, and worktree name.
350
+ * If the path doesn't exist, walks up parent directories to find a git repo.
351
+ */
352
+ function detectGitInfo(projectPath) {
353
+ // Try the exact path first, then walk up parents if it doesn't exist
354
+ const candidates = [projectPath];
355
+ let dir = projectPath;
356
+ while (true) {
357
+ const parent = path.dirname(dir);
358
+ if (parent === dir)
359
+ break; // reached filesystem root
360
+ candidates.push(parent);
361
+ dir = parent;
362
+ }
363
+ for (const candidate of candidates) {
364
+ try {
365
+ const output = execSync(`git -C "${candidate}" rev-parse --abbrev-ref HEAD --git-common-dir`, { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }).trim();
366
+ if (!output)
367
+ continue;
368
+ const lines = output.split("\n");
369
+ const branch = lines[0] || undefined;
370
+ const commonDir = lines[1];
371
+ if (!commonDir)
372
+ return { branch };
373
+ // Resolve relative paths (e.g., "../.git" from subdirectories) to absolute
374
+ const resolved = path.resolve(candidate, commonDir);
375
+ let repoRoot;
376
+ let worktree;
377
+ if (resolved.endsWith(`${path.sep}.git`) || resolved.endsWith("/.git")) {
378
+ // Normal repo or subdirectory: .git parent is repo root
379
+ repoRoot = path.dirname(resolved);
380
+ }
381
+ else {
382
+ // Worktree: common dir points to shared .git dir
383
+ repoRoot = path.dirname(resolved);
384
+ worktree = path.basename(projectPath);
385
+ }
386
+ return { repoRoot, branch, worktree };
387
+ }
388
+ catch {
389
+ // This candidate didn't work, try the next parent
390
+ continue;
391
+ }
392
+ }
393
+ return {};
394
+ }
395
+ /**
396
+ * Read the gitBranch field from early JSONL entries.
397
+ * Used when detectGitInfo fails (e.g., deleted worktrees).
398
+ */
399
+ async function readGitBranchFromJsonl(jsonlPath) {
400
+ try {
401
+ const handle = await fs.open(jsonlPath, "r");
402
+ try {
403
+ const buf = Buffer.alloc(8192);
404
+ const { bytesRead } = await handle.read(buf, 0, 8192, 0);
405
+ const chunk = buf.toString("utf-8", 0, bytesRead);
406
+ for (const line of chunk.split("\n")) {
407
+ if (!line.trim())
408
+ continue;
409
+ try {
410
+ const entry = JSON.parse(line);
411
+ if (typeof entry.gitBranch === "string") {
412
+ return entry.gitBranch;
413
+ }
414
+ }
415
+ catch {
416
+ // Partial line
417
+ }
418
+ }
419
+ }
420
+ finally {
421
+ await handle.close();
422
+ }
423
+ }
424
+ catch {
425
+ // File unreadable
426
+ }
427
+ return null;
428
+ }
429
+ /**
430
+ * Extract metadata from a single JSONL file
431
+ */
432
+ export async function extractSessionMetadata(jsonlPath, projectPath, projectSlug) {
433
+ try {
434
+ // Get file stats
435
+ const stats = await fs.stat(jsonlPath);
436
+ const sessionId = path.basename(jsonlPath, ".jsonl");
437
+ // Parse JSONL to get metadata
438
+ const entries = await parseJSONL(jsonlPath);
439
+ if (entries.length === 0) {
440
+ return null;
441
+ }
442
+ // Get statistics
443
+ const entryStats = getEntryStatistics(entries);
444
+ // Get timestamps
445
+ const { startedAt, endedAt } = extractTimestamps(entries);
446
+ // Get title
447
+ const title = extractTitle(entries);
448
+ // Check for subagents
449
+ const subagentFiles = await listSubagentFiles(jsonlPath);
450
+ // Filter out internal agents (prompt_suggestion, acompact) from user-visible count
451
+ // These are system agents that shouldn't appear in the subagent count
452
+ const userVisibleSubagents = subagentFiles.filter((f) => !f.agentId.startsWith('aprompt_suggestion-') &&
453
+ !f.agentId.startsWith('acompact-'));
454
+ // Track if auto-compact occurred (for showing indicator in UI)
455
+ const autoCompactFile = subagentFiles.find((f) => f.agentId.startsWith('acompact-'));
456
+ const hadAutoCompact = !!autoCompactFile;
457
+ const autoCompactAt = autoCompactFile?.modifiedAt.toISOString();
458
+ const hasSubagents = userVisibleSubagents.length > 0;
459
+ // Detect mode and plans
460
+ const { mode, planRefs } = detectModeAndPlans(entries);
461
+ // Extract explore agents and web searches (with token costs from subagent files)
462
+ const { exploreAgents, webSearches } = await extractAgentsAndSearches(entries, subagentFiles);
463
+ // Detect git info from project path
464
+ const gitInfo = detectGitInfo(projectPath);
465
+ // If detectGitInfo failed (e.g., deleted worktree), read gitBranch from raw JSONL
466
+ if (!gitInfo.branch) {
467
+ gitInfo.branch = await readGitBranchFromJsonl(jsonlPath) || undefined;
468
+ }
469
+ // Use LAST turn's input tokens for context window size
470
+ // Each turn reports the FULL context, so summing would overcount
471
+ // Total context = fresh input + cache read (cache_creation is subset of fresh, not additional)
472
+ const totalInput = entryStats.lastInputTokens + entryStats.lastCacheRead;
473
+ // Use tiktoken-estimated output tokens (cumulative - each turn generates NEW output)
474
+ const totalOutput = entryStats.totalOutputTokensEstimated;
475
+ const hasTokens = totalInput > 0 || totalOutput > 0;
476
+ return {
477
+ id: sessionId,
478
+ jsonlPath,
479
+ projectPath,
480
+ projectSlug,
481
+ title,
482
+ startedAt,
483
+ endedAt,
484
+ messageCount: entryStats.userMessages + entryStats.assistantMessages,
485
+ toolCallCount: entryStats.toolCalls,
486
+ hasSubagents,
487
+ subagentIds: hasSubagents
488
+ ? userVisibleSubagents.map((f) => f.agentId)
489
+ : undefined,
490
+ hadAutoCompact: hadAutoCompact || undefined,
491
+ autoCompactAt: autoCompactAt || undefined,
492
+ tokens: hasTokens ? {
493
+ input: totalInput,
494
+ output: totalOutput,
495
+ cacheCreation: entryStats.lastCacheCreation,
496
+ cacheRead: entryStats.lastCacheRead,
497
+ } : undefined,
498
+ fileSizeBytes: stats.size,
499
+ modifiedAt: stats.mtime.toISOString(),
500
+ mode: mode || undefined,
501
+ planCount: planRefs.length > 0 ? planRefs.length : undefined,
502
+ planRefs: planRefs.length > 0 ? planRefs : undefined,
503
+ gitRepoRoot: gitInfo.repoRoot || undefined,
504
+ gitBranch: gitInfo.branch || undefined,
505
+ gitWorktree: gitInfo.worktree || undefined,
506
+ exploreAgents: exploreAgents.length > 0 ? exploreAgents : undefined,
507
+ webSearches: webSearches.length > 0 ? webSearches : undefined,
508
+ };
509
+ }
510
+ catch {
511
+ return null;
512
+ }
513
+ }
514
+ /**
515
+ * List all project directories in ~/.claude/projects/
516
+ */
517
+ export async function listAllProjects() {
518
+ const projects = [];
519
+ try {
520
+ const entries = await fs.readdir(CLAUDE_PROJECTS_PATH, {
521
+ withFileTypes: true,
522
+ });
523
+ for (const entry of entries) {
524
+ if (entry.isDirectory()) {
525
+ const projectPath = await decodeProjectPath(entry.name);
526
+ const projectSlug = path.basename(projectPath);
527
+ projects.push({
528
+ encodedPath: path.join(CLAUDE_PROJECTS_PATH, entry.name),
529
+ projectPath,
530
+ projectSlug,
531
+ });
532
+ }
533
+ }
534
+ }
535
+ catch {
536
+ // Projects directory doesn't exist
537
+ }
538
+ return projects;
539
+ }
540
+ /**
541
+ * Convert a catalog SubagentEntry (type=exploration) to an ExploreAgentRef.
542
+ */
543
+ function catalogSubagentToExploreRef(entry) {
544
+ return {
545
+ id: entry.id,
546
+ description: entry.title,
547
+ timestamp: entry.timestamp,
548
+ tokenCost: entry.tokenCost,
549
+ };
550
+ }
551
+ /**
552
+ * Convert a catalog SubagentEntry (type=search) to a WebSearchRef.
553
+ */
554
+ function catalogSubagentToSearchRef(entry) {
555
+ return {
556
+ query: entry.title,
557
+ resultCount: entry.resultCount || 0,
558
+ timestamp: entry.timestamp,
559
+ };
560
+ }
561
+ /**
562
+ * Convert a catalog PlanEntry to a partial PlanRef.
563
+ * messageIndex is set to 0 since catalog doesn't track this.
564
+ */
565
+ function catalogPlanToPlanRef(plan) {
566
+ return {
567
+ title: plan.title,
568
+ source: "embedded",
569
+ messageIndex: 0,
570
+ catalogId: plan.id,
571
+ };
572
+ }
573
+ /**
574
+ * Read the session manifest JSON from .jacques/sessions/{id}.json.
575
+ * Returns null if file doesn't exist or is unreadable.
576
+ */
577
+ async function readSessionManifest(projectPath, sessionId) {
578
+ try {
579
+ const manifestPath = path.join(projectPath, ".jacques", "sessions", `${sessionId}.json`);
580
+ const content = await fs.readFile(manifestPath, "utf-8");
581
+ return JSON.parse(content);
582
+ }
583
+ catch {
584
+ return null;
585
+ }
586
+ }
587
+ /**
588
+ * Build session entries from catalog data (fast path).
589
+ *
590
+ * For each project:
591
+ * 1. Read .jacques/index.json for catalog metadata
592
+ * 2. List JSONL files in the encoded project dir
593
+ * 3. Stat JSONL files for size/mtime (in parallel)
594
+ * 4. Convert catalog entries to SessionEntry
595
+ * 5. Identify uncataloged JSONL files for fallback parsing
596
+ */
597
+ async function buildFromCatalog(projects) {
598
+ const catalogSessions = [];
599
+ const uncatalogedFiles = [];
600
+ for (const project of projects) {
601
+ // Read catalog index (returns empty default if missing)
602
+ const index = await readProjectIndex(project.projectPath);
603
+ // List JSONL files in the encoded project directory
604
+ let jsonlFilenames = [];
605
+ try {
606
+ const dirEntries = await fs.readdir(project.encodedPath, { withFileTypes: true });
607
+ jsonlFilenames = dirEntries
608
+ .filter((e) => e.isFile() && e.name.endsWith(".jsonl"))
609
+ .map((e) => e.name);
610
+ }
611
+ catch {
612
+ continue; // Skip unreadable directories
613
+ }
614
+ // Build set of cataloged session IDs
615
+ const catalogedSessionIds = new Set(index.sessions.map((s) => s.id));
616
+ // Stat all JSONL files in parallel
617
+ const statResults = await Promise.all(jsonlFilenames.map(async (filename) => {
618
+ const jsonlPath = path.join(project.encodedPath, filename);
619
+ const sessionId = path.basename(filename, ".jsonl");
620
+ try {
621
+ const stats = await fs.stat(jsonlPath);
622
+ return { sessionId, jsonlPath, stats, filename };
623
+ }
624
+ catch {
625
+ return null; // File disappeared
626
+ }
627
+ }));
628
+ for (const result of statResults) {
629
+ if (!result)
630
+ continue;
631
+ const { sessionId, jsonlPath, stats } = result;
632
+ if (!catalogedSessionIds.has(sessionId)) {
633
+ // Not in catalog - needs JSONL parsing
634
+ uncatalogedFiles.push({
635
+ filePath: jsonlPath,
636
+ projectPath: project.projectPath,
637
+ projectSlug: project.projectSlug,
638
+ });
639
+ continue;
640
+ }
641
+ // Find catalog session entry
642
+ const catalogSession = index.sessions.find((s) => s.id === sessionId);
643
+ if (!catalogSession)
644
+ continue;
645
+ // Staleness check: if JSONL is newer than catalog savedAt, re-parse
646
+ const jsonlMtime = stats.mtime.toISOString();
647
+ // Read the session manifest for planRefs and precise mtime check
648
+ const manifest = await readSessionManifest(project.projectPath, sessionId);
649
+ if (catalogSession.savedAt && jsonlMtime > catalogSession.savedAt) {
650
+ if (!manifest || jsonlMtime > manifest.jsonlModifiedAt) {
651
+ uncatalogedFiles.push({
652
+ filePath: jsonlPath,
653
+ projectPath: project.projectPath,
654
+ projectSlug: project.projectSlug,
655
+ });
656
+ continue;
657
+ }
658
+ }
659
+ // Map subagents from index
660
+ const exploreSubagents = index.subagents.filter((s) => s.sessionId === sessionId && s.type === "exploration");
661
+ const searchSubagents = index.subagents.filter((s) => s.sessionId === sessionId && s.type === "search");
662
+ // Use planRefs from manifest (preserves source: embedded/write/agent)
663
+ // Fall back to reconstructing from PlanEntry if manifest lacks planRefs
664
+ let planRefs = [];
665
+ if (manifest?.planRefs && manifest.planRefs.length > 0) {
666
+ // Manifest has full planRefs with correct source types
667
+ planRefs = manifest.planRefs.map((ref) => {
668
+ // Find matching catalogId from planIds
669
+ const catalogId = catalogSession.planIds?.find((pid) => index.plans.some((p) => p.id === pid));
670
+ return {
671
+ title: ref.title,
672
+ source: ref.source,
673
+ messageIndex: ref.messageIndex,
674
+ filePath: ref.filePath,
675
+ agentId: ref.agentId,
676
+ catalogId: ref.catalogId || catalogId,
677
+ };
678
+ });
679
+ }
680
+ else if (catalogSession.planIds) {
681
+ // Fallback: reconstruct from PlanEntry (older manifests without planRefs)
682
+ for (const planId of catalogSession.planIds) {
683
+ const plan = index.plans.find((p) => p.id === planId);
684
+ if (plan) {
685
+ planRefs.push(catalogPlanToPlanRef(plan));
686
+ }
687
+ }
688
+ }
689
+ const exploreAgents = exploreSubagents.map(catalogSubagentToExploreRef);
690
+ const webSearches = searchSubagents.map(catalogSubagentToSearchRef);
691
+ // Detect git info — probe filesystem first, fall back to JSONL
692
+ const gitInfo = detectGitInfo(project.projectPath);
693
+ if (!gitInfo.branch) {
694
+ gitInfo.branch = await readGitBranchFromJsonl(jsonlPath) || undefined;
695
+ }
696
+ // Build SessionEntry from catalog data + file stats
697
+ const entry = {
698
+ id: sessionId,
699
+ jsonlPath,
700
+ projectPath: project.projectPath,
701
+ projectSlug: project.projectSlug,
702
+ title: catalogSession.title,
703
+ startedAt: catalogSession.startedAt,
704
+ endedAt: catalogSession.endedAt,
705
+ messageCount: catalogSession.messageCount,
706
+ toolCallCount: catalogSession.toolCallCount,
707
+ hasSubagents: catalogSession.hasSubagents ?? false,
708
+ subagentIds: catalogSession.subagentIds,
709
+ hadAutoCompact: catalogSession.hadAutoCompact || undefined,
710
+ tokens: catalogSession.tokens,
711
+ fileSizeBytes: stats.size,
712
+ modifiedAt: stats.mtime.toISOString(),
713
+ mode: catalogSession.mode || undefined,
714
+ planCount: planRefs.length > 0 ? planRefs.length : (catalogSession.planCount || undefined),
715
+ planRefs: planRefs.length > 0 ? planRefs : undefined,
716
+ gitRepoRoot: gitInfo.repoRoot || undefined,
717
+ gitBranch: gitInfo.branch || undefined,
718
+ gitWorktree: gitInfo.worktree || undefined,
719
+ exploreAgents: exploreAgents.length > 0 ? exploreAgents : undefined,
720
+ webSearches: webSearches.length > 0 ? webSearches : undefined,
721
+ };
722
+ catalogSessions.push(entry);
723
+ }
724
+ }
725
+ return { catalogSessions, uncatalogedFiles };
726
+ }
727
+ /**
728
+ * Scan all sessions and build the index.
729
+ *
730
+ * Uses catalog-first loading: reads pre-extracted metadata from .jacques/index.json
731
+ * for each project, only falling back to JSONL parsing for new/uncataloged sessions.
732
+ */
733
+ export async function buildSessionIndex(options) {
734
+ const { onProgress } = options || {};
735
+ onProgress?.({
736
+ phase: "scanning",
737
+ total: 0,
738
+ completed: 0,
739
+ current: "Scanning projects...",
740
+ });
741
+ // Get all projects
742
+ const projects = await listAllProjects();
743
+ // Phase 1: Read catalog data (fast - reads .jacques/index.json + stats JSONL files)
744
+ const { catalogSessions, uncatalogedFiles } = await buildFromCatalog(projects);
745
+ const totalFiles = catalogSessions.length + uncatalogedFiles.length;
746
+ onProgress?.({
747
+ phase: "processing",
748
+ total: totalFiles,
749
+ completed: catalogSessions.length,
750
+ current: `${catalogSessions.length} from catalog, ${uncatalogedFiles.length} to parse...`,
751
+ });
752
+ // Phase 2: Parse only uncataloged/stale sessions (slow path - only for new sessions)
753
+ const sessions = [...catalogSessions];
754
+ for (let i = 0; i < uncatalogedFiles.length; i++) {
755
+ const file = uncatalogedFiles[i];
756
+ const sessionId = path.basename(file.filePath, ".jsonl");
757
+ onProgress?.({
758
+ phase: "processing",
759
+ total: totalFiles,
760
+ completed: catalogSessions.length + i,
761
+ current: `${file.projectSlug}/${sessionId.substring(0, 8)}...`,
762
+ });
763
+ const metadata = await extractSessionMetadata(file.filePath, file.projectPath, file.projectSlug);
764
+ if (metadata) {
765
+ sessions.push(metadata);
766
+ }
767
+ }
768
+ // Sort by modification time (newest first)
769
+ sessions.sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime());
770
+ const index = {
771
+ version: "2.0.0",
772
+ lastScanned: new Date().toISOString(),
773
+ sessions,
774
+ };
775
+ // Save to disk
776
+ await writeSessionIndex(index);
777
+ onProgress?.({
778
+ phase: "processing",
779
+ total: totalFiles,
780
+ completed: totalFiles,
781
+ current: "Complete",
782
+ });
783
+ return index;
784
+ }
785
+ /**
786
+ * In-flight build promise — deduplicates concurrent buildSessionIndex() calls.
787
+ * When multiple callers (e.g. /api/sessions/by-project and /api/projects)
788
+ * request the index simultaneously, only one build runs.
789
+ */
790
+ let buildInProgress = null;
791
+ /**
792
+ * Get the session index, building if necessary.
793
+ * Concurrent calls that trigger a rebuild share a single build.
794
+ * @param maxAge Maximum age in milliseconds before rebuilding (default: 5 minutes)
795
+ */
796
+ export async function getSessionIndex(options) {
797
+ const { maxAge = 5 * 60 * 1000 } = options || {};
798
+ const existing = await readSessionIndex();
799
+ // Check if index is fresh enough
800
+ const lastScanned = new Date(existing.lastScanned).getTime();
801
+ const age = Date.now() - lastScanned;
802
+ if (age < maxAge && existing.sessions.length > 0) {
803
+ return existing;
804
+ }
805
+ // Deduplicate concurrent builds
806
+ if (buildInProgress) {
807
+ return buildInProgress;
808
+ }
809
+ buildInProgress = buildSessionIndex().finally(() => {
810
+ buildInProgress = null;
811
+ });
812
+ return buildInProgress;
813
+ }
814
+ /**
815
+ * Get a single session entry by ID
816
+ */
817
+ export async function getSessionEntry(sessionId) {
818
+ const index = await getSessionIndex();
819
+ return index.sessions.find((s) => s.id === sessionId) || null;
820
+ }
821
+ /**
822
+ * Get sessions grouped by project.
823
+ * Uses basename of gitRepoRoot when available to group worktrees together.
824
+ */
825
+ export async function getSessionsByProject() {
826
+ const index = await getSessionIndex();
827
+ const byProject = new Map();
828
+ for (const session of index.sessions) {
829
+ // Group by git repo root basename when available (groups worktrees together)
830
+ const groupKey = session.gitRepoRoot
831
+ ? path.basename(session.gitRepoRoot)
832
+ : session.projectSlug;
833
+ const existing = byProject.get(groupKey) || [];
834
+ existing.push(session);
835
+ byProject.set(groupKey, existing);
836
+ }
837
+ return byProject;
838
+ }
839
+ /**
840
+ * Discover all projects from ~/.claude/projects/, grouped by git repo root.
841
+ * Git worktrees of the same repo are merged into a single project entry.
842
+ * Non-git projects are standalone entries.
843
+ */
844
+ export async function discoverProjects() {
845
+ const rawProjects = await listAllProjects();
846
+ const index = await getSessionIndex();
847
+ // Build a lookup: encoded directory name -> sessions
848
+ // Uses the encoded dir (literal folder name in ~/.claude/projects/) rather than
849
+ // decoded projectPath, because the session index cache may have been built with
850
+ // stale/naive path decoding. The encoded dir name is always stable.
851
+ const sessionsByEncodedDir = new Map();
852
+ for (const session of index.sessions) {
853
+ const encodedDir = path.basename(path.dirname(session.jsonlPath));
854
+ const existing = sessionsByEncodedDir.get(encodedDir) || [];
855
+ existing.push(session);
856
+ sessionsByEncodedDir.set(encodedDir, existing);
857
+ }
858
+ const projectMap = new Map();
859
+ for (const raw of rawProjects) {
860
+ const encodedDir = path.basename(raw.encodedPath);
861
+ const matchingSessions = sessionsByEncodedDir.get(encodedDir) || [];
862
+ // Determine group key and git repo root
863
+ let groupKey;
864
+ let gitRepoRoot = null;
865
+ let isGitProject = false;
866
+ // First: check if any indexed session has gitRepoRoot
867
+ const sessionWithGit = matchingSessions.find((s) => s.gitRepoRoot);
868
+ if (sessionWithGit?.gitRepoRoot) {
869
+ gitRepoRoot = sessionWithGit.gitRepoRoot;
870
+ groupKey = path.basename(gitRepoRoot);
871
+ isGitProject = true;
872
+ }
873
+ else {
874
+ // No git info in index — probe the filesystem
875
+ const gitInfo = detectGitInfo(raw.projectPath);
876
+ if (gitInfo.repoRoot) {
877
+ gitRepoRoot = gitInfo.repoRoot;
878
+ groupKey = path.basename(gitInfo.repoRoot);
879
+ isGitProject = true;
880
+ }
881
+ else {
882
+ // Non-git project: standalone entry
883
+ groupKey = raw.projectSlug;
884
+ isGitProject = false;
885
+ }
886
+ }
887
+ // Find most recent activity among matching sessions
888
+ let latestActivity = null;
889
+ for (const s of matchingSessions) {
890
+ if (s.endedAt && (!latestActivity || s.endedAt > latestActivity)) {
891
+ latestActivity = s.endedAt;
892
+ }
893
+ }
894
+ // Merge into existing group or create new
895
+ const existing = projectMap.get(groupKey);
896
+ if (existing) {
897
+ existing.projectPaths.push(raw.projectPath);
898
+ existing.encodedPaths.push(raw.encodedPath);
899
+ existing.sessionCount += matchingSessions.length;
900
+ if (latestActivity && (!existing.lastActivity || latestActivity > existing.lastActivity)) {
901
+ existing.lastActivity = latestActivity;
902
+ }
903
+ }
904
+ else {
905
+ projectMap.set(groupKey, {
906
+ name: groupKey,
907
+ gitRepoRoot,
908
+ isGitProject,
909
+ projectPaths: [raw.projectPath],
910
+ encodedPaths: [raw.encodedPath],
911
+ sessionCount: matchingSessions.length,
912
+ lastActivity: latestActivity,
913
+ });
914
+ }
915
+ }
916
+ // Second pass: merge non-git projects that have gitBranch into matching git projects.
917
+ // This handles deleted worktrees whose directories no longer exist on disk —
918
+ // detectGitInfo fails but the JSONL sessions still have gitBranch set.
919
+ const nonGitKeys = Array.from(projectMap.entries())
920
+ .filter(([, p]) => !p.isGitProject)
921
+ .map(([key]) => key);
922
+ const gitProjects = Array.from(projectMap.values()).filter((p) => p.isGitProject && p.gitRepoRoot);
923
+ for (const key of nonGitKeys) {
924
+ const project = projectMap.get(key);
925
+ if (!project)
926
+ continue;
927
+ // Check if any session in this project had a git branch
928
+ const allSessions = project.encodedPaths.flatMap((ep) => sessionsByEncodedDir.get(path.basename(ep)) || []);
929
+ const hasGitBranch = allSessions.some((s) => s.gitBranch);
930
+ if (!hasGitBranch)
931
+ continue;
932
+ // Find a git project in the same parent directory
933
+ const projectParent = path.dirname(project.projectPaths[0]);
934
+ const matchingGit = gitProjects.find((gp) => gp.gitRepoRoot && path.dirname(gp.gitRepoRoot) === projectParent);
935
+ if (!matchingGit)
936
+ continue;
937
+ // Merge into the git project
938
+ matchingGit.projectPaths.push(...project.projectPaths);
939
+ matchingGit.encodedPaths.push(...project.encodedPaths);
940
+ matchingGit.sessionCount += project.sessionCount;
941
+ if (project.lastActivity && (!matchingGit.lastActivity || project.lastActivity > matchingGit.lastActivity)) {
942
+ matchingGit.lastActivity = project.lastActivity;
943
+ }
944
+ projectMap.delete(key);
945
+ }
946
+ // Filter out hidden projects
947
+ const hidden = await getHiddenProjects();
948
+ if (hidden.size > 0) {
949
+ for (const [key, project] of projectMap) {
950
+ if (hidden.has(project.name)) {
951
+ projectMap.delete(key);
952
+ }
953
+ }
954
+ }
955
+ // Sort: most recent activity first, then alphabetically
956
+ return Array.from(projectMap.values()).sort((a, b) => {
957
+ if (a.lastActivity && b.lastActivity) {
958
+ return b.lastActivity.localeCompare(a.lastActivity);
959
+ }
960
+ if (a.lastActivity && !b.lastActivity)
961
+ return -1;
962
+ if (!a.lastActivity && b.lastActivity)
963
+ return 1;
964
+ return a.name.localeCompare(b.name);
965
+ });
966
+ }
967
+ /** Path to hidden projects file */
968
+ const HIDDEN_PROJECTS_FILE = path.join(homedir(), ".jacques", "hidden-projects.json");
969
+ /**
970
+ * Get the set of hidden project names.
971
+ */
972
+ async function getHiddenProjects() {
973
+ try {
974
+ const content = await fs.readFile(HIDDEN_PROJECTS_FILE, "utf-8");
975
+ const data = JSON.parse(content);
976
+ if (Array.isArray(data)) {
977
+ return new Set(data);
978
+ }
979
+ }
980
+ catch {
981
+ // File doesn't exist or is invalid
982
+ }
983
+ return new Set();
984
+ }
985
+ /**
986
+ * Hide a project from the discovered list.
987
+ */
988
+ export async function hideProject(name) {
989
+ const hidden = await getHiddenProjects();
990
+ hidden.add(name);
991
+ await fs.mkdir(path.dirname(HIDDEN_PROJECTS_FILE), { recursive: true });
992
+ await fs.writeFile(HIDDEN_PROJECTS_FILE, JSON.stringify([...hidden], null, 2));
993
+ }
994
+ /**
995
+ * Unhide a project (restore it to the discovered list).
996
+ */
997
+ export async function unhideProject(name) {
998
+ const hidden = await getHiddenProjects();
999
+ hidden.delete(name);
1000
+ await fs.writeFile(HIDDEN_PROJECTS_FILE, JSON.stringify([...hidden], null, 2));
1001
+ }
1002
+ /**
1003
+ * Get index statistics
1004
+ */
1005
+ export async function getIndexStats() {
1006
+ const index = await getSessionIndex();
1007
+ // Count unique projects
1008
+ const projects = new Set(index.sessions.map((s) => s.projectSlug));
1009
+ // Sum file sizes
1010
+ const totalSize = index.sessions.reduce((sum, s) => sum + s.fileSizeBytes, 0);
1011
+ return {
1012
+ totalSessions: index.sessions.length,
1013
+ totalProjects: projects.size,
1014
+ totalSizeBytes: totalSize,
1015
+ lastScanned: index.lastScanned,
1016
+ };
1017
+ }
1018
+ /**
1019
+ * Invalidate the index (force rebuild on next read)
1020
+ */
1021
+ export async function invalidateIndex() {
1022
+ try {
1023
+ const indexPath = getIndexPath();
1024
+ await fs.unlink(indexPath);
1025
+ }
1026
+ catch {
1027
+ // Index doesn't exist, nothing to do
1028
+ }
1029
+ }
1030
+ //# sourceMappingURL=session-index.js.map