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.
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/dist/agent/auth-gate.js +23 -0
- package/dist/agent/caveman.js +44 -0
- package/dist/agent/login.js +59 -0
- package/dist/cli.js +130 -0
- package/dist/llm/ollama.js +32 -0
- package/dist/llm/providers.js +38 -0
- package/dist/llm/registry.js +19 -0
- package/dist/llm/resolve.js +44 -0
- package/dist/modes/rpc.js +13 -0
- package/dist/platform/adapter.js +47 -0
- package/dist/platform/detect.js +18 -0
- package/dist/platform/linux.js +61 -0
- package/dist/platform/macos.js +51 -0
- package/dist/platform/types.js +5 -0
- package/dist/prompt/system.js +52 -0
- package/dist/runtime.js +124 -0
- package/dist/safety/audit.js +46 -0
- package/dist/safety/confirm.js +17 -0
- package/dist/safety/extension.js +65 -0
- package/dist/safety/policy.js +100 -0
- package/dist/sdk.js +32 -0
- package/dist/session.js +113 -0
- package/dist/sys/health.js +51 -0
- package/dist/sys/probe.js +128 -0
- package/dist/sys/report.js +55 -0
- package/dist/tools/logs.js +24 -0
- package/dist/tools/network.js +47 -0
- package/dist/tools/package.js +40 -0
- package/dist/tools/service.js +45 -0
- package/dist/tools/system.js +33 -0
- package/dist/tui/app.js +104 -0
- package/dist/tui/branding.js +14 -0
- package/dist/tui/components/audit-line.js +37 -0
- package/dist/tui/components/header.js +33 -0
- package/dist/tui/components/noah-footer.js +33 -0
- package/dist/tui/components/request-panel.js +23 -0
- package/dist/tui/components/response-view.js +17 -0
- package/dist/tui/components/safety-block.js +31 -0
- package/dist/tui/components/safety-review.js +36 -0
- package/dist/tui/components/thinking-view.js +22 -0
- package/dist/tui/components/tool-card.js +45 -0
- package/dist/tui/components/util.js +3 -0
- package/dist/tui/preview.js +33 -0
- package/dist/tui/space/app.js +566 -0
- package/dist/tui/space/components.js +261 -0
- package/dist/tui/space/dashboard.js +63 -0
- package/dist/tui/space/theme.js +39 -0
- package/dist/ui/ansi.js +93 -0
- package/dist/ui/badge.js +31 -0
- package/dist/ui/box.js +61 -0
- package/dist/ui/preview.js +37 -0
- package/dist/ui/render.js +140 -0
- package/package.json +68 -0
- package/themes/noah-dark-blue.json +85 -0
package/dist/session.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NOAH session — wires the Pi SDK with NOAH's tools, safety, and explain-mode,
|
|
3
|
+
* and renders the experience through the NOAH UI panels.
|
|
4
|
+
*/
|
|
5
|
+
import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager, } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { safetyExtension } from "./safety/extension.js";
|
|
7
|
+
import { packageTool } from "./tools/package.js";
|
|
8
|
+
import { serviceTool } from "./tools/service.js";
|
|
9
|
+
import { networkTool } from "./tools/network.js";
|
|
10
|
+
import { systemTool } from "./tools/system.js";
|
|
11
|
+
import { logsTool } from "./tools/logs.js";
|
|
12
|
+
import { NOAH_SYSTEM_PROMPT } from "./prompt/system.js";
|
|
13
|
+
import { buildRegistry } from "./llm/registry.js";
|
|
14
|
+
import { resolveModel } from "./llm/resolve.js";
|
|
15
|
+
import * as ui from "./ui/render.js";
|
|
16
|
+
const out = (s) => process.stdout.write(s);
|
|
17
|
+
/** Human-readable summary of a tool's arguments for the tool card. */
|
|
18
|
+
function describeArgs(toolName, args) {
|
|
19
|
+
if (!args || typeof args !== "object")
|
|
20
|
+
return "";
|
|
21
|
+
const a = args;
|
|
22
|
+
if (typeof a.command === "string")
|
|
23
|
+
return a.command;
|
|
24
|
+
if (toolName === "package")
|
|
25
|
+
return `${a.action ?? ""} ${a.pkg ?? ""}`.trim();
|
|
26
|
+
if (toolName === "service")
|
|
27
|
+
return `${a.action ?? ""} ${a.name ?? ""}`.trim();
|
|
28
|
+
if (toolName === "network")
|
|
29
|
+
return `${a.action ?? ""} ${a.target ?? ""}`.trim();
|
|
30
|
+
if (typeof a.path === "string")
|
|
31
|
+
return String(a.path);
|
|
32
|
+
return JSON.stringify(a);
|
|
33
|
+
}
|
|
34
|
+
export async function runNoah(opts) {
|
|
35
|
+
if (opts.dryRun)
|
|
36
|
+
process.env.NOAH_DRY_RUN = "1";
|
|
37
|
+
const resourceLoader = new DefaultResourceLoader({
|
|
38
|
+
cwd: process.cwd(),
|
|
39
|
+
agentDir: getAgentDir(),
|
|
40
|
+
// Replace Pi's "expert coding assistant" base with NOAH's OS-operator prompt.
|
|
41
|
+
systemPromptOverride: () => NOAH_SYSTEM_PROMPT,
|
|
42
|
+
// Don't auto-append the user's APPEND_SYSTEM.md — NOAH owns its prompt.
|
|
43
|
+
appendSystemPromptOverride: () => [],
|
|
44
|
+
extensionFactories: [safetyExtension({ dryRun: opts.dryRun, autoYes: opts.autoYes })],
|
|
45
|
+
});
|
|
46
|
+
await resourceLoader.reload();
|
|
47
|
+
// Layer 1 — provider abstraction: assemble the registry (local Ollama + cloud)
|
|
48
|
+
// and resolve which model this run uses.
|
|
49
|
+
const { authStorage, modelRegistry } = await buildRegistry();
|
|
50
|
+
const model = resolveModel(modelRegistry, {
|
|
51
|
+
flagModel: opts.model,
|
|
52
|
+
envModel: process.env.NOAH_MODEL,
|
|
53
|
+
});
|
|
54
|
+
const { session } = await createAgentSession({
|
|
55
|
+
resourceLoader,
|
|
56
|
+
// resolveModel returns a structural view; it is the registry's real Model.
|
|
57
|
+
model: model,
|
|
58
|
+
authStorage,
|
|
59
|
+
modelRegistry,
|
|
60
|
+
tools: ["read", "bash", "edit", "write", "grep", "find", "ls", "package", "service", "network", "system", "logs"],
|
|
61
|
+
customTools: [packageTool, serviceTool, networkTool, systemTool, logsTool],
|
|
62
|
+
sessionManager: SessionManager.create(process.cwd()),
|
|
63
|
+
});
|
|
64
|
+
// --- render state ---
|
|
65
|
+
let streaming = false; // currently streaming assistant text under the bar
|
|
66
|
+
const commands = new Map(); // toolCallId -> command summary
|
|
67
|
+
const endStream = () => {
|
|
68
|
+
if (streaming) {
|
|
69
|
+
out("\n");
|
|
70
|
+
streaming = false;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
try {
|
|
74
|
+
out(ui.brand());
|
|
75
|
+
out(ui.note(`model: ${model.provider}/${model.id}`, "info") + "\n");
|
|
76
|
+
out(ui.requestPanel(opts.prompt) + "\n");
|
|
77
|
+
if (opts.dryRun)
|
|
78
|
+
out("\n" + ui.note("dry-run: NOAH will preview steps, not execute them.", "info") + "\n");
|
|
79
|
+
session.subscribe((event) => {
|
|
80
|
+
switch (event.type) {
|
|
81
|
+
case "message_update": {
|
|
82
|
+
const e = event.assistantMessageEvent;
|
|
83
|
+
if (e.type === "text_delta") {
|
|
84
|
+
if (!streaming) {
|
|
85
|
+
out(ui.responseHeader() + "\n" + ui.barPrefix());
|
|
86
|
+
streaming = true;
|
|
87
|
+
}
|
|
88
|
+
out(e.delta.replace(/\n/g, "\n" + ui.barPrefix()));
|
|
89
|
+
}
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
case "tool_execution_start": {
|
|
93
|
+
endStream();
|
|
94
|
+
const cmd = describeArgs(event.toolName, event.args);
|
|
95
|
+
commands.set(event.toolCallId, cmd);
|
|
96
|
+
out(ui.toolCard(event.toolName, cmd, "running") + "\n");
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
case "tool_execution_end": {
|
|
100
|
+
const cmd = commands.get(event.toolCallId) ?? "";
|
|
101
|
+
out(ui.auditLine(event.toolName, cmd, !event.isError) + "\n");
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
await session.prompt(opts.prompt);
|
|
107
|
+
endStream();
|
|
108
|
+
out(ui.divider() + "\n");
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
session.dispose();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health assessment — deterministic severity rules over a telemetry snapshot.
|
|
3
|
+
* No LLM: fast, predictable, runs on launch for the dashboard and `/doctor`.
|
|
4
|
+
*/
|
|
5
|
+
import { humanBytes } from "./probe.js";
|
|
6
|
+
import { realDisks, procLabel } from "./report.js";
|
|
7
|
+
const RANK = { high: 0, medium: 1, low: 2 };
|
|
8
|
+
export function assessHealth(snap) {
|
|
9
|
+
const items = [];
|
|
10
|
+
// Disk — worst real mount
|
|
11
|
+
const disks = realDisks(snap).length ? realDisks(snap) : snap.disks;
|
|
12
|
+
const worst = disks.slice().sort((a, b) => b.usedPct - a.usedPct)[0];
|
|
13
|
+
if (worst) {
|
|
14
|
+
if (worst.usedPct >= 90)
|
|
15
|
+
items.push({ severity: "high", title: "Disk nearly full", detail: `${worst.mount} at ${worst.usedPct}% (${humanBytes(worst.available)} free)` });
|
|
16
|
+
else if (worst.usedPct >= 80)
|
|
17
|
+
items.push({ severity: "medium", title: "Disk filling up", detail: `${worst.mount} at ${worst.usedPct}% (${humanBytes(worst.available)} free)` });
|
|
18
|
+
}
|
|
19
|
+
// Memory
|
|
20
|
+
if (snap.memory) {
|
|
21
|
+
const p = snap.memory.usedPct;
|
|
22
|
+
if (p >= 90)
|
|
23
|
+
items.push({ severity: "high", title: "Memory pressure", detail: `${p}% used` });
|
|
24
|
+
else if (p >= 80)
|
|
25
|
+
items.push({ severity: "medium", title: "Memory high", detail: `${p}% used` });
|
|
26
|
+
}
|
|
27
|
+
// Failed services
|
|
28
|
+
if (snap.failedServices.length) {
|
|
29
|
+
items.push({
|
|
30
|
+
severity: "high",
|
|
31
|
+
title: `${snap.failedServices.length} failed service${snap.failedServices.length > 1 ? "s" : ""}`,
|
|
32
|
+
detail: snap.failedServices.join(", "),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
// Runaway CPU
|
|
36
|
+
const top = snap.topProcesses[0];
|
|
37
|
+
if (top && top.cpu >= 85) {
|
|
38
|
+
items.push({ severity: "low", title: "High CPU usage", detail: `${procLabel(top.command)} at ${top.cpu}%` });
|
|
39
|
+
}
|
|
40
|
+
// Updates
|
|
41
|
+
if (typeof snap.updates === "number" && snap.updates > 0) {
|
|
42
|
+
items.push({ severity: "low", title: `${snap.updates} update${snap.updates > 1 ? "s" : ""} available`, detail: "Run a system update to stay current" });
|
|
43
|
+
}
|
|
44
|
+
items.sort((a, b) => RANK[a.severity] - RANK[b.severity]);
|
|
45
|
+
const status = items.some((i) => i.severity === "high")
|
|
46
|
+
? "critical"
|
|
47
|
+
: items.some((i) => i.severity === "medium")
|
|
48
|
+
? "warn"
|
|
49
|
+
: "ok";
|
|
50
|
+
return { status, items };
|
|
51
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System telemetry probe — the machine-awareness foundation.
|
|
3
|
+
*
|
|
4
|
+
* Pure parsers (TDD'd against captured command output) + a thin, injectable
|
|
5
|
+
* collector. Every metric is independent and degrades to "unknown"/empty on
|
|
6
|
+
* failure, so a missing tool never crashes the snapshot.
|
|
7
|
+
*/
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
const exec = promisify(execFile);
|
|
11
|
+
/* ------------------------------------------------------------------ parsers */
|
|
12
|
+
/** Parse `df -k -P` (POSIX). Values are 1024-blocks → bytes. */
|
|
13
|
+
export function parseDf(output) {
|
|
14
|
+
const out = [];
|
|
15
|
+
const lines = output.trim().split("\n").slice(1); // drop header
|
|
16
|
+
for (const line of lines) {
|
|
17
|
+
const t = line.trim().split(/\s+/);
|
|
18
|
+
if (t.length < 6)
|
|
19
|
+
continue;
|
|
20
|
+
const total = Number(t[1]);
|
|
21
|
+
const used = Number(t[2]);
|
|
22
|
+
const avail = Number(t[3]);
|
|
23
|
+
const pct = parseInt(t[4], 10);
|
|
24
|
+
if (!Number.isFinite(total) || !Number.isFinite(used))
|
|
25
|
+
continue;
|
|
26
|
+
out.push({
|
|
27
|
+
mount: t.slice(5).join(" "),
|
|
28
|
+
total: total * 1024,
|
|
29
|
+
used: used * 1024,
|
|
30
|
+
available: avail * 1024,
|
|
31
|
+
usedPct: Number.isFinite(pct) ? pct : 0,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
/** Parse `ps -axo pid=,pcpu=,pmem=,comm=` → top N by cpu. */
|
|
37
|
+
export function parseProcesses(output, topN = 5) {
|
|
38
|
+
const procs = [];
|
|
39
|
+
for (const line of output.trim().split("\n")) {
|
|
40
|
+
const m = line.trim().match(/^(\d+)\s+([\d.]+)\s+([\d.]+)\s+(.+)$/);
|
|
41
|
+
if (!m)
|
|
42
|
+
continue;
|
|
43
|
+
procs.push({ pid: Number(m[1]), cpu: Number(m[2]), mem: Number(m[3]), command: m[4].trim() });
|
|
44
|
+
}
|
|
45
|
+
return procs.sort((a, b) => b.cpu - a.cpu).slice(0, topN);
|
|
46
|
+
}
|
|
47
|
+
/** Parse Linux /proc/meminfo (uses MemAvailable). */
|
|
48
|
+
export function parseMemLinux(meminfo) {
|
|
49
|
+
const kb = (key) => {
|
|
50
|
+
const m = meminfo.match(new RegExp(`^${key}:\\s+(\\d+)\\s*kB`, "m"));
|
|
51
|
+
return m ? Number(m[1]) * 1024 : 0;
|
|
52
|
+
};
|
|
53
|
+
const total = kb("MemTotal");
|
|
54
|
+
const available = kb("MemAvailable");
|
|
55
|
+
const used = total - available;
|
|
56
|
+
return { total, used, usedPct: total ? Math.round((used / total) * 100) : 0 };
|
|
57
|
+
}
|
|
58
|
+
/** Parse macOS `vm_stat` page counts → used = (active+wired+compressor)*pageSize. */
|
|
59
|
+
export function parseMemMac(vmStat, pageSize, total) {
|
|
60
|
+
const pages = (label) => {
|
|
61
|
+
const m = vmStat.match(new RegExp(`${label}:\\s+(\\d+)`));
|
|
62
|
+
return m ? Number(m[1]) : 0;
|
|
63
|
+
};
|
|
64
|
+
const used = (pages("Pages active") + pages("Pages wired down") + pages("Pages occupied by compressor")) * pageSize;
|
|
65
|
+
return { total, used, usedPct: total ? Math.round((used / total) * 100) : 0 };
|
|
66
|
+
}
|
|
67
|
+
export function parseOsRelease(content) {
|
|
68
|
+
const m = content.match(/^PRETTY_NAME="?([^"\n]+)"?/m);
|
|
69
|
+
return m ? m[1] : "Linux";
|
|
70
|
+
}
|
|
71
|
+
export function parseSwVers(content) {
|
|
72
|
+
const v = content.match(/ProductVersion:\s*([\d.]+)/);
|
|
73
|
+
return v ? `macOS ${v[1]}` : "macOS";
|
|
74
|
+
}
|
|
75
|
+
/** Parse `systemctl --failed` → failed unit names. */
|
|
76
|
+
export function parseFailedServices(output) {
|
|
77
|
+
const out = [];
|
|
78
|
+
for (const line of output.split("\n")) {
|
|
79
|
+
const m = line.trim().match(/^([\w@.\\-]+\.service)\s+\S+\s+failed/);
|
|
80
|
+
if (m)
|
|
81
|
+
out.push(m[1]);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
export function humanBytes(n) {
|
|
86
|
+
if (n <= 0)
|
|
87
|
+
return "0 B";
|
|
88
|
+
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
|
|
89
|
+
const i = Math.min(units.length - 1, Math.floor(Math.log(n) / Math.log(1024)));
|
|
90
|
+
const v = n / 1024 ** i;
|
|
91
|
+
return `${i === 0 ? v : v.toFixed(1)} ${units[i]}`;
|
|
92
|
+
}
|
|
93
|
+
const realRun = async (cmd, args) => {
|
|
94
|
+
const { stdout } = await exec(cmd, args, { timeout: 8000 });
|
|
95
|
+
return stdout;
|
|
96
|
+
};
|
|
97
|
+
/** Best-effort, never-throw system snapshot. Each probe degrades independently. */
|
|
98
|
+
export async function collectSnapshot(opts = {}) {
|
|
99
|
+
const platform = opts.platform ?? process.platform;
|
|
100
|
+
const run = opts.run ?? realRun;
|
|
101
|
+
const topN = opts.topN ?? 5;
|
|
102
|
+
const isMac = platform === "darwin";
|
|
103
|
+
const safe = async (fn, fallback) => {
|
|
104
|
+
try {
|
|
105
|
+
return await fn();
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return fallback;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
const os = await safe(async () => isMac
|
|
112
|
+
? parseSwVers(await run("sw_vers", []))
|
|
113
|
+
: parseOsRelease(await run("cat", ["/etc/os-release"])), isMac ? "macOS" : "Linux");
|
|
114
|
+
const memory = await safe(async () => {
|
|
115
|
+
if (isMac) {
|
|
116
|
+
const total = Number((await run("sysctl", ["-n", "hw.memsize"])).trim());
|
|
117
|
+
const pageSize = Number((await run("sysctl", ["-n", "hw.pagesize"])).trim()) || 4096;
|
|
118
|
+
return parseMemMac(await run("vm_stat", []), pageSize, total);
|
|
119
|
+
}
|
|
120
|
+
return parseMemLinux(await run("cat", ["/proc/meminfo"]));
|
|
121
|
+
}, undefined);
|
|
122
|
+
const disks = await safe(async () => parseDf(await run("df", ["-k", "-P"])), []);
|
|
123
|
+
const topProcesses = await safe(async () => parseProcesses(await run("ps", ["-axo", "pid=,pcpu=,pmem=,comm="]), topN), []);
|
|
124
|
+
const failedServices = isMac
|
|
125
|
+
? []
|
|
126
|
+
: await safe(async () => parseFailedServices(await run("systemctl", ["--failed", "--no-legend", "--plain"])), []);
|
|
127
|
+
return { os, memory, disks, topProcesses, failedServices };
|
|
128
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telemetry formatting — pure, plain-text renderers used by the `system` tool
|
|
3
|
+
* (LLM-facing) and the startup dashboard. No colors here; presentation layers
|
|
4
|
+
* add their own.
|
|
5
|
+
*/
|
|
6
|
+
import { humanBytes } from "./probe.js";
|
|
7
|
+
/** Clean a process command path into a readable name. */
|
|
8
|
+
export function procLabel(command) {
|
|
9
|
+
const base = command.trim().split("/").pop() ?? command;
|
|
10
|
+
return base.trim() || command;
|
|
11
|
+
}
|
|
12
|
+
/** Real (non-virtual) mounts worth reporting. */
|
|
13
|
+
export function realDisks(snap) {
|
|
14
|
+
return snap.disks.filter((d) => d.mount === "/" || d.mount.startsWith("/Volumes") || d.mount.startsWith("/mnt") || d.mount.startsWith("/home"));
|
|
15
|
+
}
|
|
16
|
+
export function formatDisks(snap) {
|
|
17
|
+
const disks = realDisks(snap);
|
|
18
|
+
const list = disks.length ? disks : snap.disks;
|
|
19
|
+
return list.map((d) => `${d.mount} ${d.usedPct}% used (${humanBytes(d.used)} / ${humanBytes(d.total)}, ${humanBytes(d.available)} free)`);
|
|
20
|
+
}
|
|
21
|
+
export function formatProcesses(snap) {
|
|
22
|
+
return snap.topProcesses.map((p) => `${procLabel(p.command)} cpu ${p.cpu}% mem ${p.mem}% (pid ${p.pid})`);
|
|
23
|
+
}
|
|
24
|
+
/** Full deterministic health report (no LLM) for `/doctor` and `noah doctor`. */
|
|
25
|
+
export function formatDoctor(snap, health) {
|
|
26
|
+
const out = [`SYSTEM HEALTH — ${health.status.toUpperCase()}`, ""];
|
|
27
|
+
for (const l of formatSnapshot(snap))
|
|
28
|
+
out.push(l);
|
|
29
|
+
out.push("");
|
|
30
|
+
if (health.items.length) {
|
|
31
|
+
out.push("Recommendations (by priority):");
|
|
32
|
+
for (const it of health.items)
|
|
33
|
+
out.push(` [${it.severity}] ${it.title} — ${it.detail}`);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
out.push("All clear — nothing needs attention.");
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
export function formatSnapshot(snap) {
|
|
41
|
+
const out = [`OS: ${snap.os}`];
|
|
42
|
+
if (snap.memory)
|
|
43
|
+
out.push(`Memory: ${snap.memory.usedPct}% used (${humanBytes(snap.memory.used)} / ${humanBytes(snap.memory.total)})`);
|
|
44
|
+
out.push("Disks:");
|
|
45
|
+
for (const l of formatDisks(snap))
|
|
46
|
+
out.push(` ${l}`);
|
|
47
|
+
out.push("Top processes (by CPU):");
|
|
48
|
+
for (const l of formatProcesses(snap))
|
|
49
|
+
out.push(` ${l}`);
|
|
50
|
+
if (snap.failedServices.length)
|
|
51
|
+
out.push(`Failed services: ${snap.failedServices.join(", ")}`);
|
|
52
|
+
if (typeof snap.updates === "number")
|
|
53
|
+
out.push(`Available updates: ${snap.updates}`);
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `logs` tool — read recent system logs (journalctl on Linux, `log show` on
|
|
3
|
+
* macOS) via the platform adapter. Read-only; the key to diagnosing failures.
|
|
4
|
+
*/
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { platform } from "../platform/adapter.js";
|
|
8
|
+
export const logsTool = defineTool({
|
|
9
|
+
name: "logs",
|
|
10
|
+
label: "System logs",
|
|
11
|
+
description: "Read recent system logs (journalctl on Linux, unified log on macOS). " +
|
|
12
|
+
"Pass an optional unit/process name to filter (e.g. the failing service). " +
|
|
13
|
+
"Use this to find the root cause when a service or the system misbehaves.",
|
|
14
|
+
promptSnippet: "Read recent system logs (optionally for one unit/process)",
|
|
15
|
+
promptGuidelines: ["Use the logs tool to find root causes when a service or the system misbehaves"],
|
|
16
|
+
parameters: Type.Object({
|
|
17
|
+
unit: Type.Optional(Type.String({ description: "Service unit (Linux) or process name (macOS) to filter" })),
|
|
18
|
+
}),
|
|
19
|
+
execute: async (_id, params) => {
|
|
20
|
+
const { unit } = params;
|
|
21
|
+
const out = await platform.logs(unit);
|
|
22
|
+
return { content: [{ type: "text", text: out || "(no log output)" }], details: {} };
|
|
23
|
+
},
|
|
24
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Abstract `network` tool — dispatches through the platform adapter.
|
|
3
|
+
*
|
|
4
|
+
* Read-only diagnostics (info/ports/connections/ping) plus a gated `fetch`
|
|
5
|
+
* (HTTP GET). `fetch` is the only mutating-risk action: the safety policy
|
|
6
|
+
* requires confirmation for it (data-exfiltration guard), and it is audited.
|
|
7
|
+
*/
|
|
8
|
+
import { Type } from "typebox";
|
|
9
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { platform } from "../platform/adapter.js";
|
|
11
|
+
/** Actions that need a target (host or URL). */
|
|
12
|
+
const NEEDS_TARGET = new Set(["ping", "fetch"]);
|
|
13
|
+
export const networkTool = defineTool({
|
|
14
|
+
name: "network",
|
|
15
|
+
label: "Network",
|
|
16
|
+
description: "Inspect networking and make simple requests, cross-platform. Actions: " +
|
|
17
|
+
"info (interfaces/IPs), ports (listening sockets), connections (active TCP), " +
|
|
18
|
+
"ping <host> (connectivity), fetch <url> (HTTP GET). Prefer this over raw " +
|
|
19
|
+
"ifconfig/ip/ss/lsof/curl in bash: it is platform-native, safety-gated, and audited. " +
|
|
20
|
+
"fetch downloads from the network and always requires user confirmation.",
|
|
21
|
+
promptSnippet: "Inspect network (info/ports/connections/ping) and fetch URLs",
|
|
22
|
+
promptGuidelines: [
|
|
23
|
+
"Use the network tool, not raw ip/ifconfig/ss/lsof/curl in bash, for network tasks",
|
|
24
|
+
],
|
|
25
|
+
parameters: Type.Object({
|
|
26
|
+
action: Type.Union([
|
|
27
|
+
Type.Literal("info"),
|
|
28
|
+
Type.Literal("ports"),
|
|
29
|
+
Type.Literal("connections"),
|
|
30
|
+
Type.Literal("ping"),
|
|
31
|
+
Type.Literal("fetch"),
|
|
32
|
+
], { description: "What to do" }),
|
|
33
|
+
target: Type.Optional(Type.String({ description: "Host (for ping) or URL (for fetch); unused otherwise" })),
|
|
34
|
+
}),
|
|
35
|
+
execute: async (_id, params) => {
|
|
36
|
+
const { action, target } = params;
|
|
37
|
+
if (NEEDS_TARGET.has(action) && !target) {
|
|
38
|
+
const kind = action === "fetch" ? "url" : "host";
|
|
39
|
+
return {
|
|
40
|
+
content: [{ type: "text", text: `network ${action} needs a target ${kind}.` }],
|
|
41
|
+
details: {},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const out = await platform.net(action, target);
|
|
45
|
+
return { content: [{ type: "text", text: out || `${action} done` }], details: {} };
|
|
46
|
+
},
|
|
47
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Abstract `package` tool — dispatches through the platform adapter.
|
|
3
|
+
* Same tool on macOS (brew) and Linux (apt/...). Cross-platform by design.
|
|
4
|
+
*/
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { platform } from "../platform/adapter.js";
|
|
8
|
+
export const packageTool = defineTool({
|
|
9
|
+
name: "package",
|
|
10
|
+
label: "Package manager",
|
|
11
|
+
description: "Install, remove, or update OS packages via the platform-native package manager " +
|
|
12
|
+
"(apt/dnf/pacman/zypper on Linux, brew on macOS) \u2014 NOAH detects the right one. " +
|
|
13
|
+
"Always prefer this over running apt/brew/etc. in bash: it is cross-platform, is gated " +
|
|
14
|
+
"by the safety layer, and is recorded in the audit trail. `action` is install, remove, " +
|
|
15
|
+
"or update; `pkg` is the package name (omit pkg with update to upgrade everything). " +
|
|
16
|
+
"Returns the package manager's combined output.",
|
|
17
|
+
promptSnippet: "Install/remove/update OS packages (apt/dnf/pacman/zypper/brew)",
|
|
18
|
+
promptGuidelines: [
|
|
19
|
+
"Use the package tool, not apt/brew/dnf/pacman/zypper in bash, to manage software",
|
|
20
|
+
],
|
|
21
|
+
parameters: Type.Object({
|
|
22
|
+
action: Type.Union([Type.Literal("install"), Type.Literal("remove"), Type.Literal("update")], {
|
|
23
|
+
description: "What to do",
|
|
24
|
+
}),
|
|
25
|
+
pkg: Type.Optional(Type.String({ description: "Package name (optional for update-all)" })),
|
|
26
|
+
}),
|
|
27
|
+
execute: async (_id, params) => {
|
|
28
|
+
const { action, pkg } = params;
|
|
29
|
+
if (process.env.NOAH_DRY_RUN === "1") {
|
|
30
|
+
return {
|
|
31
|
+
content: [
|
|
32
|
+
{ type: "text", text: `[DRY-RUN] would ${action} package "${pkg ?? "(all)"}" via ${platform.os}` },
|
|
33
|
+
],
|
|
34
|
+
details: {},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const out = await platform.pkg(action, pkg);
|
|
38
|
+
return { content: [{ type: "text", text: out || `${action} ${pkg ?? ""} done` }], details: {} };
|
|
39
|
+
},
|
|
40
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Abstract `service` tool — dispatches through the platform adapter.
|
|
3
|
+
* systemd (systemctl) on Linux, launchd (launchctl) on macOS.
|
|
4
|
+
*/
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { platform } from "../platform/adapter.js";
|
|
8
|
+
export const serviceTool = defineTool({
|
|
9
|
+
name: "service",
|
|
10
|
+
label: "Service manager",
|
|
11
|
+
description: "Manage a system service via the platform-native init system (systemd `systemctl` on " +
|
|
12
|
+
"Linux, launchd `launchctl` on macOS) \u2014 NOAH picks the right backend. Prefer this over " +
|
|
13
|
+
"raw shell: it is cross-platform, safety-gated, and audited. `action` is start, stop, " +
|
|
14
|
+
"restart, status, enable, or disable; `name` is the systemd unit (Linux) or launchd label " +
|
|
15
|
+
"(macOS). Note: enable/disable are not supported on macOS launchd. Use status (read-only) " +
|
|
16
|
+
"to inspect a service before changing it.",
|
|
17
|
+
promptSnippet: "Start/stop/restart/enable/disable/status a service (systemd/launchd)",
|
|
18
|
+
promptGuidelines: [
|
|
19
|
+
"Use the service tool, not systemctl/launchctl in bash, to manage services",
|
|
20
|
+
],
|
|
21
|
+
parameters: Type.Object({
|
|
22
|
+
action: Type.Union([
|
|
23
|
+
Type.Literal("start"),
|
|
24
|
+
Type.Literal("stop"),
|
|
25
|
+
Type.Literal("restart"),
|
|
26
|
+
Type.Literal("status"),
|
|
27
|
+
Type.Literal("enable"),
|
|
28
|
+
Type.Literal("disable"),
|
|
29
|
+
], { description: "What to do with the service" }),
|
|
30
|
+
name: Type.String({ description: "Service/unit name (Linux) or launchd label (macOS)" }),
|
|
31
|
+
}),
|
|
32
|
+
execute: async (_id, params) => {
|
|
33
|
+
const { action, name } = params;
|
|
34
|
+
if (process.env.NOAH_DRY_RUN === "1") {
|
|
35
|
+
return {
|
|
36
|
+
content: [
|
|
37
|
+
{ type: "text", text: `[DRY-RUN] would ${action} service "${name}" via ${platform.os}` },
|
|
38
|
+
],
|
|
39
|
+
details: {},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const out = await platform.service(name, action);
|
|
43
|
+
return { content: [{ type: "text", text: out || `${action} ${name} done` }], details: {} };
|
|
44
|
+
},
|
|
45
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `system` tool — gives the agent eyes. Read-only machine telemetry so analysis
|
|
3
|
+
* ("why is it slow?", "free up space") is grounded in real data, not guesses.
|
|
4
|
+
*/
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { collectSnapshot } from "../sys/probe.js";
|
|
8
|
+
import { formatSnapshot, formatDisks, formatProcesses } from "../sys/report.js";
|
|
9
|
+
export const systemTool = defineTool({
|
|
10
|
+
name: "system",
|
|
11
|
+
label: "System telemetry",
|
|
12
|
+
description: "Inspect the machine's live state (read-only): action 'info' = full snapshot " +
|
|
13
|
+
"(OS, memory, disks, top processes, failed services); 'disk' = filesystem usage; " +
|
|
14
|
+
"'processes' = top CPU consumers; 'health' = same as info. ALWAYS call this to " +
|
|
15
|
+
"understand the machine before recommending or performing system changes.",
|
|
16
|
+
promptSnippet: "Read live machine state (memory/disk/processes/services)",
|
|
17
|
+
promptGuidelines: [
|
|
18
|
+
"Before installing software or diagnosing problems, call the system tool to ground your analysis in real telemetry",
|
|
19
|
+
],
|
|
20
|
+
parameters: Type.Object({
|
|
21
|
+
action: Type.Union([Type.Literal("info"), Type.Literal("disk"), Type.Literal("processes"), Type.Literal("health")], { description: "What to inspect" }),
|
|
22
|
+
}),
|
|
23
|
+
execute: async (_id, params) => {
|
|
24
|
+
const { action } = params;
|
|
25
|
+
const snap = await collectSnapshot();
|
|
26
|
+
const lines = action === "disk"
|
|
27
|
+
? ["Disks:", ...formatDisks(snap).map((l) => ` ${l}`)]
|
|
28
|
+
: action === "processes"
|
|
29
|
+
? ["Top processes (by CPU):", ...formatProcesses(snap).map((l) => ` ${l}`)]
|
|
30
|
+
: formatSnapshot(snap);
|
|
31
|
+
return { content: [{ type: "text", text: lines.join("\n") || "(no data)" }], details: {} };
|
|
32
|
+
},
|
|
33
|
+
});
|
package/dist/tui/app.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NOAH interactive TUI — reuses Pi's full InteractiveMode for an exact pi.dev UX
|
|
3
|
+
* (streaming, tool cards, model cycling with Ctrl+P, footer, slash commands),
|
|
4
|
+
* wired to NOAH's provider registry, OS tools, system prompt, and safety gate.
|
|
5
|
+
*
|
|
6
|
+
* We build an AgentSessionRuntime via the services factory so /new, /resume,
|
|
7
|
+
* /fork all keep NOAH's customizations.
|
|
8
|
+
*/
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, getAgentDir, InteractiveMode, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { buildRegistry } from "../llm/registry.js";
|
|
12
|
+
import { resolveModel } from "../llm/resolve.js";
|
|
13
|
+
import { safetyExtension } from "../safety/extension.js";
|
|
14
|
+
import { packageTool } from "../tools/package.js";
|
|
15
|
+
import { serviceTool } from "../tools/service.js";
|
|
16
|
+
import { networkTool } from "../tools/network.js";
|
|
17
|
+
import { systemTool } from "../tools/system.js";
|
|
18
|
+
import { logsTool } from "../tools/logs.js";
|
|
19
|
+
import { NOAH_SYSTEM_PROMPT } from "../prompt/system.js";
|
|
20
|
+
import { noahBranding } from "./branding.js";
|
|
21
|
+
/** The OS tool set NOAH exposes (Pi built-ins + NOAH abstract tools). */
|
|
22
|
+
export const NOAH_TOOLS = [
|
|
23
|
+
"read",
|
|
24
|
+
"bash",
|
|
25
|
+
"edit",
|
|
26
|
+
"write",
|
|
27
|
+
"grep",
|
|
28
|
+
"find",
|
|
29
|
+
"ls",
|
|
30
|
+
"package",
|
|
31
|
+
"service",
|
|
32
|
+
"network",
|
|
33
|
+
"system",
|
|
34
|
+
"logs",
|
|
35
|
+
];
|
|
36
|
+
const CUSTOM_TOOLS = [packageTool, serviceTool, networkTool, systemTool, logsTool];
|
|
37
|
+
/** Shipped NOAH dark-blue theme (package-root /themes, resolves for src + dist). */
|
|
38
|
+
const NOAH_THEME_PATH = fileURLToPath(new URL("../../themes/noah-dark-blue.json", import.meta.url));
|
|
39
|
+
const NOAH_THEME_NAME = "noah-dark-blue";
|
|
40
|
+
export async function runNoahInteractive(opts) {
|
|
41
|
+
if (opts.dryRun)
|
|
42
|
+
process.env.NOAH_DRY_RUN = "1";
|
|
43
|
+
// Suppress Pi's startup network checks ("New version 0.79.x / Run pi update"
|
|
44
|
+
// banner and changelog box) — those are Pi's, not NOAH's. Only disables startup
|
|
45
|
+
// niceties; LLM provider requests are unaffected.
|
|
46
|
+
if (!process.env.PI_OFFLINE)
|
|
47
|
+
process.env.PI_OFFLINE = "1";
|
|
48
|
+
if (!process.env.PI_SKIP_VERSION_CHECK)
|
|
49
|
+
process.env.PI_SKIP_VERSION_CHECK = "1";
|
|
50
|
+
// Build the provider registry ONCE (local Ollama + cloud) and reuse it across
|
|
51
|
+
// session switches so we don't re-probe Ollama on every /new.
|
|
52
|
+
const { authStorage, modelRegistry } = await buildRegistry();
|
|
53
|
+
const model = resolveModel(modelRegistry, {
|
|
54
|
+
flagModel: opts.model,
|
|
55
|
+
envModel: process.env.NOAH_MODEL,
|
|
56
|
+
});
|
|
57
|
+
// All ready models are cycleable with Ctrl+P.
|
|
58
|
+
const scopedModels = modelRegistry.getAvailable().map((m) => ({ model: m }));
|
|
59
|
+
const createRuntime = async ({ cwd, sessionManager, sessionStartEvent, }) => {
|
|
60
|
+
// Activate the NOAH theme in-memory (does not touch the user's settings.json).
|
|
61
|
+
const settingsManager = SettingsManager.create(cwd, getAgentDir());
|
|
62
|
+
await settingsManager.reload();
|
|
63
|
+
settingsManager.applyOverrides({ theme: NOAH_THEME_NAME });
|
|
64
|
+
const services = await createAgentSessionServices({
|
|
65
|
+
cwd,
|
|
66
|
+
authStorage,
|
|
67
|
+
modelRegistry,
|
|
68
|
+
settingsManager,
|
|
69
|
+
resourceLoaderOptions: {
|
|
70
|
+
additionalThemePaths: [NOAH_THEME_PATH],
|
|
71
|
+
// NOAH owns its prompt; replace Pi's coding-assistant base.
|
|
72
|
+
systemPromptOverride: () => NOAH_SYSTEM_PROMPT,
|
|
73
|
+
appendSystemPromptOverride: () => [],
|
|
74
|
+
// Safety gate + audit (unbypassable) and NOAH header/footer branding.
|
|
75
|
+
extensionFactories: [
|
|
76
|
+
safetyExtension({ dryRun: opts.dryRun, autoYes: opts.autoYes }),
|
|
77
|
+
noahBranding({ dryRun: opts.dryRun }),
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
const created = await createAgentSessionFromServices({
|
|
82
|
+
services,
|
|
83
|
+
sessionManager,
|
|
84
|
+
sessionStartEvent,
|
|
85
|
+
// resolveModel returns a structural view; it is the registry's real Model.
|
|
86
|
+
model: model,
|
|
87
|
+
scopedModels,
|
|
88
|
+
tools: NOAH_TOOLS,
|
|
89
|
+
customTools: CUSTOM_TOOLS,
|
|
90
|
+
});
|
|
91
|
+
return { ...created, services, diagnostics: services.diagnostics };
|
|
92
|
+
};
|
|
93
|
+
const runtime = await createAgentSessionRuntime(createRuntime, {
|
|
94
|
+
cwd: process.cwd(),
|
|
95
|
+
agentDir: getAgentDir(),
|
|
96
|
+
sessionManager: SessionManager.create(process.cwd()),
|
|
97
|
+
});
|
|
98
|
+
const mode = new InteractiveMode(runtime, {
|
|
99
|
+
initialMessage: opts.initialMessage,
|
|
100
|
+
modelFallbackMessage: runtime.modelFallbackMessage,
|
|
101
|
+
});
|
|
102
|
+
await mode.init();
|
|
103
|
+
await mode.run();
|
|
104
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { HeaderComponent } from "./components/header.js";
|
|
2
|
+
import { NoahFooterComponent } from "./components/noah-footer.js";
|
|
3
|
+
export const noahBranding = (opts) => (pi) => {
|
|
4
|
+
const apply = (ctx) => {
|
|
5
|
+
if (!ctx.hasUI)
|
|
6
|
+
return;
|
|
7
|
+
ctx.ui.setTitle("NOAH");
|
|
8
|
+
ctx.ui.setHeader(() => new HeaderComponent());
|
|
9
|
+
ctx.ui.setFooter((tui, _theme, footerData) => new NoahFooterComponent(footerData, () => ctx.model?.id, { dryRun: opts.dryRun }, () => tui.requestRender()));
|
|
10
|
+
};
|
|
11
|
+
// session_start fires for the initial session and every /new, /resume, /fork,
|
|
12
|
+
// so branding survives session replacement.
|
|
13
|
+
pi.on("session_start", (_event, ctx) => apply(ctx));
|
|
14
|
+
};
|