diff --git a/index.ts b/index.ts index 25b2012f..28829715 100644 --- a/index.ts +++ b/index.ts @@ -1899,7 +1899,6 @@ const memoryLanceDBProPlugin = { api.logger.debug?.("memory-lancedb-pro: register() called again — skipping re-init (idempotent)"); return; } - _registeredApis.add(api); // Parse and validate configuration // ======================================================================== @@ -1908,8 +1907,22 @@ const memoryLanceDBProPlugin = { // the same singleton via destructuring. This prevents: // - Memory heap growth from repeated resource creation (~9 calls/process) // - Accumulated session Maps being lost on re-registration + // + // IMPORTANT: _registeredApis.add(api) is called AFTER successful init. + // This ensures that if _initPluginState throws, the api is NOT in the + // WeakSet, allowing a subsequent register() call with the same api to retry. + // (The old placement — before init — caused permanent breakage on init failure.) // ======================================================================== - if (!_singletonState) { _singletonState = _initPluginState(api); } + let singleton: typeof _singletonState; + try { + if (!_singletonState) { _singletonState = _initPluginState(api); } + singleton = _singletonState; + } catch (err) { + api.logger.error(`memory-lancedb-pro: _initPluginState failed — ${String(err)}`); + throw err; + } + _registeredApis.add(api); + const { config, resolvedDbPath, @@ -1930,7 +1943,7 @@ const memoryLanceDBProPlugin = { autoCaptureSeenTextCount, autoCapturePendingIngressTexts, autoCaptureRecentTexts, - } = _singletonState; + } = singleton; async function sleep(ms: number): Promise { diff --git a/src/reflection-store.ts b/src/reflection-store.ts index 38da5ce7..d51a268b 100644 --- a/src/reflection-store.ts +++ b/src/reflection-store.ts @@ -1,330 +1,331 @@ -import type { MemoryEntry, MemorySearchResult } from "./store.js"; -import { - extractInjectableReflectionSliceItems, - extractInjectableReflectionSlices, - sanitizeReflectionSliceLines, - sanitizeInjectableReflectionLines, - type ReflectionSlices, -} from "./reflection-slices.js"; -import { parseReflectionMetadata } from "./reflection-metadata.js"; -import { buildReflectionEventPayload, createReflectionEventId } from "./reflection-event-store.js"; -import { - buildReflectionItemPayloads, - getReflectionItemDecayDefaults, - REFLECTION_DERIVED_DECAY_K, - REFLECTION_DERIVED_DECAY_MIDPOINT_DAYS, - REFLECTION_INVARIANT_DECAY_K, - REFLECTION_INVARIANT_DECAY_MIDPOINT_DAYS, -} from "./reflection-item-store.js"; -import { getReflectionMappedDecayDefaults, type ReflectionMappedKind } from "./reflection-mapped-metadata.js"; -import { computeReflectionScore, normalizeReflectionLineForAggregation } from "./reflection-ranking.js"; - -export const REFLECTION_DERIVE_LOGISTIC_MIDPOINT_DAYS = 3; -export const REFLECTION_DERIVE_LOGISTIC_K = 1.2; -export const REFLECTION_DERIVE_FALLBACK_BASE_WEIGHT = 0.35; - -export const DEFAULT_REFLECTION_DERIVED_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000; -export const DEFAULT_REFLECTION_MAPPED_MAX_AGE_MS = 60 * 24 * 60 * 60 * 1000; - -type ReflectionStoreKind = "event" | "item-invariant" | "item-derived" | "combined-legacy"; - -type ReflectionErrorSignalLike = { - signatureHash: string; -}; - -interface ReflectionStorePayload { - text: string; - metadata: Record; - kind: ReflectionStoreKind; -} - -interface BuildReflectionStorePayloadsParams { - reflectionText: string; - sessionKey: string; - sessionId: string; - agentId: string; - command: string; - scope: string; - toolErrorSignals: ReflectionErrorSignalLike[]; - runAt: number; - usedFallback: boolean; - eventId?: string; - sourceReflectionPath?: string; - writeLegacyCombined?: boolean; -} - -export function buildReflectionStorePayloads(params: BuildReflectionStorePayloadsParams): { - eventId: string; - slices: ReflectionSlices; - payloads: ReflectionStorePayload[]; -} { - const slices = extractInjectableReflectionSlices(params.reflectionText); - const eventId = params.eventId || createReflectionEventId({ - runAt: params.runAt, - sessionKey: params.sessionKey, - sessionId: params.sessionId, - agentId: params.agentId, - command: params.command, - }); - - const payloads: ReflectionStorePayload[] = [ - buildReflectionEventPayload({ - eventId, - scope: params.scope, - sessionKey: params.sessionKey, - sessionId: params.sessionId, - agentId: params.agentId, - command: params.command, - toolErrorSignals: params.toolErrorSignals, - runAt: params.runAt, - usedFallback: params.usedFallback, - sourceReflectionPath: params.sourceReflectionPath, - }), - ]; - - const itemPayloads = buildReflectionItemPayloads({ - items: extractInjectableReflectionSliceItems(params.reflectionText), - eventId, - agentId: params.agentId, - sessionKey: params.sessionKey, - sessionId: params.sessionId, - runAt: params.runAt, - usedFallback: params.usedFallback, - toolErrorSignals: params.toolErrorSignals, - sourceReflectionPath: params.sourceReflectionPath, - }); - payloads.push(...itemPayloads); - - if (params.writeLegacyCombined !== false && (slices.invariants.length > 0 || slices.derived.length > 0)) { - payloads.push(buildLegacyCombinedPayload({ - slices, - scope: params.scope, - sessionKey: params.sessionKey, - sessionId: params.sessionId, - agentId: params.agentId, - command: params.command, - toolErrorSignals: params.toolErrorSignals, - runAt: params.runAt, - usedFallback: params.usedFallback, - sourceReflectionPath: params.sourceReflectionPath, - })); - } - - return { eventId, slices, payloads }; -} - -function buildLegacyCombinedPayload(params: { - slices: ReflectionSlices; - sessionKey: string; - sessionId: string; - agentId: string; - command: string; - scope: string; - toolErrorSignals: ReflectionErrorSignalLike[]; - runAt: number; - usedFallback: boolean; - sourceReflectionPath?: string; -}): ReflectionStorePayload { - const dateYmd = new Date(params.runAt).toISOString().split("T")[0]; - const deriveQuality = computeDerivedLineQuality(params.slices.derived.length); - const deriveBaseWeight = params.usedFallback ? REFLECTION_DERIVE_FALLBACK_BASE_WEIGHT : 1; - - return { - kind: "combined-legacy", - text: [ - `reflection · ${params.scope} · ${dateYmd}`, - `Session Reflection (${new Date(params.runAt).toISOString()})`, - `Session Key: ${params.sessionKey}`, - `Session ID: ${params.sessionId}`, - "", - "Invariants:", - ...(params.slices.invariants.length > 0 ? params.slices.invariants.map((x) => `- ${x}`) : ["- (none captured)"]), - "", - "Derived:", - ...(params.slices.derived.length > 0 ? params.slices.derived.map((x) => `- ${x}`) : ["- (none captured)"]), - ].join("\n"), - metadata: { - type: "memory-reflection", - stage: "reflect-store", - reflectionVersion: 3, - sessionKey: params.sessionKey, - sessionId: params.sessionId, - agentId: params.agentId, - command: params.command, - storedAt: params.runAt, - invariants: params.slices.invariants, - derived: params.slices.derived, - usedFallback: params.usedFallback, - errorSignals: params.toolErrorSignals.map((s) => s.signatureHash), - decayModel: "logistic", - decayMidpointDays: REFLECTION_DERIVE_LOGISTIC_MIDPOINT_DAYS, - decayK: REFLECTION_DERIVE_LOGISTIC_K, - deriveBaseWeight, - deriveQuality, - deriveSource: params.usedFallback ? "fallback" : "normal", - ...(params.sourceReflectionPath ? { sourceReflectionPath: params.sourceReflectionPath } : {}), - }, - }; -} - -interface ReflectionStoreDeps { - embedPassage: (text: string) => Promise; - vectorSearch: ( - vector: number[], - limit?: number, - minScore?: number, - scopeFilter?: string[] - ) => Promise; - store: (entry: Omit) => Promise; -} - -interface StoreReflectionToLanceDBParams extends BuildReflectionStorePayloadsParams, ReflectionStoreDeps { - dedupeThreshold?: number; -} - -export async function storeReflectionToLanceDB(params: StoreReflectionToLanceDBParams): Promise<{ - stored: boolean; - eventId: string; - slices: ReflectionSlices; - storedKinds: ReflectionStoreKind[]; -}> { - const { eventId, slices, payloads } = buildReflectionStorePayloads(params); - const storedKinds: ReflectionStoreKind[] = []; - const dedupeThreshold = Number.isFinite(params.dedupeThreshold) ? Number(params.dedupeThreshold) : 0.97; - - for (const payload of payloads) { - const vector = await params.embedPassage(payload.text); - - if (payload.kind === "combined-legacy") { - const existing = await params.vectorSearch(vector, 1, 0.1, [params.scope]); - if (existing.length > 0 && existing[0].score > dedupeThreshold) { - continue; - } - } - - await params.store({ - text: payload.text, - vector, - category: "reflection", - scope: params.scope, - importance: resolveReflectionImportance(payload.kind), - metadata: JSON.stringify(payload.metadata), - }); - storedKinds.push(payload.kind); - } - - return { stored: storedKinds.length > 0, eventId, slices, storedKinds }; -} - -function resolveReflectionImportance(kind: ReflectionStoreKind): number { - if (kind === "event") return 0.55; - if (kind === "item-invariant") return 0.82; - if (kind === "item-derived") return 0.78; - return 0.75; -} - -export interface LoadReflectionSlicesParams { - entries: MemoryEntry[]; - agentId: string; - now?: number; - deriveMaxAgeMs?: number; - invariantMaxAgeMs?: number; -} - -export function loadAgentReflectionSlicesFromEntries(params: LoadReflectionSlicesParams): { - invariants: string[]; - derived: string[]; -} { - const now = Number.isFinite(params.now) ? Number(params.now) : Date.now(); - const deriveMaxAgeMs = Number.isFinite(params.deriveMaxAgeMs) - ? Math.max(0, Number(params.deriveMaxAgeMs)) - : DEFAULT_REFLECTION_DERIVED_MAX_AGE_MS; - const invariantMaxAgeMs = Number.isFinite(params.invariantMaxAgeMs) - ? Math.max(0, Number(params.invariantMaxAgeMs)) - : undefined; - - const reflectionRows = params.entries - .map((entry) => ({ entry, metadata: parseReflectionMetadata(entry.metadata) })) - .filter(({ metadata }) => isReflectionMetadataType(metadata.type) && isOwnedByAgent(metadata, params.agentId)) - .sort((a, b) => b.entry.timestamp - a.entry.timestamp) - .slice(0, 160); - - const itemRows = reflectionRows.filter(({ metadata }) => metadata.type === "memory-reflection-item"); - const legacyRows = reflectionRows.filter(({ metadata }) => metadata.type === "memory-reflection"); - - const invariantCandidates = buildInvariantCandidates(itemRows, legacyRows); - const derivedCandidates = buildDerivedCandidates(itemRows, legacyRows); - - const invariants = rankReflectionLines(invariantCandidates, { - now, - maxAgeMs: invariantMaxAgeMs, - limit: 8, - }); - - const derived = rankReflectionLines(derivedCandidates, { - now, - maxAgeMs: deriveMaxAgeMs, - limit: 10, - }); - - return { invariants, derived }; -} - -type WeightedLineCandidate = { - line: string; - timestamp: number; - midpointDays: number; - k: number; - baseWeight: number; - quality: number; - usedFallback: boolean; -}; - -function buildInvariantCandidates( - itemRows: Array<{ entry: MemoryEntry; metadata: Record }>, - legacyRows: Array<{ entry: MemoryEntry; metadata: Record }> -): WeightedLineCandidate[] { - const itemCandidates = itemRows - .filter(({ metadata }) => metadata.itemKind === "invariant") - .flatMap(({ entry, metadata }) => { - const lines = sanitizeReflectionSliceLines([entry.text]); - const safeLines = sanitizeInjectableReflectionLines([entry.text]); - if (safeLines.length === 0) return []; - - const defaults = getReflectionItemDecayDefaults("invariant"); - const timestamp = metadataTimestamp(metadata, entry.timestamp); - return safeLines.map((line) => ({ - line, - timestamp, - midpointDays: readPositiveNumber(metadata.decayMidpointDays, defaults.midpointDays), - k: readPositiveNumber(metadata.decayK, defaults.k), - baseWeight: readPositiveNumber(metadata.baseWeight, defaults.baseWeight), - quality: readClampedNumber(metadata.quality, defaults.quality, 0.2, 1), - usedFallback: metadata.usedFallback === true, - })); - }); - - if (itemCandidates.length > 0) return itemCandidates; - - return legacyRows.flatMap(({ entry, metadata }) => { - const defaults = getReflectionItemDecayDefaults("invariant"); - const timestamp = metadataTimestamp(metadata, entry.timestamp); - const lines = sanitizeInjectableReflectionLines(toStringArray(metadata.invariants)); - return lines.map((line) => ({ - line, - timestamp, - midpointDays: defaults.midpointDays, - k: defaults.k, - baseWeight: defaults.baseWeight, - quality: defaults.quality, - usedFallback: metadata.usedFallback === true, - })); - }); -} - +import type { MemoryEntry, MemorySearchResult } from "./store.js"; +import { + extractInjectableReflectionSliceItems, + extractInjectableReflectionSlices, + sanitizeReflectionSliceLines, + sanitizeInjectableReflectionLines, + type ReflectionSlices, +} from "./reflection-slices.js"; +import { parseReflectionMetadata } from "./reflection-metadata.js"; +import { buildReflectionEventPayload, createReflectionEventId } from "./reflection-event-store.js"; +import { + buildReflectionItemPayloads, + getReflectionItemDecayDefaults, + REFLECTION_DERIVED_DECAY_K, + REFLECTION_DERIVED_DECAY_MIDPOINT_DAYS, + REFLECTION_INVARIANT_DECAY_K, + REFLECTION_INVARIANT_DECAY_MIDPOINT_DAYS, +} from "./reflection-item-store.js"; +import { getReflectionMappedDecayDefaults, type ReflectionMappedKind } from "./reflection-mapped-metadata.js"; +import { computeReflectionScore, normalizeReflectionLineForAggregation } from "./reflection-ranking.js"; + +export const REFLECTION_DERIVE_LOGISTIC_MIDPOINT_DAYS = 3; +export const REFLECTION_DERIVE_LOGISTIC_K = 1.2; +export const REFLECTION_DERIVE_FALLBACK_BASE_WEIGHT = 0.35; + +export const DEFAULT_REFLECTION_DERIVED_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1000; +export const DEFAULT_REFLECTION_MAPPED_MAX_AGE_MS = 60 * 24 * 60 * 60 * 1000; + +type ReflectionStoreKind = "event" | "item-invariant" | "item-derived" | "combined-legacy"; + +type ReflectionErrorSignalLike = { + signatureHash: string; +}; + +interface ReflectionStorePayload { + text: string; + metadata: Record; + kind: ReflectionStoreKind; +} + +interface BuildReflectionStorePayloadsParams { + reflectionText: string; + sessionKey: string; + sessionId: string; + agentId: string; + command: string; + scope: string; + toolErrorSignals: ReflectionErrorSignalLike[]; + runAt: number; + usedFallback: boolean; + eventId?: string; + sourceReflectionPath?: string; + writeLegacyCombined?: boolean; +} + +export function buildReflectionStorePayloads(params: BuildReflectionStorePayloadsParams): { + eventId: string; + slices: ReflectionSlices; + payloads: ReflectionStorePayload[]; +} { + const slices = extractInjectableReflectionSlices(params.reflectionText); + const eventId = params.eventId || createReflectionEventId({ + runAt: params.runAt, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: params.agentId, + command: params.command, + }); + + const payloads: ReflectionStorePayload[] = [ + buildReflectionEventPayload({ + eventId, + scope: params.scope, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: params.agentId, + command: params.command, + toolErrorSignals: params.toolErrorSignals, + runAt: params.runAt, + usedFallback: params.usedFallback, + sourceReflectionPath: params.sourceReflectionPath, + }), + ]; + + const itemPayloads = buildReflectionItemPayloads({ + items: extractInjectableReflectionSliceItems(params.reflectionText), + eventId, + agentId: params.agentId, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + runAt: params.runAt, + usedFallback: params.usedFallback, + toolErrorSignals: params.toolErrorSignals, + sourceReflectionPath: params.sourceReflectionPath, + }); + payloads.push(...itemPayloads); + + if (params.writeLegacyCombined !== false && (slices.invariants.length > 0 || slices.derived.length > 0)) { + payloads.push(buildLegacyCombinedPayload({ + slices, + scope: params.scope, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: params.agentId, + command: params.command, + toolErrorSignals: params.toolErrorSignals, + runAt: params.runAt, + usedFallback: params.usedFallback, + sourceReflectionPath: params.sourceReflectionPath, + })); + } + + return { eventId, slices, payloads }; +} + +function buildLegacyCombinedPayload(params: { + slices: ReflectionSlices; + sessionKey: string; + sessionId: string; + agentId: string; + command: string; + scope: string; + toolErrorSignals: ReflectionErrorSignalLike[]; + runAt: number; + usedFallback: boolean; + sourceReflectionPath?: string; +}): ReflectionStorePayload { + const dateYmd = new Date(params.runAt).toISOString().split("T")[0]; + const deriveQuality = computeDerivedLineQuality(params.slices.derived.length); + const deriveBaseWeight = params.usedFallback ? REFLECTION_DERIVE_FALLBACK_BASE_WEIGHT : 1; + + return { + kind: "combined-legacy", + text: [ + `reflection · ${params.scope} · ${dateYmd}`, + `Session Reflection (${new Date(params.runAt).toISOString()})`, + `Session Key: ${params.sessionKey}`, + `Session ID: ${params.sessionId}`, + "", + "Invariants:", + ...(params.slices.invariants.length > 0 ? params.slices.invariants.map((x) => `- ${x}`) : ["- (none captured)"]), + "", + "Derived:", + ...(params.slices.derived.length > 0 ? params.slices.derived.map((x) => `- ${x}`) : ["- (none captured)"]), + ].join("\n"), + metadata: { + type: "memory-reflection", + stage: "reflect-store", + reflectionVersion: 3, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + agentId: params.agentId, + command: params.command, + storedAt: params.runAt, + invariants: params.slices.invariants, + derived: params.slices.derived, + usedFallback: params.usedFallback, + errorSignals: params.toolErrorSignals.map((s) => s.signatureHash), + decayModel: "logistic", + decayMidpointDays: REFLECTION_DERIVE_LOGISTIC_MIDPOINT_DAYS, + decayK: REFLECTION_DERIVE_LOGISTIC_K, + deriveBaseWeight, + deriveQuality, + deriveSource: params.usedFallback ? "fallback" : "normal", + ...(params.sourceReflectionPath ? { sourceReflectionPath: params.sourceReflectionPath } : {}), + }, + }; +} + +interface ReflectionStoreDeps { + embedPassage: (text: string) => Promise; + vectorSearch: ( + vector: number[], + limit?: number, + minScore?: number, + scopeFilter?: string[] + ) => Promise; + store: (entry: Omit) => Promise; +} + +interface StoreReflectionToLanceDBParams extends BuildReflectionStorePayloadsParams, ReflectionStoreDeps { + dedupeThreshold?: number; +} + +export async function storeReflectionToLanceDB(params: StoreReflectionToLanceDBParams): Promise<{ + stored: boolean; + eventId: string; + slices: ReflectionSlices; + storedKinds: ReflectionStoreKind[]; +}> { + const { eventId, slices, payloads } = buildReflectionStorePayloads(params); + const storedKinds: ReflectionStoreKind[] = []; + const dedupeThreshold = Number.isFinite(params.dedupeThreshold) ? Number(params.dedupeThreshold) : 0.97; + + for (const payload of payloads) { + const vector = await params.embedPassage(payload.text); + + if (payload.kind === "combined-legacy") { + const existing = await params.vectorSearch(vector, 1, 0.1, [params.scope]); + if (existing.length > 0 && existing[0].score > dedupeThreshold) { + continue; + } + } + + await params.store({ + text: payload.text, + vector, + category: "reflection", + scope: params.scope, + importance: resolveReflectionImportance(payload.kind), + metadata: JSON.stringify(payload.metadata), + }); + storedKinds.push(payload.kind); + } + + return { stored: storedKinds.length > 0, eventId, slices, storedKinds }; +} + +function resolveReflectionImportance(kind: ReflectionStoreKind): number { + if (kind === "event") return 0.55; + if (kind === "item-invariant") return 0.82; + if (kind === "item-derived") return 0.78; + return 0.75; +} + +export interface LoadReflectionSlicesParams { + entries: MemoryEntry[]; + agentId: string; + now?: number; + deriveMaxAgeMs?: number; + invariantMaxAgeMs?: number; +} + +export function loadAgentReflectionSlicesFromEntries(params: LoadReflectionSlicesParams): { + invariants: string[]; + derived: string[]; +} { + const now = Number.isFinite(params.now) ? Number(params.now) : Date.now(); + const deriveMaxAgeMs = Number.isFinite(params.deriveMaxAgeMs) + ? Math.max(0, Number(params.deriveMaxAgeMs)) + : DEFAULT_REFLECTION_DERIVED_MAX_AGE_MS; + const invariantMaxAgeMs = Number.isFinite(params.invariantMaxAgeMs) + ? Math.max(0, Number(params.invariantMaxAgeMs)) + : undefined; + + const reflectionRows = params.entries + .map((entry) => ({ entry, metadata: parseReflectionMetadata(entry.metadata) })) + .filter(({ metadata }) => isReflectionMetadataType(metadata.type) && isOwnedByAgent(metadata, params.agentId)) + .sort((a, b) => b.entry.timestamp - a.entry.timestamp) + .slice(0, 160); + + const itemRows = reflectionRows.filter(({ metadata }) => metadata.type === "memory-reflection-item"); + const legacyRows = reflectionRows.filter(({ metadata }) => metadata.type === "memory-reflection"); + + const invariantCandidates = buildInvariantCandidates(itemRows, legacyRows); + const derivedCandidates = buildDerivedCandidates(itemRows, legacyRows, params.agentId); + + const invariants = rankReflectionLines(invariantCandidates, { + now, + maxAgeMs: invariantMaxAgeMs, + limit: 8, + }); + + const derived = rankReflectionLines(derivedCandidates, { + now, + maxAgeMs: deriveMaxAgeMs, + limit: 10, + }); + + return { invariants, derived }; +} + +type WeightedLineCandidate = { + line: string; + timestamp: number; + midpointDays: number; + k: number; + baseWeight: number; + quality: number; + usedFallback: boolean; +}; + +function buildInvariantCandidates( + itemRows: Array<{ entry: MemoryEntry; metadata: Record }>, + legacyRows: Array<{ entry: MemoryEntry; metadata: Record }> +): WeightedLineCandidate[] { + const itemCandidates = itemRows + .filter(({ metadata }) => metadata.itemKind === "invariant") + .flatMap(({ entry, metadata }) => { + const lines = sanitizeReflectionSliceLines([entry.text]); + const safeLines = sanitizeInjectableReflectionLines([entry.text]); + if (safeLines.length === 0) return []; + + const defaults = getReflectionItemDecayDefaults("invariant"); + const timestamp = metadataTimestamp(metadata, entry.timestamp); + return safeLines.map((line) => ({ + line, + timestamp, + midpointDays: readPositiveNumber(metadata.decayMidpointDays, defaults.midpointDays), + k: readPositiveNumber(metadata.decayK, defaults.k), + baseWeight: readPositiveNumber(metadata.baseWeight, defaults.baseWeight), + quality: readClampedNumber(metadata.quality, defaults.quality, 0.2, 1), + usedFallback: metadata.usedFallback === true, + })); + }); + + if (itemCandidates.length > 0) return itemCandidates; + + return legacyRows.flatMap(({ entry, metadata }) => { + const defaults = getReflectionItemDecayDefaults("invariant"); + const timestamp = metadataTimestamp(metadata, entry.timestamp); + const lines = sanitizeInjectableReflectionLines(toStringArray(metadata.invariants)); + return lines.map((line) => ({ + line, + timestamp, + midpointDays: defaults.midpointDays, + k: defaults.k, + baseWeight: defaults.baseWeight, + quality: defaults.quality, + usedFallback: metadata.usedFallback === true, + })); + }); +} + function buildDerivedCandidates( itemRows: Array<{ entry: MemoryEntry; metadata: Record }>, - legacyRows: Array<{ entry: MemoryEntry; metadata: Record }> + legacyRows: Array<{ entry: MemoryEntry; metadata: Record }>, + agentId: string ): WeightedLineCandidate[] { const itemCandidates = itemRows .filter(({ metadata }) => metadata.itemKind === "derived") @@ -348,257 +349,287 @@ function buildDerivedCandidates( if (itemCandidates.length > 0) return itemCandidates; - return legacyRows.flatMap(({ entry, metadata }) => { - const timestamp = metadataTimestamp(metadata, entry.timestamp); - const lines = sanitizeInjectableReflectionLines(toStringArray(metadata.derived)); - if (lines.length === 0) return []; - - const defaults = { - midpointDays: REFLECTION_DERIVE_LOGISTIC_MIDPOINT_DAYS, - k: REFLECTION_DERIVE_LOGISTIC_K, - baseWeight: resolveLegacyDeriveBaseWeight(metadata), - quality: computeDerivedLineQuality(lines.length), - }; - - return lines.map((line) => ({ - line, - timestamp, - midpointDays: readPositiveNumber(metadata.decayMidpointDays, defaults.midpointDays), - k: readPositiveNumber(metadata.decayK, defaults.k), - baseWeight: readPositiveNumber(metadata.deriveBaseWeight, defaults.baseWeight), - quality: readClampedNumber(metadata.deriveQuality, defaults.quality, 0.2, 1), - usedFallback: metadata.usedFallback === true, - })); - }); -} - -function rankReflectionLines( - candidates: WeightedLineCandidate[], - options: { now: number; maxAgeMs?: number; limit: number } -): string[] { - type WeightedLine = { line: string; score: number; latestTs: number }; - const lineScores = new Map(); - - for (const candidate of candidates) { - const timestamp = Number.isFinite(candidate.timestamp) ? candidate.timestamp : options.now; - if (Number.isFinite(options.maxAgeMs) && options.maxAgeMs! >= 0 && options.now - timestamp > options.maxAgeMs!) { - continue; - } - - const ageDays = Math.max(0, (options.now - timestamp) / 86_400_000); - const score = computeReflectionScore({ - ageDays, - midpointDays: candidate.midpointDays, - k: candidate.k, - baseWeight: candidate.baseWeight, - quality: candidate.quality, - usedFallback: candidate.usedFallback, - }); - if (!Number.isFinite(score) || score <= 0) continue; - - const key = normalizeReflectionLineForAggregation(candidate.line); - if (!key) continue; - - const current = lineScores.get(key); - if (!current) { - lineScores.set(key, { line: candidate.line, score, latestTs: timestamp }); - continue; - } - - current.score += score; - if (timestamp > current.latestTs) { - current.latestTs = timestamp; - current.line = candidate.line; - } - } - - return [...lineScores.values()] - .sort((a, b) => { - if (b.score !== a.score) return b.score - a.score; - if (b.latestTs !== a.latestTs) return b.latestTs - a.latestTs; - return a.line.localeCompare(b.line); + // ★ 修復:legacy fallback 中,有 derived 內容的 row(來自 combined-legacy), + // 如果 owner 是 "main",則對 sub-agent 不可見,防止 context bleed。 + // 純 legacy invariant(無 derived)不受影響,正常可見。 + return legacyRows + .filter(({ metadata }) => { + const derived = metadata.derived; + const hasDerivedContent = Array.isArray(derived) && derived.length > 0; + if (!hasDerivedContent) return true; // 無 derived → 正常 legacy invariant + const owner = typeof metadata.agentId === "string" ? metadata.agentId.trim() : ""; + if (!owner) return false; // 有 derived 但無 owner → 不可見 + if (owner === "main") return false; // ★ main 的 derived 不外流 + return owner === agentId; // 其他 agent 的 derived → 限本人 }) - .slice(0, options.limit) - .map((item) => item.line); -} - -function isReflectionMetadataType(type: unknown): boolean { - return type === "memory-reflection-item" || type === "memory-reflection"; -} - -function isOwnedByAgent(metadata: Record, agentId: string): boolean { - const owner = typeof metadata.agentId === "string" ? metadata.agentId.trim() : ""; - if (!owner) return true; - return owner === agentId || owner === "main"; -} - -function toStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return []; - return value - .map((item) => String(item).trim()) - .filter(Boolean); -} - -function metadataTimestamp(metadata: Record, fallbackTs: number): number { - const storedAt = Number(metadata.storedAt); - if (Number.isFinite(storedAt) && storedAt > 0) return storedAt; - return Number.isFinite(fallbackTs) ? fallbackTs : Date.now(); -} - -function readPositiveNumber(value: unknown, fallback: number): number { - const num = Number(value); - if (!Number.isFinite(num) || num <= 0) return fallback; - return num; -} - -function readClampedNumber(value: unknown, fallback: number, min: number, max: number): number { - const num = Number(value); - const resolved = Number.isFinite(num) ? num : fallback; - return Math.max(min, Math.min(max, resolved)); -} - -export function computeDerivedLineQuality(nonPlaceholderLineCount: number): number { - const n = Number.isFinite(nonPlaceholderLineCount) ? Math.max(0, Math.floor(nonPlaceholderLineCount)) : 0; - if (n <= 0) return 0.2; - return Math.min(1, 0.55 + Math.min(6, n) * 0.075); -} - -function resolveLegacyDeriveBaseWeight(metadata: Record): number { - const explicit = Number(metadata.deriveBaseWeight); - if (Number.isFinite(explicit) && explicit > 0) { - return Math.max(0.1, Math.min(1.2, explicit)); - } - if (metadata.usedFallback === true) { - return REFLECTION_DERIVE_FALLBACK_BASE_WEIGHT; - } - return 1; -} - -export interface LoadReflectionMappedRowsParams { - entries: MemoryEntry[]; - agentId: string; - now?: number; - maxAgeMs?: number; - maxPerKind?: number; -} - -export interface ReflectionMappedSlices { - userModel: string[]; - agentModel: string[]; - lesson: string[]; - decision: string[]; -} - -export function loadReflectionMappedRowsFromEntries(params: LoadReflectionMappedRowsParams): ReflectionMappedSlices { - const now = Number.isFinite(params.now) ? Number(params.now) : Date.now(); - const maxAgeMs = Number.isFinite(params.maxAgeMs) - ? Math.max(0, Number(params.maxAgeMs)) - : DEFAULT_REFLECTION_MAPPED_MAX_AGE_MS; - const maxPerKind = Number.isFinite(params.maxPerKind) ? Math.max(1, Math.floor(Number(params.maxPerKind))) : 10; - - type WeightedMapped = { - text: string; - mappedKind: ReflectionMappedKind; - timestamp: number; - midpointDays: number; - k: number; - baseWeight: number; - quality: number; - usedFallback: boolean; - }; - - const weighted: WeightedMapped[] = params.entries - .map((entry) => ({ entry, metadata: parseReflectionMetadata(entry.metadata) })) - .filter(({ metadata }) => metadata.type === "memory-reflection-mapped" && isOwnedByAgent(metadata, params.agentId)) .flatMap(({ entry, metadata }) => { - const mappedKind = parseMappedKind(metadata.mappedKind); - if (!mappedKind) return []; - - const lines = sanitizeReflectionSliceLines([entry.text]); - if (lines.length === 0) return []; - - const defaults = getReflectionMappedDecayDefaults(mappedKind); - const timestamp = metadataTimestamp(metadata, entry.timestamp); - - return lines.map((line) => ({ - text: line, - mappedKind, - timestamp, - midpointDays: readPositiveNumber(metadata.decayMidpointDays, defaults.midpointDays), - k: readPositiveNumber(metadata.decayK, defaults.k), - baseWeight: readPositiveNumber(metadata.baseWeight, defaults.baseWeight), - quality: readClampedNumber(metadata.quality, defaults.quality, 0.2, 1), - usedFallback: metadata.usedFallback === true, - })); - }); - - const grouped = new Map(); - - for (const item of weighted) { - if (now - item.timestamp > maxAgeMs) continue; - const ageDays = Math.max(0, (now - item.timestamp) / 86_400_000); - const score = computeReflectionScore({ - ageDays, - midpointDays: item.midpointDays, - k: item.k, - baseWeight: item.baseWeight, - quality: item.quality, - usedFallback: item.usedFallback, - }); - if (!Number.isFinite(score) || score <= 0) continue; - - const normalized = normalizeReflectionLineForAggregation(item.text); - if (!normalized) continue; - - const key = `${item.mappedKind}::${normalized}`; - const current = grouped.get(key); - if (!current) { - grouped.set(key, { text: item.text, score, latestTs: item.timestamp, kind: item.mappedKind }); - continue; - } - - current.score += score; - if (item.timestamp > current.latestTs) { - current.latestTs = item.timestamp; - current.text = item.text; - } - } - - const sortedByKind = (kind: ReflectionMappedKind) => [...grouped.values()] - .filter((row) => row.kind === kind) - .sort((a, b) => { - if (b.score !== a.score) return b.score - a.score; - if (b.latestTs !== a.latestTs) return b.latestTs - a.latestTs; - return a.text.localeCompare(b.text); - }) - .slice(0, maxPerKind) - .map((row) => row.text); - - return { - userModel: sortedByKind("user-model"), - agentModel: sortedByKind("agent-model"), - lesson: sortedByKind("lesson"), - decision: sortedByKind("decision"), - }; -} - -function parseMappedKind(value: unknown): ReflectionMappedKind | null { - if (value === "user-model" || value === "agent-model" || value === "lesson" || value === "decision") { - return value; - } - return null; -} - -export function getReflectionDerivedDecayDefaults(): { midpointDays: number; k: number } { - return { - midpointDays: REFLECTION_DERIVED_DECAY_MIDPOINT_DAYS, - k: REFLECTION_DERIVED_DECAY_K, - }; -} - -export function getReflectionInvariantDecayDefaults(): { midpointDays: number; k: number } { - return { - midpointDays: REFLECTION_INVARIANT_DECAY_MIDPOINT_DAYS, - k: REFLECTION_INVARIANT_DECAY_K, - }; -} + const timestamp = metadataTimestamp(metadata, entry.timestamp); + const lines = sanitizeInjectableReflectionLines(toStringArray(metadata.derived)); + if (lines.length === 0) return []; + + const defaults = { + midpointDays: REFLECTION_DERIVE_LOGISTIC_MIDPOINT_DAYS, + k: REFLECTION_DERIVE_LOGISTIC_K, + baseWeight: resolveLegacyDeriveBaseWeight(metadata), + quality: computeDerivedLineQuality(lines.length), + }; + + return lines.map((line) => ({ + line, + timestamp, + midpointDays: readPositiveNumber(metadata.decayMidpointDays, defaults.midpointDays), + k: readPositiveNumber(metadata.decayK, defaults.k), + baseWeight: readPositiveNumber(metadata.deriveBaseWeight, defaults.baseWeight), + quality: readClampedNumber(metadata.deriveQuality, defaults.quality, 0.2, 1), + usedFallback: metadata.usedFallback === true, + })); + }); +} + +function rankReflectionLines( + candidates: WeightedLineCandidate[], + options: { now: number; maxAgeMs?: number; limit: number } +): string[] { + type WeightedLine = { line: string; score: number; latestTs: number }; + const lineScores = new Map(); + + for (const candidate of candidates) { + const timestamp = Number.isFinite(candidate.timestamp) ? candidate.timestamp : options.now; + if (Number.isFinite(options.maxAgeMs) && options.maxAgeMs! >= 0 && options.now - timestamp > options.maxAgeMs!) { + continue; + } + + const ageDays = Math.max(0, (options.now - timestamp) / 86_400_000); + const score = computeReflectionScore({ + ageDays, + midpointDays: candidate.midpointDays, + k: candidate.k, + baseWeight: candidate.baseWeight, + quality: candidate.quality, + usedFallback: candidate.usedFallback, + }); + if (!Number.isFinite(score) || score <= 0) continue; + + const key = normalizeReflectionLineForAggregation(candidate.line); + if (!key) continue; + + const current = lineScores.get(key); + if (!current) { + lineScores.set(key, { line: candidate.line, score, latestTs: timestamp }); + continue; + } + + current.score += score; + if (timestamp > current.latestTs) { + current.latestTs = timestamp; + current.line = candidate.line; + } + } + + return [...lineScores.values()] + .sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + if (b.latestTs !== a.latestTs) return b.latestTs - a.latestTs; + return a.line.localeCompare(b.line); + }) + .slice(0, options.limit) + .map((item) => item.line); +} + +function isReflectionMetadataType(type: unknown): boolean { + return type === "memory-reflection-item" || type === "memory-reflection"; +} + +export function isOwnedByAgent(metadata: Record, agentId: string): boolean { + const owner = typeof metadata.agentId === "string" ? metadata.agentId.trim() : ""; + + const itemKind = metadata.itemKind; + + // itemKind 只存在於 memory-reflection-item(derived | invariant) + // legacy (memory-reflection) 和 mapped (memory-reflection-mapped) 沒有 itemKind + // 因此 undefined !== "derived",會走 main fallback(維護相容性) + + // 若是 derived 項目(memory-reflection-item):不做 main fallback, + // 且 derived 不允許空白 owner(空白 owner 的 derived 應完全不可見,防止洩漏) + // itemKind 必須是 string type,否則會錯誤進入 derived 分支 + // (null/undefined/number 等非 string 值應走 legacy fallback) + if (typeof itemKind === "string" && itemKind === "derived") { + if (!owner) return false; + return owner === agentId; + } + + // Invariant / legacy / mapped:允許空的 owner 通行,維護舊的 main fallback + if (!owner) return true; + return owner === agentId || owner === "main"; +} + +function toStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .map((item) => String(item).trim()) + .filter(Boolean); +} + +function metadataTimestamp(metadata: Record, fallbackTs: number): number { + const storedAt = Number(metadata.storedAt); + if (Number.isFinite(storedAt) && storedAt > 0) return storedAt; + return Number.isFinite(fallbackTs) ? fallbackTs : Date.now(); +} + +function readPositiveNumber(value: unknown, fallback: number): number { + const num = Number(value); + if (!Number.isFinite(num) || num <= 0) return fallback; + return num; +} + +function readClampedNumber(value: unknown, fallback: number, min: number, max: number): number { + const num = Number(value); + const resolved = Number.isFinite(num) ? num : fallback; + return Math.max(min, Math.min(max, resolved)); +} + +export function computeDerivedLineQuality(nonPlaceholderLineCount: number): number { + const n = Number.isFinite(nonPlaceholderLineCount) ? Math.max(0, Math.floor(nonPlaceholderLineCount)) : 0; + if (n <= 0) return 0.2; + return Math.min(1, 0.55 + Math.min(6, n) * 0.075); +} + +function resolveLegacyDeriveBaseWeight(metadata: Record): number { + const explicit = Number(metadata.deriveBaseWeight); + if (Number.isFinite(explicit) && explicit > 0) { + return Math.max(0.1, Math.min(1.2, explicit)); + } + if (metadata.usedFallback === true) { + return REFLECTION_DERIVE_FALLBACK_BASE_WEIGHT; + } + return 1; +} + +export interface LoadReflectionMappedRowsParams { + entries: MemoryEntry[]; + agentId: string; + now?: number; + maxAgeMs?: number; + maxPerKind?: number; +} + +export interface ReflectionMappedSlices { + userModel: string[]; + agentModel: string[]; + lesson: string[]; + decision: string[]; +} + +export function loadReflectionMappedRowsFromEntries(params: LoadReflectionMappedRowsParams): ReflectionMappedSlices { + const now = Number.isFinite(params.now) ? Number(params.now) : Date.now(); + const maxAgeMs = Number.isFinite(params.maxAgeMs) + ? Math.max(0, Number(params.maxAgeMs)) + : DEFAULT_REFLECTION_MAPPED_MAX_AGE_MS; + const maxPerKind = Number.isFinite(params.maxPerKind) ? Math.max(1, Math.floor(Number(params.maxPerKind))) : 10; + + type WeightedMapped = { + text: string; + mappedKind: ReflectionMappedKind; + timestamp: number; + midpointDays: number; + k: number; + baseWeight: number; + quality: number; + usedFallback: boolean; + }; + + const weighted: WeightedMapped[] = params.entries + .map((entry) => ({ entry, metadata: parseReflectionMetadata(entry.metadata) })) + .filter(({ metadata }) => metadata.type === "memory-reflection-mapped" && isOwnedByAgent(metadata, params.agentId)) + .flatMap(({ entry, metadata }) => { + const mappedKind = parseMappedKind(metadata.mappedKind); + if (!mappedKind) return []; + + const lines = sanitizeReflectionSliceLines([entry.text]); + if (lines.length === 0) return []; + + const defaults = getReflectionMappedDecayDefaults(mappedKind); + const timestamp = metadataTimestamp(metadata, entry.timestamp); + + return lines.map((line) => ({ + text: line, + mappedKind, + timestamp, + midpointDays: readPositiveNumber(metadata.decayMidpointDays, defaults.midpointDays), + k: readPositiveNumber(metadata.decayK, defaults.k), + baseWeight: readPositiveNumber(metadata.baseWeight, defaults.baseWeight), + quality: readClampedNumber(metadata.quality, defaults.quality, 0.2, 1), + usedFallback: metadata.usedFallback === true, + })); + }); + + const grouped = new Map(); + + for (const item of weighted) { + if (now - item.timestamp > maxAgeMs) continue; + const ageDays = Math.max(0, (now - item.timestamp) / 86_400_000); + const score = computeReflectionScore({ + ageDays, + midpointDays: item.midpointDays, + k: item.k, + baseWeight: item.baseWeight, + quality: item.quality, + usedFallback: item.usedFallback, + }); + if (!Number.isFinite(score) || score <= 0) continue; + + const normalized = normalizeReflectionLineForAggregation(item.text); + if (!normalized) continue; + + const key = `${item.mappedKind}::${normalized}`; + const current = grouped.get(key); + if (!current) { + grouped.set(key, { text: item.text, score, latestTs: item.timestamp, kind: item.mappedKind }); + continue; + } + + current.score += score; + if (item.timestamp > current.latestTs) { + current.latestTs = item.timestamp; + current.text = item.text; + } + } + + const sortedByKind = (kind: ReflectionMappedKind) => [...grouped.values()] + .filter((row) => row.kind === kind) + .sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + if (b.latestTs !== a.latestTs) return b.latestTs - a.latestTs; + return a.text.localeCompare(b.text); + }) + .slice(0, maxPerKind) + .map((row) => row.text); + + return { + userModel: sortedByKind("user-model"), + agentModel: sortedByKind("agent-model"), + lesson: sortedByKind("lesson"), + decision: sortedByKind("decision"), + }; +} + +function parseMappedKind(value: unknown): ReflectionMappedKind | null { + if (value === "user-model" || value === "agent-model" || value === "lesson" || value === "decision") { + return value; + } + return null; +} + +export function getReflectionDerivedDecayDefaults(): { midpointDays: number; k: number } { + return { + midpointDays: REFLECTION_DERIVED_DECAY_MIDPOINT_DAYS, + k: REFLECTION_DERIVED_DECAY_K, + }; +} + +export function getReflectionInvariantDecayDefaults(): { midpointDays: number; k: number } { + return { + midpointDays: REFLECTION_INVARIANT_DECAY_MIDPOINT_DAYS, + k: REFLECTION_INVARIANT_DECAY_K, + }; +} diff --git a/test/buildDerivedCandidates-legacy-fallback.test.mjs b/test/buildDerivedCandidates-legacy-fallback.test.mjs new file mode 100644 index 00000000..9f7b3321 --- /dev/null +++ b/test/buildDerivedCandidates-legacy-fallback.test.mjs @@ -0,0 +1,106 @@ +// Regression test:combined-legacy fallback 不應讓 sub-agent 看到 main derived +// 對應 PR: https://github.com/CortexReach/memory-lancedb-pro/pull/522 +// Review: https://github.com/CortexReach/memory-lancedb-pro/pull/522#pullrequestreview-4185627472 +// +// 問題:buildDerivedCandidates() 在 itemCandidates 為空時, +// fallback 到 legacyRows。此時 combined-legacy row(無 itemKind, +// 但有 metadata.derived)被視為 legacy,isOwnedByAgent 接受 owner="main", +// 導致 sub-agent 可以看到 main 的 derived 內容。 +// +// 修復:在 legacy fallback 前,先過濾掉 owner="main" 且有 derived 內容的 legacy row。 + +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { buildDerivedCandidates } from "../src/reflection-store.ts"; + +// 輔助:模擬 legacy row(type="memory-reflection",來自 buildLegacyCombinedPayload) +function makeLegacyRow(agentId, derived = []) { + return { + entry: { text: "dummy legacy", timestamp: Date.now() }, + metadata: { type: "memory-reflection", agentId, derived }, + }; +} + +// 輔助:模擬 item-derived row(type="memory-reflection-item") +function makeItemRow(agentId, itemKind = "derived") { + return { + entry: { text: "dummy item", timestamp: Date.now() }, + metadata: { type: "memory-reflection-item", agentId, itemKind }, + }; +} + +describe("buildDerivedCandidates — combined-legacy fallback ownership (regression)", () => { + describe("itemCandidates 為空,fallback 到 legacy rows", () => { + it("main 的 derived legacy row → sub-agent 不可見(核心修復)", () => { + const itemRows = []; + const legacyRows = [makeLegacyRow("main", ["some derived content"])]; + const candidates = buildDerivedCandidates(itemRows, legacyRows, "sub-agent-A"); + assert.strictEqual( + candidates.length, + 0, + "sub-agent 不該看到 main 的 derived fallback" + ); + }); + + it("main 的 derived legacy row → main 自己可見", () => { + const itemRows = []; + const legacyRows = [makeLegacyRow("main", ["main's derived content"])]; + const candidates = buildDerivedCandidates(itemRows, legacyRows, "main"); + assert.ok( + candidates.length > 0, + "main 應看到自己的 derived fallback" + ); + }); + + it("agent-x 的 derived legacy row → agent-x 自己可見", () => { + const itemRows = []; + const legacyRows = [makeLegacyRow("agent-x", ["x's derived"])]; + const candidates = buildDerivedCandidates(itemRows, legacyRows, "agent-x"); + assert.ok(candidates.length > 0, "agent-x 應看到自己的 derived fallback"); + }); + + it("agent-x 的 derived legacy row → agent-y 不可見", () => { + const itemRows = []; + const legacyRows = [makeLegacyRow("agent-x", ["x's derived"])]; + const candidates = buildDerivedCandidates(itemRows, legacyRows, "agent-y"); + assert.strictEqual( + candidates.length, + 0, + "agent-y 不該看到 agent-x 的 derived fallback" + ); + }); + + it("純 legacy invariant(無 derived)→ sub-agent 可見(不應被阻擋)", () => { + const itemRows = []; + const legacyRows = [makeLegacyRow("main", [])]; // 無 derived + const candidates = buildDerivedCandidates(itemRows, legacyRows, "sub-agent-A"); + assert.ok( + candidates.length > 0, + "sub-agent 應看到純 legacy invariant(無 derived 不應阻擋)" + ); + }); + + it("有 item-derived candidates 時,不走 legacy fallback(item path 優先)", () => { + // item-derived row(main 寫的)會被 isOwnedByAgent 擋掉, + // 所以傳入 buildDerivedCandidates 時 itemRows 已是過濾後的狀態(不含 main 的 derived) + const itemRows = [makeItemRow("sub-agent-B", "derived")]; + const legacyRows = [makeLegacyRow("main", ["should be ignored"])]; + const candidates = buildDerivedCandidates(itemRows, legacyRows, "sub-agent-A"); + assert.ok( + candidates.length === 0, + "item-derived 已被隔離,sub-agent-A 不應看到任何 derived" + ); + }); + + it("有 derived 但 owner 為空字串 → 不可見(防禦性)", () => { + const itemRows = []; + const legacyRows = [makeLegacyRow("", ["some content"])]; + const candidates = buildDerivedCandidates(itemRows, legacyRows, "sub-agent-A"); + assert.strictEqual( + candidates.length, + 0, + "owner 為空的有 derived legacy row 不應對任何 agent 可見" + ); + }); + }); +}); diff --git a/test/isOwnedByAgent.test.mjs b/test/isOwnedByAgent.test.mjs new file mode 100644 index 00000000..70c5146c --- /dev/null +++ b/test/isOwnedByAgent.test.mjs @@ -0,0 +1,68 @@ +// isOwnedByAgent unit tests — Issue #448 fix verification +import { describe, it } from "node:test"; +import assert from "node:assert"; + +// Import from production source — NOT a local copy +import { isOwnedByAgent } from "../src/reflection-store.ts"; + +describe("isOwnedByAgent — derived ownership fix (Issue #448)", () => { + describe("itemKind === 'derived' (memory-reflection-item)", () => { + it("main's derived -> main visible", () => { + assert.strictEqual(isOwnedByAgent({ itemKind: "derived", agentId: "main" }, "main"), true); + }); + it("main's derived -> sub-agent invisible (core bug fix)", () => { + assert.strictEqual(isOwnedByAgent({ itemKind: "derived", agentId: "main" }, "sub-agent-A"), false); + }); + it("agent-x's derived -> agent-x visible", () => { + assert.strictEqual(isOwnedByAgent({ itemKind: "derived", agentId: "agent-x" }, "agent-x"), true); + }); + it("agent-x's derived -> agent-y invisible", () => { + assert.strictEqual(isOwnedByAgent({ itemKind: "derived", agentId: "agent-x" }, "agent-y"), false); + }); + it("derived + empty owner -> completely invisible (guard)", () => { + assert.strictEqual(isOwnedByAgent({ itemKind: "derived", agentId: "" }, "main"), false); + assert.strictEqual(isOwnedByAgent({ itemKind: "derived", agentId: "" }, "sub-agent"), false); + }); + }); + + describe("itemKind === 'invariant' (maintain fallback)", () => { + it("main's invariant -> sub-agent visible", () => { + assert.strictEqual(isOwnedByAgent({ itemKind: "invariant", agentId: "main" }, "sub-agent-A"), true); + }); + it("agent-x's invariant -> agent-x visible", () => { + assert.strictEqual(isOwnedByAgent({ itemKind: "invariant", agentId: "agent-x" }, "agent-x"), true); + }); + it("agent-x's invariant -> agent-y invisible", () => { + assert.strictEqual(isOwnedByAgent({ itemKind: "invariant", agentId: "agent-x" }, "agent-y"), false); + }); + }); + + describe("legacy / mapped (no itemKind — maintain fallback)", () => { + it("main legacy -> sub-agent visible", () => { + assert.strictEqual(isOwnedByAgent({ agentId: "main" }, "sub-agent-A"), true); + }); + it("agent-x legacy -> agent-x visible", () => { + assert.strictEqual(isOwnedByAgent({ agentId: "agent-x" }, "agent-x"), true); + }); + it("agent-x legacy -> agent-y invisible", () => { + assert.strictEqual(isOwnedByAgent({ agentId: "agent-x" }, "agent-y"), false); + }); + }); + + describe("malformed itemKind (fail-closed)", () => { + // When itemKind is not a string, it cannot be "derived" (typeof guard) + // Falls through to fallback: empty owner -> true, owner-main -> true + it("itemKind = null falls through to fallback (main -> sub visible)", () => { + assert.strictEqual(isOwnedByAgent({ itemKind: null, agentId: "main" }, "sub-agent-A"), true); + }); + it("itemKind = undefined falls through to fallback (main -> sub visible)", () => { + assert.strictEqual(isOwnedByAgent({ itemKind: undefined, agentId: "main" }, "sub-agent-A"), true); + }); + it("itemKind = number falls through to fallback (main -> sub visible)", () => { + assert.strictEqual(isOwnedByAgent({ itemKind: 42, agentId: "main" }, "sub-agent-A"), true); + }); + it("itemKind = non-derived string falls through to fallback (main -> sub visible)", () => { + assert.strictEqual(isOwnedByAgent({ itemKind: "weird-kind", agentId: "main" }, "sub-agent-A"), true); + }); + }); +});