pretticlaw 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/CONTRIBUTING.md +123 -0
- package/README.md +150 -0
- package/assets/logo.png +0 -0
- package/dist/agent/context.d.ts +22 -0
- package/dist/agent/context.js +85 -0
- package/dist/agent/loop.d.ts +63 -0
- package/dist/agent/loop.js +244 -0
- package/dist/agent/memory.d.ts +16 -0
- package/dist/agent/memory.js +98 -0
- package/dist/agent/skills.d.ts +18 -0
- package/dist/agent/skills.js +121 -0
- package/dist/agent/subagent.d.ts +30 -0
- package/dist/agent/subagent.js +92 -0
- package/dist/agent/tools/base.d.ts +10 -0
- package/dist/agent/tools/base.js +58 -0
- package/dist/agent/tools/cron.d.ts +43 -0
- package/dist/agent/tools/cron.js +83 -0
- package/dist/agent/tools/filesystem.d.ts +79 -0
- package/dist/agent/tools/filesystem.js +125 -0
- package/dist/agent/tools/message.d.ts +41 -0
- package/dist/agent/tools/message.js +55 -0
- package/dist/agent/tools/registry.d.ts +9 -0
- package/dist/agent/tools/registry.js +33 -0
- package/dist/agent/tools/shell.d.ts +26 -0
- package/dist/agent/tools/shell.js +78 -0
- package/dist/agent/tools/spawn.d.ts +27 -0
- package/dist/agent/tools/spawn.js +35 -0
- package/dist/agent/tools/web.d.ts +50 -0
- package/dist/agent/tools/web.js +119 -0
- package/dist/bus/async-queue.d.ts +7 -0
- package/dist/bus/async-queue.js +20 -0
- package/dist/bus/events.d.ts +19 -0
- package/dist/bus/events.js +3 -0
- package/dist/bus/queue.d.ts +12 -0
- package/dist/bus/queue.js +23 -0
- package/dist/channels/base.d.ts +22 -0
- package/dist/channels/base.js +35 -0
- package/dist/channels/discord.d.ts +24 -0
- package/dist/channels/discord.js +133 -0
- package/dist/channels/manager.d.ts +17 -0
- package/dist/channels/manager.js +67 -0
- package/dist/channels/stub.d.ts +10 -0
- package/dist/channels/stub.js +18 -0
- package/dist/channels/telegram.d.ts +20 -0
- package/dist/channels/telegram.js +93 -0
- package/dist/cli/commands.d.ts +2 -0
- package/dist/cli/commands.js +552 -0
- package/dist/config/loader.d.ts +5 -0
- package/dist/config/loader.js +55 -0
- package/dist/config/schema.d.ts +246 -0
- package/dist/config/schema.js +94 -0
- package/dist/cron/service.d.ts +33 -0
- package/dist/cron/service.js +195 -0
- package/dist/cron/types.d.ts +47 -0
- package/dist/cron/types.js +1 -0
- package/dist/dashboard/index.html +1567 -0
- package/dist/heartbeat/service.d.ts +21 -0
- package/dist/heartbeat/service.js +101 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -0
- package/dist/providers/base.d.ts +23 -0
- package/dist/providers/base.js +21 -0
- package/dist/providers/custom-provider.d.ts +16 -0
- package/dist/providers/custom-provider.js +49 -0
- package/dist/providers/litellm-provider.d.ts +19 -0
- package/dist/providers/litellm-provider.js +128 -0
- package/dist/providers/registry.d.ts +5 -0
- package/dist/providers/registry.js +45 -0
- package/dist/session/manager.d.ts +31 -0
- package/dist/session/manager.js +116 -0
- package/dist/skills/README.md +25 -0
- package/dist/skills/clawhub/SKILL.md +53 -0
- package/dist/skills/cron/SKILL.md +57 -0
- package/dist/skills/github/SKILL.md +48 -0
- package/dist/skills/memory/SKILL.md +31 -0
- package/dist/skills/skill-creator/SKILL.md +371 -0
- package/dist/skills/summarize/SKILL.md +67 -0
- package/dist/skills/tmux/SKILL.md +121 -0
- package/dist/skills/tmux/scripts/find-sessions.sh +112 -0
- package/dist/skills/tmux/scripts/wait-for-text.sh +83 -0
- package/dist/skills/weather/SKILL.md +49 -0
- package/dist/templates/AGENTS.md +23 -0
- package/dist/templates/HEARTBEAT.md +16 -0
- package/dist/templates/SOUL.md +21 -0
- package/dist/templates/TOOLS.md +15 -0
- package/dist/templates/USER.md +49 -0
- package/dist/templates/memory/MEMORY.md +23 -0
- package/dist/types.d.ts +4 -0
- package/dist/types.js +3 -0
- package/dist/utils/helpers.d.ts +5 -0
- package/dist/utils/helpers.js +53 -0
- package/dist/web/server.d.ts +15 -0
- package/dist/web/server.js +169 -0
- package/package.json +37 -0
- package/scripts/copy-assets.mjs +21 -0
- package/src/agent/context.ts +90 -0
- package/src/agent/loop.ts +291 -0
- package/src/agent/memory.ts +104 -0
- package/src/agent/skills.ts +121 -0
- package/src/agent/subagent.ts +96 -0
- package/src/agent/tools/base.ts +59 -0
- package/src/agent/tools/cron.ts +79 -0
- package/src/agent/tools/filesystem.ts +93 -0
- package/src/agent/tools/message.ts +57 -0
- package/src/agent/tools/registry.ts +36 -0
- package/src/agent/tools/shell.ts +69 -0
- package/src/agent/tools/spawn.ts +37 -0
- package/src/agent/tools/web.ts +108 -0
- package/src/bus/async-queue.ts +20 -0
- package/src/bus/events.ts +23 -0
- package/src/bus/queue.ts +31 -0
- package/src/channels/base.ts +36 -0
- package/src/channels/discord.ts +156 -0
- package/src/channels/manager.ts +70 -0
- package/src/channels/stub.ts +20 -0
- package/src/channels/telegram.ts +120 -0
- package/src/cli/commands.ts +581 -0
- package/src/config/loader.ts +58 -0
- package/src/config/schema.ts +144 -0
- package/src/cron/service.ts +190 -0
- package/src/cron/types.ts +36 -0
- package/src/dashboard/index.html +1567 -0
- package/src/heartbeat/service.ts +95 -0
- package/src/index.ts +6 -0
- package/src/providers/base.ts +43 -0
- package/src/providers/custom-provider.ts +46 -0
- package/src/providers/litellm-provider.ts +131 -0
- package/src/providers/registry.ts +48 -0
- package/src/session/manager.ts +129 -0
- package/src/skills/README.md +25 -0
- package/src/skills/clawhub/SKILL.md +53 -0
- package/src/skills/cron/SKILL.md +57 -0
- package/src/skills/github/SKILL.md +48 -0
- package/src/skills/memory/SKILL.md +31 -0
- package/src/skills/skill-creator/SKILL.md +371 -0
- package/src/skills/summarize/SKILL.md +67 -0
- package/src/skills/tmux/SKILL.md +121 -0
- package/src/skills/tmux/scripts/find-sessions.sh +112 -0
- package/src/skills/tmux/scripts/wait-for-text.sh +83 -0
- package/src/skills/weather/SKILL.md +49 -0
- package/src/templates/AGENTS.md +23 -0
- package/src/templates/HEARTBEAT.md +16 -0
- package/src/templates/SOUL.md +21 -0
- package/src/templates/TOOLS.md +15 -0
- package/src/templates/USER.md +49 -0
- package/src/templates/memory/MEMORY.md +23 -0
- package/src/types/prompts.d.ts +14 -0
- package/src/types/ws.d.ts +15 -0
- package/src/types.ts +5 -0
- package/src/utils/helpers.ts +55 -0
- package/src/web/server.ts +198 -0
- package/test/context.test.ts +27 -0
- package/test/cron-service.test.ts +31 -0
- package/test/message-tool.test.ts +10 -0
- package/test/providers.test.ts +43 -0
- package/test/tool-validation.test.ts +61 -0
- package/tsconfig.json +16 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { LLMProvider } from "../providers/base.js";
|
|
4
|
+
import type { Session } from "../session/manager.js";
|
|
5
|
+
|
|
6
|
+
const SAVE_MEMORY_TOOL = [{
|
|
7
|
+
type: "function",
|
|
8
|
+
function: {
|
|
9
|
+
name: "save_memory",
|
|
10
|
+
description: "Save the memory consolidation result to persistent storage.",
|
|
11
|
+
parameters: {
|
|
12
|
+
type: "object",
|
|
13
|
+
properties: {
|
|
14
|
+
history_entry: { type: "string" },
|
|
15
|
+
memory_update: { type: "string" },
|
|
16
|
+
},
|
|
17
|
+
required: ["history_entry", "memory_update"],
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
}];
|
|
21
|
+
|
|
22
|
+
export class MemoryStore {
|
|
23
|
+
readonly memoryDir: string;
|
|
24
|
+
readonly memoryFile: string;
|
|
25
|
+
readonly historyFile: string;
|
|
26
|
+
|
|
27
|
+
constructor(workspace: string) {
|
|
28
|
+
this.memoryDir = path.join(workspace, "memory");
|
|
29
|
+
fs.mkdirSync(this.memoryDir, { recursive: true });
|
|
30
|
+
this.memoryFile = path.join(this.memoryDir, "MEMORY.md");
|
|
31
|
+
this.historyFile = path.join(this.memoryDir, "HISTORY.md");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
readLongTerm(): string {
|
|
35
|
+
return fs.existsSync(this.memoryFile) ? fs.readFileSync(this.memoryFile, "utf8") : "";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
writeLongTerm(content: string): void {
|
|
39
|
+
fs.writeFileSync(this.memoryFile, content, "utf8");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
appendHistory(entry: string): void {
|
|
43
|
+
fs.appendFileSync(this.historyFile, `${entry.trim()}\n\n`, "utf8");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
getMemoryContext(): string {
|
|
47
|
+
const longTerm = this.readLongTerm();
|
|
48
|
+
return longTerm ? `## Long-term Memory\n${longTerm}` : "";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async consolidate(session: Session, provider: LLMProvider, model: string, opts?: { archiveAll?: boolean; memoryWindow?: number }): Promise<boolean> {
|
|
52
|
+
const archiveAll = opts?.archiveAll ?? false;
|
|
53
|
+
const memoryWindow = opts?.memoryWindow ?? 50;
|
|
54
|
+
|
|
55
|
+
let oldMessages = [] as any[];
|
|
56
|
+
let keepCount = 0;
|
|
57
|
+
if (archiveAll) {
|
|
58
|
+
oldMessages = session.messages;
|
|
59
|
+
keepCount = 0;
|
|
60
|
+
} else {
|
|
61
|
+
keepCount = Math.floor(memoryWindow / 2);
|
|
62
|
+
if (session.messages.length <= keepCount) return true;
|
|
63
|
+
if (session.messages.length - session.lastConsolidated <= 0) return true;
|
|
64
|
+
oldMessages = session.messages.slice(session.lastConsolidated, session.messages.length - keepCount);
|
|
65
|
+
if (!oldMessages.length) return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const lines: string[] = [];
|
|
69
|
+
for (const m of oldMessages) {
|
|
70
|
+
if (!m.content) continue;
|
|
71
|
+
const tools = Array.isArray(m.tools_used) && m.tools_used.length ? ` [tools: ${m.tools_used.join(", ")}]` : "";
|
|
72
|
+
lines.push(`[${String(m.timestamp ?? "?").slice(0, 16)}] ${String(m.role).toUpperCase()}${tools}: ${String(m.content)}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const currentMemory = this.readLongTerm();
|
|
76
|
+
const prompt = `Process this conversation and call the save_memory tool with your consolidation.\n\n## Current Long-term Memory\n${currentMemory || "(empty)"}\n\n## Conversation to Process\n${lines.join("\n")}`;
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const response = await provider.chat({
|
|
80
|
+
model,
|
|
81
|
+
messages: [
|
|
82
|
+
{ role: "system", content: "You are a memory consolidation agent. Call the save_memory tool." },
|
|
83
|
+
{ role: "user", content: prompt },
|
|
84
|
+
],
|
|
85
|
+
tools: SAVE_MEMORY_TOOL,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (!response.toolCalls.length) return false;
|
|
89
|
+
const call = response.toolCalls[0];
|
|
90
|
+
const args = call.arguments ?? {};
|
|
91
|
+
const entry = args.history_entry;
|
|
92
|
+
const update = args.memory_update;
|
|
93
|
+
if (entry != null) this.appendHistory(typeof entry === "string" ? entry : JSON.stringify(entry));
|
|
94
|
+
if (update != null) {
|
|
95
|
+
const text = typeof update === "string" ? update : JSON.stringify(update);
|
|
96
|
+
if (text !== currentMemory) this.writeLongTerm(text);
|
|
97
|
+
}
|
|
98
|
+
session.lastConsolidated = archiveAll ? 0 : session.messages.length - keepCount;
|
|
99
|
+
return true;
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const BUILTIN_SKILLS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../skills");
|
|
6
|
+
|
|
7
|
+
export class SkillsLoader {
|
|
8
|
+
private readonly workspaceSkills: string;
|
|
9
|
+
private readonly builtinSkills: string;
|
|
10
|
+
|
|
11
|
+
constructor(workspace: string, builtinSkillsDir = BUILTIN_SKILLS_DIR) {
|
|
12
|
+
this.workspaceSkills = path.join(workspace, "skills");
|
|
13
|
+
this.builtinSkills = builtinSkillsDir;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
listSkills(filterUnavailable = true): Array<{ name: string; path: string; source: string }> {
|
|
17
|
+
const skills: Array<{ name: string; path: string; source: string }> = [];
|
|
18
|
+
const load = (root: string, source: string) => {
|
|
19
|
+
if (!fs.existsSync(root)) return;
|
|
20
|
+
for (const name of fs.readdirSync(root)) {
|
|
21
|
+
const skillFile = path.join(root, name, "SKILL.md");
|
|
22
|
+
if (!fs.existsSync(skillFile)) continue;
|
|
23
|
+
if (skills.some((s) => s.name === name)) continue;
|
|
24
|
+
skills.push({ name, path: skillFile, source });
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
load(this.workspaceSkills, "workspace");
|
|
28
|
+
load(this.builtinSkills, "builtin");
|
|
29
|
+
|
|
30
|
+
if (!filterUnavailable) return skills;
|
|
31
|
+
return skills.filter((s) => this.checkRequirements(this.getSkillMeta(s.name)));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
loadSkill(name: string): string | null {
|
|
35
|
+
const workspace = path.join(this.workspaceSkills, name, "SKILL.md");
|
|
36
|
+
if (fs.existsSync(workspace)) return fs.readFileSync(workspace, "utf8");
|
|
37
|
+
const builtin = path.join(this.builtinSkills, name, "SKILL.md");
|
|
38
|
+
if (fs.existsSync(builtin)) return fs.readFileSync(builtin, "utf8");
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
loadSkillsForContext(skillNames: string[]): string {
|
|
43
|
+
const parts: string[] = [];
|
|
44
|
+
for (const name of skillNames) {
|
|
45
|
+
const c = this.loadSkill(name);
|
|
46
|
+
if (c) parts.push(`### Skill: ${name}\n\n${this.stripFrontmatter(c)}`);
|
|
47
|
+
}
|
|
48
|
+
return parts.join("\n\n---\n\n");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
buildSkillsSummary(): string {
|
|
52
|
+
const all = this.listSkills(false);
|
|
53
|
+
if (!all.length) return "";
|
|
54
|
+
const esc = (s: string) => s.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
55
|
+
const lines = ["<skills>"];
|
|
56
|
+
for (const s of all) {
|
|
57
|
+
const meta = this.getSkillMetadata(s.name) ?? {};
|
|
58
|
+
const prettiMeta = this.getSkillMeta(s.name);
|
|
59
|
+
const available = this.checkRequirements(prettiMeta);
|
|
60
|
+
lines.push(` <skill available="${available}">`);
|
|
61
|
+
lines.push(` <name>${esc(s.name)}</name>`);
|
|
62
|
+
lines.push(` <description>${esc((meta.description as string) || s.name)}</description>`);
|
|
63
|
+
lines.push(` <location>${s.path}</location>`);
|
|
64
|
+
lines.push(" </skill>");
|
|
65
|
+
}
|
|
66
|
+
lines.push("</skills>");
|
|
67
|
+
return lines.join("\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
getAlwaysSkills(): string[] {
|
|
71
|
+
const out: string[] = [];
|
|
72
|
+
for (const s of this.listSkills(true)) {
|
|
73
|
+
const meta = this.getSkillMetadata(s.name) ?? {};
|
|
74
|
+
const m = this.getSkillMeta(s.name);
|
|
75
|
+
if (m.always || meta.always) out.push(s.name);
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
getSkillMetadata(name: string): Record<string, unknown> | null {
|
|
81
|
+
const content = this.loadSkill(name);
|
|
82
|
+
if (!content?.startsWith("---")) return null;
|
|
83
|
+
const m = content.match(/^---\n([\s\S]*?)\n---/);
|
|
84
|
+
if (!m) return null;
|
|
85
|
+
const out: Record<string, unknown> = {};
|
|
86
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
87
|
+
const idx = line.indexOf(":");
|
|
88
|
+
if (idx <= 0) continue;
|
|
89
|
+
out[line.slice(0, idx).trim()] = line.slice(idx + 1).trim().replace(/^['"]|['"]$/g, "");
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private stripFrontmatter(content: string): string {
|
|
95
|
+
const m = content.match(/^---\n[\s\S]*?\n---\n?/);
|
|
96
|
+
return m ? content.slice(m[0].length).trim() : content;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private getSkillMeta(name: string): Record<string, any> {
|
|
100
|
+
const meta = this.getSkillMetadata(name) ?? {};
|
|
101
|
+
const raw = typeof meta.metadata === "string" ? meta.metadata : "{}";
|
|
102
|
+
try {
|
|
103
|
+
const data = JSON.parse(raw);
|
|
104
|
+
return data.pretticlaw || data.openclaw || {};
|
|
105
|
+
} catch {
|
|
106
|
+
return {};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private checkRequirements(skillMeta: Record<string, any>): boolean {
|
|
111
|
+
const req = skillMeta.requires ?? {};
|
|
112
|
+
const bins: string[] = req.bins ?? [];
|
|
113
|
+
const env: string[] = req.env ?? [];
|
|
114
|
+
const hasBin = (name: string) => {
|
|
115
|
+
const pathEntries = (process.env.PATH ?? "").split(path.delimiter);
|
|
116
|
+
const exts = process.platform === "win32" ? ["", ".exe", ".cmd", ".bat"] : [""];
|
|
117
|
+
return pathEntries.some((p) => exts.some((e) => fs.existsSync(path.join(p, `${name}${e}`))));
|
|
118
|
+
};
|
|
119
|
+
return bins.every(hasBin) && env.every((k) => !!process.env[k]);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { MessageBus } from "../bus/queue.js";
|
|
3
|
+
import type { LLMProvider } from "../providers/base.js";
|
|
4
|
+
import type { InboundMessage } from "../bus/events.js";
|
|
5
|
+
import { ToolRegistry } from "./tools/registry.js";
|
|
6
|
+
import { ReadFileTool, WriteFileTool, EditFileTool, ListDirTool } from "./tools/filesystem.js";
|
|
7
|
+
import { ExecTool } from "./tools/shell.js";
|
|
8
|
+
import { WebSearchTool, WebFetchTool } from "./tools/web.js";
|
|
9
|
+
|
|
10
|
+
export class SubagentManager {
|
|
11
|
+
private running = new Map<string, Promise<void>>();
|
|
12
|
+
private sessionTasks = new Map<string, Set<string>>();
|
|
13
|
+
|
|
14
|
+
constructor(
|
|
15
|
+
private readonly provider: LLMProvider,
|
|
16
|
+
private readonly workspace: string,
|
|
17
|
+
private readonly bus: MessageBus,
|
|
18
|
+
private readonly model: string,
|
|
19
|
+
private readonly temperature: number,
|
|
20
|
+
private readonly maxTokens: number,
|
|
21
|
+
private readonly braveApiKey: string | null,
|
|
22
|
+
private readonly execConfig: { timeout: number; pathAppend: string },
|
|
23
|
+
private readonly restrictToWorkspace: boolean,
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
async spawn(input: { task: string; label: string | null; originChannel: string; originChatId: string; sessionKey: string }): Promise<string> {
|
|
27
|
+
const taskId = randomUUID().slice(0, 8);
|
|
28
|
+
const label = input.label ?? (input.task.length > 30 ? `${input.task.slice(0, 30)}...` : input.task);
|
|
29
|
+
|
|
30
|
+
const p = this.runSubagent(taskId, input.task, label, { channel: input.originChannel, chatId: input.originChatId })
|
|
31
|
+
.finally(() => {
|
|
32
|
+
this.running.delete(taskId);
|
|
33
|
+
const ids = this.sessionTasks.get(input.sessionKey);
|
|
34
|
+
if (ids) {
|
|
35
|
+
ids.delete(taskId);
|
|
36
|
+
if (!ids.size) this.sessionTasks.delete(input.sessionKey);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
this.running.set(taskId, p);
|
|
41
|
+
if (!this.sessionTasks.has(input.sessionKey)) this.sessionTasks.set(input.sessionKey, new Set());
|
|
42
|
+
this.sessionTasks.get(input.sessionKey)!.add(taskId);
|
|
43
|
+
|
|
44
|
+
return `Subagent [${label}] started (id: ${taskId}). I'll notify you when it completes.`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private async runSubagent(taskId: string, task: string, label: string, origin: { channel: string; chatId: string }): Promise<void> {
|
|
48
|
+
const tools = new ToolRegistry();
|
|
49
|
+
const allowedDir = this.restrictToWorkspace ? this.workspace : undefined;
|
|
50
|
+
tools.register(new ReadFileTool(this.workspace, allowedDir));
|
|
51
|
+
tools.register(new WriteFileTool(this.workspace, allowedDir));
|
|
52
|
+
tools.register(new EditFileTool(this.workspace, allowedDir));
|
|
53
|
+
tools.register(new ListDirTool(this.workspace, allowedDir));
|
|
54
|
+
tools.register(new ExecTool(this.execConfig.timeout, this.workspace, this.restrictToWorkspace, this.execConfig.pathAppend));
|
|
55
|
+
tools.register(new WebSearchTool(this.braveApiKey));
|
|
56
|
+
tools.register(new WebFetchTool());
|
|
57
|
+
|
|
58
|
+
let messages: Array<Record<string, unknown>> = [
|
|
59
|
+
{ role: "system", content: this.buildSubagentPrompt() },
|
|
60
|
+
{ role: "user", content: task },
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
let finalResult = "Task completed but no final response was generated.";
|
|
64
|
+
for (let i = 0; i < 15; i++) {
|
|
65
|
+
const response = await this.provider.chat({ messages, tools: tools.getDefinitions(), model: this.model, temperature: this.temperature, maxTokens: this.maxTokens });
|
|
66
|
+
if (response.toolCalls.length) {
|
|
67
|
+
messages.push({ role: "assistant", content: response.content ?? "", tool_calls: response.toolCalls.map((tc) => ({ id: tc.id, type: "function", function: { name: tc.name, arguments: JSON.stringify(tc.arguments) } })) });
|
|
68
|
+
for (const tc of response.toolCalls) {
|
|
69
|
+
const result = await tools.execute(tc.name, tc.arguments);
|
|
70
|
+
messages.push({ role: "tool", tool_call_id: tc.id, name: tc.name, content: result });
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
finalResult = response.content ?? finalResult;
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const announceContent = `[Subagent '${label}' completed successfully]\n\nTask: ${task}\n\nResult:\n${finalResult}\n\nSummarize this naturally for the user. Keep it brief (1-2 sentences).`;
|
|
79
|
+
const msg: InboundMessage = { channel: "system", senderId: "subagent", chatId: `${origin.channel}:${origin.chatId}`, content: announceContent };
|
|
80
|
+
await this.bus.publishInbound(msg);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private buildSubagentPrompt(): string {
|
|
84
|
+
const now = new Date().toLocaleString();
|
|
85
|
+
return `# Subagent\n\nCurrent Time: ${now}\n\nYou are a subagent spawned by the main agent to complete a specific task. Stay focused and concise.`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async cancelBySession(sessionKey: string): Promise<number> {
|
|
89
|
+
const ids = [...(this.sessionTasks.get(sessionKey) ?? [])];
|
|
90
|
+
return ids.filter((id) => this.running.has(id)).length;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
getRunningCount(): number {
|
|
94
|
+
return this.running.size;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export abstract class Tool {
|
|
2
|
+
protected static typeMap: Record<string, (v: unknown) => boolean> = {
|
|
3
|
+
string: (v) => typeof v === "string",
|
|
4
|
+
integer: (v) => Number.isInteger(v),
|
|
5
|
+
number: (v) => typeof v === "number" && Number.isFinite(v),
|
|
6
|
+
boolean: (v) => typeof v === "boolean",
|
|
7
|
+
array: (v) => Array.isArray(v),
|
|
8
|
+
object: (v) => !!v && typeof v === "object" && !Array.isArray(v),
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
abstract readonly name: string;
|
|
12
|
+
abstract readonly description: string;
|
|
13
|
+
abstract readonly parameters: Record<string, unknown>;
|
|
14
|
+
abstract execute(args: Record<string, unknown>): Promise<string>;
|
|
15
|
+
|
|
16
|
+
validateParams(params: Record<string, unknown>): string[] {
|
|
17
|
+
const schema = (this.parameters ?? {}) as Record<string, unknown>;
|
|
18
|
+
if ((schema.type as string | undefined) !== "object") {
|
|
19
|
+
throw new Error(`Schema must be object type, got ${String(schema.type)}`);
|
|
20
|
+
}
|
|
21
|
+
return this.validateValue(params, { ...schema, type: "object" }, "parameter");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private validateValue(value: unknown, schema: Record<string, unknown>, label: string): string[] {
|
|
25
|
+
const t = schema.type as string | undefined;
|
|
26
|
+
const errors: string[] = [];
|
|
27
|
+
if (t && Tool.typeMap[t] && !Tool.typeMap[t](value)) return [`${label} should be ${t}`];
|
|
28
|
+
|
|
29
|
+
if (schema.enum && !((schema.enum as unknown[]).includes(value))) errors.push(`${label} must be one of ${JSON.stringify(schema.enum)}`);
|
|
30
|
+
if (t === "integer" || t === "number") {
|
|
31
|
+
if (typeof value === "number") {
|
|
32
|
+
if (typeof schema.minimum === "number" && value < schema.minimum) errors.push(`${label} must be >= ${schema.minimum}`);
|
|
33
|
+
if (typeof schema.maximum === "number" && value > schema.maximum) errors.push(`${label} must be <= ${schema.maximum}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (t === "string" && typeof value === "string") {
|
|
37
|
+
if (typeof schema.minLength === "number" && value.length < schema.minLength) errors.push(`${label} must be at least ${schema.minLength} chars`);
|
|
38
|
+
if (typeof schema.maxLength === "number" && value.length > schema.maxLength) errors.push(`${label} must be at most ${schema.maxLength} chars`);
|
|
39
|
+
}
|
|
40
|
+
if (t === "object" && value && typeof value === "object" && !Array.isArray(value)) {
|
|
41
|
+
const v = value as Record<string, unknown>;
|
|
42
|
+
const props = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
|
|
43
|
+
for (const req of (schema.required ?? []) as string[]) {
|
|
44
|
+
if (!(req in v)) errors.push(`missing required ${label === "parameter" ? req : `${label}.${req}`}`);
|
|
45
|
+
}
|
|
46
|
+
for (const [k, child] of Object.entries(v)) {
|
|
47
|
+
if (props[k]) errors.push(...this.validateValue(child, props[k], label === "parameter" ? k : `${label}.${k}`));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (t === "array" && Array.isArray(value) && schema.items && typeof schema.items === "object") {
|
|
51
|
+
value.forEach((item, i) => errors.push(...this.validateValue(item, schema.items as Record<string, unknown>, `${label}[${i}]`)));
|
|
52
|
+
}
|
|
53
|
+
return errors;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
toSchema(): Record<string, unknown> {
|
|
57
|
+
return { type: "function", function: { name: this.name, description: this.description, parameters: this.parameters } };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Tool } from "./base.js";
|
|
2
|
+
import type { CronService } from "../../cron/service.js";
|
|
3
|
+
import type { CronSchedule } from "../../cron/types.js";
|
|
4
|
+
|
|
5
|
+
export class CronTool extends Tool {
|
|
6
|
+
readonly name = "cron";
|
|
7
|
+
readonly description = "Schedule reminders and recurring tasks. Actions: add, list, remove.";
|
|
8
|
+
readonly parameters = {
|
|
9
|
+
type: "object",
|
|
10
|
+
properties: {
|
|
11
|
+
action: { type: "string", enum: ["add", "list", "remove"] },
|
|
12
|
+
message: { type: "string" },
|
|
13
|
+
every_seconds: { type: "integer" },
|
|
14
|
+
cron_expr: { type: "string" },
|
|
15
|
+
tz: { type: "string" },
|
|
16
|
+
at: { type: "string" },
|
|
17
|
+
job_id: { type: "string" },
|
|
18
|
+
},
|
|
19
|
+
required: ["action"],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
private channel = "";
|
|
23
|
+
private chatId = "";
|
|
24
|
+
|
|
25
|
+
constructor(private readonly cron: CronService) { super(); }
|
|
26
|
+
|
|
27
|
+
setContext(channel: string, chatId: string): void {
|
|
28
|
+
this.channel = channel;
|
|
29
|
+
this.chatId = chatId;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async execute(args: Record<string, unknown>): Promise<string> {
|
|
33
|
+
const action = String(args.action ?? "");
|
|
34
|
+
if (action === "add") return this.addJob(args);
|
|
35
|
+
if (action === "list") return this.listJobs();
|
|
36
|
+
if (action === "remove") return this.removeJob(String(args.job_id ?? ""));
|
|
37
|
+
return `Unknown action: ${action}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private addJob(args: Record<string, unknown>): string {
|
|
41
|
+
const message = String(args.message ?? "");
|
|
42
|
+
if (!message) return "Error: message is required for add";
|
|
43
|
+
if (!this.channel || !this.chatId) return "Error: no session context (channel/chat_id)";
|
|
44
|
+
|
|
45
|
+
const every = args.every_seconds != null ? Number(args.every_seconds) : null;
|
|
46
|
+
const cronExpr = args.cron_expr != null ? String(args.cron_expr) : null;
|
|
47
|
+
const tz = args.tz != null ? String(args.tz) : null;
|
|
48
|
+
const at = args.at != null ? String(args.at) : null;
|
|
49
|
+
|
|
50
|
+
let schedule: CronSchedule;
|
|
51
|
+
let deleteAfterRun = false;
|
|
52
|
+
if (every) schedule = { kind: "every", everyMs: every * 1000 };
|
|
53
|
+
else if (cronExpr) schedule = { kind: "cron", expr: cronExpr, tz: tz ?? undefined };
|
|
54
|
+
else if (at) {
|
|
55
|
+
const dt = new Date(at);
|
|
56
|
+
if (Number.isNaN(dt.getTime())) return "Error: invalid ISO datetime in at";
|
|
57
|
+
schedule = { kind: "at", atMs: dt.getTime() };
|
|
58
|
+
deleteAfterRun = true;
|
|
59
|
+
} else return "Error: either every_seconds, cron_expr, or at is required";
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const job = this.cron.addJob({ name: message.slice(0, 30), schedule, message, deliver: true, channel: this.channel, to: this.chatId, deleteAfterRun });
|
|
63
|
+
return `Created job '${job.name}' (id: ${job.id})`;
|
|
64
|
+
} catch (err) {
|
|
65
|
+
return `Error: ${String(err).replace(/^Error:\s*/, "")}`;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private listJobs(): string {
|
|
70
|
+
const jobs = this.cron.listJobs();
|
|
71
|
+
if (!jobs.length) return "No scheduled jobs.";
|
|
72
|
+
return `Scheduled jobs:\n${jobs.map((j) => `- ${j.name} (id: ${j.id}, ${j.schedule.kind})`).join("\n")}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private removeJob(id: string): string {
|
|
76
|
+
if (!id) return "Error: job_id is required for remove";
|
|
77
|
+
return this.cron.removeJob(id) ? `Removed job ${id}` : `Job ${id} not found`;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Tool } from "./base.js";
|
|
4
|
+
|
|
5
|
+
function resolvePath(input: string, workspace?: string, allowedDir?: string): string {
|
|
6
|
+
const expanded = input.startsWith("~") ? path.join(process.env.USERPROFILE || process.env.HOME || "", input.slice(1)) : input;
|
|
7
|
+
const base = path.isAbsolute(expanded) ? expanded : workspace ? path.join(workspace, expanded) : expanded;
|
|
8
|
+
const resolved = path.resolve(base);
|
|
9
|
+
if (allowedDir) {
|
|
10
|
+
const allow = path.resolve(allowedDir);
|
|
11
|
+
if (!resolved.startsWith(allow)) throw new Error(`Path ${input} is outside allowed directory ${allowedDir}`);
|
|
12
|
+
}
|
|
13
|
+
return resolved;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class ReadFileTool extends Tool {
|
|
17
|
+
readonly name = "read_file";
|
|
18
|
+
readonly description = "Read the contents of a file at the given path.";
|
|
19
|
+
readonly parameters = { type: "object", properties: { path: { type: "string", description: "The file path to read" } }, required: ["path"] };
|
|
20
|
+
constructor(private readonly workspace?: string, private readonly allowedDir?: string) { super(); }
|
|
21
|
+
async execute(args: Record<string, unknown>): Promise<string> {
|
|
22
|
+
try {
|
|
23
|
+
const p = resolvePath(String(args.path), this.workspace, this.allowedDir);
|
|
24
|
+
if (!fs.existsSync(p)) return `Error: File not found: ${String(args.path)}`;
|
|
25
|
+
if (!fs.statSync(p).isFile()) return `Error: Not a file: ${String(args.path)}`;
|
|
26
|
+
return fs.readFileSync(p, "utf8");
|
|
27
|
+
} catch (err) {
|
|
28
|
+
return `Error reading file: ${String(err)}`;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class WriteFileTool extends Tool {
|
|
34
|
+
readonly name = "write_file";
|
|
35
|
+
readonly description = "Write content to a file at the given path. Creates parent directories if needed.";
|
|
36
|
+
readonly parameters = { type: "object", properties: { path: { type: "string" }, content: { type: "string" } }, required: ["path", "content"] };
|
|
37
|
+
constructor(private readonly workspace?: string, private readonly allowedDir?: string) { super(); }
|
|
38
|
+
async execute(args: Record<string, unknown>): Promise<string> {
|
|
39
|
+
try {
|
|
40
|
+
const p = resolvePath(String(args.path), this.workspace, this.allowedDir);
|
|
41
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
42
|
+
const content = String(args.content ?? "");
|
|
43
|
+
fs.writeFileSync(p, content, "utf8");
|
|
44
|
+
return `Successfully wrote ${content.length} bytes to ${p}`;
|
|
45
|
+
} catch (err) {
|
|
46
|
+
return `Error writing file: ${String(err)}`;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class EditFileTool extends Tool {
|
|
52
|
+
readonly name = "edit_file";
|
|
53
|
+
readonly description = "Edit a file by replacing old_text with new_text. The old_text must exist exactly in the file.";
|
|
54
|
+
readonly parameters = { type: "object", properties: { path: { type: "string" }, old_text: { type: "string" }, new_text: { type: "string" } }, required: ["path", "old_text", "new_text"] };
|
|
55
|
+
constructor(private readonly workspace?: string, private readonly allowedDir?: string) { super(); }
|
|
56
|
+
async execute(args: Record<string, unknown>): Promise<string> {
|
|
57
|
+
try {
|
|
58
|
+
const p = resolvePath(String(args.path), this.workspace, this.allowedDir);
|
|
59
|
+
if (!fs.existsSync(p)) return `Error: File not found: ${String(args.path)}`;
|
|
60
|
+
const content = fs.readFileSync(p, "utf8");
|
|
61
|
+
const oldText = String(args.old_text ?? "");
|
|
62
|
+
const newText = String(args.new_text ?? "");
|
|
63
|
+
if (!content.includes(oldText)) return `Error: old_text not found in ${String(args.path)}. Verify the file content.`;
|
|
64
|
+
if (content.split(oldText).length - 1 > 1) return `Warning: old_text appears ${content.split(oldText).length - 1} times. Please provide more context to make it unique.`;
|
|
65
|
+
fs.writeFileSync(p, content.replace(oldText, newText), "utf8");
|
|
66
|
+
return `Successfully edited ${p}`;
|
|
67
|
+
} catch (err) {
|
|
68
|
+
return `Error editing file: ${String(err)}`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class ListDirTool extends Tool {
|
|
74
|
+
readonly name = "list_dir";
|
|
75
|
+
readonly description = "List the contents of a directory.";
|
|
76
|
+
readonly parameters = { type: "object", properties: { path: { type: "string" } }, required: ["path"] };
|
|
77
|
+
constructor(private readonly workspace?: string, private readonly allowedDir?: string) { super(); }
|
|
78
|
+
async execute(args: Record<string, unknown>): Promise<string> {
|
|
79
|
+
try {
|
|
80
|
+
const p = resolvePath(String(args.path), this.workspace, this.allowedDir);
|
|
81
|
+
if (!fs.existsSync(p)) return `Error: Directory not found: ${String(args.path)}`;
|
|
82
|
+
if (!fs.statSync(p).isDirectory()) return `Error: Not a directory: ${String(args.path)}`;
|
|
83
|
+
const items = fs.readdirSync(p).sort().map((n) => {
|
|
84
|
+
const full = path.join(p, n);
|
|
85
|
+
return `${fs.statSync(full).isDirectory() ? "[DIR]" : "[FILE]"} ${n}`;
|
|
86
|
+
});
|
|
87
|
+
if (!items.length) return `Directory ${String(args.path)} is empty`;
|
|
88
|
+
return items.join("\n");
|
|
89
|
+
} catch (err) {
|
|
90
|
+
return `Error listing directory: ${String(err)}`;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Tool } from "./base.js";
|
|
2
|
+
import type { OutboundMessage } from "../../bus/events.js";
|
|
3
|
+
|
|
4
|
+
export class MessageTool extends Tool {
|
|
5
|
+
readonly name = "message";
|
|
6
|
+
readonly description = "Send a message to the user. Use this when you want to communicate something.";
|
|
7
|
+
readonly parameters = {
|
|
8
|
+
type: "object",
|
|
9
|
+
properties: {
|
|
10
|
+
content: { type: "string", description: "The message content to send" },
|
|
11
|
+
channel: { type: "string", description: "Optional target channel" },
|
|
12
|
+
chat_id: { type: "string", description: "Optional target chat/user ID" },
|
|
13
|
+
media: { type: "array", items: { type: "string" }, description: "Optional file attachments" },
|
|
14
|
+
},
|
|
15
|
+
required: ["content"],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
private defaultChannel = "";
|
|
19
|
+
private defaultChatId = "";
|
|
20
|
+
private defaultMessageId: string | null = null;
|
|
21
|
+
sentInTurn = false;
|
|
22
|
+
|
|
23
|
+
constructor(private sendCallback?: (msg: OutboundMessage) => Promise<void>) { super(); }
|
|
24
|
+
|
|
25
|
+
setContext(channel: string, chatId: string, messageId?: string): void {
|
|
26
|
+
this.defaultChannel = channel;
|
|
27
|
+
this.defaultChatId = chatId;
|
|
28
|
+
this.defaultMessageId = messageId ?? null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
setSendCallback(cb: (msg: OutboundMessage) => Promise<void>): void {
|
|
32
|
+
this.sendCallback = cb;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
startTurn(): void {
|
|
36
|
+
this.sentInTurn = false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async execute(args: Record<string, unknown>): Promise<string> {
|
|
40
|
+
const content = String(args.content ?? "");
|
|
41
|
+
const channel = String(args.channel ?? this.defaultChannel);
|
|
42
|
+
const chatId = String(args.chat_id ?? this.defaultChatId);
|
|
43
|
+
const messageId = String(args.message_id ?? this.defaultMessageId ?? "");
|
|
44
|
+
const media = Array.isArray(args.media) ? (args.media as string[]) : [];
|
|
45
|
+
|
|
46
|
+
if (!channel || !chatId) return "Error: No target channel/chat specified";
|
|
47
|
+
if (!this.sendCallback) return "Error: Message sending not configured";
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
await this.sendCallback({ channel, chatId, content, media, metadata: { message_id: messageId } });
|
|
51
|
+
if (channel === this.defaultChannel && chatId === this.defaultChatId) this.sentInTurn = true;
|
|
52
|
+
return `Message sent to ${channel}:${chatId}${media.length ? ` with ${media.length} attachments` : ""}`;
|
|
53
|
+
} catch (err) {
|
|
54
|
+
return `Error sending message: ${String(err)}`;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Tool } from "./base.js";
|
|
2
|
+
|
|
3
|
+
export class ToolRegistry {
|
|
4
|
+
private tools = new Map<string, Tool>();
|
|
5
|
+
|
|
6
|
+
register(tool: Tool): void {
|
|
7
|
+
this.tools.set(tool.name, tool);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
get(name: string): Tool | undefined {
|
|
11
|
+
return this.tools.get(name);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
getDefinitions(): Array<Record<string, unknown>> {
|
|
15
|
+
return [...this.tools.values()].map((t) => t.toSchema());
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
get toolNames(): string[] {
|
|
19
|
+
return [...this.tools.keys()];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async execute(name: string, params: Record<string, unknown>): Promise<string> {
|
|
23
|
+
const hint = "\n\n[Analyze the error above and try a different approach.]";
|
|
24
|
+
const tool = this.tools.get(name);
|
|
25
|
+
if (!tool) return `Error: Tool '${name}' not found. Available: ${this.toolNames.join(", ")}`;
|
|
26
|
+
try {
|
|
27
|
+
const errors = tool.validateParams(params);
|
|
28
|
+
if (errors.length) return `Error: Invalid parameters for tool '${name}': ${errors.join("; ")}${hint}`;
|
|
29
|
+
const result = await tool.execute(params);
|
|
30
|
+
if (result.startsWith("Error")) return result + hint;
|
|
31
|
+
return result;
|
|
32
|
+
} catch (err) {
|
|
33
|
+
return `Error executing ${name}: ${String(err)}${hint}`;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|