@johpaz/hive-core 1.0.6 → 1.0.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johpaz/hive-core",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "description": "Hive Gateway — Personal AI agent runtime",
5
5
  "main": "./src/index.ts",
6
6
  "module": "./src/index.ts",
@@ -23,9 +23,12 @@
23
23
  "@slack/bolt": "latest",
24
24
  "grammy": "latest",
25
25
  "discord.js": "latest",
26
+ "@sapphire/snowflake": "latest",
26
27
  "js-yaml": "latest",
27
28
  "zod": "latest",
28
- "qrcode-terminal": "latest"
29
+ "qrcode-terminal": "latest",
30
+ "@johpaz/hive-skills": "workspace:*",
31
+ "@johpaz/hive-mcp": "workspace:*"
29
32
  },
30
33
  "devDependencies": {
31
34
  "typescript": "latest",
@@ -1,11 +1,15 @@
1
1
  import type { Config } from "../config/loader.ts";
2
- import { loadSoul } from "./soul.ts";
3
- import { loadUser } from "./user.ts";
4
- import { loadEthics, type EthicsConfig, DEFAULT_ETHICS } from "./ethics.ts";
2
+ import { loadSoul, watchSoul, type SoulConfig } from "./soul.ts";
3
+ import { loadUser, watchUser, type UserConfig } from "./user.ts";
4
+ import { loadEthics, watchEthics, type EthicsConfig, DEFAULT_ETHICS } from "./ethics.ts";
5
5
  import { buildSystemPrompt } from "./context.ts";
6
6
  import { logger } from "../utils/logger.ts";
7
- import * as path from "node:path";
8
- import * as fs from "node:fs";
7
+ import { SkillLoader, type Skill } from "@johpaz/hive-skills";
8
+ import { MCPClientManager, createMCPManager } from "@johpaz/hive-mcp";
9
+ import * as path from "path";
10
+ import * as fs from "fs";
11
+
12
+ export { watchEthics, watchSoul, watchUser };
9
13
 
10
14
  export interface AgentOptions {
11
15
  agentId: string;
@@ -18,6 +22,12 @@ export class Agent {
18
22
  readonly workspacePath: string;
19
23
  private config: Config;
20
24
  private ethics: EthicsConfig | null = null;
25
+ private soul: SoulConfig | null = null;
26
+ private user: UserConfig | null = null;
27
+ private skills: Skill[] = [];
28
+ private skillLoader: SkillLoader | null = null;
29
+ private mcpManager: MCPClientManager | null = null;
30
+ private memory: string[] = [];
21
31
  private log = logger.child("agent");
22
32
 
23
33
  constructor(options: AgentOptions) {
@@ -28,26 +38,75 @@ export class Agent {
28
38
 
29
39
  async initialize(): Promise<void> {
30
40
  this.log.info(`Initializing agent: ${this.agentId}`);
31
-
32
- const soul = loadSoul(path.join(this.workspacePath, "SOUL.md"));
33
- const user = loadUser(path.join(this.workspacePath, "USER.md"));
41
+
42
+ this.soul = loadSoul(path.join(this.workspacePath, "SOUL.md"));
43
+ this.user = loadUser(path.join(this.workspacePath, "USER.md"));
34
44
  this.ethics = await loadEthics(path.join(this.workspacePath, "ETHICS.md"));
35
45
 
36
- if (soul) {
46
+ if (this.soul) {
37
47
  this.log.debug(`Loaded SOUL.md for agent ${this.agentId}`);
38
48
  }
39
- if (user) {
49
+ if (this.user) {
40
50
  this.log.debug(`Loaded USER.md for agent ${this.agentId}`);
41
51
  }
42
52
  if (this.ethics) {
43
53
  this.log.debug(`Loaded ETHICS.md for agent ${this.agentId}`);
44
54
  }
55
+
56
+ // Load skills
57
+ this.skillLoader = new SkillLoader({
58
+ skills: this.config.skills,
59
+ workspacePath: this.workspacePath,
60
+ });
61
+ this.skills = this.skillLoader.loadAllSkills();
62
+ this.log.debug(`Loaded ${this.skills.length} skills for agent ${this.agentId}`);
63
+
64
+ // Load MCP
65
+ if (this.config.mcp?.enabled !== false) {
66
+ this.mcpManager = createMCPManager(this.config.mcp || {});
67
+ await this.mcpManager.initialize();
68
+ try {
69
+ await this.mcpManager.connectAll();
70
+ } catch (error) {
71
+ this.log.warn(`Some MCP servers failed to connect: ${(error as Error).message}`);
72
+ }
73
+ this.log.debug(`MCP Manager initialized for agent ${this.agentId}`);
74
+ }
75
+
76
+ // Load memory notes
77
+ this.memory = this.loadMemoryNotes();
78
+ if (this.memory.length > 0) {
79
+ this.log.debug(`Loaded ${this.memory.length} memory notes`);
80
+ }
81
+ }
82
+
83
+ private loadMemoryNotes(): string[] {
84
+ const memoryDir = path.join(this.workspacePath, "memory");
85
+ const notes: string[] = [];
86
+
87
+ if (!fs.existsSync(memoryDir)) {
88
+ return notes;
89
+ }
90
+
91
+ try {
92
+ const files = fs.readdirSync(memoryDir).filter(f => f.endsWith(".md"));
93
+ for (const file of files) {
94
+ const content = fs.readFileSync(path.join(memoryDir, file), "utf-8");
95
+ // Extract body from frontmatter if present
96
+ const match = content.match(/^---\n[\s\S]*?\n---\n([\s\S]*)$/);
97
+ const body = match ? match[1]!.trim() : content.trim();
98
+ if (body) {
99
+ notes.push(body);
100
+ }
101
+ }
102
+ } catch (error) {
103
+ this.log.warn(`Failed to load memory notes: ${(error as Error).message}`);
104
+ }
105
+
106
+ return notes;
45
107
  }
46
108
 
47
109
  buildPrompt(): string {
48
- const soul = loadSoul(path.join(this.workspacePath, "SOUL.md"));
49
- const user = loadUser(path.join(this.workspacePath, "USER.md"));
50
-
51
110
  const ethicsConfig = this.ethics ?? {
52
111
  raw: DEFAULT_ETHICS,
53
112
  loadedAt: new Date(),
@@ -56,7 +115,7 @@ export class Agent {
56
115
 
57
116
  return buildSystemPrompt({
58
117
  ethics: ethicsConfig,
59
- soul: soul ?? {
118
+ soul: this.soul ?? {
60
119
  identity: "",
61
120
  personality: "",
62
121
  boundaries: "",
@@ -64,13 +123,78 @@ export class Agent {
64
123
  capabilities: [],
65
124
  raw: "",
66
125
  },
67
- user,
68
- skills: [],
69
- memory: [],
126
+ user: this.user,
127
+ skills: this.skills.map(s => s.content),
128
+ memory: this.memory,
70
129
  maxTokens: this.config.agent?.context?.maxTokens ?? 128000,
71
130
  });
72
131
  }
73
132
 
133
+ reloadSoul(): void {
134
+ this.soul = loadSoul(path.join(this.workspacePath, "SOUL.md"));
135
+ this.log.info("SOUL.md recargado");
136
+ }
137
+
138
+ reloadUser(): void {
139
+ this.user = loadUser(path.join(this.workspacePath, "USER.md"));
140
+ this.log.info("USER.md recargado");
141
+ }
142
+
143
+ async reloadEthics(): Promise<void> {
144
+ this.ethics = await loadEthics(path.join(this.workspacePath, "ETHICS.md"));
145
+ this.log.info("ETHICS.md recargado");
146
+ }
147
+
148
+ reloadSkills(): void {
149
+ if (this.skillLoader) {
150
+ this.skillLoader.clearCache();
151
+ this.skills = this.skillLoader.loadAllSkills();
152
+ this.log.info(`Skills recargadas: ${this.skills.length} skills`);
153
+ }
154
+ }
155
+
156
+ reloadMemory(): void {
157
+ this.memory = this.loadMemoryNotes();
158
+ this.log.info(`Memoria recargada: ${this.memory.length} notas`);
159
+ }
160
+
161
+ async reload(): Promise<void> {
162
+ this.soul = loadSoul(path.join(this.workspacePath, "SOUL.md"));
163
+ this.user = loadUser(path.join(this.workspacePath, "USER.md"));
164
+ this.ethics = await loadEthics(path.join(this.workspacePath, "ETHICS.md"));
165
+ this.reloadSkills();
166
+ this.reloadMemory();
167
+ this.log.info("Agente recargado completamente");
168
+ }
169
+
170
+ async updateConfig(config: Config): Promise<void> {
171
+ this.config = config;
172
+ if (this.mcpManager) {
173
+ await this.mcpManager.updateConfig(this.config.mcp || {});
174
+ }
175
+ this.log.info("Configuración actualizada");
176
+ }
177
+
178
+ getEthics(): EthicsConfig | null {
179
+ return this.ethics;
180
+ }
181
+
182
+ getSoul(): SoulConfig | null {
183
+ return this.soul;
184
+ }
185
+
186
+ getUser(): UserConfig | null {
187
+ return this.user;
188
+ }
189
+
190
+ getSkills(): Skill[] {
191
+ return this.skills;
192
+ }
193
+
194
+ getMemory(): string[] {
195
+ return this.memory;
196
+ }
197
+
74
198
  getConfig(): Config {
75
199
  return this.config;
76
200
  }
@@ -78,4 +202,8 @@ export class Agent {
78
202
  getWorkspacePath(): string {
79
203
  return this.workspacePath;
80
204
  }
205
+
206
+ getMCPManager(): MCPClientManager | null {
207
+ return this.mcpManager;
208
+ }
81
209
  }
@@ -218,6 +218,14 @@ export class AgentRunner {
218
218
  options.onToken(chunk);
219
219
  }
220
220
 
221
+ if (!fullText || fullText.trim().length === 0) {
222
+ this.log.warn(`Empty response from LLM (streaming)`, {
223
+ provider,
224
+ model: resolved.name,
225
+ });
226
+ fullText = "...";
227
+ }
228
+
221
229
  return {
222
230
  content: fullText,
223
231
  usage: {
@@ -236,6 +244,17 @@ export class AgentRunner {
236
244
  temperature: options.temperature ?? 0.7,
237
245
  });
238
246
 
247
+ let responseText = result.text ?? "";
248
+
249
+ if (!responseText || responseText.trim().length === 0) {
250
+ this.log.warn(`Empty response from LLM, using fallback`, {
251
+ provider,
252
+ model: resolved.name,
253
+ hadToolCalls: (result.toolCalls?.length ?? 0) > 0,
254
+ });
255
+ responseText = "...";
256
+ }
257
+
239
258
  const toolCalls = result.toolCalls?.map((tc) => {
240
259
  const t = tc as unknown as { toolCallId: string; toolName: string; input: Record<string, unknown> };
241
260
  return {
@@ -246,7 +265,7 @@ export class AgentRunner {
246
265
  });
247
266
 
248
267
  return {
249
- content: result.text,
268
+ content: responseText,
250
269
  toolCalls,
251
270
  usage: {
252
271
  promptTokens: 0,
@@ -0,0 +1,111 @@
1
+ import * as fs from "node:fs";
2
+ import * as fsPromises from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import { logger } from "../utils/logger.ts";
5
+
6
+ export type WorkspaceFile = "soul" | "user" | "ethics";
7
+
8
+ export class WorkspaceLoader {
9
+ private cache: Map<WorkspaceFile, string> = new Map();
10
+ private watchers: Map<WorkspaceFile, fs.FSWatcher> = new Map();
11
+ private workspaceDir: string;
12
+
13
+ constructor(workspaceDir: string) {
14
+ this.workspaceDir = workspaceDir;
15
+ }
16
+
17
+ private getPath(file: WorkspaceFile): string {
18
+ const names: Record<WorkspaceFile, string> = {
19
+ soul: "SOUL.md",
20
+ user: "USER.md",
21
+ ethics: "ETHICS.md",
22
+ };
23
+ return path.join(this.workspaceDir, names[file]);
24
+ }
25
+
26
+ async read(file: WorkspaceFile): Promise<string> {
27
+ const filePath = this.getPath(file);
28
+ try {
29
+ const content = await fsPromises.readFile(filePath, "utf-8");
30
+ this.cache.set(file, content);
31
+ return content;
32
+ } catch (error) {
33
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
34
+ this.cache.set(file, "");
35
+ return "";
36
+ }
37
+ logger.error(`Failed to read ${file}.md: ${(error as Error).message}`);
38
+ throw error;
39
+ }
40
+ }
41
+
42
+ async write(file: WorkspaceFile, content: string): Promise<void> {
43
+ if (file === "ethics") {
44
+ throw new Error(
45
+ "ETHICS.md cannot be modified programmatically. Only the operator can edit it directly."
46
+ );
47
+ }
48
+
49
+ const filePath = this.getPath(file);
50
+ await fsPromises.writeFile(filePath, content, "utf-8");
51
+ await fsPromises.chmod(filePath, 0o644);
52
+ this.cache.set(file, content);
53
+ logger.info(`Workspace: Updated ${file}.md`);
54
+ }
55
+
56
+ async patch(file: WorkspaceFile, section: string, newContent: string): Promise<void> {
57
+ if (file === "ethics") {
58
+ throw new Error("ETHICS.md cannot be modified programmatically.");
59
+ }
60
+
61
+ const current = await this.read(file);
62
+ const patched = this.patchSection(current, section, newContent);
63
+ await this.write(file, patched);
64
+ }
65
+
66
+ async append(file: WorkspaceFile, section: string, content: string): Promise<void> {
67
+ if (file === "ethics") {
68
+ throw new Error("ETHICS.md cannot be modified programmatically.");
69
+ }
70
+
71
+ const current = await this.read(file);
72
+ const sectionHeader = `## ${section}`;
73
+
74
+ if (current.includes(sectionHeader)) {
75
+ const patched = current.replace(
76
+ new RegExp(`(${sectionHeader}[\\s\\S]*?)(?=\\n## |$)`),
77
+ `$1\n${content}`
78
+ );
79
+ await this.write(file, patched);
80
+ } else {
81
+ await this.write(file, `${current}\n\n${sectionHeader}\n${content}`);
82
+ }
83
+ }
84
+
85
+ async reload(): Promise<void> {
86
+ for (const file of ["soul", "user", "ethics"] as WorkspaceFile[]) {
87
+ await this.read(file);
88
+ }
89
+ }
90
+
91
+ private patchSection(content: string, section: string, newContent: string): string {
92
+ const header = `## ${section}`;
93
+ const regex = new RegExp(
94
+ `(${header})([\\s\\S]*?)(?=\\n## |$)`,
95
+ "m"
96
+ );
97
+
98
+ if (content.match(regex)) {
99
+ return content.replace(regex, `$1\n\n${newContent}\n`);
100
+ }
101
+
102
+ return `${content}\n\n${header}\n\n${newContent}\n`;
103
+ }
104
+
105
+ closeWatchers(): void {
106
+ for (const watcher of this.watchers.values()) {
107
+ watcher.close();
108
+ }
109
+ this.watchers.clear();
110
+ }
111
+ }
@@ -39,6 +39,9 @@ export interface IChannel {
39
39
  send(sessionId: string, message: OutboundMessage): Promise<void>;
40
40
  onMessage(handler: MessageHandler): void;
41
41
  isRunning(): boolean;
42
+ startTyping?(sessionId: string): Promise<void>;
43
+ stopTyping?(sessionId: string): Promise<void>;
44
+ markAsRead?(sessionId: string, messageId?: string): Promise<void>;
42
45
  }
43
46
 
44
47
  export type MessageHandler = (message: IncomingMessage) => Promise<void>;
@@ -50,6 +53,7 @@ export abstract class BaseChannel implements IChannel {
50
53
 
51
54
  protected messageHandler?: MessageHandler;
52
55
  protected running = false;
56
+ protected typingIntervals: Map<string, Timer> = new Map();
53
57
 
54
58
  abstract start(): Promise<void>;
55
59
  abstract stop(): Promise<void>;
@@ -63,6 +67,22 @@ export abstract class BaseChannel implements IChannel {
63
67
  return this.running;
64
68
  }
65
69
 
70
+ async startTyping(_sessionId: string): Promise<void> {
71
+ // Default: no-op, override in subclasses
72
+ }
73
+
74
+ async stopTyping(sessionId: string): Promise<void> {
75
+ const interval = this.typingIntervals.get(sessionId);
76
+ if (interval) {
77
+ clearInterval(interval);
78
+ this.typingIntervals.delete(sessionId);
79
+ }
80
+ }
81
+
82
+ async markAsRead(_sessionId: string, _messageId?: string): Promise<void> {
83
+ // Default: no-op, override in subclasses
84
+ }
85
+
66
86
  protected async handleMessage(message: IncomingMessage): Promise<void> {
67
87
  if (this.messageHandler) {
68
88
  await this.messageHandler(message);
@@ -74,12 +94,18 @@ export abstract class BaseChannel implements IChannel {
74
94
  return true;
75
95
  }
76
96
 
97
+ const normalizedPeerId = `tg:${peerId}`;
98
+
77
99
  if (this.config.dmPolicy === "allowlist") {
78
- return this.config.allowFrom.includes(peerId);
100
+ return this.config.allowFrom.some(
101
+ (allowed) => allowed === peerId || allowed === normalizedPeerId
102
+ );
79
103
  }
80
104
 
81
105
  if (this.config.dmPolicy === "pairing") {
82
- return this.config.allowFrom.includes(peerId);
106
+ return this.config.allowFrom.some(
107
+ (allowed) => allowed === peerId || allowed === normalizedPeerId
108
+ );
83
109
  }
84
110
 
85
111
  return false;
@@ -25,6 +25,7 @@ export class DiscordChannel extends BaseChannel {
25
25
 
26
26
  private client?: Client;
27
27
  private log = logger.child("discord");
28
+ private channelCache: Map<string, DiscordTextChannel> = new Map();
28
29
 
29
30
  constructor(accountId: string, config: DiscordConfig) {
30
31
  super();
@@ -81,8 +82,13 @@ export class DiscordChannel extends BaseChannel {
81
82
  return;
82
83
  }
83
84
 
85
+ const sessionId = this.formatSessionId(peerId, kind);
86
+ if (message.channel.isTextBased()) {
87
+ this.channelCache.set(sessionId, message.channel as DiscordTextChannel);
88
+ }
89
+
84
90
  const incomingMessage: IncomingMessage = {
85
- sessionId: this.formatSessionId(peerId, kind),
91
+ sessionId,
86
92
  channel: "discord",
87
93
  accountId: this.accountId,
88
94
  peerId,
@@ -114,10 +120,11 @@ export class DiscordChannel extends BaseChannel {
114
120
  }
115
121
  }
116
122
 
117
- async send(sessionId: string, message: OutboundMessage): Promise<void> {
118
- if (!this.client) {
119
- throw new Error("Discord client not started");
120
- }
123
+ private async getChannel(sessionId: string): Promise<DiscordTextChannel | null> {
124
+ const cached = this.channelCache.get(sessionId);
125
+ if (cached) return cached;
126
+
127
+ if (!this.client) return null;
121
128
 
122
129
  const parts = sessionId.split(":");
123
130
  const peerPart = parts.slice(3).join(":");
@@ -130,10 +137,51 @@ export class DiscordChannel extends BaseChannel {
130
137
  channelId = peerPart;
131
138
  }
132
139
 
133
- const channel = await this.client.channels.fetch(channelId);
140
+ try {
141
+ const channel = await this.client.channels.fetch(channelId);
142
+ if (channel && channel.isTextBased()) {
143
+ this.channelCache.set(sessionId, channel as DiscordTextChannel);
144
+ return channel as DiscordTextChannel;
145
+ }
146
+ } catch {
147
+ // Channel not found
148
+ }
149
+
150
+ return null;
151
+ }
152
+
153
+ async startTyping(sessionId: string): Promise<void> {
154
+ const channel = await this.getChannel(sessionId);
155
+ if (!channel) return;
156
+
157
+ await channel.sendTyping();
158
+
159
+ const interval = setInterval(async () => {
160
+ try {
161
+ await channel.sendTyping();
162
+ } catch {
163
+ this.stopTyping(sessionId);
164
+ }
165
+ }, 8000);
166
+
167
+ this.typingIntervals.set(sessionId, interval);
168
+ }
169
+
170
+ async stopTyping(sessionId: string): Promise<void> {
171
+ const interval = this.typingIntervals.get(sessionId);
172
+ if (interval) {
173
+ clearInterval(interval);
174
+ this.typingIntervals.delete(sessionId);
175
+ }
176
+ }
177
+
178
+ async send(sessionId: string, message: OutboundMessage): Promise<void> {
179
+ await this.stopTyping(sessionId);
180
+
181
+ const channel = await this.getChannel(sessionId);
134
182
 
135
- if (!channel || !channel.isTextBased()) {
136
- throw new Error(`Channel not found or not text-based: ${channelId}`);
183
+ if (!channel) {
184
+ throw new Error(`Channel not found for session: ${sessionId}`);
137
185
  }
138
186
 
139
187
  const content = message.content ?? "";
@@ -141,11 +189,11 @@ export class DiscordChannel extends BaseChannel {
141
189
 
142
190
  try {
143
191
  if (content.length <= maxLength) {
144
- await (channel as DiscordTextChannel).send(content);
192
+ await channel.send(content);
145
193
  } else {
146
194
  const chunks = this.chunkMessage(content, maxLength);
147
195
  for (const chunk of chunks) {
148
- await (channel as DiscordTextChannel).send(chunk);
196
+ await channel.send(chunk);
149
197
  }
150
198
  }
151
199
  } catch (error) {