herm-tui 1.0.0-dev.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/LICENSE +21 -0
- package/README.md +54 -0
- package/package.json +82 -0
- package/scripts/postinstall.ts +29 -0
- package/src/app/gateway.tsx +83 -0
- package/src/app/gatewayEvents.ts +203 -0
- package/src/app/launch.ts +41 -0
- package/src/app/skin.tsx +31 -0
- package/src/app/spawnHistory.ts +75 -0
- package/src/app/tabs.ts +23 -0
- package/src/app/turnReducer.ts +390 -0
- package/src/app/useAppKeys.ts +268 -0
- package/src/app/useAtRefPopover.ts +99 -0
- package/src/app/useInputHistory.ts +66 -0
- package/src/app/useSession.ts +102 -0
- package/src/app/useSlashCommands.ts +70 -0
- package/src/app/useSlashPopover.ts +48 -0
- package/src/app.tsx +917 -0
- package/src/commands/slash.ts +151 -0
- package/src/components/avatar/AnimatedAvatar.tsx +66 -0
- package/src/components/avatar/eikon.ts +144 -0
- package/src/components/avatar/states/error.ts +1155 -0
- package/src/components/avatar/states/idle.ts +1155 -0
- package/src/components/avatar/states/index.ts +30 -0
- package/src/components/avatar/states/listening.ts +1155 -0
- package/src/components/avatar/states/speaking.ts +1155 -0
- package/src/components/avatar/states/thinking.ts +1155 -0
- package/src/components/avatar/states/working.ts +1155 -0
- package/src/components/chat/AtRefPopover.tsx +54 -0
- package/src/components/chat/CodeBlock.tsx +67 -0
- package/src/components/chat/Composer.tsx +347 -0
- package/src/components/chat/DiffBlock.tsx +116 -0
- package/src/components/chat/ErrorBlock.tsx +70 -0
- package/src/components/chat/MediaChip.tsx +114 -0
- package/src/components/chat/MessageItem.tsx +282 -0
- package/src/components/chat/MessageList.tsx +114 -0
- package/src/components/chat/PromptCard.tsx +359 -0
- package/src/components/chat/SlashPopover.tsx +158 -0
- package/src/components/chat/ThoughtCloud.tsx +185 -0
- package/src/components/chat/TypingIndicator.tsx +25 -0
- package/src/components/chat/tool/Subagent.tsx +75 -0
- package/src/components/chat/tool/frame.tsx +69 -0
- package/src/components/chat/tool/index.tsx +65 -0
- package/src/components/chat/tool/preview.ts +57 -0
- package/src/components/sidebar/ContextGauge.tsx +102 -0
- package/src/components/sidebar/Sidebar.tsx +143 -0
- package/src/components/tabs/TabBar.tsx +50 -0
- package/src/components/ui/FileLink.tsx +52 -0
- package/src/config/index.ts +156 -0
- package/src/config/lane.ts +161 -0
- package/src/config/models.ts +95 -0
- package/src/config/rules.ts +80 -0
- package/src/config/schema.ts +308 -0
- package/src/dialogs/alert.tsx +52 -0
- package/src/dialogs/chafa.tsx +72 -0
- package/src/dialogs/confirm.tsx +58 -0
- package/src/dialogs/curator.tsx +153 -0
- package/src/dialogs/eikon-picker.tsx +95 -0
- package/src/dialogs/help.tsx +80 -0
- package/src/dialogs/history.tsx +92 -0
- package/src/dialogs/info.tsx +115 -0
- package/src/dialogs/keys.tsx +170 -0
- package/src/dialogs/logs.tsx +42 -0
- package/src/dialogs/message.tsx +38 -0
- package/src/dialogs/model-picker.tsx +123 -0
- package/src/dialogs/new-profile.tsx +69 -0
- package/src/dialogs/new-task.tsx +103 -0
- package/src/dialogs/profile.tsx +55 -0
- package/src/dialogs/rollback.tsx +190 -0
- package/src/dialogs/spawn-history.tsx +80 -0
- package/src/dialogs/text-prompt.tsx +68 -0
- package/src/dialogs/theme-picker.tsx +50 -0
- package/src/home/index.ts +23 -0
- package/src/home/store.ts +267 -0
- package/src/index.tsx +113 -0
- package/src/keys/catalog.ts +115 -0
- package/src/keys/chord.ts +125 -0
- package/src/keys/conflicts.ts +48 -0
- package/src/keys/context.tsx +112 -0
- package/src/keys/index.ts +5 -0
- package/src/keys/list.ts +94 -0
- package/src/keys/oc-compat.ts +87 -0
- package/src/tabs/Agents.tsx +607 -0
- package/src/tabs/Analytics.tsx +154 -0
- package/src/tabs/Chat.tsx +50 -0
- package/src/tabs/Config.tsx +605 -0
- package/src/tabs/Context.tsx +599 -0
- package/src/tabs/Cron.tsx +294 -0
- package/src/tabs/Env.tsx +227 -0
- package/src/tabs/Kanban.tsx +367 -0
- package/src/tabs/Memory.tsx +294 -0
- package/src/tabs/Sessions.tsx +786 -0
- package/src/tabs/Skills.tsx +507 -0
- package/src/tabs/Toolsets.tsx +266 -0
- package/src/theme/builtin.ts +78 -0
- package/src/theme/context.tsx +106 -0
- package/src/theme/index.ts +4 -0
- package/src/theme/resolve.ts +134 -0
- package/src/theme/syntax.ts +31 -0
- package/src/theme/themes/aura.json +69 -0
- package/src/theme/themes/ayu.json +80 -0
- package/src/theme/themes/carbonfox.json +248 -0
- package/src/theme/themes/catppuccin-frappe.json +233 -0
- package/src/theme/themes/catppuccin-macchiato.json +233 -0
- package/src/theme/themes/catppuccin.json +112 -0
- package/src/theme/themes/cobalt2.json +228 -0
- package/src/theme/themes/cursor.json +249 -0
- package/src/theme/themes/dracula.json +219 -0
- package/src/theme/themes/everforest.json +241 -0
- package/src/theme/themes/flexoki.json +237 -0
- package/src/theme/themes/github.json +233 -0
- package/src/theme/themes/gruvbox.json +242 -0
- package/src/theme/themes/kanagawa.json +77 -0
- package/src/theme/themes/lucent-orng.json +237 -0
- package/src/theme/themes/material.json +235 -0
- package/src/theme/themes/matrix.json +77 -0
- package/src/theme/themes/mercury.json +252 -0
- package/src/theme/themes/monokai.json +221 -0
- package/src/theme/themes/nightowl.json +221 -0
- package/src/theme/themes/nord.json +223 -0
- package/src/theme/themes/one-dark.json +84 -0
- package/src/theme/themes/opencode.json +245 -0
- package/src/theme/themes/orng.json +249 -0
- package/src/theme/themes/osaka-jade.json +93 -0
- package/src/theme/themes/palenight.json +222 -0
- package/src/theme/themes/rosepine.json +234 -0
- package/src/theme/themes/solarized.json +223 -0
- package/src/theme/themes/synthwave84.json +226 -0
- package/src/theme/themes/tokyonight.json +243 -0
- package/src/theme/themes/vercel.json +245 -0
- package/src/theme/themes/vesper.json +218 -0
- package/src/theme/themes/zenburn.json +223 -0
- package/src/theme/types.ts +119 -0
- package/src/types/message.ts +97 -0
- package/src/ui/ChafaImage.tsx +64 -0
- package/src/ui/Splash.tsx +118 -0
- package/src/ui/borders.ts +28 -0
- package/src/ui/command.tsx +104 -0
- package/src/ui/dialog-select.tsx +164 -0
- package/src/ui/dialog.tsx +102 -0
- package/src/ui/fmt.ts +82 -0
- package/src/ui/kv.tsx +28 -0
- package/src/ui/shell.tsx +45 -0
- package/src/ui/spinner.tsx +59 -0
- package/src/ui/splash-art.ts +123 -0
- package/src/ui/table.tsx +117 -0
- package/src/ui/ticker.tsx +90 -0
- package/src/ui/toast.tsx +130 -0
- package/src/utils/categorical.ts +77 -0
- package/src/utils/chafa.ts +173 -0
- package/src/utils/clipboard.ts +67 -0
- package/src/utils/context-segments.ts +317 -0
- package/src/utils/control.ts +495 -0
- package/src/utils/drop.ts +25 -0
- package/src/utils/editor.ts +33 -0
- package/src/utils/fuzzy.ts +45 -0
- package/src/utils/gateway-client.ts +253 -0
- package/src/utils/gateway-types.ts +282 -0
- package/src/utils/git.ts +57 -0
- package/src/utils/hermes-analytics.ts +134 -0
- package/src/utils/hermes-home.ts +821 -0
- package/src/utils/hermes-kanban.ts +154 -0
- package/src/utils/hermes-profiles.ts +217 -0
- package/src/utils/interpolate.ts +31 -0
- package/src/utils/math-unicode.ts +818 -0
- package/src/utils/memory-activity.ts +140 -0
- package/src/utils/open-file.ts +13 -0
- package/src/utils/paths.ts +52 -0
- package/src/utils/perf.ts +235 -0
- package/src/utils/preferences.ts +150 -0
- package/src/utils/sessions-db.ts +396 -0
- package/src/utils/subagent-tree.ts +146 -0
- package/src/utils/terminal-reset.ts +129 -0
- package/src/utils/tips.ts +67 -0
- package/src/utils/tokens.ts +87 -0
|
@@ -0,0 +1,821 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hermes-home.ts — Reader for the ~/.hermes/ directory.
|
|
3
|
+
*
|
|
4
|
+
* This is herm's window into the Hermes Agent's persistent state.
|
|
5
|
+
* All reads are filesystem-based (Bun APIs), no HTTP needed.
|
|
6
|
+
*
|
|
7
|
+
* Every piece of extracted data carries a `source: Source` field so the
|
|
8
|
+
* UI can generically render clickable file links without knowing paths.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Database } from "bun:sqlite";
|
|
12
|
+
import { readdir, stat } from "node:fs/promises";
|
|
13
|
+
import { openSync, readSync, closeSync, readdirSync, readFileSync } from "node:fs";
|
|
14
|
+
import { homedir } from "os";
|
|
15
|
+
import { parse as parseYaml } from "yaml";
|
|
16
|
+
import { count as tokenCount } from "./tokens";
|
|
17
|
+
|
|
18
|
+
// ─── Path Resolution ─────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const HOME = process.env.HOME || homedir();
|
|
21
|
+
const HERMES_HOME = process.env.HERMES_HOME || `${HOME}/.hermes`;
|
|
22
|
+
|
|
23
|
+
/** Resolve a path relative to ~/.hermes/ */
|
|
24
|
+
export const hermesPath = (relative: string): string =>
|
|
25
|
+
`${HERMES_HOME}/${relative}`;
|
|
26
|
+
|
|
27
|
+
/** Detect a package-manager-owned install. Two signals, matching
|
|
28
|
+
* hermes_cli/config.py:get_managed_system — HERMES_MANAGED env var
|
|
29
|
+
* (systemd service sets it) or the `.managed` marker file (NixOS
|
|
30
|
+
* activation script touches it so interactive shells see it too). */
|
|
31
|
+
export const managedSystem = async (): Promise<string | null> => {
|
|
32
|
+
const env = (process.env.HERMES_MANAGED ?? "").trim()
|
|
33
|
+
if (env) {
|
|
34
|
+
const norm = env.toLowerCase()
|
|
35
|
+
if (norm === "1" || norm === "true" || norm === "yes" || norm === "on") return "NixOS"
|
|
36
|
+
const names: Record<string, string> = { homebrew: "Homebrew", nix: "NixOS", nixos: "NixOS" }
|
|
37
|
+
return names[norm] ?? env
|
|
38
|
+
}
|
|
39
|
+
return (await Bun.file(hermesPath(".managed")).exists()) ? "NixOS" : null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── Source Provenance ────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/** Every piece of data extracted from ~/.hermes/ carries its origin file. */
|
|
45
|
+
export interface Source {
|
|
46
|
+
file: string; // absolute path
|
|
47
|
+
relative: string; // relative to HERMES_HOME
|
|
48
|
+
label: string; // human-friendly display name
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Build a Source for a file relative to HERMES_HOME */
|
|
52
|
+
export const makeSource = (
|
|
53
|
+
relative: string,
|
|
54
|
+
label?: string,
|
|
55
|
+
): Source => ({
|
|
56
|
+
file: hermesPath(relative),
|
|
57
|
+
relative,
|
|
58
|
+
label: label ?? relative.split("/").pop() ?? relative,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ─── Types ───────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/** Subset of config.yaml we care about */
|
|
64
|
+
export interface HermesConfig {
|
|
65
|
+
source: Source;
|
|
66
|
+
model: {
|
|
67
|
+
default: string;
|
|
68
|
+
provider: string;
|
|
69
|
+
base_url: string;
|
|
70
|
+
};
|
|
71
|
+
agent: {
|
|
72
|
+
max_turns: number;
|
|
73
|
+
reasoning_effort: string;
|
|
74
|
+
};
|
|
75
|
+
compression: {
|
|
76
|
+
enabled: boolean;
|
|
77
|
+
threshold: number;
|
|
78
|
+
target_ratio: number;
|
|
79
|
+
protect_last_n: number;
|
|
80
|
+
summary_model: string;
|
|
81
|
+
};
|
|
82
|
+
memory: {
|
|
83
|
+
memory_enabled: boolean;
|
|
84
|
+
user_profile_enabled: boolean;
|
|
85
|
+
memory_char_limit: number;
|
|
86
|
+
user_char_limit: number;
|
|
87
|
+
provider: string;
|
|
88
|
+
nudge_interval: number;
|
|
89
|
+
flush_min_turns: number;
|
|
90
|
+
};
|
|
91
|
+
display: {
|
|
92
|
+
personality: string;
|
|
93
|
+
skin: string;
|
|
94
|
+
show_cost: boolean;
|
|
95
|
+
};
|
|
96
|
+
curator: {
|
|
97
|
+
enabled: boolean;
|
|
98
|
+
interval_hours: number;
|
|
99
|
+
stale_after_days: number;
|
|
100
|
+
archive_after_days: number;
|
|
101
|
+
};
|
|
102
|
+
gateway: {
|
|
103
|
+
platforms: {
|
|
104
|
+
api_server?: {
|
|
105
|
+
enabled: boolean;
|
|
106
|
+
host: string;
|
|
107
|
+
port: number;
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Memory file stats */
|
|
114
|
+
export interface MemoryFileInfo {
|
|
115
|
+
source: Source;
|
|
116
|
+
content: string;
|
|
117
|
+
charCount: number;
|
|
118
|
+
charLimit: number;
|
|
119
|
+
usagePercent: number;
|
|
120
|
+
entryCount: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A row from the sessions table in state.db — see sessions-db.ts. */
|
|
124
|
+
export type { SessionRow } from "./sessions-db"
|
|
125
|
+
|
|
126
|
+
/** Live session entry from sessions/sessions.json */
|
|
127
|
+
export interface LiveSession {
|
|
128
|
+
session_key: string;
|
|
129
|
+
session_id: string;
|
|
130
|
+
created_at: string;
|
|
131
|
+
updated_at: string;
|
|
132
|
+
display_name: string;
|
|
133
|
+
platform: string;
|
|
134
|
+
chat_type: string;
|
|
135
|
+
input_tokens: number;
|
|
136
|
+
output_tokens: number;
|
|
137
|
+
cache_read_tokens: number;
|
|
138
|
+
cache_write_tokens: number;
|
|
139
|
+
total_tokens: number;
|
|
140
|
+
last_prompt_tokens: number;
|
|
141
|
+
estimated_cost_usd: number;
|
|
142
|
+
cost_status: string;
|
|
143
|
+
memory_flushed: boolean;
|
|
144
|
+
origin?: {
|
|
145
|
+
platform: string;
|
|
146
|
+
chat_id: string;
|
|
147
|
+
chat_name: string;
|
|
148
|
+
user_id: string;
|
|
149
|
+
user_name: string;
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** A tool schema from the session JSON */
|
|
154
|
+
export interface ToolInfo {
|
|
155
|
+
name: string;
|
|
156
|
+
descriptionLength: number;
|
|
157
|
+
paramsLength: number;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Skill info from the skills directory */
|
|
161
|
+
export interface SkillInfo {
|
|
162
|
+
source: Source;
|
|
163
|
+
category: string;
|
|
164
|
+
name: string;
|
|
165
|
+
description: string;
|
|
166
|
+
tags: string[];
|
|
167
|
+
/**
|
|
168
|
+
* Token cost of this skill's index entry in the system prompt
|
|
169
|
+
* (name + description + tags). Body content is NOT included — it
|
|
170
|
+
* only loads on skill_view() and shows up as a tool result.
|
|
171
|
+
*/
|
|
172
|
+
tokenEstimate: number;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Read description/tags from a SKILL.md YAML frontmatter block.
|
|
177
|
+
* Cheap — reads only the first ~2KB. Missing file / no `---` → empty.
|
|
178
|
+
*/
|
|
179
|
+
export function readSkillFrontmatter(source: Source): { description: string; tags: string[] } {
|
|
180
|
+
try {
|
|
181
|
+
const fd = openSync(source.file, "r");
|
|
182
|
+
const buf = Buffer.alloc(2048);
|
|
183
|
+
const n = readSync(fd, buf, 0, 2048, 0);
|
|
184
|
+
closeSync(fd);
|
|
185
|
+
const head = buf.toString("utf-8", 0, n);
|
|
186
|
+
if (!head.startsWith("---")) return { description: "", tags: [] };
|
|
187
|
+
const end = head.indexOf("\n---", 3);
|
|
188
|
+
if (end < 0) return { description: "", tags: [] };
|
|
189
|
+
const fm = parseYaml(head.slice(3, end)) as Record<string, unknown>;
|
|
190
|
+
const tags = Array.isArray(fm.tags) ? fm.tags.map(String) : [];
|
|
191
|
+
return { description: String(fm.description ?? ""), tags };
|
|
192
|
+
} catch {
|
|
193
|
+
return { description: "", tags: [] };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Per-skill telemetry sidecar record (~/.hermes/skills/.usage.json). */
|
|
198
|
+
export interface SkillUsage {
|
|
199
|
+
use_count: number;
|
|
200
|
+
view_count: number;
|
|
201
|
+
patch_count: number;
|
|
202
|
+
last_used_at: string | null;
|
|
203
|
+
last_viewed_at: string | null;
|
|
204
|
+
last_patched_at: string | null;
|
|
205
|
+
created_at: string | null;
|
|
206
|
+
archived_at: string | null;
|
|
207
|
+
state: "active" | "stale" | "archived";
|
|
208
|
+
pinned: boolean;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Read ~/.hermes/skills/.usage.json. Keyed by skill name.
|
|
213
|
+
* Returns empty record on any failure — absent sidecar is the default.
|
|
214
|
+
*/
|
|
215
|
+
export async function readSkillUsage(): Promise<Record<string, SkillUsage>> {
|
|
216
|
+
try {
|
|
217
|
+
const f = Bun.file(hermesPath("skills/.usage.json"));
|
|
218
|
+
if (!(await f.exists())) return {};
|
|
219
|
+
const raw = await f.json() as Record<string, Partial<SkillUsage>>;
|
|
220
|
+
const out: Record<string, SkillUsage> = {};
|
|
221
|
+
for (const [k, v] of Object.entries(raw ?? {})) {
|
|
222
|
+
out[k] = {
|
|
223
|
+
use_count: Number(v.use_count ?? 0),
|
|
224
|
+
view_count: Number(v.view_count ?? 0),
|
|
225
|
+
patch_count: Number(v.patch_count ?? 0),
|
|
226
|
+
last_used_at: v.last_used_at ?? null,
|
|
227
|
+
last_viewed_at: v.last_viewed_at ?? null,
|
|
228
|
+
last_patched_at: v.last_patched_at ?? null,
|
|
229
|
+
created_at: v.created_at ?? null,
|
|
230
|
+
archived_at: v.archived_at ?? null,
|
|
231
|
+
state: (v.state as SkillUsage["state"]) ?? "active",
|
|
232
|
+
pinned: Boolean(v.pinned),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
} catch {
|
|
237
|
+
return {};
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Curator scheduler state (~/.hermes/skills/.curator_state). */
|
|
242
|
+
export interface CuratorState {
|
|
243
|
+
last_run_at: string | null;
|
|
244
|
+
last_run_duration_seconds: number | null;
|
|
245
|
+
last_run_summary: string | null;
|
|
246
|
+
paused: boolean;
|
|
247
|
+
run_count: number;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function readCuratorState(): Promise<CuratorState | null> {
|
|
251
|
+
try {
|
|
252
|
+
const f = Bun.file(hermesPath("skills/.curator_state"));
|
|
253
|
+
if (!(await f.exists())) return null;
|
|
254
|
+
const raw = await f.json() as Partial<CuratorState>;
|
|
255
|
+
return {
|
|
256
|
+
last_run_at: raw.last_run_at ?? null,
|
|
257
|
+
last_run_duration_seconds: raw.last_run_duration_seconds ?? null,
|
|
258
|
+
last_run_summary: raw.last_run_summary ?? null,
|
|
259
|
+
paused: Boolean(raw.paused),
|
|
260
|
+
run_count: Number(raw.run_count ?? 0),
|
|
261
|
+
};
|
|
262
|
+
} catch {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Locate the newest curator run report — returns {dir, mtime} of the
|
|
269
|
+
* directory under ~/.hermes/logs/curator/ with the latest mtime.
|
|
270
|
+
* Returns null if none exist.
|
|
271
|
+
*/
|
|
272
|
+
export interface CuratorReportInfo {
|
|
273
|
+
/** Source to the REPORT.md inside the newest run dir. */
|
|
274
|
+
source: Source;
|
|
275
|
+
/** Raw REPORT.md body, trimmed. */
|
|
276
|
+
content: string;
|
|
277
|
+
/** Run dir name, e.g. "20260430-120030". */
|
|
278
|
+
runId: string;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export async function readLatestCuratorReport(): Promise<CuratorReportInfo | null> {
|
|
282
|
+
try {
|
|
283
|
+
const base = `${HERMES_HOME}/logs/curator`;
|
|
284
|
+
const entries = readdirSync(base, { withFileTypes: true }).filter(e => e.isDirectory());
|
|
285
|
+
if (entries.length === 0) return null;
|
|
286
|
+
// Run dirs are named YYYYMMDD-HHMMSS — lexicographic sort = chronological.
|
|
287
|
+
entries.sort((a, b) => b.name.localeCompare(a.name));
|
|
288
|
+
const runId = entries[0]!.name;
|
|
289
|
+
const rel = `logs/curator/${runId}/REPORT.md`;
|
|
290
|
+
const source = makeSource(rel);
|
|
291
|
+
const body = await Bun.file(source.file).text();
|
|
292
|
+
return { source, content: body.trim(), runId };
|
|
293
|
+
} catch {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** One curator run — id is the YYYYMMDD-HHMMSS dir name; counts come
|
|
299
|
+
* from run.json.counts; REPORT.md is lazy-loaded on expand. */
|
|
300
|
+
export type CuratorRun = {
|
|
301
|
+
id: string
|
|
302
|
+
at: number
|
|
303
|
+
archived: number; consolidated: number; added: number
|
|
304
|
+
before: number; after: number
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function listCuratorRuns(): CuratorRun[] {
|
|
308
|
+
try {
|
|
309
|
+
const base = `${HERMES_HOME}/logs/curator`;
|
|
310
|
+
return readdirSync(base, { withFileTypes: true })
|
|
311
|
+
.filter(e => e.isDirectory())
|
|
312
|
+
.sort((a, b) => b.name.localeCompare(a.name))
|
|
313
|
+
.flatMap(e => {
|
|
314
|
+
try {
|
|
315
|
+
const fd = openSync(`${base}/${e.name}/run.json`, "r");
|
|
316
|
+
const buf = Buffer.alloc(8192);
|
|
317
|
+
const n = readSync(fd, buf);
|
|
318
|
+
closeSync(fd);
|
|
319
|
+
const j = JSON.parse(buf.toString("utf8", 0, n));
|
|
320
|
+
const c = j.counts ?? {};
|
|
321
|
+
return [{
|
|
322
|
+
id: e.name,
|
|
323
|
+
at: Date.parse(j.started_at ?? "") / 1000 || 0,
|
|
324
|
+
archived: c.archived_this_run ?? 0,
|
|
325
|
+
consolidated: c.consolidated_this_run ?? 0,
|
|
326
|
+
added: c.added_this_run ?? 0,
|
|
327
|
+
before: c.before ?? 0, after: c.after ?? 0,
|
|
328
|
+
}];
|
|
329
|
+
} catch { return [] }
|
|
330
|
+
});
|
|
331
|
+
} catch { return [] }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export async function readCuratorReport(id: string): Promise<string> {
|
|
335
|
+
try {
|
|
336
|
+
return (await Bun.file(hermesPath(`logs/curator/${id}/REPORT.md`)).text()).trim();
|
|
337
|
+
} catch { return "" }
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Per-skill curator event, projected out of run.json so DetailPanel
|
|
341
|
+
* can answer "what did curator do to *this* skill, and when". */
|
|
342
|
+
export type LineageEvent =
|
|
343
|
+
| { kind: "transition"; run: string; at: number; from: string; to: string }
|
|
344
|
+
| { kind: "absorbed"; run: string; at: number; sources: string[] }
|
|
345
|
+
| { kind: "merged"; run: string; at: number; into: string; reason?: string }
|
|
346
|
+
| { kind: "pruned"; run: string; at: number; reason?: string }
|
|
347
|
+
| { kind: "added"; run: string; at: number }
|
|
348
|
+
|
|
349
|
+
export type LineageIndex = Map<string, LineageEvent[]>
|
|
350
|
+
|
|
351
|
+
type RunJson = {
|
|
352
|
+
started_at?: string
|
|
353
|
+
archived?: string[]
|
|
354
|
+
added?: string[]
|
|
355
|
+
consolidated?: { name: string; into: string; reason?: string }[]
|
|
356
|
+
pruned?: { name: string; reason?: string }[]
|
|
357
|
+
state_transitions?: { name: string; from: string; to: string }[]
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Build a skill→events index across every run.json. Runs: dozens,
|
|
361
|
+
* payload: low-KB JSON each — call once per tab open. Newest-first. */
|
|
362
|
+
export function indexCuratorLineage(): LineageIndex {
|
|
363
|
+
const out: LineageIndex = new Map()
|
|
364
|
+
const push = (name: string, ev: LineageEvent) => {
|
|
365
|
+
const a = out.get(name) ?? []
|
|
366
|
+
a.push(ev); out.set(name, a)
|
|
367
|
+
}
|
|
368
|
+
try {
|
|
369
|
+
const base = hermesPath("logs/curator")
|
|
370
|
+
for (const e of readdirSync(base, { withFileTypes: true })) {
|
|
371
|
+
if (!e.isDirectory()) continue
|
|
372
|
+
try {
|
|
373
|
+
const j = JSON.parse(readFileSync(`${base}/${e.name}/run.json`, "utf8")) as RunJson
|
|
374
|
+
const at = Date.parse(j.started_at ?? "") / 1000 || 0
|
|
375
|
+
const run = e.name
|
|
376
|
+
// targets absorbing 1+ sources
|
|
377
|
+
const into = new Map<string, string[]>()
|
|
378
|
+
for (const c of j.consolidated ?? []) {
|
|
379
|
+
push(c.name, { kind: "merged", run, at, into: c.into, reason: c.reason })
|
|
380
|
+
into.set(c.into, [...(into.get(c.into) ?? []), c.name])
|
|
381
|
+
}
|
|
382
|
+
for (const [tgt, srcs] of into)
|
|
383
|
+
push(tgt, { kind: "absorbed", run, at, sources: srcs })
|
|
384
|
+
for (const t of j.state_transitions ?? [])
|
|
385
|
+
push(t.name, { kind: "transition", run, at, from: t.from, to: t.to })
|
|
386
|
+
for (const p of j.pruned ?? [])
|
|
387
|
+
push(p.name, { kind: "pruned", run, at, reason: p.reason })
|
|
388
|
+
for (const n of j.added ?? [])
|
|
389
|
+
push(n, { kind: "added", run, at })
|
|
390
|
+
} catch {}
|
|
391
|
+
}
|
|
392
|
+
} catch {}
|
|
393
|
+
for (const a of out.values()) a.sort((x, y) => y.at - x.at)
|
|
394
|
+
return out
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Cron-generated hermes-agent changelog digest (tji.3). The cron job
|
|
399
|
+
* writes ~/.hermes/herm/changelog.md as `# hermes-agent — N new
|
|
400
|
+
* commits` + bullets. Splash shows the first bullet; the "N behind"
|
|
401
|
+
* count already comes from session.info.update_behind, so this only
|
|
402
|
+
* needs to surface the prose.
|
|
403
|
+
*/
|
|
404
|
+
export type Changelog = { source: Source; headline: string; body: string }
|
|
405
|
+
|
|
406
|
+
export function readChangelog(): Changelog | null {
|
|
407
|
+
try {
|
|
408
|
+
const source = makeSource("herm/changelog.md");
|
|
409
|
+
// Bun.file().text() is async; splash render is sync-first-frame,
|
|
410
|
+
// so openSync/readSync to keep the hot path synchronous.
|
|
411
|
+
const fd = openSync(source.file, "r");
|
|
412
|
+
const buf = Buffer.alloc(4096);
|
|
413
|
+
const n = readSync(fd, buf);
|
|
414
|
+
closeSync(fd);
|
|
415
|
+
const body = buf.toString("utf8", 0, n).trim();
|
|
416
|
+
const line = body.split("\n").find(l => /^[-*·]/.test(l.trim()));
|
|
417
|
+
return { source, headline: line?.replace(/^[-*·]\s*/, "").trim() ?? "", body };
|
|
418
|
+
} catch {
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** SOUL.md info */
|
|
424
|
+
export interface SoulInfo {
|
|
425
|
+
source: Source;
|
|
426
|
+
charCount: number;
|
|
427
|
+
tokenEstimate: number;
|
|
428
|
+
/** Raw SOUL.md body. Consumed by the Context drill-down detail panel. */
|
|
429
|
+
content: string;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** System prompt breakdown — full text for section parsing */
|
|
433
|
+
export interface SystemPromptInfo {
|
|
434
|
+
source: Source;
|
|
435
|
+
sessionId: string;
|
|
436
|
+
text: string;
|
|
437
|
+
totalChars: number;
|
|
438
|
+
tokenEstimate: number;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Tool list with its source session file */
|
|
442
|
+
export interface ToolsInfo {
|
|
443
|
+
source: Source;
|
|
444
|
+
tools: ToolInfo[];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ─── Readers ─────────────────────────────────────────────────────────
|
|
448
|
+
|
|
449
|
+
/** Read and parse config.yaml */
|
|
450
|
+
export async function readConfig(): Promise<HermesConfig | null> {
|
|
451
|
+
try {
|
|
452
|
+
const file = Bun.file(hermesPath("config.yaml"));
|
|
453
|
+
const text = await file.text();
|
|
454
|
+
const raw = parseYaml(text);
|
|
455
|
+
return {
|
|
456
|
+
source: makeSource("config.yaml", "config.yaml"),
|
|
457
|
+
model: {
|
|
458
|
+
default: raw?.model?.default ?? "unknown",
|
|
459
|
+
provider: raw?.model?.provider ?? "auto",
|
|
460
|
+
base_url: raw?.model?.base_url ?? "",
|
|
461
|
+
},
|
|
462
|
+
agent: {
|
|
463
|
+
max_turns: raw?.agent?.max_turns ?? 60,
|
|
464
|
+
reasoning_effort: raw?.agent?.reasoning_effort ?? "medium",
|
|
465
|
+
},
|
|
466
|
+
compression: {
|
|
467
|
+
enabled: raw?.compression?.enabled ?? true,
|
|
468
|
+
threshold: raw?.compression?.threshold ?? 0.5,
|
|
469
|
+
target_ratio: raw?.compression?.target_ratio ?? 0.2,
|
|
470
|
+
protect_last_n: raw?.compression?.protect_last_n ?? 20,
|
|
471
|
+
summary_model: raw?.compression?.summary_model ?? "",
|
|
472
|
+
},
|
|
473
|
+
memory: {
|
|
474
|
+
memory_enabled: raw?.memory?.memory_enabled ?? true,
|
|
475
|
+
user_profile_enabled: raw?.memory?.user_profile_enabled ?? true,
|
|
476
|
+
memory_char_limit: raw?.memory?.memory_char_limit ?? 2200,
|
|
477
|
+
user_char_limit: raw?.memory?.user_char_limit ?? 1375,
|
|
478
|
+
provider: raw?.memory?.provider ?? "",
|
|
479
|
+
nudge_interval: raw?.memory?.nudge_interval ?? 10,
|
|
480
|
+
flush_min_turns: raw?.memory?.flush_min_turns ?? 6,
|
|
481
|
+
},
|
|
482
|
+
display: {
|
|
483
|
+
personality: raw?.display?.personality ?? "default",
|
|
484
|
+
skin: raw?.display?.skin ?? "default",
|
|
485
|
+
show_cost: raw?.display?.show_cost ?? false,
|
|
486
|
+
},
|
|
487
|
+
curator: {
|
|
488
|
+
enabled: raw?.curator?.enabled ?? true,
|
|
489
|
+
interval_hours: raw?.curator?.interval_hours ?? 168,
|
|
490
|
+
stale_after_days: raw?.curator?.stale_after_days ?? 30,
|
|
491
|
+
archive_after_days: raw?.curator?.archive_after_days ?? 90,
|
|
492
|
+
},
|
|
493
|
+
gateway: {
|
|
494
|
+
platforms: {
|
|
495
|
+
api_server: raw?.gateway?.platforms?.api_server ?? undefined,
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
};
|
|
499
|
+
} catch {
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Read a memory file (MEMORY.md or USER.md) with limit context */
|
|
505
|
+
export async function readMemoryFile(
|
|
506
|
+
filename: "MEMORY.md" | "USER.md",
|
|
507
|
+
charLimit: number,
|
|
508
|
+
): Promise<MemoryFileInfo | null> {
|
|
509
|
+
try {
|
|
510
|
+
const relative = `memories/${filename}`;
|
|
511
|
+
const file = Bun.file(hermesPath(relative));
|
|
512
|
+
const content = await file.text();
|
|
513
|
+
const entryCount = content.split("§").filter((s) => s.trim()).length;
|
|
514
|
+
return {
|
|
515
|
+
source: makeSource(relative, filename),
|
|
516
|
+
content,
|
|
517
|
+
charCount: content.length,
|
|
518
|
+
charLimit,
|
|
519
|
+
usagePercent:
|
|
520
|
+
charLimit > 0 ? Math.round((content.length / charLimit) * 100) : 0,
|
|
521
|
+
entryCount,
|
|
522
|
+
};
|
|
523
|
+
} catch {
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Read sessions/sessions.json (live session index) */
|
|
529
|
+
export async function readLiveSessions(): Promise<
|
|
530
|
+
Record<string, LiveSession>
|
|
531
|
+
> {
|
|
532
|
+
try {
|
|
533
|
+
const file = Bun.file(hermesPath("sessions/sessions.json"));
|
|
534
|
+
const text = await file.text();
|
|
535
|
+
return JSON.parse(text);
|
|
536
|
+
} catch {
|
|
537
|
+
return {};
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ─── Session store ───────────────────────────────────────────────────
|
|
542
|
+
// Everything session-shaped lives in sessions-db.ts now — one readonly
|
|
543
|
+
// handle, one parent→child classification rule, shared SQL. These
|
|
544
|
+
// aliases preserve the old hermes-home API for home/store.ts and
|
|
545
|
+
// test/hermes-home-sessions.test.ts; new code should import from
|
|
546
|
+
// sessions-db directly.
|
|
547
|
+
|
|
548
|
+
import {
|
|
549
|
+
roots as _roots, children as _children, lineage as _lineage,
|
|
550
|
+
search as _search,
|
|
551
|
+
} from "./sessions-db"
|
|
552
|
+
export type { LineageInfo, SessionHit } from "./sessions-db"
|
|
553
|
+
export const queryRecentSessions = _roots
|
|
554
|
+
export const querySubagents = _children
|
|
555
|
+
export const queryLineage = _lineage
|
|
556
|
+
export const searchSessions = _search
|
|
557
|
+
|
|
558
|
+
/** Memory provider info — what's configured and available */
|
|
559
|
+
export interface MemoryProviderInfo {
|
|
560
|
+
name: string;
|
|
561
|
+
active: boolean;
|
|
562
|
+
config: Record<string, string | number | boolean>;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Per-provider local config/state file locations under HERMES_HOME.
|
|
566
|
+
// This is lookup data, not an enumeration — discovery comes from
|
|
567
|
+
// discoverMemoryProviders() below.
|
|
568
|
+
const MEMORY_CFG_FILES: Record<string, string[]> = {
|
|
569
|
+
mem0: ["mem0.json"],
|
|
570
|
+
honcho: ["honcho.json"],
|
|
571
|
+
hindsight: ["hindsight/config.json"],
|
|
572
|
+
supermemory: ["supermemory.json"],
|
|
573
|
+
holographic: ["holographic.db"],
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
/** Scan the bundled hermes-agent memory-plugin dir for provider names
|
|
577
|
+
(mirrors plugins/memory/__init__.py discover's dir walk). User-
|
|
578
|
+
installed providers in $HERMES_HOME/plugins/ aren't distinguished
|
|
579
|
+
from non-memory plugins without importing them — wait for the
|
|
580
|
+
memory.providers RPC for those. */
|
|
581
|
+
function discoverMemoryProviders(): string[] {
|
|
582
|
+
const names = new Set<string>(["builtin"]);
|
|
583
|
+
try {
|
|
584
|
+
for (const e of readdirSync(`${HERMES_HOME}/hermes-agent/plugins/memory`, { withFileTypes: true }))
|
|
585
|
+
if (e.isDirectory() && !e.name.startsWith("_")) names.add(e.name);
|
|
586
|
+
} catch {}
|
|
587
|
+
return [...names];
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** Read memory provider configs from ~/.hermes/ — one entry per
|
|
591
|
+
discovered provider, with any local config file parsed in. */
|
|
592
|
+
export async function readMemoryProviders(
|
|
593
|
+
activeProvider: string,
|
|
594
|
+
): Promise<MemoryProviderInfo[]> {
|
|
595
|
+
const out: MemoryProviderInfo[] = [];
|
|
596
|
+
for (const name of discoverMemoryProviders()) {
|
|
597
|
+
if (name === "builtin") { out.push({ name, active: true, config: {} }); continue; }
|
|
598
|
+
const cfg: Record<string, string | number | boolean> = {};
|
|
599
|
+
for (const f of MEMORY_CFG_FILES[name] ?? []) {
|
|
600
|
+
try {
|
|
601
|
+
const file = Bun.file(hermesPath(f));
|
|
602
|
+
if (f.endsWith(".json")) {
|
|
603
|
+
const raw = await file.json();
|
|
604
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
605
|
+
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
|
|
606
|
+
// Redact keys/tokens
|
|
607
|
+
const lower = k.toLowerCase();
|
|
608
|
+
if (lower.includes("key") || lower.includes("token") || lower.includes("secret")) {
|
|
609
|
+
cfg[k] = typeof v === "string" ? `${v.slice(0, 4)}...` : v;
|
|
610
|
+
} else {
|
|
611
|
+
cfg[k] = v;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
} else {
|
|
616
|
+
const st = await file.stat();
|
|
617
|
+
if (st) cfg["db_size"] = `${Math.round(st.size / 1024)}KB`;
|
|
618
|
+
}
|
|
619
|
+
} catch {}
|
|
620
|
+
}
|
|
621
|
+
out.push({ name, active: name === activeProvider, config: cfg });
|
|
622
|
+
}
|
|
623
|
+
return out;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** Read SOUL.md */
|
|
627
|
+
export async function readSoul(): Promise<SoulInfo | null> {
|
|
628
|
+
try {
|
|
629
|
+
const file = Bun.file(hermesPath("SOUL.md"));
|
|
630
|
+
const text = await file.text();
|
|
631
|
+
return {
|
|
632
|
+
source: makeSource("SOUL.md"),
|
|
633
|
+
charCount: text.length,
|
|
634
|
+
tokenEstimate: tokenCount(text),
|
|
635
|
+
content: text,
|
|
636
|
+
};
|
|
637
|
+
} catch {
|
|
638
|
+
return null;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Read tool list from the most recent session JSON */
|
|
643
|
+
export async function readToolsFromLatestSession(): Promise<ToolsInfo | null> {
|
|
644
|
+
try {
|
|
645
|
+
const glob = new Bun.Glob("session_*.json");
|
|
646
|
+
let latestPath = "";
|
|
647
|
+
let latestTime = 0;
|
|
648
|
+
|
|
649
|
+
for await (const path of glob.scan({ cwd: hermesPath("sessions") })) {
|
|
650
|
+
const file = Bun.file(hermesPath(`sessions/${path}`));
|
|
651
|
+
const stat = await file.stat();
|
|
652
|
+
if (stat && stat.mtimeMs > latestTime) {
|
|
653
|
+
latestTime = stat.mtimeMs;
|
|
654
|
+
latestPath = path;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (!latestPath) return null;
|
|
659
|
+
|
|
660
|
+
const relative = `sessions/${latestPath}`;
|
|
661
|
+
const file = Bun.file(hermesPath(relative));
|
|
662
|
+
const data = await file.json();
|
|
663
|
+
type RawTool = { function?: { name?: string; description?: string; parameters?: unknown } };
|
|
664
|
+
const tools: ToolInfo[] = (data.tools || []).map((t: RawTool) => ({
|
|
665
|
+
name: t?.function?.name ?? "unknown",
|
|
666
|
+
descriptionLength: (t?.function?.description ?? "").length,
|
|
667
|
+
paramsLength: JSON.stringify(t?.function?.parameters ?? {}).length,
|
|
668
|
+
}));
|
|
669
|
+
return {
|
|
670
|
+
source: makeSource(relative, latestPath),
|
|
671
|
+
tools,
|
|
672
|
+
};
|
|
673
|
+
} catch {
|
|
674
|
+
return null;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** Read system prompt from the most recent state.db session that has a full one */
|
|
679
|
+
export function readSystemPromptInfo(): SystemPromptInfo | null {
|
|
680
|
+
try {
|
|
681
|
+
const db = new Database(hermesPath("state.db"), { readonly: true });
|
|
682
|
+
// Short prompts (~700 chars) are the generic fallback without SOUL/memory/skills.
|
|
683
|
+
const row = db
|
|
684
|
+
.query(
|
|
685
|
+
`SELECT id, system_prompt
|
|
686
|
+
FROM sessions
|
|
687
|
+
WHERE system_prompt IS NOT NULL AND length(system_prompt) > 1000
|
|
688
|
+
ORDER BY started_at DESC LIMIT 1`,
|
|
689
|
+
)
|
|
690
|
+
.get() as { id: string; system_prompt: string } | null;
|
|
691
|
+
db.close();
|
|
692
|
+
if (!row) return null;
|
|
693
|
+
return {
|
|
694
|
+
source: makeSource("state.db"),
|
|
695
|
+
sessionId: row.id,
|
|
696
|
+
text: row.system_prompt,
|
|
697
|
+
totalChars: row.system_prompt.length,
|
|
698
|
+
tokenEstimate: tokenCount(row.system_prompt),
|
|
699
|
+
};
|
|
700
|
+
} catch {
|
|
701
|
+
return null;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
export interface CronOutput {
|
|
706
|
+
at: Date
|
|
707
|
+
path: string
|
|
708
|
+
text: string
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** Read the most recent cron output for a job, tail-truncated. */
|
|
712
|
+
export async function readCronOutput(
|
|
713
|
+
jobId: string,
|
|
714
|
+
tailLines = 40,
|
|
715
|
+
): Promise<CronOutput | null> {
|
|
716
|
+
const dir = hermesPath(`cron/output/${jobId}`);
|
|
717
|
+
let entries: string[];
|
|
718
|
+
try {
|
|
719
|
+
entries = await readdir(dir);
|
|
720
|
+
} catch {
|
|
721
|
+
return null;
|
|
722
|
+
}
|
|
723
|
+
const md = entries.filter(f => f.endsWith(".md")).sort().reverse();
|
|
724
|
+
if (md.length === 0) return null;
|
|
725
|
+
const path = `${dir}/${md[0]}`;
|
|
726
|
+
const full = await Bun.file(path).text();
|
|
727
|
+
const lines = full.trimEnd().split("\n");
|
|
728
|
+
const text =
|
|
729
|
+
lines.length > tailLines
|
|
730
|
+
? `…(${lines.length - tailLines} earlier lines)\n` +
|
|
731
|
+
lines.slice(-tailLines).join("\n")
|
|
732
|
+
: full.trimEnd();
|
|
733
|
+
const st = await stat(path);
|
|
734
|
+
return { at: st.mtime, path, text };
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ─── Env File CRUD ──────────────────────────────────────────────────
|
|
738
|
+
|
|
739
|
+
const ENV_PATH = hermesPath(".env");
|
|
740
|
+
|
|
741
|
+
/** Parse ~/.hermes/.env into Record<string, string> */
|
|
742
|
+
export async function readEnvFile(): Promise<Record<string, string>> {
|
|
743
|
+
try {
|
|
744
|
+
const text = await Bun.file(ENV_PATH).text();
|
|
745
|
+
const vars: Record<string, string> = {};
|
|
746
|
+
for (const line of text.split("\n")) {
|
|
747
|
+
const trimmed = line.trim();
|
|
748
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
749
|
+
const eq = trimmed.indexOf("=");
|
|
750
|
+
if (eq < 1) continue;
|
|
751
|
+
vars[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
|
|
752
|
+
}
|
|
753
|
+
return vars;
|
|
754
|
+
} catch {
|
|
755
|
+
return {};
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** Update or append a KEY=VALUE in ~/.hermes/.env */
|
|
760
|
+
export async function writeEnvVar(key: string, value: string): Promise<void> {
|
|
761
|
+
let text = "";
|
|
762
|
+
try {
|
|
763
|
+
text = await Bun.file(ENV_PATH).text();
|
|
764
|
+
} catch { /* file may not exist */ }
|
|
765
|
+
|
|
766
|
+
const lines = text.split("\n");
|
|
767
|
+
let found = false;
|
|
768
|
+
const updated = lines.map(line => {
|
|
769
|
+
if (line.startsWith(`${key}=`)) {
|
|
770
|
+
found = true;
|
|
771
|
+
return `${key}=${value}`;
|
|
772
|
+
}
|
|
773
|
+
return line;
|
|
774
|
+
});
|
|
775
|
+
if (!found) updated.push(`${key}=${value}`);
|
|
776
|
+
|
|
777
|
+
await Bun.write(ENV_PATH, updated.join("\n"));
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** Remove a key from ~/.hermes/.env */
|
|
781
|
+
export async function removeEnvVar(key: string): Promise<void> {
|
|
782
|
+
let text = "";
|
|
783
|
+
try {
|
|
784
|
+
text = await Bun.file(ENV_PATH).text();
|
|
785
|
+
} catch { return; }
|
|
786
|
+
|
|
787
|
+
const lines = text.split("\n").filter(l => !l.startsWith(`${key}=`));
|
|
788
|
+
await Bun.write(ENV_PATH, lines.join("\n"));
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// ─── Provider Catalog ───────────────────────────────────────────────
|
|
792
|
+
|
|
793
|
+
export const ENV_CATALOG: ReadonlyArray<{ category: string; keys: string[] }> = [
|
|
794
|
+
{
|
|
795
|
+
category: "LLM Providers",
|
|
796
|
+
keys: [
|
|
797
|
+
"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GOOGLE_API_KEY",
|
|
798
|
+
"DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "GROQ_API_KEY",
|
|
799
|
+
"MISTRAL_API_KEY", "XAI_API_KEY", "TOGETHER_API_KEY",
|
|
800
|
+
"FIREWORKS_API_KEY", "NOUS_API_KEY",
|
|
801
|
+
],
|
|
802
|
+
},
|
|
803
|
+
{
|
|
804
|
+
category: "Tool API Keys",
|
|
805
|
+
keys: [
|
|
806
|
+
"FIRECRAWL_API_KEY", "BROWSERBASE_API_KEY", "BROWSERBASE_PROJECT_ID",
|
|
807
|
+
"TAVILY_API_KEY", "EXA_API_KEY", "ELEVENLABS_API_KEY",
|
|
808
|
+
],
|
|
809
|
+
},
|
|
810
|
+
{
|
|
811
|
+
category: "Messaging",
|
|
812
|
+
keys: [
|
|
813
|
+
"TELEGRAM_BOT_TOKEN", "DISCORD_BOT_TOKEN",
|
|
814
|
+
"SLACK_BOT_TOKEN", "SLACK_APP_TOKEN",
|
|
815
|
+
],
|
|
816
|
+
},
|
|
817
|
+
{
|
|
818
|
+
category: "Agent",
|
|
819
|
+
keys: ["API_SERVER_KEY", "MEM0_API_KEY"],
|
|
820
|
+
},
|
|
821
|
+
];
|