@osmosis-ai/openclaw 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/watcher.ts ADDED
@@ -0,0 +1,307 @@
1
+ /**
2
+ * OpenClaw Transcript Watcher
3
+ *
4
+ * Tails OpenClaw session JSONL files and captures tool calls as KnowledgeAtoms.
5
+ * This is the passive instrumentation layer — agents don't need to do anything,
6
+ * the watcher observes their work and captures knowledge automatically.
7
+ */
8
+
9
+ import { watch, readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
10
+ import { join, basename } from 'node:path';
11
+ import { createHash } from 'node:crypto';
12
+ import { captureToolCall, AtomStore } from '@osmosis-ai/core';
13
+
14
+ interface WatcherConfig {
15
+ /** OpenClaw state directory (default: ~/.openclaw) */
16
+ openclawDir: string;
17
+ /** Agent directories to watch */
18
+ agentDirs: string[];
19
+ /** How often to scan for new sessions (ms) */
20
+ scanIntervalMs: number;
21
+ /** Ignore tool calls older than this (ms) */
22
+ maxAgeMs: number;
23
+ }
24
+
25
+ interface FileState {
26
+ path: string;
27
+ offset: number; // bytes read so far
28
+ }
29
+
30
+ const DEFAULT_CONFIG: WatcherConfig = {
31
+ openclawDir: join(process.env.HOME ?? '/root', '.openclaw'),
32
+ agentDirs: [],
33
+ scanIntervalMs: 10_000,
34
+ maxAgeMs: 24 * 60 * 60 * 1000, // 24h
35
+ };
36
+
37
+ /**
38
+ * Extract tool calls from a JSONL message entry.
39
+ * OpenClaw stores messages as JSON lines with role, content[], timestamp.
40
+ */
41
+ function extractToolCalls(line: string): Array<{
42
+ toolName: string;
43
+ params: Record<string, unknown>;
44
+ result?: unknown;
45
+ error?: string;
46
+ timestamp?: number;
47
+ }> {
48
+ const calls: Array<{
49
+ toolName: string;
50
+ params: Record<string, unknown>;
51
+ result?: unknown;
52
+ error?: string;
53
+ timestamp?: number;
54
+ }> = [];
55
+
56
+ try {
57
+ const raw = JSON.parse(line);
58
+
59
+ // OpenClaw JSONL format: {type: "message", message: {role, content}}
60
+ // or flat: {role, content}
61
+ const entry = raw.message ?? raw;
62
+ const ts = raw.timestamp ?? entry.timestamp;
63
+
64
+ // Tool call messages (assistant role with toolCall content)
65
+ if (entry.role === 'assistant' && Array.isArray(entry.content)) {
66
+ for (const block of entry.content) {
67
+ if (block.type === 'toolCall' && block.name) {
68
+ calls.push({
69
+ toolName: block.name,
70
+ params: typeof block.arguments === 'object' ? block.arguments : {},
71
+ timestamp: typeof ts === 'string' ? new Date(ts).getTime() : ts,
72
+ });
73
+ }
74
+ }
75
+ }
76
+
77
+ // Tool result messages (role: "toolResult" in OpenClaw JSONL)
78
+ if (entry.role === 'toolResult' && entry.toolCallId) {
79
+ const content = Array.isArray(entry.content) ? entry.content : [];
80
+ const resultText = content.map((c: any) => c.text ?? c.data ?? '').join('');
81
+ const truncated = resultText.slice(0, 500);
82
+
83
+ const isError = entry.isError === true ||
84
+ (entry.details?.status === 'error') ||
85
+ (typeof resultText === 'string' && (
86
+ resultText.includes('Error:') ||
87
+ resultText.includes('ENOENT') ||
88
+ resultText.includes('ECONNREFUSED') ||
89
+ resultText.includes('Command failed')
90
+ ));
91
+
92
+ calls.push({
93
+ toolName: entry.toolCallId,
94
+ params: {},
95
+ result: truncated,
96
+ error: isError ? (entry.details?.error ?? resultText.slice(0, 200)) : undefined,
97
+ timestamp: typeof ts === 'string' ? new Date(ts).getTime() : (entry.timestamp ?? ts),
98
+ });
99
+ }
100
+ } catch {
101
+ // Skip malformed lines
102
+ }
103
+
104
+ return calls;
105
+ }
106
+
107
+ /**
108
+ * Hash an agent identifier for privacy.
109
+ */
110
+ function hashAgent(agentId: string): string {
111
+ return createHash('sha256').update(agentId).digest('hex').slice(0, 12);
112
+ }
113
+
114
+ export class TranscriptWatcher {
115
+ private store: AtomStore;
116
+ private config: WatcherConfig;
117
+ private fileStates: Map<string, FileState> = new Map();
118
+ private scanTimer: ReturnType<typeof setInterval> | null = null;
119
+ private pendingCalls: Map<string, { toolName: string; params: Record<string, unknown>; timestamp?: number }> = new Map();
120
+ private capturedCount = 0;
121
+
122
+ constructor(store: AtomStore, config?: Partial<WatcherConfig>) {
123
+ this.store = store;
124
+ this.config = { ...DEFAULT_CONFIG, ...config };
125
+ }
126
+
127
+ /** Start watching for new tool calls */
128
+ start(): void {
129
+ // Initial scan
130
+ this.scanSessions();
131
+
132
+ // Periodic scan for new sessions
133
+ this.scanTimer = setInterval(() => this.scanSessions(), this.config.scanIntervalMs);
134
+
135
+ console.log(`🔭 Osmosis watcher started (scanning every ${this.config.scanIntervalMs / 1000}s)`);
136
+ }
137
+
138
+ /** Stop watching */
139
+ stop(): void {
140
+ if (this.scanTimer) {
141
+ clearInterval(this.scanTimer);
142
+ this.scanTimer = null;
143
+ }
144
+ console.log(`🔭 Osmosis watcher stopped (captured ${this.capturedCount} tool calls)`);
145
+ }
146
+
147
+ /** Get stats */
148
+ get stats() {
149
+ return {
150
+ filesWatched: this.fileStates.size,
151
+ capturedCount: this.capturedCount,
152
+ pendingCalls: this.pendingCalls.size,
153
+ };
154
+ }
155
+
156
+ /** Scan for session JSONL files */
157
+ private scanSessions(): void {
158
+ const dirs = this.getSessionDirs();
159
+
160
+ for (const dir of dirs) {
161
+ if (!existsSync(dir)) continue;
162
+ try {
163
+ const files = readdirSync(dir).filter(f => f.endsWith('.jsonl'));
164
+ for (const file of files) {
165
+ const fullPath = join(dir, file);
166
+ this.processFile(fullPath);
167
+ }
168
+ } catch {
169
+ // Skip inaccessible dirs
170
+ }
171
+ }
172
+
173
+ // Also check workspace for transcript files
174
+ const workspaceDir = join(this.config.openclawDir, 'workspace');
175
+ if (existsSync(workspaceDir)) {
176
+ try {
177
+ const files = readdirSync(workspaceDir).filter(f => f.endsWith('.jsonl'));
178
+ for (const file of files) {
179
+ this.processFile(join(workspaceDir, file));
180
+ }
181
+ } catch {
182
+ // Skip
183
+ }
184
+ }
185
+ }
186
+
187
+ /** Get all session directories to watch */
188
+ private getSessionDirs(): string[] {
189
+ const dirs: string[] = [];
190
+ const agentsDir = join(this.config.openclawDir, 'agents');
191
+
192
+ if (existsSync(agentsDir)) {
193
+ try {
194
+ for (const agent of readdirSync(agentsDir)) {
195
+ const sessionsDir = join(agentsDir, agent, 'sessions');
196
+ if (existsSync(sessionsDir)) {
197
+ dirs.push(sessionsDir);
198
+ }
199
+ }
200
+ } catch {
201
+ // Skip
202
+ }
203
+ }
204
+
205
+ // Add any explicitly configured dirs
206
+ dirs.push(...this.config.agentDirs);
207
+
208
+ return dirs;
209
+ }
210
+
211
+ /** Process new lines from a JSONL file */
212
+ private processFile(filePath: string): void {
213
+ try {
214
+ const stat = statSync(filePath);
215
+ const state = this.fileStates.get(filePath);
216
+ const currentOffset = state?.offset ?? 0;
217
+
218
+ // Skip if file hasn't grown
219
+ if (stat.size <= currentOffset) return;
220
+
221
+ // Skip files older than maxAge (based on mtime)
222
+ if (Date.now() - stat.mtimeMs > this.config.maxAgeMs) return;
223
+
224
+ // Read new content
225
+ const content = readFileSync(filePath, 'utf-8');
226
+ const newContent = content.slice(currentOffset);
227
+ const lines = newContent.split('\n').filter(l => l.trim());
228
+
229
+ const agentId = this.extractAgentId(filePath);
230
+ const agentHash = hashAgent(agentId);
231
+
232
+ for (const line of lines) {
233
+ this.processLine(line, agentHash);
234
+ }
235
+
236
+ this.fileStates.set(filePath, { path: filePath, offset: stat.size });
237
+ } catch {
238
+ // Skip problematic files
239
+ }
240
+ }
241
+
242
+ /** Process a single JSONL line */
243
+ private processLine(line: string, agentHash: string): void {
244
+ const calls = extractToolCalls(line);
245
+
246
+ for (const call of calls) {
247
+ // If this is a tool call (from assistant), store it pending by toolCall ID
248
+ if (!call.result && !call.error && call.toolName) {
249
+ // toolName here is the actual tool name (e.g., "exec", "read")
250
+ // We need to also store the toolCall ID to match results
251
+ // Extract IDs from the raw line
252
+ const raw = JSON.parse(line);
253
+ const entry = raw.message ?? raw;
254
+ if (entry.role === 'assistant' && Array.isArray(entry.content)) {
255
+ for (const block of entry.content) {
256
+ if (block.type === 'toolCall' && block.id) {
257
+ this.pendingCalls.set(block.id, {
258
+ toolName: block.name,
259
+ params: typeof block.arguments === 'object' ? block.arguments : {},
260
+ timestamp: call.timestamp,
261
+ });
262
+ }
263
+ }
264
+ }
265
+ continue;
266
+ }
267
+
268
+ // If this is a tool result, match with pending call by ID
269
+ const callId = call.toolName; // For results, toolName holds the toolCallId
270
+ const pending = this.pendingCalls.get(callId);
271
+
272
+ if (pending) {
273
+ captureToolCall(
274
+ this.store,
275
+ pending.toolName,
276
+ pending.params,
277
+ call.result,
278
+ call.error ?? null,
279
+ pending.timestamp && call.timestamp
280
+ ? Math.round(call.timestamp - pending.timestamp)
281
+ : null,
282
+ );
283
+ this.pendingCalls.delete(callId);
284
+ this.capturedCount++;
285
+ }
286
+ }
287
+
288
+ // Expire old pending calls (>5 min)
289
+ const now = Date.now();
290
+ for (const [key, pending] of this.pendingCalls) {
291
+ if (pending.timestamp && now - pending.timestamp > 5 * 60 * 1000) {
292
+ this.pendingCalls.delete(key);
293
+ }
294
+ }
295
+ }
296
+
297
+ /** Extract agent ID from file path */
298
+ private extractAgentId(filePath: string): string {
299
+ // Path: ~/.openclaw/agents/{agentId}/sessions/{sessionId}.jsonl
300
+ const parts = filePath.split('/');
301
+ const agentsIdx = parts.indexOf('agents');
302
+ if (agentsIdx >= 0 && agentsIdx + 1 < parts.length) {
303
+ return parts[agentsIdx + 1]!;
304
+ }
305
+ return basename(filePath, '.jsonl');
306
+ }
307
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ES2022",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2022"],
7
+ "outDir": "dist",
8
+ "rootDir": "src",
9
+ "declaration": true,
10
+ "strict": true,
11
+ "esModuleInterop": true,
12
+ "skipLibCheck": true,
13
+ "forceConsistentCasingInFileNames": true,
14
+ "resolveJsonModule": true,
15
+ "sourceMap": true,
16
+ "composite": true
17
+ },
18
+ "include": ["src"],
19
+ "exclude": ["node_modules", "dist", "**/*.test.ts"],
20
+ "references": [
21
+ { "path": "../core" },
22
+ { "path": "../sync" }
23
+ ]
24
+ }