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
2 changes: 1 addition & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export const SUMMARY_BRANCH_FACTOR = 3;
export const SUMMARY_NODE_CHAR_LIMIT = 260;

// Store schema
export const STORE_SCHEMA_VERSION = 1;
export const STORE_SCHEMA_VERSION = 2;

// Message retrieval limits
export const EXPAND_MESSAGE_LIMIT = 6;
Expand Down
136 changes: 136 additions & 0 deletions src/invoke-cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { spawn } from 'node:child_process';

export interface InvokeCLIOptions {
command: string;
args: string[];
stdin?: string;
timeoutMs?: number;
maxOutputChars?: number;
}

const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_MAX_OUTPUT_CHARS = 10_000;

function truncateOutput(value: string, maxChars: number): string {
return value.length <= maxChars ? value : value.slice(0, maxChars);
}

function formatCommand(command: string, args: string[]): string {
return [command, ...args].join(' ');
}

function getErrorContext(stderr: string): string {
const trimmed = stderr.trim();

return trimmed.length > 0 ? `\nstderr: ${trimmed}` : '';
}

export class CLISpawnError extends Error {
readonly command: string;
readonly cause: Error;

constructor(command: string, cause: Error) {
super(`Failed to spawn CLI command: ${command}${getErrorContext(cause.message)}`);
this.name = 'CLISpawnError';
this.command = command;
this.cause = cause;
}
}

export class CLITimeoutError extends Error {
readonly command: string;
readonly timeoutMs: number;
readonly stderr: string;

constructor(command: string, timeoutMs: number, stderr: string) {
super(`CLI command timed out after ${timeoutMs}ms: ${command}${getErrorContext(stderr)}`);
this.name = 'CLITimeoutError';
this.command = command;
this.timeoutMs = timeoutMs;
this.stderr = stderr;
}
}

export class CLIExitError extends Error {
readonly command: string;
readonly exitCode: number | null;
readonly stderr: string;

constructor(command: string, exitCode: number | null, stderr: string) {
super(
`CLI command exited with code ${exitCode ?? 'unknown'}: ${command}${getErrorContext(stderr)}`,
);
this.name = 'CLIExitError';
this.command = command;
this.exitCode = exitCode;
this.stderr = stderr;
}
}

export async function invokeCLI(options: InvokeCLIOptions): Promise<string> {
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const maxOutputChars = options.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
const formattedCommand = formatCommand(options.command, options.args);
const controller = new AbortController();
let timedOut = false;

const timeout = setTimeout(() => {
timedOut = true;
controller.abort();
}, timeoutMs);

try {
return await new Promise<string>((resolve, reject) => {
let stdout = '';
let stderr = '';

const child = spawn(options.command, options.args, {
stdio: 'pipe',
signal: controller.signal,
});

child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');

child.stdout.on('data', (chunk: string) => {
stdout = truncateOutput(stdout + chunk, maxOutputChars);
});

child.stderr.on('data', (chunk: string) => {
stderr += chunk;
});

child.once('error', (error) => {
if (timedOut || error.name === 'AbortError') {
reject(new CLITimeoutError(formattedCommand, timeoutMs, stderr));
return;
}

reject(new CLISpawnError(formattedCommand, error));
});

child.once('close', (code) => {
if (timedOut) {
reject(new CLITimeoutError(formattedCommand, timeoutMs, stderr));
return;
}

if (code !== 0) {
reject(new CLIExitError(formattedCommand, code, stderr));
return;
}

resolve(stdout);
});

if (options.stdin !== undefined) {
child.stdin.end(options.stdin);
return;
}

child.stdin.end();
});
} finally {
clearTimeout(timeout);
}
}
76 changes: 76 additions & 0 deletions src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ import type {
AutomaticRetrievalScopeBudgets,
AutomaticRetrievalStopOptions,
InteropOptions,
LlmCliOptions,
OpencodeLcmOptions,
PrivacyOptions,
RetentionPolicyOptions,
ScopeDefaults,
ScopeName,
ScopeProfile,
SummaryStrategyName,
SummaryV2Options,
} from './types.js';

const DEFAULT_INTEROP: InteropOptions = {
Expand Down Expand Up @@ -54,6 +57,35 @@ const DEFAULT_AUTOMATIC_RETRIEVAL: AutomaticRetrievalOptions = {
},
};

export const DEFAULT_SUMMARY_V2: SummaryV2Options = {
strategy: 'deterministic-v2',
maxChars: 260,
includeAllMessages: true,
perMessageBudget: 110,
};

/**
* Default LLM CLI backend: `opencode run` using the host's configured provider.
*
* Two supported invocation patterns:
* 1. `opencode run --pure -m <provider/model>` (default) — reuses host config, no extra setup.
* The LCM plugin is disabled via `--pure` to prevent recursive summarization.
* 2. Any CLI tool the user installs (llm, ollama, claude) — configured via options overrides.
*
* Users override via opencode config: `llmCli: { enabled: true, command: 'llm', args: [...], ... }`
*/
export const DEFAULT_LLM_CLI: LlmCliOptions = {
enabled: false,
command: 'opencode',
args: ['run', '--pure', '--format', 'default', '-m', '{{MODEL}}'],
model: 'anthropic/claude-haiku-4-5',
promptMode: 'arg',
timeoutMs: 30_000,
maxPromptChars: 8_000,
fallbackOnError: true,
asyncEnhancement: true,
};

export const DEFAULT_OPTIONS: OpencodeLcmOptions = {
interop: DEFAULT_INTEROP,
scopeDefaults: DEFAULT_SCOPE_DEFAULTS,
Expand All @@ -78,6 +110,8 @@ export const DEFAULT_OPTIONS: OpencodeLcmOptions = {
'zip-metadata',
],
previewBytePeek: 16,
summaryV2: DEFAULT_SUMMARY_V2,
llmCli: DEFAULT_LLM_CLI,
};

function asRecord(value: unknown): Record<string, unknown> | undefined {
Expand Down Expand Up @@ -238,6 +272,46 @@ function asAutomaticRetrievalStopOptions(
};
}

function asSummaryStrategy(value: unknown, fallback: SummaryStrategyName): SummaryStrategyName {
return value === 'deterministic-v1' || value === 'deterministic-v2' || value === 'llm-cli'
? value
: fallback;
}

function asSummaryV2Options(value: unknown, fallback: SummaryV2Options): SummaryV2Options {
const record = asRecord(value);
return {
strategy: asSummaryStrategy(record?.strategy, fallback.strategy),
maxChars: asNumber(record?.maxChars, fallback.maxChars),
includeAllMessages: asBoolean(record?.includeAllMessages, fallback.includeAllMessages),
perMessageBudget: asNumber(record?.perMessageBudget, fallback.perMessageBudget),
};
}

function asLlmCliOptions(value: unknown, fallback: LlmCliOptions): LlmCliOptions {
const record = asRecord(value);
return {
enabled: asBoolean(record?.enabled, fallback.enabled),
command:
typeof record?.command === 'string' && record.command.length > 0
? record.command
: fallback.command,
args: asStringArray(record?.args, fallback.args),
model:
typeof record?.model === 'string' && record.model.length > 0 ? record.model : fallback.model,
promptMode:
record?.promptMode === 'stdin'
? 'stdin'
: record?.promptMode === 'arg'
? 'arg'
: fallback.promptMode,
timeoutMs: asNonNegativeNumber(record?.timeoutMs, fallback.timeoutMs),
maxPromptChars: asNonNegativeNumber(record?.maxPromptChars, fallback.maxPromptChars),
fallbackOnError: asBoolean(record?.fallbackOnError, fallback.fallbackOnError),
asyncEnhancement: asBoolean(record?.asyncEnhancement, fallback.asyncEnhancement),
};
}

export function resolveOptions(raw: unknown): OpencodeLcmOptions {
const options = asRecord(raw);
const interop = asRecord(options?.interop);
Expand Down Expand Up @@ -293,5 +367,7 @@ export function resolveOptions(raw: unknown): OpencodeLcmOptions {
DEFAULT_OPTIONS.binaryPreviewProviders,
),
previewBytePeek: asNumber(options?.previewBytePeek, DEFAULT_OPTIONS.previewBytePeek),
summaryV2: asSummaryV2Options(options?.summaryV2, DEFAULT_SUMMARY_V2),
llmCli: asLlmCliOptions(options?.llmCli, DEFAULT_LLM_CLI),
};
}
11 changes: 9 additions & 2 deletions src/store-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import path from 'node:path';

import { withTransaction } from './sql-utils.js';
import type { SqlDatabaseLike } from './store-types.js';
import type { SummaryStrategyName } from './types.js';
import { resolveWorkspacePath } from './workspace-path.js';
import { normalizeWorktreeKey } from './worktree-key.js';

Expand Down Expand Up @@ -50,6 +51,7 @@ export type SummaryNodeRow = {
end_index: number;
message_ids_json: string;
summary_text: string;
strategy: SummaryStrategyName;
created_at: number;
};

Expand Down Expand Up @@ -254,8 +256,8 @@ export async function importStoreSnapshot(
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
);
const insertNode = db.prepare(
`INSERT OR REPLACE INTO summary_nodes (node_id, session_id, level, node_kind, start_index, end_index, message_ids_json, summary_text, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT OR REPLACE INTO summary_nodes (node_id, session_id, level, node_kind, start_index, end_index, message_ids_json, summary_text, strategy, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
);
const insertEdge = db.prepare(
`INSERT OR REPLACE INTO summary_edges (session_id, parent_id, child_id, child_position) VALUES (?, ?, ?, ?)`,
Expand Down Expand Up @@ -315,6 +317,7 @@ export async function importStoreSnapshot(
row.end_index,
row.message_ids_json,
row.summary_text,
row.strategy,
row.created_at,
);
}
Expand Down Expand Up @@ -464,6 +467,10 @@ function parseSummaryNodeRow(value: unknown): SummaryNodeRow {
end_index: expectNumber(row.end_index, 'summary_nodes[].end_index'),
message_ids_json: expectString(row.message_ids_json, 'summary_nodes[].message_ids_json'),
summary_text: expectString(row.summary_text, 'summary_nodes[].summary_text'),
strategy: expectString(
row.strategy ?? 'deterministic-v1',
'summary_nodes[].strategy',
) as SummaryStrategyName,
created_at: expectNumber(row.created_at, 'summary_nodes[].created_at'),
};
}
Expand Down
Loading