Lint: add braces for single-line ifs

main
Benjamin Jesuiter 2026-02-02 22:56:20 +01:00 committed by Vignesh
parent 23cfcd60df
commit e332a717a8
7 changed files with 227 additions and 76 deletions

View File

@ -104,6 +104,7 @@ BM25 + vectors + reranking. Markdown stays the source of truth; Moltbot shells
out to QMD for retrieval. Key points: out to QMD for retrieval. Key points:
**Prereqs** **Prereqs**
- Disabled by default. Opt in per-config (`memory.backend = "qmd"`). - Disabled by default. Opt in per-config (`memory.backend = "qmd"`).
- Install the QMD CLI separately (`bun install -g github.com/tobi/qmd` or grab - Install the QMD CLI separately (`bun install -g github.com/tobi/qmd` or grab
a release) and make sure the `qmd` binary is on the gateways `PATH`. a release) and make sure the `qmd` binary is on the gateways `PATH`.
@ -118,6 +119,7 @@ out to QMD for retrieval. Key points:
installed. Windows is best supported via WSL2. installed. Windows is best supported via WSL2.
**How the sidecar runs** **How the sidecar runs**
- The gateway writes a self-contained QMD home under - The gateway writes a self-contained QMD home under
`~/.openclaw/agents/<agentId>/qmd/` (config + cache + sqlite DB). `~/.openclaw/agents/<agentId>/qmd/` (config + cache + sqlite DB).
- Collections are rewritten from `memory.qmd.paths` (plus default workspace - Collections are rewritten from `memory.qmd.paths` (plus default workspace
@ -160,6 +162,7 @@ out to QMD for retrieval. Key points:
``` ```
**Config surface (`memory.qmd.*`)** **Config surface (`memory.qmd.*`)**
- `command` (default `qmd`): override the executable path. - `command` (default `qmd`): override the executable path.
- `includeDefaultMemory` (default `true`): auto-index `MEMORY.md` + `memory/**/*.md`. - `includeDefaultMemory` (default `true`): auto-index `MEMORY.md` + `memory/**/*.md`.
- `paths[]`: add extra directories/files (`path`, optional `pattern`, optional - `paths[]`: add extra directories/files (`path`, optional `pattern`, optional
@ -207,6 +210,7 @@ memory: {
``` ```
**Citations & fallback** **Citations & fallback**
- `memory.citations` applies regardless of backend (`auto`/`on`/`off`). - `memory.citations` applies regardless of backend (`auto`/`on`/`off`).
- When `qmd` runs, we tag `status().backend = "qmd"` so diagnostics show which - When `qmd` runs, we tag `status().backend = "qmd"` so diagnostics show which
engine served the results. If the QMD subprocess exits or JSON output cant be engine served the results. If the QMD subprocess exits or JSON output cant be

View File

@ -48,7 +48,9 @@ describe("memory search citations", () => {
backend = "builtin"; backend = "builtin";
const cfg = { memory: { citations: "on" }, agents: { list: [{ id: "main", default: true }] } }; const cfg = { memory: { citations: "on" }, agents: { list: [{ id: "main", default: true }] } };
const tool = createMemorySearchTool({ config: cfg }); const tool = createMemorySearchTool({ config: cfg });
if (!tool) throw new Error("tool missing"); if (!tool) {
throw new Error("tool missing");
}
const result = await tool.execute("call_citations_on", { query: "notes" }); const result = await tool.execute("call_citations_on", { query: "notes" });
const details = result.details as { results: Array<{ snippet: string; citation?: string }> }; const details = result.details as { results: Array<{ snippet: string; citation?: string }> };
expect(details.results[0]?.snippet).toMatch(/Source: MEMORY.md#L5-L7/); expect(details.results[0]?.snippet).toMatch(/Source: MEMORY.md#L5-L7/);
@ -59,7 +61,9 @@ describe("memory search citations", () => {
backend = "builtin"; backend = "builtin";
const cfg = { memory: { citations: "off" }, agents: { list: [{ id: "main", default: true }] } }; const cfg = { memory: { citations: "off" }, agents: { list: [{ id: "main", default: true }] } };
const tool = createMemorySearchTool({ config: cfg }); const tool = createMemorySearchTool({ config: cfg });
if (!tool) throw new Error("tool missing"); if (!tool) {
throw new Error("tool missing");
}
const result = await tool.execute("call_citations_off", { query: "notes" }); const result = await tool.execute("call_citations_off", { query: "notes" });
const details = result.details as { results: Array<{ snippet: string; citation?: string }> }; const details = result.details as { results: Array<{ snippet: string; citation?: string }> };
expect(details.results[0]?.snippet).not.toMatch(/Source:/); expect(details.results[0]?.snippet).not.toMatch(/Source:/);
@ -73,7 +77,9 @@ describe("memory search citations", () => {
agents: { list: [{ id: "main", default: true }] }, agents: { list: [{ id: "main", default: true }] },
}; };
const tool = createMemorySearchTool({ config: cfg }); const tool = createMemorySearchTool({ config: cfg });
if (!tool) throw new Error("tool missing"); if (!tool) {
throw new Error("tool missing");
}
const result = await tool.execute("call_citations_qmd", { query: "notes" }); const result = await tool.execute("call_citations_qmd", { query: "notes" });
const details = result.details as { results: Array<{ snippet: string; citation?: string }> }; const details = result.details as { results: Array<{ snippet: string; citation?: string }> };
expect(details.results[0]?.snippet.length).toBeLessThanOrEqual(20); expect(details.results[0]?.snippet.length).toBeLessThanOrEqual(20);

View File

@ -128,7 +128,9 @@ export function createMemoryGetTool(options: {
function resolveMemoryCitationsMode(cfg: MoltbotConfig): MemoryCitationsMode { function resolveMemoryCitationsMode(cfg: MoltbotConfig): MemoryCitationsMode {
const mode = cfg.memory?.citations; const mode = cfg.memory?.citations;
if (mode === "on" || mode === "off" || mode === "auto") return mode; if (mode === "on" || mode === "off" || mode === "auto") {
return mode;
}
return "auto"; return "auto";
} }
@ -155,11 +157,15 @@ function clampResultsByInjectedChars(
results: MemorySearchResult[], results: MemorySearchResult[],
budget?: number, budget?: number,
): MemorySearchResult[] { ): MemorySearchResult[] {
if (!budget || budget <= 0) return results; if (!budget || budget <= 0) {
return results;
}
let remaining = budget; let remaining = budget;
const clamped: MemorySearchResult[] = []; const clamped: MemorySearchResult[] = [];
for (const entry of results) { for (const entry of results) {
if (remaining <= 0) break; if (remaining <= 0) {
break;
}
const snippet = entry.snippet ?? ""; const snippet = entry.snippet ?? "";
if (snippet.length <= remaining) { if (snippet.length <= remaining) {
clamped.push(entry); clamped.push(entry);
@ -177,16 +183,26 @@ function shouldIncludeCitations(params: {
mode: MemoryCitationsMode; mode: MemoryCitationsMode;
sessionKey?: string; sessionKey?: string;
}): boolean { }): boolean {
if (params.mode === "on") return true; if (params.mode === "on") {
if (params.mode === "off") return false; return true;
}
if (params.mode === "off") {
return false;
}
// auto: show citations in direct chats; suppress in groups/channels by default. // auto: show citations in direct chats; suppress in groups/channels by default.
const chatType = deriveChatTypeFromSessionKey(params.sessionKey); const chatType = deriveChatTypeFromSessionKey(params.sessionKey);
return chatType === "direct"; return chatType === "direct";
} }
function deriveChatTypeFromSessionKey(sessionKey?: string): "direct" | "group" | "channel" { function deriveChatTypeFromSessionKey(sessionKey?: string): "direct" | "group" | "channel" {
if (!sessionKey) return "direct"; if (!sessionKey) {
if (sessionKey.includes(":group:")) return "group"; return "direct";
if (sessionKey.includes(":channel:")) return "channel"; }
if (sessionKey.includes(":group:")) {
return "group";
}
if (sessionKey.includes(":channel:")) {
return "channel";
}
return "direct"; return "direct";
} }

View File

@ -100,7 +100,9 @@ function ensureUniqueName(base: string, existing: Set<string>): string {
function resolvePath(raw: string, workspaceDir: string): string { function resolvePath(raw: string, workspaceDir: string): string {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) throw new Error("path required"); if (!trimmed) {
throw new Error("path required");
}
if (trimmed.startsWith("~") || path.isAbsolute(trimmed)) { if (trimmed.startsWith("~") || path.isAbsolute(trimmed)) {
return path.normalize(resolveUserPath(trimmed)); return path.normalize(resolveUserPath(trimmed));
} }
@ -109,7 +111,9 @@ function resolvePath(raw: string, workspaceDir: string): string {
function resolveIntervalMs(raw: string | undefined): number { function resolveIntervalMs(raw: string | undefined): number {
const value = raw?.trim(); const value = raw?.trim();
if (!value) return parseDurationMs(DEFAULT_QMD_INTERVAL, { defaultUnit: "m" }); if (!value) {
return parseDurationMs(DEFAULT_QMD_INTERVAL, { defaultUnit: "m" });
}
try { try {
return parseDurationMs(value, { defaultUnit: "m" }); return parseDurationMs(value, { defaultUnit: "m" });
} catch { } catch {
@ -119,7 +123,9 @@ function resolveIntervalMs(raw: string | undefined): number {
function resolveEmbedIntervalMs(raw: string | undefined): number { function resolveEmbedIntervalMs(raw: string | undefined): number {
const value = raw?.trim(); const value = raw?.trim();
if (!value) return parseDurationMs(DEFAULT_QMD_EMBED_INTERVAL, { defaultUnit: "m" }); if (!value) {
return parseDurationMs(DEFAULT_QMD_EMBED_INTERVAL, { defaultUnit: "m" });
}
try { try {
return parseDurationMs(value, { defaultUnit: "m" }); return parseDurationMs(value, { defaultUnit: "m" });
} catch { } catch {
@ -136,7 +142,9 @@ function resolveDebounceMs(raw: number | undefined): number {
function resolveLimits(raw?: MemoryQmdConfig["limits"]): ResolvedQmdLimitsConfig { function resolveLimits(raw?: MemoryQmdConfig["limits"]): ResolvedQmdLimitsConfig {
const parsed: ResolvedQmdLimitsConfig = { ...DEFAULT_QMD_LIMITS }; const parsed: ResolvedQmdLimitsConfig = { ...DEFAULT_QMD_LIMITS };
if (raw?.maxResults && raw.maxResults > 0) parsed.maxResults = Math.floor(raw.maxResults); if (raw?.maxResults && raw.maxResults > 0) {
parsed.maxResults = Math.floor(raw.maxResults);
}
if (raw?.maxSnippetChars && raw.maxSnippetChars > 0) { if (raw?.maxSnippetChars && raw.maxSnippetChars > 0) {
parsed.maxSnippetChars = Math.floor(raw.maxSnippetChars); parsed.maxSnippetChars = Math.floor(raw.maxSnippetChars);
} }
@ -170,11 +178,15 @@ function resolveCustomPaths(
workspaceDir: string, workspaceDir: string,
existing: Set<string>, existing: Set<string>,
): ResolvedQmdCollection[] { ): ResolvedQmdCollection[] {
if (!rawPaths?.length) return []; if (!rawPaths?.length) {
return [];
}
const collections: ResolvedQmdCollection[] = []; const collections: ResolvedQmdCollection[] = [];
rawPaths.forEach((entry, index) => { rawPaths.forEach((entry, index) => {
const trimmedPath = entry?.path?.trim(); const trimmedPath = entry?.path?.trim();
if (!trimmedPath) return; if (!trimmedPath) {
return;
}
let resolved: string; let resolved: string;
try { try {
resolved = resolvePath(trimmedPath, workspaceDir); resolved = resolvePath(trimmedPath, workspaceDir);
@ -199,7 +211,9 @@ function resolveDefaultCollections(
workspaceDir: string, workspaceDir: string,
existing: Set<string>, existing: Set<string>,
): ResolvedQmdCollection[] { ): ResolvedQmdCollection[] {
if (!include) return []; if (!include) {
return [];
}
const entries: Array<{ path: string; pattern: string; base: string }> = [ const entries: Array<{ path: string; pattern: string; base: string }> = [
{ path: workspaceDir, pattern: "MEMORY.md", base: "memory-root" }, { path: workspaceDir, pattern: "MEMORY.md", base: "memory-root" },
{ path: workspaceDir, pattern: "memory.md", base: "memory-alt" }, { path: workspaceDir, pattern: "memory.md", base: "memory-alt" },

View File

@ -75,7 +75,9 @@ describe("QmdMemoryManager", () => {
const resolved = resolveMemoryBackendConfig({ cfg, agentId }); const resolved = resolveMemoryBackendConfig({ cfg, agentId });
const manager = await QmdMemoryManager.create({ cfg, agentId, resolved }); const manager = await QmdMemoryManager.create({ cfg, agentId, resolved });
expect(manager).toBeTruthy(); expect(manager).toBeTruthy();
if (!manager) throw new Error("manager missing"); if (!manager) {
throw new Error("manager missing");
}
const baselineCalls = spawnMock.mock.calls.length; const baselineCalls = spawnMock.mock.calls.length;
@ -114,7 +116,9 @@ describe("QmdMemoryManager", () => {
const resolved = resolveMemoryBackendConfig({ cfg, agentId }); const resolved = resolveMemoryBackendConfig({ cfg, agentId });
const manager = await QmdMemoryManager.create({ cfg, agentId, resolved }); const manager = await QmdMemoryManager.create({ cfg, agentId, resolved });
expect(manager).toBeTruthy(); expect(manager).toBeTruthy();
if (!manager) throw new Error("manager missing"); if (!manager) {
throw new Error("manager missing");
}
const isAllowed = (key?: string) => const isAllowed = (key?: string) =>
(manager as unknown as { isScopeAllowed: (key?: string) => boolean }).isScopeAllowed(key); (manager as unknown as { isScopeAllowed: (key?: string) => boolean }).isScopeAllowed(key);
@ -128,7 +132,9 @@ describe("QmdMemoryManager", () => {
const resolved = resolveMemoryBackendConfig({ cfg, agentId }); const resolved = resolveMemoryBackendConfig({ cfg, agentId });
const manager = await QmdMemoryManager.create({ cfg, agentId, resolved }); const manager = await QmdMemoryManager.create({ cfg, agentId, resolved });
expect(manager).toBeTruthy(); expect(manager).toBeTruthy();
if (!manager) throw new Error("manager missing"); if (!manager) {
throw new Error("manager missing");
}
const textPath = path.join(workspaceDir, "secret.txt"); const textPath = path.join(workspaceDir, "secret.txt");
await fs.writeFile(textPath, "nope", "utf-8"); await fs.writeFile(textPath, "nope", "utf-8");

View File

@ -55,7 +55,9 @@ export class QmdMemoryManager implements MemorySearchManager {
resolved: ResolvedMemoryBackendConfig; resolved: ResolvedMemoryBackendConfig;
}): Promise<QmdMemoryManager | null> { }): Promise<QmdMemoryManager | null> {
const resolved = params.resolved.qmd; const resolved = params.resolved.qmd;
if (!resolved) return null; if (!resolved) {
return null;
}
const manager = new QmdMemoryManager({ cfg: params.cfg, agentId: params.agentId, resolved }); const manager = new QmdMemoryManager({ cfg: params.cfg, agentId: params.agentId, resolved });
await manager.initialize(); await manager.initialize();
return manager; return manager;
@ -174,10 +176,13 @@ export class QmdMemoryManager implements MemorySearchManager {
const parsed = JSON.parse(result.stdout) as unknown; const parsed = JSON.parse(result.stdout) as unknown;
if (Array.isArray(parsed)) { if (Array.isArray(parsed)) {
for (const entry of parsed) { for (const entry of parsed) {
if (typeof entry === "string") existing.add(entry); if (typeof entry === "string") {
else if (entry && typeof entry === "object") { existing.add(entry);
} else if (entry && typeof entry === "object") {
const name = (entry as { name?: unknown }).name; const name = (entry as { name?: unknown }).name;
if (typeof name === "string") existing.add(name); if (typeof name === "string") {
existing.add(name);
}
} }
} }
} }
@ -186,7 +191,9 @@ export class QmdMemoryManager implements MemorySearchManager {
} }
for (const collection of this.qmd.collections) { for (const collection of this.qmd.collections) {
if (existing.has(collection.name)) continue; if (existing.has(collection.name)) {
continue;
}
try { try {
await this.runQmd([ await this.runQmd([
"collection", "collection",
@ -200,8 +207,12 @@ export class QmdMemoryManager implements MemorySearchManager {
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
// Idempotency: qmd exits non-zero if the collection name already exists. // Idempotency: qmd exits non-zero if the collection name already exists.
if (message.toLowerCase().includes("already exists")) continue; if (message.toLowerCase().includes("already exists")) {
if (message.toLowerCase().includes("exists")) continue; continue;
}
if (message.toLowerCase().includes("exists")) {
continue;
}
log.warn(`qmd collection add failed for ${collection.name}: ${message}`); log.warn(`qmd collection add failed for ${collection.name}: ${message}`);
} }
} }
@ -211,9 +222,13 @@ export class QmdMemoryManager implements MemorySearchManager {
query: string, query: string,
opts?: { maxResults?: number; minScore?: number; sessionKey?: string }, opts?: { maxResults?: number; minScore?: number; sessionKey?: string },
): Promise<MemorySearchResult[]> { ): Promise<MemorySearchResult[]> {
if (!this.isScopeAllowed(opts?.sessionKey)) return []; if (!this.isScopeAllowed(opts?.sessionKey)) {
return [];
}
const trimmed = query.trim(); const trimmed = query.trim();
if (!trimmed) return []; if (!trimmed) {
return [];
}
await this.pendingUpdate?.catch(() => undefined); await this.pendingUpdate?.catch(() => undefined);
const limit = Math.min( const limit = Math.min(
this.qmd.limits.maxResults, this.qmd.limits.maxResults,
@ -234,17 +249,21 @@ export class QmdMemoryManager implements MemorySearchManager {
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
log.warn(`qmd query returned invalid JSON: ${message}`); log.warn(`qmd query returned invalid JSON: ${message}`);
throw new Error(`qmd query returned invalid JSON: ${message}`); throw new Error(`qmd query returned invalid JSON: ${message}`, { cause: err });
} }
const results: MemorySearchResult[] = []; const results: MemorySearchResult[] = [];
for (const entry of parsed) { for (const entry of parsed) {
const doc = await this.resolveDocLocation(entry.docid); const doc = await this.resolveDocLocation(entry.docid);
if (!doc) continue; if (!doc) {
continue;
}
const snippet = entry.snippet?.slice(0, this.qmd.limits.maxSnippetChars) ?? ""; const snippet = entry.snippet?.slice(0, this.qmd.limits.maxSnippetChars) ?? "";
const lines = this.extractSnippetLines(snippet); const lines = this.extractSnippetLines(snippet);
const score = typeof entry.score === "number" ? entry.score : 0; const score = typeof entry.score === "number" ? entry.score : 0;
const minScore = opts?.minScore ?? 0; const minScore = opts?.minScore ?? 0;
if (score < minScore) continue; if (score < minScore) {
continue;
}
results.push({ results.push({
path: doc.rel, path: doc.rel,
startLine: lines.startLine, startLine: lines.startLine,
@ -277,7 +296,9 @@ export class QmdMemoryManager implements MemorySearchManager {
lines?: number; lines?: number;
}): Promise<{ text: string; path: string }> { }): Promise<{ text: string; path: string }> {
const relPath = params.relPath?.trim(); const relPath = params.relPath?.trim();
if (!relPath) throw new Error("path required"); if (!relPath) {
throw new Error("path required");
}
const absPath = this.resolveReadPath(relPath); const absPath = this.resolveReadPath(relPath);
if (!absPath.endsWith(".md")) { if (!absPath.endsWith(".md")) {
throw new Error("path required"); throw new Error("path required");
@ -339,7 +360,9 @@ export class QmdMemoryManager implements MemorySearchManager {
} }
async close(): Promise<void> { async close(): Promise<void> {
if (this.closed) return; if (this.closed) {
return;
}
this.closed = true; this.closed = true;
if (this.updateTimer) { if (this.updateTimer) {
clearInterval(this.updateTimer); clearInterval(this.updateTimer);
@ -353,7 +376,9 @@ export class QmdMemoryManager implements MemorySearchManager {
} }
private async runUpdate(reason: string, force?: boolean): Promise<void> { private async runUpdate(reason: string, force?: boolean): Promise<void> {
if (this.pendingUpdate && !force) return this.pendingUpdate; if (this.pendingUpdate && !force) {
return this.pendingUpdate;
}
if (this.shouldSkipUpdate(force)) { if (this.shouldSkipUpdate(force)) {
return; return;
} }
@ -408,11 +433,15 @@ export class QmdMemoryManager implements MemorySearchManager {
stderr += data.toString(); stderr += data.toString();
}); });
child.on("error", (err) => { child.on("error", (err) => {
if (timer) clearTimeout(timer); if (timer) {
clearTimeout(timer);
}
reject(err); reject(err);
}); });
child.on("close", (code) => { child.on("close", (code) => {
if (timer) clearTimeout(timer); if (timer) {
clearTimeout(timer);
}
if (code === 0) { if (code === 0) {
resolve({ stdout, stderr }); resolve({ stdout, stderr });
} else { } else {
@ -423,14 +452,18 @@ export class QmdMemoryManager implements MemorySearchManager {
} }
private ensureDb(): SqliteDatabase { private ensureDb(): SqliteDatabase {
if (this.db) return this.db; if (this.db) {
return this.db;
}
const { DatabaseSync } = requireNodeSqlite(); const { DatabaseSync } = requireNodeSqlite();
this.db = new DatabaseSync(this.indexPath, { readOnly: true }); this.db = new DatabaseSync(this.indexPath, { readOnly: true });
return this.db; return this.db;
} }
private async exportSessions(): Promise<void> { private async exportSessions(): Promise<void> {
if (!this.sessionExporter) return; if (!this.sessionExporter) {
return;
}
const exportDir = this.sessionExporter.dir; const exportDir = this.sessionExporter.dir;
await fs.mkdir(exportDir, { recursive: true }); await fs.mkdir(exportDir, { recursive: true });
const files = await listSessionFilesForAgent(this.agentId); const files = await listSessionFilesForAgent(this.agentId);
@ -440,15 +473,21 @@ export class QmdMemoryManager implements MemorySearchManager {
: null; : null;
for (const sessionFile of files) { for (const sessionFile of files) {
const entry = await buildSessionEntry(sessionFile); const entry = await buildSessionEntry(sessionFile);
if (!entry) continue; if (!entry) {
if (cutoff && entry.mtimeMs < cutoff) continue; continue;
}
if (cutoff && entry.mtimeMs < cutoff) {
continue;
}
const target = path.join(exportDir, `${path.basename(sessionFile, ".jsonl")}.md`); const target = path.join(exportDir, `${path.basename(sessionFile, ".jsonl")}.md`);
await fs.writeFile(target, this.renderSessionMarkdown(entry), "utf-8"); await fs.writeFile(target, this.renderSessionMarkdown(entry), "utf-8");
keep.add(target); keep.add(target);
} }
const exported = await fs.readdir(exportDir).catch(() => []); const exported = await fs.readdir(exportDir).catch(() => []);
for (const name of exported) { for (const name of exported) {
if (!name.endsWith(".md")) continue; if (!name.endsWith(".md")) {
continue;
}
const full = path.join(exportDir, name); const full = path.join(exportDir, name);
if (!keep.has(full)) { if (!keep.has(full)) {
await fs.rm(full, { force: true }); await fs.rm(full, { force: true });
@ -464,7 +503,9 @@ export class QmdMemoryManager implements MemorySearchManager {
private pickSessionCollectionName(): string { private pickSessionCollectionName(): string {
const existing = new Set(this.qmd.collections.map((collection) => collection.name)); const existing = new Set(this.qmd.collections.map((collection) => collection.name));
if (!existing.has("sessions")) return "sessions"; if (!existing.has("sessions")) {
return "sessions";
}
let counter = 2; let counter = 2;
let candidate = `sessions-${counter}`; let candidate = `sessions-${counter}`;
while (existing.has(candidate)) { while (existing.has(candidate)) {
@ -477,18 +518,28 @@ export class QmdMemoryManager implements MemorySearchManager {
private async resolveDocLocation( private async resolveDocLocation(
docid?: string, docid?: string,
): Promise<{ rel: string; abs: string; source: MemorySource } | null> { ): Promise<{ rel: string; abs: string; source: MemorySource } | null> {
if (!docid) return null; if (!docid) {
return null;
}
const normalized = docid.startsWith("#") ? docid.slice(1) : docid; const normalized = docid.startsWith("#") ? docid.slice(1) : docid;
if (!normalized) return null; if (!normalized) {
return null;
}
const cached = this.docPathCache.get(normalized); const cached = this.docPathCache.get(normalized);
if (cached) return cached; if (cached) {
return cached;
}
const db = this.ensureDb(); const db = this.ensureDb();
const row = db const row = db
.prepare("SELECT collection, path FROM documents WHERE hash LIKE ? AND active = 1 LIMIT 1") .prepare("SELECT collection, path FROM documents WHERE hash LIKE ? AND active = 1 LIMIT 1")
.get(`${normalized}%`) as { collection: string; path: string } | undefined; .get(`${normalized}%`) as { collection: string; path: string } | undefined;
if (!row) return null; if (!row) {
return null;
}
const location = this.toDocLocation(row.collection, row.path); const location = this.toDocLocation(row.collection, row.path);
if (!location) return null; if (!location) {
return null;
}
this.docPathCache.set(normalized, location); this.docPathCache.set(normalized, location);
return location; return location;
} }
@ -550,16 +601,26 @@ export class QmdMemoryManager implements MemorySearchManager {
private isScopeAllowed(sessionKey?: string): boolean { private isScopeAllowed(sessionKey?: string): boolean {
const scope = this.qmd.scope; const scope = this.qmd.scope;
if (!scope) return true; if (!scope) {
return true;
}
const channel = this.deriveChannelFromKey(sessionKey); const channel = this.deriveChannelFromKey(sessionKey);
const chatType = this.deriveChatTypeFromKey(sessionKey); const chatType = this.deriveChatTypeFromKey(sessionKey);
const normalizedKey = sessionKey ?? ""; const normalizedKey = sessionKey ?? "";
for (const rule of scope.rules ?? []) { for (const rule of scope.rules ?? []) {
if (!rule) continue; if (!rule) {
continue;
}
const match = rule.match ?? {}; const match = rule.match ?? {};
if (match.channel && match.channel !== channel) continue; if (match.channel && match.channel !== channel) {
if (match.chatType && match.chatType !== chatType) continue; continue;
if (match.keyPrefix && !normalizedKey.startsWith(match.keyPrefix)) continue; }
if (match.chatType && match.chatType !== chatType) {
continue;
}
if (match.keyPrefix && !normalizedKey.startsWith(match.keyPrefix)) {
continue;
}
return rule.action === "allow"; return rule.action === "allow";
} }
const fallback = scope.default ?? "allow"; const fallback = scope.default ?? "allow";
@ -567,9 +628,13 @@ export class QmdMemoryManager implements MemorySearchManager {
} }
private deriveChannelFromKey(key?: string) { private deriveChannelFromKey(key?: string) {
if (!key) return undefined; if (!key) {
return undefined;
}
const normalized = this.normalizeSessionKey(key); const normalized = this.normalizeSessionKey(key);
if (!normalized) return undefined; if (!normalized) {
return undefined;
}
const parts = normalized.split(":").filter(Boolean); const parts = normalized.split(":").filter(Boolean);
if ( if (
parts.length >= 2 && parts.length >= 2 &&
@ -581,20 +646,32 @@ export class QmdMemoryManager implements MemorySearchManager {
} }
private deriveChatTypeFromKey(key?: string) { private deriveChatTypeFromKey(key?: string) {
if (!key) return undefined; if (!key) {
return undefined;
}
const normalized = this.normalizeSessionKey(key); const normalized = this.normalizeSessionKey(key);
if (!normalized) return undefined; if (!normalized) {
if (normalized.includes(":group:")) return "group"; return undefined;
if (normalized.includes(":channel:")) return "channel"; }
if (normalized.includes(":group:")) {
return "group";
}
if (normalized.includes(":channel:")) {
return "channel";
}
return "direct"; return "direct";
} }
private normalizeSessionKey(key: string): string | undefined { private normalizeSessionKey(key: string): string | undefined {
const trimmed = key.trim(); const trimmed = key.trim();
if (!trimmed) return undefined; if (!trimmed) {
return undefined;
}
const parsed = parseAgentSessionKey(trimmed); const parsed = parseAgentSessionKey(trimmed);
const normalized = (parsed?.rest ?? trimmed).toLowerCase(); const normalized = (parsed?.rest ?? trimmed).toLowerCase();
if (normalized.startsWith("subagent:")) return undefined; if (normalized.startsWith("subagent:")) {
return undefined;
}
return normalized; return normalized;
} }
@ -603,7 +680,9 @@ export class QmdMemoryManager implements MemorySearchManager {
collectionRelativePath: string, collectionRelativePath: string,
): { rel: string; abs: string; source: MemorySource } | null { ): { rel: string; abs: string; source: MemorySource } | null {
const root = this.collectionRoots.get(collection); const root = this.collectionRoots.get(collection);
if (!root) return null; if (!root) {
return null;
}
const normalizedRelative = collectionRelativePath.replace(/\\/g, "/"); const normalizedRelative = collectionRelativePath.replace(/\\/g, "/");
const absPath = path.normalize(path.resolve(root.path, collectionRelativePath)); const absPath = path.normalize(path.resolve(root.path, collectionRelativePath));
const relativeToWorkspace = path.relative(this.workspaceDir, absPath); const relativeToWorkspace = path.relative(this.workspaceDir, absPath);
@ -625,7 +704,9 @@ export class QmdMemoryManager implements MemorySearchManager {
const insideWorkspace = this.isInsideWorkspace(relativeToWorkspace); const insideWorkspace = this.isInsideWorkspace(relativeToWorkspace);
if (insideWorkspace) { if (insideWorkspace) {
const normalized = relativeToWorkspace.replace(/\\/g, "/"); const normalized = relativeToWorkspace.replace(/\\/g, "/");
if (!normalized) return path.basename(absPath); if (!normalized) {
return path.basename(absPath);
}
return normalized; return normalized;
} }
const sanitized = collectionRelativePath.replace(/^\/+/, ""); const sanitized = collectionRelativePath.replace(/^\/+/, "");
@ -633,9 +714,15 @@ export class QmdMemoryManager implements MemorySearchManager {
} }
private isInsideWorkspace(relativePath: string): boolean { private isInsideWorkspace(relativePath: string): boolean {
if (!relativePath) return true; if (!relativePath) {
if (relativePath.startsWith("..")) return false; return true;
if (relativePath.startsWith(`..${path.sep}`)) return false; }
if (relativePath.startsWith("..")) {
return false;
}
if (relativePath.startsWith(`..${path.sep}`)) {
return false;
}
return !path.isAbsolute(relativePath); return !path.isAbsolute(relativePath);
} }
@ -646,7 +733,9 @@ export class QmdMemoryManager implements MemorySearchManager {
throw new Error("invalid qmd path"); throw new Error("invalid qmd path");
} }
const root = this.collectionRoots.get(collection); const root = this.collectionRoots.get(collection);
if (!root) throw new Error(`unknown qmd collection: ${collection}`); if (!root) {
throw new Error(`unknown qmd collection: ${collection}`);
}
const joined = rest.join("/"); const joined = rest.join("/");
const resolved = path.resolve(root.path, joined); const resolved = path.resolve(root.path, joined);
if (!this.isWithinRoot(root.path, resolved)) { if (!this.isWithinRoot(root.path, resolved)) {
@ -665,25 +754,33 @@ export class QmdMemoryManager implements MemorySearchManager {
const normalizedWorkspace = this.workspaceDir.endsWith(path.sep) const normalizedWorkspace = this.workspaceDir.endsWith(path.sep)
? this.workspaceDir ? this.workspaceDir
: `${this.workspaceDir}${path.sep}`; : `${this.workspaceDir}${path.sep}`;
if (absPath === this.workspaceDir) return true; if (absPath === this.workspaceDir) {
return true;
}
const candidate = absPath.endsWith(path.sep) ? absPath : `${absPath}${path.sep}`; const candidate = absPath.endsWith(path.sep) ? absPath : `${absPath}${path.sep}`;
return candidate.startsWith(normalizedWorkspace); return candidate.startsWith(normalizedWorkspace);
} }
private isWithinRoot(root: string, candidate: string): boolean { private isWithinRoot(root: string, candidate: string): boolean {
const normalizedRoot = root.endsWith(path.sep) ? root : `${root}${path.sep}`; const normalizedRoot = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
if (candidate === root) return true; if (candidate === root) {
return true;
}
const next = candidate.endsWith(path.sep) ? candidate : `${candidate}${path.sep}`; const next = candidate.endsWith(path.sep) ? candidate : `${candidate}${path.sep}`;
return next.startsWith(normalizedRoot); return next.startsWith(normalizedRoot);
} }
private clampResultsByInjectedChars(results: MemorySearchResult[]): MemorySearchResult[] { private clampResultsByInjectedChars(results: MemorySearchResult[]): MemorySearchResult[] {
const budget = this.qmd.limits.maxInjectedChars; const budget = this.qmd.limits.maxInjectedChars;
if (!budget || budget <= 0) return results; if (!budget || budget <= 0) {
return results;
}
let remaining = budget; let remaining = budget;
const clamped: MemorySearchResult[] = []; const clamped: MemorySearchResult[] = [];
for (const entry of results) { for (const entry of results) {
if (remaining <= 0) break; if (remaining <= 0) {
break;
}
const snippet = entry.snippet ?? ""; const snippet = entry.snippet ?? "";
if (snippet.length <= remaining) { if (snippet.length <= remaining) {
clamped.push(entry); clamped.push(entry);
@ -698,10 +795,16 @@ export class QmdMemoryManager implements MemorySearchManager {
} }
private shouldSkipUpdate(force?: boolean): boolean { private shouldSkipUpdate(force?: boolean): boolean {
if (force) return false; if (force) {
return false;
}
const debounceMs = this.qmd.update.debounceMs; const debounceMs = this.qmd.update.debounceMs;
if (debounceMs <= 0) return false; if (debounceMs <= 0) {
if (!this.lastUpdateAt) return false; return false;
}
if (!this.lastUpdateAt) {
return false;
}
return Date.now() - this.lastUpdateAt < debounceMs; return Date.now() - this.lastUpdateAt < debounceMs;
} }
} }

View File

@ -177,7 +177,9 @@ class FallbackMemoryManager implements MemorySearchManager {
} }
private async ensureFallback(): Promise<MemorySearchManager | null> { private async ensureFallback(): Promise<MemorySearchManager | null> {
if (this.fallback) return this.fallback; if (this.fallback) {
return this.fallback;
}
const fallback = await this.deps.fallbackFactory(); const fallback = await this.deps.fallbackFactory();
if (!fallback) { if (!fallback) {
log.warn("memory fallback requested but builtin index is unavailable"); log.warn("memory fallback requested but builtin index is unavailable");