nothumanallowed 16.0.63 → 17.0.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/README.md +98 -26
- package/bin/nha +3 -2
- package/package.json +7 -4
- package/src/bootstrap.mjs +30 -66
- package/src/cli.mjs +62 -20
- package/src/commands/autostart.mjs +1 -1
- package/src/commands/chat.mjs +1 -1
- package/src/commands/ops.mjs +1 -1
- package/src/config.mjs +37 -1
- package/src/constants.mjs +8 -2
- package/src/downloader.mjs +8 -3
- package/src/legion/agents/ade.mjs +201 -0
- package/src/legion/agents/athena.mjs +162 -0
- package/src/legion/agents/atlas.mjs +159 -0
- package/src/legion/agents/babel.mjs +167 -0
- package/src/legion/agents/cartographer.mjs +213 -0
- package/src/legion/agents/cassandra.mjs +185 -0
- package/src/legion/agents/conductor.mjs +183 -0
- package/src/legion/agents/cron.mjs +165 -0
- package/src/legion/agents/echo.mjs +165 -0
- package/src/legion/agents/edi.mjs +166 -0
- package/src/legion/agents/epicure.mjs +211 -0
- package/src/legion/agents/flux.mjs +165 -0
- package/src/legion/agents/forge.mjs +166 -0
- package/src/legion/agents/glitch.mjs +169 -0
- package/src/legion/agents/heimdall.mjs +171 -0
- package/src/legion/agents/herald.mjs +208 -0
- package/src/legion/agents/hermes.mjs +163 -0
- package/src/legion/agents/jarvis.mjs +163 -0
- package/src/legion/agents/link.mjs +163 -0
- package/src/legion/agents/logos.mjs +171 -0
- package/src/legion/agents/macro.mjs +158 -0
- package/src/legion/agents/mercury.mjs +234 -0
- package/src/legion/agents/murasaki.mjs +163 -0
- package/src/legion/agents/muse.mjs +217 -0
- package/src/legion/agents/navi.mjs +161 -0
- package/src/legion/agents/oracle.mjs +165 -0
- package/src/legion/agents/pipe.mjs +160 -0
- package/src/legion/agents/polyglot.mjs +226 -0
- package/src/legion/agents/prometheus.mjs +171 -0
- package/src/legion/agents/quill.mjs +165 -0
- package/src/legion/agents/saber.mjs +162 -0
- package/src/legion/agents/sauron.mjs +173 -0
- package/src/legion/agents/scheherazade.mjs +179 -0
- package/src/legion/agents/shell.mjs +166 -0
- package/src/legion/agents/shogun.mjs +163 -0
- package/src/legion/agents/tempest.mjs +211 -0
- package/src/legion/agents/veritas.mjs +166 -0
- package/src/legion/agents/zero.mjs +162 -0
- package/src/legion/legion-x.mjs +12114 -0
- package/src/legion-bundle.mjs +64 -0
- package/src/legion-config.mjs +134 -0
- package/src/server/index.mjs +51 -7
- package/src/services/llm.mjs +44 -15
- package/src/services/message-responder.mjs +1 -1
- package/src/spawn.mjs +22 -44
- package/src/updater.mjs +48 -63
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** Legion X as shipped in the package: its version, and the agents copied to ~/.nha/agents */
|
|
2
|
+
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import {
|
|
6
|
+
AGENTS, AGENTS_DIR, AGENTS_STAMP_FILE, BUNDLED_AGENTS_DIR, LEGION_FILE, VERSION,
|
|
7
|
+
} from './constants.mjs';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Version of the bundled Legion X, read from the file itself so it cannot
|
|
11
|
+
* drift from what actually runs.
|
|
12
|
+
* @returns {string} the version, or 'unknown' when the file cannot be read
|
|
13
|
+
*/
|
|
14
|
+
export function bundledLegionVersion(legionFile = LEGION_FILE) {
|
|
15
|
+
try {
|
|
16
|
+
const head = fs.readFileSync(legionFile, 'utf-8').slice(0, 20_000);
|
|
17
|
+
const match = head.match(/^var VERSION = '([^']+)';/m);
|
|
18
|
+
return match ? match[1] : 'unknown';
|
|
19
|
+
} catch {
|
|
20
|
+
return 'unknown';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Copy the bundled agents into the user's agents directory.
|
|
26
|
+
*
|
|
27
|
+
* Runs when the package version changed since the last copy, or when an agent
|
|
28
|
+
* file is missing. A copy is skipped otherwise, so the check costs one small
|
|
29
|
+
* read per command.
|
|
30
|
+
*
|
|
31
|
+
* @param {object} [options] overrides, used by the tests
|
|
32
|
+
* @returns {{ copied: number, missing: string[], reason: 'fresh'|'version'|'incomplete'|'up-to-date' }}
|
|
33
|
+
*/
|
|
34
|
+
export function syncBundledAgents(options = {}) {
|
|
35
|
+
const sourceDir = options.sourceDir || BUNDLED_AGENTS_DIR;
|
|
36
|
+
const targetDir = options.targetDir || AGENTS_DIR;
|
|
37
|
+
const stampFile = options.stampFile || AGENTS_STAMP_FILE;
|
|
38
|
+
const version = options.version || VERSION;
|
|
39
|
+
const names = options.agents || AGENTS;
|
|
40
|
+
|
|
41
|
+
let stamp = '';
|
|
42
|
+
try { stamp = fs.readFileSync(stampFile, 'utf-8').trim(); } catch { /* no stamp yet */ }
|
|
43
|
+
|
|
44
|
+
const absent = names.filter((name) => !fs.existsSync(path.join(targetDir, `${name}.mjs`)));
|
|
45
|
+
let reason = 'up-to-date';
|
|
46
|
+
if (!stamp) reason = 'fresh';
|
|
47
|
+
else if (stamp !== version) reason = 'version';
|
|
48
|
+
else if (absent.length > 0) reason = 'incomplete';
|
|
49
|
+
if (reason === 'up-to-date') return { copied: 0, missing: [], reason };
|
|
50
|
+
|
|
51
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
52
|
+
const toCopy = reason === 'incomplete' ? absent : names;
|
|
53
|
+
const missing = [];
|
|
54
|
+
let copied = 0;
|
|
55
|
+
for (const name of toCopy) {
|
|
56
|
+
const source = path.join(sourceDir, `${name}.mjs`);
|
|
57
|
+
if (!fs.existsSync(source)) { missing.push(name); continue; }
|
|
58
|
+
fs.copyFileSync(source, path.join(targetDir, `${name}.mjs`));
|
|
59
|
+
copied++;
|
|
60
|
+
}
|
|
61
|
+
// The stamp is written only for a complete copy: a partial one is retried.
|
|
62
|
+
if (missing.length === 0) fs.writeFileSync(stampFile, version + '\n', 'utf-8');
|
|
63
|
+
return { copied, missing, reason };
|
|
64
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/** Translate the nha config into the flat config Legion X reads, and say whether it can run */
|
|
2
|
+
|
|
3
|
+
/** Providers Legion X can deliberate with. */
|
|
4
|
+
export const LEGION_PROVIDERS = [
|
|
5
|
+
'ollama', 'local-openai', 'anthropic', 'openai', 'gemini', 'deepseek', 'grok', 'mistral', 'cohere',
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
/** Cloud provider → [field of nha's llm section, key name Legion reads]. */
|
|
9
|
+
const CLOUD_KEYS = {
|
|
10
|
+
anthropic: ['anthropicKey', 'anthropicApiKey'],
|
|
11
|
+
openai: ['openaiKey', 'openaiApiKey'],
|
|
12
|
+
gemini: ['geminiKey', 'geminiApiKey'],
|
|
13
|
+
deepseek: ['deepseekKey', 'deepseekApiKey'],
|
|
14
|
+
grok: ['grokKey', 'grokApiKey'],
|
|
15
|
+
mistral: ['mistralKey', 'mistralApiKey'],
|
|
16
|
+
cohere: ['cohereKey', 'cohereApiKey'],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const LOCAL_PROVIDERS = ['ollama', 'local-openai'];
|
|
20
|
+
|
|
21
|
+
/** Environment variable Legion reads for the key of each cloud provider. */
|
|
22
|
+
export const CLOUD_KEY_ENV = {
|
|
23
|
+
anthropic: 'ANTHROPIC_API_KEY', openai: 'OPENAI_API_KEY', gemini: 'GEMINI_API_KEY',
|
|
24
|
+
deepseek: 'DEEPSEEK_API_KEY', grok: 'XAI_API_KEY', mistral: 'MISTRAL_API_KEY', cohere: 'COHERE_API_KEY',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The provider Legion deliberates with: the one chosen for Legion, else the
|
|
29
|
+
* toolkit provider when Legion supports it, else none (Legion then uses
|
|
30
|
+
* whatever has a key, or a local model).
|
|
31
|
+
*/
|
|
32
|
+
export function resolveLegionProvider(config) {
|
|
33
|
+
const chosen = String(config.legion?.provider || '').trim();
|
|
34
|
+
if (chosen) return chosen;
|
|
35
|
+
const shared = String(config.llm?.provider || '').trim();
|
|
36
|
+
return LEGION_PROVIDERS.includes(shared) ? shared : '';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build the Legion config from the nha config. The result is derived in full
|
|
41
|
+
* every time: the nha config is the only place settings are kept, so a key
|
|
42
|
+
* removed there is gone from Legion too.
|
|
43
|
+
*
|
|
44
|
+
* `llm.apiKey` is the key of `llm.provider` and of no other provider.
|
|
45
|
+
*/
|
|
46
|
+
export function buildLegionConfig(config) {
|
|
47
|
+
const llm = config.llm || {};
|
|
48
|
+
const legion = config.legion || {};
|
|
49
|
+
const features = config.features || {};
|
|
50
|
+
const deliberation = config.deliberation || {};
|
|
51
|
+
|
|
52
|
+
const out = {
|
|
53
|
+
provider: resolveLegionProvider(config),
|
|
54
|
+
llmModel: llm.model || '',
|
|
55
|
+
// Always empty: every key is written under its provider's own name.
|
|
56
|
+
llmApiKey: '',
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Legion spreads the agents across every provider it has a key for. With
|
|
60
|
+
// local-only on, no cloud key is handed over: nothing leaves the machine.
|
|
61
|
+
out.localOnly = legion.localOnly === true;
|
|
62
|
+
for (const [provider, [nhaField, legionField]] of Object.entries(CLOUD_KEYS)) {
|
|
63
|
+
out[legionField] = out.localOnly ? '' : llm[nhaField] || (llm.provider === provider ? llm.apiKey || '' : '');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
out.ollamaUrl = legion.ollamaUrl || 'http://localhost:11434';
|
|
67
|
+
if (legion.ollamaModel) out.ollamaModel = legion.ollamaModel;
|
|
68
|
+
if (legion.ollamaModels) out.ollamaModels = legion.ollamaModels;
|
|
69
|
+
if (legion.ollamaEmbedModel) out.ollamaEmbedModel = legion.ollamaEmbedModel;
|
|
70
|
+
if (legion.localOpenaiUrl) out.localOpenaiUrl = legion.localOpenaiUrl;
|
|
71
|
+
if (legion.localOpenaiModel) out.localOpenaiModel = legion.localOpenaiModel;
|
|
72
|
+
if (legion.localOpenaiKey) out.localOpenaiKey = legion.localOpenaiKey;
|
|
73
|
+
if (legion.orchestratorProvider) out.orchestratorProvider = legion.orchestratorProvider;
|
|
74
|
+
out.economy = legion.economy === true;
|
|
75
|
+
out.factCheckEnabled = legion.factCheck !== false;
|
|
76
|
+
if (Number(legion.crossReadingChars) > 0) out.crossReadingChars = Number(legion.crossReadingChars);
|
|
77
|
+
|
|
78
|
+
out.timeout = llm.timeout || 120000;
|
|
79
|
+
out.maxRetries = llm.maxRetries || 2;
|
|
80
|
+
out.parallelism = llm.parallelism || 4;
|
|
81
|
+
out.verbose = features.verbose ?? true;
|
|
82
|
+
out.immersive = features.immersive ?? true;
|
|
83
|
+
out.knowledgeEnabled = features.knowledgeEnabled ?? true;
|
|
84
|
+
out.workspaceEnabled = features.workspaceEnabled ?? true;
|
|
85
|
+
out.latentSpaceEnabled = features.latentSpaceEnabled ?? true;
|
|
86
|
+
out.knowledgeGraphEnabled = features.knowledgeGraphEnabled ?? true;
|
|
87
|
+
out.promptEvolutionEnabled = features.promptEvolutionEnabled ?? true;
|
|
88
|
+
out.metaIntelligenceEnabled = features.metaIntelligenceEnabled ?? true;
|
|
89
|
+
out.deliberationEnabled = deliberation.enabled ?? true;
|
|
90
|
+
out.deliberationRounds = deliberation.rounds || 3;
|
|
91
|
+
out.deliberationConvergence = deliberation.convergence || 0.82;
|
|
92
|
+
out.minDeliberationRounds = deliberation.minRounds || 2;
|
|
93
|
+
out.semanticConvergenceEnabled = deliberation.semanticConvergence ?? true;
|
|
94
|
+
out.tribunalEnabled = deliberation.tribunalEnabled ?? true;
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Whether a deliberation can start with this Legion config.
|
|
100
|
+
* A cloud provider needs its key; a local one needs nothing here (Legion
|
|
101
|
+
* reports by itself when the local runtime does not answer).
|
|
102
|
+
*
|
|
103
|
+
* @param {object} legionConfig result of buildLegionConfig
|
|
104
|
+
* @param {object} [env] environment, for keys given as variables
|
|
105
|
+
* @returns {{ ready: boolean, providers: string[], reason: string }}
|
|
106
|
+
*/
|
|
107
|
+
export function legionReadiness(legionConfig, env = process.env) {
|
|
108
|
+
const providers = [];
|
|
109
|
+
if (!legionConfig.localOnly) {
|
|
110
|
+
for (const [provider, [, legionField]] of Object.entries(CLOUD_KEYS)) {
|
|
111
|
+
if (legionConfig[legionField] || env[CLOUD_KEY_ENV[provider]]) providers.push(provider);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (legionConfig.localOpenaiUrl) providers.push('local-openai');
|
|
115
|
+
if (legionConfig.provider === 'ollama' || legionConfig.ollamaModel || legionConfig.ollamaModels) providers.push('ollama');
|
|
116
|
+
|
|
117
|
+
const chosen = legionConfig.provider;
|
|
118
|
+
if (chosen && !LEGION_PROVIDERS.includes(chosen)) {
|
|
119
|
+
return { ready: false, providers, reason: `"${chosen}" is not a provider Legion can deliberate with.` };
|
|
120
|
+
}
|
|
121
|
+
if (chosen && !LOCAL_PROVIDERS.includes(chosen) && legionConfig.localOnly) {
|
|
122
|
+
return { ready: false, providers, reason: `local-only is on, but "${chosen}" is a cloud provider. Set legion-provider to ollama or local-openai.` };
|
|
123
|
+
}
|
|
124
|
+
if (chosen && !LOCAL_PROVIDERS.includes(chosen) && !providers.includes(chosen)) {
|
|
125
|
+
return { ready: false, providers, reason: `Provider "${chosen}" is selected but has no API key.` };
|
|
126
|
+
}
|
|
127
|
+
if (chosen === 'local-openai' && !legionConfig.localOpenaiUrl) {
|
|
128
|
+
return { ready: false, providers, reason: 'Provider "local-openai" is selected but local-openai-url is not set.' };
|
|
129
|
+
}
|
|
130
|
+
if (providers.length === 0) {
|
|
131
|
+
return { ready: false, providers, reason: 'No LLM provider is configured for deliberations.' };
|
|
132
|
+
}
|
|
133
|
+
return { ready: true, providers, reason: '' };
|
|
134
|
+
}
|
package/src/server/index.mjs
CHANGED
|
@@ -473,20 +473,64 @@ export async function startServer({ port = 3847, host = '127.0.0.1', noBrowser =
|
|
|
473
473
|
|
|
474
474
|
if (!noBrowser) openBrowser(`http://localhost:${port}`);
|
|
475
475
|
|
|
476
|
-
//
|
|
476
|
+
// ── Unified graceful shutdown ─────────────────────────────────────────────
|
|
477
|
+
// Stops, in this order: PAO daemon (Telegram bot + cron + Gmail/Calendar
|
|
478
|
+
// pollers), orphan WS listener on 3848, sandbox processes on 4000-4010, the
|
|
479
|
+
// HTTP server itself. Second Ctrl+C bails out hard. 5-second hard timeout
|
|
480
|
+
// guards against any step hanging.
|
|
481
|
+
let shuttingDown = false;
|
|
477
482
|
const cleanup = async () => {
|
|
483
|
+
if (shuttingDown) {
|
|
484
|
+
process.stderr.write(`\n ${R}✗${NC} ${D}Force exit${NC}\n`);
|
|
485
|
+
process.exit(130);
|
|
486
|
+
}
|
|
487
|
+
shuttingDown = true;
|
|
488
|
+
process.stdout.write(`\n ${D}Shutting down...${NC}\n`);
|
|
489
|
+
|
|
490
|
+
const hardTimeout = setTimeout(() => {
|
|
491
|
+
process.stderr.write(` ${R}!${NC} ${D}Timed out, killing process${NC}\n`);
|
|
492
|
+
process.exit(1);
|
|
493
|
+
}, 5000);
|
|
494
|
+
hardTimeout.unref();
|
|
495
|
+
|
|
496
|
+
// 1. PAO daemon (Telegram, cron, Gmail/Calendar pollers, notifications)
|
|
497
|
+
try {
|
|
498
|
+
const { stopDaemon, isRunning: isDaemonRunning } = await import('../services/ops-daemon.mjs');
|
|
499
|
+
if (isDaemonRunning()) {
|
|
500
|
+
const r = stopDaemon();
|
|
501
|
+
if (r.ok) process.stdout.write(` ${G}✓${NC} ${D}PAO daemon stopped (PID ${r.pid})${NC}\n`);
|
|
502
|
+
}
|
|
503
|
+
} catch {}
|
|
504
|
+
|
|
505
|
+
// 2. Orphan WS listeners on port 3848 (stale daemon instances from
|
|
506
|
+
// previous sessions that never cleaned up)
|
|
507
|
+
try {
|
|
508
|
+
const { execSync } = await import('child_process');
|
|
509
|
+
const out = execSync('lsof -ti:3848 2>/dev/null', { encoding: 'utf-8', timeout: 1500 }).trim();
|
|
510
|
+
const pids = out.split('\n').filter(Boolean).filter((p) => p !== String(process.pid));
|
|
511
|
+
for (const pid of pids) { try { process.kill(parseInt(pid), 'SIGTERM'); } catch {} }
|
|
512
|
+
if (pids.length) process.stdout.write(` ${G}✓${NC} ${D}Cleaned ${pids.length} orphan(s) on port 3848${NC}\n`);
|
|
513
|
+
} catch {}
|
|
514
|
+
|
|
515
|
+
// 3. WebCraft sandbox processes (4000-4010)
|
|
478
516
|
try {
|
|
479
|
-
const {
|
|
480
|
-
|
|
481
|
-
const execAsync = promisify(exec);
|
|
517
|
+
const { execSync } = await import('child_process');
|
|
518
|
+
let sandboxKilled = 0;
|
|
482
519
|
for (let p = 4000; p <= 4010; p++) {
|
|
483
520
|
try {
|
|
484
|
-
const
|
|
485
|
-
const pids =
|
|
486
|
-
for (const pid of pids) { try { process.kill(parseInt(pid), 'SIGKILL'); } catch {} }
|
|
521
|
+
const out = execSync(`lsof -ti:${p} 2>/dev/null`, { encoding: 'utf-8', timeout: 1500 }).trim();
|
|
522
|
+
const pids = out.split('\n').filter(Boolean);
|
|
523
|
+
for (const pid of pids) { try { process.kill(parseInt(pid), 'SIGKILL'); sandboxKilled++; } catch {} }
|
|
487
524
|
} catch {}
|
|
488
525
|
}
|
|
526
|
+
if (sandboxKilled) process.stdout.write(` ${G}✓${NC} ${D}Stopped ${sandboxKilled} sandbox process(es)${NC}\n`);
|
|
489
527
|
} catch {}
|
|
528
|
+
|
|
529
|
+
// 4. HTTP server — close listening socket so the port frees up immediately
|
|
530
|
+
try { server.close(); } catch {}
|
|
531
|
+
|
|
532
|
+
process.stdout.write(` ${G}✓${NC} ${D}Bye${NC}\n`);
|
|
533
|
+
clearTimeout(hardTimeout);
|
|
490
534
|
process.exit(0);
|
|
491
535
|
};
|
|
492
536
|
process.on('SIGINT', cleanup);
|
package/src/services/llm.mjs
CHANGED
|
@@ -720,6 +720,29 @@ export async function callCohere(apiKey, model, systemPrompt, userMessage, _stre
|
|
|
720
720
|
return data.text || '';
|
|
721
721
|
}
|
|
722
722
|
|
|
723
|
+
// ── Hosted free model offline ──────────────────────────────────────────────
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* What the user reads when the hosted free model is switched off. The server
|
|
727
|
+
* then answers 200 with a canned assistant message ("try again in a few
|
|
728
|
+
* minutes") marked `__liara_unavailable`: shown as an answer, it reads like a
|
|
729
|
+
* passing fault and never says that another model has to be set.
|
|
730
|
+
*/
|
|
731
|
+
export const HOSTED_OFFLINE_MESSAGE = [
|
|
732
|
+
'The hosted free model (Liara) is offline. Set your own model:',
|
|
733
|
+
' nha config set provider anthropic (or openai, gemini, deepseek, grok, mistral, cohere, openrouter)',
|
|
734
|
+
' nha config set key YOUR_API_KEY',
|
|
735
|
+
'For deliberations (nha run) a local model works with no key:',
|
|
736
|
+
' nha config set legion-provider ollama',
|
|
737
|
+
].join('\n');
|
|
738
|
+
|
|
739
|
+
/** The error for a `__liara_unavailable` answer, flagged so callers can tell it apart. */
|
|
740
|
+
export function hostedOfflineError() {
|
|
741
|
+
const err = new Error(HOSTED_OFFLINE_MESSAGE);
|
|
742
|
+
err.__hosted_offline = true;
|
|
743
|
+
return err;
|
|
744
|
+
}
|
|
745
|
+
|
|
723
746
|
// ── SSE Stream Parser ──────────────────────────────────────────────────────
|
|
724
747
|
|
|
725
748
|
export async function streamSSE(res, format) {
|
|
@@ -741,23 +764,23 @@ export async function streamSSE(res, format) {
|
|
|
741
764
|
const data = line.slice(6).trim();
|
|
742
765
|
if (data === '[DONE]') continue;
|
|
743
766
|
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
767
|
+
let json;
|
|
768
|
+
try { json = JSON.parse(data); } catch { continue; }
|
|
769
|
+
if (json.__liara_unavailable) throw hostedOfflineError();
|
|
747
770
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
} else {
|
|
753
|
-
chunk = json.choices?.[0]?.delta?.content || '';
|
|
771
|
+
let chunk = '';
|
|
772
|
+
if (format === 'anthropic') {
|
|
773
|
+
if (json.type === 'content_block_delta') {
|
|
774
|
+
chunk = json.delta?.text || '';
|
|
754
775
|
}
|
|
776
|
+
} else {
|
|
777
|
+
chunk = json.choices?.[0]?.delta?.content || '';
|
|
778
|
+
}
|
|
755
779
|
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
} catch {}
|
|
780
|
+
if (chunk) {
|
|
781
|
+
process.stdout.write(chunk);
|
|
782
|
+
fullText += chunk;
|
|
783
|
+
}
|
|
761
784
|
}
|
|
762
785
|
}
|
|
763
786
|
|
|
@@ -832,6 +855,7 @@ export async function callNHA(apiKey, model, systemPrompt, userMessage, stream =
|
|
|
832
855
|
}
|
|
833
856
|
if (stream) return streamSSE(res, 'openai');
|
|
834
857
|
const data = await res.json();
|
|
858
|
+
if (data.__liara_unavailable) throw hostedOfflineError();
|
|
835
859
|
let content = data.choices?.[0]?.message?.content || '';
|
|
836
860
|
// Strip thinking tags if present
|
|
837
861
|
content = content.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
|
|
@@ -1228,7 +1252,7 @@ export async function callLLMVision(config, systemPrompt, userMessage, media) {
|
|
|
1228
1252
|
}
|
|
1229
1253
|
|
|
1230
1254
|
const apiKey = getApiKey(config, provider);
|
|
1231
|
-
if (!apiKey) throw new Error(`No API key for ${provider}. Vision requires Claude, GPT-4
|
|
1255
|
+
if (!apiKey) throw new Error(`No API key for ${provider}. Vision requires Claude, GPT-4 or Gemini with your own key (nha config set key YOUR_KEY).`);
|
|
1232
1256
|
|
|
1233
1257
|
const { base64, mimeType } = media;
|
|
1234
1258
|
if (!base64 || !mimeType) throw new Error('media.base64 and media.mimeType are required');
|
|
@@ -1410,6 +1434,11 @@ export async function callLLMStream(config, systemPrompt, userMessage, onToken,
|
|
|
1410
1434
|
}
|
|
1411
1435
|
// Non-streaming: vLLM returns complete text — no BPE subword splitting issues
|
|
1412
1436
|
const nhaJson = await nhaRes.json();
|
|
1437
|
+
// Hosted model switched off — shown to the user, never saved as an answer
|
|
1438
|
+
if (nhaJson.__liara_unavailable) {
|
|
1439
|
+
if (onToken) onToken(HOSTED_OFFLINE_MESSAGE);
|
|
1440
|
+
throw hostedOfflineError();
|
|
1441
|
+
}
|
|
1413
1442
|
// SENTINEL blocked — throw special error so caller skips conversation persistence
|
|
1414
1443
|
if (nhaJson.__sentinel_blocked) {
|
|
1415
1444
|
const blockedMsg = nhaJson.choices?.[0]?.message?.content || 'Message blocked by SENTINEL.';
|
|
@@ -3868,7 +3868,7 @@ export function startResponder(config, log, wsBroadcast) {
|
|
|
3868
3868
|
const provider = (config.llm?.provider || 'nha').toLowerCase();
|
|
3869
3869
|
const PAID_PROVIDERS = new Set(['anthropic', 'openai', 'gemini', 'deepseek', 'grok', 'mistral', 'cohere']);
|
|
3870
3870
|
if (PAID_PROVIDERS.has(provider) && !config.llm?.apiKey) {
|
|
3871
|
-
log(`[Responder] Provider "${provider}" requires an API key — cannot respond. Run: nha config set
|
|
3871
|
+
log(`[Responder] Provider "${provider}" requires an API key — cannot respond. Run: nha config set key YOUR_KEY`);
|
|
3872
3872
|
return { telegram: false, discord: false, reason: `missing_key:${provider}` };
|
|
3873
3873
|
}
|
|
3874
3874
|
|
package/src/spawn.mjs
CHANGED
|
@@ -4,51 +4,22 @@ import fs from 'fs';
|
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import { spawn } from 'child_process';
|
|
6
6
|
import { loadConfig } from './config.mjs';
|
|
7
|
-
import {
|
|
7
|
+
import { buildLegionConfig, CLOUD_KEY_ENV } from './legion-config.mjs';
|
|
8
|
+
import {
|
|
9
|
+
NHA_DIR, LEGION_FILE, LEGION_CONFIG_FILE, PIF_FILE, AGENTS_DIR, EXTENSIONS_DIR, SESSIONS_DIR,
|
|
10
|
+
} from './constants.mjs';
|
|
8
11
|
|
|
9
12
|
/**
|
|
10
|
-
* Write
|
|
11
|
-
*
|
|
12
|
-
* NHA stores: { llm: { provider, apiKey }, agent: { id, privateKeyPem }, ... }
|
|
13
|
+
* Write the flat config Legion X reads, derived in full from the nha config.
|
|
14
|
+
* The file holds API keys: it is readable by its owner only.
|
|
13
15
|
*/
|
|
14
16
|
function writeLegionConfig(config) {
|
|
15
|
-
const legionConfig =
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
deepseekKey: config.llm?.deepseekKey || '',
|
|
22
|
-
grokKey: config.llm?.grokKey || '',
|
|
23
|
-
mistralKey: config.llm?.mistralKey || '',
|
|
24
|
-
cohereKey: config.llm?.cohereKey || '',
|
|
25
|
-
timeout: config.llm?.timeout || 120000,
|
|
26
|
-
maxRetries: config.llm?.maxRetries || 2,
|
|
27
|
-
parallelism: config.llm?.parallelism || 4,
|
|
28
|
-
verbose: config.features?.verbose ?? true,
|
|
29
|
-
immersive: config.features?.immersive ?? true,
|
|
30
|
-
knowledgeEnabled: config.features?.knowledgeEnabled ?? true,
|
|
31
|
-
workspaceEnabled: config.features?.workspaceEnabled ?? true,
|
|
32
|
-
latentSpaceEnabled: config.features?.latentSpaceEnabled ?? true,
|
|
33
|
-
knowledgeGraphEnabled: config.features?.knowledgeGraphEnabled ?? true,
|
|
34
|
-
promptEvolutionEnabled: config.features?.promptEvolutionEnabled ?? true,
|
|
35
|
-
metaIntelligenceEnabled: config.features?.metaIntelligenceEnabled ?? true,
|
|
36
|
-
deliberationEnabled: config.deliberation?.enabled ?? true,
|
|
37
|
-
deliberationRounds: config.deliberation?.rounds || 3,
|
|
38
|
-
deliberationConvergence: config.deliberation?.convergence || 0.82,
|
|
39
|
-
minDeliberationRounds: config.deliberation?.minRounds || 2,
|
|
40
|
-
semanticConvergenceEnabled: config.deliberation?.semanticConvergence ?? true,
|
|
41
|
-
tribunalEnabled: config.deliberation?.tribunalEnabled ?? true,
|
|
42
|
-
// Agent identity
|
|
43
|
-
nhaAgentId: config.agent?.id || '',
|
|
44
|
-
nhaAgentName: config.agent?.name || '',
|
|
45
|
-
nhaPrivateKeyPem: config.agent?.privateKeyPem || '',
|
|
46
|
-
nhaPublicKeyHex: config.agent?.publicKeyHex || '',
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
const legionConfigFile = path.join(NHA_DIR, '.legion-config.json');
|
|
50
|
-
fs.writeFileSync(legionConfigFile, JSON.stringify(legionConfig, null, 2) + '\n', 'utf-8');
|
|
51
|
-
return legionConfigFile;
|
|
17
|
+
const legionConfig = buildLegionConfig(config);
|
|
18
|
+
fs.mkdirSync(NHA_DIR, { recursive: true });
|
|
19
|
+
fs.writeFileSync(LEGION_CONFIG_FILE, JSON.stringify(legionConfig, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
|
|
20
|
+
// The mode above applies to a new file only: an existing one is tightened here.
|
|
21
|
+
fs.chmodSync(LEGION_CONFIG_FILE, 0o600);
|
|
22
|
+
return legionConfig;
|
|
52
23
|
}
|
|
53
24
|
|
|
54
25
|
/**
|
|
@@ -62,9 +33,8 @@ export function spawnCore(target, args) {
|
|
|
62
33
|
const config = loadConfig();
|
|
63
34
|
|
|
64
35
|
// For Legion: write a flat config it can understand
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
: path.join(NHA_DIR, 'config.json');
|
|
36
|
+
const legionConfig = target === 'legion' ? writeLegionConfig(config) : null;
|
|
37
|
+
const configFile = legionConfig ? LEGION_CONFIG_FILE : path.join(NHA_DIR, 'config.json');
|
|
68
38
|
|
|
69
39
|
const env = {
|
|
70
40
|
...process.env,
|
|
@@ -72,8 +42,16 @@ export function spawnCore(target, args) {
|
|
|
72
42
|
NHA_EXTENSIONS_DIR: EXTENSIONS_DIR,
|
|
73
43
|
NHA_SESSIONS_DIR: SESSIONS_DIR,
|
|
74
44
|
NHA_CONFIG_FILE: configFile,
|
|
45
|
+
// Legion ships with this package and is updated with it: it has no
|
|
46
|
+
// version of its own to look up.
|
|
47
|
+
LEGION_NO_UPDATE_CHECK: '1',
|
|
75
48
|
};
|
|
76
49
|
|
|
50
|
+
// Local-only: a cloud key exported in the shell must not reach Legion either.
|
|
51
|
+
if (legionConfig?.localOnly) {
|
|
52
|
+
for (const name of Object.values(CLOUD_KEY_ENV)) delete env[name];
|
|
53
|
+
}
|
|
54
|
+
|
|
77
55
|
return new Promise((resolve) => {
|
|
78
56
|
const child = spawn(process.execPath, [file, ...args], {
|
|
79
57
|
stdio: 'inherit',
|
package/src/updater.mjs
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Update checker and updater.
|
|
3
|
+
*
|
|
4
|
+
* Legion X and the agents ship inside the npm package: they are updated by
|
|
5
|
+
* updating the package, never downloaded on their own. Only PIF still comes
|
|
6
|
+
* from the website.
|
|
7
|
+
*/
|
|
2
8
|
|
|
3
9
|
import fs from 'fs';
|
|
4
10
|
import {
|
|
5
|
-
BASE_URL, VERSIONS_FILE, LAST_UPDATE_CHECK,
|
|
6
|
-
LEGION_FILE, PIF_FILE, AGENTS_DIR, AGENTS, VERSION,
|
|
11
|
+
BASE_URL, VERSIONS_FILE, LAST_UPDATE_CHECK, PIF_FILE, VERSION,
|
|
7
12
|
} from './constants.mjs';
|
|
8
|
-
import { download
|
|
9
|
-
import {
|
|
13
|
+
import { download } from './downloader.mjs';
|
|
14
|
+
import { bundledLegionVersion, syncBundledAgents } from './legion-bundle.mjs';
|
|
15
|
+
import { info, ok, warn } from './ui.mjs';
|
|
10
16
|
|
|
11
17
|
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
12
18
|
|
|
@@ -37,10 +43,7 @@ export async function checkForUpdates() {
|
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
const updates = [];
|
|
40
|
-
if (remote['
|
|
41
|
-
updates.push({ name: 'Legion X', from: local['legion-x']?.latest ?? '?', to: remote['legion-x'].latest });
|
|
42
|
-
}
|
|
43
|
-
if (remote['pif']?.latest !== local['pif']?.latest) {
|
|
46
|
+
if (remote['pif']?.latest && remote['pif'].latest !== local['pif']?.latest) {
|
|
44
47
|
updates.push({ name: 'PIF', from: local['pif']?.latest ?? '?', to: remote['pif'].latest });
|
|
45
48
|
}
|
|
46
49
|
|
|
@@ -128,7 +131,7 @@ async function npmSelfInstall(targetVersion) {
|
|
|
128
131
|
}
|
|
129
132
|
|
|
130
133
|
/**
|
|
131
|
-
* Full update:
|
|
134
|
+
* Full update: self-upgrade the npm package (which carries Legion X and the agents) and refresh PIF.
|
|
132
135
|
*/
|
|
133
136
|
export async function runUpdate() {
|
|
134
137
|
info('Checking for updates...');
|
|
@@ -169,65 +172,47 @@ export async function runUpdate() {
|
|
|
169
172
|
ok(`npm package nothumanallowed@${npmCheck.current} (up to date)`);
|
|
170
173
|
}
|
|
171
174
|
|
|
172
|
-
// ── Agents + Legion + PIF (downloaded from website, not npm) ───────────
|
|
173
|
-
// 45s timeout (was 15s) — VMs / slow connections can take that long for
|
|
174
|
-
// the first manifest fetch. The downloader retries internally for batch
|
|
175
|
-
// downloads, so this is just for the initial manifest.
|
|
176
|
-
const res = await fetch(`${BASE_URL}/versions.json`, {
|
|
177
|
-
signal: AbortSignal.timeout(45000),
|
|
178
|
-
headers: { 'User-Agent': `nha-cli/${(await import('./constants.mjs')).VERSION}` },
|
|
179
|
-
});
|
|
180
|
-
if (!res.ok) {
|
|
181
|
-
warn('Could not reach nothumanallowed.com for agent updates.');
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const remote = await res.json();
|
|
186
|
-
let local = {};
|
|
187
|
-
if (fs.existsSync(VERSIONS_FILE)) {
|
|
188
|
-
try { local = JSON.parse(fs.readFileSync(VERSIONS_FILE, 'utf-8')); } catch {}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const legionCurrent = local['legion-x']?.latest ?? '?';
|
|
192
|
-
const legionLatest = remote['legion-x']?.latest ?? '?';
|
|
193
|
-
const pifCurrent = local['pif']?.latest ?? '?';
|
|
194
|
-
const pifLatest = remote['pif']?.latest ?? '?';
|
|
195
|
-
|
|
196
175
|
let updated = npmUpdated;
|
|
197
176
|
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
177
|
+
// ── Legion X + agents: part of the package ─────────────────────────────
|
|
178
|
+
// A freshly installed package brings its own Legion and agents; they are
|
|
179
|
+
// put in place the next time `nha` starts. Here the agents of the running
|
|
180
|
+
// version are restored if any went missing.
|
|
181
|
+
ok(`Legion X v${bundledLegionVersion()} (bundled with nothumanallowed@${VERSION})`);
|
|
182
|
+
const agents = syncBundledAgents();
|
|
183
|
+
if (agents.copied > 0) { ok(`${agents.copied} agents restored from the package`); updated = true; }
|
|
184
|
+
|
|
185
|
+
// ── PIF (downloaded from website, not npm) ─────────────────────────────
|
|
186
|
+
// 45s timeout — VMs / slow connections can take that long for the manifest.
|
|
187
|
+
let remote = null;
|
|
188
|
+
try {
|
|
189
|
+
const res = await fetch(`${BASE_URL}/versions.json`, {
|
|
190
|
+
signal: AbortSignal.timeout(45000),
|
|
191
|
+
headers: { 'User-Agent': `nha-cli/${VERSION}` },
|
|
192
|
+
});
|
|
193
|
+
if (res.ok) remote = await res.json();
|
|
194
|
+
} catch { /* reported below */ }
|
|
206
195
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
info(`PIF: ${pifCurrent} → ${pifLatest}`);
|
|
210
|
-
const success = await download(`${BASE_URL}/pif.mjs`, PIF_FILE, { timeout: 90_000, retries: 4 });
|
|
211
|
-
if (success) { ok(`PIF updated to v${pifLatest}`); updated = true; }
|
|
196
|
+
if (!remote) {
|
|
197
|
+
warn('Could not reach nothumanallowed.com: PIF not checked. Nothing else depends on it.');
|
|
212
198
|
} else {
|
|
213
|
-
|
|
199
|
+
let local = {};
|
|
200
|
+
if (fs.existsSync(VERSIONS_FILE)) {
|
|
201
|
+
try { local = JSON.parse(fs.readFileSync(VERSIONS_FILE, 'utf-8')); } catch {}
|
|
202
|
+
}
|
|
203
|
+
const pifCurrent = local['pif']?.latest ?? '?';
|
|
204
|
+
const pifLatest = remote['pif']?.latest ?? '?';
|
|
205
|
+
if (pifCurrent !== pifLatest || !fs.existsSync(PIF_FILE)) {
|
|
206
|
+
info(`PIF: ${pifCurrent} → ${pifLatest}`);
|
|
207
|
+
const success = await download(`${BASE_URL}/pif.mjs`, PIF_FILE, { timeout: 90_000, retries: 4 });
|
|
208
|
+
if (success) { ok(`PIF updated to v${pifLatest}`); updated = true; }
|
|
209
|
+
} else {
|
|
210
|
+
ok(`PIF v${pifCurrent} (up to date)`);
|
|
211
|
+
}
|
|
212
|
+
await download(`${BASE_URL}/versions.json`, VERSIONS_FILE);
|
|
213
|
+
fs.writeFileSync(LAST_UPDATE_CHECK, String(Date.now()));
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
-
// Re-download all agents (they may have been updated)
|
|
217
|
-
info(`Updating ${AGENTS.length} agents...`);
|
|
218
|
-
const agentTasks = AGENTS.map(name => ({
|
|
219
|
-
url: `${BASE_URL}/agents/${name}.mjs`,
|
|
220
|
-
dest: `${AGENTS_DIR}/${name}.mjs`,
|
|
221
|
-
}));
|
|
222
|
-
const result = await downloadBatch(agentTasks, 8, (done, total) => {
|
|
223
|
-
progress(done, total, 'agents');
|
|
224
|
-
});
|
|
225
|
-
ok(`${result.ok}/${AGENTS.length} agents updated`);
|
|
226
|
-
|
|
227
|
-
// Save new versions manifest
|
|
228
|
-
await download(`${BASE_URL}/versions.json`, VERSIONS_FILE);
|
|
229
|
-
fs.writeFileSync(LAST_UPDATE_CHECK, String(Date.now()));
|
|
230
|
-
|
|
231
216
|
if (updated) {
|
|
232
217
|
console.log('');
|
|
233
218
|
ok('Update complete!');
|