failproofai 0.0.9-beta.2 → 0.0.10-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/standalone/.cursor/hooks.json +47 -0
- package/.next/standalone/.gemini/settings.json +147 -0
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +2 -2
- package/.next/standalone/.next/server/app/_not-found.rsc +17 -17
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +17 -17
- package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +11 -11
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +2 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +16 -16
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +16 -16
- package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +11 -11
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +8 -8
- package/.next/standalone/.next/server/app/policies/page.js +1 -1
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js +2 -2
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +5 -5
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js +2 -2
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0.~nmr9._.js +3 -0
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0yspgjy._.js → [root-of-the-server]__010i6f5._.js} +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__08px0ym._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0b57.gk._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0dtn9lr._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0kjo7d_._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0vlhtkc._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0wu7fr7._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0yfq1yr._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0z4c5dj._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zso~62._.js +3 -0
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_0z7w.hh._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0-2wr.c._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0.~m-w2._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__09icjsf._.js → [root-of-the-server]__0709m8.._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0bz245.._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0dl0kgt._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0gmhxyo._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0mup1hi._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ohb3gc._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qbpe_v._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s~gy6y._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0t5l7a5._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ymlddl._.js +152 -6
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0vrlxf2._.js → [root-of-the-server]__0ymn496._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0eu4j_n._.js → [root-of-the-server]__10h.ggz._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_03d7qyt._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/{_07a1g.3._.js → _0zx~s__._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_10lm7or._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/app_0cdqd9w._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_0xerkr6._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_0q-m0y-._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/lib_codex-projects_ts_0eosib~._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/lib_copilot-projects_ts_0r8xkn8._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/lib_cursor-projects_ts_0qt1scg._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/lib_gemini-projects_ts_0sl~yqr._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/lib_opencode-projects_ts_0op9gyp._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/lib_pi-projects_ts_103tsh1._.js +3 -0
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/.next/server/pages/404.html +2 -2
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +9 -9
- package/.next/standalone/.next/static/chunks/{06j6c0ofqjy0v.js → 00ay03h8bq4b~.js} +2 -2
- package/.next/standalone/.next/static/chunks/{15_mi91qaeieu.js → 0agmlhk5ml7x5.js} +1 -1
- package/.next/standalone/.next/static/chunks/0bi2r.m~yokoo.js +1 -0
- package/.next/standalone/.next/static/chunks/0en4v5k2nnxks.js +1 -0
- package/.next/standalone/.next/static/chunks/0q5bmqop--9yk.js +1 -0
- package/.next/standalone/.next/static/chunks/{0zdn~84f58hvf.js → 0s6nux54y~l~r.js} +1 -1
- package/.next/standalone/.next/static/chunks/{10mlwc4y_kqo2.js → 0tpse0wu2wwo0.js} +1 -1
- package/.next/standalone/.next/static/chunks/12po2vpc-4_c1.css +1 -0
- package/.next/standalone/.next/static/chunks/{0e8c_1f7-8e7t.js → 1400rtd5ywbt..js} +2 -2
- package/.next/standalone/.next/static/chunks/{0mumk7h5i.1xd.js → 14lmf8boay-zu.js} +1 -1
- package/.next/standalone/.next/static/chunks/{18a9xv2p3~x.9.js → 17htukxga7bil.js} +1 -1
- package/.next/standalone/.opencode/opencode.json +4 -0
- package/.next/standalone/.opencode/plugins/failproofai.mjs +131 -0
- package/.next/standalone/.pi/settings.json +5 -0
- package/.next/standalone/app/components/cli-badge.tsx +7 -11
- package/.next/standalone/app/components/project-list.tsx +32 -4
- package/.next/standalone/app/policies/hooks-client.tsx +31 -15
- package/.next/standalone/app/project/[name]/page.tsx +52 -16
- package/.next/standalone/app/project/[name]/session/[sessionId]/page.tsx +92 -15
- package/.next/standalone/assets/logos/copilot-dark.svg +1 -0
- package/.next/standalone/assets/logos/copilot-light.svg +1 -0
- package/.next/standalone/assets/logos/cursor-dark.svg +1 -0
- package/.next/standalone/assets/logos/cursor-light.svg +1 -0
- package/.next/standalone/assets/logos/gemini-dark.svg +13 -0
- package/.next/standalone/assets/logos/gemini-light.svg +13 -0
- package/.next/standalone/assets/logos/opencode-dark.svg +1 -0
- package/.next/standalone/assets/logos/opencode-light.svg +1 -0
- package/.next/standalone/assets/logos/pi-dark.svg +7 -0
- package/.next/standalone/assets/logos/pi-light.svg +7 -0
- package/.next/standalone/lib/cli-registry.ts +107 -0
- package/.next/standalone/lib/codex-projects.ts +3 -3
- package/.next/standalone/lib/copilot-projects.ts +224 -0
- package/.next/standalone/lib/copilot-sessions.ts +395 -0
- package/.next/standalone/lib/cursor-projects.ts +312 -0
- package/.next/standalone/lib/cursor-sessions.ts +467 -0
- package/.next/standalone/lib/gemini-projects.ts +203 -0
- package/.next/standalone/lib/gemini-sessions.ts +365 -0
- package/.next/standalone/lib/opencode-projects.ts +232 -0
- package/.next/standalone/lib/opencode-sessions.ts +237 -0
- package/.next/standalone/lib/pi-projects.ts +230 -0
- package/.next/standalone/lib/pi-sessions.ts +325 -0
- package/.next/standalone/lib/projects.ts +67 -31
- package/.next/standalone/next.config.ts +5 -4
- package/.next/standalone/package.json +2 -1
- package/.next/standalone/pi-extension/index.ts +373 -0
- package/.next/standalone/pi-extension/package.json +12 -0
- package/.next/standalone/server.js +1 -1
- package/README.md +37 -3
- package/bin/failproofai.mjs +61 -21
- package/dist/cli.mjs +2248 -246
- package/lib/cli-registry.ts +107 -0
- package/lib/codex-projects.ts +3 -3
- package/lib/copilot-projects.ts +224 -0
- package/lib/copilot-sessions.ts +395 -0
- package/lib/cursor-projects.ts +312 -0
- package/lib/cursor-sessions.ts +467 -0
- package/lib/gemini-projects.ts +203 -0
- package/lib/gemini-sessions.ts +365 -0
- package/lib/opencode-projects.ts +232 -0
- package/lib/opencode-sessions.ts +237 -0
- package/lib/pi-projects.ts +230 -0
- package/lib/pi-sessions.ts +325 -0
- package/lib/projects.ts +67 -31
- package/package.json +2 -1
- package/pi-extension/index.ts +373 -0
- package/pi-extension/package.json +12 -0
- package/scripts/translate-docs/mdx-translator.ts +56 -2
- package/scripts/translate-docs/translator.ts +1 -1
- package/src/hooks/builtin-policies.ts +84 -14
- package/src/hooks/handler.ts +67 -5
- package/src/hooks/install-prompt.ts +33 -10
- package/src/hooks/integrations.ts +1007 -6
- package/src/hooks/policy-evaluator.ts +299 -3
- package/src/hooks/resolve-permission-mode.ts +23 -0
- package/src/hooks/types.ts +307 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0g72weg._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0su~k6f._.js +0 -3
- package/.next/standalone/.next/server/chunks/lib_codex-projects_ts_07qqk1g._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01743wx._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__092s1ta._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0g.lg8b._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0gs6wz4._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h..k-e._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0it81ys._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u4a9jq._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11pa2ra._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__12.h2mg._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__12t-wym._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/_04w00cm._.js +0 -3
- package/.next/standalone/.next/static/chunks/0.rk1iwdt1d7c.css +0 -1
- package/.next/standalone/.next/static/chunks/03egp37o1l629.js +0 -1
- package/.next/standalone/.next/static/chunks/06x4-d1~o-opr.js +0 -1
- package/.next/standalone/.next/static/chunks/0n~s0gafwnp2y.js +0 -1
- /package/.next/standalone/.next/static/{SyaO-J1hupjAiRCG-Syzg → 68TLSFdjAQYIulNHfP0QY}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{SyaO-J1hupjAiRCG-Syzg → 68TLSFdjAQYIulNHfP0QY}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{SyaO-J1hupjAiRCG-Syzg → 68TLSFdjAQYIulNHfP0QY}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor Agent CLI session transcript discovery + JSONL parser.
|
|
3
|
+
*
|
|
4
|
+
* Cursor stores per-session state under `~/.cursor/<subdir>/<sessionId>/`
|
|
5
|
+
* (subdir is one of `agent-sessions/`, `conversations/`, or `sessions/` —
|
|
6
|
+
* see `lib/cursor-projects.ts` for the rationale). Each session directory
|
|
7
|
+
* is expected to contain a JSONL transcript. The on-disk format is not
|
|
8
|
+
* fully specified by Cursor's docs — the parser below handles the common
|
|
9
|
+
* `{ type, data, timestamp }` shape and gracefully preserves unknown record
|
|
10
|
+
* types as generic system entries so nothing is silently dropped.
|
|
11
|
+
*
|
|
12
|
+
* If a future Cursor release tightens the format, extend
|
|
13
|
+
* `parseCursorLog()` rather than fanning out new modules; the discovery
|
|
14
|
+
* helpers here key only on filenames + sessionIds.
|
|
15
|
+
*
|
|
16
|
+
* Refs: https://cursor.com/docs/hooks
|
|
17
|
+
*/
|
|
18
|
+
import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
|
|
19
|
+
import { readFile } from "node:fs/promises";
|
|
20
|
+
import { basename, join, resolve, sep } from "node:path";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { runtimeCache } from "./runtime-cache";
|
|
23
|
+
import {
|
|
24
|
+
baseEntry,
|
|
25
|
+
formatTimestamp,
|
|
26
|
+
type LogEntry,
|
|
27
|
+
type UserEntry,
|
|
28
|
+
type AssistantEntry,
|
|
29
|
+
type GenericEntry,
|
|
30
|
+
type QueueOperationEntry,
|
|
31
|
+
type ContentBlock,
|
|
32
|
+
type ToolUseBlock,
|
|
33
|
+
type LogSource,
|
|
34
|
+
} from "./log-entries";
|
|
35
|
+
import { formatDuration } from "./format-duration";
|
|
36
|
+
|
|
37
|
+
// ── Paths ──
|
|
38
|
+
//
|
|
39
|
+
// Cursor's on-disk layout has shifted over releases. As of cursor-agent
|
|
40
|
+
// 2026.04.x, transcripts live at:
|
|
41
|
+
// ~/.cursor/projects/<encoded-cwd>/agent-transcripts/<sessionId>/<sessionId>.jsonl
|
|
42
|
+
//
|
|
43
|
+
// Older releases shipped per-session dirs directly under ~/.cursor/agent-sessions
|
|
44
|
+
// (or conversations/, sessions/) with transcript filenames like events.jsonl.
|
|
45
|
+
// We probe the new layout first and fall back to the legacy candidates so an
|
|
46
|
+
// older install still works.
|
|
47
|
+
|
|
48
|
+
/** Legacy subdirectories under `~/.cursor/` that may carry per-session
|
|
49
|
+
* transcripts (cursor-agent ≤ 2026-04 ish). */
|
|
50
|
+
const LEGACY_SESSION_ROOT_CANDIDATES = ["agent-sessions", "conversations", "sessions"] as const;
|
|
51
|
+
|
|
52
|
+
/** Legacy transcript filenames inside a session dir. */
|
|
53
|
+
const LEGACY_TRANSCRIPT_FILE_CANDIDATES = ["events.jsonl", "transcript.jsonl", "messages.jsonl"] as const;
|
|
54
|
+
|
|
55
|
+
/** New (2026-04+) transcript root: `~/.cursor/projects/<encoded-cwd>/agent-transcripts/`. */
|
|
56
|
+
const NEW_PROJECTS_DIR = "projects";
|
|
57
|
+
const NEW_AGENT_TRANSCRIPTS_DIR = "agent-transcripts";
|
|
58
|
+
|
|
59
|
+
/** Root directory for Cursor session state, honoring CURSOR_HOME. */
|
|
60
|
+
export function getCursorHome(): string {
|
|
61
|
+
return process.env.CURSOR_HOME || join(homedir(), ".cursor");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Locate the session directory for `sessionId` by probing each candidate root.
|
|
65
|
+
* Returns null on path-traversal sessionIds or if no directory is found.
|
|
66
|
+
* Tries the new `projects/<cwd>/agent-transcripts/<sessionId>/` layout first,
|
|
67
|
+
* then the legacy flat candidates. */
|
|
68
|
+
export function getCursorSessionDir(sessionId: string): string | null {
|
|
69
|
+
if (!sessionId) return null;
|
|
70
|
+
const home = resolve(getCursorHome());
|
|
71
|
+
|
|
72
|
+
// New layout: walk ~/.cursor/projects/<cwd>/agent-transcripts/<sessionId>/.
|
|
73
|
+
const projectsRoot = resolve(home, NEW_PROJECTS_DIR);
|
|
74
|
+
let projectEntries: import("node:fs").Dirent[] = [];
|
|
75
|
+
try { projectEntries = readdirSync(projectsRoot, { withFileTypes: true }); } catch { /* missing */ }
|
|
76
|
+
for (const entry of projectEntries) {
|
|
77
|
+
if (!entry.isDirectory()) continue;
|
|
78
|
+
const candidate = resolve(projectsRoot, entry.name, NEW_AGENT_TRANSCRIPTS_DIR, sessionId);
|
|
79
|
+
// Containment check guards path-traversal sessionIds.
|
|
80
|
+
const transcriptRoot = resolve(projectsRoot, entry.name, NEW_AGENT_TRANSCRIPTS_DIR);
|
|
81
|
+
if (candidate === transcriptRoot || !candidate.startsWith(`${transcriptRoot}${sep}`)) continue;
|
|
82
|
+
if (existsSync(candidate)) return candidate;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Legacy fallback.
|
|
86
|
+
for (const sub of LEGACY_SESSION_ROOT_CANDIDATES) {
|
|
87
|
+
const root = resolve(home, sub);
|
|
88
|
+
const candidate = resolve(root, sessionId);
|
|
89
|
+
if (candidate === root || !candidate.startsWith(`${root}${sep}`)) continue;
|
|
90
|
+
if (existsSync(candidate)) return candidate;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Locate the JSONL transcript for a session by probing each filename candidate.
|
|
96
|
+
* Cursor 2026-04+ stores `<sessionId>.jsonl` inside `<sessionId>/`; older
|
|
97
|
+
* layouts use `events.jsonl`/`transcript.jsonl`/`messages.jsonl`. */
|
|
98
|
+
export function findCursorTranscript(sessionId: string): string | null {
|
|
99
|
+
const dir = getCursorSessionDir(sessionId);
|
|
100
|
+
if (!dir) return null;
|
|
101
|
+
// New layout: `<dir>/<sessionId>.jsonl` (matches the parent dir name).
|
|
102
|
+
const newCandidate = join(dir, `${basename(dir)}.jsonl`);
|
|
103
|
+
if (existsSync(newCandidate)) return newCandidate;
|
|
104
|
+
for (const name of LEGACY_TRANSCRIPT_FILE_CANDIDATES) {
|
|
105
|
+
const candidate = join(dir, name);
|
|
106
|
+
if (existsSync(candidate)) return candidate;
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Parser ──
|
|
112
|
+
//
|
|
113
|
+
// The parser handles the common JSONL shape `{ type, data, timestamp }` and
|
|
114
|
+
// degrades gracefully for unknown record types. Field names are intentionally
|
|
115
|
+
// aligned with Copilot's parser (`session.start`, `user.message`,
|
|
116
|
+
// `assistant.message`, `tool.execution_start`, `tool.execution_complete`)
|
|
117
|
+
// since Cursor's hook payloads share most of the snake_case naming. If a real
|
|
118
|
+
// transcript format diverges materially, this module is the single place to
|
|
119
|
+
// adapt — the dashboard renders whatever LogEntry[] the parser produces.
|
|
120
|
+
|
|
121
|
+
interface CursorRecord {
|
|
122
|
+
type?: string;
|
|
123
|
+
data?: Record<string, unknown>;
|
|
124
|
+
id?: string;
|
|
125
|
+
timestamp?: string;
|
|
126
|
+
parentId?: string | null;
|
|
127
|
+
/** Cursor 2026-04+ transcript shape: `{role, message: {content: [...]}}`. */
|
|
128
|
+
role?: "user" | "assistant" | "system" | string;
|
|
129
|
+
message?: {
|
|
130
|
+
content?: Array<{ type?: string; text?: string }>;
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface CursorParseResult {
|
|
135
|
+
entries: LogEntry[];
|
|
136
|
+
rawLines: Record<string, unknown>[];
|
|
137
|
+
/** Working directory pulled from the first session-start record, when available. */
|
|
138
|
+
cwd?: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface CursorToolResult {
|
|
142
|
+
content?: string;
|
|
143
|
+
detailedContent?: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Parse a Cursor JSONL transcript into `LogEntry[]` plus the raw lines.
|
|
148
|
+
* Yields to the event loop every 200 lines so big transcripts don't block
|
|
149
|
+
* the request.
|
|
150
|
+
*/
|
|
151
|
+
export async function parseCursorLog(
|
|
152
|
+
fileContent: string,
|
|
153
|
+
source: LogSource = "session",
|
|
154
|
+
): Promise<CursorParseResult> {
|
|
155
|
+
const lines = fileContent.split("\n").filter((line) => line.trim() !== "");
|
|
156
|
+
const entries: LogEntry[] = [];
|
|
157
|
+
const rawLines: Record<string, unknown>[] = [];
|
|
158
|
+
const toolUseById = new Map<string, ToolUseBlock>();
|
|
159
|
+
const toolUseStartMs = new Map<string, number>();
|
|
160
|
+
let cwd: string | undefined;
|
|
161
|
+
let seenSessionStart = false;
|
|
162
|
+
// Synthesized timestamps for the new `{role, message}` shape, which carries
|
|
163
|
+
// no timestamp field. We use file-position time so entries sort in order.
|
|
164
|
+
const SYNTH_T0 = Date.now();
|
|
165
|
+
|
|
166
|
+
for (let i = 0; i < lines.length; i++) {
|
|
167
|
+
if (i > 0 && i % 200 === 0) await new Promise<void>((r) => setImmediate(r));
|
|
168
|
+
|
|
169
|
+
const line = lines[i];
|
|
170
|
+
let raw: CursorRecord;
|
|
171
|
+
try {
|
|
172
|
+
raw = JSON.parse(line) as CursorRecord;
|
|
173
|
+
} catch {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const rawCopy = { ...(raw as Record<string, unknown>), _source: source };
|
|
178
|
+
rawLines.push(rawCopy);
|
|
179
|
+
|
|
180
|
+
// ── New `{role, message: {content: [...]}}` shape (cursor-agent 2026-04+) ──
|
|
181
|
+
// No `type` / `timestamp`; synthesize a per-record timestamp by index so
|
|
182
|
+
// entries sort in input order.
|
|
183
|
+
if (!raw.type && raw.role && raw.message?.content) {
|
|
184
|
+
const synthDate = new Date(SYNTH_T0 + i);
|
|
185
|
+
const synthTs = synthDate.toISOString();
|
|
186
|
+
const textParts = raw.message.content
|
|
187
|
+
.filter((c) => c?.type === "text" && typeof c.text === "string")
|
|
188
|
+
.map((c) => c.text!)
|
|
189
|
+
.join("");
|
|
190
|
+
if (raw.role === "user") {
|
|
191
|
+
// Strip the synthesized `<timestamp>...</timestamp>\n<user_query>...\n</user_query>`
|
|
192
|
+
// wrapper Cursor adds for context — keep just the user_query body.
|
|
193
|
+
const queryMatch = /<user_query>\s*([\s\S]*?)\s*<\/user_query>/.exec(textParts);
|
|
194
|
+
const text = queryMatch ? queryMatch[1] : textParts;
|
|
195
|
+
if (text) {
|
|
196
|
+
entries.push({
|
|
197
|
+
type: "user",
|
|
198
|
+
...baseEntry(rawCopy, synthTs, synthDate, source),
|
|
199
|
+
message: { role: "user", content: text },
|
|
200
|
+
} satisfies UserEntry);
|
|
201
|
+
}
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (raw.role === "assistant") {
|
|
205
|
+
const blocks: ContentBlock[] = textParts
|
|
206
|
+
? [{ type: "text", text: textParts }]
|
|
207
|
+
: [];
|
|
208
|
+
if (blocks.length === 0) {
|
|
209
|
+
entries.push({
|
|
210
|
+
type: "system",
|
|
211
|
+
...baseEntry(rawCopy, synthTs, synthDate, source),
|
|
212
|
+
raw: rawCopy,
|
|
213
|
+
} satisfies GenericEntry);
|
|
214
|
+
} else {
|
|
215
|
+
entries.push({
|
|
216
|
+
type: "assistant",
|
|
217
|
+
...baseEntry(rawCopy, synthTs, synthDate, source),
|
|
218
|
+
message: { role: "assistant", content: blocks },
|
|
219
|
+
} satisfies AssistantEntry);
|
|
220
|
+
}
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
// Unknown role — preserve raw.
|
|
224
|
+
entries.push({
|
|
225
|
+
type: "system",
|
|
226
|
+
...baseEntry(rawCopy, synthTs, synthDate, source),
|
|
227
|
+
raw: rawCopy,
|
|
228
|
+
} satisfies GenericEntry);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const timestampStr = raw.timestamp;
|
|
233
|
+
if (!timestampStr) continue;
|
|
234
|
+
const date = new Date(timestampStr);
|
|
235
|
+
if (Number.isNaN(date.getTime())) continue;
|
|
236
|
+
const timestamp = date.toISOString();
|
|
237
|
+
|
|
238
|
+
const recType = raw.type;
|
|
239
|
+
const data = raw.data ?? {};
|
|
240
|
+
|
|
241
|
+
// Cursor variants: "session.start", "sessionStart", "session_start" — accept all.
|
|
242
|
+
if (recType === "session.start" || recType === "sessionStart" || recType === "session_start") {
|
|
243
|
+
const ctx = (data.context ?? data) as { cwd?: unknown; workspace_roots?: unknown };
|
|
244
|
+
const c = ctx.cwd;
|
|
245
|
+
if (typeof c === "string" && !cwd) cwd = c;
|
|
246
|
+
// Fallback to workspace_roots[0] (Cursor stdin field).
|
|
247
|
+
if (!cwd && Array.isArray(ctx.workspace_roots) && typeof ctx.workspace_roots[0] === "string") {
|
|
248
|
+
cwd = ctx.workspace_roots[0] as string;
|
|
249
|
+
}
|
|
250
|
+
const label: QueueOperationEntry["label"] = seenSessionStart ? "Session Resumed" : "Session Started";
|
|
251
|
+
seenSessionStart = true;
|
|
252
|
+
entries.push({
|
|
253
|
+
type: "queue-operation",
|
|
254
|
+
...baseEntry(rawCopy, timestamp, date, source),
|
|
255
|
+
label,
|
|
256
|
+
} satisfies QueueOperationEntry);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (recType === "user.message" || recType === "userMessage") {
|
|
261
|
+
const text =
|
|
262
|
+
typeof data.content === "string"
|
|
263
|
+
? data.content
|
|
264
|
+
: typeof data.text === "string"
|
|
265
|
+
? data.text
|
|
266
|
+
: "";
|
|
267
|
+
if (!text) continue;
|
|
268
|
+
entries.push({
|
|
269
|
+
type: "user",
|
|
270
|
+
...baseEntry(rawCopy, timestamp, date, source),
|
|
271
|
+
message: { role: "user", content: text },
|
|
272
|
+
} satisfies UserEntry);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (recType === "system.message" || recType === "systemMessage") {
|
|
277
|
+
entries.push({
|
|
278
|
+
type: "system",
|
|
279
|
+
...baseEntry(rawCopy, timestamp, date, source),
|
|
280
|
+
raw: rawCopy,
|
|
281
|
+
} satisfies GenericEntry);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (recType === "assistant.message" || recType === "assistantMessage") {
|
|
286
|
+
const text =
|
|
287
|
+
typeof data.content === "string"
|
|
288
|
+
? data.content
|
|
289
|
+
: typeof data.text === "string"
|
|
290
|
+
? data.text
|
|
291
|
+
: "";
|
|
292
|
+
if (!text) {
|
|
293
|
+
entries.push({
|
|
294
|
+
type: "system",
|
|
295
|
+
...baseEntry(rawCopy, timestamp, date, source),
|
|
296
|
+
raw: rawCopy,
|
|
297
|
+
} satisfies GenericEntry);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
const blocks: ContentBlock[] = [{ type: "text", text }];
|
|
301
|
+
entries.push({
|
|
302
|
+
type: "assistant",
|
|
303
|
+
...baseEntry(rawCopy, timestamp, date, source),
|
|
304
|
+
message: { role: "assistant", content: blocks },
|
|
305
|
+
} satisfies AssistantEntry);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (
|
|
310
|
+
recType === "tool.execution_start" ||
|
|
311
|
+
recType === "tool.executionStart" ||
|
|
312
|
+
recType === "preToolUse"
|
|
313
|
+
) {
|
|
314
|
+
const callId = (data.toolCallId as string) ?? (data.tool_use_id as string);
|
|
315
|
+
const name = (data.toolName as string) ?? (data.tool_name as string) ?? "tool";
|
|
316
|
+
const args = ((data.arguments ?? data.tool_input) as Record<string, unknown>) ?? {};
|
|
317
|
+
const id = callId ?? `${date.getTime()}-${name}`;
|
|
318
|
+
const toolUse: ToolUseBlock = {
|
|
319
|
+
type: "tool_use",
|
|
320
|
+
id,
|
|
321
|
+
name,
|
|
322
|
+
input: args,
|
|
323
|
+
};
|
|
324
|
+
const entry: AssistantEntry = {
|
|
325
|
+
type: "assistant",
|
|
326
|
+
...baseEntry(rawCopy, timestamp, date, source),
|
|
327
|
+
message: { role: "assistant", content: [toolUse] },
|
|
328
|
+
};
|
|
329
|
+
entries.push(entry);
|
|
330
|
+
if (callId) {
|
|
331
|
+
toolUseById.set(callId, toolUse);
|
|
332
|
+
toolUseStartMs.set(callId, date.getTime());
|
|
333
|
+
}
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (
|
|
338
|
+
recType === "tool.execution_complete" ||
|
|
339
|
+
recType === "tool.executionComplete" ||
|
|
340
|
+
recType === "postToolUse"
|
|
341
|
+
) {
|
|
342
|
+
const callId = (data.toolCallId as string) ?? (data.tool_use_id as string);
|
|
343
|
+
const block = callId ? toolUseById.get(callId) : undefined;
|
|
344
|
+
if (block) {
|
|
345
|
+
const startMs = toolUseStartMs.get(callId!) ?? date.getTime();
|
|
346
|
+
const result = (data.result as CursorToolResult | undefined) ?? {};
|
|
347
|
+
const reportedMs = data.duration as number | undefined;
|
|
348
|
+
const durationMs =
|
|
349
|
+
typeof reportedMs === "number" && reportedMs >= 0
|
|
350
|
+
? reportedMs
|
|
351
|
+
: Math.max(0, date.getTime() - startMs);
|
|
352
|
+
const content =
|
|
353
|
+
result.detailedContent ?? result.content ?? (data.tool_output as string) ?? "";
|
|
354
|
+
block.result = {
|
|
355
|
+
timestamp,
|
|
356
|
+
timestampFormatted: formatTimestamp(date),
|
|
357
|
+
content: typeof content === "string" ? content : JSON.stringify(content),
|
|
358
|
+
durationMs,
|
|
359
|
+
durationFormatted: formatDuration(durationMs),
|
|
360
|
+
};
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
// Orphan tool result — preserve as system.
|
|
364
|
+
entries.push({
|
|
365
|
+
type: "system",
|
|
366
|
+
...baseEntry(rawCopy, timestamp, date, source),
|
|
367
|
+
raw: rawCopy,
|
|
368
|
+
} satisfies GenericEntry);
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Unknown record type — preserve raw so nothing is silently dropped.
|
|
373
|
+
entries.push({
|
|
374
|
+
type: "system",
|
|
375
|
+
...baseEntry(rawCopy, timestamp, date, source),
|
|
376
|
+
raw: rawCopy,
|
|
377
|
+
} satisfies GenericEntry);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (entries.length > 500) await new Promise<void>((r) => setImmediate(r));
|
|
381
|
+
entries.sort((a, b) => a.timestampMs - b.timestampMs);
|
|
382
|
+
|
|
383
|
+
return { entries, rawLines, cwd };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ── Public loader ──
|
|
387
|
+
|
|
388
|
+
export interface CursorSessionLogData {
|
|
389
|
+
entries: LogEntry[];
|
|
390
|
+
rawLines: Record<string, unknown>[];
|
|
391
|
+
cwd?: string;
|
|
392
|
+
filePath: string;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export async function getCursorSessionLog(sessionId: string): Promise<CursorSessionLogData | null> {
|
|
396
|
+
const filePath = findCursorTranscript(sessionId);
|
|
397
|
+
if (!filePath) return null;
|
|
398
|
+
let fileContent: string;
|
|
399
|
+
try {
|
|
400
|
+
fileContent = await readFile(filePath, "utf-8");
|
|
401
|
+
} catch {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
const { entries, rawLines, cwd } = await parseCursorLog(fileContent, "session");
|
|
405
|
+
return { entries, rawLines, cwd, filePath };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export const getCachedCursorSessionLog = runtimeCache(
|
|
409
|
+
(sessionId: string) => getCursorSessionLog(sessionId),
|
|
410
|
+
60,
|
|
411
|
+
{ maxSize: 50 },
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
// ── Test helpers ──
|
|
415
|
+
|
|
416
|
+
/** For tests: read raw stat of the transcript path, returning null on miss. */
|
|
417
|
+
export function _statTranscript(sessionId: string): { mtimeMs: number } | null {
|
|
418
|
+
const path = findCursorTranscript(sessionId);
|
|
419
|
+
if (!path) return null;
|
|
420
|
+
try {
|
|
421
|
+
const s = statSync(path);
|
|
422
|
+
return { mtimeMs: s.mtimeMs };
|
|
423
|
+
} catch {
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** For tests: list session IDs found in any candidate session-state subdir
|
|
429
|
+
* (both the new `projects/<cwd>/agent-transcripts/` layout and the legacy
|
|
430
|
+
* flat candidates). */
|
|
431
|
+
export function _listSessionIds(): string[] {
|
|
432
|
+
const home = getCursorHome();
|
|
433
|
+
const ids: string[] = [];
|
|
434
|
+
// New layout: ~/.cursor/projects/<encoded>/agent-transcripts/<sessionId>/
|
|
435
|
+
try {
|
|
436
|
+
const projectsRoot = join(home, "projects");
|
|
437
|
+
const projectEntries = readdirSync(projectsRoot, { withFileTypes: true });
|
|
438
|
+
for (const proj of projectEntries) {
|
|
439
|
+
if (!proj.isDirectory()) continue;
|
|
440
|
+
try {
|
|
441
|
+
const sessionDirs = readdirSync(join(projectsRoot, proj.name, "agent-transcripts"), { withFileTypes: true });
|
|
442
|
+
for (const e of sessionDirs) if (e.isDirectory()) ids.push(e.name);
|
|
443
|
+
} catch { /* no agent-transcripts under this project */ }
|
|
444
|
+
}
|
|
445
|
+
} catch { /* missing projects/ */ }
|
|
446
|
+
// Legacy flat: ~/.cursor/{agent-sessions,conversations,sessions}/<sessionId>/
|
|
447
|
+
for (const sub of LEGACY_SESSION_ROOT_CANDIDATES) {
|
|
448
|
+
try {
|
|
449
|
+
const entries = readdirSync(join(home, sub), { withFileTypes: true });
|
|
450
|
+
for (const e of entries) if (e.isDirectory()) ids.push(e.name);
|
|
451
|
+
} catch {
|
|
452
|
+
// missing sub — skip
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return ids;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Surface a sync read variant used by lower-level code paths. */
|
|
459
|
+
export function readCursorTranscriptSync(sessionId: string): string | null {
|
|
460
|
+
const path = findCursorTranscript(sessionId);
|
|
461
|
+
if (!path) return null;
|
|
462
|
+
try {
|
|
463
|
+
return readFileSync(path, "utf-8");
|
|
464
|
+
} catch {
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini CLI project discovery.
|
|
3
|
+
*
|
|
4
|
+
* Empirically verified against gemini-cli v0.40.1:
|
|
5
|
+
*
|
|
6
|
+
* • Session-state root: `~/.gemini/tmp/<project-basename>/`. Each subdir
|
|
7
|
+
* corresponds to one project. The basename is the cwd's last path segment
|
|
8
|
+
* (lossy when two projects share a basename — but every dir carries a
|
|
9
|
+
* `.project_root` text file with the absolute cwd to disambiguate).
|
|
10
|
+
* • Project list registry: `~/.gemini/projects.json` maps absolute cwd →
|
|
11
|
+
* basename. Authoritative when present, but we read each `.project_root`
|
|
12
|
+
* anyway so the dashboard tolerates partially-pruned registries.
|
|
13
|
+
* • Per-session file: `~/.gemini/tmp/<project>/chats/session-<ISO-timestamp>-<uuid-prefix>.jsonl`.
|
|
14
|
+
* A sidecar `<file>.jsonl.tool-calls.json` may sit alongside.
|
|
15
|
+
* • File format: JSONL. First line is metadata
|
|
16
|
+
* `{sessionId, projectHash, startTime, lastUpdated, kind}`; subsequent
|
|
17
|
+
* lines are message records `{id, timestamp, type, content: [{text}]}`
|
|
18
|
+
* and `{$set: {...}}` partial updates.
|
|
19
|
+
*
|
|
20
|
+
* As with Cursor / Pi / OpenCode, this module is intentionally permissive — a
|
|
21
|
+
* missing `~/.gemini/` returns `[]`, malformed JSONL falls open without
|
|
22
|
+
* surfacing the session.
|
|
23
|
+
*/
|
|
24
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
25
|
+
import { homedir } from "node:os";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import type { ProjectFolder, SessionFile } from "./projects";
|
|
28
|
+
import { runtimeCache } from "./runtime-cache";
|
|
29
|
+
import { batchAll } from "./concurrency";
|
|
30
|
+
import { formatDate } from "./format-date";
|
|
31
|
+
import { encodeFolderName } from "./paths";
|
|
32
|
+
import { logWarn } from "./logger";
|
|
33
|
+
|
|
34
|
+
/** Filename pattern for a Gemini session JSONL:
|
|
35
|
+
* `session-<ISO-timestamp-with-dashes>-<8-hex-uuid-prefix>.jsonl`. */
|
|
36
|
+
const SESSION_FILE_RE = /^session-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2})-([0-9a-f]{8})\.jsonl$/i;
|
|
37
|
+
|
|
38
|
+
/** Override for tests. Defaults to the live Gemini session-state root. */
|
|
39
|
+
function getGeminiTmpRoot(): string {
|
|
40
|
+
return process.env.GEMINI_SESSIONS_DIR
|
|
41
|
+
|| join(homedir(), ".gemini", "tmp");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface GeminiSessionMeta {
|
|
45
|
+
filePath: string;
|
|
46
|
+
sessionFilename: string;
|
|
47
|
+
cwd: string;
|
|
48
|
+
fileMtime: Date;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function safeReaddir(dir: string) {
|
|
52
|
+
try {
|
|
53
|
+
return await readdir(dir, { withFileTypes: true });
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function statMtime(path: string): Promise<Date | null> {
|
|
60
|
+
try {
|
|
61
|
+
return (await stat(path)).mtime;
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Read `.project_root` to recover the absolute cwd for a basename folder.
|
|
68
|
+
* Returns null if missing or empty (caller treats the folder as un-mappable). */
|
|
69
|
+
async function readProjectRoot(projectDir: string): Promise<string | null> {
|
|
70
|
+
try {
|
|
71
|
+
const text = await readFile(join(projectDir, ".project_root"), "utf-8");
|
|
72
|
+
const trimmed = text.trim();
|
|
73
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function scanGeminiSessions(): Promise<GeminiSessionMeta[]> {
|
|
80
|
+
const root = getGeminiTmpRoot();
|
|
81
|
+
const projectDirs = await safeReaddir(root);
|
|
82
|
+
if (!projectDirs) return [];
|
|
83
|
+
|
|
84
|
+
const out: GeminiSessionMeta[] = [];
|
|
85
|
+
|
|
86
|
+
await batchAll(
|
|
87
|
+
projectDirs
|
|
88
|
+
.filter((d) => d.isDirectory())
|
|
89
|
+
.map((d) => async () => {
|
|
90
|
+
const projectDir = join(root, d.name);
|
|
91
|
+
const cwd = await readProjectRoot(projectDir);
|
|
92
|
+
if (!cwd) return;
|
|
93
|
+
|
|
94
|
+
const chatsDir = join(projectDir, "chats");
|
|
95
|
+
const files = await safeReaddir(chatsDir);
|
|
96
|
+
if (!files) return;
|
|
97
|
+
|
|
98
|
+
for (const f of files) {
|
|
99
|
+
if (!f.isFile()) continue;
|
|
100
|
+
if (!SESSION_FILE_RE.test(f.name)) continue;
|
|
101
|
+
const filePath = join(chatsDir, f.name);
|
|
102
|
+
const mtime = await statMtime(filePath);
|
|
103
|
+
if (!mtime) continue;
|
|
104
|
+
out.push({ filePath, sessionFilename: f.name, cwd, fileMtime: mtime });
|
|
105
|
+
}
|
|
106
|
+
}),
|
|
107
|
+
16,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Returns one ProjectFolder per unique cwd that has at least one session file.
|
|
114
|
+
* `name` is the encoded full-cwd slug (`encodeFolderName(cwd)`), matching the
|
|
115
|
+
* routing scheme used by the dashboard's project URL — `mergeProjectFolders`
|
|
116
|
+
* unions by `name`, and `getGeminiSessionsByEncodedName` looks up by the same
|
|
117
|
+
* slug, so every cross-CLI merge and Gemini-only project link round-trips. */
|
|
118
|
+
export async function getGeminiProjects(): Promise<ProjectFolder[]> {
|
|
119
|
+
const sessions = await scanGeminiSessions();
|
|
120
|
+
const byCwd = new Map<string, { mtime: Date }>();
|
|
121
|
+
for (const s of sessions) {
|
|
122
|
+
const existing = byCwd.get(s.cwd);
|
|
123
|
+
if (!existing || s.fileMtime.getTime() > existing.mtime.getTime()) {
|
|
124
|
+
byCwd.set(s.cwd, { mtime: s.fileMtime });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const folders: ProjectFolder[] = [...byCwd.entries()].map(([cwd, { mtime }]) => ({
|
|
128
|
+
name: encodeFolderName(cwd),
|
|
129
|
+
path: cwd,
|
|
130
|
+
isDirectory: true,
|
|
131
|
+
lastModified: mtime,
|
|
132
|
+
lastModifiedFormatted: formatDate(mtime),
|
|
133
|
+
cli: ["gemini"],
|
|
134
|
+
}));
|
|
135
|
+
folders.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
|
|
136
|
+
return folders;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Returns SessionFile entries for the given absolute cwd. Empty if none. */
|
|
140
|
+
export async function getGeminiSessionsForCwd(cwd: string): Promise<SessionFile[]> {
|
|
141
|
+
const sessions = await scanGeminiSessions();
|
|
142
|
+
const matches = sessions.filter((s) => s.cwd === cwd);
|
|
143
|
+
const files: SessionFile[] = matches.map((s) => {
|
|
144
|
+
const m = s.sessionFilename.match(SESSION_FILE_RE);
|
|
145
|
+
return {
|
|
146
|
+
name: s.sessionFilename,
|
|
147
|
+
path: s.filePath,
|
|
148
|
+
lastModified: s.fileMtime,
|
|
149
|
+
lastModifiedFormatted: formatDate(s.fileMtime),
|
|
150
|
+
sessionId: m ? m[2] : undefined,
|
|
151
|
+
cli: "gemini",
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
files.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
|
|
155
|
+
return files;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface GeminiProjectByName {
|
|
159
|
+
cwd: string | null;
|
|
160
|
+
sessions: SessionFile[];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Looks up Gemini sessions for a project URL slug. `decodeFolderName` is lossy
|
|
165
|
+
* on cwds containing `-`, so we re-encode each session's cwd via
|
|
166
|
+
* `encodeFolderName` and match in that direction. Returns both the canonical
|
|
167
|
+
* cwd and the matching sessions. When two distinct cwds collapse to the same
|
|
168
|
+
* encoded slug we return null to avoid mis-labeling the project.
|
|
169
|
+
*/
|
|
170
|
+
export async function getGeminiSessionsByEncodedName(name: string): Promise<GeminiProjectByName> {
|
|
171
|
+
let metas: GeminiSessionMeta[];
|
|
172
|
+
try {
|
|
173
|
+
metas = await scanGeminiSessions();
|
|
174
|
+
} catch (error) {
|
|
175
|
+
logWarn("Failed to scan Gemini sessions:", error);
|
|
176
|
+
return { cwd: null, sessions: [] };
|
|
177
|
+
}
|
|
178
|
+
const matches = metas.filter((m) => encodeFolderName(m.cwd) === name);
|
|
179
|
+
const uniqueCwds = Array.from(new Set(matches.map((m) => m.cwd)));
|
|
180
|
+
if (uniqueCwds.length !== 1) {
|
|
181
|
+
return { cwd: null, sessions: [] };
|
|
182
|
+
}
|
|
183
|
+
const sessions = matches.map((s) => {
|
|
184
|
+
const m = s.sessionFilename.match(SESSION_FILE_RE);
|
|
185
|
+
return {
|
|
186
|
+
name: s.sessionFilename,
|
|
187
|
+
path: s.filePath,
|
|
188
|
+
lastModified: s.fileMtime,
|
|
189
|
+
lastModifiedFormatted: formatDate(s.fileMtime),
|
|
190
|
+
sessionId: m ? m[2] : undefined,
|
|
191
|
+
cli: "gemini" as const,
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
sessions.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
|
|
195
|
+
return { cwd: uniqueCwds[0], sessions };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export const getCachedGeminiProjects = runtimeCache(getGeminiProjects, 30);
|
|
199
|
+
export const getCachedGeminiSessionsByEncodedName = runtimeCache(
|
|
200
|
+
(name: string) => getGeminiSessionsByEncodedName(name),
|
|
201
|
+
30,
|
|
202
|
+
{ maxSize: 50 },
|
|
203
|
+
);
|