@inceptionstack/roundhouse 0.2.2 → 0.3.1
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/README.md +321 -9
- package/architecture.md +77 -8
- package/package.json +9 -6
- package/src/agents/pi.ts +433 -26
- package/src/agents/registry.ts +8 -0
- package/src/cli/cli.ts +384 -189
- package/src/cli/cron.ts +296 -0
- package/src/cli/doctor/checks/agent.ts +68 -0
- package/src/cli/doctor/checks/config.ts +88 -0
- package/src/cli/doctor/checks/credentials.ts +62 -0
- package/src/cli/doctor/checks/disk.ts +69 -0
- package/src/cli/doctor/checks/stt.ts +76 -0
- package/src/cli/doctor/checks/system.ts +86 -0
- package/src/cli/doctor/checks/systemd.ts +76 -0
- package/src/cli/doctor/output.ts +58 -0
- package/src/cli/doctor/runner.ts +142 -0
- package/src/cli/doctor/shell.ts +33 -0
- package/src/cli/doctor/types.ts +44 -0
- package/src/cli/doctor.ts +48 -0
- package/src/cli/setup-telegram.ts +148 -0
- package/src/cli/setup.ts +936 -0
- package/src/commands.ts +23 -0
- package/src/config.ts +188 -0
- package/src/cron/constants.ts +54 -0
- package/src/cron/durations.ts +33 -0
- package/src/cron/format.ts +139 -0
- package/src/cron/helpers.ts +30 -0
- package/src/cron/runner.ts +148 -0
- package/src/cron/schedule.ts +101 -0
- package/src/cron/scheduler.ts +295 -0
- package/src/cron/store.ts +125 -0
- package/src/cron/template.ts +89 -0
- package/src/cron/types.ts +76 -0
- package/src/gateway.ts +927 -18
- package/src/index.ts +1 -58
- package/src/memory/bootstrap.ts +98 -0
- package/src/memory/files.ts +100 -0
- package/src/memory/inject.ts +41 -0
- package/src/memory/lifecycle.ts +245 -0
- package/src/memory/policy.ts +122 -0
- package/src/memory/prompts.ts +42 -0
- package/src/memory/state.ts +43 -0
- package/src/memory/types.ts +90 -0
- package/src/notify/telegram.ts +48 -0
- package/src/types.ts +68 -1
- package/src/util.ts +28 -2
- package/src/voice/providers/whisper.ts +339 -0
- package/src/voice/stt-service.ts +284 -0
- package/src/voice/types.ts +63 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory/prompts.ts — Memory flush prompts for maintenance turns
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { MemoryMode } from "./types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Build a memory flush prompt based on mode and urgency.
|
|
9
|
+
*/
|
|
10
|
+
export function buildFlushPrompt(mode: MemoryMode, level: "soft" | "hard" | "emergency"): string {
|
|
11
|
+
const urgency = level === "emergency"
|
|
12
|
+
? "URGENT: Context is nearly full. "
|
|
13
|
+
: level === "hard"
|
|
14
|
+
? "Context is filling up. "
|
|
15
|
+
: "";
|
|
16
|
+
|
|
17
|
+
if (mode === "complement") {
|
|
18
|
+
// Agent has its own memory extension — only ask for narrative context
|
|
19
|
+
return [
|
|
20
|
+
`Roundhouse maintenance: ${urgency}Before context compaction, save important narrative context:`,
|
|
21
|
+
``,
|
|
22
|
+
`- Project decisions, architecture choices, investigation status → ~/MEMORY.md`,
|
|
23
|
+
`- Today's notable events, open loops, task progress → today's daily note`,
|
|
24
|
+
``,
|
|
25
|
+
`Do NOT save individual preferences or corrections — your memory extension handles those automatically.`,
|
|
26
|
+
`Only write facts worth preserving. Do not summarize the whole conversation.`,
|
|
27
|
+
`When done, reply with a one-line confirmation of what you saved.`,
|
|
28
|
+
].join("\n");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Full mode — agent has no memory extension, save everything
|
|
32
|
+
return [
|
|
33
|
+
`Roundhouse maintenance: ${urgency}Before context compaction, update your durable memory files:`,
|
|
34
|
+
``,
|
|
35
|
+
`- User preferences, project conventions, stable facts → ~/MEMORY.md`,
|
|
36
|
+
`- Today's notable events, decisions, open loops → today's daily note`,
|
|
37
|
+
`- Corrections or lessons learned → ~/MEMORY.md`,
|
|
38
|
+
``,
|
|
39
|
+
`Only write facts worth preserving. Do not summarize the whole conversation.`,
|
|
40
|
+
`When done, reply with a one-line confirmation of what you saved.`,
|
|
41
|
+
].join("\n");
|
|
42
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory/state.ts — Per-thread memory state persistence
|
|
3
|
+
*
|
|
4
|
+
* State lives in ~/.roundhouse/memory-state/<thread-dir>.json
|
|
5
|
+
* Outside the agent workspace so the agent can't accidentally corrupt it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFile, writeFile, mkdir, rename, unlink } from "node:fs/promises";
|
|
9
|
+
import { resolve, dirname } from "node:path";
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
import { ROUNDHOUSE_DIR } from "../config";
|
|
12
|
+
import { threadIdToDir } from "../util";
|
|
13
|
+
import type { ThreadMemoryState } from "./types";
|
|
14
|
+
|
|
15
|
+
const STATE_DIR = resolve(ROUNDHOUSE_DIR, "memory-state");
|
|
16
|
+
|
|
17
|
+
function stateFilePath(threadId: string): string {
|
|
18
|
+
return resolve(STATE_DIR, `${threadIdToDir(threadId)}.json`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Load per-thread memory state (returns empty state if none exists) */
|
|
22
|
+
export async function loadThreadMemoryState(threadId: string): Promise<ThreadMemoryState> {
|
|
23
|
+
try {
|
|
24
|
+
const raw = await readFile(stateFilePath(threadId), "utf8");
|
|
25
|
+
return JSON.parse(raw) as ThreadMemoryState;
|
|
26
|
+
} catch {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Save per-thread memory state (atomic write to prevent corruption) */
|
|
32
|
+
export async function saveThreadMemoryState(threadId: string, state: ThreadMemoryState): Promise<void> {
|
|
33
|
+
const path = stateFilePath(threadId);
|
|
34
|
+
await mkdir(dirname(path), { recursive: true });
|
|
35
|
+
const tmp = `${path}.tmp.${randomBytes(4).toString("hex")}`;
|
|
36
|
+
try {
|
|
37
|
+
await writeFile(tmp, JSON.stringify(state, null, 2) + "\n");
|
|
38
|
+
await rename(tmp, path);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
try { await unlink(tmp); } catch {}
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory/types.ts — Memory system types
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Memory operating mode */
|
|
6
|
+
export type MemoryMode = "full" | "complement" | "unknown";
|
|
7
|
+
|
|
8
|
+
/** Memory configuration (in gateway.config.json) */
|
|
9
|
+
export interface MemoryConfig {
|
|
10
|
+
/** Enable memory system (default: true) */
|
|
11
|
+
enabled?: boolean;
|
|
12
|
+
/** Root directory for memory files (default: agent cwd) */
|
|
13
|
+
rootDir?: string;
|
|
14
|
+
/** Main durable memory file (default: "MEMORY.md") */
|
|
15
|
+
mainFile?: string;
|
|
16
|
+
/** Daily notes directory (default: "daily") */
|
|
17
|
+
dailyDir?: string;
|
|
18
|
+
/** Injection settings */
|
|
19
|
+
inject?: {
|
|
20
|
+
/** Include today's daily note (default: true) */
|
|
21
|
+
includeToday?: boolean;
|
|
22
|
+
/** Number of recent days to include (default: 1 = yesterday) */
|
|
23
|
+
includeRecentDays?: number;
|
|
24
|
+
/** Max bytes to inject (default: 48000) */
|
|
25
|
+
maxBytes?: number;
|
|
26
|
+
};
|
|
27
|
+
/** Compaction settings (active in BOTH modes) */
|
|
28
|
+
compact?: {
|
|
29
|
+
/** Enable proactive compaction (default: true) */
|
|
30
|
+
enabled?: boolean;
|
|
31
|
+
/** Soft flush threshold: percent of context (default: 0.45) */
|
|
32
|
+
softPercent?: number;
|
|
33
|
+
/** Soft flush threshold: absolute tokens (default: 180000) */
|
|
34
|
+
softTokens?: number;
|
|
35
|
+
/** Hard compact threshold: percent (default: 0.50) */
|
|
36
|
+
hardPercent?: number;
|
|
37
|
+
/** Hard compact threshold: absolute tokens (default: 200000) */
|
|
38
|
+
hardTokens?: number;
|
|
39
|
+
/** Emergency: compact when remaining tokens < this (default: 32768) */
|
|
40
|
+
emergencyThresholdTokens?: number;
|
|
41
|
+
/** Min time between soft flushes in ms (default: 600000 = 10min) */
|
|
42
|
+
cooldownMs?: number;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Per-thread memory tracking state */
|
|
47
|
+
export interface ThreadMemoryState {
|
|
48
|
+
/** Hash of memory files when last injected into this thread */
|
|
49
|
+
lastInjectedDigest?: string;
|
|
50
|
+
/** Hash of memory files after last agent turn (may differ if agent wrote memory) */
|
|
51
|
+
lastKnownDigest?: string;
|
|
52
|
+
/** When memory was last injected */
|
|
53
|
+
lastInjectedAt?: string;
|
|
54
|
+
/** Local date when memory was last injected (detects day boundary) */
|
|
55
|
+
lastSeenLocalDate?: string;
|
|
56
|
+
/** Force re-injection on next turn */
|
|
57
|
+
forceInjectReason?: "new-session" | "after-compact" | "manual";
|
|
58
|
+
/** When last compaction happened */
|
|
59
|
+
lastCompactAt?: string;
|
|
60
|
+
/** Pending compaction level (from interrupted flush) */
|
|
61
|
+
pendingCompact?: "soft" | "hard" | "emergency";
|
|
62
|
+
/** When last soft flush happened (for cooldown) */
|
|
63
|
+
lastSoftFlushAt?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Resolved memory file set to inject */
|
|
67
|
+
export interface MemoryFileSet {
|
|
68
|
+
files: Array<{ label: string; path: string }>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Snapshot of memory file contents */
|
|
72
|
+
export interface MemorySnapshot {
|
|
73
|
+
entries: Array<{ label: string; content: string }>;
|
|
74
|
+
digest: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Context pressure classification */
|
|
78
|
+
export type PressureLevel = "none" | "soft" | "hard" | "emergency";
|
|
79
|
+
|
|
80
|
+
/** Result of preparing memory for a turn */
|
|
81
|
+
export interface PreparedTurn {
|
|
82
|
+
/** Message to send (may have memory prepended) */
|
|
83
|
+
message: import("../types").AgentMessage;
|
|
84
|
+
/** Digest before the turn (for finalize) */
|
|
85
|
+
beforeDigest: string | null;
|
|
86
|
+
/** Whether memory was injected */
|
|
87
|
+
injected: boolean;
|
|
88
|
+
/** Pending compact level from a previously interrupted flush */
|
|
89
|
+
pendingCompact?: "soft" | "hard" | "emergency";
|
|
90
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* notify/telegram.ts — Shared Telegram Bot API sender
|
|
3
|
+
*
|
|
4
|
+
* Used by gateway startup notifications, cron notifications, etc.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const DEFAULT_TIMEOUT = 15_000;
|
|
8
|
+
|
|
9
|
+
/** Send a text message via Telegram Bot API */
|
|
10
|
+
export async function sendTelegramMessage(
|
|
11
|
+
chatId: string | number,
|
|
12
|
+
text: string,
|
|
13
|
+
options?: { parseMode?: string; timeout?: number },
|
|
14
|
+
): Promise<boolean> {
|
|
15
|
+
const token = process.env.TELEGRAM_BOT_TOKEN;
|
|
16
|
+
if (!token) return false;
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const body: Record<string, unknown> = { chat_id: chatId, text };
|
|
20
|
+
if (options?.parseMode) body.parse_mode = options.parseMode;
|
|
21
|
+
|
|
22
|
+
const res = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: { "Content-Type": "application/json" },
|
|
25
|
+
body: JSON.stringify(body),
|
|
26
|
+
signal: AbortSignal.timeout(options?.timeout ?? DEFAULT_TIMEOUT),
|
|
27
|
+
});
|
|
28
|
+
if (!res.ok) {
|
|
29
|
+
const errBody = await res.text().catch(() => "");
|
|
30
|
+
console.warn(`[telegram] sendMessage to ${chatId} failed (${res.status}): ${errBody.slice(0, 200)}`);
|
|
31
|
+
}
|
|
32
|
+
return res.ok;
|
|
33
|
+
} catch (err) {
|
|
34
|
+
console.warn(`[telegram] sendMessage to ${chatId} failed:`, (err as Error).message);
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Send a message to multiple chat IDs */
|
|
40
|
+
export async function sendTelegramToMany(
|
|
41
|
+
chatIds: (string | number)[],
|
|
42
|
+
text: string,
|
|
43
|
+
options?: { parseMode?: string },
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
for (const chatId of chatIds) {
|
|
46
|
+
await sendTelegramMessage(chatId, text, options);
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -2,14 +2,73 @@
|
|
|
2
2
|
* types.ts — Core abstractions for roundhouse
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
// ── Attachments ──────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
/** A file attachment received from a chat platform and saved locally */
|
|
8
|
+
export interface MessageAttachment {
|
|
9
|
+
/** Stable attachment ID (e.g. "att_a1b2c3d4") */
|
|
10
|
+
id: string;
|
|
11
|
+
/** Attachment type from the chat platform */
|
|
12
|
+
mediaType: "audio" | "image" | "file" | "video";
|
|
13
|
+
/** Sanitized filename */
|
|
14
|
+
name: string;
|
|
15
|
+
/** Absolute local path where the file was saved */
|
|
16
|
+
localPath: string;
|
|
17
|
+
/** MIME type (from platform metadata or fallback) */
|
|
18
|
+
mime: string;
|
|
19
|
+
/** File size in bytes */
|
|
20
|
+
sizeBytes: number;
|
|
21
|
+
/** Whether this is user-provided (untrusted) content */
|
|
22
|
+
untrusted: true;
|
|
23
|
+
/** Transcript of audio content (populated by STT service) */
|
|
24
|
+
transcript?: import("./voice/types").AttachmentTranscript;
|
|
25
|
+
}
|
|
26
|
+
|
|
5
27
|
// ── Agent adapter ────────────────────────────────────
|
|
6
28
|
|
|
29
|
+
/** A user message with optional attachments */
|
|
30
|
+
export interface AgentMessage {
|
|
31
|
+
/** User's text (may be empty for attachment-only messages) */
|
|
32
|
+
text: string;
|
|
33
|
+
/** File attachments saved locally by the gateway */
|
|
34
|
+
attachments?: MessageAttachment[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Events yielded by the streaming prompt interface */
|
|
38
|
+
export type AgentStreamEvent =
|
|
39
|
+
| { type: "text_delta"; text: string }
|
|
40
|
+
| { type: "tool_start"; toolName: string; toolCallId: string }
|
|
41
|
+
| { type: "tool_end"; toolName: string; toolCallId: string; isError: boolean }
|
|
42
|
+
| { type: "turn_end" }
|
|
43
|
+
| { type: "draining" }
|
|
44
|
+
| { type: "drain_complete" }
|
|
45
|
+
| { type: "agent_end" }
|
|
46
|
+
| { type: "custom_message"; customType: string; content: string };
|
|
47
|
+
|
|
7
48
|
export interface AgentAdapter {
|
|
8
49
|
/** Unique agent name, e.g. "pi", "kiro" */
|
|
9
50
|
name: string;
|
|
10
51
|
|
|
11
52
|
/** Send a user message and return the full assistant response */
|
|
12
|
-
prompt(threadId: string,
|
|
53
|
+
prompt(threadId: string, message: AgentMessage): Promise<AgentResponse>;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Send a user message and stream back events in real time.
|
|
57
|
+
* Falls back to prompt() if not implemented.
|
|
58
|
+
*/
|
|
59
|
+
promptStream?(threadId: string, message: AgentMessage): AsyncIterable<AgentStreamEvent>;
|
|
60
|
+
|
|
61
|
+
/** Dispose the session for a thread and start fresh on next prompt */
|
|
62
|
+
restart?(threadId: string): Promise<void>;
|
|
63
|
+
|
|
64
|
+
/** Compact the session context for a thread */
|
|
65
|
+
compact?(threadId: string): Promise<{ tokensBefore: number; tokensAfter: number | null } | null>;
|
|
66
|
+
|
|
67
|
+
/** Abort the current agent run for a thread */
|
|
68
|
+
abort?(threadId: string): Promise<void>;
|
|
69
|
+
|
|
70
|
+
/** Return runtime info about the agent (model, version, etc.) */
|
|
71
|
+
getInfo?(threadId?: string): Record<string, unknown>;
|
|
13
72
|
|
|
14
73
|
/** Tear down all sessions */
|
|
15
74
|
dispose(): Promise<void>;
|
|
@@ -46,6 +105,10 @@ export interface GatewayConfig {
|
|
|
46
105
|
chat: {
|
|
47
106
|
botUsername: string;
|
|
48
107
|
allowedUsers?: string[];
|
|
108
|
+
/** Immutable Telegram user IDs (paired during setup) */
|
|
109
|
+
allowedUserIds?: number[];
|
|
110
|
+
/** Telegram chat IDs to notify on startup */
|
|
111
|
+
notifyChatIds?: number[];
|
|
49
112
|
adapters: {
|
|
50
113
|
telegram?: Record<string, unknown>;
|
|
51
114
|
slack?: Record<string, unknown>;
|
|
@@ -53,4 +116,8 @@ export interface GatewayConfig {
|
|
|
53
116
|
[key: string]: Record<string, unknown> | undefined;
|
|
54
117
|
};
|
|
55
118
|
};
|
|
119
|
+
voice?: {
|
|
120
|
+
stt?: import("./voice/types").SttConfig;
|
|
121
|
+
};
|
|
122
|
+
memory?: import("./memory/types").MemoryConfig;
|
|
56
123
|
}
|
package/src/util.ts
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
* util.ts — Pure utility functions for roundhouse
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Debug flag for per-event stream logging. Enabled via
|
|
9
|
+
* ROUNDHOUSE_DEBUG_STREAM=1 in the roundhouse env file. Evaluated once at
|
|
10
|
+
* module load so the hot path (subscription callbacks, event loops) is a
|
|
11
|
+
* single boolean check rather than an env read on every event.
|
|
12
|
+
*/
|
|
13
|
+
export const DEBUG_STREAM = process.env.ROUNDHOUSE_DEBUG_STREAM === "1";
|
|
14
|
+
|
|
5
15
|
/**
|
|
6
16
|
* Split a long message into chunks that fit within maxLen.
|
|
7
17
|
* Prefers splitting at newline boundaries.
|
|
@@ -36,10 +46,19 @@ export function splitMessage(text: string, maxLen: number): string[] {
|
|
|
36
46
|
*/
|
|
37
47
|
export function isAllowed(
|
|
38
48
|
message: { author?: { userName?: string; userId?: string; fullName?: string } },
|
|
39
|
-
allowedUsers: string[]
|
|
49
|
+
allowedUsers: string[],
|
|
50
|
+
allowedUserIds?: number[],
|
|
40
51
|
): boolean {
|
|
41
|
-
if (allowedUsers.length === 0) return true;
|
|
52
|
+
if (allowedUsers.length === 0 && (!allowedUserIds || allowedUserIds.length === 0)) return true;
|
|
42
53
|
const author = message.author ?? {};
|
|
54
|
+
|
|
55
|
+
// Check immutable numeric user ID first
|
|
56
|
+
if (allowedUserIds?.length && author.userId) {
|
|
57
|
+
const numericId = parseInt(author.userId, 10);
|
|
58
|
+
if (!isNaN(numericId) && allowedUserIds.includes(numericId)) return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Fall back to username check
|
|
43
62
|
const candidates = [author.userName, author.userId]
|
|
44
63
|
.filter(Boolean)
|
|
45
64
|
.map((s) => String(s).toLowerCase());
|
|
@@ -85,3 +104,10 @@ export function threadIdToDir(threadId: string): string {
|
|
|
85
104
|
.replace(/:/g, "_c") // encode colons
|
|
86
105
|
.replace(/[^a-zA-Z0-9_-]/g, "_x"); // encode everything else
|
|
87
106
|
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Generate a short random attachment ID (e.g. "att_a1b2c3d4").
|
|
110
|
+
*/
|
|
111
|
+
export function generateAttachmentId(): string {
|
|
112
|
+
return `att_${randomBytes(4).toString("hex")}`;
|
|
113
|
+
}
|