@danieltmn/tamnora 0.1.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 (123) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/LICENSE +21 -0
  3. package/README.md +130 -0
  4. package/SECURITY.md +31 -0
  5. package/harness/packages/cli/bin/tamnora.mjs +11 -0
  6. package/harness/packages/cli/package.json +10 -0
  7. package/harness/packages/cli/src/cli.js +90 -0
  8. package/harness/packages/cli/src/commands/autostart.js +121 -0
  9. package/harness/packages/cli/src/commands/bridge.js +274 -0
  10. package/harness/packages/cli/src/commands/doctor.js +73 -0
  11. package/harness/packages/cli/src/commands/eval.js +183 -0
  12. package/harness/packages/cli/src/commands/init.js +31 -0
  13. package/harness/packages/cli/src/commands/mcp.js +101 -0
  14. package/harness/packages/cli/src/commands/repl.js +172 -0
  15. package/harness/packages/cli/src/commands/run.js +165 -0
  16. package/harness/packages/cli/src/commands/serve.js +1 -0
  17. package/harness/packages/cli/src/commands/server.js +345 -0
  18. package/harness/packages/cli/src/commands/tui.js +110 -0
  19. package/harness/packages/cli/src/commands/update.js +76 -0
  20. package/harness/packages/cli/src/commands/user.js +135 -0
  21. package/harness/packages/cli/src/lib/core.js +2 -0
  22. package/harness/packages/cli/src/lib/daemon.js +153 -0
  23. package/harness/packages/cli/src/lib/qr.js +203 -0
  24. package/harness/packages/cli/src/lib/runtime.js +110 -0
  25. package/harness/packages/cli/src/lib/tunnel.js +113 -0
  26. package/harness/packages/cli/src/lib/util.js +24 -0
  27. package/harness/packages/cli/src/tui/app.js +302 -0
  28. package/harness/packages/cli/src/tui/keys.js +78 -0
  29. package/harness/packages/cli/src/tui/layout.js +130 -0
  30. package/harness/packages/cli/src/tui/screen.js +13 -0
  31. package/harness/packages/cli/src/ui/color.js +15 -0
  32. package/harness/packages/cli/test/release.test.js +34 -0
  33. package/harness/packages/cli/test/tui.test.js +99 -0
  34. package/harness/packages/cli/test/tunnel.test.js +20 -0
  35. package/harness/packages/core/package.json +14 -0
  36. package/harness/packages/core/src/agent/context.js +66 -0
  37. package/harness/packages/core/src/agent/modes.js +26 -0
  38. package/harness/packages/core/src/agent/runAgent.js +257 -0
  39. package/harness/packages/core/src/config/app.js +49 -0
  40. package/harness/packages/core/src/config/home.js +98 -0
  41. package/harness/packages/core/src/config/identity.js +41 -0
  42. package/harness/packages/core/src/config/index.js +69 -0
  43. package/harness/packages/core/src/config/providers.js +24 -0
  44. package/harness/packages/core/src/env.js +19 -0
  45. package/harness/packages/core/src/eval/dataset.js +47 -0
  46. package/harness/packages/core/src/eval/judge.js +39 -0
  47. package/harness/packages/core/src/eval/report.js +48 -0
  48. package/harness/packages/core/src/eval/runTask.js +71 -0
  49. package/harness/packages/core/src/eval/runner.js +150 -0
  50. package/harness/packages/core/src/eval/scorers.js +217 -0
  51. package/harness/packages/core/src/eval/task.js +59 -0
  52. package/harness/packages/core/src/index.js +72 -0
  53. package/harness/packages/core/src/mcp/common.js +82 -0
  54. package/harness/packages/core/src/mcp/http.js +149 -0
  55. package/harness/packages/core/src/mcp/index.js +167 -0
  56. package/harness/packages/core/src/mcp/stdio.js +218 -0
  57. package/harness/packages/core/src/providers/index.js +119 -0
  58. package/harness/packages/core/src/providers/ollama.js +76 -0
  59. package/harness/packages/core/src/providers/openaiCompatible.js +57 -0
  60. package/harness/packages/core/src/providers/openrouter.js +57 -0
  61. package/harness/packages/core/src/providers/presets.js +19 -0
  62. package/harness/packages/core/src/providers/resilience.js +74 -0
  63. package/harness/packages/core/src/providers/stream.js +200 -0
  64. package/harness/packages/core/src/providers/utils.js +66 -0
  65. package/harness/packages/core/src/storage/sqlite.js +453 -0
  66. package/harness/packages/core/src/tools/diff.js +90 -0
  67. package/harness/packages/core/src/tools/executors.js +277 -0
  68. package/harness/packages/core/src/tools/glob.js +67 -0
  69. package/harness/packages/core/src/tools/index.js +28 -0
  70. package/harness/packages/core/src/tools/paths.js +29 -0
  71. package/harness/packages/core/src/tools/permissions.js +69 -0
  72. package/harness/packages/core/src/tools/registry.js +44 -0
  73. package/harness/packages/core/src/tools/schema.js +177 -0
  74. package/harness/packages/core/test/agent.test.js +237 -0
  75. package/harness/packages/core/test/config.test.js +33 -0
  76. package/harness/packages/core/test/context.test.js +44 -0
  77. package/harness/packages/core/test/diff.test.js +20 -0
  78. package/harness/packages/core/test/eval.test.js +144 -0
  79. package/harness/packages/core/test/git-tools.test.js +44 -0
  80. package/harness/packages/core/test/home.test.js +62 -0
  81. package/harness/packages/core/test/mcp.test.js +158 -0
  82. package/harness/packages/core/test/providers.test.js +80 -0
  83. package/harness/packages/core/test/resilience.test.js +61 -0
  84. package/harness/packages/core/test/storage.test.js +55 -0
  85. package/harness/packages/core/test/stream.test.js +91 -0
  86. package/harness/packages/core/test/tools.test.js +96 -0
  87. package/harness/packages/server/bin/tamnora-server.mjs +11 -0
  88. package/harness/packages/server/package.json +10 -0
  89. package/harness/packages/server/public/app.js +2188 -0
  90. package/harness/packages/server/public/img/fondo-agentes.webp +0 -0
  91. package/harness/packages/server/public/index.html +368 -0
  92. package/harness/packages/server/public/landing/index.html +523 -0
  93. package/harness/packages/server/public/landing/landing.js +89 -0
  94. package/harness/packages/server/public/markdown.js +174 -0
  95. package/harness/packages/server/public/styles.css +923 -0
  96. package/harness/packages/server/src/auth.js +146 -0
  97. package/harness/packages/server/src/cli.js +61 -0
  98. package/harness/packages/server/src/index.js +1 -0
  99. package/harness/packages/server/src/lib/approvals.js +51 -0
  100. package/harness/packages/server/src/lib/core.js +2 -0
  101. package/harness/packages/server/src/lib/http.js +74 -0
  102. package/harness/packages/server/src/lib/modelCache.js +32 -0
  103. package/harness/packages/server/src/lib/prompt.js +48 -0
  104. package/harness/packages/server/src/lib/workspace.js +74 -0
  105. package/harness/packages/server/src/routes/auth.js +56 -0
  106. package/harness/packages/server/src/routes/chat.js +222 -0
  107. package/harness/packages/server/src/routes/eval.js +100 -0
  108. package/harness/packages/server/src/routes/files.js +117 -0
  109. package/harness/packages/server/src/routes/folders.js +80 -0
  110. package/harness/packages/server/src/routes/mcp.js +50 -0
  111. package/harness/packages/server/src/routes/models.js +26 -0
  112. package/harness/packages/server/src/routes/providers.js +135 -0
  113. package/harness/packages/server/src/routes/sessions.js +157 -0
  114. package/harness/packages/server/src/server.js +249 -0
  115. package/harness/packages/server/src/static.js +67 -0
  116. package/harness/packages/server/test/markdown.test.js +36 -0
  117. package/harness/packages/server/test/modelCache.test.js +72 -0
  118. package/harness/packages/server/test/server.test.js +809 -0
  119. package/harness/scripts/example-dataset.jsonl +4 -0
  120. package/harness/scripts/mcp-echo-server.mjs +108 -0
  121. package/harness/scripts/mock-provider.mjs +134 -0
  122. package/harness/tamnora.example.json +18 -0
  123. package/package.json +70 -0
@@ -0,0 +1,277 @@
1
+ import { readFile, writeFile, mkdir, stat, readdir } from 'node:fs/promises';
2
+ import { spawn } from 'node:child_process';
3
+ import path from 'node:path';
4
+ import { resolveInRoot, toRel } from './paths.js';
5
+ import { globToRegExp, walkFiles } from './glob.js';
6
+ import { diffLines } from './diff.js';
7
+
8
+ const MAX_READ_LINES = 2000;
9
+ const MAX_LIST = 500;
10
+ const MAX_GLOB = 500;
11
+ const MAX_GREP = 300;
12
+ const MAX_GREP_FILE_BYTES = 2_000_000;
13
+ const MAX_BASH_OUTPUT = 100_000;
14
+
15
+ export async function readTool(root, { path: p, offset, limit } = {}) {
16
+ const abs = resolveInRoot(root, p);
17
+ let text;
18
+ try {
19
+ text = await readFile(abs, 'utf8');
20
+ } catch (err) {
21
+ return `Error leyendo ${toRel(root, abs)}: ${err.message}`;
22
+ }
23
+ const lines = text.split('\n');
24
+ const start = Math.max(0, (Number(offset) || 1) - 1);
25
+ const count = Number.isFinite(Number(limit)) ? Number(limit) : MAX_READ_LINES;
26
+ const slice = lines.slice(start, start + count);
27
+ const numbered = slice
28
+ .map((line, i) => `${String(start + i + 1).padStart(5)}| ${line}`)
29
+ .join('\n');
30
+ const truncated = start + count < lines.length;
31
+ return (
32
+ `${toRel(root, abs)} (${lines.length} líneas)\n${numbered}` +
33
+ (truncated ? `\n[... truncado en ${start + count} de ${lines.length} líneas]` : '')
34
+ );
35
+ }
36
+
37
+ export async function writeTool(root, { path: p, content } = {}) {
38
+ const abs = resolveInRoot(root, p);
39
+ let previous = null;
40
+ try {
41
+ previous = await readFile(abs, 'utf8');
42
+ } catch {
43
+ previous = null;
44
+ }
45
+ try {
46
+ await mkdir(path.dirname(abs), { recursive: true });
47
+ await writeFile(abs, content ?? '', 'utf8');
48
+ } catch (err) {
49
+ return `Error escribiendo ${toRel(root, abs)}: ${err.message}`;
50
+ }
51
+ const rel = toRel(root, abs);
52
+ const head = previous === null
53
+ ? `Creado ${rel} (${Buffer.byteLength(content ?? '')} bytes)`
54
+ : `Escrito ${rel} (${Buffer.byteLength(content ?? '')} bytes)`;
55
+ const diff = diffLines(previous ?? '', content ?? '');
56
+ return diff ? `${head}\n${diff}` : head;
57
+ }
58
+
59
+ function countOccurrences(text, needle) {
60
+ if (!needle) return 0;
61
+ let count = 0;
62
+ let index = text.indexOf(needle);
63
+ while (index !== -1) {
64
+ count++;
65
+ index = text.indexOf(needle, index + needle.length);
66
+ }
67
+ return count;
68
+ }
69
+
70
+ export async function editTool(root, { path: p, oldString, newString, replaceAll } = {}) {
71
+ const abs = resolveInRoot(root, p);
72
+ if (!oldString) return 'Error: falta "oldString".';
73
+ let text;
74
+ try {
75
+ text = await readFile(abs, 'utf8');
76
+ } catch (err) {
77
+ return `Error leyendo ${toRel(root, abs)}: ${err.message}`;
78
+ }
79
+ const count = countOccurrences(text, oldString);
80
+ if (count === 0) return `Error: no se encontró "oldString" en ${toRel(root, abs)}.`;
81
+ if (count > 1 && !replaceAll) {
82
+ return `Error: "oldString" aparece ${count} veces en ${toRel(root, abs)}; agregá más contexto o usá replaceAll.`;
83
+ }
84
+ const updated = replaceAll
85
+ ? text.split(oldString).join(newString ?? '')
86
+ : text.replace(oldString, newString ?? '');
87
+ await writeFile(abs, updated, 'utf8');
88
+ const head = `Editado ${toRel(root, abs)}${replaceAll ? ` (${count} reemplazos)` : ''}`;
89
+ const diff = diffLines(text, updated);
90
+ return diff ? `${head}\n${diff}` : head;
91
+ }
92
+
93
+ export async function listTool(root, { path: p } = {}) {
94
+ const abs = resolveInRoot(root, p ?? '.');
95
+ let entries;
96
+ try {
97
+ entries = await readdir(abs, { withFileTypes: true });
98
+ } catch (err) {
99
+ return `Error listando ${toRel(root, abs)}: ${err.message}`;
100
+ }
101
+ const lines = entries
102
+ .sort((a, b) => a.name.localeCompare(b.name))
103
+ .slice(0, MAX_LIST)
104
+ .map((e) => (e.isDirectory() ? `${e.name}/` : e.name));
105
+ return `Contenido de ${toRel(root, abs)}:\n${lines.join('\n') || '(vacío)'}`;
106
+ }
107
+
108
+ export async function globTool(root, { pattern, path: p } = {}) {
109
+ if (!pattern) return 'Error: falta "pattern".';
110
+ const base = resolveInRoot(root, p ?? '.');
111
+ const re = globToRegExp(pattern);
112
+ const results = [];
113
+ for await (const file of walkFiles(base)) {
114
+ const relToBase = path.relative(base, file).split(path.sep).join('/');
115
+ const relToRoot = toRel(root, file);
116
+ if (re.test(relToBase) || re.test(relToRoot)) results.push(relToRoot);
117
+ if (results.length >= MAX_GLOB) break;
118
+ }
119
+ results.sort((a, b) => a.localeCompare(b));
120
+ return results.length ? results.join('\n') : 'Sin coincidencias';
121
+ }
122
+
123
+ export async function grepTool(root, { pattern, include, path: p } = {}) {
124
+ if (!pattern) return 'Error: falta "pattern".';
125
+ let re;
126
+ try {
127
+ re = new RegExp(pattern, 'i');
128
+ } catch (err) {
129
+ return `Error: regex inválida (${err.message}).`;
130
+ }
131
+ const base = resolveInRoot(root, p ?? '.');
132
+ const includeRe = include ? globToRegExp(include) : null;
133
+ const results = [];
134
+
135
+ for await (const file of walkFiles(base)) {
136
+ const relToBase = path.relative(base, file).split(path.sep).join('/');
137
+ if (includeRe && !includeRe.test(relToBase) && !includeRe.test(path.basename(file))) continue;
138
+ let text;
139
+ try {
140
+ const info = await stat(file);
141
+ if (info.size > MAX_GREP_FILE_BYTES) continue;
142
+ text = await readFile(file, 'utf8');
143
+ } catch {
144
+ continue;
145
+ }
146
+ if (text.includes('\u0000')) continue;
147
+ const lines = text.split('\n');
148
+ for (let i = 0; i < lines.length; i++) {
149
+ if (re.test(lines[i])) {
150
+ results.push(`${toRel(root, file)}:${i + 1}: ${lines[i].trim().slice(0, 200)}`);
151
+ }
152
+ if (results.length >= MAX_GREP) break;
153
+ }
154
+ if (results.length >= MAX_GREP) break;
155
+ }
156
+
157
+ return results.length ? results.join('\n') : 'Sin coincidencias';
158
+ }
159
+
160
+ export function bashTool(root, { command, timeout } = {}) {
161
+ return new Promise((resolve) => {
162
+ if (!command || typeof command !== 'string') {
163
+ resolve('Error: falta "command".');
164
+ return;
165
+ }
166
+ const seconds = Math.min(Math.max(Number(timeout) || 30, 1), 300);
167
+ let child;
168
+ try {
169
+ child = spawn(command, { cwd: root, shell: true, windowsHide: true });
170
+ } catch (err) {
171
+ resolve(`Error ejecutando el comando: ${err.message}`);
172
+ return;
173
+ }
174
+
175
+ let output = '';
176
+ let settled = false;
177
+ const append = (chunk) => {
178
+ if (output.length < MAX_BASH_OUTPUT) output += chunk.toString();
179
+ };
180
+
181
+ const timer = setTimeout(() => {
182
+ if (settled) return;
183
+ settled = true;
184
+ child.kill('SIGKILL');
185
+ resolve(`${output}\n[timeout tras ${seconds}s]\n[exit -1]`);
186
+ }, seconds * 1000);
187
+
188
+ child.stdout?.on('data', append);
189
+ child.stderr?.on('data', append);
190
+ child.on('error', (err) => {
191
+ if (settled) return;
192
+ settled = true;
193
+ clearTimeout(timer);
194
+ resolve(`Error ejecutando el comando: ${err.message}`);
195
+ });
196
+ child.on('close', (code) => {
197
+ if (settled) return;
198
+ settled = true;
199
+ clearTimeout(timer);
200
+ const truncated = output.length >= MAX_BASH_OUTPUT ? '\n[output truncado]' : '';
201
+ resolve(`${output}${truncated}\n[exit ${code}]`);
202
+ });
203
+ });
204
+ }
205
+
206
+ function runGit(root, args) {
207
+ return new Promise((resolve) => {
208
+ let child;
209
+ try {
210
+ child = spawn('git', args, { cwd: root, windowsHide: true });
211
+ } catch (err) {
212
+ resolve(`Error ejecutando git: ${err.message}`);
213
+ return;
214
+ }
215
+ let output = '';
216
+ let settled = false;
217
+ const append = (chunk) => {
218
+ if (output.length < MAX_BASH_OUTPUT) output += chunk.toString();
219
+ };
220
+ child.stdout?.on('data', append);
221
+ child.stderr?.on('data', append);
222
+ child.on('error', (err) => {
223
+ if (settled) return;
224
+ settled = true;
225
+ resolve(`Error ejecutando git: ${err.message}`);
226
+ });
227
+ child.on('close', (code) => {
228
+ if (settled) return;
229
+ settled = true;
230
+ const text = output.trim() || '(sin salida)';
231
+ resolve(code === 0 ? text : `${text}\n[exit ${code}]`);
232
+ });
233
+ });
234
+ }
235
+
236
+ export function gitStatusTool(root) {
237
+ return runGit(root, ['status', '--short', '--branch']);
238
+ }
239
+
240
+ export function gitDiffTool(root, { path: p, staged, ref } = {}) {
241
+ const args = ['diff', '--no-color'];
242
+ if (staged) args.push('--staged');
243
+ if (ref) args.push(String(ref));
244
+ if (p) args.push('--', String(p));
245
+ return runGit(root, args);
246
+ }
247
+
248
+ export function gitLogTool(root, { limit } = {}) {
249
+ const count = Math.min(Math.max(Number(limit) || 20, 1), 200);
250
+ return runGit(root, ['log', `-n${count}`, '--oneline', '--decorate']);
251
+ }
252
+
253
+ export async function gitCommitTool(root, { message, files } = {}) {
254
+ if (!message || typeof message !== 'string') return 'Error: falta "message".';
255
+ if (files) {
256
+ const list = Array.isArray(files) ? files : String(files).split(/\s+/).filter(Boolean);
257
+ if (list.length) {
258
+ const added = await runGit(root, ['add', '--', ...list]);
259
+ if (/\[exit [1-9]/.test(added)) return added;
260
+ }
261
+ }
262
+ return runGit(root, ['commit', '-m', message]);
263
+ }
264
+
265
+ export const EXECUTORS = {
266
+ read: readTool,
267
+ write: writeTool,
268
+ edit: editTool,
269
+ list: listTool,
270
+ glob: globTool,
271
+ grep: grepTool,
272
+ bash: bashTool,
273
+ git_status: gitStatusTool,
274
+ git_diff: gitDiffTool,
275
+ git_log: gitLogTool,
276
+ git_commit: gitCommitTool,
277
+ };
@@ -0,0 +1,67 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ export const DEFAULT_IGNORES = new Set([
5
+ 'node_modules',
6
+ '.git',
7
+ '.tamnora',
8
+ 'dist',
9
+ 'build',
10
+ '.cache',
11
+ '.next',
12
+ '.venv',
13
+ 'venv',
14
+ ]);
15
+
16
+ /**
17
+ * Convert a glob pattern to a RegExp. Supports `**`, `*`, `?` and escapes
18
+ * regex metacharacters. `/` is the separator in patterns.
19
+ */
20
+ export function globToRegExp(glob) {
21
+ let re = '';
22
+ for (let i = 0; i < glob.length; i++) {
23
+ const c = glob[i];
24
+ if (c === '*') {
25
+ if (glob[i + 1] === '*') {
26
+ re += '.*';
27
+ i++;
28
+ if (glob[i + 1] === '/') i++;
29
+ } else {
30
+ re += '[^/]*';
31
+ }
32
+ } else if (c === '?') {
33
+ re += '[^/]';
34
+ } else if ('+.^$(){}|[]\\'.includes(c)) {
35
+ re += `\\${c}`;
36
+ } else {
37
+ re += c;
38
+ }
39
+ }
40
+ return new RegExp(`^${re}$`);
41
+ }
42
+
43
+ /**
44
+ * Recursively yield file paths under `root`, skipping common heavy dirs.
45
+ * @param {string} root
46
+ * @param {{ignores?: Set<string>}} [opts]
47
+ */
48
+ export async function* walkFiles(root, { ignores = DEFAULT_IGNORES } = {}) {
49
+ const stack = [root];
50
+ while (stack.length) {
51
+ const dir = stack.pop();
52
+ let entries;
53
+ try {
54
+ entries = await readdir(dir, { withFileTypes: true });
55
+ } catch {
56
+ continue;
57
+ }
58
+ for (const entry of entries) {
59
+ const full = path.join(dir, entry.name);
60
+ if (entry.isDirectory()) {
61
+ if (!ignores.has(entry.name)) stack.push(full);
62
+ } else if (entry.isFile()) {
63
+ yield full;
64
+ }
65
+ }
66
+ }
67
+ }
@@ -0,0 +1,28 @@
1
+ import { createToolRegistry } from './registry.js';
2
+
3
+ export { createToolRegistry } from './registry.js';
4
+ export { TOOL_DEFINITIONS, TOOL_NAMES } from './schema.js';
5
+ export { DEFAULT_PERMISSIONS, evaluatePermission } from './permissions.js';
6
+ export { resolveInRoot, toRel } from './paths.js';
7
+ export { globToRegExp, walkFiles, DEFAULT_IGNORES } from './glob.js';
8
+ export * as executors from './executors.js';
9
+
10
+ /**
11
+ * Combine local tools with an optional MCP registry into a single
12
+ * `{ definitions, execute }` pair for the agent loop.
13
+ *
14
+ * @param {object} opts - same options as createToolRegistry, plus:
15
+ * @param {{definitions:object[], handles:(n:string)=>boolean, call:Function}} [opts.mcp]
16
+ */
17
+ export function createTools({ mcp, ...registryOptions } = {}) {
18
+ const registry = createToolRegistry(registryOptions);
19
+ const definitions = [...registry.definitions, ...(mcp?.definitions ?? [])];
20
+
21
+ async function execute(name, args) {
22
+ if (registry.handles(name)) return registry.execute(name, args);
23
+ if (mcp?.handles?.(name)) return mcp.call(name, args);
24
+ return `Error: herramienta desconocida "${name}".`;
25
+ }
26
+
27
+ return { definitions, execute, registry };
28
+ }
@@ -0,0 +1,29 @@
1
+ import path from 'node:path';
2
+
3
+ /**
4
+ * Resolve a user-supplied path against the workspace root, refusing to escape
5
+ * it. This is a lexical guard: `..`, absolute paths outside the root and
6
+ * drive changes are rejected.
7
+ *
8
+ * @param {string} root - absolute workspace root
9
+ * @param {string} input - relative (or absolute-inside-root) path
10
+ * @returns {string} absolute path inside the root
11
+ */
12
+ export function resolveInRoot(root, input) {
13
+ const base = path.resolve(root);
14
+ const raw = String(input ?? '.').trim() || '.';
15
+ const target = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(base, raw);
16
+
17
+ const rel = path.relative(base, target);
18
+ const escapes = rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel);
19
+ if (escapes) {
20
+ throw new Error(`Ruta fuera del workspace: "${input}"`);
21
+ }
22
+ return target;
23
+ }
24
+
25
+ /** Path relative to the root, using forward slashes, or '.' for the root. */
26
+ export function toRel(root, absolute) {
27
+ const rel = path.relative(path.resolve(root), absolute);
28
+ return rel ? rel.split(path.sep).join('/') : '.';
29
+ }
@@ -0,0 +1,69 @@
1
+ // Permission policy for tools: 'allow' | 'ask' | 'deny'.
2
+ //
3
+ // A tool's policy is either a single decision or an object mapping patterns to
4
+ // decisions (useful for `bash`, matching the command). The special key '*'
5
+ // provides the fallback.
6
+
7
+ export const DEFAULT_PERMISSIONS = {
8
+ read: 'allow',
9
+ list: 'allow',
10
+ glob: 'allow',
11
+ grep: 'allow',
12
+ git_status: 'allow',
13
+ git_diff: 'allow',
14
+ git_log: 'allow',
15
+ write: 'ask',
16
+ edit: 'ask',
17
+ bash: 'ask',
18
+ git_commit: 'ask',
19
+ };
20
+
21
+ function patternToRegExp(pattern) {
22
+ let re = '';
23
+ for (const c of String(pattern)) {
24
+ if (c === '*') re += '.*';
25
+ else if (c === '?') re += '.';
26
+ else if ('+.^$(){}|[]\\'.includes(c)) re += `\\${c}`;
27
+ else re += c;
28
+ }
29
+ return new RegExp(`^${re}$`);
30
+ }
31
+
32
+ function matchPattern(pattern, subject) {
33
+ try {
34
+ return patternToRegExp(pattern).test(subject);
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ function subjectFor(name, args) {
41
+ if (name === 'bash') return args?.command ?? '';
42
+ if (name === 'git_commit') return args?.message ?? '';
43
+ return args?.path ?? '';
44
+ }
45
+
46
+ /**
47
+ * Decide the permission for a tool call.
48
+ * @returns {'allow'|'ask'|'deny'}
49
+ */
50
+ export function evaluatePermission(name, args = {}, permissions = {}) {
51
+ const value = permissions[name] ?? DEFAULT_PERMISSIONS[name] ?? 'ask';
52
+
53
+ if (typeof value === 'string') return value;
54
+
55
+ if (value && typeof value === 'object') {
56
+ const subject = subjectFor(name, args);
57
+ let fallback = 'ask';
58
+ for (const [pattern, decision] of Object.entries(value)) {
59
+ if (pattern === '*') {
60
+ fallback = decision;
61
+ continue;
62
+ }
63
+ if (matchPattern(pattern, subject)) return decision;
64
+ }
65
+ return fallback;
66
+ }
67
+
68
+ return 'ask';
69
+ }
@@ -0,0 +1,44 @@
1
+ import { TOOL_DEFINITIONS, TOOL_NAMES } from './schema.js';
2
+ import { EXECUTORS } from './executors.js';
3
+ import { evaluatePermission } from './permissions.js';
4
+
5
+ /**
6
+ * Build the local tool registry.
7
+ *
8
+ * @param {object} opts
9
+ * @param {string} opts.root - workspace root (all paths are confined here)
10
+ * @param {string[]} [opts.enabled] - tool names to expose (default: all)
11
+ * @param {object} [opts.permissions] - policy per tool ('allow'|'ask'|'deny' or pattern map)
12
+ * @param {(name:string, args:object)=>Promise<boolean>} [opts.onAsk] - interactive approval
13
+ * @param {boolean} [opts.yes] - auto-approve 'ask' (non-interactive)
14
+ */
15
+ export function createToolRegistry({ root, enabled, permissions = {}, onAsk, yes = false } = {}) {
16
+ const names = new Set(enabled?.length ? enabled : TOOL_NAMES);
17
+ const definitions = TOOL_DEFINITIONS.filter((d) => names.has(d.function.name));
18
+
19
+ async function execute(name, args = {}) {
20
+ if (!names.has(name) || !(name in EXECUTORS)) {
21
+ return `Error: herramienta desconocida "${name}".`;
22
+ }
23
+
24
+ let decision = evaluatePermission(name, args, permissions);
25
+ if (decision === 'ask') {
26
+ if (onAsk) decision = (await onAsk(name, args)) ? 'allow' : 'deny';
27
+ else decision = yes ? 'allow' : 'deny';
28
+ }
29
+ if (decision === 'deny') {
30
+ const detail = args?.command ?? args?.path ?? '';
31
+ return `Permiso denegado para "${name}"${detail ? ` (${detail})` : ''}.`;
32
+ }
33
+
34
+ return EXECUTORS[name](root, args);
35
+ }
36
+
37
+ return {
38
+ root,
39
+ definitions,
40
+ names: [...names],
41
+ handles: (name) => names.has(name) && name in EXECUTORS,
42
+ execute,
43
+ };
44
+ }
@@ -0,0 +1,177 @@
1
+ // OpenAI-schema definitions for the local, workspace-scoped tools.
2
+ // These are shared by the CLI and the harness runner and sent to the model.
3
+
4
+ export const TOOL_DEFINITIONS = [
5
+ {
6
+ type: 'function',
7
+ function: {
8
+ name: 'read',
9
+ description:
10
+ 'Read a text file from the workspace. Returns the file content with line numbers. Optionally read a slice with offset/limit.',
11
+ parameters: {
12
+ type: 'object',
13
+ properties: {
14
+ path: { type: 'string', description: 'Path relative to the workspace root' },
15
+ offset: { type: 'integer', description: '1-based first line to read' },
16
+ limit: { type: 'integer', description: 'Maximum number of lines' },
17
+ },
18
+ required: ['path'],
19
+ },
20
+ },
21
+ },
22
+ {
23
+ type: 'function',
24
+ function: {
25
+ name: 'write',
26
+ description:
27
+ 'Create or overwrite a file in the workspace. Missing parent directories are created.',
28
+ parameters: {
29
+ type: 'object',
30
+ properties: {
31
+ path: { type: 'string', description: 'Path relative to the workspace root' },
32
+ content: { type: 'string', description: 'Full file content' },
33
+ },
34
+ required: ['path', 'content'],
35
+ },
36
+ },
37
+ },
38
+ {
39
+ type: 'function',
40
+ function: {
41
+ name: 'edit',
42
+ description:
43
+ 'Replace an exact string in a file. By default oldString must be unique. Use replaceAll to replace every occurrence.',
44
+ parameters: {
45
+ type: 'object',
46
+ properties: {
47
+ path: { type: 'string', description: 'Path relative to the workspace root' },
48
+ oldString: { type: 'string', description: 'Exact text to replace' },
49
+ newString: { type: 'string', description: 'Replacement text' },
50
+ replaceAll: { type: 'boolean', description: 'Replace all occurrences' },
51
+ },
52
+ required: ['path', 'oldString', 'newString'],
53
+ },
54
+ },
55
+ },
56
+ {
57
+ type: 'function',
58
+ function: {
59
+ name: 'list',
60
+ description: 'List files and folders in a workspace directory.',
61
+ parameters: {
62
+ type: 'object',
63
+ properties: {
64
+ path: { type: 'string', description: 'Directory relative to the workspace root (default: root)' },
65
+ },
66
+ required: [],
67
+ },
68
+ },
69
+ },
70
+ {
71
+ type: 'function',
72
+ function: {
73
+ name: 'glob',
74
+ description:
75
+ 'Find files by glob pattern (supports **, * and ?). Returns paths relative to the workspace root.',
76
+ parameters: {
77
+ type: 'object',
78
+ properties: {
79
+ pattern: { type: 'string', description: 'Glob pattern, e.g. "src/**/*.js"' },
80
+ path: { type: 'string', description: 'Base directory (default: root)' },
81
+ },
82
+ required: ['pattern'],
83
+ },
84
+ },
85
+ },
86
+ {
87
+ type: 'function',
88
+ function: {
89
+ name: 'grep',
90
+ description:
91
+ 'Search file contents with a regular expression. Returns file:line matches (case-insensitive).',
92
+ parameters: {
93
+ type: 'object',
94
+ properties: {
95
+ pattern: { type: 'string', description: 'Regular expression' },
96
+ include: { type: 'string', description: 'Only search files matching this glob, e.g. "*.js"' },
97
+ path: { type: 'string', description: 'Base directory (default: root)' },
98
+ },
99
+ required: ['pattern'],
100
+ },
101
+ },
102
+ },
103
+ {
104
+ type: 'function',
105
+ function: {
106
+ name: 'bash',
107
+ description:
108
+ 'Run a shell command in the workspace root. Use for tests, builds and git. Output is truncated.',
109
+ parameters: {
110
+ type: 'object',
111
+ properties: {
112
+ command: { type: 'string', description: 'Shell command to execute' },
113
+ timeout: { type: 'integer', description: 'Timeout in seconds (default 30, max 300)' },
114
+ },
115
+ required: ['command'],
116
+ },
117
+ },
118
+ },
119
+ {
120
+ type: 'function',
121
+ function: {
122
+ name: 'git_status',
123
+ description: 'Show the git working-tree status (short) and current branch.',
124
+ parameters: { type: 'object', properties: {}, required: [] },
125
+ },
126
+ },
127
+ {
128
+ type: 'function',
129
+ function: {
130
+ name: 'git_diff',
131
+ description: 'Show a git diff. Default: unstaged changes. Use staged or a ref (e.g. HEAD~1).',
132
+ parameters: {
133
+ type: 'object',
134
+ properties: {
135
+ path: { type: 'string', description: 'Limit the diff to this path' },
136
+ staged: { type: 'boolean', description: 'Diff staged (--staged) changes' },
137
+ ref: { type: 'string', description: 'Compare against a ref, e.g. HEAD~1 or a branch' },
138
+ },
139
+ required: [],
140
+ },
141
+ },
142
+ },
143
+ {
144
+ type: 'function',
145
+ function: {
146
+ name: 'git_log',
147
+ description: 'Show recent commits (one line each).',
148
+ parameters: {
149
+ type: 'object',
150
+ properties: { limit: { type: 'integer', description: 'Number of commits (default 20)' } },
151
+ required: [],
152
+ },
153
+ },
154
+ },
155
+ {
156
+ type: 'function',
157
+ function: {
158
+ name: 'git_commit',
159
+ description:
160
+ 'Commit changes. Optionally stage the given files first. Requires a message. Does not push.',
161
+ parameters: {
162
+ type: 'object',
163
+ properties: {
164
+ message: { type: 'string', description: 'Commit message' },
165
+ files: {
166
+ type: 'array',
167
+ items: { type: 'string' },
168
+ description: 'Files to stage before committing (optional)',
169
+ },
170
+ },
171
+ required: ['message'],
172
+ },
173
+ },
174
+ },
175
+ ];
176
+
177
+ export const TOOL_NAMES = TOOL_DEFINITIONS.map((d) => d.function.name);