@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.
- package/CHANGELOG.md +131 -0
- package/HEADLESS-FLEET-PLAN.md +1 -1
- package/UNIFIED-TREE-PLAN.md +2 -0
- package/bun.lock +6 -6
- package/docs/AGENT-ONBOARDING.md +9 -8
- package/docs/DEPLOYMENTS.md +2 -2
- package/docs/DEV-ENVIRONMENT.md +28 -26
- package/docs/LOCUS-ROUTING-DESIGN.md +8 -2
- package/package.json +4 -4
- package/src/commands.ts +50 -4
- package/src/framework-agent-config.ts +3 -0
- package/src/framework-strategy.ts +3 -0
- package/src/index.ts +9 -0
- package/src/modules/channel-mode-module.ts +9 -6
- package/src/modules/subscription-gc-module.ts +163 -34
- package/src/modules/tts-relay-module.ts +481 -0
- package/src/modules/web-ui-module.ts +280 -1
- package/src/recipe.ts +58 -1
- package/src/tui.ts +363 -104
- package/src/web/protocol.ts +130 -0
- package/test/commands-checkpoint-restore.test.ts +118 -0
- package/test/settings-protocol.test.ts +64 -0
- package/test/subscription-gc-module.test.ts +156 -1
- package/test/tui-quit-confirm.test.ts +42 -0
- package/test/tui-short-agent-name.test.ts +36 -0
- package/web/src/App.tsx +35 -2
- package/web/src/Settings.tsx +434 -0
|
@@ -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
|
|
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 {
|
|
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
|
|
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;
|