Skip to content

Commit 9782c11

Browse files
author
Splash Agent
committed
feat: complete Milestone 4 (self-improve loop and telegram polish)
1 parent 8bf272a commit 9782c11

6 files changed

Lines changed: 220 additions & 3 deletions

File tree

bots/telegram.ts

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@
1010
* - Uses character.md persona if present
1111
*/
1212

13-
import { Telegraf } from "telegraf";
13+
import { Telegraf, Markup } from "telegraf";
1414
import pc from "picocolors";
1515
import { runChatTask, getAlpClaw, chunkText } from "./lib/chat-agent.js";
16+
import { readGlobalConfig, writeGlobalConfig } from "@alpclaw/config";
1617

1718
const MAX_MSG = 3800;
1819

@@ -49,6 +50,64 @@ async function main() {
4950
console.log(pc.dim("─".repeat(60)));
5051
console.log(pc.dim("Waiting for messages... (Ctrl+C to stop)\n"));
5152

53+
bot.command("mode", async (ctx) => {
54+
try {
55+
const cfg = readGlobalConfig();
56+
const currentMode = cfg.safety?.mode || "permissive";
57+
58+
const keyboard = Markup.inlineKeyboard([
59+
Markup.button.callback(currentMode === "permissive" ? "✅ Permissive" : "Permissive", "mode:permissive"),
60+
Markup.button.callback(currentMode === "strict" ? "✅ Strict" : "Strict", "mode:strict"),
61+
]);
62+
63+
await ctx.reply("Select safety mode:", keyboard);
64+
} catch (e: any) {
65+
await ctx.reply("⚠️ Failed to load modes. Please try again.");
66+
}
67+
});
68+
69+
bot.command("provider", async (ctx) => {
70+
try {
71+
const alpclaw = await getAlpClaw();
72+
const providers = alpclaw.router.listProviders();
73+
const current = readGlobalConfig().providers?.default || "openrouter";
74+
75+
const buttons = providers.map(p => [
76+
Markup.button.callback(current === p.name ? `✅ ${p.name}` : p.name, `provider:${p.name}`)
77+
]);
78+
79+
await ctx.reply("Select default provider:", Markup.inlineKeyboard(buttons));
80+
} catch (e: any) {
81+
await ctx.reply("⚠️ Failed to load providers. Please try again.");
82+
}
83+
});
84+
85+
bot.on("callback_query", async (ctx) => {
86+
try {
87+
const cb = ctx.callbackQuery as any;
88+
if (!cb.data) return;
89+
90+
const [action, value] = cb.data.split(":");
91+
const cfg = readGlobalConfig();
92+
93+
if (action === "mode") {
94+
cfg.safety = cfg.safety || { mode: "permissive" };
95+
cfg.safety.mode = value as "permissive" | "strict";
96+
writeGlobalConfig(cfg);
97+
await ctx.editMessageText(`✅ Safety mode set to ${value}.`);
98+
} else if (action === "provider") {
99+
cfg.providers = cfg.providers || { default: value, apiKeys: {} };
100+
cfg.providers.default = value;
101+
writeGlobalConfig(cfg);
102+
await ctx.editMessageText(`✅ Provider set to ${value}.`);
103+
}
104+
105+
await ctx.answerCbQuery();
106+
} catch (e: any) {
107+
await ctx.answerCbQuery("Error updating config.").catch(() => null);
108+
}
109+
});
110+
52111
bot.on("text", async (ctx) => {
53112
const text = ctx.message.text;
54113
const from = ctx.message.from;
@@ -65,7 +124,25 @@ async function main() {
65124
});
66125

67126
const startMs = Date.now();
68-
const { reply, success } = await runChatTask(text);
127+
let reply = "";
128+
let success = false;
129+
let attempts = 0;
130+
const maxAttempts = 2;
131+
132+
while (attempts < maxAttempts) {
133+
attempts++;
134+
const res = await runChatTask(text);
135+
reply = res.reply;
136+
success = res.success;
137+
if (success || attempts >= maxAttempts) break;
138+
console.log(`${ts()} ${pc.yellow("⚠ RETRY")} Attempt ${attempts} failed, retrying...`);
139+
}
140+
141+
if (!success) {
142+
// Friendly error wrapper — hide stack trace from user
143+
reply = "⚠️ I encountered an internal error while processing your request. Please check the terminal logs or try again later.";
144+
}
145+
69146
const elapsed = ((Date.now() - startMs) / 1000).toFixed(1);
70147

71148
const pieces = chunkText(reply, MAX_MSG);

examples/cli.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ async function main() {
108108
case "dashboard":
109109
await launchTui();
110110
return;
111+
case "self-improve":
112+
await runSelfImprove();
113+
return;
111114
case "_worker":
112115
// internal: spawned by background runs to execute a pre-allocated run id
113116
if (args[1] && args[2]) {
@@ -153,7 +156,7 @@ function printHelp(): void {
153156
` ${pc.cyan("Config")} ${pc.dim("│")} config list/get/set, providers list/test`,
154157
` ${pc.cyan("Skills")} ${pc.dim("│")} skills list, skills enable/disable, skills suggest`,
155158
` ${pc.cyan("Runs ")} ${pc.dim("│")} runs list/logs/stop/retry`,
156-
` ${pc.cyan("System")} ${pc.dim("│")} doctor, update, auth, init`,
159+
` ${pc.cyan("System")} ${pc.dim("│")} doctor, update, auth, init, self-improve`,
157160
"",
158161
pc.dim(` Config: ${globalConfigPath()}`),
159162
pc.dim(` Env: SPLASH_* vars work. .env in cwd is read.`),
@@ -635,6 +638,54 @@ async function runFromCli(prompt: string, opts: { background: boolean }): Promis
635638
await runOneShot(alpclaw, prompt);
636639
}
637640

641+
// ──────────────────────────────────────────────────────────────────────────
642+
// Self-Improvement Loop
643+
// ──────────────────────────────────────────────────────────────────────────
644+
645+
async function runSelfImprove() {
646+
console.log(pc.magenta("\n🧠 SPLASH SELF-MODIFICATION ENGINE"));
647+
console.log(pc.dim("Analyzing recent sessions to extract learnings...\n"));
648+
649+
const { EpisodicMemory } = await import("@alpclaw/memory");
650+
const episodic = new EpisodicMemory();
651+
const sessions = episodic.getAllSessions();
652+
if (sessions.length === 0) {
653+
console.log(pc.yellow("No sessions found to learn from."));
654+
return;
655+
}
656+
657+
const latestSessions = sessions.slice(0, 5);
658+
let aggregatedLogs = "";
659+
for (const s of latestSessions) {
660+
const msgs = await episodic.getLastNMessages(s.sessionId, 100);
661+
aggregatedLogs += `\n--- SESSION ${s.sessionId} ---\n`;
662+
for (const msg of msgs) {
663+
aggregatedLogs += `[${msg.role}] ${msg.content}\n`;
664+
}
665+
}
666+
667+
const alpclaw = await buildAgent();
668+
const prompt = `You are the core intelligence of Splash. Your goal is to analyze your recent conversation logs, identify mistakes you made, and write rules to prevent them in the future.
669+
670+
Recent Logs:
671+
${aggregatedLogs.slice(-10000)}
672+
673+
Output ONLY a list of crisp, actionable rules you should adopt. Do not explain them. Be concise.`;
674+
675+
console.log(pc.cyan("Analyzing..."));
676+
const result = await alpclaw.createAgent({ onPhaseChange: () => {} }).run(prompt);
677+
678+
const fs = await import("node:fs");
679+
const path = await import("node:path");
680+
const { globalConfigDir } = await import("@alpclaw/config");
681+
const learningsFile = path.join(globalConfigDir(), "learnings.md");
682+
const learnings = `\n## Learnings (${new Date().toISOString()})\n${result.text}\n`;
683+
fs.appendFileSync(learningsFile, learnings);
684+
685+
console.log(pc.green(`✓ Success! New rules added to ${learningsFile}:\n`));
686+
console.log(result.text);
687+
}
688+
638689
// ──────────────────────────────────────────────────────────────────────────
639690
// Bots
640691
// ──────────────────────────────────────────────────────────────────────────

packages/core/src/alpclaw.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
GitHelperSkill,
5050
LinearTriageSkill,
5151
NotionSyncSkill,
52+
ConfigEditorSkill,
5253
} from "@alpclaw/skills";
5354
import { createLogger } from "@alpclaw/utils";
5455
import { AgentLoop, type AgentLoopCallbacks } from "./agent-loop.js";
@@ -152,6 +153,7 @@ export class AlpClaw {
152153
this.skills.register(new GitHelperSkill());
153154
this.skills.register(new LinearTriageSkill());
154155
this.skills.register(new NotionSyncSkill());
156+
this.skills.register(new ConfigEditorSkill());
155157

156158
// ── Safety ─────────────────────────────────────────────────────────────
157159
this.safety = new SafetyEngine(config.safety.mode, config.safety.blockedPatterns);

packages/memory/src/episodic.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,18 @@ export class EpisodicMemory {
2424
return path.join(this.baseDir, `${sessionId}.jsonl`);
2525
}
2626

27+
public getAllSessions(): { sessionId: string; mtimeMs: number }[] {
28+
if (!fs.existsSync(this.baseDir)) return [];
29+
const files = fs.readdirSync(this.baseDir).filter((file) => file.endsWith(".jsonl"));
30+
return files.map((file) => {
31+
const stat = fs.statSync(path.join(this.baseDir, file));
32+
return {
33+
sessionId: path.basename(file, ".jsonl"),
34+
mtimeMs: stat.mtimeMs,
35+
};
36+
}).sort((a, b) => b.mtimeMs - a.mtimeMs);
37+
}
38+
2739
public append(sessionId: string, message: MessageEntry): void {
2840
const filePath = this.getSessionFile(sessionId);
2941
const line = JSON.stringify(message) + "\n";
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { type Result, type SkillManifest, type SkillResult, ok, err, createError } from "@alpclaw/utils";
2+
import type { Skill, SkillContext } from "../skill.js";
3+
import { readGlobalConfig, writeGlobalConfig } from "@alpclaw/config";
4+
5+
export class ConfigEditorSkill implements Skill {
6+
readonly manifest: SkillManifest = {
7+
name: "config-editor",
8+
description: "Read or edit the global agent configuration (e.g. ~/.splash/config.json). Use this to persist settings, themes, or provider changes.",
9+
version: "1.0.0",
10+
tags: ["config", "settings", "preferences", "system"],
11+
requiredConnectors: [],
12+
parameters: {
13+
type: "object",
14+
properties: {
15+
action: { type: "string", enum: ["read", "update"], description: "The action to perform." },
16+
keyPath: { type: "string", description: "Dot-separated path to the config key (e.g. 'safety.mode' or 'cli.style'). Required for 'update'." },
17+
value: { type: "string", description: "The new JSON-serializable value. Required for 'update'." }
18+
},
19+
required: ["action"],
20+
},
21+
};
22+
23+
async execute(params: Record<string, unknown>, ctx: SkillContext): Promise<Result<SkillResult>> {
24+
const action = String(params.action || "");
25+
26+
try {
27+
const cfg = readGlobalConfig();
28+
29+
if (action === "read") {
30+
return ok({
31+
success: true,
32+
output: cfg,
33+
summary: "Successfully read global configuration.",
34+
});
35+
} else if (action === "update") {
36+
const keyPath = String(params.keyPath || "");
37+
if (!keyPath) return err(createError("validation", "keyPath is required for update"));
38+
39+
// Basic dot-notation assignment
40+
const keys = keyPath.split(".");
41+
let current: any = cfg;
42+
for (let i = 0; i < keys.length - 1; i++) {
43+
const k = keys[i] as string;
44+
if (typeof current[k] !== "object") {
45+
current[k] = {};
46+
}
47+
current = current[k];
48+
}
49+
50+
let parsedValue = params.value;
51+
try {
52+
parsedValue = JSON.parse(String(params.value));
53+
} catch (e) {
54+
// Treat as raw string if it doesn't parse
55+
}
56+
57+
const lastKey = keys[keys.length - 1] as string;
58+
current[lastKey] = parsedValue;
59+
60+
writeGlobalConfig(cfg);
61+
62+
return ok({
63+
success: true,
64+
output: cfg,
65+
summary: `Successfully updated config key '${keyPath}' to ${JSON.stringify(parsedValue)}.`,
66+
});
67+
}
68+
69+
return err(createError("validation", `Unknown action: ${action}`));
70+
} catch (e: any) {
71+
return err(createError("skill", String(e.message || e)));
72+
}
73+
}
74+
}

packages/skills/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,4 @@ export { SubagentRunnerSkill } from "./built-in/subagent-runner.js";
2222
export { GitHelperSkill } from "./built-in/git-helper.js";
2323
export { LinearTriageSkill } from "./built-in/linear-triage.js";
2424
export { NotionSyncSkill } from "./built-in/notion-sync.js";
25+
export { ConfigEditorSkill } from "./built-in/config-editor.js";

0 commit comments

Comments
 (0)