nomarmy 0.1.0-alpha.2 → 0.1.0-alpha.21
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/README.md +86 -480
- package/bin/nomarmy.mjs +1081 -185
- package/docker/Dockerfile +2 -2
- package/docker/Dockerfile.go +6 -4
- package/docker/Dockerfile.rust +17 -2
- package/harnesses/_template/README.md +27 -0
- package/harnesses/_template/harness.yml +26 -0
- package/harnesses/browser-playwright/README.md +35 -0
- package/harnesses/browser-playwright/fixture/package.json +1 -0
- package/harnesses/browser-playwright/fixture/page.html +1 -0
- package/harnesses/browser-playwright/fixture/page.spec.js +5 -0
- package/harnesses/browser-playwright/fixture/playwright.config.js +8 -0
- package/harnesses/browser-playwright/harness.yml +18 -0
- package/harnesses/go/README.md +45 -0
- package/harnesses/go/harness.yml +14 -0
- package/harnesses/mock-oidc/README.md +31 -0
- package/harnesses/mock-oidc/fixture/.nomarmy.yml +4 -0
- package/harnesses/mock-oidc/fixture/discovery.test.mjs +16 -0
- package/harnesses/mock-oidc/harness.yml +19 -0
- package/harnesses/node/README.md +53 -0
- package/harnesses/node/harness.yml +18 -0
- package/harnesses/python/README.md +46 -0
- package/harnesses/python/harness.yml +16 -0
- package/harnesses/rust/README.md +45 -0
- package/harnesses/rust/harness.yml +13 -0
- package/install.sh +29 -9
- package/lib/admission.mjs +178 -30
- package/lib/agents.mjs +8 -6
- package/lib/army.mjs +25 -10
- package/lib/codex-link.mjs +37 -0
- package/lib/config.mjs +15 -0
- package/lib/connect.mjs +232 -19
- package/lib/continue-from.mjs +103 -0
- package/lib/coordinator-instructions.mjs +5 -1
- package/lib/diff-checks.mjs +114 -0
- package/lib/dispatch-schema.mjs +14 -12
- package/lib/doctor.mjs +98 -9
- package/lib/egress-proxy.mjs +116 -0
- package/lib/execute.mjs +241 -33
- package/lib/git-record.mjs +27 -3
- package/lib/harness-schema.mjs +61 -0
- package/lib/harnesses.mjs +99 -0
- package/lib/health.mjs +162 -18
- package/lib/install-freshness.mjs +114 -0
- package/lib/jev-checks.mjs +110 -0
- package/lib/job-format.mjs +54 -0
- package/lib/judge.mjs +130 -0
- package/lib/limits.mjs +77 -0
- package/lib/model-probe.mjs +61 -0
- package/lib/mutation.mjs +159 -0
- package/lib/notify.mjs +30 -3
- package/lib/openclaw-install.mjs +122 -0
- package/lib/openclaw-path.mjs +28 -0
- package/lib/openclaw-run.mjs +74 -12
- package/lib/openclaw-runtime-health.mjs +56 -0
- package/lib/outcome.mjs +21 -2
- package/lib/outcomes.mjs +6 -0
- package/lib/path-utils.mjs +4 -0
- package/lib/podman-health.mjs +41 -0
- package/lib/process.mjs +4 -1
- package/lib/propose.mjs +10 -11
- package/lib/refusal-retry.mjs +16 -0
- package/lib/registry-python.mjs +98 -0
- package/lib/registry-secrets.mjs +140 -0
- package/lib/repo-query.mjs +13 -7
- package/lib/runs.mjs +7 -1
- package/lib/same-path.mjs +14 -0
- package/lib/sandbox-images.mjs +499 -83
- package/lib/sandbox-vm.mjs +32 -0
- package/lib/scan.mjs +5 -1
- package/lib/schema.mjs +20 -11
- package/lib/scout.mjs +21 -3
- package/lib/server-context.mjs +21 -1
- package/lib/setup-steps.mjs +55 -0
- package/lib/share.mjs +82 -0
- package/lib/stale-sessions.mjs +60 -0
- package/lib/stats.mjs +315 -0
- package/lib/statusline.mjs +32 -6
- package/lib/subscription-setup.mjs +13 -0
- package/lib/suggestions.mjs +153 -0
- package/lib/thinking.mjs +23 -0
- package/lib/transcript.mjs +30 -5
- package/lib/usage-limits.mjs +329 -0
- package/lib/user-config.mjs +106 -0
- package/lib/validators.mjs +220 -0
- package/lib/verification-artifacts.mjs +46 -0
- package/lib/verification-flow.mjs +52 -7
- package/lib/verification-network.mjs +66 -0
- package/lib/verify.mjs +338 -85
- package/lib/worker-prompt.mjs +5 -2
- package/lib/wsl-cli.mjs +152 -0
- package/lib/wsl.mjs +230 -0
- package/lib/zod-issues.mjs +15 -0
- package/mcp/server.mjs +165 -34
- package/package.json +7 -5
- package/playbooks/feature.md +8 -5
- package/scripts/configure-openclaw.sh +4 -2
- package/scripts/generate-harness-docs.mjs +42 -0
- package/scripts/install-openclaw.mjs +23 -0
- package/scripts/lib.sh +9 -2
- package/scripts/select-model.mjs +12 -5
- package/scripts/start-inference.sh +2 -2
package/lib/transcript.mjs
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// Estimates are labeled as such and use the same 4-chars-per-token rule as
|
|
15
15
|
// the budgets; the point is the sign and the order of magnitude.
|
|
16
16
|
import fs from "node:fs";
|
|
17
|
+
import zlib from "node:zlib";
|
|
17
18
|
import path from "node:path";
|
|
18
19
|
import { CALIBRATED } from "./budget.mjs";
|
|
19
20
|
|
|
@@ -33,6 +34,27 @@ export function findTranscriptDb(stateDir) {
|
|
|
33
34
|
return null;
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
/**
|
|
38
|
+
* One transcript row's event. OpenClaw 2026.9.6 stores larger events
|
|
39
|
+
* zstd-compressed in event_zstd with event_json null; reading event_json alone
|
|
40
|
+
* missed 85 of a Grok scout's 140 events (the file reads and command output
|
|
41
|
+
* report recovery and the idle breaker rely on). Null when the row can't be
|
|
42
|
+
* read: a torn row, or a Node without zstd (before 22.15).
|
|
43
|
+
*/
|
|
44
|
+
export function eventFromRow(row) {
|
|
45
|
+
try {
|
|
46
|
+
if (row.event_json != null) return JSON.parse(row.event_json);
|
|
47
|
+
if (row.event_zstd != null && typeof zlib.zstdDecompressSync === "function") return JSON.parse(zlib.zstdDecompressSync(Buffer.from(row.event_zstd)).toString("utf8"));
|
|
48
|
+
} catch { /* torn or unreadable */ }
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// event_zstd arrived with OpenClaw 2026.9.6; older databases don't have it.
|
|
53
|
+
function eventColumns(db) {
|
|
54
|
+
try { return db.prepare("select name from pragma_table_info('transcript_events')").all().some((c) => c.name === "event_zstd") ? "event_json, event_zstd" : "event_json"; }
|
|
55
|
+
catch { return "event_json"; }
|
|
56
|
+
}
|
|
57
|
+
|
|
36
58
|
/**
|
|
37
59
|
* Reduce raw transcript events to what the coordinator cares about. Pure:
|
|
38
60
|
* takes the parsed `event_json` objects in order.
|
|
@@ -99,6 +121,9 @@ export function summarizeTranscriptEvents(events) {
|
|
|
99
121
|
}
|
|
100
122
|
}
|
|
101
123
|
out.filesRead = [...new Set(out.filesRead)];
|
|
124
|
+
// A tool call with no result yet: the worker is waiting on it (a long test
|
|
125
|
+
// run writes nothing to the transcript until it returns).
|
|
126
|
+
out.toolInFlight = pending.length > 0;
|
|
102
127
|
return out;
|
|
103
128
|
}
|
|
104
129
|
|
|
@@ -116,9 +141,9 @@ export async function readOpenClawTranscript(stateDir) {
|
|
|
116
141
|
try {
|
|
117
142
|
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
118
143
|
try {
|
|
119
|
-
const rows = db.prepare(
|
|
144
|
+
const rows = db.prepare(`select ${eventColumns(db)} from transcript_events order by seq, rowid`).all();
|
|
120
145
|
const events = [];
|
|
121
|
-
for (const r of rows) {
|
|
146
|
+
for (const r of rows) { const e = eventFromRow(r); if (e) events.push(e); }
|
|
122
147
|
return { available: true, reason: null, dbPath, events: events.length, ...summarizeTranscriptEvents(events) };
|
|
123
148
|
} finally { db.close(); }
|
|
124
149
|
} catch (error) {
|
|
@@ -161,10 +186,10 @@ export async function readOpenClawTranscriptTail(stateDir, { limit = 40, sinceEv
|
|
|
161
186
|
try {
|
|
162
187
|
const total = Number(db.prepare("select count(*) as n from transcript_events").get()?.n ?? 0);
|
|
163
188
|
const rows = sinceEvent !== null
|
|
164
|
-
? db.prepare(
|
|
165
|
-
: limit > 0 ? db.prepare(
|
|
189
|
+
? db.prepare(`select ${eventColumns(db)} from transcript_events order by rowid limit -1 offset ?`).all(sinceEvent)
|
|
190
|
+
: limit > 0 ? db.prepare(`select ${eventColumns(db)} from transcript_events order by rowid desc limit ?`).all(limit).reverse() : [];
|
|
166
191
|
const events = [];
|
|
167
|
-
for (const r of rows) {
|
|
192
|
+
for (const r of rows) { const e = eventFromRow(r); if (e) events.push(e); }
|
|
168
193
|
return { available: true, reason: null, dbPath, events: total, ...summarizeTranscriptEvents(events) };
|
|
169
194
|
} finally { db.close(); }
|
|
170
195
|
} catch (error) {
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { isDeepStrictEqual, stripVTControlCharacters } from "node:util";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
// How much of each subscription's usage limit is used, as its vendor reports
|
|
8
|
+
// it: Codex writes rate_limits into every job's session log, and Claude Code
|
|
9
|
+
// passes them to the status line. Kept per OpenClaw provider id (one host
|
|
10
|
+
// login per provider) in <stateRoot>/usage-limits.json. Admission holds a job
|
|
11
|
+
// on an agent that's over its limit until the General confirms.
|
|
12
|
+
//
|
|
13
|
+
// Those readings only update when a job finishes. A reading taken before a
|
|
14
|
+
// reset, or before the operator bought more usage, would then hold every
|
|
15
|
+
// job, and the reading would never refresh. A reading older than a few
|
|
16
|
+
// minutes is refreshed from `openclaw models status` before that hold, and
|
|
17
|
+
// again when health or the army/capacity views show it. The refresh is
|
|
18
|
+
// async: spawnSync would freeze the server's event loop.
|
|
19
|
+
|
|
20
|
+
const object = value => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
21
|
+
const finite = value => typeof value === "number" && Number.isFinite(value);
|
|
22
|
+
const windowName = minutes => ({ 300: "5h", 10080: "week", 1440: "day" })[minutes] ?? `${minutes}m`;
|
|
23
|
+
const USAGE_SOURCES = ["codex", "claude", "openclaw"];
|
|
24
|
+
// Older than this, an over-limit reading is refreshed before it holds a job.
|
|
25
|
+
export const USAGE_STALE_MINUTES = 10;
|
|
26
|
+
// Source observations age out independently, even when the reset stays the same.
|
|
27
|
+
export const CLAUDE_USAGE_SOURCE_TTL_MS = 30 * 60 * 1000;
|
|
28
|
+
const CLAUDE_USAGE_SOURCE_REFRESH_MS = 5 * 60 * 1000;
|
|
29
|
+
export const USAGE_REFRESH_TIMEOUT_MS = 20000;
|
|
30
|
+
const RESET_UNIT_MS = { d: 86400000, h: 3600000, m: 60000, s: 1000 };
|
|
31
|
+
function window(value, name, minutes, percentKey) {
|
|
32
|
+
if (!object(value) || !finite(value[percentKey]) || value[percentKey] < 0) return null;
|
|
33
|
+
return { name, usedPercent: value[percentKey], windowMinutes: minutes,
|
|
34
|
+
resetsAt: finite(value.resets_at) ? value.resets_at * 1000 : null };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Read the last rate-limit event from the newest rollout (by modification time). */
|
|
38
|
+
export function readCodexRateLimits(jobDir) {
|
|
39
|
+
try {
|
|
40
|
+
const files = [];
|
|
41
|
+
function walk(dir) {
|
|
42
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
43
|
+
const file = path.join(dir, entry.name);
|
|
44
|
+
if (entry.isDirectory()) walk(file);
|
|
45
|
+
else if (entry.isFile() && /^rollout-.*\.jsonl$/.test(entry.name)) files.push({ file, mtime: fs.statSync(file).mtimeMs });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const agents = path.join(jobDir, "runtime/state/agents");
|
|
49
|
+
for (const agent of fs.readdirSync(agents, { withFileTypes: true })) {
|
|
50
|
+
if (!agent.isDirectory()) continue;
|
|
51
|
+
const sessions = path.join(agents, agent.name, "agent/codex-home/sessions");
|
|
52
|
+
if (fs.existsSync(sessions)) walk(sessions);
|
|
53
|
+
}
|
|
54
|
+
files.sort((a, b) => b.mtime - a.mtime || b.file.localeCompare(a.file));
|
|
55
|
+
if (!files.length) return null;
|
|
56
|
+
let last = null;
|
|
57
|
+
for (const line of fs.readFileSync(files[0].file, "utf8").split(/\r?\n/)) {
|
|
58
|
+
try {
|
|
59
|
+
const event = JSON.parse(line);
|
|
60
|
+
if (event.type === "event_msg" && event.payload?.type === "token_count" && object(event.payload.rate_limits)) last = event;
|
|
61
|
+
} catch { /* A partial trailing write is not an event. */ }
|
|
62
|
+
}
|
|
63
|
+
if (!last) return null;
|
|
64
|
+
const limits = last.payload.rate_limits;
|
|
65
|
+
const windows = [limits.primary, limits.secondary].map(value => {
|
|
66
|
+
const minutes = finite(value?.window_minutes) ? value.window_minutes : null;
|
|
67
|
+
return window(value, minutes === null ? "unknown" : windowName(minutes), minutes, "used_percent");
|
|
68
|
+
}).filter(Boolean);
|
|
69
|
+
const limitReached = limits.rate_limit_reached_type != null || Boolean(limits.spend_control_reached);
|
|
70
|
+
if (!windows.length && !limitReached) return null;
|
|
71
|
+
const timestamp = Date.parse(last.timestamp);
|
|
72
|
+
return { source: "codex", plan: typeof limits.plan_type === "string" ? limits.plan_type : null,
|
|
73
|
+
limitReached, observedAt: Number.isFinite(timestamp) ? timestamp : files[0].mtime, windows };
|
|
74
|
+
} catch { return null; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function normalizeClaudeRateLimits(rateLimits) {
|
|
78
|
+
if (!object(rateLimits)) return null;
|
|
79
|
+
const windows = [["five_hour", "5h", 300], ["seven_day", "week", 10080], ["spend_limit", "spend", null]]
|
|
80
|
+
.map(([key, name, minutes]) => window(rateLimits[key], name, minutes, "used_percentage")).filter(Boolean);
|
|
81
|
+
// Claude supplies percentages, not an independent provider-wide reached flag.
|
|
82
|
+
return windows.length ? { source: "claude", plan: null, limitReached: false, observedAt: Date.now(), windows } : null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Maximum of the recent sources for one reset window. */
|
|
86
|
+
function recentUsageWindow(w, observedAt, now, sourceTtlMs) {
|
|
87
|
+
if (w.resetsAt !== null && w.resetsAt <= now) return null;
|
|
88
|
+
const sources = (w.sources ?? [{ usedPercent: w.usedPercent, observedAt }])
|
|
89
|
+
.filter(s => now - s.observedAt <= sourceTtlMs);
|
|
90
|
+
if (!sources.length) return null;
|
|
91
|
+
return { ...w, usedPercent: Math.max(...sources.map(s => s.usedPercent)), sources };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Keep the latest observation per source and window. At the same reset,
|
|
96
|
+
* recent sources compete by percentage; a later reset replaces all sources
|
|
97
|
+
* for the earlier reset. Legacy snapshots count as one source at observedAt.
|
|
98
|
+
* Anonymous readings each get their own source. Unchanged redraws refresh
|
|
99
|
+
* source timestamps at most once every five minutes, before sources expire.
|
|
100
|
+
*/
|
|
101
|
+
export function mergeUsageSnapshot(previous, incoming, now = Date.now(), sourceTtlMs = CLAUDE_USAGE_SOURCE_TTL_MS) {
|
|
102
|
+
const sourceId = typeof incoming.sourceId === "string" && incoming.sourceId ? incoming.sourceId : randomUUID();
|
|
103
|
+
const legacyId = previous?.sourceId ?? randomUUID();
|
|
104
|
+
const byName = new Map((previous?.windows ?? [])
|
|
105
|
+
.filter(w => w.resetsAt === null || w.resetsAt > now)
|
|
106
|
+
.map(w => [w.name, { ...w, sources: w.sources ?? [{ sourceId: legacyId, observedAt: previous.observedAt, usedPercent: w.usedPercent }] }]));
|
|
107
|
+
for (const w of incoming.windows) {
|
|
108
|
+
if (w.resetsAt !== null && w.resetsAt <= now) continue;
|
|
109
|
+
const stored = byName.get(w.name);
|
|
110
|
+
if (stored && (stored.resetsAt ?? 0) > (w.resetsAt ?? 0)) continue;
|
|
111
|
+
const sources = stored && stored.resetsAt === w.resetsAt ? stored.sources : [];
|
|
112
|
+
const last = sources.find(s => s.sourceId === sourceId);
|
|
113
|
+
if (last && last.observedAt > incoming.observedAt) continue;
|
|
114
|
+
if (last && last.usedPercent === w.usedPercent && stored.windowMinutes === w.windowMinutes
|
|
115
|
+
&& incoming.observedAt - last.observedAt < Math.min(CLAUDE_USAGE_SOURCE_REFRESH_MS, sourceTtlMs / 2)) continue;
|
|
116
|
+
byName.set(w.name, { ...w, sources: [
|
|
117
|
+
...sources.filter(s => s.sourceId !== sourceId),
|
|
118
|
+
{ sourceId, observedAt: incoming.observedAt, usedPercent: w.usedPercent },
|
|
119
|
+
] });
|
|
120
|
+
}
|
|
121
|
+
const windows = [...byName.values()].map(w => recentUsageWindow(w, incoming.observedAt, now, sourceTtlMs)).filter(Boolean);
|
|
122
|
+
const { sourceId: ignored, ...snapshot } = incoming;
|
|
123
|
+
const merged = { ...snapshot, limitReached: incoming.limitReached || Boolean(previous?.limitReached), observedAt: now, windows };
|
|
124
|
+
// Keep identity for callers that avoid writes when only the redraw time changed.
|
|
125
|
+
return previous && isDeepStrictEqual({ ...merged, observedAt: previous.observedAt }, previous) ? previous : merged;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function readUsageSnapshots(stateRoot) {
|
|
129
|
+
try {
|
|
130
|
+
const data = JSON.parse(fs.readFileSync(path.join(stateRoot, "usage-limits.json"), "utf8"));
|
|
131
|
+
if (!object(data)) return {};
|
|
132
|
+
return Object.fromEntries(Object.entries(data).filter(([, s]) => object(s) && USAGE_SOURCES.includes(s.source)
|
|
133
|
+
&& finite(s.observedAt) && typeof s.limitReached === "boolean" && (s.plan === null || typeof s.plan === "string")
|
|
134
|
+
&& Array.isArray(s.windows) && s.windows.every(w => object(w) && typeof w.name === "string" && finite(w.usedPercent)
|
|
135
|
+
&& (w.windowMinutes === null || finite(w.windowMinutes)) && (w.resetsAt === null || finite(w.resetsAt))
|
|
136
|
+
&& (w.sources === undefined || (Array.isArray(w.sources) && w.sources.every(s => object(s)
|
|
137
|
+
&& typeof s.sourceId === "string" && finite(s.observedAt) && finite(s.usedPercent) && s.usedPercent >= 0))))));
|
|
138
|
+
} catch { return {}; }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Save a provider's snapshot, unless the one on file is newer (jobs finish out of order). */
|
|
142
|
+
export function recordUsageSnapshot(stateRoot, provider, snapshot) {
|
|
143
|
+
const current = readUsageSnapshots(stateRoot);
|
|
144
|
+
if (current[provider]?.observedAt > snapshot.observedAt) return;
|
|
145
|
+
const snapshots = { ...current, [provider]: snapshot };
|
|
146
|
+
fs.mkdirSync(stateRoot, { recursive: true });
|
|
147
|
+
const file = path.join(stateRoot, "usage-limits.json"), tmp = `${file}.${randomUUID()}.tmp`;
|
|
148
|
+
try {
|
|
149
|
+
fs.writeFileSync(tmp, JSON.stringify(snapshots, null, 2));
|
|
150
|
+
fs.renameSync(tmp, file);
|
|
151
|
+
} finally { fs.rmSync(tmp, { force: true }); }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function usageStatus(snapshot, now = Date.now(), sourceTtlMs = CLAUDE_USAGE_SOURCE_TTL_MS) {
|
|
155
|
+
const windows = snapshot.source === "claude"
|
|
156
|
+
? snapshot.windows.map(w => recentUsageWindow(w, snapshot.observedAt, now, sourceTtlMs)).filter(Boolean)
|
|
157
|
+
: snapshot.windows;
|
|
158
|
+
const live = windows.filter(w => w.resetsAt === null || w.resetsAt > now).sort((a, b) => b.usedPercent - a.usedPercent);
|
|
159
|
+
const reached = snapshot.limitReached && (snapshot.windows.length === 0 || live.length > 0);
|
|
160
|
+
const highest = live[0];
|
|
161
|
+
const level = reached || highest?.usedPercent >= 100 ? "over" : highest?.usedPercent >= 80 ? "high" : "ok";
|
|
162
|
+
const text = live.length ? live.map(w => `${w.usedPercent}% of ${w.name}, resets ${w.resetsAt === null ? "unknown" : new Date(w.resetsAt).toLocaleString("en-US", { weekday: "short", hour: "2-digit", minute: "2-digit", hour12: false })}`).join("; ") : reached ? "limit reached, reset unknown" : "no live usage windows";
|
|
163
|
+
// A short label for tight spaces (status line, health): "85% wk".
|
|
164
|
+
const short = highest ? `${highest.usedPercent}% ${highest.name === "week" ? "wk" : highest.name}` : reached ? "limit" : null;
|
|
165
|
+
return { level, text, short, resetsAt: level === "over" ? highest?.resetsAt ?? null : null,
|
|
166
|
+
ageMinutes: Math.max(0, Math.floor((now - snapshot.observedAt) / 60000)) };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** An over-limit reading old enough that the window may already have reset. */
|
|
170
|
+
export function usageReadingIsStale(status) {
|
|
171
|
+
return status?.level === "over" && status.ageMinutes >= USAGE_STALE_MINUTES;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** View text. A stale over-limit reading is marked; a fresh one is unchanged. */
|
|
175
|
+
export function usageDisplayText(status) {
|
|
176
|
+
if (!usageReadingIsStale(status)) return status.text;
|
|
177
|
+
return `${status.text}; possibly stale (${status.ageMinutes} minutes old)`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function staleUsageRefreshedNote(provider) {
|
|
181
|
+
return `stale usage reading for ${provider} was refreshed from OpenClaw`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function refreshFailureMessage(error) {
|
|
185
|
+
const message = String(error?.message ?? error ?? "");
|
|
186
|
+
return /timed out|timeout/i.test(message) ? "OpenClaw usage refresh failed (timed out)" : "OpenClaw usage refresh failed";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function withTimeout(promise, timeoutMs) {
|
|
190
|
+
return new Promise((resolve, reject) => {
|
|
191
|
+
const timer = setTimeout(() => reject(new Error("OpenClaw usage refresh timed out")), timeoutMs);
|
|
192
|
+
Promise.resolve(promise).then(
|
|
193
|
+
(value) => { clearTimeout(timer); resolve(value); },
|
|
194
|
+
(error) => { clearTimeout(timer); reject(error); },
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Async `openclaw` runner. Never spawnSync: that blocks every other request. */
|
|
200
|
+
export function runOpenClawStatus(cmd, args, { timeoutMs = USAGE_REFRESH_TIMEOUT_MS } = {}) {
|
|
201
|
+
return new Promise((resolve) => {
|
|
202
|
+
execFile(cmd, args, { encoding: "utf8", timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024, windowsHide: true }, (error, stdout) => {
|
|
203
|
+
resolve({ ok: !error, stdout: stdout ?? "", error: error ? String(error.message ?? error) : null });
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function windowFromLabel(label) {
|
|
209
|
+
const text = String(label ?? "").trim();
|
|
210
|
+
const hours = /^(\d+(?:\.\d+)?)h$/i.exec(text);
|
|
211
|
+
if (hours) {
|
|
212
|
+
const minutes = Math.round(Number(hours[1]) * 60);
|
|
213
|
+
return { name: windowName(minutes), windowMinutes: minutes };
|
|
214
|
+
}
|
|
215
|
+
const days = /^(\d+(?:\.\d+)?)d$/i.exec(text);
|
|
216
|
+
if (days) {
|
|
217
|
+
const minutes = Math.round(Number(days[1]) * 1440);
|
|
218
|
+
return { name: windowName(minutes), windowMinutes: minutes };
|
|
219
|
+
}
|
|
220
|
+
const mins = /^(\d+(?:\.\d+)?)m$/i.exec(text);
|
|
221
|
+
if (mins) {
|
|
222
|
+
const minutes = Math.round(Number(mins[1]));
|
|
223
|
+
return { name: windowName(minutes), windowMinutes: minutes };
|
|
224
|
+
}
|
|
225
|
+
const named = { week: 10080, day: 1440, "5h": 300, spend: null };
|
|
226
|
+
if (Object.hasOwn(named, text)) return { name: text === "week" || text === "day" || text === "5h" ? windowName(named[text]) : text, windowMinutes: named[text] };
|
|
227
|
+
return { name: text || "unknown", windowMinutes: null };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function resetAtFromDelay(delay, now) {
|
|
231
|
+
if (delay == null || delay === "") return null;
|
|
232
|
+
if (typeof delay === "number" && Number.isFinite(delay)) {
|
|
233
|
+
if (delay > 1e12) return delay;
|
|
234
|
+
if (delay > 1e9) return delay * 1000;
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
const text = String(delay).trim();
|
|
238
|
+
if (!text) return null;
|
|
239
|
+
if (/^\d{4}-\d{2}-\d{2}/.test(text)) {
|
|
240
|
+
const iso = Date.parse(text);
|
|
241
|
+
return Number.isFinite(iso) ? iso : null;
|
|
242
|
+
}
|
|
243
|
+
let ms = 0, matched = false;
|
|
244
|
+
for (const part of text.matchAll(/(\d+(?:\.\d+)?)\s*([dhms])/gi)) {
|
|
245
|
+
matched = true;
|
|
246
|
+
ms += Number(part[1]) * RESET_UNIT_MS[part[2].toLowerCase()];
|
|
247
|
+
}
|
|
248
|
+
return matched ? now + ms : null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function snapshotFromWindows(windows, now) {
|
|
252
|
+
if (!windows.length) return null;
|
|
253
|
+
return { source: "openclaw", plan: null, limitReached: false, observedAt: now, windows };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function parseUsageRemainder(remainder, now) {
|
|
257
|
+
const windows = [];
|
|
258
|
+
const re = /(\S+)\s+(\d+(?:\.\d+)?)%\s+left(?:\s*⏱\uFE0F?\s*((?:\d+(?:\.\d+)?\s*[dhms]\s*)+))?/gi;
|
|
259
|
+
for (const match of String(remainder).matchAll(re)) {
|
|
260
|
+
const built = windowFromLabel(match[1]);
|
|
261
|
+
const usedPercent = 100 - Number(match[2]);
|
|
262
|
+
if (!Number.isFinite(usedPercent) || usedPercent < 0) continue;
|
|
263
|
+
windows.push({ name: built.name, usedPercent, windowMinutes: built.windowMinutes, resetsAt: resetAtFromDelay(match[3] ?? null, now) });
|
|
264
|
+
}
|
|
265
|
+
return windows;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function parseTextUsage(raw, now) {
|
|
269
|
+
const out = {};
|
|
270
|
+
const lineRe = /(?:^|[\n"\s\u2500-\u257f])([A-Za-z0-9._-]+)\s+usage:\s+([^"\n]+)/g;
|
|
271
|
+
for (const line of String(raw).matchAll(lineRe)) {
|
|
272
|
+
const snapshot = snapshotFromWindows(parseUsageRemainder(line[2], now), now);
|
|
273
|
+
if (snapshot) out[line[1]] = snapshot;
|
|
274
|
+
}
|
|
275
|
+
return out;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* OpenClaw usage from plain-text `models status`:
|
|
280
|
+
* `- <provider> usage: <window> <N>% left ⏱<duration>`.
|
|
281
|
+
* "N% left" becomes used percent (100 - N). Returns provider -> snapshot.
|
|
282
|
+
* The JSON status output does not include usage readings.
|
|
283
|
+
*/
|
|
284
|
+
export function parseOpenClawUsageOutput(raw, now = Date.now()) {
|
|
285
|
+
return parseTextUsage(stripVTControlCharacters(String(raw ?? "")), now);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Ask OpenClaw for current usage without blocking the event loop. */
|
|
289
|
+
export async function fetchOpenClawUsage({ run = runOpenClawStatus, now = Date.now(), timeoutMs = USAGE_REFRESH_TIMEOUT_MS, openclawCmd = process.env.NOMARMY_OPENCLAW_CMD || "openclaw" } = {}) {
|
|
290
|
+
const failure = (error) => ({ ok: false, snapshots: {}, error: refreshFailureMessage(error) });
|
|
291
|
+
try {
|
|
292
|
+
return await withTimeout((async () => {
|
|
293
|
+
const result = await run(openclawCmd, ["models", "status"], { timeoutMs });
|
|
294
|
+
// Partial stdout from a failed command is not a fresh usage reading.
|
|
295
|
+
if (!result?.ok) return failure(result?.error);
|
|
296
|
+
const snapshots = parseOpenClawUsageOutput(result.stdout, now);
|
|
297
|
+
return Object.keys(snapshots).length ? { ok: true, snapshots, error: null } : failure(null);
|
|
298
|
+
})(), timeoutMs);
|
|
299
|
+
} catch (error) { return failure(error); }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Refresh over-limit readings older than USAGE_STALE_MINUTES. No OpenClaw
|
|
304
|
+
* call when nothing is stale. A failed refresh leaves the stored readings
|
|
305
|
+
* and says so. `snapshots` lets a caller pass what it already read.
|
|
306
|
+
*/
|
|
307
|
+
export async function refreshStaleOverLimitReadings(stateRoot, { run, now = Date.now(), snapshots = null, timeoutMs = USAGE_REFRESH_TIMEOUT_MS, openclawCmd } = {}) {
|
|
308
|
+
const current = snapshots ?? (stateRoot ? readUsageSnapshots(stateRoot) : {});
|
|
309
|
+
const stale = Object.entries(current).filter(([, snapshot]) => usageReadingIsStale(usageStatus(snapshot, now))).map(([provider]) => provider);
|
|
310
|
+
if (!stale.length) return { called: false, ok: true, snapshots: current, error: null, failedProviders: [] };
|
|
311
|
+
let fetched;
|
|
312
|
+
try { fetched = await fetchOpenClawUsage({ run, now, timeoutMs, openclawCmd }); }
|
|
313
|
+
catch (error) { return { called: true, ok: false, snapshots: current, error: refreshFailureMessage(error), failedProviders: stale }; }
|
|
314
|
+
if (!fetched.ok) return { called: true, ok: false, snapshots: current, error: fetched.error ?? "OpenClaw usage refresh failed", failedProviders: stale };
|
|
315
|
+
const updated = { ...current };
|
|
316
|
+
const seen = new Set();
|
|
317
|
+
for (const provider of stale) {
|
|
318
|
+
const snapshot = fetched.snapshots[provider];
|
|
319
|
+
if (!snapshot) continue;
|
|
320
|
+
try {
|
|
321
|
+
if (stateRoot) recordUsageSnapshot(stateRoot, provider, snapshot);
|
|
322
|
+
// A job may have saved a newer observation while the refresh was pending.
|
|
323
|
+
updated[provider] = stateRoot ? readUsageSnapshots(stateRoot)[provider] ?? snapshot : snapshot;
|
|
324
|
+
seen.add(provider);
|
|
325
|
+
} catch { /* Keep the hold if the fresh snapshot could not be recorded. */ }
|
|
326
|
+
}
|
|
327
|
+
const failedProviders = stale.filter((provider) => !seen.has(provider));
|
|
328
|
+
return { called: true, ok: failedProviders.length === 0, snapshots: updated, error: failedProviders.length ? "OpenClaw usage refresh failed" : null, failedProviders };
|
|
329
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Your setup choices (where models run, the local model, hardware profiles)
|
|
2
|
+
// live in ~/.config/nomarmy/, layered over the package's config/ defaults.
|
|
3
|
+
// They used to be written into config/ itself, which for an npm install is
|
|
4
|
+
// inside the package: every `nomarmy update` put them back to the defaults
|
|
5
|
+
// (found in a fresh-install practice run: hosted silently became local).
|
|
6
|
+
//
|
|
7
|
+
// ~/.config/nomarmy/common.env your overrides of config/common.env
|
|
8
|
+
// ~/.config/nomarmy/profiles/<name>.env your profiles, used before config/profiles/
|
|
9
|
+
//
|
|
10
|
+
// scripts/lib.sh layers the same files the same way.
|
|
11
|
+
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { globalConfigDir } from "./army.mjs";
|
|
15
|
+
|
|
16
|
+
export function userCommonPath(env = process.env) { return path.join(globalConfigDir(env), "common.env"); }
|
|
17
|
+
export function userProfilePath(name, env = process.env) { return path.join(globalConfigDir(env), "profiles", `${name}.env`); }
|
|
18
|
+
|
|
19
|
+
export function parseEnvFile(filePath) {
|
|
20
|
+
const out = {};
|
|
21
|
+
let text;
|
|
22
|
+
try { text = fs.readFileSync(filePath, "utf8"); } catch { return out; }
|
|
23
|
+
for (const line of text.split("\n")) {
|
|
24
|
+
const m = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line.trim());
|
|
25
|
+
if (m) out[m[1]] = m[2].trim();
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Your value, else the package default. */
|
|
31
|
+
export function readSetting(key, { nomarmyRoot, env = process.env }) {
|
|
32
|
+
const mine = parseEnvFile(userCommonPath(env));
|
|
33
|
+
if (key in mine) return mine[key];
|
|
34
|
+
return parseEnvFile(path.join(nomarmyRoot, "config", "common.env"))[key] ?? null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Every common setting, package defaults with yours on top. */
|
|
38
|
+
export function readSettings({ nomarmyRoot, env = process.env }) {
|
|
39
|
+
return { ...parseEnvFile(path.join(nomarmyRoot, "config", "common.env")), ...parseEnvFile(userCommonPath(env)) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A profile's file: yours if you have one by that name, else the shipped one (null if neither). */
|
|
43
|
+
export function profilePathFor(name, { nomarmyRoot, env = process.env }) {
|
|
44
|
+
if (!name) return null;
|
|
45
|
+
const mine = userProfilePath(name, env);
|
|
46
|
+
if (fs.existsSync(mine)) return mine;
|
|
47
|
+
const shipped = path.join(nomarmyRoot, "config", "profiles", `${name}.env`);
|
|
48
|
+
return fs.existsSync(shipped) ? shipped : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const HEADER = "# Your nomArmy settings, layered over the package's config/common.env.\n# Written by nomarmy setup and nomarmy model; kept across updates.\n";
|
|
52
|
+
|
|
53
|
+
/** Set one KEY=VALUE in a settings file, replacing it if present. */
|
|
54
|
+
export function writeEnvLine(filePath, key, value, { header = HEADER } = {}) {
|
|
55
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
56
|
+
const existing = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : header;
|
|
57
|
+
const line = `${key}=${value}`;
|
|
58
|
+
const re = new RegExp(`^${key}=.*$`, "m");
|
|
59
|
+
const updated = re.test(existing) ? existing.replace(re, line) : `${existing.trimEnd()}\n${line}\n`.replace(/^\n/, "");
|
|
60
|
+
fs.writeFileSync(filePath, updated);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function writeSetting(key, value, { env = process.env } = {}) {
|
|
64
|
+
writeEnvLine(userCommonPath(env), key, value);
|
|
65
|
+
return userCommonPath(env);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* One-time rescue, run before an update's copy replaces the installed one:
|
|
70
|
+
* settings that only ever lived in the package (or its installed copy) move
|
|
71
|
+
* to ~/.config/nomarmy/. A key is moved when the old copy's value differs from
|
|
72
|
+
* the new package's default; a profile when the new package doesn't ship it
|
|
73
|
+
* or ships it differently. Never overwrites what you already have there.
|
|
74
|
+
* @returns {{ keys: string[], profiles: string[] }}
|
|
75
|
+
*/
|
|
76
|
+
export function migrateUserConfig({ oldConfigDir, nomarmyRoot, env = process.env }) {
|
|
77
|
+
const moved = { keys: [], profiles: [] };
|
|
78
|
+
if (!oldConfigDir || !fs.existsSync(oldConfigDir)) return moved;
|
|
79
|
+
if (path.resolve(oldConfigDir) === path.resolve(path.join(nomarmyRoot, "config"))) return moved;
|
|
80
|
+
const old = parseEnvFile(path.join(oldConfigDir, "common.env"));
|
|
81
|
+
const shipped = parseEnvFile(path.join(nomarmyRoot, "config", "common.env"));
|
|
82
|
+
const mine = parseEnvFile(userCommonPath(env));
|
|
83
|
+
for (const [key, value] of Object.entries(old)) {
|
|
84
|
+
if (key in mine || shipped[key] === value) continue;
|
|
85
|
+
writeSetting(key, value, { env });
|
|
86
|
+
moved.keys.push(key);
|
|
87
|
+
}
|
|
88
|
+
let names = [];
|
|
89
|
+
try { names = fs.readdirSync(path.join(oldConfigDir, "profiles")).filter((f) => f.endsWith(".env")); } catch { /* none */ }
|
|
90
|
+
for (const file of names) {
|
|
91
|
+
const target = userProfilePath(path.basename(file, ".env"), env);
|
|
92
|
+
if (fs.existsSync(target)) continue;
|
|
93
|
+
const text = fs.readFileSync(path.join(oldConfigDir, "profiles", file), "utf8");
|
|
94
|
+
const shippedFile = path.join(nomarmyRoot, "config", "profiles", file);
|
|
95
|
+
if (fs.existsSync(shippedFile) && fs.readFileSync(shippedFile, "utf8") === text) continue;
|
|
96
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
97
|
+
fs.writeFileSync(target, text);
|
|
98
|
+
moved.profiles.push(path.basename(file, ".env"));
|
|
99
|
+
}
|
|
100
|
+
return moved;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A path for messages: ~ for the home directory. */
|
|
104
|
+
export function tildePath(p, home = process.env.HOME) {
|
|
105
|
+
return home && p.startsWith(home + path.sep) ? `~${p.slice(home.length)}` : p;
|
|
106
|
+
}
|