@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,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Working memory — persistent, account-scoped notes injected into every turn.
|
|
3
|
+
*
|
|
4
|
+
* Stored at `~/.botcord/agents/{agentId}/state/working-memory.json` (the
|
|
5
|
+
* per-agent state dir owned by the daemon; see docs/daemon-agent-workspace-plan.md §8).
|
|
6
|
+
*
|
|
7
|
+
* Ported from plugin/src/memory.ts (dropping workspace + OpenClaw runtime
|
|
8
|
+
* branches) and plugin/src/memory-protocol.ts (prompt builder).
|
|
9
|
+
*/
|
|
10
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { agentStateDir } from "./agent-workspace.js";
|
|
13
|
+
import { DAEMON_DIR_PATH } from "./config.js";
|
|
14
|
+
import { log as daemonLog } from "./log.js";
|
|
15
|
+
const VALID_SECTION_KEY_RE = /^[a-zA-Z0-9_]+$/;
|
|
16
|
+
/** Characters per section; matches the plugin-side limit. */
|
|
17
|
+
export const MAX_SECTION_CHARS = 10_000;
|
|
18
|
+
export const MAX_GOAL_CHARS = 500;
|
|
19
|
+
export const MAX_TOTAL_CHARS = 20_000;
|
|
20
|
+
export const DEFAULT_SECTION = "notes";
|
|
21
|
+
const MEMORY_SIZE_WARN_CHARS = 2_000;
|
|
22
|
+
/** Tags that must not appear verbatim in injected memory content. */
|
|
23
|
+
const RESERVED_TAGS_RE = /<\/?(?:current_memory|section_\w+)\b[^>]*>/gi;
|
|
24
|
+
// ── Path resolution ────────────────────────────────────────────────
|
|
25
|
+
/**
|
|
26
|
+
* Canonical per-agent state directory. Returns the new location
|
|
27
|
+
* (`~/.botcord/agents/{agentId}/state`). The legacy location under
|
|
28
|
+
* `~/.botcord/daemon/memory/{agentId}` is migrated lazily on first read —
|
|
29
|
+
* see §8 of the daemon-agent-workspace plan.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveMemoryDir(agentId) {
|
|
32
|
+
if (!agentId)
|
|
33
|
+
throw new Error("resolveMemoryDir: agentId is required");
|
|
34
|
+
return agentStateDir(agentId);
|
|
35
|
+
}
|
|
36
|
+
/** Legacy location retained for one-shot migration on read. */
|
|
37
|
+
function legacyMemoryDir(agentId) {
|
|
38
|
+
return path.join(DAEMON_DIR_PATH, "memory", agentId);
|
|
39
|
+
}
|
|
40
|
+
function workingMemoryPath(agentId) {
|
|
41
|
+
return path.join(resolveMemoryDir(agentId), "working-memory.json");
|
|
42
|
+
}
|
|
43
|
+
function legacyWorkingMemoryPath(agentId) {
|
|
44
|
+
return path.join(legacyMemoryDir(agentId), "working-memory.json");
|
|
45
|
+
}
|
|
46
|
+
// Migration conflict warnings are emitted at most once per agent per
|
|
47
|
+
// process. Reset only by daemon restart — good enough for a one-release
|
|
48
|
+
// transitional branch that gets removed later.
|
|
49
|
+
const warnedMigrationConflict = new Set();
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the path to read from, migrating from the legacy location if
|
|
52
|
+
* necessary. Returns the path the caller should read, or `null` when no
|
|
53
|
+
* memory file exists anywhere.
|
|
54
|
+
*
|
|
55
|
+
* Migration branch (the `else if` on `legacyExists` below) is meant to be
|
|
56
|
+
* deleted one release after this change ships; see plan §8 step 6.
|
|
57
|
+
*/
|
|
58
|
+
function resolveReadPath(agentId) {
|
|
59
|
+
const newPath = workingMemoryPath(agentId);
|
|
60
|
+
const oldPath = legacyWorkingMemoryPath(agentId);
|
|
61
|
+
const newExists = existsSync(newPath);
|
|
62
|
+
const oldExists = existsSync(oldPath);
|
|
63
|
+
if (newExists) {
|
|
64
|
+
if (oldExists && !warnedMigrationConflict.has(agentId)) {
|
|
65
|
+
warnedMigrationConflict.add(agentId);
|
|
66
|
+
daemonLog.warn("working-memory: both new and legacy paths exist; using new", {
|
|
67
|
+
agentId,
|
|
68
|
+
oldPath,
|
|
69
|
+
newPath,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return newPath;
|
|
73
|
+
}
|
|
74
|
+
if (oldExists) {
|
|
75
|
+
try {
|
|
76
|
+
mkdirSync(path.dirname(newPath), { recursive: true, mode: 0o700 });
|
|
77
|
+
try {
|
|
78
|
+
renameSync(oldPath, newPath);
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
// EXDEV = legacy and new paths live on different filesystems
|
|
82
|
+
// (bind mounts, tmpfs overlays). `renameSync` cannot cross fs
|
|
83
|
+
// boundaries, so fall back to copy + unlink. Without this, the
|
|
84
|
+
// next write would go to newPath while legacy still has the old
|
|
85
|
+
// payload — silent divergence the reviewer of §8 flagged.
|
|
86
|
+
if (err.code === "EXDEV") {
|
|
87
|
+
copyFileSync(oldPath, newPath);
|
|
88
|
+
unlinkSync(oldPath);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return newPath;
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
const e = err;
|
|
98
|
+
daemonLog.warn("working-memory: migration rename failed; reading legacy path", {
|
|
99
|
+
agentId,
|
|
100
|
+
oldPath,
|
|
101
|
+
newPath,
|
|
102
|
+
code: e.code,
|
|
103
|
+
error: e.message ?? String(err),
|
|
104
|
+
});
|
|
105
|
+
return oldPath;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
// ── File I/O ───────────────────────────────────────────────────────
|
|
111
|
+
function readJson(filePath) {
|
|
112
|
+
try {
|
|
113
|
+
return JSON.parse(readFileSync(filePath, "utf-8"));
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** Atomic write: tmp file + rename so a crash never leaves a half-file. */
|
|
120
|
+
function writeJsonAtomic(filePath, data) {
|
|
121
|
+
mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
122
|
+
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
123
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
124
|
+
renameSync(tmp, filePath);
|
|
125
|
+
}
|
|
126
|
+
function sanitizeSections(raw) {
|
|
127
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
128
|
+
return {};
|
|
129
|
+
const out = {};
|
|
130
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
131
|
+
if (VALID_SECTION_KEY_RE.test(k) && typeof v === "string")
|
|
132
|
+
out[k] = v;
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
function normalize(raw) {
|
|
137
|
+
if (!raw || typeof raw !== "object")
|
|
138
|
+
return null;
|
|
139
|
+
const r = raw;
|
|
140
|
+
if (r.version === 2 && r.sections && typeof r.sections === "object") {
|
|
141
|
+
return {
|
|
142
|
+
version: 2,
|
|
143
|
+
goal: typeof r.goal === "string" ? r.goal : undefined,
|
|
144
|
+
sections: sanitizeSections(r.sections),
|
|
145
|
+
updatedAt: typeof r.updatedAt === "string" ? r.updatedAt : "",
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (r.version === 1 && typeof r.content === "string") {
|
|
149
|
+
const v1 = r;
|
|
150
|
+
return {
|
|
151
|
+
version: 2,
|
|
152
|
+
sections: v1.content ? { [DEFAULT_SECTION]: v1.content } : {},
|
|
153
|
+
updatedAt: typeof v1.updatedAt === "string" ? v1.updatedAt : "",
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
export function readWorkingMemory(agentId) {
|
|
159
|
+
const p = resolveReadPath(agentId);
|
|
160
|
+
if (!p)
|
|
161
|
+
return null;
|
|
162
|
+
return normalize(readJson(p));
|
|
163
|
+
}
|
|
164
|
+
export function writeWorkingMemory(agentId, data) {
|
|
165
|
+
writeJsonAtomic(workingMemoryPath(agentId), data);
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Upsert a section (or goal). Passing empty `content` with a `section`
|
|
169
|
+
* deletes that section. `goal === ""` clears the goal.
|
|
170
|
+
*/
|
|
171
|
+
export function updateWorkingMemory(agentId, update) {
|
|
172
|
+
if (update.goal === undefined && update.content === undefined) {
|
|
173
|
+
throw new Error("updateWorkingMemory: must provide 'goal' or 'content'");
|
|
174
|
+
}
|
|
175
|
+
if (update.goal !== undefined && update.goal.length > MAX_GOAL_CHARS) {
|
|
176
|
+
throw new Error(`goal exceeds ${MAX_GOAL_CHARS} characters`);
|
|
177
|
+
}
|
|
178
|
+
const sectionName = (update.section ?? DEFAULT_SECTION).trim();
|
|
179
|
+
if (update.content !== undefined && !VALID_SECTION_KEY_RE.test(sectionName)) {
|
|
180
|
+
throw new Error("section name must contain only letters, digits, and underscores");
|
|
181
|
+
}
|
|
182
|
+
if (update.content !== undefined &&
|
|
183
|
+
update.content.length > MAX_SECTION_CHARS) {
|
|
184
|
+
throw new Error(`content exceeds ${MAX_SECTION_CHARS} characters for section '${sectionName}'`);
|
|
185
|
+
}
|
|
186
|
+
const existing = readWorkingMemory(agentId) ?? {
|
|
187
|
+
version: 2,
|
|
188
|
+
sections: {},
|
|
189
|
+
updatedAt: "",
|
|
190
|
+
};
|
|
191
|
+
if (update.goal !== undefined) {
|
|
192
|
+
existing.goal = update.goal === "" ? undefined : update.goal;
|
|
193
|
+
}
|
|
194
|
+
if (update.content !== undefined) {
|
|
195
|
+
if (update.content === "")
|
|
196
|
+
delete existing.sections[sectionName];
|
|
197
|
+
else
|
|
198
|
+
existing.sections[sectionName] = update.content;
|
|
199
|
+
}
|
|
200
|
+
const totalChars = (existing.goal?.length ?? 0) +
|
|
201
|
+
Object.values(existing.sections).reduce((s, v) => s + v.length, 0);
|
|
202
|
+
if (totalChars > MAX_TOTAL_CHARS) {
|
|
203
|
+
throw new Error(`total working memory exceeds ${MAX_TOTAL_CHARS} characters (current: ${totalChars})`);
|
|
204
|
+
}
|
|
205
|
+
existing.updatedAt = new Date().toISOString();
|
|
206
|
+
writeWorkingMemory(agentId, existing);
|
|
207
|
+
return {
|
|
208
|
+
memory: existing,
|
|
209
|
+
totalChars,
|
|
210
|
+
sectionPresent: update.content !== undefined ? update.content !== "" : sectionName in existing.sections,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
/** Wipe the agent's working memory entirely (goal + all sections). */
|
|
214
|
+
export function clearWorkingMemory(agentId) {
|
|
215
|
+
const empty = {
|
|
216
|
+
version: 2,
|
|
217
|
+
sections: {},
|
|
218
|
+
updatedAt: new Date().toISOString(),
|
|
219
|
+
};
|
|
220
|
+
writeWorkingMemory(agentId, empty);
|
|
221
|
+
}
|
|
222
|
+
// ── Prompt builder ─────────────────────────────────────────────────
|
|
223
|
+
function sanitizeMemoryContent(content) {
|
|
224
|
+
return content.replace(RESERVED_TAGS_RE, (tag) => tag.replace(/</g, "‹").replace(/>/g, "›"));
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Render a system-prompt block describing the agent's working memory. The
|
|
228
|
+
* format intentionally mirrors the plugin's so a CLI-side agent sees the
|
|
229
|
+
* same shape as an OpenClaw-hosted one.
|
|
230
|
+
*/
|
|
231
|
+
export function buildWorkingMemoryPrompt(opts) {
|
|
232
|
+
const { workingMemory, warnLarge = true } = opts;
|
|
233
|
+
const lines = [
|
|
234
|
+
"[BotCord Working Memory]",
|
|
235
|
+
"You have a persistent working memory that survives across turns and rooms.",
|
|
236
|
+
"Use it to track your goal, important facts, pending commitments, and context worth remembering.",
|
|
237
|
+
"",
|
|
238
|
+
"Update via the daemon's `memory` CLI (or whatever tool the operator wires):",
|
|
239
|
+
"- goal: a short pinned statement of what you're working on.",
|
|
240
|
+
"- sections: named buckets (contacts, pending_tasks, preferences, etc.).",
|
|
241
|
+
"- Updating one section never touches others. Empty content deletes a section.",
|
|
242
|
+
"",
|
|
243
|
+
"Only update when something meaningful changes. Keep each section tight.",
|
|
244
|
+
];
|
|
245
|
+
if (!workingMemory) {
|
|
246
|
+
lines.push("", "Your working memory is currently empty.");
|
|
247
|
+
return lines.join("\n");
|
|
248
|
+
}
|
|
249
|
+
const entries = Object.entries(workingMemory.sections ?? {});
|
|
250
|
+
const hasGoal = !!workingMemory.goal;
|
|
251
|
+
const hasSections = entries.length > 0;
|
|
252
|
+
if (!hasGoal && !hasSections) {
|
|
253
|
+
lines.push("", "Your working memory is currently empty.");
|
|
254
|
+
return lines.join("\n");
|
|
255
|
+
}
|
|
256
|
+
lines.push("", `Current working memory (last updated: ${workingMemory.updatedAt}):`);
|
|
257
|
+
let totalChars = 0;
|
|
258
|
+
if (hasGoal) {
|
|
259
|
+
const goal = sanitizeMemoryContent(workingMemory.goal.replace(/[\r\n]+/g, " ").trim());
|
|
260
|
+
lines.push("", `Goal: ${goal}`);
|
|
261
|
+
totalChars += goal.length;
|
|
262
|
+
}
|
|
263
|
+
for (const [name, content] of entries) {
|
|
264
|
+
if (!content)
|
|
265
|
+
continue;
|
|
266
|
+
const body = sanitizeMemoryContent(content);
|
|
267
|
+
lines.push("", `<section_${name}>`, body, `</section_${name}>`);
|
|
268
|
+
totalChars += body.length;
|
|
269
|
+
}
|
|
270
|
+
if (warnLarge && totalChars > MEMORY_SIZE_WARN_CHARS) {
|
|
271
|
+
lines.push("", `⚠ Your working memory is ${totalChars} characters. Consider condensing sections to keep token usage low.`);
|
|
272
|
+
}
|
|
273
|
+
return lines.join("\n");
|
|
274
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@botcord/daemon",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"botcord-daemon": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc -p tsconfig.build.json",
|
|
13
|
+
"prepublishOnly": "tsc -p tsconfig.build.json",
|
|
14
|
+
"test": "vitest run",
|
|
15
|
+
"test:watch": "vitest"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist/",
|
|
19
|
+
"src/",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18"
|
|
24
|
+
},
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@botcord/protocol-core": "^0.1.1",
|
|
31
|
+
"ws": "^8.18.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^20.0.0",
|
|
35
|
+
"@types/ws": "^8.5.0",
|
|
36
|
+
"typescript": "^5.4.0",
|
|
37
|
+
"vitest": "^4.0.18"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { ActivityTracker } from "../activity-tracker.js";
|
|
6
|
+
|
|
7
|
+
let tmpDir = "";
|
|
8
|
+
let filePath = "";
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), "daemon-act-"));
|
|
12
|
+
filePath = path.join(tmpDir, "activity.json");
|
|
13
|
+
});
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("ActivityTracker", () => {
|
|
19
|
+
it("records + reads by (agent, room, topic)", () => {
|
|
20
|
+
const t = new ActivityTracker({ filePath });
|
|
21
|
+
t.record({
|
|
22
|
+
agentId: "ag_a",
|
|
23
|
+
roomId: "rm_1",
|
|
24
|
+
topic: "tp_plan",
|
|
25
|
+
lastInboundPreview: "hi there",
|
|
26
|
+
lastSenderKind: "agent",
|
|
27
|
+
lastSender: "ag_peer",
|
|
28
|
+
});
|
|
29
|
+
const got = t.get("ag_a", "rm_1", "tp_plan");
|
|
30
|
+
expect(got?.lastSender).toBe("ag_peer");
|
|
31
|
+
expect(got?.lastActivityAt).toBeGreaterThan(Date.now() - 1000);
|
|
32
|
+
// Different topic → distinct entry
|
|
33
|
+
expect(t.get("ag_a", "rm_1", null)).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("caps preview length at ACTIVITY_PREVIEW_CHARS", () => {
|
|
37
|
+
const t = new ActivityTracker({ filePath });
|
|
38
|
+
t.record({
|
|
39
|
+
agentId: "ag_a",
|
|
40
|
+
roomId: "rm_1",
|
|
41
|
+
topic: null,
|
|
42
|
+
lastInboundPreview: "x".repeat(500),
|
|
43
|
+
lastSenderKind: "agent",
|
|
44
|
+
lastSender: "ag_peer",
|
|
45
|
+
});
|
|
46
|
+
const got = t.get("ag_a", "rm_1", null);
|
|
47
|
+
expect(got?.lastInboundPreview.length).toBe(120);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("listActive filters by agent + window, excludes current key, sorts newest first", () => {
|
|
51
|
+
const t = new ActivityTracker({ filePath });
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
t.record({
|
|
54
|
+
agentId: "ag_a",
|
|
55
|
+
roomId: "rm_old",
|
|
56
|
+
topic: null,
|
|
57
|
+
lastInboundPreview: "old",
|
|
58
|
+
lastSenderKind: "agent",
|
|
59
|
+
lastSender: "p",
|
|
60
|
+
lastActivityAt: now - 3 * 60 * 60 * 1000, // 3h ago
|
|
61
|
+
});
|
|
62
|
+
t.record({
|
|
63
|
+
agentId: "ag_a",
|
|
64
|
+
roomId: "rm_new",
|
|
65
|
+
topic: null,
|
|
66
|
+
lastInboundPreview: "new",
|
|
67
|
+
lastSenderKind: "agent",
|
|
68
|
+
lastSender: "p",
|
|
69
|
+
lastActivityAt: now - 10 * 60 * 1000, // 10m ago
|
|
70
|
+
});
|
|
71
|
+
t.record({
|
|
72
|
+
agentId: "ag_a",
|
|
73
|
+
roomId: "rm_cur",
|
|
74
|
+
topic: null,
|
|
75
|
+
lastInboundPreview: "cur",
|
|
76
|
+
lastSenderKind: "agent",
|
|
77
|
+
lastSender: "p",
|
|
78
|
+
lastActivityAt: now - 1 * 60 * 1000,
|
|
79
|
+
});
|
|
80
|
+
// Different agent entry — must be ignored.
|
|
81
|
+
t.record({
|
|
82
|
+
agentId: "ag_b",
|
|
83
|
+
roomId: "rm_other",
|
|
84
|
+
topic: null,
|
|
85
|
+
lastInboundPreview: "nope",
|
|
86
|
+
lastSenderKind: "agent",
|
|
87
|
+
lastSender: "p",
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const list = t.listActive({
|
|
91
|
+
agentId: "ag_a",
|
|
92
|
+
windowMs: 2 * 60 * 60 * 1000,
|
|
93
|
+
excludeKey: t.keyFor("ag_a", "rm_cur", null),
|
|
94
|
+
});
|
|
95
|
+
expect(list.map((e) => e.roomId)).toEqual(["rm_new"]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("flushes atomically to disk", () => {
|
|
99
|
+
const t = new ActivityTracker({ filePath });
|
|
100
|
+
t.record({
|
|
101
|
+
agentId: "ag_a",
|
|
102
|
+
roomId: "rm_1",
|
|
103
|
+
topic: null,
|
|
104
|
+
lastInboundPreview: "hi",
|
|
105
|
+
lastSenderKind: "agent",
|
|
106
|
+
lastSender: "p",
|
|
107
|
+
});
|
|
108
|
+
t.flushSync();
|
|
109
|
+
const raw = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
110
|
+
expect(raw.version).toBe(1);
|
|
111
|
+
const entry = Object.values(raw.entries)[0] as Record<string, unknown>;
|
|
112
|
+
expect(entry.roomId).toBe("rm_1");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("survives reopen — previous records reload from disk", () => {
|
|
116
|
+
const t1 = new ActivityTracker({ filePath });
|
|
117
|
+
t1.record({
|
|
118
|
+
agentId: "ag_a",
|
|
119
|
+
roomId: "rm_1",
|
|
120
|
+
topic: null,
|
|
121
|
+
lastInboundPreview: "x",
|
|
122
|
+
lastSenderKind: "agent",
|
|
123
|
+
lastSender: "p",
|
|
124
|
+
});
|
|
125
|
+
t1.flushSync();
|
|
126
|
+
|
|
127
|
+
const t2 = new ActivityTracker({ filePath });
|
|
128
|
+
expect(t2.get("ag_a", "rm_1", null)?.lastSender).toBe("p");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { Stats } from "node:fs";
|
|
3
|
+
import { discoverAgentCredentials, resolveBootAgents } from "../agent-discovery.js";
|
|
4
|
+
import type { DaemonConfig } from "../config.js";
|
|
5
|
+
|
|
6
|
+
function fakeStat(mtimeMs: number): Stats {
|
|
7
|
+
return { mtimeMs } as unknown as Stats;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function fakeCreds(agentId: string, extra: Record<string, unknown> = {}) {
|
|
11
|
+
return {
|
|
12
|
+
version: 1 as const,
|
|
13
|
+
hubUrl: "https://hub.example.com",
|
|
14
|
+
agentId,
|
|
15
|
+
keyId: "k_1",
|
|
16
|
+
privateKey: "priv",
|
|
17
|
+
publicKey: "pub",
|
|
18
|
+
savedAt: new Date().toISOString(),
|
|
19
|
+
...extra,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("discoverAgentCredentials", () => {
|
|
24
|
+
it("returns an empty result when the credentials directory is missing", () => {
|
|
25
|
+
const res = discoverAgentCredentials({
|
|
26
|
+
credentialsDir: "/no/such/dir",
|
|
27
|
+
readDir: () => {
|
|
28
|
+
const err = new Error("ENOENT") as NodeJS.ErrnoException;
|
|
29
|
+
err.code = "ENOENT";
|
|
30
|
+
throw err;
|
|
31
|
+
},
|
|
32
|
+
stat: () => fakeStat(0),
|
|
33
|
+
loadCredentials: () => {
|
|
34
|
+
throw new Error("should not be called");
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
expect(res.agents).toEqual([]);
|
|
38
|
+
expect(res.warnings).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("loads valid credential files and returns the internal agentId (not the filename)", () => {
|
|
42
|
+
const savedAt = "2025-01-01T00:00:00.000Z";
|
|
43
|
+
const res = discoverAgentCredentials({
|
|
44
|
+
credentialsDir: "/creds",
|
|
45
|
+
readDir: () => ["wrong-name.json"],
|
|
46
|
+
stat: () => fakeStat(100),
|
|
47
|
+
loadCredentials: () =>
|
|
48
|
+
fakeCreds("ag_internal", { displayName: "Alice", savedAt }),
|
|
49
|
+
});
|
|
50
|
+
expect(res.agents).toEqual([
|
|
51
|
+
{
|
|
52
|
+
agentId: "ag_internal",
|
|
53
|
+
credentialsFile: "/creds/wrong-name.json",
|
|
54
|
+
hubUrl: "https://hub.example.com",
|
|
55
|
+
displayName: "Alice",
|
|
56
|
+
keyId: "k_1",
|
|
57
|
+
savedAt,
|
|
58
|
+
},
|
|
59
|
+
]);
|
|
60
|
+
expect(res.warnings).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("surfaces keyId and savedAt from the credentials file (plan §9 BootAgent shape)", () => {
|
|
64
|
+
const savedAt = "2026-04-23T12:00:00.000Z";
|
|
65
|
+
const res = discoverAgentCredentials({
|
|
66
|
+
credentialsDir: "/creds",
|
|
67
|
+
readDir: () => ["ag.json"],
|
|
68
|
+
stat: () => fakeStat(1),
|
|
69
|
+
loadCredentials: () =>
|
|
70
|
+
fakeCreds("ag_one", { keyId: "k_42", savedAt }),
|
|
71
|
+
});
|
|
72
|
+
expect(res.agents).toHaveLength(1);
|
|
73
|
+
expect(res.agents[0].keyId).toBe("k_42");
|
|
74
|
+
expect(res.agents[0].savedAt).toBe(savedAt);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("ignores non-JSON files silently", () => {
|
|
78
|
+
const loaded: string[] = [];
|
|
79
|
+
const res = discoverAgentCredentials({
|
|
80
|
+
credentialsDir: "/creds",
|
|
81
|
+
readDir: () => ["ignore.txt", "README", "ag.json"],
|
|
82
|
+
stat: () => fakeStat(1),
|
|
83
|
+
loadCredentials: (f) => {
|
|
84
|
+
loaded.push(f);
|
|
85
|
+
return fakeCreds("ag_one");
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
expect(loaded).toEqual(["/creds/ag.json"]);
|
|
89
|
+
expect(res.agents.map((a) => a.agentId)).toEqual(["ag_one"]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("skips invalid credentials and records a warning", () => {
|
|
93
|
+
const res = discoverAgentCredentials({
|
|
94
|
+
credentialsDir: "/creds",
|
|
95
|
+
readDir: () => ["bad.json", "good.json"],
|
|
96
|
+
stat: () => fakeStat(1),
|
|
97
|
+
loadCredentials: (f) => {
|
|
98
|
+
if (f.endsWith("bad.json")) throw new Error("missing hubUrl");
|
|
99
|
+
return fakeCreds("ag_good");
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
expect(res.agents.map((a) => a.agentId)).toEqual(["ag_good"]);
|
|
103
|
+
expect(res.warnings.some((w) => w.includes("invalid credentials") && w.includes("bad.json"))).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("prefers the newer mtime on duplicate agentIds", () => {
|
|
107
|
+
const res = discoverAgentCredentials({
|
|
108
|
+
credentialsDir: "/creds",
|
|
109
|
+
readDir: () => ["a.json", "b.json"],
|
|
110
|
+
stat: (p) => (p.endsWith("b.json") ? fakeStat(200) : fakeStat(100)),
|
|
111
|
+
loadCredentials: () => fakeCreds("ag_dup"),
|
|
112
|
+
});
|
|
113
|
+
expect(res.agents).toHaveLength(1);
|
|
114
|
+
expect(res.agents[0].credentialsFile).toBe("/creds/b.json");
|
|
115
|
+
expect(res.warnings.some((w) => w.includes("duplicate agentId"))).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("falls back to lexical order when mtimes tie", () => {
|
|
119
|
+
const res = discoverAgentCredentials({
|
|
120
|
+
credentialsDir: "/creds",
|
|
121
|
+
readDir: () => ["z.json", "a.json"],
|
|
122
|
+
stat: () => fakeStat(42),
|
|
123
|
+
loadCredentials: () => fakeCreds("ag_dup"),
|
|
124
|
+
});
|
|
125
|
+
expect(res.agents).toHaveLength(1);
|
|
126
|
+
// Sorted lexically first wins when mtimes are equal.
|
|
127
|
+
expect(res.agents[0].credentialsFile).toBe("/creds/a.json");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("resolveBootAgents", () => {
|
|
132
|
+
function cfg(overrides: Partial<DaemonConfig> = {}): DaemonConfig {
|
|
133
|
+
return {
|
|
134
|
+
defaultRoute: { adapter: "claude-code", cwd: "/home/a" },
|
|
135
|
+
routes: [],
|
|
136
|
+
streamBlocks: true,
|
|
137
|
+
...overrides,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
it("uses explicit `agents` list and derives default credential paths", () => {
|
|
142
|
+
const res = resolveBootAgents(cfg({ agents: ["ag_one", "ag_two"] }));
|
|
143
|
+
expect(res.source).toBe("config");
|
|
144
|
+
expect(res.agents).toHaveLength(2);
|
|
145
|
+
expect(res.agents[0].agentId).toBe("ag_one");
|
|
146
|
+
expect(res.agents[0].credentialsFile).toContain("ag_one.json");
|
|
147
|
+
expect(res.warnings).toEqual([]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("falls back to discovery when no explicit list is present", () => {
|
|
151
|
+
const res = resolveBootAgents(cfg(), {
|
|
152
|
+
credentialsDir: "/creds",
|
|
153
|
+
readDir: () => ["x.json"],
|
|
154
|
+
stat: () => fakeStat(1),
|
|
155
|
+
loadCredentials: () => fakeCreds("ag_discovered"),
|
|
156
|
+
});
|
|
157
|
+
expect(res.source).toBe("credentials");
|
|
158
|
+
expect(res.agents.map((a) => a.agentId)).toEqual(["ag_discovered"]);
|
|
159
|
+
expect(res.agents[0].credentialsFile).toBe("/creds/x.json");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("returns an empty agent list (not a throw) when discovery finds nothing", () => {
|
|
163
|
+
const res = resolveBootAgents(cfg(), {
|
|
164
|
+
credentialsDir: "/creds",
|
|
165
|
+
readDir: () => [],
|
|
166
|
+
stat: () => fakeStat(0),
|
|
167
|
+
loadCredentials: () => {
|
|
168
|
+
throw new Error("should not be called");
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
expect(res.source).toBe("credentials");
|
|
172
|
+
expect(res.agents).toEqual([]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("honors cfg.agentDiscovery.credentialsDir when discovery runs", () => {
|
|
176
|
+
const calls: string[] = [];
|
|
177
|
+
const res = resolveBootAgents(
|
|
178
|
+
cfg({ agentDiscovery: { credentialsDir: "/custom" } }),
|
|
179
|
+
{
|
|
180
|
+
readDir: (d) => {
|
|
181
|
+
calls.push(d);
|
|
182
|
+
return ["a.json"];
|
|
183
|
+
},
|
|
184
|
+
stat: () => fakeStat(1),
|
|
185
|
+
loadCredentials: () => fakeCreds("ag_c"),
|
|
186
|
+
},
|
|
187
|
+
);
|
|
188
|
+
expect(calls).toEqual(["/custom"]);
|
|
189
|
+
expect(res.credentialsDir).toBe("/custom");
|
|
190
|
+
});
|
|
191
|
+
});
|