noah-agent 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 (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/dist/agent/auth-gate.js +23 -0
  4. package/dist/agent/caveman.js +44 -0
  5. package/dist/agent/login.js +59 -0
  6. package/dist/cli.js +130 -0
  7. package/dist/llm/ollama.js +32 -0
  8. package/dist/llm/providers.js +38 -0
  9. package/dist/llm/registry.js +19 -0
  10. package/dist/llm/resolve.js +44 -0
  11. package/dist/modes/rpc.js +13 -0
  12. package/dist/platform/adapter.js +47 -0
  13. package/dist/platform/detect.js +18 -0
  14. package/dist/platform/linux.js +61 -0
  15. package/dist/platform/macos.js +51 -0
  16. package/dist/platform/types.js +5 -0
  17. package/dist/prompt/system.js +52 -0
  18. package/dist/runtime.js +124 -0
  19. package/dist/safety/audit.js +46 -0
  20. package/dist/safety/confirm.js +17 -0
  21. package/dist/safety/extension.js +65 -0
  22. package/dist/safety/policy.js +100 -0
  23. package/dist/sdk.js +32 -0
  24. package/dist/session.js +113 -0
  25. package/dist/sys/health.js +51 -0
  26. package/dist/sys/probe.js +128 -0
  27. package/dist/sys/report.js +55 -0
  28. package/dist/tools/logs.js +24 -0
  29. package/dist/tools/network.js +47 -0
  30. package/dist/tools/package.js +40 -0
  31. package/dist/tools/service.js +45 -0
  32. package/dist/tools/system.js +33 -0
  33. package/dist/tui/app.js +104 -0
  34. package/dist/tui/branding.js +14 -0
  35. package/dist/tui/components/audit-line.js +37 -0
  36. package/dist/tui/components/header.js +33 -0
  37. package/dist/tui/components/noah-footer.js +33 -0
  38. package/dist/tui/components/request-panel.js +23 -0
  39. package/dist/tui/components/response-view.js +17 -0
  40. package/dist/tui/components/safety-block.js +31 -0
  41. package/dist/tui/components/safety-review.js +36 -0
  42. package/dist/tui/components/thinking-view.js +22 -0
  43. package/dist/tui/components/tool-card.js +45 -0
  44. package/dist/tui/components/util.js +3 -0
  45. package/dist/tui/preview.js +33 -0
  46. package/dist/tui/space/app.js +566 -0
  47. package/dist/tui/space/components.js +261 -0
  48. package/dist/tui/space/dashboard.js +63 -0
  49. package/dist/tui/space/theme.js +39 -0
  50. package/dist/ui/ansi.js +93 -0
  51. package/dist/ui/badge.js +31 -0
  52. package/dist/ui/box.js +61 -0
  53. package/dist/ui/preview.js +37 -0
  54. package/dist/ui/render.js +140 -0
  55. package/package.json +68 -0
  56. package/themes/noah-dark-blue.json +85 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sriman (NOAH contributors)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,169 @@
1
+ # NOAH
2
+
3
+ ### An AI System Administrator for your terminal
4
+
5
+ > Tell your machine what you want in plain English. NOAH **reads the machine**,
6
+ > **analyzes the impact**, **recommends** the best action, and only then
7
+ > **executes — with your approval, and a full audit trail.**
8
+
9
+ NOAH is not another agent that blindly runs commands. It behaves like a senior
10
+ sysadmin: it inspects real telemetry (disk, memory, processes, services, logs)
11
+ *before* it acts, explains what will change, asks before anything dangerous, and
12
+ **hard-blocks** the catastrophic. Cross-platform — Linux and macOS.
13
+
14
+ ```
15
+ ███╗ ██╗ ██████╗ █████╗ ██╗ ██╗
16
+ ████╗ ██║ ██╔═══██╗ ██╔══██╗ ██║ ██║
17
+ ██╔██╗ ██║ ██║ ██║ ███████║ ███████║
18
+ ██║╚██╗██║ ██║ ██║ ██╔══██║ ██╔══██║
19
+ ██║ ╚████║ ╚██████╔╝ ██║ ██║ ██║ ██║
20
+ ╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝
21
+ A G E N T I C O P E R A T I N G S Y S T E M
22
+
23
+ ◆ SYSTEM macOS 14.5 · WARN
24
+ memory ██████████████░░░░ 82% (13.1 GB / 16.0 GB)
25
+ disk █████████████████░ 86% (70 GB free on /)
26
+ ◆ recommendations
27
+ ▸ Disk filling up / at 86%
28
+ ▸ 3 updates available Run a system update to stay current
29
+ ╭──────────────────────────────────────────────────────────╮
30
+ │ › how healthy is my machine? │
31
+ ╰──────────────────────────────────────────────────────────╯
32
+ ◆ claude-sonnet-4-5 · safety on / commands · enter send
33
+ ```
34
+
35
+ ---
36
+
37
+ ## Why NOAH
38
+
39
+ A coding copilot edits files in a repo. **NOAH operates the machine.** The
40
+ difference is trust and awareness:
41
+
42
+ | | Coding copilots | **NOAH** |
43
+ |---|---|---|
44
+ | Knows the machine | guesses | **reads live telemetry first** |
45
+ | Destructive ops | runs them | **blocklist + confirm + dry-run** |
46
+ | Accountability | none | **every action → `.noah/audit.jsonl`** |
47
+ | Cross-platform | shells out, guesses apt/brew | **one tool, auto-routed per OS** |
48
+ | Runs offline | rarely | **local Ollama, nothing leaves the box** |
49
+
50
+ **Examples**
51
+ - *"Install Docker"* → checks disk/memory/existing setup → recommends → installs → verifies.
52
+ - *"Why is my laptop slow?"* → root-cause from real top processes + memory pressure, with severity.
53
+ - *"Free up space"* → finds large files, stale caches → suggests **safe** cleanup → confirms.
54
+ - *"How healthy is my machine?"* → full report with prioritized actions.
55
+
56
+ ---
57
+
58
+ ## Install
59
+
60
+ ```bash
61
+ npm install -g noah-agent
62
+ noah # launch the interactive console
63
+ ```
64
+
65
+ Authenticate once (Claude Pro/Max or an API key) from inside NOAH:
66
+
67
+ ```
68
+ /login # pick Anthropic · GitHub Copilot · ChatGPT/Codex
69
+ ```
70
+
71
+ Or run a **local** model with [Ollama](https://ollama.com) (`ollama pull qwen2.5-coder`).
72
+
73
+ ---
74
+
75
+ ## Usage
76
+
77
+ ```bash
78
+ noah # interactive AI-SysAdmin console (TUI)
79
+ noah "install docker and verify" # send a task on startup
80
+ noah doctor # full machine health report (no LLM)
81
+ noah --dry-run "free up space" # preview; make no changes
82
+ noah --print "show big files" # single-shot, no TUI
83
+ noah --rpc # headless JSON-RPC (embed NOAH)
84
+ noah --list-models # available models (✓ = ready)
85
+ noah --check "rm -rf /" # see how the safety gate classifies a command
86
+ noah --log # print the audit trail
87
+ ```
88
+
89
+ **In-console commands:** `/doctor` · `/model` · `/login` · `/logout` ·
90
+ `/caveman` (token saver) · `/compact` · `/audit` · `/help` · `/clear` · `/quit`.
91
+
92
+ ---
93
+
94
+ ## Safety
95
+
96
+ The gate lives in the **agent pipeline, not inside any tool** — nothing runs
97
+ without passing through it.
98
+
99
+ | Verdict | Examples | Behaviour |
100
+ |---|---|---|
101
+ | `deny` | `rm -rf /`, fork bomb, `mkfs`, `dd of=/dev/disk`, `shutdown` | Hard-blocked, no override |
102
+ | `confirm` | installs, `sudo`, deletes, writes, service/network changes | Asks before running |
103
+ | `allow` | `ls`, `grep`, telemetry reads, redirects to `/dev/null` | Runs freely |
104
+
105
+ Dry-run neutralizes side effects. Every executed action is appended to
106
+ `.noah/audit.jsonl`.
107
+
108
+ ---
109
+
110
+ ## Embed it (SDK)
111
+
112
+ ```ts
113
+ import { createNoahSession } from "noah-agent/sdk";
114
+
115
+ const { session } = await createNoahSession({ dryRun: false, autoYes: false });
116
+ session.subscribe((e) => {
117
+ if (e.type === "message_update" && e.assistantMessageEvent.type === "text_delta")
118
+ process.stdout.write(e.assistantMessageEvent.delta);
119
+ });
120
+ await session.prompt("install htop and start it");
121
+ session.dispose();
122
+ ```
123
+
124
+ Also exported: `classify` (safety policy), `platform` (OS adapter),
125
+ `collectSnapshot`/`assessHealth` (telemetry), `buildNoahRuntime` (for RPC).
126
+
127
+ ---
128
+
129
+ ## How it works
130
+
131
+ ```
132
+ Your request ── reads telemetry (system · logs) ── analyzes impact
133
+
134
+
135
+ SAFETY GATE classify → deny / confirm / allow + audit trail
136
+
137
+
138
+ TOOLS bash · files · package · service · network · system · logs
139
+
140
+
141
+ PLATFORM ADAPTER Linux (apt/dnf/pacman/zypper · systemd) · macOS (brew · launchd)
142
+
143
+
144
+ Host OS
145
+ ```
146
+
147
+ Built on the [Pi](https://pi.dev) agent SDK (`@earendil-works/pi-coding-agent`)
148
+ for the loop, sessions, streaming, and multi-provider transport; NOAH adds the
149
+ OS tool layer, the telemetry/health engine, and the safety gate.
150
+
151
+ ---
152
+
153
+ ## Development
154
+
155
+ ```bash
156
+ git clone https://github.com/srimanh/NOAH
157
+ cd NOAH && npm install
158
+ npm run build
159
+ npm test # full suite
160
+ npm run dev -- "how healthy is my machine?"
161
+ ```
162
+
163
+ Requires Node ≥ 20.6.
164
+
165
+ ---
166
+
167
+ ## License
168
+
169
+ [MIT](./LICENSE) © Sriman
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Auth gate — make /login and /logout actually mean something.
3
+ *
4
+ * After /logout the running session still holds a model, so without a gate the
5
+ * agent would keep answering. This checks the live credential before each prompt
6
+ * so signing out blocks usage until /login restores it (matching pi's behaviour).
7
+ */
8
+ /** True if the provider currently has a stored credential (api key or oauth). */
9
+ export function isAuthed(provider, store) {
10
+ return store.get(provider) != null;
11
+ }
12
+ /** Decide whether a prompt may run for the current model. */
13
+ export function authGate(model, store) {
14
+ if (!model)
15
+ return { ok: false, reason: "No model selected. Use /model to choose one." };
16
+ if (!isAuthed(model.provider, store)) {
17
+ return {
18
+ ok: false,
19
+ reason: `Signed out of ${model.provider}. Run /login (subscription or API key) to reconnect.`,
20
+ };
21
+ }
22
+ return { ok: true };
23
+ }
@@ -0,0 +1,44 @@
1
+ export const CAVEMAN_LEVELS = ["lite", "full", "ultra", "micro"];
2
+ export function isCavemanLevel(s) {
3
+ return s === "off" || CAVEMAN_LEVELS.includes(s);
4
+ }
5
+ const BASE = `IMPORTANT: You are in CAVEMAN MODE. Respond terse like smart caveman. All technical substance stay. Only fluff die.
6
+
7
+ Rules:
8
+ - Drop articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries, hedging
9
+ - Fragments OK. Short synonyms preferred. Technical terms exact
10
+ - Code blocks unchanged. Errors quoted exact
11
+ - Pattern: [thing] [action] [reason]. [next step].`;
12
+ const SAFETY = `Auto-clarity: drop caveman for security warnings, irreversible action confirmations, or when the user is confused. Resume after.
13
+ Boundaries: only compress explanations, never code. "stop caveman" or "normal mode" reverts.`;
14
+ const INTENSITY = {
15
+ lite: `No filler/hedging. Keep articles + full sentences. Professional but tight.`,
16
+ full: `Drop articles, fragments OK, short synonyms.`,
17
+ ultra: `Abbreviate (DB/auth/config/req/res/fn/impl), strip conjunctions, arrows for causality (X → Y).`,
18
+ };
19
+ const MICRO = `# Token efficiency
20
+ Respond like smart caveman. Cut all filler, keep technical substance.
21
+ - Drop articles (a, an, the), filler (just, really, basically, actually).
22
+ - Drop pleasantries. No hedging. Fragments fine. Short synonyms.
23
+ - Technical terms exact. Code blocks unchanged.
24
+ - Pattern: [thing] [action] [reason]. [next step].`;
25
+ /** The system-prompt fragment for a level ("" when off). */
26
+ export function cavemanInstruction(level) {
27
+ if (level === "off")
28
+ return "";
29
+ if (level === "micro")
30
+ return MICRO;
31
+ return `${BASE}\n\n${INTENSITY[level]}\n\n${SAFETY}`;
32
+ }
33
+ /**
34
+ * Extension that appends the caveman rules to each run's system prompt.
35
+ * `getLevel` is read at agent-start time so the level can be toggled live.
36
+ */
37
+ export const cavemanExtension = (getLevel) => (pi) => {
38
+ pi.on("before_agent_start", (event) => {
39
+ const frag = cavemanInstruction(getLevel());
40
+ if (!frag)
41
+ return undefined;
42
+ return { systemPrompt: `${event.systemPrompt}\n\n${frag}` };
43
+ });
44
+ };
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Login — reuses pi's real OAuth providers (Anthropic Claude Pro/Max,
3
+ * GitHub Copilot, ChatGPT/Codex) instead of reimplementing the flow.
4
+ *
5
+ * pi-ai hides the oauth module behind its exports map, so we resolve the
6
+ * installed package and import the file directly. The TUI supplies a LoginUI
7
+ * (browser-open, code paste, sub-select); we map it to pi's OAuthLoginCallbacks.
8
+ */
9
+ import { pathToFileURL, fileURLToPath } from "node:url";
10
+ import { dirname, join } from "node:path";
11
+ /** Map our LoginUI to pi's OAuthLoginCallbacks shape. */
12
+ export function buildCallbacks(ui) {
13
+ return {
14
+ onAuth: (info) => {
15
+ ui.note(`Opening your browser to sign in…\nIf it doesn't open, visit:\n${info.url}`);
16
+ ui.openUrl(info.url);
17
+ },
18
+ onDeviceCode: (info) => ui.note(`Go to ${info.verificationUri} and enter code: ${info.userCode}`),
19
+ onProgress: (message) => ui.note(message),
20
+ onPrompt: (p) => ui.prompt(p.message),
21
+ onManualCodeInput: () => ui.prompt("Paste the authorization code:"),
22
+ onSelect: (p) => ui.select(p.message, p.options),
23
+ };
24
+ }
25
+ /** Tag raw OAuth credentials so AuthStorage persists them as a subscription. */
26
+ export function oauthCredential(creds) {
27
+ return { type: "oauth", ...creds };
28
+ }
29
+ let oauthMod;
30
+ function loadOAuth() {
31
+ if (!oauthMod) {
32
+ // pi-ai exposes oauth only as an internal (non-exported) module. Resolve the
33
+ // package's ESM entry, then walk to the oauth file by path and import it.
34
+ const idxPath = fileURLToPath(import.meta.resolve("@earendil-works/pi-ai"));
35
+ const file = join(dirname(idxPath), "utils", "oauth", "index.js");
36
+ oauthMod = import(pathToFileURL(file).href);
37
+ }
38
+ return oauthMod;
39
+ }
40
+ export async function listLoginProviders() {
41
+ const m = await loadOAuth();
42
+ return (m.getOAuthProviders?.() ?? []).map((p) => ({ id: p.id, name: p.name }));
43
+ }
44
+ /** Run pi's OAuth login for `id`, persist the credential, refresh models. */
45
+ export async function runLogin(id, ui, authStorage, registry) {
46
+ try {
47
+ const m = await loadOAuth();
48
+ const provider = m.getOAuthProvider?.(id);
49
+ if (!provider)
50
+ return { ok: false, error: `unknown provider: ${id}` };
51
+ const creds = await provider.login(buildCallbacks(ui));
52
+ authStorage.set(id, oauthCredential(creds));
53
+ registry.refresh();
54
+ return { ok: true };
55
+ }
56
+ catch (err) {
57
+ return { ok: false, error: err.message };
58
+ }
59
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * NOAH CLI — `noah "<natural language task>"`
4
+ *
5
+ * Flags:
6
+ * --dry-run preview steps without executing (neutralises side effects)
7
+ * --yes, -y auto-approve confirmations (non-interactive demo / scripts)
8
+ * --log print the audit log and exit
9
+ * --help, -h usage
10
+ */
11
+ import { runNoah } from "./session.js";
12
+ import { runNoahInteractive } from "./tui/app.js";
13
+ import { runNoahSpace } from "./tui/space/app.js";
14
+ import { runNoahRpc } from "./modes/rpc.js";
15
+ import { collectSnapshot } from "./sys/probe.js";
16
+ import { assessHealth } from "./sys/health.js";
17
+ import { formatDoctor } from "./sys/report.js";
18
+ import { printAuditLog } from "./safety/audit.js";
19
+ import { classify } from "./safety/policy.js";
20
+ import { buildRegistry } from "./llm/registry.js";
21
+ import { formatModelList } from "./llm/resolve.js";
22
+ import * as ui from "./ui/render.js";
23
+ const BANNER = "NOAH — Native Operating-system Agentic Harness";
24
+ function usage() {
25
+ console.log(`${BANNER}
26
+
27
+ Usage:
28
+ noah Launch the interactive TUI
29
+ noah "install htop and start it" TUI, sending your task first
30
+ noah --print "show my biggest files" Single-shot, no TUI (scripts/demos)
31
+ noah --dry-run "set up a venv" Preview steps; make no changes
32
+ noah doctor Full machine health report (no LLM)
33
+ noah --log Print the audit trail
34
+
35
+ Flags:
36
+ --print Single-shot branded run; do not open the TUI
37
+ --rpc Headless JSON-RPC over stdin/stdout (embed NOAH)
38
+ --classic Use the classic (pi-style) interactive mode
39
+ --caveman[=LVL] Start in token-saver mode (off/lite/full/ultra/micro)
40
+ --dry-run Preview the plan; do not execute (side effects neutralised)
41
+ --yes, -y Auto-approve confirmation prompts
42
+ --model REF Pick the LLM as provider/id (e.g. ollama/llama3.1)
43
+ --list-models List available models (✓ = ready) and exit
44
+ --check CMD Show how NOAH's safety gate would classify a shell command
45
+ --log Print the audit trail and exit
46
+ --help, -h Show this help
47
+ `);
48
+ }
49
+ function checkCommand(command) {
50
+ const v = classify("bash", { command });
51
+ if (v.action === "deny") {
52
+ console.log(ui.safetyBlock(command, v.reason));
53
+ return;
54
+ }
55
+ console.log(ui.checkVerdict(command, v.action, v.reason));
56
+ }
57
+ async function main() {
58
+ const argv = process.argv.slice(2);
59
+ if (argv.includes("--help") || argv.includes("-h")) {
60
+ usage();
61
+ return;
62
+ }
63
+ if (argv.includes("--log")) {
64
+ printAuditLog();
65
+ return;
66
+ }
67
+ if (argv[0] === "doctor" || argv.includes("--doctor")) {
68
+ console.log(ui.brand());
69
+ const snap = await collectSnapshot();
70
+ console.log(formatDoctor(snap, assessHealth(snap)).join("\n"));
71
+ return;
72
+ }
73
+ if (argv.includes("--list-models")) {
74
+ const { modelRegistry } = await buildRegistry();
75
+ console.log(ui.brand());
76
+ console.log(formatModelList(modelRegistry));
77
+ return;
78
+ }
79
+ const checkIdx = argv.indexOf("--check");
80
+ if (checkIdx !== -1) {
81
+ const command = argv.slice(checkIdx + 1).join(" ").trim();
82
+ if (!command) {
83
+ console.error('✗ --check needs a command, e.g. noah --check "rm -rf /"');
84
+ process.exitCode = 1;
85
+ return;
86
+ }
87
+ console.log(ui.brand());
88
+ checkCommand(command);
89
+ return;
90
+ }
91
+ const dryRun = argv.includes("--dry-run");
92
+ const autoYes = argv.includes("--yes") || argv.includes("-y");
93
+ const printMode = argv.includes("--print");
94
+ const classicMode = argv.includes("--classic");
95
+ const rpcMode = argv.includes("--rpc") || argv.includes("--mode=rpc");
96
+ const modelIdx = argv.indexOf("--model");
97
+ const model = modelIdx !== -1 ? argv[modelIdx + 1] : undefined;
98
+ const caveArg = argv.find((a) => a === "--caveman" || a.startsWith("--caveman="));
99
+ const caveman = caveArg ? (caveArg.split("=")[1] ?? "full") : undefined;
100
+ const prompt = argv
101
+ .filter((a, i) => !a.startsWith("-") && i !== modelIdx + 1)
102
+ .join(" ")
103
+ .trim();
104
+ try {
105
+ if (rpcMode) {
106
+ await runNoahRpc({ dryRun, autoYes, model, caveman });
107
+ }
108
+ else if (printMode) {
109
+ if (!prompt) {
110
+ console.error("✗ --print needs a task.\n");
111
+ usage();
112
+ process.exitCode = 1;
113
+ return;
114
+ }
115
+ await runNoah({ prompt, dryRun, autoYes, model });
116
+ }
117
+ else if (classicMode) {
118
+ await runNoahInteractive({ initialMessage: prompt || undefined, dryRun, autoYes, model });
119
+ }
120
+ else {
121
+ // Default: NOAH's bespoke space TUI.
122
+ await runNoahSpace({ initialMessage: prompt || undefined, dryRun, autoYes, model, caveman });
123
+ }
124
+ }
125
+ catch (err) {
126
+ console.error(`\n✗ NOAH error: ${err.message}`);
127
+ process.exitCode = 1;
128
+ }
129
+ }
130
+ main();
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Ollama dynamic model discovery.
3
+ *
4
+ * Queries the local Ollama daemon for installed models so NOAH lists exactly
5
+ * what the user has pulled. Always degrades gracefully: if Ollama is down or the
6
+ * payload is odd, returns [] and the caller falls back to the static defaults.
7
+ */
8
+ import { OLLAMA_BASE_URL, ollamaModelConfig } from "./providers.js";
9
+ /** Derive Ollama's native `/api/tags` endpoint from the OpenAI-compat `/v1` base url. */
10
+ export function tagsUrl(baseUrl) {
11
+ const root = baseUrl.replace(/\/v1\/?$/, "").replace(/\/$/, "");
12
+ return `${root}/api/tags`;
13
+ }
14
+ export async function discoverOllamaModels(opts = {}) {
15
+ const baseUrl = opts.baseUrl ?? OLLAMA_BASE_URL;
16
+ const doFetch = opts.fetchImpl ?? fetch;
17
+ try {
18
+ const res = await doFetch(tagsUrl(baseUrl));
19
+ if (!res.ok)
20
+ return [];
21
+ const body = (await res.json());
22
+ if (!Array.isArray(body.models))
23
+ return [];
24
+ return body.models
25
+ .map((t) => t.name)
26
+ .filter((n) => typeof n === "string" && n.length > 0)
27
+ .map((id) => ollamaModelConfig(id));
28
+ }
29
+ catch {
30
+ return [];
31
+ }
32
+ }
@@ -0,0 +1,38 @@
1
+ /** Default local Ollama OpenAI-compatible endpoint. */
2
+ export const OLLAMA_BASE_URL = "http://localhost:11434/v1";
3
+ /** Sensible local defaults (good tool-calling models). */
4
+ export const DEFAULT_OLLAMA_MODELS = ["qwen2.5-coder", "llama3.1"];
5
+ /**
6
+ * Build a model config for a local Ollama model.
7
+ * Local inference is free, text-only, no extended thinking by default.
8
+ */
9
+ export function ollamaModelConfig(id, overrides = {}) {
10
+ return {
11
+ id,
12
+ name: id,
13
+ reasoning: false,
14
+ input: ["text"],
15
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
16
+ contextWindow: 32_768,
17
+ maxTokens: 4096,
18
+ ...overrides,
19
+ };
20
+ }
21
+ /**
22
+ * Build a ProviderConfig for local Ollama.
23
+ *
24
+ * A dummy apiKey is set so Pi treats these models as "available" (auth configured);
25
+ * Ollama ignores the bearer token for local use.
26
+ */
27
+ export function ollamaProvider(opts = {}) {
28
+ const ids = opts.models ?? [...DEFAULT_OLLAMA_MODELS];
29
+ const models = ids.map((m) => (typeof m === "string" ? ollamaModelConfig(m) : m));
30
+ return {
31
+ name: "Ollama (local)",
32
+ baseUrl: opts.baseUrl ?? OLLAMA_BASE_URL,
33
+ apiKey: "ollama",
34
+ authHeader: true,
35
+ api: "openai-completions",
36
+ models,
37
+ };
38
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Registry assembly — wires Pi's AuthStorage + ModelRegistry and registers
3
+ * NOAH's pluggable providers (local Ollama first; cloud providers come from
4
+ * Pi's built-ins + ~/.pi auth).
5
+ */
6
+ import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent";
7
+ import { discoverOllamaModels } from "./ollama.js";
8
+ import { ollamaProvider, OLLAMA_BASE_URL } from "./providers.js";
9
+ export async function buildRegistry(opts = {}) {
10
+ const baseUrl = opts.baseUrl ?? OLLAMA_BASE_URL;
11
+ const authStorage = opts.authStorage ?? AuthStorage.create();
12
+ const modelRegistry = opts.modelRegistry ?? ModelRegistry.create(authStorage);
13
+ const discover = opts.discover ?? discoverOllamaModels;
14
+ const discovered = await discover({ baseUrl });
15
+ // Discovered models win; otherwise register the built-in defaults so the
16
+ // provider still appears (and works once the user pulls a model).
17
+ modelRegistry.registerProvider("ollama", ollamaProvider({ baseUrl, models: discovered.length ? discovered : undefined }));
18
+ return { authStorage, modelRegistry };
19
+ }
@@ -0,0 +1,44 @@
1
+ /** Parse "provider/id" (id may contain further slashes). Null if no provider segment. */
2
+ export function parseModelRef(ref) {
3
+ const i = ref.indexOf("/");
4
+ if (i <= 0 || i === ref.length - 1)
5
+ return null;
6
+ return { provider: ref.slice(0, i), id: ref.slice(i + 1) };
7
+ }
8
+ export class ModelResolutionError extends Error {
9
+ }
10
+ function findRef(reg, ref) {
11
+ const parsed = parseModelRef(ref);
12
+ if (!parsed) {
13
+ throw new ModelResolutionError(`Invalid model ref "${ref}". Use provider/id, e.g. ollama/llama3.1`);
14
+ }
15
+ const found = reg.find(parsed.provider, parsed.id);
16
+ if (!found) {
17
+ throw new ModelResolutionError(`Model "${ref}" not found. Run \`noah --list-models\` to see options.`);
18
+ }
19
+ return found;
20
+ }
21
+ export function resolveModel(reg, opts) {
22
+ if (opts.flagModel)
23
+ return findRef(reg, opts.flagModel);
24
+ if (opts.envModel)
25
+ return findRef(reg, opts.envModel);
26
+ const available = reg.getAvailable();
27
+ if (available.length > 0)
28
+ return available[0];
29
+ throw new ModelResolutionError("No model available. Start Ollama (`ollama serve`) or configure a cloud key via `pi /login`, " +
30
+ "then pick one with `--model provider/id`.");
31
+ }
32
+ /** Human-readable model list for `--list-models`. ✓ marks models with auth configured. */
33
+ export function formatModelList(reg) {
34
+ const available = new Set(reg.getAvailable().map((m) => `${m.provider}/${m.id}`));
35
+ const all = reg.getAll();
36
+ if (all.length === 0)
37
+ return "No models registered.";
38
+ const lines = all.map((m) => {
39
+ const ref = `${m.provider}/${m.id}`;
40
+ const mark = available.has(ref) ? "✓" : " ";
41
+ return ` ${mark} ${ref}`;
42
+ });
43
+ return ["Models (✓ = ready to use):", ...lines].join("\n");
44
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * RPC run mode — headless JSON-RPC over stdin/stdout, reusing pi's `runRpcMode`
3
+ * driven by a NOAH-wired runtime. Lets a desktop applet / voice frontend / any
4
+ * app embed NOAH as a subprocess with all OS tools + safety + caveman intact.
5
+ *
6
+ * noah --rpc # then send {"type":"prompt","message":"..."} lines
7
+ */
8
+ import { runRpcMode } from "@earendil-works/pi-coding-agent";
9
+ import { buildNoahRuntime } from "../runtime.js";
10
+ export async function runNoahRpc(opts) {
11
+ const runtime = await buildNoahRuntime(opts);
12
+ await runRpcMode(runtime);
13
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Platform adapter entry — detects the OS + package manager and wires the
3
+ * matching backend (Linux apt/dnf/pacman/zypper + systemd, or macOS brew + launchd).
4
+ *
5
+ * Detection logic lives in detect.ts (pure); this module supplies the real
6
+ * `has()` (PATH probe) and `sh()` (child process) seams.
7
+ */
8
+ import { execFile } from "node:child_process";
9
+ import { accessSync, constants } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { promisify } from "node:util";
12
+ import { detectPlatform } from "./detect.js";
13
+ import { makeLinuxAdapter } from "./linux.js";
14
+ import { makeMacosAdapter } from "./macos.js";
15
+ const exec = promisify(execFile);
16
+ /** Run a command, returning combined stdout+stderr; never throws (errors surface as text). */
17
+ const sh = async (cmd, args) => {
18
+ try {
19
+ const { stdout, stderr } = await exec(cmd, args, { timeout: 120_000 });
20
+ return (stdout || "") + (stderr ? `\n${stderr}` : "");
21
+ }
22
+ catch (err) {
23
+ const e = err;
24
+ return e.stdout || e.stderr || e.message;
25
+ }
26
+ };
27
+ /** True if `cmd` is an executable on PATH (sync, no child process). */
28
+ function hasOnPath(cmd) {
29
+ const dirs = (process.env.PATH ?? "").split(":").filter(Boolean);
30
+ for (const dir of dirs) {
31
+ try {
32
+ accessSync(join(dir, cmd), constants.X_OK);
33
+ return true;
34
+ }
35
+ catch {
36
+ // not here, keep looking
37
+ }
38
+ }
39
+ return false;
40
+ }
41
+ function build() {
42
+ const d = detectPlatform({ platform: process.platform, has: hasOnPath });
43
+ if (d.kind === "macos")
44
+ return makeMacosAdapter(sh);
45
+ return makeLinuxAdapter(d.pkgManager, sh);
46
+ }
47
+ export const platform = build();
@@ -0,0 +1,18 @@
1
+ /** Linux package managers probed in priority order: (binary, manager). */
2
+ const LINUX_PMS = [
3
+ { bin: "apt-get", pm: "apt" },
4
+ { bin: "dnf", pm: "dnf" },
5
+ { bin: "pacman", pm: "pacman" },
6
+ { bin: "zypper", pm: "zypper" },
7
+ ];
8
+ export function detectPlatform(input) {
9
+ if (input.platform === "darwin") {
10
+ return { kind: "macos", pkgManager: "brew" };
11
+ }
12
+ for (const { bin, pm } of LINUX_PMS) {
13
+ if (input.has(bin))
14
+ return { kind: "linux", pkgManager: pm };
15
+ }
16
+ // Unknown Linux: assume apt (most common); commands will surface real errors.
17
+ return { kind: "linux", pkgManager: "apt" };
18
+ }