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
4 changes: 4 additions & 0 deletions apps/memos-local-plugin/core/config/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,10 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
bgLlmConcurrency: 2,
},
retrieval: {
// Confine turn-start recall to the calling Session by default. A trace
// is a transcript, and injecting another Session's transcript makes the
// agent adopt that conversation as its own history (MemOS#1532).
recallScope: "session",
tier1TopK: 3,
tier2TopK: 5,
tier3TopK: 2,
Expand Down
15 changes: 15 additions & 0 deletions apps/memos-local-plugin/core/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,21 @@ const AlgorithmSchema = Type.Object({
bgLlmConcurrency: NumberInRange(2, 1, 8),
}, { default: {} }),
retrieval: Type.Object({
/**
* Whether automatic (turn-start) recall may read traces written by other
* Sessions. A trace is a transcript (`[user] … / [assistant] …`), so
* injecting another Session's transcript makes the agent adopt that
* conversation as its own history (MemOS#1532).
*
* Only the turn-start injection path is confined. Explicit searches
* (`memos_search`), decision-repair and sub-agent retrieval stay
* profile-wide, because those paths deliberately look across history.
* L2/L3 artifacts are never confined by this switch either.
*/
recallScope: Type.Union([
Type.Literal("session"),
Type.Literal("profile"),
], { default: "session" }),
/** How many Skill snippets to inject at turn start. */
tier1TopK: NumberInRange(3, 0, 100),
/** How many trace/episode snippets to inject. */
Expand Down
1 change: 1 addition & 0 deletions apps/memos-local-plugin/core/pipeline/deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ export function extractAlgorithmConfig(
skill: alg.skill,
feedback: alg.feedback,
retrieval: {
recallScope: alg.retrieval.recallScope,
tier1TopK: alg.retrieval.tier1TopK,
tier2TopK: alg.retrieval.tier2TopK,
tier3TopK: alg.retrieval.tier3TopK,
Expand Down
25 changes: 22 additions & 3 deletions apps/memos-local-plugin/core/pipeline/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1093,11 +1093,30 @@ export function createPipeline(deps: PipelineDeps): PipelineHandle {
const retrievalDeps = buildRetrievalDeps(deps, algorithm, foregroundResources);
const turnStartRetrievalStats = new Map<string, RetrievalResult["stats"]>();

function retrievalDepsFor(namespace = deps.namespace): typeof retrievalDeps {
/**
* Build the retrieval surface for one call.
*
* `recallScope` is only passed by the turn-start path. Every other entry
* point (tool-driven, skill-invoke, sub-agent, decision-repair) keeps
* profile-wide recall on purpose: decision-repair exists to find historical
* anti-patterns across Sessions, and `memos_search` is an explicit user
* request. Pinning them would turn a privacy fix into a functional
* regression.
*
* `config.recallScope` is narrowed in lockstep so tier-2 filter builders can
* suppress the same-session exclusions that would otherwise AND against the
* pin into an always-empty predicate.
*/
function retrievalDepsFor(
namespace = deps.namespace,
recallScope?: "session" | "profile",
): typeof retrievalDeps {
const scope = recallScope ?? "profile";
return {
...retrievalDeps,
config: { ...retrievalDeps.config, recallScope: scope },
namespace,
repos: wrapRetrievalRepos(deps.repos, namespace),
repos: wrapRetrievalRepos(deps.repos, namespace, { recallScope: scope }),
};
}

Expand All @@ -1116,7 +1135,7 @@ export function createPipeline(deps: PipelineDeps): PipelineHandle {
ts: input.ts,
};
const result: RetrievalResult = await turnStartRetrieve(
retrievalDepsFor(input.namespace),
retrievalDepsFor(input.namespace, algorithm.retrieval.recallScope),
ctx,
{
events: buses.retrieval,
Expand Down
55 changes: 48 additions & 7 deletions apps/memos-local-plugin/core/pipeline/retrieval-repos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,34 @@ import type { Repos } from "../storage/repos/index.js";
import type { RuntimeNamespace, TraceId } from "../../agent-contract/dto.js";
import { isVisibleTo, visibilityWhere } from "../runtime/namespace.js";

export function wrapRetrievalRepos(repos: Repos, namespace: RuntimeNamespace): RetrievalRepos {
export interface RetrievalRepoOptions {
/**
* `session` confines L1 trace reads to `namespace.sessionKey`; anything else
* (the default) leaves recall profile-wide.
*
* A trace is a verbatim transcript (`[user] … / [assistant] …`). Injecting
* another Session's transcript makes the agent adopt that conversation as
* its own history, which is MemOS#1532. Only the turn-start injection path
* sets this; explicit searches, decision-repair and sub-agent retrieval
* deliberately keep looking across history.
*/
recallScope?: "session" | "profile";
}

export function wrapRetrievalRepos(
repos: Repos,
namespace: RuntimeNamespace,
options: RetrievalRepoOptions = {},
): RetrievalRepos {
// Absent or non-string `sessionKey` fails open to profile-wide recall: a
// missing Session id must never silently confine recall to a Session that
// has no rows.
const sessionKey
= options.recallScope === "session"
&& typeof namespace.sessionKey === "string"
&& namespace.sessionKey.length > 0
? namespace.sessionKey
: undefined;
return {
skills: {
searchByVector(query, k, opts) {
Expand Down Expand Up @@ -47,21 +74,21 @@ export function wrapRetrievalRepos(repos: Repos, namespace: RuntimeNamespace): R
return repos.traces.searchByVector(
query,
k,
mergeVisibility(opts, namespace),
mergeVisibility(opts, namespace, "", sessionKey),
);
},
searchByText(ftsMatch, k, opts) {
return repos.traces.searchByText(
ftsMatch,
k,
mergeVisibility(opts, namespace, "t"),
mergeVisibility(opts, namespace, "t", sessionKey),
);
},
searchByPattern(terms, k, opts) {
return repos.traces.searchByPattern(
terms,
k,
mergeVisibility(opts, namespace),
mergeVisibility(opts, namespace, "", sessionKey),
);
},
getManyByIds(ids) {
Expand All @@ -85,7 +112,7 @@ export function wrapRetrievalRepos(repos: Repos, namespace: RuntimeNamespace): R
const rows = repos.traces.searchByErrorSignature(
fragments,
limit,
mergeVisibility(opts, namespace),
mergeVisibility(opts, namespace, "", sessionKey),
);
return rows.filter((r) => isVisibleTo(r, namespace)).map((r) => ({
id: r.id,
Expand Down Expand Up @@ -197,16 +224,29 @@ function mergeVisibility<T extends {
opts: T | undefined,
namespace: RuntimeNamespace,
alias = "",
sessionKey?: string,
): T {
const visible = visibilityWhere(namespace, alias);
// The pin goes into SQL rather than a post-filter so a foreign Session's
// rows cannot consume the LIMIT budget (`k=1` would otherwise come back
// empty). The parameter name is deliberately distinct from the tier-2
// `visible_context_*` / `exclude_session_id` params it is merged after.
const pin = sessionKey === undefined
? null
: {
sql: `${alias ? `${alias}.` : ""}session_id = @vis_recall_session_key`,
params: { vis_recall_session_key: sessionKey },
};
const scopeSql = pin ? `(${visible.sql}) AND (${pin.sql})` : visible.sql;
return {
...(opts ?? {}),
where: opts?.where
? `(${opts.where}) AND (${visible.sql})`
: visible.sql,
? `(${opts.where}) AND (${scopeSql})`
: scopeSql,
params: {
...(opts?.params ?? {}),
...visible.params,
...(pin?.params ?? {}),
},
} as T;
}
Expand Down Expand Up @@ -235,3 +275,4 @@ function normaliseSkillDecisionGuidance(
: [],
};
}

26 changes: 17 additions & 9 deletions apps/memos-local-plugin/core/retrieval/tier2-trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,16 +346,23 @@ function buildTraceSearchFilters(
const params: Record<string, unknown> = {};
const includeLow = input.includeLowValue ?? deps.config.includeLowValue;
if (!includeLow) parts.push("priority > 0");
// When the caller pinned recall to one Session, the trace SQL already has
// `session_id = <pin>`. The `session_id != X` half of the predicates below
// would then AND into an always-empty result (`session_id = X AND
// session_id != X`), silently zeroing trace recall. Keep the other half of
// each OR — the time bound and the exact-text exclusion — so already-visible
// context is still filtered out.
const scoped = deps.config.recallScope === "session";
const visible = input.visibleContext;
if (
visible &&
typeof visible.startTs === "number" &&
Number.isFinite(visible.startTs)
) {
parts.push(
"(session_id != @visible_context_session_id OR ts < @visible_context_start_ts)",
);
params.visible_context_session_id = visible.sessionId;
parts.push(scoped
? "ts < @visible_context_start_ts"
: "(session_id != @visible_context_session_id OR ts < @visible_context_start_ts)");
if (!scoped) params.visible_context_session_id = visible.sessionId;
params.visible_context_start_ts = visible.startTs;
} else if (visible) {
const texts = Array.from(
Expand All @@ -370,12 +377,13 @@ function buildTraceSearchFilters(
texts.forEach((text, index) => {
params[`visible_user_text_${index}`] = text;
});
parts.push(
`(session_id != @visible_context_session_id OR user_text NOT IN (${placeholders.join(", ")}))`,
);
params.visible_context_session_id = visible.sessionId;
const exclusion = `user_text NOT IN (${placeholders.join(", ")})`;
parts.push(scoped
? exclusion
: `(session_id != @visible_context_session_id OR ${exclusion})`);
if (!scoped) params.visible_context_session_id = visible.sessionId;
}
} else if (input.excludeSessionId) {
} else if (!scoped && input.excludeSessionId) {
parts.push("session_id != @exclude_session_id");
params.exclude_session_id = input.excludeSessionId;
}
Expand Down
8 changes: 8 additions & 0 deletions apps/memos-local-plugin/core/retrieval/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,14 @@ export interface RankedSnippet {
* build minimal configs without standing up the TypeBox defaults.
*/
export interface RetrievalConfig {
/**
* Runtime marker: whether this particular retrieval is pinned to its
* Session. `buildRetrievalDeps` seeds it from the on-disk preference;
* `orchestrator.retrievalDepsFor` then narrows it to `"profile"` for every
* path except turn-start automatic injection. Optional so hand-written
* configs in tests and embedders stay valid; absent means no pin.
*/
recallScope?: "session" | "profile";
tier1TopK: number;
tier2TopK: number;
tier3TopK: number;
Expand Down
18 changes: 18 additions & 0 deletions apps/memos-local-plugin/docs/CONFIG-ADVANCED.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,24 @@ algorithm:
traceCharCap: 500 # chars per evidence trace in the decision-repair prompt
evidenceLimit: 4 # max high-value / low-value traces per synthesis call
retrieval:
recallScope: session # session (default) | profile — see note below
# `session` confines turn-start automatic recall to the calling Session: a
# trace is a transcript (`[user] … / [assistant] …`), and injecting another
# Session's transcript makes the agent adopt that conversation as its own
# history (MemOS#1532). `profile` restores the old behaviour, where every
# Session sharing this home AND `profileId` can recall the others'
# transcripts.
#
# Only the turn-start injection path is affected. Explicit `memos_search`
# calls, decision repair and sub-agent retrieval stay profile-wide: they
# deliberately look across history, and pinning them returns nothing.
#
# UPGRADE NOTE: existing installs have no `recallScope` on disk and
# therefore inherit `session`, losing cross-session transcript recall from
# automatic injection. Set `recallScope: profile` to opt back in.
#
# L2/L3 artifacts (`policies`, `world_model`) and skills are
# statement-shaped rather than transcript-shaped and are never confined.
tier1TopK: 3 # skills injected at turn start
tier2TopK: 5 # trace/episode snippets
tier3TopK: 2 # world-model snippets
Expand Down
Loading