Compare commits

..

15 commits

Author SHA1 Message Date
Nick Tabeling
e396e6d163 feat(DIS-106): RunResult discriminated union replaces Promise<string>
Some checks failed
CI / build-and-test (ubuntu-latest) (pull_request) Has been cancelled
CI / build-and-test (windows-latest) (pull_request) Has been cancelled
CI / lint (pull_request) Has been cancelled
Add RunResult type in src/agent/types.ts (success|timeout|cli-error|parse-error).
Runner resolves instead of rejects for all error cases.
Router handles all four status variants explicitly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 10:18:19 +02:00
dev
e390365810 Merge pull request 'feat(DIS-105): codeblock-aware message splitting for Discord 2000-char limit' (#47) from phase-1/split-for-discord into main
Some checks are pending
CI / build-and-test (ubuntu-latest) (push) Waiting to run
CI / build-and-test (windows-latest) (push) Waiting to run
CI / lint (push) Waiting to run
Reviewed-on: #47
2026-04-10 08:15:37 +00:00
Nick Tabeling
62ea507974 fix(DIS-105): prevent fence closure from pushing chunks beyond 2000-char hard limit
Some checks failed
CI / build-and-test (ubuntu-latest) (pull_request) Has been cancelled
CI / build-and-test (windows-latest) (pull_request) Has been cancelled
CI / lint (pull_request) Has been cancelled
The flush() helper now enforces the Discord hard limit by hard-splitting
any chunk that exceeds 2000 chars (e.g. when "\n```" was appended after
the soft-limit check). Also reserves 4 chars of overhead in the
soft-limit check when inside a fenced code block.

Adds a regression test that verifies no chunk exceeds 2000 chars even
when the fence closure is appended at the limit boundary.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 10:13:58 +02:00
Nick Tabeling
a85217f753 merge: integrate origin/main into phase-1/split-for-discord
Resolve conflict in src/router.ts: keep both splitForDiscord (DIS-105)
and sanitizeForDiscord (DIS-102). Apply splitForDiscord to the already
sanitized response text.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 10:11:13 +02:00
dev
ce54b4c824 Merge pull request 'feat(DIS-102): sanitizeForDiscord strips paths and tokens before Discord send' (#46) from phase-1/sanitize-for-discord into main
Some checks are pending
CI / build-and-test (ubuntu-latest) (push) Waiting to run
CI / build-and-test (windows-latest) (push) Waiting to run
CI / lint (push) Waiting to run
Reviewed-on: #46
2026-04-10 08:05:43 +00:00
Nick Tabeling
714e0c01e5 fix(DIS-102): sanitize response before DB storage to break path feedback loop
Some checks failed
CI / build-and-test (ubuntu-latest) (pull_request) Has been cancelled
CI / build-and-test (windows-latest) (pull_request) Has been cancelled
CI / lint (pull_request) Has been cancelled
The root cause was twofold:
1. Raw (unsanitized) agent responses were stored in the conversation
   history DB. On subsequent calls, these paths were fed back to the
   agent as context, causing it to reproduce full paths regardless of
   Discord-side sanitization.
2. repoRoot (workspace_path) was not passed to sanitizeForDiscord,
   so workspace-specific paths were only partially masked via the
   home directory fallback.

Fix: sanitize the full response once before both sending to Discord
and storing in the DB. Pass workspace_path as repoRoot for precise
path replacement. Add Windows path tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 10:02:17 +02:00
Nick Tabeling
5869c15f9d fix(DIS-102): handle both slash styles for Windows path sanitization
Some checks failed
CI / build-and-test (ubuntu-latest) (pull_request) Has been cancelled
CI / build-and-test (windows-latest) (pull_request) Has been cancelled
CI / lint (pull_request) Has been cancelled
os.homedir() returns backslashes on Windows but Claude Code outputs
paths with forward slashes. Replace both C:\Users\x and C:/Users/x
variants so home and repoRoot are always redacted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 09:58:34 +02:00
Nick Tabeling
90f96bdbf4 fix: add local type imports for AgentIdentity and DisclawConfig
Some checks failed
CI / build-and-test (ubuntu-latest) (pull_request) Has been cancelled
CI / build-and-test (windows-latest) (pull_request) Has been cancelled
CI / lint (pull_request) Has been cancelled
Re-exported types via 'export type { X } from' are not available
as local names in the same file. Add explicit 'import type' so
TypeScript resolves the types used in function signatures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 09:51:15 +02:00
Nick Tabeling
57cd3d4a43 feat(DIS-102): sanitizeForDiscord strips paths and tokens before Discord send
Add sanitizeForDiscord() in src/runtime/sanitize.ts.
Router applies sanitizer to all channel.send() calls.
Prevents path/token leaks into Discord channels.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 09:50:37 +02:00
dev
67433d63fa Merge pull request 'refactor(DIS-108): extract DB schema to src/db/schema.sql (SSOT)' (#50) from phase-1/db-schema-ssot into main
Some checks are pending
CI / build-and-test (ubuntu-latest) (push) Waiting to run
CI / build-and-test (windows-latest) (push) Waiting to run
CI / lint (push) Waiting to run
Reviewed-on: #50
2026-04-09 16:01:48 +00:00
Nick Tabeling
aeb4bd5b59 fix(DIS-108): copy schema.sql to dist/ after tsc build
Some checks failed
CI / build-and-test (ubuntu-latest) (pull_request) Has been cancelled
CI / build-and-test (windows-latest) (pull_request) Has been cancelled
CI / lint (pull_request) Has been cancelled
TypeScript does not copy non-.ts files. Add a cpSync step to the
build and dev scripts so dist/db/schema.sql exists at runtime.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 18:01:11 +02:00
dev
419d0990bf Merge pull request 'feat(DIS-107): Zod schemas for disclaw.yaml, agent.yaml, CLI output' (#49) from phase-1/zod-schemas into main
Some checks are pending
CI / build-and-test (ubuntu-latest) (push) Waiting to run
CI / build-and-test (windows-latest) (push) Waiting to run
CI / lint (push) Waiting to run
Reviewed-on: #49
2026-04-09 15:54:59 +00:00
Nick Tabeling
3dd75ddc30 refactor(DIS-108): extract DB schema to src/db/schema.sql (SSOT)
Some checks failed
CI / build-and-test (ubuntu-latest) (pull_request) Has been cancelled
CI / build-and-test (windows-latest) (pull_request) Has been cancelled
CI / lint (pull_request) Has been cancelled
Move all CREATE TABLE/INDEX DDL from database.ts to schema.sql.
database.ts loads and executes schema.sql via db.exec().
Add integration test verifying idempotent schema init.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 17:50:14 +02:00
Nick Tabeling
5718284518 feat(DIS-107): Zod schemas for disclaw.yaml, agent.yaml, CLI output
Some checks failed
CI / build-and-test (ubuntu-latest) (pull_request) Has been cancelled
CI / build-and-test (windows-latest) (pull_request) Has been cancelled
CI / lint (pull_request) Has been cancelled
Add DisclawConfigSchema and AgentYamlSchema with Zod validation.
loader.ts and identity.ts validate external data at parse time.
Clear error messages with field name and expected type.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 17:49:44 +02:00
Nick Tabeling
00132c6d22 feat(DIS-105): codeblock-aware message splitting for Discord 2000-char limit
Some checks failed
CI / build-and-test (ubuntu-latest) (pull_request) Has been cancelled
CI / build-and-test (windows-latest) (pull_request) Has been cancelled
CI / lint (pull_request) Has been cancelled
Add splitForDiscord() in src/discord/split.ts.
Fences are properly closed and reopened across chunk boundaries.
Router uses splitForDiscord instead of internal splitMessage().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 17:48:58 +02:00
15 changed files with 674 additions and 133 deletions

48
package-lock.json generated
View file

@ -13,7 +13,8 @@
"discord.js": "^14.16.0", "discord.js": "^14.16.0",
"dotenv": "^16.4.0", "dotenv": "^16.4.0",
"pino": "^10.3.1", "pino": "^10.3.1",
"yaml": "^2.6.0" "yaml": "^2.6.0",
"zod": "^3.23.0"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.0", "@types/better-sqlite3": "^7.6.0",
@ -1862,15 +1863,6 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/ast-v8-to-istanbul": { "node_modules/ast-v8-to-istanbul": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz",
@ -1883,6 +1875,15 @@
"js-tokens": "^10.0.0" "js-tokens": "^10.0.0"
} }
}, },
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@ -2756,7 +2757,8 @@
"node_modules/help-me": { "node_modules/help-me": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==" "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
"dev": true
}, },
"node_modules/html-escaper": { "node_modules/html-escaper": {
"version": "2.0.2", "version": "2.0.2",
@ -2885,13 +2887,6 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/joycon": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
"integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
"dev": true,
"license": "MIT"
},
"node_modules/istanbul-lib-coverage": { "node_modules/istanbul-lib-coverage": {
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
@ -2931,6 +2926,13 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/joycon": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
"integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
"dev": true,
"license": "MIT"
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "10.0.0", "version": "10.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
@ -4343,7 +4345,15 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
} }
} }
} }

View file

@ -4,9 +4,9 @@
"description": "Discord-bot-powered multi-agent workspace manager built on Claude Code", "description": "Discord-bot-powered multi-agent workspace manager built on Claude Code",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
"build": "tsc", "build": "tsc && node -e \"require('fs').cpSync('src/db/schema.sql', 'dist/db/schema.sql')\"",
"start": "node dist/index.js", "start": "node dist/index.js",
"dev": "tsc && node dist/index.js", "dev": "tsc && node -e \"require('fs').cpSync('src/db/schema.sql', 'dist/db/schema.sql')\" && node dist/index.js",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
@ -24,7 +24,8 @@
"discord.js": "^14.16.0", "discord.js": "^14.16.0",
"dotenv": "^16.4.0", "dotenv": "^16.4.0",
"pino": "^10.3.1", "pino": "^10.3.1",
"yaml": "^2.6.0" "yaml": "^2.6.0",
"zod": "^3.23.0"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.0", "@types/better-sqlite3": "^7.6.0",

View file

@ -1,24 +1,17 @@
import * as fs from "fs"; import * as fs from "fs";
import * as path from "path"; import * as path from "path";
import * as YAML from "yaml"; import * as YAML from "yaml";
import { ZodError } from "zod";
import { AgentYamlSchema } from "./schema";
import type { AgentYaml as AgentIdentity } from "./schema";
export type { AgentYaml as AgentIdentity } from "./schema";
/** /**
* Metadata stored in agent.yaml -- DisClaw-specific fields only. * Loads agent.yaml from the workspace folder and returns the validated identity.
* Identity, personality, and instructions now live in CLAUDE.md,
* which Claude Code reads automatically.
*/
export interface AgentIdentity {
name: string;
display_name: string;
role: string;
channel_id: string;
}
/**
* Loads agent.yaml from the workspace folder and returns the parsed identity.
* Re-read on every call so edits take effect immediately. * Re-read on every call so edits take effect immediately.
*/ */
export function loadAgentIdentity(workspacePath: string): AgentIdentity { export function loadAgentIdentity(workspacePath: string): import("./schema").AgentYaml {
const yamlPath = path.join(workspacePath, "agent.yaml"); const yamlPath = path.join(workspacePath, "agent.yaml");
if (!fs.existsSync(yamlPath)) { if (!fs.existsSync(yamlPath)) {
@ -26,7 +19,20 @@ export function loadAgentIdentity(workspacePath: string): AgentIdentity {
} }
const raw = fs.readFileSync(yamlPath, "utf-8"); const raw = fs.readFileSync(yamlPath, "utf-8");
return YAML.parse(raw) as AgentIdentity; const rawParsed = YAML.parse(raw);
try {
return AgentYamlSchema.parse(rawParsed);
} catch (err) {
if (err instanceof ZodError) {
const first = err.errors[0];
const fieldPath = first.path.length > 0 ? first.path.join(".") : "(root)";
throw new Error(
`Invalid agent.yaml at ${yamlPath}: field "${fieldPath}" -- ${first.message}`
);
}
throw err;
}
} }
/** /**

19
src/agent/schema.ts Normal file
View file

@ -0,0 +1,19 @@
import { z } from "zod";
export const AgentYamlSchema = z.object({
name: z.string().min(1),
display_name: z.string().optional(),
role: z.string().optional(),
channel_id: z.string().min(1),
});
export type AgentYaml = z.infer<typeof AgentYamlSchema>;
export const ClaudeJsonOutputSchema = z.union([
z.object({ result: z.string() }),
z.object({ content: z.array(z.object({ type: z.string(), text: z.string().optional() })) }),
z.array(z.object({ type: z.string(), text: z.string().optional() })),
z.string(),
]);
export type ClaudeJsonOutput = z.infer<typeof ClaudeJsonOutputSchema>;

View file

@ -3,19 +3,15 @@ import * as os from "os";
import * as path from "path"; import * as path from "path";
import * as dotenv from "dotenv"; import * as dotenv from "dotenv";
import * as YAML from "yaml"; import * as YAML from "yaml";
import { ZodError } from "zod";
import { childLogger } from "../runtime/logger"; import { childLogger } from "../runtime/logger";
import { DisclawConfigSchema } from "./schema";
import type { DisclawConfig } from "./schema";
export type { DisclawConfig } from "./schema";
const log = childLogger("config"); const log = childLogger("config");
export interface DisclawConfig {
workspaces_root: string;
management_channel: string;
claude_command: string;
claude_timeout_seconds: number;
env_blocklist: string[];
max_concurrent_agents: number;
}
export interface EnvConfig { export interface EnvConfig {
DISCORD_BOT_TOKEN: string; DISCORD_BOT_TOKEN: string;
DISCORD_GUILD_ID?: string; DISCORD_GUILD_ID?: string;
@ -78,18 +74,25 @@ export function loadDisclawConfig(rootDir: string): DisclawConfig {
} }
const raw = fs.readFileSync(configPath, "utf-8"); const raw = fs.readFileSync(configPath, "utf-8");
const parsed = YAML.parse(raw) as Partial<DisclawConfig>; const rawParsed = YAML.parse(raw);
// Apply defaults let validated: DisclawConfig;
const rawWorkspacesRoot = parsed.workspaces_root ?? "~/.disclaw/workspaces"; try {
validated = DisclawConfigSchema.parse(rawParsed);
} catch (err) {
if (err instanceof ZodError) {
const first = err.errors[0];
const fieldPath = first.path.length > 0 ? first.path.join(".") : "(root)";
throw new Error(
`Invalid disclaw.yaml: field "${fieldPath}" -- ${first.message}`
);
}
throw err;
}
const config: DisclawConfig = { const config: DisclawConfig = {
workspaces_root: expandWorkspacesRoot(rawWorkspacesRoot, rootDir), ...validated,
management_channel: parsed.management_channel ?? "disclaw", workspaces_root: expandWorkspacesRoot(validated.workspaces_root, rootDir),
claude_command: parsed.claude_command ?? "claude",
claude_timeout_seconds: parsed.claude_timeout_seconds ?? 120,
env_blocklist: parsed.env_blocklist ?? [],
max_concurrent_agents: parsed.max_concurrent_agents ?? 4,
}; };
return config; return config;

12
src/config/schema.ts Normal file
View file

@ -0,0 +1,12 @@
import { z } from "zod";
export const DisclawConfigSchema = z.object({
workspaces_root: z.string().min(1),
management_channel: z.string().min(1).default("disclaw"),
claude_command: z.string().min(1).default("claude"),
claude_timeout_seconds: z.number().int().positive().default(120),
max_concurrent_agents: z.number().int().positive().default(4),
env_blocklist: z.array(z.string()).default([]),
});
export type DisclawConfig = z.infer<typeof DisclawConfigSchema>;

View file

@ -2,29 +2,7 @@ import Database from "better-sqlite3";
import * as fs from "fs"; import * as fs from "fs";
import * as path from "path"; import * as path from "path";
const SCHEMA_SQL = ` const schema = fs.readFileSync(path.join(__dirname, "schema.sql"), "utf-8");
CREATE TABLE IF NOT EXISTS workspaces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id TEXT NOT NULL UNIQUE,
guild_id TEXT NOT NULL,
agent_name TEXT NOT NULL UNIQUE,
workspace_path TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER NOT NULL REFERENCES workspaces(id),
discord_msg_id TEXT NOT NULL UNIQUE,
role TEXT NOT NULL,
content TEXT NOT NULL,
author_name TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_conversations_workspace
ON conversations(workspace_id, created_at);
`;
export interface Workspace { export interface Workspace {
id: number; id: number;
@ -61,7 +39,7 @@ export class DisclawDatabase {
} }
private runMigrations(): void { private runMigrations(): void {
this.db.exec(SCHEMA_SQL); this.db.exec(schema);
} }
// --- Workspace queries --- // --- Workspace queries ---

View file

@ -1,3 +1,7 @@
-- DisClaw SQLite schema (SSOT)
-- All statements use IF NOT EXISTS for idempotent initialization.
-- Workspaces: maps a Discord channel to a local agent workspace directory.
CREATE TABLE IF NOT EXISTS workspaces ( CREATE TABLE IF NOT EXISTS workspaces (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id TEXT NOT NULL UNIQUE, channel_id TEXT NOT NULL UNIQUE,
@ -7,6 +11,7 @@ CREATE TABLE IF NOT EXISTS workspaces (
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
-- Conversations: stores per-message history for each workspace.
CREATE TABLE IF NOT EXISTS conversations ( CREATE TABLE IF NOT EXISTS conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER NOT NULL REFERENCES workspaces(id), workspace_id INTEGER NOT NULL REFERENCES workspaces(id),
@ -17,5 +22,6 @@ CREATE TABLE IF NOT EXISTS conversations (
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
-- Index for efficient conversation history retrieval ordered by time.
CREATE INDEX IF NOT EXISTS idx_conversations_workspace CREATE INDEX IF NOT EXISTS idx_conversations_workspace
ON conversations(workspace_id, created_at); ON conversations(workspace_id, created_at);

93
src/discord/split.ts Normal file
View file

@ -0,0 +1,93 @@
const DISCORD_HARD_LIMIT = 2000;
/**
* Splits text into chunks that fit within Discord's character limit while
* keeping Markdown fenced code blocks intact. When a chunk boundary falls
* inside a fence, the current chunk is closed with ``` and the next chunk
* reopens with ```<lang> so the fence state is preserved across chunks.
*
* @param text The full text to split.
* @param limit Soft target size per chunk (default 1900). Must be <= 2000.
*/
export function splitForDiscord(text: string, limit = 1900): string[] {
if (text.length === 0) {
return [""];
}
if (text.length <= limit) {
return [text];
}
const effectiveLimit = Math.min(limit, DISCORD_HARD_LIMIT);
const chunks: string[] = [];
const lines = text.split("\n");
let current = "";
let inFence = false;
let fenceLang = "";
const flush = () => {
// Hard-split if current exceeds the Discord hard limit (e.g. because
// a fence closure "\n```" was appended after the soft-limit check).
while (current.length > DISCORD_HARD_LIMIT) {
chunks.push(current.slice(0, DISCORD_HARD_LIMIT));
current = current.slice(DISCORD_HARD_LIMIT);
}
if (current.length > 0) {
chunks.push(current);
current = "";
}
};
for (const line of lines) {
// Detect fence toggle: line starts with ```
const fenceMatch = line.match(/^```(\S*)/);
const isFenceLine = fenceMatch !== null;
// Calculate the length this line would add to current chunk
const separator = current.length > 0 ? "\n" : "";
const addition = separator + line;
// Reserve space for a closing fence (``` + newline) when inside a code block
const fenceOverhead = inFence ? 4 : 0; // "\n```".length
if (current.length + addition.length + fenceOverhead > effectiveLimit) {
// Need to flush before adding this line
if (inFence) {
// Close the open fence before flushing
current += "\n```";
}
flush();
// Reopen fence in new chunk if we were inside one
if (inFence) {
current = "```" + fenceLang;
}
}
// Toggle fence state after deciding to flush (so reopening uses correct lang)
if (isFenceLine) {
if (!inFence) {
inFence = true;
fenceLang = fenceMatch[1] ?? "";
} else {
inFence = false;
fenceLang = "";
}
}
const sep = current.length > 0 ? "\n" : "";
current += sep + line;
// Safety: if a single line alone exceeds the hard limit, hard-split it
while (current.length > DISCORD_HARD_LIMIT) {
chunks.push(current.slice(0, DISCORD_HARD_LIMIT));
current = current.slice(DISCORD_HARD_LIMIT);
}
}
flush();
return chunks;
}

View file

@ -1,57 +1,12 @@
import * as os from "os";
import { Message, TextChannel } from "discord.js"; import { Message, TextChannel } from "discord.js";
import { DisclawDatabase } from "./db/database"; import { DisclawDatabase } from "./db/database";
import { DisclawConfig } from "./config/loader"; import { DisclawConfig } from "./config/loader";
import { runAgent } from "./agent/runner"; import { runAgent } from "./agent/runner";
import { splitForDiscord } from "./discord/split";
import { sanitizeForDiscord } from "./runtime/sanitize";
import type { RunResult } from "./agent/types"; import type { RunResult } from "./agent/types";
const DISCORD_MAX_LENGTH = 2000;
/**
* Splits a message at line breaks so each chunk stays under the Discord
* character limit. If a single line exceeds the limit, it is split at
* the character boundary as a last resort.
*/
function splitMessage(text: string): string[] {
if (text.length <= DISCORD_MAX_LENGTH) {
return [text];
}
const chunks: string[] = [];
const lines = text.split("\n");
let current = "";
for (const line of lines) {
// If adding this line would exceed the limit, flush current chunk
if (current.length + line.length + 1 > DISCORD_MAX_LENGTH) {
if (current.length > 0) {
chunks.push(current);
current = "";
}
// Handle lines that are themselves too long
if (line.length > DISCORD_MAX_LENGTH) {
let remaining = line;
while (remaining.length > DISCORD_MAX_LENGTH) {
chunks.push(remaining.slice(0, DISCORD_MAX_LENGTH));
remaining = remaining.slice(DISCORD_MAX_LENGTH);
}
if (remaining.length > 0) {
current = remaining;
}
continue;
}
}
current += (current.length > 0 ? "\n" : "") + line;
}
if (current.length > 0) {
chunks.push(current);
}
return chunks;
}
/** /**
* Routes a Discord message to the correct agent based on channel_id. * Routes a Discord message to the correct agent based on channel_id.
* Returns true if the message was handled, false if the channel is not * Returns true if the message was handled, false if the channel is not
@ -85,6 +40,11 @@ export async function routeMessage(
// Send immediately as well // Send immediately as well
await channel.sendTyping().catch(() => {}); await channel.sendTyping().catch(() => {});
const sanitizeOpts = {
repoRoot: workspace.workspace_path,
home: os.homedir(),
};
try { try {
// Load recent conversation history for context // Load recent conversation history for context
const history = db.getConversationHistory(workspace.id, 30); const history = db.getConversationHistory(workspace.id, 30);
@ -107,8 +67,14 @@ export async function routeMessage(
message.author.username message.author.username
); );
// Sanitize the full response once, then use the sanitized version
// for both Discord output and conversation history storage.
// This prevents a feedback loop where raw paths in stored history
// get fed back to the agent as context, causing it to reproduce them.
const sanitizedResponse = sanitizeForDiscord(result.text, sanitizeOpts);
// Split and send response // Split and send response
const chunks = splitMessage(result.text); const chunks = splitForDiscord(sanitizedResponse);
let firstMsgId: string | null = null; let firstMsgId: string | null = null;
for (const chunk of chunks) { for (const chunk of chunks) {
@ -118,13 +84,13 @@ export async function routeMessage(
} }
} }
// Store assistant response (use first message id for dedup) // Store sanitized assistant response (use first message id for dedup)
if (firstMsgId) { if (firstMsgId) {
db.addConversation( db.addConversation(
workspace.id, workspace.id,
firstMsgId, firstMsgId,
"assistant", "assistant",
result.text, sanitizedResponse,
null null
); );
} }
@ -134,7 +100,7 @@ export async function routeMessage(
.catch(() => {}); .catch(() => {});
} else if (result.status === "cli-error") { } else if (result.status === "cli-error") {
await channel await channel
.send(`❌ CLI-Fehler: ${result.message}`) .send(sanitizeForDiscord(`❌ CLI-Fehler: ${result.message}`, sanitizeOpts))
.catch(() => {}); .catch(() => {});
} else if (result.status === "parse-error") { } else if (result.status === "parse-error") {
await channel await channel
@ -142,9 +108,10 @@ export async function routeMessage(
.catch(() => {}); .catch(() => {});
} }
} catch (error) { } catch (error) {
// Fallback for unexpected errors not captured by RunResult
const msg = error instanceof Error ? error.message : "Unbekannter Fehler"; const msg = error instanceof Error ? error.message : "Unbekannter Fehler";
await channel.send(`Fehler bei der Verarbeitung: ${msg}`).catch(() => {}); await channel
.send(sanitizeForDiscord(`Fehler bei der Verarbeitung: ${msg}`, sanitizeOpts))
.catch(() => {});
} finally { } finally {
clearInterval(typingInterval); clearInterval(typingInterval);
} }

58
src/runtime/sanitize.ts Normal file
View file

@ -0,0 +1,58 @@
export interface SanitizeOptions {
repoRoot?: string; // e.g. /home/user/.disclaw
home?: string; // os.homedir()
}
/**
* Sanitizes agent output before it is sent to a Discord channel.
* Replaces absolute paths and sensitive tokens so they are never leaked.
*
* Replacement order is intentional longest/most-specific patterns first:
* 1. repoRoot <disclaw>
* 2. home ~
* 3. Discord bot token pattern <redacted>
* 4. Anthropic API key pattern <redacted>
*/
/** Returns both backslash and forward-slash variants of a path (for Windows). */
function pathVariants(p: string): string[] {
const fwd = p.replace(/\\/g, "/");
const bwd = p.replace(/\//g, "\\");
return fwd === bwd ? [p] : [p, fwd, bwd];
}
function replaceAll(text: string, search: string, replacement: string): string {
return text.split(search).join(replacement);
}
export function sanitizeForDiscord(
text: string,
opts: SanitizeOptions = {}
): string {
let result = text;
// 1. Replace repo root before home so that sub-paths are caught by the
// more specific substitution first (repoRoot is usually inside home).
if (opts.repoRoot) {
for (const variant of pathVariants(opts.repoRoot)) {
result = replaceAll(result, variant, "<disclaw>");
}
}
// 2. Replace home directory with ~ (both slash styles for Windows)
if (opts.home) {
for (const variant of pathVariants(opts.home)) {
result = replaceAll(result, variant, "~");
}
}
// 3. Discord bot token — format: [MN]<23 base64url>.<6 base64url>.<27 base64url>
result = result.replace(
/[MN][A-Za-z\d]{23}\.[\w-]{6}\.[\w-]{27}/g,
"<redacted>"
);
// 4. Anthropic API key — format: sk-ant-<20+ base64url chars>
result = result.replace(/sk-ant-[A-Za-z0-9\-_]{20,}/g, "<redacted>");
return result;
}

View file

@ -0,0 +1,97 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { DisclawDatabase } from "../../src/db/database";
/**
* Integration tests for DisclawDatabase schema initialization.
*
* Each test uses a fresh SQLite file in os.tmpdir() to ensure full isolation.
* The DB file is cleaned up after each test.
*/
function makeTmpDbPath(): string {
return path.join(os.tmpdir(), `disclaw-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
}
describe("db-init: schema initialization", () => {
let dbPath: string;
let db: DisclawDatabase;
beforeEach(() => {
dbPath = makeTmpDbPath();
});
afterEach(() => {
try {
db.close();
} catch {
// ignore if already closed
}
try {
fs.unlinkSync(dbPath);
} catch {
// best-effort cleanup
}
});
// (a) All expected tables exist after initialization
it("creates all expected tables on first init", () => {
db = new DisclawDatabase(dbPath);
// Query sqlite_master for user-created tables
const rawDb = (db as unknown as { db: import("better-sqlite3").Database }).db;
const tables = rawDb
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
.all() as { name: string }[];
const tableNames = tables.map((t) => t.name);
expect(tableNames).toContain("workspaces");
expect(tableNames).toContain("conversations");
});
// (b) Initializing the same DB path twice is idempotent (IF NOT EXISTS)
it("is idempotent when initialized twice (IF NOT EXISTS)", () => {
db = new DisclawDatabase(dbPath);
db.close();
// Second initialization must not throw
expect(() => {
db = new DisclawDatabase(dbPath);
}).not.toThrow();
const rawDb = (db as unknown as { db: import("better-sqlite3").Database }).db;
const tables = rawDb
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
.all() as { name: string }[];
const tableNames = tables.map((t) => t.name);
expect(tableNames).toContain("workspaces");
expect(tableNames).toContain("conversations");
});
// (c) Basic CRUD: create a workspace and retrieve it by channel_id
it("supports basic workspace CRUD", () => {
db = new DisclawDatabase(dbPath);
const created = db.createWorkspace(
"channel-001",
"guild-001",
"test-agent",
"/tmp/test-agent"
);
expect(created.id).toBeGreaterThan(0);
expect(created.channel_id).toBe("channel-001");
expect(created.guild_id).toBe("guild-001");
expect(created.agent_name).toBe("test-agent");
expect(created.workspace_path).toBe("/tmp/test-agent");
expect(created.created_at).toBeTruthy();
const fetched = db.getWorkspaceByChannelId("channel-001");
expect(fetched).toBeDefined();
expect(fetched!.id).toBe(created.id);
expect(fetched!.agent_name).toBe("test-agent");
});
});

View file

@ -0,0 +1,87 @@
import { describe, it, expect } from "vitest";
import { sanitizeForDiscord } from "../../src/runtime/sanitize";
describe("sanitizeForDiscord", () => {
// (a) Repo-root path is replaced with <disclaw>
it("replaces repoRoot with <disclaw>", () => {
const text = "Reading config from /home/user/project/disclaw.yaml";
const result = sanitizeForDiscord(text, { repoRoot: "/home/user/project" });
expect(result).toBe("Reading config from <disclaw>/disclaw.yaml");
expect(result).not.toContain("/home/user/project");
});
// (b) Home directory is replaced with ~
it("replaces home directory with ~", () => {
const text = "Workspace located at /home/user/.disclaw/workspaces/agent-x";
const result = sanitizeForDiscord(text, { home: "/home/user" });
expect(result).toBe("Workspace located at ~/.disclaw/workspaces/agent-x");
expect(result).not.toContain("/home/user");
});
// (c) Discord bot token pattern is redacted
it("redacts Discord bot tokens", () => {
const token = "MTA1NjYwNDczNTA3NDU2NjE2.GbXkRa.abcdefghijklmnopqrstuvwxyz123";
const text = `Bot initialized with token ${token} — ready.`;
const result = sanitizeForDiscord(text);
expect(result).not.toContain(token);
expect(result).toContain("<redacted>");
});
// (d) Anthropic API key is redacted
it("redacts Anthropic API keys", () => {
const key = "sk-ant-api03-ABCDEFGHIJ1234567890abcdefghij";
const text = `Using key ${key} for requests.`;
const result = sanitizeForDiscord(text);
expect(result).not.toContain(key);
expect(result).toContain("<redacted>");
});
// (e) Harmless text is not modified (no false positives)
it("does not modify harmless text", () => {
const text = "Hello! The agent processed your request successfully in 120ms.";
const result = sanitizeForDiscord(text, { home: "/home/user", repoRoot: "/home/user/project" });
expect(result).toBe(text);
});
// (f) Empty string returns empty string
it("handles empty string input", () => {
expect(sanitizeForDiscord("")).toBe("");
expect(sanitizeForDiscord("", { home: "/home/user", repoRoot: "/home/user/project" })).toBe("");
});
// (g) No opts provided — plain text stays unchanged when no token patterns present
it("returns text unchanged when no opts and no token patterns", () => {
const text = "This is a perfectly normal response from the agent.";
expect(sanitizeForDiscord(text)).toBe(text);
});
// Ordering: repoRoot (sub-path of home) is replaced before home
it("replaces repoRoot before home so nested paths are handled correctly", () => {
const text = "/home/user/project/src/index.ts and /home/user/other.ts";
const result = sanitizeForDiscord(text, {
repoRoot: "/home/user/project",
home: "/home/user",
});
expect(result).toBe("<disclaw>/src/index.ts and ~/other.ts");
});
// Windows-style backslash paths are sanitized
it("replaces Windows backslash paths", () => {
const text = "File at C:\\Users\\dev\\.disclaw\\workspaces\\agent-x\\src\\main.ts";
const result = sanitizeForDiscord(text, {
repoRoot: "C:\\Users\\dev\\.disclaw\\workspaces\\agent-x",
home: "C:\\Users\\dev",
});
expect(result).toBe("File at <disclaw>\\src\\main.ts");
});
// Mixed slash styles (common on Windows when tools output forward slashes)
it("replaces forward-slash variants of Windows paths", () => {
const text = "File at C:/Users/dev/.disclaw/workspaces/agent-x/src/main.ts";
const result = sanitizeForDiscord(text, {
repoRoot: "C:\\Users\\dev\\.disclaw\\workspaces\\agent-x",
home: "C:\\Users\\dev",
});
expect(result).toBe("File at <disclaw>/src/main.ts");
});
});

View file

@ -0,0 +1,84 @@
import { describe, it, expect } from "vitest";
import { ZodError } from "zod";
import { DisclawConfigSchema } from "../../src/config/schema";
import { AgentYamlSchema } from "../../src/agent/schema";
describe("DisclawConfigSchema", () => {
it("(a) parses valid disclaw.yaml data correctly", () => {
const input = {
workspaces_root: "/home/user/.disclaw/workspaces",
management_channel: "disclaw",
claude_command: "claude",
claude_timeout_seconds: 60,
max_concurrent_agents: 2,
env_blocklist: ["SECRET_KEY"],
};
const result = DisclawConfigSchema.parse(input);
expect(result.workspaces_root).toBe("/home/user/.disclaw/workspaces");
expect(result.management_channel).toBe("disclaw");
expect(result.claude_command).toBe("claude");
expect(result.claude_timeout_seconds).toBe(60);
expect(result.max_concurrent_agents).toBe(2);
expect(result.env_blocklist).toEqual(["SECRET_KEY"]);
});
it("(b) throws ZodError when workspaces_root is missing", () => {
const input = {
management_channel: "disclaw",
};
expect(() => DisclawConfigSchema.parse(input)).toThrow(ZodError);
});
it("(c) throws ZodError when claude_timeout_seconds is a string", () => {
const input = {
workspaces_root: "/some/path",
claude_timeout_seconds: "abc",
};
expect(() => DisclawConfigSchema.parse(input)).toThrow(ZodError);
});
it("(d) applies defaults when optional fields are not provided", () => {
const input = {
workspaces_root: "/some/path",
};
const result = DisclawConfigSchema.parse(input);
expect(result.management_channel).toBe("disclaw");
expect(result.claude_command).toBe("claude");
expect(result.claude_timeout_seconds).toBe(120);
expect(result.max_concurrent_agents).toBe(4);
expect(result.env_blocklist).toEqual([]);
});
});
describe("AgentYamlSchema", () => {
it("(e) parses valid agent.yaml data correctly", () => {
const input = {
name: "my-agent",
display_name: "My Agent",
role: "Backend developer",
channel_id: "1234567890",
};
const result = AgentYamlSchema.parse(input);
expect(result.name).toBe("my-agent");
expect(result.display_name).toBe("My Agent");
expect(result.role).toBe("Backend developer");
expect(result.channel_id).toBe("1234567890");
});
it("(f) throws ZodError when channel_id is missing", () => {
const input = {
name: "my-agent",
role: "Backend developer",
};
expect(() => AgentYamlSchema.parse(input)).toThrow(ZodError);
});
});

View file

@ -0,0 +1,120 @@
import { describe, it, expect } from "vitest";
import { splitForDiscord } from "../../src/discord/split";
const DISCORD_HARD_LIMIT = 2000;
describe("splitForDiscord", () => {
// (a) Short message below limit → returned as single unchanged chunk
it("returns a single chunk for short messages", () => {
const text = "Hello, world!";
const result = splitForDiscord(text);
expect(result).toHaveLength(1);
expect(result[0]).toBe(text);
});
// (b) Text exactly at the limit → single chunk
it("returns a single chunk when text is exactly at the limit", () => {
const text = "a".repeat(1900);
const result = splitForDiscord(text, 1900);
expect(result).toHaveLength(1);
expect(result[0]).toBe(text);
});
// (c) Long plain text → split at line boundaries
it("splits long plain text at line boundaries", () => {
const line = "x".repeat(100);
// 25 lines = 25*100 + 24 newlines = 2524 chars → must split with limit 1900
const text = Array.from({ length: 25 }, () => line).join("\n");
const result = splitForDiscord(text, 1900);
expect(result.length).toBeGreaterThan(1);
// Reassembled text must equal original
expect(result.join("\n")).toBe(text);
});
// (d) Code block spanning a split → fence is closed and reopened with lang tag
it("closes and reopens fenced code blocks across chunk boundaries", () => {
const preamble = "Here is some code:\n";
const fenceOpen = "```typescript\n";
// Fill enough code lines to force a split inside the fence
const codeLine = "const x = 1; // " + "y".repeat(80) + "\n";
const codeLines = Array.from({ length: 20 }, () => codeLine).join("");
const fenceClose = "```\n";
const epilogue = "End of message.";
const text = preamble + fenceOpen + codeLines + fenceClose + epilogue;
const result = splitForDiscord(text, 1900);
expect(result.length).toBeGreaterThan(1);
// Every chunk that starts mid-fence must open with ```typescript
// Every chunk that ends mid-fence must close with ```
for (let i = 0; i < result.length - 1; i++) {
const chunk = result[i];
// Count fence toggles to determine if we end inside a fence
const fenceToggles = (chunk.match(/^```/gm) ?? []).length;
if (fenceToggles % 2 === 1) {
// Odd number means we opened but didn't close — the splitter must close it
expect(chunk.trimEnd()).toMatch(/```$/);
// And the next chunk must reopen with the same language
expect(result[i + 1]).toMatch(/^```typescript/);
}
}
});
// (e) No chunk exceeds 2000 characters
it("never produces a chunk exceeding 2000 characters", () => {
const line = "z".repeat(150);
const text = Array.from({ length: 50 }, () => line).join("\n");
const result = splitForDiscord(text, 1900);
for (const chunk of result) {
expect(chunk.length).toBeLessThanOrEqual(DISCORD_HARD_LIMIT);
}
});
// (f) Empty input → no crash, returns consistent value
it("handles empty input without crashing", () => {
expect(() => splitForDiscord("")).not.toThrow();
const result = splitForDiscord("");
expect(Array.isArray(result)).toBe(true);
});
// Additional: fence language tag is preserved correctly
it("preserves the fence language tag when reopening after a split", () => {
const limit = 200;
const header = "Intro\n";
const fenceOpen = "```python\n";
// Enough lines to exceed 200 chars inside the fence
const codeLine = "print('hello')\n";
const codeLines = Array.from({ length: 20 }, () => codeLine).join("");
const fenceClose = "```";
const text = header + fenceOpen + codeLines + fenceClose;
const result = splitForDiscord(text, limit);
// Find the first chunk that ends with ``` (closed fence)
const closingChunk = result.find(
(c, i) => i < result.length - 1 && c.trimEnd().endsWith("```")
);
expect(closingChunk).toBeDefined();
// The chunk following a mid-fence close should start with ```python
const idx = result.indexOf(closingChunk!);
expect(result[idx + 1]).toMatch(/^```python/);
});
// Regression: fence closure "\n```" must not push a chunk beyond the 2000 hard limit
it("does not exceed 2000 chars even when fence closure is appended at limit boundary", () => {
const fenceOpen = "```typescript\n";
// Line long enough that current is near 2000 when fence closure is added
const longLine = "x".repeat(1984);
const nextLine = "y".repeat(10);
const fenceClose = "\n```";
const text = fenceOpen + longLine + "\n" + nextLine + fenceClose;
const result = splitForDiscord(text, 2000);
for (const chunk of result) {
expect(chunk.length).toBeLessThanOrEqual(DISCORD_HARD_LIMIT);
}
});
});