@dot-ai/adapter-openclaw 0.11.6 → 0.11.7

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.
@@ -13,6 +13,12 @@ interface CapturedHook {
13
13
  options?: { priority?: number };
14
14
  }
15
15
 
16
+ interface CapturedInternalHook {
17
+ events: string | string[];
18
+ handler: (...args: unknown[]) => unknown;
19
+ opts?: { name?: string; description?: string };
20
+ }
21
+
16
22
  interface CapturedService {
17
23
  id: string;
18
24
  start: (ctx: { logger: { info: (msg: string) => void } }) => void;
@@ -27,6 +33,7 @@ interface CapturedTool {
27
33
  function createMockOpenClawApi() {
28
34
  const logs: string[] = [];
29
35
  const hooks: CapturedHook[] = [];
36
+ const internalHooks: CapturedInternalHook[] = [];
30
37
  const services: CapturedService[] = [];
31
38
  const tools: CapturedTool[] = [];
32
39
 
@@ -43,6 +50,13 @@ function createMockOpenClawApi() {
43
50
  ) {
44
51
  hooks.push({ event, handler, options });
45
52
  },
53
+ registerHook(
54
+ events: string | string[],
55
+ handler: (...args: unknown[]) => unknown,
56
+ opts?: { name?: string; description?: string },
57
+ ) {
58
+ internalHooks.push({ events, handler, opts });
59
+ },
46
60
  registerService(service: CapturedService) {
47
61
  services.push(service);
48
62
  },
@@ -51,31 +65,70 @@ function createMockOpenClawApi() {
51
65
  },
52
66
  };
53
67
 
54
- return { api, logs, hooks, services, tools };
68
+ return { api, logs, hooks, internalHooks, services, tools };
55
69
  }
56
70
 
57
71
  // ── Tests ──
58
72
 
59
73
  describe('OpenClaw Plugin Integration', () => {
60
74
  describe('plugin.register() structure', () => {
61
- it('registers before_agent_start hook with priority 10', async () => {
75
+ it('registers before_prompt_build hook with priority 10', async () => {
62
76
  const { api, hooks } = createMockOpenClawApi();
63
77
 
64
78
  const { default: plugin } = await import('../index.js');
65
79
  plugin.register(api as never);
66
80
 
67
- const beforeStart = hooks.find(h => h.event === 'before_agent_start');
68
- expect(beforeStart).toBeDefined();
69
- expect(beforeStart!.options?.priority).toBe(10);
81
+ const beforeBuild = hooks.find(h => h.event === 'before_prompt_build');
82
+ expect(beforeBuild).toBeDefined();
83
+ expect(beforeBuild!.options?.priority).toBe(10);
84
+ });
85
+
86
+ it('registers agent:bootstrap internal hook', async () => {
87
+ const { api, internalHooks } = createMockOpenClawApi();
88
+ const { default: plugin } = await import('../index.js');
89
+ plugin.register(api as never);
90
+
91
+ const bootstrapHook = internalHooks.find(h => h.events === 'agent:bootstrap');
92
+ expect(bootstrapHook).toBeDefined();
93
+ expect(bootstrapHook!.opts?.name).toBe('dot-ai-bootstrap-filter');
70
94
  });
71
95
 
72
- it('registers after_agent_end hook', async () => {
96
+ it('agent:bootstrap hook removes all bootstrap files', async () => {
97
+ const { api, internalHooks } = createMockOpenClawApi();
98
+ const { default: plugin } = await import('../index.js');
99
+ plugin.register(api as never);
100
+
101
+ const bootstrapHook = internalHooks.find(h => h.events === 'agent:bootstrap')!;
102
+ const event = {
103
+ type: 'agent',
104
+ action: 'bootstrap',
105
+ sessionKey: 'test',
106
+ context: {
107
+ workspaceDir: '/tmp/test',
108
+ bootstrapFiles: [
109
+ { name: 'AGENTS.md', path: '/tmp/AGENTS.md', content: '# Agents', missing: false },
110
+ { name: 'SOUL.md', path: '/tmp/SOUL.md', content: '# Soul', missing: false },
111
+ { name: 'IDENTITY.md', path: '/tmp/IDENTITY.md', content: '# Identity', missing: false },
112
+ { name: 'USER.md', path: '/tmp/USER.md', content: '# User', missing: false },
113
+ { name: 'TOOLS.md', path: '/tmp/TOOLS.md', content: '# Tools', missing: false },
114
+ { name: 'HEARTBEAT.md', path: '/tmp/HEARTBEAT.md', content: '', missing: false },
115
+ ],
116
+ },
117
+ timestamp: new Date(),
118
+ messages: [],
119
+ };
120
+
121
+ bootstrapHook.handler(event);
122
+ expect(event.context.bootstrapFiles).toEqual([]);
123
+ });
124
+
125
+ it('registers agent_end hook', async () => {
73
126
  const { api, hooks } = createMockOpenClawApi();
74
127
  const { default: plugin } = await import('../index.js');
75
128
  plugin.register(api as never);
76
129
 
77
- const afterEnd = hooks.find(h => h.event === 'after_agent_end');
78
- expect(afterEnd).toBeDefined();
130
+ const agentEnd = hooks.find(h => h.event === 'agent_end');
131
+ expect(agentEnd).toBeDefined();
79
132
  });
80
133
 
81
134
  it('registers dot-ai service', async () => {
@@ -114,16 +167,16 @@ describe('OpenClaw Plugin Integration', () => {
114
167
  });
115
168
  });
116
169
 
117
- describe('before_agent_start → boot → processPrompt', () => {
170
+ describe('before_prompt_build → boot → processPrompt', () => {
118
171
  it('skips sub-agent sessions', async () => {
119
172
  const { api, hooks, logs } = createMockOpenClawApi();
120
173
  const { default: plugin } = await import('../index.js');
121
174
  plugin.register(api as never);
122
175
 
123
- const beforeStart = hooks.find(h => h.event === 'before_agent_start')!;
124
- const result = await beforeStart.handler(
125
- {},
126
- { workspaceDir: '/tmp/test', sessionKey: 'session:subagent:1', prompt: 'hello' },
176
+ const beforeBuild = hooks.find(h => h.event === 'before_prompt_build')!;
177
+ const result = await beforeBuild.handler(
178
+ { prompt: 'hello' },
179
+ { workspaceDir: '/tmp/test', sessionKey: 'session:subagent:1' },
127
180
  );
128
181
 
129
182
  expect(result).toBeUndefined();
@@ -135,10 +188,10 @@ describe('OpenClaw Plugin Integration', () => {
135
188
  const { default: plugin } = await import('../index.js');
136
189
  plugin.register(api as never);
137
190
 
138
- const beforeStart = hooks.find(h => h.event === 'before_agent_start')!;
139
- const result = await beforeStart.handler(
140
- {},
141
- { workspaceDir: '/tmp/test', sessionKey: 'session:cron:cleanup', prompt: 'cleanup' },
191
+ const beforeBuild = hooks.find(h => h.event === 'before_prompt_build')!;
192
+ const result = await beforeBuild.handler(
193
+ { prompt: 'cleanup' },
194
+ { workspaceDir: '/tmp/test', sessionKey: 'session:cron:cleanup' },
142
195
  );
143
196
 
144
197
  expect(result).toBeUndefined();
@@ -146,21 +199,20 @@ describe('OpenClaw Plugin Integration', () => {
146
199
  });
147
200
 
148
201
  it('skips when no workspaceDir and no .ai/ in cwd', async () => {
149
- // Mock cwd to a directory without .ai/
150
202
  const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue('/tmp');
151
203
 
152
204
  const { api, hooks, logs } = createMockOpenClawApi();
153
205
  const { default: plugin } = await import('../index.js');
154
206
  plugin.register(api as never);
155
207
 
156
- const beforeStart = hooks.find(h => h.event === 'before_agent_start')!;
157
- const result = await beforeStart.handler(
158
- {},
208
+ const beforeBuild = hooks.find(h => h.event === 'before_prompt_build')!;
209
+ const result = await beforeBuild.handler(
159
210
  { prompt: 'hello' },
211
+ {},
160
212
  );
161
213
 
162
214
  expect(result).toBeUndefined();
163
- expect(logs.some(l => l.includes('No workspaceRoot configured'))).toBe(true);
215
+ expect(logs.some(l => l.includes('No workspace found'))).toBe(true);
164
216
 
165
217
  cwdSpy.mockRestore();
166
218
  });
package/src/index.ts CHANGED
@@ -1,22 +1,29 @@
1
1
  /**
2
- * dot-ai OpenClaw plugin v7
2
+ * dot-ai OpenClaw plugin v8
3
3
  *
4
- * Hooks into before_agent_start to run the full dot-ai pipeline via DotAiRuntime.
5
- * Uses the v7 extension-based pipeline no providers required.
6
- * Returns enriched context as prependContext for the agent.
4
+ * Full integration with OpenClaw hook system:
5
+ * - agent:bootstrap hook removes ALL OpenClaw workspace files (dot-ai owns context)
6
+ * - before_prompt_build → injects dot-ai context (static prependSystemContext, dynamic → prependContext)
7
+ * - agent_end → feeds response back to runtime
8
+ * - Tools → delegates to runtime capabilities (memory, tasks, etc.)
7
9
  */
8
10
  import { createRequire } from 'node:module';
9
11
  import { existsSync } from 'node:fs';
10
12
  import { join } from 'node:path';
11
- import { DotAiRuntime, formatSections } from '@dot-ai/core';
13
+ import { DotAiRuntime, assembleSections } from '@dot-ai/core';
14
+ import type { Section } from '@dot-ai/core';
12
15
 
13
16
  const require = createRequire(import.meta.url);
14
17
  const { version: PKG_VERSION } = require('../package.json') as { version: string };
15
18
 
16
- // Inline OpenClaw plugin API types
19
+ // ════════════════════════════════════════════════════════════════════════════
20
+ // Inline OpenClaw plugin API types (avoid hard dependency on OpenClaw internals)
21
+ // ════════════════════════════════════════════════════════════════════════════
22
+
17
23
  interface OpenClawLogger {
18
24
  info(msg: string): void;
19
25
  debug?(msg: string): void;
26
+ warn?(msg: string): void;
20
27
  }
21
28
 
22
29
  interface OpenClawPluginApi {
@@ -24,12 +31,14 @@ interface OpenClawPluginApi {
24
31
  pluginConfig?: Record<string, unknown>;
25
32
  on(
26
33
  event: string,
27
- handler: (
28
- event: unknown,
29
- ctx: { workspaceDir?: string; sessionKey?: string; prompt?: string },
30
- ) => Promise<{ prependContext?: string } | void> | void,
34
+ handler: (event: unknown, ctx: Record<string, unknown>) => Promise<Record<string, unknown> | void> | void,
31
35
  options?: { priority?: number },
32
36
  ): void;
37
+ registerHook(
38
+ events: string | string[],
39
+ handler: (event: InternalHookEvent) => Promise<void> | void,
40
+ opts?: { name?: string; description?: string },
41
+ ): void;
33
42
  registerService(service: {
34
43
  id: string;
35
44
  start(ctx: { logger: OpenClawLogger }): void;
@@ -38,6 +47,22 @@ interface OpenClawPluginApi {
38
47
  registerTool(tool: OpenClawTool | OpenClawToolFactory, opts?: { name?: string; names?: string[] }): void;
39
48
  }
40
49
 
50
+ interface InternalHookEvent {
51
+ type: string;
52
+ action: string;
53
+ sessionKey: string;
54
+ context: Record<string, unknown>;
55
+ timestamp: Date;
56
+ messages: string[];
57
+ }
58
+
59
+ interface BootstrapFile {
60
+ name: string;
61
+ path: string;
62
+ content?: string;
63
+ missing: boolean;
64
+ }
65
+
41
66
  interface OpenClawToolResult {
42
67
  content: Array<{ type: string; text: string }>;
43
68
  details?: Record<string, unknown>;
@@ -53,17 +78,86 @@ interface OpenClawTool {
53
78
 
54
79
  type OpenClawToolFactory = (ctx: Record<string, unknown>) => OpenClawTool | OpenClawTool[] | null;
55
80
 
56
- // Session-level cache
81
+ // ════════════════════════════════════════════════════════════════════════════
82
+ // Runtime cache
83
+ // ════════════════════════════════════════════════════════════════════════════
84
+
57
85
  let cachedRuntime: DotAiRuntime | null = null;
58
86
  let cachedWorkspace: string | null = null;
59
87
 
60
- /**
61
- * Check if a directory contains a .ai/ workspace.
62
- */
63
88
  function hasWorkspace(dir: string): boolean {
64
89
  return existsSync(join(dir, '.ai'));
65
90
  }
66
91
 
92
+ function resolveWorkspace(
93
+ configuredWorkspace: string | undefined,
94
+ ctxWorkspaceDir: string | undefined,
95
+ ): string | null {
96
+ const cwd = process.cwd();
97
+ const cwdWorkspace = hasWorkspace(cwd) ? cwd : null;
98
+ const raw = cwdWorkspace ?? configuredWorkspace ?? ctxWorkspaceDir;
99
+ if (!raw) return null;
100
+ // Strip trailing .ai/ if present
101
+ return raw.endsWith('/.ai') || raw.endsWith('\\.ai') ? raw.slice(0, -4) : raw;
102
+ }
103
+
104
+ async function ensureRuntime(
105
+ workspaceDir: string,
106
+ logger: OpenClawLogger,
107
+ ): Promise<DotAiRuntime> {
108
+ if (cachedRuntime && cachedWorkspace === workspaceDir) {
109
+ return cachedRuntime;
110
+ }
111
+
112
+ logger.info(`[dot-ai] workspaceRoot=${workspaceDir}`);
113
+ cachedRuntime = new DotAiRuntime({ workspaceRoot: workspaceDir });
114
+ await cachedRuntime.boot();
115
+ cachedWorkspace = workspaceDir;
116
+ logger.info(`[dot-ai] Runtime booted (v${PKG_VERSION})`);
117
+
118
+ const diag = cachedRuntime.diagnostics;
119
+ logger.info(`[dot-ai] extensions=${diag.extensions.length}, capabilities=${diag.capabilityCount}`);
120
+ if (diag.vocabularySize !== undefined) {
121
+ logger.info(`[dot-ai] Vocabulary size: ${diag.vocabularySize}`);
122
+ }
123
+
124
+ return cachedRuntime;
125
+ }
126
+
127
+ // ════════════════════════════════════════════════════════════════════════════
128
+ // Section splitting: static (cacheable) vs dynamic (per-turn)
129
+ // ════════════════════════════════════════════════════════════════════════════
130
+
131
+ /**
132
+ * Static sections = identity files + system section (trimStrategy: 'never').
133
+ * These rarely change and benefit from provider prompt caching.
134
+ *
135
+ * Dynamic sections = memory, skills, tasks, tools, project agents.
136
+ * These change based on the current prompt.
137
+ */
138
+ function splitSections(sections: Section[]): { static: Section[]; dynamic: Section[] } {
139
+ const staticSections: Section[] = [];
140
+ const dynamicSections: Section[] = [];
141
+
142
+ for (const section of sections) {
143
+ if (section.trimStrategy === 'never') {
144
+ staticSections.push(section);
145
+ } else {
146
+ dynamicSections.push(section);
147
+ }
148
+ }
149
+
150
+ // Sort each group by priority DESC
151
+ staticSections.sort((a, b) => b.priority - a.priority);
152
+ dynamicSections.sort((a, b) => b.priority - a.priority);
153
+
154
+ return { static: staticSections, dynamic: dynamicSections };
155
+ }
156
+
157
+ // ════════════════════════════════════════════════════════════════════════════
158
+ // Plugin definition
159
+ // ════════════════════════════════════════════════════════════════════════════
160
+
67
161
  const plugin = {
68
162
  id: 'dot-ai',
69
163
  name: 'dot-ai — Universal AI Workspace Convention',
@@ -74,19 +168,40 @@ const plugin = {
74
168
  register(api: OpenClawPluginApi) {
75
169
  api.logger.info(`[dot-ai] Plugin loaded (v${PKG_VERSION})`);
76
170
 
77
- // Capture plugin config workspace for use in before_agent_start handler.
78
- // Set in openclaw.json: plugins.entries.dot-ai.config.workspace = "/path/to/project"
79
171
  const configuredWorkspace = api.pluginConfig?.workspace as string | undefined;
80
172
  if (configuredWorkspace) {
81
173
  api.logger.info(`[dot-ai] workspace from config: ${configuredWorkspace}`);
82
174
  }
83
175
 
84
- // Register tools from core capabilities (delegates to extensions)
176
+ // ══════════════════════════════════════════════════════════════════════
177
+ // Internal Hook: agent:bootstrap — remove ALL OpenClaw workspace files
178
+ // dot-ai owns the full context; OpenClaw's AGENTS.md, SOUL.md, etc. are stubs.
179
+ // ══════════════════════════════════════════════════════════════════════
180
+
181
+ api.registerHook(
182
+ 'agent:bootstrap',
183
+ (event: InternalHookEvent) => {
184
+ if (event.type !== 'agent' || event.action !== 'bootstrap') return;
185
+ const ctx = event.context as { bootstrapFiles?: BootstrapFile[] };
186
+ if (!Array.isArray(ctx.bootstrapFiles)) return;
187
+
188
+ const removed = ctx.bootstrapFiles.map(f => f.name);
189
+ // Clear ALL bootstrap files — dot-ai provides everything
190
+ ctx.bootstrapFiles = [];
191
+
192
+ api.logger.debug?.(`[dot-ai] Removed ${removed.length} OpenClaw bootstrap files: ${removed.join(', ')}`);
193
+ },
194
+ { name: 'dot-ai-bootstrap-filter', description: 'Remove OpenClaw workspace files — dot-ai owns context' },
195
+ );
196
+
197
+ // ══════════════════════════════════════════════════════════════════════
198
+ // Tools: delegate to runtime capabilities
199
+ // ══════════════════════════════════════════════════════════════════════
200
+
85
201
  api.registerTool(
86
202
  (_ctx: Record<string, unknown>) => {
87
203
  if (!cachedRuntime?.isBooted) return null;
88
- const capabilities = cachedRuntime.capabilities;
89
- return capabilities.map((cap): OpenClawTool => ({
204
+ return cachedRuntime.capabilities.map((cap): OpenClawTool => ({
90
205
  name: cap.name,
91
206
  label: cap.name.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
92
207
  description: cap.description,
@@ -103,61 +218,56 @@ const plugin = {
103
218
  { names: ['memory_recall', 'memory_store', 'task_list', 'task_create', 'task_update'] },
104
219
  );
105
220
 
106
- // Hook: before_agent_start — run the full pipeline
221
+ // ══════════════════════════════════════════════════════════════════════
222
+ // Plugin Hook: before_prompt_build — inject dot-ai context
223
+ // Replaces legacy before_agent_start. Has access to messages[] and
224
+ // supports prependSystemContext for prompt caching.
225
+ // ══════════════════════════════════════════════════════════════════════
226
+
107
227
  api.on(
108
- 'before_agent_start',
228
+ 'before_prompt_build',
109
229
  async (
110
230
  _event: unknown,
111
- ctx: { workspaceDir?: string; sessionKey?: string; prompt?: string },
231
+ ctx: Record<string, unknown>,
112
232
  ) => {
113
- // Resolve workspace root with priority:
114
- // 1. cwd — if process.cwd() has .ai/ (Claude Code, Pi, local CLI)
115
- // 2. Plugin config (openclaw.json "dot-ai.workspace")for gateway/Discord/TUI
116
- // 3. OpenClaw's ctx.workspaceDir — fallback
117
- const cwd = process.cwd();
118
- const cwdWorkspace = hasWorkspace(cwd) ? cwd : null;
119
- const rawWorkspaceDir = cwdWorkspace ?? configuredWorkspace ?? ctx.workspaceDir;
120
-
121
- if (!rawWorkspaceDir) {
122
- api.logger.info('[dot-ai] No workspaceRoot configured, no .ai/ in cwd, no workspaceDir — skipping');
233
+ const workspaceDir = resolveWorkspace(configuredWorkspace, ctx.workspaceDir as string | undefined);
234
+ if (!workspaceDir) {
235
+ api.logger.info('[dot-ai] No workspace found skipping');
123
236
  return;
124
237
  }
125
238
 
126
- // Strip trailing .ai/ if the path points to the .ai dir itself
127
- const workspaceDir = rawWorkspaceDir.endsWith('/.ai') || rawWorkspaceDir.endsWith('\\.ai')
128
- ? rawWorkspaceDir.slice(0, -4)
129
- : rawWorkspaceDir;
130
-
131
- const isSubagent = ctx.sessionKey?.includes(':subagent:') || ctx.sessionKey?.includes(':cron:');
239
+ const sessionKey = ctx.sessionKey as string | undefined;
240
+ const isSubagent = sessionKey?.includes(':subagent:') || sessionKey?.includes(':cron:');
132
241
  if (isSubagent) {
133
242
  api.logger.debug?.('[dot-ai] Sub-agent/cron session, skipping');
134
243
  return;
135
244
  }
136
245
 
137
246
  try {
138
- if (!cachedRuntime || cachedWorkspace !== workspaceDir) {
139
- api.logger.info(`[dot-ai] workspaceRoot=${workspaceDir}`);
140
- cachedRuntime = new DotAiRuntime({ workspaceRoot: workspaceDir });
141
- await cachedRuntime.boot();
142
- cachedWorkspace = workspaceDir;
143
- api.logger.info(`[dot-ai] Runtime booted (v${PKG_VERSION})`);
144
-
145
- // Log extension diagnostics
146
- const diag = cachedRuntime.diagnostics;
147
- api.logger.info(`[dot-ai] extensions=${diag.extensions.length}, capabilities=${diag.capabilityCount}`);
148
- if (diag.vocabularySize !== undefined) {
149
- api.logger.info(`[dot-ai] Vocabulary size: ${diag.vocabularySize}`);
150
- }
151
- }
247
+ const runtime = await ensureRuntime(workspaceDir, api.logger);
248
+ const prompt = ((_event as { prompt?: string })?.prompt) ?? '';
249
+ const { sections } = await runtime.processPrompt(prompt);
152
250
 
153
- const prompt = ctx.prompt ?? '';
154
- const { sections } = await cachedRuntime.processPrompt(prompt);
155
- const formatted = formatSections(sections);
251
+ if (sections.length === 0) return;
156
252
 
157
- if (formatted) {
158
- api.logger.info(`[dot-ai] Injected: ${sections.length} sections`);
159
- return { prependContext: formatted };
253
+ const { static: staticSections, dynamic: dynamicSections } = splitSections(sections);
254
+
255
+ const result: Record<string, string> = {};
256
+
257
+ // Static context → prependSystemContext (cached by providers like Anthropic)
258
+ if (staticSections.length > 0) {
259
+ result.prependSystemContext = assembleSections(staticSections);
160
260
  }
261
+
262
+ // Dynamic context → prependContext (per-turn, changes with each prompt)
263
+ if (dynamicSections.length > 0) {
264
+ result.prependContext = assembleSections(dynamicSections);
265
+ }
266
+
267
+ const totalSections = staticSections.length + dynamicSections.length;
268
+ api.logger.info(`[dot-ai] Injected: ${totalSections} sections (${staticSections.length} cached, ${dynamicSections.length} per-turn)`);
269
+
270
+ return result;
161
271
  } catch (err) {
162
272
  api.logger.info(`[dot-ai] Pipeline error: ${err}`);
163
273
  }
@@ -166,8 +276,11 @@ const plugin = {
166
276
  { priority: 10 },
167
277
  );
168
278
 
169
- // Hook: after_agent_end — feed response back to runtime for learning + extension events
170
- api.on('after_agent_end', async (_event, ctx) => {
279
+ // ══════════════════════════════════════════════════════════════════════
280
+ // Plugin Hook: agent_end feed response back to runtime
281
+ // ══════════════════════════════════════════════════════════════════════
282
+
283
+ api.on('agent_end', async (_event, ctx) => {
171
284
  if (!cachedRuntime) return;
172
285
  const response = (ctx as { response?: string }).response ?? '';
173
286
  if (response) {
@@ -175,7 +288,10 @@ const plugin = {
175
288
  }
176
289
  });
177
290
 
291
+ // ══════════════════════════════════════════════════════════════════════
178
292
  // Service registration
293
+ // ══════════════════════════════════════════════════════════════════════
294
+
179
295
  api.registerService({
180
296
  id: 'dot-ai',
181
297
  start: (svc) => svc.logger.info(`[dot-ai] Active (workspace: ${configuredWorkspace ?? 'cwd'})`),