From 8c6b15edc897c05154547b8d84df7654547d8985 Mon Sep 17 00:00:00 2001 From: eightHundreds Date: Thu, 9 Apr 2026 11:04:55 +0800 Subject: [PATCH 1/6] feat: per-user memory isolation via template scopes (#555) Add support for template variables in `scopes.default` (e.g. `user:${accountId}`) and wildcard patterns in `agentAccess` (e.g. `user:*`) to enable per-user memory isolation when multiple users interact with the same bot. Key changes: - Template utilities: hasTemplateVars, resolveTemplateScope, matchesWildcardScope, inferWildcardFromTemplate in src/scopes.ts - Hook-layer template resolution via resolveHookDefaultScope() in index.ts - SQL wildcard support: scopeFilterToSqlCondition with proper LIKE escaping - Application-layer wildcard matching: scopeFilterIncludes replaces includes() - Smart extractor dedup narrowed to [defaultScope] instead of accessibleScopes - 30 new tests covering template/wildcard utilities and integration scenarios Design: scope system stays purely static (Plan B), template resolution is a hook-layer concern. No auto-wildcard injection into accessible scopes. Co-Authored-By: Claude Opus 4.6 --- index.ts | 32 +++- package-lock.json | 7 +- package.json | 2 +- src/scopes.ts | 123 +++++++++++++-- src/store.ts | 59 ++++++-- test/scope-template-wildcard.test.mjs | 210 ++++++++++++++++++++++++++ 6 files changed, 398 insertions(+), 35 deletions(-) create mode 100644 test/scope-template-wildcard.test.mjs diff --git a/index.ts b/index.ts index ef3a4c15..010b1502 100644 --- a/index.ts +++ b/index.ts @@ -23,7 +23,7 @@ const isCliMode = () => process.env.OPENCLAW_CLI === "1"; import { MemoryStore, validateStoragePath } from "./src/store.js"; import { createEmbedder, getVectorDimensions } from "./src/embedder.js"; import { createRetriever, DEFAULT_RETRIEVAL_CONFIG } from "./src/retriever.js"; -import { createScopeManager, resolveScopeFilter, isSystemBypassId, parseAgentIdFromSessionKey } from "./src/scopes.js"; +import { createScopeManager, resolveScopeFilter, isSystemBypassId, parseAgentIdFromSessionKey, resolveTemplateScope, hasTemplateVars } from "./src/scopes.js"; import { createMigrator } from "./src/migrate.js"; import { registerAllMemoryTools } from "./src/tools.js"; import { appendSelfImprovementEntry, ensureSelfImprovementLearningFiles } from "./src/self-improvement-files.js"; @@ -304,6 +304,30 @@ function resolveHookAgentId( : parseAgentIdFromSessionKey(sessionKey)) || "main"; } +/** + * Hook-layer template resolution for scopes.default. + * The scope system stays static — template resolution happens here at runtime. + * Falls back to scopeManager.getDefaultScope(agentId) when not a template or unresolvable. + */ +function resolveHookDefaultScope( + config: PluginConfig, + scopeManager: { getDefaultScope(agentId?: string): string }, + agentId: string, + ctx: any, +): string { + const tpl = config.scopes?.default; + if (tpl && hasTemplateVars(tpl)) { + const resolved = resolveTemplateScope(tpl, { + agentId, + accountId: ctx?.accountId, + channelId: ctx?.channelId, + conversationId: ctx?.conversationId, + }); + if (resolved) return resolved; + } + return scopeManager.getDefaultScope(agentId); +} + function resolveSourceFromSessionKey(sessionKey: string | undefined): string { const trimmed = sessionKey?.trim() ?? ""; const match = /^agent:[^:]+:([^:]+)/.exec(trimmed); @@ -2586,7 +2610,7 @@ const memoryLanceDBProPlugin = { const accessibleScopes = resolveScopeFilter(scopeManager, agentId); const defaultScope = isSystemBypassId(agentId) ? config.scopes?.default ?? "global" - : scopeManager.getDefaultScope(agentId); + : resolveHookDefaultScope(config, scopeManager, agentId, ctx); const sessionKey = ctx?.sessionKey || (event as any).sessionKey || "unknown"; api.logger.debug( @@ -2759,7 +2783,7 @@ const memoryLanceDBProPlugin = { const conversationText = cleanTexts.join("\n"); const stats = await smartExtractor.extractAndPersist( conversationText, sessionKey, - { scope: defaultScope, scopeFilter: accessibleScopes }, + { scope: defaultScope, scopeFilter: [defaultScope] }, ); // Charge rate limiter only after successful extraction extractionRateLimiter.recordExtraction(); @@ -3527,7 +3551,7 @@ const memoryLanceDBProPlugin = { ); const defaultScope = isSystemBypassId(agentId) ? config.scopes?.default ?? "global" - : scopeManager.getDefaultScope(agentId); + : resolveHookDefaultScope(config, scopeManager, agentId, ctx); const currentSessionId = typeof ctx.sessionId === "string" && ctx.sessionId.trim().length > 0 ? ctx.sessionId diff --git a/package-lock.json b/package-lock.json index fcbf1b04..59b226fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "memory-lancedb-pro", - "version": "1.1.0-beta.9", + "version": "1.1.0-beta.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "memory-lancedb-pro", - "version": "1.1.0-beta.9", + "version": "1.1.0-beta.10", "license": "MIT", "dependencies": { "@lancedb/lancedb": "^0.26.2", @@ -18,7 +18,7 @@ }, "devDependencies": { "commander": "^14.0.0", - "jiti": "^2.6.0", + "jiti": "^2.6.1", "typescript": "^5.9.3" } }, @@ -223,6 +223,7 @@ "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-18.1.0.tgz", "integrity": "sha512-v/ShMp57iBnBp4lDgV8Jx3d3Q5/Hac25FWmQ98eMahUiHPXcvwIMKJD0hBIgclm/FCG+LwPkAKtkRO1O/W0YGg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@swc/helpers": "^0.5.11", "@types/command-line-args": "^5.2.3", diff --git a/package.json b/package.json index cfd47cd0..f90dd531 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ }, "devDependencies": { "commander": "^14.0.0", - "jiti": "^2.6.0", + "jiti": "^2.6.1", "typescript": "^5.9.3" } } diff --git a/src/scopes.ts b/src/scopes.ts index 5e3e1071..cae6d3af 100644 --- a/src/scopes.ts +++ b/src/scopes.ts @@ -18,6 +18,17 @@ export interface ScopeConfig { agentAccess: Record; } +/** + * Context variables available for template resolution in scope strings. + * Populated from hook ctx at runtime (e.g. autoCapture / autoRecall). + */ +export interface ScopeContext { + agentId?: string; + accountId?: string; + channelId?: string; + conversationId?: string; +} + export interface ScopeManager { /** * Enumerate known scopes for the caller. @@ -71,6 +82,76 @@ const SCOPE_PATTERNS = { const SYSTEM_BYPASS_IDS = new Set(["system", "undefined"]); const warnedLegacyFallbackBypassIds = new Set(); +// ============================================================================ +// Template & Wildcard Utilities +// ============================================================================ + +const TEMPLATE_VAR_RE = /\$\{(\w+)\}/g; + +/** Returns true if the string contains `${...}` template variables. */ +export function hasTemplateVars(s: string): boolean { + return /\$\{\w+\}/.test(s); +} + +/** + * Resolve template variables in a scope string. + * Unresolved variables (missing or empty in ctx) cause the function to return `undefined`, + * signalling that the caller should fall back to a safe default. + */ +export function resolveTemplateScope(template: string, ctx: ScopeContext | undefined): string | undefined { + if (!ctx) return undefined; + let failed = false; + const resolved = template.replace(TEMPLATE_VAR_RE, (_match, key: string) => { + const val = (ctx as Record)[key]; + if (typeof val === "string" && val.length > 0) return val; + failed = true; + return ""; + }); + return failed ? undefined : resolved; +} + +/** + * Check if a concrete scope matches a wildcard pattern. + * Only trailing `*` is supported: `"user:*"` matches `"user:alice"`. + * Non-wildcard strings are compared with strict equality. + */ +export function matchesWildcardScope(pattern: string, scope: string): boolean { + if (!pattern.endsWith(":*")) return pattern === scope; + const prefix = pattern.slice(0, -1); // "user:*" → "user:" + return scope.startsWith(prefix) && scope.length > prefix.length; +} + +/** + * Infer the wildcard pattern from a template default scope. + * e.g. `"user:${accountId}"` → `"user:*"` + * `"bot-1:user:${accountId}"` → `"bot-1:user:*"` + * `"agent:${agentId}:user:${accountId}"` → `undefined` (prefix contains only a top-level built-in namespace, too broad) + * `"${agentId}:user:${accountId}"` → `undefined` (starts with variable) + * + * Only produces a wildcard when the template has a static prefix before the first variable, + * and the prefix is specific enough (not just a top-level built-in like "agent:"). + */ +export function inferWildcardFromTemplate(template: string): string | undefined { + const idx = template.indexOf("${"); + if (idx <= 0) return undefined; // no prefix or starts with variable + const prefix = template.slice(0, idx); + // Prefix must end with ":" to form a valid scope namespace + if (!prefix.endsWith(":")) return undefined; + // Reject if the prefix is just a top-level built-in namespace — the resulting wildcard + // (e.g. "agent:*") would be far too broad and break isolation between agents/users/projects. + const topLevelBuiltins = ["agent:", "user:", "custom:", "project:", "reflection:"]; + if (topLevelBuiltins.includes(prefix)) { + // Exception: "user:${accountId}" → "user:*" is the primary use case and is safe + // because user scopes are per-user by definition. But "agent:${agentId}:..." → "agent:*" + // would grant access to ALL agent scopes. + // Allow only if the entire remainder after prefix is a single variable (no further segments). + const remainder = template.slice(idx); + const isSingleVar = /^\$\{\w+\}$/.test(remainder); + if (!isSingleVar) return undefined; + } + return prefix + "*"; +} + export function isSystemBypassId(agentId?: string): boolean { return typeof agentId === "string" && SYSTEM_BYPASS_IDS.has(agentId); } @@ -151,8 +232,8 @@ export class MemoryScopeManager implements ScopeManager { } private validateConfiguration(): void { - // Validate default scope exists in definitions - if (!this.config.definitions[this.config.default]) { + // Validate default scope exists in definitions (skip validation for template defaults) + if (!hasTemplateVars(this.config.default) && !this.config.definitions[this.config.default]) { throw new Error(`Default scope '${this.config.default}' not found in definitions`); } @@ -167,6 +248,8 @@ export class MemoryScopeManager implements ScopeManager { ); } for (const scope of scopes) { + // Wildcard patterns (e.g. "user:*") are always valid + if (scope.endsWith(":*")) continue; if (!this.config.definitions[scope] && !this.isBuiltInScope(scope)) { console.warn(`Agent '${agentId}' has access to undefined scope '${scope}'`); } @@ -175,6 +258,13 @@ export class MemoryScopeManager implements ScopeManager { } private isBuiltInScope(scope: string): boolean { + // Accept wildcard patterns like "user:*" as valid built-in scopes + if (scope.endsWith(":*")) { + const prefix = scope.slice(0, -2); // "user:*" → "user" + return ["agent", "custom", "project", "user", "reflection"].includes(prefix) || + // Also accept compound prefixes like "bot-1:user" → startsWith check + this.isBuiltInScope(prefix.includes(":") ? prefix.slice(prefix.lastIndexOf(":") + 1) + ":x" : ""); + } return ( scope === "global" || scope.startsWith("agent:") || @@ -187,12 +277,9 @@ export class MemoryScopeManager implements ScopeManager { getAccessibleScopes(agentId?: string): string[] { if (isSystemBypassId(agentId) || !agentId) { - // Keep enumeration semantics consistent for callers that inspect the list. - // This enumerates registered scopes, not every valid built-in pattern. return this.getAllScopes(); } - // Explicit ACLs still inherit the agent's own reflection scope. const normalizedAgentId = agentId.trim(); const explicitAccess = this.config.agentAccess[normalizedAgentId]; if (explicitAccess) { @@ -221,9 +308,6 @@ export class MemoryScopeManager implements ScopeManager { */ getScopeFilter(agentId?: string): string[] | undefined { if (!agentId || isSystemBypassId(agentId)) { - // No agent specified or internal system tasks bypass store-level scope - // filtering entirely. This aligns with isAccessible(scope, undefined) - // which also uses bypass semantics for missing agentId. return undefined; } return this.getAccessibleScopes(agentId); @@ -231,6 +315,9 @@ export class MemoryScopeManager implements ScopeManager { getDefaultScope(agentId?: string): string { if (!agentId) { + // If default is a template, return "global" — callers without agentId + // should use resolveHookDefaultScope() in the hook layer instead. + if (hasTemplateVars(this.config.default)) return "global"; return this.config.default; } if (isSystemBypassId(agentId)) { @@ -243,10 +330,14 @@ export class MemoryScopeManager implements ScopeManager { const agentScope = SCOPE_PATTERNS.AGENT(agentId); const accessibleScopes = this.getAccessibleScopes(agentId); - if (accessibleScopes.includes(agentScope)) { + if (accessibleScopes.some(s => matchesWildcardScope(s, agentScope))) { return agentScope; } + // If config default is a template, don't return the raw template string — + // return agent scope as a safe fallback (hook layer handles template resolution). + if (hasTemplateVars(this.config.default)) return agentScope; + return this.config.default; } @@ -257,7 +348,8 @@ export class MemoryScopeManager implements ScopeManager { } const accessibleScopes = this.getAccessibleScopes(agentId); - return accessibleScopes.includes(scope); + // Exact match first, then wildcard match (e.g. "user:*" matches "user:alice") + return accessibleScopes.some(s => matchesWildcardScope(s, scope)); } validateScope(scope: string): boolean { @@ -267,6 +359,9 @@ export class MemoryScopeManager implements ScopeManager { const trimmedScope = scope.trim(); + // Wildcard patterns are valid scope specifiers + if (trimmedScope.endsWith(":*")) return true; + // Check if scope is defined or is a built-in pattern return ( this.config.definitions[trimmedScope] !== undefined || @@ -329,9 +424,9 @@ export class MemoryScopeManager implements ScopeManager { // Note: an agent's own reflection scope is still auto-granted by getAccessibleScopes(). // This setter can add access, but it does not revoke `reflection:agent:${normalizedAgentId}`. - // Validate all scopes + // Validate all scopes (wildcards are always valid) for (const scope of scopes) { - if (!this.validateScope(scope)) { + if (!scope.endsWith(":*") && !this.validateScope(scope)) { throw new Error(`Invalid scope: ${scope}`); } } @@ -361,8 +456,8 @@ export class MemoryScopeManager implements ScopeManager { return false; } - // Allow alphanumeric, hyphens, underscores, colons, and dots - const validFormat = /^[a-zA-Z0-9._:-]+$/.test(trimmed); + // Allow alphanumeric, hyphens, underscores, colons, dots, and wildcard asterisk + const validFormat = /^[a-zA-Z0-9._:*-]+$/.test(trimmed); return validFormat; } diff --git a/src/store.ts b/src/store.ts index ce80034c..c4d0afe6 100644 --- a/src/store.ts +++ b/src/store.ts @@ -102,6 +102,39 @@ function isExplicitDenyAllScopeFilter(scopeFilter?: string[]): boolean { return Array.isArray(scopeFilter) && scopeFilter.length === 0; } +/** + * Build a SQL condition for a single scope filter entry. + * Supports wildcard patterns: "user:*" → `scope LIKE 'user:%'` + * Plain scopes use exact match: "global" → `scope = 'global'` + */ +function scopeFilterToSqlCondition(scope: string): string { + if (scope.endsWith(":*")) { + const prefix = scope.slice(0, -1); // "user:*" → "user:" + // Escape LIKE meta-characters (_ and %) in the prefix to prevent unintended matching + const escapedPrefix = escapeSqlLiteral(prefix).replace(/%/g, "\\%").replace(/_/g, "\\_"); + // Require at least one character after the prefix to match matchesWildcardScope() semantics + // (which rejects "user:" but accepts "user:alice") + return `(scope LIKE '${escapedPrefix}_%' ESCAPE '\\')`; + } + return `scope = '${escapeSqlLiteral(scope)}'`; +} + +/** + * Check if a concrete scope matches any entry in a scope filter list. + * Supports wildcard patterns: "user:*" matches "user:alice". + */ +function scopeFilterIncludes(scopeFilter: string[], scope: string): boolean { + for (const pattern of scopeFilter) { + if (pattern.endsWith(":*")) { + const prefix = pattern.slice(0, -1); // "user:*" → "user:" + if (scope.startsWith(prefix) && scope.length > prefix.length) return true; + } else if (pattern === scope) { + return true; + } + } + return false; +} + function scoreLexicalHit(query: string, candidates: Array<{ text: string; weight: number }>): number { const normalizedQuery = normalizeSearchText(query); if (!normalizedQuery) return 0; @@ -460,7 +493,7 @@ export class MemoryStore { const row = rows[0]; const rowScope = (row.scope as string | undefined) ?? "global"; - if (scopeFilter && scopeFilter.length > 0 && !scopeFilter.includes(rowScope)) { + if (scopeFilter && scopeFilter.length > 0 && !scopeFilterIncludes(scopeFilter, rowScope)) { return null; } @@ -493,7 +526,7 @@ export class MemoryStore { // Apply scope filter if provided if (scopeFilter && scopeFilter.length > 0) { const scopeConditions = scopeFilter - .map((scope) => `scope = '${escapeSqlLiteral(scope)}'`) + .map((scope) => scopeFilterToSqlCondition(scope)) .join(" OR "); query = query.where(`(${scopeConditions}) OR scope IS NULL`); // NULL for backward compatibility } @@ -513,7 +546,7 @@ export class MemoryStore { if ( scopeFilter && scopeFilter.length > 0 && - !scopeFilter.includes(rowScope) + !scopeFilterIncludes(scopeFilter, rowScope) ) { continue; } @@ -568,7 +601,7 @@ export class MemoryStore { // Apply scope filter if provided if (scopeFilter && scopeFilter.length > 0) { const scopeConditions = scopeFilter - .map((scope) => `scope = '${escapeSqlLiteral(scope)}'`) + .map((scope) => scopeFilterToSqlCondition(scope)) .join(" OR "); searchQuery = searchQuery.where( `(${scopeConditions}) OR scope IS NULL`, @@ -585,7 +618,7 @@ export class MemoryStore { if ( scopeFilter && scopeFilter.length > 0 && - !scopeFilter.includes(rowScope) + !scopeFilterIncludes(scopeFilter, rowScope) ) { continue; } @@ -646,7 +679,7 @@ export class MemoryStore { if (scopeFilter && scopeFilter.length > 0) { const scopeConditions = scopeFilter - .map(scope => `scope = '${escapeSqlLiteral(scope)}'`) + .map(scope => scopeFilterToSqlCondition(scope)) .join(" OR "); searchQuery = searchQuery.where(`(${scopeConditions}) OR scope IS NULL`); } @@ -656,7 +689,7 @@ export class MemoryStore { for (const row of rows) { const rowScope = (row.scope as string | undefined) ?? "global"; - if (scopeFilter && scopeFilter.length > 0 && !scopeFilter.includes(rowScope)) { + if (scopeFilter && scopeFilter.length > 0 && !scopeFilterIncludes(scopeFilter, rowScope)) { continue; } @@ -742,7 +775,7 @@ export class MemoryStore { if ( scopeFilter && scopeFilter.length > 0 && - !scopeFilter.includes(rowScope) + !scopeFilterIncludes(scopeFilter, rowScope) ) { throw new Error(`Memory ${resolvedId} is outside accessible scopes`); } @@ -770,7 +803,7 @@ export class MemoryStore { if (scopeFilter && scopeFilter.length > 0) { const scopeConditions = scopeFilter - .map((scope) => `scope = '${escapeSqlLiteral(scope)}'`) + .map((scope) => scopeFilterToSqlCondition(scope)) .join(" OR "); conditions.push(`((${scopeConditions}) OR scope IS NULL)`); } @@ -832,7 +865,7 @@ export class MemoryStore { if (scopeFilter && scopeFilter.length > 0) { const scopeConditions = scopeFilter - .map((scope) => `scope = '${escapeSqlLiteral(scope)}'`) + .map((scope) => scopeFilterToSqlCondition(scope)) .join(" OR "); query = query.where(`((${scopeConditions}) OR scope IS NULL)`); } @@ -925,7 +958,7 @@ export class MemoryStore { if ( scopeFilter && scopeFilter.length > 0 && - !scopeFilter.includes(rowScope) + !scopeFilterIncludes(scopeFilter, rowScope) ) { throw new Error(`Memory ${id} is outside accessible scopes`); } @@ -1031,7 +1064,7 @@ export class MemoryStore { if (scopeFilter.length > 0) { const scopeConditions = scopeFilter - .map((scope) => `scope = '${escapeSqlLiteral(scope)}'`) + .map((scope) => scopeFilterToSqlCondition(scope)) .join(" OR "); conditions.push(`(${scopeConditions})`); } @@ -1126,7 +1159,7 @@ export class MemoryStore { if (scopeFilter && scopeFilter.length > 0) { const scopeConditions = scopeFilter - .map((scope) => `scope = '${escapeSqlLiteral(scope)}'`) + .map((scope) => scopeFilterToSqlCondition(scope)) .join(" OR "); conditions.push(`((${scopeConditions}) OR scope IS NULL)`); } diff --git a/test/scope-template-wildcard.test.mjs b/test/scope-template-wildcard.test.mjs new file mode 100644 index 00000000..2f4361b9 --- /dev/null +++ b/test/scope-template-wildcard.test.mjs @@ -0,0 +1,210 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import jitiFactory from "jiti"; + +const jiti = jitiFactory(import.meta.url, { interopDefault: true }); +const { + MemoryScopeManager, + hasTemplateVars, + resolveTemplateScope, + matchesWildcardScope, + inferWildcardFromTemplate, +} = jiti("../src/scopes.ts"); + +// ============================================================================ +// Unit tests for template & wildcard utilities +// ============================================================================ + +describe("hasTemplateVars", () => { + it("detects template variables", () => { + assert.strictEqual(hasTemplateVars("user:${accountId}"), true); + assert.strictEqual(hasTemplateVars("${agentId}:user:${accountId}"), true); + }); + + it("returns false for static strings", () => { + assert.strictEqual(hasTemplateVars("global"), false); + assert.strictEqual(hasTemplateVars("user:alice"), false); + }); + + it("returns false for malformed templates", () => { + assert.strictEqual(hasTemplateVars("user:$accountId"), false); + }); +}); + +describe("resolveTemplateScope", () => { + it("resolves a single variable", () => { + assert.strictEqual( + resolveTemplateScope("user:${accountId}", { accountId: "alice" }), + "user:alice", + ); + }); + + it("resolves multiple variables", () => { + assert.strictEqual( + resolveTemplateScope("${agentId}:user:${accountId}", { agentId: "bot-1", accountId: "alice" }), + "bot-1:user:alice", + ); + }); + + it("returns undefined when ctx is missing", () => { + assert.strictEqual(resolveTemplateScope("user:${accountId}", undefined), undefined); + }); + + it("returns undefined when a variable is missing from ctx", () => { + assert.strictEqual(resolveTemplateScope("user:${accountId}", { agentId: "bot-1" }), undefined); + }); + + it("returns undefined when a variable is empty string", () => { + assert.strictEqual(resolveTemplateScope("user:${accountId}", { accountId: "" }), undefined); + }); +}); + +describe("matchesWildcardScope", () => { + it("matches concrete scopes against wildcard", () => { + assert.strictEqual(matchesWildcardScope("user:*", "user:alice"), true); + assert.strictEqual(matchesWildcardScope("user:*", "user:bob"), true); + }); + + it("does not match the wildcard pattern itself", () => { + assert.strictEqual(matchesWildcardScope("user:*", "user:"), false); + }); + + it("does not match unrelated scopes", () => { + assert.strictEqual(matchesWildcardScope("user:*", "agent:main"), false); + assert.strictEqual(matchesWildcardScope("user:*", "global"), false); + }); + + it("falls back to exact match for non-wildcard patterns", () => { + assert.strictEqual(matchesWildcardScope("global", "global"), true); + assert.strictEqual(matchesWildcardScope("user:alice", "user:bob"), false); + }); + + it("supports compound wildcard prefixes", () => { + assert.strictEqual(matchesWildcardScope("bot-1:user:*", "bot-1:user:alice"), true); + assert.strictEqual(matchesWildcardScope("bot-1:user:*", "bot-2:user:alice"), false); + }); +}); + +describe("inferWildcardFromTemplate", () => { + it("infers wildcard from simple template", () => { + assert.strictEqual(inferWildcardFromTemplate("user:${accountId}"), "user:*"); + }); + + it("infers wildcard from compound template", () => { + assert.strictEqual(inferWildcardFromTemplate("bot-1:user:${accountId}"), "bot-1:user:*"); + }); + + it("returns undefined when template starts with variable", () => { + assert.strictEqual(inferWildcardFromTemplate("${agentId}:user:${accountId}"), undefined); + }); + + it("returns undefined for static strings", () => { + assert.strictEqual(inferWildcardFromTemplate("global"), undefined); + }); + + it("rejects agent:${agentId}:user:${accountId} — would produce overly broad agent:*", () => { + assert.strictEqual(inferWildcardFromTemplate("agent:${agentId}:user:${accountId}"), undefined); + }); + + it("allows agent:${agentId} as single-var template", () => { + assert.strictEqual(inferWildcardFromTemplate("agent:${agentId}"), "agent:*"); + }); +}); + +// ============================================================================ +// Integration tests: MemoryScopeManager with explicit wildcard agentAccess +// (Plan B: scope system stays static, wildcards only via explicit config) +// ============================================================================ + +describe("MemoryScopeManager - Explicit Wildcard agentAccess", () => { + it("isAccessible allows concrete scope matching explicit wildcard", () => { + const manager = new MemoryScopeManager({ + default: "global", + agentAccess: { "bot-1": ["global", "user:*"] }, + }); + assert.strictEqual(manager.isAccessible("user:alice", "bot-1"), true); + assert.strictEqual(manager.isAccessible("user:bob", "bot-1"), true); + assert.strictEqual(manager.isAccessible("agent:bot-1", "bot-1"), false); + }); + + it("getScopeFilter includes wildcard from explicit agentAccess", () => { + const manager = new MemoryScopeManager({ + default: "global", + agentAccess: { "bot-1": ["global", "user:*"] }, + }); + const filter = manager.getScopeFilter("bot-1"); + assert.ok(Array.isArray(filter)); + assert.ok(filter.includes("user:*")); + }); + + it("setAgentAccess accepts wildcard scopes", () => { + const manager = new MemoryScopeManager({ default: "global" }); + manager.setAgentAccess("bot-1", ["global", "user:*"]); + assert.strictEqual(manager.isAccessible("user:alice", "bot-1"), true); + }); + + it("validateScope accepts wildcard patterns", () => { + const manager = new MemoryScopeManager({ default: "global" }); + assert.strictEqual(manager.validateScope("user:*"), true); + assert.strictEqual(manager.validateScope("custom:*"), true); + }); +}); + +describe("MemoryScopeManager - Template Default (no auto-wildcard)", () => { + it("accepts a template default without throwing", () => { + const manager = new MemoryScopeManager({ default: "user:${accountId}" }); + assert.ok(manager); + }); + + it("getDefaultScope returns static default for template (no resolution in scope layer)", () => { + // In Plan B, scope system does NOT resolve templates — it returns the raw config default + // or falls back to agent scope. The hook layer handles template resolution. + const manager = new MemoryScopeManager({ default: "user:${accountId}" }); + // getDefaultScope without template awareness returns agent scope (default behavior) + assert.strictEqual(manager.getDefaultScope("main"), "agent:main"); + }); + + it("does NOT auto-append wildcard to accessible scopes", () => { + const manager = new MemoryScopeManager({ default: "user:${accountId}" }); + const scopes = manager.getAccessibleScopes("main"); + assert.ok(!scopes.some(s => s.endsWith(":*")), `Should not contain wildcards: ${JSON.stringify(scopes)}`); + }); + + it("isAccessible does NOT grant access to user:alice without explicit agentAccess", () => { + const manager = new MemoryScopeManager({ default: "user:${accountId}" }); + assert.strictEqual(manager.isAccessible("user:alice", "main"), false); + }); +}); + +describe("MemoryScopeManager - Backward Compatibility", () => { + it("static default still works exactly as before", () => { + const manager = new MemoryScopeManager({ default: "global" }); + assert.strictEqual(manager.getDefaultScope("main"), "agent:main"); + assert.deepStrictEqual(manager.getAccessibleScopes("main"), [ + "global", + "agent:main", + "reflection:agent:main", + ]); + }); + + it("explicit agentAccess without wildcards still works", () => { + const manager = new MemoryScopeManager({ + default: "global", + agentAccess: { main: ["global", "custom:shared"] }, + }); + assert.deepStrictEqual(manager.getAccessibleScopes("main"), [ + "global", + "custom:shared", + "reflection:agent:main", + ]); + assert.strictEqual(manager.isAccessible("custom:shared", "main"), true); + assert.strictEqual(manager.isAccessible("agent:main", "main"), false); + }); + + it("system bypass still works with template default", () => { + const manager = new MemoryScopeManager({ default: "user:${accountId}" }); + assert.strictEqual(manager.getScopeFilter("system"), undefined); + assert.strictEqual(manager.getScopeFilter(undefined), undefined); + assert.strictEqual(manager.isAccessible("user:alice", "system"), true); + }); +}); From 0d712c38cae5a728be9a32f4aedb6f7172103e5e Mon Sep 17 00:00:00 2001 From: eightHundreds Date: Thu, 9 Apr 2026 11:04:55 +0800 Subject: [PATCH 2/6] chore: revert unnecessary jiti version bump The project already has jiti as a devDependency. No need to bump ^2.6.0 to ^2.6.1 in this PR. Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 7 +++---- package.json | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 59b226fa..fcbf1b04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "memory-lancedb-pro", - "version": "1.1.0-beta.10", + "version": "1.1.0-beta.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "memory-lancedb-pro", - "version": "1.1.0-beta.10", + "version": "1.1.0-beta.9", "license": "MIT", "dependencies": { "@lancedb/lancedb": "^0.26.2", @@ -18,7 +18,7 @@ }, "devDependencies": { "commander": "^14.0.0", - "jiti": "^2.6.1", + "jiti": "^2.6.0", "typescript": "^5.9.3" } }, @@ -223,7 +223,6 @@ "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-18.1.0.tgz", "integrity": "sha512-v/ShMp57iBnBp4lDgV8Jx3d3Q5/Hac25FWmQ98eMahUiHPXcvwIMKJD0hBIgclm/FCG+LwPkAKtkRO1O/W0YGg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/helpers": "^0.5.11", "@types/command-line-args": "^5.2.3", diff --git a/package.json b/package.json index f90dd531..cfd47cd0 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ }, "devDependencies": { "commander": "^14.0.0", - "jiti": "^2.6.1", + "jiti": "^2.6.0", "typescript": "^5.9.3" } } From 911031cbe009765f47934e1ef22118ea648aa27d Mon Sep 17 00:00:00 2001 From: eightHundreds Date: Thu, 9 Apr 2026 11:04:55 +0800 Subject: [PATCH 3/6] chore: restore original comments removed during refactor Co-Authored-By: Claude Opus 4.6 --- src/scopes.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/scopes.ts b/src/scopes.ts index cae6d3af..d63b930d 100644 --- a/src/scopes.ts +++ b/src/scopes.ts @@ -277,9 +277,12 @@ export class MemoryScopeManager implements ScopeManager { getAccessibleScopes(agentId?: string): string[] { if (isSystemBypassId(agentId) || !agentId) { + // Keep enumeration semantics consistent for callers that inspect the list. + // This enumerates registered scopes, not every valid built-in pattern. return this.getAllScopes(); } + // Explicit ACLs still inherit the agent's own reflection scope. const normalizedAgentId = agentId.trim(); const explicitAccess = this.config.agentAccess[normalizedAgentId]; if (explicitAccess) { @@ -308,6 +311,9 @@ export class MemoryScopeManager implements ScopeManager { */ getScopeFilter(agentId?: string): string[] | undefined { if (!agentId || isSystemBypassId(agentId)) { + // No agent specified or internal system tasks bypass store-level scope + // filtering entirely. This aligns with isAccessible(scope, undefined) + // which also uses bypass semantics for missing agentId. return undefined; } return this.getAccessibleScopes(agentId); From a6a04944c7a08ed8dd31dff1a21a6d242f059a60 Mon Sep 17 00:00:00 2001 From: eightHundreds Date: Thu, 9 Apr 2026 14:09:56 +0800 Subject: [PATCH 4/6] fix: resolve implicit template write scopes --- index.ts | 82 +++++-- src/scopes.ts | 98 ++++++++- src/tools.ts | 39 +++- test/template-default-write-scope.test.mjs | 239 +++++++++++++++++++++ 4 files changed, 434 insertions(+), 24 deletions(-) create mode 100644 test/template-default-write-scope.test.mjs diff --git a/index.ts b/index.ts index 010b1502..05c421ec 100644 --- a/index.ts +++ b/index.ts @@ -23,7 +23,8 @@ const isCliMode = () => process.env.OPENCLAW_CLI === "1"; import { MemoryStore, validateStoragePath } from "./src/store.js"; import { createEmbedder, getVectorDimensions } from "./src/embedder.js"; import { createRetriever, DEFAULT_RETRIEVAL_CONFIG } from "./src/retriever.js"; -import { createScopeManager, resolveScopeFilter, isSystemBypassId, parseAgentIdFromSessionKey, resolveTemplateScope, hasTemplateVars } from "./src/scopes.js"; +import { createScopeManager, resolveScopeFilter, isSystemBypassId, parseAgentIdFromSessionKey, resolveImplicitWriteScope, hasTemplateVars } from "./src/scopes.js"; +import type { ImplicitWriteScopeResolution } from "./src/scopes.js"; import { createMigrator } from "./src/migrate.js"; import { registerAllMemoryTools } from "./src/tools.js"; import { appendSelfImprovementEntry, ensureSelfImprovementLearningFiles } from "./src/self-improvement-files.js"; @@ -306,26 +307,42 @@ function resolveHookAgentId( /** * Hook-layer template resolution for scopes.default. - * The scope system stays static — template resolution happens here at runtime. - * Falls back to scopeManager.getDefaultScope(agentId) when not a template or unresolvable. + * Hook-initiated writes must resolve to a concrete readable scope or skip. */ function resolveHookDefaultScope( config: PluginConfig, - scopeManager: { getDefaultScope(agentId?: string): string }, + scopeManager: { + getDefaultScope(agentId?: string): string; + isAccessible(scope: string, agentId?: string): boolean; + validateScope(scope: string): boolean; + }, agentId: string, ctx: any, -): string { - const tpl = config.scopes?.default; - if (tpl && hasTemplateVars(tpl)) { - const resolved = resolveTemplateScope(tpl, { +): ImplicitWriteScopeResolution { + return resolveImplicitWriteScope({ + configuredDefaultScope: config.scopes?.default, + scopeManager, + agentId, + context: { agentId, accountId: ctx?.accountId, channelId: ctx?.channelId, conversationId: ctx?.conversationId, - }); - if (resolved) return resolved; + }, + }); +} + +function formatImplicitWriteScopeFailure( + resolution: ImplicitWriteScopeResolution, + configuredDefaultScope: string | undefined, +): string { + if (resolution.reason === "template_unresolved") { + return `template default scope '${configuredDefaultScope ?? "(missing)"}' could not be resolved`; + } + if (resolution.reason === "scope_inaccessible") { + return `implicit write scope '${resolution.candidate ?? "(missing)"}' is not accessible to the caller`; } - return scopeManager.getDefaultScope(agentId); + return `implicit write scope '${resolution.candidate ?? "(missing)"}' is not a valid concrete write scope`; } function resolveSourceFromSessionKey(sessionKey: string | undefined): string { @@ -1759,7 +1776,11 @@ const memoryLanceDBProPlugin = { user: "User", extractMinMessages: config.extractMinMessages ?? 4, extractMaxChars: config.extractMaxChars ?? 8000, - defaultScope: config.scopes?.default ?? "global", + // Template defaults must be resolved at the call site with runtime context. + defaultScope: + config.scopes?.default && !hasTemplateVars(config.scopes.default) + ? config.scopes.default + : undefined, workspaceBoundary: config.workspaceBoundary, admissionControl: config.admissionControl, onAdmissionRejected: admissionRejectionAuditWriter ?? undefined, @@ -2067,6 +2088,7 @@ const memoryLanceDBProPlugin = { scopeManager, embedder, agentId: undefined, // Will be determined at runtime from context + configuredDefaultScope: config.scopes?.default, workspaceDir: getDefaultWorkspaceDir(), mdMirror, workspaceBoundary: config.workspaceBoundary, @@ -2608,9 +2630,15 @@ const memoryLanceDBProPlugin = { // Determine agent ID and default scope const agentId = resolveHookAgentId(ctx?.agentId, (event as any).sessionKey); const accessibleScopes = resolveScopeFilter(scopeManager, agentId); - const defaultScope = isSystemBypassId(agentId) - ? config.scopes?.default ?? "global" - : resolveHookDefaultScope(config, scopeManager, agentId, ctx); + const defaultScopeResolution = resolveHookDefaultScope(config, scopeManager, agentId, ctx); + if (!defaultScopeResolution.ok || !defaultScopeResolution.scope) { + api.logger.warn( + `memory-lancedb-pro: auto-capture skipped for agent ${agentId}: ` + + formatImplicitWriteScopeFailure(defaultScopeResolution, config.scopes?.default), + ); + return; + } + const defaultScope = defaultScopeResolution.scope; const sessionKey = ctx?.sessionKey || (event as any).sessionKey || "unknown"; api.logger.debug( @@ -3272,9 +3300,15 @@ const memoryLanceDBProPlugin = { const timeHms = timeIso.split(".")[0]; const timeCompact = timeIso.replace(/[:.]/g, ""); const reflectionRunAgentId = resolveReflectionRunAgentId(cfg, sourceAgentId); - const targetScope = isSystemBypassId(sourceAgentId) - ? config.scopes?.default ?? "global" - : scopeManager.getDefaultScope(sourceAgentId); + const targetScopeResolution = resolveHookDefaultScope(config, scopeManager, sourceAgentId, context); + if (!targetScopeResolution.ok || !targetScopeResolution.scope) { + api.logger.warn( + `memory-reflection: command:${action} skipped for agent ${sourceAgentId}: ` + + formatImplicitWriteScopeFailure(targetScopeResolution, config.scopes?.default), + ); + return; + } + const targetScope = targetScopeResolution.scope; const toolErrorSignals = sessionKey ? (reflectionErrorStateBySession.get(sessionKey)?.entries ?? []).slice(-reflectionErrorReminderMaxEntries) : []; @@ -3549,9 +3583,15 @@ const memoryLanceDBProPlugin = { typeof ctx.agentId === "string" ? ctx.agentId : undefined, sessionKey, ); - const defaultScope = isSystemBypassId(agentId) - ? config.scopes?.default ?? "global" - : resolveHookDefaultScope(config, scopeManager, agentId, ctx); + const defaultScopeResolution = resolveHookDefaultScope(config, scopeManager, agentId, ctx); + if (!defaultScopeResolution.ok || !defaultScopeResolution.scope) { + api.logger.warn( + `session-memory: skipped before_reset write for agent ${agentId}: ` + + formatImplicitWriteScopeFailure(defaultScopeResolution, config.scopes?.default), + ); + return; + } + const defaultScope = defaultScopeResolution.scope; const currentSessionId = typeof ctx.sessionId === "string" && ctx.sessionId.trim().length > 0 ? ctx.sessionId diff --git a/src/scopes.ts b/src/scopes.ts index d63b930d..38694f3c 100644 --- a/src/scopes.ts +++ b/src/scopes.ts @@ -29,6 +29,14 @@ export interface ScopeContext { conversationId?: string; } +export interface ImplicitWriteScopeResolution { + ok: boolean; + scope?: string; + source: "default" | "template"; + reason?: "template_unresolved" | "scope_invalid" | "scope_inaccessible"; + candidate?: string; +} + export interface ScopeManager { /** * Enumerate known scopes for the caller. @@ -152,6 +160,94 @@ export function inferWildcardFromTemplate(template: string): string | undefined return prefix + "*"; } +function isConcreteWriteScope(scope: string): boolean { + return !scope.endsWith(":*"); +} + +export function resolveImplicitWriteScope( + params: { + configuredDefaultScope?: string; + scopeManager: Pick; + agentId?: string; + context?: ScopeContext; + }, +): ImplicitWriteScopeResolution { + const { + configuredDefaultScope, + scopeManager, + agentId, + context, + } = params; + const defaultScope = configuredDefaultScope?.trim(); + + if (defaultScope && hasTemplateVars(defaultScope)) { + const resolved = resolveTemplateScope(defaultScope, context); + if (!resolved) { + return { + ok: false, + source: "template", + reason: "template_unresolved", + }; + } + if (!isConcreteWriteScope(resolved) || !scopeManager.validateScope(resolved)) { + return { + ok: false, + source: "template", + reason: "scope_invalid", + candidate: resolved, + }; + } + if ( + agentId && + !isSystemBypassId(agentId) && + !scopeManager.isAccessible(resolved, agentId) + ) { + return { + ok: false, + source: "template", + reason: "scope_inaccessible", + candidate: resolved, + }; + } + return { + ok: true, + scope: resolved, + source: "template", + }; + } + + const candidate = + agentId && !isSystemBypassId(agentId) + ? scopeManager.getDefaultScope(agentId) + : (defaultScope || scopeManager.getDefaultScope(agentId)); + + if (!candidate || !isConcreteWriteScope(candidate) || !scopeManager.validateScope(candidate)) { + return { + ok: false, + source: "default", + reason: "scope_invalid", + candidate, + }; + } + if ( + agentId && + !isSystemBypassId(agentId) && + !scopeManager.isAccessible(candidate, agentId) + ) { + return { + ok: false, + source: "default", + reason: "scope_inaccessible", + candidate, + }; + } + return { + ok: true, + scope: candidate, + source: "default", + }; +} + export function isSystemBypassId(agentId?: string): boolean { return typeof agentId === "string" && SYSTEM_BYPASS_IDS.has(agentId); } @@ -637,4 +733,4 @@ export function filterScopesForAgent(scopes: string[], agentId?: string, scopeMa } return scopes.filter(scope => scopeManager.isAccessible(scope, agentId)); -} \ No newline at end of file +} diff --git a/src/tools.ts b/src/tools.ts index 3f587a30..2f6ecb43 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -11,7 +11,7 @@ import { join } from "node:path"; import type { MemoryRetriever, RetrievalResult } from "./retriever.js"; import type { MemoryStore } from "./store.js"; import { isNoise } from "./noise-filter.js"; -import { isSystemBypassId, resolveScopeFilter, parseAgentIdFromSessionKey, type MemoryScopeManager } from "./scopes.js"; +import { isSystemBypassId, resolveScopeFilter, parseAgentIdFromSessionKey, resolveImplicitWriteScope, type MemoryScopeManager, type ScopeContext } from "./scopes.js"; import type { Embedder } from "./embedder.js"; import { appendRelation, @@ -60,6 +60,7 @@ interface ToolContext { scopeManager: MemoryScopeManager; embedder: Embedder; agentId?: string; + configuredDefaultScope?: string; workspaceDir?: string; mdMirror?: MdMirrorWriter | null; workspaceBoundary?: WorkspaceBoundaryConfig; @@ -161,6 +162,19 @@ function resolveToolContext( }; } +function resolveRuntimeScopeContext(runtimeCtx: unknown, agentId: string | undefined): ScopeContext { + if (!runtimeCtx || typeof runtimeCtx !== "object") { + return { agentId }; + } + const ctx = runtimeCtx as Record; + return { + agentId, + accountId: typeof ctx.accountId === "string" ? ctx.accountId : undefined, + channelId: typeof ctx.channelId === "string" ? ctx.channelId : undefined, + conversationId: typeof ctx.conversationId === "string" ? ctx.conversationId : undefined, + }; +} + async function sleep(ms: number): Promise { await new Promise(resolve => setTimeout(resolve, ms)); } @@ -713,7 +727,28 @@ export function registerMemoryStoreTool( }, }; } - targetScope = runtimeContext.scopeManager.getDefaultScope(agentId); + const implicitScope = resolveImplicitWriteScope({ + configuredDefaultScope: runtimeContext.configuredDefaultScope, + scopeManager: runtimeContext.scopeManager, + agentId, + context: resolveRuntimeScopeContext(toolCtx, agentId), + }); + if (!implicitScope.ok || !implicitScope.scope) { + return { + content: [ + { + type: "text", + text: "Implicit default scope could not be resolved for memory_store writes. Provide an explicit scope.", + }, + ], + details: { + error: "explicit_scope_required", + reason: implicitScope.reason, + agentId, + }, + }; + } + targetScope = implicitScope.scope; } // Validate scope access diff --git a/test/template-default-write-scope.test.mjs b/test/template-default-write-scope.test.mjs new file mode 100644 index 00000000..19bc7262 --- /dev/null +++ b/test/template-default-write-scope.test.mjs @@ -0,0 +1,239 @@ +import { afterEach, beforeEach, describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import jitiFactory from "jiti"; + +const jiti = jitiFactory(import.meta.url, { interopDefault: true }); +const pluginModule = jiti("../index.ts"); +const memoryLanceDBProPlugin = pluginModule.default || pluginModule; +const { MemoryStore } = jiti("../src/store.ts"); +const embedderMod = jiti("../src/embedder.js"); + +const EMBEDDING_DIMENSIONS = 4; +const FIXED_VECTOR = [0.5, 0.5, 0.5, 0.5]; +const originalCreateEmbedder = embedderMod.createEmbedder; + +function createApiHarness({ pluginConfig, logs }) { + return { + pluginConfig, + hooks: {}, + toolFactories: {}, + logger: { + info(message) { + logs.push(["info", String(message)]); + }, + warn(message) { + logs.push(["warn", String(message)]); + }, + error(message) { + logs.push(["error", String(message)]); + }, + debug(message) { + logs.push(["debug", String(message)]); + }, + }, + resolvePath(value) { + return value; + }, + registerTool(toolOrFactory, meta) { + this.toolFactories[meta.name] = + typeof toolOrFactory === "function" ? toolOrFactory : () => toolOrFactory; + }, + registerCli() {}, + registerService() {}, + on(name, handler) { + this.hooks[name] = handler; + }, + registerHook(name, handler) { + this.hooks[name] = handler; + }, + }; +} + +function createBasePluginConfig({ dbPath }) { + return { + dbPath, + autoCapture: false, + autoRecall: false, + smartExtraction: false, + extractionThrottle: { skipLowValue: false, maxExtractionsPerHour: 200 }, + embedding: { + apiKey: "dummy", + dimensions: EMBEDDING_DIMENSIONS, + }, + scopes: { + default: "user:${accountId}", + definitions: { + global: { description: "shared" }, + }, + agentAccess: { + main: ["global", "user:*"], + }, + }, + }; +} + +async function listEntries(dbPath) { + const store = new MemoryStore({ dbPath, vectorDim: EMBEDDING_DIMENSIONS }); + return store.list(undefined, undefined, 20, 0); +} + +async function runAgentEndHook(api, event, ctx) { + await api.hooks.agent_end(event, ctx); + const backgroundRun = api.hooks.agent_end?.__lastRun; + if (backgroundRun && typeof backgroundRun.then === "function") { + await backgroundRun; + } +} + +describe("template default implicit write scope", () => { + let workDir; + + beforeEach(() => { + workDir = mkdtempSync(path.join(tmpdir(), "template-default-scope-")); + embedderMod.createEmbedder = function mockCreateEmbedder() { + return { + async embedQuery() { + return FIXED_VECTOR; + }, + async embedPassage() { + return FIXED_VECTOR; + }, + }; + }; + }); + + afterEach(() => { + embedderMod.createEmbedder = originalCreateEmbedder; + rmSync(workDir, { recursive: true, force: true }); + }); + + it("skips before_reset writes when a template default scope cannot be resolved", async () => { + const dbPath = path.join(workDir, "db-before-reset"); + const logs = []; + const api = createApiHarness({ + pluginConfig: { + ...createBasePluginConfig({ dbPath }), + sessionMemory: { enabled: true }, + }, + logs, + }); + + memoryLanceDBProPlugin.register(api); + + await api.hooks.before_reset( + { + reason: "new", + messages: [ + { role: "user", content: "请记住我偏好乌龙茶。" }, + { role: "assistant", content: "已记录这个偏好。" }, + ], + }, + { + agentId: "main", + sessionKey: "agent:main:discord:dm:42", + sessionId: "session-before-reset", + workspaceDir: workDir, + }, + ); + + const entries = await listEntries(dbPath); + assert.equal(entries.length, 0); + assert.equal( + logs.some((entry) => entry[0] === "warn"), + true, + "expected unresolved template scope to emit a warning", + ); + }); + + it("resolves bypass auto-capture writes to a concrete template scope instead of the raw template string", async () => { + const dbPath = path.join(workDir, "db-agent-end"); + const logs = []; + const api = createApiHarness({ + pluginConfig: { + ...createBasePluginConfig({ dbPath }), + autoCapture: true, + }, + logs, + }); + + memoryLanceDBProPlugin.register(api); + + await runAgentEndHook( + api, + { + success: true, + sessionKey: "agent:system:test", + messages: [ + { role: "user", content: "请记住,我喜欢乌龙茶。" }, + ], + }, + { + agentId: "system", + sessionKey: "agent:system:test", + accountId: "alice", + }, + ); + + const entries = await listEntries(dbPath); + assert.equal(entries.length, 1); + assert.equal(entries[0].scope, "user:alice"); + assert.notEqual(entries[0].scope, "user:${accountId}"); + assert.equal( + logs.some((entry) => entry[1].includes("scope user:alice")), + true, + "expected logs to mention the resolved concrete scope", + ); + }); + + it("memory_store resolves a template default scope from runtime tool context", async () => { + const dbPath = path.join(workDir, "db-tool-runtime"); + const logs = []; + const api = createApiHarness({ + pluginConfig: createBasePluginConfig({ dbPath }), + logs, + }); + + memoryLanceDBProPlugin.register(api); + + const tool = api.toolFactories.memory_store({ + agentId: "main", + accountId: "alice", + }); + const result = await tool.execute(null, { + text: "请记住我喜欢乌龙茶。", + category: "preference", + }); + + assert.match(result.content[0].text, /Stored:/); + const entries = await listEntries(dbPath); + assert.equal(entries.length, 1); + assert.equal(entries[0].scope, "user:alice"); + }); + + it("memory_store requires an explicit scope when a template default scope cannot be resolved", async () => { + const dbPath = path.join(workDir, "db-tool-unresolved"); + const logs = []; + const api = createApiHarness({ + pluginConfig: createBasePluginConfig({ dbPath }), + logs, + }); + + memoryLanceDBProPlugin.register(api); + + const tool = api.toolFactories.memory_store({ + agentId: "main", + }); + const result = await tool.execute(null, { + text: "请记住我喜欢乌龙茶。", + category: "preference", + }); + + assert.equal(result.details?.error, "explicit_scope_required"); + assert.match(result.content[0].text, /explicit scope/i); + const entries = await listEntries(dbPath); + assert.equal(entries.length, 0); + }); +}); From 5ec4d535dfb81950fb54b3ded8fa83d9b41fcf8b Mon Sep 17 00:00:00 2001 From: eightHundreds Date: Thu, 9 Apr 2026 14:48:25 +0800 Subject: [PATCH 5/6] refactor: slim implicit write scope resolution --- index.ts | 6 +-- src/scopes.ts | 16 ------- src/tools.ts | 2 +- test/scope-template-wildcard.test.mjs | 37 +++++++++++++++ test/template-default-write-scope.test.mjs | 53 +++++++++++----------- 5 files changed, 68 insertions(+), 46 deletions(-) diff --git a/index.ts b/index.ts index 05c421ec..38c93dae 100644 --- a/index.ts +++ b/index.ts @@ -2631,7 +2631,7 @@ const memoryLanceDBProPlugin = { const agentId = resolveHookAgentId(ctx?.agentId, (event as any).sessionKey); const accessibleScopes = resolveScopeFilter(scopeManager, agentId); const defaultScopeResolution = resolveHookDefaultScope(config, scopeManager, agentId, ctx); - if (!defaultScopeResolution.ok || !defaultScopeResolution.scope) { + if (!defaultScopeResolution.scope) { api.logger.warn( `memory-lancedb-pro: auto-capture skipped for agent ${agentId}: ` + formatImplicitWriteScopeFailure(defaultScopeResolution, config.scopes?.default), @@ -3301,7 +3301,7 @@ const memoryLanceDBProPlugin = { const timeCompact = timeIso.replace(/[:.]/g, ""); const reflectionRunAgentId = resolveReflectionRunAgentId(cfg, sourceAgentId); const targetScopeResolution = resolveHookDefaultScope(config, scopeManager, sourceAgentId, context); - if (!targetScopeResolution.ok || !targetScopeResolution.scope) { + if (!targetScopeResolution.scope) { api.logger.warn( `memory-reflection: command:${action} skipped for agent ${sourceAgentId}: ` + formatImplicitWriteScopeFailure(targetScopeResolution, config.scopes?.default), @@ -3584,7 +3584,7 @@ const memoryLanceDBProPlugin = { sessionKey, ); const defaultScopeResolution = resolveHookDefaultScope(config, scopeManager, agentId, ctx); - if (!defaultScopeResolution.ok || !defaultScopeResolution.scope) { + if (!defaultScopeResolution.scope) { api.logger.warn( `session-memory: skipped before_reset write for agent ${agentId}: ` + formatImplicitWriteScopeFailure(defaultScopeResolution, config.scopes?.default), diff --git a/src/scopes.ts b/src/scopes.ts index 38694f3c..42038a54 100644 --- a/src/scopes.ts +++ b/src/scopes.ts @@ -30,9 +30,7 @@ export interface ScopeContext { } export interface ImplicitWriteScopeResolution { - ok: boolean; scope?: string; - source: "default" | "template"; reason?: "template_unresolved" | "scope_invalid" | "scope_inaccessible"; candidate?: string; } @@ -184,15 +182,11 @@ export function resolveImplicitWriteScope( const resolved = resolveTemplateScope(defaultScope, context); if (!resolved) { return { - ok: false, - source: "template", reason: "template_unresolved", }; } if (!isConcreteWriteScope(resolved) || !scopeManager.validateScope(resolved)) { return { - ok: false, - source: "template", reason: "scope_invalid", candidate: resolved, }; @@ -203,16 +197,12 @@ export function resolveImplicitWriteScope( !scopeManager.isAccessible(resolved, agentId) ) { return { - ok: false, - source: "template", reason: "scope_inaccessible", candidate: resolved, }; } return { - ok: true, scope: resolved, - source: "template", }; } @@ -223,8 +213,6 @@ export function resolveImplicitWriteScope( if (!candidate || !isConcreteWriteScope(candidate) || !scopeManager.validateScope(candidate)) { return { - ok: false, - source: "default", reason: "scope_invalid", candidate, }; @@ -235,16 +223,12 @@ export function resolveImplicitWriteScope( !scopeManager.isAccessible(candidate, agentId) ) { return { - ok: false, - source: "default", reason: "scope_inaccessible", candidate, }; } return { - ok: true, scope: candidate, - source: "default", }; } diff --git a/src/tools.ts b/src/tools.ts index 2f6ecb43..7778c215 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -733,7 +733,7 @@ export function registerMemoryStoreTool( agentId, context: resolveRuntimeScopeContext(toolCtx, agentId), }); - if (!implicitScope.ok || !implicitScope.scope) { + if (!implicitScope.scope) { return { content: [ { diff --git a/test/scope-template-wildcard.test.mjs b/test/scope-template-wildcard.test.mjs index 2f4361b9..ac341d55 100644 --- a/test/scope-template-wildcard.test.mjs +++ b/test/scope-template-wildcard.test.mjs @@ -7,6 +7,7 @@ const { MemoryScopeManager, hasTemplateVars, resolveTemplateScope, + resolveImplicitWriteScope, matchesWildcardScope, inferWildcardFromTemplate, } = jiti("../src/scopes.ts"); @@ -111,6 +112,42 @@ describe("inferWildcardFromTemplate", () => { }); }); +describe("resolveImplicitWriteScope", () => { + it("returns the concrete resolved scope directly for template defaults", () => { + const manager = new MemoryScopeManager({ + default: "user:${accountId}", + agentAccess: { main: ["global", "user:*"] }, + }); + + assert.deepStrictEqual( + resolveImplicitWriteScope({ + configuredDefaultScope: "user:${accountId}", + scopeManager: manager, + agentId: "main", + context: { agentId: "main", accountId: "alice" }, + }), + { scope: "user:alice" }, + ); + }); + + it("returns only failure details when a template default cannot be resolved", () => { + const manager = new MemoryScopeManager({ + default: "user:${accountId}", + agentAccess: { main: ["global", "user:*"] }, + }); + + assert.deepStrictEqual( + resolveImplicitWriteScope({ + configuredDefaultScope: "user:${accountId}", + scopeManager: manager, + agentId: "main", + context: { agentId: "main" }, + }), + { reason: "template_unresolved" }, + ); + }); +}); + // ============================================================================ // Integration tests: MemoryScopeManager with explicit wildcard agentAccess // (Plan B: scope system stays static, wildcards only via explicit config) diff --git a/test/template-default-write-scope.test.mjs b/test/template-default-write-scope.test.mjs index 19bc7262..b379f145 100644 --- a/test/template-default-write-scope.test.mjs +++ b/test/template-default-write-scope.test.mjs @@ -75,6 +75,19 @@ function createBasePluginConfig({ dbPath }) { }; } +function registerPluginForTest({ workDir, dbName, logs, overrides = {} }) { + const dbPath = path.join(workDir, dbName); + const api = createApiHarness({ + pluginConfig: { + ...createBasePluginConfig({ dbPath }), + ...overrides, + }, + logs, + }); + memoryLanceDBProPlugin.register(api); + return { api, dbPath }; +} + async function listEntries(dbPath) { const store = new MemoryStore({ dbPath, vectorDim: EMBEDDING_DIMENSIONS }); return store.list(undefined, undefined, 20, 0); @@ -111,18 +124,14 @@ describe("template default implicit write scope", () => { }); it("skips before_reset writes when a template default scope cannot be resolved", async () => { - const dbPath = path.join(workDir, "db-before-reset"); const logs = []; - const api = createApiHarness({ - pluginConfig: { - ...createBasePluginConfig({ dbPath }), - sessionMemory: { enabled: true }, - }, + const { api, dbPath } = registerPluginForTest({ + workDir, + dbName: "db-before-reset", logs, + overrides: { sessionMemory: { enabled: true } }, }); - memoryLanceDBProPlugin.register(api); - await api.hooks.before_reset( { reason: "new", @@ -149,18 +158,14 @@ describe("template default implicit write scope", () => { }); it("resolves bypass auto-capture writes to a concrete template scope instead of the raw template string", async () => { - const dbPath = path.join(workDir, "db-agent-end"); const logs = []; - const api = createApiHarness({ - pluginConfig: { - ...createBasePluginConfig({ dbPath }), - autoCapture: true, - }, + const { api, dbPath } = registerPluginForTest({ + workDir, + dbName: "db-agent-end", logs, + overrides: { autoCapture: true }, }); - memoryLanceDBProPlugin.register(api); - await runAgentEndHook( api, { @@ -189,15 +194,13 @@ describe("template default implicit write scope", () => { }); it("memory_store resolves a template default scope from runtime tool context", async () => { - const dbPath = path.join(workDir, "db-tool-runtime"); const logs = []; - const api = createApiHarness({ - pluginConfig: createBasePluginConfig({ dbPath }), + const { api, dbPath } = registerPluginForTest({ + workDir, + dbName: "db-tool-runtime", logs, }); - memoryLanceDBProPlugin.register(api); - const tool = api.toolFactories.memory_store({ agentId: "main", accountId: "alice", @@ -214,15 +217,13 @@ describe("template default implicit write scope", () => { }); it("memory_store requires an explicit scope when a template default scope cannot be resolved", async () => { - const dbPath = path.join(workDir, "db-tool-unresolved"); const logs = []; - const api = createApiHarness({ - pluginConfig: createBasePluginConfig({ dbPath }), + const { api, dbPath } = registerPluginForTest({ + workDir, + dbName: "db-tool-unresolved", logs, }); - memoryLanceDBProPlugin.register(api); - const tool = api.toolFactories.memory_store({ agentId: "main", }); From 522f0b35c4a13dd76a3dd1bdb7959a4d8cf1b33f Mon Sep 17 00:00:00 2001 From: eightHundreds Date: Thu, 9 Apr 2026 20:33:43 +0800 Subject: [PATCH 6/6] fix: handle bypass implicit scope fallback --- src/scopes.ts | 3 ++- test/recall-text-cleanup.test.mjs | 2 +- test/scope-template-wildcard.test.mjs | 31 +++++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/scopes.ts b/src/scopes.ts index 42038a54..33201049 100644 --- a/src/scopes.ts +++ b/src/scopes.ts @@ -209,7 +209,8 @@ export function resolveImplicitWriteScope( const candidate = agentId && !isSystemBypassId(agentId) ? scopeManager.getDefaultScope(agentId) - : (defaultScope || scopeManager.getDefaultScope(agentId)); + // Reserved bypass IDs must never be passed into getDefaultScope(agentId). + : (defaultScope || scopeManager.getDefaultScope()); if (!candidate || !isConcreteWriteScope(candidate) || !scopeManager.validateScope(candidate)) { return { diff --git a/test/recall-text-cleanup.test.mjs b/test/recall-text-cleanup.test.mjs index 3788ccd8..dcb4cedc 100644 --- a/test/recall-text-cleanup.test.mjs +++ b/test/recall-text-cleanup.test.mjs @@ -299,6 +299,7 @@ function makeRecallContext(results = makeResults()) { getAccessibleScopes: () => ["global"], isAccessible: () => true, getDefaultScope: () => "global", + validateScope: () => true, }, embedder: { embedPassage: async () => [] }, agentId: "main", @@ -922,4 +923,3 @@ describe("recall text cleanup", () => { assert.match(res.content[0].text, /称呼偏好:宙斯/); }); }); - diff --git a/test/scope-template-wildcard.test.mjs b/test/scope-template-wildcard.test.mjs index ac341d55..2ed1f4fa 100644 --- a/test/scope-template-wildcard.test.mjs +++ b/test/scope-template-wildcard.test.mjs @@ -146,6 +146,37 @@ describe("resolveImplicitWriteScope", () => { { reason: "template_unresolved" }, ); }); + + it("falls back to the manager default without passing a bypass agent id", () => { + const calls = []; + const scopeManager = { + getDefaultScope(agentId) { + calls.push(agentId); + if (agentId === "system" || agentId === "undefined") { + throw new Error(`unexpected bypass lookup for ${agentId}`); + } + return "global"; + }, + isAccessible: () => true, + validateScope: () => true, + }; + + assert.deepStrictEqual( + resolveImplicitWriteScope({ + scopeManager, + agentId: "system", + }), + { scope: "global" }, + ); + assert.deepStrictEqual( + resolveImplicitWriteScope({ + scopeManager, + agentId: "undefined", + }), + { scope: "global" }, + ); + assert.deepStrictEqual(calls, [undefined, undefined]); + }); }); // ============================================================================