ashlrcode 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +295 -0
  3. package/package.json +46 -0
  4. package/src/__tests__/branded-types.test.ts +47 -0
  5. package/src/__tests__/context.test.ts +163 -0
  6. package/src/__tests__/cost-tracker.test.ts +274 -0
  7. package/src/__tests__/cron.test.ts +197 -0
  8. package/src/__tests__/dream.test.ts +204 -0
  9. package/src/__tests__/error-handler.test.ts +192 -0
  10. package/src/__tests__/features.test.ts +69 -0
  11. package/src/__tests__/file-history.test.ts +177 -0
  12. package/src/__tests__/hooks.test.ts +145 -0
  13. package/src/__tests__/keybindings.test.ts +159 -0
  14. package/src/__tests__/model-patches.test.ts +82 -0
  15. package/src/__tests__/permissions-rules.test.ts +121 -0
  16. package/src/__tests__/permissions.test.ts +108 -0
  17. package/src/__tests__/project-config.test.ts +63 -0
  18. package/src/__tests__/retry.test.ts +321 -0
  19. package/src/__tests__/router.test.ts +158 -0
  20. package/src/__tests__/session-compact.test.ts +191 -0
  21. package/src/__tests__/session.test.ts +145 -0
  22. package/src/__tests__/skill-registry.test.ts +130 -0
  23. package/src/__tests__/speculation.test.ts +196 -0
  24. package/src/__tests__/tasks-v2.test.ts +267 -0
  25. package/src/__tests__/telemetry.test.ts +149 -0
  26. package/src/__tests__/tool-executor.test.ts +141 -0
  27. package/src/__tests__/tool-registry.test.ts +166 -0
  28. package/src/__tests__/undercover.test.ts +93 -0
  29. package/src/__tests__/workflow.test.ts +195 -0
  30. package/src/agent/async-context.ts +64 -0
  31. package/src/agent/context.ts +245 -0
  32. package/src/agent/cron.ts +189 -0
  33. package/src/agent/dream.ts +165 -0
  34. package/src/agent/error-handler.ts +108 -0
  35. package/src/agent/ipc.ts +256 -0
  36. package/src/agent/kairos.ts +207 -0
  37. package/src/agent/loop.ts +314 -0
  38. package/src/agent/model-patches.ts +68 -0
  39. package/src/agent/speculation.ts +219 -0
  40. package/src/agent/sub-agent.ts +125 -0
  41. package/src/agent/system-prompt.ts +231 -0
  42. package/src/agent/team.ts +220 -0
  43. package/src/agent/tool-executor.ts +162 -0
  44. package/src/agent/workflow.ts +189 -0
  45. package/src/agent/worktree-manager.ts +86 -0
  46. package/src/autopilot/queue.ts +186 -0
  47. package/src/autopilot/scanner.ts +245 -0
  48. package/src/autopilot/types.ts +58 -0
  49. package/src/bridge/bridge-client.ts +57 -0
  50. package/src/bridge/bridge-server.ts +81 -0
  51. package/src/cli.ts +1120 -0
  52. package/src/config/features.ts +51 -0
  53. package/src/config/git.ts +137 -0
  54. package/src/config/hooks.ts +201 -0
  55. package/src/config/permissions.ts +251 -0
  56. package/src/config/project-config.ts +63 -0
  57. package/src/config/remote-settings.ts +163 -0
  58. package/src/config/settings-sync.ts +170 -0
  59. package/src/config/settings.ts +113 -0
  60. package/src/config/undercover.ts +76 -0
  61. package/src/config/upgrade-notice.ts +65 -0
  62. package/src/mcp/client.ts +197 -0
  63. package/src/mcp/manager.ts +125 -0
  64. package/src/mcp/oauth.ts +252 -0
  65. package/src/mcp/types.ts +61 -0
  66. package/src/persistence/memory.ts +129 -0
  67. package/src/persistence/session.ts +289 -0
  68. package/src/planning/plan-mode.ts +128 -0
  69. package/src/planning/plan-tools.ts +138 -0
  70. package/src/providers/anthropic.ts +177 -0
  71. package/src/providers/cost-tracker.ts +184 -0
  72. package/src/providers/retry.ts +264 -0
  73. package/src/providers/router.ts +159 -0
  74. package/src/providers/types.ts +79 -0
  75. package/src/providers/xai.ts +217 -0
  76. package/src/repl.tsx +1384 -0
  77. package/src/setup.ts +119 -0
  78. package/src/skills/loader.ts +78 -0
  79. package/src/skills/registry.ts +78 -0
  80. package/src/skills/types.ts +11 -0
  81. package/src/state/file-history.ts +264 -0
  82. package/src/telemetry/event-log.ts +116 -0
  83. package/src/tools/agent.ts +133 -0
  84. package/src/tools/ask-user.ts +229 -0
  85. package/src/tools/bash.ts +146 -0
  86. package/src/tools/config.ts +147 -0
  87. package/src/tools/diff.ts +137 -0
  88. package/src/tools/file-edit.ts +123 -0
  89. package/src/tools/file-read.ts +82 -0
  90. package/src/tools/file-write.ts +82 -0
  91. package/src/tools/glob.ts +76 -0
  92. package/src/tools/grep.ts +187 -0
  93. package/src/tools/ls.ts +77 -0
  94. package/src/tools/lsp.ts +375 -0
  95. package/src/tools/mcp-resources.ts +83 -0
  96. package/src/tools/mcp-tool.ts +47 -0
  97. package/src/tools/memory.ts +148 -0
  98. package/src/tools/notebook-edit.ts +133 -0
  99. package/src/tools/peers.ts +113 -0
  100. package/src/tools/powershell.ts +83 -0
  101. package/src/tools/registry.ts +114 -0
  102. package/src/tools/send-message.ts +75 -0
  103. package/src/tools/sleep.ts +50 -0
  104. package/src/tools/snip.ts +143 -0
  105. package/src/tools/tasks.ts +349 -0
  106. package/src/tools/team.ts +309 -0
  107. package/src/tools/todo-write.ts +93 -0
  108. package/src/tools/tool-search.ts +83 -0
  109. package/src/tools/types.ts +52 -0
  110. package/src/tools/web-browser.ts +263 -0
  111. package/src/tools/web-fetch.ts +118 -0
  112. package/src/tools/web-search.ts +107 -0
  113. package/src/tools/workflow.ts +188 -0
  114. package/src/tools/worktree.ts +143 -0
  115. package/src/types/branded.ts +22 -0
  116. package/src/ui/App.tsx +184 -0
  117. package/src/ui/BuddyPanel.tsx +52 -0
  118. package/src/ui/PermissionPrompt.tsx +29 -0
  119. package/src/ui/banner.ts +217 -0
  120. package/src/ui/buddy-ai.ts +108 -0
  121. package/src/ui/buddy.ts +466 -0
  122. package/src/ui/context-bar.ts +60 -0
  123. package/src/ui/effort.ts +65 -0
  124. package/src/ui/keybindings.ts +143 -0
  125. package/src/ui/markdown.ts +271 -0
  126. package/src/ui/message-renderer.ts +73 -0
  127. package/src/ui/mode.ts +80 -0
  128. package/src/ui/notifications.ts +57 -0
  129. package/src/ui/speech-bubble.ts +95 -0
  130. package/src/ui/spinner.ts +116 -0
  131. package/src/ui/theme.ts +98 -0
  132. package/src/version.ts +5 -0
  133. package/src/voice/voice-mode.ts +169 -0
@@ -0,0 +1,145 @@
1
+ import { test, expect, describe, beforeEach, afterEach } from "bun:test";
2
+ import { Session, resumeSession } from "../persistence/session.ts";
3
+ import { rmSync, existsSync, mkdtempSync } from "fs";
4
+ import { join } from "path";
5
+ import { tmpdir } from "os";
6
+ import { setConfigDirForTests } from "../config/settings.ts";
7
+
8
+ describe("Session", () => {
9
+ const testIds: string[] = [];
10
+ let configDir: string;
11
+ let sessionsDir: string;
12
+
13
+ beforeEach(() => {
14
+ configDir = mkdtempSync(join(tmpdir(), "ashlrcode-session-test-"));
15
+ sessionsDir = join(configDir, "sessions");
16
+ setConfigDirForTests(configDir);
17
+ });
18
+
19
+ function trackId(id: string) {
20
+ testIds.push(id);
21
+ return id;
22
+ }
23
+
24
+ afterEach(() => {
25
+ setConfigDirForTests(null);
26
+ if (existsSync(configDir)) rmSync(configDir, { recursive: true, force: true });
27
+ testIds.length = 0;
28
+ });
29
+
30
+ test("creates a session with unique id", () => {
31
+ const s1 = new Session();
32
+ const s2 = new Session();
33
+ expect(s1.id).not.toBe(s2.id);
34
+ expect(s1.id.length).toBeGreaterThan(0);
35
+ });
36
+
37
+ test("creates a session with provided id", () => {
38
+ const s = new Session("test-123");
39
+ expect(s.id).toBe("test-123");
40
+ });
41
+
42
+ test("init creates file and writes metadata", async () => {
43
+ const id = trackId(`test-init-${Date.now()}`);
44
+ const session = new Session(id);
45
+ await session.init("xai", "grok-4");
46
+
47
+ const path = join(sessionsDir, `${id}.jsonl`);
48
+ expect(existsSync(path)).toBe(true);
49
+
50
+ const content = await Bun.file(path).text();
51
+ const entry = JSON.parse(content.split("\n")[0]!);
52
+ expect(entry.type).toBe("metadata");
53
+ expect(entry.data.provider).toBe("xai");
54
+ expect(entry.data.model).toBe("grok-4");
55
+ });
56
+
57
+ test("appendMessage adds messages to file", async () => {
58
+ const id = trackId(`test-append-${Date.now()}`);
59
+ const session = new Session(id);
60
+ await session.init("anthropic", "claude");
61
+
62
+ await session.appendMessage({ role: "user", content: "Hello" });
63
+ await session.appendMessage({
64
+ role: "assistant",
65
+ content: "Hi there!",
66
+ });
67
+
68
+ const path = join(sessionsDir, `${id}.jsonl`);
69
+ const lines = (await Bun.file(path).text()).trim().split("\n");
70
+ // 1 metadata + 2 messages = 3 lines
71
+ expect(lines.length).toBe(3);
72
+ });
73
+
74
+ test("loadMessages returns only message entries", async () => {
75
+ const id = trackId(`test-load-${Date.now()}`);
76
+ const session = new Session(id);
77
+ await session.init("xai", "grok");
78
+
79
+ await session.appendMessage({ role: "user", content: "question" });
80
+ await session.appendMessage({
81
+ role: "assistant",
82
+ content: "answer",
83
+ });
84
+
85
+ const messages = await session.loadMessages();
86
+ expect(messages).toHaveLength(2);
87
+ expect(messages[0]!.role).toBe("user");
88
+ expect(messages[0]!.content).toBe("question");
89
+ expect(messages[1]!.role).toBe("assistant");
90
+ expect(messages[1]!.content).toBe("answer");
91
+ });
92
+
93
+ test("loadMessages returns empty for non-existent session", async () => {
94
+ const session = new Session("nonexistent-xyz");
95
+ const messages = await session.loadMessages();
96
+ expect(messages).toEqual([]);
97
+ });
98
+
99
+ test("setTitle persists title in metadata entry", async () => {
100
+ const id = trackId(`test-title-${Date.now()}`);
101
+ const session = new Session(id);
102
+ await session.init("xai", "grok");
103
+ await session.setTitle("My cool session");
104
+
105
+ const path = join(sessionsDir, `${id}.jsonl`);
106
+ const lines = (await Bun.file(path).text()).trim().split("\n");
107
+ const lastEntry = JSON.parse(lines[lines.length - 1]!);
108
+ expect(lastEntry.type).toBe("metadata");
109
+ expect(lastEntry.data.title).toBe("My cool session");
110
+ });
111
+ });
112
+
113
+ describe("resumeSession", () => {
114
+ const testIds: string[] = [];
115
+ let configDir: string;
116
+
117
+ beforeEach(() => {
118
+ configDir = mkdtempSync(join(tmpdir(), "ashlrcode-resume-test-"));
119
+ setConfigDirForTests(configDir);
120
+ });
121
+
122
+ afterEach(() => {
123
+ setConfigDirForTests(null);
124
+ if (existsSync(configDir)) rmSync(configDir, { recursive: true, force: true });
125
+ testIds.length = 0;
126
+ });
127
+
128
+ test("returns session and messages for existing session", async () => {
129
+ const id = `test-resume-${Date.now()}`;
130
+ testIds.push(id);
131
+ const session = new Session(id);
132
+ await session.init("xai", "grok");
133
+ await session.appendMessage({ role: "user", content: "hello" });
134
+
135
+ const result = await resumeSession(id);
136
+ expect(result).not.toBeNull();
137
+ expect(result!.session.id).toBe(id);
138
+ expect(result!.messages).toHaveLength(1);
139
+ });
140
+
141
+ test("returns null for empty/nonexistent session", async () => {
142
+ const result = await resumeSession("does-not-exist-999");
143
+ expect(result).toBeNull();
144
+ });
145
+ });
@@ -0,0 +1,130 @@
1
+ import { test, expect, describe, beforeEach } from "bun:test";
2
+ import { SkillRegistry } from "../skills/registry.ts";
3
+ import type { SkillDefinition } from "../skills/types.ts";
4
+
5
+ function makeSkill(overrides: Partial<SkillDefinition> = {}): SkillDefinition {
6
+ return {
7
+ name: overrides.name ?? "commit",
8
+ description: overrides.description ?? "Create a git commit",
9
+ trigger: overrides.trigger ?? "/commit",
10
+ prompt: overrides.prompt ?? "Create a well-crafted git commit.",
11
+ };
12
+ }
13
+
14
+ describe("SkillRegistry", () => {
15
+ let registry: SkillRegistry;
16
+
17
+ beforeEach(() => {
18
+ registry = new SkillRegistry();
19
+ });
20
+
21
+ describe("register and get", () => {
22
+ test("registers and retrieves a skill by trigger", () => {
23
+ const skill = makeSkill();
24
+ registry.register(skill);
25
+ expect(registry.get("/commit")).toBe(skill);
26
+ });
27
+
28
+ test("registers by /name if trigger does not start with /", () => {
29
+ const skill = makeSkill({ name: "deploy", trigger: "deploy-prod" });
30
+ registry.register(skill);
31
+ expect(registry.get("deploy-prod")).toBe(skill);
32
+ expect(registry.get("/deploy")).toBe(skill);
33
+ });
34
+
35
+ test("does not double-register if trigger already starts with /", () => {
36
+ const skill = makeSkill({ name: "review", trigger: "/review" });
37
+ registry.register(skill);
38
+ // Only one entry with trigger /review
39
+ expect(registry.get("/review")).toBe(skill);
40
+ });
41
+
42
+ test("registerAll registers multiple skills", () => {
43
+ registry.registerAll([
44
+ makeSkill({ name: "a", trigger: "/a" }),
45
+ makeSkill({ name: "b", trigger: "/b" }),
46
+ ]);
47
+ expect(registry.get("/a")).toBeDefined();
48
+ expect(registry.get("/b")).toBeDefined();
49
+ });
50
+ });
51
+
52
+ describe("isSkill", () => {
53
+ test("returns true for registered trigger", () => {
54
+ registry.register(makeSkill({ trigger: "/commit" }));
55
+ expect(registry.isSkill("/commit")).toBe(true);
56
+ });
57
+
58
+ test("returns true when trigger has trailing args", () => {
59
+ registry.register(makeSkill({ trigger: "/commit" }));
60
+ expect(registry.isSkill("/commit fix typo")).toBe(true);
61
+ });
62
+
63
+ test("returns false for unregistered trigger", () => {
64
+ expect(registry.isSkill("/unknown")).toBe(false);
65
+ });
66
+ });
67
+
68
+ describe("expand", () => {
69
+ test("returns prompt for valid trigger", () => {
70
+ registry.register(makeSkill({ trigger: "/commit", prompt: "Do a commit." }));
71
+ expect(registry.expand("/commit")).toBe("Do a commit.");
72
+ });
73
+
74
+ test("replaces {{args}} with user arguments", () => {
75
+ registry.register(
76
+ makeSkill({
77
+ trigger: "/review",
78
+ prompt: "Review this: {{args}}",
79
+ })
80
+ );
81
+ expect(registry.expand("/review PR #42")).toBe("Review this: PR #42");
82
+ });
83
+
84
+ test("appends args as additional context when no template variable", () => {
85
+ registry.register(
86
+ makeSkill({ trigger: "/fix", prompt: "Fix the bug." })
87
+ );
88
+ const result = registry.expand("/fix memory leak");
89
+ expect(result).toContain("Fix the bug.");
90
+ expect(result).toContain("Additional context: memory leak");
91
+ });
92
+
93
+ test("returns null for unknown trigger", () => {
94
+ expect(registry.expand("/nope")).toBeNull();
95
+ });
96
+
97
+ test("does not append extra context when args are empty", () => {
98
+ registry.register(
99
+ makeSkill({ trigger: "/test", prompt: "Run tests." })
100
+ );
101
+ const result = registry.expand("/test");
102
+ expect(result).toBe("Run tests.");
103
+ });
104
+
105
+ test("replaces all occurrences of {{args}}", () => {
106
+ registry.register(
107
+ makeSkill({
108
+ trigger: "/search",
109
+ prompt: "Search for {{args}} and display {{args}} results.",
110
+ })
111
+ );
112
+ const result = registry.expand("/search foo");
113
+ expect(result).toBe("Search for foo and display foo results.");
114
+ });
115
+ });
116
+
117
+ describe("getAll", () => {
118
+ test("returns deduplicated skills", () => {
119
+ // A skill with trigger not starting with / gets registered twice
120
+ registry.register(makeSkill({ name: "deploy", trigger: "deploy-prod" }));
121
+ const all = registry.getAll();
122
+ expect(all).toHaveLength(1);
123
+ expect(all[0]!.name).toBe("deploy");
124
+ });
125
+
126
+ test("returns empty array when no skills registered", () => {
127
+ expect(registry.getAll()).toEqual([]);
128
+ });
129
+ });
130
+ });
@@ -0,0 +1,196 @@
1
+ import { describe, test, expect, beforeEach } from "bun:test";
2
+ import { SpeculationCache } from "../agent/speculation.ts";
3
+ import { mkdtempSync, writeFileSync, rmSync } from "fs";
4
+ import { join } from "path";
5
+ import { tmpdir } from "os";
6
+
7
+ describe("SpeculationCache", () => {
8
+ let cache: SpeculationCache;
9
+
10
+ beforeEach(() => {
11
+ cache = new SpeculationCache(5, 200); // small size, short TTL for testing
12
+ });
13
+
14
+ // ── Core get/set ──────────────────────────────────────────────────────
15
+
16
+ test("cache hit returns stored result", () => {
17
+ cache.set("Read", { file_path: "/foo/bar.ts" }, "file contents here");
18
+ const result = cache.get("Read", { file_path: "/foo/bar.ts" });
19
+ expect(result).toBe("file contents here");
20
+ });
21
+
22
+ test("cache miss returns null", () => {
23
+ const result = cache.get("Read", { file_path: "/nonexistent" });
24
+ expect(result).toBeNull();
25
+ });
26
+
27
+ test("different tool names are different cache keys", () => {
28
+ cache.set("Read", { file_path: "/foo" }, "read result");
29
+ cache.set("Grep", { file_path: "/foo" }, "grep result");
30
+
31
+ expect(cache.get("Read", { file_path: "/foo" })).toBe("read result");
32
+ expect(cache.get("Grep", { file_path: "/foo" })).toBe("grep result");
33
+ });
34
+
35
+ test("different inputs are different cache keys", () => {
36
+ cache.set("Read", { file_path: "/a.ts" }, "content a");
37
+ cache.set("Read", { file_path: "/b.ts" }, "content b");
38
+
39
+ expect(cache.get("Read", { file_path: "/a.ts" })).toBe("content a");
40
+ expect(cache.get("Read", { file_path: "/b.ts" })).toBe("content b");
41
+ });
42
+
43
+ // ── TTL expiry ────────────────────────────────────────────────────────
44
+
45
+ test("TTL expiry evicts entries", async () => {
46
+ cache.set("Read", { file_path: "/foo" }, "content");
47
+ expect(cache.get("Read", { file_path: "/foo" })).toBe("content");
48
+
49
+ // Wait for TTL to expire (200ms)
50
+ await new Promise((r) => setTimeout(r, 250));
51
+
52
+ expect(cache.get("Read", { file_path: "/foo" })).toBeNull();
53
+ });
54
+
55
+ test("entry is valid before TTL expiry", async () => {
56
+ cache.set("Read", { file_path: "/foo" }, "content");
57
+
58
+ // Wait less than TTL
59
+ await new Promise((r) => setTimeout(r, 50));
60
+
61
+ expect(cache.get("Read", { file_path: "/foo" })).toBe("content");
62
+ });
63
+
64
+ // ── Max size eviction ─────────────────────────────────────────────────
65
+
66
+ test("max size eviction removes oldest entry", () => {
67
+ // Cache maxSize = 5, add 5 entries
68
+ for (let i = 0; i < 5; i++) {
69
+ cache.set("Read", { file_path: `/file${i}` }, `content${i}`);
70
+ }
71
+
72
+ // All 5 should be present
73
+ expect(cache.get("Read", { file_path: "/file0" })).toBe("content0");
74
+ expect(cache.get("Read", { file_path: "/file4" })).toBe("content4");
75
+
76
+ // Add a 6th entry — should evict the oldest
77
+ cache.set("Read", { file_path: "/file5" }, "content5");
78
+
79
+ // file0 was the oldest and should be evicted
80
+ // Note: get() updates hitCount, so file0 was actually accessed above.
81
+ // The eviction is based on timestamp, so file0 (earliest timestamp) should be gone
82
+ expect(cache.getStats().size).toBe(5);
83
+ expect(cache.get("Read", { file_path: "/file5" })).toBe("content5");
84
+ });
85
+
86
+ test("overwriting existing key does not trigger eviction", () => {
87
+ for (let i = 0; i < 5; i++) {
88
+ cache.set("Read", { file_path: `/file${i}` }, `content${i}`);
89
+ }
90
+
91
+ // Overwrite an existing entry — should NOT evict
92
+ cache.set("Read", { file_path: "/file0" }, "updated content");
93
+
94
+ expect(cache.getStats().size).toBe(5);
95
+ expect(cache.get("Read", { file_path: "/file0" })).toBe("updated content");
96
+ expect(cache.get("Read", { file_path: "/file4" })).toBe("content4");
97
+ });
98
+
99
+ // ── prefetchRead ──────────────────────────────────────────────────────
100
+
101
+ test("prefetchRead caches file content", async () => {
102
+ const tmpDir = mkdtempSync(join(tmpdir(), "spec-prefetch-"));
103
+ const filePath = join(tmpDir, "test.ts");
104
+ writeFileSync(filePath, "export const x = 42;", "utf-8");
105
+
106
+ try {
107
+ await cache.prefetchRead(filePath);
108
+ const result = cache.get("Read", { file_path: filePath });
109
+ expect(result).toBe("export const x = 42;");
110
+ } finally {
111
+ rmSync(tmpDir, { recursive: true, force: true });
112
+ }
113
+ });
114
+
115
+ test("prefetchRead skips nonexistent files", async () => {
116
+ await cache.prefetchRead("/nonexistent/path/file.ts");
117
+ const result = cache.get("Read", { file_path: "/nonexistent/path/file.ts" });
118
+ expect(result).toBeNull();
119
+ });
120
+
121
+ test("prefetchRead skips files larger than 1MB", async () => {
122
+ const tmpDir = mkdtempSync(join(tmpdir(), "spec-large-"));
123
+ const filePath = join(tmpDir, "big.bin");
124
+ // Write >1MB file
125
+ writeFileSync(filePath, Buffer.alloc(1_100_000, "x"), "utf-8");
126
+
127
+ try {
128
+ await cache.prefetchRead(filePath);
129
+ const result = cache.get("Read", { file_path: filePath });
130
+ expect(result).toBeNull();
131
+ } finally {
132
+ rmSync(tmpDir, { recursive: true, force: true });
133
+ }
134
+ });
135
+
136
+ // ── Invalidation on write ─────────────────────────────────────────────
137
+
138
+ test("invalidateForFile removes Read cache for that file", () => {
139
+ cache.set("Read", { file_path: "/foo/bar.ts" }, "old content");
140
+ cache.set("Read", { file_path: "/foo/baz.ts" }, "other content");
141
+
142
+ cache.invalidateForFile("/foo/bar.ts");
143
+
144
+ expect(cache.get("Read", { file_path: "/foo/bar.ts" })).toBeNull();
145
+ // Other files unaffected
146
+ expect(cache.get("Read", { file_path: "/foo/baz.ts" })).toBe("other content");
147
+ });
148
+
149
+ test("invalidateForFile removes all Grep and Glob cache entries", () => {
150
+ cache.set("Grep", { pattern: "foo" }, "grep results");
151
+ cache.set("Glob", { pattern: "*.ts" }, "glob results");
152
+ cache.set("Read", { file_path: "/other.ts" }, "content");
153
+
154
+ cache.invalidateForFile("/some/file.ts");
155
+
156
+ // Grep and Glob results should be cleared (conservative invalidation)
157
+ expect(cache.get("Grep", { pattern: "foo" })).toBeNull();
158
+ expect(cache.get("Glob", { pattern: "*.ts" })).toBeNull();
159
+ // Read for other files should survive
160
+ expect(cache.get("Read", { file_path: "/other.ts" })).toBe("content");
161
+ });
162
+
163
+ // ── Stats and clear ───────────────────────────────────────────────────
164
+
165
+ test("getStats tracks size and misses", () => {
166
+ cache.set("Read", { file_path: "/a" }, "a");
167
+ cache.set("Read", { file_path: "/b" }, "b");
168
+ cache.get("Read", { file_path: "/nonexistent" }); // miss
169
+
170
+ const stats = cache.getStats();
171
+ expect(stats.size).toBe(2);
172
+ expect(stats.misses).toBe(1);
173
+ });
174
+
175
+ test("getStats tracks hits", () => {
176
+ cache.set("Read", { file_path: "/a" }, "a");
177
+ cache.get("Read", { file_path: "/a" }); // hit
178
+ cache.get("Read", { file_path: "/a" }); // hit
179
+
180
+ const stats = cache.getStats();
181
+ expect(stats.hits).toBe(2);
182
+ });
183
+
184
+ test("clear resets everything", () => {
185
+ cache.set("Read", { file_path: "/a" }, "a");
186
+ cache.get("Read", { file_path: "/miss" }); // miss
187
+ cache.get("Read", { file_path: "/a" }); // hit
188
+
189
+ cache.clear();
190
+
191
+ const stats = cache.getStats();
192
+ expect(stats.size).toBe(0);
193
+ expect(stats.hits).toBe(0);
194
+ expect(stats.misses).toBe(0);
195
+ });
196
+ });