@animalabs/connectome-host 0.3.10 → 0.5.0

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.
@@ -21,7 +21,9 @@
21
21
  *
22
22
  * Config (recipe `modules.subscriptionGc`): `defaultLimitChars` (20000),
23
23
  * `serverId` (`discord`), `toolPrefix` (`mcpl--<serverId>`). The agent can
24
- * override per channel at runtime with `set_channel_idle_limit`.
24
+ * override per channel at runtime through `agent_settings` (field
25
+ * `channel_idle_limits`); other modules pin channels open through the
26
+ * internal `pin_channel_idle_limit` tool.
25
27
  */
26
28
 
27
29
  import type {
@@ -49,7 +51,14 @@ export interface SubscriptionGcConfig {
49
51
  type LimitOverride = number | 'off';
50
52
 
51
53
  interface GcState {
54
+ /** Agent-set per-channel limits. Cleared by agent_settings reset. */
52
55
  overrides: Record<string, LimitOverride>;
56
+ /** System pins (never auto-close), set by other modules via
57
+ * pin_channel_idle_limit — e.g. ChannelModeModule's debounced mode.
58
+ * These encode mode invariants, not agent preferences, so
59
+ * agent_settings reset leaves them alone; only unpinning (a mode
60
+ * change) clears them. A pin wins over any override. */
61
+ pins: Record<string, true>;
53
62
  /** Ambient chars accrued per channel since the last activation. */
54
63
  counters: Record<string, number>;
55
64
  }
@@ -67,7 +76,7 @@ export class SubscriptionGcModule implements Module {
67
76
  private readonly defaultLimitChars: number;
68
77
  private readonly serverId: string;
69
78
 
70
- private state: GcState = { overrides: {}, counters: {} };
79
+ private state: GcState = { overrides: {}, pins: {}, counters: {} };
71
80
 
72
81
  constructor(config: SubscriptionGcConfig = {}) {
73
82
  this.defaultLimitChars =
@@ -81,8 +90,12 @@ export class SubscriptionGcModule implements Module {
81
90
  this.ctx = ctx;
82
91
  const saved = ctx.getState<Partial<GcState>>();
83
92
  if (saved) {
93
+ // Pre-pins state stored ChannelMode's programmatic 'off' pins in
94
+ // `overrides`; they stay there as agent-level overrides and get
95
+ // re-asserted as pins on the next mode change.
84
96
  this.state = {
85
97
  overrides: saved.overrides ?? {},
98
+ pins: saved.pins ?? {},
86
99
  counters: saved.counters ?? {},
87
100
  };
88
101
  }
@@ -112,37 +125,16 @@ export class SubscriptionGcModule implements Module {
112
125
  this.ctx = null;
113
126
  }
114
127
 
128
+ /** No agent-visible tools — idle limits live inside the framework's
129
+ * `agent_settings` tool via getAgentSettingsExtension() below (same
130
+ * consolidation as the reasoning controls). The former tools remain
131
+ * ROUTABLE though undeclared (module tool routing is prefix-based), so
132
+ * agent muscle memory keeps working. ChannelModeModule holds channels
133
+ * open via the internal `subscription-gc--pin_channel_idle_limit` tool
134
+ * — a separate layer from agent overrides, so agent_settings reset
135
+ * can't break debounced mode's invariant. */
115
136
  getTools(): ToolDefinition[] {
116
- return [
117
- {
118
- name: 'set_channel_idle_limit',
119
- description:
120
- 'Set how many characters of ambient (non-mention, non-DM) traffic a ' +
121
- 'subscribed channel may emit between your activations before it is ' +
122
- 'auto-closed. `limit` is a number of characters, "default" to ' +
123
- `use the global default (${this.defaultLimitChars}), or "off" to ` +
124
- 'never auto-close this channel.',
125
- inputSchema: {
126
- type: 'object',
127
- properties: {
128
- channelId: { type: 'string', description: 'Discord channel ID' },
129
- limit: {
130
- type: 'string',
131
- description: 'A character count (e.g. "20000"), "default", or "off".',
132
- },
133
- },
134
- required: ['channelId', 'limit'],
135
- },
136
- },
137
- {
138
- name: 'list_channel_idle_limits',
139
- description:
140
- 'Show the auto-close configuration: the global default limit, ' +
141
- 'per-channel overrides, and the current since-last-activation ambient ' +
142
- 'character counts per channel.',
143
- inputSchema: { type: 'object', properties: {} },
144
- },
145
- ];
137
+ return [];
146
138
  }
147
139
 
148
140
  async handleToolCall(call: ToolCall): Promise<ToolResult> {
@@ -181,6 +173,28 @@ export class SubscriptionGcModule implements Module {
181
173
  this.persistNow();
182
174
  return ok(`Idle limit for ${channelId} set to ${desc}.`);
183
175
  }
176
+ case 'pin_channel_idle_limit': {
177
+ // Internal (module-to-module) verb: hold a channel open regardless of
178
+ // agent-level overrides. Not part of the agent settings surface.
179
+ const channelId = input.channelId;
180
+ if (typeof channelId !== 'string' || channelId.length === 0) {
181
+ return { success: false, error: 'channelId is required', isError: true };
182
+ }
183
+ if (typeof input.pinned !== 'boolean') {
184
+ return { success: false, error: 'pinned must be a boolean', isError: true };
185
+ }
186
+ if (input.pinned) {
187
+ this.state.pins[channelId] = true;
188
+ } else {
189
+ delete this.state.pins[channelId];
190
+ }
191
+ this.persistNow();
192
+ return ok(
193
+ input.pinned
194
+ ? `Channel ${channelId} pinned open (never auto-close).`
195
+ : `Channel ${channelId} unpinned; its agent override or the default limit applies again.`,
196
+ );
197
+ }
184
198
  case 'list_channel_idle_limits':
185
199
  return {
186
200
  success: true,
@@ -188,14 +202,128 @@ export class SubscriptionGcModule implements Module {
188
202
  data: {
189
203
  defaultLimitChars: this.defaultLimitChars,
190
204
  overrides: this.state.overrides,
205
+ pins: Object.keys(this.state.pins),
191
206
  counters: this.state.counters,
192
207
  },
193
208
  };
194
209
  default:
195
- return { success: false, error: `Unknown tool: ${call.name}`, isError: true };
210
+ return {
211
+ success: false,
212
+ error:
213
+ `Unknown tool: ${call.name}. Idle-limit controls live in agent_settings ` +
214
+ `(field channel_idle_limits).`,
215
+ isError: true,
216
+ };
196
217
  }
197
218
  }
198
219
 
220
+ /**
221
+ * Declare idle limits as an agent_settings extension: the framework merges
222
+ * the field into the agent_settings tool and routes get/update/reset back
223
+ * here. Same pattern as SettingsModule's reasoning fields. Update semantics
224
+ * mirror the (undeclared but still routable) set_channel_idle_limit tool:
225
+ * per entry, a positive number sets an override, "off" disables auto-close
226
+ * for the channel, and "default"/null clears back to the global default.
227
+ * Entries not mentioned in a patch are left untouched, and a patch applies
228
+ * all-or-nothing: it is validated in full before any entry takes effect.
229
+ *
230
+ * `get` additionally reports read-only companions (the framework merges
231
+ * ext.get() into the response but only routes declared `keys` on update):
232
+ * `channel_idle_default`, live `channel_idle_counters`, and
233
+ * `channel_idle_pinned` — system pins held by other modules, which sit
234
+ * above these overrides and are not touched by update or reset.
235
+ */
236
+ getAgentSettingsExtension(): {
237
+ properties: Record<string, unknown>;
238
+ keys: string[];
239
+ get(agentName: string): Record<string, unknown>;
240
+ update(agentName: string, patch: Record<string, unknown>): Record<string, unknown>;
241
+ reset(agentName: string, keys?: string[]): Record<string, unknown>;
242
+ } {
243
+ return {
244
+ properties: {
245
+ channel_idle_limits: {
246
+ type: 'object',
247
+ description:
248
+ 'Per-channel ambient auto-close budgets, as {"<channelId>": value}: ' +
249
+ 'a positive number of characters, "off" (never auto-close this ' +
250
+ 'channel), or "default"/null (clear the override). Channels not ' +
251
+ `listed use the global default (${this.defaultLimitChars} chars of ` +
252
+ 'ambient traffic between your activations before auto-close). ' +
253
+ 'Patches merge per entry; unmentioned channels are untouched. ' +
254
+ 'get also reports the read-only channel_idle_default, ' +
255
+ 'channel_idle_counters (ambient chars since your last activation), ' +
256
+ 'and channel_idle_pinned (channels held open by a channel mode; ' +
257
+ 'not affected by update or reset — change the channel mode instead).',
258
+ },
259
+ },
260
+ keys: ['channel_idle_limits'],
261
+ get: () => this.settingsSnapshot(),
262
+ update: (_agentName, patch) => {
263
+ const raw = patch.channel_idle_limits;
264
+ if (raw === undefined) return this.settingsSnapshot();
265
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
266
+ throw new Error(
267
+ 'channel_idle_limits must be an object mapping channel ids to a ' +
268
+ 'positive number, "off", or "default"/null',
269
+ );
270
+ }
271
+ // All-or-nothing: validate the full patch into a staging copy and
272
+ // swap only if every entry passes. Mutating live state per-entry
273
+ // would leave earlier entries active (limitFor reads the map
274
+ // directly, and any later flush persists them) after a failure the
275
+ // agent was told was rejected.
276
+ const staged = { ...this.state.overrides };
277
+ for (const [channelId, value] of Object.entries(raw as Record<string, unknown>)) {
278
+ if (channelId.length === 0) throw new Error('channel id must be non-empty');
279
+ const numeric =
280
+ typeof value === 'number'
281
+ ? value
282
+ : typeof value === 'string' && /^\d+$/.test(value.trim())
283
+ ? Number(value.trim())
284
+ : NaN;
285
+ if (value === 'default' || value === null) {
286
+ delete staged[channelId];
287
+ } else if (value === 'off') {
288
+ staged[channelId] = 'off';
289
+ } else if (Number.isFinite(numeric) && numeric > 0) {
290
+ staged[channelId] = Math.round(numeric);
291
+ } else {
292
+ throw new Error(
293
+ `channel_idle_limits[${JSON.stringify(channelId)}] must be a positive ` +
294
+ 'number, "off", or "default"/null (no entries were applied)',
295
+ );
296
+ }
297
+ }
298
+ this.state.overrides = staged;
299
+ this.persistNow();
300
+ return this.settingsSnapshot();
301
+ },
302
+ reset: (_agentName, keys) => {
303
+ // Clears agent overrides only. Pins are mode invariants owned by
304
+ // other modules (ChannelMode's debounced mode), not agent
305
+ // preferences — a blanket `agent_settings reset` must not silently
306
+ // reopen a debounced channel to auto-close.
307
+ const all = !keys || keys.length === 0;
308
+ if (all || keys?.includes('channel_idle_limits')) {
309
+ this.state.overrides = {};
310
+ this.persistNow();
311
+ }
312
+ return this.settingsSnapshot();
313
+ },
314
+ };
315
+ }
316
+
317
+ /** agent_settings view: the writable overrides plus read-only status. */
318
+ private settingsSnapshot(): Record<string, unknown> {
319
+ return {
320
+ channel_idle_limits: { ...this.state.overrides },
321
+ channel_idle_default: this.defaultLimitChars,
322
+ channel_idle_counters: { ...this.state.counters },
323
+ channel_idle_pinned: Object.keys(this.state.pins),
324
+ };
325
+ }
326
+
199
327
  async onProcess(event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
200
328
  const ambient = this.extractAmbient(event);
201
329
  if (!ambient || ambient.chars <= 0) return {};
@@ -232,7 +360,7 @@ export class SubscriptionGcModule implements Module {
232
360
  `[subscription-gc] Auto-closed channel ${channelId}: it emitted ` +
233
361
  `over ${limit} characters of ambient traffic since your last activation without ` +
234
362
  `engagement. Direct addresses there still reach you. Reopen with channel_open, ` +
235
- `or raise/disable its limit with set_channel_idle_limit.`,
363
+ `or raise/disable its limit via agent_settings (field channel_idle_limits).`,
236
364
  },
237
365
  ],
238
366
  },
@@ -254,6 +382,7 @@ export class SubscriptionGcModule implements Module {
254
382
  // ── internals ──
255
383
 
256
384
  private limitFor(channelId: string): number {
385
+ if (this.state.pins[channelId]) return Infinity;
257
386
  const o = this.state.overrides[channelId];
258
387
  if (o === 'off') return Infinity;
259
388
  if (typeof o === 'number') return o;