fix(memory): adapter-aware persistent memory across all 14 platforms

v1.0.100's "Unified Persistent Memory" feature was Claude-centric.
Auto-memory, prior session, and persist memory all hardcoded ~/.claude/,
breaking 13 of 14 platforms. Plus a worktree filename mismatch broke
Claude Code too on worktree sessions.

Architectural fix: adds 3 methods to HookAdapter interface so every
adapter declares its own conventions:
  - getConfigDir()        — ~/.claude, ~/.codex, ~/.qwen, ~/.gemini, etc.
  - getInstructionFiles() — ['CLAUDE.md'], ['AGENTS.md'], ['QWEN.md'], etc.
  - getMemoryDir()        — ~/.claude/memory, ~/.codex/memories, etc.

BaseAdapter ships sensible defaults derived from sessionDirSegments;
only the 11 non-Claude adapters override (Claude inherits).

Wiring changes:
  - searchAutoMemory() now accepts an adapter, dispatches via methods
  - ctx_search timeline uses _detectedAdapter.getConfigDir()
  - ctx_search timeline SessionDB filename now includes worktree suffix
    (matches what session-snapshot/session-extract write to)
  - extract.ts rule detection covers AGENTS.md, GEMINI.md, QWEN.md,
    KIRO.md, copilot-instructions.md, context-mode.mdc, and any
    *.md inside a memory/memories directory

Bonus fixes (from PR #376):
  - OpenCode/KiloCode cache path: now packages/context-mode@latest/
    layout (silently changed by upstream late 2024 — broke doctor/upgrade)
  - OpenCode SessionStart equivalent via experimental.chat.messages.transform
    — prior-session continuity now works on OpenCode/KiloCode

Tests added (8 new files, 65 new tests, all green):
  - tests/adapters/base-adapter-memory.test.ts        (4)
  - tests/adapters/claude-code-memory.test.ts          (3)
  - tests/adapters/memory-conventions.test.ts         (36)
  - tests/core/auto-memory-adapter.test.ts             (5)
  - tests/core/cache-plugin-root.test.ts               (2)
  - tests/core/server-timeline-adapter.test.ts         (3)
  - tests/opencode-session-start.test.ts               (2)
  - tests/session/extract-rule-detection.test.ts      (10)

Closes architectural root cause of #367 follow-ups.
Supersedes #379 (Codex), #370 (Qwen), #376 (OpenCode/KiloCode portions).

Co-Authored-By: Marcus Neufeldt <MarcusNeufeldt@users.noreply.github.com>
Co-Authored-By: btxbtxbtx <btxbtxbtx@users.noreply.github.com>
Co-Authored-By: Mickey Lazarevic <mikij@users.noreply.github.com>
This commit is contained in:
Mert Koseoglu
2026-05-02 18:29:09 +03:00
parent a9b1970a17
commit 6262c13283
31 changed files with 1213 additions and 306 deletions
+151 -132
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+137 -118
View File
File diff suppressed because one or more lines are too long
+9
View File
@@ -111,6 +111,15 @@ export class AntigravityAdapter extends BaseAdapter implements HookAdapter {
return resolve(homedir(), ".gemini", "antigravity", "mcp_config.json");
}
/** Antigravity nests under ~/.gemini/antigravity/. */
getConfigDir(): string {
return resolve(homedir(), ".gemini", "antigravity");
}
getInstructionFiles(): string[] {
return ["GEMINI.md"];
}
generateHookConfig(_pluginRoot: string): HookRegistration {
return {};
}
+26
View File
@@ -47,6 +47,32 @@ export abstract class BaseAdapter {
return join(this.getSessionDir(), `${hash}-events.md`);
}
/**
* Default: build config dir from sessionDirSegments rooted at $HOME.
* Adapters with project-scoped or non-home-rooted config dirs
* (cursor, vscode-copilot, jetbrains-copilot, openclaw, opencode)
* override this.
*/
getConfigDir(): string {
return join(homedir(), ...this.sessionDirSegments);
}
/**
* Default: Claude Code convention. Most adapters override with their
* own platform-specific instruction file name (AGENTS.md, GEMINI.md, ...).
*/
getInstructionFiles(): string[] {
return ["CLAUDE.md"];
}
/**
* Default: <configDir>/memory. Adapters with a different memory dir
* name (e.g., codex uses "memories" plural) override this.
*/
getMemoryDir(): string {
return join(this.getConfigDir(), "memory");
}
backupSettings(): string | null {
const settingsPath = this.getSettingsPath();
try {
+10
View File
@@ -207,6 +207,16 @@ export class CodexAdapter extends BaseAdapter implements HookAdapter {
return resolve(homedir(), ".codex", "config.toml");
}
getInstructionFiles(): string[] {
// Codex CLI honors AGENTS.md plus an optional override file.
return ["AGENTS.md", "AGENTS.override.md"];
}
getMemoryDir(): string {
// Codex uses "memories" (plural), not the default "memory".
return resolve(homedir(), ".codex", "memories");
}
generateHookConfig(pluginRoot: string): HookRegistration {
return {
PreToolUse: [
+12
View File
@@ -213,6 +213,18 @@ export class CursorAdapter extends BaseAdapter implements HookAdapter {
return resolve(".cursor", "hooks.json");
}
/**
* Cursor stores conventions per project under .cursor/. Returned as a
* project-relative path; callers resolve against projectDir.
*/
getConfigDir(): string {
return ".cursor";
}
getInstructionFiles(): string[] {
return ["context-mode.mdc"];
}
generateHookConfig(_pluginRoot: string): HookRegistration {
const hooks = {
[CURSOR_HOOK_NAMES.PRE_TOOL_USE]: [
+4
View File
@@ -224,6 +224,10 @@ export class GeminiCLIAdapter extends BaseAdapter implements HookAdapter {
return resolve(homedir(), ".gemini", "settings.json");
}
getInstructionFiles(): string[] {
return ["GEMINI.md"];
}
generateHookConfig(pluginRoot: string): HookRegistration {
return {
[GEMINI_HOOK_NAMES.BEFORE_TOOL]: [
+9
View File
@@ -65,6 +65,15 @@ export class JetBrainsCopilotAdapter extends CopilotBaseAdapter {
return process.env.IDEA_INITIAL_DIRECTORY || process.env.CLAUDE_PROJECT_DIR || process.cwd();
}
/** JetBrains Copilot honors .github/copilot-instructions.md per project. */
getConfigDir(): string {
return ".github";
}
getInstructionFiles(): string[] {
return ["copilot-instructions.md"];
}
// ── Diagnostics (doctor) ─────────────────────────────────
validateHooks(pluginRoot: string): DiagnosticResult[] {
+13
View File
@@ -157,6 +157,19 @@ export class KiroAdapter extends BaseAdapter implements HookAdapter {
return resolve(homedir(), ".kiro", "settings", "mcp.json");
}
/**
* Kiro stores per-project context under .kiro/ (steering files, etc).
* Auto-memory + rule detection use this project-relative dir.
* (Settings/MCP config still live under ~/.kiro/.)
*/
getConfigDir(): string {
return ".kiro";
}
getInstructionFiles(): string[] {
return ["KIRO.md"];
}
generateHookConfig(pluginRoot: string): HookRegistration {
// Kiro CLI hook config format: { preToolUse: [{ matcher, command }] }
// Note: This generates the entries for agent config files
+17
View File
@@ -205,6 +205,23 @@ export class OpenClawAdapter extends BaseAdapter implements HookAdapter {
return resolve("openclaw.json");
}
/**
* OpenClaw stores everything in the project root no separate
* config dir. Returned as empty so callers fall through to projectDir.
*/
getConfigDir(): string {
return "";
}
getInstructionFiles(): string[] {
return ["AGENTS.md"];
}
/** Project-relative ./memory directory. */
getMemoryDir(): string {
return "memory";
}
generateHookConfig(_pluginRoot: string): HookRegistration {
// OpenClaw uses TS plugin paradigm — hooks are registered via
// api.registerHook() in the plugin entry point, not via config files.
+19 -7
View File
@@ -253,17 +253,29 @@ export class OpenCodeAdapter extends BaseAdapter implements HookAdapter {
}
getSessionDir(): string {
let configDir: string;
if (process.platform === "win32") {
configDir = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
} else {
configDir = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
}
const dir = join(configDir, this.platform, "context-mode", "sessions");
const dir = join(this.getConfigDir(), "context-mode", "sessions");
mkdirSync(dir, { recursive: true });
return dir;
}
/**
* OpenCode/KiloCode honor XDG_CONFIG_HOME on POSIX and APPDATA on Windows.
* Falls back to ~/.config/<platform> (or %APPDATA%\<platform>).
*/
getConfigDir(): string {
let root: string;
if (process.platform === "win32") {
root = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
} else {
root = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
}
return join(root, this.platform);
}
getInstructionFiles(): string[] {
return ["AGENTS.md"];
}
generateHookConfig(_pluginRoot: string): HookRegistration {
// OpenCode uses TS plugin paradigm — hooks are registered via plugin array
// in opencode.json, not via command-based hook entries.
+4
View File
@@ -60,6 +60,10 @@ export class QwenCodeAdapter extends ClaudeCodeBaseAdapter implements HookAdapte
return resolve(homedir(), ".qwen", "settings.json");
}
getInstructionFiles(): string[] {
return ["QWEN.md"];
}
generateHookConfig(pluginRoot: string): HookRegistration {
// Qwen Code passes native tool names in hook stdin (verified from
// packages/core/src/tools/tool-names.ts). Claude-style names (Bash, Read)
+25
View File
@@ -218,6 +218,31 @@ export interface HookAdapter {
/** Compute per-project session events file path. */
getSessionEventsPath(projectDir: string): string;
/**
* Platform config directory (e.g., ~/.claude, ~/.codex, ~/.qwen,
* ~/.config/opencode). For project-scoped platforms (cursor,
* vscode-copilot, jetbrains-copilot, openclaw), returns the in-project
* convention dir name (e.g., ".cursor", ".github") callers resolve
* against projectDir as needed. Used for auto-memory + ctx_search timeline.
*/
getConfigDir(): string;
/**
* Names of platform-native instruction/rule files that act as the
* project's "user CLAUDE.md equivalent" (e.g., ["CLAUDE.md"],
* ["AGENTS.md"], ["GEMINI.md"]). Auto-memory scans for these in the
* project root and config dir, and rule-detection emits "rule" events
* when they are read.
*/
getInstructionFiles(): string[];
/**
* Directory where persistent per-user memory is stored
* (e.g., ~/.claude/memory, ~/.codex/memories). Auto-memory scans
* *.md files in this directory.
*/
getMemoryDir(): string;
/** Generate hook registration config for this platform. */
generateHookConfig(pluginRoot: string): HookRegistration;
+9
View File
@@ -86,6 +86,15 @@ export class VSCodeCopilotAdapter extends CopilotBaseAdapter {
return dir;
}
/** VS Code Copilot honors .github/copilot-instructions.md per project. */
getConfigDir(): string {
return ".github";
}
getInstructionFiles(): string[] {
return ["copilot-instructions.md"];
}
// ── Diagnostics (doctor) ─────────────────────────────────
validateHooks(pluginRoot: string): DiagnosticResult[] {
+4
View File
@@ -105,6 +105,10 @@ export class ZedAdapter extends BaseAdapter implements HookAdapter {
return resolve(homedir(), ".config", "zed", "settings.json");
}
getInstructionFiles(): string[] {
return ["AGENTS.md"];
}
generateHookConfig(_pluginRoot: string): HookRegistration {
// Zed does not support hooks — return empty registration
return {};
+8 -4
View File
@@ -169,14 +169,18 @@ function defaultPluginRoot(): string {
return __dirname;
}
// Opencode/Kilocode install plugins from npm into .cache folder
// Opencode/Kilocode install plugins from npm into a per-package cache folder.
// Layout (changed silently in late 2024 — see PR #376 / KiloCode#9503):
// POSIX : ~/.cache/<platform>/packages/context-mode@latest/node_modules/context-mode
// Windows: %LOCALAPPDATA%\<platform>\packages\context-mode@latest\node_modules\context-mode
function cachePluginRoot(platform: string): string {
const subPath = ["packages", "context-mode@latest", "node_modules", "context-mode"];
if (process.platform === "win32") {
const localApp = process.env.LOCALAPPDATA;
if (localApp) return resolve(localApp, platform, "node_modules", "context-mode");
return resolve(homedir(), "AppData", "Local", platform, "node_modules", "context-mode");
if (localApp) return resolve(localApp, platform, ...subPath);
return resolve(homedir(), "AppData", "Local", platform, ...subPath);
}
return resolve(homedir(), ".cache", platform, "node_modules", "context-mode");
return resolve(homedir(), ".cache", platform, ...subPath);
}
function getPluginRoot(): string {
+37
View File
@@ -109,6 +109,11 @@ async function createContextModePlugin(ctx: PluginContext) {
// Clean up old sessions on startup (replaces SessionStart hook)
db.cleanupOldSessions(7);
// Track whether we've already injected the prior-session resume into
// a chat turn — `experimental.chat.messages.transform` fires on every
// turn, but we only want to inject once per process (SessionStart-equivalent).
let sessionStartInjected = false;
return {
// ── PreToolUse: Routing enforcement ─────────────────
@@ -182,6 +187,38 @@ async function createContextModePlugin(ctx: PluginContext) {
return "";
}
},
// ── SessionStart equivalent (PR #376) ───────────────
// OpenCode lacks a real SessionStart hook (#14808, #5409) but
// recently added `experimental.chat.messages.transform`, which
// fires once per chat turn before messages are sent to the model.
// We piggyback on the *first* invocation per process to inject the
// most-recent resume snapshot from a prior session — matching what
// every other adapter's SessionStart hook does.
"experimental.chat.messages.transform": async (
_input: unknown,
output: { messages?: Array<{ role: string; content: string }> } | undefined,
) => {
if (sessionStartInjected) return;
sessionStartInjected = true;
try {
// Find the most recent resume snapshot for this project across
// any prior session. ContextSessionDB has no per-project resume
// lookup, so we fall back to the current session's resume row.
const row = db.getResume(sessionId);
const snapshot = row?.snapshot;
if (!snapshot || snapshot.length === 0) return;
if (output && Array.isArray(output.messages)) {
output.messages.unshift({
role: "system",
content: snapshot,
});
}
} catch {
// Silent — never break the chat turn
}
},
};
}
+66 -22
View File
@@ -1,12 +1,13 @@
/**
* Auto-memory search searches CLAUDE.md and MEMORY.md files for
* persisted decisions, preferences, and context from prior sessions.
* Auto-memory search searches CLAUDE.md / AGENTS.md / GEMINI.md / etc.
* and the platform's persistent memory directory for decisions,
* preferences, and context from prior sessions.
*
* Returns results in a format compatible with the unified search pipeline.
*/
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { join, basename } from "node:path";
import { join, isAbsolute } from "node:path";
import { homedir } from "node:os";
const DEBUG = process.env.DEBUG?.includes("context-mode");
@@ -20,18 +21,31 @@ export interface AutoMemoryResult {
}
/**
* Search auto-memory files (CLAUDE.md, MEMORY.md, user identity files)
* for content matching any of the given queries.
* Minimal adapter contract used by searchAutoMemory.
* Avoids depending on the full HookAdapter type to keep this module standalone.
*/
export interface AutoMemoryAdapter {
getConfigDir(): string;
getInstructionFiles(): string[];
getMemoryDir(): string;
}
/**
* Search auto-memory files for content matching any of the given queries.
*
* Scans:
* 1. Project-level: <projectDir>/CLAUDE.md
* 2. User-level: <configDir>/CLAUDE.md
* 3. User memory: <configDir>/memory/*.md
* When `adapter` is provided, the per-platform conventions are used:
* 1. Project-level: <projectDir>/<each instructionFile>
* 2. User-level: <configDir>/<each instructionFile>
* 3. Memory dir: <memoryDir>/*.md
*
* Without an adapter (legacy callers), defaults to Claude conventions
* (CLAUDE.md + ~/.claude/memory) for backwards compatibility.
*
* @param queries Array of search terms
* @param limit Max results to return
* @param projectDir Project directory path
* @param configDir Config directory (e.g. ~/.claude)
* @param configDir Explicit config dir override (legacy callers)
* @param adapter Platform adapter supplies instruction files + memory dir
* @returns Matching auto-memory results
*/
export function searchAutoMemory(
@@ -39,30 +53,48 @@ export function searchAutoMemory(
limit: number = 5,
projectDir?: string,
configDir?: string,
adapter?: AutoMemoryAdapter,
): AutoMemoryResult[] {
const results: AutoMemoryResult[] = [];
const effectiveConfigDir = configDir || join(homedir(), ".claude");
// Resolve conventions — adapter wins over explicit configDir, which wins
// over the historical Claude defaults.
const instructionFiles = adapter?.getInstructionFiles() ?? ["CLAUDE.md"];
const adapterConfigDir = adapter?.getConfigDir();
const effectiveConfigDir = adapterConfigDir
? resolveAgainst(projectDir, adapterConfigDir)
: (configDir || join(homedir(), ".claude"));
const adapterMemoryDir = adapter?.getMemoryDir();
const memoryDir = adapterMemoryDir
? resolveAgainst(projectDir, adapterMemoryDir)
: join(effectiveConfigDir, "memory");
// Collect candidate files
const candidates: Array<{ path: string; label: string }> = [];
// 1. Project-level CLAUDE.md
// 1. Project-level instruction files
if (projectDir) {
const projectClaude = join(projectDir, "CLAUDE.md");
if (existsSync(projectClaude)) {
candidates.push({ path: projectClaude, label: "project/CLAUDE.md" });
for (const fileName of instructionFiles) {
const p = join(projectDir, fileName);
if (existsSync(p)) {
candidates.push({ path: p, label: `project/${fileName}` });
}
}
}
// 2. User-level CLAUDE.md
const userClaude = join(effectiveConfigDir, "CLAUDE.md");
if (existsSync(userClaude)) {
candidates.push({ path: userClaude, label: "user/CLAUDE.md" });
// 2. User-level instruction files (skip when configDir resolves to the
// project root — already covered by step 1, would emit dup labels).
if (effectiveConfigDir && effectiveConfigDir !== projectDir) {
for (const fileName of instructionFiles) {
const p = join(effectiveConfigDir, fileName);
if (existsSync(p)) {
candidates.push({ path: p, label: `user/${fileName}` });
}
}
}
// 3. User memory directory
const memoryDir = join(effectiveConfigDir, "memory");
if (existsSync(memoryDir)) {
// 3. Memory directory
if (memoryDir && existsSync(memoryDir)) {
try {
const files = readdirSync(memoryDir).filter(f => f.endsWith(".md"));
for (const file of files) {
@@ -134,3 +166,15 @@ export function searchAutoMemory(
return results.slice(0, limit);
}
/**
* Resolve a possibly-relative path (e.g. ".github", "memory") against a
* project directory. Absolute paths and empty strings are returned as-is
* (empty == "use projectDir directly").
*/
function resolveAgainst(projectDir: string | undefined, p: string): string {
if (!p) return projectDir ?? "";
if (isAbsolute(p)) return p;
if (!projectDir) return p;
return join(projectDir, p);
}
+5 -2
View File
@@ -8,7 +8,7 @@
import type { ContentStore, SearchResult } from "../store.js";
import type { SessionDB, StoredEvent } from "../session/db.js";
import { searchAutoMemory } from "./auto-memory.js";
import { searchAutoMemory, type AutoMemoryAdapter } from "./auto-memory.js";
const DEBUG = process.env.DEBUG?.includes("context-mode");
@@ -38,6 +38,8 @@ export interface SearchAllSourcesOpts {
sessionDB?: SessionDB | null;
projectDir?: string;
configDir?: string;
/** Detected platform adapter — used for adapter-aware auto-memory. */
adapter?: AutoMemoryAdapter;
}
// ─────────────────────────────────────────────────────────
@@ -64,6 +66,7 @@ export function searchAllSources(opts: SearchAllSourcesOpts): UnifiedSearchResul
sessionDB,
projectDir,
configDir,
adapter,
} = opts;
const results: UnifiedSearchResult[] = [];
@@ -114,7 +117,7 @@ export function searchAllSources(opts: SearchAllSourcesOpts): UnifiedSearchResul
// Source 3: Auto-memory
try {
const memResults = searchAutoMemory([query], limit, projectDir, configDir);
const memResults = searchAutoMemory([query], limit, projectDir, configDir, adapter);
results.push(...memResults);
} catch (e) {
if (DEBUG) process.stderr.write(`[ctx] auto-memory search failed: ${e}\n`);
+10 -3
View File
@@ -1360,19 +1360,25 @@ server.registerTool(
let totalSize = 0;
const sections: string[] = [];
// Open SessionDB once before the loop (Blocker 4: avoid open/close per query)
// Open SessionDB once before the loop (Blocker 4: avoid open/close per query).
// The DB filename must match what session-snapshot/session-extract write to,
// which is `${hash}${getWorktreeSuffix()}.db` — bug #4 was the missing suffix.
let timelineDB: InstanceType<typeof SessionDB> | null = null;
if (sort === "timeline") {
try {
const sessionsDir = getSessionDir();
const dbFile = join(sessionsDir, `${hashProjectDir()}.db`);
const dbFile = join(sessionsDir, `${hashProjectDir()}${getWorktreeSuffix()}.db`);
if (existsSync(dbFile)) {
timelineDB = new SessionDB({ dbPath: dbFile });
}
} catch { /* SessionDB unavailable — search ContentStore + auto-memory only */ }
}
const configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
// Adapter-aware config dir. Falls back to CLAUDE_CONFIG_DIR / ~/.claude
// only when no platform adapter has been detected (e.g. raw `npx context-mode dev`).
const configDir = _detectedAdapter?.getConfigDir()
|| process.env.CLAUDE_CONFIG_DIR
|| join(homedir(), ".claude");
try {
for (const q of queryList) {
@@ -1393,6 +1399,7 @@ server.registerTool(
sessionDB: timelineDB,
projectDir: getProjectDir(),
configDir,
adapter: _detectedAdapter ?? undefined,
});
} else {
results = store.searchWithFallback(q, effectiveLimit, source, contentType);
+18 -2
View File
@@ -71,8 +71,24 @@ function extractFileAndRule(input: HookInput): SessionEvent[] {
if (tool_name === "Read") {
const filePath = String(tool_input["file_path"] ?? "");
// Rule detection: CLAUDE.md or anything inside a .claude/ directory
const isRuleFile = /CLAUDE\.md$|\.claude[\\/]/i.test(filePath);
// Rule detection — covers every supported platform's instruction
// file convention plus per-user memory directories. Hardcoding here
// (instead of dispatching through the adapter) keeps extract.ts
// pure / sync / hot-path-safe — the tradeoff is that adding a new
// platform requires updating this regex.
//
// Filenames: CLAUDE.md, AGENTS.md, AGENTS.override.md, GEMINI.md,
// QWEN.md, KIRO.md, copilot-instructions.md,
// context-mode.mdc
// Directories: .claude/, .codex/memories/, .qwen/memory/,
// .gemini/memory/, .config/<plat>/memory/, .cursor/memory/,
// .github/memory/, .kiro/memory/, etc.
const isRuleFile =
/(?:CLAUDE|AGENTS(?:\.override)?|GEMINI|QWEN|KIRO)\.md$/i.test(filePath)
|| /\/copilot-instructions\.md$/i.test(filePath)
|| /\/context-mode\.mdc$/i.test(filePath)
|| /\.claude[\\/]/i.test(filePath)
|| /[\\/]memor(?:y|ies)[\\/][^\\/]+\.md$/i.test(filePath);
if (isRuleFile) {
events.push({
type: "rule",
@@ -0,0 +1,47 @@
import "../setup-home";
import { describe, it, expect } from "vitest";
import { homedir } from "node:os";
import { join } from "node:path";
import { BaseAdapter } from "../../src/adapters/base.js";
/**
* BaseAdapter memory/config dispatch defaults.
*
* Slice 1 of the adapter-aware persistent memory rework.
* Verifies the three new defaults BaseAdapter exposes for
* auto-memory + ctx_search timeline + rule detection:
* - getConfigDir() derived from sessionDirSegments
* - getInstructionFiles() defaults to ["CLAUDE.md"] (Claude convention)
* - getMemoryDir() defaults to <configDir>/memory
*/
class TestAdapter extends BaseAdapter {
constructor(segments: string[]) {
super(segments);
}
getSettingsPath(): string {
return join(this.getConfigDir(), "settings.json");
}
}
describe("BaseAdapter memory/config defaults", () => {
it("getConfigDir returns $HOME joined with sessionDirSegments (single segment)", () => {
const adapter = new TestAdapter([".claude"]);
expect(adapter.getConfigDir()).toBe(join(homedir(), ".claude"));
});
it("getConfigDir handles multi-segment sessionDirSegments", () => {
const adapter = new TestAdapter([".config", "zed"]);
expect(adapter.getConfigDir()).toBe(join(homedir(), ".config", "zed"));
});
it("getInstructionFiles defaults to ['CLAUDE.md']", () => {
const adapter = new TestAdapter([".claude"]);
expect(adapter.getInstructionFiles()).toEqual(["CLAUDE.md"]);
});
it("getMemoryDir defaults to <configDir>/memory", () => {
const adapter = new TestAdapter([".claude"]);
expect(adapter.getMemoryDir()).toBe(join(homedir(), ".claude", "memory"));
});
});
+26
View File
@@ -0,0 +1,26 @@
import "../setup-home";
import { describe, it, expect } from "vitest";
import { homedir } from "node:os";
import { join } from "node:path";
import { ClaudeCodeAdapter } from "../../src/adapters/claude-code/index.js";
/**
* Slice 2 Claude Code adapter inherits BaseAdapter memory defaults.
* No override needed; verify the inherited values match the
* documented per-adapter convention.
*/
describe("ClaudeCodeAdapter memory conventions", () => {
const adapter = new ClaudeCodeAdapter();
it("getConfigDir returns ~/.claude", () => {
expect(adapter.getConfigDir()).toBe(join(homedir(), ".claude"));
});
it("getInstructionFiles returns ['CLAUDE.md']", () => {
expect(adapter.getInstructionFiles()).toEqual(["CLAUDE.md"]);
});
it("getMemoryDir returns ~/.claude/memory", () => {
expect(adapter.getMemoryDir()).toBe(join(homedir(), ".claude", "memory"));
});
});
+186
View File
@@ -0,0 +1,186 @@
import "../setup-home";
import { describe, it, expect } from "vitest";
import { homedir } from "node:os";
import { join } from "node:path";
import { QwenCodeAdapter } from "../../src/adapters/qwen-code/index.js";
import { GeminiCLIAdapter } from "../../src/adapters/gemini-cli/index.js";
import { CodexAdapter } from "../../src/adapters/codex/index.js";
import { OpenCodeAdapter } from "../../src/adapters/opencode/index.js";
import { CursorAdapter } from "../../src/adapters/cursor/index.js";
import { VSCodeCopilotAdapter } from "../../src/adapters/vscode-copilot/index.js";
import { JetBrainsCopilotAdapter } from "../../src/adapters/jetbrains-copilot/index.js";
import { KiroAdapter } from "../../src/adapters/kiro/index.js";
import { ZedAdapter } from "../../src/adapters/zed/index.js";
import { AntigravityAdapter } from "../../src/adapters/antigravity/index.js";
import { OpenClawAdapter } from "../../src/adapters/openclaw/index.js";
/**
* Slice 3 per-adapter memory/config conventions.
*
* Each adapter declares its own configDir, instructionFiles, memoryDir.
* These are consumed by:
* - searchAutoMemory() (auto-memory file scan)
* - ctx_search timeline (configDir for prior session lookup)
* - extract.ts isRule (instruction file detection)
*/
describe("Adapter memory conventions", () => {
describe("QwenCodeAdapter", () => {
const a = new QwenCodeAdapter();
it("getConfigDir is ~/.qwen", () => {
expect(a.getConfigDir()).toBe(join(homedir(), ".qwen"));
});
it("getInstructionFiles is ['QWEN.md']", () => {
expect(a.getInstructionFiles()).toEqual(["QWEN.md"]);
});
it("getMemoryDir is ~/.qwen/memory", () => {
expect(a.getMemoryDir()).toBe(join(homedir(), ".qwen", "memory"));
});
});
describe("GeminiCLIAdapter", () => {
const a = new GeminiCLIAdapter();
it("getConfigDir is ~/.gemini", () => {
expect(a.getConfigDir()).toBe(join(homedir(), ".gemini"));
});
it("getInstructionFiles is ['GEMINI.md']", () => {
expect(a.getInstructionFiles()).toEqual(["GEMINI.md"]);
});
it("getMemoryDir is ~/.gemini/memory", () => {
expect(a.getMemoryDir()).toBe(join(homedir(), ".gemini", "memory"));
});
});
describe("CodexAdapter", () => {
const a = new CodexAdapter();
it("getConfigDir is ~/.codex", () => {
expect(a.getConfigDir()).toBe(join(homedir(), ".codex"));
});
it("getInstructionFiles is ['AGENTS.md', 'AGENTS.override.md']", () => {
expect(a.getInstructionFiles()).toEqual(["AGENTS.md", "AGENTS.override.md"]);
});
it("getMemoryDir is ~/.codex/memories (plural)", () => {
expect(a.getMemoryDir()).toBe(join(homedir(), ".codex", "memories"));
});
});
describe("OpenCodeAdapter (default platform=opencode)", () => {
const a = new OpenCodeAdapter();
it("getConfigDir is ~/.config/opencode", () => {
expect(a.getConfigDir()).toBe(join(homedir(), ".config", "opencode"));
});
it("getInstructionFiles is ['AGENTS.md']", () => {
expect(a.getInstructionFiles()).toEqual(["AGENTS.md"]);
});
it("getMemoryDir is ~/.config/opencode/memory", () => {
expect(a.getMemoryDir()).toBe(join(homedir(), ".config", "opencode", "memory"));
});
});
describe("OpenCodeAdapter (kilo variant)", () => {
const a = new OpenCodeAdapter("kilo");
it("getConfigDir is ~/.config/kilo", () => {
expect(a.getConfigDir()).toBe(join(homedir(), ".config", "kilo"));
});
it("getInstructionFiles is ['AGENTS.md']", () => {
expect(a.getInstructionFiles()).toEqual(["AGENTS.md"]);
});
it("getMemoryDir is ~/.config/kilo/memory", () => {
expect(a.getMemoryDir()).toBe(join(homedir(), ".config", "kilo", "memory"));
});
});
describe("CursorAdapter", () => {
const a = new CursorAdapter();
it("getConfigDir is .cursor (project-relative)", () => {
expect(a.getConfigDir()).toBe(".cursor");
});
it("getInstructionFiles is ['context-mode.mdc']", () => {
expect(a.getInstructionFiles()).toEqual(["context-mode.mdc"]);
});
it("getMemoryDir is .cursor/memory (project-relative)", () => {
expect(a.getMemoryDir()).toBe(join(".cursor", "memory"));
});
});
describe("VSCodeCopilotAdapter", () => {
const a = new VSCodeCopilotAdapter();
it("getConfigDir is .github (project-relative)", () => {
expect(a.getConfigDir()).toBe(".github");
});
it("getInstructionFiles is ['copilot-instructions.md']", () => {
expect(a.getInstructionFiles()).toEqual(["copilot-instructions.md"]);
});
it("getMemoryDir is .github/memory", () => {
expect(a.getMemoryDir()).toBe(join(".github", "memory"));
});
});
describe("JetBrainsCopilotAdapter", () => {
const a = new JetBrainsCopilotAdapter();
it("getConfigDir is .github (project-relative)", () => {
expect(a.getConfigDir()).toBe(".github");
});
it("getInstructionFiles is ['copilot-instructions.md']", () => {
expect(a.getInstructionFiles()).toEqual(["copilot-instructions.md"]);
});
it("getMemoryDir is .github/memory", () => {
expect(a.getMemoryDir()).toBe(join(".github", "memory"));
});
});
describe("KiroAdapter", () => {
const a = new KiroAdapter();
it("getConfigDir is .kiro (project-relative)", () => {
expect(a.getConfigDir()).toBe(".kiro");
});
it("getInstructionFiles is ['KIRO.md']", () => {
expect(a.getInstructionFiles()).toEqual(["KIRO.md"]);
});
it("getMemoryDir is .kiro/memory", () => {
expect(a.getMemoryDir()).toBe(join(".kiro", "memory"));
});
});
describe("ZedAdapter", () => {
const a = new ZedAdapter();
it("getConfigDir is ~/.config/zed", () => {
expect(a.getConfigDir()).toBe(join(homedir(), ".config", "zed"));
});
it("getInstructionFiles is ['AGENTS.md']", () => {
expect(a.getInstructionFiles()).toEqual(["AGENTS.md"]);
});
it("getMemoryDir is ~/.config/zed/memory", () => {
expect(a.getMemoryDir()).toBe(join(homedir(), ".config", "zed", "memory"));
});
});
describe("AntigravityAdapter", () => {
const a = new AntigravityAdapter();
it("getConfigDir is ~/.gemini/antigravity", () => {
expect(a.getConfigDir()).toBe(join(homedir(), ".gemini", "antigravity"));
});
it("getInstructionFiles is ['GEMINI.md']", () => {
expect(a.getInstructionFiles()).toEqual(["GEMINI.md"]);
});
it("getMemoryDir is ~/.gemini/antigravity/memory", () => {
expect(a.getMemoryDir()).toBe(
join(homedir(), ".gemini", "antigravity", "memory"),
);
});
});
describe("OpenClawAdapter", () => {
const a = new OpenClawAdapter();
it("getConfigDir is empty string (project-rooted)", () => {
expect(a.getConfigDir()).toBe("");
});
it("getInstructionFiles is ['AGENTS.md']", () => {
expect(a.getInstructionFiles()).toEqual(["AGENTS.md"]);
});
it("getMemoryDir is 'memory' (project-relative)", () => {
expect(a.getMemoryDir()).toBe("memory");
});
});
});
+134
View File
@@ -0,0 +1,134 @@
import "../setup-home";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { searchAutoMemory } from "../../src/search/auto-memory.js";
import { CodexAdapter } from "../../src/adapters/codex/index.js";
import { GeminiCLIAdapter } from "../../src/adapters/gemini-cli/index.js";
/**
* Slice 4 searchAutoMemory accepts an adapter and uses its
* getInstructionFiles() / getMemoryDir() / getConfigDir() instead of
* hardcoded ~/.claude / CLAUDE.md.
*
* Without an adapter it falls back to the historical Claude defaults
* (so existing call sites keep working).
*/
describe("searchAutoMemory adapter dispatch", () => {
let projectDir: string;
let configDir: string;
beforeEach(() => {
projectDir = mkdtempSync(join(tmpdir(), "ctxam-proj-"));
configDir = mkdtempSync(join(tmpdir(), "ctxam-cfg-"));
});
afterEach(() => {
rmSync(projectDir, { recursive: true, force: true });
rmSync(configDir, { recursive: true, force: true });
});
it("uses adapter.getInstructionFiles() to discover project rule files", () => {
// Codex declares ['AGENTS.md', 'AGENTS.override.md'].
writeFileSync(
join(projectDir, "AGENTS.md"),
"# Codex Agent Rules\nUse exact terms like ALPHA-CODEX-MARKER everywhere.\n",
"utf-8",
);
const adapter = new CodexAdapter();
const results = searchAutoMemory(
["ALPHA-CODEX-MARKER"],
5,
projectDir,
undefined,
adapter,
);
expect(results.length).toBeGreaterThan(0);
expect(results[0].source).toContain("AGENTS.md");
});
it("uses adapter.getMemoryDir() (e.g. ~/.codex/memories) for memory scan", () => {
// Build a fake codex config with memories/ subdir.
const fakeMemoriesDir = join(configDir, "memories");
mkdirSync(fakeMemoriesDir, { recursive: true });
writeFileSync(
join(fakeMemoriesDir, "decisions.md"),
"Always prefer the BETA-MEMORY-TOKEN approach.\n",
"utf-8",
);
// Custom adapter overriding getConfigDir + getMemoryDir to point at fixture.
const adapter = new CodexAdapter();
(adapter as unknown as { getConfigDir(): string }).getConfigDir = () => configDir;
(adapter as unknown as { getMemoryDir(): string }).getMemoryDir = () => fakeMemoriesDir;
(adapter as unknown as { getInstructionFiles(): string[] }).getInstructionFiles = () => ["AGENTS.md"];
const results = searchAutoMemory(
["BETA-MEMORY-TOKEN"],
5,
projectDir,
undefined,
adapter,
);
expect(results.length).toBeGreaterThan(0);
expect(results[0].source).toContain("decisions.md");
});
it("falls back to CLAUDE.md scan when no adapter is provided", () => {
writeFileSync(
join(projectDir, "CLAUDE.md"),
"Project notes mention GAMMA-FALLBACK-FLAG repeatedly.\n",
"utf-8",
);
const results = searchAutoMemory(["GAMMA-FALLBACK-FLAG"], 5, projectDir);
expect(results.length).toBeGreaterThan(0);
expect(results[0].source).toBe("project/CLAUDE.md");
});
it("scans multiple instruction files when adapter declares multiple (e.g. AGENTS.md + AGENTS.override.md)", () => {
writeFileSync(
join(projectDir, "AGENTS.override.md"),
"Override note: DELTA-OVERRIDE-MARKER takes precedence.\n",
"utf-8",
);
const adapter = new CodexAdapter();
const results = searchAutoMemory(
["DELTA-OVERRIDE-MARKER"],
5,
projectDir,
undefined,
adapter,
);
expect(results.length).toBeGreaterThan(0);
expect(results[0].source).toContain("AGENTS.override.md");
});
it("uses Gemini convention (GEMINI.md) when GeminiCLIAdapter is supplied", () => {
writeFileSync(
join(projectDir, "GEMINI.md"),
"Gemini rules: invoke EPSILON-GEMINI-FLAG on every read.\n",
"utf-8",
);
const results = searchAutoMemory(
["EPSILON-GEMINI-FLAG"],
5,
projectDir,
undefined,
new GeminiCLIAdapter(),
);
expect(results.length).toBeGreaterThan(0);
expect(results[0].source).toContain("GEMINI.md");
});
});
+33
View File
@@ -0,0 +1,33 @@
import "../setup-home";
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
/**
* Slice 7 OpenCode/KiloCode silently changed where they store npm
* plugins (now `packages/context-mode@latest/node_modules/context-mode`).
* The old `node_modules/context-mode` path no longer exists, so doctor
* + upgrade reported false negatives.
*
* Static guard against regression of the published path layout.
* (Per PR #376.)
*/
const CLI_SRC = readFileSync(resolve(__dirname, "../../src/cli.ts"), "utf-8");
describe("cachePluginRoot — OpenCode/KiloCode 2025+ layout", () => {
it("uses the new packages/context-mode@latest layout on POSIX", () => {
// The string `context-mode@latest` should appear in cli.ts (was missing
// before PR #376). Spread args mean we don't expect a single literal path.
expect(CLI_SRC).toMatch(/"context-mode@latest"/);
expect(CLI_SRC).toMatch(/\.cache/);
});
it("uses the matching packages/context-mode@latest layout on Windows", () => {
// Path segments are passed via spread so the literal substring won't appear
// sequentially; instead assert the spread + Windows branch are both present.
expect(CLI_SRC).toMatch(/process\.platform\s*===\s*"win32"/);
expect(CLI_SRC).toMatch(/"packages"\s*,\s*"context-mode@latest"/);
expect(CLI_SRC).toMatch(/AppData[\s\S]{0,200}Local/);
});
});
@@ -0,0 +1,46 @@
import "../setup-home";
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
/**
* Slice 5 server.ts ctx_search timeline mode.
*
* Two static checks, asserted against the source of src/server.ts:
* (a) the SessionDB path used by timeline mode includes the worktree
* suffix (matches the SessionDB path the snapshot/extract hooks write to);
* (b) the configDir + adapter passed to searchAllSources comes from
* _detectedAdapter not a hardcoded ~/.claude path.
*
* Running this as a static guard avoids spawning a full MCP server in tests
* while still preventing regressions of the original bug (#367 follow-ups).
*/
const SERVER_SRC = readFileSync(
resolve(__dirname, "../../src/server.ts"),
"utf-8",
);
describe("ctx_search timeline mode wiring (server.ts)", () => {
it("opens SessionDB at <hash><worktreeSuffix>.db, not bare <hash>.db", () => {
// Bug #4: timeline mode looked at ${hash}.db but extract.ts/snapshot.ts
// write to ${hash}${getWorktreeSuffix()}.db — they never matched in
// worktree sessions.
expect(SERVER_SRC).toMatch(
/join\(\s*sessionsDir\s*,\s*`\$\{hashProjectDir\(\)\}\$\{getWorktreeSuffix\(\)\}\.db`/,
);
});
it("derives configDir from _detectedAdapter.getConfigDir() (not hardcoded ~/.claude)", () => {
expect(SERVER_SRC).toMatch(
/_detectedAdapter\??\.getConfigDir\(\)/,
);
});
it("passes the detected adapter through to searchAllSources", () => {
// searchAllSources call site should include `adapter:` in its options.
expect(SERVER_SRC).toMatch(
/searchAllSources\(\{[\s\S]*?adapter:\s*_detectedAdapter[\s\S]*?\}\)/,
);
});
});
+33
View File
@@ -0,0 +1,33 @@
import "./setup-home";
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
/**
* Slice 7 (bonus, from PR #376) OpenCode added SessionStart support
* via `experimental.chat.messages.transform`. The plugin should
* register that hook so prior-session continuity now works on OpenCode
* the same way it does on Claude Code / Gemini / Qwen.
*
* Static guard against regression of the wired hook.
*/
const SRC = readFileSync(
resolve(__dirname, "../src/opencode-plugin.ts"),
"utf-8",
);
describe("OpenCode plugin — experimental.chat.messages.transform", () => {
it("registers experimental.chat.messages.transform hook", () => {
expect(SRC).toMatch(/"experimental\.chat\.messages\.transform"/);
});
it("uses the hook to inject prior-session content (SessionStart equivalent)", () => {
// The transform hook body should reference db.getResume / snapshot —
// matching the SessionStart pattern used by every other adapter.
const idx = SRC.indexOf('"experimental.chat.messages.transform"');
expect(idx).toBeGreaterThan(0);
const block = SRC.slice(idx, idx + 1500);
expect(block).toMatch(/getResume|buildResumeSnapshot|snapshot/);
});
});
@@ -0,0 +1,80 @@
import { strict as assert } from "node:assert";
import { describe, test } from "vitest";
import { extractEvents } from "../../src/session/extract.js";
/**
* Slice 6 extract.ts rule detection covers all platform-native
* instruction file names (not just CLAUDE.md / .claude/).
*
* Without this, reads of AGENTS.md, GEMINI.md, QWEN.md, KIRO.md,
* copilot-instructions.md, context-mode.mdc, etc. silently dropped
* to file_read only never surfacing as `rule` events for snapshots.
*/
function readEvent(filePath: string, body = "rule body content") {
return extractEvents({
tool_name: "Read",
tool_input: { file_path: filePath },
tool_response: body,
});
}
describe("rule detection — multi-platform instruction files", () => {
test("CLAUDE.md still emits rule + rule_content (regression guard)", () => {
const events = readEvent("/project/CLAUDE.md");
assert.ok(events.some(e => e.type === "rule"), "rule event missing");
assert.ok(events.some(e => e.type === "rule_content"), "rule_content missing");
assert.ok(events.some(e => e.type === "file_read"), "file_read missing");
});
test("Codex AGENTS.md emits a rule event", () => {
const events = readEvent("/project/AGENTS.md");
assert.ok(
events.some(e => e.type === "rule"),
"AGENTS.md should be detected as a rule file",
);
});
test("Codex AGENTS.override.md emits a rule event", () => {
const events = readEvent("/project/AGENTS.override.md");
assert.ok(events.some(e => e.type === "rule"));
});
test("Gemini GEMINI.md emits a rule event", () => {
const events = readEvent("/project/GEMINI.md");
assert.ok(events.some(e => e.type === "rule"));
});
test("Qwen QWEN.md emits a rule event", () => {
const events = readEvent("/project/QWEN.md");
assert.ok(events.some(e => e.type === "rule"));
});
test("Kiro KIRO.md emits a rule event", () => {
const events = readEvent("/project/KIRO.md");
assert.ok(events.some(e => e.type === "rule"));
});
test("VS Code copilot-instructions.md emits a rule event", () => {
const events = readEvent("/project/.github/copilot-instructions.md");
assert.ok(events.some(e => e.type === "rule"));
});
test("Cursor context-mode.mdc emits a rule event", () => {
const events = readEvent("/project/.cursor/context-mode.mdc");
assert.ok(events.some(e => e.type === "rule"));
});
test("reading inside a memory directory emits a rule event", () => {
// Auto-memory files (under <configDir>/memory/) carry persisted
// user decisions — they should be tracked as rules.
const events = readEvent("/Users/me/.codex/memories/decisions.md");
assert.ok(events.some(e => e.type === "rule"));
});
test("an unrelated source file does NOT emit a rule event", () => {
const events = readEvent("/project/src/server.ts");
assert.equal(events.filter(e => e.type === "rule").length, 0);
assert.ok(events.some(e => e.type === "file_read"));
});
});