@botcord/daemon 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.
- package/dist/activity-tracker.d.ts +43 -0
- package/dist/activity-tracker.js +110 -0
- package/dist/adapters/runtimes.d.ts +14 -0
- package/dist/adapters/runtimes.js +18 -0
- package/dist/agent-discovery.d.ts +81 -0
- package/dist/agent-discovery.js +181 -0
- package/dist/agent-workspace.d.ts +31 -0
- package/dist/agent-workspace.js +221 -0
- package/dist/config.d.ts +116 -0
- package/dist/config.js +180 -0
- package/dist/control-channel.d.ts +99 -0
- package/dist/control-channel.js +388 -0
- package/dist/cross-room.d.ts +23 -0
- package/dist/cross-room.js +55 -0
- package/dist/daemon-config-map.d.ts +61 -0
- package/dist/daemon-config-map.js +153 -0
- package/dist/daemon.d.ts +123 -0
- package/dist/daemon.js +349 -0
- package/dist/doctor.d.ts +89 -0
- package/dist/doctor.js +191 -0
- package/dist/gateway/channel-manager.d.ts +54 -0
- package/dist/gateway/channel-manager.js +292 -0
- package/dist/gateway/channels/botcord.d.ts +93 -0
- package/dist/gateway/channels/botcord.js +510 -0
- package/dist/gateway/channels/index.d.ts +2 -0
- package/dist/gateway/channels/index.js +1 -0
- package/dist/gateway/channels/sanitize.d.ts +20 -0
- package/dist/gateway/channels/sanitize.js +56 -0
- package/dist/gateway/dispatcher.d.ts +73 -0
- package/dist/gateway/dispatcher.js +431 -0
- package/dist/gateway/gateway.d.ts +87 -0
- package/dist/gateway/gateway.js +158 -0
- package/dist/gateway/index.d.ts +15 -0
- package/dist/gateway/index.js +15 -0
- package/dist/gateway/log.d.ts +9 -0
- package/dist/gateway/log.js +20 -0
- package/dist/gateway/router.d.ts +10 -0
- package/dist/gateway/router.js +48 -0
- package/dist/gateway/runtimes/claude-code.d.ts +30 -0
- package/dist/gateway/runtimes/claude-code.js +162 -0
- package/dist/gateway/runtimes/codex.d.ts +83 -0
- package/dist/gateway/runtimes/codex.js +272 -0
- package/dist/gateway/runtimes/gemini.d.ts +15 -0
- package/dist/gateway/runtimes/gemini.js +29 -0
- package/dist/gateway/runtimes/ndjson-stream.d.ts +43 -0
- package/dist/gateway/runtimes/ndjson-stream.js +169 -0
- package/dist/gateway/runtimes/probe.d.ts +17 -0
- package/dist/gateway/runtimes/probe.js +54 -0
- package/dist/gateway/runtimes/registry.d.ts +59 -0
- package/dist/gateway/runtimes/registry.js +94 -0
- package/dist/gateway/session-store.d.ts +39 -0
- package/dist/gateway/session-store.js +133 -0
- package/dist/gateway/types.d.ts +265 -0
- package/dist/gateway/types.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +854 -0
- package/dist/log.d.ts +7 -0
- package/dist/log.js +44 -0
- package/dist/provision.d.ts +88 -0
- package/dist/provision.js +749 -0
- package/dist/room-context-fetcher.d.ts +18 -0
- package/dist/room-context-fetcher.js +101 -0
- package/dist/room-context.d.ts +53 -0
- package/dist/room-context.js +112 -0
- package/dist/sender-classify.d.ts +30 -0
- package/dist/sender-classify.js +32 -0
- package/dist/snapshot-writer.d.ts +37 -0
- package/dist/snapshot-writer.js +84 -0
- package/dist/status-render.d.ts +28 -0
- package/dist/status-render.js +97 -0
- package/dist/system-context.d.ts +57 -0
- package/dist/system-context.js +91 -0
- package/dist/turn-text.d.ts +36 -0
- package/dist/turn-text.js +57 -0
- package/dist/user-auth.d.ts +75 -0
- package/dist/user-auth.js +245 -0
- package/dist/working-memory.d.ts +46 -0
- package/dist/working-memory.js +274 -0
- package/package.json +39 -0
- package/src/__tests__/activity-tracker.test.ts +130 -0
- package/src/__tests__/agent-discovery.test.ts +191 -0
- package/src/__tests__/agent-workspace.test.ts +147 -0
- package/src/__tests__/control-channel.test.ts +327 -0
- package/src/__tests__/cross-room.test.ts +116 -0
- package/src/__tests__/daemon-config-map.test.ts +416 -0
- package/src/__tests__/daemon.test.ts +300 -0
- package/src/__tests__/device-code.test.ts +152 -0
- package/src/__tests__/doctor.test.ts +218 -0
- package/src/__tests__/protocol-core-reexport.test.ts +24 -0
- package/src/__tests__/provision.test.ts +922 -0
- package/src/__tests__/room-context.test.ts +233 -0
- package/src/__tests__/runtime-discovery.test.ts +173 -0
- package/src/__tests__/snapshot-writer.test.ts +141 -0
- package/src/__tests__/status-render.test.ts +137 -0
- package/src/__tests__/system-context.test.ts +315 -0
- package/src/__tests__/turn-text.test.ts +116 -0
- package/src/__tests__/user-auth.test.ts +125 -0
- package/src/__tests__/working-memory.test.ts +240 -0
- package/src/activity-tracker.ts +140 -0
- package/src/adapters/runtimes.ts +30 -0
- package/src/agent-discovery.ts +262 -0
- package/src/agent-workspace.ts +247 -0
- package/src/config.ts +290 -0
- package/src/control-channel.ts +455 -0
- package/src/cross-room.ts +89 -0
- package/src/daemon-config-map.ts +200 -0
- package/src/daemon.ts +478 -0
- package/src/doctor.ts +282 -0
- package/src/gateway/__tests__/.gitkeep +0 -0
- package/src/gateway/__tests__/botcord-channel.test.ts +480 -0
- package/src/gateway/__tests__/channel-manager.test.ts +475 -0
- package/src/gateway/__tests__/claude-code-adapter.test.ts +318 -0
- package/src/gateway/__tests__/codex-adapter.test.ts +350 -0
- package/src/gateway/__tests__/dispatcher.test.ts +1159 -0
- package/src/gateway/__tests__/gateway-add-channel.test.ts +180 -0
- package/src/gateway/__tests__/gateway-managed-routes.test.ts +181 -0
- package/src/gateway/__tests__/gateway.test.ts +222 -0
- package/src/gateway/__tests__/router.test.ts +247 -0
- package/src/gateway/__tests__/sanitize.test.ts +193 -0
- package/src/gateway/__tests__/session-store.test.ts +235 -0
- package/src/gateway/channel-manager.ts +349 -0
- package/src/gateway/channels/botcord.ts +605 -0
- package/src/gateway/channels/index.ts +6 -0
- package/src/gateway/channels/sanitize.ts +68 -0
- package/src/gateway/dispatcher.ts +554 -0
- package/src/gateway/gateway.ts +211 -0
- package/src/gateway/index.ts +29 -0
- package/src/gateway/log.ts +30 -0
- package/src/gateway/router.ts +60 -0
- package/src/gateway/runtimes/claude-code.ts +180 -0
- package/src/gateway/runtimes/codex.ts +312 -0
- package/src/gateway/runtimes/gemini.ts +43 -0
- package/src/gateway/runtimes/ndjson-stream.ts +225 -0
- package/src/gateway/runtimes/probe.ts +73 -0
- package/src/gateway/runtimes/registry.ts +143 -0
- package/src/gateway/session-store.ts +157 -0
- package/src/gateway/types.ts +325 -0
- package/src/index.ts +961 -0
- package/src/log.ts +47 -0
- package/src/provision.ts +879 -0
- package/src/room-context-fetcher.ts +124 -0
- package/src/room-context.ts +167 -0
- package/src/sender-classify.ts +46 -0
- package/src/snapshot-writer.ts +103 -0
- package/src/status-render.ts +132 -0
- package/src/system-context.ts +162 -0
- package/src/turn-text.ts +93 -0
- package/src/user-auth.ts +295 -0
- package/src/working-memory.ts +352 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { RoomContextFetcher } from "./room-context.js";
|
|
2
|
+
export interface RoomContextFetcherOptions {
|
|
3
|
+
/** agentId → credentials JSON path. Populated by `resolveBootAgents`. */
|
|
4
|
+
credentialPathByAgentId: Map<string, string>;
|
|
5
|
+
/** Default creds path when an agent isn't in the map (rare). */
|
|
6
|
+
defaultCredentialsPath?: string;
|
|
7
|
+
/** Hub base URL override; when set, wins over the URL stored in credentials. */
|
|
8
|
+
hubBaseUrl?: string;
|
|
9
|
+
log?: {
|
|
10
|
+
warn: (msg: string, meta?: Record<string, unknown>) => void;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Build a {@link RoomContextFetcher} that resolves against Hub. Returns
|
|
15
|
+
* `null` on any error (missing creds, network, non-JSON, etc.) so the
|
|
16
|
+
* system-context builder can skip the block without blocking the turn.
|
|
17
|
+
*/
|
|
18
|
+
export declare function createRoomContextFetcher(opts: RoomContextFetcherOptions): RoomContextFetcher;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hub-backed implementation of `RoomContextFetcher`.
|
|
3
|
+
*
|
|
4
|
+
* Maintains a per-`accountId` `BotCordClient` (so token refreshes amortize
|
|
5
|
+
* across turns) and translates the shared `/hub/rooms/:id` response into the
|
|
6
|
+
* `{ room, members }` shape the builder expects. A single GET is enough —
|
|
7
|
+
* Hub returns both the room record and its member list in one payload.
|
|
8
|
+
*/
|
|
9
|
+
import { BotCordClient, loadStoredCredentials } from "@botcord/protocol-core";
|
|
10
|
+
/**
|
|
11
|
+
* Build a {@link RoomContextFetcher} that resolves against Hub. Returns
|
|
12
|
+
* `null` on any error (missing creds, network, non-JSON, etc.) so the
|
|
13
|
+
* system-context builder can skip the block without blocking the turn.
|
|
14
|
+
*/
|
|
15
|
+
export function createRoomContextFetcher(opts) {
|
|
16
|
+
const clients = new Map();
|
|
17
|
+
function getClient(accountId) {
|
|
18
|
+
const existing = clients.get(accountId);
|
|
19
|
+
if (existing)
|
|
20
|
+
return existing.client;
|
|
21
|
+
const credsPath = opts.credentialPathByAgentId.get(accountId) ?? opts.defaultCredentialsPath;
|
|
22
|
+
if (!credsPath) {
|
|
23
|
+
opts.log?.warn("daemon.room-context.no-credentials", { accountId });
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const creds = loadStoredCredentials(credsPath);
|
|
28
|
+
const client = new BotCordClient({
|
|
29
|
+
hubUrl: opts.hubBaseUrl ?? creds.hubUrl,
|
|
30
|
+
agentId: creds.agentId,
|
|
31
|
+
keyId: creds.keyId,
|
|
32
|
+
privateKey: creds.privateKey,
|
|
33
|
+
...(creds.token ? { token: creds.token } : {}),
|
|
34
|
+
...(creds.tokenExpiresAt !== undefined
|
|
35
|
+
? { tokenExpiresAt: creds.tokenExpiresAt }
|
|
36
|
+
: {}),
|
|
37
|
+
});
|
|
38
|
+
clients.set(accountId, { client, credentialsPath: credsPath });
|
|
39
|
+
return client;
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
opts.log?.warn("daemon.room-context.client-init-failed", {
|
|
43
|
+
accountId,
|
|
44
|
+
credsPath,
|
|
45
|
+
error: err instanceof Error ? err.message : String(err),
|
|
46
|
+
});
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return async ({ accountId, roomId }) => {
|
|
51
|
+
const client = getClient(accountId);
|
|
52
|
+
if (!client)
|
|
53
|
+
return null;
|
|
54
|
+
try {
|
|
55
|
+
// Hub returns `{ room_id, name, description, rule, visibility,
|
|
56
|
+
// join_policy, member_count, members: [...], ... }` in a single
|
|
57
|
+
// `/hub/rooms/:id` response. Use the raw value so we don't pay for
|
|
58
|
+
// the typed cast that drops `members`.
|
|
59
|
+
const room = (await client.getRoomInfo(roomId));
|
|
60
|
+
const members = Array.isArray(room.members)
|
|
61
|
+
? room.members
|
|
62
|
+
: [];
|
|
63
|
+
return {
|
|
64
|
+
room: {
|
|
65
|
+
room_id: typeof room.room_id === "string" ? room.room_id : roomId,
|
|
66
|
+
...(typeof room.name === "string" ? { name: room.name } : {}),
|
|
67
|
+
...(typeof room.description === "string"
|
|
68
|
+
? { description: room.description }
|
|
69
|
+
: {}),
|
|
70
|
+
...(typeof room.rule === "string" || room.rule === null
|
|
71
|
+
? { rule: room.rule ?? null }
|
|
72
|
+
: {}),
|
|
73
|
+
...(typeof room.visibility === "string"
|
|
74
|
+
? { visibility: room.visibility }
|
|
75
|
+
: {}),
|
|
76
|
+
...(typeof room.join_policy === "string"
|
|
77
|
+
? { join_policy: room.join_policy }
|
|
78
|
+
: {}),
|
|
79
|
+
...(typeof room.member_count === "number"
|
|
80
|
+
? { member_count: room.member_count }
|
|
81
|
+
: {}),
|
|
82
|
+
},
|
|
83
|
+
members: members.map((m) => ({
|
|
84
|
+
agent_id: typeof m.agent_id === "string" ? m.agent_id : "unknown",
|
|
85
|
+
...(typeof m.display_name === "string"
|
|
86
|
+
? { display_name: m.display_name }
|
|
87
|
+
: {}),
|
|
88
|
+
...(typeof m.role === "string" ? { role: m.role } : {}),
|
|
89
|
+
})),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
opts.log?.warn("daemon.room-context.fetch-failed", {
|
|
94
|
+
accountId,
|
|
95
|
+
roomId,
|
|
96
|
+
error: err instanceof Error ? err.message : String(err),
|
|
97
|
+
});
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { GatewayInboundMessage } from "./gateway/index.js";
|
|
2
|
+
/** Subset of Hub `/hub/rooms/:id` needed to render the block. */
|
|
3
|
+
export interface RoomInfoSnapshot {
|
|
4
|
+
room_id: string;
|
|
5
|
+
name?: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
rule?: string | null;
|
|
8
|
+
visibility?: string;
|
|
9
|
+
join_policy?: string;
|
|
10
|
+
member_count?: number;
|
|
11
|
+
}
|
|
12
|
+
/** Subset of a room-member record needed to render the block. */
|
|
13
|
+
export interface RoomMemberSnapshot {
|
|
14
|
+
agent_id: string;
|
|
15
|
+
display_name?: string;
|
|
16
|
+
role?: string;
|
|
17
|
+
}
|
|
18
|
+
/** Combined result returned by the injected fetcher. */
|
|
19
|
+
export interface RoomContextFetchResult {
|
|
20
|
+
room: RoomInfoSnapshot;
|
|
21
|
+
members: RoomMemberSnapshot[];
|
|
22
|
+
}
|
|
23
|
+
/** Injected fetcher — daemon wraps a `BotCordClient` behind this contract. */
|
|
24
|
+
export type RoomContextFetcher = (params: {
|
|
25
|
+
accountId: string;
|
|
26
|
+
roomId: string;
|
|
27
|
+
}) => Promise<RoomContextFetchResult | null>;
|
|
28
|
+
/** Minimal logger surface — matches the daemon/gateway logger shape. */
|
|
29
|
+
interface CtxLogger {
|
|
30
|
+
warn: (msg: string, meta?: Record<string, unknown>) => void;
|
|
31
|
+
}
|
|
32
|
+
export interface RoomContextBuilderOptions {
|
|
33
|
+
fetchRoomInfo: RoomContextFetcher;
|
|
34
|
+
/** Cache TTL in ms. Defaults to 5 minutes to match the plugin. */
|
|
35
|
+
ttlMs?: number;
|
|
36
|
+
/** Clock override for tests. */
|
|
37
|
+
now?: () => number;
|
|
38
|
+
log?: CtxLogger;
|
|
39
|
+
}
|
|
40
|
+
/** Render the block. Exported for tests; production callers go through the builder. */
|
|
41
|
+
export declare function renderRoomContextBlock(room: RoomInfoSnapshot, members: RoomMemberSnapshot[]): string;
|
|
42
|
+
/**
|
|
43
|
+
* Return `true` if the inbound message is eligible for a room-context block.
|
|
44
|
+
* Exported for tests + reuse by the system-context builder.
|
|
45
|
+
*/
|
|
46
|
+
export declare function shouldInjectRoomContext(message: GatewayInboundMessage): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Create a per-turn builder: `(msg) => Promise<string | null>`. The returned
|
|
49
|
+
* function honors TTL, dedupes concurrent fetches, and tolerates fetcher
|
|
50
|
+
* failures (logs + returns null so the turn is never blocked).
|
|
51
|
+
*/
|
|
52
|
+
export declare function createRoomStaticContextBuilder(opts: RoomContextBuilderOptions): (message: GatewayInboundMessage) => Promise<string | null>;
|
|
53
|
+
export {};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Room static context builder — injects room name, description, rule, and
|
|
3
|
+
* member list into the system prompt for group conversations. Mirrors
|
|
4
|
+
* `plugin/src/room-context.ts#buildRoomStaticContext` so Claude Code in
|
|
5
|
+
* daemon-mode carries the same awareness as when hosted by OpenClaw.
|
|
6
|
+
*
|
|
7
|
+
* Scope:
|
|
8
|
+
* - Group rooms only. DMs (`rm_dm_`) and owner-chat (`rm_oc_`) rooms skip
|
|
9
|
+
* the block — DMs don't need it and owner-chat already has a scene
|
|
10
|
+
* prompt from system-context.ts.
|
|
11
|
+
* - Cached per `accountId:roomId` with a 5-minute TTL to keep Hub load
|
|
12
|
+
* bounded. Fetch failures are NOT cached so the next turn retries.
|
|
13
|
+
* - Concurrent fetches are de-duplicated via an in-flight promise slot.
|
|
14
|
+
*/
|
|
15
|
+
import { sanitizeUntrustedContent } from "./gateway/index.js";
|
|
16
|
+
const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
|
17
|
+
/** Strip CR/LF so tenant-controlled values can't reshape the prompt header. */
|
|
18
|
+
function stripNewlines(s) {
|
|
19
|
+
return s.replace(/[\r\n]+/g, " ");
|
|
20
|
+
}
|
|
21
|
+
/** Render the block. Exported for tests; production callers go through the builder. */
|
|
22
|
+
export function renderRoomContextBlock(room, members) {
|
|
23
|
+
const safeName = sanitizeUntrustedContent(stripNewlines(room.name ?? ""));
|
|
24
|
+
const lines = [
|
|
25
|
+
"[BotCord Room Context]",
|
|
26
|
+
`Room: ${safeName || "(unnamed)"} (${room.room_id})`,
|
|
27
|
+
];
|
|
28
|
+
if (room.description) {
|
|
29
|
+
lines.push(`Description: ${sanitizeUntrustedContent(room.description)}`);
|
|
30
|
+
}
|
|
31
|
+
if (room.rule) {
|
|
32
|
+
lines.push(`Rule: ${sanitizeUntrustedContent(room.rule)}`);
|
|
33
|
+
}
|
|
34
|
+
if (room.visibility || room.join_policy) {
|
|
35
|
+
const visibility = room.visibility ?? "unknown";
|
|
36
|
+
const joinPolicy = room.join_policy ?? "unknown";
|
|
37
|
+
lines.push(`Visibility: ${visibility}, Join: ${joinPolicy}`);
|
|
38
|
+
}
|
|
39
|
+
if (members.length > 0) {
|
|
40
|
+
const list = members
|
|
41
|
+
.map((m) => {
|
|
42
|
+
const raw = m.display_name || m.agent_id;
|
|
43
|
+
const safe = sanitizeUntrustedContent(stripNewlines(raw));
|
|
44
|
+
return m.role && m.role !== "member" ? `${safe} (${m.role})` : safe;
|
|
45
|
+
})
|
|
46
|
+
.join(", ");
|
|
47
|
+
lines.push(`Members (${members.length}): ${list}`);
|
|
48
|
+
}
|
|
49
|
+
return lines.join("\n");
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Return `true` if the inbound message is eligible for a room-context block.
|
|
53
|
+
* Exported for tests + reuse by the system-context builder.
|
|
54
|
+
*/
|
|
55
|
+
export function shouldInjectRoomContext(message) {
|
|
56
|
+
if (message.conversation.kind !== "group")
|
|
57
|
+
return false;
|
|
58
|
+
const id = message.conversation.id;
|
|
59
|
+
if (id.startsWith("rm_dm_"))
|
|
60
|
+
return false;
|
|
61
|
+
if (id.startsWith("rm_oc_"))
|
|
62
|
+
return false;
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Create a per-turn builder: `(msg) => Promise<string | null>`. The returned
|
|
67
|
+
* function honors TTL, dedupes concurrent fetches, and tolerates fetcher
|
|
68
|
+
* failures (logs + returns null so the turn is never blocked).
|
|
69
|
+
*/
|
|
70
|
+
export function createRoomStaticContextBuilder(opts) {
|
|
71
|
+
const ttl = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
72
|
+
const now = opts.now ?? Date.now;
|
|
73
|
+
const cache = new Map();
|
|
74
|
+
const inflight = new Map();
|
|
75
|
+
return async function getBlock(message) {
|
|
76
|
+
if (!shouldInjectRoomContext(message))
|
|
77
|
+
return null;
|
|
78
|
+
const accountId = message.accountId;
|
|
79
|
+
const roomId = message.conversation.id;
|
|
80
|
+
const key = `${accountId}:${roomId}`;
|
|
81
|
+
const hit = cache.get(key);
|
|
82
|
+
if (hit && now() - hit.fetchedAt < ttl)
|
|
83
|
+
return hit.blockText;
|
|
84
|
+
const existing = inflight.get(key);
|
|
85
|
+
if (existing)
|
|
86
|
+
return existing;
|
|
87
|
+
const p = (async () => {
|
|
88
|
+
try {
|
|
89
|
+
const result = await opts.fetchRoomInfo({ accountId, roomId });
|
|
90
|
+
if (!result)
|
|
91
|
+
return null;
|
|
92
|
+
const blockText = renderRoomContextBlock(result.room, result.members);
|
|
93
|
+
cache.set(key, { blockText, fetchedAt: now() });
|
|
94
|
+
return blockText;
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
opts.log?.warn("daemon.room-context.fetch-failed", {
|
|
98
|
+
accountId,
|
|
99
|
+
roomId,
|
|
100
|
+
error: err instanceof Error ? err.message : String(err),
|
|
101
|
+
});
|
|
102
|
+
// Don't poison the cache — next turn will retry.
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
inflight.delete(key);
|
|
107
|
+
}
|
|
108
|
+
})();
|
|
109
|
+
inflight.set(key, p);
|
|
110
|
+
return p;
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sender classification helper — shared between the daemon's activity
|
|
3
|
+
* recorder (daemon.ts) and the user-turn composer (turn-text.ts).
|
|
4
|
+
*
|
|
5
|
+
* Lives in its own module so both callers can import it without forming a
|
|
6
|
+
* dependency cycle through daemon.ts.
|
|
7
|
+
*/
|
|
8
|
+
import type { GatewayInboundMessage } from "./gateway/index.js";
|
|
9
|
+
/**
|
|
10
|
+
* BotCord owner-chat room prefix. Rooms with this prefix are direct-message
|
|
11
|
+
* rooms between an operator and their own agent; turns here are treated as
|
|
12
|
+
* owner-trust by the daemon's trust classifier.
|
|
13
|
+
*/
|
|
14
|
+
export declare const OWNER_CHAT_PREFIX = "rm_oc_";
|
|
15
|
+
/**
|
|
16
|
+
* Map a gateway inbound message to a sender label + kind.
|
|
17
|
+
*
|
|
18
|
+
* The gateway BotCord channel collapses two distinct owner-trust cases
|
|
19
|
+
* (`rm_oc_` rooms AND `source_type === "dashboard_user_chat"`) into a single
|
|
20
|
+
* `sender.kind === "user"` marker — which also covers `dashboard_human_room`
|
|
21
|
+
* humans. We need them separated here so callers can distinguish "owner"
|
|
22
|
+
* (admin, fully trusted) from "human Alice" (a regular human in a normal
|
|
23
|
+
* room). Falling back to just the `rm_oc_` prefix when `raw` is an
|
|
24
|
+
* unexpected shape keeps the classifier working even if a non-BotCord
|
|
25
|
+
* channel is later plugged in.
|
|
26
|
+
*/
|
|
27
|
+
export declare function classifyActivitySender(msg: GatewayInboundMessage): {
|
|
28
|
+
kind: "agent" | "human" | "owner";
|
|
29
|
+
label: string;
|
|
30
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BotCord owner-chat room prefix. Rooms with this prefix are direct-message
|
|
3
|
+
* rooms between an operator and their own agent; turns here are treated as
|
|
4
|
+
* owner-trust by the daemon's trust classifier.
|
|
5
|
+
*/
|
|
6
|
+
export const OWNER_CHAT_PREFIX = "rm_oc_";
|
|
7
|
+
/**
|
|
8
|
+
* Map a gateway inbound message to a sender label + kind.
|
|
9
|
+
*
|
|
10
|
+
* The gateway BotCord channel collapses two distinct owner-trust cases
|
|
11
|
+
* (`rm_oc_` rooms AND `source_type === "dashboard_user_chat"`) into a single
|
|
12
|
+
* `sender.kind === "user"` marker — which also covers `dashboard_human_room`
|
|
13
|
+
* humans. We need them separated here so callers can distinguish "owner"
|
|
14
|
+
* (admin, fully trusted) from "human Alice" (a regular human in a normal
|
|
15
|
+
* room). Falling back to just the `rm_oc_` prefix when `raw` is an
|
|
16
|
+
* unexpected shape keeps the classifier working even if a non-BotCord
|
|
17
|
+
* channel is later plugged in.
|
|
18
|
+
*/
|
|
19
|
+
export function classifyActivitySender(msg) {
|
|
20
|
+
const sourceType = msg.raw && typeof msg.raw === "object" && "source_type" in msg.raw
|
|
21
|
+
? msg.raw.source_type
|
|
22
|
+
: undefined;
|
|
23
|
+
const isOwner = msg.conversation.id.startsWith(OWNER_CHAT_PREFIX) ||
|
|
24
|
+
sourceType === "dashboard_user_chat";
|
|
25
|
+
if (isOwner) {
|
|
26
|
+
return { kind: "owner", label: msg.sender.name || msg.sender.id || "owner" };
|
|
27
|
+
}
|
|
28
|
+
if (msg.sender.kind === "user") {
|
|
29
|
+
return { kind: "human", label: msg.sender.name || msg.sender.id || "user" };
|
|
30
|
+
}
|
|
31
|
+
return { kind: "agent", label: msg.sender.id || "unknown" };
|
|
32
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { GatewayLogger, GatewayRuntimeSnapshot } from "./gateway/index.js";
|
|
2
|
+
/** Envelope written to the snapshot file; `version` lets readers guard shape. */
|
|
3
|
+
export interface SnapshotFile {
|
|
4
|
+
version: 1;
|
|
5
|
+
writtenAt: number;
|
|
6
|
+
snapshot: GatewayRuntimeSnapshot;
|
|
7
|
+
}
|
|
8
|
+
/** Options for {@link SnapshotWriter}. */
|
|
9
|
+
export interface SnapshotWriterOptions {
|
|
10
|
+
path: string;
|
|
11
|
+
intervalMs: number;
|
|
12
|
+
snapshot: () => GatewayRuntimeSnapshot;
|
|
13
|
+
log?: GatewayLogger;
|
|
14
|
+
/** Injection point for tests. Defaults to `Date.now`. */
|
|
15
|
+
now?: () => number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Periodically writes `gateway.snapshot()` to a file so out-of-process CLI
|
|
19
|
+
* commands can read daemon state. Writes are atomic (tmp + rename) and
|
|
20
|
+
* failures are logged, never thrown.
|
|
21
|
+
*/
|
|
22
|
+
export declare class SnapshotWriter {
|
|
23
|
+
private readonly opts;
|
|
24
|
+
private timer;
|
|
25
|
+
private stopped;
|
|
26
|
+
constructor(opts: SnapshotWriterOptions);
|
|
27
|
+
/** Begin periodic writes; performs one write immediately. */
|
|
28
|
+
start(): void;
|
|
29
|
+
/** Stop the interval. Does not delete the file — call {@link writeFinal} / {@link remove} as needed. */
|
|
30
|
+
stop(): void;
|
|
31
|
+
/** Write one synchronous snapshot immediately; swallows errors. */
|
|
32
|
+
writeOnce(): void;
|
|
33
|
+
/** Write one last snapshot — used right before {@link remove}. */
|
|
34
|
+
writeFinal(): void;
|
|
35
|
+
/** Best-effort delete of the snapshot file; swallows + logs on failure. */
|
|
36
|
+
remove(): void;
|
|
37
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { mkdirSync, renameSync, writeFileSync, unlinkSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Periodically writes `gateway.snapshot()` to a file so out-of-process CLI
|
|
5
|
+
* commands can read daemon state. Writes are atomic (tmp + rename) and
|
|
6
|
+
* failures are logged, never thrown.
|
|
7
|
+
*/
|
|
8
|
+
export class SnapshotWriter {
|
|
9
|
+
opts;
|
|
10
|
+
timer = null;
|
|
11
|
+
stopped = false;
|
|
12
|
+
constructor(opts) {
|
|
13
|
+
this.opts = opts;
|
|
14
|
+
}
|
|
15
|
+
/** Begin periodic writes; performs one write immediately. */
|
|
16
|
+
start() {
|
|
17
|
+
if (this.timer || this.stopped)
|
|
18
|
+
return;
|
|
19
|
+
this.writeOnce();
|
|
20
|
+
this.timer = setInterval(() => this.writeOnce(), this.opts.intervalMs);
|
|
21
|
+
// Don't keep the event loop alive just for status writes.
|
|
22
|
+
if (typeof this.timer.unref === "function")
|
|
23
|
+
this.timer.unref();
|
|
24
|
+
}
|
|
25
|
+
/** Stop the interval. Does not delete the file — call {@link writeFinal} / {@link remove} as needed. */
|
|
26
|
+
stop() {
|
|
27
|
+
this.stopped = true;
|
|
28
|
+
if (this.timer) {
|
|
29
|
+
clearInterval(this.timer);
|
|
30
|
+
this.timer = null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Write one synchronous snapshot immediately; swallows errors. */
|
|
34
|
+
writeOnce() {
|
|
35
|
+
let snap;
|
|
36
|
+
try {
|
|
37
|
+
snap = this.opts.snapshot();
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
this.opts.log?.warn("daemon.snapshot-writer.snapshot-fn-threw", {
|
|
41
|
+
error: err.message,
|
|
42
|
+
});
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const now = (this.opts.now ?? Date.now)();
|
|
46
|
+
const payload = {
|
|
47
|
+
version: 1,
|
|
48
|
+
writtenAt: now,
|
|
49
|
+
snapshot: snap,
|
|
50
|
+
};
|
|
51
|
+
try {
|
|
52
|
+
const dir = path.dirname(this.opts.path);
|
|
53
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
54
|
+
const tmp = `${this.opts.path}.${process.pid}.tmp`;
|
|
55
|
+
writeFileSync(tmp, JSON.stringify(payload, null, 2), { mode: 0o600 });
|
|
56
|
+
renameSync(tmp, this.opts.path);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
this.opts.log?.warn("daemon.snapshot-writer.write-failed", {
|
|
60
|
+
path: this.opts.path,
|
|
61
|
+
error: err.message,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Write one last snapshot — used right before {@link remove}. */
|
|
66
|
+
writeFinal() {
|
|
67
|
+
this.writeOnce();
|
|
68
|
+
}
|
|
69
|
+
/** Best-effort delete of the snapshot file; swallows + logs on failure. */
|
|
70
|
+
remove() {
|
|
71
|
+
try {
|
|
72
|
+
unlinkSync(this.opts.path);
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
const code = err.code;
|
|
76
|
+
if (code === "ENOENT")
|
|
77
|
+
return;
|
|
78
|
+
this.opts.log?.warn("daemon.snapshot-writer.remove-failed", {
|
|
79
|
+
path: this.opts.path,
|
|
80
|
+
error: err.message,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { GatewayRuntimeSnapshot } from "./gateway/index.js";
|
|
2
|
+
/** Threshold after which a snapshot is flagged `⚠ stale` in rendered output. */
|
|
3
|
+
export declare const STALE_THRESHOLD_MS = 30000;
|
|
4
|
+
/** Input bundle for {@link renderStatus}. */
|
|
5
|
+
export interface StatusRenderInput {
|
|
6
|
+
pid: number | null;
|
|
7
|
+
alive: boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Effective list of agent ids the daemon is bound to. Single-agent installs
|
|
10
|
+
* show one entry; multi-agent configs show all. `agentId` (scalar) is kept
|
|
11
|
+
* for backward-compat callers and, when provided alone, rendered the same
|
|
12
|
+
* way.
|
|
13
|
+
*/
|
|
14
|
+
agents?: string[] | null;
|
|
15
|
+
/** @deprecated prefer `agents`. */
|
|
16
|
+
agentId?: string | null;
|
|
17
|
+
/** "config" — explicit list; "credentials" — auto-discovered. */
|
|
18
|
+
agentsSource?: "config" | "credentials" | null;
|
|
19
|
+
configPath?: string | null;
|
|
20
|
+
snapshot?: GatewayRuntimeSnapshot | null;
|
|
21
|
+
/** `writtenAt` age in ms. Undefined when no snapshot is available. */
|
|
22
|
+
snapshotAgeMs?: number | null;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Format a human-readable status block. Kept pure so it can be unit-tested
|
|
26
|
+
* without touching disk or spawning a daemon.
|
|
27
|
+
*/
|
|
28
|
+
export declare function renderStatus(input: StatusRenderInput, now?: number): string;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/** Threshold after which a snapshot is flagged `⚠ stale` in rendered output. */
|
|
2
|
+
export const STALE_THRESHOLD_MS = 30_000;
|
|
3
|
+
function pad(s, n) {
|
|
4
|
+
return s + " ".repeat(Math.max(0, n - s.length));
|
|
5
|
+
}
|
|
6
|
+
function relTime(ms) {
|
|
7
|
+
if (!Number.isFinite(ms) || ms < 0)
|
|
8
|
+
return "—";
|
|
9
|
+
const s = Math.round(ms / 1000);
|
|
10
|
+
if (s < 60)
|
|
11
|
+
return `${s}s ago`;
|
|
12
|
+
const m = Math.round(s / 60);
|
|
13
|
+
if (m < 60)
|
|
14
|
+
return `${m}m ago`;
|
|
15
|
+
const h = Math.round(m / 60);
|
|
16
|
+
return `${h}h ago`;
|
|
17
|
+
}
|
|
18
|
+
function renderChannels(snap) {
|
|
19
|
+
const entries = Object.values(snap.channels);
|
|
20
|
+
if (entries.length === 0)
|
|
21
|
+
return ["Channels:", " (none)"];
|
|
22
|
+
const idW = Math.max(2, ...entries.map((c) => c.channel.length));
|
|
23
|
+
const accW = Math.max(7, ...entries.map((c) => c.accountId.length));
|
|
24
|
+
const out = ["Channels:"];
|
|
25
|
+
out.push(` ${pad("ID", idW)} ${pad("ACCOUNT", accW)} RUNNING CONNECTED RETRIES RESTART LAST ERROR`);
|
|
26
|
+
for (const c of entries) {
|
|
27
|
+
const running = c.running ? "yes" : "no";
|
|
28
|
+
const connected = c.connected === undefined ? "—" : c.connected ? "yes" : "no";
|
|
29
|
+
const retries = c.reconnectAttempts === undefined ? "—" : String(c.reconnectAttempts);
|
|
30
|
+
const restart = c.restartPending ? "yes" : "no";
|
|
31
|
+
const err = c.lastError ?? "—";
|
|
32
|
+
out.push(` ${pad(c.channel, idW)} ${pad(c.accountId, accW)} ${pad(running, 7)} ${pad(connected, 9)} ${pad(retries, 7)} ${pad(restart, 7)} ${err}`);
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
function renderTurns(snap, now) {
|
|
37
|
+
const entries = Object.values(snap.turns);
|
|
38
|
+
if (entries.length === 0)
|
|
39
|
+
return ["In-flight turns:", " (none)"];
|
|
40
|
+
const out = ["In-flight turns:"];
|
|
41
|
+
const keyW = Math.max(3, ...entries.map((t) => t.key.length));
|
|
42
|
+
const chW = Math.max(7, ...entries.map((t) => t.channel.length));
|
|
43
|
+
const convW = Math.max(14, ...entries.map((t) => t.conversationId.length));
|
|
44
|
+
const rtW = Math.max(7, ...entries.map((t) => t.runtime.length));
|
|
45
|
+
out.push(` ${pad("KEY", keyW)} ${pad("CHANNEL", chW)} ${pad("CONVERSATION", convW)} ${pad("RUNTIME", rtW)} STARTED CWD`);
|
|
46
|
+
for (const t of entries) {
|
|
47
|
+
const started = relTime(now - t.startedAt);
|
|
48
|
+
out.push(` ${pad(t.key, keyW)} ${pad(t.channel, chW)} ${pad(t.conversationId, convW)} ${pad(t.runtime, rtW)} ${pad(started, 16)} ${t.cwd}`);
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Format a human-readable status block. Kept pure so it can be unit-tested
|
|
54
|
+
* without touching disk or spawning a daemon.
|
|
55
|
+
*/
|
|
56
|
+
export function renderStatus(input, now = Date.now()) {
|
|
57
|
+
const lines = [];
|
|
58
|
+
if (input.pid === null) {
|
|
59
|
+
lines.push("daemon: stopped");
|
|
60
|
+
return lines.join("\n");
|
|
61
|
+
}
|
|
62
|
+
lines.push(`daemon: pid ${input.pid} (${input.alive ? "alive" : "not alive"})`);
|
|
63
|
+
const agents = input.agents && input.agents.length > 0
|
|
64
|
+
? input.agents
|
|
65
|
+
: input.agentId
|
|
66
|
+
? [input.agentId]
|
|
67
|
+
: [];
|
|
68
|
+
const sourceTag = input.agentsSource === "credentials"
|
|
69
|
+
? " (discovered)"
|
|
70
|
+
: input.agentsSource === "config"
|
|
71
|
+
? ""
|
|
72
|
+
: "";
|
|
73
|
+
if (agents.length === 1) {
|
|
74
|
+
lines.push(`agent: ${agents[0]}${sourceTag}`);
|
|
75
|
+
}
|
|
76
|
+
else if (agents.length > 1) {
|
|
77
|
+
lines.push(`agents: ${agents.join(", ")}${sourceTag}`);
|
|
78
|
+
}
|
|
79
|
+
else if (input.agentsSource === "credentials") {
|
|
80
|
+
lines.push(`agents: (none discovered; drop credentials in ~/.botcord/credentials)`);
|
|
81
|
+
}
|
|
82
|
+
if (input.configPath)
|
|
83
|
+
lines.push(`config: ${input.configPath}`);
|
|
84
|
+
if (input.snapshot) {
|
|
85
|
+
const age = input.snapshotAgeMs ?? 0;
|
|
86
|
+
const stale = age > STALE_THRESHOLD_MS ? " ⚠ stale" : "";
|
|
87
|
+
lines.push(`snapshot: ${relTime(age)}${stale}`);
|
|
88
|
+
lines.push("");
|
|
89
|
+
lines.push(...renderChannels(input.snapshot));
|
|
90
|
+
lines.push("");
|
|
91
|
+
lines.push(...renderTurns(input.snapshot, now));
|
|
92
|
+
}
|
|
93
|
+
else if (input.alive) {
|
|
94
|
+
lines.push("snapshot: unavailable (daemon running but no snapshot file found)");
|
|
95
|
+
}
|
|
96
|
+
return lines.join("\n");
|
|
97
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon-flavored `SystemContextBuilder` factory for the gateway dispatcher.
|
|
3
|
+
*
|
|
4
|
+
* The gateway dispatcher is channel-agnostic; it calls an optional
|
|
5
|
+
* `buildSystemContext` hook and forwards the result to the runtime via
|
|
6
|
+
* `RuntimeRunOptions.systemContext`. This module composes the daemon's
|
|
7
|
+
* system-context string from:
|
|
8
|
+
*
|
|
9
|
+
* 1. `[BotCord Scene: Owner Chat]` (owner-trust turns only)
|
|
10
|
+
* 2. `[BotCord Working Memory]`
|
|
11
|
+
* 3. `[BotCord Room Context]` (group rooms, via optional async fetcher)
|
|
12
|
+
* 4. `[BotCord Cross-Room Awareness]` (optional activity tracker)
|
|
13
|
+
*
|
|
14
|
+
* Behavior:
|
|
15
|
+
* - Working memory is loaded fresh per turn, so a `memory set` from another
|
|
16
|
+
* process is visible immediately.
|
|
17
|
+
* - If `ActivityTracker` is injected, we build the cross-room digest and
|
|
18
|
+
* EXCLUDE the current room + topic from the list.
|
|
19
|
+
* - If `roomContextBuilder` is injected, the factory returns an async
|
|
20
|
+
* builder and awaits the fetcher; otherwise it stays synchronous.
|
|
21
|
+
* - If every block is empty we return `undefined` so the dispatcher passes
|
|
22
|
+
* `systemContext: undefined` to the runtime (adapter then skips the
|
|
23
|
+
* injection flag).
|
|
24
|
+
*/
|
|
25
|
+
import type { GatewayInboundMessage } from "./gateway/index.js";
|
|
26
|
+
import type { ActivityTracker } from "./activity-tracker.js";
|
|
27
|
+
/**
|
|
28
|
+
* Async per-turn room-context builder (see `room-context.ts`). Returns the
|
|
29
|
+
* rendered `[BotCord Room Context]` block, or `null` when there is nothing
|
|
30
|
+
* to inject (DM, owner-chat, fetch failure, etc.).
|
|
31
|
+
*/
|
|
32
|
+
export type RoomStaticContextBuilder = (message: GatewayInboundMessage) => Promise<string | null>;
|
|
33
|
+
/** Dependencies injected by the daemon bootstrap. */
|
|
34
|
+
export interface SystemContextDeps {
|
|
35
|
+
/** The owning daemon's agent id. Used to scope working-memory + activity lookups. */
|
|
36
|
+
agentId: string;
|
|
37
|
+
/**
|
|
38
|
+
* Activity tracker used to compose the cross-room digest. If omitted the
|
|
39
|
+
* digest block is skipped entirely (working memory still injects).
|
|
40
|
+
*/
|
|
41
|
+
activityTracker?: ActivityTracker;
|
|
42
|
+
/**
|
|
43
|
+
* Optional per-turn room-context fetcher. When wired, group-room turns
|
|
44
|
+
* receive the `[BotCord Room Context]` block (room name, description,
|
|
45
|
+
* rule, members). Omitting keeps the builder synchronous and the block
|
|
46
|
+
* is skipped.
|
|
47
|
+
*/
|
|
48
|
+
roomContextBuilder?: RoomStaticContextBuilder;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Build a {@link SystemContextBuilder} for the gateway dispatcher.
|
|
52
|
+
*
|
|
53
|
+
* When `deps.roomContextBuilder` is provided the returned function is async
|
|
54
|
+
* so it can await the Hub fetch; otherwise it stays synchronous (same shape
|
|
55
|
+
* as the pre-P1 daemon builder). Both shapes satisfy `SystemContextBuilder`.
|
|
56
|
+
*/
|
|
57
|
+
export declare function createDaemonSystemContextBuilder(deps: SystemContextDeps): (message: GatewayInboundMessage) => Promise<string | undefined> | string | undefined;
|