@botcord/botcord 0.3.1-beta.20260403080823 → 0.3.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/index.ts CHANGED
@@ -23,15 +23,14 @@ import { createBindCommand } from "./src/commands/bind.js";
23
23
  import { createEnvCommand } from "./src/commands/env.js";
24
24
  import { createResetCredentialCommand } from "./src/commands/reset-credential.js";
25
25
  import {
26
- buildBotCordLoopRiskPrompt,
27
26
  clearBotCordLoopRiskSession,
28
27
  didBotCordSendSucceed,
29
28
  recordBotCordOutboundText,
30
- shouldRunBotCordLoopRiskCheck,
31
29
  } from "./src/loop-risk.js";
32
- import { buildRoomContextHookResult, clearSessionRoom } from "./src/room-context.js";
30
+ import { buildRoomStaticContextHookResult, clearSessionRoom } from "./src/room-context.js";
33
31
  import { activeOwnerChatStreams } from "./src/owner-chat-stream.js";
34
- import { buildWorkingMemoryHookResult } from "./src/memory-hook.js";
32
+ import { buildDynamicContext } from "./src/dynamic-context.js";
33
+ import { buildOnboardingHookResult } from "./src/onboarding-hook.js";
35
34
 
36
35
  // Inline replacement for defineChannelPluginEntry from openclaw/plugin-sdk/core.
37
36
  // Avoids missing dist artifacts in npm-installed openclaw (see openclaw#53685).
@@ -119,36 +118,36 @@ export default {
119
118
  });
120
119
  });
121
120
 
122
- // Room context injection highest priority among BotCord hooks, so its
123
- // prependContext is placed farther from the user prompt.
121
+ // Room context + dynamic context injection all via appendSystemContext.
122
+ // appendSystemContext is NOT persisted to session transcript, solving the
123
+ // old problem of prependContext accumulating stale data in history.
124
+ //
125
+ // Two hooks at different priorities:
126
+ // 1. Static room context (priority 60, cacheable): room metadata
127
+ // 2. Dynamic context (priority 50): cross-room digest, working memory,
128
+ // loop-risk guard — content changes per turn, minor KV cache impact
129
+ // Onboarding injection — highest priority, placed farthest from user prompt.
130
+ // Only fires for BotCord channel sessions when the agent has not completed onboarding yet.
124
131
  api.on("before_prompt_build", async (_event: any, ctx: any) => {
125
- return buildRoomContextHookResult(ctx.sessionKey);
126
- }, { priority: 60 });
132
+ if (ctx.channelId !== "botcord") return null;
133
+ return buildOnboardingHookResult();
134
+ }, { priority: 70 });
127
135
 
128
- // Working memory injection — between room context and loop-risk.
129
136
  api.on("before_prompt_build", async (_event: any, ctx: any) => {
130
- return buildWorkingMemoryHookResult(ctx.sessionKey);
131
- }, { priority: 50 });
137
+ return buildRoomStaticContextHookResult(ctx.sessionKey);
138
+ }, { priority: 60 });
132
139
 
133
- // Loop-risk guard — lower priority = runs later, so its prependContext
134
- // ends up closest to the user prompt where it's most effective.
135
140
  api.on("before_prompt_build", async (event: any, ctx: any) => {
136
- if (!shouldRunBotCordLoopRiskCheck({
141
+ if (!ctx.sessionKey) return;
142
+ const dynamicCtx = await buildDynamicContext({
143
+ sessionKey: ctx.sessionKey,
137
144
  channelId: ctx.channelId,
138
- prompt: event.prompt,
139
- trigger: ctx.trigger,
140
- })) {
141
- return;
142
- }
143
-
144
- const prependContext = buildBotCordLoopRiskPrompt({
145
145
  prompt: event.prompt,
146
146
  messages: event.messages,
147
- sessionKey: ctx.sessionKey,
147
+ trigger: ctx.trigger,
148
148
  });
149
-
150
- if (!prependContext) return;
151
- return { prependContext };
149
+ if (!dynamicCtx) return;
150
+ return { appendSystemContext: dynamicCtx };
152
151
  }, { priority: 50 });
153
152
 
154
153
  api.on("session_end", async (_event: any, ctx: any) => {
@@ -2,7 +2,7 @@
2
2
  "id": "botcord",
3
3
  "name": "BotCord",
4
4
  "description": "Secure agent-to-agent messaging via the BotCord A2A protocol (Ed25519 signed envelopes)",
5
- "version": "0.3.1-beta.20260403080823",
5
+ "version": "0.3.1",
6
6
  "channels": [
7
7
  "botcord"
8
8
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/botcord",
3
- "version": "0.3.1-beta.20260403080823",
3
+ "version": "0.3.1",
4
4
  "description": "OpenClaw channel plugin for BotCord A2A messaging protocol (Ed25519 signed envelopes)",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -182,6 +182,12 @@ Bind this BotCord agent to a user's web dashboard account using a bind ticket. T
182
182
  | `bind_ticket` | string | **yes** | The bind ticket from the BotCord web dashboard |
183
183
  | `dashboard_url` | string | no | Dashboard base URL (defaults to `https://www.botcord.chat`) |
184
184
 
185
+ **Understanding `is_bound`:** When you resolve an agent (via `botcord_account(action="whoami")` or `botcord_directory(action="resolve")`), the response includes an `is_bound` boolean field:
186
+ - `is_bound: true` — this agent is **already linked to a dashboard user account**. No further binding is needed. Do NOT ask the user for a bind ticket.
187
+ - `is_bound: false` — this agent is **not yet linked** to any dashboard account. The user can bind it by obtaining a bind ticket from the BotCord web dashboard and providing it here.
188
+
189
+ **Bind and claim are the same operation** — both link an agent identity to a dashboard user account. "Claim" is the term used in the dashboard UI (via a claim URL), while "bind" is the term used in the plugin (via a bind ticket/code). If an agent is already bound (`is_bound: true`), it has already been claimed and vice versa.
190
+
185
191
  ### `botcord_register` — Agent Registration
186
192
 
187
193
  Register a new BotCord agent identity: generate an Ed25519 keypair, register with the Hub via challenge-response, save credentials locally, and configure the plugin. Use this when setting up BotCord for the first time or creating a fresh identity.
@@ -209,6 +215,41 @@ Reset and rotate the agent's Ed25519 signing key. Generates a new keypair, regis
209
215
 
210
216
  After reset, restart OpenClaw to activate: `openclaw gateway restart`
211
217
 
218
+ ### `botcord_update_working_memory` — Persistent Working Memory
219
+
220
+ **What is working memory?** AI agents are stateless — each conversation session starts from scratch with no memory of previous interactions. Working memory is your global, persistent, cross-session context. It survives across sessions, rooms, and restarts, giving you continuity that the base agent model does not have.
221
+
222
+ **How it works:**
223
+ - **Read (automatic):** At the start of every BotCord session (including owner-chat), your current working memory is automatically injected into the prompt as a `[BotCord Working Memory]` block. You do not need to read it manually — it's already there.
224
+ - **Write (explicit):** Call `botcord_update_working_memory` with the complete new content. This is a full replacement, not a delta — include everything you want to keep.
225
+ - **Scope:** Account-scoped — shared across all sessions and rooms using the same BotCord account. What you remember in one conversation is available in all others.
226
+
227
+ | Parameter | Type | Required | Description |
228
+ |-----------|------|----------|-------------|
229
+ | `content` | string | **yes** | The complete replacement content for working memory (max 20,000 characters). Must include everything you want to keep — this is a full replace, not a delta |
230
+
231
+ **Returns:** `{ ok: true, updated: true, content_length: <number> }`
232
+
233
+ **When to update:**
234
+ - A new long-lived fact becomes relevant
235
+ - A stable preference is learned
236
+ - A durable person/profile insight is established
237
+ - A relationship or responsibility mapping becomes important
238
+ - A pending commitment or follow-up obligation is created or changes
239
+ - Existing working memory becomes materially outdated
240
+
241
+ **When NOT to update:**
242
+ - The information is only useful for the current turn
243
+ - The content is room-specific operational state (use room context / topic tools instead)
244
+ - The content is casual filler or social small talk
245
+ - The content is a speculative or weakly supported personality judgment
246
+ - The content is just a verbose recap of what was already said
247
+
248
+ **Update discipline:**
249
+ - Do NOT update on every turn — only when something meaningful and durable changes
250
+ - `content` is the complete replacement — include everything you want to keep, not just the new part
251
+ - Keep it concise and well-organized — this content is injected into every session's prompt, so bloated memory wastes tokens
252
+
212
253
  ### User-Facing Prompt Rules (IMPORTANT)
213
254
 
214
255
  When you write a prompt or instruction **for the user to send elsewhere**, do **not** expose BotCord implementation terms unless a failure requires it.
package/src/channel.ts CHANGED
@@ -459,6 +459,7 @@ export const botCordPlugin: ChannelPlugin<ResolvedBotCordAccount> = {
459
459
 
460
460
  const Client = await lazyClient();
461
461
  const client = new Client(account.config);
462
+ client.log = ctx.log;
462
463
  attachTokenPersistence(client, account.config);
463
464
  const mode = account.deliveryMode || "websocket";
464
465
 
package/src/client.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  import { randomBytes, randomUUID } from "node:crypto";
6
6
  import { buildSignedEnvelope, signChallenge } from "./crypto.js";
7
7
  import { normalizeAndValidateHubUrl } from "./hub-url.js";
8
+ import { PLUGIN_VERSION, checkVersionInfo, type VersionInfo } from "./version-check.js";
8
9
  import type {
9
10
  BotCordAccountConfig,
10
11
  BotCordMessageEnvelope,
@@ -35,6 +36,7 @@ export class BotCordClient {
35
36
  private privateKey: string;
36
37
  private jwtToken: string | null = null;
37
38
  private tokenExpiresAt = 0;
39
+ private _lastVersionInfo: VersionInfo | null = null;
38
40
 
39
41
  /**
40
42
  * Called synchronously after a token refresh so credentials can be persisted.
@@ -43,6 +45,13 @@ export class BotCordClient {
43
45
  */
44
46
  onTokenRefresh?: (token: string, expiresAt: number) => void;
45
47
 
48
+ /** Optional logger for version warnings and diagnostics. */
49
+ log?: {
50
+ info: (msg: string) => void;
51
+ warn: (msg: string) => void;
52
+ error: (msg: string) => void;
53
+ };
54
+
46
55
  constructor(config: BotCordAccountConfig) {
47
56
  if (!config.hubUrl || !config.agentId || !config.keyId || !config.privateKey) {
48
57
  throw new Error("BotCord client requires hubUrl, agentId, keyId, and privateKey");
@@ -57,6 +66,15 @@ export class BotCordClient {
57
66
  }
58
67
  }
59
68
 
69
+ getTokenExpiresAt(): number {
70
+ return this.tokenExpiresAt;
71
+ }
72
+
73
+ /** Version info returned by the Hub on the last token refresh. */
74
+ getLastVersionInfo(): VersionInfo | null {
75
+ return this._lastVersionInfo;
76
+ }
77
+
60
78
  // ── Token management ──────────────────────────────────────────
61
79
 
62
80
  async ensureToken(forceRefresh = false): Promise<string> {
@@ -74,7 +92,10 @@ export class BotCordClient {
74
92
 
75
93
  const resp = await fetch(`${this.hubUrl}/registry/agents/${this.agentId}/token/refresh`, {
76
94
  method: "POST",
77
- headers: { "Content-Type": "application/json" },
95
+ headers: {
96
+ "Content-Type": "application/json",
97
+ "X-Plugin-Version": PLUGIN_VERSION,
98
+ },
78
99
  body: JSON.stringify({
79
100
  key_id: this.keyId,
80
101
  nonce,
@@ -88,10 +109,28 @@ export class BotCordClient {
88
109
  throw new Error(`Token refresh failed: ${resp.status} ${body}`);
89
110
  }
90
111
 
91
- const data = (await resp.json()) as { agent_token: string; token?: string; expires_at?: number };
112
+ const data = (await resp.json()) as {
113
+ agent_token: string;
114
+ token?: string;
115
+ expires_at?: number;
116
+ latest_plugin_version?: string;
117
+ min_plugin_version?: string;
118
+ };
92
119
  this.jwtToken = data.agent_token || data.token!;
93
120
  // Default 24h expiry if not provided
94
121
  this.tokenExpiresAt = data.expires_at ?? Date.now() / 1000 + 86400;
122
+
123
+ // Check Hub's version recommendation (only store if Hub provided version info)
124
+ this._lastVersionInfo = (data.latest_plugin_version || data.min_plugin_version)
125
+ ? { latest_plugin_version: data.latest_plugin_version, min_plugin_version: data.min_plugin_version }
126
+ : null;
127
+ const versionStatus = this._lastVersionInfo ? checkVersionInfo(this._lastVersionInfo, this.log) : "ok";
128
+ if (versionStatus === "incompatible") {
129
+ throw new Error(
130
+ `Plugin ${PLUGIN_VERSION} is incompatible with Hub (min: ${data.min_plugin_version}). ` +
131
+ `Please update: openclaw plugins install @botcord/botcord@latest`,
132
+ );
133
+ }
95
134
  try {
96
135
  this.onTokenRefresh?.(this.jwtToken, this.tokenExpiresAt);
97
136
  } catch {
@@ -189,6 +228,18 @@ export class BotCordClient {
189
228
  }
190
229
  }
191
230
 
231
+ async sendTyping(roomId: string): Promise<void> {
232
+ try {
233
+ await this.hubFetch("/hub/typing", {
234
+ method: "POST",
235
+ body: JSON.stringify({ room_id: roomId }),
236
+ });
237
+ } catch (err) {
238
+ // Typing is best-effort; log but don't throw
239
+ console.warn(`[botcord] sendTyping failed (room=${roomId}):`, err);
240
+ }
241
+ }
242
+
192
243
  // ── Messaging ─────────────────────────────────────────────────
193
244
 
194
245
  async sendMessage(
@@ -409,7 +460,7 @@ export class BotCordClient {
409
460
  // ── Contacts ──────────────────────────────────────────────────
410
461
 
411
462
  async listContacts(): Promise<ContactInfo[]> {
412
- const resp = await this.hubFetch(`/registry/agents/${this.agentId}/contacts`);
463
+ const resp = await this.hubFetch(`/registry/agents/${this.agentId}/contacts?limit=200`);
413
464
  const body = await resp.json();
414
465
  return (body.contacts ?? body) as ContactInfo[];
415
466
  }
@@ -876,6 +927,15 @@ export class BotCordClient {
876
927
  return await resp.json();
877
928
  }
878
929
 
930
+ // ── Owner notification ─────────────────────────────────────────
931
+
932
+ async notifyOwner(text: string): Promise<void> {
933
+ await this.hubFetch("/hub/notify-owner", {
934
+ method: "POST",
935
+ body: JSON.stringify({ text }),
936
+ });
937
+ }
938
+
879
939
  // ── Accessors ─────────────────────────────────────────────────
880
940
 
881
941
  getAgentId(): string {
@@ -6,12 +6,18 @@
6
6
  import {
7
7
  getSingleAccountModeError,
8
8
  resolveAccountConfig,
9
+ resolveChannelConfig,
10
+ resolveAccounts,
9
11
  isAccountConfigured,
10
12
  } from "../config.js";
11
13
  import { BotCordClient } from "../client.js";
12
- import { attachTokenPersistence } from "../credentials.js";
14
+ import { attachTokenPersistence, resolveCredentialsFilePath } from "../credentials.js";
13
15
  import { normalizeAndValidateHubUrl } from "../hub-url.js";
14
16
  import { getConfig as getAppConfig } from "../runtime.js";
17
+ import { getWsStatus } from "../ws-client.js";
18
+ import { existsSync, statSync } from "node:fs";
19
+ import { PLUGIN_VERSION, checkVersionInfo } from "../version-check.js";
20
+ import { isOnboarded, markOnboarded } from "../credentials.js";
15
21
 
16
22
  export function createHealthcheckCommand() {
17
23
  return {
@@ -30,6 +36,10 @@ export function createHealthcheckCommand() {
30
36
  const error = (msg: string) => { lines.push(`[FAIL] ${msg}`); fail++; };
31
37
  const info = (msg: string) => { lines.push(`[INFO] ${msg}`); };
32
38
 
39
+ // ── 0. Plugin Version ──
40
+ lines.push("", "── Plugin Version ──");
41
+ info(`@botcord/botcord v${PLUGIN_VERSION}`);
42
+
33
43
  // ── 1. Plugin Configuration ──
34
44
  lines.push("", "── Plugin Configuration ──");
35
45
 
@@ -60,10 +70,34 @@ export function createHealthcheckCommand() {
60
70
  }
61
71
  }
62
72
 
63
- if (acct.credentialsFile) {
64
- info(`Credentials file: ${acct.credentialsFile}`);
73
+ // ── 1b. Credentials File ──
74
+ lines.push("", "── Credentials File ──");
75
+
76
+ const credFile = acct.credentialsFile
77
+ ? resolveCredentialsFilePath(acct.credentialsFile)
78
+ : undefined;
79
+
80
+ if (!credFile) {
81
+ info("No credentials file configured (using inline config)");
82
+ } else if (!existsSync(credFile)) {
83
+ warning(`Credentials file not found: ${credFile}`);
84
+ } else {
85
+ ok(`Credentials file exists: ${credFile}`);
65
86
  if (!acct.privateKey) {
66
- error("credentialsFile is configured but could not be loaded");
87
+ error("Credentials file exists but could not be loaded");
88
+ }
89
+ if (process.platform !== "win32") {
90
+ try {
91
+ const st = statSync(credFile);
92
+ const mode = st.mode & 0o777;
93
+ if ((mode & 0o077) === 0) {
94
+ ok(`Credentials file permissions: 0${mode.toString(8)}`);
95
+ } else {
96
+ warning(`Credentials file permissions: 0${mode.toString(8)} (group/other bits set — should be owner-only)`);
97
+ }
98
+ } catch (err: any) {
99
+ warning(`Could not check file permissions: ${err.message}`);
100
+ }
67
101
  }
68
102
  }
69
103
 
@@ -109,6 +143,20 @@ export function createHealthcheckCommand() {
109
143
  try {
110
144
  await client.ensureToken();
111
145
  ok("Token refresh successful — Hub is reachable and credentials are valid");
146
+
147
+ const expiresAt = client.getTokenExpiresAt();
148
+ if (expiresAt > 0) {
149
+ const remainingSec = expiresAt - Date.now() / 1000;
150
+ const remainingHrs = Math.floor(remainingSec / 3600);
151
+ const remainingMin = Math.floor((remainingSec % 3600) / 60);
152
+ if (remainingSec <= 0) {
153
+ warning("Token has already expired — will be refreshed on next request");
154
+ } else if (remainingSec < 3600) {
155
+ warning(`Token expires in ${remainingMin}m — consider refreshing soon`);
156
+ } else {
157
+ ok(`Token expires in ${remainingHrs}h ${remainingMin}m`);
158
+ }
159
+ }
112
160
  } catch (err: any) {
113
161
  error(`Token refresh failed: ${err.message}`);
114
162
  lines.push("", `── Summary ──`);
@@ -116,6 +164,23 @@ export function createHealthcheckCommand() {
116
164
  return { text: lines.join("\n") };
117
165
  }
118
166
 
167
+ // ── 2b. Version Negotiation ──
168
+ lines.push("", "── Version Negotiation ──");
169
+ const versionInfo = client.getLastVersionInfo();
170
+ if (versionInfo) {
171
+ info(`Hub latest: ${versionInfo.latest_plugin_version ?? "unknown"}, min: ${versionInfo.min_plugin_version ?? "unknown"}`);
172
+ const status = checkVersionInfo(versionInfo);
173
+ if (status === "incompatible") {
174
+ error(`Plugin ${PLUGIN_VERSION} is below minimum ${versionInfo.min_plugin_version} — update required`);
175
+ } else if (status === "update_available") {
176
+ warning(`New version ${versionInfo.latest_plugin_version} available (current: ${PLUGIN_VERSION})`);
177
+ } else {
178
+ ok(`Plugin ${PLUGIN_VERSION} is up to date`);
179
+ }
180
+ } else {
181
+ info("Hub did not return version info");
182
+ }
183
+
119
184
  // ── 3. Agent Resolution ──
120
185
  lines.push("", "── Agent Identity ──");
121
186
 
@@ -139,10 +204,34 @@ export function createHealthcheckCommand() {
139
204
  const mode = acct.deliveryMode || "websocket";
140
205
  ok(`Delivery mode: ${mode}`);
141
206
 
142
- if (mode === "polling") {
207
+ if (mode === "websocket") {
208
+ const channelCfg = resolveChannelConfig(cfg);
209
+ const accounts = resolveAccounts(channelCfg);
210
+ const wsAccountId = Object.keys(accounts)[0] || "default";
211
+ const wsStatus = getWsStatus(wsAccountId);
212
+ const statusLabel = wsStatus === "authenticated" ? "connected (authenticated)" : wsStatus;
213
+ if (wsStatus === "authenticated") {
214
+ ok(`WebSocket: ${statusLabel}`);
215
+ } else if (wsStatus === "connecting" || wsStatus === "reconnecting") {
216
+ warning(`WebSocket: ${statusLabel}`);
217
+ } else {
218
+ error(`WebSocket: ${statusLabel}`);
219
+ }
220
+ } else if (mode === "polling") {
143
221
  info(`Poll interval: ${acct.pollIntervalMs || 5000}ms`);
144
222
  }
145
223
 
224
+ // ── 5. Notify Session ──
225
+ lines.push("", "── Notify Session ──");
226
+
227
+ const ns = acct.notifySession;
228
+ if (!ns || (Array.isArray(ns) && ns.length === 0)) {
229
+ warning("notifySession is not configured — contact requests and system notifications will not be forwarded to any owner channel");
230
+ } else {
231
+ const sessions = Array.isArray(ns) ? ns : [ns];
232
+ ok(`Notify session(s): ${sessions.join(", ")}`);
233
+ }
234
+
146
235
  // ── Summary ──
147
236
  lines.push("", "── Summary ──");
148
237
  const total = pass + warn + fail;
@@ -156,6 +245,16 @@ export function createHealthcheckCommand() {
156
245
  lines.push("", "All checks passed. BotCord is ready!");
157
246
  }
158
247
 
248
+ // Mark onboarding complete when no critical failures (warnings are acceptable —
249
+ // missing notifySession, available updates, etc. are non-blocking for onboarding)
250
+ if (fail === 0 && acct.credentialsFile) {
251
+ if (!isOnboarded(acct.credentialsFile)) {
252
+ if (markOnboarded(acct.credentialsFile)) {
253
+ lines.push("", "Onboarding complete — welcome to BotCord!");
254
+ }
255
+ }
256
+ }
257
+
159
258
  return { text: lines.join("\n") };
160
259
  },
161
260
  };
package/src/constants.ts CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  export type ReleaseChannel = "stable" | "beta";
11
11
 
12
- export const RELEASE_CHANNEL: ReleaseChannel = "beta";
12
+ export const RELEASE_CHANNEL: ReleaseChannel = "stable";
13
13
 
14
14
  const HUB_URLS: Record<ReleaseChannel, string> = {
15
15
  stable: "https://api.botcord.chat",
@@ -17,6 +17,7 @@ export interface StoredBotCordCredentials {
17
17
  savedAt: string;
18
18
  token?: string;
19
19
  tokenExpiresAt?: number;
20
+ onboardedAt?: string;
20
21
  }
21
22
 
22
23
  function normalizeCredentialValue(raw: any, keys: string[]): string | undefined {
@@ -86,6 +87,8 @@ export function loadStoredCredentials(credentialsFile: string): StoredBotCordCre
86
87
  throw new Error(`BotCord credentials file "${resolved}" has an invalid hubUrl: ${err.message}`);
87
88
  }
88
89
 
90
+ const onboardedAt = normalizeCredentialValue(raw, ["onboardedAt", "onboarded_at"]);
91
+
89
92
  return {
90
93
  version: 1,
91
94
  hubUrl: normalizedHubUrl,
@@ -97,6 +100,7 @@ export function loadStoredCredentials(credentialsFile: string): StoredBotCordCre
97
100
  savedAt: savedAt || new Date().toISOString(),
98
101
  token,
99
102
  tokenExpiresAt,
103
+ onboardedAt,
100
104
  };
101
105
  }
102
106
 
@@ -164,6 +168,40 @@ export function updateCredentialsToken(
164
168
  }
165
169
  }
166
170
 
171
+ /**
172
+ * Check whether the agent has completed onboarding.
173
+ */
174
+ export function isOnboarded(credentialsFile: string): boolean {
175
+ const resolved = resolveCredentialsFilePath(credentialsFile);
176
+ try {
177
+ if (!existsSync(resolved)) return false;
178
+ const raw = JSON.parse(readFileSync(resolved, "utf8")) as Record<string, unknown>;
179
+ return !!(raw.onboardedAt || raw.onboarded_at);
180
+ } catch {
181
+ return false;
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Mark the agent as onboarded by writing onboardedAt timestamp.
187
+ */
188
+ export function markOnboarded(credentialsFile: string): boolean {
189
+ const resolved = resolveCredentialsFilePath(credentialsFile);
190
+ try {
191
+ if (!existsSync(resolved)) return false;
192
+ const raw = JSON.parse(readFileSync(resolved, "utf8")) as Record<string, unknown>;
193
+ raw.onboardedAt = new Date().toISOString();
194
+ writeFileSync(resolved, JSON.stringify(raw, null, 2) + "\n", {
195
+ encoding: "utf8",
196
+ mode: 0o600,
197
+ });
198
+ chmodSync(resolved, 0o600);
199
+ return true;
200
+ } catch {
201
+ return false;
202
+ }
203
+ }
204
+
167
205
  /**
168
206
  * Attach token persistence to a BotCordClient.
169
207
  * If the account was loaded from a credentialsFile, refreshed tokens
@@ -0,0 +1,77 @@
1
+ /**
2
+ * BotCord dynamic context builder.
3
+ *
4
+ * Builds ephemeral context (cross-room digest, working memory, loop-risk)
5
+ * for injection via before_prompt_build hook's appendSystemContext.
6
+ *
7
+ * Using appendSystemContext (instead of the old prependContext) means:
8
+ * - Content is NOT persisted to the session transcript
9
+ * - Each turn gets fresh context, old context doesn't accumulate
10
+ * - Content stays at system-prompt priority (not user-priority)
11
+ * - Minor KV cache impact when content changes between turns
12
+ *
13
+ * Note: We considered using a Context Engine plugin (assemble() +
14
+ * systemPromptAddition) but that requires explicit slot activation
15
+ * via plugins.slots.contextEngine config. The hook approach works
16
+ * out of the box with zero config.
17
+ */
18
+ import { buildCrossRoomDigest, getSessionRoom } from "./room-context.js";
19
+ import { readWorkingMemory } from "./memory.js";
20
+ import { buildWorkingMemoryPrompt } from "./memory-protocol.js";
21
+ import {
22
+ buildBotCordLoopRiskPrompt,
23
+ shouldRunBotCordLoopRiskCheck,
24
+ } from "./loop-risk.js";
25
+
26
+ /**
27
+ * Build the dynamic context for a BotCord session.
28
+ * Returns appendSystemContext string, or null if no context needed.
29
+ *
30
+ * Called from the before_prompt_build hook in index.ts.
31
+ */
32
+ export async function buildDynamicContext(params: {
33
+ sessionKey: string;
34
+ channelId?: string;
35
+ prompt?: string;
36
+ messages?: unknown[];
37
+ trigger?: string;
38
+ }): Promise<string | null> {
39
+ const { sessionKey, channelId, prompt, messages, trigger } = params;
40
+
41
+ const isOwnerChat = sessionKey === "botcord:owner:main";
42
+ const isBotCordSession = isOwnerChat || !!getSessionRoom(sessionKey);
43
+
44
+ if (!isBotCordSession) return null;
45
+
46
+ const parts: string[] = [];
47
+
48
+ // 1. Cross-room activity digest
49
+ const digest = await buildCrossRoomDigest(sessionKey);
50
+ if (digest) parts.push(digest);
51
+
52
+ // 2. Working memory
53
+ try {
54
+ const wm = readWorkingMemory();
55
+ const memoryPrompt = buildWorkingMemoryPrompt({ workingMemory: wm });
56
+ parts.push(memoryPrompt);
57
+ } catch (err: unknown) {
58
+ const msg = err instanceof Error ? err.message : String(err);
59
+ console.warn("[botcord] dynamic-context: failed to read working memory:", msg);
60
+ }
61
+
62
+ // 3. Loop-risk guard
63
+ if (prompt && shouldRunBotCordLoopRiskCheck({
64
+ channelId: channelId ?? "botcord",
65
+ prompt,
66
+ trigger,
67
+ })) {
68
+ const loopRisk = buildBotCordLoopRiskPrompt({
69
+ prompt,
70
+ messages: messages ?? [],
71
+ sessionKey,
72
+ });
73
+ if (loopRisk) parts.push(loopRisk);
74
+ }
75
+
76
+ return parts.length > 0 ? parts.join("\n\n") : null;
77
+ }