hoomanjs 1.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hoomanjs",
3
- "version": "1.5.0",
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",
package/src/cli.ts CHANGED
@@ -146,20 +146,27 @@ program
146
146
  channel?: string[];
147
147
  debug?: boolean;
148
148
  }) => {
149
- const sessionId = options.session?.trim() || crypto.randomUUID();
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
- { sessionId, toolkit: options.toolkit ?? "full" },
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 {
@@ -35,7 +35,7 @@ export async function create(
35
35
  print: boolean = false,
36
36
  meta: {
37
37
  userId?: string;
38
- sessionId: string;
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/index.ts CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  export async function bootstrap(
23
23
  meta: {
24
24
  userId?: string;
25
- sessionId: string;
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
  });
@@ -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 { Agent, SummarizingConversationManager } from "@strands-agents/sdk";
2
- import { SessionManager, FileStorage } from "@strands-agents/sdk";
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: string) {
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
+ }
@@ -1,5 +1,5 @@
1
1
  import { stderr } from "node:process";
2
- import { BeforeToolCallEvent, type Agent } from "@strands-agents/sdk";
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
- // Daemon mode is non-interactive: approve tool calls by default.
29
- options.agent.addHook(BeforeToolCallEvent, async () => {});
53
+ let unsubscribe = () => {};
30
54
 
31
- let fasterq: Awaited<ReturnType<typeof createQueue>>[0] | null = null;
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
- const unsubscribe = await options.manager.subscribeToChannels(
34
- channels,
35
- (message) => {
36
- if (fasterq != null) {
37
- void fasterq.push(message);
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
- const [queue, stop] = await createQueue(async (message: ChannelMessage) => {
43
- debug(`processing → ${message.meta.server}:${message.meta.channel}`);
44
- if (options.debug) {
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
- `turn failed → ${message.meta.server}:${message.meta.channel}: ${text}`,
84
+ `received notification → ${message.meta.server}:${message.meta.channel}`,
53
85
  );
54
- }
55
- }, unsubscribe);
56
-
57
- fasterq = queue;
86
+ void queue.push(message);
87
+ },
88
+ );
89
+ debug(`subscribed to ${channels.length} channel(s)`);
58
90
 
59
- await stop();
91
+ try {
92
+ await stop();
93
+ } finally {
94
+ debug("stopping daemon");
95
+ }
60
96
  }