failproofai 0.0.9 → 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]__0_b7pgn._.js → [root-of-the-server]__0ymn496._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__01g_w_e._.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/{0n-_j_6fo6jex.js → 00ay03h8bq4b~.js} +2 -2
- package/.next/standalone/.next/static/chunks/{11kt_9zaooda3.js → 0agmlhk5ml7x5.js} +1 -1
- package/.next/standalone/.next/static/chunks/0bi2r.m~yokoo.js +1 -0
- package/.next/standalone/.next/static/chunks/{095l4hc7-h.~~.js → 0en4v5k2nnxks.js} +1 -1
- package/.next/standalone/.next/static/chunks/0q5bmqop--9yk.js +1 -0
- package/.next/standalone/.next/static/chunks/{0756i.7omnnl6.js → 0s6nux54y~l~r.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0t~iusm_fxoao.js → 0tpse0wu2wwo0.js} +1 -1
- package/.next/standalone/.next/static/chunks/12po2vpc-4_c1.css +1 -0
- package/.next/standalone/.next/static/chunks/{0u-ys71jc4y68.js → 1400rtd5ywbt..js} +2 -2
- package/.next/standalone/.next/static/chunks/{09ose_165ra4d.js → 14lmf8boay-zu.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0pr7k36o_.du1.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/06x4-d1~o-opr.js +0 -1
- package/.next/standalone/.next/static/chunks/0n~s0gafwnp2y.js +0 -1
- /package/.next/standalone/.next/static/{A_Ax17P33facL0OmIwFXj → 68TLSFdjAQYIulNHfP0QY}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{A_Ax17P33facL0OmIwFXj → 68TLSFdjAQYIulNHfP0QY}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{A_Ax17P33facL0OmIwFXj → 68TLSFdjAQYIulNHfP0QY}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini CLI session transcript discovery + JSONL parser.
|
|
3
|
+
*
|
|
4
|
+
* Empirically verified against gemini-cli v0.40.1:
|
|
5
|
+
*
|
|
6
|
+
* Session files live at
|
|
7
|
+
* `~/.gemini/tmp/<project-basename>/chats/session-<ISO-timestamp-with-dashes>-<8-hex-uuid-prefix>.jsonl`
|
|
8
|
+
* with a sidecar `<file>.jsonl.tool-calls.json` of tool-call records.
|
|
9
|
+
* The basename component is just the cwd's last path segment; the canonical
|
|
10
|
+
* cwd lives at `~/.gemini/tmp/<project-basename>/.project_root`.
|
|
11
|
+
*
|
|
12
|
+
* JSONL record schema (observed):
|
|
13
|
+
* • Line 1 (metadata): `{sessionId, projectHash, startTime, lastUpdated, kind}`
|
|
14
|
+
* • Subsequent lines:
|
|
15
|
+
* `{id, timestamp, type: "user" | "assistant" | ..., content: [{text}]}` (messages)
|
|
16
|
+
* `{$set: {lastUpdated: "..."}}` (partial update)
|
|
17
|
+
*
|
|
18
|
+
* Parser is intentionally permissive — unknown record types degrade to
|
|
19
|
+
* "system" entries; malformed lines are skipped without aborting; and the
|
|
20
|
+
* loader fall-opens (returns null) on any I/O or parse failure.
|
|
21
|
+
*/
|
|
22
|
+
import { closeSync, openSync, readFileSync, readSync, readdirSync, existsSync, statSync } from "node:fs";
|
|
23
|
+
import { readFile } from "node:fs/promises";
|
|
24
|
+
import { join, resolve, sep } from "node:path";
|
|
25
|
+
import { homedir } from "node:os";
|
|
26
|
+
import { runtimeCache } from "./runtime-cache";
|
|
27
|
+
import {
|
|
28
|
+
baseEntry,
|
|
29
|
+
type LogEntry,
|
|
30
|
+
type UserEntry,
|
|
31
|
+
type AssistantEntry,
|
|
32
|
+
type GenericEntry,
|
|
33
|
+
type QueueOperationEntry,
|
|
34
|
+
type ContentBlock,
|
|
35
|
+
type LogSource,
|
|
36
|
+
} from "./log-entries";
|
|
37
|
+
|
|
38
|
+
// ── Paths ──
|
|
39
|
+
|
|
40
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
41
|
+
/** Matches `session-<timestamp-of-arbitrary-shape>-<8-hex-uuid-prefix>.jsonl`.
|
|
42
|
+
* Empirically gemini-cli v0.40.1 emits minute-precision (`YYYY-MM-DDTHH-mm`),
|
|
43
|
+
* but the documented format includes seconds (`YYYY-MM-DDTHH-mm:ss`) and may
|
|
44
|
+
* evolve further. The first-line `sessionId` validation in findGeminiTranscript
|
|
45
|
+
* is the load-bearing safety check, so we accept any timestamp shape. */
|
|
46
|
+
const SESSION_FILE_RE = /^session-(.+)-([0-9a-f]{8})\.jsonl$/i;
|
|
47
|
+
|
|
48
|
+
/** Root directory for Gemini session state, honoring GEMINI_SESSIONS_DIR. */
|
|
49
|
+
export function getGeminiSessionStateRoot(): string {
|
|
50
|
+
return process.env.GEMINI_SESSIONS_DIR
|
|
51
|
+
|| join(homedir(), ".gemini", "tmp");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Reject a sessionId that isn't a UUID — defends against path traversal. */
|
|
55
|
+
function isSafeSessionId(sessionId: string): boolean {
|
|
56
|
+
return UUID_RE.test(sessionId);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Read up to the first newline from `path` without loading the whole file.
|
|
61
|
+
* Used by `findGeminiTranscript` to inspect the JSONL metadata header on
|
|
62
|
+
* candidate matches; the typical header is well under 1KB and we cap at 4KB
|
|
63
|
+
* for safety so a degenerate single-line file can't blow memory.
|
|
64
|
+
*
|
|
65
|
+
* Returns the first line as a UTF-8 string, or null on read failure / empty
|
|
66
|
+
* file. If the first 4KB contain no newline, returns whatever was read so the
|
|
67
|
+
* JSON.parse caller can still attempt — JSON.parse will fail for a truncated
|
|
68
|
+
* object, which findGeminiTranscript treats as a malformed-header miss.
|
|
69
|
+
*/
|
|
70
|
+
function readFirstLineSync(path: string): string | null {
|
|
71
|
+
let fd: number;
|
|
72
|
+
try {
|
|
73
|
+
fd = openSync(path, "r");
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const buf = Buffer.alloc(4096);
|
|
79
|
+
const bytesRead = readSync(fd, buf, 0, buf.length, 0);
|
|
80
|
+
if (bytesRead === 0) return null;
|
|
81
|
+
const text = buf.subarray(0, bytesRead).toString("utf-8");
|
|
82
|
+
const nl = text.indexOf("\n");
|
|
83
|
+
return nl >= 0 ? text.slice(0, nl) : text;
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
} finally {
|
|
87
|
+
try { closeSync(fd); } catch { /* best-effort */ }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Find the JSONL transcript for `sessionId`. Walks every per-project subdir's
|
|
93
|
+
* `chats/` directory, matches the 8-hex prefix, and verifies the first record's
|
|
94
|
+
* full `sessionId` field matches. Rejects path-traversal sessionIds and
|
|
95
|
+
* verifies resolved paths stay under the root. Returns null on miss.
|
|
96
|
+
*/
|
|
97
|
+
export function findGeminiTranscript(sessionId: string): string | null {
|
|
98
|
+
if (!isSafeSessionId(sessionId)) return null;
|
|
99
|
+
const root = resolve(getGeminiSessionStateRoot());
|
|
100
|
+
const wantPrefix = sessionId.slice(0, 8).toLowerCase();
|
|
101
|
+
|
|
102
|
+
let projectDirs: string[];
|
|
103
|
+
try {
|
|
104
|
+
projectDirs = readdirSync(root);
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const projectDir of projectDirs) {
|
|
110
|
+
const projectPath = resolve(root, projectDir);
|
|
111
|
+
if (!projectPath.startsWith(`${root}${sep}`)) continue;
|
|
112
|
+
|
|
113
|
+
const chatsDir = resolve(projectPath, "chats");
|
|
114
|
+
let files: string[];
|
|
115
|
+
try {
|
|
116
|
+
files = readdirSync(chatsDir);
|
|
117
|
+
} catch {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
for (const f of files) {
|
|
122
|
+
const m = SESSION_FILE_RE.exec(f);
|
|
123
|
+
if (!m || m[2].toLowerCase() !== wantPrefix) continue;
|
|
124
|
+
const candidate = resolve(chatsDir, f);
|
|
125
|
+
if (!candidate.startsWith(`${chatsDir}${sep}`)) continue;
|
|
126
|
+
if (!existsSync(candidate)) continue;
|
|
127
|
+
// Confirm the full sessionId — the 8-hex prefix isn't unique on its own.
|
|
128
|
+
// Read only the first ~4KB so large transcripts don't bloat memory; the
|
|
129
|
+
// metadata header sits on line 1 well within that bound. The full file
|
|
130
|
+
// is re-read in getGeminiSessionLog() once we've matched.
|
|
131
|
+
const firstLine = readFirstLineSync(candidate);
|
|
132
|
+
if (!firstLine) continue;
|
|
133
|
+
try {
|
|
134
|
+
const meta = JSON.parse(firstLine) as { sessionId?: unknown };
|
|
135
|
+
if (typeof meta.sessionId === "string" && meta.sessionId.toLowerCase() === sessionId.toLowerCase()) {
|
|
136
|
+
return candidate;
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
// Malformed first record — try next file.
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Parser ──
|
|
148
|
+
|
|
149
|
+
interface GeminiSessionRecord {
|
|
150
|
+
// Metadata-line fields (line 1)
|
|
151
|
+
sessionId?: string;
|
|
152
|
+
projectHash?: string;
|
|
153
|
+
startTime?: string;
|
|
154
|
+
lastUpdated?: string;
|
|
155
|
+
kind?: string;
|
|
156
|
+
// Message-line fields
|
|
157
|
+
id?: string;
|
|
158
|
+
timestamp?: string;
|
|
159
|
+
type?: string;
|
|
160
|
+
content?: Array<Record<string, unknown>>;
|
|
161
|
+
// Partial-update line: `{$set: {...}}`
|
|
162
|
+
$set?: Record<string, unknown>;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
interface GeminiParseResult {
|
|
166
|
+
entries: LogEntry[];
|
|
167
|
+
rawLines: Record<string, unknown>[];
|
|
168
|
+
/** Working directory pulled from the sidecar `.project_root` if available. */
|
|
169
|
+
cwd?: string;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function extractMessageText(content: Array<Record<string, unknown>> | undefined): string {
|
|
173
|
+
if (!Array.isArray(content)) return "";
|
|
174
|
+
const parts: string[] = [];
|
|
175
|
+
for (const block of content) {
|
|
176
|
+
if (typeof block?.text === "string") parts.push(block.text);
|
|
177
|
+
}
|
|
178
|
+
return parts.join("\n\n");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function buildAssistantContent(content: Array<Record<string, unknown>> | undefined): ContentBlock[] {
|
|
182
|
+
if (!Array.isArray(content)) return [];
|
|
183
|
+
const blocks: ContentBlock[] = [];
|
|
184
|
+
for (const block of content) {
|
|
185
|
+
if (typeof block?.text === "string" && block.text.length > 0) {
|
|
186
|
+
blocks.push({ type: "text", text: block.text });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return blocks;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Parse a Gemini JSONL transcript into `LogEntry[]` plus the raw lines.
|
|
194
|
+
* Yields to the event loop every 200 lines so big transcripts don't block
|
|
195
|
+
* the request.
|
|
196
|
+
*/
|
|
197
|
+
export async function parseGeminiLog(
|
|
198
|
+
fileContent: string,
|
|
199
|
+
source: LogSource = "session",
|
|
200
|
+
cwdHint?: string,
|
|
201
|
+
): Promise<GeminiParseResult> {
|
|
202
|
+
const lines = fileContent.split("\n").filter((line) => line.trim() !== "");
|
|
203
|
+
const entries: LogEntry[] = [];
|
|
204
|
+
const rawLines: Record<string, unknown>[] = [];
|
|
205
|
+
let cwd: string | undefined = cwdHint;
|
|
206
|
+
let seenStart = false;
|
|
207
|
+
|
|
208
|
+
for (let i = 0; i < lines.length; i++) {
|
|
209
|
+
if (i > 0 && i % 200 === 0) await new Promise<void>((r) => setImmediate(r));
|
|
210
|
+
|
|
211
|
+
const line = lines[i];
|
|
212
|
+
let raw: GeminiSessionRecord;
|
|
213
|
+
try {
|
|
214
|
+
raw = JSON.parse(line) as GeminiSessionRecord;
|
|
215
|
+
} catch {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const rawCopy = { ...(raw as Record<string, unknown>), _source: source };
|
|
220
|
+
rawLines.push(rawCopy);
|
|
221
|
+
|
|
222
|
+
// Metadata line — derive a synthetic "Session Started" entry from startTime.
|
|
223
|
+
if (typeof raw.sessionId === "string" && typeof raw.startTime === "string") {
|
|
224
|
+
const date = new Date(raw.startTime);
|
|
225
|
+
if (!Number.isNaN(date.getTime())) {
|
|
226
|
+
const label: QueueOperationEntry["label"] = seenStart ? "Session Resumed" : "Session Started";
|
|
227
|
+
seenStart = true;
|
|
228
|
+
entries.push({
|
|
229
|
+
type: "queue-operation",
|
|
230
|
+
...baseEntry(rawCopy, date.toISOString(), date, source),
|
|
231
|
+
label,
|
|
232
|
+
} satisfies QueueOperationEntry);
|
|
233
|
+
}
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Partial-update line — preserve in raw but skip rendering.
|
|
238
|
+
if (raw.$set) continue;
|
|
239
|
+
|
|
240
|
+
const timestampStr = raw.timestamp;
|
|
241
|
+
if (!timestampStr) continue;
|
|
242
|
+
const date = new Date(timestampStr);
|
|
243
|
+
if (Number.isNaN(date.getTime())) continue;
|
|
244
|
+
const timestamp = date.toISOString();
|
|
245
|
+
|
|
246
|
+
const recType = raw.type;
|
|
247
|
+
|
|
248
|
+
if (recType === "user") {
|
|
249
|
+
const text = extractMessageText(raw.content);
|
|
250
|
+
if (!text) continue;
|
|
251
|
+
entries.push({
|
|
252
|
+
type: "user",
|
|
253
|
+
...baseEntry(rawCopy, timestamp, date, source),
|
|
254
|
+
message: { role: "user", content: text },
|
|
255
|
+
} satisfies UserEntry);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (recType === "assistant" || recType === "model") {
|
|
260
|
+
const blocks = buildAssistantContent(raw.content);
|
|
261
|
+
if (blocks.length === 0) {
|
|
262
|
+
entries.push({
|
|
263
|
+
type: "system",
|
|
264
|
+
...baseEntry(rawCopy, timestamp, date, source),
|
|
265
|
+
raw: rawCopy,
|
|
266
|
+
} satisfies GenericEntry);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
entries.push({
|
|
270
|
+
type: "assistant",
|
|
271
|
+
...baseEntry(rawCopy, timestamp, date, source),
|
|
272
|
+
message: { role: "assistant", content: blocks },
|
|
273
|
+
} satisfies AssistantEntry);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Unknown type — preserve raw.
|
|
278
|
+
entries.push({
|
|
279
|
+
type: "system",
|
|
280
|
+
...baseEntry(rawCopy, timestamp, date, source),
|
|
281
|
+
raw: rawCopy,
|
|
282
|
+
} satisfies GenericEntry);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (entries.length > 500) await new Promise<void>((r) => setImmediate(r));
|
|
286
|
+
entries.sort((a, b) => a.timestampMs - b.timestampMs);
|
|
287
|
+
|
|
288
|
+
return { entries, rawLines, cwd };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── Public loader ──
|
|
292
|
+
|
|
293
|
+
export interface GeminiSessionLogData {
|
|
294
|
+
entries: LogEntry[];
|
|
295
|
+
rawLines: Record<string, unknown>[];
|
|
296
|
+
cwd?: string;
|
|
297
|
+
filePath: string;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Read `.project_root` next to the chats dir to recover the absolute cwd. */
|
|
301
|
+
function readSiblingProjectRoot(transcriptPath: string): string | undefined {
|
|
302
|
+
// transcriptPath = .../<basename>/chats/<file>.jsonl
|
|
303
|
+
const chatsDir = resolve(transcriptPath, "..");
|
|
304
|
+
const projectDir = resolve(chatsDir, "..");
|
|
305
|
+
const rootFile = join(projectDir, ".project_root");
|
|
306
|
+
try {
|
|
307
|
+
const text = readFileSync(rootFile, "utf-8").trim();
|
|
308
|
+
return text.length > 0 ? text : undefined;
|
|
309
|
+
} catch {
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export async function getGeminiSessionLog(sessionId: string): Promise<GeminiSessionLogData | null> {
|
|
315
|
+
const filePath = findGeminiTranscript(sessionId);
|
|
316
|
+
if (!filePath) return null;
|
|
317
|
+
let fileContent: string;
|
|
318
|
+
try {
|
|
319
|
+
fileContent = await readFile(filePath, "utf-8");
|
|
320
|
+
} catch {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
const cwdHint = readSiblingProjectRoot(filePath);
|
|
324
|
+
let parsed: GeminiParseResult;
|
|
325
|
+
try {
|
|
326
|
+
parsed = await parseGeminiLog(fileContent, "session", cwdHint);
|
|
327
|
+
} catch {
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
return {
|
|
331
|
+
entries: parsed.entries,
|
|
332
|
+
rawLines: parsed.rawLines,
|
|
333
|
+
cwd: parsed.cwd,
|
|
334
|
+
filePath,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export const getCachedGeminiSessionLog = runtimeCache(
|
|
339
|
+
(sessionId: string) => getGeminiSessionLog(sessionId),
|
|
340
|
+
60,
|
|
341
|
+
{ maxSize: 50 },
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
// ── Test helpers ──
|
|
345
|
+
|
|
346
|
+
export function _statGeminiTranscript(sessionId: string): { mtimeMs: number } | null {
|
|
347
|
+
const path = findGeminiTranscript(sessionId);
|
|
348
|
+
if (!path) return null;
|
|
349
|
+
try {
|
|
350
|
+
const s = statSync(path);
|
|
351
|
+
return { mtimeMs: s.mtimeMs };
|
|
352
|
+
} catch {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function readGeminiTranscriptSync(sessionId: string): string | null {
|
|
358
|
+
const path = findGeminiTranscript(sessionId);
|
|
359
|
+
if (!path) return null;
|
|
360
|
+
try {
|
|
361
|
+
return readFileSync(path, "utf-8");
|
|
362
|
+
} catch {
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode (sst/opencode) project discovery.
|
|
3
|
+
*
|
|
4
|
+
* Unlike the other agent CLIs that store sessions as filesystem JSONL trees,
|
|
5
|
+
* OpenCode stores everything in a single SQLite database (managed by
|
|
6
|
+
* Drizzle ORM) at `~/.local/share/opencode/opencode.db`. Rather than read
|
|
7
|
+
* the DB directly with `bun:sqlite` (which would couple us to opencode's
|
|
8
|
+
* internal schema), we shell out to `opencode db --format json "<sql>"` —
|
|
9
|
+
* the same surface opencode itself documents as read-only safe.
|
|
10
|
+
*
|
|
11
|
+
* Verified live against opencode v1.14.31:
|
|
12
|
+
* • `session` columns: id, project_id, parent_id, slug, directory, title,
|
|
13
|
+
* time_created, time_updated, …
|
|
14
|
+
* • `project` columns: id, worktree, vcs, name, time_created, time_updated, …
|
|
15
|
+
* • Session ID format: `ses_*`. Project ID is a content-addressable hash
|
|
16
|
+
* of the worktree path.
|
|
17
|
+
*
|
|
18
|
+
* If the `opencode` binary is absent on PATH, every operation degrades to
|
|
19
|
+
* an empty result — same fail-open contract as the other per-CLI providers.
|
|
20
|
+
*
|
|
21
|
+
* Refs: https://opencode.ai/docs/ (CLI reference)
|
|
22
|
+
* https://opencode.ai/docs/plugins/ (plugin model context)
|
|
23
|
+
*/
|
|
24
|
+
import { execFileSync } from "node:child_process";
|
|
25
|
+
import { basename } from "node:path";
|
|
26
|
+
import { encodeFolderName } from "./paths";
|
|
27
|
+
import type { ProjectFolder, SessionFile } from "./projects";
|
|
28
|
+
import { runtimeCache } from "./runtime-cache";
|
|
29
|
+
import { formatDate } from "./format-date";
|
|
30
|
+
import { logWarn } from "./logger";
|
|
31
|
+
|
|
32
|
+
/** Approximate cap on rows pulled per query — enough for hundreds of projects. */
|
|
33
|
+
const SESSION_LIMIT = 1000;
|
|
34
|
+
|
|
35
|
+
interface OpenCodeSessionRow {
|
|
36
|
+
id: string;
|
|
37
|
+
project_id: string;
|
|
38
|
+
slug: string | null;
|
|
39
|
+
directory: string | null;
|
|
40
|
+
title: string | null;
|
|
41
|
+
time_created: number;
|
|
42
|
+
time_updated: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface OpenCodeProjectRow {
|
|
46
|
+
id: string;
|
|
47
|
+
worktree: string | null;
|
|
48
|
+
vcs: string | null;
|
|
49
|
+
name: string | null;
|
|
50
|
+
time_created: number;
|
|
51
|
+
time_updated: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Run `opencode db --format json "<sql>"` and parse the result. Returns
|
|
56
|
+
* `null` (not an empty array) when the binary is missing or the query fails
|
|
57
|
+
* — callers can decide whether absent vs. empty is meaningful.
|
|
58
|
+
*/
|
|
59
|
+
function runOpenCodeDb<T>(sql: string): T[] | null {
|
|
60
|
+
try {
|
|
61
|
+
const stdout = execFileSync("opencode", ["db", "--format", "json", sql], {
|
|
62
|
+
encoding: "utf8",
|
|
63
|
+
timeout: 5_000,
|
|
64
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
65
|
+
});
|
|
66
|
+
if (!stdout.trim()) return [];
|
|
67
|
+
const parsed = JSON.parse(stdout) as unknown;
|
|
68
|
+
if (!Array.isArray(parsed)) return null;
|
|
69
|
+
return parsed as T[];
|
|
70
|
+
} catch {
|
|
71
|
+
// Binary missing, db locked, malformed JSON, or SQL error — fail open.
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Pull session rows directly from opencode's SQLite. Selects the columns the
|
|
78
|
+
* dashboard needs and orders newest-first.
|
|
79
|
+
*/
|
|
80
|
+
function readSessionRows(): OpenCodeSessionRow[] | null {
|
|
81
|
+
return runOpenCodeDb<OpenCodeSessionRow>(
|
|
82
|
+
`SELECT id, project_id, slug, directory, title, time_created, time_updated FROM session ORDER BY time_updated DESC LIMIT ${SESSION_LIMIT}`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readProjectRows(): OpenCodeProjectRow[] | null {
|
|
87
|
+
return runOpenCodeDb<OpenCodeProjectRow>(
|
|
88
|
+
`SELECT id, worktree, vcs, name, time_created, time_updated FROM project`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Group sessions by `project_id` and produce one ProjectFolder per project.
|
|
94
|
+
* The folder name comes from `project.name` when set, else `basename(worktree)`,
|
|
95
|
+
* else the project_id (last-resort). `lastModified` is the max session
|
|
96
|
+
* `time_updated` for that project (or the project's own time_updated if no
|
|
97
|
+
* sessions exist yet).
|
|
98
|
+
*/
|
|
99
|
+
export async function getOpenCodeProjects(): Promise<ProjectFolder[]> {
|
|
100
|
+
const sessions = readSessionRows();
|
|
101
|
+
const projects = readProjectRows();
|
|
102
|
+
if (sessions === null && projects === null) {
|
|
103
|
+
// Binary missing or query failed — silent degrade (no log spam).
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const projectMap = new Map<string, OpenCodeProjectRow>();
|
|
108
|
+
for (const p of projects ?? []) projectMap.set(p.id, p);
|
|
109
|
+
|
|
110
|
+
// Group sessions by project_id; track the latest update time per group.
|
|
111
|
+
const groups = new Map<string, { rows: OpenCodeSessionRow[]; latest: number }>();
|
|
112
|
+
for (const s of sessions ?? []) {
|
|
113
|
+
if (!s.project_id) continue;
|
|
114
|
+
let g = groups.get(s.project_id);
|
|
115
|
+
if (!g) {
|
|
116
|
+
g = { rows: [], latest: 0 };
|
|
117
|
+
groups.set(s.project_id, g);
|
|
118
|
+
}
|
|
119
|
+
g.rows.push(s);
|
|
120
|
+
if (s.time_updated > g.latest) g.latest = s.time_updated;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Emit one ProjectFolder per project that has at least one session OR a
|
|
124
|
+
// project row (covers projects opencode knows about but hasn't run yet).
|
|
125
|
+
const seen = new Set<string>();
|
|
126
|
+
const out: ProjectFolder[] = [];
|
|
127
|
+
for (const [projectId, group] of groups) {
|
|
128
|
+
seen.add(projectId);
|
|
129
|
+
const proj = projectMap.get(projectId);
|
|
130
|
+
const worktree = proj?.worktree ?? group.rows[0]?.directory ?? null;
|
|
131
|
+
const name = proj?.name?.trim() || (worktree ? basename(worktree) : projectId);
|
|
132
|
+
const path = worktree ?? "";
|
|
133
|
+
const lastModified = new Date(Math.max(group.latest, proj?.time_updated ?? 0));
|
|
134
|
+
out.push({
|
|
135
|
+
name,
|
|
136
|
+
path,
|
|
137
|
+
isDirectory: true,
|
|
138
|
+
lastModified,
|
|
139
|
+
lastModifiedFormatted: formatDate(lastModified),
|
|
140
|
+
cli: ["opencode"],
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
for (const p of projects ?? []) {
|
|
144
|
+
if (seen.has(p.id)) continue;
|
|
145
|
+
const worktree = p.worktree ?? "";
|
|
146
|
+
const name = p.name?.trim() || (worktree ? basename(worktree) : p.id);
|
|
147
|
+
const lastModified = new Date(p.time_updated);
|
|
148
|
+
out.push({
|
|
149
|
+
name,
|
|
150
|
+
path: worktree,
|
|
151
|
+
isDirectory: true,
|
|
152
|
+
lastModified,
|
|
153
|
+
lastModifiedFormatted: formatDate(lastModified),
|
|
154
|
+
cli: ["opencode"],
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
out.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Sessions under a given absolute cwd. Used by the project detail page. */
|
|
163
|
+
export async function getOpenCodeSessionsForCwd(cwd: string): Promise<SessionFile[]> {
|
|
164
|
+
const sessions = readSessionRows();
|
|
165
|
+
if (!sessions) return [];
|
|
166
|
+
const matches = sessions.filter((s) => s.directory === cwd);
|
|
167
|
+
return matches.map((s) => {
|
|
168
|
+
const lastModified = new Date(s.time_updated);
|
|
169
|
+
return {
|
|
170
|
+
name: s.title ?? s.slug ?? s.id,
|
|
171
|
+
path: `opencode://${s.id}`, // synthetic — opencode keeps transcripts in the DB
|
|
172
|
+
lastModified,
|
|
173
|
+
lastModifiedFormatted: formatDate(lastModified),
|
|
174
|
+
sessionId: s.id,
|
|
175
|
+
cli: "opencode" as const,
|
|
176
|
+
};
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface OpenCodeProjectByName {
|
|
181
|
+
cwd: string | null;
|
|
182
|
+
sessions: SessionFile[];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Resolve sessions by the dashboard's encoded folder name (e.g.
|
|
187
|
+
* `-home-nivedit-prs-failproofai-266`). We scan all project worktrees and
|
|
188
|
+
* pick the one whose encoded form matches.
|
|
189
|
+
*/
|
|
190
|
+
export async function getOpenCodeSessionsByEncodedName(name: string): Promise<OpenCodeProjectByName> {
|
|
191
|
+
let projects: OpenCodeProjectRow[] | null;
|
|
192
|
+
let sessions: OpenCodeSessionRow[] | null;
|
|
193
|
+
try {
|
|
194
|
+
projects = readProjectRows();
|
|
195
|
+
sessions = readSessionRows();
|
|
196
|
+
} catch (error) {
|
|
197
|
+
logWarn("Failed to read OpenCode DB:", error);
|
|
198
|
+
return { cwd: null, sessions: [] };
|
|
199
|
+
}
|
|
200
|
+
if (!projects || !sessions) return { cwd: null, sessions: [] };
|
|
201
|
+
|
|
202
|
+
const matchingProject = projects.find((p) => p.worktree && encodeFolderName(p.worktree) === name);
|
|
203
|
+
if (!matchingProject || !matchingProject.worktree) return { cwd: null, sessions: [] };
|
|
204
|
+
|
|
205
|
+
const matched = sessions.filter((s) => s.project_id === matchingProject.id);
|
|
206
|
+
return {
|
|
207
|
+
cwd: matchingProject.worktree,
|
|
208
|
+
sessions: matched.map((s) => {
|
|
209
|
+
const lastModified = new Date(s.time_updated);
|
|
210
|
+
return {
|
|
211
|
+
name: s.title ?? s.slug ?? s.id,
|
|
212
|
+
path: `opencode://${s.id}`,
|
|
213
|
+
lastModified,
|
|
214
|
+
lastModifiedFormatted: formatDate(lastModified),
|
|
215
|
+
sessionId: s.id,
|
|
216
|
+
cli: "opencode" as const,
|
|
217
|
+
};
|
|
218
|
+
}),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export const getCachedOpenCodeProjects = runtimeCache(getOpenCodeProjects, 30);
|
|
223
|
+
export const getCachedOpenCodeSessionsForCwd = runtimeCache(
|
|
224
|
+
(cwd: string) => getOpenCodeSessionsForCwd(cwd),
|
|
225
|
+
30,
|
|
226
|
+
{ maxSize: 50 },
|
|
227
|
+
);
|
|
228
|
+
export const getCachedOpenCodeSessionsByEncodedName = runtimeCache(
|
|
229
|
+
(name: string) => getOpenCodeSessionsByEncodedName(name),
|
|
230
|
+
30,
|
|
231
|
+
{ maxSize: 50 },
|
|
232
|
+
);
|