diff --git a/src/memory/qmd-manager.ts b/src/memory/qmd-manager.ts index 11a7ec4d2..389421ab0 100644 --- a/src/memory/qmd-manager.ts +++ b/src/memory/qmd-manager.ts @@ -14,7 +14,7 @@ import type { import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import { resolveStateDir } from "../config/paths.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { parseAgentSessionKey } from "../sessions/session-key-utils.js"; +import { deriveQmdScopeChannel, deriveQmdScopeChatType, isQmdScopeAllowed } from "./qmd-scope.js"; import { listSessionFilesForAgent, buildSessionEntry, @@ -751,89 +751,17 @@ export class QmdMemoryManager implements MemorySearchManager { } } - private isScopeAllowed(sessionKey?: string): boolean { - const scope = this.qmd.scope; - if (!scope) { - return true; - } - const channel = this.deriveChannelFromKey(sessionKey); - const chatType = this.deriveChatTypeFromKey(sessionKey); - const normalizedKey = sessionKey ?? ""; - for (const rule of scope.rules ?? []) { - if (!rule) { - continue; - } - const match = rule.match ?? {}; - if (match.channel && match.channel !== channel) { - continue; - } - if (match.chatType && match.chatType !== chatType) { - continue; - } - if (match.keyPrefix && !normalizedKey.startsWith(match.keyPrefix)) { - continue; - } - return rule.action === "allow"; - } - const fallback = scope.default ?? "allow"; - return fallback === "allow"; - } - private logScopeDenied(sessionKey?: string): void { - const channel = this.deriveChannelFromKey(sessionKey) ?? "unknown"; - const chatType = this.deriveChatTypeFromKey(sessionKey) ?? "unknown"; + const channel = deriveQmdScopeChannel(sessionKey) ?? "unknown"; + const chatType = deriveQmdScopeChatType(sessionKey) ?? "unknown"; const key = sessionKey?.trim() || ""; log.warn( `qmd search denied by scope (channel=${channel}, chatType=${chatType}, session=${key})`, ); } - private deriveChannelFromKey(key?: string) { - if (!key) { - return undefined; - } - const normalized = this.normalizeSessionKey(key); - if (!normalized) { - return undefined; - } - const parts = normalized.split(":").filter(Boolean); - if ( - parts.length >= 2 && - (parts[1] === "group" || parts[1] === "channel" || parts[1] === "direct" || parts[1] === "dm") - ) { - return parts[0]?.toLowerCase(); - } - return undefined; - } - - private deriveChatTypeFromKey(key?: string) { - if (!key) { - return undefined; - } - const normalized = this.normalizeSessionKey(key); - if (!normalized) { - return undefined; - } - if (normalized.includes(":group:")) { - return "group"; - } - if (normalized.includes(":channel:")) { - return "channel"; - } - return "direct"; - } - - private normalizeSessionKey(key: string): string | undefined { - const trimmed = key.trim(); - if (!trimmed) { - return undefined; - } - const parsed = parseAgentSessionKey(trimmed); - const normalized = (parsed?.rest ?? trimmed).toLowerCase(); - if (normalized.startsWith("subagent:")) { - return undefined; - } - return normalized; + private isScopeAllowed(sessionKey?: string): boolean { + return isQmdScopeAllowed(this.qmd.scope, sessionKey); } private toDocLocation( diff --git a/src/memory/qmd-scope.ts b/src/memory/qmd-scope.ts new file mode 100644 index 000000000..9fc03abf0 --- /dev/null +++ b/src/memory/qmd-scope.ts @@ -0,0 +1,77 @@ +import type { ResolvedQmdConfig } from "./backend-config.js"; +import { parseAgentSessionKey } from "../sessions/session-key-utils.js"; + +export function isQmdScopeAllowed(scope: ResolvedQmdConfig["scope"], sessionKey?: string): boolean { + if (!scope) { + return true; + } + const channel = deriveQmdScopeChannel(sessionKey); + const chatType = deriveQmdScopeChatType(sessionKey); + const normalizedKey = sessionKey ?? ""; + for (const rule of scope.rules ?? []) { + if (!rule) { + continue; + } + const match = rule.match ?? {}; + if (match.channel && match.channel !== channel) { + continue; + } + if (match.chatType && match.chatType !== chatType) { + continue; + } + if (match.keyPrefix && !normalizedKey.startsWith(match.keyPrefix)) { + continue; + } + return rule.action === "allow"; + } + const fallback = scope.default ?? "allow"; + return fallback === "allow"; +} + +export function deriveQmdScopeChannel(key?: string): string | undefined { + if (!key) { + return undefined; + } + const normalized = normalizeQmdSessionKey(key); + if (!normalized) { + return undefined; + } + const parts = normalized.split(":").filter(Boolean); + if ( + parts.length >= 2 && + (parts[1] === "group" || parts[1] === "channel" || parts[1] === "direct" || parts[1] === "dm") + ) { + return parts[0]?.toLowerCase(); + } + return undefined; +} + +export function deriveQmdScopeChatType(key?: string): "channel" | "group" | "direct" | undefined { + if (!key) { + return undefined; + } + const normalized = normalizeQmdSessionKey(key); + if (!normalized) { + return undefined; + } + if (normalized.includes(":group:")) { + return "group"; + } + if (normalized.includes(":channel:")) { + return "channel"; + } + return "direct"; +} + +function normalizeQmdSessionKey(key: string): string | undefined { + const trimmed = key.trim(); + if (!trimmed) { + return undefined; + } + const parsed = parseAgentSessionKey(trimmed); + const normalized = (parsed?.rest ?? trimmed).toLowerCase(); + if (normalized.startsWith("subagent:")) { + return undefined; + } + return normalized; +}