@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,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared wire-protocol types for the headless daemon ↔ FleetModule IPC.
|
|
3
|
+
*
|
|
4
|
+
* Both `src/headless.ts` (child runtime) and `src/modules/fleet-module.ts`
|
|
5
|
+
* (parent module) import from here so the JSONL envelope shapes stay
|
|
6
|
+
* identical at both ends.
|
|
7
|
+
*
|
|
8
|
+
* See HEADLESS-FLEET-PLAN.md for the full protocol spec.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Parent → Child: commands
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export type IncomingCommand =
|
|
16
|
+
/** Set which event types the child emits. Supports glob: ["tool:*", "lifecycle"]. */
|
|
17
|
+
| { type: 'subscribe'; events: string[] }
|
|
18
|
+
/** Inject a user-like message; equivalent to typing in the child's TUI. */
|
|
19
|
+
| { type: 'text'; content: string }
|
|
20
|
+
/** Run a slash command in the child's commands.ts handler. */
|
|
21
|
+
| { type: 'command'; command: string }
|
|
22
|
+
/** Graceful (default) or immediate shutdown. */
|
|
23
|
+
| { type: 'shutdown'; graceful?: boolean }
|
|
24
|
+
/** Request a state snapshot. The child responds with a single 'snapshot'
|
|
25
|
+
* event carrying the full agent tree, exempt from subscription filtering.
|
|
26
|
+
* Used as a recovery verb (TUI cold start, reconnect, after restart) — not
|
|
27
|
+
* a query verb. See UNIFIED-TREE-PLAN.md §3 for the lockstep model. */
|
|
28
|
+
| { type: 'describe'; corrId?: string }
|
|
29
|
+
/** Pull the child's lesson library. Response is `lessons-snapshot`. */
|
|
30
|
+
| { type: 'request-lessons'; corrId?: string }
|
|
31
|
+
/** Pull the child's workspace mount list. Response is `workspace-mounts-snapshot`. */
|
|
32
|
+
| { type: 'request-workspace-mounts'; corrId?: string }
|
|
33
|
+
/** Pull a recursive listing of one mount in the child. Response is `workspace-tree-snapshot`. */
|
|
34
|
+
| { type: 'request-workspace-tree'; mount: string; corrId?: string }
|
|
35
|
+
/** Read a workspace file from the child. Response is `workspace-file-snapshot`. */
|
|
36
|
+
| { type: 'request-workspace-file'; path: string; corrId?: string }
|
|
37
|
+
/** Cancel a specific in-process subagent by display name. Response is
|
|
38
|
+
* `cancel-subagent-result`. The child looks the agent up in its own
|
|
39
|
+
* SubagentModule, so this is the only way to stop a subagent that lives
|
|
40
|
+
* in a fleet child rather than the conductor. */
|
|
41
|
+
| { type: 'cancel-subagent'; name: string; corrId?: string };
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Child → Parent: events
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/** Lifecycle events added by the headless runtime (not framework TraceEvents). */
|
|
48
|
+
export type LifecycleEvent =
|
|
49
|
+
| { type: 'lifecycle'; phase: 'ready'; pid: number; dataDir: string; recipe?: string; ts?: number }
|
|
50
|
+
| { type: 'lifecycle'; phase: 'idle'; ts?: number }
|
|
51
|
+
| { type: 'lifecycle'; phase: 'exiting'; reason: string; ts?: number };
|
|
52
|
+
|
|
53
|
+
/** Output line from a slash-command run, surfaced as an event. */
|
|
54
|
+
export interface CommandOutputEvent {
|
|
55
|
+
type: 'command-output';
|
|
56
|
+
text: string;
|
|
57
|
+
style: string | null;
|
|
58
|
+
ts?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Response to a {type:'describe'} request. Carries the child's full agent
|
|
62
|
+
* tree as folded by AgentTreeReducer. Always emitted regardless of the
|
|
63
|
+
* client's subscription filter. */
|
|
64
|
+
export interface SnapshotEvent {
|
|
65
|
+
type: 'snapshot';
|
|
66
|
+
corrId?: string;
|
|
67
|
+
/** Wall-clock at which the child built the snapshot. Receivers should drop
|
|
68
|
+
* events with `timestamp < asOfTs` after applying. */
|
|
69
|
+
asOfTs: number;
|
|
70
|
+
child: {
|
|
71
|
+
name: string;
|
|
72
|
+
pid: number;
|
|
73
|
+
recipe?: string;
|
|
74
|
+
startedAt: number;
|
|
75
|
+
};
|
|
76
|
+
/** Serialized AgentTreeReducer state. Shape matches AgentTreeSnapshot in
|
|
77
|
+
* src/state/agent-tree-reducer.ts. Kept structurally typed here to avoid
|
|
78
|
+
* cross-cutting imports. */
|
|
79
|
+
tree: {
|
|
80
|
+
nodes: Array<Record<string, unknown>>;
|
|
81
|
+
callIdIndex: Record<string, string>;
|
|
82
|
+
};
|
|
83
|
+
ts?: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Response to a {type:'request-lessons'} request. */
|
|
87
|
+
export interface LessonsSnapshotEvent {
|
|
88
|
+
type: 'lessons-snapshot';
|
|
89
|
+
corrId?: string;
|
|
90
|
+
/** True iff the child has LessonsModule loaded. */
|
|
91
|
+
loaded: boolean;
|
|
92
|
+
lessons: Array<{
|
|
93
|
+
id: string;
|
|
94
|
+
content: string;
|
|
95
|
+
confidence: number;
|
|
96
|
+
tags: string[];
|
|
97
|
+
deprecated: boolean;
|
|
98
|
+
deprecationReason?: string;
|
|
99
|
+
created?: number;
|
|
100
|
+
updated?: number;
|
|
101
|
+
}>;
|
|
102
|
+
ts?: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Response to a {type:'request-workspace-mounts'} request. */
|
|
106
|
+
export interface WorkspaceMountsSnapshotEvent {
|
|
107
|
+
type: 'workspace-mounts-snapshot';
|
|
108
|
+
corrId?: string;
|
|
109
|
+
loaded: boolean;
|
|
110
|
+
mounts: Array<{ name: string; path: string; mode: string }>;
|
|
111
|
+
ts?: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Response to a {type:'request-workspace-tree'} request. */
|
|
115
|
+
export interface WorkspaceTreeSnapshotEvent {
|
|
116
|
+
type: 'workspace-tree-snapshot';
|
|
117
|
+
corrId?: string;
|
|
118
|
+
mount: string;
|
|
119
|
+
entries: Array<{ path: string; size: number }>;
|
|
120
|
+
ts?: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Response to a {type:'cancel-subagent'} request. */
|
|
124
|
+
export interface CancelSubagentResultEvent {
|
|
125
|
+
type: 'cancel-subagent-result';
|
|
126
|
+
corrId?: string;
|
|
127
|
+
/** Subagent display name the parent asked us to cancel. */
|
|
128
|
+
name: string;
|
|
129
|
+
/** True iff a matching live subagent was found and signalled to stop. */
|
|
130
|
+
cancelled: boolean;
|
|
131
|
+
/** Set when cancelled=false to explain why (e.g. "subagent module not
|
|
132
|
+
* loaded", "subagent not running"). */
|
|
133
|
+
reason?: string;
|
|
134
|
+
ts?: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Response to a {type:'request-workspace-file'} request. */
|
|
138
|
+
export interface WorkspaceFileSnapshotEvent {
|
|
139
|
+
type: 'workspace-file-snapshot';
|
|
140
|
+
corrId?: string;
|
|
141
|
+
path: string;
|
|
142
|
+
totalLines: number;
|
|
143
|
+
fromLine: number;
|
|
144
|
+
toLine: number;
|
|
145
|
+
content: string;
|
|
146
|
+
truncated: boolean;
|
|
147
|
+
/** Set on lookup failure (file not found, mount unknown, etc.). */
|
|
148
|
+
error?: string;
|
|
149
|
+
ts?: number;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A wire event from the child. In practice this is either a framework
|
|
154
|
+
* TraceEvent (typed loosely as Record<string,unknown>), or one of our
|
|
155
|
+
* lifecycle / command-output additions.
|
|
156
|
+
*/
|
|
157
|
+
export type WireEvent =
|
|
158
|
+
| LifecycleEvent
|
|
159
|
+
| CommandOutputEvent
|
|
160
|
+
| SnapshotEvent
|
|
161
|
+
| LessonsSnapshotEvent
|
|
162
|
+
| WorkspaceMountsSnapshotEvent
|
|
163
|
+
| WorkspaceTreeSnapshotEvent
|
|
164
|
+
| WorkspaceFileSnapshotEvent
|
|
165
|
+
| CancelSubagentResultEvent
|
|
166
|
+
// Arbitrary framework TraceEvent passthrough. The child stamps every emitted
|
|
167
|
+
// event with `ts: Date.now()` in `emit()` (see headless.ts), so ts is always
|
|
168
|
+
// present on the wire even when the underlying TraceEvent doesn't declare it.
|
|
169
|
+
| (Record<string, unknown> & { type: string; ts?: number });
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Subscription matching (used by both ends)
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Match an event type against a subscription set. Supports:
|
|
177
|
+
* - exact match: "inference:completed"
|
|
178
|
+
* - prefix wildcard: "tool:*" (matches "tool:started", "tool:completed", ...)
|
|
179
|
+
* - global wildcard: "*"
|
|
180
|
+
*/
|
|
181
|
+
export function matchesSubscription(eventType: string, subscription: Set<string>): boolean {
|
|
182
|
+
if (subscription.has('*')) return true;
|
|
183
|
+
if (subscription.has(eventType)) return true;
|
|
184
|
+
for (const pattern of subscription) {
|
|
185
|
+
if (pattern.endsWith('*') && eventType.startsWith(pattern.slice(0, -1))) return true;
|
|
186
|
+
}
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Direct-routing helper for TUI input
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
export interface FleetRoute {
|
|
195
|
+
/** Target child's name as parsed from the prefix. */
|
|
196
|
+
childName: string;
|
|
197
|
+
/** Message body after the prefix. */
|
|
198
|
+
content: string;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Parse a "@childname rest of message" line as a direct-route command,
|
|
203
|
+
* bypassing the conductor agent. Returns null if the line is not an
|
|
204
|
+
* @-prefixed route (so TUI falls back to the default chat-to-conductor path).
|
|
205
|
+
*
|
|
206
|
+
* Accepted forms:
|
|
207
|
+
* "@miner hello there" → { childName: "miner", content: "hello there" }
|
|
208
|
+
* "@miner: hello" → { childName: "miner", content: "hello" }
|
|
209
|
+
* "@my-bot list channels" → { childName: "my-bot", content: "list channels" }
|
|
210
|
+
*
|
|
211
|
+
* Rejected:
|
|
212
|
+
* "@miner" → null (no payload)
|
|
213
|
+
* "no prefix" → null
|
|
214
|
+
* "@@escaped" → null (literal @, e.g. paste of an email)
|
|
215
|
+
*/
|
|
216
|
+
export function parseFleetRoute(input: string): FleetRoute | null {
|
|
217
|
+
const trimmed = input.trimStart();
|
|
218
|
+
if (!trimmed.startsWith('@') || trimmed.startsWith('@@')) return null;
|
|
219
|
+
const match = /^@([a-zA-Z0-9_.-]+)(?::|\s)\s*(.+)$/s.exec(trimmed);
|
|
220
|
+
if (!match) return null;
|
|
221
|
+
const childName = match[1]!;
|
|
222
|
+
const content = match[2]!.trim();
|
|
223
|
+
if (!content) return null;
|
|
224
|
+
return { childName, content };
|
|
225
|
+
}
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LessonsModule — persistent knowledge store backed by Chronicle.
|
|
3
|
+
*
|
|
4
|
+
* Lessons are units of extracted knowledge with:
|
|
5
|
+
* - Confidence scores (0–1)
|
|
6
|
+
* - Tags for categorization
|
|
7
|
+
* - Evidence links (source message references)
|
|
8
|
+
*
|
|
9
|
+
* The module also implements gatherContext() to auto-inject
|
|
10
|
+
* relevant lessons into the agent's context before inference.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
Module,
|
|
15
|
+
ModuleContext,
|
|
16
|
+
ProcessState,
|
|
17
|
+
ProcessEvent,
|
|
18
|
+
EventResponse,
|
|
19
|
+
ToolDefinition,
|
|
20
|
+
ToolCall,
|
|
21
|
+
ToolResult,
|
|
22
|
+
} from '@animalabs/agent-framework';
|
|
23
|
+
import type { ContextInjection } from '@animalabs/context-manager';
|
|
24
|
+
import { randomUUID } from 'node:crypto';
|
|
25
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
26
|
+
import { dirname } from 'node:path';
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Types
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
export interface Lesson {
|
|
33
|
+
id: string;
|
|
34
|
+
content: string;
|
|
35
|
+
confidence: number;
|
|
36
|
+
tags: string[];
|
|
37
|
+
evidence: string[];
|
|
38
|
+
created: number;
|
|
39
|
+
updated: number;
|
|
40
|
+
deprecated: boolean;
|
|
41
|
+
deprecationReason?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface LessonsState {
|
|
45
|
+
lessons: Lesson[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface CreateInput {
|
|
49
|
+
content: string;
|
|
50
|
+
tags: string[];
|
|
51
|
+
evidence?: string[];
|
|
52
|
+
confidence?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface UpdateInput {
|
|
56
|
+
id: string;
|
|
57
|
+
content?: string;
|
|
58
|
+
tags?: string[];
|
|
59
|
+
confidence?: number;
|
|
60
|
+
evidence?: string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface DeprecateInput {
|
|
64
|
+
id: string;
|
|
65
|
+
reason: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface QueryInput {
|
|
69
|
+
text?: string;
|
|
70
|
+
tags?: string[];
|
|
71
|
+
minConfidence?: number;
|
|
72
|
+
limit?: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface ListInput {
|
|
76
|
+
tags?: string[];
|
|
77
|
+
sort?: 'confidence' | 'created' | 'updated';
|
|
78
|
+
limit?: number;
|
|
79
|
+
includeDeprecated?: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface IdInput {
|
|
83
|
+
id: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Module
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
export class LessonsModule implements Module {
|
|
91
|
+
readonly name = 'lessons';
|
|
92
|
+
|
|
93
|
+
private ctx: ModuleContext | null = null;
|
|
94
|
+
private state: LessonsState = { lessons: [] };
|
|
95
|
+
private globalPath: string | null;
|
|
96
|
+
|
|
97
|
+
constructor(opts?: { globalPath?: string }) {
|
|
98
|
+
this.globalPath = opts?.globalPath ?? null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async start(ctx: ModuleContext): Promise<void> {
|
|
102
|
+
this.ctx = ctx;
|
|
103
|
+
const saved = ctx.getState<LessonsState>();
|
|
104
|
+
if (saved) {
|
|
105
|
+
this.state = saved;
|
|
106
|
+
}
|
|
107
|
+
// Merge in lessons from the global shared file
|
|
108
|
+
if (this.globalPath) {
|
|
109
|
+
this.mergeFromGlobal();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async stop(): Promise<void> {
|
|
114
|
+
this.ctx = null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
getTools(): ToolDefinition[] {
|
|
118
|
+
return [
|
|
119
|
+
{
|
|
120
|
+
name: 'create',
|
|
121
|
+
description: 'Create a new lesson (unit of extracted knowledge).',
|
|
122
|
+
inputSchema: {
|
|
123
|
+
type: 'object',
|
|
124
|
+
properties: {
|
|
125
|
+
content: { type: 'string', description: 'The knowledge content' },
|
|
126
|
+
tags: {
|
|
127
|
+
type: 'array',
|
|
128
|
+
items: { type: 'string' },
|
|
129
|
+
description: 'Categorization tags (e.g., people, process, decision, technical)',
|
|
130
|
+
},
|
|
131
|
+
evidence: {
|
|
132
|
+
type: 'array',
|
|
133
|
+
items: { type: 'string' },
|
|
134
|
+
description: 'Source references (e.g., stream:topic:messageId)',
|
|
135
|
+
},
|
|
136
|
+
confidence: { type: 'number', description: 'Initial confidence 0–1 (default: 0.5)' },
|
|
137
|
+
},
|
|
138
|
+
required: ['content', 'tags'],
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
name: 'update',
|
|
143
|
+
description: 'Update an existing lesson.',
|
|
144
|
+
inputSchema: {
|
|
145
|
+
type: 'object',
|
|
146
|
+
properties: {
|
|
147
|
+
id: { type: 'string', description: 'Lesson ID' },
|
|
148
|
+
content: { type: 'string', description: 'Updated content' },
|
|
149
|
+
tags: { type: 'array', items: { type: 'string' }, description: 'Updated tags' },
|
|
150
|
+
confidence: { type: 'number', description: 'Updated confidence' },
|
|
151
|
+
evidence: { type: 'array', items: { type: 'string' }, description: 'Additional evidence' },
|
|
152
|
+
},
|
|
153
|
+
required: ['id'],
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: 'deprecate',
|
|
158
|
+
description: 'Mark a lesson as deprecated (no longer considered accurate).',
|
|
159
|
+
inputSchema: {
|
|
160
|
+
type: 'object',
|
|
161
|
+
properties: {
|
|
162
|
+
id: { type: 'string', description: 'Lesson ID' },
|
|
163
|
+
reason: { type: 'string', description: 'Why this lesson is being deprecated' },
|
|
164
|
+
},
|
|
165
|
+
required: ['id', 'reason'],
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
name: 'query',
|
|
170
|
+
description: 'Search lessons by text and/or tags.',
|
|
171
|
+
inputSchema: {
|
|
172
|
+
type: 'object',
|
|
173
|
+
properties: {
|
|
174
|
+
text: { type: 'string', description: 'Text to search for (keyword matching)' },
|
|
175
|
+
tags: { type: 'array', items: { type: 'string' }, description: 'Filter by tags (any match)' },
|
|
176
|
+
minConfidence: { type: 'number', description: 'Minimum confidence threshold' },
|
|
177
|
+
limit: { type: 'number', description: 'Max results (default: 20)' },
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
name: 'list',
|
|
183
|
+
description: 'List all lessons, optionally filtered and sorted.',
|
|
184
|
+
inputSchema: {
|
|
185
|
+
type: 'object',
|
|
186
|
+
properties: {
|
|
187
|
+
tags: { type: 'array', items: { type: 'string' }, description: 'Filter by tags' },
|
|
188
|
+
sort: {
|
|
189
|
+
type: 'string',
|
|
190
|
+
description: 'Sort by: confidence, created, or updated (default: confidence)',
|
|
191
|
+
},
|
|
192
|
+
limit: { type: 'number', description: 'Max results (default: 50)' },
|
|
193
|
+
includeDeprecated: { type: 'boolean', description: 'Include deprecated lessons' },
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
name: 'boost',
|
|
199
|
+
description: 'Increase a lesson\'s confidence (it proved useful).',
|
|
200
|
+
inputSchema: {
|
|
201
|
+
type: 'object',
|
|
202
|
+
properties: {
|
|
203
|
+
id: { type: 'string', description: 'Lesson ID' },
|
|
204
|
+
},
|
|
205
|
+
required: ['id'],
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
name: 'demote',
|
|
210
|
+
description: 'Decrease a lesson\'s confidence (it was wrong or unhelpful).',
|
|
211
|
+
inputSchema: {
|
|
212
|
+
type: 'object',
|
|
213
|
+
properties: {
|
|
214
|
+
id: { type: 'string', description: 'Lesson ID' },
|
|
215
|
+
},
|
|
216
|
+
required: ['id'],
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async handleToolCall(call: ToolCall): Promise<ToolResult> {
|
|
223
|
+
switch (call.name) {
|
|
224
|
+
case 'create': return this.handleCreate(call.input as CreateInput);
|
|
225
|
+
case 'update': return this.handleUpdate(call.input as UpdateInput);
|
|
226
|
+
case 'deprecate': return this.handleDeprecate(call.input as DeprecateInput);
|
|
227
|
+
case 'query': return this.handleQuery(call.input as QueryInput);
|
|
228
|
+
case 'list': return this.handleList(call.input as ListInput);
|
|
229
|
+
case 'boost': return this.handleBoost(call.input as IdInput);
|
|
230
|
+
case 'demote': return this.handleDemote(call.input as IdInput);
|
|
231
|
+
default:
|
|
232
|
+
return { success: false, error: `Unknown tool: ${call.name}`, isError: true };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async onProcess(_event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
|
|
237
|
+
return {};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Lesson injection is handled solely by RetrievalModule (intelligent retrieval
|
|
241
|
+
// pipeline). LessonsModule provides storage + CRUD tools only.
|
|
242
|
+
|
|
243
|
+
// Public accessor for other modules (e.g., RetrievalModule)
|
|
244
|
+
getLessons(): Lesson[] {
|
|
245
|
+
return this.state.lessons;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// =========================================================================
|
|
249
|
+
// Tool Handlers
|
|
250
|
+
// =========================================================================
|
|
251
|
+
|
|
252
|
+
private handleCreate(input: CreateInput): ToolResult {
|
|
253
|
+
const lesson: Lesson = {
|
|
254
|
+
id: randomUUID().slice(0, 8),
|
|
255
|
+
content: input.content,
|
|
256
|
+
confidence: input.confidence ?? 0.5,
|
|
257
|
+
tags: input.tags,
|
|
258
|
+
evidence: input.evidence ?? [],
|
|
259
|
+
created: Date.now(),
|
|
260
|
+
updated: Date.now(),
|
|
261
|
+
deprecated: false,
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
this.state.lessons.push(lesson);
|
|
265
|
+
this.save();
|
|
266
|
+
|
|
267
|
+
return { success: true, data: { id: lesson.id, message: 'Lesson created' } };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private handleUpdate(input: UpdateInput): ToolResult {
|
|
271
|
+
const lesson = this.state.lessons.find(l => l.id === input.id);
|
|
272
|
+
if (!lesson) {
|
|
273
|
+
return { success: false, error: `Lesson not found: ${input.id}`, isError: true };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (input.content !== undefined) lesson.content = input.content;
|
|
277
|
+
if (input.tags !== undefined) lesson.tags = input.tags;
|
|
278
|
+
if (input.confidence !== undefined) lesson.confidence = Math.max(0, Math.min(1, input.confidence));
|
|
279
|
+
if (input.evidence !== undefined) {
|
|
280
|
+
// Merge evidence, dedup
|
|
281
|
+
const existing = new Set(lesson.evidence);
|
|
282
|
+
for (const e of input.evidence) existing.add(e);
|
|
283
|
+
lesson.evidence = [...existing];
|
|
284
|
+
}
|
|
285
|
+
lesson.updated = Date.now();
|
|
286
|
+
this.save();
|
|
287
|
+
|
|
288
|
+
return { success: true, data: { id: lesson.id, message: 'Lesson updated' } };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private handleDeprecate(input: DeprecateInput): ToolResult {
|
|
292
|
+
const lesson = this.state.lessons.find(l => l.id === input.id);
|
|
293
|
+
if (!lesson) {
|
|
294
|
+
return { success: false, error: `Lesson not found: ${input.id}`, isError: true };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
lesson.deprecated = true;
|
|
298
|
+
lesson.deprecationReason = input.reason;
|
|
299
|
+
lesson.updated = Date.now();
|
|
300
|
+
this.save();
|
|
301
|
+
|
|
302
|
+
return { success: true, data: { id: lesson.id, message: 'Lesson deprecated' } };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private handleQuery(input: QueryInput): ToolResult {
|
|
306
|
+
const limit = input.limit ?? 20;
|
|
307
|
+
let results = this.state.lessons.filter(l => !l.deprecated);
|
|
308
|
+
|
|
309
|
+
// Filter by minimum confidence
|
|
310
|
+
if (input.minConfidence !== undefined) {
|
|
311
|
+
results = results.filter(l => l.confidence >= input.minConfidence!);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Filter by tags (any match)
|
|
315
|
+
if (input.tags && input.tags.length > 0) {
|
|
316
|
+
const tagSet = new Set(input.tags.map(t => t.toLowerCase()));
|
|
317
|
+
results = results.filter(l =>
|
|
318
|
+
l.tags.some(t => tagSet.has(t.toLowerCase()))
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Text search (simple keyword matching)
|
|
323
|
+
if (input.text) {
|
|
324
|
+
const keywords = input.text.toLowerCase().split(/\s+/);
|
|
325
|
+
results = results.filter(l => {
|
|
326
|
+
const text = l.content.toLowerCase();
|
|
327
|
+
return keywords.some(kw => text.includes(kw));
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Sort by relevance (confidence descending)
|
|
332
|
+
results.sort((a, b) => b.confidence - a.confidence);
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
success: true,
|
|
336
|
+
data: {
|
|
337
|
+
count: results.length,
|
|
338
|
+
lessons: results.slice(0, limit).map(l => ({
|
|
339
|
+
id: l.id,
|
|
340
|
+
content: l.content,
|
|
341
|
+
confidence: l.confidence,
|
|
342
|
+
tags: l.tags,
|
|
343
|
+
evidence: l.evidence,
|
|
344
|
+
})),
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private handleList(input: ListInput): ToolResult {
|
|
350
|
+
const limit = input.limit ?? 50;
|
|
351
|
+
let results = input.includeDeprecated
|
|
352
|
+
? [...this.state.lessons]
|
|
353
|
+
: this.state.lessons.filter(l => !l.deprecated);
|
|
354
|
+
|
|
355
|
+
// Filter by tags
|
|
356
|
+
if (input.tags && input.tags.length > 0) {
|
|
357
|
+
const tagSet = new Set(input.tags.map(t => t.toLowerCase()));
|
|
358
|
+
results = results.filter(l =>
|
|
359
|
+
l.tags.some(t => tagSet.has(t.toLowerCase()))
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Sort
|
|
364
|
+
const sort = input.sort ?? 'confidence';
|
|
365
|
+
switch (sort) {
|
|
366
|
+
case 'confidence':
|
|
367
|
+
results.sort((a, b) => b.confidence - a.confidence);
|
|
368
|
+
break;
|
|
369
|
+
case 'created':
|
|
370
|
+
results.sort((a, b) => b.created - a.created);
|
|
371
|
+
break;
|
|
372
|
+
case 'updated':
|
|
373
|
+
results.sort((a, b) => b.updated - a.updated);
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
success: true,
|
|
379
|
+
data: {
|
|
380
|
+
total: results.length,
|
|
381
|
+
lessons: results.slice(0, limit).map(l => ({
|
|
382
|
+
id: l.id,
|
|
383
|
+
content: l.content,
|
|
384
|
+
confidence: l.confidence,
|
|
385
|
+
tags: l.tags,
|
|
386
|
+
deprecated: l.deprecated,
|
|
387
|
+
})),
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private handleBoost(input: IdInput): ToolResult {
|
|
393
|
+
const lesson = this.state.lessons.find(l => l.id === input.id);
|
|
394
|
+
if (!lesson) {
|
|
395
|
+
return { success: false, error: `Lesson not found: ${input.id}`, isError: true };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Diminishing returns boost
|
|
399
|
+
lesson.confidence = Math.min(1, lesson.confidence + 0.1 * (1 - lesson.confidence));
|
|
400
|
+
lesson.updated = Date.now();
|
|
401
|
+
this.save();
|
|
402
|
+
|
|
403
|
+
return { success: true, data: { id: lesson.id, confidence: lesson.confidence } };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private handleDemote(input: IdInput): ToolResult {
|
|
407
|
+
const lesson = this.state.lessons.find(l => l.id === input.id);
|
|
408
|
+
if (!lesson) {
|
|
409
|
+
return { success: false, error: `Lesson not found: ${input.id}`, isError: true };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Diminishing returns demote
|
|
413
|
+
lesson.confidence = Math.max(0, lesson.confidence - 0.1 * lesson.confidence);
|
|
414
|
+
lesson.updated = Date.now();
|
|
415
|
+
this.save();
|
|
416
|
+
|
|
417
|
+
return { success: true, data: { id: lesson.id, confidence: lesson.confidence } };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
private save(): void {
|
|
421
|
+
this.ctx?.setState(this.state);
|
|
422
|
+
if (this.globalPath) {
|
|
423
|
+
this.saveToGlobal();
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Merge lessons from the global JSON file. Newer `updated` wins on ID conflicts. */
|
|
428
|
+
private mergeFromGlobal(): void {
|
|
429
|
+
if (!this.globalPath) return;
|
|
430
|
+
let global: LessonsState;
|
|
431
|
+
try {
|
|
432
|
+
global = JSON.parse(readFileSync(this.globalPath, 'utf-8'));
|
|
433
|
+
} catch {
|
|
434
|
+
return; // File doesn't exist yet — nothing to merge
|
|
435
|
+
}
|
|
436
|
+
if (!Array.isArray(global.lessons)) return;
|
|
437
|
+
|
|
438
|
+
const byId = new Map(this.state.lessons.map(l => [l.id, l]));
|
|
439
|
+
let merged = false;
|
|
440
|
+
for (const gl of global.lessons) {
|
|
441
|
+
const existing = byId.get(gl.id);
|
|
442
|
+
if (!existing) {
|
|
443
|
+
this.state.lessons.push(gl);
|
|
444
|
+
merged = true;
|
|
445
|
+
} else if (gl.updated > existing.updated) {
|
|
446
|
+
Object.assign(existing, gl);
|
|
447
|
+
merged = true;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (merged) {
|
|
451
|
+
this.ctx?.setState(this.state);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Write current lessons to the global JSON file. */
|
|
456
|
+
private saveToGlobal(): void {
|
|
457
|
+
if (!this.globalPath) return;
|
|
458
|
+
try {
|
|
459
|
+
mkdirSync(dirname(this.globalPath), { recursive: true });
|
|
460
|
+
writeFileSync(this.globalPath, JSON.stringify({ lessons: this.state.lessons }, null, 2));
|
|
461
|
+
} catch {
|
|
462
|
+
// Best-effort — don't break the module if the file can't be written
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|