ai-runtime-engine 1.1.1 → 1.2.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 CHANGED
@@ -5,6 +5,31 @@ All notable changes to `ai-runtime` are documented here. The format follows
5
5
  Versioning](https://semver.org/). Development history and rationale live in
6
6
  [docs/DECISIONS.md](docs/DECISIONS.md) and [docs/PROGRESS.md](docs/PROGRESS.md).
7
7
 
8
+ ## [1.2.0] — 2026-08-31
9
+
10
+ Closes the known gaps between what the CLI/REPL exposed and what the Runtime supported. Additive and
11
+ backward compatible, with three small behavior corrections noted under Changed.
12
+
13
+ ### Added
14
+
15
+ - **Approve / deny from the terminal** — `resume-execution --approve` / `--deny`, and REPL `/approve <id>`
16
+ / `/deny <id>`. A plan that stops at `waiting_for_approval` can now be advanced or cancelled from the
17
+ CLI or interactive terminal (previously reachable only programmatically or via a host `ApprovalProvider`).
18
+ - **Live progress in the interactive terminal** — the REPL now prints concise mode/routing/fallback lines
19
+ during a run by subscribing to lifecycle events (it always claimed to, but never did).
20
+
21
+ ### Changed
22
+
23
+ - **`chat` mode now honors `--dry-run` / `dryRun`** — a chat dry run makes **no** model call and captures
24
+ **no** memory; it reports what it would send. (Orchestration modes already did; chat used to run live.)
25
+ - **`ai-runtime config`** now resolves `.ai-runtime/config.yaml` through the runtime loader, matching
26
+ every other command. It previously used the legacy root-only loader and reported "no config file found"
27
+ even when the canonical file was in use.
28
+ - **`ai-runtime init`** now scaffolds the canonical `.ai-runtime/config.yaml` (same location as `setup`)
29
+ instead of a root `ai-runtime.yaml`. A root `ai-runtime.yaml` still works as a fallback if present.
30
+ - **`ai-runtime telemetry`** empty-state message now explains that telemetry is buffered in-process and
31
+ points to `telemetry: { sink: file }` for history that survives across CLI invocations.
32
+
8
33
  ## [1.1.1] — 2026-08-31
9
34
 
10
35
  Documentation only — no code changes.
@@ -82,6 +107,8 @@ Initial release: the provider-agnostic AI **router** — capability-based routin
82
107
  scoring, evidence validation, fallback, health tracking, learning-based scoring, multi-model verification,
83
108
  budgets, MCP tools, OpenAPI-based adapter generation, and the `AI` class + CLI.
84
109
 
110
+ [1.2.0]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v1.2.0
111
+ [1.1.1]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v1.1.1
85
112
  [1.1.0]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v1.1.0
86
113
  [1.0.0]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v1.0.0
87
114
  [0.1.0]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v0.1.0
package/dist/cli/cli.js CHANGED
@@ -20,7 +20,7 @@ import { skillsCommand } from './commands/skills.js';
20
20
  import { startRepl } from './interactive/repl.js';
21
21
  import { printError } from './render.js';
22
22
  const program = new Command();
23
- program.name('ai-runtime').description('Universal, provider-agnostic AI Runtime & Orchestration Platform').version('1.1.1');
23
+ program.name('ai-runtime').description('Universal, provider-agnostic AI Runtime & Orchestration Platform').version('1.2.0');
24
24
  const configOpt = ['-c, --config <path>', 'path to an ai-runtime config file'];
25
25
  // Bare `ai-runtime` (no subcommand) opens the interactive terminal. `allowExcessArguments(false)` keeps
26
26
  // a mistyped subcommand (e.g. `ai-runtime porviders`) failing fast instead of silently opening the REPL.
@@ -48,6 +48,8 @@ program
48
48
  .description('Resume a persisted execution (reconciles the workspace first)')
49
49
  .option(...configOpt)
50
50
  .option('--answer <text>', 'answer a pending clarification')
51
+ .option('--approve', 'approve a plan that is waiting for approval, then continue')
52
+ .option('--deny', 'deny a plan waiting for approval (cancels the execution)')
51
53
  .action((id, o) => resumeExecutionCommand(id, o));
52
54
  program.command('init').description('Scaffold ai-runtime.yaml, .env.example, and .gitignore entries').action(() => initCommand());
53
55
  program
@@ -3,16 +3,18 @@
3
3
  * defaults). Shows weights, strategy, policy posture, and timeouts. No secrets are read or printed.
4
4
  */
5
5
  import { resolveConfig } from '../../config/defaults.js';
6
- import { loadConfig, findConfigFile } from '../../config/load.js';
6
+ import { loadRuntimeConfig } from '../../runtime/config.js';
7
7
  import { print } from '../render.js';
8
8
  export function configCommand(options) {
9
- const file = options.config ?? findConfigFile();
10
- const resolved = resolveConfig(loadConfig(options.config));
9
+ // Use the runtime loader so `.ai-runtime/config.yaml` is honored (root `ai-runtime.yaml` still works
10
+ // as a fallback) — matching every other command, instead of the legacy root-only loader.
11
+ const loaded = loadRuntimeConfig({ workspaceRoot: process.cwd(), ...(options.config ? { explicitPath: options.config } : {}) });
12
+ const resolved = resolveConfig(loaded.config.router);
11
13
  if (options.json) {
12
14
  print(JSON.stringify(resolved, null, 2));
13
15
  return;
14
16
  }
15
- print(file ? `Resolved config (${file}):` : 'Resolved defaults (no config file found):');
17
+ print(loaded.configFile ? `Resolved config (${loaded.configFile}):` : 'Resolved defaults (no config file found):');
16
18
  print(` providers: ${resolved.providers.length}`);
17
19
  print(` strategy: ${resolved.strategy}`);
18
20
  print(` timeoutMs: ${resolved.timeoutMs}`);
@@ -6,4 +6,6 @@ export declare function executionsCommand(options: {
6
6
  export declare function resumeExecutionCommand(id: string, options: {
7
7
  config?: string;
8
8
  answer?: string;
9
+ approve?: boolean;
10
+ deny?: boolean;
9
11
  }): Promise<void>;
@@ -14,10 +14,17 @@ export async function executionsCommand(options) {
14
14
  }
15
15
  export async function resumeExecutionCommand(id, options) {
16
16
  const rt = await Runtime.load({ ...(options.config ? { config: options.config } : {}) });
17
- const r = await rt.resumeExecution(id, { ...(options.answer ? { clarificationAnswer: options.answer } : {}) });
17
+ // --approve proceeds past a waiting-for-approval gate; --deny cancels it. Neither leave the decision.
18
+ const approve = options.approve ? true : options.deny ? false : undefined;
19
+ const r = await rt.resumeExecution(id, {
20
+ ...(options.answer ? { clarificationAnswer: options.answer } : {}),
21
+ ...(approve !== undefined ? { approve } : {}),
22
+ });
18
23
  if (r.response?.text)
19
24
  print(r.response.text);
20
25
  print(`status: ${r.status}`);
26
+ if (r.status === 'waiting_for_approval')
27
+ print('(waiting for approval — re-run with --approve to proceed or --deny to cancel)');
21
28
  if (r.clarification)
22
29
  print(`? ${r.clarification.question}`);
23
30
  if (!r.ok)
@@ -2,8 +2,8 @@
2
2
  * `ai-runtime init` — scaffold a config, an env template, and gitignore entries. Never writes secrets;
3
3
  * the config carries only env-var NAMES, and `.env` is added to .gitignore.
4
4
  */
5
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
6
- import { resolve } from 'node:path';
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { dirname, resolve } from 'node:path';
7
7
  import { print } from '../render.js';
8
8
  const EXAMPLE_YAML = `# ai-runtime configuration. Secret VALUES live in .env (gitignored); this file names env vars only.
9
9
  strategy: best
@@ -48,6 +48,7 @@ function writeIfAbsent(file, contents) {
48
48
  print(` • ${file} already exists — left unchanged`);
49
49
  return;
50
50
  }
51
+ mkdirSync(dirname(full), { recursive: true });
51
52
  writeFileSync(full, contents);
52
53
  print(` ✓ wrote ${file}`);
53
54
  }
@@ -65,11 +66,13 @@ function ensureGitignore() {
65
66
  }
66
67
  export function initCommand() {
67
68
  print('Initializing ai-runtime...');
68
- writeIfAbsent('ai-runtime.yaml', EXAMPLE_YAML);
69
+ // Write the canonical `.ai-runtime/config.yaml` (the location the runtime loader prefers) so `init`
70
+ // and `setup` scaffold the same file. A root `ai-runtime.yaml` still works as a fallback if present.
71
+ writeIfAbsent('.ai-runtime/config.yaml', EXAMPLE_YAML);
69
72
  writeIfAbsent('.env.example', EXAMPLE_ENV);
70
73
  ensureGitignore();
71
74
  print('\nNext steps:');
72
75
  print(' 1. Copy .env.example to .env and fill in the keys you have');
73
76
  print(' 2. Run `ai-runtime doctor` to verify configuration');
74
- print(' 3. Run `ai-runtime route <task>` to route a task');
77
+ print(' 3. Run `ai-runtime` to open the interactive terminal (or `ai-runtime run "<task>"`)');
75
78
  }
@@ -65,8 +65,12 @@ export function telemetryCommand(opts) {
65
65
  const events = ai.telemetryEvents();
66
66
  const limit = opts.limit ? Number(opts.limit) : 20;
67
67
  const recent = events.slice(-limit);
68
- if (recent.length === 0)
69
- return print('No telemetry recorded in this session.');
68
+ if (recent.length === 0) {
69
+ print('No telemetry recorded in this session.');
70
+ print('(Telemetry is buffered in-process, so a fresh CLI invocation starts empty. For telemetry that');
71
+ print(' persists across processes, set `telemetry: { sink: file }` in your config.)');
72
+ return;
73
+ }
70
74
  for (const e of recent)
71
75
  print(JSON.stringify(e));
72
76
  }
@@ -15,10 +15,30 @@ function banner(rt) {
15
15
  print(`mode: auto · ${rt.ai.providers().length} providers · type /help or a request, /exit to quit`);
16
16
  print('');
17
17
  }
18
+ /** A concise, redacted progress line for a lifecycle event — the REPL's live feedback during a run. */
19
+ function progressLine(e) {
20
+ switch (e.type) {
21
+ case 'mode.selected':
22
+ return e.source === 'auto' ? ` · mode: ${e.selected} (auto, ${Math.round(e.confidence * 100)}%)` : ` · mode: ${e.selected}`;
23
+ case 'provider.selected':
24
+ return ` · routed → ${e.providerId}/${e.model}`;
25
+ case 'provider.failed':
26
+ return ` · ${e.providerId}/${e.model} failed${e.category ? ` (${e.category})` : ''} — falling back`;
27
+ default:
28
+ return undefined; // runtime.started / clarification.requested / run.completed are covered by the result block
29
+ }
30
+ }
18
31
  /** Start the interactive session. Resolves when the user exits (or stdin closes). */
19
32
  export async function startRepl(configPath) {
20
33
  const rt = await Runtime.load({ ...(configPath ? { config: configPath } : {}) });
21
34
  const session = new ReplSession(rt);
35
+ // Live progress: print concise routing/mode lines as they happen during a run. The events are already
36
+ // redacted; a throwing observer can never break a run (the emitter swallows sink errors).
37
+ rt.on((e) => {
38
+ const line = progressLine(e);
39
+ if (line)
40
+ print(line);
41
+ });
22
42
  banner(rt);
23
43
  const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: '> ' });
24
44
  rl.prompt();
@@ -36,6 +36,8 @@ const HELP = [
36
36
  ' /resume <id> resume a conversation',
37
37
  ' /executions list persisted executions',
38
38
  ' /resume-execution <id> resume an execution',
39
+ ' /approve <id> approve an execution waiting for approval, then continue',
40
+ ' /deny <id> deny an execution waiting for approval (cancels it)',
39
41
  ' /pause <id> pause an execution',
40
42
  ' /cancel <id> cancel an execution',
41
43
  ' /config show the resolved configuration',
@@ -141,6 +143,10 @@ export class ReplSession {
141
143
  return this.executionsList();
142
144
  case 'resume-execution':
143
145
  return this.resumeExecution(args[0]);
146
+ case 'approve':
147
+ return this.resumeExecution(args[0], true);
148
+ case 'deny':
149
+ return this.resumeExecution(args[0], false);
144
150
  case 'pause':
145
151
  return args[0] ? { lines: [this.runtime.pauseExecution(args[0]) ? `paused ${args[0]}` : `cannot pause '${args[0]}'`] } : { lines: ['usage: /pause <execution-id>'] };
146
152
  case 'cancel':
@@ -165,8 +171,10 @@ export class ReplSession {
165
171
  lines.push(`[${result.error.category}] ${result.error.message}`);
166
172
  if (result.mode.executed !== result.mode.selected)
167
173
  lines.push(`(mode: ${result.mode.selected} → ${result.mode.executed})`);
168
- if (result.status === 'waiting_for_approval')
169
- lines.push('(approval required before execution — resume the execution to approve)');
174
+ if (result.status === 'waiting_for_approval') {
175
+ const execId = result.execution?.id;
176
+ lines.push(execId ? `(approval required — /approve ${execId} to proceed, or /deny ${execId})` : '(approval required — approve the execution via /executions then /approve <id>)');
177
+ }
170
178
  if (result.status === 'failed')
171
179
  lines.push('(did not complete)');
172
180
  if (result.clarification)
@@ -249,14 +257,16 @@ export class ReplSession {
249
257
  return { lines: ['no executions yet.'] };
250
258
  return { lines: ['Executions:', ...list.map((e) => ` ${e.id} [${e.status}] ${e.mode}: ${e.goal.slice(0, 50)}`)] };
251
259
  }
252
- async resumeExecution(id) {
260
+ async resumeExecution(id, approve) {
253
261
  if (!id)
254
- return { lines: ['usage: /resume-execution <execution-id>'] };
255
- const r = await this.runtime.resumeExecution(id);
262
+ return { lines: [`usage: /${approve === undefined ? 'resume-execution' : approve ? 'approve' : 'deny'} <execution-id>`] };
263
+ const r = await this.runtime.resumeExecution(id, approve !== undefined ? { approve } : {});
256
264
  const lines = [];
257
265
  if (r.response?.text)
258
266
  lines.push(r.response.text);
259
267
  lines.push(`status: ${r.status}`);
268
+ if (r.status === 'waiting_for_approval')
269
+ lines.push('(waiting for approval — /approve ' + id + ' to proceed, /deny ' + id + ' to cancel)');
260
270
  if (r.clarification)
261
271
  lines.push(`? ${r.clarification.question}`);
262
272
  return { lines };
@@ -186,6 +186,10 @@ export declare class Runtime {
186
186
  private contextBlocks;
187
187
  /** Best-effort estimator calibration from provider-reported input usage (estimates never claim to be exact). */
188
188
  private calibrate;
189
- /** Capture an explicit "remember …" fact and retrieve relevant facts for context. Off when disabled. */
189
+ /**
190
+ * Capture an explicit "remember …" fact and retrieve relevant facts for context. Off when disabled.
191
+ * `capture` is false on a dry run — retrieval is read-only, but writing a fact is a mutation the dry
192
+ * run must not perform.
193
+ */
190
194
  private applyMemory;
191
195
  }
@@ -350,8 +350,9 @@ export class Runtime {
350
350
  const clarification = buildClarification(modeResult);
351
351
  if (clarification)
352
352
  this.emitter.emit({ type: 'clarification.requested', runId, question: clarification.question });
353
- // Memory: capture an explicit "remember …" and retrieve relevant facts into context.
354
- const memTrace = this.applyMemory(text);
353
+ // Memory: retrieve relevant facts into context, and (unless this is a dry run) capture an explicit
354
+ // "remember …". A dry run performs zero mutations, so it retrieves but never writes.
355
+ const memTrace = this.applyMemory(text, !policy.dryRun);
355
356
  // Compile the model context (workspace summary + memory facts + user system) under a token budget.
356
357
  const budget = resolveContextBudget({
357
358
  ...(req.context?.maxTokens !== undefined ? { perRun: req.context.maxTokens } : {}),
@@ -360,6 +361,19 @@ export class Runtime {
360
361
  });
361
362
  const compiled = compileContext(this.contextBlocks(req, context.workspace, memTrace?.retrieved), { budgetTokens: budget, estimator: this.estimator });
362
363
  const contextReport = { metrics: compiled.metrics, validation: compiled.validation };
364
+ // Dry-run: chat's only "action" is the model call itself, so a dry run makes NO call. It reports
365
+ // what it would send (mode, compiled-context size) and performs zero mutations.
366
+ if (policy.dryRun) {
367
+ const status = clarification ? 'waiting_for_clarification' : 'completed';
368
+ this.emitter.emit({ type: 'run.completed', runId, ok: true, status, confidence: 1 });
369
+ const preview = `[dry-run] chat — no model call made. Would compile ${compiled.metrics.compiledTokens} context token(s) (budget ${budget}) and send one request to the best-matching provider. Run without --dry-run to execute.`;
370
+ const result = { ok: true, runId, mode: resolution, status, response: { text: preview }, context: contextReport, artifacts: [] };
371
+ if (clarification)
372
+ result.clarification = clarification;
373
+ if (memTrace)
374
+ result.memory = memTrace;
375
+ return result;
376
+ }
363
377
  const runRequest = buildChatRequest(req, strategy, compiled.system || undefined, routing);
364
378
  const runResult = await this._ai.run(runRequest);
365
379
  this.calibrate(runRequest.system, text, runResult);
@@ -673,16 +687,22 @@ export class Runtime {
673
687
  if (tokens && tokens > 0)
674
688
  this.estimator.calibrate((system?.length ?? 0) + inputText.length, tokens);
675
689
  }
676
- /** Capture an explicit "remember …" fact and retrieve relevant facts for context. Off when disabled. */
677
- applyMemory(text) {
690
+ /**
691
+ * Capture an explicit "remember …" fact and retrieve relevant facts for context. Off when disabled.
692
+ * `capture` is false on a dry run — retrieval is read-only, but writing a fact is a mutation the dry
693
+ * run must not perform.
694
+ */
695
+ applyMemory(text, capture = true) {
678
696
  if (!this.memoryEnabled())
679
697
  return undefined;
680
698
  try {
681
699
  const trace = { retrieved: [] };
682
- const candidate = classifyMemory(text);
683
- if (candidate) {
684
- const rec = this._memory.remember(candidate);
685
- trace.captured = { id: rec.id, scope: rec.scope };
700
+ if (capture) {
701
+ const candidate = classifyMemory(text);
702
+ if (candidate) {
703
+ const rec = this._memory.remember(candidate);
704
+ trace.captured = { id: rec.id, scope: rec.scope };
705
+ }
686
706
  }
687
707
  trace.retrieved = this._memory.search(text, { limit: 3 }).map((h) => h.text);
688
708
  if (trace.retrieved.length === 0 && !trace.captured)
package/docs/GUIDE.md CHANGED
@@ -66,8 +66,9 @@ You can run the whole pipeline with a **mock provider** — no credentials, no n
66
66
  ```js
67
67
  import { Runtime, MockProvider, makeModel } from 'ai-runtime-engine';
68
68
 
69
- // A fresh runtime with no persistence (nothing written to disk).
70
- const runtime = new Runtime();
69
+ // A fresh runtime in stateless mode, so this demo writes nothing to disk.
70
+ // (Persistence is ON by default; pass { persistence: 'disabled' } to turn it off.)
71
+ const runtime = new Runtime(undefined, { persistence: 'disabled' });
71
72
 
72
73
  // Register one fake provider with one fake model that can "reason" and output "text".
73
74
  runtime.ai.registerProvider(new MockProvider({
package/docs/README.md CHANGED
@@ -13,8 +13,15 @@ with the `ai-runtime-engine` package:
13
13
  permissions, credentials, and the learning safety rail.
14
14
 
15
15
  Additional development records — the append-only decision log (`DECISIONS.md`), the phase-by-phase build
16
- log (`PROGRESS.md`), and the release checklist (`NPM_PUBLISHING.md`) live in the (private) source
17
- repository and are not part of the published package.
16
+ log (`PROGRESS.md`), the release checklist (`NPM_PUBLISHING.md`), and the complete project **handbook**
17
+ (`handbook/00-index.md` and its chapters) — live in the (private) source repository and are not part of
18
+ the published package. The handbook is the deepest reference: architecture, a file-by-file source tour,
19
+ the CLI/REPL, the npm story, testing, and maintenance.
20
+
21
+ > Note: this index file (`docs/README.md`) itself *does* end up in the npm tarball, because npm always
22
+ > includes any `README*` file regardless of the `files` allowlist. It only links to shipped docs, so that
23
+ > is harmless — but keep anything private out of `README*`-named files. (The handbook index is named
24
+ > `00-index.md`, not `README.md`, for exactly this reason.)
18
25
 
19
26
  Per-subsystem detail is documented at the source: each module under `src/**` opens with a doc comment
20
27
  describing its contract and invariants, and the README's [How to use it](../README.md#how-to-use-it)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-runtime-engine",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "AI Runtime — a provider-agnostic AI runtime and orchestration platform. Point it at whatever AI providers you have; it routes each task to the best available model. Ships the `ai-runtime` CLI and the `Runtime`/`AI` library API.",
5
5
  "type": "module",
6
6
  "license": "ISC",