@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.
- package/CHANGELOG.md +48 -0
- package/LICENSE +21 -0
- package/README.md +130 -0
- package/SECURITY.md +31 -0
- package/harness/packages/cli/bin/tamnora.mjs +11 -0
- package/harness/packages/cli/package.json +10 -0
- package/harness/packages/cli/src/cli.js +90 -0
- package/harness/packages/cli/src/commands/autostart.js +121 -0
- package/harness/packages/cli/src/commands/bridge.js +274 -0
- package/harness/packages/cli/src/commands/doctor.js +73 -0
- package/harness/packages/cli/src/commands/eval.js +183 -0
- package/harness/packages/cli/src/commands/init.js +31 -0
- package/harness/packages/cli/src/commands/mcp.js +101 -0
- package/harness/packages/cli/src/commands/repl.js +172 -0
- package/harness/packages/cli/src/commands/run.js +165 -0
- package/harness/packages/cli/src/commands/serve.js +1 -0
- package/harness/packages/cli/src/commands/server.js +345 -0
- package/harness/packages/cli/src/commands/tui.js +110 -0
- package/harness/packages/cli/src/commands/update.js +76 -0
- package/harness/packages/cli/src/commands/user.js +135 -0
- package/harness/packages/cli/src/lib/core.js +2 -0
- package/harness/packages/cli/src/lib/daemon.js +153 -0
- package/harness/packages/cli/src/lib/qr.js +203 -0
- package/harness/packages/cli/src/lib/runtime.js +110 -0
- package/harness/packages/cli/src/lib/tunnel.js +113 -0
- package/harness/packages/cli/src/lib/util.js +24 -0
- package/harness/packages/cli/src/tui/app.js +302 -0
- package/harness/packages/cli/src/tui/keys.js +78 -0
- package/harness/packages/cli/src/tui/layout.js +130 -0
- package/harness/packages/cli/src/tui/screen.js +13 -0
- package/harness/packages/cli/src/ui/color.js +15 -0
- package/harness/packages/cli/test/release.test.js +34 -0
- package/harness/packages/cli/test/tui.test.js +99 -0
- package/harness/packages/cli/test/tunnel.test.js +20 -0
- package/harness/packages/core/package.json +14 -0
- package/harness/packages/core/src/agent/context.js +66 -0
- package/harness/packages/core/src/agent/modes.js +26 -0
- package/harness/packages/core/src/agent/runAgent.js +257 -0
- package/harness/packages/core/src/config/app.js +49 -0
- package/harness/packages/core/src/config/home.js +98 -0
- package/harness/packages/core/src/config/identity.js +41 -0
- package/harness/packages/core/src/config/index.js +69 -0
- package/harness/packages/core/src/config/providers.js +24 -0
- package/harness/packages/core/src/env.js +19 -0
- package/harness/packages/core/src/eval/dataset.js +47 -0
- package/harness/packages/core/src/eval/judge.js +39 -0
- package/harness/packages/core/src/eval/report.js +48 -0
- package/harness/packages/core/src/eval/runTask.js +71 -0
- package/harness/packages/core/src/eval/runner.js +150 -0
- package/harness/packages/core/src/eval/scorers.js +217 -0
- package/harness/packages/core/src/eval/task.js +59 -0
- package/harness/packages/core/src/index.js +72 -0
- package/harness/packages/core/src/mcp/common.js +82 -0
- package/harness/packages/core/src/mcp/http.js +149 -0
- package/harness/packages/core/src/mcp/index.js +167 -0
- package/harness/packages/core/src/mcp/stdio.js +218 -0
- package/harness/packages/core/src/providers/index.js +119 -0
- package/harness/packages/core/src/providers/ollama.js +76 -0
- package/harness/packages/core/src/providers/openaiCompatible.js +57 -0
- package/harness/packages/core/src/providers/openrouter.js +57 -0
- package/harness/packages/core/src/providers/presets.js +19 -0
- package/harness/packages/core/src/providers/resilience.js +74 -0
- package/harness/packages/core/src/providers/stream.js +200 -0
- package/harness/packages/core/src/providers/utils.js +66 -0
- package/harness/packages/core/src/storage/sqlite.js +453 -0
- package/harness/packages/core/src/tools/diff.js +90 -0
- package/harness/packages/core/src/tools/executors.js +277 -0
- package/harness/packages/core/src/tools/glob.js +67 -0
- package/harness/packages/core/src/tools/index.js +28 -0
- package/harness/packages/core/src/tools/paths.js +29 -0
- package/harness/packages/core/src/tools/permissions.js +69 -0
- package/harness/packages/core/src/tools/registry.js +44 -0
- package/harness/packages/core/src/tools/schema.js +177 -0
- package/harness/packages/core/test/agent.test.js +237 -0
- package/harness/packages/core/test/config.test.js +33 -0
- package/harness/packages/core/test/context.test.js +44 -0
- package/harness/packages/core/test/diff.test.js +20 -0
- package/harness/packages/core/test/eval.test.js +144 -0
- package/harness/packages/core/test/git-tools.test.js +44 -0
- package/harness/packages/core/test/home.test.js +62 -0
- package/harness/packages/core/test/mcp.test.js +158 -0
- package/harness/packages/core/test/providers.test.js +80 -0
- package/harness/packages/core/test/resilience.test.js +61 -0
- package/harness/packages/core/test/storage.test.js +55 -0
- package/harness/packages/core/test/stream.test.js +91 -0
- package/harness/packages/core/test/tools.test.js +96 -0
- package/harness/packages/server/bin/tamnora-server.mjs +11 -0
- package/harness/packages/server/package.json +10 -0
- package/harness/packages/server/public/app.js +2188 -0
- package/harness/packages/server/public/img/fondo-agentes.webp +0 -0
- package/harness/packages/server/public/index.html +368 -0
- package/harness/packages/server/public/landing/index.html +523 -0
- package/harness/packages/server/public/landing/landing.js +89 -0
- package/harness/packages/server/public/markdown.js +174 -0
- package/harness/packages/server/public/styles.css +923 -0
- package/harness/packages/server/src/auth.js +146 -0
- package/harness/packages/server/src/cli.js +61 -0
- package/harness/packages/server/src/index.js +1 -0
- package/harness/packages/server/src/lib/approvals.js +51 -0
- package/harness/packages/server/src/lib/core.js +2 -0
- package/harness/packages/server/src/lib/http.js +74 -0
- package/harness/packages/server/src/lib/modelCache.js +32 -0
- package/harness/packages/server/src/lib/prompt.js +48 -0
- package/harness/packages/server/src/lib/workspace.js +74 -0
- package/harness/packages/server/src/routes/auth.js +56 -0
- package/harness/packages/server/src/routes/chat.js +222 -0
- package/harness/packages/server/src/routes/eval.js +100 -0
- package/harness/packages/server/src/routes/files.js +117 -0
- package/harness/packages/server/src/routes/folders.js +80 -0
- package/harness/packages/server/src/routes/mcp.js +50 -0
- package/harness/packages/server/src/routes/models.js +26 -0
- package/harness/packages/server/src/routes/providers.js +135 -0
- package/harness/packages/server/src/routes/sessions.js +157 -0
- package/harness/packages/server/src/server.js +249 -0
- package/harness/packages/server/src/static.js +67 -0
- package/harness/packages/server/test/markdown.test.js +36 -0
- package/harness/packages/server/test/modelCache.test.js +72 -0
- package/harness/packages/server/test/server.test.js +809 -0
- package/harness/scripts/example-dataset.jsonl +4 -0
- package/harness/scripts/mcp-echo-server.mjs +108 -0
- package/harness/scripts/mock-provider.mjs +134 -0
- package/harness/tamnora.example.json +18 -0
- package/package.json +70 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Pure renderer for the full-screen console. Returns a frame string plus the
|
|
2
|
+
// cursor position so the app can draw it in one write (no deps).
|
|
3
|
+
|
|
4
|
+
import { color } from '../ui/color.js';
|
|
5
|
+
import { moveTo } from './screen.js';
|
|
6
|
+
|
|
7
|
+
const CURSOR = '▋';
|
|
8
|
+
|
|
9
|
+
const THEME = {
|
|
10
|
+
head: color.bold,
|
|
11
|
+
user: color.green,
|
|
12
|
+
assistant: (t) => t,
|
|
13
|
+
dim: color.dim,
|
|
14
|
+
tool: color.cyan,
|
|
15
|
+
error: color.red,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const chars = (text) => Array.from(String(text ?? ''));
|
|
19
|
+
|
|
20
|
+
export function wrapText(text, width) {
|
|
21
|
+
const lines = [];
|
|
22
|
+
for (const paragraph of String(text ?? '').split('\n')) {
|
|
23
|
+
if (!paragraph) {
|
|
24
|
+
lines.push('');
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
let current = [];
|
|
28
|
+
for (const word of paragraph.split(' ')) {
|
|
29
|
+
const wordChars = chars(word);
|
|
30
|
+
if (current.length === 0) current = wordChars;
|
|
31
|
+
else if (current.length + 1 + wordChars.length <= width) current = current.concat([' '], wordChars);
|
|
32
|
+
else {
|
|
33
|
+
lines.push(current.join(''));
|
|
34
|
+
current = wordChars;
|
|
35
|
+
}
|
|
36
|
+
while (current.length > width) {
|
|
37
|
+
lines.push(current.slice(0, width).join(''));
|
|
38
|
+
current = current.slice(width);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
lines.push(current.join(''));
|
|
42
|
+
}
|
|
43
|
+
return lines;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function clip(text, width) {
|
|
47
|
+
const arr = chars(text);
|
|
48
|
+
return arr.length <= width ? text : arr.slice(0, width).join('');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function buildBody(state, width) {
|
|
52
|
+
const out = [];
|
|
53
|
+
for (const message of state.messages) {
|
|
54
|
+
if (message.role === 'user') {
|
|
55
|
+
for (const line of wrapText(message.text, width - 2)) out.push({ text: ` ${line}`, style: 'user' });
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (message.reasoning) {
|
|
59
|
+
for (const line of wrapText(message.reasoning, width - 4)) out.push({ text: ` · ${line}`, style: 'dim' });
|
|
60
|
+
}
|
|
61
|
+
if (message.text) {
|
|
62
|
+
for (const line of wrapText(message.text, width - 2)) out.push({ text: ` ${line}`, style: 'assistant' });
|
|
63
|
+
}
|
|
64
|
+
for (const tool of message.tools ?? []) {
|
|
65
|
+
out.push({ text: ` ⚙ ${tool.name} ${clip(tool.args ?? '', width - 8)}`, style: 'tool' });
|
|
66
|
+
if (tool.result !== undefined) {
|
|
67
|
+
for (const line of wrapText(tool.result, width - 6)) out.push({ text: ` → ${line}`, style: 'dim' });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function headerText(state, width) {
|
|
75
|
+
const left = ` ❯ tamnora`;
|
|
76
|
+
const middle = ` ${state.header.provider}/${state.header.model}`;
|
|
77
|
+
const tokens = state.tokens?.prompt ? ` ctx ${formatK(state.tokens.prompt)}` : '';
|
|
78
|
+
const right = state.streaming ? `generando…${tokens} ` : `${state.header.cwd ?? ''}${tokens} `;
|
|
79
|
+
const pad = Math.max(0, width - chars(left).length - chars(middle).length - chars(right).length);
|
|
80
|
+
return clip(`${left}${middle}${' '.repeat(pad)}${right}`, width);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function formatK(n) {
|
|
84
|
+
const value = Number(n) || 0;
|
|
85
|
+
return value >= 1000 ? `${(value / 1000).toFixed(1)}k` : String(value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function statusText(state) {
|
|
89
|
+
if (state.pendingAsk) return ' y = permitir · n = denegar';
|
|
90
|
+
if (state.streaming) return ' generando… · ctrl+c cancelar';
|
|
91
|
+
return ' enter enviar · ↑↓ historial · ←→ editar · pgup/pgdn scroll · ctrl+l limpiar · ctrl+c salir';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function render(state, { cols = 80, rows = 24 } = {}) {
|
|
95
|
+
const width = Math.max(30, cols);
|
|
96
|
+
const viewportRows = Math.max(3, rows - 5);
|
|
97
|
+
|
|
98
|
+
const body = buildBody(state, width);
|
|
99
|
+
const scroll = Math.max(0, state.scroll ?? 0);
|
|
100
|
+
const end = Math.max(0, body.length - scroll);
|
|
101
|
+
const start = Math.max(0, end - viewportRows);
|
|
102
|
+
const viewport = body.slice(start, end);
|
|
103
|
+
while (viewport.length < viewportRows) viewport.push({ text: '', style: 'assistant' });
|
|
104
|
+
|
|
105
|
+
const lines = [{ text: headerText(state, width), style: 'head' }, { text: '', style: 'dim' }];
|
|
106
|
+
for (const entry of viewport) lines.push(entry);
|
|
107
|
+
lines.push({ text: '', style: 'dim' });
|
|
108
|
+
|
|
109
|
+
const inputIndex = lines.length;
|
|
110
|
+
const inputChars = chars(state.input ?? '');
|
|
111
|
+
const cursorPos = Math.max(0, Math.min(state.inputCursor ?? inputChars.length, inputChars.length));
|
|
112
|
+
const before = inputChars.slice(0, cursorPos).join('');
|
|
113
|
+
const after = inputChars.slice(cursorPos).join('');
|
|
114
|
+
if (state.pendingAsk) {
|
|
115
|
+
lines.push({ text: ` permitir ${state.pendingAsk.name} (${state.pendingAsk.detail})? [y/N] `, style: 'error' });
|
|
116
|
+
} else {
|
|
117
|
+
lines.push({ text: ` ❯ ${before}${CURSOR}${after}`, style: 'user' });
|
|
118
|
+
}
|
|
119
|
+
lines.push({ text: statusText(state), style: 'dim' });
|
|
120
|
+
|
|
121
|
+
const parts = [];
|
|
122
|
+
for (let i = 0; i < rows; i++) {
|
|
123
|
+
const entry = lines[i] ?? { text: '', style: 'dim' };
|
|
124
|
+
const style = THEME[entry.style] ?? THEME.assistant;
|
|
125
|
+
parts.push(moveTo(i + 1, 1), '\x1b[2K', style(clip(entry.text, width)));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const cursorCol = state.pendingAsk ? 2 : Math.min(width, chars(` ❯ ${before}`).length + 1);
|
|
129
|
+
return { frame: parts.join(''), cursor: { row: Math.min(inputIndex + 1, rows), col: cursorCol } };
|
|
130
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// ANSI helpers for the full-screen console.
|
|
2
|
+
|
|
3
|
+
export const enterAltScreen = '\x1b[?1049h';
|
|
4
|
+
export const leaveAltScreen = '\x1b[?1049l';
|
|
5
|
+
export const hideCursor = '\x1b[?25l';
|
|
6
|
+
export const showCursor = '\x1b[?25h';
|
|
7
|
+
export const enableBracketedPaste = '\x1b[?2004h';
|
|
8
|
+
export const disableBracketedPaste = '\x1b[?2004l';
|
|
9
|
+
export const reset = '\x1b[0m';
|
|
10
|
+
|
|
11
|
+
export function moveTo(row, col = 1) {
|
|
12
|
+
return `\x1b[${row};${col}H`;
|
|
13
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const enabled = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
|
|
2
|
+
|
|
3
|
+
const wrap = (code) => (text) => (enabled ? `\x1b[${code}m${text}\x1b[0m` : String(text));
|
|
4
|
+
|
|
5
|
+
export const color = {
|
|
6
|
+
bold: wrap('1'),
|
|
7
|
+
dim: wrap('2'),
|
|
8
|
+
red: wrap('31'),
|
|
9
|
+
green: wrap('32'),
|
|
10
|
+
yellow: wrap('33'),
|
|
11
|
+
magenta: wrap('35'),
|
|
12
|
+
cyan: wrap('36'),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const colorsEnabled = enabled;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { computeVersion, distTagFor, parseVersion, renderChangelog } from '../../../../scripts/release.mjs';
|
|
4
|
+
|
|
5
|
+
test('computeVersion handles patch/minor/major and explicit versions', () => {
|
|
6
|
+
assert.equal(computeVersion('1.2.3', 'patch'), '1.2.4');
|
|
7
|
+
assert.equal(computeVersion('1.2.3', 'minor'), '1.3.0');
|
|
8
|
+
assert.equal(computeVersion('1.2.3', 'major'), '2.0.0');
|
|
9
|
+
assert.equal(computeVersion('1.2.3', '0.5.0'), '0.5.0');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('computeVersion builds prereleases', () => {
|
|
13
|
+
assert.equal(computeVersion('1.2.3', 'prerelease', 'beta'), '1.2.4-beta.0');
|
|
14
|
+
assert.equal(computeVersion('1.2.4-beta.0', 'prerelease', 'beta'), '1.2.4-beta.1');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('distTagFor picks beta for prereleases and latest otherwise', () => {
|
|
18
|
+
assert.equal(distTagFor('1.2.4-beta.0'), 'beta');
|
|
19
|
+
assert.equal(distTagFor('1.2.4'), 'latest');
|
|
20
|
+
assert.equal(distTagFor('1.2.4', 'next'), 'next');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('parseVersion rejects invalid input', () => {
|
|
24
|
+
assert.equal(parseVersion('nope'), null);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('renderChangelog promotes the Unreleased section', () => {
|
|
28
|
+
const cl = '# Changelog\n\n## [Unreleased]\n\n### Agregado\n\n- algo\n\n## [1.0.0] - 2026-01-01\n\n- viejo\n';
|
|
29
|
+
const out = renderChangelog(cl, '1.1.0', '- nota', '2026-02-02');
|
|
30
|
+
assert.match(out, /## \[1\.1\.0\] - 2026-02-02/);
|
|
31
|
+
assert.match(out, /- algo/);
|
|
32
|
+
assert.doesNotMatch(out, /## \[Unreleased\]/);
|
|
33
|
+
assert.match(out, /## \[1\.0\.0\]/);
|
|
34
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { parseKeys } from '../src/tui/keys.js';
|
|
4
|
+
import { wrapText, render } from '../src/tui/layout.js';
|
|
5
|
+
|
|
6
|
+
test('parseKeys maps raw input to actions', () => {
|
|
7
|
+
assert.deepEqual(parseKeys('a'), [{ type: 'char', value: 'a' }]);
|
|
8
|
+
assert.deepEqual(parseKeys('\r'), [{ type: 'enter' }]);
|
|
9
|
+
assert.deepEqual(parseKeys('\x7f'), [{ type: 'backspace' }]);
|
|
10
|
+
assert.deepEqual(parseKeys('\x03'), [{ type: 'interrupt' }]);
|
|
11
|
+
assert.deepEqual(parseKeys('\x0c'), [{ type: 'clear' }]);
|
|
12
|
+
assert.deepEqual(parseKeys('\x1b[A'), [{ type: 'up' }]);
|
|
13
|
+
assert.deepEqual(parseKeys('\x1b[B'), [{ type: 'down' }]);
|
|
14
|
+
assert.deepEqual(parseKeys('ok!'), [
|
|
15
|
+
{ type: 'char', value: 'o' },
|
|
16
|
+
{ type: 'char', value: 'k' },
|
|
17
|
+
{ type: 'char', value: '!' },
|
|
18
|
+
]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('parseKeys handles editing and navigation keys', () => {
|
|
22
|
+
assert.deepEqual(parseKeys('\x1b[C'), [{ type: 'right' }]);
|
|
23
|
+
assert.deepEqual(parseKeys('\x1b[D'), [{ type: 'left' }]);
|
|
24
|
+
assert.deepEqual(parseKeys('\x1b[H'), [{ type: 'home' }]);
|
|
25
|
+
assert.deepEqual(parseKeys('\x1b[F'), [{ type: 'end' }]);
|
|
26
|
+
assert.deepEqual(parseKeys('\x1b[3~'), [{ type: 'delete' }]);
|
|
27
|
+
assert.deepEqual(parseKeys('\x1b[5~'), [{ type: 'pageup' }]);
|
|
28
|
+
assert.deepEqual(parseKeys('\x1b[6~'), [{ type: 'pagedown' }]);
|
|
29
|
+
assert.deepEqual(parseKeys('\x01'), [{ type: 'home' }]);
|
|
30
|
+
assert.deepEqual(parseKeys('\x05'), [{ type: 'end' }]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('parseKeys reads bracketed paste as a single action', () => {
|
|
34
|
+
assert.deepEqual(parseKeys('\x1b[200~hola mundo\x1b[201~'), [{ type: 'paste', value: 'hola mundo' }]);
|
|
35
|
+
assert.deepEqual(parseKeys('\x1b[200~a\nb\x1b[201~'), [{ type: 'paste', value: 'a\nb' }]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('wrapText wraps paragraphs to the width', () => {
|
|
39
|
+
assert.deepEqual(wrapText('hola mundo', 20), ['hola mundo']);
|
|
40
|
+
assert.deepEqual(wrapText('uno dos tres cuatro', 7), ['uno dos', 'tres', 'cuatro']);
|
|
41
|
+
assert.deepEqual(wrapText('a\nb', 10), ['a', 'b']);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('render composes header, body and input', () => {
|
|
45
|
+
const state = {
|
|
46
|
+
header: { provider: 'ollama', model: 'llama', cwd: '/tmp/x' },
|
|
47
|
+
messages: [
|
|
48
|
+
{ role: 'user', text: 'hola', tools: [] },
|
|
49
|
+
{ role: 'assistant', text: 'buenas', tools: [{ name: 'read', args: '{"path":"a"}', result: 'ok' }] },
|
|
50
|
+
],
|
|
51
|
+
input: 'escribiendo',
|
|
52
|
+
streaming: false,
|
|
53
|
+
pendingAsk: null,
|
|
54
|
+
};
|
|
55
|
+
const { frame, cursor } = render(state, { cols: 80, rows: 24 });
|
|
56
|
+
assert.match(frame, /tamnora/);
|
|
57
|
+
assert.match(frame, /ollama\/llama/);
|
|
58
|
+
assert.match(frame, /hola/);
|
|
59
|
+
assert.match(frame, /buenas/);
|
|
60
|
+
assert.match(frame, /read/);
|
|
61
|
+
assert.match(frame, /escribiendo/);
|
|
62
|
+
assert.ok(cursor.col > 1);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('render places the cursor at inputCursor', () => {
|
|
66
|
+
const state = {
|
|
67
|
+
header: { provider: 'p', model: 'm', cwd: '/x' },
|
|
68
|
+
messages: [],
|
|
69
|
+
input: 'abc',
|
|
70
|
+
inputCursor: 1,
|
|
71
|
+
streaming: false,
|
|
72
|
+
pendingAsk: null,
|
|
73
|
+
};
|
|
74
|
+
const { frame, cursor } = render(state, { cols: 80, rows: 20 });
|
|
75
|
+
assert.match(frame, /a▋bc/);
|
|
76
|
+
assert.equal(cursor.col, 6);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('render scrolls the viewport', () => {
|
|
80
|
+
const messages = Array.from({ length: 40 }, (_, i) => ({ role: 'assistant', text: `linea ${i}`, tools: [] }));
|
|
81
|
+
const base = { header: { provider: 'p', model: 'm', cwd: '/x' }, input: '', streaming: false, pendingAsk: null };
|
|
82
|
+
const bottom = render({ ...base, messages, scroll: 0 }, { cols: 40, rows: 12 }).frame;
|
|
83
|
+
const up = render({ ...base, messages, scroll: 20 }, { cols: 40, rows: 12 }).frame;
|
|
84
|
+
assert.match(bottom, /linea 39/);
|
|
85
|
+
assert.doesNotMatch(up, /linea 39/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('render shows the permission prompt while asking', () => {
|
|
89
|
+
const state = {
|
|
90
|
+
header: { provider: 'p', model: 'm', cwd: '/x' },
|
|
91
|
+
messages: [],
|
|
92
|
+
input: '',
|
|
93
|
+
streaming: false,
|
|
94
|
+
pendingAsk: { name: 'write', detail: 'out.txt' },
|
|
95
|
+
};
|
|
96
|
+
const { frame } = render(state, { cols: 80, rows: 20 });
|
|
97
|
+
assert.match(frame, /permitir write/);
|
|
98
|
+
assert.match(frame, /\[y\/N\]/);
|
|
99
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { parseTunnelmoleUrls } from '../src/lib/tunnel.js';
|
|
4
|
+
import { qrTerminal } from '../src/lib/qr.js';
|
|
5
|
+
|
|
6
|
+
test('parseTunnelmoleUrls extracts the https URL', () => {
|
|
7
|
+
const out = parseTunnelmoleUrls('Listening at https://abc-123.tunnelmole.net (forwarding to 4517)');
|
|
8
|
+
assert.equal(out.https, 'https://abc-123.tunnelmole.net');
|
|
9
|
+
assert.equal(out.http, '');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('parseTunnelmoleUrls ignores unrelated output', () => {
|
|
13
|
+
assert.deepEqual(parseTunnelmoleUrls('starting tunnelmole...'), { https: '', http: '' });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('qrTerminal renders a block matrix for a URL', () => {
|
|
17
|
+
const art = qrTerminal('https://abc-123.tunnelmole.net', 1);
|
|
18
|
+
assert.ok(art.length > 0);
|
|
19
|
+
assert.match(art, /[\u2580\u2584\u2588]/);
|
|
20
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tamnora/core",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Tamnora harness core: agent engine, providers, tools, MCP, sessions and storage (Node-only).",
|
|
7
|
+
"main": "src/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.js"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "node --test test"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Estimacion y recorte de contexto. Sin tokenizador real: aproximacion por
|
|
3
|
+
* caracteres (4 por token), suficiente para mantener las sesiones dentro del
|
|
4
|
+
* limite del modelo.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const CHARS_PER_TOKEN = 4;
|
|
8
|
+
const TOOL_KEEP_CHARS = 800;
|
|
9
|
+
|
|
10
|
+
function contentLength(content) {
|
|
11
|
+
if (typeof content === 'string') return content.length;
|
|
12
|
+
if (Array.isArray(content)) {
|
|
13
|
+
return content.reduce((sum, part) => sum + (typeof part?.text === 'string' ? part.text.length : 200), 0);
|
|
14
|
+
}
|
|
15
|
+
return 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function estimateTokens(messages) {
|
|
19
|
+
let chars = 0;
|
|
20
|
+
for (const message of messages) {
|
|
21
|
+
chars += contentLength(message.content);
|
|
22
|
+
if (message.tool_calls) chars += JSON.stringify(message.tool_calls).length;
|
|
23
|
+
if (message.name) chars += message.name.length;
|
|
24
|
+
}
|
|
25
|
+
return Math.ceil(chars / CHARS_PER_TOKEN);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Devuelve una copia recortada de `messages` que entra en `maxTokens`:
|
|
30
|
+
* 1. acorta el contenido de tool-results viejos;
|
|
31
|
+
* 2. si hace falta, descarta los grupos mas antiguos (assistant + sus tools),
|
|
32
|
+
* sin tocar el primer mensaje (system) ni los ultimos `keepLast`.
|
|
33
|
+
*
|
|
34
|
+
* @returns {{messages: object[], trimmed: number, dropped: number}}
|
|
35
|
+
*/
|
|
36
|
+
export function trimContext(messages, { maxTokens, keepLast = 8 } = {}) {
|
|
37
|
+
if (!(maxTokens > 0) || !Array.isArray(messages) || messages.length === 0) {
|
|
38
|
+
return { messages, trimmed: 0, dropped: 0 };
|
|
39
|
+
}
|
|
40
|
+
if (estimateTokens(messages) <= maxTokens) return { messages, trimmed: 0, dropped: 0 };
|
|
41
|
+
|
|
42
|
+
const out = messages.map((message) => ({ ...message }));
|
|
43
|
+
let trimmed = 0;
|
|
44
|
+
const cutoff = Math.max(1, out.length - keepLast);
|
|
45
|
+
for (let i = 1; i < cutoff; i++) {
|
|
46
|
+
const message = out[i];
|
|
47
|
+
if (message.role === 'tool' && typeof message.content === 'string' && message.content.length > TOOL_KEEP_CHARS) {
|
|
48
|
+
out[i] = { ...message, content: `${message.content.slice(0, TOOL_KEEP_CHARS)}\n…[recortado por contexto]` };
|
|
49
|
+
trimmed += 1;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (estimateTokens(out) <= maxTokens) return { messages: out, trimmed, dropped: 0 };
|
|
53
|
+
|
|
54
|
+
let dropped = 0;
|
|
55
|
+
while (estimateTokens(out) > maxTokens && out.length > keepLast + 1) {
|
|
56
|
+
const first = out[1];
|
|
57
|
+
let end = 2;
|
|
58
|
+
if (first.role === 'assistant' && Array.isArray(first.tool_calls)) {
|
|
59
|
+
while (end < out.length && out[end].role === 'tool') end += 1;
|
|
60
|
+
}
|
|
61
|
+
if (out.length - (end - 1) < keepLast) break;
|
|
62
|
+
out.splice(1, end - 1);
|
|
63
|
+
dropped += end - 1;
|
|
64
|
+
}
|
|
65
|
+
return { messages: out, trimmed, dropped };
|
|
66
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Agent modes: preset tool sets + system-prompt guidance. `agent` can change
|
|
2
|
+
// the workspace; `plan` is read-only and answers with a plan.
|
|
3
|
+
|
|
4
|
+
export const MODES = {
|
|
5
|
+
agent: {
|
|
6
|
+
id: 'agent',
|
|
7
|
+
label: 'Agent',
|
|
8
|
+
description: 'Lee, escribe y ejecuta comandos en el workspace.',
|
|
9
|
+
tools: null,
|
|
10
|
+
systemPrompt: '',
|
|
11
|
+
},
|
|
12
|
+
plan: {
|
|
13
|
+
id: 'plan',
|
|
14
|
+
label: 'Plan',
|
|
15
|
+
description: 'Solo lectura: investiga y propone un plan, sin modificar nada.',
|
|
16
|
+
tools: ['read', 'list', 'glob', 'grep', 'git_status', 'git_diff', 'git_log'],
|
|
17
|
+
systemPrompt:
|
|
18
|
+
'Estás en modo PLAN: no modifiques archivos ni ejecutes comandos. Investigá el proyecto con las herramientas de solo lectura y respondé con un plan concreto y accionable.',
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const MODE_IDS = Object.keys(MODES);
|
|
23
|
+
|
|
24
|
+
export function resolveMode(id) {
|
|
25
|
+
return MODES[id] ?? MODES.agent;
|
|
26
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { withRetry, isRetryableError } from '../providers/resilience.js';
|
|
2
|
+
import { trimContext, estimateTokens } from './context.js';
|
|
3
|
+
|
|
4
|
+
export const MAX_TOOL_ROUNDS = 8;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Provider-agnostic agent loop. Runs the model, executes any tool calls it
|
|
8
|
+
* requests, feeds the results back, and repeats until the model answers
|
|
9
|
+
* without tools (or `maxRounds` is reached).
|
|
10
|
+
*
|
|
11
|
+
* Free of React and browser APIs so it can run headless in Node.
|
|
12
|
+
*
|
|
13
|
+
* @param {object} opts
|
|
14
|
+
* @param {object} opts.provider - provider adapter (from core/providers)
|
|
15
|
+
* @param {object} opts.config - resolved provider config
|
|
16
|
+
* @param {string} opts.model - model id
|
|
17
|
+
* @param {object[]} opts.messages - full history (including the system message)
|
|
18
|
+
* @param {object[]} [opts.tools] - OpenAI-schema tool definitions
|
|
19
|
+
* @param {Function} [opts.execute] - (name, args, context) => Promise<string>
|
|
20
|
+
* @param {object} [opts.toolContext] - passed through to `execute`
|
|
21
|
+
* @param {AbortSignal} [opts.signal]
|
|
22
|
+
* @param {Function} [opts.onEvent] - streaming/lifecycle callback
|
|
23
|
+
* @param {number} [opts.maxRounds]
|
|
24
|
+
* @param {number} [opts.stallWarnMs] - warn once if the model emits nothing for
|
|
25
|
+
* this long (0 disables; default 90000)
|
|
26
|
+
* @param {number} [opts.retries] - reintentos por ronda ante error transitorio
|
|
27
|
+
* (default 2; solo si la ronda no emitio contenido todavia)
|
|
28
|
+
* @param {number} [opts.retryDelayMs] - base del backoff (default 500)
|
|
29
|
+
* @returns {Promise<{content, reasoning, toolCalls, messages, rounds, stopReason, usage}>}
|
|
30
|
+
*/
|
|
31
|
+
export async function runAgent({
|
|
32
|
+
provider,
|
|
33
|
+
config,
|
|
34
|
+
model,
|
|
35
|
+
messages,
|
|
36
|
+
tools = [],
|
|
37
|
+
execute = defaultExecute,
|
|
38
|
+
toolContext = {},
|
|
39
|
+
signal,
|
|
40
|
+
onEvent,
|
|
41
|
+
maxRounds = MAX_TOOL_ROUNDS,
|
|
42
|
+
stallWarnMs = 90000,
|
|
43
|
+
retries = 2,
|
|
44
|
+
retryDelayMs = 500,
|
|
45
|
+
contextTokens = 0,
|
|
46
|
+
}) {
|
|
47
|
+
const working = [...messages];
|
|
48
|
+
let usageTotal = null;
|
|
49
|
+
|
|
50
|
+
for (let round = 1; round <= maxRounds; round++) {
|
|
51
|
+
if (signal?.aborted) {
|
|
52
|
+
return {
|
|
53
|
+
content: '',
|
|
54
|
+
reasoning: '',
|
|
55
|
+
toolCalls: [],
|
|
56
|
+
messages: working,
|
|
57
|
+
rounds: Math.max(0, round - 1),
|
|
58
|
+
stopReason: 'aborted',
|
|
59
|
+
usage: usageTotal,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const stall = createStallGuard(onEvent, stallWarnMs);
|
|
64
|
+
let emitted = false;
|
|
65
|
+
|
|
66
|
+
// Presupuesto de contexto: el recorte va sobre una copia; `working` sigue
|
|
67
|
+
// completo para persistir el historial con indices estables.
|
|
68
|
+
let contextMessages = working;
|
|
69
|
+
if (contextTokens > 0) {
|
|
70
|
+
const trimmedResult = trimContext(working, { maxTokens: contextTokens });
|
|
71
|
+
contextMessages = trimmedResult.messages;
|
|
72
|
+
if (trimmedResult.trimmed || trimmedResult.dropped) {
|
|
73
|
+
onEvent?.({
|
|
74
|
+
type: 'trim',
|
|
75
|
+
trimmed: trimmedResult.trimmed,
|
|
76
|
+
dropped: trimmedResult.dropped,
|
|
77
|
+
tokens: estimateTokens(contextMessages),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let result;
|
|
83
|
+
try {
|
|
84
|
+
result = await withRetry(
|
|
85
|
+
(attempt) => {
|
|
86
|
+
if (attempt > 0) emitted = false;
|
|
87
|
+
return provider.streamChat(config, {
|
|
88
|
+
model,
|
|
89
|
+
messages: contextMessages,
|
|
90
|
+
tools,
|
|
91
|
+
signal,
|
|
92
|
+
onEvent: (event) => {
|
|
93
|
+
if (event && (event.type === 'reasoning' || event.type === 'tool_calls')) emitted = true;
|
|
94
|
+
if (event?.type === 'text' && event.delta) emitted = true;
|
|
95
|
+
stall.onEvent(event);
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
retries,
|
|
101
|
+
baseDelayMs: retryDelayMs,
|
|
102
|
+
signal,
|
|
103
|
+
shouldRetry: (err) => !emitted && isRetryableError(err),
|
|
104
|
+
onRetry: (attempt, delay, err) =>
|
|
105
|
+
onEvent?.({ type: 'retry', attempt, delayMs: Math.round(delay), message: err?.message ?? String(err) }),
|
|
106
|
+
}
|
|
107
|
+
);
|
|
108
|
+
} finally {
|
|
109
|
+
stall.dispose();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const roundUsage = result.usage ?? null;
|
|
113
|
+
if (roundUsage) {
|
|
114
|
+
usageTotal = {
|
|
115
|
+
prompt_tokens: (usageTotal?.prompt_tokens ?? 0) + roundUsage.prompt_tokens,
|
|
116
|
+
completion_tokens: (usageTotal?.completion_tokens ?? 0) + roundUsage.completion_tokens,
|
|
117
|
+
total_tokens: (usageTotal?.total_tokens ?? 0) + roundUsage.total_tokens,
|
|
118
|
+
// last round = current context footprint (for context meters)
|
|
119
|
+
last_prompt_tokens: roundUsage.prompt_tokens,
|
|
120
|
+
last_completion_tokens: roundUsage.completion_tokens,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const tokens = roundUsage
|
|
124
|
+
? { tokens_in: roundUsage.prompt_tokens, tokens_out: roundUsage.completion_tokens }
|
|
125
|
+
: {};
|
|
126
|
+
|
|
127
|
+
const calls = result.toolCalls ?? [];
|
|
128
|
+
if (calls.length === 0) {
|
|
129
|
+
working.push({ role: 'assistant', content: result.content ?? '', ...tokens });
|
|
130
|
+
return {
|
|
131
|
+
content: result.content,
|
|
132
|
+
reasoning: result.reasoning ?? '',
|
|
133
|
+
toolCalls: [],
|
|
134
|
+
messages: working,
|
|
135
|
+
rounds: round,
|
|
136
|
+
stopReason: 'end_turn',
|
|
137
|
+
usage: usageTotal,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
working.push({ role: 'assistant', content: result.content || null, tool_calls: calls, ...tokens });
|
|
142
|
+
onEvent?.({ type: 'tool_round', round, toolCalls: calls, content: result.content });
|
|
143
|
+
|
|
144
|
+
for (let index = 0; index < calls.length; index++) {
|
|
145
|
+
const call = calls[index];
|
|
146
|
+
const name = call.function?.name ?? call.name;
|
|
147
|
+
|
|
148
|
+
// Cancelacion entre tools: rellena los tool_calls pendientes para que el
|
|
149
|
+
// historial quede consistente y corta.
|
|
150
|
+
if (signal?.aborted) {
|
|
151
|
+
for (let rest = index; rest < calls.length; rest++) {
|
|
152
|
+
const pending = calls[rest];
|
|
153
|
+
working.push({
|
|
154
|
+
role: 'tool',
|
|
155
|
+
tool_call_id: pending.id,
|
|
156
|
+
name: pending.function?.name ?? pending.name,
|
|
157
|
+
content: 'Cancelado por el usuario.',
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
content: result.content ?? '',
|
|
162
|
+
reasoning: result.reasoning ?? '',
|
|
163
|
+
toolCalls: calls,
|
|
164
|
+
messages: working,
|
|
165
|
+
rounds: round,
|
|
166
|
+
stopReason: 'aborted',
|
|
167
|
+
usage: usageTotal,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const args = parseToolArguments(call);
|
|
172
|
+
onEvent?.({ type: 'tool_call', id: call.id, name, args });
|
|
173
|
+
|
|
174
|
+
let output;
|
|
175
|
+
try {
|
|
176
|
+
output = await execute(name, args, toolContext);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
output = `Error ejecutando ${name}: ${err?.message ?? err}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
onEvent?.({ type: 'tool_result', id: call.id, name, result: output });
|
|
182
|
+
working.push({
|
|
183
|
+
role: 'tool',
|
|
184
|
+
tool_call_id: call.id,
|
|
185
|
+
name,
|
|
186
|
+
content: typeof output === 'string' ? output : JSON.stringify(output),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
content: '',
|
|
193
|
+
reasoning: '',
|
|
194
|
+
toolCalls: [],
|
|
195
|
+
messages: working,
|
|
196
|
+
rounds: maxRounds,
|
|
197
|
+
stopReason: 'max_rounds',
|
|
198
|
+
usage: usageTotal,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Emit a single warning event when the model emits nothing for `stallWarnMs`
|
|
204
|
+
* during one stream (openbridge's stall watchdog port). Any provider event
|
|
205
|
+
* resets the window; the guard is scoped to a single streamChat call so long
|
|
206
|
+
* tool executions do not trigger false alarms.
|
|
207
|
+
*/
|
|
208
|
+
function createStallGuard(onEvent, stallWarnMs) {
|
|
209
|
+
if (!onEvent || !(stallWarnMs > 0)) return { onEvent: onEvent ?? (() => {}), dispose() {} };
|
|
210
|
+
|
|
211
|
+
let lastActivity = Date.now();
|
|
212
|
+
let warned = false;
|
|
213
|
+
const timer = setInterval(() => {
|
|
214
|
+
if (warned || Date.now() - lastActivity < stallWarnMs) return;
|
|
215
|
+
warned = true;
|
|
216
|
+
onEvent({ type: 'stall', seconds: Math.max(1, Math.round(stallWarnMs / 1000)) });
|
|
217
|
+
}, Math.min(stallWarnMs, 15000));
|
|
218
|
+
timer.unref?.();
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
onEvent(event) {
|
|
222
|
+
lastActivity = Date.now();
|
|
223
|
+
onEvent(event);
|
|
224
|
+
},
|
|
225
|
+
dispose() {
|
|
226
|
+
clearInterval(timer);
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function defaultExecute(name) {
|
|
232
|
+
return `Error: la herramienta "${name}" no está disponible todavía (se agrega en la Fase 1).`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Parse tool-call arguments, which may arrive as an object (Ollama) or a
|
|
237
|
+
* (possibly malformed) JSON string (OpenAI-compatible streams).
|
|
238
|
+
*/
|
|
239
|
+
export function parseToolArguments(call) {
|
|
240
|
+
const raw = call.function?.arguments ?? call.arguments;
|
|
241
|
+
if (raw && typeof raw === 'object') return raw;
|
|
242
|
+
if (typeof raw !== 'string') return {};
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
return JSON.parse(raw);
|
|
246
|
+
} catch {
|
|
247
|
+
try {
|
|
248
|
+
const fixed = raw
|
|
249
|
+
// eslint-disable-next-line no-control-regex -- limpia caracteres de control del stream
|
|
250
|
+
.replace(/[\x00-\x1F\x7F]/g, '')
|
|
251
|
+
.replace(/,\s*([}\]])/g, '$1');
|
|
252
|
+
return JSON.parse(fixed);
|
|
253
|
+
} catch {
|
|
254
|
+
return {};
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|