hoomanjs 1.4.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -1
- package/package.json +2 -1
- package/src/cli.ts +9 -2
- package/src/core/agent/index.ts +3 -3
- package/src/core/config.ts +1 -0
- package/src/core/index.ts +3 -4
- package/src/core/mcp/manager.ts +39 -0
- package/src/core/memory/stm/index.ts +23 -8
- package/src/core/memory/stm/lazy-session-manager.ts +122 -0
- package/src/core/models/groq.ts +48 -0
- package/src/core/models/index.ts +1 -0
- package/src/daemon/index.ts +60 -24
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ It gives you:
|
|
|
26
26
|
|
|
27
27
|
## Features
|
|
28
28
|
|
|
29
|
-
- Multiple LLM providers: `ollama`, `openai`, `anthropic`, `google`, `bedrock`, `xai`
|
|
29
|
+
- Multiple LLM providers: `ollama`, `openai`, `anthropic`, `google`, `bedrock`, `groq`, `xai`
|
|
30
30
|
- Local configuration under `~/.hooman`
|
|
31
31
|
- MCP server support via `stdio`, `streamable-http`, and `sse`
|
|
32
32
|
- MCP server `instructions` support: server-provided instructions are appended to the agent system prompt
|
|
@@ -262,6 +262,7 @@ Supported `llm.provider` values:
|
|
|
262
262
|
- `anthropic`
|
|
263
263
|
- `google`
|
|
264
264
|
- `bedrock`
|
|
265
|
+
- `groq`
|
|
265
266
|
- `xai`
|
|
266
267
|
|
|
267
268
|
## Provider Notes
|
|
@@ -329,6 +330,21 @@ Uses Strands `GoogleModel` on top of `@google/genai`. Top-level options like `ap
|
|
|
329
330
|
|
|
330
331
|
Supports `region`, `clientConfig`, and optional `apiKey`, with all other values forwarded as Bedrock model options.
|
|
331
332
|
|
|
333
|
+
### Groq
|
|
334
|
+
|
|
335
|
+
Uses the Vercel AI SDK Groq provider (`@ai-sdk/groq`) on top of Strands `VercelModel`. Provider-specific settings `apiKey`, `baseURL`, and `headers` are picked up; other values are forwarded into the model config (`temperature`, `maxTokens`, etc.). Defaults to `GROQ_API_KEY` from the environment when no `apiKey` is supplied.
|
|
336
|
+
|
|
337
|
+
```json
|
|
338
|
+
{
|
|
339
|
+
"provider": "groq",
|
|
340
|
+
"model": "gemma2-9b-it",
|
|
341
|
+
"params": {
|
|
342
|
+
"apiKey": "...",
|
|
343
|
+
"temperature": 0.7
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
```
|
|
347
|
+
|
|
332
348
|
### xAI
|
|
333
349
|
|
|
334
350
|
Uses the Vercel AI SDK xAI provider (`@ai-sdk/xai`) on top of Strands `VercelModel`. Provider-specific settings `apiKey`, `baseURL`, and `headers` are picked up; other values are forwarded into the model config (`temperature`, `maxTokens`, etc.). Defaults to `XAI_API_KEY` from the environment when no `apiKey` is supplied.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hoomanjs",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Bun-powered local AI agent CLI with chat, exec, ACP, MCP, and skills support.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Vaibhav Pandey",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"dependencies": {
|
|
52
52
|
"@agentclientprotocol/sdk": "^0.18.2",
|
|
53
53
|
"@ai-sdk/anthropic": "^3.0.69",
|
|
54
|
+
"@ai-sdk/groq": "^3.0.35",
|
|
54
55
|
"@ai-sdk/xai": "^3.0.83",
|
|
55
56
|
"@aws-sdk/client-bedrock-runtime": "^3.1028.0",
|
|
56
57
|
"@google/genai": "^1.40.0",
|
package/src/cli.ts
CHANGED
|
@@ -146,20 +146,27 @@ program
|
|
|
146
146
|
channel?: string[];
|
|
147
147
|
debug?: boolean;
|
|
148
148
|
}) => {
|
|
149
|
-
const
|
|
149
|
+
const session = options.session?.trim();
|
|
150
150
|
const channels = options.channel ?? [];
|
|
151
151
|
const {
|
|
152
152
|
agent,
|
|
153
153
|
mcp: { manager },
|
|
154
154
|
} = await bootstrap(
|
|
155
|
-
{
|
|
155
|
+
{
|
|
156
|
+
sessionId: session,
|
|
157
|
+
userId: session,
|
|
158
|
+
toolkit: options.toolkit ?? "full",
|
|
159
|
+
},
|
|
156
160
|
true,
|
|
157
161
|
);
|
|
162
|
+
// Daemon mode is non-interactive: approve tool calls by default.
|
|
163
|
+
agent.addHook(BeforeToolCallEvent, async () => {});
|
|
158
164
|
try {
|
|
159
165
|
await daemon({
|
|
160
166
|
agent,
|
|
161
167
|
manager,
|
|
162
168
|
channels,
|
|
169
|
+
session,
|
|
163
170
|
debug: Boolean(options.debug),
|
|
164
171
|
});
|
|
165
172
|
} finally {
|
package/src/core/agent/index.ts
CHANGED
|
@@ -35,7 +35,7 @@ export async function create(
|
|
|
35
35
|
print: boolean = false,
|
|
36
36
|
meta: {
|
|
37
37
|
userId?: string;
|
|
38
|
-
sessionId
|
|
38
|
+
sessionId?: string;
|
|
39
39
|
systemPrompt?: string;
|
|
40
40
|
toolkit?: Toolkit;
|
|
41
41
|
},
|
|
@@ -57,8 +57,8 @@ export async function create(
|
|
|
57
57
|
systemPrompt: prompt,
|
|
58
58
|
model: llm.create(config.llm.model, config.llm.params),
|
|
59
59
|
appState: {
|
|
60
|
-
userId,
|
|
61
|
-
sessionId,
|
|
60
|
+
...(userId ? { userId } : {}),
|
|
61
|
+
...(sessionId ? { sessionId } : {}),
|
|
62
62
|
},
|
|
63
63
|
tools: [
|
|
64
64
|
...createTimeTools(),
|
package/src/core/config.ts
CHANGED
package/src/core/index.ts
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
export async function bootstrap(
|
|
23
23
|
meta: {
|
|
24
24
|
userId?: string;
|
|
25
|
-
sessionId
|
|
25
|
+
sessionId?: string;
|
|
26
26
|
systemPrompt?: string;
|
|
27
27
|
mcpServers?: NamedMcpTransport[];
|
|
28
28
|
toolkit?: Toolkit;
|
|
@@ -45,10 +45,9 @@ export async function bootstrap(
|
|
|
45
45
|
config,
|
|
46
46
|
toolkit,
|
|
47
47
|
);
|
|
48
|
-
const sessionId = meta?.sessionId ?? crypto.randomUUID();
|
|
49
48
|
const agent = await createAgent(config, system, registry, mcp, print, {
|
|
50
|
-
userId: meta?.userId ?? sessionId,
|
|
51
|
-
sessionId,
|
|
49
|
+
userId: meta?.userId ?? meta?.sessionId,
|
|
50
|
+
sessionId: meta?.sessionId,
|
|
52
51
|
systemPrompt: meta?.systemPrompt,
|
|
53
52
|
toolkit,
|
|
54
53
|
});
|
package/src/core/mcp/manager.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|
|
4
4
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
5
5
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
6
6
|
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
7
|
+
import { get } from "lodash";
|
|
7
8
|
import { z } from "zod";
|
|
8
9
|
import { Config, type NamedMcpTransport } from "./config.ts";
|
|
9
10
|
import type { McpTransport } from "./types.ts";
|
|
@@ -13,6 +14,10 @@ export type ChannelMessageMeta = {
|
|
|
13
14
|
channel: string;
|
|
14
15
|
method: string;
|
|
15
16
|
params: unknown;
|
|
17
|
+
identity: {
|
|
18
|
+
user?: string;
|
|
19
|
+
session?: string;
|
|
20
|
+
};
|
|
16
21
|
};
|
|
17
22
|
|
|
18
23
|
export type ChannelMessage = {
|
|
@@ -49,6 +54,34 @@ function transportFor(spec: McpTransport): Transport {
|
|
|
49
54
|
}
|
|
50
55
|
}
|
|
51
56
|
|
|
57
|
+
function readPathValue(
|
|
58
|
+
value: unknown,
|
|
59
|
+
path: string | undefined,
|
|
60
|
+
): string | undefined {
|
|
61
|
+
const key = path?.trim();
|
|
62
|
+
if (!key) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const current = get(value, key);
|
|
67
|
+
if (typeof current !== "string") {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const trimmed = current.trim();
|
|
72
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readIdentityPath(
|
|
76
|
+
experimental: unknown,
|
|
77
|
+
key: "identity/user" | "identity/session",
|
|
78
|
+
): string | undefined {
|
|
79
|
+
const path = get(experimental, [key, "path"]);
|
|
80
|
+
return typeof path === "string" && path.trim().length > 0
|
|
81
|
+
? path.trim()
|
|
82
|
+
: undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
52
85
|
/**
|
|
53
86
|
* Holds one {@link McpClient} per named entry in {@link Config}. Call {@link reload}
|
|
54
87
|
* after changing the file on disk (or construct and then {@link reload} once).
|
|
@@ -173,6 +206,8 @@ export class Manager {
|
|
|
173
206
|
await client.connect();
|
|
174
207
|
const experimental =
|
|
175
208
|
client.client.getServerCapabilities()?.experimental ?? {};
|
|
209
|
+
const user = readIdentityPath(experimental, "identity/user");
|
|
210
|
+
const session = readIdentityPath(experimental, "identity/session");
|
|
176
211
|
for (const channel of requested) {
|
|
177
212
|
if (!Object.hasOwn(experimental, channel)) {
|
|
178
213
|
continue;
|
|
@@ -200,6 +235,10 @@ export class Manager {
|
|
|
200
235
|
channel,
|
|
201
236
|
method,
|
|
202
237
|
params,
|
|
238
|
+
identity: {
|
|
239
|
+
user: readPathValue(params, user),
|
|
240
|
+
session: readPathValue(params, session),
|
|
241
|
+
},
|
|
203
242
|
},
|
|
204
243
|
});
|
|
205
244
|
};
|
|
@@ -1,17 +1,32 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
FileStorage,
|
|
3
|
+
SessionManager,
|
|
4
|
+
SummarizingConversationManager,
|
|
5
|
+
} from "@strands-agents/sdk";
|
|
3
6
|
import { sessionsPath } from "../../utils/paths";
|
|
7
|
+
import { LazySessionManager } from "./lazy-session-manager";
|
|
4
8
|
|
|
5
|
-
export function create(sessionId
|
|
6
|
-
const sessionManager = new SessionManager({
|
|
7
|
-
sessionId,
|
|
8
|
-
storage: { snapshot: new FileStorage(sessionsPath()) },
|
|
9
|
-
});
|
|
10
|
-
|
|
9
|
+
export function create(sessionId?: string) {
|
|
11
10
|
const conversationManager = new SummarizingConversationManager({
|
|
12
11
|
summaryRatio: 0.5,
|
|
13
12
|
preserveRecentMessages: 5,
|
|
14
13
|
});
|
|
14
|
+
const storage = new FileStorage(sessionsPath());
|
|
15
|
+
|
|
16
|
+
if (!sessionId) {
|
|
17
|
+
return {
|
|
18
|
+
plugins: [new LazySessionManager({ storage })],
|
|
19
|
+
conversationManager,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const sessionManager = new SessionManager({
|
|
24
|
+
sessionId,
|
|
25
|
+
storage: { snapshot: storage },
|
|
26
|
+
});
|
|
15
27
|
|
|
16
28
|
return { sessionManager, conversationManager };
|
|
17
29
|
}
|
|
30
|
+
|
|
31
|
+
export { LazySessionManager } from "./lazy-session-manager";
|
|
32
|
+
export type { LazySessionManagerConfig } from "./lazy-session-manager";
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AfterInvocationEvent,
|
|
3
|
+
BeforeInvocationEvent,
|
|
4
|
+
Message,
|
|
5
|
+
type LocalAgent,
|
|
6
|
+
type MessageData,
|
|
7
|
+
type Plugin,
|
|
8
|
+
type Snapshot,
|
|
9
|
+
type SnapshotLocation,
|
|
10
|
+
type SnapshotStorage,
|
|
11
|
+
} from "@strands-agents/sdk";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_SESSION_ID = "default-session";
|
|
14
|
+
const DEFAULT_APP_STATE_KEY = "sessionId";
|
|
15
|
+
const DEFAULT_SCOPE_ID = "agent";
|
|
16
|
+
const SCHEMA_VERSION = "1.0";
|
|
17
|
+
// `FileStorage` (and any backend that follows its convention) validates ids
|
|
18
|
+
// against `[a-z0-9_-]+`, so coerce anything else (e.g. `919599960600@c.us`).
|
|
19
|
+
const UNSAFE_CHARS = /[^a-z0-9_-]+/g;
|
|
20
|
+
|
|
21
|
+
export type LazySessionManagerConfig = {
|
|
22
|
+
/** Pluggable snapshot backend (e.g. `FileStorage`). */
|
|
23
|
+
storage: SnapshotStorage;
|
|
24
|
+
/** Fallback session id when `appState` does not provide one. Defaults to `"default-session"`. */
|
|
25
|
+
defaultSessionId?: string;
|
|
26
|
+
/** `appState` key used to derive the active session id. Defaults to `"sessionId"`. */
|
|
27
|
+
appStateKey?: string;
|
|
28
|
+
/** Scope id passed through to the storage backend. Defaults to `"agent"`. */
|
|
29
|
+
scopeId?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Short-term memory plugin that resolves the active session id at invocation
|
|
34
|
+
* time from `agent.appState` instead of binding it once at construction.
|
|
35
|
+
*
|
|
36
|
+
* Designed for long-lived agents that fan out to many independent
|
|
37
|
+
* conversations (e.g. a daemon routing notifications from multiple chat
|
|
38
|
+
* channels). Persistence is delegated to a `SnapshotStorage` so any backend
|
|
39
|
+
* (filesystem, S3, custom) works.
|
|
40
|
+
*/
|
|
41
|
+
export class LazySessionManager implements Plugin {
|
|
42
|
+
private readonly storage: SnapshotStorage;
|
|
43
|
+
private readonly defaultSessionId: string;
|
|
44
|
+
private readonly appStateKey: string;
|
|
45
|
+
private readonly scopeId: string;
|
|
46
|
+
|
|
47
|
+
constructor(config: LazySessionManagerConfig) {
|
|
48
|
+
this.storage = config.storage;
|
|
49
|
+
this.defaultSessionId = sanitize(
|
|
50
|
+
config.defaultSessionId ?? DEFAULT_SESSION_ID,
|
|
51
|
+
);
|
|
52
|
+
this.appStateKey = config.appStateKey ?? DEFAULT_APP_STATE_KEY;
|
|
53
|
+
this.scopeId = sanitize(config.scopeId ?? DEFAULT_SCOPE_ID);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get name(): string {
|
|
57
|
+
return "hooman:lazy-session-manager";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
initAgent(agent: LocalAgent): void {
|
|
61
|
+
agent.addHook(BeforeInvocationEvent, async (event) => {
|
|
62
|
+
await this.restore(event.agent);
|
|
63
|
+
});
|
|
64
|
+
agent.addHook(AfterInvocationEvent, async (event) => {
|
|
65
|
+
await this.save(event.agent);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Removes the persisted history for the given session, if present. */
|
|
70
|
+
async deleteSession(sessionId: string): Promise<void> {
|
|
71
|
+
await this.storage.deleteSession({ sessionId: sanitize(sessionId) });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private location(agent: LocalAgent): SnapshotLocation {
|
|
75
|
+
return {
|
|
76
|
+
sessionId: sanitize(this.resolveSessionId(agent)),
|
|
77
|
+
scope: "agent",
|
|
78
|
+
scopeId: this.scopeId,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private resolveSessionId(agent: LocalAgent): string {
|
|
83
|
+
const raw = agent.appState.get(this.appStateKey);
|
|
84
|
+
const candidate = typeof raw === "string" ? raw.trim() : "";
|
|
85
|
+
return candidate.length > 0 ? candidate : this.defaultSessionId;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private async restore(agent: LocalAgent): Promise<void> {
|
|
89
|
+
const snapshot = await this.storage.loadSnapshot({
|
|
90
|
+
location: this.location(agent),
|
|
91
|
+
});
|
|
92
|
+
agent.messages.length = 0;
|
|
93
|
+
if (!snapshot) return;
|
|
94
|
+
const raw = snapshot.data.messages;
|
|
95
|
+
if (!Array.isArray(raw)) return;
|
|
96
|
+
for (const md of raw as unknown as MessageData[]) {
|
|
97
|
+
agent.messages.push(Message.fromJSON(md));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private async save(agent: LocalAgent): Promise<void> {
|
|
102
|
+
const messages = agent.messages.map((m) => m.toJSON());
|
|
103
|
+
const snapshot: Snapshot = {
|
|
104
|
+
scope: "agent",
|
|
105
|
+
schemaVersion: SCHEMA_VERSION,
|
|
106
|
+
createdAt: new Date().toISOString(),
|
|
107
|
+
data: { messages: messages as unknown as Snapshot["data"]["messages"] },
|
|
108
|
+
appData: {},
|
|
109
|
+
};
|
|
110
|
+
await this.storage.saveSnapshot({
|
|
111
|
+
location: this.location(agent),
|
|
112
|
+
snapshotId: "latest",
|
|
113
|
+
isLatest: true,
|
|
114
|
+
snapshot,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function sanitize(value: string): string {
|
|
120
|
+
const trimmed = value.trim().toLowerCase().replace(UNSAFE_CHARS, "_");
|
|
121
|
+
return trimmed.length > 0 ? trimmed : DEFAULT_SESSION_ID;
|
|
122
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createGroq, groq } from "@ai-sdk/groq";
|
|
2
|
+
import { VercelModel } from "@strands-agents/sdk/models/vercel";
|
|
3
|
+
import type { GroqProviderSettings } from "@ai-sdk/groq";
|
|
4
|
+
import type { VercelModelConfig } from "@strands-agents/sdk/models/vercel";
|
|
5
|
+
import { omit, pick } from "lodash";
|
|
6
|
+
|
|
7
|
+
const PROVIDER_SETTINGS_KEYS = ["apiKey", "baseURL", "headers"] as const;
|
|
8
|
+
|
|
9
|
+
function pickProviderSettings(
|
|
10
|
+
params: Record<string, unknown>,
|
|
11
|
+
): GroqProviderSettings {
|
|
12
|
+
const picked = pick(params, [...PROVIDER_SETTINGS_KEYS]) as Record<
|
|
13
|
+
string,
|
|
14
|
+
unknown
|
|
15
|
+
>;
|
|
16
|
+
const unset = Object.keys(picked).filter((k) => picked[k] === undefined);
|
|
17
|
+
return omit(picked, unset) as GroqProviderSettings;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function pickVercelModelConfig(
|
|
21
|
+
params: Record<string, unknown>,
|
|
22
|
+
): Partial<VercelModelConfig> {
|
|
23
|
+
return omit(params, [
|
|
24
|
+
...PROVIDER_SETTINGS_KEYS,
|
|
25
|
+
]) as Partial<VercelModelConfig>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Groq via AI SDK + Strands {@link VercelModel}.
|
|
30
|
+
*
|
|
31
|
+
* - **`config.llm.model`**: model id passed to `groq(...)` (e.g. `gemma2-9b-it`).
|
|
32
|
+
* - **`params`**: {@link GroqProviderSettings} (`apiKey`, `baseURL`, `headers`).
|
|
33
|
+
* If none are set, the default provider is used (`GROQ_API_KEY` from env).
|
|
34
|
+
* - Any other `params` keys are forwarded as {@link VercelModelConfig} (e.g. `temperature`, `maxTokens`).
|
|
35
|
+
*/
|
|
36
|
+
export function create(
|
|
37
|
+
model: string,
|
|
38
|
+
params: Record<string, unknown> = {},
|
|
39
|
+
): VercelModel {
|
|
40
|
+
const settings = pickProviderSettings(params);
|
|
41
|
+
const provider =
|
|
42
|
+
Object.keys(settings).length > 0 ? createGroq(settings) : groq;
|
|
43
|
+
const config = pickVercelModelConfig(params);
|
|
44
|
+
return new VercelModel({
|
|
45
|
+
provider: provider(model),
|
|
46
|
+
...config,
|
|
47
|
+
});
|
|
48
|
+
}
|
package/src/core/models/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ export const modelProviders: Record<string, () => Promise<ModelProvider>> = {
|
|
|
11
11
|
anthropic: () => import("./anthropic.ts"),
|
|
12
12
|
bedrock: () => import("./bedrock.ts"),
|
|
13
13
|
google: () => import("./google.ts"),
|
|
14
|
+
groq: () => import("./groq.ts"),
|
|
14
15
|
ollama: () => import("./ollama/index.ts"),
|
|
15
16
|
openai: () => import("./openai.ts"),
|
|
16
17
|
xai: () => import("./xai.ts"),
|
package/src/daemon/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { stderr } from "node:process";
|
|
2
|
-
import {
|
|
2
|
+
import type { Agent } from "@strands-agents/sdk";
|
|
3
3
|
import type {
|
|
4
4
|
ChannelMessage,
|
|
5
5
|
Manager as McpManager,
|
|
@@ -9,6 +9,7 @@ import { createQueue } from "./queue.ts";
|
|
|
9
9
|
type RunDaemonOptions = {
|
|
10
10
|
agent: Agent;
|
|
11
11
|
manager: McpManager;
|
|
12
|
+
session?: string;
|
|
12
13
|
channels: string[];
|
|
13
14
|
debug?: boolean;
|
|
14
15
|
};
|
|
@@ -17,6 +18,29 @@ function debug(text: string): void {
|
|
|
17
18
|
stderr.write(`[daemon] ${text}\n`);
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
function resolveSessionId(
|
|
22
|
+
message: ChannelMessage,
|
|
23
|
+
fallback?: string,
|
|
24
|
+
): string | undefined {
|
|
25
|
+
const raw = message.meta.identity.session?.trim() || fallback;
|
|
26
|
+
if (!raw) return undefined;
|
|
27
|
+
// Namespace per `server:channel` so the same chat id coming from two
|
|
28
|
+
// different MCP servers (or two channels on the same server) never collide.
|
|
29
|
+
return `${message.meta.server}:${message.meta.channel}:${raw}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function resolveUserId(
|
|
33
|
+
message: ChannelMessage,
|
|
34
|
+
session?: string,
|
|
35
|
+
): string | undefined {
|
|
36
|
+
const raw = message.meta.identity.user?.trim();
|
|
37
|
+
if (!raw) return session;
|
|
38
|
+
// Same user id across different servers is not the same human, so scope
|
|
39
|
+
// user ids by server. Channel is intentionally omitted so long-term memory
|
|
40
|
+
// can stay consistent for a user across rooms within one server.
|
|
41
|
+
return `${message.meta.server}:${raw}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
20
44
|
export async function main(options: RunDaemonOptions): Promise<void> {
|
|
21
45
|
const channels = [
|
|
22
46
|
...new Set(options.channels.map((value) => value.trim()).filter(Boolean)),
|
|
@@ -24,37 +48,49 @@ export async function main(options: RunDaemonOptions): Promise<void> {
|
|
|
24
48
|
if (channels.length === 0) {
|
|
25
49
|
throw new Error("At least one --channel <name> is required.");
|
|
26
50
|
}
|
|
51
|
+
debug(`starting daemon for channels: ${channels.join(", ")}`);
|
|
27
52
|
|
|
28
|
-
|
|
29
|
-
options.agent.addHook(BeforeToolCallEvent, async () => {});
|
|
53
|
+
let unsubscribe = () => {};
|
|
30
54
|
|
|
31
|
-
|
|
55
|
+
const [queue, stop] = await createQueue(
|
|
56
|
+
async (message: ChannelMessage) => {
|
|
57
|
+
const tag = `${message.meta.server}:${message.meta.channel}`;
|
|
58
|
+
const session = resolveSessionId(message, options.session);
|
|
59
|
+
const user = resolveUserId(message, session);
|
|
32
60
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
61
|
+
debug(`dequeued → ${tag} session=${session} user=${user}`);
|
|
62
|
+
if (options.debug) {
|
|
63
|
+
debug(`raw → ${JSON.stringify(message.meta)}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
options.agent.appState.set("userId", user);
|
|
67
|
+
options.agent.appState.set("sessionId", session);
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
await options.agent.invoke(message.prompt);
|
|
71
|
+
debug(`completed → ${tag} session=${session} user=${user}`);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
74
|
+
debug(`turn failed → ${tag} session=${session} user=${user}: ${text}`);
|
|
38
75
|
}
|
|
39
76
|
},
|
|
77
|
+
() => unsubscribe(),
|
|
40
78
|
);
|
|
41
79
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
debug(`raw → ${JSON.stringify(message.meta)}`);
|
|
46
|
-
}
|
|
47
|
-
try {
|
|
48
|
-
await options.agent.invoke(message.prompt);
|
|
49
|
-
} catch (error) {
|
|
50
|
-
const text = error instanceof Error ? error.message : String(error);
|
|
80
|
+
unsubscribe = await options.manager.subscribeToChannels(
|
|
81
|
+
channels,
|
|
82
|
+
(message) => {
|
|
51
83
|
debug(
|
|
52
|
-
`
|
|
84
|
+
`received notification → ${message.meta.server}:${message.meta.channel}`,
|
|
53
85
|
);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
86
|
+
void queue.push(message);
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
debug(`subscribed to ${channels.length} channel(s)`);
|
|
58
90
|
|
|
59
|
-
|
|
91
|
+
try {
|
|
92
|
+
await stop();
|
|
93
|
+
} finally {
|
|
94
|
+
debug("stopping daemon");
|
|
95
|
+
}
|
|
60
96
|
}
|