@botcord/daemon 0.2.5 → 0.2.6

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 (84) hide show
  1. package/dist/agent-discovery.d.ts +4 -0
  2. package/dist/agent-discovery.js +8 -0
  3. package/dist/agent-workspace.d.ts +62 -0
  4. package/dist/agent-workspace.js +140 -8
  5. package/dist/config.d.ts +49 -1
  6. package/dist/config.js +57 -1
  7. package/dist/daemon-config-map.d.ts +27 -9
  8. package/dist/daemon-config-map.js +105 -8
  9. package/dist/daemon.d.ts +2 -0
  10. package/dist/daemon.js +52 -5
  11. package/dist/doctor.d.ts +27 -1
  12. package/dist/doctor.js +22 -1
  13. package/dist/gateway/cli-resolver.d.ts +34 -0
  14. package/dist/gateway/cli-resolver.js +74 -0
  15. package/dist/gateway/dispatcher.d.ts +31 -1
  16. package/dist/gateway/dispatcher.js +337 -29
  17. package/dist/gateway/gateway.d.ts +29 -1
  18. package/dist/gateway/gateway.js +10 -0
  19. package/dist/gateway/index.d.ts +2 -0
  20. package/dist/gateway/index.js +2 -0
  21. package/dist/gateway/policy-resolver.d.ts +57 -0
  22. package/dist/gateway/policy-resolver.js +123 -0
  23. package/dist/gateway/runtimes/acp-stream.d.ts +99 -0
  24. package/dist/gateway/runtimes/acp-stream.js +394 -0
  25. package/dist/gateway/runtimes/codex.js +7 -0
  26. package/dist/gateway/runtimes/hermes-agent.d.ts +83 -0
  27. package/dist/gateway/runtimes/hermes-agent.js +180 -0
  28. package/dist/gateway/runtimes/ndjson-stream.d.ts +7 -2
  29. package/dist/gateway/runtimes/ndjson-stream.js +16 -3
  30. package/dist/gateway/runtimes/openclaw-acp.d.ts +44 -0
  31. package/dist/gateway/runtimes/openclaw-acp.js +500 -0
  32. package/dist/gateway/runtimes/registry.d.ts +4 -0
  33. package/dist/gateway/runtimes/registry.js +22 -0
  34. package/dist/gateway/transcript-paths.d.ts +30 -0
  35. package/dist/gateway/transcript-paths.js +114 -0
  36. package/dist/gateway/transcript.d.ts +123 -0
  37. package/dist/gateway/transcript.js +147 -0
  38. package/dist/gateway/types.d.ts +31 -0
  39. package/dist/index.js +286 -27
  40. package/dist/mention-scan.d.ts +22 -0
  41. package/dist/mention-scan.js +35 -0
  42. package/dist/provision.d.ts +72 -1
  43. package/dist/provision.js +370 -7
  44. package/dist/system-context.d.ts +5 -4
  45. package/dist/system-context.js +35 -5
  46. package/dist/url-utils.d.ts +9 -0
  47. package/dist/url-utils.js +18 -0
  48. package/package.json +2 -1
  49. package/src/__tests__/agent-workspace.test.ts +93 -0
  50. package/src/__tests__/daemon-config-map.test.ts +79 -0
  51. package/src/__tests__/openclaw-acp.test.ts +234 -0
  52. package/src/__tests__/policy-resolver.test.ts +124 -0
  53. package/src/__tests__/policy-updated-handler.test.ts +144 -0
  54. package/src/__tests__/provision.test.ts +160 -0
  55. package/src/__tests__/system-context.test.ts +52 -0
  56. package/src/__tests__/url-utils.test.ts +37 -0
  57. package/src/agent-discovery.ts +8 -0
  58. package/src/agent-workspace.ts +173 -7
  59. package/src/config.ts +132 -4
  60. package/src/daemon-config-map.ts +154 -9
  61. package/src/daemon.ts +66 -5
  62. package/src/doctor.ts +49 -2
  63. package/src/gateway/__tests__/dispatcher.test.ts +65 -0
  64. package/src/gateway/__tests__/hermes-agent-adapter.test.ts +302 -0
  65. package/src/gateway/__tests__/transcript.test.ts +496 -0
  66. package/src/gateway/cli-resolver.ts +92 -0
  67. package/src/gateway/dispatcher.ts +394 -26
  68. package/src/gateway/gateway.ts +46 -0
  69. package/src/gateway/index.ts +25 -0
  70. package/src/gateway/policy-resolver.ts +171 -0
  71. package/src/gateway/runtimes/acp-stream.ts +535 -0
  72. package/src/gateway/runtimes/codex.ts +7 -0
  73. package/src/gateway/runtimes/hermes-agent.ts +206 -0
  74. package/src/gateway/runtimes/ndjson-stream.ts +16 -3
  75. package/src/gateway/runtimes/openclaw-acp.ts +606 -0
  76. package/src/gateway/runtimes/registry.ts +24 -0
  77. package/src/gateway/transcript-paths.ts +145 -0
  78. package/src/gateway/transcript.ts +300 -0
  79. package/src/gateway/types.ts +32 -0
  80. package/src/index.ts +295 -30
  81. package/src/mention-scan.ts +38 -0
  82. package/src/provision.ts +438 -9
  83. package/src/system-context.ts +41 -9
  84. package/src/url-utils.ts +17 -0
@@ -3,10 +3,16 @@ import { Dispatcher, type RuntimeFactory } from "./dispatcher.js";
3
3
  import { consoleLogger, type GatewayLogger } from "./log.js";
4
4
  import { createRuntime } from "./runtimes/registry.js";
5
5
  import { DEFAULT_SESSION_STORE_MAX_ENTRY_AGE_MS, SessionStore } from "./session-store.js";
6
+ import {
7
+ createTranscriptWriter,
8
+ resolveTranscriptEnabled,
9
+ type TranscriptWriter,
10
+ } from "./transcript.js";
6
11
  import type {
7
12
  ChannelAdapter,
8
13
  GatewayChannelConfig,
9
14
  GatewayConfig,
15
+ GatewayInboundMessage,
10
16
  GatewayRoute,
11
17
  GatewayRuntimeSnapshot,
12
18
  InboundObserver,
@@ -48,6 +54,35 @@ export interface GatewayBootOptions {
48
54
  * bookkeeping like loop-risk tracking.
49
55
  */
50
56
  onOutbound?: OutboundObserver;
57
+ /**
58
+ * Optional attention gate (PR3, design §4.2). Forwarded to the dispatcher
59
+ * verbatim — see {@link Dispatcher} for semantics. Returning `false` skips
60
+ * the runtime turn while preserving ack + onInbound side effects.
61
+ */
62
+ attentionGate?: (
63
+ message: GatewayInboundMessage,
64
+ ) => Promise<boolean> | boolean;
65
+ /**
66
+ * Resolve the per-agent hub URL for an inbound message. Forwarded to the
67
+ * dispatcher as `RuntimeRunOptions.hubUrl` so spawned CLI subprocesses
68
+ * (`BOTCORD_HUB`) target the correct hub for the owning agent.
69
+ */
70
+ resolveHubUrl?: (accountId: string) => string | undefined;
71
+ /**
72
+ * Persistent NDJSON transcript writer (design §3 / §6). Optional — when
73
+ * omitted the dispatcher uses a noop writer. Pass `transcriptEnabled` plus
74
+ * `transcriptRootDir` to let the gateway construct one for you.
75
+ */
76
+ transcript?: TranscriptWriter;
77
+ /**
78
+ * Tri-state convenience: if `transcript` is not provided, the gateway
79
+ * constructs a writer using this flag plus `transcriptRootDir`. Use
80
+ * {@link resolveTranscriptEnabled} to combine `BOTCORD_TRANSCRIPT` env with
81
+ * the persistent daemon-config flag.
82
+ */
83
+ transcriptEnabled?: boolean;
84
+ /** Root directory for transcript files. Defaults to `~/.botcord/agents`. */
85
+ transcriptRootDir?: string;
51
86
  }
52
87
 
53
88
  /** Default runtime factory: delegates to the built-in registry; ignores extraArgs at construction. */
@@ -106,6 +141,14 @@ export class Gateway {
106
141
 
107
142
  const runtimeFactory = opts.createRuntime ?? defaultRuntimeFactory;
108
143
 
144
+ const transcript =
145
+ opts.transcript
146
+ ?? createTranscriptWriter({
147
+ log: this.log,
148
+ enabled: opts.transcriptEnabled === true,
149
+ rootDir: opts.transcriptRootDir,
150
+ });
151
+
109
152
  this.dispatcher = new Dispatcher({
110
153
  config: this.config,
111
154
  channels: this.channelMap,
@@ -118,6 +161,9 @@ export class Gateway {
118
161
  composeUserTurn: opts.composeUserTurn,
119
162
  onOutbound: opts.onOutbound,
120
163
  managedRoutes: this.managedRoutes,
164
+ attentionGate: opts.attentionGate,
165
+ resolveHubUrl: opts.resolveHubUrl,
166
+ transcript,
121
167
  });
122
168
 
123
169
  this.channelManager = new ChannelManager({
@@ -8,6 +8,31 @@ export { resolveRoute, matchesRoute } from "./router.js";
8
8
  export { ChannelManager, type ChannelManagerOptions, type ChannelBackoffOptions } from "./channel-manager.js";
9
9
  export { Dispatcher, type DispatcherOptions, type RuntimeFactory } from "./dispatcher.js";
10
10
  export { Gateway, type GatewayBootOptions } from "./gateway.js";
11
+ export {
12
+ createTranscriptWriter,
13
+ resolveTranscriptEnabled,
14
+ defaultTranscriptRoot,
15
+ truncateTextField,
16
+ TRANSCRIPT_TEXT_LIMIT,
17
+ TRANSCRIPT_FILE_LIMIT,
18
+ type TranscriptWriter,
19
+ type TranscriptRecord,
20
+ type InboundTranscriptRecord,
21
+ type DispatchedTranscriptRecord,
22
+ type ComposeFailedTranscriptRecord,
23
+ type OutboundTranscriptRecord,
24
+ type TurnErrorTranscriptRecord,
25
+ type AttentionSkippedTranscriptRecord,
26
+ type DroppedTranscriptRecord,
27
+ type DeliveryStatus,
28
+ type DroppedReason,
29
+ } from "./transcript.js";
30
+ export {
31
+ safePathSegment,
32
+ transcriptFilePath,
33
+ transcriptRoomDir,
34
+ transcriptAgentRoot,
35
+ } from "./transcript-paths.js";
11
36
  export {
12
37
  ClaudeCodeAdapter,
13
38
  probeClaude,
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Daemon-side per-agent attention-policy cache (PR3, design §5).
3
+ *
4
+ * The dispatcher consults this resolver after `onInbound` fires and before
5
+ * the runtime turn enqueues. Cache layout:
6
+ *
7
+ * - `agent_id` → global default policy (seeded by
8
+ * `provision_agent` + `policy_updated{agent}`).
9
+ * - `agent_id:room_id` → genuine per-room override only — installed
10
+ * exclusively via `put` from a per-room
11
+ * `policy_updated` frame. Inheritance reads
12
+ * never write here.
13
+ *
14
+ * `resolve(agent, room)` checks the room key first, then falls back to the
15
+ * global key. This means a per-room override always wins, and the global
16
+ * propagates to every room without explicit fan-out (a global update only
17
+ * needs to refresh the agent_id entry).
18
+ *
19
+ * `invalidate(agent_id, room_id)` drops the matching room entry; the next
20
+ * resolve falls through to the global. `invalidate(agent_id)` drops every
21
+ * entry for that agent — both global and any room overrides — used when
22
+ * the agent is revoked or the cache must rebuild from scratch.
23
+ */
24
+
25
+ import type { AttentionPolicy } from "@botcord/protocol-core";
26
+
27
+ /** Public surface — kept narrow so the dispatcher can mock easily in tests. */
28
+ export interface PolicyResolverLike {
29
+ resolve(agentId: string, roomId: string | null): Promise<AttentionPolicy>;
30
+ invalidate(agentId: string, roomId?: string): void;
31
+ /**
32
+ * Install (or replace) the cached policy entry for an agent / room. Used
33
+ * by the `policy_updated` control-frame handler to apply embedded policy
34
+ * payloads without forcing a refetch.
35
+ */
36
+ put(agentId: string, roomId: string | null, policy: AttentionPolicy): void;
37
+ }
38
+
39
+ export interface PolicyResolverOptions {
40
+ /** Fetcher for the per-agent default. Returning `undefined` means "no policy known"; the resolver falls back to `mode=always`. */
41
+ fetchGlobal: (agentId: string) => Promise<AttentionPolicy | undefined>;
42
+ /**
43
+ * Optional per-room fetcher. PR2 supplies this; PR3 leaves it
44
+ * unimplemented and the resolver collapses to the global policy.
45
+ */
46
+ fetchEffective?: (
47
+ agentId: string,
48
+ roomId: string,
49
+ ) => Promise<AttentionPolicy | undefined>;
50
+ /** Cache TTL in milliseconds. Defaults to 5 minutes. */
51
+ ttlMs?: number;
52
+ }
53
+
54
+ interface Entry {
55
+ policy: AttentionPolicy;
56
+ expiresAt: number;
57
+ }
58
+
59
+ const DEFAULT_TTL_MS = 5 * 60 * 1000;
60
+ const FETCH_FAILED = Symbol("fetch_failed");
61
+
62
+ /**
63
+ * Force DM rooms (`rm_dm_*`) to `mode: "always"` per design §4.2 — UI never
64
+ * lets the user mute a DM, but a stale cache from before a UX bug is cheap
65
+ * to defend against here.
66
+ */
67
+ function maybeForceDm(roomId: string | null, policy: AttentionPolicy): AttentionPolicy {
68
+ if (roomId && roomId.startsWith("rm_dm_") && policy.mode !== "always") {
69
+ return { ...policy, mode: "always" };
70
+ }
71
+ return policy;
72
+ }
73
+
74
+ function defaultPolicy(): AttentionPolicy {
75
+ return { mode: "always", keywords: [] };
76
+ }
77
+
78
+ export class PolicyResolver implements PolicyResolverLike {
79
+ private readonly fetchGlobal: PolicyResolverOptions["fetchGlobal"];
80
+ private readonly fetchEffective?: PolicyResolverOptions["fetchEffective"];
81
+ private readonly ttlMs: number;
82
+ private readonly cache: Map<string, Entry> = new Map();
83
+
84
+ constructor(opts: PolicyResolverOptions) {
85
+ this.fetchGlobal = opts.fetchGlobal;
86
+ this.fetchEffective = opts.fetchEffective;
87
+ this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
88
+ }
89
+
90
+ async resolve(agentId: string, roomId: string | null): Promise<AttentionPolicy> {
91
+ const now = Date.now();
92
+
93
+ // 1. Per-room cache — populated either by a `policy_updated{room_id}`
94
+ // push (genuine override) or by a prior `fetchEffective` cold-start.
95
+ if (roomId) {
96
+ const roomHit = this.cache.get(cacheKey(agentId, roomId));
97
+ if (roomHit && roomHit.expiresAt > now) return roomHit.policy;
98
+ }
99
+
100
+ // 2. If a per-room fetcher is wired, treat it as authoritative for cold
101
+ // rooms — it returns the override-merged effective policy and so must
102
+ // not be skipped just because the global cache is warm.
103
+ if (roomId && this.fetchEffective) {
104
+ const fetched = await this.safeFetch(() =>
105
+ this.fetchEffective!(agentId, roomId),
106
+ );
107
+ if (fetched === FETCH_FAILED) return defaultPolicy();
108
+ const policy = fetched ?? defaultPolicy();
109
+ this.cache.set(cacheKey(agentId, roomId), {
110
+ policy: maybeForceDm(roomId, policy),
111
+ expiresAt: now + this.ttlMs,
112
+ });
113
+ return maybeForceDm(roomId, policy);
114
+ }
115
+
116
+ // 3. No room override known — inherit from the cached agent-wide global.
117
+ // Without this layer, group messages collapsed to mode=always whenever
118
+ // the daemon ran without a per-room fetcher (the current production
119
+ // state), silently breaking global mention_only/muted.
120
+ const globalKey = cacheKey(agentId, null);
121
+ const globalHit = this.cache.get(globalKey);
122
+ if (globalHit && globalHit.expiresAt > now) {
123
+ return maybeForceDm(roomId, globalHit.policy);
124
+ }
125
+
126
+ // 4. Cold start for global.
127
+ const fetched = await this.safeFetch(() => this.fetchGlobal(agentId));
128
+ if (fetched === FETCH_FAILED) return defaultPolicy();
129
+ const policy = fetched ?? defaultPolicy();
130
+ this.cache.set(globalKey, { policy, expiresAt: now + this.ttlMs });
131
+ return maybeForceDm(roomId, policy);
132
+ }
133
+
134
+ private async safeFetch(
135
+ fn: () => Promise<AttentionPolicy | undefined>,
136
+ ): Promise<AttentionPolicy | undefined | typeof FETCH_FAILED> {
137
+ try {
138
+ return await fn();
139
+ } catch {
140
+ // Fail-open: a fetch error must not silence the agent. The caller
141
+ // returns the default policy without caching so the next resolve retries.
142
+ return FETCH_FAILED;
143
+ }
144
+ }
145
+
146
+ invalidate(agentId: string, roomId?: string): void {
147
+ if (roomId !== undefined) {
148
+ this.cache.delete(cacheKey(agentId, roomId));
149
+ return;
150
+ }
151
+ // Drop every entry for this agent.
152
+ const prefix = agentId + ":";
153
+ for (const key of Array.from(this.cache.keys())) {
154
+ if (key === agentId || key.startsWith(prefix)) {
155
+ this.cache.delete(key);
156
+ }
157
+ }
158
+ }
159
+
160
+ put(agentId: string, roomId: string | null, policy: AttentionPolicy): void {
161
+ const key = cacheKey(agentId, roomId);
162
+ this.cache.set(key, {
163
+ policy: maybeForceDm(roomId, policy),
164
+ expiresAt: Date.now() + this.ttlMs,
165
+ });
166
+ }
167
+ }
168
+
169
+ function cacheKey(agentId: string, roomId: string | null): string {
170
+ return roomId ? `${agentId}:${roomId}` : agentId;
171
+ }