Skip to content

Commit 30beee3

Browse files
author
Splash Agent
committed
feat: Splash 2.0 Milestone 2 - Hermes-Grade Memory & CLI Redesign
1 parent 22867d5 commit 30beee3

8 files changed

Lines changed: 355 additions & 38 deletions

File tree

examples/cli.ts

Lines changed: 74 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -143,42 +143,22 @@ async function main() {
143143
// Help
144144
// ──────────────────────────────────────────────────────────────────────────
145145

146-
function printHelp() {
147-
console.log(renderBanner({ subtitle: "Autonomous Agent" }));
146+
function printHelp(): void {
147+
console.log(renderBanner({ compact: true }));
148148
console.log(
149149
[
150-
pc.bold("USAGE"),
151-
` ${pc.cyan("splash")} Open an interactive chat`,
152-
` ${pc.cyan("splash")} "build me a script" Run a one-shot task`,
153-
` ${pc.cyan("splash")} "..." --background Detach and return a run id`,
154150
"",
155-
pc.bold("CORE COMMANDS"),
156-
` ${pc.cyan("splash init")} 30-second setup wizard`,
157-
` ${pc.cyan("splash tui")} Open the live run dashboard`,
158-
` ${pc.cyan("splash help")} Show this help`,
151+
` ${pc.cyan("Run ")} ${pc.dim("│")} run, exec, ask, "prompt"`,
152+
` ${pc.cyan("Memory")} ${pc.dim("│")} memory list/search/export, profile show/set`,
153+
` ${pc.cyan("Config")} ${pc.dim("│")} config list/get/set, providers list/test`,
154+
` ${pc.cyan("Skills")} ${pc.dim("│")} skills list, skills enable/disable, skills suggest`,
155+
` ${pc.cyan("Runs ")} ${pc.dim("│")} runs list/logs/stop/retry`,
156+
` ${pc.cyan("System")} ${pc.dim("│")} doctor, update, auth, init`,
159157
"",
160-
pc.bold("RUNS"),
161-
` ${pc.cyan("splash runs list")} Show active/recent runs`,
162-
` ${pc.cyan("splash runs logs")} ID [--follow] Tail events for a run`,
163-
` ${pc.cyan("splash runs attach")} ID Open a run in the TUI`,
164-
` ${pc.cyan("splash runs stop")} ID Cancel a running run`,
165-
` ${pc.cyan("splash runs retry")} ID [-b] Re-run a task`,
158+
pc.dim(` Config: ${globalConfigPath()}`),
159+
pc.dim(` Env: SPLASH_* vars work. .env in cwd is read.`),
166160
"",
167-
pc.bold("CONFIG"),
168-
` ${pc.cyan("splash config list")} Show effective config`,
169-
` ${pc.cyan("splash config doctor")} Verify keys / perms / reachability`,
170-
` ${pc.cyan("splash config preset")} fast|balanced|safe`,
171-
` ${pc.cyan("splash config set")} KEY VAL Set a field`,
172-
` ${pc.cyan("splash config set-key")} P VAL Save API key for provider`,
173-
"",
174-
pc.bold("PLATFORMS"),
175-
` ${pc.cyan("splash telegram")}|${pc.cyan("slack")}|${pc.cyan("whatsapp")}|${pc.cyan("messenger")}|${pc.cyan("discord")}`,
176-
"",
177-
pc.dim(`Config: ${globalConfigPath()}`),
178-
pc.dim(`Env: SPLASH_* and legacy ALPCLAW_* vars both work. .env in cwd is read.`),
179-
pc.dim(`Alias: \`alpclaw\` still works — it delegates to \`splash\`.`),
180-
"",
181-
].join("\n"),
161+
].join("\n")
182162
);
183163
}
184164

@@ -196,13 +176,19 @@ async function runInit() {
196176
next.apiKeys = { ...(existing.apiKeys || {}) };
197177

198178
const provider = await p.select({
199-
message: "Default provider:",
179+
message: "1. Default provider:",
200180
options: [
201-
{ value: "openrouter", label: "OpenRouter — recommended, unlocks Kimi K2, DeepSeek, Qwen, Claude, GPT-4" },
181+
{ value: "openrouter", label: "OpenRouter — recommended, unlocks massive model catalog" },
202182
{ value: "claude", label: "Anthropic Claude" },
203183
{ value: "openai", label: "OpenAI (GPT-4o)" },
204184
{ value: "gemini", label: "Google Gemini" },
205185
{ value: "deepseek", label: "DeepSeek" },
186+
{ value: "nous", label: "Nous Portal" },
187+
{ value: "groq", label: "Groq (ultra-fast)" },
188+
{ value: "mistral", label: "Mistral" },
189+
{ value: "cerebras", label: "Cerebras" },
190+
{ value: "cohere", label: "Cohere" },
191+
{ value: "nvidia", label: "NVIDIA NIM" },
206192
{ value: "ollama", label: "Ollama (local, no key needed)" },
207193
],
208194
});
@@ -211,14 +197,14 @@ async function runInit() {
211197
next.defaultProvider = provider as string;
212198

213199
if (provider !== "ollama") {
214-
const key = await p.password({ message: `Paste your ${provider} API key (input hidden):` });
200+
const key = await p.password({ message: `2. Paste your ${provider} API key (input hidden):` });
215201
if (p.isCancel(key)) return abort();
216202
if (key) next.apiKeys[provider as string] = key as string;
217203
}
218204

219205
if (provider === "openrouter") {
220206
const model = await p.select({
221-
message: "Default model:",
207+
message: "3. Default model:",
222208
options: [
223209
{ value: "moonshotai/kimi-k2", label: "Kimi K2 (Moonshot) — long context, strong coding" },
224210
{ value: "moonshotai/kimi-k2-0905", label: "Kimi K2 0905 (newer snapshot)" },
@@ -232,7 +218,7 @@ async function runInit() {
232218
}
233219

234220
const safety = await p.select({
235-
message: "Safety level:",
221+
message: "4. Safety level:",
236222
options: [
237223
{ value: "standard", label: "Standard — confirms risky actions (recommended)" },
238224
{ value: "strict", label: "Strict — confirms every action" },
@@ -241,6 +227,20 @@ async function runInit() {
241227
});
242228
if (!p.isCancel(safety)) next.safetyMode = safety as GlobalConfigShape["safetyMode"];
243229

230+
const theme = await p.select({
231+
message: "5. CLI Theme:",
232+
options: [
233+
{ value: "splash", label: "Splash (Default) — Water animations & rich output" },
234+
{ value: "hermes", label: "Hermes — Clean bracket-based logs" },
235+
{ value: "openclaw", label: "OpenClaw — Compact status-light header" },
236+
{ value: "minimal", label: "Minimal — Pure prompt, silent execution" },
237+
],
238+
});
239+
if (!p.isCancel(theme)) {
240+
next.cli = next.cli || {};
241+
next.cli.style = theme as "splash" | "hermes" | "openclaw" | "minimal";
242+
}
243+
244244
writeGlobalConfig(next);
245245
p.outro(pc.green(`✓ Saved to ${globalConfigPath()}`));
246246
console.log(pc.dim(`\nTry it: ${pc.cyan("splash \"summarize this folder\"")}`));
@@ -340,8 +340,9 @@ async function runConfig(args: string[]) {
340340

341341
async function runDoctor(args: string[]): Promise<void> {
342342
const json = args.includes("--json");
343+
const autofix = args.includes("--fix") || args.includes("--autofix");
343344
const cfg = readGlobalConfig();
344-
const checks: { name: string; ok: boolean; detail: string; fix?: string }[] = [];
345+
const checks: { name: string; ok: boolean; detail: string; fix?: string; autoFixer?: () => Promise<void> | void }[] = [];
345346

346347
// 1. provider key present
347348
const hasAnyKey =
@@ -357,6 +358,10 @@ async function runDoctor(args: string[]): Promise<void> {
357358
ok: hasAnyKey,
358359
detail: hasAnyKey ? "found" : "none",
359360
fix: "splash init — or splash config set-key openrouter sk-or-...",
361+
autoFixer: async () => {
362+
console.log(pc.yellow("\nMissing provider key. Launching setup wizard..."));
363+
await runInit();
364+
}
360365
});
361366

362367
// 2. write perms
@@ -374,6 +379,12 @@ async function runDoctor(args: string[]): Promise<void> {
374379
ok: writable,
375380
detail: dir,
376381
fix: `chmod u+w ${dir}`,
382+
autoFixer: () => {
383+
try {
384+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
385+
fs.chmodSync(dir, 0o700);
386+
} catch (e) {}
387+
}
377388
});
378389

379390
// 3. runs dir
@@ -386,7 +397,18 @@ async function runDoctor(args: string[]): Promise<void> {
386397
} catch {
387398
runsOK = false;
388399
}
389-
checks.push({ name: "runs dir writable", ok: runsOK, detail: rdir });
400+
checks.push({
401+
name: "runs dir writable",
402+
ok: runsOK,
403+
detail: rdir,
404+
fix: `chmod u+w ${rdir}`,
405+
autoFixer: () => {
406+
try {
407+
if (!fs.existsSync(rdir)) fs.mkdirSync(rdir, { recursive: true, mode: 0o700 });
408+
fs.chmodSync(rdir, 0o700);
409+
} catch (e) {}
410+
}
411+
});
390412

391413
// 4. provider reachability (best effort — skip if no fetch)
392414
const defaultProvider = cfg.defaultProvider || "openrouter";
@@ -433,6 +455,20 @@ async function runDoctor(args: string[]): Promise<void> {
433455
const bad = checks.filter((c) => !c.ok).length;
434456
console.log();
435457
console.log(bad === 0 ? pc.green("All checks passed.") : pc.yellow(`${bad} check(s) failed.`));
458+
459+
if (bad !== 0 && autofix) {
460+
console.log(pc.cyan("\nRunning autofix for failed checks..."));
461+
for (const c of checks) {
462+
if (!c.ok && c.autoFixer) {
463+
await c.autoFixer();
464+
}
465+
}
466+
console.log(pc.green("\nAutofix complete. Run `splash doctor` again to verify."));
467+
process.exit(0);
468+
} else if (bad !== 0 && !autofix) {
469+
console.log(pc.dim(`\nRun \`${pc.cyan("splash doctor --fix")}\` to automatically resolve issues.`));
470+
}
471+
436472
if (bad !== 0) process.exit(1);
437473
}
438474

packages/memory/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"clean": "rm -rf dist"
1010
},
1111
"dependencies": {
12+
"@alpclaw/config": "workspace:*",
1213
"@alpclaw/utils": "workspace:*",
1314
"zod": "^3.23.0"
1415
}

packages/memory/src/episodic.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import readline from "node:readline";
4+
import { globalConfigDir } from "@alpclaw/config";
5+
6+
export interface MessageEntry {
7+
role: string;
8+
content: string;
9+
timestamp: string;
10+
[key: string]: any;
11+
}
12+
13+
export class EpisodicMemory {
14+
private baseDir: string;
15+
16+
constructor() {
17+
this.baseDir = path.join(globalConfigDir(), "memory", "sessions");
18+
if (!fs.existsSync(this.baseDir)) {
19+
fs.mkdirSync(this.baseDir, { recursive: true });
20+
}
21+
}
22+
23+
private getSessionFile(sessionId: string): string {
24+
return path.join(this.baseDir, `${sessionId}.jsonl`);
25+
}
26+
27+
public append(sessionId: string, message: MessageEntry): void {
28+
const filePath = this.getSessionFile(sessionId);
29+
const line = JSON.stringify(message) + "\n";
30+
fs.appendFileSync(filePath, line, "utf8");
31+
}
32+
33+
public async getLastNMessages(sessionId: string, n: number): Promise<MessageEntry[]> {
34+
const filePath = this.getSessionFile(sessionId);
35+
if (!fs.existsSync(filePath)) {
36+
return [];
37+
}
38+
39+
const lines: string[] = [];
40+
const fileStream = fs.createReadStream(filePath);
41+
const rl = readline.createInterface({
42+
input: fileStream,
43+
crlfDelay: Infinity,
44+
});
45+
46+
for await (const line of rl) {
47+
if (line.trim()) {
48+
lines.push(line);
49+
}
50+
}
51+
52+
return lines.slice(-n).map((line) => JSON.parse(line));
53+
}
54+
55+
public async search(query: string, maxResults: number = 10): Promise<{ sessionId: string, message: MessageEntry }[]> {
56+
const results: { sessionId: string, message: MessageEntry }[] = [];
57+
if (!fs.existsSync(this.baseDir)) return results;
58+
59+
const files = fs.readdirSync(this.baseDir).filter((file) => file.endsWith(".jsonl"));
60+
const regex = new RegExp(query, "i");
61+
62+
for (const file of files) {
63+
const sessionId = path.basename(file, ".jsonl");
64+
const filePath = path.join(this.baseDir, file);
65+
const fileStream = fs.createReadStream(filePath);
66+
const rl = readline.createInterface({
67+
input: fileStream,
68+
crlfDelay: Infinity,
69+
});
70+
71+
for await (const line of rl) {
72+
if (line.trim() && regex.test(line)) {
73+
try {
74+
results.push({ sessionId, message: JSON.parse(line) });
75+
if (results.length >= maxResults) {
76+
return results;
77+
}
78+
} catch (e) {
79+
// Ignore parse errors
80+
}
81+
}
82+
}
83+
}
84+
85+
return results;
86+
}
87+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2+
import { EpisodicMemory } from "./episodic.js";
3+
import { UserProfile } from "./profile.js";
4+
import { SkillMemory } from "./skill-memory.js";
5+
import { rm } from "node:fs/promises";
6+
import * as path from "node:path";
7+
import * as os from "node:os";
8+
import * as fs from "node:fs";
9+
10+
// Use a temporary test directory instead of the real config dir
11+
const TEST_DIR = path.join(os.tmpdir(), "alpclaw-memory-test-" + Date.now());
12+
13+
vi.mock("@alpclaw/config", () => ({
14+
globalConfigDir: () => TEST_DIR
15+
}));
16+
17+
describe("Hermes-Grade Memory Expansion", () => {
18+
beforeEach(async () => {
19+
await rm(TEST_DIR, { recursive: true, force: true });
20+
fs.mkdirSync(TEST_DIR, { recursive: true });
21+
});
22+
23+
afterEach(async () => {
24+
await rm(TEST_DIR, { recursive: true, force: true });
25+
});
26+
27+
describe("EpisodicMemory", () => {
28+
it("should append and retrieve last N messages", async () => {
29+
const memory = new EpisodicMemory();
30+
memory.append("sess1", { role: "user", content: "hello", timestamp: "123" });
31+
memory.append("sess1", { role: "bot", content: "hi", timestamp: "124" });
32+
33+
const last1 = await memory.getLastNMessages("sess1", 1);
34+
expect(last1).toHaveLength(1);
35+
expect(last1[0]?.content).toBe("hi");
36+
37+
const last2 = await memory.getLastNMessages("sess1", 2);
38+
expect(last2).toHaveLength(2);
39+
expect(last2[0]?.content).toBe("hello");
40+
});
41+
42+
it("should search messages across sessions", async () => {
43+
const memory = new EpisodicMemory();
44+
memory.append("sess1", { role: "user", content: "find this string", timestamp: "1" });
45+
memory.append("sess2", { role: "user", content: "some other string", timestamp: "2" });
46+
memory.append("sess2", { role: "bot", content: "find this too", timestamp: "3" });
47+
48+
const results = await memory.search("find this");
49+
expect(results).toHaveLength(2);
50+
expect(results.some(r => r.sessionId === "sess1" && r.message.content.includes("string"))).toBe(true);
51+
expect(results.some(r => r.sessionId === "sess2" && r.message.content.includes("too"))).toBe(true);
52+
});
53+
});
54+
55+
describe("UserProfile", () => {
56+
it("should read and update user profile", () => {
57+
const profile = new UserProfile();
58+
let data = profile.read();
59+
expect(data).toEqual({});
60+
61+
profile.update({ name: "Alice", tone: "helpful" });
62+
data = profile.read();
63+
expect(data.name).toBe("Alice");
64+
expect(data.tone).toBe("helpful");
65+
66+
profile.update({ timezone: "UTC" });
67+
data = profile.read();
68+
expect(data.name).toBe("Alice");
69+
expect(data.timezone).toBe("UTC");
70+
});
71+
});
72+
73+
describe("SkillMemory", () => {
74+
it("should record and retrieve skill stats", () => {
75+
const skills = new SkillMemory();
76+
skills.record("test-skill", true);
77+
skills.record("test-skill", false);
78+
skills.record("test-skill", true);
79+
80+
const stats = skills.getStats();
81+
expect(stats["test-skill"]?.successCount).toBe(2);
82+
expect(stats["test-skill"]?.failureCount).toBe(1);
83+
expect(stats["test-skill"]?.lastUsed).toBeDefined();
84+
});
85+
});
86+
});

packages/memory/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
export type { MemoryStore } from "./store.js";
22
export { FileMemoryStore } from "./file-store.js";
33
export { MemoryManager } from "./manager.js";
4+
export { EpisodicMemory, type MessageEntry } from "./episodic.js";
5+
export { UserProfile, type UserProfileData } from "./profile.js";
6+
export { SkillMemory, type SkillStats } from "./skill-memory.js";

0 commit comments

Comments
 (0)