mirror of
https://github.com/mksglu/context-mode.git
synced 2026-09-19 03:27:16 +08:00
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:
+151
-132
File diff suppressed because one or more lines are too long
+33
-14
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+137
-118
File diff suppressed because one or more lines are too long
@@ -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 {};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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]: [
|
||||
|
||||
@@ -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]: [
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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]*?\}\)/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user