@hybridaione/hybridclaw 0.1.5
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 +14 -0
- package/.github/workflows/pages.yml +41 -0
- package/AGENTS.md +24 -0
- package/CHANGELOG.md +113 -0
- package/LICENSE +21 -0
- package/README.md +226 -0
- package/SECURITY.md +67 -0
- package/config.example.json +62 -0
- package/container/Dockerfile +23 -0
- package/container/package-lock.json +246 -0
- package/container/package.json +17 -0
- package/container/src/hybridai-client.ts +38 -0
- package/container/src/index.ts +198 -0
- package/container/src/ipc.ts +37 -0
- package/container/src/tools.ts +1008 -0
- package/container/src/types.ts +74 -0
- package/container/src/web-fetch.ts +389 -0
- package/container/tsconfig.json +16 -0
- package/dist/agent.d.ts +3 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +21 -0
- package/dist/agent.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +32 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +38 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +107 -0
- package/dist/config.js.map +1 -0
- package/dist/container-runner.d.ts +11 -0
- package/dist/container-runner.d.ts.map +1 -0
- package/dist/container-runner.js +246 -0
- package/dist/container-runner.js.map +1 -0
- package/dist/conversation.d.ts +18 -0
- package/dist/conversation.d.ts.map +1 -0
- package/dist/conversation.js +29 -0
- package/dist/conversation.js.map +1 -0
- package/dist/db.d.ts +29 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +205 -0
- package/dist/db.js.map +1 -0
- package/dist/discord.d.ts +17 -0
- package/dist/discord.d.ts.map +1 -0
- package/dist/discord.js +115 -0
- package/dist/discord.js.map +1 -0
- package/dist/env.d.ts +6 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +36 -0
- package/dist/env.js.map +1 -0
- package/dist/gateway-client.d.ts +8 -0
- package/dist/gateway-client.d.ts.map +1 -0
- package/dist/gateway-client.js +57 -0
- package/dist/gateway-client.js.map +1 -0
- package/dist/gateway-service.d.ts +23 -0
- package/dist/gateway-service.d.ts.map +1 -0
- package/dist/gateway-service.js +360 -0
- package/dist/gateway-service.js.map +1 -0
- package/dist/gateway-types.d.ts +40 -0
- package/dist/gateway-types.d.ts.map +1 -0
- package/dist/gateway-types.js +6 -0
- package/dist/gateway-types.js.map +1 -0
- package/dist/gateway.d.ts +2 -0
- package/dist/gateway.d.ts.map +1 -0
- package/dist/gateway.js +138 -0
- package/dist/gateway.js.map +1 -0
- package/dist/hatch.d.ts +7 -0
- package/dist/hatch.d.ts.map +1 -0
- package/dist/hatch.js +99 -0
- package/dist/hatch.js.map +1 -0
- package/dist/health.d.ts +2 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +169 -0
- package/dist/health.js.map +1 -0
- package/dist/heartbeat.d.ts +3 -0
- package/dist/heartbeat.d.ts.map +1 -0
- package/dist/heartbeat.js +103 -0
- package/dist/heartbeat.js.map +1 -0
- package/dist/hybridai-bots.d.ts +5 -0
- package/dist/hybridai-bots.d.ts.map +1 -0
- package/dist/hybridai-bots.js +34 -0
- package/dist/hybridai-bots.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +60 -0
- package/dist/index.js.map +1 -0
- package/dist/ipc.d.ts +33 -0
- package/dist/ipc.d.ts.map +1 -0
- package/dist/ipc.js +142 -0
- package/dist/ipc.js.map +1 -0
- package/dist/logger.d.ts +3 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +21 -0
- package/dist/logger.js.map +1 -0
- package/dist/mount-security.d.ts +28 -0
- package/dist/mount-security.d.ts.map +1 -0
- package/dist/mount-security.js +187 -0
- package/dist/mount-security.js.map +1 -0
- package/dist/onboarding.d.ts +7 -0
- package/dist/onboarding.d.ts.map +1 -0
- package/dist/onboarding.js +445 -0
- package/dist/onboarding.js.map +1 -0
- package/dist/prompt-hooks.d.ts +17 -0
- package/dist/prompt-hooks.d.ts.map +1 -0
- package/dist/prompt-hooks.js +83 -0
- package/dist/prompt-hooks.js.map +1 -0
- package/dist/runtime-config.d.ts +78 -0
- package/dist/runtime-config.d.ts.map +1 -0
- package/dist/runtime-config.js +471 -0
- package/dist/runtime-config.js.map +1 -0
- package/dist/scheduled-task-runner.d.ts +11 -0
- package/dist/scheduled-task-runner.d.ts.map +1 -0
- package/dist/scheduled-task-runner.js +16 -0
- package/dist/scheduled-task-runner.js.map +1 -0
- package/dist/scheduler.d.ts +11 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +165 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/session-maintenance.d.ts +9 -0
- package/dist/session-maintenance.d.ts.map +1 -0
- package/dist/session-maintenance.js +168 -0
- package/dist/session-maintenance.js.map +1 -0
- package/dist/session-transcripts.d.ts +11 -0
- package/dist/session-transcripts.d.ts.map +1 -0
- package/dist/session-transcripts.js +32 -0
- package/dist/session-transcripts.js.map +1 -0
- package/dist/side-effects.d.ts +3 -0
- package/dist/side-effects.d.ts.map +1 -0
- package/dist/side-effects.js +30 -0
- package/dist/side-effects.js.map +1 -0
- package/dist/skills.d.ts +32 -0
- package/dist/skills.d.ts.map +1 -0
- package/dist/skills.js +376 -0
- package/dist/skills.js.map +1 -0
- package/dist/tui.d.ts +2 -0
- package/dist/tui.d.ts.map +1 -0
- package/dist/tui.js +305 -0
- package/dist/tui.js.map +1 -0
- package/dist/types.d.ts +132 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/workspace.d.ts +25 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +154 -0
- package/dist/workspace.js.map +1 -0
- package/docs/chat.html +929 -0
- package/docs/hai_logo_free.png +0 -0
- package/docs/hero.png +0 -0
- package/docs/index.html +1213 -0
- package/package.json +34 -0
- package/skills/current-time/SKILL.md +26 -0
- package/skills/iss-position/SKILL.md +46 -0
- package/skills/iss-position/agents/openai.yaml +3 -0
- package/skills/iss-position/scripts/get_iss_position.py +107 -0
- package/skills/repo-orientation/SKILL.md +74 -0
- package/src/agent.ts +35 -0
- package/src/cli.ts +35 -0
- package/src/config.ts +137 -0
- package/src/container-runner.ts +305 -0
- package/src/conversation.ts +49 -0
- package/src/db.ts +290 -0
- package/src/discord.ts +156 -0
- package/src/env.ts +36 -0
- package/src/gateway-client.ts +73 -0
- package/src/gateway-service.ts +456 -0
- package/src/gateway-types.ts +47 -0
- package/src/gateway.ts +199 -0
- package/src/health.ts +189 -0
- package/src/heartbeat.ts +121 -0
- package/src/hybridai-bots.ts +48 -0
- package/src/ipc.ts +163 -0
- package/src/logger.ts +26 -0
- package/src/mount-security.ts +216 -0
- package/src/onboarding.ts +569 -0
- package/src/prompt-hooks.ts +113 -0
- package/src/runtime-config.ts +588 -0
- package/src/scheduled-task-runner.ts +26 -0
- package/src/scheduler.ts +196 -0
- package/src/session-maintenance.ts +263 -0
- package/src/session-transcripts.ts +45 -0
- package/src/side-effects.ts +41 -0
- package/src/skills.ts +429 -0
- package/src/tui.ts +327 -0
- package/src/types.ts +135 -0
- package/src/workspace.ts +171 -0
- package/templates/AGENTS.md +104 -0
- package/templates/BOOT.md +3 -0
- package/templates/BOOTSTRAP.md +54 -0
- package/templates/HEARTBEAT.md +5 -0
- package/templates/IDENTITY.md +18 -0
- package/templates/MEMORY.md +19 -0
- package/templates/SOUL.md +36 -0
- package/templates/TOOLS.md +22 -0
- package/templates/USER.md +17 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,1008 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
import type { ScheduleSideEffect, ToolDefinition } from './types.js';
|
|
6
|
+
import { webFetch } from './web-fetch.js';
|
|
7
|
+
|
|
8
|
+
// --- Exec safety deny-list (defense-in-depth, adapted from PicoClaw) ---
|
|
9
|
+
|
|
10
|
+
const DENY_PATTERNS: RegExp[] = [
|
|
11
|
+
/\brm\s+-[rf]{1,2}\b/, // rm -r, rm -f, rm -rf
|
|
12
|
+
/(^|[;&|]\s*)mkfs(?:\.[a-z0-9_+-]+)?\b/, // mkfs command at segment start
|
|
13
|
+
/(^|[;&|]\s*)format(?:\.com|\.exe)?\b/, // format command at segment start (Windows)
|
|
14
|
+
/\bdd\s+if=/, // raw disk I/O
|
|
15
|
+
/:\(\)\s*\{.*\};\s*:/, // fork bomb :(){ :|:& };:
|
|
16
|
+
/\|\s*(sh|bash|zsh)\b/, // pipe to shell
|
|
17
|
+
/;\s*rm\s+-[rf]/, // chained rm after semicolon
|
|
18
|
+
/&&\s*rm\s+-[rf]/, // chained rm after &&
|
|
19
|
+
/\|\|\s*rm\s+-[rf]/, // chained rm after ||
|
|
20
|
+
/\bcurl\b.*\|\s*(sh|bash)/, // curl | sh
|
|
21
|
+
/\bwget\b.*\|\s*(sh|bash)/, // wget | sh
|
|
22
|
+
/\beval\b/, // eval execution
|
|
23
|
+
/\bsource\s+.*\.sh\b/, // source shell scripts
|
|
24
|
+
/\bpkill\b/, // process killing
|
|
25
|
+
/\bkillall\b/, // process killing
|
|
26
|
+
/\bkill\s+-9\b/, // force kill
|
|
27
|
+
/\b(shutdown|reboot|poweroff)\b/, // system power control
|
|
28
|
+
/>\s*\/dev\/sd[a-z]\b/, // write to block devices
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
function guardCommand(command: string): string | null {
|
|
32
|
+
const lower = command.toLowerCase();
|
|
33
|
+
for (const pattern of DENY_PATTERNS) {
|
|
34
|
+
if (pattern.test(lower)) {
|
|
35
|
+
return 'Command blocked by safety guard (dangerous pattern detected)';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// --- Side-effect accumulator for host-processed actions ---
|
|
42
|
+
|
|
43
|
+
type ScheduledTaskInfo = { id: number; cronExpr: string; runAt: string | null; everyMs: number | null; prompt: string; enabled: number; lastRun: string | null; createdAt: string };
|
|
44
|
+
|
|
45
|
+
let pendingSchedules: ScheduleSideEffect[] = [];
|
|
46
|
+
let injectedTasks: ScheduledTaskInfo[] = [];
|
|
47
|
+
let currentSessionId = '';
|
|
48
|
+
|
|
49
|
+
export function resetSideEffects(): void {
|
|
50
|
+
pendingSchedules = [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function getPendingSideEffects(): { schedules?: ScheduleSideEffect[] } | undefined {
|
|
54
|
+
if (pendingSchedules.length === 0) return undefined;
|
|
55
|
+
return { schedules: pendingSchedules };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function setScheduledTasks(tasks: ScheduledTaskInfo[] | undefined): void {
|
|
59
|
+
injectedTasks = tasks || [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function setSessionContext(sessionId: string): void {
|
|
63
|
+
currentSessionId = String(sessionId || '');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const PREVIEW_MAX_OUTPUT_LINES = 6;
|
|
67
|
+
const PREVIEW_MAX_LINE_LENGTH = 200;
|
|
68
|
+
const BASH_MAX_OUTPUT_LINES = 400;
|
|
69
|
+
const BASH_MAX_OUTPUT_BYTES = 128 * 1024;
|
|
70
|
+
const READ_MAX_LINES = 2000;
|
|
71
|
+
const READ_MAX_BYTES = 50 * 1024;
|
|
72
|
+
|
|
73
|
+
function abbreviatePreview(text: string): string {
|
|
74
|
+
const lines = text.split('\n');
|
|
75
|
+
const truncated = lines.slice(0, PREVIEW_MAX_OUTPUT_LINES).map((line) =>
|
|
76
|
+
line.length > PREVIEW_MAX_LINE_LENGTH ? line.slice(0, PREVIEW_MAX_LINE_LENGTH) + '...' : line
|
|
77
|
+
);
|
|
78
|
+
if (lines.length > PREVIEW_MAX_OUTPUT_LINES) {
|
|
79
|
+
truncated.push(`... (${lines.length - PREVIEW_MAX_OUTPUT_LINES} more lines)`);
|
|
80
|
+
}
|
|
81
|
+
return truncated.join('\n');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type ReadTruncationResult = {
|
|
85
|
+
content: string;
|
|
86
|
+
truncated: boolean;
|
|
87
|
+
truncatedBy: 'lines' | 'bytes' | null;
|
|
88
|
+
outputLines: number;
|
|
89
|
+
firstLineExceedsLimit: boolean;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
function formatBytes(bytes: number): string {
|
|
93
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
94
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
95
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function truncateReadContent(content: string, maxLines = READ_MAX_LINES, maxBytes = READ_MAX_BYTES): ReadTruncationResult {
|
|
99
|
+
const lines = content.split('\n');
|
|
100
|
+
const totalBytes = Buffer.byteLength(content, 'utf-8');
|
|
101
|
+
if (lines.length <= maxLines && totalBytes <= maxBytes) {
|
|
102
|
+
return {
|
|
103
|
+
content,
|
|
104
|
+
truncated: false,
|
|
105
|
+
truncatedBy: null,
|
|
106
|
+
outputLines: lines.length,
|
|
107
|
+
firstLineExceedsLimit: false,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const firstLine = lines[0] ?? '';
|
|
112
|
+
if (Buffer.byteLength(firstLine, 'utf-8') > maxBytes) {
|
|
113
|
+
return {
|
|
114
|
+
content: '',
|
|
115
|
+
truncated: true,
|
|
116
|
+
truncatedBy: 'bytes',
|
|
117
|
+
outputLines: 0,
|
|
118
|
+
firstLineExceedsLimit: true,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const out: string[] = [];
|
|
123
|
+
let bytes = 0;
|
|
124
|
+
let truncatedBy: 'lines' | 'bytes' = 'lines';
|
|
125
|
+
for (let i = 0; i < lines.length && i < maxLines; i++) {
|
|
126
|
+
const line = lines[i];
|
|
127
|
+
const lineBytes = Buffer.byteLength(line, 'utf-8') + (i > 0 ? 1 : 0);
|
|
128
|
+
if (bytes + lineBytes > maxBytes) {
|
|
129
|
+
truncatedBy = 'bytes';
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
out.push(line);
|
|
133
|
+
bytes += lineBytes;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (out.length >= maxLines && bytes <= maxBytes) truncatedBy = 'lines';
|
|
137
|
+
return {
|
|
138
|
+
content: out.join('\n'),
|
|
139
|
+
truncated: true,
|
|
140
|
+
truncatedBy,
|
|
141
|
+
outputLines: out.length,
|
|
142
|
+
firstLineExceedsLimit: false,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function formatBashOutput(content: string): string {
|
|
147
|
+
const raw = content || '(no output)';
|
|
148
|
+
const totalLines = raw.split('\n').length;
|
|
149
|
+
const truncation = truncateReadContent(raw, BASH_MAX_OUTPUT_LINES, BASH_MAX_OUTPUT_BYTES);
|
|
150
|
+
if (!truncation.truncated) return raw;
|
|
151
|
+
|
|
152
|
+
if (truncation.firstLineExceedsLimit) {
|
|
153
|
+
return `[Command output truncated: first line exceeds ${formatBytes(BASH_MAX_OUTPUT_BYTES)}. Consider narrowing command output.]`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const shownLines = truncation.outputLines;
|
|
157
|
+
if (truncation.truncatedBy === 'bytes') {
|
|
158
|
+
return `${truncation.content}\n\n[Output truncated at ${formatBytes(BASH_MAX_OUTPUT_BYTES)} after ${shownLines}/${totalLines} lines]`;
|
|
159
|
+
}
|
|
160
|
+
return `${truncation.content}\n\n[Output truncated after ${shownLines}/${totalLines} lines]`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const WORKSPACE_ROOT = '/workspace';
|
|
164
|
+
|
|
165
|
+
function safeJoin(userPath: string): string {
|
|
166
|
+
const input = String(userPath || '').trim();
|
|
167
|
+
const root = path.resolve(WORKSPACE_ROOT);
|
|
168
|
+
const resolved = path.isAbsolute(input)
|
|
169
|
+
? path.resolve(input)
|
|
170
|
+
: path.resolve(root, input);
|
|
171
|
+
|
|
172
|
+
if (resolved === root || resolved.startsWith(root + path.sep)) {
|
|
173
|
+
return resolved;
|
|
174
|
+
}
|
|
175
|
+
throw new Error(`Path escapes workspace: ${userPath}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const MEMORY_ROOT_FILES = new Set(['MEMORY.md', 'USER.md']);
|
|
179
|
+
const DAILY_MEMORY_FILE_RE = /^memory\/\d{4}-\d{2}-\d{2}\.md$/;
|
|
180
|
+
const ROOT_MEMORY_CHAR_LIMITS: Record<string, number> = {
|
|
181
|
+
'MEMORY.md': 12_000,
|
|
182
|
+
'USER.md': 8_000,
|
|
183
|
+
};
|
|
184
|
+
const DAILY_MEMORY_CHAR_LIMIT = 24_000;
|
|
185
|
+
|
|
186
|
+
function normalizeDateStamp(input: string): string | null {
|
|
187
|
+
const trimmed = input.trim();
|
|
188
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(trimmed) ? trimmed : null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function currentDateStamp(): string {
|
|
192
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
193
|
+
year: 'numeric',
|
|
194
|
+
month: '2-digit',
|
|
195
|
+
day: '2-digit',
|
|
196
|
+
}).formatToParts(new Date());
|
|
197
|
+
const year = parts.find((p) => p.type === 'year')?.value;
|
|
198
|
+
const month = parts.find((p) => p.type === 'month')?.value;
|
|
199
|
+
const day = parts.find((p) => p.type === 'day')?.value;
|
|
200
|
+
if (year && month && day) return `${year}-${month}-${day}`;
|
|
201
|
+
return new Date().toISOString().slice(0, 10);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function normalizeMemoryFilePath(rawPath: unknown): string | null {
|
|
205
|
+
if (typeof rawPath !== 'string' || rawPath.trim() === '') return null;
|
|
206
|
+
const normalized = rawPath
|
|
207
|
+
.trim()
|
|
208
|
+
.replace(/\\/g, '/')
|
|
209
|
+
.replace(/^\/workspace\//, '')
|
|
210
|
+
.replace(/^\.?\//, '');
|
|
211
|
+
if (MEMORY_ROOT_FILES.has(normalized)) return normalized;
|
|
212
|
+
if (DAILY_MEMORY_FILE_RE.test(normalized)) return normalized;
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function resolveMemoryFilePath(args: Record<string, unknown>): string | null {
|
|
217
|
+
const direct =
|
|
218
|
+
normalizeMemoryFilePath(args.file_path) ||
|
|
219
|
+
normalizeMemoryFilePath(args.path);
|
|
220
|
+
if (direct) return direct;
|
|
221
|
+
|
|
222
|
+
const target = typeof args.target === 'string' ? args.target.trim().toLowerCase() : '';
|
|
223
|
+
if (target === 'memory') return 'MEMORY.md';
|
|
224
|
+
if (target === 'user') return 'USER.md';
|
|
225
|
+
if (target === 'daily') {
|
|
226
|
+
const date = typeof args.date === 'string' ? normalizeDateStamp(args.date) : null;
|
|
227
|
+
return `memory/${date || currentDateStamp()}.md`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return 'MEMORY.md';
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function listMemoryFiles(): string[] {
|
|
234
|
+
const files: string[] = [];
|
|
235
|
+
for (const rootFile of MEMORY_ROOT_FILES) {
|
|
236
|
+
const abs = safeJoin(rootFile);
|
|
237
|
+
if (fs.existsSync(abs)) files.push(rootFile);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const dailyDir = safeJoin('memory');
|
|
241
|
+
if (fs.existsSync(dailyDir)) {
|
|
242
|
+
for (const entry of fs.readdirSync(dailyDir)) {
|
|
243
|
+
if (!/^\d{4}-\d{2}-\d{2}\.md$/.test(entry)) continue;
|
|
244
|
+
files.push(`memory/${entry}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
files.sort((a, b) => a.localeCompare(b));
|
|
249
|
+
return files;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function memoryCharLimit(relativePath: string): number {
|
|
253
|
+
return ROOT_MEMORY_CHAR_LIMITS[relativePath] || DAILY_MEMORY_CHAR_LIMIT;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
interface TranscriptRow {
|
|
257
|
+
sessionId: string;
|
|
258
|
+
channelId?: string;
|
|
259
|
+
role: string;
|
|
260
|
+
userId?: string;
|
|
261
|
+
username?: string | null;
|
|
262
|
+
content: string;
|
|
263
|
+
createdAt?: string;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
type SessionSearchCandidate = {
|
|
267
|
+
sessionId: string;
|
|
268
|
+
filePath: string;
|
|
269
|
+
rows: TranscriptRow[];
|
|
270
|
+
matchIndexes: number[];
|
|
271
|
+
score: number;
|
|
272
|
+
mtimeMs: number;
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const SESSION_TRANSCRIPTS_DIR = '.session-transcripts';
|
|
276
|
+
const SESSION_SEARCH_MAX_FILES = 300;
|
|
277
|
+
const SESSION_SEARCH_MAX_RESULTS = 5;
|
|
278
|
+
const SESSION_SEARCH_MAX_ROWS_PER_SESSION = 2_000;
|
|
279
|
+
const SESSION_SEARCH_SNIPPET_CONTEXT = 1;
|
|
280
|
+
const SESSION_SEARCH_MAX_SNIPPETS = 8;
|
|
281
|
+
|
|
282
|
+
function parseRoleFilter(value: unknown): Set<string> | null {
|
|
283
|
+
if (typeof value !== 'string' || value.trim() === '') return null;
|
|
284
|
+
const roles = value
|
|
285
|
+
.split(',')
|
|
286
|
+
.map((part) => part.trim().toLowerCase())
|
|
287
|
+
.filter(Boolean);
|
|
288
|
+
return roles.length > 0 ? new Set(roles) : null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function truncateInline(text: string, max = 240): string {
|
|
292
|
+
const oneLine = text.replace(/\s+/g, ' ').trim();
|
|
293
|
+
if (oneLine.length <= max) return oneLine;
|
|
294
|
+
return `${oneLine.slice(0, max)}...`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function collectTranscriptRows(filePath: string): TranscriptRow[] {
|
|
298
|
+
let raw = '';
|
|
299
|
+
try {
|
|
300
|
+
raw = fs.readFileSync(filePath, 'utf-8');
|
|
301
|
+
} catch {
|
|
302
|
+
return [];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const parsed: TranscriptRow[] = [];
|
|
306
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
307
|
+
const start = Math.max(0, lines.length - SESSION_SEARCH_MAX_ROWS_PER_SESSION);
|
|
308
|
+
for (let i = start; i < lines.length; i++) {
|
|
309
|
+
try {
|
|
310
|
+
const row = JSON.parse(lines[i]) as Partial<TranscriptRow>;
|
|
311
|
+
if (
|
|
312
|
+
typeof row.sessionId !== 'string' ||
|
|
313
|
+
typeof row.role !== 'string' ||
|
|
314
|
+
typeof row.content !== 'string'
|
|
315
|
+
) {
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
parsed.push({
|
|
319
|
+
sessionId: row.sessionId,
|
|
320
|
+
channelId: typeof row.channelId === 'string' ? row.channelId : undefined,
|
|
321
|
+
role: row.role,
|
|
322
|
+
userId: typeof row.userId === 'string' ? row.userId : undefined,
|
|
323
|
+
username: row.username == null ? null : String(row.username),
|
|
324
|
+
content: row.content,
|
|
325
|
+
createdAt: typeof row.createdAt === 'string' ? row.createdAt : undefined,
|
|
326
|
+
});
|
|
327
|
+
} catch {
|
|
328
|
+
// Skip malformed row
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return parsed;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function scoreTranscript(rows: TranscriptRow[], query: string, roleFilter: Set<string> | null): number {
|
|
335
|
+
const terms = query
|
|
336
|
+
.toLowerCase()
|
|
337
|
+
.split(/\s+/)
|
|
338
|
+
.map((part) => part.trim())
|
|
339
|
+
.filter((part) => part.length > 1);
|
|
340
|
+
if (terms.length === 0) terms.push(query.toLowerCase());
|
|
341
|
+
|
|
342
|
+
let score = 0;
|
|
343
|
+
for (const row of rows) {
|
|
344
|
+
const role = row.role.toLowerCase();
|
|
345
|
+
if (roleFilter && !roleFilter.has(role)) continue;
|
|
346
|
+
const haystack = row.content.toLowerCase();
|
|
347
|
+
if (haystack.includes(query.toLowerCase())) score += 6;
|
|
348
|
+
for (const term of terms) {
|
|
349
|
+
if (haystack.includes(term)) score += 2;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return score;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function findMatchIndexes(rows: TranscriptRow[], query: string, roleFilter: Set<string> | null): number[] {
|
|
356
|
+
const lower = query.toLowerCase();
|
|
357
|
+
const terms = lower
|
|
358
|
+
.split(/\s+/)
|
|
359
|
+
.map((part) => part.trim())
|
|
360
|
+
.filter((part) => part.length > 1);
|
|
361
|
+
if (terms.length === 0) terms.push(lower);
|
|
362
|
+
|
|
363
|
+
const indexes: number[] = [];
|
|
364
|
+
for (let i = 0; i < rows.length; i++) {
|
|
365
|
+
const role = rows[i].role.toLowerCase();
|
|
366
|
+
if (roleFilter && !roleFilter.has(role)) continue;
|
|
367
|
+
const content = rows[i].content.toLowerCase();
|
|
368
|
+
if (content.includes(lower) || terms.some((term) => content.includes(term))) {
|
|
369
|
+
indexes.push(i);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return indexes;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function summarizeSessionCandidate(candidate: SessionSearchCandidate, query: string): Record<string, unknown> {
|
|
376
|
+
const rows = candidate.rows;
|
|
377
|
+
const snippets: string[] = [];
|
|
378
|
+
const seenIndexes = new Set<number>();
|
|
379
|
+
|
|
380
|
+
for (const idx of candidate.matchIndexes) {
|
|
381
|
+
const from = Math.max(0, idx - SESSION_SEARCH_SNIPPET_CONTEXT);
|
|
382
|
+
const to = Math.min(rows.length - 1, idx + SESSION_SEARCH_SNIPPET_CONTEXT);
|
|
383
|
+
for (let i = from; i <= to; i++) {
|
|
384
|
+
if (seenIndexes.has(i)) continue;
|
|
385
|
+
seenIndexes.add(i);
|
|
386
|
+
const line = `[${rows[i].role.toUpperCase()}] ${truncateInline(rows[i].content)}`;
|
|
387
|
+
snippets.push(line);
|
|
388
|
+
if (snippets.length >= SESSION_SEARCH_MAX_SNIPPETS) break;
|
|
389
|
+
}
|
|
390
|
+
if (snippets.length >= SESSION_SEARCH_MAX_SNIPPETS) break;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const userMatches: string[] = [];
|
|
394
|
+
const assistantMatches: string[] = [];
|
|
395
|
+
for (const idx of candidate.matchIndexes) {
|
|
396
|
+
const row = rows[idx];
|
|
397
|
+
const role = row.role.toLowerCase();
|
|
398
|
+
if (role === 'user' && userMatches.length < 2) {
|
|
399
|
+
userMatches.push(truncateInline(row.content));
|
|
400
|
+
} else if (role === 'assistant' && assistantMatches.length < 2) {
|
|
401
|
+
assistantMatches.push(truncateInline(row.content));
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const firstTs = rows.find((row) => typeof row.createdAt === 'string')?.createdAt || null;
|
|
406
|
+
const lastTs = [...rows].reverse().find((row) => typeof row.createdAt === 'string')?.createdAt || null;
|
|
407
|
+
const summaryParts = [
|
|
408
|
+
`Matched ${candidate.matchIndexes.length} turn(s) for "${query}".`,
|
|
409
|
+
userMatches.length > 0 ? `User focus: ${userMatches.join(' | ')}` : '',
|
|
410
|
+
assistantMatches.length > 0 ? `Assistant outcomes: ${assistantMatches.join(' | ')}` : '',
|
|
411
|
+
].filter(Boolean);
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
session_id: candidate.sessionId,
|
|
415
|
+
match_count: candidate.matchIndexes.length,
|
|
416
|
+
first_message_at: firstTs,
|
|
417
|
+
last_message_at: lastTs,
|
|
418
|
+
summary: summaryParts.join(' '),
|
|
419
|
+
snippets,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export async function executeTool(name: string, argsJson: string): Promise<string> {
|
|
424
|
+
try {
|
|
425
|
+
const args = JSON.parse(argsJson);
|
|
426
|
+
|
|
427
|
+
switch (name) {
|
|
428
|
+
case 'read': {
|
|
429
|
+
if (typeof args.path !== 'string' || args.path.trim() === '') {
|
|
430
|
+
return 'Error: path is required';
|
|
431
|
+
}
|
|
432
|
+
const filePath = safeJoin(args.path);
|
|
433
|
+
if (!fs.existsSync(filePath)) return `Error: File not found: ${args.path}`;
|
|
434
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
435
|
+
const lines = content.split('\n');
|
|
436
|
+
const totalFileLines = lines.length;
|
|
437
|
+
|
|
438
|
+
const rawOffset = typeof args.offset === 'number' && Number.isFinite(args.offset) ? args.offset : 1;
|
|
439
|
+
const startLine = Math.max(1, Math.floor(rawOffset));
|
|
440
|
+
if (startLine > totalFileLines) {
|
|
441
|
+
return `Error: Offset ${startLine} is beyond end of file (${totalFileLines} lines total)`;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const rawLimit =
|
|
445
|
+
typeof args.limit === 'number' && Number.isFinite(args.limit) && args.limit > 0
|
|
446
|
+
? Math.floor(args.limit)
|
|
447
|
+
: undefined;
|
|
448
|
+
|
|
449
|
+
let selected = lines.slice(startLine - 1);
|
|
450
|
+
let userLimitedLines: number | undefined;
|
|
451
|
+
if (rawLimit !== undefined) {
|
|
452
|
+
selected = selected.slice(0, rawLimit);
|
|
453
|
+
userLimitedLines = selected.length;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const selectedContent = selected.join('\n');
|
|
457
|
+
const truncation = truncateReadContent(selectedContent);
|
|
458
|
+
if (truncation.firstLineExceedsLimit) {
|
|
459
|
+
const firstSelectedLine = selected[0] ?? '';
|
|
460
|
+
const firstLineSize = formatBytes(Buffer.byteLength(firstSelectedLine, 'utf-8'));
|
|
461
|
+
return `[Line ${startLine} is ${firstLineSize}, exceeds ${formatBytes(READ_MAX_BYTES)} limit. Use bash: sed -n '${startLine}p' ${args.path} | head -c ${READ_MAX_BYTES}]`;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (truncation.truncated) {
|
|
465
|
+
const endLine = startLine + truncation.outputLines - 1;
|
|
466
|
+
const nextOffset = endLine + 1;
|
|
467
|
+
if (truncation.truncatedBy === 'lines') {
|
|
468
|
+
return `${truncation.content}\n\n[Showing lines ${startLine}-${endLine} of ${totalFileLines}. Use offset=${nextOffset} to continue]`;
|
|
469
|
+
}
|
|
470
|
+
return `${truncation.content}\n\n[Showing lines ${startLine}-${endLine} of ${totalFileLines} (${formatBytes(READ_MAX_BYTES)} limit). Use offset=${nextOffset} to continue]`;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (userLimitedLines !== undefined) {
|
|
474
|
+
const linesFromStart = startLine - 1 + userLimitedLines;
|
|
475
|
+
if (linesFromStart < totalFileLines) {
|
|
476
|
+
const remaining = totalFileLines - linesFromStart;
|
|
477
|
+
const nextOffset = startLine + userLimitedLines;
|
|
478
|
+
return `${truncation.content}\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue]`;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return truncation.content;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
case 'write': {
|
|
486
|
+
const filePath = safeJoin(args.path);
|
|
487
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
488
|
+
fs.writeFileSync(filePath, args.contents);
|
|
489
|
+
return `Wrote ${args.contents.length} bytes to ${args.path}`;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
case 'edit': {
|
|
493
|
+
const filePath = safeJoin(args.path);
|
|
494
|
+
if (!fs.existsSync(filePath)) return `Error: File not found: ${args.path}`;
|
|
495
|
+
let content = fs.readFileSync(filePath, 'utf-8');
|
|
496
|
+
const count = args.count || 1;
|
|
497
|
+
for (let i = 0; i < count; i++) {
|
|
498
|
+
const idx = content.indexOf(args.old);
|
|
499
|
+
if (idx === -1) {
|
|
500
|
+
if (i === 0) return `Error: Text not found in ${args.path}`;
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
content = content.slice(0, idx) + args.new + content.slice(idx + args.old.length);
|
|
504
|
+
}
|
|
505
|
+
fs.writeFileSync(filePath, content);
|
|
506
|
+
return `Edited ${args.path} (${count} replacement${count > 1 ? 's' : ''})`;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
case 'delete': {
|
|
510
|
+
const filePath = safeJoin(args.path);
|
|
511
|
+
if (!fs.existsSync(filePath)) return `Error: File not found: ${args.path}`;
|
|
512
|
+
fs.unlinkSync(filePath);
|
|
513
|
+
return `Deleted ${args.path}`;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
case 'glob': {
|
|
517
|
+
const pattern = args.pattern;
|
|
518
|
+
try {
|
|
519
|
+
// Use find as a simple glob implementation
|
|
520
|
+
const cmd = `find /workspace -path "${pattern.replace(/\*/g, '*')}" -type f 2>/dev/null | head -50`;
|
|
521
|
+
const result = execSync(cmd, { timeout: 10000, encoding: 'utf-8' });
|
|
522
|
+
if (!result.trim()) return 'No files found.';
|
|
523
|
+
// Convert absolute paths to relative
|
|
524
|
+
const files = result.trim().split('\n').map((f) => f.replace('/workspace/', ''));
|
|
525
|
+
return abbreviatePreview(files.join('\n'));
|
|
526
|
+
} catch {
|
|
527
|
+
return 'No files found.';
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
case 'grep': {
|
|
532
|
+
const searchPath = args.path ? safeJoin(args.path) : '/workspace';
|
|
533
|
+
try {
|
|
534
|
+
const cmd = `rg --no-heading --line-number "${args.pattern.replace(/"/g, '\\"')}" "${searchPath}" 2>/dev/null | head -30`;
|
|
535
|
+
const result = execSync(cmd, { timeout: 10000, encoding: 'utf-8' });
|
|
536
|
+
if (!result.trim()) return 'No matches found.';
|
|
537
|
+
// Convert absolute paths to relative
|
|
538
|
+
return abbreviatePreview(result.replace(/\/workspace\//g, ''));
|
|
539
|
+
} catch {
|
|
540
|
+
return 'No matches found.';
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
case 'bash': {
|
|
545
|
+
const blocked = guardCommand(args.command);
|
|
546
|
+
if (blocked) return blocked;
|
|
547
|
+
try {
|
|
548
|
+
// Strip secrets from subprocess environment (belt-and-suspenders)
|
|
549
|
+
const cleanEnv = { ...process.env };
|
|
550
|
+
delete cleanEnv.HYBRIDAI_API_KEY;
|
|
551
|
+
const result = execSync(args.command, {
|
|
552
|
+
timeout: 30000,
|
|
553
|
+
encoding: 'utf-8',
|
|
554
|
+
cwd: '/workspace',
|
|
555
|
+
maxBuffer: 1024 * 1024,
|
|
556
|
+
env: cleanEnv,
|
|
557
|
+
});
|
|
558
|
+
return formatBashOutput(result || '(no output)');
|
|
559
|
+
} catch (err: unknown) {
|
|
560
|
+
const execErr = err as { stderr?: string; message?: string };
|
|
561
|
+
return `Error: ${execErr.stderr || execErr.message || 'Command failed'}`;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
case 'memory': {
|
|
566
|
+
const action = typeof args.action === 'string' ? args.action.trim().toLowerCase() : 'read';
|
|
567
|
+
const relativePath = resolveMemoryFilePath(args);
|
|
568
|
+
if (!relativePath) {
|
|
569
|
+
return 'Error: memory file_path must be MEMORY.md, USER.md, or memory/YYYY-MM-DD.md';
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const filePath = safeJoin(relativePath);
|
|
573
|
+
if (action === 'list') {
|
|
574
|
+
const files = listMemoryFiles();
|
|
575
|
+
if (files.length === 0) {
|
|
576
|
+
return 'No memory files found yet. Use action="append" with MEMORY.md or memory/YYYY-MM-DD.md.';
|
|
577
|
+
}
|
|
578
|
+
return files.join('\n');
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (action === 'search') {
|
|
582
|
+
const query = typeof args.query === 'string' ? args.query.trim().toLowerCase() : '';
|
|
583
|
+
if (!query) return 'Error: query is required for memory search';
|
|
584
|
+
const files = listMemoryFiles();
|
|
585
|
+
const matches: string[] = [];
|
|
586
|
+
for (const rel of files) {
|
|
587
|
+
const abs = safeJoin(rel);
|
|
588
|
+
let lines: string[] = [];
|
|
589
|
+
try {
|
|
590
|
+
lines = fs.readFileSync(abs, 'utf-8').split('\n');
|
|
591
|
+
} catch {
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
for (let i = 0; i < lines.length; i++) {
|
|
595
|
+
if (!lines[i].toLowerCase().includes(query)) continue;
|
|
596
|
+
const trimmed = lines[i].trim();
|
|
597
|
+
matches.push(`${rel}:${i + 1}: ${trimmed}`);
|
|
598
|
+
if (matches.length >= 40) break;
|
|
599
|
+
}
|
|
600
|
+
if (matches.length >= 40) break;
|
|
601
|
+
}
|
|
602
|
+
return matches.length > 0 ? matches.join('\n') : `No memory matches for "${query}".`;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (action === 'read') {
|
|
606
|
+
if (!fs.existsSync(filePath)) {
|
|
607
|
+
return `${relativePath}\n\n(empty)`;
|
|
608
|
+
}
|
|
609
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
610
|
+
return `${relativePath}\n\n${content || '(empty)'}`;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (action === 'append') {
|
|
614
|
+
const content = typeof args.content === 'string' ? args.content.trim() : '';
|
|
615
|
+
if (!content) return 'Error: content is required for memory append';
|
|
616
|
+
|
|
617
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
618
|
+
const existing = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : '';
|
|
619
|
+
let next = existing.replace(/\s+$/, '');
|
|
620
|
+
if (next.length > 0) next += '\n\n';
|
|
621
|
+
next += `${content}\n`;
|
|
622
|
+
const limit = memoryCharLimit(relativePath);
|
|
623
|
+
if (next.length > limit) {
|
|
624
|
+
return `Error: ${relativePath} would exceed ${limit} chars. Shorten content or remove older entries first.`;
|
|
625
|
+
}
|
|
626
|
+
fs.writeFileSync(filePath, next, 'utf-8');
|
|
627
|
+
return `Appended ${content.length} chars to ${relativePath}`;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (action === 'write') {
|
|
631
|
+
const content = typeof args.content === 'string' ? args.content : '';
|
|
632
|
+
const limit = memoryCharLimit(relativePath);
|
|
633
|
+
if (content.length > limit) {
|
|
634
|
+
return `Error: ${relativePath} exceeds ${limit} char limit.`;
|
|
635
|
+
}
|
|
636
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
637
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
638
|
+
return `Wrote ${content.length} chars to ${relativePath}`;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (action === 'replace') {
|
|
642
|
+
const oldText = typeof args.old_text === 'string' ? args.old_text : '';
|
|
643
|
+
const newText = typeof args.new_text === 'string' ? args.new_text : '';
|
|
644
|
+
if (!oldText) return 'Error: old_text is required for memory replace';
|
|
645
|
+
if (!fs.existsSync(filePath)) return `Error: File not found: ${relativePath}`;
|
|
646
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
647
|
+
if (!content.includes(oldText)) return `Error: old_text not found in ${relativePath}`;
|
|
648
|
+
const next = content.replace(oldText, newText);
|
|
649
|
+
const limit = memoryCharLimit(relativePath);
|
|
650
|
+
if (next.length > limit) {
|
|
651
|
+
return `Error: replacement would exceed ${limit} chars for ${relativePath}.`;
|
|
652
|
+
}
|
|
653
|
+
fs.writeFileSync(filePath, next, 'utf-8');
|
|
654
|
+
return `Updated ${relativePath}`;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (action === 'remove') {
|
|
658
|
+
const oldText = typeof args.old_text === 'string' ? args.old_text : '';
|
|
659
|
+
if (!oldText) return 'Error: old_text is required for memory remove';
|
|
660
|
+
if (!fs.existsSync(filePath)) return `Error: File not found: ${relativePath}`;
|
|
661
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
662
|
+
if (!content.includes(oldText)) return `Error: old_text not found in ${relativePath}`;
|
|
663
|
+
fs.writeFileSync(filePath, content.replace(oldText, ''), 'utf-8');
|
|
664
|
+
return `Removed matching text from ${relativePath}`;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
return `Error: unknown memory action "${action}". Use read, append, write, replace, remove, list, or search.`;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
case 'session_search': {
|
|
671
|
+
const query = typeof args.query === 'string' ? args.query.trim() : '';
|
|
672
|
+
if (!query) return 'Error: query is required for session_search';
|
|
673
|
+
|
|
674
|
+
const requestedLimit =
|
|
675
|
+
typeof args.limit === 'number' && Number.isFinite(args.limit)
|
|
676
|
+
? Math.floor(args.limit)
|
|
677
|
+
: 3;
|
|
678
|
+
const limit = Math.max(1, Math.min(requestedLimit, SESSION_SEARCH_MAX_RESULTS));
|
|
679
|
+
const includeCurrent = args.include_current === true;
|
|
680
|
+
const roleFilter = parseRoleFilter(args.role_filter);
|
|
681
|
+
|
|
682
|
+
const transcriptDir = safeJoin(SESSION_TRANSCRIPTS_DIR);
|
|
683
|
+
if (!fs.existsSync(transcriptDir)) {
|
|
684
|
+
return JSON.stringify({
|
|
685
|
+
success: true,
|
|
686
|
+
query,
|
|
687
|
+
count: 0,
|
|
688
|
+
results: [],
|
|
689
|
+
message: 'No historical transcripts found yet.',
|
|
690
|
+
}, null, 2);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const files = fs
|
|
694
|
+
.readdirSync(transcriptDir)
|
|
695
|
+
.filter((name) => name.endsWith('.jsonl'))
|
|
696
|
+
.slice(0, SESSION_SEARCH_MAX_FILES);
|
|
697
|
+
|
|
698
|
+
const candidates: SessionSearchCandidate[] = [];
|
|
699
|
+
for (const filename of files) {
|
|
700
|
+
const filePath = path.join(transcriptDir, filename);
|
|
701
|
+
const rows = collectTranscriptRows(filePath);
|
|
702
|
+
if (rows.length === 0) continue;
|
|
703
|
+
|
|
704
|
+
const sessionId = rows[0].sessionId || filename.replace(/\.jsonl$/, '');
|
|
705
|
+
if (!includeCurrent && currentSessionId && sessionId === currentSessionId) continue;
|
|
706
|
+
|
|
707
|
+
const matchIndexes = findMatchIndexes(rows, query, roleFilter);
|
|
708
|
+
if (matchIndexes.length === 0) continue;
|
|
709
|
+
|
|
710
|
+
const stat = fs.statSync(filePath);
|
|
711
|
+
const score = scoreTranscript(rows, query, roleFilter);
|
|
712
|
+
candidates.push({
|
|
713
|
+
sessionId,
|
|
714
|
+
filePath,
|
|
715
|
+
rows,
|
|
716
|
+
matchIndexes,
|
|
717
|
+
score,
|
|
718
|
+
mtimeMs: stat.mtimeMs,
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
candidates.sort((a, b) => {
|
|
723
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
724
|
+
return b.mtimeMs - a.mtimeMs;
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
const top = candidates.slice(0, limit);
|
|
728
|
+
const results = top.map((candidate) => summarizeSessionCandidate(candidate, query));
|
|
729
|
+
|
|
730
|
+
return JSON.stringify({
|
|
731
|
+
success: true,
|
|
732
|
+
query,
|
|
733
|
+
count: results.length,
|
|
734
|
+
sessions_searched: candidates.length,
|
|
735
|
+
results,
|
|
736
|
+
}, null, 2);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
case 'web_fetch': {
|
|
740
|
+
const result = await webFetch({
|
|
741
|
+
url: args.url,
|
|
742
|
+
extractMode: args.extractMode,
|
|
743
|
+
maxChars: args.maxChars,
|
|
744
|
+
});
|
|
745
|
+
const header = result.title ? `# ${result.title}\n\n` : '';
|
|
746
|
+
const meta = `[${result.extractor}] ${result.finalUrl} (${result.status}, ${result.tookMs}ms)`;
|
|
747
|
+
return `${meta}\n\n${header}${result.text}`;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
case 'cron': {
|
|
751
|
+
const action = args.action;
|
|
752
|
+
|
|
753
|
+
if (action === 'list') {
|
|
754
|
+
if (injectedTasks.length === 0) return 'No scheduled tasks.';
|
|
755
|
+
const lines = injectedTasks.map((t) => {
|
|
756
|
+
let schedule: string;
|
|
757
|
+
if (t.runAt) schedule = `at ${t.runAt}`;
|
|
758
|
+
else if (t.everyMs) {
|
|
759
|
+
const secs = t.everyMs / 1000;
|
|
760
|
+
if (secs < 120) schedule = `every ${secs}s`;
|
|
761
|
+
else if (secs < 7200) schedule = `every ${Math.round(secs / 60)}m`;
|
|
762
|
+
else schedule = `every ${Math.round(secs / 3600)}h`;
|
|
763
|
+
} else schedule = t.cronExpr;
|
|
764
|
+
const status = t.enabled ? 'enabled' : 'disabled';
|
|
765
|
+
return `#${t.id} [${status}] ${schedule} — ${t.prompt}`;
|
|
766
|
+
});
|
|
767
|
+
return lines.join('\n');
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
if (action === 'add') {
|
|
771
|
+
if (!args.prompt) return 'Error: prompt is required';
|
|
772
|
+
|
|
773
|
+
if (args.at) {
|
|
774
|
+
const runAt = new Date(args.at);
|
|
775
|
+
if (isNaN(runAt.getTime())) return `Error: invalid ISO-8601 timestamp: ${args.at}`;
|
|
776
|
+
if (runAt.getTime() <= Date.now()) return `Error: timestamp must be in the future: ${args.at}`;
|
|
777
|
+
pendingSchedules.push({ action: 'add', runAt: runAt.toISOString(), prompt: args.prompt });
|
|
778
|
+
return `Scheduled one-shot task at ${runAt.toISOString()}: ${args.prompt}`;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
if (args.cron) {
|
|
782
|
+
pendingSchedules.push({ action: 'add', cronExpr: args.cron, prompt: args.prompt });
|
|
783
|
+
return `Scheduled recurring task with cron "${args.cron}": ${args.prompt}`;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
if (args.every) {
|
|
787
|
+
const secs = Number(args.every);
|
|
788
|
+
if (isNaN(secs) || secs < 10) return 'Error: "every" must be a number of seconds >= 10';
|
|
789
|
+
const everyMs = Math.round(secs * 1000);
|
|
790
|
+
pendingSchedules.push({ action: 'add', everyMs, prompt: args.prompt });
|
|
791
|
+
return `Scheduled interval task every ${secs}s: ${args.prompt}`;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
return 'Error: provide "at" (ISO-8601 timestamp), "cron" (cron expression), or "every" (seconds)';
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
if (action === 'remove') {
|
|
798
|
+
if (!args.taskId) return 'Error: taskId is required';
|
|
799
|
+
pendingSchedules.push({ action: 'remove', taskId: args.taskId });
|
|
800
|
+
return `Scheduled removal of task #${args.taskId}`;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
return `Error: unknown cron action "${action}". Use "list", "add", or "remove".`;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
default:
|
|
807
|
+
return `Unknown tool: ${name}`;
|
|
808
|
+
}
|
|
809
|
+
} catch (err) {
|
|
810
|
+
return `Tool error: ${err instanceof Error ? err.message : String(err)}`;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
815
|
+
{
|
|
816
|
+
type: 'function',
|
|
817
|
+
function: {
|
|
818
|
+
name: 'read',
|
|
819
|
+
description:
|
|
820
|
+
'Read a file and return its contents. Output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files.',
|
|
821
|
+
parameters: {
|
|
822
|
+
type: 'object',
|
|
823
|
+
properties: {
|
|
824
|
+
path: { type: 'string', description: 'Path to the file to read' },
|
|
825
|
+
offset: { type: 'number', description: 'Line number to start reading from (1-indexed, default: 1)' },
|
|
826
|
+
limit: { type: 'number', description: 'Maximum number of lines to read before truncation logic (optional)' },
|
|
827
|
+
},
|
|
828
|
+
required: ['path'],
|
|
829
|
+
},
|
|
830
|
+
},
|
|
831
|
+
},
|
|
832
|
+
{
|
|
833
|
+
type: 'function',
|
|
834
|
+
function: {
|
|
835
|
+
name: 'write',
|
|
836
|
+
description: 'Write contents to a file, overwriting if it exists',
|
|
837
|
+
parameters: {
|
|
838
|
+
type: 'object',
|
|
839
|
+
properties: {
|
|
840
|
+
path: { type: 'string', description: 'Path to the file to write' },
|
|
841
|
+
contents: { type: 'string', description: 'Content to write' },
|
|
842
|
+
},
|
|
843
|
+
required: ['path', 'contents'],
|
|
844
|
+
},
|
|
845
|
+
},
|
|
846
|
+
},
|
|
847
|
+
{
|
|
848
|
+
type: 'function',
|
|
849
|
+
function: {
|
|
850
|
+
name: 'edit',
|
|
851
|
+
description: 'Replace text in a file using old/new strings',
|
|
852
|
+
parameters: {
|
|
853
|
+
type: 'object',
|
|
854
|
+
properties: {
|
|
855
|
+
path: { type: 'string', description: 'Path to the file to edit' },
|
|
856
|
+
old: { type: 'string', description: 'Text to find and replace' },
|
|
857
|
+
new: { type: 'string', description: 'Replacement text' },
|
|
858
|
+
count: { type: 'number', description: 'Number of replacements (default: 1)' },
|
|
859
|
+
},
|
|
860
|
+
required: ['path', 'old', 'new'],
|
|
861
|
+
},
|
|
862
|
+
},
|
|
863
|
+
},
|
|
864
|
+
{
|
|
865
|
+
type: 'function',
|
|
866
|
+
function: {
|
|
867
|
+
name: 'delete',
|
|
868
|
+
description: 'Delete a file from the workspace',
|
|
869
|
+
parameters: {
|
|
870
|
+
type: 'object',
|
|
871
|
+
properties: {
|
|
872
|
+
path: { type: 'string', description: 'Path to the file to delete' },
|
|
873
|
+
},
|
|
874
|
+
required: ['path'],
|
|
875
|
+
},
|
|
876
|
+
},
|
|
877
|
+
},
|
|
878
|
+
{
|
|
879
|
+
type: 'function',
|
|
880
|
+
function: {
|
|
881
|
+
name: 'glob',
|
|
882
|
+
description: 'List files matching a glob pattern',
|
|
883
|
+
parameters: {
|
|
884
|
+
type: 'object',
|
|
885
|
+
properties: {
|
|
886
|
+
pattern: { type: 'string', description: 'Glob pattern to match files' },
|
|
887
|
+
},
|
|
888
|
+
required: ['pattern'],
|
|
889
|
+
},
|
|
890
|
+
},
|
|
891
|
+
},
|
|
892
|
+
{
|
|
893
|
+
type: 'function',
|
|
894
|
+
function: {
|
|
895
|
+
name: 'grep',
|
|
896
|
+
description: 'Search for a regex pattern in files',
|
|
897
|
+
parameters: {
|
|
898
|
+
type: 'object',
|
|
899
|
+
properties: {
|
|
900
|
+
pattern: { type: 'string', description: 'Regex pattern to search for' },
|
|
901
|
+
path: { type: 'string', description: 'Directory or file to search in (default: workspace root)' },
|
|
902
|
+
},
|
|
903
|
+
required: ['pattern'],
|
|
904
|
+
},
|
|
905
|
+
},
|
|
906
|
+
},
|
|
907
|
+
{
|
|
908
|
+
type: 'function',
|
|
909
|
+
function: {
|
|
910
|
+
name: 'bash',
|
|
911
|
+
description: 'Run a shell command and return stdout/stderr',
|
|
912
|
+
parameters: {
|
|
913
|
+
type: 'object',
|
|
914
|
+
properties: {
|
|
915
|
+
command: { type: 'string', description: 'Shell command to execute' },
|
|
916
|
+
},
|
|
917
|
+
required: ['command'],
|
|
918
|
+
},
|
|
919
|
+
},
|
|
920
|
+
},
|
|
921
|
+
{
|
|
922
|
+
type: 'function',
|
|
923
|
+
function: {
|
|
924
|
+
name: 'memory',
|
|
925
|
+
description:
|
|
926
|
+
'Manage durable agent memory files. Supports MEMORY.md, USER.md, and daily files at memory/YYYY-MM-DD.md. Actions: read, append, write, replace, remove, list, search. Memory files are char-bounded to prevent unbounded growth.',
|
|
927
|
+
parameters: {
|
|
928
|
+
type: 'object',
|
|
929
|
+
properties: {
|
|
930
|
+
action: { type: 'string', description: 'Action: "read", "append", "write", "replace", "remove", "list", or "search"' },
|
|
931
|
+
file_path: { type: 'string', description: 'Target file path. Allowed: MEMORY.md, USER.md, memory/YYYY-MM-DD.md' },
|
|
932
|
+
target: { type: 'string', description: 'Optional shorthand target: "memory", "user", or "daily"' },
|
|
933
|
+
date: { type: 'string', description: 'Date for target="daily" in YYYY-MM-DD format (defaults to today)' },
|
|
934
|
+
content: { type: 'string', description: 'Text payload for append/write' },
|
|
935
|
+
old_text: { type: 'string', description: 'Existing substring for replace/remove' },
|
|
936
|
+
new_text: { type: 'string', description: 'Replacement text for replace' },
|
|
937
|
+
query: { type: 'string', description: 'Case-insensitive query string for search' },
|
|
938
|
+
},
|
|
939
|
+
required: ['action'],
|
|
940
|
+
},
|
|
941
|
+
},
|
|
942
|
+
},
|
|
943
|
+
{
|
|
944
|
+
type: 'function',
|
|
945
|
+
function: {
|
|
946
|
+
name: 'session_search',
|
|
947
|
+
description:
|
|
948
|
+
'Search and summarize historical session transcripts. Returns top matching sessions with concise summaries and key snippets.',
|
|
949
|
+
parameters: {
|
|
950
|
+
type: 'object',
|
|
951
|
+
properties: {
|
|
952
|
+
query: { type: 'string', description: 'Search query over prior session transcripts' },
|
|
953
|
+
limit: { type: 'number', description: 'Maximum number of sessions to summarize (default 3, max 5)' },
|
|
954
|
+
role_filter: { type: 'string', description: 'Optional comma-separated roles to match (e.g. "user,assistant")' },
|
|
955
|
+
include_current: { type: 'boolean', description: 'Include the current session in results (default false)' },
|
|
956
|
+
},
|
|
957
|
+
required: ['query'],
|
|
958
|
+
},
|
|
959
|
+
},
|
|
960
|
+
},
|
|
961
|
+
{
|
|
962
|
+
type: 'function',
|
|
963
|
+
function: {
|
|
964
|
+
name: 'web_fetch',
|
|
965
|
+
description:
|
|
966
|
+
'Fetch a URL and extract its readable content as markdown or plain text. Works with HTML pages, JSON APIs, and markdown URLs. Use for reading web pages, documentation, API responses, etc.',
|
|
967
|
+
parameters: {
|
|
968
|
+
type: 'object',
|
|
969
|
+
properties: {
|
|
970
|
+
url: { type: 'string', description: 'HTTP or HTTPS URL to fetch' },
|
|
971
|
+
extractMode: {
|
|
972
|
+
type: 'string',
|
|
973
|
+
description: 'Extraction mode: "markdown" (default) or "text"',
|
|
974
|
+
},
|
|
975
|
+
maxChars: {
|
|
976
|
+
type: 'number',
|
|
977
|
+
description: 'Maximum characters to return (default 50000, max 50000)',
|
|
978
|
+
},
|
|
979
|
+
},
|
|
980
|
+
required: ['url'],
|
|
981
|
+
},
|
|
982
|
+
},
|
|
983
|
+
},
|
|
984
|
+
{
|
|
985
|
+
type: 'function',
|
|
986
|
+
function: {
|
|
987
|
+
name: 'cron',
|
|
988
|
+
description:
|
|
989
|
+
'Manage scheduled tasks and reminders. Actions:\n' +
|
|
990
|
+
'- "list": show all scheduled tasks\n' +
|
|
991
|
+
'- "add": create a task. Provide "prompt" plus one of: "at" (ISO-8601 timestamp for one-shot), "cron" (cron expression for cron-based recurring), or "every" (interval in seconds for simple recurring)\n' +
|
|
992
|
+
'- "remove": delete a task by taskId\n' +
|
|
993
|
+
'For relative times like "in 5 minutes", compute the ISO-8601 timestamp and use "at".',
|
|
994
|
+
parameters: {
|
|
995
|
+
type: 'object',
|
|
996
|
+
properties: {
|
|
997
|
+
action: { type: 'string', description: 'Action to perform: "list", "add", or "remove"' },
|
|
998
|
+
prompt: { type: 'string', description: 'Task prompt / reminder text (required for "add")' },
|
|
999
|
+
at: { type: 'string', description: 'ISO-8601 timestamp for one-shot schedule (e.g. "2025-01-15T14:30:00Z")' },
|
|
1000
|
+
cron: { type: 'string', description: 'Cron expression for recurring schedule (e.g. "0 9 * * *")' },
|
|
1001
|
+
every: { type: 'number', description: 'Interval in seconds for simple recurring schedule (minimum 10)' },
|
|
1002
|
+
taskId: { type: 'number', description: 'Task ID to remove (required for "remove")' },
|
|
1003
|
+
},
|
|
1004
|
+
required: ['action'],
|
|
1005
|
+
},
|
|
1006
|
+
},
|
|
1007
|
+
},
|
|
1008
|
+
];
|