Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 84 additions & 11 deletions crates/hk-core/src/adapter/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,34 @@ impl ClaudeAdapter {
let content = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&content).ok()
}

/// The real working directory a Claude session ran in, read from the
/// first `*.jsonl` transcript in `project_dir`. Every transcript line
/// carries a `"cwd"` field; we read lazily (line by line) and return the
/// first parseable one, so a multi-MB transcript costs one line. Returns
/// `None` when there is no transcript or no `cwd` — caller treats that as
/// an unknown owner.
fn session_cwd(project_dir: &Path) -> Option<PathBuf> {
use std::io::BufRead;
let entries = std::fs::read_dir(project_dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "jsonl") {
let Ok(file) = std::fs::File::open(&path) else {
continue;
};
for line in std::io::BufReader::new(file).lines().map_while(Result::ok) {
if let Some(cwd) = serde_json::from_str::<serde_json::Value>(&line)
.ok()
.and_then(|v| v.get("cwd").and_then(|c| c.as_str()).map(PathBuf::from))
{
return Some(cwd);
}
}
}
}
None
}
}

impl AgentAdapter for ClaudeAdapter {
Expand Down Expand Up @@ -181,20 +209,26 @@ impl AgentAdapter for ClaudeAdapter {
files
}

fn global_memory_files(&self) -> Vec<PathBuf> {
// ~/.claude/projects/*/memory/*.md — outer level iterates project
// dirs (no extension filter); inner level reuses the helper.
fn external_project_memory(&self) -> Vec<(Option<PathBuf>, Vec<PathBuf>)> {
// ~/.claude/projects/<encoded-cwd>/memory/*.md — one group per project
// dir, tagged with the real cwd from its session transcript. The
// encoded dir name is lossy (`/`, space and `~` all collapse to `-`),
// so we read the cwd instead of decoding the name. Single pass.
let projects_dir = self.base_dir().join("projects");
let mut files = Vec::new();
if let Ok(entries) = std::fs::read_dir(&projects_dir) {
for entry in entries.flatten() {
let memory_dir = entry.path().join("memory");
if memory_dir.is_dir() {
files.extend(super::files_with_ext(&memory_dir, "md"));
}
let Ok(entries) = std::fs::read_dir(&projects_dir) else {
return vec![];
};
let mut groups = Vec::new();
for entry in entries.flatten() {
let dir = entry.path();
let files: Vec<PathBuf> =
super::files_with_ext(&dir.join("memory"), "md").collect();
if files.is_empty() {
continue;
}
groups.push((Self::session_cwd(&dir), files));
}
files
groups
}

fn global_settings_files(&self) -> Vec<PathBuf> {
Expand Down Expand Up @@ -383,6 +417,45 @@ mod tests {
assert_eq!(adapter.name(), "claude");
}

#[test]
fn external_project_memory_groups_files_with_session_cwd() {
use std::fs;
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();

// Project A: has a transcript → owner cwd is known.
let a = home.join(".claude/projects/-Users-zoe-Demo-Proj");
fs::create_dir_all(a.join("memory")).unwrap();
fs::write(a.join("s.jsonl"), "{\"cwd\":\"/Users/zoe/Demo/Proj\"}\n").unwrap();
fs::write(a.join("memory/note.md"), "x").unwrap();

// Project B: memory but NO transcript → owner unknown (None).
let b = home.join(".claude/projects/-orphan");
fs::create_dir_all(b.join("memory")).unwrap();
fs::write(b.join("memory/orphan.md"), "y").unwrap();

let adapter = ClaudeAdapter::with_home(home.to_path_buf());
let groups = adapter.external_project_memory();

// One group per project dir that actually has memory.
assert_eq!(groups.len(), 2);

let known = groups
.iter()
.find(|(_, f)| f.iter().any(|p| p.ends_with("note.md")))
.unwrap();
assert_eq!(
known.0.as_deref(),
Some(std::path::Path::new("/Users/zoe/Demo/Proj"))
);

let orphan = groups
.iter()
.find(|(_, f)| f.iter().any(|p| p.ends_with("orphan.md")))
.unwrap();
assert_eq!(orphan.0, None);
}

#[test]
fn test_claude_detect_with_dir() {
let dir = TempDir::new().unwrap();
Expand Down
16 changes: 16 additions & 0 deletions crates/hk-core/src/adapter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,21 @@ pub trait AgentAdapter: Send + Sync {
vec![]
}

/// Per-project memory stored OUTSIDE the project tree, grouped by the
/// project `cwd` that owns it. Claude keeps this at
/// `~/.claude/projects/<encoded-cwd>/memory/`, keyed by the session cwd
/// rather than inside the project.
///
/// Each entry is `(owner_cwd, files)`. The scanner assigns
/// `ConfigScope::Project` when `owner_cwd` is `Some` and matches a
/// registered project, and `ConfigScope::Global` otherwise — including
/// when the owner can't be determined (`None`). An agent implements
/// EITHER this or `global_memory_files` for a given store, never both
/// over the same files, so the scanner never double-lists.
fn external_project_memory(&self) -> Vec<(Option<PathBuf>, Vec<PathBuf>)> {
vec![]
}

/// Global settings files (absolute paths, e.g. ~/.claude/settings.json)
fn global_settings_files(&self) -> Vec<PathBuf> {
vec![]
Expand Down Expand Up @@ -504,6 +519,7 @@ mod tests {
for a in &adapters {
let _ = a.global_rules_files();
let _ = a.global_memory_files();
let _ = a.external_project_memory();
let _ = a.global_settings_files();
let _ = a.global_subagent_files();
let _ = a.project_rules_patterns();
Expand Down
68 changes: 68 additions & 0 deletions crates/hk-core/src/scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1986,6 +1986,30 @@ pub fn scan_agent_configs(
) -> Vec<AgentConfigFile> {
let mut configs = Vec::new();

// --- External project memory (stored outside the project tree) ---
// Agents like Claude keep per-project memory in a global location keyed by
// the session cwd. Attribute each group to its registered project, else
// fall back to Global (unregistered project, or cwd undeterminable). This
// is the ONLY producer of such memory rows — those adapters do not also
// return it from `global_memory_files`, so there is nothing to de-dupe.
for (owner_cwd, files) in adapter.external_project_memory() {
let scope = owner_cwd
.as_ref()
.and_then(|cwd| projects.iter().find(|(_, path)| Path::new(path) == cwd.as_path()))
.map(|(name, path)| ConfigScope::Project {
name: name.clone(),
path: path.clone(),
})
.unwrap_or(ConfigScope::Global);
for path in &files {
if let Some(cf) =
stat_config_file(path, adapter.name(), ConfigCategory::Memory, scope.clone())
{
configs.push(cf);
}
}
}

// --- Global files ---
let global_groups: [(ConfigCategory, Vec<std::path::PathBuf>); 5] = [
(ConfigCategory::Rules, adapter.global_rules_files()),
Expand Down Expand Up @@ -2847,6 +2871,50 @@ mod config_tests {
use crate::adapter::claude::ClaudeAdapter;
use std::fs;

#[test]
fn test_scan_agent_configs_claude_external_memory_scoping() {
use crate::adapter::claude::ClaudeAdapter;
use std::fs;

let tmp = tempfile::tempdir().unwrap();
let home = tmp.path();

// Registered project → Project scope.
let reg = home.join(".claude/projects/-Users-zoe-Demo-Proj");
fs::create_dir_all(reg.join("memory")).unwrap();
fs::write(reg.join("s.jsonl"), "{\"cwd\":\"/Users/zoe/Demo/Proj\"}\n").unwrap();
fs::write(reg.join("memory/note.md"), "x").unwrap();

// Unregistered project → Global scope (behavior preserved).
let unreg = home.join(".claude/projects/-Users-zoe-Other");
fs::create_dir_all(unreg.join("memory")).unwrap();
fs::write(unreg.join("s.jsonl"), "{\"cwd\":\"/Users/zoe/Other\"}\n").unwrap();
fs::write(unreg.join("memory/misc.md"), "y").unwrap();

let adapter = ClaudeAdapter::with_home(home.to_path_buf());
let projects = vec![("Demo".to_string(), "/Users/zoe/Demo/Proj".to_string())];
let configs = scan_agent_configs(&adapter, &projects);

let note = configs.iter().find(|c| c.file_name == "note.md").unwrap();
assert!(
matches!(&note.scope, ConfigScope::Project { name, .. } if name == "Demo"),
"note.md should be Project-scoped, got {:?}",
note.scope
);
// Not duplicated across passes.
assert_eq!(
configs.iter().filter(|c| c.file_name == "note.md").count(),
1
);

let misc = configs.iter().find(|c| c.file_name == "misc.md").unwrap();
assert!(
matches!(misc.scope, ConfigScope::Global),
"misc.md should stay Global, got {:?}",
misc.scope
);
}

#[test]
fn test_scan_agent_configs_global_files() {
let tmp = tempfile::tempdir().unwrap();
Expand Down
54 changes: 54 additions & 0 deletions src/components/agents/__tests__/config-section-memory.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
import type { AgentConfigFile } from "@/lib/types";
import { ConfigSection } from "../config-section";

function mem(path: string, scope: AgentConfigFile["scope"]): AgentConfigFile {
return {
path,
agent: "claude",
category: "memory",
scope,
file_name: path.slice(path.lastIndexOf("/") + 1),
size_bytes: 100,
modified_at: null,
is_dir: false,
exists: true,
};
}
beforeEach(() => localStorage.clear());

describe("ConfigSection memory grouping", () => {
it("renders one header per storage dir; project name for project scope, raw path for global", () => {
render(
<ConfigSection
category="memory"
files={[
mem("/h/.claude/projects/-cs/memory/a.md", {
type: "project",
name: "CS",
path: "/real/CS",
}),
mem("/h/.claude/projects/-dl/memory/b.md", { type: "global" }),
]}
agentName="claude"
/>,
);
expect(screen.getByText("CS")).toBeInTheDocument();
// Project group header now also shows its storage path (not just the name).
expect(
screen.getByText("/h/.claude/projects/-cs/memory"),
).toBeInTheDocument();
expect(
screen.getByText("/h/.claude/projects/-dl/memory"),
).toBeInTheDocument();
// Never decoded into a fake project path.
expect(screen.queryByText("/h/dl")).not.toBeInTheDocument();
// Rows hide their per-row scope path (now shown once in the group header):
// the project file's scope path (/real/CS) must not render on the row.
expect(screen.queryByText("/real/CS")).not.toBeInTheDocument();
// Badge now lives on the rows (not the header): each file row shows its scope badge.
expect(screen.getByText("Project")).toBeInTheDocument();
expect(screen.getByText("Global")).toBeInTheDocument();
});
});
71 changes: 71 additions & 0 deletions src/components/agents/__tests__/memory-grouping.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import type { AgentConfigFile } from "@/lib/types";
import { groupMemoryFiles } from "../memory-grouping";

function file(
path: string,
size: number,
scope: AgentConfigFile["scope"],
): AgentConfigFile {
return {
path,
agent: "claude",
category: "memory",
scope,
file_name: path.slice(
Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + 1,
),
size_bytes: size,
modified_at: null,
is_dir: false,
exists: true,
};
}
const GLOBAL = { type: "global" } as const;

describe("groupMemoryFiles", () => {
it("groups by storage directory, sums bytes, sorts files by name", () => {
const groups = groupMemoryFiles([
file("/h/.claude/projects/-a/memory/two.md", 50, GLOBAL),
file("/h/.claude/projects/-a/memory/one.md", 100, GLOBAL),
file("/h/.claude/projects/-b/memory/three.md", 200, GLOBAL),
]);
expect(groups).toHaveLength(2);
const a = groups.find(
(g) => g.storePath === "/h/.claude/projects/-a/memory",
)!;
expect(a.files.map((f) => f.file_name)).toEqual(["one.md", "two.md"]);
expect(a.totalBytes).toBe(150);
expect(a.projectName).toBeNull();
expect(a.storePath).toBe("/h/.claude/projects/-a/memory");
});

it("puts project groups first with their project name", () => {
const proj = { type: "project", name: "CS", path: "/real/CS" } as const;
const groups = groupMemoryFiles([
file("/h/.claude/projects/-z/memory/g.md", 10, GLOBAL),
file("/h/.claude/projects/-cs/memory/p.md", 10, proj),
]);
expect(groups[0].projectName).toBe("CS");
expect(groups[1].projectName).toBeNull();
});

it("groups by directory on Windows-style backslash paths", () => {
const groups = groupMemoryFiles([
file("C:\\Users\\z\\.claude\\projects\\-a\\memory\\one.md", 100, GLOBAL),
file("C:\\Users\\z\\.claude\\projects\\-a\\memory\\two.md", 50, GLOBAL),
]);
expect(groups).toHaveLength(1);
expect(groups[0].storePath).toBe(
"C:\\Users\\z\\.claude\\projects\\-a\\memory",
);
expect(groups[0].files.map((f) => f.file_name)).toEqual([
"one.md",
"two.md",
]);
});

it("returns [] for empty input", () => {
expect(groupMemoryFiles([])).toEqual([]);
});
});
Loading
Loading