Skip to content
Closed
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
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,58 @@ and age restrictions. A successful search with no eligible matches stays empty i
trigger failure fallback. If recent fallback also fails, recall remains empty and
nonfatal. Missing-query fallback and per-turn refresh behavior are unchanged.

### File-aware recall (experimental, V2 only)

File-aware recall is enabled by default after successful native OpenCode V2
`read`, `edit`, and `write` tools when using the database backend. Set
`chatMessage.fileAwareRecall: false` to disable it in the config file; the web
dashboard is not required. Only boolean `false` disables it; omitted or
non-boolean values use the enabled default when no valid inherited setting exists.
A global setting remains in effect when a project changes unrelated `chatMessage`
options; an explicit boolean project `fileAwareRecall` value overrides it. Ordinary
prompt recall stays enabled and V1 behavior is unchanged.
Managed workspace drivers (which may operate on remote files) are excluded;
this first pass supports the host's ordinary local filesystem only.

New auto-captured memories can carry up to 16 validated file paths from their
request's completed tools, restricted to paths explicitly named in the sanitized
saved summary. Observation alone does not attach the memory to a file.
The same summary check applies when retrieving older file associations, without
rewriting stored memories. Read-only investigations can qualify; editing a file is
neither required nor sufficient. When a later authored turn encounters the same file,
the next model context can select one associated memory absent from ordinary recall within the existing
`maxMemories` count. Its `selection="file"` label has no numerical score. An
association means the file was observed and named in that earlier lesson. It does
not prove the summary is correct, current, or applicable; current source code
remains authoritative. Creation date and memory ID remain visible. Summaries that
omit paths or use only basenames cannot supply this extra recall signal. Exact
relative references (optionally prefixed with `./`) are recognized in ordinary
prose and code quotes; root filenames without an extension require quotes. Absolute
paths and other ambiguous spellings are not inferred.

Matches require the same project and canonical workspace directory, even with
`recallScope: "all-projects"`. Separate worktrees do not share file matches.
Paths are exact and case-preserving; similarly named files are not fuzzy matched.
Outside-root, private/redacted, missing, directory, and symlink-alias paths are
ignored. No source file contents are read by this feature. Renames are not tracked.
Shell commands, patches, globs, external tools, and IDE active files are not signals.

The tool-completion hook only records bounded candidate paths; filesystem metadata
checks and memory lookup happen at the next model-context boundary. Repeated paths
do not trigger another refresh, and each authored turn allows at most four file
refreshes over at most 16 paths. The existing context block is replaced, not appended.
New prompts and compaction clear the file signals. Age, current-session and deletion
filters still apply; rewritten memory content invalidates its old file association.
Files with no eligible association leave the existing context unchanged without
rerunning prompt recall or rebuilding the profile. A positive file match refreshes
ordinary recall before merging the single file memory into the shared count budget.

This first pass does not backfill old memories or reconstruct file metadata from
saved-evidence recovery after a host session disappears. File lookup uses exact
metadata filtering within current-project database shards, without a separate file
index. Large-store performance and usefulness still need real-workload evaluation.
This is not source-change detection or a freshness guarantee.

### Update-on-capture (optional)

Opt-in path that keeps memories current as facts change. When `updateOnCaptureEnabled` is
Expand Down
8 changes: 6 additions & 2 deletions github-site/docs-map.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"nl": ".github/i18n/README.nl.md"
},
"untranslated": {
"Memory credential redaction": ["de", "zh", "ar", "tr", "nl"]
"Memory credential redaction": ["de", "zh", "ar", "tr", "nl"],
"File-aware recall (experimental, V2 only)": ["de", "zh", "ar", "tr", "nl"]
},
"dropLines": ["^\\[!\\[", "^!\\[", "README\\.[a-z]{2}\\.md\\)"],
"configPages": ["configure/"],
Expand Down Expand Up @@ -89,7 +90,10 @@
{
"slug": "configure/chat-message",
"group": "Configure",
"sections": ["Chat message capture and injection (`chatMessage`)"]
"sections": [
"Chat message capture and injection (`chatMessage`)",
"File-aware recall (experimental, V2 only)"
]
},
{
"slug": "configure/update-on-capture",
Expand Down
3 changes: 3 additions & 0 deletions opencode-mem.example.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@
"recall": "relevant",
// "default" follows memory.defaultScope; or "project" / "all-projects"
"recallScope": "default",
// V2 native-file recall, database backend only (enabled by default).
// Set false to disable; omitted or non-boolean values use the default.
// "fileAwareRecall": false,
// Optional override of top-level similarityThreshold for relevant recall:
// "similarityThreshold": 0.6,
// Skip prompt text injected by the host or by other OpenCode plugins
Expand Down
19 changes: 19 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,8 @@ interface OpenCodeMemConfig {
* Scope for auto-recall search/list. "default" follows memory.defaultScope.
*/
recallScope?: "project" | "all-projects" | "default";
/** V2 exact-file recall after completed native file tools. Enabled unless explicitly false. */
fileAwareRecall?: boolean;
/** Optional override of top-level similarityThreshold for relevant recall. */
similarityThreshold?: number;
filterInjectedPrompts?: boolean;
Expand Down Expand Up @@ -404,6 +406,7 @@ const DEFAULTS: Required<
injectOn: "first",
recall: "relevant",
recallScope: "default",
fileAwareRecall: true,
similarityThreshold: undefined,
filterInjectedPrompts: true,
injectionMarkers: [...getDefaultInjectionMarkers()],
Expand Down Expand Up @@ -912,6 +915,9 @@ const CONFIG_TEMPLATE = `{
"recall": "relevant",
// Scope for auto-recall. "default" follows memory.defaultScope above.
// "recallScope": "default",
// V2 file-aware recall (database backend only) is enabled by default.
// Set false to disable; omitted or non-boolean values use the default.
// "fileAwareRecall": false,
// Optional similarity floor for relevant recall (else similarityThreshold).
// "similarityThreshold": 0.6,

Expand Down Expand Up @@ -1477,6 +1483,7 @@ function buildConfig(fileConfig: OpenCodeMemConfig) {
"first" | "always",
recall: normalizeChatMessageRecall(fileConfig.chatMessage?.recall),
recallScope: normalizeChatMessageRecallScope(fileConfig.chatMessage?.recallScope),
fileAwareRecall: fileConfig.chatMessage?.fileAwareRecall !== false,
similarityThreshold:
typeof fileConfig.chatMessage?.similarityThreshold === "number" &&
Number.isFinite(fileConfig.chatMessage.similarityThreshold)
Expand Down Expand Up @@ -1588,6 +1595,18 @@ export function initConfig(directory: string): void {
delete projectOverrides.autoCleanupEnabled;
delete projectOverrides.autoCleanupRetentionDays;
const merged: OpenCodeMemConfig = { ...globalConfig, ...projectOverrides };
// Preserve this opt-out when a project overrides unrelated chatMessage
// settings. A valid boolean project value still wins; other fields keep their
// established shallow-merge behavior.
if (
typeof projectConfig.chatMessage?.fileAwareRecall !== "boolean" &&
typeof globalConfig.chatMessage?.fileAwareRecall === "boolean"
) {
merged.chatMessage = {
...merged.chatMessage,
fileAwareRecall: globalConfig.chatMessage.fileAwareRecall,
};
}
CONFIG = buildConfig(merged);
}

Expand Down
103 changes: 81 additions & 22 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@ import { tool } from "@opencode-ai/plugin";

import { memoryClient } from "./services/client.js";
import { formatContextForPrompt } from "./services/context.js";
import { shouldRefreshRecallOnPrompt } from "./services/memory-recall.js";
import {
mergeFileRecall,
shouldRefreshRecallOnPrompt,
type RecallMemory,
} from "./services/memory-recall.js";
import {
createObservedFileContext,
supportsLocalFileObservations,
} from "./services/observed-files.js";
import { buildMemoryProvenanceSuffix } from "./services/memory-provenance.js";
import { getTags } from "./services/tags.js";
import { stripPrivateContent, isFullyPrivate, sanitizeMemoryContent } from "./services/privacy.js";
Expand Down Expand Up @@ -724,11 +732,14 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => {
return true;
};

const loadMemoryContext = async (sessionID: string): Promise<string> => {
const { loadRecallMemoriesForSession } = await import("./services/memory-recall.js");
const queryText = await userPromptManager.getLatestPromptTextForSession(sessionID);
const memories = await loadRecallMemoriesForSession(tags.project.tag, sessionID, queryText);

const fileRecallEnabled = () =>
isConfigured() &&
CONFIG.chatMessage.enabled &&
CONFIG.chatMessage.fileAwareRecall === true &&
supportsLocalFileObservations(ctx) &&
CONFIG.memoryBackend === "database";
let observedFileContext: ReturnType<typeof createObservedFileContext> | undefined;
const formatMemoryContext = (memories: RecallMemory[]): Promise<string> => {
// Still format an empty project-memory payload so injectProfile can emit
// <user_profile> on its own (e.g. chatMessage.maxMemories: 0).
const projectMemories = {
Expand All @@ -748,6 +759,58 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => {
return formatContextForPrompt(userId, projectMemories);
};

const loadMemoryContext = async (sessionID: string): Promise<string> => {
const { loadRecallMemoriesForSession } = await import("./services/memory-recall.js");
const queryText = await userPromptManager.getLatestPromptTextForSession(sessionID);
const memories = await loadRecallMemoriesForSession(tags.project.tag, sessionID, queryText);
return formatMemoryContext(memories);
};

const loadFileMemoryContext = async (
sessionID: string,
filePaths: readonly string[],
promptMessageID: string,
baseContext: string
): Promise<string> => {
if (!fileRecallEnabled() || !filePaths.length || !promptMessageID) return baseContext;
try {
// Submission can precede inbox delivery. Bind additional recall to the
// authored prompt actually present in this model request.
const queryText = (
await userPromptManager.getPendingPromptForMessage(sessionID, promptMessageID, directory)
)?.content;
if (!queryText || isFullyPrivate(queryText)) return baseContext;
observedFileContext ??= createObservedFileContext(directory);
const context = await observedFileContext;
const observedFiles = await context?.normalizeCandidates(filePaths);
if (!observedFiles) return baseContext;
const { loadFileRecallMemoriesForSession, loadRecallMemoriesForSession } =
await import("./services/memory-recall.js");
const files = await loadFileRecallMemoriesForSession(
tags.project.tag,
sessionID,
observedFiles
);
// With no association, keep the exact cached context. In particular,
// recent/first and profile caches must not refresh because a file was read.
if (!files.length) return baseContext;
const ordinary = await loadRecallMemoriesForSession(tags.project.tag, sessionID, queryText);
const merged = mergeFileRecall(files, ordinary, CONFIG.chatMessage.maxMemories ?? 3);
return merged === ordinary ? baseContext : await formatMemoryContext(merged);
} catch {
log("File memory context failed, keeping cached prompt context", { sessionID });
return baseContext;
}
};

const canLoadMemoryContext = async (sessionID: string): Promise<boolean> =>
isConfigured() &&
!!CONFIG.chatMessage.enabled &&
!isInternalStructuredSession(sessionID) &&
!(await isInternalCaptureSession(ctx.client, sessionID)) &&
// Reloaded V2 sessions can rehydrate without passing through capturePrompt.
(CONFIG.chatMessage.captureChildSessions || !(await isChildSession(ctx.client, sessionID)));

return {
// V1 ignores this extra hook; the V2 adapter uses it during plugin reload.
dispose: disposePlugin,
Expand All @@ -759,6 +822,7 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => {
CONFIG.autoCaptureEnabled &&
userPromptManager.hasPendingCaptureSession(sessionID, directory),
enabled: () => isConfigured() && CONFIG.chatMessage.enabled,
fileRecallEnabled,
refreshOnPrompt: () =>
shouldRefreshRecallOnPrompt(
CONFIG.chatMessage.recall ?? "relevant",
Expand All @@ -768,22 +832,17 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => {
input: { sessionID: string },
output: { message: { id: string }; parts: Part[] }
) => capturePrompt(input.sessionID, output.message.id, output.parts),
load: async (sessionID: string) => {
if (
!isConfigured() ||
!CONFIG.chatMessage.enabled ||
isInternalStructuredSession(sessionID) ||
(await isInternalCaptureSession(ctx.client, sessionID)) ||
// Same child-session gate as capture: after plugin reload the V2
// context hook can rehydrate via load() without going through
// capturePrompt, so skip injection for orchestrator children too.
(!CONFIG.chatMessage.captureChildSessions &&
(await isChildSession(ctx.client, sessionID)))
) {
return "";
}
return loadMemoryContext(sessionID);
},
load: async (sessionID: string) =>
(await canLoadMemoryContext(sessionID)) ? loadMemoryContext(sessionID) : "",
loadFileContext: async (
sessionID: string,
filePaths: readonly string[],
promptMessageID: string,
baseContext: string
) =>
(await canLoadMemoryContext(sessionID))
? loadFileMemoryContext(sessionID, filePaths, promptMessageID, baseContext)
: "",
},
config: async (cfg) => {
applyStructuredOutputAgentConfig(cfg);
Expand Down
33 changes: 31 additions & 2 deletions src/services/auto-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { getTags } from "./tags.js";
import { log, serializeError } from "./logger.js";
import { sanitizeMemoryContent, sanitizeMemoryField } from "./privacy.js";
import { CONFIG } from "../config.js";
import { bindFilesToMemory } from "./file-memory-association.js";
import { supportsLocalFileObservations, type ObservedFiles } from "./observed-files.js";
import { userPromptManager, type UserPrompt } from "./user-prompt/user-prompt-manager.js";
import { loadOpencodeProvider } from "./ai/opencode-provider-loader.js";
import { truncateToMaxBytes, utf8ByteLength } from "../utils/context-limit.js";
Expand Down Expand Up @@ -162,6 +164,7 @@ async function capturePrompt(
const tags = getTags(directory);
const savedEvidence = await userPromptManager.getCaptureEvidence(prompt.id, directory);
let context: string | undefined;
let observedFiles: ObservedFiles | undefined;

// Deleted-session / startup replay: durable evidence is authoritative.
// Normal idle keeps the richer live transcript and only falls back when
Expand Down Expand Up @@ -205,6 +208,26 @@ async function capturePrompt(
return;
}

// Only the live, prompt-delimited transcript carries structured
// successful tool inputs. Recovery evidence is text and must never
// be mined for paths. Missing files or path errors only lose this
// optional signal; ordinary capture still proceeds.
if (
CONFIG.chatMessage?.fileAwareRecall === true &&
supportsLocalFileObservations(ctx) &&
CONFIG.memoryBackend !== "markdown"
) {
try {
const { createObservedFileContext, collectObservedFiles } =
await import("./observed-files.js");
const fileContext = await createObservedFileContext(directory);
if (fileContext)
observedFiles = await collectObservedFiles(aiMessages, fileContext);
} catch {
log("Auto-capture: observed file association unavailable", { sessionID });
}
}

const latestMemory = await getLatestProjectMemory(tags.project.tag);
context = buildMarkdownContext(
prompt.content,
Expand Down Expand Up @@ -291,6 +314,9 @@ async function capturePrompt(
projectPath: tags.project.projectPath,
projectName: tags.project.projectName,
gitRepoUrl: tags.project.gitRepoUrl,
...(observedFiles
? { observedFiles: bindFilesToMemory(observedFiles, summaryWithTags) }
: {}),
};

const result = await persistAutoCaptureMemory(
Expand All @@ -301,7 +327,8 @@ async function capturePrompt(
summaryResult.type,
summaryResult.tags || [],
tags.project.tag,
persistMeta
persistMeta,
observedFiles
);

if (result.success) {
Expand Down Expand Up @@ -914,7 +941,8 @@ async function persistAutoCaptureMemory(
type: string,
tags: string[],
containerTag: string,
metadata: Record<string, unknown>
metadata: Record<string, unknown>,
observedFiles?: ObservedFiles
): Promise<{ success: boolean; id?: string; error?: string; action: string }> {
if (!CONFIG.updateOnCaptureEnabled) {
const result = await memoryClient.addMemory(summaryWithTags, containerTag, metadata as any);
Expand Down Expand Up @@ -978,6 +1006,7 @@ async function persistAutoCaptureMemory(
type,
tags,
containerTag,
observedFiles,
metadata: metadata as any,
},
new Set(candidates.map((c) => c.id))
Expand Down
Loading
Loading