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/agent-contract/jsonrpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ export const RPC_METHODS = {
HUB_PUBLISH: "hub.publish",
HUB_PULL: "hub.pull",

// ── policies (gain maintenance) ──
POLICIES_GAIN_PREVIEW: "policies.gainPreview",
POLICIES_GAIN_ROLLBACK: "policies.gainRollback",

// ── logs ──
LOGS_TAIL: "logs.tail",
/** Notification: forward a log line from a non-TS adapter back into our sinks. */
Expand Down
141 changes: 141 additions & 0 deletions apps/memos-local-plugin/agent-contract/memory-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,97 @@ export interface EmbeddingMaintenanceRunResult {
error?: string;
}

// ─── Gain repair maintenance ─────────────────────────────────────────────────

export type GainPreviewProposedTransition =
| "promote_to_active"
| "retain_candidate"
| "retain_active"
| "none";

export type GainPreviewSkipReason = "no_resolved_with" | "unknown_owner";

export interface GainPreviewQueueState {
state: "pending" | "blocked" | "claimed";
reason: string | null;
blockedReason: string | null;
attemptCount: number;
inferenceVersion: number;
}

export interface GainPreviewPolicyEntry {
policyId: string;
title: string;
status: "candidate" | "active";
support: number;
/** Currently stored gain / version (the "old" side of a repair). */
oldGain: number;
oldGainVersion: number;
/** RAW (un-smoothed) recomputation — preview cannot rebuild it from a scalar. */
rawGain: number;
/** EMA-smoothed gain a repair would persist. */
newGain: number;
newGainVersion: 1 | 2;
resolvedWith: number;
resolvedWithout: number;
provenance: { liveNormalized: number; inferredNormalized: number; legacyUnscaled: number };
excluded: {
unresolvedWith: number;
unresolvedWithout: number;
withBeyondLimit: number;
withoutBeyondLimit: number;
};
reported: { danglingIds: number; invalidScores: number; outOfNamespace: number };
proposedTransition: GainPreviewProposedTransition;
skipReason: GainPreviewSkipReason | null;
unknownOwner: boolean;
queue: GainPreviewQueueState | null;
}

export interface GainPreviewResult {
policies: GainPreviewPolicyEntry[];
total: number;
limit: number;
offset: number;
queue: { pending: number; blocked: number; claimed: number };
/** Attempted/limit/remaining via the existing durable-budget readback. */
budget: { attempted: number; limit: number | null; remaining: number | null; initialized: boolean };
inferenceVersion: number;
legacy: {
groups: number;
traces: number;
postCutoverGroups: number;
postCutoverTraces: number;
unknownChronologyGroups: number;
unknownChronologyTraces: number;
};
}

export interface GainRollbackConflict {
journalId: string;
/**
* Null when the journal row is missing or belongs to another namespace —
* the caller learns nothing about foreign rows.
*/
policyId: string | null;
reason:
| "not_found_or_forbidden"
| "not_rollback_eligible"
| "policy_missing"
| "policy_changed"
| "duplicate_policy_entries";
field?: "status" | "support" | "gain" | "gain_version" | "updated_at";
}

export type GainRollbackResult =
| {
ok: true;
batchId: string | null;
rolledBack: Array<{ journalId: string; policyId: string }>;
rolledBackAt: number;
}
| { ok: false; batchId: string | null; conflicts: GainRollbackConflict[] };

// ─── Subscriptions ────────────────────────────────────────────────────────────

export type Unsubscribe = () => void;
Expand Down Expand Up @@ -337,6 +428,47 @@ export interface MemoryCore {
id: string,
patch: { preference?: string[]; antiPattern?: string[] },
): Promise<PolicyDTO | null>;
// ── gain repair maintenance ──
/**
* Read-only repair preview for an EXACT namespace, paginated. Returns the
* current stored gain/version alongside the freshly recomputed raw/new
* gain, provenance/exclusion counts, the proposed transition (or skip
* reason), per-policy queue state, queue totals, the durable budget
* readback, the inference version and post-cutover/unknown-chronology
* legacy summaries.
*
* This is a sanity check, NOT a frozen approval artifact: the timer
* recomputes from fresh evidence on every tick, so a preview never
* authorizes or locks in a future repair. It performs ZERO writes.
*/
previewGainRepair(input: {
namespace: RuntimeNamespace;
limit?: number;
offset?: number;
}): Promise<GainPreviewResult>;
/**
* Policy-field CAS rollback of explicit journal rows (one batch or an
* explicit ID list) within an EXACT namespace. Compares ALL requested rows
* against the recorded post-write fields (status, support, gain,
* gain_version, updated_at) BEFORE any write and rejects the entire batch
* on any mismatch. On match it restores the repair-owned gain/version/
* status, stamps a fresh updated_at, PRESERVES support and all trace/link
* data, and marks the journal rows `rolled_back` + the queue entries
* `blocked` atomically. Never restores historical timestamps, never
* refunds the budget, never touches evidence.
*
* Operational preconditions (NOT enforced in code — they are operator
* steps): pause repair first (`gainRepairBatchSize: 0`); rolled-back
* entries resume through the config re-screen generation (bump
* `gainRepairRescreenGeneration`), not a separate approval flow; keep a
* WAL-consistent SQLite backup with `quick_check` before first enable as
* disaster recovery.
*/
rollbackGainRepair(input: {
namespace: RuntimeNamespace;
batchId?: string;
journalIds?: readonly string[];
}): Promise<GainRollbackResult>;
/** Hard-delete a world-model row. */
deleteWorldModel(id: string): Promise<{ deleted: boolean }>;
/**
Expand Down Expand Up @@ -588,6 +720,15 @@ export interface MemoryCore {
worldModels: WorldModelDTO[];
skills: SkillDTO[];
}>;
/**
* Restore a bundle-1 export. Imports never trust supplied policy
* certification or manufacture a complete reward-pass set from a partial
* bundle — imported traces keep unresolved gain scores and imported
* policies stay UNCERTIFIED (`gain_version` 1). Eligible (candidate/active)
* imported policies are queued for gain repair without changing their
* status/support; the next startup's union reconcile resolves pending vs
* blocked from real evidence.
*/
importBundle(bundle: {
version?: number;
traces?: unknown[];
Expand Down
52 changes: 52 additions & 0 deletions apps/memos-local-plugin/bridge/methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,58 @@ export function makeDispatcher(
// but we route it via a notification on the events stream instead.
// Leaving a branch here would be dead code; we intentionally drop.

// ── policies (gain maintenance) ──
case RPC_METHODS.POLICIES_GAIN_PREVIEW: {
const p = asRecord(params, method);
const ns = namespaceParam(p);
// Exact namespace is mandatory: preview must never guess a namespace
// and leak another owner's rows.
if (!ns) {
throw new MemosError(
"invalid_argument",
`${method}: 'namespace' is required (exact namespace)`,
);
}
return await core.previewGainRepair({
namespace: ns,
limit: typeof p.limit === "number" ? p.limit : undefined,
offset: typeof p.offset === "number" ? p.offset : undefined,
});
}
case RPC_METHODS.POLICIES_GAIN_ROLLBACK: {
const p = asRecord(params, method);
const ns = namespaceParam(p);
if (!ns) {
throw new MemosError(
"invalid_argument",
`${method}: 'namespace' is required (exact namespace)`,
);
}
const batchId =
typeof p.batchId === "string" && p.batchId.length > 0 ? p.batchId : undefined;
const journalIds = Array.isArray(p.journalIds)
? (p.journalIds as unknown[]).filter(
(id): id is string => typeof id === "string" && id.length > 0,
)
: undefined;
if (batchId !== undefined && journalIds !== undefined) {
throw new MemosError(
"invalid_argument",
`${method}: pass either 'batchId' or 'journalIds', not both`,
);
}
if (batchId !== undefined) {
return await core.rollbackGainRepair({ namespace: ns, batchId });
}
if (journalIds !== undefined && journalIds.length > 0) {
return await core.rollbackGainRepair({ namespace: ns, journalIds });
}
throw new MemosError(
"invalid_argument",
`${method}: exactly one of 'batchId' / non-empty 'journalIds' is required`,
);
}

// ── config / hub ──
case RPC_METHODS.CONFIG_GET:
case RPC_METHODS.CONFIG_PATCH:
Expand Down
10 changes: 10 additions & 0 deletions apps/memos-local-plugin/core/config/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,16 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
traceCharCap: 3_000,
gainEmaAlpha: 0.4,
archiveGain: -0.05,
// v2 gain scoring is OFF by default; the operator flips it on
// after a schema-only migration + inference pass + preview inspection.
gainV2Enabled: false,
minGainValue: 0.02,
gainRepairBatchSize: 0,
gainRepairIntervalMs: 900_000,
gainRepairMaxTotal: null,
gainRepairRescreenGeneration: 0,
gainInferenceBootMaxGroups: 2_000,
gainInferenceBootTimeBudgetMs: 30_000,
},
l3Abstraction: {
// Lowered from 3 → 2. The original threshold required THREE
Expand Down
50 changes: 50 additions & 0 deletions apps/memos-local-plugin/core/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,56 @@ const AlgorithmSchema = Type.Object({
gainEmaAlpha: NumberInRange(0.4, 0, 1),
/** Archive active policies whose gain dips below this value. */
archiveGain: NumberInRange(-0.05, -1, 1),
/**
* use `gainValue` (clamp(N·V, -1, 1)) for L2 gain/induction and
* permit configured repair. Disabled keeps legacy `minTraceValue`
* semantics on V; disabling v2 after v2 gains are written is not a clean
* semantic rollback.
*/
gainV2Enabled: Bool(false),
/** resolved `gainValue` induction floor for enabled mode. */
minGainValue: NumberInRange(0.02, -1, 1),
/**
* repair attempts per timer tick (integer 0..25); 0 pauses
* repair while retaining v2 scoring.
*/
gainRepairBatchSize: Type.Integer({ default: 0, minimum: 0, maximum: 25 }),
/**
* timer cadence (integer ms, 60000..86399999); default 15
* minutes, always faster than daily.
*/
gainRepairIntervalMs: Type.Integer({ default: 900_000, minimum: 60_000, maximum: 86_399_999 }),
/**
* durable absolute total-attempt ceiling since initial enable.
* `null`/omitted = unlimited; otherwise a nonnegative integer. Raising
* it permits more attempts; lowering it below attempted pauses new
* attempts; clearing to null removes the ceiling without resetting the
* counter. Restarts/pause/resume/enable toggles never reset/refund it.
*/
gainRepairMaxTotal: Type.Union(
[Type.Integer({ minimum: 0 }), Type.Null()],
{ default: null },
),
/**
* config-driven re-screen generation. A nonnegative integer;
* increasing it requests one re-screen of blocked evidence at the current
* inference version (consumed once, cannot bypass the attempt budget).
*/
gainRepairRescreenGeneration: Type.Integer({ default: 0, minimum: 0 }),
/**
* override for the per-boot historical inference pass's group
* cap (`GAIN_INFERENCE_BOOT_MAX_GROUPS` default). Raise on hosts whose
* backlog doesn't converge in one restart under the time budget; a large
* corpus can need several thousand groups per pass.
*/
gainInferenceBootMaxGroups: Type.Integer({ default: 2_000, minimum: 1 }),
/**
* override for the per-boot historical inference pass's time
* budget in ms (`GAIN_INFERENCE_BOOT_TIME_BUDGET_MS` default). Capped
* well under `bridge.initWatchdogMs` so a large backlog still can't stall
* startup past the watchdog.
*/
gainInferenceBootTimeBudgetMs: Type.Integer({ default: 30_000, minimum: 1_000, maximum: 300_000 }),
}, { default: {} }),
l3Abstraction: Type.Object({
/** Minimum number of compatible active L2 policies to trigger an L3 abstraction. */
Expand Down
9 changes: 9 additions & 0 deletions apps/memos-local-plugin/core/experience/feedback-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,11 @@ export async function runFeedbackExperience(
boundary: draft.boundary,
support: 1,
gain: Math.max(0.02, draft.salience),
// feedback-derived salience is NOT a shared v2 gainValue
// calculation: the row stays uncertified until a real v2 pass certifies
// it. Explicit feedback activation (status above) is distinct from the
// L2 evidence gate and is preserved as-is.
gainVersion: 1,
status: draft.salience >= 0.5 ? "active" : "candidate",
experienceType: draft.type,
evidencePolarity: draft.polarity,
Expand Down Expand Up @@ -400,6 +405,10 @@ function mergePolicy(
...existing,
support: Math.max(1, existing.support) + 1,
gain: Math.max(existing.gain, draft.salience, 0.02),
// a feedback/salience-derived gain overwrite invalidates v2
// certification: only an actual shared gainValue calculation certifies v2,
// never a blanket merge.
gainVersion: 1,
status: existing.status === "archived" ? existing.status : "active",
experienceType: skillEligible && polarity === "mixed"
? "repair_validated"
Expand Down
Loading
Loading