@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.
Files changed (149) hide show
  1. package/dist/activity-tracker.d.ts +43 -0
  2. package/dist/activity-tracker.js +110 -0
  3. package/dist/adapters/runtimes.d.ts +14 -0
  4. package/dist/adapters/runtimes.js +18 -0
  5. package/dist/agent-discovery.d.ts +81 -0
  6. package/dist/agent-discovery.js +181 -0
  7. package/dist/agent-workspace.d.ts +31 -0
  8. package/dist/agent-workspace.js +221 -0
  9. package/dist/config.d.ts +116 -0
  10. package/dist/config.js +180 -0
  11. package/dist/control-channel.d.ts +99 -0
  12. package/dist/control-channel.js +388 -0
  13. package/dist/cross-room.d.ts +23 -0
  14. package/dist/cross-room.js +55 -0
  15. package/dist/daemon-config-map.d.ts +61 -0
  16. package/dist/daemon-config-map.js +153 -0
  17. package/dist/daemon.d.ts +123 -0
  18. package/dist/daemon.js +349 -0
  19. package/dist/doctor.d.ts +89 -0
  20. package/dist/doctor.js +191 -0
  21. package/dist/gateway/channel-manager.d.ts +54 -0
  22. package/dist/gateway/channel-manager.js +292 -0
  23. package/dist/gateway/channels/botcord.d.ts +93 -0
  24. package/dist/gateway/channels/botcord.js +510 -0
  25. package/dist/gateway/channels/index.d.ts +2 -0
  26. package/dist/gateway/channels/index.js +1 -0
  27. package/dist/gateway/channels/sanitize.d.ts +20 -0
  28. package/dist/gateway/channels/sanitize.js +56 -0
  29. package/dist/gateway/dispatcher.d.ts +73 -0
  30. package/dist/gateway/dispatcher.js +431 -0
  31. package/dist/gateway/gateway.d.ts +87 -0
  32. package/dist/gateway/gateway.js +158 -0
  33. package/dist/gateway/index.d.ts +15 -0
  34. package/dist/gateway/index.js +15 -0
  35. package/dist/gateway/log.d.ts +9 -0
  36. package/dist/gateway/log.js +20 -0
  37. package/dist/gateway/router.d.ts +10 -0
  38. package/dist/gateway/router.js +48 -0
  39. package/dist/gateway/runtimes/claude-code.d.ts +30 -0
  40. package/dist/gateway/runtimes/claude-code.js +162 -0
  41. package/dist/gateway/runtimes/codex.d.ts +83 -0
  42. package/dist/gateway/runtimes/codex.js +272 -0
  43. package/dist/gateway/runtimes/gemini.d.ts +15 -0
  44. package/dist/gateway/runtimes/gemini.js +29 -0
  45. package/dist/gateway/runtimes/ndjson-stream.d.ts +43 -0
  46. package/dist/gateway/runtimes/ndjson-stream.js +169 -0
  47. package/dist/gateway/runtimes/probe.d.ts +17 -0
  48. package/dist/gateway/runtimes/probe.js +54 -0
  49. package/dist/gateway/runtimes/registry.d.ts +59 -0
  50. package/dist/gateway/runtimes/registry.js +94 -0
  51. package/dist/gateway/session-store.d.ts +39 -0
  52. package/dist/gateway/session-store.js +133 -0
  53. package/dist/gateway/types.d.ts +265 -0
  54. package/dist/gateway/types.js +1 -0
  55. package/dist/index.d.ts +2 -0
  56. package/dist/index.js +854 -0
  57. package/dist/log.d.ts +7 -0
  58. package/dist/log.js +44 -0
  59. package/dist/provision.d.ts +88 -0
  60. package/dist/provision.js +749 -0
  61. package/dist/room-context-fetcher.d.ts +18 -0
  62. package/dist/room-context-fetcher.js +101 -0
  63. package/dist/room-context.d.ts +53 -0
  64. package/dist/room-context.js +112 -0
  65. package/dist/sender-classify.d.ts +30 -0
  66. package/dist/sender-classify.js +32 -0
  67. package/dist/snapshot-writer.d.ts +37 -0
  68. package/dist/snapshot-writer.js +84 -0
  69. package/dist/status-render.d.ts +28 -0
  70. package/dist/status-render.js +97 -0
  71. package/dist/system-context.d.ts +57 -0
  72. package/dist/system-context.js +91 -0
  73. package/dist/turn-text.d.ts +36 -0
  74. package/dist/turn-text.js +57 -0
  75. package/dist/user-auth.d.ts +75 -0
  76. package/dist/user-auth.js +245 -0
  77. package/dist/working-memory.d.ts +46 -0
  78. package/dist/working-memory.js +274 -0
  79. package/package.json +39 -0
  80. package/src/__tests__/activity-tracker.test.ts +130 -0
  81. package/src/__tests__/agent-discovery.test.ts +191 -0
  82. package/src/__tests__/agent-workspace.test.ts +147 -0
  83. package/src/__tests__/control-channel.test.ts +327 -0
  84. package/src/__tests__/cross-room.test.ts +116 -0
  85. package/src/__tests__/daemon-config-map.test.ts +416 -0
  86. package/src/__tests__/daemon.test.ts +300 -0
  87. package/src/__tests__/device-code.test.ts +152 -0
  88. package/src/__tests__/doctor.test.ts +218 -0
  89. package/src/__tests__/protocol-core-reexport.test.ts +24 -0
  90. package/src/__tests__/provision.test.ts +922 -0
  91. package/src/__tests__/room-context.test.ts +233 -0
  92. package/src/__tests__/runtime-discovery.test.ts +173 -0
  93. package/src/__tests__/snapshot-writer.test.ts +141 -0
  94. package/src/__tests__/status-render.test.ts +137 -0
  95. package/src/__tests__/system-context.test.ts +315 -0
  96. package/src/__tests__/turn-text.test.ts +116 -0
  97. package/src/__tests__/user-auth.test.ts +125 -0
  98. package/src/__tests__/working-memory.test.ts +240 -0
  99. package/src/activity-tracker.ts +140 -0
  100. package/src/adapters/runtimes.ts +30 -0
  101. package/src/agent-discovery.ts +262 -0
  102. package/src/agent-workspace.ts +247 -0
  103. package/src/config.ts +290 -0
  104. package/src/control-channel.ts +455 -0
  105. package/src/cross-room.ts +89 -0
  106. package/src/daemon-config-map.ts +200 -0
  107. package/src/daemon.ts +478 -0
  108. package/src/doctor.ts +282 -0
  109. package/src/gateway/__tests__/.gitkeep +0 -0
  110. package/src/gateway/__tests__/botcord-channel.test.ts +480 -0
  111. package/src/gateway/__tests__/channel-manager.test.ts +475 -0
  112. package/src/gateway/__tests__/claude-code-adapter.test.ts +318 -0
  113. package/src/gateway/__tests__/codex-adapter.test.ts +350 -0
  114. package/src/gateway/__tests__/dispatcher.test.ts +1159 -0
  115. package/src/gateway/__tests__/gateway-add-channel.test.ts +180 -0
  116. package/src/gateway/__tests__/gateway-managed-routes.test.ts +181 -0
  117. package/src/gateway/__tests__/gateway.test.ts +222 -0
  118. package/src/gateway/__tests__/router.test.ts +247 -0
  119. package/src/gateway/__tests__/sanitize.test.ts +193 -0
  120. package/src/gateway/__tests__/session-store.test.ts +235 -0
  121. package/src/gateway/channel-manager.ts +349 -0
  122. package/src/gateway/channels/botcord.ts +605 -0
  123. package/src/gateway/channels/index.ts +6 -0
  124. package/src/gateway/channels/sanitize.ts +68 -0
  125. package/src/gateway/dispatcher.ts +554 -0
  126. package/src/gateway/gateway.ts +211 -0
  127. package/src/gateway/index.ts +29 -0
  128. package/src/gateway/log.ts +30 -0
  129. package/src/gateway/router.ts +60 -0
  130. package/src/gateway/runtimes/claude-code.ts +180 -0
  131. package/src/gateway/runtimes/codex.ts +312 -0
  132. package/src/gateway/runtimes/gemini.ts +43 -0
  133. package/src/gateway/runtimes/ndjson-stream.ts +225 -0
  134. package/src/gateway/runtimes/probe.ts +73 -0
  135. package/src/gateway/runtimes/registry.ts +143 -0
  136. package/src/gateway/session-store.ts +157 -0
  137. package/src/gateway/types.ts +325 -0
  138. package/src/index.ts +961 -0
  139. package/src/log.ts +47 -0
  140. package/src/provision.ts +879 -0
  141. package/src/room-context-fetcher.ts +124 -0
  142. package/src/room-context.ts +167 -0
  143. package/src/sender-classify.ts +46 -0
  144. package/src/snapshot-writer.ts +103 -0
  145. package/src/status-render.ts +132 -0
  146. package/src/system-context.ts +162 -0
  147. package/src/turn-text.ts +93 -0
  148. package/src/user-auth.ts +295 -0
  149. package/src/working-memory.ts +352 -0
@@ -0,0 +1,352 @@
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 {
11
+ copyFileSync,
12
+ existsSync,
13
+ mkdirSync,
14
+ readFileSync,
15
+ renameSync,
16
+ unlinkSync,
17
+ writeFileSync,
18
+ } from "node:fs";
19
+ import path from "node:path";
20
+ import { agentStateDir } from "./agent-workspace.js";
21
+ import { DAEMON_DIR_PATH } from "./config.js";
22
+ import { log as daemonLog } from "./log.js";
23
+
24
+ export interface WorkingMemory {
25
+ version: 2;
26
+ goal?: string;
27
+ sections: Record<string, string>;
28
+ updatedAt: string;
29
+ }
30
+
31
+ /** v1 shape kept only for one-way migration on read. */
32
+ interface WorkingMemoryV1 {
33
+ version: 1;
34
+ content: string;
35
+ updatedAt: string;
36
+ }
37
+
38
+ const VALID_SECTION_KEY_RE = /^[a-zA-Z0-9_]+$/;
39
+
40
+ /** Characters per section; matches the plugin-side limit. */
41
+ export const MAX_SECTION_CHARS = 10_000;
42
+ export const MAX_GOAL_CHARS = 500;
43
+ export const MAX_TOTAL_CHARS = 20_000;
44
+ export const DEFAULT_SECTION = "notes";
45
+
46
+ const MEMORY_SIZE_WARN_CHARS = 2_000;
47
+ /** Tags that must not appear verbatim in injected memory content. */
48
+ const RESERVED_TAGS_RE = /<\/?(?:current_memory|section_\w+)\b[^>]*>/gi;
49
+
50
+ // ── Path resolution ────────────────────────────────────────────────
51
+
52
+ /**
53
+ * Canonical per-agent state directory. Returns the new location
54
+ * (`~/.botcord/agents/{agentId}/state`). The legacy location under
55
+ * `~/.botcord/daemon/memory/{agentId}` is migrated lazily on first read —
56
+ * see §8 of the daemon-agent-workspace plan.
57
+ */
58
+ export function resolveMemoryDir(agentId: string): string {
59
+ if (!agentId) throw new Error("resolveMemoryDir: agentId is required");
60
+ return agentStateDir(agentId);
61
+ }
62
+
63
+ /** Legacy location retained for one-shot migration on read. */
64
+ function legacyMemoryDir(agentId: string): string {
65
+ return path.join(DAEMON_DIR_PATH, "memory", agentId);
66
+ }
67
+
68
+ function workingMemoryPath(agentId: string): string {
69
+ return path.join(resolveMemoryDir(agentId), "working-memory.json");
70
+ }
71
+
72
+ function legacyWorkingMemoryPath(agentId: string): string {
73
+ return path.join(legacyMemoryDir(agentId), "working-memory.json");
74
+ }
75
+
76
+ // Migration conflict warnings are emitted at most once per agent per
77
+ // process. Reset only by daemon restart — good enough for a one-release
78
+ // transitional branch that gets removed later.
79
+ const warnedMigrationConflict = new Set<string>();
80
+
81
+ /**
82
+ * Resolve the path to read from, migrating from the legacy location if
83
+ * necessary. Returns the path the caller should read, or `null` when no
84
+ * memory file exists anywhere.
85
+ *
86
+ * Migration branch (the `else if` on `legacyExists` below) is meant to be
87
+ * deleted one release after this change ships; see plan §8 step 6.
88
+ */
89
+ function resolveReadPath(agentId: string): string | null {
90
+ const newPath = workingMemoryPath(agentId);
91
+ const oldPath = legacyWorkingMemoryPath(agentId);
92
+ const newExists = existsSync(newPath);
93
+ const oldExists = existsSync(oldPath);
94
+
95
+ if (newExists) {
96
+ if (oldExists && !warnedMigrationConflict.has(agentId)) {
97
+ warnedMigrationConflict.add(agentId);
98
+ daemonLog.warn("working-memory: both new and legacy paths exist; using new", {
99
+ agentId,
100
+ oldPath,
101
+ newPath,
102
+ });
103
+ }
104
+ return newPath;
105
+ }
106
+ if (oldExists) {
107
+ try {
108
+ mkdirSync(path.dirname(newPath), { recursive: true, mode: 0o700 });
109
+ try {
110
+ renameSync(oldPath, newPath);
111
+ } catch (err) {
112
+ // EXDEV = legacy and new paths live on different filesystems
113
+ // (bind mounts, tmpfs overlays). `renameSync` cannot cross fs
114
+ // boundaries, so fall back to copy + unlink. Without this, the
115
+ // next write would go to newPath while legacy still has the old
116
+ // payload — silent divergence the reviewer of §8 flagged.
117
+ if ((err as NodeJS.ErrnoException).code === "EXDEV") {
118
+ copyFileSync(oldPath, newPath);
119
+ unlinkSync(oldPath);
120
+ } else {
121
+ throw err;
122
+ }
123
+ }
124
+ return newPath;
125
+ } catch (err) {
126
+ const e = err as NodeJS.ErrnoException;
127
+ daemonLog.warn("working-memory: migration rename failed; reading legacy path", {
128
+ agentId,
129
+ oldPath,
130
+ newPath,
131
+ code: e.code,
132
+ error: e.message ?? String(err),
133
+ });
134
+ return oldPath;
135
+ }
136
+ }
137
+ return null;
138
+ }
139
+
140
+ // ── File I/O ───────────────────────────────────────────────────────
141
+
142
+ function readJson<T>(filePath: string): T | null {
143
+ try {
144
+ return JSON.parse(readFileSync(filePath, "utf-8")) as T;
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ /** Atomic write: tmp file + rename so a crash never leaves a half-file. */
151
+ function writeJsonAtomic(filePath: string, data: unknown): void {
152
+ mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
153
+ const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
154
+ writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
155
+ renameSync(tmp, filePath);
156
+ }
157
+
158
+ function sanitizeSections(raw: unknown): Record<string, string> {
159
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
160
+ const out: Record<string, string> = {};
161
+ for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
162
+ if (VALID_SECTION_KEY_RE.test(k) && typeof v === "string") out[k] = v;
163
+ }
164
+ return out;
165
+ }
166
+
167
+ function normalize(raw: unknown): WorkingMemory | null {
168
+ if (!raw || typeof raw !== "object") return null;
169
+ const r = raw as Record<string, unknown>;
170
+ if (r.version === 2 && r.sections && typeof r.sections === "object") {
171
+ return {
172
+ version: 2,
173
+ goal: typeof r.goal === "string" ? r.goal : undefined,
174
+ sections: sanitizeSections(r.sections),
175
+ updatedAt: typeof r.updatedAt === "string" ? r.updatedAt : "",
176
+ };
177
+ }
178
+ if (r.version === 1 && typeof r.content === "string") {
179
+ const v1 = r as unknown as WorkingMemoryV1;
180
+ return {
181
+ version: 2,
182
+ sections: v1.content ? { [DEFAULT_SECTION]: v1.content } : {},
183
+ updatedAt: typeof v1.updatedAt === "string" ? v1.updatedAt : "",
184
+ };
185
+ }
186
+ return null;
187
+ }
188
+
189
+ export function readWorkingMemory(agentId: string): WorkingMemory | null {
190
+ const p = resolveReadPath(agentId);
191
+ if (!p) return null;
192
+ return normalize(readJson<unknown>(p));
193
+ }
194
+
195
+ export function writeWorkingMemory(agentId: string, data: WorkingMemory): void {
196
+ writeJsonAtomic(workingMemoryPath(agentId), data);
197
+ }
198
+
199
+ // ── Mutations used by the memory CLI ───────────────────────────────
200
+
201
+ export interface SetSectionResult {
202
+ memory: WorkingMemory;
203
+ totalChars: number;
204
+ /** Whether the targeted section ended up present after the write. */
205
+ sectionPresent: boolean;
206
+ }
207
+
208
+ /**
209
+ * Upsert a section (or goal). Passing empty `content` with a `section`
210
+ * deletes that section. `goal === ""` clears the goal.
211
+ */
212
+ export function updateWorkingMemory(
213
+ agentId: string,
214
+ update: { goal?: string; section?: string; content?: string },
215
+ ): SetSectionResult {
216
+ if (update.goal === undefined && update.content === undefined) {
217
+ throw new Error("updateWorkingMemory: must provide 'goal' or 'content'");
218
+ }
219
+ if (update.goal !== undefined && update.goal.length > MAX_GOAL_CHARS) {
220
+ throw new Error(`goal exceeds ${MAX_GOAL_CHARS} characters`);
221
+ }
222
+ const sectionName = (update.section ?? DEFAULT_SECTION).trim();
223
+ if (update.content !== undefined && !VALID_SECTION_KEY_RE.test(sectionName)) {
224
+ throw new Error(
225
+ "section name must contain only letters, digits, and underscores",
226
+ );
227
+ }
228
+ if (
229
+ update.content !== undefined &&
230
+ update.content.length > MAX_SECTION_CHARS
231
+ ) {
232
+ throw new Error(
233
+ `content exceeds ${MAX_SECTION_CHARS} characters for section '${sectionName}'`,
234
+ );
235
+ }
236
+
237
+ const existing: WorkingMemory = readWorkingMemory(agentId) ?? {
238
+ version: 2,
239
+ sections: {},
240
+ updatedAt: "",
241
+ };
242
+
243
+ if (update.goal !== undefined) {
244
+ existing.goal = update.goal === "" ? undefined : update.goal;
245
+ }
246
+ if (update.content !== undefined) {
247
+ if (update.content === "") delete existing.sections[sectionName];
248
+ else existing.sections[sectionName] = update.content;
249
+ }
250
+
251
+ const totalChars =
252
+ (existing.goal?.length ?? 0) +
253
+ Object.values(existing.sections).reduce((s, v) => s + v.length, 0);
254
+ if (totalChars > MAX_TOTAL_CHARS) {
255
+ throw new Error(
256
+ `total working memory exceeds ${MAX_TOTAL_CHARS} characters (current: ${totalChars})`,
257
+ );
258
+ }
259
+
260
+ existing.updatedAt = new Date().toISOString();
261
+ writeWorkingMemory(agentId, existing);
262
+
263
+ return {
264
+ memory: existing,
265
+ totalChars,
266
+ sectionPresent:
267
+ update.content !== undefined ? update.content !== "" : sectionName in existing.sections,
268
+ };
269
+ }
270
+
271
+ /** Wipe the agent's working memory entirely (goal + all sections). */
272
+ export function clearWorkingMemory(agentId: string): void {
273
+ const empty: WorkingMemory = {
274
+ version: 2,
275
+ sections: {},
276
+ updatedAt: new Date().toISOString(),
277
+ };
278
+ writeWorkingMemory(agentId, empty);
279
+ }
280
+
281
+ // ── Prompt builder ─────────────────────────────────────────────────
282
+
283
+ function sanitizeMemoryContent(content: string): string {
284
+ return content.replace(RESERVED_TAGS_RE, (tag) =>
285
+ tag.replace(/</g, "‹").replace(/>/g, "›"),
286
+ );
287
+ }
288
+
289
+ /**
290
+ * Render a system-prompt block describing the agent's working memory. The
291
+ * format intentionally mirrors the plugin's so a CLI-side agent sees the
292
+ * same shape as an OpenClaw-hosted one.
293
+ */
294
+ export function buildWorkingMemoryPrompt(opts: {
295
+ workingMemory: WorkingMemory | null;
296
+ warnLarge?: boolean;
297
+ }): string {
298
+ const { workingMemory, warnLarge = true } = opts;
299
+
300
+ const lines: string[] = [
301
+ "[BotCord Working Memory]",
302
+ "You have a persistent working memory that survives across turns and rooms.",
303
+ "Use it to track your goal, important facts, pending commitments, and context worth remembering.",
304
+ "",
305
+ "Update via the daemon's `memory` CLI (or whatever tool the operator wires):",
306
+ "- goal: a short pinned statement of what you're working on.",
307
+ "- sections: named buckets (contacts, pending_tasks, preferences, etc.).",
308
+ "- Updating one section never touches others. Empty content deletes a section.",
309
+ "",
310
+ "Only update when something meaningful changes. Keep each section tight.",
311
+ ];
312
+
313
+ if (!workingMemory) {
314
+ lines.push("", "Your working memory is currently empty.");
315
+ return lines.join("\n");
316
+ }
317
+
318
+ const entries = Object.entries(workingMemory.sections ?? {});
319
+ const hasGoal = !!workingMemory.goal;
320
+ const hasSections = entries.length > 0;
321
+
322
+ if (!hasGoal && !hasSections) {
323
+ lines.push("", "Your working memory is currently empty.");
324
+ return lines.join("\n");
325
+ }
326
+
327
+ lines.push("", `Current working memory (last updated: ${workingMemory.updatedAt}):`);
328
+
329
+ let totalChars = 0;
330
+ if (hasGoal) {
331
+ const goal = sanitizeMemoryContent(
332
+ workingMemory.goal!.replace(/[\r\n]+/g, " ").trim(),
333
+ );
334
+ lines.push("", `Goal: ${goal}`);
335
+ totalChars += goal.length;
336
+ }
337
+ for (const [name, content] of entries) {
338
+ if (!content) continue;
339
+ const body = sanitizeMemoryContent(content);
340
+ lines.push("", `<section_${name}>`, body, `</section_${name}>`);
341
+ totalChars += body.length;
342
+ }
343
+
344
+ if (warnLarge && totalChars > MEMORY_SIZE_WARN_CHARS) {
345
+ lines.push(
346
+ "",
347
+ `⚠ Your working memory is ${totalChars} characters. Consider condensing sections to keep token usage low.`,
348
+ );
349
+ }
350
+
351
+ return lines.join("\n");
352
+ }