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
37 changes: 37 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ interface OpenCodeMemConfig {
autoCaptureMaxIterations?: number;
autoCaptureIterationTimeout?: number;
autoCaptureMaxRetries?: number;
autoCaptureMaxContextBytes?: number;
autoCaptureLanguage?: string;
memoryProvider?: "openai-chat" | "openai-responses" | "anthropic" | "minimax";
memoryModel?: string;
Expand Down Expand Up @@ -81,6 +82,8 @@ interface OpenCodeMemConfig {
userProfileCentroidDriftThreshold?: number;
userProfileEmbeddingMinDescriptionLength?: number;
userProfileMinEvidenceForRetention?: number;
userProfileAutoCleanupEnabled?: boolean;
userProfileAutoCleanupInterval?: number;
userProfileValidationEnabled?: boolean;
showAutoCaptureToasts?: boolean;
showUserProfileToasts?: boolean;
Expand Down Expand Up @@ -152,6 +155,7 @@ const DEFAULTS: Required<
autoCaptureMaxIterations: 5,
autoCaptureIterationTimeout: 30000,
autoCaptureMaxRetries: 3,
autoCaptureMaxContextBytes: 131072,
aiSessionRetentionDays: 7,
webServerEnabled: true,
webServerPort: 4747,
Expand Down Expand Up @@ -179,6 +183,8 @@ const DEFAULTS: Required<
userProfileCentroidDriftThreshold: 0.65,
userProfileEmbeddingMinDescriptionLength: 5,
userProfileMinEvidenceForRetention: 3,
userProfileAutoCleanupEnabled: true,
userProfileAutoCleanupInterval: 100,
userProfileValidationEnabled: false,
showAutoCaptureToasts: true,
showUserProfileToasts: true,
Expand Down Expand Up @@ -410,6 +416,11 @@ const CONFIG_TEMPLATE = `{

// Maximum number of times to retry capturing a prompt if it fails (due to network, API errors, etc.)
"autoCaptureMaxRetries": 3,

// Maximum UTF-8 bytes for the auto-capture markdown context sent to the summary model.
// Prevents HTTP 400 context overflows on models with ~131K token windows (e.g. Groq Llama).
// Rough guide: tokens ≈ bytes / 4 for mixed code/prose.
"autoCaptureMaxContextBytes": 131072,

// Days to keep AI session history before cleanup
"aiSessionRetentionDays": 7,
Expand Down Expand Up @@ -488,6 +499,11 @@ const CONFIG_TEMPLATE = `{
// Items confirmed fewer times are more likely to be pruned when confidence decays
"userProfileMinEvidenceForRetention": 3,

// Periodically merge duplicate or irrelevant profile items with the configured AI provider
"userProfileAutoCleanupEnabled": true,
// Number of analyzed user prompts between automatic AI cleanup runs
"userProfileAutoCleanupInterval": 100,

// Enable LLM validation of existing preferences against recent behavior.
// When enabled, each analysis round checks if top-5 preferences still match recent prompts.
// Experimental — disabled by default.
Expand Down Expand Up @@ -568,11 +584,23 @@ function getEmbeddingDimensions(model: string): number {
return dimensionMap[model] || 768;
}

export function normalizeAutoCaptureMaxContextBytes(value: number): number {
if (!Number.isInteger(value) || value < 16384 || value > 16 * 1024 * 1024) {
throw new Error(`Invalid autoCaptureMaxContextBytes config: ${value}`);
}
return value;
}

function buildConfig(fileConfig: OpenCodeMemConfig) {
const memoryApiKey = resolveSecretValue(fileConfig.memoryApiKey);
const embeddingDimensions =
fileConfig.embeddingDimensions ??
getEmbeddingDimensions(fileConfig.embeddingModel ?? DEFAULTS.embeddingModel);
const autoCaptureMaxContextBytes = normalizeAutoCaptureMaxContextBytes(
fileConfig.autoCaptureMaxContextBytes ?? DEFAULTS.autoCaptureMaxContextBytes
);
const userProfileAutoCleanupInterval =
fileConfig.userProfileAutoCleanupInterval ?? DEFAULTS.userProfileAutoCleanupInterval;

if (
!Number.isInteger(embeddingDimensions) ||
Expand All @@ -581,6 +609,11 @@ function buildConfig(fileConfig: OpenCodeMemConfig) {
) {
throw new Error(`Invalid embeddingDimensions config: ${embeddingDimensions}`);
}
if (!Number.isInteger(userProfileAutoCleanupInterval) || userProfileAutoCleanupInterval <= 0) {
throw new Error(
`Invalid userProfileAutoCleanupInterval config: ${userProfileAutoCleanupInterval}`
);
}

return {
storagePath: expandPath(fileConfig.storagePath ?? DEFAULTS.storagePath),
Expand All @@ -605,6 +638,7 @@ function buildConfig(fileConfig: OpenCodeMemConfig) {
autoCaptureIterationTimeout:
fileConfig.autoCaptureIterationTimeout ?? DEFAULTS.autoCaptureIterationTimeout,
autoCaptureMaxRetries: fileConfig.autoCaptureMaxRetries ?? DEFAULTS.autoCaptureMaxRetries,
autoCaptureMaxContextBytes,
autoCaptureLanguage: fileConfig.autoCaptureLanguage,
memoryProvider: (fileConfig.memoryProvider ?? "openai-chat") as
"openai-chat" | "openai-responses" | "anthropic" | "minimax",
Expand Down Expand Up @@ -677,6 +711,9 @@ function buildConfig(fileConfig: OpenCodeMemConfig) {
DEFAULTS.userProfileEmbeddingMinDescriptionLength,
userProfileMinEvidenceForRetention:
fileConfig.userProfileMinEvidenceForRetention ?? DEFAULTS.userProfileMinEvidenceForRetention,
userProfileAutoCleanupEnabled:
fileConfig.userProfileAutoCleanupEnabled ?? DEFAULTS.userProfileAutoCleanupEnabled,
userProfileAutoCleanupInterval,
userProfileValidationEnabled:
fileConfig.userProfileValidationEnabled ?? DEFAULTS.userProfileValidationEnabled,
userProfileStaleDays: fileConfig.userProfileStaleDays ?? DEFAULTS.userProfileStaleDays,
Expand Down
98 changes: 98 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,92 @@ function extractSessionTitle(response: unknown): string | undefined {
return obj.data?.title ?? obj.title;
}

function unwrapSdkData<T>(response: unknown): T | undefined {
if (!response || typeof response !== "object") return undefined;
const obj = response as { data?: T };
return (obj.data ?? response) as T;
}

/**
* Resolve the session's active agent so compaction memory injection does not
* reset OpenCode to the stock "general-purpose" fallback (issue #236).
*
* Preference order:
* 1. session.get().agent (v2 hosts)
* 2. Latest non-compaction user message agent
* 3. Latest non-compaction / non-summary assistant mode (v1) or agent (v2)
*/
export async function resolveSessionAgent(
client: unknown,
sessionID: string
): Promise<string | undefined> {
const sessionClient = (
client as {
session?: {
get?: (args: unknown) => Promise<unknown>;
messages?: (args: unknown) => Promise<unknown>;
};
}
)?.session;

if (typeof sessionClient?.get === "function") {
try {
const session = unwrapSdkData<{ agent?: string }>(
await sessionClient.get({ path: { id: sessionID } })
);
if (typeof session?.agent === "string" && session.agent.trim()) {
return session.agent.trim();
}
} catch (error) {
log("resolveSessionAgent: session.get failed", { sessionID, error: String(error) });
}
}

if (typeof sessionClient?.messages !== "function") {
return undefined;
}

try {
const messages = unwrapSdkData<
Array<{
info?: {
role?: string;
agent?: string;
mode?: string;
summary?: boolean;
};
}>
>(await sessionClient.messages({ path: { id: sessionID } }));

if (!Array.isArray(messages)) return undefined;

for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i]?.info;
if (!info) continue;

if (info.role === "user") {
if (typeof info.agent === "string" && info.agent.trim()) {
return info.agent.trim();
}
continue;
}

if (info.role === "assistant") {
if (info.summary === true || info.mode === "compaction") continue;
const agent =
(typeof info.agent === "string" && info.agent.trim()) ||
(typeof info.mode === "string" && info.mode.trim()) ||
undefined;
if (agent) return agent;
}
}
} catch (error) {
log("resolveSessionAgent: session.messages failed", { sessionID, error: String(error) });
}

return undefined;
}

async function isInternalCaptureSession(client: unknown, sessionID: string): Promise<boolean> {
// Fast path: sessions we created ourselves (survives brief post-delete window).
if (isTrackedInternalCaptureSession(sessionID)) {
Expand Down Expand Up @@ -873,12 +959,23 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => {
}

const memoryContext = formatMemoriesForCompaction(memoriesResult.results);
const agent = await resolveSessionAgent(ctx.client, sessionID);
if (!agent) {
log(
"Compaction: skipped memory injection because session agent could not be resolved",
{
sessionID,
}
);
return;
}

await ctx.client.session.prompt({
path: { id: sessionID },
body: {
parts: [{ id: `prt-compaction-${Date.now()}`, type: "text", text: memoryContext }],
noReply: true,
agent,
},
});

Expand All @@ -898,6 +995,7 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => {
log("Compaction memory injected", {
sessionID,
count: memoriesResult.results.length,
agent: agent ?? null,
});
} catch (error) {
log("Compaction handler error", { error: String(error) });
Expand Down
9 changes: 7 additions & 2 deletions src/services/ai/validators/user-profile-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,13 @@ export class UserProfileValidator {
if (!pref.description || typeof pref.description !== "string") {
errors.push(`preferences[${i}].description is missing or invalid`);
}
if (typeof pref.confidence !== "number") {
errors.push(`preferences[${i}].confidence is missing or invalid`);
if (
typeof pref.confidence !== "number" ||
!Number.isFinite(pref.confidence) ||
pref.confidence < 0 ||
pref.confidence > 1
) {
errors.push(`preferences[${i}].confidence must be a finite number between 0 and 1`);
}
if (!Array.isArray(pref.evidence)) {
errors.push(`preferences[${i}].evidence must be an array`);
Expand Down
Loading