@roackb2/heddle 0.0.7 → 0.0.8

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 (203) hide show
  1. package/README.md +120 -2
  2. package/dist/examples/heartbeat.d.ts +2 -0
  3. package/dist/examples/heartbeat.d.ts.map +1 -0
  4. package/dist/examples/heartbeat.js +61 -0
  5. package/dist/examples/heartbeat.js.map +1 -0
  6. package/dist/examples/programmatic-loop.d.ts +2 -0
  7. package/dist/examples/programmatic-loop.d.ts.map +1 -0
  8. package/dist/examples/programmatic-loop.js +126 -0
  9. package/dist/examples/programmatic-loop.js.map +1 -0
  10. package/dist/src/cli/ask.d.ts.map +1 -1
  11. package/dist/src/cli/ask.js +10 -41
  12. package/dist/src/cli/ask.js.map +1 -1
  13. package/dist/src/cli/chat/hooks/useAgentRun.d.ts.map +1 -1
  14. package/dist/src/cli/chat/hooks/useAgentRun.js +14 -25
  15. package/dist/src/cli/chat/hooks/useAgentRun.js.map +1 -1
  16. package/dist/src/cli/chat/utils/runtime.d.ts.map +1 -1
  17. package/dist/src/cli/chat/utils/runtime.js +3 -19
  18. package/dist/src/cli/chat/utils/runtime.js.map +1 -1
  19. package/dist/src/cli/main.js +0 -0
  20. package/dist/src/index.d.ts +12 -0
  21. package/dist/src/index.d.ts.map +1 -1
  22. package/dist/src/index.js +6 -0
  23. package/dist/src/index.js.map +1 -1
  24. package/dist/src/llm/factory.d.ts +1 -1
  25. package/dist/src/llm/factory.d.ts.map +1 -1
  26. package/dist/src/llm/factory.js +2 -19
  27. package/dist/src/llm/factory.js.map +1 -1
  28. package/dist/src/llm/providers.d.ts +3 -0
  29. package/dist/src/llm/providers.d.ts.map +1 -0
  30. package/dist/src/llm/providers.js +21 -0
  31. package/dist/src/llm/providers.js.map +1 -0
  32. package/dist/src/runtime/agent-loop.d.ts +42 -0
  33. package/dist/src/runtime/agent-loop.d.ts.map +1 -0
  34. package/dist/src/runtime/agent-loop.js +118 -0
  35. package/dist/src/runtime/agent-loop.js.map +1 -0
  36. package/dist/src/runtime/api-keys.d.ts +8 -0
  37. package/dist/src/runtime/api-keys.d.ts.map +1 -0
  38. package/dist/src/runtime/api-keys.js +25 -0
  39. package/dist/src/runtime/api-keys.js.map +1 -0
  40. package/dist/src/runtime/default-tools.d.ts +12 -0
  41. package/dist/src/runtime/default-tools.d.ts.map +1 -0
  42. package/dist/src/runtime/default-tools.js +40 -0
  43. package/dist/src/runtime/default-tools.js.map +1 -0
  44. package/dist/src/runtime/events.d.ts +62 -0
  45. package/dist/src/runtime/events.d.ts.map +1 -0
  46. package/dist/src/runtime/events.js +30 -0
  47. package/dist/src/runtime/events.js.map +1 -0
  48. package/dist/src/runtime/heartbeat-store.d.ts +20 -0
  49. package/dist/src/runtime/heartbeat-store.d.ts.map +1 -0
  50. package/dist/src/runtime/heartbeat-store.js +42 -0
  51. package/dist/src/runtime/heartbeat-store.js.map +1 -0
  52. package/dist/src/runtime/heartbeat.d.ts +39 -0
  53. package/dist/src/runtime/heartbeat.d.ts.map +1 -0
  54. package/dist/src/runtime/heartbeat.js +72 -0
  55. package/dist/src/runtime/heartbeat.js.map +1 -0
  56. package/dist/src/utils/logger.d.ts.map +1 -1
  57. package/dist/src/utils/logger.js +2 -2
  58. package/dist/src/utils/logger.js.map +1 -1
  59. package/package.json +18 -2
  60. package/dist/src/__tests__/chat-activity-format.test.d.ts +0 -2
  61. package/dist/src/__tests__/chat-activity-format.test.d.ts.map +0 -1
  62. package/dist/src/__tests__/chat-activity-format.test.js +0 -41
  63. package/dist/src/__tests__/chat-activity-format.test.js.map +0 -1
  64. package/dist/src/__tests__/chat-compaction.test.d.ts +0 -2
  65. package/dist/src/__tests__/chat-compaction.test.d.ts.map +0 -1
  66. package/dist/src/__tests__/chat-compaction.test.js +0 -63
  67. package/dist/src/__tests__/chat-compaction.test.js.map +0 -1
  68. package/dist/src/__tests__/chat-format.test.d.ts +0 -2
  69. package/dist/src/__tests__/chat-format.test.d.ts.map +0 -1
  70. package/dist/src/__tests__/chat-format.test.js +0 -137
  71. package/dist/src/__tests__/chat-format.test.js.map +0 -1
  72. package/dist/src/__tests__/chat-runtime.test.d.ts +0 -2
  73. package/dist/src/__tests__/chat-runtime.test.d.ts.map +0 -1
  74. package/dist/src/__tests__/chat-runtime.test.js +0 -39
  75. package/dist/src/__tests__/chat-runtime.test.js.map +0 -1
  76. package/dist/src/__tests__/core-utils.test.d.ts +0 -2
  77. package/dist/src/__tests__/core-utils.test.d.ts.map +0 -1
  78. package/dist/src/__tests__/core-utils.test.js +0 -87
  79. package/dist/src/__tests__/core-utils.test.js.map +0 -1
  80. package/dist/src/__tests__/file-mentions.test.d.ts +0 -2
  81. package/dist/src/__tests__/file-mentions.test.d.ts.map +0 -1
  82. package/dist/src/__tests__/file-mentions.test.js +0 -29
  83. package/dist/src/__tests__/file-mentions.test.js.map +0 -1
  84. package/dist/src/__tests__/llm-factory.test.d.ts +0 -2
  85. package/dist/src/__tests__/llm-factory.test.d.ts.map +0 -1
  86. package/dist/src/__tests__/llm-factory.test.js +0 -45
  87. package/dist/src/__tests__/llm-factory.test.js.map +0 -1
  88. package/dist/src/__tests__/local-commands.test.d.ts +0 -2
  89. package/dist/src/__tests__/local-commands.test.d.ts.map +0 -1
  90. package/dist/src/__tests__/local-commands.test.js +0 -153
  91. package/dist/src/__tests__/local-commands.test.js.map +0 -1
  92. package/dist/src/__tests__/project-approval-rules.test.d.ts +0 -2
  93. package/dist/src/__tests__/project-approval-rules.test.d.ts.map +0 -1
  94. package/dist/src/__tests__/project-approval-rules.test.js +0 -135
  95. package/dist/src/__tests__/project-approval-rules.test.js.map +0 -1
  96. package/dist/src/__tests__/prompt-input.test.d.ts +0 -2
  97. package/dist/src/__tests__/prompt-input.test.d.ts.map +0 -1
  98. package/dist/src/__tests__/prompt-input.test.js +0 -57
  99. package/dist/src/__tests__/prompt-input.test.js.map +0 -1
  100. package/dist/src/__tests__/prompts.test.d.ts +0 -2
  101. package/dist/src/__tests__/prompts.test.d.ts.map +0 -1
  102. package/dist/src/__tests__/prompts.test.js +0 -46
  103. package/dist/src/__tests__/prompts.test.js.map +0 -1
  104. package/dist/src/__tests__/run-agent.test.d.ts +0 -2
  105. package/dist/src/__tests__/run-agent.test.d.ts.map +0 -1
  106. package/dist/src/__tests__/run-agent.test.js +0 -1276
  107. package/dist/src/__tests__/run-agent.test.js.map +0 -1
  108. package/dist/src/__tests__/run-shell.command.test.d.ts +0 -2
  109. package/dist/src/__tests__/run-shell.command.test.d.ts.map +0 -1
  110. package/dist/src/__tests__/run-shell.command.test.js +0 -188
  111. package/dist/src/__tests__/run-shell.command.test.js.map +0 -1
  112. package/dist/src/__tests__/smoke.test.d.ts +0 -2
  113. package/dist/src/__tests__/smoke.test.d.ts.map +0 -1
  114. package/dist/src/__tests__/smoke.test.js +0 -314
  115. package/dist/src/__tests__/smoke.test.js.map +0 -1
  116. package/dist/src/__tests__/tools.test.d.ts +0 -2
  117. package/dist/src/__tests__/tools.test.d.ts.map +0 -1
  118. package/dist/src/__tests__/tools.test.js +0 -698
  119. package/dist/src/__tests__/tools.test.js.map +0 -1
  120. package/dist/src/__tests__/trace-format.test.d.ts +0 -2
  121. package/dist/src/__tests__/trace-format.test.d.ts.map +0 -1
  122. package/dist/src/__tests__/trace-format.test.js +0 -148
  123. package/dist/src/__tests__/trace-format.test.js.map +0 -1
  124. package/dist/src/cli/chat/actions.d.ts +0 -47
  125. package/dist/src/cli/chat/actions.d.ts.map +0 -1
  126. package/dist/src/cli/chat/actions.js +0 -215
  127. package/dist/src/cli/chat/actions.js.map +0 -1
  128. package/dist/src/cli/chat/format.d.ts +0 -23
  129. package/dist/src/cli/chat/format.d.ts.map +0 -1
  130. package/dist/src/cli/chat/format.js +0 -243
  131. package/dist/src/cli/chat/format.js.map +0 -1
  132. package/dist/src/cli/chat/local-commands.d.ts +0 -17
  133. package/dist/src/cli/chat/local-commands.d.ts.map +0 -1
  134. package/dist/src/cli/chat/local-commands.js +0 -180
  135. package/dist/src/cli/chat/local-commands.js.map +0 -1
  136. package/dist/src/cli/chat/panels.d.ts +0 -37
  137. package/dist/src/cli/chat/panels.d.ts.map +0 -1
  138. package/dist/src/cli/chat/panels.js +0 -142
  139. package/dist/src/cli/chat/panels.js.map +0 -1
  140. package/dist/src/cli/chat/runtime.d.ts +0 -26
  141. package/dist/src/cli/chat/runtime.d.ts.map +0 -1
  142. package/dist/src/cli/chat/runtime.js +0 -28
  143. package/dist/src/cli/chat/runtime.js.map +0 -1
  144. package/dist/src/cli/chat/storage.d.ts +0 -13
  145. package/dist/src/cli/chat/storage.d.ts.map +0 -1
  146. package/dist/src/cli/chat/storage.js +0 -126
  147. package/dist/src/cli/chat/storage.js.map +0 -1
  148. package/dist/src/cli/chat/types.d.ts +0 -51
  149. package/dist/src/cli/chat/types.d.ts.map +0 -1
  150. package/dist/src/cli/chat/types.js +0 -2
  151. package/dist/src/cli/chat/types.js.map +0 -1
  152. package/dist/src/cli/chat/use-run-state.d.ts +0 -23
  153. package/dist/src/cli/chat/use-run-state.d.ts.map +0 -1
  154. package/dist/src/cli/chat/use-run-state.js +0 -118
  155. package/dist/src/cli/chat/use-run-state.js.map +0 -1
  156. package/dist/src/cli/chat/use-sessions.d.ts +0 -21
  157. package/dist/src/cli/chat/use-sessions.d.ts.map +0 -1
  158. package/dist/src/cli/chat/use-sessions.js +0 -111
  159. package/dist/src/cli/chat/use-sessions.js.map +0 -1
  160. package/dist/src/cli/chat-actions.d.ts +0 -47
  161. package/dist/src/cli/chat-actions.d.ts.map +0 -1
  162. package/dist/src/cli/chat-actions.js +0 -215
  163. package/dist/src/cli/chat-actions.js.map +0 -1
  164. package/dist/src/cli/chat-format.d.ts +0 -23
  165. package/dist/src/cli/chat-format.d.ts.map +0 -1
  166. package/dist/src/cli/chat-format.js +0 -243
  167. package/dist/src/cli/chat-format.js.map +0 -1
  168. package/dist/src/cli/chat-local-commands.d.ts +0 -17
  169. package/dist/src/cli/chat-local-commands.d.ts.map +0 -1
  170. package/dist/src/cli/chat-local-commands.js +0 -180
  171. package/dist/src/cli/chat-local-commands.js.map +0 -1
  172. package/dist/src/cli/chat-panels.d.ts +0 -37
  173. package/dist/src/cli/chat-panels.d.ts.map +0 -1
  174. package/dist/src/cli/chat-panels.js +0 -142
  175. package/dist/src/cli/chat-panels.js.map +0 -1
  176. package/dist/src/cli/chat-runtime.d.ts +0 -26
  177. package/dist/src/cli/chat-runtime.d.ts.map +0 -1
  178. package/dist/src/cli/chat-runtime.js +0 -28
  179. package/dist/src/cli/chat-runtime.js.map +0 -1
  180. package/dist/src/cli/chat-storage.d.ts +0 -13
  181. package/dist/src/cli/chat-storage.d.ts.map +0 -1
  182. package/dist/src/cli/chat-storage.js +0 -126
  183. package/dist/src/cli/chat-storage.js.map +0 -1
  184. package/dist/src/cli/chat-submit.d.ts +0 -28
  185. package/dist/src/cli/chat-submit.d.ts.map +0 -1
  186. package/dist/src/cli/chat-submit.js +0 -90
  187. package/dist/src/cli/chat-submit.js.map +0 -1
  188. package/dist/src/cli/chat-types.d.ts +0 -51
  189. package/dist/src/cli/chat-types.d.ts.map +0 -1
  190. package/dist/src/cli/chat-types.js +0 -2
  191. package/dist/src/cli/chat-types.js.map +0 -1
  192. package/dist/src/cli/chat.d.ts +0 -4
  193. package/dist/src/cli/chat.d.ts.map +0 -1
  194. package/dist/src/cli/chat.js +0 -153
  195. package/dist/src/cli/chat.js.map +0 -1
  196. package/dist/src/cli/useChatRunState.d.ts +0 -23
  197. package/dist/src/cli/useChatRunState.d.ts.map +0 -1
  198. package/dist/src/cli/useChatRunState.js +0 -118
  199. package/dist/src/cli/useChatRunState.js.map +0 -1
  200. package/dist/src/cli/useChatSessions.d.ts +0 -21
  201. package/dist/src/cli/useChatSessions.d.ts.map +0 -1
  202. package/dist/src/cli/useChatSessions.js +0 -111
  203. package/dist/src/cli/useChatSessions.js.map +0 -1
@@ -1,698 +0,0 @@
1
- import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
2
- import { tmpdir } from 'node:os';
3
- import { join } from 'node:path';
4
- import { describe, it, expect, vi } from 'vitest';
5
- import { listFilesTool } from '../tools/list-files.js';
6
- import { readFileTool } from '../tools/read-file.js';
7
- import { editFileTool, previewEditFileInput } from '../tools/edit-file.js';
8
- import { reportStateTool } from '../tools/report-state.js';
9
- import { updatePlanTool } from '../tools/update-plan.js';
10
- import { classifyShellCommandPolicy, createRunShellInspectTool, createRunShellMutateTool, DEFAULT_MUTATE_RULES, } from '../tools/run-shell.js';
11
- import { createSearchFilesTool, searchFilesTool } from '../tools/search-files.js';
12
- import { webSearchTool } from '../tools/web-search.js';
13
- import { viewImageTool } from '../tools/view-image.js';
14
- import { createListMemoryNotesTool, createReadMemoryNoteTool, createSearchMemoryNotesTool, createEditMemoryNoteTool, } from '../tools/memory-notes.js';
15
- describe('tool input validation', () => {
16
- it('rejects unexpected fields for list_files', async () => {
17
- const result = await listFilesTool.execute({ path: '.', maxLines: 20 });
18
- expect(result).toEqual({
19
- ok: false,
20
- error: 'Invalid input for list_files. Allowed fields: path. Example: { "path": "." }',
21
- });
22
- });
23
- it('rejects unexpected fields for read_file', async () => {
24
- const result = await readFileTool.execute({ path: 'README.md', query: 'tool' });
25
- expect(result).toEqual({
26
- ok: false,
27
- error: 'Invalid input for read_file. Required field: path. Optional fields: maxLines, offset.',
28
- });
29
- });
30
- it('rejects ambiguous edit_file input', async () => {
31
- const result = await editFileTool.execute({ path: 'README.md', newText: 'x' });
32
- expect(result).toEqual({
33
- ok: false,
34
- error: 'Invalid input for edit_file. Use either { "path", "oldText", "newText", "replaceAll?" } or { "path", "content", "createIfMissing?" }.',
35
- });
36
- });
37
- it('tool descriptions distinguish directories from files', () => {
38
- expect(listFilesTool.description).toContain('Use this to inspect folders, not to read file contents');
39
- expect(listFilesTool.description).toContain('explore an obvious nearby folder');
40
- expect(listFilesTool.description).toContain('may also point to nearby parent or sibling folders');
41
- expect(listFilesTool.description).toContain('newline-separated list of entry names');
42
- expect(readFileTool.description).toContain('not when you want to inspect a directory');
43
- expect(readFileTool.description).toContain('may also point to nearby parent or sibling folders');
44
- expect(readFileTool.description).toContain('Returns the file text directly');
45
- expect(readFileTool.description).toContain('0-based line offset');
46
- expect(editFileTool.description).toContain('Edit a file directly inside the current workspace');
47
- expect(editFileTool.description).toContain('Prefer this over shell commands');
48
- expect(editFileTool.description).toContain('exact replacement');
49
- expect(editFileTool.description).toContain('overwrite an existing file or create a new one explicitly');
50
- expect(listFilesTool.description).toContain('{ "path": "." }');
51
- expect(listFilesTool.description).toContain('{ "path": ".." }');
52
- expect(readFileTool.description).toContain('{ "path": "path/to/file.txt" }');
53
- expect(readFileTool.description).toContain('{ "path": "../shared-notes/summary.md" }');
54
- expect(searchFilesTool.description).toContain('locate a specific symbol or text string');
55
- expect(searchFilesTool.description).toContain('Prefer searching for concrete terms');
56
- expect(searchFilesTool.description).toContain('may also point to nearby parent or sibling folders');
57
- expect(searchFilesTool.description).toContain('grep-style path:line:content format');
58
- expect(searchFilesTool.description).toContain('{ "query": "createUser" }');
59
- expect(searchFilesTool.description).toContain('{ "query": "incident", "path": "../shared-notes" }');
60
- expect(webSearchTool.description).toContain('Search the public web');
61
- expect(webSearchTool.description).toContain("active model provider's hosted web search");
62
- expect(webSearchTool.description).toContain('{ "query": "OpenAI Responses API web search tool" }');
63
- expect(viewImageTool.description).toContain('Inspect a local image file');
64
- expect(viewImageTool.description).toContain('{ "path": "/absolute/path/to/screenshot.png" }');
65
- const listMemoryTool = createListMemoryNotesTool();
66
- const readMemoryTool = createReadMemoryNoteTool();
67
- const searchMemoryTool = createSearchMemoryNotesTool();
68
- const editMemoryTool = createEditMemoryNoteTool();
69
- expect(listMemoryTool.description).toContain('List markdown notes inside Heddle persistent memory');
70
- expect(readMemoryTool.description).toContain('Read a persistent memory note');
71
- expect(searchMemoryTool.description).toContain('mature command-line search tools');
72
- expect(editMemoryTool.description).toContain('Create or edit a persistent markdown note');
73
- expect(editMemoryTool.description).toContain('does not require approval');
74
- expect(editMemoryTool.requiresApproval).toBeUndefined();
75
- expect(reportStateTool.description).toContain('Use this when you are blocked, uncertain');
76
- expect(reportStateTool.description).toContain('tell the library author what capability, input, or support was missing');
77
- expect(reportStateTool.description).toContain('Returns the same structured report back');
78
- expect(reportStateTool.description).toContain('"nextNeed": "list_files on ."');
79
- expect(updatePlanTool.description).toContain('Record or revise a short working plan');
80
- expect(updatePlanTool.description).toContain('At most one item may be in_progress');
81
- });
82
- it('validates structured update_plan input', async () => {
83
- const result = await updatePlanTool.execute({
84
- explanation: 'Starting implementation.',
85
- plan: [
86
- { step: 'Inspect current flow', status: 'completed' },
87
- { step: 'Implement bounded change', status: 'in_progress' },
88
- { step: 'Verify with tests', status: 'pending' },
89
- ],
90
- });
91
- expect(result).toEqual({
92
- ok: true,
93
- output: {
94
- explanation: 'Starting implementation.',
95
- plan: [
96
- { step: 'Inspect current flow', status: 'completed' },
97
- { step: 'Implement bounded change', status: 'in_progress' },
98
- { step: 'Verify with tests', status: 'pending' },
99
- ],
100
- },
101
- });
102
- });
103
- it('rejects update_plan input with multiple in-progress items', async () => {
104
- const result = await updatePlanTool.execute({
105
- plan: [
106
- { step: 'A', status: 'in_progress' },
107
- { step: 'B', status: 'in_progress' },
108
- ],
109
- });
110
- expect(result).toEqual({
111
- ok: false,
112
- error: 'Invalid input for update_plan. Required field: plan. Optional field: explanation. Each plan item must have step and status (pending, in_progress, completed), with at most one in_progress item.',
113
- });
114
- });
115
- });
116
- describe('tool path mismatch guidance', () => {
117
- it('tells the caller to use read_file when list_files receives a file path', async () => {
118
- const result = await listFilesTool.execute({ path: 'README.md' });
119
- expect(result).toEqual({
120
- ok: false,
121
- error: `Failed to list ${join(process.cwd(), 'README.md')}: path is a file, not a directory. Use read_file for file contents.`,
122
- });
123
- });
124
- it('tells the caller to use list_files when read_file receives a directory path', async () => {
125
- const result = await readFileTool.execute({ path: 'src' });
126
- expect(result).toEqual({
127
- ok: false,
128
- error: `Failed to read ${join(process.cwd(), 'src')}: path is a directory, not a file. Use list_files to inspect directories.`,
129
- });
130
- });
131
- });
132
- describe('readFileTool', () => {
133
- it('supports paging into later lines with offset and maxLines', async () => {
134
- const root = await mkdtemp(join(tmpdir(), 'heddle-read-offset-'));
135
- const filePath = join(root, 'sample.txt');
136
- await writeFile(filePath, ['zero', 'one', 'two', 'three', 'four'].join('\n'));
137
- const previousCwd = process.cwd();
138
- process.chdir(root);
139
- try {
140
- const result = await readFileTool.execute({
141
- path: 'sample.txt',
142
- offset: 2,
143
- maxLines: 2,
144
- });
145
- expect(result).toEqual({
146
- ok: true,
147
- output: 'two\nthree',
148
- });
149
- }
150
- finally {
151
- process.chdir(previousCwd);
152
- }
153
- });
154
- });
155
- describe('searchFilesTool', () => {
156
- it('ignores generated directories like dist and node_modules by default', async () => {
157
- const root = await mkdtemp(join(tmpdir(), 'heddle-search-'));
158
- await mkdir(join(root, 'src'));
159
- await mkdir(join(root, 'dist'));
160
- await mkdir(join(root, 'node_modules'));
161
- await writeFile(join(root, 'src', 'main.ts'), 'const needle = true;\n');
162
- await writeFile(join(root, 'dist', 'generated.ts'), 'const needle = true;\n');
163
- await writeFile(join(root, 'node_modules', 'pkg.ts'), 'const needle = true;\n');
164
- const result = await searchFilesTool.execute({ query: 'needle', path: root });
165
- expect(result.ok).toBe(true);
166
- expect(result.output).toContain('src/main.ts');
167
- expect(result.output).not.toContain('dist/generated.ts');
168
- expect(result.output).not.toContain('node_modules/pkg.ts');
169
- });
170
- it('supports project-specific excluded directories', async () => {
171
- const root = await mkdtemp(join(tmpdir(), 'heddle-search-config-'));
172
- await mkdir(join(root, 'src'));
173
- await mkdir(join(root, 'vendor'));
174
- await writeFile(join(root, 'src', 'main.ts'), 'const needle = true;\n');
175
- await writeFile(join(root, 'vendor', 'hidden.ts'), 'const needle = true;\n');
176
- const tool = createSearchFilesTool({ excludedDirs: ['vendor'] });
177
- const result = await tool.execute({ query: 'needle', path: root });
178
- expect(result.ok).toBe(true);
179
- expect(result.output).toContain('src/main.ts');
180
- expect(result.output).not.toContain('vendor/hidden.ts');
181
- });
182
- it('searches inside an explicitly targeted excluded directory', async () => {
183
- const root = await mkdtemp(join(tmpdir(), 'heddle-search-state-'));
184
- await mkdir(join(root, '.heddle'));
185
- await mkdir(join(root, '.heddle', 'traces'));
186
- await writeFile(join(root, '.heddle', 'traces', 'trace-1.json'), '{"needle":true}\n');
187
- const result = await searchFilesTool.execute({ query: 'needle', path: join(root, '.heddle') });
188
- expect(result.ok).toBe(true);
189
- expect(result.output).toContain('.heddle/traces/trace-1.json');
190
- });
191
- });
192
- describe('webSearchTool', () => {
193
- it('rejects invalid input', async () => {
194
- const result = await webSearchTool.execute({ query: 'docs', mode: 'fast' });
195
- expect(result).toEqual({
196
- ok: false,
197
- error: 'Invalid input for web_search. Required field: query. Optional field: contextSize ("low", "medium", or "high").',
198
- });
199
- });
200
- it('fails clearly when no OpenAI key is available', async () => {
201
- vi.stubEnv('OPENAI_API_KEY', '');
202
- vi.stubEnv('PERSONAL_OPENAI_API_KEY', '');
203
- try {
204
- const result = await webSearchTool.execute({ query: 'OpenAI Responses API web search tool' });
205
- expect(result).toEqual({
206
- ok: false,
207
- error: 'web_search requires OPENAI_API_KEY (or PERSONAL_OPENAI_API_KEY) when the active model provider is OpenAI.',
208
- });
209
- }
210
- finally {
211
- vi.unstubAllEnvs();
212
- }
213
- });
214
- });
215
- describe('viewImageTool', () => {
216
- it('rejects invalid input', async () => {
217
- const result = await viewImageTool.execute({ prompt: 'describe it' });
218
- expect(result).toEqual({
219
- ok: false,
220
- error: 'Invalid input for view_image. Required field: path. Optional field: prompt.',
221
- });
222
- });
223
- it('rejects unsupported file types before any provider call', async () => {
224
- const result = await viewImageTool.execute({ path: 'notes.txt' });
225
- expect(result).toEqual({
226
- ok: false,
227
- error: 'view_image supports .png, .jpg, .jpeg, .gif, and .webp files.',
228
- });
229
- });
230
- it('fails clearly when no OpenAI key is available for the default provider', async () => {
231
- const root = await mkdtemp(join(tmpdir(), 'heddle-view-image-'));
232
- const imagePath = join(root, 'screen.png');
233
- await writeFile(imagePath, 'fake');
234
- vi.stubEnv('OPENAI_API_KEY', '');
235
- vi.stubEnv('PERSONAL_OPENAI_API_KEY', '');
236
- try {
237
- const result = await viewImageTool.execute({ path: imagePath });
238
- expect(result).toEqual({
239
- ok: false,
240
- error: 'view_image requires OPENAI_API_KEY (or PERSONAL_OPENAI_API_KEY) when the active model provider is OpenAI.',
241
- });
242
- }
243
- finally {
244
- vi.unstubAllEnvs();
245
- }
246
- });
247
- });
248
- describe('memory note tools', () => {
249
- it('lists markdown notes recursively inside the memory root', async () => {
250
- const root = await mkdtemp(join(tmpdir(), 'heddle-memory-list-'));
251
- await mkdir(join(root, 'architecture'), { recursive: true });
252
- await writeFile(join(root, 'project-summary.md'), '# Summary\n');
253
- await writeFile(join(root, 'architecture', 'auth.md'), '# Auth\n');
254
- await writeFile(join(root, 'architecture', 'notes.txt'), 'ignore\n');
255
- const tool = createListMemoryNotesTool({ memoryRoot: root });
256
- const result = await tool.execute({});
257
- expect(result).toEqual({
258
- ok: true,
259
- output: ['architecture/auth.md', 'project-summary.md'].join('\n'),
260
- });
261
- });
262
- it('reads memory notes with paging support', async () => {
263
- const root = await mkdtemp(join(tmpdir(), 'heddle-memory-read-'));
264
- await writeFile(join(root, 'project-summary.md'), ['zero', 'one', 'two'].join('\n'));
265
- const tool = createReadMemoryNoteTool({ memoryRoot: root });
266
- const result = await tool.execute({ path: 'project-summary.md', offset: 1, maxLines: 1 });
267
- expect(result).toEqual({
268
- ok: true,
269
- output: 'one',
270
- });
271
- });
272
- it('searches memory notes with grep-style output', async () => {
273
- const root = await mkdtemp(join(tmpdir(), 'heddle-memory-search-'));
274
- await writeFile(join(root, 'known-issues.md'), ['first line', 'test command is yarn test', 'another'].join('\n'));
275
- const tool = createSearchMemoryNotesTool({ memoryRoot: root });
276
- const result = await tool.execute({ query: 'test command' });
277
- expect(result).toEqual({
278
- ok: true,
279
- output: 'known-issues.md:2:test command is yarn test',
280
- });
281
- });
282
- it('edits memory notes inside the memory root without approval gating', async () => {
283
- const root = await mkdtemp(join(tmpdir(), 'heddle-memory-write-'));
284
- await writeFile(join(root, 'project-summary.md'), '# Summary\nKnown fact');
285
- const tool = createEditMemoryNoteTool({ memoryRoot: root });
286
- const replaced = await tool.execute({
287
- path: 'project-summary.md',
288
- oldText: 'Known fact',
289
- newText: 'Updated fact',
290
- });
291
- expect(replaced).toEqual({
292
- ok: true,
293
- output: {
294
- path: 'project-summary.md',
295
- action: 'replaced',
296
- matchCount: 1,
297
- bytesWritten: Buffer.byteLength('# Summary\nUpdated fact', 'utf8'),
298
- diff: {
299
- path: 'project-summary.md',
300
- action: 'replaced',
301
- diff: ['--- a/project-summary.md', '+++ b/project-summary.md', '@@ -1,2 +1,2 @@', ' # Summary', '-Known fact', '+Updated fact'].join('\n'),
302
- truncated: false,
303
- },
304
- },
305
- });
306
- });
307
- it('refuses to access paths outside the memory root', async () => {
308
- const root = await mkdtemp(join(tmpdir(), 'heddle-memory-scope-'));
309
- const tool = createReadMemoryNoteTool({ memoryRoot: root });
310
- const result = await tool.execute({ path: '../outside.md' });
311
- expect(result.ok).toBe(false);
312
- expect(result.error).toContain('Memory note paths must stay inside');
313
- });
314
- });
315
- describe('editFileTool', () => {
316
- it('creates a new file when explicitly allowed', async () => {
317
- const root = await mkdtemp(join(tmpdir(), 'heddle-edit-create-'));
318
- const previousCwd = process.cwd();
319
- process.chdir(root);
320
- try {
321
- const result = await editFileTool.execute({
322
- path: 'notes/output.txt',
323
- content: 'hello\n',
324
- createIfMissing: true,
325
- });
326
- expect(result).toEqual({
327
- ok: true,
328
- output: {
329
- path: 'notes/output.txt',
330
- action: 'created',
331
- bytesWritten: Buffer.byteLength('hello\n', 'utf8'),
332
- diff: {
333
- path: 'notes/output.txt',
334
- action: 'created',
335
- diff: ['--- /dev/null', '+++ b/notes/output.txt', '@@ -1,0 +1 @@', '+hello'].join('\n'),
336
- truncated: false,
337
- },
338
- },
339
- });
340
- }
341
- finally {
342
- process.chdir(previousCwd);
343
- }
344
- });
345
- it('replaces an exact single match in an existing file', async () => {
346
- const root = await mkdtemp(join(tmpdir(), 'heddle-edit-replace-'));
347
- const filePath = join(root, 'sample.ts');
348
- await writeFile(filePath, 'const mode = "old";\n');
349
- const previousCwd = process.cwd();
350
- process.chdir(root);
351
- try {
352
- const result = await editFileTool.execute({
353
- path: 'sample.ts',
354
- oldText: '"old"',
355
- newText: '"new"',
356
- });
357
- expect(result).toEqual({
358
- ok: true,
359
- output: {
360
- path: 'sample.ts',
361
- action: 'replaced',
362
- matchCount: 1,
363
- bytesWritten: Buffer.byteLength('const mode = "new";\n', 'utf8'),
364
- diff: {
365
- path: 'sample.ts',
366
- action: 'replaced',
367
- diff: ['--- a/sample.ts', '+++ b/sample.ts', '@@ -1 +1 @@', '-const mode = "old";', '+const mode = "new";'].join('\n'),
368
- truncated: false,
369
- },
370
- },
371
- });
372
- }
373
- finally {
374
- process.chdir(previousCwd);
375
- }
376
- });
377
- it('rejects ambiguous replacements unless replaceAll is set', async () => {
378
- const root = await mkdtemp(join(tmpdir(), 'heddle-edit-multi-'));
379
- const filePath = join(root, 'sample.ts');
380
- await writeFile(filePath, 'value\nvalue\n');
381
- const previousCwd = process.cwd();
382
- process.chdir(root);
383
- try {
384
- const result = await editFileTool.execute({
385
- path: 'sample.ts',
386
- oldText: 'value',
387
- newText: 'next',
388
- });
389
- expect(result.ok).toBe(false);
390
- expect(result.error).toContain('edit_file found 2 matches for oldText');
391
- expect(result.error).toContain('sample.ts');
392
- }
393
- finally {
394
- process.chdir(previousCwd);
395
- }
396
- });
397
- it('refuses to write outside the current workspace root', async () => {
398
- const root = await mkdtemp(join(tmpdir(), 'heddle-edit-scope-'));
399
- const previousCwd = process.cwd();
400
- process.chdir(root);
401
- try {
402
- const result = await editFileTool.execute({
403
- path: '../outside.txt',
404
- content: 'nope\n',
405
- createIfMissing: true,
406
- });
407
- expect(result.ok).toBe(false);
408
- expect(result.error).toContain('edit_file only writes inside the current workspace root');
409
- expect(result.error).toContain('outside.txt');
410
- }
411
- finally {
412
- process.chdir(previousCwd);
413
- }
414
- });
415
- it('builds an approval preview for edit_file before the write happens', async () => {
416
- const root = await mkdtemp(join(tmpdir(), 'heddle-edit-preview-'));
417
- const filePath = join(root, 'sample.ts');
418
- await writeFile(filePath, 'const mode = "old";\n');
419
- const previousCwd = process.cwd();
420
- process.chdir(root);
421
- try {
422
- const preview = await previewEditFileInput({
423
- path: 'sample.ts',
424
- oldText: '"old"',
425
- newText: '"new"',
426
- });
427
- expect(preview).toEqual({
428
- path: 'sample.ts',
429
- action: 'replaced',
430
- diff: ['--- a/sample.ts', '+++ b/sample.ts', '@@ -1 +1 @@', '-const mode = "old";', '+const mode = "new";'].join('\n'),
431
- truncated: false,
432
- });
433
- }
434
- finally {
435
- process.chdir(previousCwd);
436
- }
437
- });
438
- });
439
- describe('runShell tools', () => {
440
- it('documents inspect-oriented shell usage and safe prefixes', () => {
441
- const tool = createRunShellInspectTool();
442
- expect(tool.name).toBe('run_shell_inspect');
443
- expect(tool.description).toContain('Use this for CLI-native inspection, search, diff, and git state checks');
444
- expect(tool.description).toContain('policy metadata');
445
- expect(tool.description).toContain('low-risk inspect rules');
446
- });
447
- it('documents mutate-oriented shell usage and bounded workspace actions', () => {
448
- const tool = createRunShellMutateTool();
449
- expect(tool.name).toBe('run_shell_mutate');
450
- expect(tool.requiresApproval).toBe(true);
451
- expect(tool.description).toContain('Use this when inspection is not enough');
452
- expect(tool.description).toContain('inline scripts or broader shell expressiveness');
453
- expect(tool.description).toContain('host-side execution rules');
454
- });
455
- it('allows read-only pipes in inspect mode', async () => {
456
- const tool = createRunShellInspectTool();
457
- const result = await tool.execute({ command: 'cat README.md | head -n 1' });
458
- expect(result.ok).toBe(true);
459
- expect(result.output).toMatchObject({
460
- command: 'cat README.md | head -n 1',
461
- exitCode: 0,
462
- policy: {
463
- binary: 'cat',
464
- scope: 'inspect',
465
- risk: 'low',
466
- },
467
- });
468
- });
469
- it('allows numbered file inspection in inspect mode', async () => {
470
- const tool = createRunShellInspectTool();
471
- const result = await tool.execute({ command: 'nl -ba README.md' });
472
- expect(result.ok).toBe(true);
473
- expect(result.output).toMatchObject({
474
- command: 'nl -ba README.md',
475
- exitCode: 0,
476
- policy: {
477
- binary: 'nl',
478
- scope: 'inspect',
479
- risk: 'low',
480
- capability: 'file_inspection',
481
- },
482
- });
483
- });
484
- it('still rejects blocked shell operators in inspect mode', async () => {
485
- const tool = createRunShellInspectTool();
486
- const result = await tool.execute({ command: 'ls > out.txt' });
487
- expect(result).toEqual({
488
- ok: false,
489
- error: 'Command not allowed. Inspect mode permits read-only pipes, but redirects, command chaining, backgrounding, and subshells are blocked. If the command is still needed, retry with run_shell_mutate.',
490
- });
491
- });
492
- it('returns structured stdout and exit code for successful inspect commands', async () => {
493
- const tool = createRunShellInspectTool();
494
- const result = await tool.execute({ command: 'pwd' });
495
- expect(result.ok).toBe(true);
496
- expect(result.output).toMatchObject({
497
- command: 'pwd',
498
- exitCode: 0,
499
- stderr: '',
500
- policy: {
501
- binary: 'pwd',
502
- scope: 'inspect',
503
- risk: 'low',
504
- capability: 'environment_inspection',
505
- },
506
- });
507
- expect(typeof result.output.stdout).toBe('string');
508
- });
509
- it('returns structured failure details for allowed inspect commands that exit non-zero', async () => {
510
- const tool = createRunShellInspectTool();
511
- const result = await tool.execute({ command: 'grep definitely-not-present README.md' });
512
- expect(result).toMatchObject({
513
- ok: false,
514
- error: 'Shell command failed with exit code 1',
515
- output: {
516
- command: 'grep definitely-not-present README.md',
517
- exitCode: 1,
518
- },
519
- });
520
- });
521
- it('rejects invalid inspect input using the new tool name', async () => {
522
- const tool = createRunShellInspectTool();
523
- const result = await tool.execute({ path: '.' });
524
- expect(result).toEqual({
525
- ok: false,
526
- error: 'Invalid input for run_shell_inspect. Required field: command.',
527
- });
528
- });
529
- it('ignores unrelated extra input fields for inspect commands when command is present', async () => {
530
- const tool = createRunShellInspectTool();
531
- const result = await tool.execute({ command: 'pwd', maxLines: 400 });
532
- expect(result.ok).toBe(true);
533
- expect(result.output).toMatchObject({
534
- command: 'pwd',
535
- exitCode: 0,
536
- });
537
- });
538
- it('allows bounded mutate commands with structured output', async () => {
539
- const tool = createRunShellMutateTool();
540
- const result = await tool.execute({ command: 'tsc --version' });
541
- expect(result.ok).toBe(true);
542
- expect(result.output).toMatchObject({
543
- command: 'tsc --version',
544
- exitCode: 0,
545
- stderr: '',
546
- });
547
- });
548
- it('ignores unrelated extra input fields for mutate commands when command is present', async () => {
549
- const tool = createRunShellMutateTool();
550
- const result = await tool.execute({ command: 'tsc --version', rationale: 'verify compiler exists' });
551
- expect(result.ok).toBe(true);
552
- expect(result.output).toMatchObject({
553
- command: 'tsc --version',
554
- exitCode: 0,
555
- });
556
- });
557
- it('does not treat > inside quoted node -e source as a shell redirect', async () => {
558
- const tool = createRunShellMutateTool();
559
- const result = await tool.execute({ command: 'node -e "const fn = () => 1; console.log(fn())"' });
560
- expect(result.ok).toBe(true);
561
- expect(result.output).toMatchObject({
562
- command: 'node -e "const fn = () => 1; console.log(fn())"',
563
- exitCode: 0,
564
- });
565
- });
566
- it('allows pipes in mutate mode because mutate is approval-gated', async () => {
567
- const tool = createRunShellMutateTool();
568
- const result = await tool.execute({ command: 'echo ok | cat' });
569
- expect(result.ok).toBe(true);
570
- expect(result.output).toMatchObject({
571
- command: 'echo ok | cat',
572
- exitCode: 0,
573
- policy: {
574
- binary: 'echo',
575
- scope: 'workspace',
576
- risk: 'unknown',
577
- capability: 'unknown_workspace',
578
- reason: 'unclassified workspace command requiring explicit approval',
579
- },
580
- });
581
- });
582
- it('allows approved dependency install commands through mutate policy', async () => {
583
- const tool = createRunShellMutateTool();
584
- const result = await tool.execute({ command: 'yarn add --help' });
585
- expect(result.ok).toBe(true);
586
- expect(result.output).toMatchObject({
587
- command: 'yarn add --help',
588
- exitCode: 0,
589
- policy: {
590
- binary: 'yarn',
591
- scope: 'workspace',
592
- risk: 'medium',
593
- capability: 'dependency',
594
- reason: 'workspace dependency install command',
595
- },
596
- });
597
- });
598
- it('allows project-local script execution through mutate policy metadata', async () => {
599
- const tool = createRunShellMutateTool();
600
- const result = await tool.execute({ command: 'yarn run --help' });
601
- expect(result.ok).toBe(true);
602
- expect(result.output).toMatchObject({
603
- command: 'yarn run --help',
604
- exitCode: 0,
605
- policy: {
606
- binary: 'yarn',
607
- scope: 'workspace',
608
- risk: 'medium',
609
- capability: 'project_script',
610
- reason: 'workspace project script command',
611
- },
612
- });
613
- });
614
- it('treats unclassified mutate commands as approval-gated unknown workspace commands instead of hard rejecting them', async () => {
615
- const tool = createRunShellMutateTool();
616
- const result = await tool.execute({ command: 'pwd' });
617
- expect(result.ok).toBe(true);
618
- expect(result.output).toMatchObject({
619
- command: 'pwd',
620
- exitCode: 0,
621
- policy: {
622
- binary: 'pwd',
623
- scope: 'workspace',
624
- risk: 'unknown',
625
- capability: 'unknown_workspace',
626
- reason: 'unclassified workspace command requiring explicit approval',
627
- },
628
- });
629
- });
630
- it('treats unclassified mutate commands as approval-gated unknown commands instead of hard rejecting them', () => {
631
- const result = classifyShellCommandPolicy('ffmpeg -i input.mp4 output.gif', {
632
- toolName: 'run_shell_mutate',
633
- rules: DEFAULT_MUTATE_RULES,
634
- allowUnknown: true,
635
- });
636
- expect(result).toEqual({
637
- binary: 'ffmpeg',
638
- scope: 'workspace',
639
- risk: 'unknown',
640
- capability: 'unknown_workspace',
641
- reason: 'unclassified workspace command requiring explicit approval',
642
- });
643
- });
644
- it('allows bounded workspace file operations on mutate with policy metadata', async () => {
645
- const tool = createRunShellMutateTool();
646
- const root = await mkdtemp(join(tmpdir(), 'heddle-shell-'));
647
- const fromPath = join(root, 'from.txt');
648
- const toPath = join(root, 'to.txt');
649
- await writeFile(fromPath, 'hello\n');
650
- const previousCwd = process.cwd();
651
- process.chdir(root);
652
- try {
653
- const result = await tool.execute({ command: 'mv from.txt to.txt' });
654
- expect(result.ok).toBe(true);
655
- expect(result.output).toMatchObject({
656
- command: 'mv from.txt to.txt',
657
- exitCode: 0,
658
- policy: {
659
- binary: 'mv',
660
- scope: 'workspace',
661
- risk: 'medium',
662
- capability: 'file_operation',
663
- },
664
- });
665
- }
666
- finally {
667
- process.chdir(previousCwd);
668
- }
669
- expect(toPath).not.toBe(fromPath);
670
- });
671
- });
672
- describe('reportStateTool', () => {
673
- it('accepts structured missing-gap reports and echoes them back', async () => {
674
- const result = await reportStateTool.execute({
675
- rationale: 'I need to inspect the top-level directory first.',
676
- missing: ['Top-level directory contents'],
677
- nextNeed: 'list_files on .',
678
- });
679
- expect(result).toEqual({
680
- ok: true,
681
- output: {
682
- rationale: 'I need to inspect the top-level directory first.',
683
- missing: ['Top-level directory contents'],
684
- nextNeed: 'list_files on .',
685
- },
686
- });
687
- });
688
- it('rejects invalid report_state input', async () => {
689
- const result = await reportStateTool.execute({
690
- missing: ['Need more context'],
691
- });
692
- expect(result).toEqual({
693
- ok: false,
694
- error: 'Invalid input for report_state. Required field: rationale. Optional fields: missing, nextNeed.',
695
- });
696
- });
697
- });
698
- //# sourceMappingURL=tools.test.js.map