diff --git a/UserGuide.md b/UserGuide.md
index 35da4767..df8ee801 100644
--- a/UserGuide.md
+++ b/UserGuide.md
@@ -4,7 +4,18 @@
## Jay - X
-## Cathy - X
+## Cathy - Repository Learning Map (`/group`)
+
+Open an OpenCode session in a repository, select **Learn** mode, and enter `/group`. The map lists functionality groups. Select one to browse subgroups and files; use **Next page** or **Previous page** on long file lists. Choose **EXPLAIN THIS FUNCTIONALITY** or **EXPLAIN THIS SUBGROUP** to ask for a focused, read-only guide. The answer should cite files, explain relationships to other areas, suggest a reading order, and ask a check-your-understanding question.
+
+To see session-aware ordering, ask Learn to read a file, then run `/group` in the same session. Groups containing files read in this session are prioritized. If model-generated names are unavailable, directory-based names remain. The file inventory is capped at 10,000 paths, so very large repositories can show a partial map. Naming and explanations use model tokens.
+
+**End-to-end test (manual):** In Learn mode, run `/group` in a repository containing `src/`, `test/`, or `packages/` files. Open a group and subgroup, page through files, and request both explanations. The answers should name real files, describe evidence-backed relationships, give a reading order, and ask a learning question. Ask Learn to read a file, reopen `/group`, and confirm its group moves toward the top. In an empty repository, `/group` should show an informational message without crashing. Learn should remain selected throughout. This manual test covers the complete TUI and model workflow; the automated tests below do not launch the full application.
+
+**Unit tests (automated):** [`packages/tui/test/util/repository-functionality.test.ts`](packages/tui/test/util/repository-functionality.test.ts) covers repository layouts, empty inventories, 30-group and 20-subgroup limits, overflow preservation, priority, prompt bounds, and naming fallback. [`packages/tui/test/util/referenced-file.test.ts`](packages/tui/test/util/referenced-file.test.ts) covers completed reads, excluded results, absolute and relative paths, de-duplication, and `/group` registration.
+
+**Integration test (automated):** [`packages/tui/test/integration/repository-map.test.ts`](packages/tui/test/integration/repository-map.test.ts) passes completed read records through path collection, repository grouping, and both naming and explanation prompts. It verifies that a real session path remains prioritized and appears as evidence in the resulting guides. From `packages/tui`, run `bun test test/util/repository-functionality.test.ts test/util/referenced-file.test.ts test/integration/repository-map.test.ts` and `bun typecheck`.
+
## Janna - X
diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx
index 13cc77e7..ef046410 100644
--- a/packages/tui/src/routes/session/index.tsx
+++ b/packages/tui/src/routes/session/index.tsx
@@ -478,6 +478,7 @@ export function Session() {
run: async () => {
const referencedFiles = collectReferencedFiles(
messages().flatMap((message) => sync.data.part[message.id] ?? []),
+ project.instance.directory(),
)
dialog.replace(() => )
const result = await sdk.client.find.files({
@@ -503,6 +504,7 @@ export function Session() {
const naming = await sdk.client.session.prompt({
sessionID: namingSession.data.id,
workspace: project.workspace.current(),
+ agent: "learn",
parts: [{ type: "text", text: buildGroupNamingPrompt(groups) }],
})
const text = naming.data?.parts
diff --git a/packages/tui/src/util/referenced-file.ts b/packages/tui/src/util/referenced-file.ts
index d97c65fc..b0e40bd4 100644
--- a/packages/tui/src/util/referenced-file.ts
+++ b/packages/tui/src/util/referenced-file.ts
@@ -18,14 +18,16 @@ export const referencedFileCommand = {
},
} as const
-export function collectReferencedFiles(parts: readonly SessionPart[]) {
+export function collectReferencedFiles(parts: readonly SessionPart[], directory = "") {
const seen = new Set()
+ const root = directory.replaceAll("\\", "/").replace(/\/$/, "")
return parts.flatMap((part) => {
const filePath = referencedFilePath(part)
if (!filePath) return []
- const normalized = filePath.replaceAll("\\", "/")
+ const path = filePath.replaceAll("\\", "/")
+ const normalized = root && path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path
if (seen.has(normalized)) return []
seen.add(normalized)
return [normalized]
diff --git a/packages/tui/src/util/repository-functionality.ts b/packages/tui/src/util/repository-functionality.ts
index 47d30aae..bbb13ef5 100644
--- a/packages/tui/src/util/repository-functionality.ts
+++ b/packages/tui/src/util/repository-functionality.ts
@@ -107,7 +107,13 @@ export function analyzeRepositoryGroups(input: readonly string[], referencedFile
root: ".",
files: rest.flatMap((item) => item.files),
referencedFiles: rest.flatMap((item) => item.referencedFiles),
- subgroups: rest.flatMap((item) => item.subgroups),
+ subgroups: limit(rest.flatMap((item) => item.subgroups), 20, (subgroups) => ({
+ id: "other-repository-groups/other",
+ title: "Other areas",
+ root: ".",
+ files: subgroups.flatMap((item) => item.files),
+ referencedFiles: subgroups.flatMap((item) => item.referencedFiles),
+ })),
}))
}
@@ -115,7 +121,10 @@ export function buildGroupNamingPrompt(groups: readonly FunctionalGroup[]) {
return [
"Give each repository group a concise, beginner-friendly functionality name.",
"Return only a JSON object mapping each exact group id to a name of at most four words.",
- ...groups.flatMap((group) => [`Group: ${group.id}`, ...group.files.slice(0, 5).map((file) => `- ${file}`)]),
+ ...groups.flatMap((group) => [
+ `Group: ${group.id}`,
+ ...[...new Set([...group.referencedFiles, ...group.files])].slice(0, 5).map((file) => `- ${file}`),
+ ]),
].join("\n")
}
diff --git a/packages/tui/test/integration/repository-map.test.ts b/packages/tui/test/integration/repository-map.test.ts
new file mode 100644
index 00000000..4ae6db34
--- /dev/null
+++ b/packages/tui/test/integration/repository-map.test.ts
@@ -0,0 +1,39 @@
+import { expect, test } from "bun:test"
+import { collectReferencedFiles } from "../../src/util/referenced-file"
+import { analyzeRepositoryGroups, buildFunctionalGroupPrompt, buildGroupNamingPrompt } from "../../src/util/repository-functionality"
+
+test("integration: completed session reads prioritize map groups and ground their guides", () => {
+ const referenced = collectReferencedFiles(
+ [
+ { type: "tool", tool: "read", state: { status: "completed", input: { filePath: "/repo/apps/web/src/home.tsx" } } },
+ { type: "tool", tool: "read", state: { status: "error", input: { filePath: "/repo/packages/api/src/user.ts" } } },
+ ],
+ "/repo",
+ )
+ const groups = analyzeRepositoryGroups(
+ ["packages/api/src/user.ts", "apps/web/src/home.tsx", "apps/web/test/home.test.ts"],
+ referenced,
+ )
+ const web = groups[0]
+
+ expect(referenced).toEqual(["apps/web/src/home.tsx"])
+ expect(web.id).toBe("apps/web")
+ expect(web.referencedFiles).toEqual(referenced)
+ expect(buildGroupNamingPrompt(groups)).toContain("- apps/web/src/home.tsx")
+ expect(buildFunctionalGroupPrompt(web, groups)).toContain("- apps/web/src/home.tsx")
+ expect(buildFunctionalGroupPrompt(web.subgroups[0], groups)).toContain("- apps/web/src/home.tsx")
+})
+
+test("integration: repeated session reads count once in the map and guide", () => {
+ const reads = [
+ { type: "tool", tool: "read", state: { status: "completed", input: { filePath: "/repo/apps/web/src/home.tsx" } } },
+ { type: "tool", tool: "read", state: { status: "completed", input: { filePath: "apps/web/src/home.tsx" } } },
+ ]
+ const referenced = collectReferencedFiles(reads, "/repo")
+ const [web] = analyzeRepositoryGroups(["apps/web/src/home.tsx", "apps/web/src/about.tsx"], referenced)
+ const prompt = buildFunctionalGroupPrompt(web, [web])
+
+ expect(referenced).toEqual(["apps/web/src/home.tsx"])
+ expect(web.referencedFiles).toEqual(["apps/web/src/home.tsx"])
+ expect(prompt.match(/- apps\/web\/src\/home\.tsx/g)).toHaveLength(1)
+})
diff --git a/packages/tui/test/util/referenced-file.test.ts b/packages/tui/test/util/referenced-file.test.ts
index e24111d7..f9ef5c21 100644
--- a/packages/tui/test/util/referenced-file.test.ts
+++ b/packages/tui/test/util/referenced-file.test.ts
@@ -14,6 +14,61 @@ describe("referenced files", () => {
).toEqual(["src/index.ts", "test/index.test.ts"])
})
+ test("equivalence partitioning: metadata file paths override input paths", () => {
+ expect(
+ collectReferencedFiles([
+ {
+ type: "tool",
+ tool: "read",
+ state: {
+ status: "completed",
+ input: { filePath: "outdated.ts" },
+ metadata: { display: { type: "file", path: "src/current.ts" } },
+ },
+ },
+ {
+ type: "tool",
+ tool: "read",
+ state: {
+ status: "completed",
+ input: { filePath: "directory.ts" },
+ metadata: { display: { type: "directory", path: "src" } },
+ },
+ },
+ completedRead("src/fallback.ts"),
+ ]),
+ ).toEqual(["src/current.ts", "src/fallback.ts"])
+ })
+
+ test("equivalence partitioning: ignores unreadable or unfinished tool results", () => {
+ expect(
+ collectReferencedFiles([
+ { type: "tool", tool: "read", state: { status: "completed", input: { filePath: " " } } },
+ { type: "tool", tool: "read", state: { status: "completed", input: { filePath: 42 } } },
+ { type: "tool", tool: "read", state: { status: "error", input: { filePath: "failed.ts" } } },
+ { type: "tool", tool: "write", state: { status: "completed", input: { filePath: "written.ts" } } },
+ { type: "text" },
+ ]),
+ ).toEqual([])
+ })
+
+ test("matches absolute read paths to relative repository files", () => {
+ expect(
+ collectReferencedFiles(
+ [
+ completedRead("/home/student/project/src/index.ts"),
+ completedRead("src/index.ts"),
+ completedRead("/home/student/project-copy/src/other.ts"),
+ ],
+ "/home/student/project",
+ ),
+ ).toEqual(["src/index.ts", "/home/student/project-copy/src/other.ts"])
+
+ expect(
+ collectReferencedFiles([completedRead("C:\\repo\\src\\index.ts")], "C:\\repo"),
+ ).toEqual(["src/index.ts"])
+ })
+
test("registers /group", () => {
expect(referencedFileCommand.slash.name).toBe("group")
})
diff --git a/packages/tui/test/util/repository-functionality.test.ts b/packages/tui/test/util/repository-functionality.test.ts
index 05dbd777..7e8d8740 100644
--- a/packages/tui/test/util/repository-functionality.test.ts
+++ b/packages/tui/test/util/repository-functionality.test.ts
@@ -49,6 +49,16 @@ describe("repository areas", () => {
expect(prompt).not.toContain("src/session/file-8.ts")
})
+ test("includes session-referenced files in the naming sample", () => {
+ const files = Array.from({ length: 12 }, (_, index) => `src/session/file-${index}.ts`)
+ const referenced = files.at(-1)!
+ const [group] = analyzeRepositoryGroups(files, [referenced])
+ const prompt = buildGroupNamingPrompt([group])
+
+ expect(prompt).toContain(`- ${referenced}`)
+ expect(prompt.split("\n- ")).toHaveLength(6)
+ })
+
test("limits the map and applies model-generated names", () => {
const groups = analyzeRepositoryGroups(
Array.from({ length: 35 }, (_, index) => `packages/area-${index}/src/index.ts`),
@@ -61,4 +71,130 @@ describe("repository areas", () => {
expect(renamed[0].title).toBe("Session History")
expect(buildGroupNamingPrompt(groups)).toContain("Group: packages/area-34")
})
+
+ test("caps subgroups in the overflow group without losing files", () => {
+ const files = Array.from({ length: 55 }, (_, index) => `packages/area-${index}/src/index.ts`)
+ const groups = analyzeRepositoryGroups(files, files)
+ const overflow = groups.at(-1)!
+
+ expect(groups).toHaveLength(30)
+ expect(overflow.title).toBe("Other repository groups")
+ expect(overflow.subgroups).toHaveLength(20)
+ expect(overflow.subgroups.flatMap((subgroup) => subgroup.files).sort()).toEqual([...overflow.files].sort())
+ expect(overflow.subgroups.flatMap((subgroup) => subgroup.referencedFiles).sort()).toEqual(
+ [...overflow.referencedFiles].sort(),
+ )
+ })
+
+ test("handles empty repositories and ignores generated-only inventories", () => {
+ expect(analyzeRepositoryGroups([])).toEqual([])
+ expect(analyzeRepositoryGroups(["node_modules/pkg/index.js", "dist/app.js", ".git/config"])).toEqual([])
+ })
+
+ test("keeps root files and unfamiliar layouts without losing paths", () => {
+ const files = [
+ "README.md",
+ "mystery/component.custom",
+ "services/api/lib/start.ts",
+ "modules/parser/spec/check.txt",
+ ]
+ const groups = analyzeRepositoryGroups(files)
+
+ expect(groups.flatMap((group) => group.files).sort()).toEqual([...files].sort())
+ expect(groups.find((group) => group.id === ".")?.files).toEqual(["README.md"])
+ expect(groups.map((group) => group.id)).toEqual(
+ expect.arrayContaining(["mystery", "services/api", "modules/parser"]),
+ )
+ })
+
+ test.each([19, 20, 21, 25])("boundary-value analysis: preserves files with %i subgroups", (count) => {
+ const files = Array.from({ length: count }, (_, index) => `packages/api/src/feature-${index}/index.ts`)
+ const [group] = analyzeRepositoryGroups(files, files)
+
+ expect(group.subgroups).toHaveLength(Math.min(count, 20))
+ expect(group.subgroups.flatMap((subgroup) => subgroup.files).sort()).toEqual([...files].sort())
+ expect(group.subgroups.flatMap((subgroup) => subgroup.referencedFiles).sort()).toEqual([...files].sort())
+ if (count > 20) {
+ expect(group.subgroups.at(-1)?.title).toBe("Other areas")
+ expect(group.subgroups.at(-1)?.files).toHaveLength(count - 19)
+ }
+ })
+
+ test.each([29, 30, 31, 35])("boundary-value analysis: preserves files with %i groups", (count) => {
+ const files = Array.from({ length: count }, (_, index) => `packages/area-${index}/src/index.ts`)
+ const groups = analyzeRepositoryGroups(files, files)
+
+ expect(groups).toHaveLength(Math.min(count, 30))
+ expect(groups.flatMap((group) => group.files).sort()).toEqual([...files].sort())
+ expect(groups.flatMap((group) => group.referencedFiles).sort()).toEqual([...files].sort())
+ expect(groups.flatMap((group) => group.subgroups.flatMap((subgroup) => subgroup.files)).sort()).toEqual(
+ [...files].sort(),
+ )
+ if (count > 30) {
+ expect(groups.at(-1)?.title).toBe("Other repository groups")
+ expect(groups.at(-1)?.files).toHaveLength(count - 29)
+ }
+ })
+
+ test.each(["", "No names available", '{"src":', '{"src": "Source",}'])(
+ "retains structural names for malformed naming response %j",
+ (response) => {
+ const groups = analyzeRepositoryGroups(["src/main.ts", "docs/guide.md"])
+ expect(applyGroupNames(groups, response)).toEqual(groups)
+ },
+ )
+
+ test("metamorphic testing: duplicate and reordered path formats preserve the map", () => {
+ const files = ["packages/api/src/login.ts", "packages/api/test/login.test.ts", "apps/web/src/home.tsx"]
+ const expected = analyzeRepositoryGroups(files, ["apps/web/src/home.tsx"])
+ const changed = analyzeRepositoryGroups(
+ ["./apps/web/src/home.tsx", "packages\\api\\test\\login.test.ts", ...files.toReversed(), files[0]],
+ ["apps\\web\\src\\home.tsx"],
+ )
+
+ expect(changed).toEqual(expected)
+ })
+
+ test("generated-input property: every accepted file belongs to one group and subgroup", () => {
+ for (const count of [1, 2, 17, 18, 19, 30, 31, 75]) {
+ const files = Array.from({ length: count }, (_, index) =>
+ index % 2 ? `apps/web/test/case-${index}.test.ts` : `packages/api/src/feature-${index}/index.ts`,
+ )
+ const groups = analyzeRepositoryGroups(
+ files.flatMap((file, index) => (index % 2 ? [file, file] : [`./${file}`, file])),
+ [files[0], files.at(-1)!],
+ )
+ const references = [...new Set([files[0], files.at(-1)!])].sort()
+
+ expect(groups.flatMap((group) => group.files).sort()).toEqual([...files].sort())
+ expect(groups.flatMap((group) => group.subgroups.flatMap((subgroup) => subgroup.files)).sort()).toEqual(
+ [...files].sort(),
+ )
+ expect(groups.flatMap((group) => group.referencedFiles).sort()).toEqual(references)
+ }
+ })
+
+ test("pairwise testing: a late referenced file survives prompt length limits", () => {
+ const files = Array.from({ length: 35 }, (_, index) => `src/session/file-${index}.ts`)
+ const referenced = files.at(-1)!
+ const [group] = analyzeRepositoryGroups(files, [referenced])
+ const prompt = buildFunctionalGroupPrompt(group, [group])
+ const listed = prompt.split("Representative files:\n")[1].split("\n- (", 1)[0].split("\n")
+
+ expect(listed).toHaveLength(15)
+ expect(listed[0]).toBe(`- ${referenced}`)
+ expect(prompt).toContain(`(${files.length - 15} additional files omitted)`)
+ })
+
+ test("retains names for missing and non-string values without changing the original map", () => {
+ const groups = analyzeRepositoryGroups(["src/main.ts", "docs/guide.md", "tests/main.test.ts"])
+ const original = structuredClone(groups)
+ const renamed = applyGroupNames(groups, '{"src": "Application", "docs": 42, "unrelated": "Unused"}')
+
+ expect(renamed.find((group) => group.id === "src")?.title).toBe("Application")
+ expect(renamed.find((group) => group.id === "docs")).toEqual(groups.find((group) => group.id === "docs"))
+ expect(renamed.find((group) => group.id === "tests")).toEqual(groups.find((group) => group.id === "tests"))
+ expect(renamed).toHaveLength(groups.length)
+ expect(groups).toEqual(original)
+ })
})