agent-termo 1.0.1 → 1.0.2

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 (65) hide show
  1. package/README.md +27 -2
  2. package/dist/agents/index.d.ts +5 -0
  3. package/dist/agents/index.d.ts.map +1 -0
  4. package/dist/agents/index.js +86 -0
  5. package/dist/agents/index.js.map +1 -0
  6. package/dist/cli.d.ts +3 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +97 -168
  9. package/dist/cli.js.map +4 -4
  10. package/dist/diff/index.d.ts +9 -0
  11. package/dist/diff/index.d.ts.map +1 -0
  12. package/dist/diff/index.js +213 -0
  13. package/dist/diff/index.js.map +1 -0
  14. package/dist/llm/client.d.ts +39 -0
  15. package/dist/llm/client.d.ts.map +1 -0
  16. package/dist/llm/client.js +188 -0
  17. package/dist/llm/client.js.map +1 -0
  18. package/dist/llm/models.d.ts +19 -0
  19. package/dist/llm/models.d.ts.map +1 -0
  20. package/dist/llm/models.js +45 -0
  21. package/dist/llm/models.js.map +1 -0
  22. package/dist/llm/tools.d.ts +3 -0
  23. package/dist/llm/tools.d.ts.map +1 -0
  24. package/dist/llm/tools.js +204 -0
  25. package/dist/llm/tools.js.map +1 -0
  26. package/dist/main.d.ts +2 -0
  27. package/dist/main.d.ts.map +1 -0
  28. package/dist/main.js +91 -140
  29. package/dist/main.js.map +4 -4
  30. package/dist/memory/index.d.ts +10 -0
  31. package/dist/memory/index.d.ts.map +1 -0
  32. package/dist/memory/index.js +117 -0
  33. package/dist/memory/index.js.map +1 -0
  34. package/dist/tools/index.d.ts +5 -0
  35. package/dist/tools/index.d.ts.map +1 -0
  36. package/dist/tools/index.js +334 -0
  37. package/dist/tools/index.js.map +1 -0
  38. package/dist/types/index.d.ts +119 -0
  39. package/dist/types/index.d.ts.map +1 -0
  40. package/dist/types/index.js +5 -0
  41. package/dist/types/index.js.map +1 -0
  42. package/dist/ui/index.d.ts +47 -0
  43. package/dist/ui/index.d.ts.map +1 -0
  44. package/dist/ui/index.js +321 -0
  45. package/dist/ui/index.js.map +1 -0
  46. package/dist/ui/screen.d.ts +59 -0
  47. package/dist/ui/screen.d.ts.map +1 -0
  48. package/dist/ui/screen.js +629 -0
  49. package/dist/ui/screen.js.map +1 -0
  50. package/dist/utils/ansi.d.ts +62 -0
  51. package/dist/utils/ansi.d.ts.map +1 -0
  52. package/dist/utils/ansi.js +116 -0
  53. package/dist/utils/ansi.js.map +1 -0
  54. package/dist/utils/config.d.ts +9 -0
  55. package/dist/utils/config.d.ts.map +1 -0
  56. package/dist/utils/config.js +120 -0
  57. package/dist/utils/config.js.map +1 -0
  58. package/package.json +1 -1
  59. package/src/cli.ts +1 -1
  60. package/src/llm/client.ts +24 -0
  61. package/src/llm/models.ts +55 -0
  62. package/src/main.ts +201 -75
  63. package/src/ui/index.ts +256 -389
  64. package/src/ui/screen.ts +698 -0
  65. package/src/utils/config.ts +37 -20
package/src/main.ts CHANGED
@@ -8,8 +8,9 @@ import { executeTool, applyFileChange } from './tools/index.js';
8
8
  import { loadMemory, logToTimeline, logAgentTermoTimeline } from './memory/index.js';
9
9
  import { loadAllAgents } from './agents/index.js';
10
10
  import { buildSystemPrompt } from './agents/index.js';
11
- import { resolveProjectRoot } from './utils/config.js';
11
+ import { resolveProjectRoot, saveNimModel } from './utils/config.js';
12
12
  import {
13
+ MarkdownPrinter,
13
14
  printWelcome,
14
15
  printUserMessage,
15
16
  printAssistantMessage,
@@ -26,7 +27,9 @@ import {
26
27
  stopSpinner,
27
28
  clearScreen,
28
29
  createInput,
29
- moveToFreshLine,
30
+ initScreen,
31
+ pick,
32
+ setScreenStatus,
30
33
  promptAcceptReject,
31
34
  } from './ui/index.js';
32
35
  import type { ConversationMessage, ToolCall, ProposedFileChange } from './types/index.js';
@@ -53,6 +56,85 @@ function dedup(arr: ToolCall[]): ToolCall[] {
53
56
  });
54
57
  }
55
58
 
59
+ /**
60
+ * Get a plain-text assistant response without tool calling: stream first, fall
61
+ * back to a single non-streaming completion. Appends the assistant message to
62
+ * the conversation and prints it. Never throws: if both the stream and the
63
+ * retry fail, an LLM error is printed and no message is appended.
64
+ */
65
+ async function replyWithText(
66
+ conversation: ConversationMessage[],
67
+ nimClient: NimClient,
68
+ ): Promise<void> {
69
+ startSpinner('Thinking');
70
+ const md = new MarkdownPrinter(() => stopSpinner());
71
+ try {
72
+ const gen = nimClient.streamComplete(conversation, undefined, (text: string) => md.push(text));
73
+ // The generator streams everything, then yields once at the end; the
74
+ // chunks have already been rendered through the callback.
75
+ await gen.next();
76
+ md.end();
77
+ stopSpinner();
78
+ return;
79
+ } catch {
80
+ md.end();
81
+ stopSpinner();
82
+ }
83
+
84
+ startSpinner('Retrying');
85
+ try {
86
+ const result = await nimClient.complete(conversation, undefined);
87
+ stopSpinner();
88
+ const cleanText = (result.content ?? '').trim();
89
+ if (cleanText) {
90
+ conversation.push({
91
+ id: crypto.randomUUID(),
92
+ role: 'assistant',
93
+ content: cleanText,
94
+ timestamp: new Date(),
95
+ });
96
+ printAssistantMessage(cleanText);
97
+ }
98
+ } catch (err) {
99
+ stopSpinner();
100
+ printError('LLM error: ' + (err instanceof Error ? err.message : String(err)));
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Parse JSON tool calls that a non-tool-calling model may emit as plain text,
106
+ * e.g. {"tool_calls":[{"id":"call_1","name":"file:write","arguments":{...}}]}.
107
+ * Returns an empty array when nothing parseable is found.
108
+ */
109
+ function parseToolCallsFromText(text: string): ToolCall[] {
110
+ let cleaned = (text ?? '').trim();
111
+ cleaned = cleaned.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/i, '').trim();
112
+
113
+ let obj: { tool_calls?: unknown };
114
+ try {
115
+ obj = JSON.parse(cleaned);
116
+ } catch {
117
+ const start = cleaned.indexOf('{');
118
+ const end = cleaned.lastIndexOf('}');
119
+ if (start === -1 || end === -1 || end <= start) return [];
120
+ try {
121
+ obj = JSON.parse(cleaned.slice(start, end + 1));
122
+ } catch {
123
+ return [];
124
+ }
125
+ }
126
+
127
+ const rawCalls = Array.isArray(obj.tool_calls) ? obj.tool_calls : [];
128
+ return rawCalls.map((tc: { id?: string; name?: string; function?: { name?: string; arguments?: unknown } }) => ({
129
+ id: tc.id ?? crypto.randomUUID(),
130
+ name: tc.name ?? (tc.function?.name ?? ''),
131
+ arguments:
132
+ typeof tc.function?.arguments === 'string'
133
+ ? (() => { try { return JSON.parse(tc.function.arguments as string); } catch { return { _raw: tc.function.arguments }; } })()
134
+ : (tc.function?.arguments ?? {}),
135
+ }));
136
+ }
137
+
56
138
  function extractProposedChange(toolName: string, resultOutput: string): ProposedFileChange | undefined {
57
139
  const isFileTool = toolName === 'file:write' || toolName === 'file:edit';
58
140
  if (!isFileTool) return undefined;
@@ -116,7 +198,6 @@ async function executeToolWithReview(
116
198
  projectRoot: string,
117
199
  input: InputController,
118
200
  ): Promise<{ toolResult: { success: boolean; output?: string; error?: string }; change?: ProposedFileChange; applied: boolean }> {
119
- console.log('');
120
201
  printToolCall(toolName, args);
121
202
  startSpinner('Running ' + toolName);
122
203
  const toolResult = await executeTool(toolName, args, projectRoot);
@@ -197,36 +278,94 @@ async function runToolLoop(
197
278
 
198
279
  // Send conversation (including tool results) back to the LLM
199
280
  // The LLM will either produce text or more tool calls
200
- moveToFreshLine();
201
- startSpinner('Working...');
281
+ startSpinner('Working');
282
+ let result: { content: string; toolCalls: ToolCall[] };
202
283
  try {
203
284
  const tools = nimClient.buildToolDefs(TOOL_DEFINITIONS);
204
- const result = await nimClient.complete(conversation, tools);
205
- finalText = result.content;
206
- toolCalls = dedup(result.toolCalls);
207
- process.stdout.write('\n');
208
- stopSpinner(true);
209
-
210
- // If the LLM produced text alongside tool calls, save it
211
- if (finalText && finalText.trim() && finalText.trim() !== 'null') {
212
- conversation.push({
213
- id: crypto.randomUUID(),
214
- role: 'assistant',
215
- content: finalText.trim(),
216
- timestamp: new Date(),
217
- });
285
+ result = await nimClient.complete(conversation, tools);
286
+ } catch {
287
+ // Model doesn't support native tool calling — fall back to plain text
288
+ // and parse any JSON tool calls emitted in the content.
289
+ try {
290
+ const fallback = await nimClient.complete(conversation, undefined);
291
+ result = {
292
+ content: fallback.content,
293
+ toolCalls: parseToolCallsFromText(fallback.content),
294
+ };
295
+ } catch (err2) {
296
+ stopSpinner();
297
+ printError('LLM error: ' + (err2 instanceof Error ? err2.message : String(err2)));
298
+ break;
218
299
  }
219
- } catch (err) {
220
- process.stdout.write('\n');
221
- stopSpinner(false);
222
- printError('LLM error: ' + (err instanceof Error ? err.message : String(err)));
223
- break;
300
+ }
301
+ stopSpinner();
302
+
303
+ finalText = result.content;
304
+ toolCalls = dedup(result.toolCalls);
305
+
306
+ // If the LLM produced text alongside tool calls, save it
307
+ if (finalText && finalText.trim() && finalText.trim() !== 'null') {
308
+ conversation.push({
309
+ id: crypto.randomUUID(),
310
+ role: 'assistant',
311
+ content: finalText.trim(),
312
+ timestamp: new Date(),
313
+ });
314
+ // Narration that comes with more tool calls is shown now; the
315
+ // final answer (no tool calls) is printed by the caller.
316
+ if (toolCalls.length > 0) printAssistantMessage(finalText.trim());
224
317
  }
225
318
  }
226
319
 
227
320
  return finalText;
228
321
  }
229
322
 
323
+ /**
324
+ * /model — pick from the live NVIDIA NIM catalogue (falls back to a built-in list)
325
+ * /model <model-id> — switch directly, e.g. /model meta/llama-3.3-70b-instruct
326
+ */
327
+ async function handleModelCommand(arg: string, nimClient: NimClient, projectRoot: string): Promise<void> {
328
+ let selected: string | null = arg || null;
329
+
330
+ if (!selected) {
331
+ startSpinner('Fetching NVIDIA NIM models');
332
+ const { models, live, error } = await nimClient.listModels();
333
+ stopSpinner();
334
+ if (!live) {
335
+ printInfo('Could not fetch the live model list (' + (error ?? 'unknown error') + '). Showing built-in suggestions; you can type any model ID.');
336
+ }
337
+
338
+ selected = await pick({
339
+ title: 'Select a model',
340
+ items: models.map((m) => ({ value: m.id, label: m.id, hint: m.note })),
341
+ current: nimClient.getModel(),
342
+ allowCustom: true,
343
+ });
344
+ if (!selected) {
345
+ printInfo('Model unchanged: ' + nimClient.getModel());
346
+ return;
347
+ }
348
+ }
349
+
350
+ if (selected === nimClient.getModel()) {
351
+ printInfo('Already using ' + selected);
352
+ return;
353
+ }
354
+
355
+ nimClient.setModel(selected);
356
+ setScreenStatus({ model: selected });
357
+ logAgentTermoTimeline('Model switched to ' + selected);
358
+ logToTimeline(projectRoot, 'Model switched to ' + selected);
359
+
360
+ try {
361
+ saveNimModel(selected);
362
+ printSuccess('Model set to ' + selected + ' (saved as your default)');
363
+ } catch {
364
+ printSuccess('Model set to ' + selected + ' for this session');
365
+ printInfo('Could not save it as your default.');
366
+ }
367
+ }
368
+
230
369
  export async function runRepl(projectRoot?: string): Promise<void> {
231
370
  projectRoot = resolveProjectRoot(projectRoot);
232
371
 
@@ -245,8 +384,8 @@ export async function runRepl(projectRoot?: string): Promise<void> {
245
384
  },
246
385
  ];
247
386
 
248
- printWelcome(projectRoot);
249
- console.log('Model: ' + nimClient.getModel());
387
+ initScreen({ model: nimClient.getModel(), project: projectRoot });
388
+ printWelcome(projectRoot, nimClient.getModel());
250
389
  logAgentTermoTimeline('Agent-Termo session started. Model: ' + nimClient.getModel());
251
390
  logToTimeline(projectRoot, 'Agent-Termo session started. Model: ' + nimClient.getModel());
252
391
  logAgentTermoTimeline('Commands: /help /clear /memory /timeline /model /agents /history');
@@ -277,6 +416,7 @@ export async function runRepl(projectRoot?: string): Promise<void> {
277
416
 
278
417
  if (userInput.startsWith('/')) {
279
418
  const cmd = userInput.toLowerCase().trim();
419
+ printUserMessage(userInput);
280
420
 
281
421
  if (cmd === '/help' || cmd === '/') {
282
422
  printHelp();
@@ -285,7 +425,7 @@ export async function runRepl(projectRoot?: string): Promise<void> {
285
425
  if (cmd === '/clear' || cmd === '/cls') {
286
426
  conversation.length = 1;
287
427
  clearScreen();
288
- printWelcome(projectRoot);
428
+ printWelcome(projectRoot, nimClient.getModel());
289
429
  printSuccess('Conversation cleared.');
290
430
  continue mainLoop;
291
431
  }
@@ -297,8 +437,9 @@ export async function runRepl(projectRoot?: string): Promise<void> {
297
437
  await printTimelineFile();
298
438
  continue mainLoop;
299
439
  }
300
- if (cmd === '/model') {
301
- printInfo('Current model: ' + nimClient.getModel());
440
+ if (cmd === '/model' || cmd.startsWith('/model ')) {
441
+ // Take the argument from the original text: model IDs are case-sensitive.
442
+ await handleModelCommand(userInput.trim().slice('/model'.length).trim(), nimClient, projectRoot);
302
443
  continue mainLoop;
303
444
  }
304
445
  if (cmd === '/agents') {
@@ -315,10 +456,7 @@ export async function runRepl(projectRoot?: string): Promise<void> {
315
456
  break;
316
457
  }
317
458
 
318
- console.log('');
319
- printHelp();
320
- console.log('Unknown command: ' + cmd);
321
- console.log('Type /help for available commands.\n');
459
+ printError('Unknown command: ' + cmd, 'Type /help for available commands.');
322
460
  continue mainLoop;
323
461
  }
324
462
 
@@ -329,20 +467,18 @@ export async function runRepl(projectRoot?: string): Promise<void> {
329
467
  timestamp: new Date(),
330
468
  };
331
469
  conversation.push(userMsg);
332
- printUserMessage(userMsg);
333
-
334
- moveToFreshLine();
470
+ printUserMessage(userInput);
335
471
 
336
472
  const useTools = mightNeedTools(userInput);
337
473
  const tools = useTools ? nimClient.buildToolDefs(TOOL_DEFINITIONS) : undefined;
338
474
 
339
475
  if (useTools) {
340
- startSpinner('Working...');
476
+ startSpinner('Working');
341
477
  try {
342
478
  const result = await nimClient.complete(conversation, tools);
343
479
  const fullText = result.content;
344
480
  let toolCalls = dedup(result.toolCalls);
345
- stopSpinner(true);
481
+ stopSpinner();
346
482
 
347
483
  if (toolCalls.length === 0) {
348
484
  // No tool calls — just display the text response
@@ -354,8 +490,13 @@ export async function runRepl(projectRoot?: string): Promise<void> {
354
490
  content: cleanText,
355
491
  timestamp: new Date(),
356
492
  });
493
+ printAssistantMessage(cleanText);
357
494
  }
358
495
  } else {
496
+ // Show any narration that came with the first tool calls
497
+ const narration = (fullText ?? '').trim().replace(/^null$/, '').trim();
498
+ if (narration) printAssistantMessage(narration);
499
+
359
500
  // Run the tool-call loop: execute tools, feed results back, repeat
360
501
  const finalText = await runToolLoop(conversation, projectRoot, toolCalls, input, nimClient);
361
502
  if (finalText && finalText.trim() && finalText.trim() !== 'null') {
@@ -363,41 +504,16 @@ export async function runRepl(projectRoot?: string): Promise<void> {
363
504
  }
364
505
  }
365
506
  } catch (err) {
366
- process.stdout.write('\n');
367
- stopSpinner(false);
368
- printError('LLM error: ' + (err instanceof Error ? err.message : String(err)));
369
- conversation.pop();
370
- continue mainLoop;
371
- }
372
- } else {
373
- // No tools — stream the response
374
- startSpinner('Thinking...');
375
- try {
376
- const gen = nimClient.streamComplete(conversation, undefined, (text: string) => {
377
- process.stdout.write(text);
378
- });
379
- let receivedContent = false;
380
- for await (const result of gen) {
381
- if (result.content && result.content.trim()) {
382
- receivedContent = true;
383
- }
384
- }
385
- if (receivedContent) {
386
- process.stdout.write('\n');
387
- stopSpinner(true);
388
- } else {
389
- process.stdout.write('\n');
390
- stopSpinner(undefined);
391
- }
392
- } catch {
393
- process.stdout.write('\n');
394
- stopSpinner(false);
395
- startSpinner('Retrying...');
507
+ stopSpinner();
508
+ // Tool calling failed (e.g. HTTP 400 because the model does not
509
+ // support the `tools` parameter). Retry without tools and parse
510
+ // any JSON tool calls the model may have emitted as plain text.
511
+ printInfo('Tool calls not supported by this model — retrying without tools.');
396
512
  try {
397
- const result = await nimClient.complete(conversation, undefined);
398
- process.stdout.write('\n');
399
- stopSpinner(true);
400
- const cleanText = (result.content ?? '').trim();
513
+ const fallback = await nimClient.complete(conversation, undefined);
514
+ const text = (fallback.content ?? '').trim();
515
+ // Strip any leading/trailing "null" reasoning noise
516
+ const cleanText = text.replace(/^null$/, '').trim();
401
517
  if (cleanText) {
402
518
  conversation.push({
403
519
  id: crypto.randomUUID(),
@@ -406,21 +522,31 @@ export async function runRepl(projectRoot?: string): Promise<void> {
406
522
  timestamp: new Date(),
407
523
  });
408
524
  }
525
+ const narration = cleanText.replace(/^null$/, '').trim();
526
+ if (narration) printAssistantMessage(narration);
527
+
528
+ const toolCalls = dedup(parseToolCallsFromText(fallback.content));
529
+ if (toolCalls.length > 0) {
530
+ const finalText = await runToolLoop(conversation, projectRoot, toolCalls, input, nimClient);
531
+ if (finalText && finalText.trim() && finalText.trim() !== 'null') {
532
+ printAssistantMessage(finalText.trim());
533
+ }
534
+ }
409
535
  } catch (err2) {
410
- process.stdout.write('\n');
411
- stopSpinner(false);
412
536
  printError('LLM error: ' + (err2 instanceof Error ? err2.message : String(err2)));
413
537
  conversation.pop();
414
538
  continue mainLoop;
415
539
  }
416
540
  }
541
+ } else {
542
+ await replyWithText(conversation, nimClient);
417
543
  }
418
544
 
419
545
  if (conversation.length > 42) {
420
546
  conversation.splice(1, conversation.length - 42);
421
547
  }
422
548
  } catch (err) {
423
- stopSpinner(false);
549
+ stopSpinner();
424
550
  printError('Session error: ' + (err instanceof Error ? err.message : String(err)));
425
551
  }
426
552
  }