@animalabs/connectome-host 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/.env.example +20 -0
- package/.github/workflows/publish.yml +65 -0
- package/ARCHITECTURE.md +355 -0
- package/CHANGELOG.md +30 -0
- package/HEADLESS-FLEET-PLAN.md +330 -0
- package/README.md +189 -0
- package/UNIFIED-TREE-PLAN.md +242 -0
- package/bun.lock +288 -0
- package/docs/AGENT-MEMORY-GUIDE.md +214 -0
- package/docs/AGENT-ONBOARDING.md +541 -0
- package/docs/ATTENTION-AND-GATING.md +120 -0
- package/docs/DEPLOYMENTS.md +130 -0
- package/docs/DEV-ENVIRONMENT.md +215 -0
- package/docs/LIBRARY-PIPELINE.md +286 -0
- package/docs/LOCUS-ROUTING-DESIGN.md +115 -0
- package/docs/claudeai-evacuation.md +228 -0
- package/docs/debug-context-api.md +208 -0
- package/docs/webui-deployment.md +219 -0
- package/package.json +33 -0
- package/recipes/SETUP.md +308 -0
- package/recipes/TRIUMVIRATE-SETUP.md +467 -0
- package/recipes/claude-export-revive.json +19 -0
- package/recipes/clerk.json +157 -0
- package/recipes/knowledge-miner.json +174 -0
- package/recipes/knowledge-reviewer.json +76 -0
- package/recipes/mcpl-editor-test.json +38 -0
- package/recipes/prompts/transplant-addendum.md +17 -0
- package/recipes/triumvirate.json +63 -0
- package/recipes/webui-fleet-test.json +27 -0
- package/recipes/webui-test.json +16 -0
- package/recipes/zulip-miner.json +87 -0
- package/scripts/connectome-doctor +373 -0
- package/scripts/evacuator.ts +956 -0
- package/scripts/import-claudeai-export.ts +747 -0
- package/scripts/lib/line-reader.ts +53 -0
- package/scripts/test-historical-thinking.ts +148 -0
- package/scripts/warmup-session.ts +336 -0
- package/src/agent-name.ts +58 -0
- package/src/commands.ts +1074 -0
- package/src/headless.ts +528 -0
- package/src/index.ts +867 -0
- package/src/logging-adapter.ts +208 -0
- package/src/mcpl-config.ts +199 -0
- package/src/modules/activity-module.ts +270 -0
- package/src/modules/channel-mode-module.ts +260 -0
- package/src/modules/fleet-module.ts +1705 -0
- package/src/modules/fleet-types.ts +225 -0
- package/src/modules/lessons-module.ts +465 -0
- package/src/modules/mcpl-admin-module.ts +345 -0
- package/src/modules/retrieval-module.ts +327 -0
- package/src/modules/settings-module.ts +143 -0
- package/src/modules/subagent-module.ts +2074 -0
- package/src/modules/subscription-gc-module.ts +322 -0
- package/src/modules/time-module.ts +85 -0
- package/src/modules/tui-module.ts +76 -0
- package/src/modules/web-ui-curve-page.ts +249 -0
- package/src/modules/web-ui-module.ts +2595 -0
- package/src/recipe.ts +1003 -0
- package/src/session-manager.ts +278 -0
- package/src/state/agent-tree-reducer.ts +431 -0
- package/src/state/fleet-tree-aggregator.ts +195 -0
- package/src/strategies/frontdesk-strategy.ts +364 -0
- package/src/synesthete.ts +68 -0
- package/src/tui.ts +2200 -0
- package/src/types/bun-ffi.d.ts +6 -0
- package/src/web/protocol.ts +648 -0
- package/test/agent-name.test.ts +62 -0
- package/test/agent-tree-fleet-launch.test.ts +64 -0
- package/test/agent-tree-reducer-parity.test.ts +194 -0
- package/test/agent-tree-reducer.test.ts +443 -0
- package/test/agent-tree-rollup.test.ts +109 -0
- package/test/autobio-progress-snapshot.test.ts +40 -0
- package/test/claudeai-export-importer.test.ts +386 -0
- package/test/evacuator.test.ts +332 -0
- package/test/fleet-commands.test.ts +244 -0
- package/test/fleet-no-subfleets.test.ts +147 -0
- package/test/fleet-orchestration.test.ts +353 -0
- package/test/fleet-recipe.test.ts +124 -0
- package/test/fleet-route.test.ts +44 -0
- package/test/fleet-smoke.test.ts +479 -0
- package/test/fleet-subscribe-union-e2e.test.ts +123 -0
- package/test/fleet-subscribe-union.test.ts +85 -0
- package/test/fleet-tree-aggregator-e2e.test.ts +110 -0
- package/test/fleet-tree-aggregator.test.ts +215 -0
- package/test/frontdesk-strategy.test.ts +326 -0
- package/test/headless-describe.test.ts +182 -0
- package/test/headless-smoke.test.ts +190 -0
- package/test/logging-adapter.test.ts +87 -0
- package/test/mcpl-admin-module.test.ts +213 -0
- package/test/mcpl-agent-overlay.test.ts +126 -0
- package/test/mock-headless-child.ts +133 -0
- package/test/recipe-cache-ttl.test.ts +37 -0
- package/test/recipe-env.test.ts +157 -0
- package/test/recipe-path-resolution.test.ts +170 -0
- package/test/subagent-async-timeout.test.ts +381 -0
- package/test/subagent-fork-materialise.test.ts +241 -0
- package/test/subagent-peek-scoping.test.ts +180 -0
- package/test/subagent-peek-zombie.test.ts +148 -0
- package/test/subagent-reaper-in-flight.test.ts +229 -0
- package/test/subscription-gc-module.test.ts +136 -0
- package/test/warmup-handoff.test.ts +150 -0
- package/test/web-ui-bind.test.ts +51 -0
- package/test/web-ui-module.test.ts +246 -0
- package/test/web-ui-protocol.test.ts +0 -0
- package/tsconfig.json +14 -0
- package/web/bun.lock +357 -0
- package/web/index.html +12 -0
- package/web/package.json +24 -0
- package/web/src/App.tsx +1931 -0
- package/web/src/Context.tsx +158 -0
- package/web/src/ContextDocument.tsx +150 -0
- package/web/src/Files.tsx +271 -0
- package/web/src/Lessons.tsx +164 -0
- package/web/src/Mcpl.tsx +310 -0
- package/web/src/Stream.tsx +119 -0
- package/web/src/TreeSidebar.tsx +283 -0
- package/web/src/Usage.tsx +182 -0
- package/web/src/main.tsx +7 -0
- package/web/src/styles.css +26 -0
- package/web/src/tree.ts +268 -0
- package/web/src/wire.ts +120 -0
- package/web/tsconfig.json +21 -0
- package/web/vite.config.ts +32 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SubscriptionGcModule — auto-unsubscribe noisy ambient Discord channels.
|
|
3
|
+
*
|
|
4
|
+
* Problem: a subscribed channel delivers ambient (non-mention, non-DM) messages
|
|
5
|
+
* into the agent's context whether or not the agent ever acts on them. A busy
|
|
6
|
+
* channel the agent isn't engaging is pure context cost.
|
|
7
|
+
*
|
|
8
|
+
* Mechanic (host-side, because only the host sees activations):
|
|
9
|
+
* - Per subscribed channel, count ambient CHARACTERS as they arrive
|
|
10
|
+
* (`onProcess` over `mcpl:push-event` / `mcpl:channel-incoming`).
|
|
11
|
+
* - Every agent ACTIVATION (`inference:started` trace) zeroes ALL counters —
|
|
12
|
+
* the agent saw every subscribed channel in that context (compressed if
|
|
13
|
+
* large; nothing is dropped), so the slate is clean.
|
|
14
|
+
* - The instant a channel's since-last-activation count crosses its limit, it
|
|
15
|
+
* is auto-unsubscribed via the surface's `unsubscribe_channel` tool. This
|
|
16
|
+
* bounds any one channel's context pollution to ~limit characters.
|
|
17
|
+
*
|
|
18
|
+
* Everything is durable across restarts: counters + per-channel overrides
|
|
19
|
+
* persist to chronicle (a restart is NOT an activation, so counters must carry
|
|
20
|
+
* across downtime — they only reset when the agent actually runs).
|
|
21
|
+
*
|
|
22
|
+
* Config (recipe `modules.subscriptionGc`): `defaultLimitChars` (20000),
|
|
23
|
+
* `serverId` (`discord`), `toolPrefix` (`mcpl--<serverId>`). The agent can
|
|
24
|
+
* override per channel at runtime with `set_channel_idle_limit`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type {
|
|
28
|
+
Module,
|
|
29
|
+
ModuleContext,
|
|
30
|
+
ProcessEvent,
|
|
31
|
+
ProcessState,
|
|
32
|
+
EventResponse,
|
|
33
|
+
ToolDefinition,
|
|
34
|
+
ToolCall,
|
|
35
|
+
ToolResult,
|
|
36
|
+
} from '@animalabs/agent-framework';
|
|
37
|
+
|
|
38
|
+
export interface SubscriptionGcConfig {
|
|
39
|
+
/** Default ambient-character budget per channel before auto-unsubscribe. */
|
|
40
|
+
defaultLimitChars?: number;
|
|
41
|
+
/** MCPL server id that owns subscriptions (default 'discord'). */
|
|
42
|
+
serverId?: string;
|
|
43
|
+
/** Tool-name prefix for that server (default `mcpl--<serverId>`). */
|
|
44
|
+
toolPrefix?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `'off'` pins a channel (never auto-unsubscribe); a number overrides the
|
|
48
|
+
* default limit; absence falls back to the default. */
|
|
49
|
+
type LimitOverride = number | 'off';
|
|
50
|
+
|
|
51
|
+
interface GcState {
|
|
52
|
+
overrides: Record<string, LimitOverride>;
|
|
53
|
+
/** Ambient chars accrued per channel since the last activation. */
|
|
54
|
+
counters: Record<string, number>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const FLUSH_DEBOUNCE_MS = 2000;
|
|
58
|
+
|
|
59
|
+
export class SubscriptionGcModule implements Module {
|
|
60
|
+
readonly name = 'subscription-gc';
|
|
61
|
+
|
|
62
|
+
private ctx: ModuleContext | null = null;
|
|
63
|
+
private unsubscribeTrace: (() => void) | null = null;
|
|
64
|
+
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
65
|
+
private callSeq = 0;
|
|
66
|
+
|
|
67
|
+
private readonly defaultLimitChars: number;
|
|
68
|
+
private readonly serverId: string;
|
|
69
|
+
private readonly toolPrefix: string;
|
|
70
|
+
|
|
71
|
+
private state: GcState = { overrides: {}, counters: {} };
|
|
72
|
+
|
|
73
|
+
constructor(config: SubscriptionGcConfig = {}) {
|
|
74
|
+
this.defaultLimitChars =
|
|
75
|
+
typeof config.defaultLimitChars === 'number' && config.defaultLimitChars > 0
|
|
76
|
+
? Math.round(config.defaultLimitChars)
|
|
77
|
+
: 20000;
|
|
78
|
+
this.serverId = config.serverId ?? 'discord';
|
|
79
|
+
this.toolPrefix = config.toolPrefix ?? `mcpl--${this.serverId}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async start(ctx: ModuleContext): Promise<void> {
|
|
83
|
+
this.ctx = ctx;
|
|
84
|
+
const saved = ctx.getState<Partial<GcState>>();
|
|
85
|
+
if (saved) {
|
|
86
|
+
this.state = {
|
|
87
|
+
overrides: saved.overrides ?? {},
|
|
88
|
+
counters: saved.counters ?? {},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
// Reset all counters on every activation: the agent just saw the full
|
|
92
|
+
// context, so every subscribed channel is "seen". This is the ONLY reset —
|
|
93
|
+
// never on restart.
|
|
94
|
+
this.unsubscribeTrace = ctx.onTrace((e) => {
|
|
95
|
+
if (e.type === 'inference:started') {
|
|
96
|
+
if (Object.keys(this.state.counters).length > 0) {
|
|
97
|
+
this.state.counters = {};
|
|
98
|
+
this.persistNow();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async stop(): Promise<void> {
|
|
105
|
+
if (this.unsubscribeTrace) {
|
|
106
|
+
this.unsubscribeTrace();
|
|
107
|
+
this.unsubscribeTrace = null;
|
|
108
|
+
}
|
|
109
|
+
if (this.flushTimer) {
|
|
110
|
+
clearTimeout(this.flushTimer);
|
|
111
|
+
this.flushTimer = null;
|
|
112
|
+
}
|
|
113
|
+
this.persistNow(); // flush any pending counter changes before teardown
|
|
114
|
+
this.ctx = null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
getTools(): ToolDefinition[] {
|
|
118
|
+
return [
|
|
119
|
+
{
|
|
120
|
+
name: 'set_channel_idle_limit',
|
|
121
|
+
description:
|
|
122
|
+
'Set how many characters of ambient (non-mention, non-DM) traffic a ' +
|
|
123
|
+
'subscribed channel may emit between your activations before it is ' +
|
|
124
|
+
'auto-unsubscribed. `limit` is a number of characters, "default" to ' +
|
|
125
|
+
`use the global default (${this.defaultLimitChars}), or "off" to ` +
|
|
126
|
+
'never auto-unsubscribe this channel.',
|
|
127
|
+
inputSchema: {
|
|
128
|
+
type: 'object',
|
|
129
|
+
properties: {
|
|
130
|
+
channelId: { type: 'string', description: 'Discord channel ID' },
|
|
131
|
+
limit: {
|
|
132
|
+
type: 'string',
|
|
133
|
+
description: 'A character count (e.g. "20000"), "default", or "off".',
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
required: ['channelId', 'limit'],
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
name: 'list_channel_idle_limits',
|
|
141
|
+
description:
|
|
142
|
+
'Show the auto-unsubscribe configuration: the global default limit, ' +
|
|
143
|
+
'per-channel overrides, and the current since-last-activation ambient ' +
|
|
144
|
+
'character counts per channel.',
|
|
145
|
+
inputSchema: { type: 'object', properties: {} },
|
|
146
|
+
},
|
|
147
|
+
];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async handleToolCall(call: ToolCall): Promise<ToolResult> {
|
|
151
|
+
const input = (call.input ?? {}) as Record<string, unknown>;
|
|
152
|
+
switch (call.name) {
|
|
153
|
+
case 'set_channel_idle_limit': {
|
|
154
|
+
const channelId = input.channelId;
|
|
155
|
+
if (typeof channelId !== 'string' || channelId.length === 0) {
|
|
156
|
+
return { success: false, error: 'channelId is required', isError: true };
|
|
157
|
+
}
|
|
158
|
+
// `limit` accepts "default", "off", a number, or a numeric string.
|
|
159
|
+
const limit = input.limit;
|
|
160
|
+
const numeric =
|
|
161
|
+
typeof limit === 'number'
|
|
162
|
+
? limit
|
|
163
|
+
: typeof limit === 'string' && /^\d+$/.test(limit.trim())
|
|
164
|
+
? Number(limit.trim())
|
|
165
|
+
: NaN;
|
|
166
|
+
let desc: string;
|
|
167
|
+
if (limit === 'default') {
|
|
168
|
+
delete this.state.overrides[channelId];
|
|
169
|
+
desc = `default (${this.defaultLimitChars})`;
|
|
170
|
+
} else if (limit === 'off') {
|
|
171
|
+
this.state.overrides[channelId] = 'off';
|
|
172
|
+
desc = 'off (never auto-unsubscribe)';
|
|
173
|
+
} else if (Number.isFinite(numeric) && numeric > 0) {
|
|
174
|
+
this.state.overrides[channelId] = Math.round(numeric);
|
|
175
|
+
desc = `${Math.round(numeric)} characters`;
|
|
176
|
+
} else {
|
|
177
|
+
return {
|
|
178
|
+
success: false,
|
|
179
|
+
error: 'limit must be a positive number, "default", or "off"',
|
|
180
|
+
isError: true,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
this.persistNow();
|
|
184
|
+
return ok(`Idle limit for ${channelId} set to ${desc}.`);
|
|
185
|
+
}
|
|
186
|
+
case 'list_channel_idle_limits':
|
|
187
|
+
return {
|
|
188
|
+
success: true,
|
|
189
|
+
isError: false,
|
|
190
|
+
data: {
|
|
191
|
+
defaultLimitChars: this.defaultLimitChars,
|
|
192
|
+
overrides: this.state.overrides,
|
|
193
|
+
counters: this.state.counters,
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
default:
|
|
197
|
+
return { success: false, error: `Unknown tool: ${call.name}`, isError: true };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async onProcess(event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
|
|
202
|
+
const ambient = this.extractAmbient(event);
|
|
203
|
+
if (!ambient || ambient.chars <= 0) return {};
|
|
204
|
+
|
|
205
|
+
const { channelId, chars } = ambient;
|
|
206
|
+
const limit = this.limitFor(channelId);
|
|
207
|
+
const next = (this.state.counters[channelId] ?? 0) + chars;
|
|
208
|
+
|
|
209
|
+
if (limit !== Infinity && next > limit) {
|
|
210
|
+
// Cross the threshold → unsubscribe and clear the counter.
|
|
211
|
+
delete this.state.counters[channelId];
|
|
212
|
+
this.persistNow();
|
|
213
|
+
const result = await this.ctx
|
|
214
|
+
?.callTool({
|
|
215
|
+
id: `gc-unsub-${this.callSeq++}`,
|
|
216
|
+
name: `${this.toolPrefix}--unsubscribe_channel`,
|
|
217
|
+
input: { channelId },
|
|
218
|
+
})
|
|
219
|
+
.catch((err: unknown) => ({
|
|
220
|
+
success: false,
|
|
221
|
+
error: err instanceof Error ? err.message : String(err),
|
|
222
|
+
isError: true,
|
|
223
|
+
}));
|
|
224
|
+
|
|
225
|
+
if (result && result.success) {
|
|
226
|
+
return {
|
|
227
|
+
addMessages: [
|
|
228
|
+
{
|
|
229
|
+
participant: 'system',
|
|
230
|
+
content: [
|
|
231
|
+
{
|
|
232
|
+
type: 'text',
|
|
233
|
+
text:
|
|
234
|
+
`[subscription-gc] Auto-unsubscribed from channel ${channelId}: it emitted ` +
|
|
235
|
+
`over ${limit} characters of ambient traffic since your last activation without ` +
|
|
236
|
+
`engagement. Mentions and DMs there still reach you. Resubscribe with ` +
|
|
237
|
+
`${this.toolPrefix}--subscribe_channel, raise/disable its limit with ` +
|
|
238
|
+
`set_channel_idle_limit, or check what you're missing with ${this.toolPrefix}--channel_missed.`,
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
// Unsubscribe failed — keep the channel counted so we retry on the next
|
|
246
|
+
// ambient message rather than silently giving up.
|
|
247
|
+
this.state.counters[channelId] = next;
|
|
248
|
+
this.persistNow();
|
|
249
|
+
return {};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
this.state.counters[channelId] = next;
|
|
253
|
+
this.scheduleFlush();
|
|
254
|
+
return {};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── internals ──
|
|
258
|
+
|
|
259
|
+
private limitFor(channelId: string): number {
|
|
260
|
+
const o = this.state.overrides[channelId];
|
|
261
|
+
if (o === 'off') return Infinity;
|
|
262
|
+
if (typeof o === 'number') return o;
|
|
263
|
+
return this.defaultLimitChars;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Pull (channelId, ambient char count) out of an MCPL message event for our
|
|
267
|
+
* target server, or null if it isn't ambient traffic we should count. */
|
|
268
|
+
private extractAmbient(event: ProcessEvent): { channelId: string; chars: number } | null {
|
|
269
|
+
if (event.type === 'mcpl:push-event') {
|
|
270
|
+
if (event.serverId !== this.serverId) return null;
|
|
271
|
+
const origin = (event.origin ?? {}) as Record<string, unknown>;
|
|
272
|
+
if (origin.isMention || origin.isDM) return null;
|
|
273
|
+
const channelId = origin.channelId;
|
|
274
|
+
if (typeof channelId !== 'string' || channelId.length === 0) return null;
|
|
275
|
+
return { channelId, chars: textLen(event.content) };
|
|
276
|
+
}
|
|
277
|
+
if (event.type === 'mcpl:channel-incoming') {
|
|
278
|
+
if (event.serverId !== this.serverId) return null;
|
|
279
|
+
const md = (event.metadata ?? {}) as Record<string, unknown>;
|
|
280
|
+
if (md.isMention || md.isDM) return null;
|
|
281
|
+
if (typeof event.channelId !== 'string' || event.channelId.length === 0) return null;
|
|
282
|
+
return { channelId: event.channelId, chars: textLen(event.content) };
|
|
283
|
+
}
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private persistNow(): void {
|
|
288
|
+
if (this.flushTimer) {
|
|
289
|
+
clearTimeout(this.flushTimer);
|
|
290
|
+
this.flushTimer = null;
|
|
291
|
+
}
|
|
292
|
+
this.ctx?.setState(this.state);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Debounced persistence so a firehose channel doesn't drive a chronicle
|
|
296
|
+
* write per message. Durability target: a clean restart flushes via stop();
|
|
297
|
+
* a hard crash loses at most the debounce window of counter increments. */
|
|
298
|
+
private scheduleFlush(): void {
|
|
299
|
+
if (this.flushTimer) return;
|
|
300
|
+
this.flushTimer = setTimeout(() => {
|
|
301
|
+
this.flushTimer = null;
|
|
302
|
+
this.ctx?.setState(this.state);
|
|
303
|
+
}, FLUSH_DEBOUNCE_MS);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Total length of text content blocks (non-text blocks count as 0). */
|
|
308
|
+
function textLen(content: unknown): number {
|
|
309
|
+
if (!Array.isArray(content)) return 0;
|
|
310
|
+
let n = 0;
|
|
311
|
+
for (const b of content) {
|
|
312
|
+
if (b && typeof b === 'object' && (b as { type?: unknown }).type === 'text') {
|
|
313
|
+
const t = (b as { text?: unknown }).text;
|
|
314
|
+
if (typeof t === 'string') n += t.length;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return n;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function ok(message: string): ToolResult {
|
|
321
|
+
return { success: true, data: { message }, isError: false };
|
|
322
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TimeModule — provides the agent with wall-clock awareness.
|
|
3
|
+
*
|
|
4
|
+
* On fresh session start, injects a message stating the session's start time.
|
|
5
|
+
* Exposes `time:now` tool so the agent can check the current time on demand.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
Module,
|
|
10
|
+
ModuleContext,
|
|
11
|
+
ProcessEvent,
|
|
12
|
+
ProcessState,
|
|
13
|
+
EventResponse,
|
|
14
|
+
ToolDefinition,
|
|
15
|
+
ToolCall,
|
|
16
|
+
ToolResult,
|
|
17
|
+
} from '@animalabs/agent-framework';
|
|
18
|
+
|
|
19
|
+
interface TimeState {
|
|
20
|
+
sessionStartAnnounced?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function formatNow(date: Date = new Date()): {
|
|
24
|
+
iso: string;
|
|
25
|
+
local: string;
|
|
26
|
+
timezone: string;
|
|
27
|
+
unixMs: number;
|
|
28
|
+
} {
|
|
29
|
+
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
30
|
+
return {
|
|
31
|
+
iso: date.toISOString(),
|
|
32
|
+
local: date.toString(),
|
|
33
|
+
timezone,
|
|
34
|
+
unixMs: date.getTime(),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class TimeModule implements Module {
|
|
39
|
+
readonly name = 'time';
|
|
40
|
+
|
|
41
|
+
private ctx: ModuleContext | null = null;
|
|
42
|
+
|
|
43
|
+
async start(ctx: ModuleContext): Promise<void> {
|
|
44
|
+
this.ctx = ctx;
|
|
45
|
+
|
|
46
|
+
const state = ctx.getState<TimeState>() ?? {};
|
|
47
|
+
if (state.sessionStartAnnounced) return;
|
|
48
|
+
|
|
49
|
+
const now = formatNow();
|
|
50
|
+
const text =
|
|
51
|
+
`The time at the start of this session is: ${now.iso} ` +
|
|
52
|
+
`(local: ${now.local}, timezone: ${now.timezone}).`;
|
|
53
|
+
|
|
54
|
+
ctx.addMessage('user', [{ type: 'text', text }]);
|
|
55
|
+
ctx.setState<TimeState>({ ...state, sessionStartAnnounced: true });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async stop(): Promise<void> {
|
|
59
|
+
this.ctx = null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async onProcess(_event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
|
|
63
|
+
return {};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
getTools(): ToolDefinition[] {
|
|
67
|
+
return [
|
|
68
|
+
{
|
|
69
|
+
name: 'now',
|
|
70
|
+
description: 'Return the current wall-clock time as ISO 8601, local string, timezone, and unix milliseconds.',
|
|
71
|
+
inputSchema: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: {},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async handleToolCall(call: ToolCall): Promise<ToolResult> {
|
|
80
|
+
if (call.name === 'now') {
|
|
81
|
+
return { success: true, data: formatNow() };
|
|
82
|
+
}
|
|
83
|
+
return { success: false, isError: true, error: `Unknown tool: ${call.name}` };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TuiModule — handles external-message events from the TUI/CLI.
|
|
3
|
+
*
|
|
4
|
+
* Converts them to context messages and triggers inference.
|
|
5
|
+
* Follows the same pattern as ApiModule's handleMessage().
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
Module,
|
|
10
|
+
ModuleContext,
|
|
11
|
+
ProcessState,
|
|
12
|
+
ProcessEvent,
|
|
13
|
+
EventResponse,
|
|
14
|
+
ToolDefinition,
|
|
15
|
+
ToolCall,
|
|
16
|
+
ToolResult,
|
|
17
|
+
} from '@animalabs/agent-framework';
|
|
18
|
+
|
|
19
|
+
export class TuiModule implements Module {
|
|
20
|
+
readonly name = 'tui';
|
|
21
|
+
|
|
22
|
+
async start(_ctx: ModuleContext): Promise<void> {}
|
|
23
|
+
async stop(): Promise<void> {}
|
|
24
|
+
getTools(): ToolDefinition[] { return []; }
|
|
25
|
+
|
|
26
|
+
async handleToolCall(_call: ToolCall): Promise<ToolResult> {
|
|
27
|
+
return { success: false, error: 'TuiModule has no tools', isError: true };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async onProcess(event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
|
|
31
|
+
if (event.type !== 'external-message') return {};
|
|
32
|
+
|
|
33
|
+
const source = (event as { source: string }).source;
|
|
34
|
+
if (source !== 'tui' && source !== 'cli' && source !== 'system' && source !== 'headless') return {};
|
|
35
|
+
|
|
36
|
+
const content = (event as { content: unknown }).content;
|
|
37
|
+
const text = typeof content === 'string' ? content : JSON.stringify(content);
|
|
38
|
+
const triggerInference = (event as { triggerInference?: boolean }).triggerInference;
|
|
39
|
+
const targetAgents = (event as { targetAgents?: string[] }).targetAgents;
|
|
40
|
+
|
|
41
|
+
// IPC/headless wakes carry no channel locus: the wake clears any
|
|
42
|
+
// active-channel routing, so a plain-prose reply is NOT delivered to the
|
|
43
|
+
// sender — it either falls back to the agent's home/default publish
|
|
44
|
+
// channel (possibly the wrong audience) or strands in the chronicle
|
|
45
|
+
// (routeSpeech: "no locus"). Agents can't see that from the message
|
|
46
|
+
// itself, so tell them explicitly; otherwise they write replies into the
|
|
47
|
+
// void (labclaude's stranded "Ack, ops" reply, 2026-07-09).
|
|
48
|
+
const blocks: Array<{ type: 'text'; text: string }> = [{ type: 'text', text }];
|
|
49
|
+
if (source === 'headless') {
|
|
50
|
+
blocks.push({
|
|
51
|
+
type: 'text',
|
|
52
|
+
text:
|
|
53
|
+
'[host note: this message arrived over IPC and carries no channel locus — ' +
|
|
54
|
+
'any active-channel routing from your previous turn no longer applies. A plain-prose ' +
|
|
55
|
+
'reply will NOT reach the sender: it may fall back to your home/default channel or stay ' +
|
|
56
|
+
'in your chronicle only. To answer a specific person or channel, use an explicit send tool; ' +
|
|
57
|
+
'if no reply is needed, none is expected.]',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const response: EventResponse = {
|
|
62
|
+
addMessages: [
|
|
63
|
+
{
|
|
64
|
+
participant: 'user',
|
|
65
|
+
content: blocks,
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
if (triggerInference !== false) {
|
|
71
|
+
response.requestInference = targetAgents ?? true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return response;
|
|
75
|
+
}
|
|
76
|
+
}
|