@johpaz/hive-core 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/package.json +43 -0
  2. package/src/agent/compaction.ts +161 -0
  3. package/src/agent/context-guard.ts +91 -0
  4. package/src/agent/context.ts +148 -0
  5. package/src/agent/ethics.ts +102 -0
  6. package/src/agent/hooks.ts +166 -0
  7. package/src/agent/index.ts +67 -0
  8. package/src/agent/providers/index.ts +278 -0
  9. package/src/agent/providers.ts +1 -0
  10. package/src/agent/soul.ts +89 -0
  11. package/src/agent/stuck-loop.ts +133 -0
  12. package/src/agent/user.ts +86 -0
  13. package/src/channels/base.ts +91 -0
  14. package/src/channels/discord.ts +185 -0
  15. package/src/channels/index.ts +7 -0
  16. package/src/channels/manager.ts +204 -0
  17. package/src/channels/slack.ts +209 -0
  18. package/src/channels/telegram.ts +177 -0
  19. package/src/channels/webchat.ts +83 -0
  20. package/src/channels/whatsapp.ts +305 -0
  21. package/src/config/index.ts +1 -0
  22. package/src/config/loader.ts +508 -0
  23. package/src/gateway/index.ts +5 -0
  24. package/src/gateway/lane-queue.ts +169 -0
  25. package/src/gateway/router.ts +124 -0
  26. package/src/gateway/server.ts +347 -0
  27. package/src/gateway/session.ts +131 -0
  28. package/src/gateway/slash-commands.ts +176 -0
  29. package/src/heartbeat/index.ts +157 -0
  30. package/src/index.ts +21 -0
  31. package/src/memory/index.ts +1 -0
  32. package/src/memory/notes.ts +170 -0
  33. package/src/multi-agent/bindings.ts +171 -0
  34. package/src/multi-agent/index.ts +4 -0
  35. package/src/multi-agent/manager.ts +182 -0
  36. package/src/multi-agent/sandbox.ts +130 -0
  37. package/src/multi-agent/subagents.ts +302 -0
  38. package/src/security/index.ts +187 -0
  39. package/src/tools/cron.ts +156 -0
  40. package/src/tools/exec.ts +105 -0
  41. package/src/tools/index.ts +6 -0
  42. package/src/tools/memory.ts +176 -0
  43. package/src/tools/notify.ts +53 -0
  44. package/src/tools/read.ts +154 -0
  45. package/src/tools/registry.ts +115 -0
  46. package/src/tools/web.ts +186 -0
  47. package/src/utils/crypto.ts +73 -0
  48. package/src/utils/index.ts +3 -0
  49. package/src/utils/logger.ts +254 -0
  50. package/src/utils/retry.ts +70 -0
@@ -0,0 +1,67 @@
1
+ import type { Config } from "../config/loader.ts";
2
+ import { loadSoul } from "./soul.ts";
3
+ import { loadUser } from "./user.ts";
4
+ import { buildSystemPrompt } from "./context.ts";
5
+ import { logger } from "../utils/logger.ts";
6
+ import * as path from "node:path";
7
+
8
+ export interface AgentOptions {
9
+ agentId: string;
10
+ config: Config;
11
+ workspacePath: string;
12
+ }
13
+
14
+ export class Agent {
15
+ readonly agentId: string;
16
+ readonly workspacePath: string;
17
+ private config: Config;
18
+ private log = logger.child("agent");
19
+
20
+ constructor(options: AgentOptions) {
21
+ this.agentId = options.agentId;
22
+ this.workspacePath = options.workspacePath;
23
+ this.config = options.config;
24
+ }
25
+
26
+ async initialize(): Promise<void> {
27
+ this.log.info(`Initializing agent: ${this.agentId}`);
28
+
29
+ const soul = loadSoul(path.join(this.workspacePath, "SOUL.md"));
30
+ const user = loadUser(path.join(this.workspacePath, "USER.md"));
31
+
32
+ if (soul) {
33
+ this.log.debug(`Loaded SOUL.md for agent ${this.agentId}`);
34
+ }
35
+ if (user) {
36
+ this.log.debug(`Loaded USER.md for agent ${this.agentId}`);
37
+ }
38
+ }
39
+
40
+ buildPrompt(): string {
41
+ const soul = loadSoul(path.join(this.workspacePath, "SOUL.md"));
42
+ const user = loadUser(path.join(this.workspacePath, "USER.md"));
43
+
44
+ return buildSystemPrompt({
45
+ soul: soul ?? {
46
+ identity: "",
47
+ personality: "",
48
+ boundaries: "",
49
+ instructions: "",
50
+ capabilities: [],
51
+ raw: "",
52
+ },
53
+ user,
54
+ skills: [],
55
+ memory: [],
56
+ maxTokens: this.config.agent?.context?.maxTokens ?? 128000,
57
+ });
58
+ }
59
+
60
+ getConfig(): Config {
61
+ return this.config;
62
+ }
63
+
64
+ getWorkspacePath(): string {
65
+ return this.workspacePath;
66
+ }
67
+ }
@@ -0,0 +1,278 @@
1
+ import { generateText, streamText } from "ai";
2
+ import { createOpenAI } from "@ai-sdk/openai";
3
+ import { createAnthropic } from "@ai-sdk/anthropic";
4
+ import { createGoogleGenerativeAI } from "@ai-sdk/google";
5
+ import type { Config, ProviderConfig } from "../../config/loader.ts";
6
+ import { logger } from "../../utils/logger.ts";
7
+
8
+ export type Provider = "openai" | "anthropic" | "gemini" | "kimi" | "ollama" | "openrouter" | "deepseek";
9
+
10
+ export interface ModelOptions {
11
+ provider?: Provider;
12
+ model?: string;
13
+ maxTokens?: number;
14
+ temperature?: number;
15
+ system?: string;
16
+ messages: Array<{ role: string; content: string }>;
17
+ onToken?: (token: string) => void;
18
+ }
19
+
20
+ export interface ModelResponse {
21
+ content: string;
22
+ toolCalls?: Array<{
23
+ id: string;
24
+ name: string;
25
+ args: Record<string, unknown>;
26
+ }>;
27
+ usage?: {
28
+ promptTokens: number;
29
+ completionTokens: number;
30
+ totalTokens: number;
31
+ };
32
+ finishReason?: string;
33
+ }
34
+
35
+ interface ProviderState {
36
+ rateLimited: boolean;
37
+ rateLimitReset: number;
38
+ requestCount: number;
39
+ }
40
+
41
+ type LanguageModel = ReturnType<ReturnType<typeof createOpenAI>>;
42
+
43
+ const DEFAULT_MODELS: Record<Provider, string> = {
44
+ openai: "gpt-5.2",
45
+ anthropic: "claude-sonnet-4-6-20250514",
46
+ gemini: "gemini-2.5-flash",
47
+ kimi: "kimi-k2.5",
48
+ ollama: "llama4-maverick",
49
+ openrouter: "anthropic/claude-sonnet-4.6",
50
+ deepseek: "deepseek-v3.2",
51
+ };
52
+
53
+ export class ModelResolver {
54
+ private config: Config;
55
+ private providers: Map<Provider, ProviderState> = new Map();
56
+ private log = logger.child("model");
57
+
58
+ constructor(config: Config) {
59
+ this.config = config;
60
+ for (const p of Object.keys(DEFAULT_MODELS) as Provider[]) {
61
+ this.providers.set(p, { rateLimited: false, rateLimitReset: 0, requestCount: 0 });
62
+ }
63
+ }
64
+
65
+ private createOpenAIProvider(provConfig?: ProviderConfig) {
66
+ const apiKey = provConfig?.apiKey ?? process.env.OPENAI_API_KEY;
67
+ if (!apiKey) return null;
68
+ return createOpenAI({ apiKey, baseURL: provConfig?.baseUrl });
69
+ }
70
+
71
+ private createAnthropicProvider(provConfig?: ProviderConfig) {
72
+ const apiKey = provConfig?.apiKey ?? process.env.ANTHROPIC_API_KEY;
73
+ if (!apiKey) return null;
74
+ return createAnthropic({ apiKey, baseURL: provConfig?.baseUrl });
75
+ }
76
+
77
+ private createGeminiProvider(provConfig?: ProviderConfig) {
78
+ const apiKey = provConfig?.apiKey ?? process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? process.env.GEMINI_API_KEY;
79
+ if (!apiKey) return null;
80
+ return createGoogleGenerativeAI({ apiKey, baseURL: provConfig?.baseUrl });
81
+ }
82
+
83
+ private createKimiProvider(provConfig?: ProviderConfig) {
84
+ const apiKey = provConfig?.apiKey ?? process.env.MOONSHOT_API_KEY ?? process.env.KIMI_API_KEY;
85
+ if (!apiKey) return null;
86
+ return createOpenAI({
87
+ apiKey,
88
+ baseURL: provConfig?.baseUrl ?? "https://api.moonshot.ai/v1"
89
+ });
90
+ }
91
+
92
+ private createOllamaProvider(provConfig?: ProviderConfig) {
93
+ const baseUrl = provConfig?.baseUrl ?? "http://localhost:11434";
94
+ return createOpenAI({ apiKey: "ollama", baseURL: `${baseUrl}/v1` });
95
+ }
96
+
97
+ private createOpenRouterProvider(provConfig?: ProviderConfig) {
98
+ const apiKey = provConfig?.apiKey ?? process.env.OPENROUTER_API_KEY;
99
+ if (!apiKey) return null;
100
+ return createOpenAI({ apiKey, baseURL: provConfig?.baseUrl ?? "https://openrouter.ai/api/v1" });
101
+ }
102
+
103
+ private createDeepSeekProvider(provConfig?: ProviderConfig) {
104
+ const apiKey = provConfig?.apiKey ?? process.env.DEEPSEEK_API_KEY;
105
+ if (!apiKey) return null;
106
+ return createOpenAI({ apiKey, baseURL: provConfig?.baseUrl ?? "https://api.deepseek.com/v1" });
107
+ }
108
+
109
+ resolve(provider: Provider): { model: LanguageModel; name: string } | null {
110
+ const state = this.providers.get(provider);
111
+ if (!state) return null;
112
+
113
+ if (state.rateLimited && Date.now() < state.rateLimitReset) {
114
+ this.log.warn(`Provider ${provider} is rate limited`);
115
+ return null;
116
+ }
117
+
118
+ if (state.rateLimited && Date.now() >= state.rateLimitReset) {
119
+ state.rateLimited = false;
120
+ this.log.info(`Rate limit reset for ${provider}`);
121
+ }
122
+
123
+ const provConfig = this.config.models?.providers?.[provider];
124
+ const defaultModel = this.config.models?.defaults?.[provider] ?? DEFAULT_MODELS[provider];
125
+
126
+ let client: ReturnType<typeof createOpenAI> | ReturnType<typeof createAnthropic> | ReturnType<typeof createGoogleGenerativeAI> | null = null;
127
+
128
+ switch (provider) {
129
+ case "openai":
130
+ client = this.createOpenAIProvider(provConfig);
131
+ break;
132
+ case "anthropic":
133
+ client = this.createAnthropicProvider(provConfig);
134
+ break;
135
+ case "gemini":
136
+ client = this.createGeminiProvider(provConfig);
137
+ break;
138
+ case "kimi":
139
+ client = this.createKimiProvider(provConfig);
140
+ break;
141
+ case "ollama":
142
+ client = this.createOllamaProvider(provConfig);
143
+ break;
144
+ case "openrouter":
145
+ client = this.createOpenRouterProvider(provConfig);
146
+ break;
147
+ case "deepseek":
148
+ client = this.createDeepSeekProvider(provConfig);
149
+ break;
150
+ }
151
+
152
+ if (!client) {
153
+ this.log.warn(`Could not initialize provider ${provider}`);
154
+ return null;
155
+ }
156
+
157
+ state.requestCount++;
158
+ return { model: client(defaultModel) as LanguageModel, name: defaultModel };
159
+ }
160
+
161
+ markRateLimited(provider: Provider, resetAfterMs: number): void {
162
+ const state = this.providers.get(provider);
163
+ if (state) {
164
+ state.rateLimited = true;
165
+ state.rateLimitReset = Date.now() + resetAfterMs;
166
+ this.log.warn(`Rate limited ${provider} for ${resetAfterMs}ms`);
167
+ }
168
+ }
169
+
170
+ getAvailableProvider(): Provider | null {
171
+ const preferred = this.config.models?.defaultProvider ?? "gemini";
172
+ if (this.resolve(preferred)) return preferred;
173
+
174
+ for (const p of Object.keys(DEFAULT_MODELS) as Provider[]) {
175
+ if (p !== preferred && this.resolve(p)) return p;
176
+ }
177
+ return null;
178
+ }
179
+ }
180
+
181
+ export class AgentRunner {
182
+ private config: Config;
183
+ private resolver: ModelResolver;
184
+ private log = logger.child("runner");
185
+
186
+ constructor(config: Config) {
187
+ this.config = config;
188
+ this.resolver = new ModelResolver(config);
189
+ }
190
+
191
+ async generate(options: ModelOptions): Promise<ModelResponse> {
192
+ const provider = options.provider ?? this.config.models?.defaultProvider ?? "gemini";
193
+ const resolved = this.resolver.resolve(provider);
194
+
195
+ if (!resolved) {
196
+ throw new Error(`No available provider: ${provider}`);
197
+ }
198
+
199
+ this.log.debug(`Generating with ${provider}/${resolved.name}`);
200
+
201
+ const messages = options.messages.map((m) => ({
202
+ role: m.role as "user" | "assistant" | "system",
203
+ content: m.content,
204
+ }));
205
+
206
+ try {
207
+ if (options.onToken) {
208
+ const stream = await streamText({
209
+ model: resolved.model,
210
+ system: options.system,
211
+ messages,
212
+ temperature: options.temperature ?? 0.7,
213
+ });
214
+
215
+ let fullText = "";
216
+ for await (const chunk of stream.textStream) {
217
+ fullText += chunk;
218
+ options.onToken(chunk);
219
+ }
220
+
221
+ return {
222
+ content: fullText,
223
+ usage: {
224
+ promptTokens: 0,
225
+ completionTokens: 0,
226
+ totalTokens: 0,
227
+ },
228
+ finishReason: await stream.finishReason,
229
+ };
230
+ }
231
+
232
+ const result = await generateText({
233
+ model: resolved.model,
234
+ system: options.system,
235
+ messages,
236
+ temperature: options.temperature ?? 0.7,
237
+ });
238
+
239
+ const toolCalls = result.toolCalls?.map((tc) => {
240
+ const t = tc as unknown as { toolCallId: string; toolName: string; input: Record<string, unknown> };
241
+ return {
242
+ id: t.toolCallId,
243
+ name: t.toolName,
244
+ args: t.input,
245
+ };
246
+ });
247
+
248
+ return {
249
+ content: result.text,
250
+ toolCalls,
251
+ usage: {
252
+ promptTokens: 0,
253
+ completionTokens: 0,
254
+ totalTokens: 0,
255
+ },
256
+ finishReason: result.finishReason,
257
+ };
258
+ } catch (error) {
259
+ const err = error as Error & { statusCode?: number };
260
+
261
+ if (err.statusCode === 429 || err.message.includes("rate limit")) {
262
+ this.resolver.markRateLimited(provider, 60000);
263
+ throw new Error(`Rate limited on ${provider}, try another provider`);
264
+ }
265
+
266
+ this.log.error(`Generation failed: ${err.message}`);
267
+ throw error;
268
+ }
269
+ }
270
+
271
+ getResolver(): ModelResolver {
272
+ return this.resolver;
273
+ }
274
+ }
275
+
276
+ export function createAgentRunner(config: Config): AgentRunner {
277
+ return new AgentRunner(config);
278
+ }
@@ -0,0 +1 @@
1
+ export * from "./providers/index.ts";
@@ -0,0 +1,89 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { logger } from "../utils/logger.ts";
4
+
5
+ export interface SoulConfig {
6
+ identity: string;
7
+ personality: string;
8
+ boundaries: string;
9
+ instructions: string;
10
+ capabilities: string[];
11
+ raw: string;
12
+ }
13
+
14
+ function extractSection(content: string, sectionName: string): string {
15
+ const regex = new RegExp(`## ${sectionName}[\\s\\S]*?(?=## |$)`, "i");
16
+ const match = content.match(regex);
17
+ if (!match) return "";
18
+ return match[0]
19
+ .replace(new RegExp(`## ${sectionName}`, "i"), "")
20
+ .trim();
21
+ }
22
+
23
+ export function loadSoul(soulPath: string): SoulConfig | null {
24
+ try {
25
+ if (!fs.existsSync(soulPath)) {
26
+ return null;
27
+ }
28
+
29
+ const content = fs.readFileSync(soulPath, "utf-8");
30
+
31
+ return {
32
+ identity: extractSection(content, "Identity"),
33
+ personality: extractSection(content, "Personality"),
34
+ boundaries: extractSection(content, "Boundaries"),
35
+ instructions: extractSection(content, "Instructions"),
36
+ capabilities: [],
37
+ raw: content,
38
+ };
39
+ } catch (error) {
40
+ logger.error(`Failed to load SOUL.md: ${(error as Error).message}`);
41
+ return null;
42
+ }
43
+ }
44
+
45
+ export function soulExpandPath(p: string): string {
46
+ if (p.startsWith("~")) {
47
+ return path.join(process.env.HOME ?? "", p.slice(1));
48
+ }
49
+ return p;
50
+ }
51
+
52
+ export function watchSoul(
53
+ soulPath: string,
54
+ onChange: (soul: SoulConfig) => void
55
+ ): () => void {
56
+ const expandedPath = soulExpandPath(soulPath);
57
+ let lastContent = "";
58
+
59
+ try {
60
+ if (fs.existsSync(expandedPath)) {
61
+ lastContent = fs.readFileSync(expandedPath, "utf-8");
62
+ }
63
+ } catch {
64
+ // Ignore initial read errors
65
+ }
66
+
67
+ const watcher = fs.watch(
68
+ path.dirname(expandedPath),
69
+ (eventType, filename) => {
70
+ if (filename !== path.basename(expandedPath)) return;
71
+ if (eventType !== "change") return;
72
+
73
+ try {
74
+ const newContent = fs.readFileSync(expandedPath, "utf-8");
75
+ if (newContent !== lastContent) {
76
+ lastContent = newContent;
77
+ const soul = loadSoul(expandedPath);
78
+ if (soul) {
79
+ onChange(soul);
80
+ }
81
+ }
82
+ } catch (error) {
83
+ logger.error(`Error watching SOUL.md: ${(error as Error).message}`);
84
+ }
85
+ }
86
+ );
87
+
88
+ return () => watcher.close();
89
+ }
@@ -0,0 +1,133 @@
1
+ import type { Config } from "../config/loader.ts";
2
+ import { logger } from "../utils/logger.ts";
3
+ import { hashObject } from "../utils/crypto.ts";
4
+
5
+ interface ToolCallRecord {
6
+ toolName: string;
7
+ argsHash: string;
8
+ errorMessage?: string;
9
+ timestamp: number;
10
+ }
11
+
12
+ interface StuckLoopState {
13
+ detected: boolean;
14
+ toolName: string;
15
+ count: number;
16
+ lastError?: string;
17
+ }
18
+
19
+ export class StuckLoopDetector {
20
+ private log = logger.child("stuck-loop");
21
+ private history: Map<string, ToolCallRecord[]> = new Map();
22
+ private readonly maxHistoryPerSession = 50;
23
+ private readonly triggerThreshold = 3;
24
+
25
+ constructor(_config: Config) {}
26
+
27
+ recordToolCall(
28
+ sessionId: string,
29
+ toolName: string,
30
+ args: Record<string, unknown>,
31
+ error?: string
32
+ ): void {
33
+ let sessionHistory = this.history.get(sessionId);
34
+ if (!sessionHistory) {
35
+ sessionHistory = [];
36
+ this.history.set(sessionId, sessionHistory);
37
+ }
38
+
39
+ const record: ToolCallRecord = {
40
+ toolName,
41
+ argsHash: hashObject(args),
42
+ errorMessage: error,
43
+ timestamp: Date.now(),
44
+ };
45
+
46
+ sessionHistory.push(record);
47
+
48
+ if (sessionHistory.length > this.maxHistoryPerSession) {
49
+ sessionHistory.shift();
50
+ }
51
+
52
+ this.log.debug(`Recorded tool call: ${toolName} for session ${sessionId}`);
53
+ }
54
+
55
+ check(sessionId: string): StuckLoopState {
56
+ const sessionHistory = this.history.get(sessionId) ?? [];
57
+
58
+ if (sessionHistory.length < this.triggerThreshold) {
59
+ return { detected: false, toolName: "", count: 0 };
60
+ }
61
+
62
+ const recent = sessionHistory.slice(-10);
63
+ const counts = new Map<string, { count: number; error?: string }>();
64
+
65
+ for (const record of recent) {
66
+ const key = `${record.toolName}:${record.argsHash}`;
67
+ const existing = counts.get(key);
68
+
69
+ if (existing) {
70
+ existing.count++;
71
+ if (record.errorMessage) {
72
+ existing.error = record.errorMessage;
73
+ }
74
+ } else {
75
+ counts.set(key, { count: 1, error: record.errorMessage });
76
+ }
77
+ }
78
+
79
+ for (const [key, data] of counts) {
80
+ if (data.count >= this.triggerThreshold && data.error) {
81
+ const toolName = key.split(":")[0] ?? "unknown";
82
+
83
+ this.log.warn(`Stuck loop detected: ${toolName} called ${data.count} times with same args and error`);
84
+
85
+ return {
86
+ detected: true,
87
+ toolName,
88
+ count: data.count,
89
+ lastError: data.error,
90
+ };
91
+ }
92
+ }
93
+
94
+ return { detected: false, toolName: "", count: 0 };
95
+ }
96
+
97
+ getInterventionMessage(state: StuckLoopState): string | null {
98
+ if (!state.detected) return null;
99
+
100
+ if (state.count >= this.triggerThreshold + 1) {
101
+ return `CRITICAL: You have called ${state.toolName} ${state.count} times with the same arguments and it keeps failing with: "${state.lastError}". The user has been notified. You MUST try a completely different approach or ask the user for guidance.`;
102
+ }
103
+
104
+ return `WARNING: You have called ${state.toolName} ${state.count} times with the same arguments and it keeps failing. You MUST try a completely different approach instead of repeating the same action.`;
105
+ }
106
+
107
+ clear(sessionId: string): void {
108
+ this.history.delete(sessionId);
109
+ this.log.debug(`Cleared stuck loop history for session ${sessionId}`);
110
+ }
111
+
112
+ prune(maxAgeMs: number = 30 * 60 * 1000): number {
113
+ const now = Date.now();
114
+ let pruned = 0;
115
+
116
+ for (const [sessionId, history] of this.history) {
117
+ const filtered = history.filter(r => now - r.timestamp < maxAgeMs);
118
+
119
+ if (filtered.length === 0) {
120
+ this.history.delete(sessionId);
121
+ pruned++;
122
+ } else if (filtered.length !== history.length) {
123
+ this.history.set(sessionId, filtered);
124
+ }
125
+ }
126
+
127
+ return pruned;
128
+ }
129
+ }
130
+
131
+ export function createStuckLoopDetector(config: Config): StuckLoopDetector {
132
+ return new StuckLoopDetector(config);
133
+ }
@@ -0,0 +1,86 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { logger } from "../utils/logger.ts";
4
+
5
+ export interface UserConfig {
6
+ name: string;
7
+ language: string;
8
+ timezone: string;
9
+ activeProjects: string[];
10
+ preferences: Record<string, unknown>;
11
+ raw: string;
12
+ }
13
+
14
+ function extractFieldValue(content: string, fieldName: string): string {
15
+ const regex = new RegExp(`${fieldName}:\\s*(.+)`, "i");
16
+ const match = content.match(regex);
17
+ return match?.[1]?.trim() ?? "";
18
+ }
19
+
20
+ export function loadUser(userPath: string): UserConfig | null {
21
+ try {
22
+ if (!fs.existsSync(userPath)) {
23
+ return null;
24
+ }
25
+
26
+ const content = fs.readFileSync(userPath, "utf-8");
27
+
28
+ return {
29
+ name: extractFieldValue(content, "name") || "User",
30
+ language: extractFieldValue(content, "language") || "English",
31
+ timezone: extractFieldValue(content, "timezone") || "UTC",
32
+ activeProjects: [],
33
+ preferences: {},
34
+ raw: content,
35
+ };
36
+ } catch (error) {
37
+ logger.error(`Failed to load USER.md: ${(error as Error).message}`);
38
+ return null;
39
+ }
40
+ }
41
+
42
+ export function userExpandPath(p: string): string {
43
+ if (p.startsWith("~")) {
44
+ return path.join(process.env.HOME ?? "", p.slice(1));
45
+ }
46
+ return p;
47
+ }
48
+
49
+ export function watchUser(
50
+ userPath: string,
51
+ onChange: (user: UserConfig) => void
52
+ ): () => void {
53
+ const expandedPath = userExpandPath(userPath);
54
+ let lastContent = "";
55
+
56
+ try {
57
+ if (fs.existsSync(expandedPath)) {
58
+ lastContent = fs.readFileSync(expandedPath, "utf-8");
59
+ }
60
+ } catch {
61
+ // Ignore initial read errors
62
+ }
63
+
64
+ const watcher = fs.watch(
65
+ path.dirname(expandedPath),
66
+ (eventType, filename) => {
67
+ if (filename !== path.basename(expandedPath)) return;
68
+ if (eventType !== "change") return;
69
+
70
+ try {
71
+ const newContent = fs.readFileSync(expandedPath, "utf-8");
72
+ if (newContent !== lastContent) {
73
+ lastContent = newContent;
74
+ const user = loadUser(expandedPath);
75
+ if (user) {
76
+ onChange(user);
77
+ }
78
+ }
79
+ } catch (error) {
80
+ logger.error(`Error watching USER.md: ${(error as Error).message}`);
81
+ }
82
+ }
83
+ );
84
+
85
+ return () => watcher.close();
86
+ }