Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.85",
"@highflame/sdk": "file:/Workspace/highflame-sdk/javascript",
"@xenova/transformers": "^2.17.2",
"commander": "^13.0.0",
"grammy": "^1.35.0",
"zod": "^4.0.0"
Expand Down
7 changes: 7 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ program
auth: config.auth,
oauth: config.oauth,
agentIdentity: config.agentIdentity,
memory: config.memory?.enabled
? {
dbPath: config.memory.dbPath,
model: config.memory.model,
modelCacheDir: config.memory.modelCacheDir,
}
: undefined,
});

// ── Register frontends ────────────────────────────────────────
Expand Down
28 changes: 28 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ export interface CodeoidConfig {
accountId: string;
projectId: string;
};
/** Memory / recall config — when enabled, stores episodes and exposes recall() to Claude. */
memory?: {
enabled: boolean;
dbPath: string;
model?: string;
modelCacheDir?: string;
};
}

const CONFIG_DIR = join(homedir(), ".codeoid");
Expand All @@ -53,6 +60,21 @@ export function loadConfig(): CodeoidConfig {
const accountId = process.env["ZEROID_ACCOUNT_ID"] ?? config.agentIdentity?.accountId ?? "personal";
const projectId = process.env["ZEROID_PROJECT_ID"] ?? config.agentIdentity?.projectId ?? "dev";

const memoryEnabled =
process.env["CODEOID_MEMORY"] !== undefined
? process.env["CODEOID_MEMORY"] === "1" ||
process.env["CODEOID_MEMORY"]?.toLowerCase() === "true"
: (config.memory?.enabled ?? true);
const memoryDbPath =
process.env["CODEOID_MEMORY_DB_PATH"] ??
config.memory?.dbPath ??
join(CONFIG_DIR, "memory.db");
const memoryModel = process.env["CODEOID_MEMORY_MODEL"] ?? config.memory?.model;
const memoryCacheDir =
process.env["CODEOID_MEMORY_CACHE_DIR"] ??
config.memory?.modelCacheDir ??
join(CONFIG_DIR, "models");

return {
daemonUrl: process.env["CODEOID_DAEMON_URL"] ?? config.daemonUrl ?? "ws://127.0.0.1:7400",
dbPath: process.env["CODEOID_DB_PATH"] ?? config.dbPath ?? join(CONFIG_DIR, "codeoid.db"),
Expand Down Expand Up @@ -83,6 +105,12 @@ export function loadConfig(): CodeoidConfig {
agentIdentity: (process.env["ZEROID_ACCOUNT_ID"] || config.agentIdentity)
? { accountId, projectId }
: undefined,
memory: {
enabled: memoryEnabled,
dbPath: memoryDbPath,
model: memoryModel,
modelCacheDir: memoryCacheDir,
},
};
}

Expand Down
279 changes: 279 additions & 0 deletions src/daemon/memory/chunker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
/**
* EpisodeChunker — converts a stream of session messages into episodes.
*
* Chunking strategy: one episode per natural boundary.
* - Each completed tool call (input + result + surrounding reasoning) → tool_call episode
* - A user message followed by assistant text with NO tool use → user_turn + assistant_turn merged into one episode
* - An error event → error episode
*
* The chunker is stateful per-session: it accumulates pending context (the last
* user prompt, any assistant text leading into a tool call) and emits episodes
* as boundaries close. Callers push messages via `onMessage`; the chunker
* invokes the `emit` callback whenever an episode is ready.
*/

import type { SessionMessage } from "../../protocol/types.js";
import type { Episode } from "./types.js";

export interface ChunkerContext {
workspaceId: string;
sessionId: string;
createdBy: string;
}

/** Paths seen in a tool input — used for path-overlap ranking. */
export function extractFilePaths(
toolName: string | undefined,
input: unknown,
): string[] {
if (!input || typeof input !== "object") return [];
const rec = input as Record<string, unknown>;
const paths: string[] = [];

const candidates = ["file_path", "path", "filePath", "filename"];
for (const key of candidates) {
const v = rec[key];
if (typeof v === "string" && v.length > 0) paths.push(v);
}

const arrCandidates = ["paths", "files"];
for (const key of arrCandidates) {
const v = rec[key];
if (Array.isArray(v)) {
for (const item of v) {
if (typeof item === "string") paths.push(item);
}
}
}

// Grep/Glob patterns aren't literal file paths but are still routing signals.
if (toolName === "Grep" || toolName === "Glob") {
const pat = rec["pattern"] ?? rec["glob"];
if (typeof pat === "string") paths.push(pat);
}

return [...new Set(paths)];
}

function estimateTokens(text: string): number {
// Conservative ~4 chars/token heuristic. Good enough for budget tracking.
return Math.ceil(text.length / 4);
}

export class EpisodeChunker {
#ctx: ChunkerContext;
#onEpisode: (ep: Omit<Episode, "id">) => void;

/** The most recent user prompt, kept as context for the next tool_call or assistant_turn. */
#pendingUserPrompt: string | null = null;
/** Assistant reasoning accumulated between tool calls. */
#pendingAssistantText: string[] = [];
/** Open tool_call episode awaiting completion. */
#openToolCall: {
messageId: string;
toolName: string;
input: unknown;
filePaths: string[];
startedAt: number;
userPrompt: string | null;
priorAssistantText: string;
} | null = null;

constructor(ctx: ChunkerContext, onEpisode: (ep: Omit<Episode, "id">) => void) {
this.#ctx = ctx;
this.#onEpisode = onEpisode;
}

/** Push a session message. May synchronously invoke the emit callback. */
onMessage(msg: SessionMessage): void {
switch (msg.role) {
case "user":
this.#closeStandaloneAssistantTurnIfAny();
this.#pendingUserPrompt = msg.content;
this.#pendingAssistantText = [];
break;

case "assistant":
if (this.#openToolCall) {
// Assistant text between tool calls — treat as follow-up reasoning for next tool call.
this.#pendingAssistantText.push(msg.content);
} else {
// Plain assistant turn with no tool use — emit user+assistant combined.
this.#emitTurnEpisode(msg.content, msg.timestamp, msg.identity.sub);
this.#pendingUserPrompt = null;
this.#pendingAssistantText = [];
}
break;

case "tool_call": {
if (!msg.tool) break;
const phase = msg.tool.state.phase;
if (phase === "executing" || phase === "waiting_confirmation") {
// Close any prior unfinished tool_call before opening a new one.
if (this.#openToolCall) {
this.#emitOpenToolCall("unfinished", msg.timestamp, msg.identity.sub);
}
const input =
"input" in msg.tool.state ? (msg.tool.state.input as unknown) : {};
this.#openToolCall = {
messageId: msg.messageId,
toolName: msg.tool.name,
input,
filePaths: extractFilePaths(msg.tool.name, input),
startedAt: Date.parse(msg.timestamp),
userPrompt: this.#pendingUserPrompt,
priorAssistantText: this.#pendingAssistantText.join("\n\n"),
};
this.#pendingAssistantText = [];
} else if (phase === "completed" || phase === "cancelled") {
if (
this.#openToolCall &&
this.#openToolCall.messageId === msg.messageId
) {
const output =
phase === "completed" && "output" in msg.tool.state
? (msg.tool.state.output as string | undefined)
: undefined;
this.#emitOpenToolCall(output ?? "", msg.timestamp, msg.identity.sub);
}
}
break;
}

case "tool_result": {
// If the SDK ever surfaces explicit tool_result messages, treat as completion.
if (this.#openToolCall) {
this.#emitOpenToolCall(msg.content, msg.timestamp, msg.identity.sub);
}
break;
}

case "system":
case "info": {
const isError = msg.role === "system" && /error|failed/i.test(msg.content);
if (isError) {
this.#emitErrorEpisode(msg.content, msg.timestamp, msg.identity.sub);
}
break;
}

case "thinking":
// Thinking isn't persisted as an episode — it's transient UX.
break;
}
}

/** Call when the turn is done (SDK result message) to flush any standalone assistant turn. */
onTurnEnd(): void {
this.#closeStandaloneAssistantTurnIfAny();
}

// ── Episode emission ──────────────────────────────────────────────────

#emitOpenToolCall(output: string, timestamp: string, createdBy: string): void {
const tc = this.#openToolCall;
if (!tc) return;
this.#openToolCall = null;

const parts: string[] = [];
if (tc.userPrompt) parts.push(`# User intent\n${tc.userPrompt}`);
if (tc.priorAssistantText) parts.push(`# Reasoning\n${tc.priorAssistantText}`);
parts.push(`# Tool: ${tc.toolName}`);
parts.push(`## Input\n${safeStringify(tc.input)}`);
if (output) parts.push(`## Result\n${output}`);

const content = parts.join("\n\n");
const summary = `${tc.toolName}(${summarizeInput(tc.input)})`;

this.#onEpisode({
workspaceId: this.#ctx.workspaceId,
sessionId: this.#ctx.sessionId,
kind: "tool_call",
toolName: tc.toolName,
summary,
content,
filePaths: tc.filePaths,
tokenEstimate: estimateTokens(content),
createdAt: Date.parse(timestamp) || Date.now(),
createdBy,
});

// Leave pendingUserPrompt in place — later tool calls in the same turn share it.
}

#emitTurnEpisode(assistantText: string, timestamp: string, createdBy: string): void {
const userPrompt = this.#pendingUserPrompt;
if (!userPrompt && !assistantText) return;

const content = [
userPrompt ? `# User\n${userPrompt}` : "",
assistantText ? `# Assistant\n${assistantText}` : "",
]
.filter(Boolean)
.join("\n\n");

const summary = userPrompt
? truncate(userPrompt.replace(/\s+/g, " "), 120)
: truncate(assistantText.replace(/\s+/g, " "), 120);

this.#onEpisode({
workspaceId: this.#ctx.workspaceId,
sessionId: this.#ctx.sessionId,
kind: userPrompt ? "user_turn" : "assistant_turn",
summary,
content,
filePaths: [],
tokenEstimate: estimateTokens(content),
createdAt: Date.parse(timestamp) || Date.now(),
createdBy,
});
}

#emitErrorEpisode(text: string, timestamp: string, createdBy: string): void {
this.#onEpisode({
workspaceId: this.#ctx.workspaceId,
sessionId: this.#ctx.sessionId,
kind: "error",
summary: truncate(text, 120),
content: text,
filePaths: [],
tokenEstimate: estimateTokens(text),
createdAt: Date.parse(timestamp) || Date.now(),
createdBy,
});
}

#closeStandaloneAssistantTurnIfAny(): void {
// If a user prompt is pending with no tool calls or assistant reply,
// it stays pending — we only emit once the assistant responds or a new turn starts.
// This method is a hook for future behaviors (timeouts, etc.); currently a no-op.
}
}

// ── Helpers ─────────────────────────────────────────────────────────────

function safeStringify(v: unknown): string {
try {
return JSON.stringify(v, null, 2);
} catch {
return String(v);
}
}

function summarizeInput(v: unknown): string {
if (!v || typeof v !== "object") return "";
const rec = v as Record<string, unknown>;
const keys = Object.keys(rec);
if (keys.length === 0) return "";
// Prefer path-like fields in the summary.
for (const key of ["file_path", "path", "pattern", "command"]) {
const val = rec[key];
if (typeof val === "string") return truncate(val, 60);
}
return keys.slice(0, 3).join(", ");
}

function truncate(s: string, n: number): string {
if (s.length <= n) return s;
return `${s.slice(0, n - 1)}…`;
}
Loading