@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.
Files changed (123) hide show
  1. package/.env.example +20 -0
  2. package/.github/workflows/publish.yml +65 -0
  3. package/ARCHITECTURE.md +355 -0
  4. package/CHANGELOG.md +30 -0
  5. package/HEADLESS-FLEET-PLAN.md +330 -0
  6. package/README.md +189 -0
  7. package/UNIFIED-TREE-PLAN.md +242 -0
  8. package/bun.lock +288 -0
  9. package/docs/AGENT-MEMORY-GUIDE.md +214 -0
  10. package/docs/AGENT-ONBOARDING.md +541 -0
  11. package/docs/ATTENTION-AND-GATING.md +120 -0
  12. package/docs/DEPLOYMENTS.md +130 -0
  13. package/docs/DEV-ENVIRONMENT.md +215 -0
  14. package/docs/LIBRARY-PIPELINE.md +286 -0
  15. package/docs/LOCUS-ROUTING-DESIGN.md +115 -0
  16. package/docs/claudeai-evacuation.md +228 -0
  17. package/docs/debug-context-api.md +208 -0
  18. package/docs/webui-deployment.md +219 -0
  19. package/package.json +33 -0
  20. package/recipes/SETUP.md +308 -0
  21. package/recipes/TRIUMVIRATE-SETUP.md +467 -0
  22. package/recipes/claude-export-revive.json +19 -0
  23. package/recipes/clerk.json +157 -0
  24. package/recipes/knowledge-miner.json +174 -0
  25. package/recipes/knowledge-reviewer.json +76 -0
  26. package/recipes/mcpl-editor-test.json +38 -0
  27. package/recipes/prompts/transplant-addendum.md +17 -0
  28. package/recipes/triumvirate.json +63 -0
  29. package/recipes/webui-fleet-test.json +27 -0
  30. package/recipes/webui-test.json +16 -0
  31. package/recipes/zulip-miner.json +87 -0
  32. package/scripts/connectome-doctor +373 -0
  33. package/scripts/evacuator.ts +956 -0
  34. package/scripts/import-claudeai-export.ts +747 -0
  35. package/scripts/lib/line-reader.ts +53 -0
  36. package/scripts/test-historical-thinking.ts +148 -0
  37. package/scripts/warmup-session.ts +336 -0
  38. package/src/agent-name.ts +58 -0
  39. package/src/commands.ts +1074 -0
  40. package/src/headless.ts +528 -0
  41. package/src/index.ts +867 -0
  42. package/src/logging-adapter.ts +208 -0
  43. package/src/mcpl-config.ts +199 -0
  44. package/src/modules/activity-module.ts +270 -0
  45. package/src/modules/channel-mode-module.ts +260 -0
  46. package/src/modules/fleet-module.ts +1705 -0
  47. package/src/modules/fleet-types.ts +225 -0
  48. package/src/modules/lessons-module.ts +465 -0
  49. package/src/modules/mcpl-admin-module.ts +345 -0
  50. package/src/modules/retrieval-module.ts +327 -0
  51. package/src/modules/settings-module.ts +143 -0
  52. package/src/modules/subagent-module.ts +2074 -0
  53. package/src/modules/subscription-gc-module.ts +322 -0
  54. package/src/modules/time-module.ts +85 -0
  55. package/src/modules/tui-module.ts +76 -0
  56. package/src/modules/web-ui-curve-page.ts +249 -0
  57. package/src/modules/web-ui-module.ts +2595 -0
  58. package/src/recipe.ts +1003 -0
  59. package/src/session-manager.ts +278 -0
  60. package/src/state/agent-tree-reducer.ts +431 -0
  61. package/src/state/fleet-tree-aggregator.ts +195 -0
  62. package/src/strategies/frontdesk-strategy.ts +364 -0
  63. package/src/synesthete.ts +68 -0
  64. package/src/tui.ts +2200 -0
  65. package/src/types/bun-ffi.d.ts +6 -0
  66. package/src/web/protocol.ts +648 -0
  67. package/test/agent-name.test.ts +62 -0
  68. package/test/agent-tree-fleet-launch.test.ts +64 -0
  69. package/test/agent-tree-reducer-parity.test.ts +194 -0
  70. package/test/agent-tree-reducer.test.ts +443 -0
  71. package/test/agent-tree-rollup.test.ts +109 -0
  72. package/test/autobio-progress-snapshot.test.ts +40 -0
  73. package/test/claudeai-export-importer.test.ts +386 -0
  74. package/test/evacuator.test.ts +332 -0
  75. package/test/fleet-commands.test.ts +244 -0
  76. package/test/fleet-no-subfleets.test.ts +147 -0
  77. package/test/fleet-orchestration.test.ts +353 -0
  78. package/test/fleet-recipe.test.ts +124 -0
  79. package/test/fleet-route.test.ts +44 -0
  80. package/test/fleet-smoke.test.ts +479 -0
  81. package/test/fleet-subscribe-union-e2e.test.ts +123 -0
  82. package/test/fleet-subscribe-union.test.ts +85 -0
  83. package/test/fleet-tree-aggregator-e2e.test.ts +110 -0
  84. package/test/fleet-tree-aggregator.test.ts +215 -0
  85. package/test/frontdesk-strategy.test.ts +326 -0
  86. package/test/headless-describe.test.ts +182 -0
  87. package/test/headless-smoke.test.ts +190 -0
  88. package/test/logging-adapter.test.ts +87 -0
  89. package/test/mcpl-admin-module.test.ts +213 -0
  90. package/test/mcpl-agent-overlay.test.ts +126 -0
  91. package/test/mock-headless-child.ts +133 -0
  92. package/test/recipe-cache-ttl.test.ts +37 -0
  93. package/test/recipe-env.test.ts +157 -0
  94. package/test/recipe-path-resolution.test.ts +170 -0
  95. package/test/subagent-async-timeout.test.ts +381 -0
  96. package/test/subagent-fork-materialise.test.ts +241 -0
  97. package/test/subagent-peek-scoping.test.ts +180 -0
  98. package/test/subagent-peek-zombie.test.ts +148 -0
  99. package/test/subagent-reaper-in-flight.test.ts +229 -0
  100. package/test/subscription-gc-module.test.ts +136 -0
  101. package/test/warmup-handoff.test.ts +150 -0
  102. package/test/web-ui-bind.test.ts +51 -0
  103. package/test/web-ui-module.test.ts +246 -0
  104. package/test/web-ui-protocol.test.ts +0 -0
  105. package/tsconfig.json +14 -0
  106. package/web/bun.lock +357 -0
  107. package/web/index.html +12 -0
  108. package/web/package.json +24 -0
  109. package/web/src/App.tsx +1931 -0
  110. package/web/src/Context.tsx +158 -0
  111. package/web/src/ContextDocument.tsx +150 -0
  112. package/web/src/Files.tsx +271 -0
  113. package/web/src/Lessons.tsx +164 -0
  114. package/web/src/Mcpl.tsx +310 -0
  115. package/web/src/Stream.tsx +119 -0
  116. package/web/src/TreeSidebar.tsx +283 -0
  117. package/web/src/Usage.tsx +182 -0
  118. package/web/src/main.tsx +7 -0
  119. package/web/src/styles.css +26 -0
  120. package/web/src/tree.ts +268 -0
  121. package/web/src/wire.ts +120 -0
  122. package/web/tsconfig.json +21 -0
  123. 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
+ }