Skip to content
Open
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
11 changes: 10 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -955,8 +955,17 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => {
try {
await performAutoCapture(ctx, sessionID, directory);

// Prompts are shared across projects, but web-server ownership tracks
// whoever bound the port first and is never handed over while that
// process stays reachable. Gating learning on it stalls the queue
// whenever the owner stops seeing sessions, and disables learning
// outright when the web server is off. Any active instance may learn;
// performUserProfileLearning holds a cross-process lock internally.
await performUserProfileLearning(ctx, directory);

// Retention cleanup stays owner-only: it is storage-wide maintenance
// that has no reason to run once per active instance.
if (webServer?.isServerOwner()) {
await performUserProfileLearning(ctx, directory);
const { cleanupService } = await import("./services/cleanup-service.js");
if (await cleanupService.shouldRunCleanup()) await cleanupService.runCleanup();
}
Expand Down
54 changes: 47 additions & 7 deletions src/services/user-memory-learning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { userProfileManager } from "./user-profile/user-profile-manager.js";
import { sortProfileItems } from "../utils/profile.js";
import type { UserProfile, UserProfileData } from "./user-profile/types.js";
import { loadOpencodeProvider } from "./ai/opencode-provider-loader.js";
import { tryAcquireProfileLearningLock } from "./user-profile/learning-lock.js";

let isLearningRunning = false;

Expand Down Expand Up @@ -66,8 +67,24 @@ export async function performUserProfileLearning(
});
return;
}

// Set before the first await so a same-process re-entry bounces off the flag
// instead of queueing behind this run and running a second analysis.
//
// `isLearningRunning` only guards re-entry inside one process. Prompt selection
// is a plain SELECT and the batch is marked only after the LLM responds, so
// without cross-process exclusion two instances sharing this storage would
// analyze the same prompts and the slower writer would clobber the faster
// one's profile update. Contention skips this round; the next idle retries.
isLearningRunning = true;
let releaseLearningLock: (() => Promise<void> | void) | null = null;
try {
releaseLearningLock = await tryAcquireProfileLearningLock(directory);
if (!releaseLearningLock) {
log("user-profile-learning: skipped (another process holds the learning lock)");
return;
}

const count = await userPromptManager.countUnanalyzedForUserLearning();
const threshold = CONFIG.userProfileAnalysisInterval;

Expand Down Expand Up @@ -290,7 +307,17 @@ Rules:
log("user-profile-learning: aborted", { error: String(error) });
throw error;
} finally {
isLearningRunning = false;
// Release only when the lock was actually acquired (contention return,
// acquisition failure, and any throw before acquisition all leave it null).
// The flag resets in a nested finally so it stays raised while the release
// await is in flight — otherwise a same-process re-entry could slip in and
// find the cross-process lock already gone — and it still resets when the
// release itself rejects.
try {
await releaseLearningLock?.();
} finally {
isLearningRunning = false;
}
}
}

Expand Down Expand Up @@ -648,6 +675,14 @@ async function analyzeUserProfile(
let opencodeProviderError: unknown;
if (CONFIG.opencodeProvider && CONFIG.opencodeModel) {
log("user-profile-learning: trying opencode provider");
// The try/catch boundary is the provider only: LLM client construction,
// the structured-output call, and schema binding. Stored-profile parsing
// and merging happen AFTER a successful LLM response and outside this
// try — a cold-storage read/parse/merge failure must propagate to the
// caller (logged + rethrown there), never be mistaken for a provider
// fault that silently falls back to the external API and then
// re-reads/re-merges the same broken storage.
let rawData: UserProfileData | null = null;
try {
const { generateStructuredOutput } = await loadOpencodeProvider();
const { getOpenCodeClient } = await import("./ai/profile-llm-client.js");
Expand Down Expand Up @@ -694,8 +729,18 @@ Use the update_user_profile tool to save the ${existingProfile ? "updated" : "ne
wfCount: result.workflows?.length,
});

const rawData = result as unknown as UserProfileData;
rawData = result as unknown as UserProfileData;
} catch (e) {
opencodeProviderError = e;
log("user-profile-learning: opencode provider failed, falling back to external API", {
error: String(e),
});
}

// Stored-profile parse/merge runs only after a native success and is
// deliberately OUTSIDE the provider catch: storage faults here are not
// provider faults and must not trigger the external fallback.
if (rawData !== null) {
if (existingProfile) {
const existingData: UserProfileData = JSON.parse(existingProfile.profileData);
const merged = await userProfileManager.mergeProfileData(
Expand All @@ -707,11 +752,6 @@ Use the update_user_profile tool to save the ${existingProfile ? "updated" : "ne
return { raw: rawData, merged };
}
return { raw: rawData, merged: null };
} catch (e) {
opencodeProviderError = e;
log("user-profile-learning: opencode provider failed, falling back to external API", {
error: String(e),
});
}
}

Expand Down
Loading