failproofai 0.0.13-beta.0 → 0.0.13-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.
- 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 +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
- 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 +10 -10
- 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/audit/invite/route.js +2 -2
- package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js +3 -2
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js +2 -2
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/logout/route.js +2 -2
- package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/reminder/route.js +2 -2
- package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js +2 -2
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page.js +1 -1
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
- 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 +2 -2
- 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 +3 -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 +3 -2
- 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_0xu5z._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0cefjd9._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0ekj92q._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0hv08mx._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0obaznk._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0xvsnvt._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__12v-lma._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__136y39g._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__13h8pzr._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1hkdkmz._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1i9rknc._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1mrihkj._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +3 -0
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0cycwg6._.js → [root-of-the-server]__1tvn4wf._.js} +2 -2
- package/.next/standalone/.next/server/chunks/_1z062h5._.js +3 -0
- package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +5 -5
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__015_i4t._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__03lv-pe._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__04bqvos._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1-m1rhq._.js → [root-of-the-server]__099afy6._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0dqgs96._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1uvfwgr._.js → [root-of-the-server]__0hdsupo._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0q5rfxg._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qeuy9c._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qukut7._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s89hoe._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0ehe24g._.js → [root-of-the-server]__0ynf7tx._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__170799-._.js +245 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1d8omgc._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ql-mch._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0bbpv2d._.js → [root-of-the-server]__1qlh4jx._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1tkjqka._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/_05whahf._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0ul5rf_._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_11_p9y8._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_11v0g3z._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_1p0-leb._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/lib_hermes-projects_ts_05b8-it._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_1u91ynr.js → node_modules_html-to-image_es_index_0pv57nx.js} +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/.next/server/pages/404.html +1 -1
- 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 +10 -10
- package/.next/standalone/.next/static/chunks/{0azkpcy8eb1gr.js → 0b6rdtcge_3ec.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1zxtyef-iqede.js → 0pj_6lbbjd000.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1h569gia53ant.js → 0xnrgonfdeivm.js} +1 -1
- package/.next/standalone/.next/static/chunks/{39rn602wszcbd.js → 105w_oznq-van.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0byua6ng1f_ka.js → 13tbm6i1swiru.js} +1 -1
- package/.next/standalone/.next/static/chunks/{41mhnhfkmglpp.js → 1fhuj4fjovzp5.js} +1 -1
- package/.next/standalone/.next/static/chunks/209ful1jrphvy.js +1 -0
- package/.next/standalone/.next/static/chunks/{3jka0hvhqouyv.js → 23odnajn2ltwu.js} +1 -1
- package/.next/standalone/.next/static/chunks/25s52un3k_wht.css +2 -0
- package/.next/standalone/.next/static/chunks/36r2vgi6854za.js +6 -0
- package/.next/standalone/.next/static/chunks/3b77kulbdbrqw.js +1 -0
- package/.next/standalone/.next/static/chunks/3rjg-1s5ouu5r.js +1 -0
- package/.next/standalone/app/components/sessions-list.tsx +19 -0
- package/.next/standalone/app/project/[name]/page.tsx +9 -4
- package/.next/standalone/app/project/[name]/session/[sessionId]/page.tsx +26 -5
- package/.next/standalone/assets/logos/hermes-dark.svg +1 -0
- package/.next/standalone/assets/logos/hermes-light.svg +1 -0
- package/.next/standalone/lib/cli-registry.ts +6 -1
- package/.next/standalone/lib/download-session.ts +16 -0
- package/.next/standalone/lib/hermes-projects.ts +164 -0
- package/.next/standalone/lib/hermes-sessions.ts +273 -0
- package/.next/standalone/lib/projects.ts +14 -3
- package/.next/standalone/lib/sqlite-reader.ts +102 -0
- package/.next/standalone/package.json +5 -3
- package/.next/standalone/server.js +1 -1
- package/README.md +10 -1
- package/bin/failproofai.mjs +17 -16
- package/dist/cli.mjs +7762 -226
- package/lib/cli-registry.ts +6 -1
- package/lib/download-session.ts +16 -0
- package/lib/hermes-projects.ts +164 -0
- package/lib/hermes-sessions.ts +273 -0
- package/lib/projects.ts +14 -3
- package/lib/sql-js-asm.d.ts +26 -0
- package/lib/sqlite-reader.ts +102 -0
- package/package.json +5 -3
- package/src/audit/cli-adapters/hermes.ts +58 -0
- package/src/audit/cli-adapters/index.ts +6 -0
- package/src/hooks/handler.ts +8 -0
- package/src/hooks/install-prompt.ts +7 -3
- package/src/hooks/integrations.ts +179 -5
- package/src/hooks/policy-evaluator.ts +42 -0
- package/src/hooks/resolve-transcript-path.ts +4 -0
- package/src/hooks/tool-name-canonicalize.ts +6 -0
- package/src/hooks/types.ts +67 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0dwpg-h._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0lnenda._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__13i_sva._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1_mqemn._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1m2_4t0._.js +0 -3
- package/.next/standalone/.next/server/chunks/node_modules_0-tu4ot._.js +0 -3
- package/.next/standalone/.next/server/chunks/node_modules_0ttxbz7._.js +0 -3
- package/.next/standalone/.next/server/chunks/node_modules_1bnh1y0._.js +0 -3
- package/.next/standalone/.next/server/chunks/node_modules_1epycqa._.js +0 -3
- package/.next/standalone/.next/server/chunks/node_modules_1wpdcgo._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0808sha._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e4-6d8._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0f62vu9._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0g253ve._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0k65l27._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0vxf0_g._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__12mcauo._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1e-x7j4._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1mt35_w._.js +0 -221
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1pcxxwg._.js +0 -4
- package/.next/standalone/.next/static/chunks/03zg3ms7uaoq3.js +0 -1
- package/.next/standalone/.next/static/chunks/0f7d7hnbh4djs.js +0 -1
- package/.next/standalone/.next/static/chunks/1o0xa47736gi9.css +0 -2
- package/.next/standalone/.next/static/chunks/2ks4pzu5b-l_q.js +0 -6
- package/.next/standalone/.next/static/chunks/2wbuxnsvux4di.js +0 -1
- /package/.next/standalone/.next/static/{W6ELw5VwHTFy2amS2px6e → 31ZoDDYKHEIN_TrgKv85t}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{W6ELw5VwHTFy2amS2px6e → 31ZoDDYKHEIN_TrgKv85t}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{W6ELw5VwHTFy2amS2px6e → 31ZoDDYKHEIN_TrgKv85t}/_ssgManifest.js +0 -0
package/lib/cli-registry.ts
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
import type { IntegrationType } from "@/src/hooks/types";
|
|
28
28
|
|
|
29
29
|
/** Canonical CLI ids the registry knows about. Mirrors `INTEGRATION_TYPES`. */
|
|
30
|
-
export const KNOWN_CLI_IDS = ["claude", "codex", "copilot", "cursor", "opencode", "pi", "gemini"] as const satisfies readonly IntegrationType[];
|
|
30
|
+
export const KNOWN_CLI_IDS = ["claude", "codex", "copilot", "cursor", "opencode", "pi", "gemini", "hermes"] as const satisfies readonly IntegrationType[];
|
|
31
31
|
export type CliId = (typeof KNOWN_CLI_IDS)[number];
|
|
32
32
|
|
|
33
33
|
/** Per-CLI metadata consumed by the dashboard. */
|
|
@@ -74,6 +74,11 @@ const CLI_ENTRIES: Record<CliId, CliEntry> = {
|
|
|
74
74
|
label: "Gemini CLI",
|
|
75
75
|
badgeClasses: "bg-sky-500/10 text-sky-400 border-sky-500/20",
|
|
76
76
|
},
|
|
77
|
+
hermes: {
|
|
78
|
+
id: "hermes",
|
|
79
|
+
label: "Hermes",
|
|
80
|
+
badgeClasses: "bg-indigo-500/10 text-indigo-400 border-indigo-500/20",
|
|
81
|
+
},
|
|
77
82
|
};
|
|
78
83
|
|
|
79
84
|
export function getCliEntry(id: string): CliEntry | undefined {
|
package/lib/download-session.ts
CHANGED
|
@@ -14,6 +14,11 @@ import { resolveSessionFilePath, UUID_RE } from "./projects";
|
|
|
14
14
|
import type { CliId } from "./cli-registry";
|
|
15
15
|
|
|
16
16
|
export const OPENCODE_SESSION_RE = /^ses_[A-Za-z0-9]+$/;
|
|
17
|
+
/** Hermes session IDs (e.g. `YYYYMMDD_HHMMSS_<hash>` / `cron_<hash>_...`). Kept
|
|
18
|
+
* in sync with `getHermesSessionLog`'s validator in lib/hermes-sessions.ts —
|
|
19
|
+
* a stricter pattern here would let a session list/open in the viewer yet fail
|
|
20
|
+
* its download with `RangeError("Invalid session ID")`. */
|
|
21
|
+
export const HERMES_SESSION_RE = /^[A-Za-z0-9_-]+$/;
|
|
17
22
|
|
|
18
23
|
export type DownloadSource =
|
|
19
24
|
| { kind: "file"; path: string }
|
|
@@ -23,6 +28,7 @@ export type DownloadSource =
|
|
|
23
28
|
* prefixes; everyone else is a UUID. */
|
|
24
29
|
export function isValidSessionId(cli: CliId, sessionId: string): boolean {
|
|
25
30
|
if (cli === "opencode") return OPENCODE_SESSION_RE.test(sessionId);
|
|
31
|
+
if (cli === "hermes") return HERMES_SESSION_RE.test(sessionId);
|
|
26
32
|
return UUID_RE.test(sessionId);
|
|
27
33
|
}
|
|
28
34
|
|
|
@@ -85,6 +91,16 @@ export async function resolveDownloadSource(
|
|
|
85
91
|
return { kind: "synthesized", body, contentType: "application/json", extension: "json" };
|
|
86
92
|
}
|
|
87
93
|
|
|
94
|
+
if (cli === "hermes") {
|
|
95
|
+
// Hermes keeps sessions in SQLite (~/.hermes/state.db). Synthesize a JSONL
|
|
96
|
+
// export of the session's raw `messages` rows.
|
|
97
|
+
const { getHermesSessionLog } = await import("./hermes-sessions");
|
|
98
|
+
const result = await getHermesSessionLog(sessionId);
|
|
99
|
+
if (!result) return null;
|
|
100
|
+
const body = result.rawLines.map((r) => JSON.stringify(r)).join("\n") + "\n";
|
|
101
|
+
return { kind: "synthesized", body, contentType: "application/x-ndjson", extension: "jsonl" };
|
|
102
|
+
}
|
|
103
|
+
|
|
88
104
|
// Exhaustive — but TypeScript can't always see CliId is exhausted across the
|
|
89
105
|
// if-chain above, so guard with a runtime fallback.
|
|
90
106
|
const _exhaustive: never = cli;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hermes (hermes-agent) session enumeration — AUDIT-ONLY.
|
|
3
|
+
*
|
|
4
|
+
* Reads the `sessions` table directly from `~/.hermes/state.db` via the bundled
|
|
5
|
+
* sql.js reader. All gateway users' sessions live in the one DB → "audit
|
|
6
|
+
* everyone from one place". Because we query the table (not a text `sessions
|
|
7
|
+
* list`), we get `source`/`cwd`/`message_count` up front — enough to group by
|
|
8
|
+
* channel and to build a real per-transcript cache key.
|
|
9
|
+
*/
|
|
10
|
+
import { openSqliteReadonly } from "./sqlite-reader";
|
|
11
|
+
import { hermesDbPath, epochToMs } from "./hermes-sessions";
|
|
12
|
+
import { runtimeCache } from "./runtime-cache";
|
|
13
|
+
import type { ProjectFolder, SessionFile } from "./projects";
|
|
14
|
+
import { formatDate } from "./format-date";
|
|
15
|
+
|
|
16
|
+
export interface HermesSessionRef {
|
|
17
|
+
sessionId: string;
|
|
18
|
+
source?: string;
|
|
19
|
+
cwd?: string;
|
|
20
|
+
title?: string;
|
|
21
|
+
/** Slack/Telegram user that drove the session. */
|
|
22
|
+
userId?: string;
|
|
23
|
+
/** Channel id + type (group/dm) the session ran in. */
|
|
24
|
+
chatId?: string;
|
|
25
|
+
chatType?: string;
|
|
26
|
+
/** From `ended_at` (falls back to `started_at`) — epoch ms. */
|
|
27
|
+
mtimeMs: number;
|
|
28
|
+
/** `message_count` — a stable cache key for an ended session. Can lag (Hermes
|
|
29
|
+
* may write it lazily / only at session end), so don't use it alone to decide
|
|
30
|
+
* emptiness. */
|
|
31
|
+
messageCount: number;
|
|
32
|
+
/** True when the session has ≥1 real message row (`MAX(messages.timestamp)`
|
|
33
|
+
* non-null). Reliable "non-empty" signal even when `message_count` is a
|
|
34
|
+
* stale `0` for an in-progress session. */
|
|
35
|
+
hasMessages: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface SessionRow {
|
|
39
|
+
id: string;
|
|
40
|
+
source: string | null;
|
|
41
|
+
cwd: string | null;
|
|
42
|
+
title: string | null;
|
|
43
|
+
user_id: string | null;
|
|
44
|
+
chat_id: string | null;
|
|
45
|
+
chat_type: string | null;
|
|
46
|
+
started_at: number | null;
|
|
47
|
+
ended_at: number | null;
|
|
48
|
+
message_count: number | null;
|
|
49
|
+
/** MAX(messages.timestamp) — the real last-activity time; advances on every
|
|
50
|
+
* new message even while the session is still open (ended_at is null). */
|
|
51
|
+
last_activity: number | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* List every Hermes session (all users). Returns `[]` when the DB is missing or
|
|
56
|
+
* unreadable (fail-open — the audit just skips Hermes).
|
|
57
|
+
*/
|
|
58
|
+
export async function getHermesSessions(): Promise<HermesSessionRef[]> {
|
|
59
|
+
const db = await openSqliteReadonly(hermesDbPath());
|
|
60
|
+
if (!db) return [];
|
|
61
|
+
try {
|
|
62
|
+
const rows = db.query<SessionRow>(
|
|
63
|
+
"SELECT s.id, s.source, s.cwd, s.title, s.user_id, s.chat_id, s.chat_type, " +
|
|
64
|
+
"s.started_at, s.ended_at, s.message_count, lm.last_activity " +
|
|
65
|
+
"FROM sessions s " +
|
|
66
|
+
"LEFT JOIN (SELECT session_id, MAX(timestamp) AS last_activity FROM messages GROUP BY session_id) lm " +
|
|
67
|
+
"ON lm.session_id = s.id " +
|
|
68
|
+
"ORDER BY lm.last_activity DESC",
|
|
69
|
+
);
|
|
70
|
+
return rows.map((r) => ({
|
|
71
|
+
sessionId: r.id,
|
|
72
|
+
source: r.source ?? undefined,
|
|
73
|
+
cwd: r.cwd ?? undefined,
|
|
74
|
+
title: r.title ?? undefined,
|
|
75
|
+
userId: r.user_id ?? undefined,
|
|
76
|
+
chatId: r.chat_id ?? undefined,
|
|
77
|
+
chatType: r.chat_type ?? undefined,
|
|
78
|
+
// Prefer the latest message time (advances live); fall back to ended/started.
|
|
79
|
+
mtimeMs: epochToMs(r.last_activity ?? r.ended_at ?? r.started_at),
|
|
80
|
+
messageCount: typeof r.message_count === "number" ? r.message_count : 0,
|
|
81
|
+
hasMessages: r.last_activity != null,
|
|
82
|
+
}));
|
|
83
|
+
} catch {
|
|
84
|
+
return [];
|
|
85
|
+
} finally {
|
|
86
|
+
db.close();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const getCachedHermesSessions = runtimeCache(getHermesSessions, 2);
|
|
91
|
+
|
|
92
|
+
// ── Dashboard history browser (projects list + project-detail sessions) ──
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Surface Hermes gateway sessions as synthetic "projects" grouped by `source`
|
|
96
|
+
* (slack/telegram/cli/cron) — gateway sessions have no cwd to group by. One
|
|
97
|
+
* ProjectFolder per source; its `name` is `hermes-<source>`, reversed in
|
|
98
|
+
* `getHermesSessionsByEncodedName`.
|
|
99
|
+
*/
|
|
100
|
+
export async function getHermesProjects(): Promise<ProjectFolder[]> {
|
|
101
|
+
const sessions = await getHermesSessions();
|
|
102
|
+
const latestBySource = new Map<string, number>();
|
|
103
|
+
for (const s of sessions) {
|
|
104
|
+
if (s.messageCount <= 0 && !s.hasMessages) continue; // skip empty (message_count can lag; trust real messages)
|
|
105
|
+
const src = s.source ?? "unknown";
|
|
106
|
+
latestBySource.set(src, Math.max(latestBySource.get(src) ?? 0, s.mtimeMs));
|
|
107
|
+
}
|
|
108
|
+
const out: ProjectFolder[] = [];
|
|
109
|
+
for (const [src, latest] of latestBySource) {
|
|
110
|
+
const lastModified = new Date(latest);
|
|
111
|
+
out.push({
|
|
112
|
+
name: `hermes-${src}`,
|
|
113
|
+
path: `hermes:${src}`,
|
|
114
|
+
isDirectory: true,
|
|
115
|
+
lastModified,
|
|
116
|
+
lastModifiedFormatted: formatDate(lastModified),
|
|
117
|
+
cli: ["hermes"],
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
out.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface HermesProjectByName {
|
|
125
|
+
cwd: string | null;
|
|
126
|
+
sessions: SessionFile[];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Resolve the Hermes sessions for a synthetic project name (`hermes-<source>`),
|
|
131
|
+
* for the project-detail page. Non-Hermes names resolve to empty.
|
|
132
|
+
*/
|
|
133
|
+
export async function getHermesSessionsByEncodedName(
|
|
134
|
+
name: string,
|
|
135
|
+
): Promise<HermesProjectByName> {
|
|
136
|
+
if (!name.startsWith("hermes-")) return { cwd: null, sessions: [] };
|
|
137
|
+
const source = name.slice("hermes-".length);
|
|
138
|
+
const sessions = await getHermesSessions();
|
|
139
|
+
const matched = sessions.filter((s) => (s.messageCount > 0 || s.hasMessages) && (s.source ?? "unknown") === source);
|
|
140
|
+
return {
|
|
141
|
+
cwd: `hermes:${source}`,
|
|
142
|
+
sessions: matched.map((s) => {
|
|
143
|
+
const lastModified = new Date(s.mtimeMs);
|
|
144
|
+
return {
|
|
145
|
+
name: s.title ?? s.sessionId,
|
|
146
|
+
path: `hermes://${s.sessionId}`,
|
|
147
|
+
lastModified,
|
|
148
|
+
lastModifiedFormatted: formatDate(lastModified),
|
|
149
|
+
sessionId: s.sessionId,
|
|
150
|
+
cli: "hermes" as const,
|
|
151
|
+
userId: s.userId,
|
|
152
|
+
channelId: s.chatId,
|
|
153
|
+
channelType: s.chatType,
|
|
154
|
+
};
|
|
155
|
+
}),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export const getCachedHermesProjects = runtimeCache(getHermesProjects, 2);
|
|
160
|
+
export const getCachedHermesSessionsByEncodedName = runtimeCache(
|
|
161
|
+
(name: string) => getHermesSessionsByEncodedName(name),
|
|
162
|
+
2,
|
|
163
|
+
{ maxSize: 50 },
|
|
164
|
+
);
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hermes (hermes-agent) session transcript loader + parser.
|
|
3
|
+
*
|
|
4
|
+
* AUDIT-ONLY (Pillar 2). Hermes stores every gateway user's sessions in one
|
|
5
|
+
* SQLite DB (`~/.hermes/state.db`). We read it DIRECTLY via the bundled sql.js
|
|
6
|
+
* driver (lib/sqlite-reader.ts) — a reusable path for SQLite-backed agents.
|
|
7
|
+
* (opencode and the already-shipped CLIs keep their CLI shell-out unchanged.)
|
|
8
|
+
*
|
|
9
|
+
* Message shape is OpenAI Chat-Completions style (verified live): assistant tool
|
|
10
|
+
* calls are `tool_calls[].function.{name, arguments}` (stored as a JSON string
|
|
11
|
+
* column) and results are separate `role:"tool"` rows keyed by `tool_call_id`.
|
|
12
|
+
* `hermesRowsToLogEntries` pairs them (mirrors `lib/codex-sessions.ts`) and is a
|
|
13
|
+
* PURE function of the message rows, so it is unit-testable without a DB.
|
|
14
|
+
*
|
|
15
|
+
* DB path override: set `HERMES_DB_PATH` (used by tests and to point at a copied
|
|
16
|
+
* or remote state.db).
|
|
17
|
+
*/
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { openSqliteReadonly } from "./sqlite-reader";
|
|
21
|
+
import { runtimeCache } from "./runtime-cache";
|
|
22
|
+
import {
|
|
23
|
+
baseEntry,
|
|
24
|
+
formatTimestamp,
|
|
25
|
+
type LogEntry,
|
|
26
|
+
type UserEntry,
|
|
27
|
+
type AssistantEntry,
|
|
28
|
+
type GenericEntry,
|
|
29
|
+
type ContentBlock,
|
|
30
|
+
type ToolUseBlock,
|
|
31
|
+
type LogSource,
|
|
32
|
+
} from "./log-entries";
|
|
33
|
+
import { formatDuration } from "./format-duration";
|
|
34
|
+
|
|
35
|
+
/** Absolute path to Hermes's SQLite DB (override with HERMES_DB_PATH). */
|
|
36
|
+
export function hermesDbPath(): string {
|
|
37
|
+
return process.env.HERMES_DB_PATH || join(homedir(), ".hermes", "state.db");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Coerce a Hermes epoch value (seconds or ms) to epoch ms. Hermes stores
|
|
41
|
+
* `started_at`/`ended_at`/`timestamp` as REAL epoch seconds. */
|
|
42
|
+
export function epochToMs(value: unknown): number {
|
|
43
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
44
|
+
if (value > 1e12) return value; // already ms
|
|
45
|
+
if (value > 1e9) return value * 1000; // seconds
|
|
46
|
+
}
|
|
47
|
+
return Date.now();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Parsing helpers ──
|
|
51
|
+
|
|
52
|
+
function safeJsonParse(s: unknown): unknown {
|
|
53
|
+
if (typeof s !== "string" || s.length === 0) return undefined;
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(s);
|
|
56
|
+
} catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
62
|
+
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Text from a `content` field that may be a string or an array of
|
|
66
|
+
* `{ type:"text", text }` blocks. */
|
|
67
|
+
function extractText(content: unknown): string {
|
|
68
|
+
if (typeof content === "string") return content;
|
|
69
|
+
if (Array.isArray(content)) {
|
|
70
|
+
return content
|
|
71
|
+
.map((c) => (isPlainObject(c) && typeof c.text === "string" ? (c.text as string) : ""))
|
|
72
|
+
.filter(Boolean)
|
|
73
|
+
.join("\n");
|
|
74
|
+
}
|
|
75
|
+
return "";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function toDate(value: unknown, fallbackMs: number): Date {
|
|
79
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
80
|
+
if (value > 1e12) return new Date(value);
|
|
81
|
+
if (value > 1e9) return new Date(value * 1000);
|
|
82
|
+
}
|
|
83
|
+
if (typeof value === "string") {
|
|
84
|
+
const ms = Date.parse(value);
|
|
85
|
+
if (!Number.isNaN(ms)) return new Date(ms);
|
|
86
|
+
}
|
|
87
|
+
return new Date(fallbackMs);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface NormalizedToolCall {
|
|
91
|
+
id: string;
|
|
92
|
+
name: string;
|
|
93
|
+
input: Record<string, unknown>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Normalize `tool_calls` (array, or the JSON-string column Hermes stores) into
|
|
97
|
+
* `{ id, name, input }[]`. OpenAI shape: `{ id|call_id, function:{ name,
|
|
98
|
+
* arguments } }` where `arguments` is itself a JSON string. */
|
|
99
|
+
function normalizeToolCalls(raw: unknown): NormalizedToolCall[] {
|
|
100
|
+
const arr = Array.isArray(raw) ? raw : safeJsonParse(raw);
|
|
101
|
+
if (!Array.isArray(arr)) return [];
|
|
102
|
+
const out: NormalizedToolCall[] = [];
|
|
103
|
+
for (const tc of arr) {
|
|
104
|
+
if (!isPlainObject(tc)) continue;
|
|
105
|
+
const fn = isPlainObject(tc.function) ? tc.function : {};
|
|
106
|
+
const name =
|
|
107
|
+
typeof fn.name === "string"
|
|
108
|
+
? fn.name
|
|
109
|
+
: typeof tc.name === "string"
|
|
110
|
+
? (tc.name as string)
|
|
111
|
+
: "tool";
|
|
112
|
+
const id =
|
|
113
|
+
(typeof tc.id === "string" && tc.id) ||
|
|
114
|
+
(typeof tc.call_id === "string" && tc.call_id) ||
|
|
115
|
+
`${name}-${out.length}`;
|
|
116
|
+
const parsedArgs = isPlainObject(fn.arguments) ? fn.arguments : safeJsonParse(fn.arguments);
|
|
117
|
+
out.push({ id, name, input: isPlainObject(parsedArgs) ? parsedArgs : {} });
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Pure parser: message rows → LogEntry[] ──
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Convert `messages`-table rows (ordered by timestamp) into `LogEntry[]`.
|
|
126
|
+
* Pairs each assistant `tool_calls` entry with its later `role:"tool"` result
|
|
127
|
+
* by `tool_call_id`. Roles other than user/assistant/tool (e.g. Hermes's
|
|
128
|
+
* `session_meta`) become generic system entries so nothing is dropped. Pure —
|
|
129
|
+
* unit-testable with plain row objects.
|
|
130
|
+
*/
|
|
131
|
+
export function hermesRowsToLogEntries(
|
|
132
|
+
rows: Record<string, unknown>[],
|
|
133
|
+
source: LogSource = "session",
|
|
134
|
+
): LogEntry[] {
|
|
135
|
+
const entries: LogEntry[] = [];
|
|
136
|
+
const toolUseById = new Map<string, ToolUseBlock>();
|
|
137
|
+
const toolUseStartMs = new Map<string, number>();
|
|
138
|
+
const baseMs = Date.now();
|
|
139
|
+
|
|
140
|
+
for (let i = 0; i < rows.length; i++) {
|
|
141
|
+
const m = rows[i];
|
|
142
|
+
if (!isPlainObject(m)) continue;
|
|
143
|
+
|
|
144
|
+
const role = typeof m.role === "string" ? m.role : "system";
|
|
145
|
+
const date = toDate(m.timestamp ?? m.time_created, baseMs + i);
|
|
146
|
+
const timestamp = date.toISOString();
|
|
147
|
+
const raw: Record<string, unknown> = {
|
|
148
|
+
uuid: m.id != null ? String(m.id) : `hermes-${i}`,
|
|
149
|
+
parentUuid: null,
|
|
150
|
+
};
|
|
151
|
+
const base = baseEntry(raw, timestamp, date, source);
|
|
152
|
+
|
|
153
|
+
if (role === "user") {
|
|
154
|
+
entries.push({
|
|
155
|
+
type: "user",
|
|
156
|
+
...base,
|
|
157
|
+
message: { role: "user", content: extractText(m.content) },
|
|
158
|
+
} satisfies UserEntry);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (role === "assistant") {
|
|
163
|
+
const blocks: ContentBlock[] = [];
|
|
164
|
+
const text = extractText(m.content);
|
|
165
|
+
if (text) blocks.push({ type: "text", text });
|
|
166
|
+
for (const tc of normalizeToolCalls(m.tool_calls)) {
|
|
167
|
+
const block: ToolUseBlock = { type: "tool_use", id: tc.id, name: tc.name, input: tc.input };
|
|
168
|
+
blocks.push(block);
|
|
169
|
+
toolUseById.set(tc.id, block);
|
|
170
|
+
toolUseStartMs.set(tc.id, date.getTime());
|
|
171
|
+
}
|
|
172
|
+
if (blocks.length === 0) continue; // empty assistant turn
|
|
173
|
+
entries.push({
|
|
174
|
+
type: "assistant",
|
|
175
|
+
...base,
|
|
176
|
+
message: { role: "assistant", content: blocks },
|
|
177
|
+
} satisfies AssistantEntry);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (role === "tool") {
|
|
182
|
+
const callId = typeof m.tool_call_id === "string" ? m.tool_call_id : undefined;
|
|
183
|
+
const block = callId ? toolUseById.get(callId) : undefined;
|
|
184
|
+
if (block) {
|
|
185
|
+
const startMs = (callId && toolUseStartMs.get(callId)) || date.getTime();
|
|
186
|
+
const durationMs = Math.max(0, date.getTime() - startMs);
|
|
187
|
+
block.result = {
|
|
188
|
+
timestamp,
|
|
189
|
+
timestampFormatted: formatTimestamp(date),
|
|
190
|
+
content: extractText(m.content),
|
|
191
|
+
durationMs,
|
|
192
|
+
durationFormatted: formatDuration(durationMs),
|
|
193
|
+
};
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
// Orphan tool result — fall through to a system entry.
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
entries.push({ type: "system", ...base, raw } satisfies GenericEntry);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
entries.sort((a, b) => a.timestampMs - b.timestampMs);
|
|
203
|
+
return entries;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── DB loader ──
|
|
207
|
+
|
|
208
|
+
export interface HermesSessionLogData {
|
|
209
|
+
entries: LogEntry[];
|
|
210
|
+
rawLines: Record<string, unknown>[];
|
|
211
|
+
cwd?: string;
|
|
212
|
+
filePath: string; // synthetic — hermes keeps sessions in a DB; we use hermes://<id>
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
interface HermesMessageRow {
|
|
216
|
+
id: number | null;
|
|
217
|
+
role: string | null;
|
|
218
|
+
content: string | null;
|
|
219
|
+
tool_call_id: string | null;
|
|
220
|
+
tool_calls: string | null;
|
|
221
|
+
tool_name: string | null;
|
|
222
|
+
timestamp: number | null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Load one session by ID from `state.db`. Returns `null` when the DB is
|
|
227
|
+
* unavailable or the session doesn't exist.
|
|
228
|
+
*/
|
|
229
|
+
export async function getHermesSessionLog(
|
|
230
|
+
sessionId: string,
|
|
231
|
+
): Promise<HermesSessionLogData | null> {
|
|
232
|
+
if (!sessionId || !/^[A-Za-z0-9_-]+$/.test(sessionId)) return null;
|
|
233
|
+
const db = await openSqliteReadonly(hermesDbPath());
|
|
234
|
+
if (!db) return null;
|
|
235
|
+
try {
|
|
236
|
+
const sessionRows = db.query<{ source: string | null; cwd: string | null }>(
|
|
237
|
+
"SELECT source, cwd FROM sessions WHERE id = ?",
|
|
238
|
+
[sessionId],
|
|
239
|
+
);
|
|
240
|
+
if (sessionRows.length === 0) return null;
|
|
241
|
+
const { source, cwd: realCwd } = sessionRows[0];
|
|
242
|
+
|
|
243
|
+
const msgRows = db.query<HermesMessageRow>(
|
|
244
|
+
"SELECT id, role, content, tool_call_id, tool_calls, tool_name, timestamp " +
|
|
245
|
+
"FROM messages WHERE session_id = ? ORDER BY timestamp ASC",
|
|
246
|
+
[sessionId],
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
const entries = hermesRowsToLogEntries(
|
|
250
|
+
msgRows as unknown as Record<string, unknown>[],
|
|
251
|
+
"session",
|
|
252
|
+
);
|
|
253
|
+
// Gateway sessions have no cwd → group by source (slack/telegram/cli/cron).
|
|
254
|
+
const cwd =
|
|
255
|
+
realCwd && realCwd.length > 0 ? realCwd : source ? `hermes:${source}` : undefined;
|
|
256
|
+
return {
|
|
257
|
+
entries,
|
|
258
|
+
rawLines: msgRows as unknown as Record<string, unknown>[],
|
|
259
|
+
cwd,
|
|
260
|
+
filePath: `hermes://${sessionId}`,
|
|
261
|
+
};
|
|
262
|
+
} catch {
|
|
263
|
+
return null;
|
|
264
|
+
} finally {
|
|
265
|
+
db.close();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export const getCachedHermesSessionLog = runtimeCache(
|
|
270
|
+
(sessionId: string) => getHermesSessionLog(sessionId),
|
|
271
|
+
2,
|
|
272
|
+
{ maxSize: 50 },
|
|
273
|
+
);
|
package/lib/projects.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { formatDate } from "./format-date";
|
|
|
16
16
|
export const UUID_RE = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
17
17
|
export const PATH_TRAVERSAL_RE = /(^|[\\/])\.\.($|[\\/])/;
|
|
18
18
|
|
|
19
|
-
export type ProjectCli = "claude" | "codex" | "copilot" | "cursor" | "opencode" | "pi" | "gemini";
|
|
19
|
+
export type ProjectCli = "claude" | "codex" | "copilot" | "cursor" | "opencode" | "pi" | "gemini" | "hermes";
|
|
20
20
|
|
|
21
21
|
export interface ProjectFolder {
|
|
22
22
|
name: string;
|
|
@@ -40,6 +40,11 @@ export interface SessionFile {
|
|
|
40
40
|
/** Originating agent CLI. Set when the session list mixes sources from more
|
|
41
41
|
* than one CLI, so the table can render a per-row CLI badge. */
|
|
42
42
|
cli?: ProjectCli;
|
|
43
|
+
/** Gateway session metadata (Hermes only today): who drove it and where.
|
|
44
|
+
* Other CLIs leave these undefined; the list renders them only when present. */
|
|
45
|
+
userId?: string;
|
|
46
|
+
channelId?: string;
|
|
47
|
+
channelType?: string; // e.g. "group" | "dm"
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
/** Stats a path and returns mtime. Falls back to epoch (1970-01-01) on error
|
|
@@ -139,6 +144,7 @@ export async function getProjectFolders(): Promise<ProjectFolder[]> {
|
|
|
139
144
|
{ getOpenCodeProjects },
|
|
140
145
|
{ getPiProjects },
|
|
141
146
|
{ getGeminiProjects },
|
|
147
|
+
{ getHermesProjects },
|
|
142
148
|
] = await Promise.all([
|
|
143
149
|
import("./codex-projects"),
|
|
144
150
|
import("./copilot-projects"),
|
|
@@ -146,8 +152,9 @@ export async function getProjectFolders(): Promise<ProjectFolder[]> {
|
|
|
146
152
|
import("./opencode-projects"),
|
|
147
153
|
import("./pi-projects"),
|
|
148
154
|
import("./gemini-projects"),
|
|
155
|
+
import("./hermes-projects"),
|
|
149
156
|
]);
|
|
150
|
-
const [claude, codex, copilot, cursor, opencode, pi, gemini] = await Promise.all([
|
|
157
|
+
const [claude, codex, copilot, cursor, opencode, pi, gemini, hermes] = await Promise.all([
|
|
151
158
|
getClaudeProjectFolders(),
|
|
152
159
|
getCodexProjects().catch((error) => {
|
|
153
160
|
logError("Error reading Codex projects:", error);
|
|
@@ -173,8 +180,12 @@ export async function getProjectFolders(): Promise<ProjectFolder[]> {
|
|
|
173
180
|
logError("Error reading Gemini projects:", error);
|
|
174
181
|
return [] as ProjectFolder[];
|
|
175
182
|
}),
|
|
183
|
+
getHermesProjects().catch((error) => {
|
|
184
|
+
logError("Error reading Hermes projects:", error);
|
|
185
|
+
return [] as ProjectFolder[];
|
|
186
|
+
}),
|
|
176
187
|
]);
|
|
177
|
-
return mergeProjectFolders(claude, codex, copilot, cursor, opencode, pi, gemini);
|
|
188
|
+
return mergeProjectFolders(claude, codex, copilot, cursor, opencode, pi, gemini, hermes);
|
|
178
189
|
}
|
|
179
190
|
|
|
180
191
|
/**
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal ambient types for the pure-JS (asm.js) build of sql.js, which we
|
|
3
|
+
* import by subpath (`sql.js/dist/sql-asm.js`) so there is no `.wasm` to ship or
|
|
4
|
+
* locate. We only use a small read-only slice of the API.
|
|
5
|
+
*/
|
|
6
|
+
declare module "sql.js/dist/sql-asm.js" {
|
|
7
|
+
export interface SqlJsStatement {
|
|
8
|
+
bind(params?: unknown[]): boolean;
|
|
9
|
+
step(): boolean;
|
|
10
|
+
getAsObject(): Record<string, unknown>;
|
|
11
|
+
free(): boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface SqlJsDatabase {
|
|
14
|
+
prepare(sql: string): SqlJsStatement;
|
|
15
|
+
run(sql: string, params?: unknown[]): void;
|
|
16
|
+
export(): Uint8Array;
|
|
17
|
+
close(): void;
|
|
18
|
+
}
|
|
19
|
+
export interface SqlJsStatic {
|
|
20
|
+
Database: new (data?: Uint8Array | null) => SqlJsDatabase;
|
|
21
|
+
}
|
|
22
|
+
export interface InitSqlJsConfig {
|
|
23
|
+
locateFile?: (file: string) => string;
|
|
24
|
+
}
|
|
25
|
+
export default function initSqlJs(config?: InitSqlJsConfig): Promise<SqlJsStatic>;
|
|
26
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable read-only SQLite access for audit adapters of SQLite-backed agents.
|
|
3
|
+
*
|
|
4
|
+
* Tiered so we read LIVE data (including the write-ahead log) wherever possible,
|
|
5
|
+
* and still run anywhere:
|
|
6
|
+
* 1. `node:sqlite` — a real connection that reads main DB + WAL together, so
|
|
7
|
+
* brand-new rows are visible immediately. Built into Node ≥ 22.5 (no native
|
|
8
|
+
* module, no flag on recent 22.x). Preferred.
|
|
9
|
+
* 2. `sql.js` (pure-JS/asm) — portable fallback for older Node. Reads the main
|
|
10
|
+
* DB file's bytes only, so it reflects a snapshot up to the last WAL
|
|
11
|
+
* checkpoint (very recent rows may lag). No native module — survives
|
|
12
|
+
* `npm install --ignore-scripts`.
|
|
13
|
+
*
|
|
14
|
+
* Either way: read-only, and `null` on any failure (fail-open, so an absent or
|
|
15
|
+
* locked DB makes the audit skip that agent rather than crash).
|
|
16
|
+
*
|
|
17
|
+
* NOTE: opencode and every CLI that already ships keep their existing CLI
|
|
18
|
+
* shell-out — this layer is only for new SQLite integrations.
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
21
|
+
import initSqlJs, { type SqlJsStatic } from "sql.js/dist/sql-asm.js";
|
|
22
|
+
|
|
23
|
+
export interface SqliteReader {
|
|
24
|
+
/** Run a parameterized read-only query; returns rows as plain objects. */
|
|
25
|
+
query<T = Record<string, unknown>>(sql: string, params?: unknown[]): T[];
|
|
26
|
+
close(): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── Tier 1: node:sqlite (WAL-aware, Node ≥ 22.5) ──
|
|
30
|
+
|
|
31
|
+
interface NodeSqliteStmt {
|
|
32
|
+
all(...params: unknown[]): Record<string, unknown>[];
|
|
33
|
+
}
|
|
34
|
+
interface NodeSqliteDb {
|
|
35
|
+
prepare(sql: string): NodeSqliteStmt;
|
|
36
|
+
close(): void;
|
|
37
|
+
}
|
|
38
|
+
interface NodeSqliteModule {
|
|
39
|
+
DatabaseSync: new (path: string, options?: { readOnly?: boolean }) => NodeSqliteDb;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function tryNodeSqlite(dbPath: string): Promise<SqliteReader | null> {
|
|
43
|
+
try {
|
|
44
|
+
// Dynamic import: on Node < 22.5 this rejects and we fall through to sql.js.
|
|
45
|
+
const mod = (await import("node:sqlite")) as unknown as NodeSqliteModule;
|
|
46
|
+
const db = new mod.DatabaseSync(dbPath, { readOnly: true });
|
|
47
|
+
return {
|
|
48
|
+
query<T = Record<string, unknown>>(sql: string, params: unknown[] = []): T[] {
|
|
49
|
+
const stmt = db.prepare(sql);
|
|
50
|
+
return (params.length ? stmt.all(...params) : stmt.all()) as T[];
|
|
51
|
+
},
|
|
52
|
+
close() {
|
|
53
|
+
db.close();
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Tier 2: sql.js (portable snapshot) ──
|
|
62
|
+
|
|
63
|
+
let sqlPromise: Promise<SqlJsStatic> | null = null;
|
|
64
|
+
function loadSqlJs(): Promise<SqlJsStatic> {
|
|
65
|
+
if (!sqlPromise) sqlPromise = initSqlJs();
|
|
66
|
+
return sqlPromise;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function trySqlJs(dbPath: string): Promise<SqliteReader | null> {
|
|
70
|
+
try {
|
|
71
|
+
const SQL = await loadSqlJs();
|
|
72
|
+
const db = new SQL.Database(readFileSync(dbPath));
|
|
73
|
+
return {
|
|
74
|
+
query<T = Record<string, unknown>>(sql: string, params?: unknown[]): T[] {
|
|
75
|
+
const stmt = db.prepare(sql);
|
|
76
|
+
try {
|
|
77
|
+
if (params && params.length) stmt.bind(params);
|
|
78
|
+
const rows: T[] = [];
|
|
79
|
+
while (stmt.step()) rows.push(stmt.getAsObject() as T);
|
|
80
|
+
return rows;
|
|
81
|
+
} finally {
|
|
82
|
+
stmt.free();
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
close() {
|
|
86
|
+
db.close();
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Open a SQLite database file READ-ONLY. Prefers a WAL-aware `node:sqlite`
|
|
96
|
+
* connection (live data), falls back to a `sql.js` snapshot. Returns `null` on
|
|
97
|
+
* any failure. Always `close()` the reader when done.
|
|
98
|
+
*/
|
|
99
|
+
export async function openSqliteReadonly(dbPath: string): Promise<SqliteReader | null> {
|
|
100
|
+
if (!existsSync(dbPath)) return null;
|
|
101
|
+
return (await tryNodeSqlite(dbPath)) ?? (await trySqlJs(dbPath));
|
|
102
|
+
}
|