nothumanallowed 16.0.64 → 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.
Files changed (56) hide show
  1. package/README.md +98 -26
  2. package/bin/nha +3 -2
  3. package/package.json +7 -4
  4. package/src/bootstrap.mjs +30 -66
  5. package/src/cli.mjs +62 -20
  6. package/src/commands/autostart.mjs +1 -1
  7. package/src/commands/chat.mjs +1 -1
  8. package/src/commands/ops.mjs +1 -1
  9. package/src/config.mjs +37 -1
  10. package/src/constants.mjs +8 -2
  11. package/src/downloader.mjs +8 -3
  12. package/src/legion/agents/ade.mjs +201 -0
  13. package/src/legion/agents/athena.mjs +162 -0
  14. package/src/legion/agents/atlas.mjs +159 -0
  15. package/src/legion/agents/babel.mjs +167 -0
  16. package/src/legion/agents/cartographer.mjs +213 -0
  17. package/src/legion/agents/cassandra.mjs +185 -0
  18. package/src/legion/agents/conductor.mjs +183 -0
  19. package/src/legion/agents/cron.mjs +165 -0
  20. package/src/legion/agents/echo.mjs +165 -0
  21. package/src/legion/agents/edi.mjs +166 -0
  22. package/src/legion/agents/epicure.mjs +211 -0
  23. package/src/legion/agents/flux.mjs +165 -0
  24. package/src/legion/agents/forge.mjs +166 -0
  25. package/src/legion/agents/glitch.mjs +169 -0
  26. package/src/legion/agents/heimdall.mjs +171 -0
  27. package/src/legion/agents/herald.mjs +208 -0
  28. package/src/legion/agents/hermes.mjs +163 -0
  29. package/src/legion/agents/jarvis.mjs +163 -0
  30. package/src/legion/agents/link.mjs +163 -0
  31. package/src/legion/agents/logos.mjs +171 -0
  32. package/src/legion/agents/macro.mjs +158 -0
  33. package/src/legion/agents/mercury.mjs +234 -0
  34. package/src/legion/agents/murasaki.mjs +163 -0
  35. package/src/legion/agents/muse.mjs +217 -0
  36. package/src/legion/agents/navi.mjs +161 -0
  37. package/src/legion/agents/oracle.mjs +165 -0
  38. package/src/legion/agents/pipe.mjs +160 -0
  39. package/src/legion/agents/polyglot.mjs +226 -0
  40. package/src/legion/agents/prometheus.mjs +171 -0
  41. package/src/legion/agents/quill.mjs +165 -0
  42. package/src/legion/agents/saber.mjs +162 -0
  43. package/src/legion/agents/sauron.mjs +173 -0
  44. package/src/legion/agents/scheherazade.mjs +179 -0
  45. package/src/legion/agents/shell.mjs +166 -0
  46. package/src/legion/agents/shogun.mjs +163 -0
  47. package/src/legion/agents/tempest.mjs +211 -0
  48. package/src/legion/agents/veritas.mjs +166 -0
  49. package/src/legion/agents/zero.mjs +162 -0
  50. package/src/legion/legion-x.mjs +12114 -0
  51. package/src/legion-bundle.mjs +64 -0
  52. package/src/legion-config.mjs +134 -0
  53. package/src/services/llm.mjs +44 -15
  54. package/src/services/message-responder.mjs +1 -1
  55. package/src/spawn.mjs +22 -44
  56. 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
+ }
@@ -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
- try {
745
- const json = JSON.parse(data);
746
- let chunk = '';
767
+ let json;
768
+ try { json = JSON.parse(data); } catch { continue; }
769
+ if (json.__liara_unavailable) throw hostedOfflineError();
747
770
 
748
- if (format === 'anthropic') {
749
- if (json.type === 'content_block_delta') {
750
- chunk = json.delta?.text || '';
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
- if (chunk) {
757
- process.stdout.write(chunk);
758
- fullText += chunk;
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, Gemini, or NHA Free (nha config set provider nha).`);
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 provider nha (to switch to free Liara) OR nha config set ${provider}-key YOUR_KEY`);
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 { NHA_DIR, LEGION_FILE, PIF_FILE, AGENTS_DIR, EXTENSIONS_DIR, SESSIONS_DIR } from './constants.mjs';
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 a Legion-compatible flat config from the NHA structured config.
11
- * Legion expects: { llmProvider, llmApiKey, nhaAgentId, nhaPrivateKeyPem, ... }
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
- llmProvider: config.llm?.provider || 'anthropic',
17
- llmApiKey: config.llm?.apiKey || '',
18
- llmModel: config.llm?.model || '',
19
- openaiKey: config.llm?.openaiKey || '',
20
- geminiKey: config.llm?.geminiKey || '',
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 configFile = target === 'legion'
66
- ? writeLegionConfig(config)
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
- /** Update checker and updater for core files + agents */
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, downloadBatch } from './downloader.mjs';
9
- import { info, ok, warn, progress } from './ui.mjs';
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['legion-x']?.latest !== local['legion-x']?.latest) {
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: re-download core files + agents + self-upgrade the npm package.
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
- // Update Legion
199
- if (legionCurrent !== legionLatest) {
200
- info(`Legion X: ${legionCurrent} → ${legionLatest}`);
201
- const success = await download(`${BASE_URL}/legion-x.mjs`, LEGION_FILE, { timeout: 90_000, retries: 4 });
202
- if (success) { ok(`Legion X updated to v${legionLatest}`); updated = true; }
203
- } else {
204
- ok(`Legion X v${legionCurrent} (up to date)`);
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
- // Update PIF
208
- if (pifCurrent !== pifLatest) {
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
- ok(`PIF v${pifCurrent} (up to date)`);
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!');