failproofai 0.0.9 → 0.0.10-beta.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 (202) hide show
  1. package/.next/standalone/.cursor/hooks.json +47 -0
  2. package/.next/standalone/.gemini/settings.json +147 -0
  3. package/.next/standalone/.next/BUILD_ID +1 -1
  4. package/.next/standalone/.next/build-manifest.json +3 -3
  5. package/.next/standalone/.next/prerender-manifest.json +3 -3
  6. package/.next/standalone/.next/required-server-files.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page.js +1 -1
  9. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  10. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  11. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  12. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  14. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  15. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  16. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  17. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  18. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  19. package/.next/standalone/.next/server/app/_not-found/page.js +1 -1
  20. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  21. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  22. package/.next/standalone/.next/server/app/_not-found.html +2 -2
  23. package/.next/standalone/.next/server/app/_not-found.rsc +17 -17
  24. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +17 -17
  25. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  26. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +11 -11
  27. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  28. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  29. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  30. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +2 -1
  31. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/index.html +1 -1
  33. package/.next/standalone/.next/server/app/index.rsc +16 -16
  34. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  35. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +16 -16
  36. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  37. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +11 -11
  38. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  39. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  40. package/.next/standalone/.next/server/app/page.js +1 -1
  41. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  43. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +8 -8
  44. package/.next/standalone/.next/server/app/policies/page.js +1 -1
  45. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  46. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  47. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  48. package/.next/standalone/.next/server/app/project/[name]/page.js +2 -2
  49. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  50. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  51. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  52. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  53. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +5 -5
  54. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  55. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  56. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  57. package/.next/standalone/.next/server/app/projects/page.js +2 -2
  58. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  59. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  60. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0.~nmr9._.js +3 -0
  61. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0yspgjy._.js → [root-of-the-server]__010i6f5._.js} +2 -2
  62. package/.next/standalone/.next/server/chunks/[root-of-the-server]__08px0ym._.js +3 -0
  63. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0b57.gk._.js +3 -0
  64. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0dtn9lr._.js +3 -0
  65. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0kjo7d_._.js +1 -1
  66. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0vlhtkc._.js +3 -0
  67. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0wu7fr7._.js +3 -0
  68. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0yfq1yr._.js +3 -0
  69. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0z4c5dj._.js +3 -0
  70. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zso~62._.js +3 -0
  71. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_0z7w.hh._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0-2wr.c._.js +4 -0
  73. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0.~m-w2._.js +4 -0
  74. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__09icjsf._.js → [root-of-the-server]__0709m8.._.js} +3 -3
  75. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0bz245.._.js +4 -0
  76. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0dl0kgt._.js +4 -0
  77. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0gmhxyo._.js +4 -0
  78. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0_b7pgn._.js → [root-of-the-server]__0lkkjl_._.js} +2 -2
  79. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__01g_w_e._.js → [root-of-the-server]__0mb9b9d._.js} +2 -2
  80. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0mup1hi._.js +3 -0
  81. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ohb3gc._.js +4 -0
  82. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qbpe_v._.js +3 -0
  83. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s~gy6y._.js +3 -0
  84. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0t5l7a5._.js +3 -0
  85. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ymlddl._.js +152 -6
  86. package/.next/standalone/.next/server/chunks/ssr/_03d7qyt._.js +3 -0
  87. package/.next/standalone/.next/server/chunks/ssr/{_07a1g.3._.js → _0zx~s__._.js} +2 -2
  88. package/.next/standalone/.next/server/chunks/ssr/_10lm7or._.js +2 -2
  89. package/.next/standalone/.next/server/chunks/ssr/app_0cdqd9w._.js +1 -1
  90. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_0xerkr6._.js +1 -1
  91. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_0q-m0y-._.js +2 -2
  92. package/.next/standalone/.next/server/chunks/ssr/lib_codex-projects_ts_0eosib~._.js +1 -1
  93. package/.next/standalone/.next/server/chunks/ssr/lib_copilot-projects_ts_0r8xkn8._.js +3 -0
  94. package/.next/standalone/.next/server/chunks/ssr/lib_cursor-projects_ts_0qt1scg._.js +3 -0
  95. package/.next/standalone/.next/server/chunks/ssr/lib_gemini-projects_ts_0sl~yqr._.js +3 -0
  96. package/.next/standalone/.next/server/chunks/ssr/lib_opencode-projects_ts_0op9gyp._.js +3 -0
  97. package/.next/standalone/.next/server/chunks/ssr/lib_pi-projects_ts_103tsh1._.js +3 -0
  98. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_esm_build_templates_app-page_0ef3uwk.js +1 -1
  99. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  100. package/.next/standalone/.next/server/pages/404.html +2 -2
  101. package/.next/standalone/.next/server/pages/500.html +1 -1
  102. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  103. package/.next/standalone/.next/server/server-reference-manifest.json +9 -9
  104. package/.next/standalone/.next/static/chunks/{0n-_j_6fo6jex.js → 0-wd3kiz5wrsz.js} +2 -2
  105. package/.next/standalone/.next/static/chunks/{0756i.7omnnl6.js → 0222q~_4u7p6h.js} +1 -1
  106. package/.next/standalone/.next/static/chunks/{0t~iusm_fxoao.js → 02y~6tp1j1wkh.js} +1 -1
  107. package/.next/standalone/.next/static/chunks/{09ose_165ra4d.js → 09qdljea8j.3~.js} +1 -1
  108. package/.next/standalone/.next/static/chunks/0bi2r.m~yokoo.js +1 -0
  109. package/.next/standalone/.next/static/chunks/{11kt_9zaooda3.js → 0pt38lwlsaxvs.js} +1 -1
  110. package/.next/standalone/.next/static/chunks/0q5bmqop--9yk.js +1 -0
  111. package/.next/standalone/.next/static/chunks/{0u-ys71jc4y68.js → 0vl201wjmz17m.js} +2 -2
  112. package/.next/standalone/.next/static/chunks/{0pr7k36o_.du1.js → 0vl~p17i-4qt2.js} +1 -1
  113. package/.next/standalone/.next/static/chunks/0xkzmsj-sniqz.js +1 -0
  114. package/.next/standalone/.next/static/chunks/12po2vpc-4_c1.css +1 -0
  115. package/.next/standalone/.opencode/opencode.json +4 -0
  116. package/.next/standalone/.opencode/plugins/failproofai.mjs +131 -0
  117. package/.next/standalone/.pi/settings.json +5 -0
  118. package/.next/standalone/app/components/cli-badge.tsx +7 -11
  119. package/.next/standalone/app/components/project-list.tsx +32 -4
  120. package/.next/standalone/app/policies/hooks-client.tsx +31 -15
  121. package/.next/standalone/app/project/[name]/page.tsx +52 -16
  122. package/.next/standalone/app/project/[name]/session/[sessionId]/page.tsx +92 -15
  123. package/.next/standalone/assets/logos/copilot-dark.svg +1 -0
  124. package/.next/standalone/assets/logos/copilot-light.svg +1 -0
  125. package/.next/standalone/assets/logos/cursor-dark.svg +1 -0
  126. package/.next/standalone/assets/logos/cursor-light.svg +1 -0
  127. package/.next/standalone/assets/logos/gemini-dark.svg +13 -0
  128. package/.next/standalone/assets/logos/gemini-light.svg +13 -0
  129. package/.next/standalone/assets/logos/opencode-dark.svg +1 -0
  130. package/.next/standalone/assets/logos/opencode-light.svg +1 -0
  131. package/.next/standalone/assets/logos/pi-dark.svg +7 -0
  132. package/.next/standalone/assets/logos/pi-light.svg +7 -0
  133. package/.next/standalone/lib/cli-registry.ts +107 -0
  134. package/.next/standalone/lib/codex-projects.ts +3 -3
  135. package/.next/standalone/lib/copilot-projects.ts +224 -0
  136. package/.next/standalone/lib/copilot-sessions.ts +395 -0
  137. package/.next/standalone/lib/cursor-projects.ts +312 -0
  138. package/.next/standalone/lib/cursor-sessions.ts +467 -0
  139. package/.next/standalone/lib/gemini-projects.ts +203 -0
  140. package/.next/standalone/lib/gemini-sessions.ts +365 -0
  141. package/.next/standalone/lib/opencode-projects.ts +232 -0
  142. package/.next/standalone/lib/opencode-sessions.ts +237 -0
  143. package/.next/standalone/lib/pi-projects.ts +230 -0
  144. package/.next/standalone/lib/pi-sessions.ts +325 -0
  145. package/.next/standalone/lib/projects.ts +67 -31
  146. package/.next/standalone/next.config.ts +5 -4
  147. package/.next/standalone/package.json +2 -1
  148. package/.next/standalone/pi-extension/index.ts +373 -0
  149. package/.next/standalone/pi-extension/package.json +12 -0
  150. package/.next/standalone/server.js +1 -1
  151. package/README.md +37 -3
  152. package/bin/failproofai.mjs +61 -21
  153. package/dist/cli.mjs +2405 -253
  154. package/lib/cli-registry.ts +107 -0
  155. package/lib/codex-projects.ts +3 -3
  156. package/lib/copilot-projects.ts +224 -0
  157. package/lib/copilot-sessions.ts +395 -0
  158. package/lib/cursor-projects.ts +312 -0
  159. package/lib/cursor-sessions.ts +467 -0
  160. package/lib/gemini-projects.ts +203 -0
  161. package/lib/gemini-sessions.ts +365 -0
  162. package/lib/opencode-projects.ts +232 -0
  163. package/lib/opencode-sessions.ts +237 -0
  164. package/lib/pi-projects.ts +230 -0
  165. package/lib/pi-sessions.ts +325 -0
  166. package/lib/projects.ts +67 -31
  167. package/package.json +2 -1
  168. package/pi-extension/index.ts +373 -0
  169. package/pi-extension/package.json +12 -0
  170. package/scripts/install-diagnosis.mjs +190 -0
  171. package/scripts/launch.ts +32 -0
  172. package/scripts/postinstall.mjs +25 -0
  173. package/scripts/translate-docs/mdx-translator.ts +56 -2
  174. package/scripts/translate-docs/translator.ts +1 -1
  175. package/src/hooks/builtin-policies.ts +84 -14
  176. package/src/hooks/handler.ts +67 -5
  177. package/src/hooks/install-prompt.ts +33 -10
  178. package/src/hooks/integrations.ts +1007 -6
  179. package/src/hooks/policy-evaluator.ts +299 -3
  180. package/src/hooks/resolve-permission-mode.ts +23 -0
  181. package/src/hooks/types.ts +307 -3
  182. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0g72weg._.js +0 -3
  183. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0su~k6f._.js +0 -3
  184. package/.next/standalone/.next/server/chunks/lib_codex-projects_ts_07qqk1g._.js +0 -3
  185. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01743wx._.js +0 -3
  186. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__092s1ta._.js +0 -4
  187. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0g.lg8b._.js +0 -4
  188. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0gs6wz4._.js +0 -3
  189. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h..k-e._.js +0 -4
  190. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0it81ys._.js +0 -3
  191. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u4a9jq._.js +0 -4
  192. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11pa2ra._.js +0 -4
  193. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__12.h2mg._.js +0 -3
  194. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__12t-wym._.js +0 -4
  195. package/.next/standalone/.next/server/chunks/ssr/_04w00cm._.js +0 -3
  196. package/.next/standalone/.next/static/chunks/0.rk1iwdt1d7c.css +0 -1
  197. package/.next/standalone/.next/static/chunks/06x4-d1~o-opr.js +0 -1
  198. package/.next/standalone/.next/static/chunks/095l4hc7-h.~~.js +0 -1
  199. package/.next/standalone/.next/static/chunks/0n~s0gafwnp2y.js +0 -1
  200. /package/.next/standalone/.next/static/{A_Ax17P33facL0OmIwFXj → w0GG7S5UEj1-p5g9hfsh2}/_buildManifest.js +0 -0
  201. /package/.next/standalone/.next/static/{A_Ax17P33facL0OmIwFXj → w0GG7S5UEj1-p5g9hfsh2}/_clientMiddlewareManifest.js +0 -0
  202. /package/.next/standalone/.next/static/{A_Ax17P33facL0OmIwFXj → w0GG7S5UEj1-p5g9hfsh2}/_ssgManifest.js +0 -0
@@ -0,0 +1,395 @@
1
+ /**
2
+ * GitHub Copilot CLI session transcript discovery + JSONL parser.
3
+ *
4
+ * Copilot stores per-session state at:
5
+ * ~/.copilot/session-state/<sessionId>/
6
+ * workspace.yaml — session metadata (id, cwd, git_root, branch, …)
7
+ * events.jsonl — event log (only created after first interaction)
8
+ * session.db — per-session SQLite (cross-session index lives at ~/.copilot/session-store.db)
9
+ * checkpoints/index.md — checkpoint history
10
+ * files/, research/ — workspace artifacts
11
+ *
12
+ * (configurable via COPILOT_HOME). Each `events.jsonl` line is a record with
13
+ * shape `{ type, data, id, timestamp, parentId }` where `type` is a dotted
14
+ * path. Verified record types as of Copilot CLI 1.0.39:
15
+ * • session.start — data.sessionId, data.context.{cwd, gitRoot, branch, repository, headCommit}
16
+ * • session.model_change — data.newModel
17
+ * • session.shutdown — data.shutdownType, data.codeChanges, …
18
+ * • system.message — data.role, data.content
19
+ * • user.message — data.content, data.transformedContent
20
+ * • assistant.turn_start — data.turnId, data.interactionId
21
+ * • assistant.message — data.messageId, data.content, data.toolRequests
22
+ * • assistant.turn_end — data.turnId
23
+ * • tool.execution_start — data.toolCallId, data.toolName, data.arguments
24
+ * • tool.execution_complete — data.toolCallId, data.success, data.result.{content, detailedContent}
25
+ *
26
+ * Unknown record types are preserved as generic system entries so nothing is
27
+ * silently dropped.
28
+ *
29
+ * Refs:
30
+ * https://docs.github.com/en/copilot/concepts/agents/copilot-cli/chronicle
31
+ * https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference
32
+ */
33
+ import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
34
+ import { readFile } from "node:fs/promises";
35
+ import { join, resolve, sep } from "node:path";
36
+ import { homedir } from "node:os";
37
+ import { runtimeCache } from "./runtime-cache";
38
+ import {
39
+ baseEntry,
40
+ formatTimestamp,
41
+ type LogEntry,
42
+ type UserEntry,
43
+ type AssistantEntry,
44
+ type GenericEntry,
45
+ type QueueOperationEntry,
46
+ type ContentBlock,
47
+ type ToolUseBlock,
48
+ type LogSource,
49
+ } from "./log-entries";
50
+ import { formatDuration } from "./format-duration";
51
+
52
+ // ── Paths ──
53
+
54
+ /** Root directory for Copilot CLI session state, honoring COPILOT_HOME. */
55
+ export function getCopilotHome(): string {
56
+ return process.env.COPILOT_HOME || join(homedir(), ".copilot");
57
+ }
58
+
59
+ export function getCopilotSessionStateRoot(): string {
60
+ return join(getCopilotHome(), "session-state");
61
+ }
62
+
63
+ /** Session-state dir for a given sessionId. Returns null if `sessionId` is
64
+ * empty or contains path-traversal segments that would escape the
65
+ * session-state root. */
66
+ export function getCopilotSessionDir(sessionId: string): string | null {
67
+ if (!sessionId) return null;
68
+ const root = resolve(getCopilotSessionStateRoot());
69
+ const candidate = resolve(root, sessionId);
70
+ // Containment check: the resolved path must be a child of `root`.
71
+ // (Equality means `sessionId` resolved to root itself — also rejected.)
72
+ if (candidate === root || !candidate.startsWith(`${root}${sep}`)) return null;
73
+ return candidate;
74
+ }
75
+
76
+ /** Resolve a single file under a session directory, applying the same
77
+ * containment check. Returns null if the sessionId is invalid. */
78
+ function resolveSessionFile(sessionId: string, filename: string): string | null {
79
+ const dir = getCopilotSessionDir(sessionId);
80
+ if (!dir) return null;
81
+ return join(dir, filename);
82
+ }
83
+
84
+ // ── Transcript discovery ──
85
+
86
+ /**
87
+ * Locate a Copilot CLI events.jsonl by sessionId. Copilot lays sessions out
88
+ * directly under `session-state/<sessionId>/events.jsonl` (only created after
89
+ * the first user interaction), so the lookup is a single existence check.
90
+ * Path-traversal sessionIds (`../foo`) are rejected.
91
+ *
92
+ * Synchronous so the hook hot path can call it without awaits.
93
+ */
94
+ export function findCopilotTranscript(sessionId: string): string | null {
95
+ const candidate = resolveSessionFile(sessionId, "events.jsonl");
96
+ if (!candidate) return null;
97
+ return existsSync(candidate) ? candidate : null;
98
+ }
99
+
100
+ /** Locate the workspace.yaml for a session (always present, even pre-interaction). */
101
+ export function findCopilotWorkspace(sessionId: string): string | null {
102
+ const candidate = resolveSessionFile(sessionId, "workspace.yaml");
103
+ if (!candidate) return null;
104
+ return existsSync(candidate) ? candidate : null;
105
+ }
106
+
107
+ /**
108
+ * Extract a single key from the YAML at `path` using a permissive regex
109
+ * (avoids adding a YAML parser dep for the handful of flat scalar fields
110
+ * Copilot writes). Returns the trimmed string value or undefined.
111
+ */
112
+ function readYamlScalar(path: string, key: string): string | undefined {
113
+ try {
114
+ const text = readFileSync(path, "utf-8");
115
+ const re = new RegExp(`^${key}\\s*:\\s*(.+?)\\s*$`, "m");
116
+ const m = text.match(re);
117
+ if (!m) return undefined;
118
+ // Strip surrounding quotes if any.
119
+ return m[1].replace(/^['"]|['"]$/g, "");
120
+ } catch {
121
+ return undefined;
122
+ }
123
+ }
124
+
125
+ /** Read the cwd recorded in workspace.yaml for a session. */
126
+ export function readCopilotWorkspaceCwd(sessionId: string): string | undefined {
127
+ const path = findCopilotWorkspace(sessionId);
128
+ if (!path) return undefined;
129
+ return readYamlScalar(path, "cwd");
130
+ }
131
+
132
+ // ── Parser ──
133
+
134
+ interface CopilotRecord {
135
+ type?: string;
136
+ data?: Record<string, unknown>;
137
+ id?: string;
138
+ timestamp?: string;
139
+ parentId?: string | null;
140
+ }
141
+
142
+ interface CopilotParseResult {
143
+ entries: LogEntry[];
144
+ rawLines: Record<string, unknown>[];
145
+ /** Working directory pulled from the first session.start record. */
146
+ cwd?: string;
147
+ }
148
+
149
+ interface CopilotToolResult {
150
+ content?: string;
151
+ detailedContent?: string;
152
+ }
153
+
154
+ interface CopilotToolTelemetry {
155
+ metrics?: { commandTimeMs?: number; durationMs?: number };
156
+ properties?: Record<string, unknown>;
157
+ }
158
+
159
+ /**
160
+ * Parse a Copilot CLI events.jsonl transcript into `LogEntry[]` plus the raw
161
+ * lines. Yields to the event loop every 200 lines so big transcripts don't
162
+ * block the request.
163
+ */
164
+ export async function parseCopilotLog(
165
+ fileContent: string,
166
+ source: LogSource = "session",
167
+ ): Promise<CopilotParseResult> {
168
+ const lines = fileContent.split("\n").filter((line) => line.trim() !== "");
169
+ const entries: LogEntry[] = [];
170
+ const rawLines: Record<string, unknown>[] = [];
171
+ // toolCallId → tool_use block, so we can attach tool.execution_complete back.
172
+ const toolUseById = new Map<string, ToolUseBlock>();
173
+ const toolUseStartMs = new Map<string, number>();
174
+ let cwd: string | undefined;
175
+ let seenSessionStart = false;
176
+
177
+ for (let i = 0; i < lines.length; i++) {
178
+ if (i > 0 && i % 200 === 0) await new Promise<void>((r) => setImmediate(r));
179
+
180
+ const line = lines[i];
181
+ let raw: CopilotRecord;
182
+ try {
183
+ raw = JSON.parse(line) as CopilotRecord;
184
+ } catch {
185
+ continue;
186
+ }
187
+
188
+ const rawCopy = { ...(raw as Record<string, unknown>), _source: source };
189
+ rawLines.push(rawCopy);
190
+
191
+ const timestampStr = raw.timestamp;
192
+ if (!timestampStr) continue;
193
+ const date = new Date(timestampStr);
194
+ if (Number.isNaN(date.getTime())) continue;
195
+ const timestamp = date.toISOString();
196
+
197
+ const recType = raw.type;
198
+ const data = raw.data ?? {};
199
+
200
+ if (recType === "session.start") {
201
+ const ctx = data.context as { cwd?: unknown } | undefined;
202
+ const c = ctx?.cwd;
203
+ if (typeof c === "string" && !cwd) cwd = c;
204
+ const label: QueueOperationEntry["label"] = seenSessionStart ? "Session Resumed" : "Session Started";
205
+ seenSessionStart = true;
206
+ entries.push({
207
+ type: "queue-operation",
208
+ ...baseEntry(rawCopy, timestamp, date, source),
209
+ label,
210
+ } satisfies QueueOperationEntry);
211
+ continue;
212
+ }
213
+
214
+ if (recType === "user.message") {
215
+ const text = (data.content as string) ?? "";
216
+ if (!text) continue;
217
+ entries.push({
218
+ type: "user",
219
+ ...baseEntry(rawCopy, timestamp, date, source),
220
+ message: { role: "user", content: text },
221
+ } satisfies UserEntry);
222
+ continue;
223
+ }
224
+
225
+ if (recType === "system.message") {
226
+ // System prompts are noisy; render as a generic system entry so they're
227
+ // visible in the raw view but don't dominate the structured timeline.
228
+ entries.push({
229
+ type: "system",
230
+ ...baseEntry(rawCopy, timestamp, date, source),
231
+ raw: rawCopy,
232
+ } satisfies GenericEntry);
233
+ continue;
234
+ }
235
+
236
+ if (recType === "assistant.message") {
237
+ const text = (data.content as string) ?? "";
238
+ if (!text) {
239
+ entries.push({
240
+ type: "system",
241
+ ...baseEntry(rawCopy, timestamp, date, source),
242
+ raw: rawCopy,
243
+ } satisfies GenericEntry);
244
+ continue;
245
+ }
246
+ const blocks: ContentBlock[] = [{ type: "text", text }];
247
+ entries.push({
248
+ type: "assistant",
249
+ ...baseEntry(rawCopy, timestamp, date, source),
250
+ message: { role: "assistant", content: blocks },
251
+ } satisfies AssistantEntry);
252
+ continue;
253
+ }
254
+
255
+ if (recType === "tool.execution_start") {
256
+ const callId = data.toolCallId as string | undefined;
257
+ const name = (data.toolName as string) ?? "tool";
258
+ const args = (data.arguments as Record<string, unknown>) ?? {};
259
+ const id = callId ?? `${date.getTime()}-${name}`;
260
+ const toolUse: ToolUseBlock = {
261
+ type: "tool_use",
262
+ id,
263
+ name,
264
+ input: args,
265
+ };
266
+ const entry: AssistantEntry = {
267
+ type: "assistant",
268
+ ...baseEntry(rawCopy, timestamp, date, source),
269
+ message: { role: "assistant", content: [toolUse] },
270
+ };
271
+ entries.push(entry);
272
+ if (callId) {
273
+ toolUseById.set(callId, toolUse);
274
+ toolUseStartMs.set(callId, date.getTime());
275
+ }
276
+ continue;
277
+ }
278
+
279
+ if (recType === "tool.execution_complete") {
280
+ const callId = data.toolCallId as string | undefined;
281
+ const block = callId ? toolUseById.get(callId) : undefined;
282
+ if (block) {
283
+ const startMs = toolUseStartMs.get(callId!) ?? date.getTime();
284
+ const result = (data.result as CopilotToolResult | undefined) ?? {};
285
+ const telemetry = (data.toolTelemetry as CopilotToolTelemetry | undefined) ?? {};
286
+ const reportedMs =
287
+ telemetry.metrics?.commandTimeMs ?? telemetry.metrics?.durationMs ?? null;
288
+ const durationMs =
289
+ typeof reportedMs === "number" && reportedMs >= 0
290
+ ? reportedMs
291
+ : Math.max(0, date.getTime() - startMs);
292
+ const content = result.detailedContent ?? result.content ?? "";
293
+ block.result = {
294
+ timestamp,
295
+ timestampFormatted: formatTimestamp(date),
296
+ content: typeof content === "string" ? content : JSON.stringify(content),
297
+ durationMs,
298
+ durationFormatted: formatDuration(durationMs),
299
+ };
300
+ continue;
301
+ }
302
+ // Orphan tool result — preserve as system.
303
+ entries.push({
304
+ type: "system",
305
+ ...baseEntry(rawCopy, timestamp, date, source),
306
+ raw: rawCopy,
307
+ } satisfies GenericEntry);
308
+ continue;
309
+ }
310
+
311
+ // assistant.turn_start, assistant.turn_end, session.model_change,
312
+ // session.shutdown, and any unrecognized type — preserve raw so nothing is
313
+ // silently dropped, but keep them out of the structured user/assistant
314
+ // timeline (they're scaffolding events).
315
+ entries.push({
316
+ type: "system",
317
+ ...baseEntry(rawCopy, timestamp, date, source),
318
+ raw: rawCopy,
319
+ } satisfies GenericEntry);
320
+ }
321
+
322
+ if (entries.length > 500) await new Promise<void>((r) => setImmediate(r));
323
+ entries.sort((a, b) => a.timestampMs - b.timestampMs);
324
+
325
+ return { entries, rawLines, cwd };
326
+ }
327
+
328
+ // ── Public loader ──
329
+
330
+ export interface CopilotSessionLogData {
331
+ entries: LogEntry[];
332
+ rawLines: Record<string, unknown>[];
333
+ cwd?: string;
334
+ filePath: string;
335
+ }
336
+
337
+ export async function getCopilotSessionLog(sessionId: string): Promise<CopilotSessionLogData | null> {
338
+ const filePath = findCopilotTranscript(sessionId);
339
+ if (!filePath) return null;
340
+ // findCopilotTranscript only proves existence at lookup time; the file can be
341
+ // removed/rotated before this readFile lands. Preserve the nullable contract
342
+ // instead of throwing into the session page.
343
+ let fileContent: string;
344
+ try {
345
+ fileContent = await readFile(filePath, "utf-8");
346
+ } catch {
347
+ return null;
348
+ }
349
+ const { entries, rawLines, cwd } = await parseCopilotLog(fileContent, "session");
350
+ // Fall back to workspace.yaml if events.jsonl didn't expose a session.start.
351
+ const resolvedCwd = cwd ?? readCopilotWorkspaceCwd(sessionId);
352
+ return { entries, rawLines, cwd: resolvedCwd, filePath };
353
+ }
354
+
355
+ export const getCachedCopilotSessionLog = runtimeCache(
356
+ (sessionId: string) => getCopilotSessionLog(sessionId),
357
+ 60,
358
+ { maxSize: 50 },
359
+ );
360
+
361
+ // ── Test helpers ──
362
+
363
+ /** For tests: read raw stat of the events.jsonl path, returning null on miss. */
364
+ export function _statTranscript(sessionId: string): { mtimeMs: number } | null {
365
+ const path = findCopilotTranscript(sessionId);
366
+ if (!path) return null;
367
+ try {
368
+ const s = statSync(path);
369
+ return { mtimeMs: s.mtimeMs };
370
+ } catch {
371
+ return null;
372
+ }
373
+ }
374
+
375
+ /** For tests: list session IDs found in session-state/. */
376
+ export function _listSessionIds(): string[] {
377
+ try {
378
+ return readdirSync(getCopilotSessionStateRoot(), { withFileTypes: true })
379
+ .filter((e) => e.isDirectory())
380
+ .map((e) => e.name);
381
+ } catch {
382
+ return [];
383
+ }
384
+ }
385
+
386
+ /** Surface a sync read variant used by lower-level code paths. */
387
+ export function readCopilotTranscriptSync(sessionId: string): string | null {
388
+ const path = findCopilotTranscript(sessionId);
389
+ if (!path) return null;
390
+ try {
391
+ return readFileSync(path, "utf-8");
392
+ } catch {
393
+ return null;
394
+ }
395
+ }
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Cursor Agent CLI project discovery.
3
+ *
4
+ * Cursor stores per-session state under `~/.cursor/` (configurable via
5
+ * CURSOR_HOME). The exact subdirectory layout is not yet documented in
6
+ * cursor.com/docs/hooks; we probe the candidates Cursor has historically
7
+ * shipped (`agent-sessions/`, `conversations/`, `sessions/`) so this works
8
+ * across installs without a hard-coded version pin. Every candidate is a
9
+ * tolerant `safeReaddir` — a missing directory yields `[]`, never an
10
+ * exception.
11
+ *
12
+ * Each session directory is expected to contain at minimum a metadata file
13
+ * carrying the session's `cwd`. When a JSONL transcript is also present we
14
+ * surface the session under `/projects` keyed by encoded cwd so multiple
15
+ * stores naturally merge in `lib/projects.ts`.
16
+ *
17
+ * Refs: https://cursor.com/docs/hooks (env: CURSOR_PROJECT_DIR,
18
+ * CURSOR_TRANSCRIPT_PATH; transcript format intentionally unspecified).
19
+ */
20
+ import { readdir, readFile, stat } from "node:fs/promises";
21
+ import { homedir } from "node:os";
22
+ import { join } from "node:path";
23
+ import { decodeFolderName, encodeFolderName } from "./paths";
24
+ import type { ProjectFolder, SessionFile } from "./projects";
25
+ import { runtimeCache } from "./runtime-cache";
26
+ import { batchAll } from "./concurrency";
27
+ import { formatDate } from "./format-date";
28
+ import { logWarn } from "./logger";
29
+
30
+ /** Legacy subdirectories under `~/.cursor/` that may carry per-session state
31
+ * (cursor-agent ≤ 2026-04 ish — kept as a fallback). */
32
+ const LEGACY_SESSION_ROOT_CANDIDATES = ["agent-sessions", "conversations", "sessions"] as const;
33
+
34
+ /** Legacy filenames that may carry session-level metadata (cwd, model, …). */
35
+ const META_FILE_CANDIDATES = ["meta.json", "session.json", "workspace.json", "workspace.yaml"] as const;
36
+
37
+ /** Legacy filenames that may carry the JSONL transcript. */
38
+ const LEGACY_TRANSCRIPT_FILE_CANDIDATES = ["events.jsonl", "transcript.jsonl", "messages.jsonl"] as const;
39
+
40
+ /** New (2026-04+) layout: `~/.cursor/projects/<encoded-cwd>/agent-transcripts/<sessionId>/<sessionId>.jsonl`. */
41
+ const NEW_PROJECTS_DIR = "projects";
42
+ const NEW_AGENT_TRANSCRIPTS_DIR = "agent-transcripts";
43
+
44
+ function getCursorHome(): string {
45
+ return process.env.CURSOR_HOME || join(homedir(), ".cursor");
46
+ }
47
+
48
+ interface CursorSessionMeta {
49
+ metaPath: string;
50
+ transcriptPath: string | null;
51
+ sessionId: string;
52
+ cwd: string;
53
+ fileMtime: Date;
54
+ hasTranscript: boolean;
55
+ }
56
+
57
+ async function safeReaddir(dir: string) {
58
+ try {
59
+ return await readdir(dir, { withFileTypes: true });
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ async function statMtime(path: string): Promise<Date | null> {
66
+ try {
67
+ return (await stat(path)).mtime;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ /** Parse a flat scalar for `cwd` from JSON-or-YAML metadata. Tries `JSON.parse`
74
+ * first so escape sequences in JSON strings (e.g. Windows `C:\\Users\\...`) are
75
+ * decoded, then falls back to a permissive YAML-ish regex when the file isn't
76
+ * valid JSON. Tolerant on purpose: Cursor's metadata format is unspecified. */
77
+ function parseCwdFromMetaText(text: string): string | undefined {
78
+ try {
79
+ const parsed = JSON.parse(text) as { cwd?: unknown };
80
+ if (typeof parsed.cwd === "string" && parsed.cwd.length > 0) return parsed.cwd;
81
+ } catch {
82
+ // Not JSON — fall through to the YAML-ish parser.
83
+ }
84
+ // YAML shape: `cwd: /path` (optionally quoted).
85
+ const yaml = text.match(/^\s*cwd\s*:\s*(.+?)\s*$/m);
86
+ if (yaml) {
87
+ const stripped = yaml[1].replace(/^['"]|['"]$/g, "");
88
+ if (stripped.length > 0) return stripped;
89
+ }
90
+ return undefined;
91
+ }
92
+
93
+ /** Try each candidate under `dir`; for metadata files we keep probing until one
94
+ * yields a usable `cwd` (a stale `meta.json` shouldn't shadow a valid
95
+ * `workspace.yaml`). Returns `{path, cwd}` for the first usable file, or null. */
96
+ async function findFirstUsableMeta(
97
+ dir: string,
98
+ candidates: readonly string[],
99
+ ): Promise<{ path: string; cwd: string } | null> {
100
+ for (const name of candidates) {
101
+ const path = join(dir, name);
102
+ if ((await statMtime(path)) === null) continue;
103
+ let text: string;
104
+ try {
105
+ text = await readFile(path, "utf-8");
106
+ } catch {
107
+ continue;
108
+ }
109
+ const cwd = parseCwdFromMetaText(text);
110
+ if (cwd) return { path, cwd };
111
+ }
112
+ return null;
113
+ }
114
+
115
+ /** First existing path from `candidates` under `dir`. Used for transcript files
116
+ * where existence is the only check we can perform without parsing JSONL. */
117
+ async function findFirstExistingPath(dir: string, candidates: readonly string[]): Promise<string | null> {
118
+ for (const name of candidates) {
119
+ const path = join(dir, name);
120
+ if ((await statMtime(path)) !== null) return path;
121
+ }
122
+ return null;
123
+ }
124
+
125
+ async function scanCursorSessions(): Promise<CursorSessionMeta[]> {
126
+ const home = getCursorHome();
127
+ const newCandidates: { sessionId: string; dir: string; cwd: string }[] = [];
128
+ const legacyCandidates: { sessionId: string; dir: string }[] = [];
129
+
130
+ // New layout: ~/.cursor/projects/<encoded-cwd>/agent-transcripts/<sessionId>/<sessionId>.jsonl
131
+ // Cursor's project-folder encoding drops the leading slash (e.g.
132
+ // `home-u-repo` decodes to `/home/u/repo`, not `home/u/repo`). Re-add the
133
+ // leading slash for non-Windows-drive-letter results so the cwd is absolute.
134
+ const projectsRoot = join(home, NEW_PROJECTS_DIR);
135
+ const projectEntries = await safeReaddir(projectsRoot);
136
+ if (projectEntries) {
137
+ for (const proj of projectEntries) {
138
+ if (!proj.isDirectory()) continue;
139
+ const decoded = decodeFolderName(proj.name);
140
+ const cwd = decoded.startsWith("/") || /^[A-Za-z]:\//.test(decoded) ? decoded : `/${decoded}`;
141
+ const transcriptsRoot = join(projectsRoot, proj.name, NEW_AGENT_TRANSCRIPTS_DIR);
142
+ const sessionDirs = await safeReaddir(transcriptsRoot);
143
+ if (!sessionDirs) continue;
144
+ for (const sd of sessionDirs) {
145
+ if (!sd.isDirectory()) continue;
146
+ newCandidates.push({
147
+ sessionId: sd.name,
148
+ dir: join(transcriptsRoot, sd.name),
149
+ cwd,
150
+ });
151
+ }
152
+ }
153
+ }
154
+
155
+ // Legacy layout: ~/.cursor/{agent-sessions,conversations,sessions}/<sessionId>/
156
+ for (const sub of LEGACY_SESSION_ROOT_CANDIDATES) {
157
+ const root = join(home, sub);
158
+ const entries = await safeReaddir(root);
159
+ if (!entries) continue;
160
+ for (const e of entries) {
161
+ if (!e.isDirectory()) continue;
162
+ legacyCandidates.push({ sessionId: e.name, dir: join(root, e.name) });
163
+ }
164
+ }
165
+
166
+ if (newCandidates.length === 0 && legacyCandidates.length === 0) return [];
167
+
168
+ const settled = await batchAll(
169
+ [
170
+ ...newCandidates.map((c) => async (): Promise<CursorSessionMeta | null> => {
171
+ // Transcript file is `<sessionId>.jsonl` inside the dir.
172
+ const transcriptPath = join(c.dir, `${c.sessionId}.jsonl`);
173
+ const transcriptMtime = await statMtime(transcriptPath);
174
+ if (!transcriptMtime) return null;
175
+ return {
176
+ metaPath: c.dir, // No separate meta file; cwd is decoded from the parent dir name.
177
+ transcriptPath,
178
+ sessionId: c.sessionId,
179
+ cwd: c.cwd,
180
+ fileMtime: transcriptMtime,
181
+ hasTranscript: true,
182
+ };
183
+ }),
184
+ ...legacyCandidates.map((c) => async (): Promise<CursorSessionMeta | null> => {
185
+ const meta = await findFirstUsableMeta(c.dir, META_FILE_CANDIDATES);
186
+ if (!meta) return null;
187
+ const transcriptPath = await findFirstExistingPath(c.dir, LEGACY_TRANSCRIPT_FILE_CANDIDATES);
188
+ const transcriptMtime = transcriptPath ? await statMtime(transcriptPath) : null;
189
+ const metaMtime = await statMtime(meta.path);
190
+ const fileMtime =
191
+ transcriptMtime && metaMtime
192
+ ? new Date(Math.max(transcriptMtime.getTime(), metaMtime.getTime()))
193
+ : transcriptMtime ?? metaMtime ?? new Date(0);
194
+ return {
195
+ metaPath: meta.path,
196
+ transcriptPath,
197
+ sessionId: c.sessionId,
198
+ cwd: meta.cwd,
199
+ fileMtime,
200
+ hasTranscript: transcriptPath !== null,
201
+ };
202
+ }),
203
+ ],
204
+ 16,
205
+ );
206
+ return settled
207
+ .filter((r): r is PromiseFulfilledResult<CursorSessionMeta | null> => r.status === "fulfilled")
208
+ .map((r) => r.value)
209
+ .filter((v): v is CursorSessionMeta => v !== null);
210
+ }
211
+
212
+ const cachedScan = runtimeCache(scanCursorSessions, 30);
213
+
214
+ /** Returns one ProjectFolder per unique cwd discovered in Cursor transcripts. */
215
+ export async function getCursorProjects(): Promise<ProjectFolder[]> {
216
+ let metas: CursorSessionMeta[];
217
+ try {
218
+ metas = await cachedScan();
219
+ } catch (error) {
220
+ logWarn("Failed to scan Cursor sessions:", error);
221
+ return [];
222
+ }
223
+
224
+ // Skip metadata-only sessions whose `/projects` row would click through to an
225
+ // empty session list (metasToSessionFiles also filters on hasTranscript).
226
+ const byCwd = new Map<string, { latest: Date; cwd: string }>();
227
+ for (const m of metas) {
228
+ if (!m.hasTranscript) continue;
229
+ const existing = byCwd.get(m.cwd);
230
+ if (!existing || m.fileMtime.getTime() > existing.latest.getTime()) {
231
+ byCwd.set(m.cwd, { latest: m.fileMtime, cwd: m.cwd });
232
+ }
233
+ }
234
+
235
+ const folders: ProjectFolder[] = [];
236
+ for (const { cwd, latest } of byCwd.values()) {
237
+ folders.push({
238
+ name: encodeFolderName(cwd),
239
+ path: cwd,
240
+ isDirectory: true,
241
+ lastModified: latest,
242
+ lastModifiedFormatted: formatDate(latest),
243
+ cli: ["cursor"],
244
+ });
245
+ }
246
+ folders.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
247
+ return folders;
248
+ }
249
+
250
+ function metasToSessionFiles(metas: CursorSessionMeta[]): SessionFile[] {
251
+ const files: SessionFile[] = metas
252
+ .filter((m) => m.hasTranscript && m.transcriptPath)
253
+ .map((m) => ({
254
+ name: m.sessionId,
255
+ path: m.transcriptPath!,
256
+ lastModified: m.fileMtime,
257
+ lastModifiedFormatted: formatDate(m.fileMtime),
258
+ sessionId: m.sessionId,
259
+ cli: "cursor",
260
+ }));
261
+ files.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
262
+ return files;
263
+ }
264
+
265
+ /** Returns SessionFile entries for every Cursor transcript whose cwd matches `cwd` exactly. */
266
+ export async function getCursorSessionsForCwd(cwd: string): Promise<SessionFile[]> {
267
+ let metas: CursorSessionMeta[];
268
+ try {
269
+ metas = await cachedScan();
270
+ } catch (error) {
271
+ logWarn("Failed to scan Cursor sessions:", error);
272
+ return [];
273
+ }
274
+ return metasToSessionFiles(metas.filter((m) => m.cwd === cwd));
275
+ }
276
+
277
+ export interface CursorProjectByName {
278
+ cwd: string | null;
279
+ sessions: SessionFile[];
280
+ }
281
+
282
+ /**
283
+ * Looks up Cursor sessions for a project URL slug. `decodeFolderName` is lossy,
284
+ * so we re-encode each session's cwd and match in that direction. Returns both
285
+ * the canonical cwd and the matching sessions.
286
+ */
287
+ export async function getCursorSessionsByEncodedName(name: string): Promise<CursorProjectByName> {
288
+ let metas: CursorSessionMeta[];
289
+ try {
290
+ metas = await cachedScan();
291
+ } catch (error) {
292
+ logWarn("Failed to scan Cursor sessions:", error);
293
+ return { cwd: null, sessions: [] };
294
+ }
295
+ const matches = metas.filter((m) => m.hasTranscript && encodeFolderName(m.cwd) === name);
296
+ return {
297
+ cwd: matches[0]?.cwd ?? null,
298
+ sessions: metasToSessionFiles(matches),
299
+ };
300
+ }
301
+
302
+ export const getCachedCursorProjects = runtimeCache(getCursorProjects, 30);
303
+ export const getCachedCursorSessionsForCwd = runtimeCache(
304
+ (cwd: string) => getCursorSessionsForCwd(cwd),
305
+ 30,
306
+ { maxSize: 50 },
307
+ );
308
+ export const getCachedCursorSessionsByEncodedName = runtimeCache(
309
+ (name: string) => getCursorSessionsByEncodedName(name),
310
+ 30,
311
+ { maxSize: 50 },
312
+ );