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,169 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import { loadConfig, saveConfig } from "../config/loader.js";
|
|
6
|
+
function dashboardDir() {
|
|
7
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const candidates = [
|
|
9
|
+
path.resolve(here, "../dashboard"),
|
|
10
|
+
path.resolve(here, "../../src/dashboard"),
|
|
11
|
+
path.resolve(process.cwd(), "dist/dashboard"),
|
|
12
|
+
path.resolve(process.cwd(), "src/dashboard"),
|
|
13
|
+
];
|
|
14
|
+
const found = candidates.find((p) => fs.existsSync(path.join(p, "index.html")));
|
|
15
|
+
return found ?? candidates[candidates.length - 1];
|
|
16
|
+
}
|
|
17
|
+
function readBody(req) {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
let buf = "";
|
|
20
|
+
req.on("data", (c) => {
|
|
21
|
+
buf += c.toString();
|
|
22
|
+
if (buf.length > 2_000_000)
|
|
23
|
+
req.destroy();
|
|
24
|
+
});
|
|
25
|
+
req.on("end", () => {
|
|
26
|
+
try {
|
|
27
|
+
resolve(buf ? JSON.parse(buf) : {});
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
resolve({});
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
req.on("error", () => resolve({}));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
function sendJson(res, status, data) {
|
|
37
|
+
res.statusCode = status;
|
|
38
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
39
|
+
res.end(JSON.stringify(data));
|
|
40
|
+
}
|
|
41
|
+
function contentType(file) {
|
|
42
|
+
if (file.endsWith(".html"))
|
|
43
|
+
return "text/html; charset=utf-8";
|
|
44
|
+
if (file.endsWith(".js"))
|
|
45
|
+
return "text/javascript; charset=utf-8";
|
|
46
|
+
if (file.endsWith(".css"))
|
|
47
|
+
return "text/css; charset=utf-8";
|
|
48
|
+
if (file.endsWith(".json"))
|
|
49
|
+
return "application/json; charset=utf-8";
|
|
50
|
+
if (file.endsWith(".svg"))
|
|
51
|
+
return "image/svg+xml";
|
|
52
|
+
return "application/octet-stream";
|
|
53
|
+
}
|
|
54
|
+
export function startDashboardServer(input) {
|
|
55
|
+
const staticDir = dashboardDir();
|
|
56
|
+
const server = createServer(async (req, res) => {
|
|
57
|
+
const method = req.method || "GET";
|
|
58
|
+
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
|
59
|
+
const pathname = url.pathname;
|
|
60
|
+
if (method === "GET" && pathname === "/api/status") {
|
|
61
|
+
const cfg = loadConfig();
|
|
62
|
+
return sendJson(res, 200, {
|
|
63
|
+
provider: cfg.agents.defaults.provider,
|
|
64
|
+
model: cfg.agents.defaults.model,
|
|
65
|
+
cron: input.cron.status(),
|
|
66
|
+
channels: cfg.channels,
|
|
67
|
+
gateway: cfg.gateway,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
if (method === "GET" && pathname === "/api/history") {
|
|
71
|
+
const session = input.sessionManager.getOrCreate(input.sessionKey);
|
|
72
|
+
const messages = (session.messages || []).map(msg => {
|
|
73
|
+
if (msg && Array.isArray(msg.tool_calls)) {
|
|
74
|
+
return { ...msg, tool_calls: msg.tool_calls };
|
|
75
|
+
}
|
|
76
|
+
return msg;
|
|
77
|
+
});
|
|
78
|
+
return sendJson(res, 200, { messages });
|
|
79
|
+
}
|
|
80
|
+
if (method === "POST" && pathname === "/api/chat") {
|
|
81
|
+
const body = await readBody(req);
|
|
82
|
+
const message = String(body.message ?? "").trim();
|
|
83
|
+
const session = String(body.session ?? "web:dashboard");
|
|
84
|
+
if (!message)
|
|
85
|
+
return sendJson(res, 400, { error: "message required" });
|
|
86
|
+
const progress = [];
|
|
87
|
+
try {
|
|
88
|
+
const response = await input.agent.processDirect(message, session, "cli", "dashboard", async (content, meta) => {
|
|
89
|
+
// meta.tool_calls may exist (array of tool call objects)
|
|
90
|
+
progress.push({ content, toolHint: !!meta?.toolHint, tool_calls: meta?.tool_calls });
|
|
91
|
+
});
|
|
92
|
+
return sendJson(res, 200, { response, progress });
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
return sendJson(res, 500, { error: String(err) });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (method === "GET" && pathname === "/api/config") {
|
|
99
|
+
return sendJson(res, 200, loadConfig());
|
|
100
|
+
}
|
|
101
|
+
if (method === "PUT" && pathname === "/api/config") {
|
|
102
|
+
const raw = await readBody(req);
|
|
103
|
+
const cfg = raw;
|
|
104
|
+
saveConfig(cfg);
|
|
105
|
+
return sendJson(res, 200, { ok: true });
|
|
106
|
+
}
|
|
107
|
+
if (method === "GET" && pathname === "/api/cron/jobs") {
|
|
108
|
+
const includeDisabled = url.searchParams.get("all") === "1";
|
|
109
|
+
return sendJson(res, 200, input.cron.listJobs(includeDisabled));
|
|
110
|
+
}
|
|
111
|
+
if (method === "POST" && pathname === "/api/cron/jobs") {
|
|
112
|
+
const body = await readBody(req);
|
|
113
|
+
try {
|
|
114
|
+
const job = input.cron.addJob({
|
|
115
|
+
name: String(body.name ?? "job"),
|
|
116
|
+
schedule: body.schedule,
|
|
117
|
+
message: String(body.message ?? ""),
|
|
118
|
+
deliver: !!body.deliver,
|
|
119
|
+
channel: body.channel ? String(body.channel) : undefined,
|
|
120
|
+
to: body.to ? String(body.to) : undefined,
|
|
121
|
+
deleteAfterRun: !!body.deleteAfterRun,
|
|
122
|
+
});
|
|
123
|
+
return sendJson(res, 200, job);
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
return sendJson(res, 400, { error: String(err) });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const cronIdMatch = pathname.match(/^\/api\/cron\/jobs\/([^/]+)$/);
|
|
130
|
+
if (cronIdMatch && method === "DELETE") {
|
|
131
|
+
return sendJson(res, 200, { ok: input.cron.removeJob(cronIdMatch[1]) });
|
|
132
|
+
}
|
|
133
|
+
const cronEnableMatch = pathname.match(/^\/api\/cron\/jobs\/([^/]+)\/enable$/);
|
|
134
|
+
if (cronEnableMatch && method === "POST") {
|
|
135
|
+
const body = await readBody(req);
|
|
136
|
+
const enabled = body.enabled !== false;
|
|
137
|
+
const job = input.cron.enableJob(cronEnableMatch[1], enabled);
|
|
138
|
+
if (!job)
|
|
139
|
+
return sendJson(res, 404, { error: "not found" });
|
|
140
|
+
return sendJson(res, 200, job);
|
|
141
|
+
}
|
|
142
|
+
const cronRunMatch = pathname.match(/^\/api\/cron\/jobs\/([^/]+)\/run$/);
|
|
143
|
+
if (cronRunMatch && method === "POST") {
|
|
144
|
+
const body = await readBody(req);
|
|
145
|
+
const ok = await input.cron.runJob(cronRunMatch[1], !!body.force);
|
|
146
|
+
return sendJson(res, 200, { ok });
|
|
147
|
+
}
|
|
148
|
+
let filePath = path.join(staticDir, pathname === "/" ? "index.html" : pathname.slice(1));
|
|
149
|
+
if (!filePath.startsWith(staticDir))
|
|
150
|
+
filePath = path.join(staticDir, "index.html");
|
|
151
|
+
if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
|
|
152
|
+
filePath = path.join(staticDir, "index.html");
|
|
153
|
+
}
|
|
154
|
+
if (!fs.existsSync(filePath)) {
|
|
155
|
+
res.statusCode = 404;
|
|
156
|
+
res.end("Dashboard assets not found. Run npm run build.");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
res.statusCode = 200;
|
|
160
|
+
res.setHeader("Content-Type", contentType(filePath));
|
|
161
|
+
fs.createReadStream(filePath).pipe(res);
|
|
162
|
+
});
|
|
163
|
+
const port = input.port ?? 6767;
|
|
164
|
+
server.listen(port);
|
|
165
|
+
return {
|
|
166
|
+
port,
|
|
167
|
+
close: () => new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))),
|
|
168
|
+
};
|
|
169
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pretticlaw",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Lightweight AI Assistant That Lives in Your Computer",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"pretticlaw": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc -p tsconfig.json && node scripts/copy-assets.mjs",
|
|
12
|
+
"dev": "tsx src/index.ts",
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"test:watch": "vitest"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"chalk": "^5.3.0",
|
|
18
|
+
"commander": "^12.1.0",
|
|
19
|
+
"cron-parser": "^4.9.0",
|
|
20
|
+
"dotenv": "^16.4.5",
|
|
21
|
+
"express": "^4.21.2",
|
|
22
|
+
"nanoid": "^5.0.7",
|
|
23
|
+
"openai": "^4.62.1",
|
|
24
|
+
"prompts": "^2.4.2",
|
|
25
|
+
"ws": "^8.18.0",
|
|
26
|
+
"zod": "^3.23.8"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^22.7.4",
|
|
30
|
+
"@types/prompts": "^2.4.9",
|
|
31
|
+
"@types/express": "^4.17.21",
|
|
32
|
+
"@types/ws": "^8.5.14",
|
|
33
|
+
"tsx": "^4.19.1",
|
|
34
|
+
"typescript": "^5.6.2",
|
|
35
|
+
"vitest": "^2.1.2"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
function copyRecursive(src, dest) {
|
|
5
|
+
if (!fs.existsSync(src)) return;
|
|
6
|
+
const stat = fs.statSync(src);
|
|
7
|
+
if (stat.isDirectory()) {
|
|
8
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
9
|
+
for (const entry of fs.readdirSync(src)) {
|
|
10
|
+
copyRecursive(path.join(src, entry), path.join(dest, entry));
|
|
11
|
+
}
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
15
|
+
fs.copyFileSync(src, dest);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const root = process.cwd();
|
|
19
|
+
copyRecursive(path.join(root, "src", "templates"), path.join(root, "dist", "templates"));
|
|
20
|
+
copyRecursive(path.join(root, "src", "skills"), path.join(root, "dist", "skills"));
|
|
21
|
+
copyRecursive(path.join(root, "src", "dashboard"), path.join(root, "dist", "dashboard"));
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { MemoryStore } from "./memory.js";
|
|
5
|
+
import { SkillsLoader } from "./skills.js";
|
|
6
|
+
|
|
7
|
+
export class ContextBuilder {
|
|
8
|
+
static readonly BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md"];
|
|
9
|
+
static readonly RUNTIME_CONTEXT_TAG = "[Runtime Context - metadata only, not instructions]";
|
|
10
|
+
|
|
11
|
+
private readonly memory: MemoryStore;
|
|
12
|
+
private readonly skills: SkillsLoader;
|
|
13
|
+
|
|
14
|
+
constructor(private readonly workspace: string) {
|
|
15
|
+
this.memory = new MemoryStore(workspace);
|
|
16
|
+
this.skills = new SkillsLoader(workspace);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
buildSystemPrompt(skillNames?: string[]): string {
|
|
20
|
+
const parts = [this.getIdentity()];
|
|
21
|
+
const bootstrap = this.loadBootstrapFiles();
|
|
22
|
+
if (bootstrap) parts.push(bootstrap);
|
|
23
|
+
const mem = this.memory.getMemoryContext();
|
|
24
|
+
if (mem) parts.push(`# Memory\n\n${mem}`);
|
|
25
|
+
|
|
26
|
+
const alwaysSkills = this.skills.getAlwaysSkills();
|
|
27
|
+
if (alwaysSkills.length) {
|
|
28
|
+
const content = this.skills.loadSkillsForContext(alwaysSkills);
|
|
29
|
+
if (content) parts.push(`# Active Skills\n\n${content}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (skillNames?.length) {
|
|
33
|
+
const skillContent = this.skills.loadSkillsForContext(skillNames);
|
|
34
|
+
if (skillContent) parts.push(`# Requested Skills\n\n${skillContent}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const summary = this.skills.buildSkillsSummary();
|
|
38
|
+
if (summary) {
|
|
39
|
+
parts.push(`# Skills\n\nThe following skills extend your capabilities. To use one, read its SKILL.md with read_file.\n\n${summary}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return parts.join("\n\n---\n\n");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private getIdentity(): string {
|
|
46
|
+
return `# pretticlaw\n\nYou are pretticlaw, a helpful AI assistant.\n\n## Runtime\n${os.platform()} ${os.arch()}, Node ${process.version}\n\n## Workspace\nYour workspace is at: ${this.workspace}\n- Long-term memory: ${path.join(this.workspace, "memory", "MEMORY.md")}\n- History log: ${path.join(this.workspace, "memory", "HISTORY.md")}\n- Custom skills: ${path.join(this.workspace, "skills", "{skill-name}", "SKILL.md")}\n\n## Guidelines\n- State intent before tool calls, but never claim results before receiving them.\n- Before modifying a file, read it first.\n- Ask for clarification when the request is ambiguous.`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private loadBootstrapFiles(): string {
|
|
50
|
+
const parts: string[] = [];
|
|
51
|
+
for (const f of ContextBuilder.BOOTSTRAP_FILES) {
|
|
52
|
+
const p = path.join(this.workspace, f);
|
|
53
|
+
if (!fs.existsSync(p)) continue;
|
|
54
|
+
parts.push(`## ${f}\n\n${fs.readFileSync(p, "utf8")}`);
|
|
55
|
+
}
|
|
56
|
+
return parts.join("\n\n");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
static buildRuntimeContext(channel?: string, chatId?: string): string {
|
|
60
|
+
const now = new Date();
|
|
61
|
+
const lines = [`Current Time: ${now.toISOString()}`];
|
|
62
|
+
if (channel && chatId) {
|
|
63
|
+
lines.push(`Channel: ${channel}`);
|
|
64
|
+
lines.push(`Chat ID: ${chatId}`);
|
|
65
|
+
}
|
|
66
|
+
return `${ContextBuilder.RUNTIME_CONTEXT_TAG}\n${lines.join("\n")}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
buildMessages(input: { history: Array<Record<string, unknown>>; currentMessage: string; skillNames?: string[]; media?: string[]; channel?: string; chatId?: string }): Array<Record<string, unknown>> {
|
|
70
|
+
return [
|
|
71
|
+
{ role: "system", content: this.buildSystemPrompt(input.skillNames) },
|
|
72
|
+
...input.history,
|
|
73
|
+
{ role: "user", content: ContextBuilder.buildRuntimeContext(input.channel, input.chatId) },
|
|
74
|
+
{ role: "user", content: input.currentMessage },
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
addToolResult(messages: Array<Record<string, unknown>>, toolCallId: string, toolName: string, result: string): Array<Record<string, unknown>> {
|
|
79
|
+
messages.push({ role: "tool", tool_call_id: toolCallId, name: toolName, content: result });
|
|
80
|
+
return messages;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
addAssistantMessage(messages: Array<Record<string, unknown>>, content: string | null, toolCalls?: Array<Record<string, unknown>>, reasoningContent?: string | null): Array<Record<string, unknown>> {
|
|
84
|
+
const msg: Record<string, unknown> = { role: "assistant", content };
|
|
85
|
+
if (toolCalls?.length) msg.tool_calls = toolCalls;
|
|
86
|
+
if (reasoningContent != null) msg.reasoning_content = reasoningContent;
|
|
87
|
+
messages.push(msg);
|
|
88
|
+
return messages;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { ContextBuilder } from "./context.js";
|
|
3
|
+
import { MemoryStore } from "./memory.js";
|
|
4
|
+
import { SubagentManager } from "./subagent.js";
|
|
5
|
+
import { ReadFileTool, WriteFileTool, EditFileTool, ListDirTool } from "./tools/filesystem.js";
|
|
6
|
+
import { ExecTool } from "./tools/shell.js";
|
|
7
|
+
import { WebSearchTool, WebFetchTool } from "./tools/web.js";
|
|
8
|
+
import { MessageTool } from "./tools/message.js";
|
|
9
|
+
import { SpawnTool } from "./tools/spawn.js";
|
|
10
|
+
import { CronTool } from "./tools/cron.js";
|
|
11
|
+
import { ToolRegistry } from "./tools/registry.js";
|
|
12
|
+
import type { MessageBus } from "../bus/queue.js";
|
|
13
|
+
import type { InboundMessage, OutboundMessage } from "../bus/events.js";
|
|
14
|
+
import { sessionKey as getSessionKey } from "../bus/events.js";
|
|
15
|
+
import type { LLMProvider } from "../providers/base.js";
|
|
16
|
+
import { SessionManager, type Session } from "../session/manager.js";
|
|
17
|
+
import type { CronService } from "../cron/service.js";
|
|
18
|
+
|
|
19
|
+
function stripThink(text: string | null): string | null {
|
|
20
|
+
if (!text) return null;
|
|
21
|
+
return text.replace(/<think>[\s\S]*?<\/think>/g, "").trim() || null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type ProgressFn = (content: string, meta?: { toolHint?: boolean; tool_calls?: any[] }) => Promise<void>;
|
|
25
|
+
|
|
26
|
+
export class AgentLoop {
|
|
27
|
+
private readonly context: ContextBuilder;
|
|
28
|
+
private readonly sessions: SessionManager;
|
|
29
|
+
readonly tools: ToolRegistry;
|
|
30
|
+
readonly subagents: SubagentManager;
|
|
31
|
+
private running = false;
|
|
32
|
+
private readonly consolidating = new Set<string>();
|
|
33
|
+
private readonly activeTasks = new Map<string, Set<Promise<void>>>();
|
|
34
|
+
private processing = Promise.resolve();
|
|
35
|
+
private static readonly TOOL_RESULT_MAX_CHARS = 500;
|
|
36
|
+
|
|
37
|
+
constructor(private readonly input: {
|
|
38
|
+
bus: MessageBus;
|
|
39
|
+
provider: LLMProvider;
|
|
40
|
+
workspace: string;
|
|
41
|
+
model?: string;
|
|
42
|
+
maxIterations?: number;
|
|
43
|
+
temperature?: number;
|
|
44
|
+
maxTokens?: number;
|
|
45
|
+
memoryWindow?: number;
|
|
46
|
+
braveApiKey?: string | null;
|
|
47
|
+
execConfig?: { timeout: number; pathAppend: string };
|
|
48
|
+
cronService?: CronService;
|
|
49
|
+
restrictToWorkspace?: boolean;
|
|
50
|
+
sessionManager?: SessionManager;
|
|
51
|
+
channelsConfig?: { sendProgress: boolean; sendToolHints: boolean };
|
|
52
|
+
}) {
|
|
53
|
+
const workspace = path.resolve(input.workspace);
|
|
54
|
+
this.context = new ContextBuilder(workspace);
|
|
55
|
+
this.sessions = input.sessionManager ?? new SessionManager(workspace);
|
|
56
|
+
this.tools = new ToolRegistry();
|
|
57
|
+
|
|
58
|
+
const model = input.model ?? input.provider.getDefaultModel();
|
|
59
|
+
this.subagents = new SubagentManager(
|
|
60
|
+
input.provider,
|
|
61
|
+
workspace,
|
|
62
|
+
input.bus,
|
|
63
|
+
model,
|
|
64
|
+
input.temperature ?? 0.1,
|
|
65
|
+
input.maxTokens ?? 4096,
|
|
66
|
+
input.braveApiKey ?? null,
|
|
67
|
+
input.execConfig ?? { timeout: 60, pathAppend: "" },
|
|
68
|
+
!!input.restrictToWorkspace,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
this.registerDefaultTools();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get model(): string {
|
|
75
|
+
return this.input.model ?? this.input.provider.getDefaultModel();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
get channelsConfig(): { sendProgress: boolean; sendToolHints: boolean } | undefined {
|
|
79
|
+
return this.input.channelsConfig;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private registerDefaultTools(): void {
|
|
83
|
+
const workspace = path.resolve(this.input.workspace);
|
|
84
|
+
const allowed = this.input.restrictToWorkspace ? workspace : undefined;
|
|
85
|
+
this.tools.register(new ReadFileTool(workspace, allowed));
|
|
86
|
+
this.tools.register(new WriteFileTool(workspace, allowed));
|
|
87
|
+
this.tools.register(new EditFileTool(workspace, allowed));
|
|
88
|
+
this.tools.register(new ListDirTool(workspace, allowed));
|
|
89
|
+
this.tools.register(new ExecTool(this.input.execConfig?.timeout ?? 60, workspace, !!this.input.restrictToWorkspace, this.input.execConfig?.pathAppend ?? ""));
|
|
90
|
+
this.tools.register(new WebSearchTool(this.input.braveApiKey ?? null));
|
|
91
|
+
this.tools.register(new WebFetchTool());
|
|
92
|
+
this.tools.register(new MessageTool((msg) => this.input.bus.publishOutbound(msg)));
|
|
93
|
+
this.tools.register(new SpawnTool(this.subagents));
|
|
94
|
+
if (this.input.cronService) this.tools.register(new CronTool(this.input.cronService));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private setToolContext(channel: string, chatId: string, messageId?: string): void {
|
|
98
|
+
const message = this.tools.get("message");
|
|
99
|
+
if (message && message instanceof MessageTool) message.setContext(channel, chatId, messageId);
|
|
100
|
+
const spawn = this.tools.get("spawn") as any;
|
|
101
|
+
if (spawn?.setContext) spawn.setContext(channel, chatId);
|
|
102
|
+
const cron = this.tools.get("cron") as any;
|
|
103
|
+
if (cron?.setContext) cron.setContext(channel, chatId);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private toolHint(toolCalls: Array<{ name: string; arguments: Record<string, unknown> }>): string {
|
|
107
|
+
return toolCalls.map((tc) => {
|
|
108
|
+
const val = Object.values(tc.arguments ?? {})[0];
|
|
109
|
+
if (typeof val !== "string") return tc.name;
|
|
110
|
+
return val.length > 40 ? `${tc.name}("${val.slice(0, 40)}...")` : `${tc.name}("${val}")`;
|
|
111
|
+
}).join(", ");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private async runAgentLoop(initialMessages: Array<Record<string, unknown>>, onProgress?: ProgressFn): Promise<{ finalContent: string | null; toolsUsed: string[]; messages: Array<Record<string, unknown>> }> {
|
|
115
|
+
const messages = [...initialMessages];
|
|
116
|
+
let finalContent: string | null = null;
|
|
117
|
+
const toolsUsed: string[] = [];
|
|
118
|
+
const maxIterations = this.input.maxIterations ?? 40;
|
|
119
|
+
|
|
120
|
+
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
|
121
|
+
const response = await this.input.provider.chat({
|
|
122
|
+
messages,
|
|
123
|
+
tools: this.tools.getDefinitions(),
|
|
124
|
+
model: this.model,
|
|
125
|
+
temperature: this.input.temperature ?? 0.1,
|
|
126
|
+
maxTokens: this.input.maxTokens ?? 4096,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
if (response.toolCalls.length) {
|
|
130
|
+
if (onProgress) {
|
|
131
|
+
const clean = stripThink(response.content);
|
|
132
|
+
if (clean) await onProgress(clean, { toolHint: false });
|
|
133
|
+
await onProgress(this.toolHint(response.toolCalls), { toolHint: true });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const toolCallDicts = response.toolCalls.map((tc) => ({ id: tc.id, type: "function", function: { name: tc.name, arguments: JSON.stringify(tc.arguments) } }));
|
|
137
|
+
this.context.addAssistantMessage(messages, response.content, toolCallDicts, response.reasoningContent);
|
|
138
|
+
|
|
139
|
+
for (const call of response.toolCalls) {
|
|
140
|
+
toolsUsed.push(call.name);
|
|
141
|
+
const result = await this.tools.execute(call.name, call.arguments);
|
|
142
|
+
this.context.addToolResult(messages, call.id, call.name, result);
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
finalContent = stripThink(response.content);
|
|
146
|
+
this.context.addAssistantMessage(messages, finalContent, undefined, response.reasoningContent);
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (finalContent == null) {
|
|
152
|
+
finalContent = `I reached the maximum number of tool call iterations (${maxIterations}) without completing the task.`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { finalContent, toolsUsed, messages };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async run(): Promise<void> {
|
|
159
|
+
this.running = true;
|
|
160
|
+
while (this.running) {
|
|
161
|
+
const msg = await this.input.bus.consumeInbound();
|
|
162
|
+
if (msg.content.trim().toLowerCase() === "/stop") {
|
|
163
|
+
await this.handleStop(msg);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const session = getSessionKey(msg);
|
|
167
|
+
const task = this.dispatch(msg).finally(() => {
|
|
168
|
+
const set = this.activeTasks.get(session);
|
|
169
|
+
if (!set) return;
|
|
170
|
+
set.delete(task);
|
|
171
|
+
if (!set.size) this.activeTasks.delete(session);
|
|
172
|
+
});
|
|
173
|
+
if (!this.activeTasks.has(session)) this.activeTasks.set(session, new Set());
|
|
174
|
+
this.activeTasks.get(session)!.add(task);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
stop(): void {
|
|
179
|
+
this.running = false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async processDirect(content: string, session = "cli:direct", channel = "cli", chatId = "direct", onProgress?: ProgressFn): Promise<string> {
|
|
183
|
+
const msg: InboundMessage = { channel, senderId: "user", chatId, content };
|
|
184
|
+
const out = await this.processMessage(msg, session, onProgress);
|
|
185
|
+
return out?.content ?? "";
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private async dispatch(msg: InboundMessage): Promise<void> {
|
|
189
|
+
this.processing = this.processing.then(async () => {
|
|
190
|
+
try {
|
|
191
|
+
const response = await this.processMessage(msg);
|
|
192
|
+
if (response) {
|
|
193
|
+
await this.input.bus.publishOutbound(response);
|
|
194
|
+
} else if (msg.channel === "cli") {
|
|
195
|
+
await this.input.bus.publishOutbound({ channel: msg.channel, chatId: msg.chatId, content: "", metadata: msg.metadata ?? {} });
|
|
196
|
+
}
|
|
197
|
+
} catch {
|
|
198
|
+
await this.input.bus.publishOutbound({ channel: msg.channel, chatId: msg.chatId, content: "Sorry, I encountered an error." });
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
return this.processing;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private async handleStop(msg: InboundMessage): Promise<void> {
|
|
205
|
+
const key = getSessionKey(msg);
|
|
206
|
+
const tasks = [...(this.activeTasks.get(key) ?? [])];
|
|
207
|
+
const subCancelled = await this.subagents.cancelBySession(key);
|
|
208
|
+
const total = tasks.length + subCancelled;
|
|
209
|
+
await this.input.bus.publishOutbound({ channel: msg.channel, chatId: msg.chatId, content: total ? `Stopped ${total} task(s).` : "No active task to stop." });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private async consolidateMemory(session: Session, archiveAll = false): Promise<boolean> {
|
|
213
|
+
return new MemoryStore(this.input.workspace).consolidate(session, this.input.provider, this.model, { archiveAll, memoryWindow: this.input.memoryWindow ?? 100 });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private saveTurn(session: Session, messages: Array<Record<string, unknown>>, skip: number): void {
|
|
217
|
+
for (const m of messages.slice(skip)) {
|
|
218
|
+
const entry: Record<string, unknown> = { ...m };
|
|
219
|
+
delete entry.reasoning_content;
|
|
220
|
+
if (entry.role === "tool" && typeof entry.content === "string" && entry.content.length > AgentLoop.TOOL_RESULT_MAX_CHARS) {
|
|
221
|
+
entry.content = `${entry.content.slice(0, AgentLoop.TOOL_RESULT_MAX_CHARS)}\n... (truncated)`;
|
|
222
|
+
}
|
|
223
|
+
if (entry.role === "user" && typeof entry.content === "string" && entry.content.startsWith(ContextBuilder.RUNTIME_CONTEXT_TAG)) continue;
|
|
224
|
+
if (!entry.timestamp) entry.timestamp = new Date().toISOString();
|
|
225
|
+
session.messages.push(entry as any);
|
|
226
|
+
}
|
|
227
|
+
session.updatedAt = new Date().toISOString();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async processMessage(msg: InboundMessage, sessionKeyOverride?: string, onProgress?: ProgressFn): Promise<OutboundMessage | null> {
|
|
231
|
+
if (msg.channel === "system") {
|
|
232
|
+
const [channel, chatId] = String(msg.chatId).includes(":") ? String(msg.chatId).split(/:(.*)/s, 2) : ["cli", String(msg.chatId)];
|
|
233
|
+
const key = `${channel}:${chatId}`;
|
|
234
|
+
const session = this.sessions.getOrCreate(key);
|
|
235
|
+
this.setToolContext(channel, chatId, msg.metadata?.message_id as string | undefined);
|
|
236
|
+
const history = session.getHistory(this.input.memoryWindow ?? 100);
|
|
237
|
+
const messages = this.context.buildMessages({ history, currentMessage: msg.content, channel, chatId });
|
|
238
|
+
const { finalContent, messages: allMsgs } = await this.runAgentLoop(messages);
|
|
239
|
+
this.saveTurn(session, allMsgs, 1 + history.length);
|
|
240
|
+
this.sessions.save(session);
|
|
241
|
+
return { channel, chatId, content: finalContent ?? "Background task completed." };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const key = sessionKeyOverride ?? getSessionKey(msg);
|
|
245
|
+
const session = this.sessions.getOrCreate(key);
|
|
246
|
+
const cmd = msg.content.trim().toLowerCase();
|
|
247
|
+
|
|
248
|
+
if (cmd === "/new") {
|
|
249
|
+
if (session.messages.length) {
|
|
250
|
+
const ok = await this.consolidateMemory(session, true);
|
|
251
|
+
if (!ok) return { channel: msg.channel, chatId: msg.chatId, content: "Memory archival failed, session not cleared. Please try again." };
|
|
252
|
+
}
|
|
253
|
+
session.clear();
|
|
254
|
+
this.sessions.save(session);
|
|
255
|
+
this.sessions.invalidate(session.key);
|
|
256
|
+
return { channel: msg.channel, chatId: msg.chatId, content: "New session started." };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (cmd === "/help") {
|
|
260
|
+
return { channel: msg.channel, chatId: msg.chatId, content: "pretticlaw commands:\n/new - Start a new conversation\n/stop - Stop the current task\n/help - Show available commands" };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const unconsolidated = session.messages.length - session.lastConsolidated;
|
|
264
|
+
if (unconsolidated >= (this.input.memoryWindow ?? 100) && !this.consolidating.has(session.key)) {
|
|
265
|
+
this.consolidating.add(session.key);
|
|
266
|
+
void this.consolidateMemory(session).finally(() => this.consolidating.delete(session.key));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
this.setToolContext(msg.channel, msg.chatId, msg.metadata?.message_id as string | undefined);
|
|
270
|
+
const messageTool = this.tools.get("message");
|
|
271
|
+
if (messageTool instanceof MessageTool) messageTool.startTurn();
|
|
272
|
+
|
|
273
|
+
const history = session.getHistory(this.input.memoryWindow ?? 100);
|
|
274
|
+
const initialMessages = this.context.buildMessages({ history, currentMessage: msg.content, media: msg.media, channel: msg.channel, chatId: msg.chatId });
|
|
275
|
+
|
|
276
|
+
const busProgress: ProgressFn = async (content, meta) => {
|
|
277
|
+
const metadata = { ...(msg.metadata ?? {}), _progress: true, _tool_hint: !!meta?.toolHint } as Record<string, unknown>;
|
|
278
|
+
await this.input.bus.publishOutbound({ channel: msg.channel, chatId: msg.chatId, content, metadata });
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const { finalContent, messages: allMsgs } = await this.runAgentLoop(initialMessages, onProgress ?? busProgress);
|
|
282
|
+
const out = finalContent ?? "I've completed processing but have no response to give.";
|
|
283
|
+
|
|
284
|
+
this.saveTurn(session, allMsgs, 1 + history.length);
|
|
285
|
+
this.sessions.save(session);
|
|
286
|
+
|
|
287
|
+
if (messageTool instanceof MessageTool && messageTool.sentInTurn) return null;
|
|
288
|
+
|
|
289
|
+
return { channel: msg.channel, chatId: msg.chatId, content: out, metadata: msg.metadata ?? {} };
|
|
290
|
+
}
|
|
291
|
+
}
|