nexus-prime 7.1.1 → 7.3.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 (34) hide show
  1. package/dist/agents/adapters/mcp/handlers/orchestration.js +3 -2
  2. package/dist/architects/config.js +6 -1
  3. package/dist/cli/cleanup.js +44 -23
  4. package/dist/cli/doctor-storage.d.ts +19 -0
  5. package/dist/cli/doctor-storage.js +72 -3
  6. package/dist/cli/install-wizard.d.ts +15 -0
  7. package/dist/cli/install-wizard.js +46 -2
  8. package/dist/cli/uninstall.d.ts +0 -5
  9. package/dist/cli/uninstall.js +7 -17
  10. package/dist/dashboard/routes/health.js +20 -0
  11. package/dist/engines/ngram-index.d.ts +37 -3
  12. package/dist/engines/ngram-index.js +209 -23
  13. package/dist/engines/orchestrator/types.d.ts +5 -0
  14. package/dist/engines/orchestrator/types.js +64 -2
  15. package/dist/engines/orchestrator.js +29 -4
  16. package/dist/engines/runtime-hygiene.d.ts +4 -0
  17. package/dist/engines/runtime-hygiene.js +68 -36
  18. package/dist/install/manifest.d.ts +29 -0
  19. package/dist/install/manifest.js +44 -1
  20. package/dist/install/process-cleanup.js +57 -20
  21. package/dist/install/state-locator.d.ts +11 -0
  22. package/dist/install/state-locator.js +28 -0
  23. package/dist/licensing/enforcement.js +13 -1
  24. package/dist/licensing/index.d.ts +1 -1
  25. package/dist/licensing/index.js +1 -1
  26. package/dist/licensing/license-manager.d.ts +12 -0
  27. package/dist/licensing/license-manager.js +91 -4
  28. package/dist/licensing/types.d.ts +5 -1
  29. package/dist/licensing/upgrade-prompts.d.ts +10 -0
  30. package/dist/licensing/upgrade-prompts.js +23 -0
  31. package/dist/licensing/web-auth.d.ts +4 -1
  32. package/dist/licensing/web-auth.js +34 -9
  33. package/dist/synapse/config.js +15 -11
  34. package/package.json +2 -2
@@ -293,7 +293,7 @@ export async function handleOrchestrationGroup(toolName, hctx, request, args, ct
293
293
  detailLevel === 'debug' && bootstrap.tokenOptimization?.autoApplied && bootstrap.tokenOptimization?.plan
294
294
  ? `Auto token plan\n\`\`\`txt\n${bootstrap.tokenOptimization.plan}\n\`\`\``
295
295
  : '',
296
- detailLevel === 'debug' ? formatJsonDetails('Structured details', payload) : '',
296
+ formatJsonDetails('Structured details', payload),
297
297
  hctx.formatProtocolChecklist(),
298
298
  ].filter(Boolean).join('\n\n'),
299
299
  }],
@@ -499,6 +499,7 @@ export async function handleOrchestrationGroup(toolName, hctx, request, args, ct
499
499
  `Verification: ${verifiedWorkers}/${execution.workerResults.length} worker(s) verified`,
500
500
  `Tokens: saved ${Number(execution.tokenTelemetry?.savedTokens || 0).toLocaleString()} · compression ${Number(execution.tokenTelemetry?.compressionPct || 0)}%`,
501
501
  autoTokenApplyNote || null,
502
+ `Payload ref: ${workspace.stateKey} · ${detailLevel}`,
502
503
  ...(detailLevel === 'debug' ? [
503
504
  `Specialists: ${execution.plannerState?.selectedSpecialists.map((specialist) => specialist.name).slice(0, 4).join(', ') || 'none selected'}`,
504
505
  `Assets: ${(execution.activeSkills || []).length} skills · ${(execution.activeWorkflows || []).length} workflows · ${(runtimeUsage.artifactSelectionAudit?.selected?.length || 0)} audited selections`,
@@ -512,7 +513,7 @@ export async function handleOrchestrationGroup(toolName, hctx, request, args, ct
512
513
  ] : []),
513
514
  ]),
514
515
  detailLevel === 'debug' && execution.result ? `Result\n\`\`\`\n${execution.result}\n\`\`\`` : `Result preview\n\`\`\`\n${truncateText(execution.result || summarizeExecution(execution), 900)}\n\`\`\``,
515
- detailLevel === 'debug' ? formatJsonDetails('Structured details', payload) : '',
516
+ formatJsonDetails('Structured details', payload),
516
517
  hctx.formatRemainingProtocolSteps(),
517
518
  ].filter(Boolean).join('\n\n'),
518
519
  }],
@@ -7,9 +7,14 @@ function parseInteger(value, fallback) {
7
7
  const parsed = Number.parseInt(value ?? '', 10);
8
8
  return Number.isFinite(parsed) ? parsed : fallback;
9
9
  }
10
+ function clampInteger(value, min, max) {
11
+ if (!Number.isFinite(value))
12
+ return min;
13
+ return Math.max(min, Math.min(max, Math.floor(value)));
14
+ }
10
15
  export const ArchitectsConfig = {
11
16
  enabled: parseBoolean(process.env.ARCHITECTS_ENABLED, true),
12
- maxConcurrent: parseInteger(process.env.ARCHITECTS_MAX_CONCURRENT, -1),
17
+ maxConcurrent: clampInteger(parseInteger(process.env.ARCHITECTS_MAX_CONCURRENT, 2), 1, 4),
13
18
  sentinelPatrolMs: parseInteger(process.env.ARCHITECTS_SENTINEL_PATROL_MS, 120_000),
14
19
  wardPatrolMs: parseInteger(process.env.ARCHITECTS_WARD_PATROL_MS, 180_000),
15
20
  convergenceStrategy: (process.env.ARCHITECTS_CONVERGENCE_STRATEGY || 'bisecting'),
@@ -1,22 +1,15 @@
1
1
  /**
2
2
  * nexus cleanup — bound the growth of worktree + runs directories.
3
3
  *
4
- * Different from `uninstall`: this never touches persistent state. It enforces
5
- * TTL / count / bytes budgets over transient scratch dirs so local disk usage
6
- * can't balloon without an operator noticing.
7
- *
8
- * Defaults chosen to be loose enough that a busy day of work doesn't trip them:
9
- * - TTL: 12h per worktree, 6h per run
10
- * - maxCount: 32 worktrees, 64 runs
11
- * - maxBytes: 2 GiB across worktrees, 1 GiB across runs
12
- *
13
- * Override with env: NEXUS_WORKTREE_TTL_MS, NEXUS_WORKTREE_MAX_COUNT,
14
- * NEXUS_WORKTREE_MAX_BYTES, NEXUS_RUNS_TTL_MS, NEXUS_RUNS_MAX_COUNT,
15
- * NEXUS_RUNS_MAX_BYTES.
4
+ * This never touches durable memory. It enforces aggressive defaults because
5
+ * transient Nexus state must never surprise users with 10GB+ growth. Heavy
6
+ * artifacts can still be retained by raising env budgets explicitly.
16
7
  */
8
+ import * as fs from 'fs';
9
+ import * as os from 'os';
17
10
  import * as path from 'path';
18
11
  import { formatBytes, sweepDirectory, sweepOrphanWorktrees, } from '../install/fs-purge.js';
19
- import { getNexusStateDir, getWorktreeRoots, getRuntimeTmpRoots, } from '../install/state-locator.js';
12
+ import { getNexusStateDir, getWorktreeRoots, getRuntimeTmpRoots, NEXUS_TMP_GLOB_PREFIXES, } from '../install/state-locator.js';
20
13
  function parseIntEnv(name, fallback) {
21
14
  const raw = process.env[name];
22
15
  const parsed = Number.parseInt(raw ?? '', 10);
@@ -24,18 +17,42 @@ function parseIntEnv(name, fallback) {
24
17
  }
25
18
  export function resolveWorktreeBudget() {
26
19
  return {
27
- ttlMs: parseIntEnv('NEXUS_WORKTREE_TTL_MS', 12 * 60 * 60 * 1000),
28
- maxCount: parseIntEnv('NEXUS_WORKTREE_MAX_COUNT', 32),
29
- maxBytes: parseIntEnv('NEXUS_WORKTREE_MAX_BYTES', 2 * 1024 * 1024 * 1024),
20
+ ttlMs: parseIntEnv('NEXUS_WORKTREE_TTL_MS', 60 * 60 * 1000),
21
+ maxCount: parseIntEnv('NEXUS_WORKTREE_MAX_COUNT', 8),
22
+ maxBytes: parseIntEnv('NEXUS_WORKTREE_MAX_BYTES', 512 * 1024 * 1024),
30
23
  };
31
24
  }
32
25
  export function resolveRunsBudget() {
33
26
  return {
34
- ttlMs: parseIntEnv('NEXUS_RUNS_TTL_MS', 6 * 60 * 60 * 1000),
35
- maxCount: parseIntEnv('NEXUS_RUNS_MAX_COUNT', 64),
36
- maxBytes: parseIntEnv('NEXUS_RUNS_MAX_BYTES', 1024 * 1024 * 1024),
27
+ ttlMs: parseIntEnv('NEXUS_RUNS_TTL_MS', 30 * 60 * 1000),
28
+ maxCount: parseIntEnv('NEXUS_RUNS_MAX_COUNT', 16),
29
+ maxBytes: parseIntEnv('NEXUS_RUNS_MAX_BYTES', 256 * 1024 * 1024),
37
30
  };
38
31
  }
32
+ function collectLegacyTmpRoots() {
33
+ const tmpRoot = os.tmpdir();
34
+ let entries = [];
35
+ try {
36
+ entries = fs.readdirSync(tmpRoot);
37
+ }
38
+ catch {
39
+ return [];
40
+ }
41
+ const roots = new Set();
42
+ for (const entry of entries) {
43
+ if (!NEXUS_TMP_GLOB_PREFIXES.some((prefix) => entry.startsWith(prefix)))
44
+ continue;
45
+ const fullPath = path.join(tmpRoot, entry);
46
+ try {
47
+ if (fs.statSync(fullPath).isDirectory())
48
+ roots.add(fullPath);
49
+ }
50
+ catch {
51
+ // ignore races with concurrently removed tmp dirs
52
+ }
53
+ }
54
+ return [...roots].sort();
55
+ }
39
56
  export async function runCleanup(options) {
40
57
  const dryRun = options.dryRun || !options.fix;
41
58
  const repoRoot = options.repoRoot ?? process.cwd();
@@ -51,7 +68,6 @@ export async function runCleanup(options) {
51
68
  };
52
69
  const label = dryRun ? '[dry-run]' : '[cleanup]';
53
70
  console.log(`\n${label} Nexus cleanup — repo: ${repoRoot}`);
54
- // Worktree roots
55
71
  for (const root of getWorktreeRoots()) {
56
72
  const sweep = sweepDirectory(root, {
57
73
  maxAgeMs: worktreeBudget.ttlMs,
@@ -65,6 +81,7 @@ export async function runCleanup(options) {
65
81
  report.totalEntriesRemoved += sweep.removed.length;
66
82
  if (sweep.entriesBefore > 0 || sweep.removed.length > 0) {
67
83
  console.log(` worktree: ${root}`);
84
+ console.log(` budget: ttl=${Math.round(worktreeBudget.ttlMs / 60000)}m, max=${worktreeBudget.maxCount}, bytes=${formatBytes(worktreeBudget.maxBytes)}`);
68
85
  console.log(` before: ${sweep.entriesBefore} entries, ${formatBytes(sweep.totalBytesBefore)}`);
69
86
  console.log(` removed: ${sweep.removed.length} entries, ${formatBytes(sweep.removed.reduce((s, r) => s + r.bytes, 0))}`);
70
87
  for (const r of sweep.removed) {
@@ -72,8 +89,12 @@ export async function runCleanup(options) {
72
89
  }
73
90
  }
74
91
  }
75
- // Runs roots
76
- for (const root of [path.join(getNexusStateDir(), 'runs'), ...getRuntimeTmpRoots()]) {
92
+ const runsRoots = new Set([
93
+ path.join(getNexusStateDir(), 'runs'),
94
+ ...getRuntimeTmpRoots(),
95
+ ...collectLegacyTmpRoots(),
96
+ ]);
97
+ for (const root of runsRoots) {
77
98
  const sweep = sweepDirectory(root, {
78
99
  maxAgeMs: runsBudget.ttlMs,
79
100
  maxCount: runsBudget.maxCount,
@@ -86,11 +107,11 @@ export async function runCleanup(options) {
86
107
  report.totalEntriesRemoved += sweep.removed.length;
87
108
  if (sweep.entriesBefore > 0 || sweep.removed.length > 0) {
88
109
  console.log(` runs: ${root}`);
110
+ console.log(` budget: ttl=${Math.round(runsBudget.ttlMs / 60000)}m, max=${runsBudget.maxCount}, bytes=${formatBytes(runsBudget.maxBytes)}`);
89
111
  console.log(` before: ${sweep.entriesBefore} entries, ${formatBytes(sweep.totalBytesBefore)}`);
90
112
  console.log(` removed: ${sweep.removed.length} entries, ${formatBytes(sweep.removed.reduce((s, r) => s + r.bytes, 0))}`);
91
113
  }
92
114
  }
93
- // Orphan worktrees (dir exists on disk but `git worktree list` forgot about it)
94
115
  try {
95
116
  const orphanReport = await sweepOrphanWorktrees(repoRoot, { dryRun });
96
117
  report.orphans.paths = orphanReport.ops.filter((op) => op.kind !== 'missing').map((op) => op.path);
@@ -1,4 +1,15 @@
1
1
  import type { StateScope } from '../install/state-locator.js';
2
+ import type { LicenseStatus } from '../licensing/types.js';
3
+ export interface NgramFootprint {
4
+ dbPath: string;
5
+ dbBytes: number;
6
+ walBytes: number;
7
+ shmBytes: number;
8
+ totalBytes: number;
9
+ walLimitBytes: number;
10
+ footprintLimitBytes: number;
11
+ archives: number;
12
+ }
2
13
  export interface DoctorReport {
3
14
  stateDir: string;
4
15
  totals: Record<StateScope, number>;
@@ -15,5 +26,13 @@ export interface DoctorReport {
15
26
  limit: number;
16
27
  }>;
17
28
  suggestCleanup: boolean;
29
+ /** Install manifest's recorded architecture generation, if present. */
30
+ installArchGeneration?: string;
31
+ /** Architecture generation built into this binary. */
32
+ currentArchGeneration: string;
33
+ /** ngram-index footprint snapshot for the canonical state-dir DB. */
34
+ ngram: NgramFootprint;
35
+ /** Active license status. */
36
+ license: Pick<LicenseStatus, 'tier' | 'valid' | 'expiresAt' | 'trial' | 'degradedReason'>;
18
37
  }
19
38
  export declare function runDoctorStorage(): DoctorReport;
@@ -2,13 +2,29 @@
2
2
  * nexus doctor storage — read-only disk usage report.
3
3
  *
4
4
  * Shows scope-by-scope bytes under the state dir + tmp prefixes, flags any
5
- * scope that exceeds the corresponding cleanup budget, and prints a single
6
- * remediation hint (`nexus cleanup --fix`) when needed.
5
+ * scope that exceeds the corresponding cleanup budget, surfaces the install
6
+ * architecture generation + ngram WAL footprint + license status, and prints
7
+ * a single remediation hint (`nexus cleanup --fix`) when needed.
7
8
  */
8
9
  import * as fs from 'fs';
10
+ import * as path from 'path';
9
11
  import { resolveRunsBudget, resolveWorktreeBudget } from './cleanup.js';
10
12
  import { dirBytes, formatBytes } from '../install/fs-purge.js';
11
- import { enumerateStatePaths, getNexusStateDir, getRuntimeTmpRoots, getWorktreeRoots, } from '../install/state-locator.js';
13
+ import { enumerateNgramArchives, enumerateStatePaths, getNexusStateDir, getRuntimeTmpRoots, getWorktreeRoots, } from '../install/state-locator.js';
14
+ import { INSTALL_ARCH_GENERATION, loadManifest, } from '../install/manifest.js';
15
+ import { getNgramFootprintBytes } from '../engines/ngram-index.js';
16
+ import { getSharedLicenseManager } from '../licensing/license-manager.js';
17
+ const NGRAM_DEFAULT_WAL_LIMIT_BYTES = 64 * 1024 * 1024;
18
+ const NGRAM_DEFAULT_FOOTPRINT_BYTES = 512 * 1024 * 1024;
19
+ function readEnvBytesPositive(name, fallback) {
20
+ const raw = process.env[name];
21
+ if (!raw)
22
+ return fallback;
23
+ const parsed = Number(raw);
24
+ if (!Number.isFinite(parsed) || parsed <= 0)
25
+ return fallback;
26
+ return Math.floor(parsed);
27
+ }
12
28
  export function runDoctorStorage() {
13
29
  const stateDir = getNexusStateDir();
14
30
  const entries = [];
@@ -47,14 +63,67 @@ export function runDoctorStorage() {
47
63
  if (totals.runtime > runsBudget.maxBytes) {
48
64
  flags.push({ scope: 'runtime', reason: 'bytes-over-limit', actual: totals.runtime, limit: runsBudget.maxBytes });
49
65
  }
66
+ const ngramDbPath = path.join(stateDir, 'ngram-index.db');
67
+ const walLimitBytes = readEnvBytesPositive('NEXUS_NGRAM_WAL_LIMIT_BYTES', NGRAM_DEFAULT_WAL_LIMIT_BYTES);
68
+ const footprintLimitBytes = readEnvBytesPositive('NEXUS_NGRAM_MAX_FOOTPRINT_BYTES', NGRAM_DEFAULT_FOOTPRINT_BYTES);
69
+ const safeStat = (p) => {
70
+ try {
71
+ return fs.existsSync(p) ? fs.statSync(p).size : 0;
72
+ }
73
+ catch {
74
+ return 0;
75
+ }
76
+ };
77
+ const ngram = {
78
+ dbPath: ngramDbPath,
79
+ dbBytes: safeStat(ngramDbPath),
80
+ walBytes: safeStat(`${ngramDbPath}-wal`),
81
+ shmBytes: safeStat(`${ngramDbPath}-shm`),
82
+ totalBytes: getNgramFootprintBytes(ngramDbPath),
83
+ walLimitBytes,
84
+ footprintLimitBytes,
85
+ archives: enumerateNgramArchives(stateDir).length,
86
+ };
87
+ if (ngram.walBytes > walLimitBytes) {
88
+ flags.push({ scope: 'db', reason: 'ngram-wal-over-limit', actual: ngram.walBytes, limit: walLimitBytes });
89
+ }
90
+ if (ngram.totalBytes > footprintLimitBytes) {
91
+ flags.push({ scope: 'db', reason: 'ngram-footprint-over-limit', actual: ngram.totalBytes, limit: footprintLimitBytes });
92
+ }
93
+ const manifest = loadManifest();
94
+ const license = (() => {
95
+ try {
96
+ const status = getSharedLicenseManager().getStatus();
97
+ return {
98
+ tier: status.tier,
99
+ valid: status.valid,
100
+ expiresAt: status.expiresAt,
101
+ trial: status.trial,
102
+ degradedReason: status.degradedReason,
103
+ };
104
+ }
105
+ catch {
106
+ return { tier: 'free', valid: false, expiresAt: null, trial: false, degradedReason: 'malformed' };
107
+ }
108
+ })();
50
109
  const report = {
51
110
  stateDir,
52
111
  totals,
53
112
  entries,
54
113
  flags,
55
114
  suggestCleanup: flags.length > 0,
115
+ installArchGeneration: manifest.architectureGeneration,
116
+ currentArchGeneration: INSTALL_ARCH_GENERATION,
117
+ ngram,
118
+ license,
56
119
  };
57
120
  console.log(`\n[doctor:storage] state dir: ${stateDir}`);
121
+ console.log(` install arch ${report.installArchGeneration ?? '(unstamped)'} → current ${report.currentArchGeneration}`);
122
+ const trialNote = license.trial && license.expiresAt
123
+ ? ` (trial, ${Math.max(0, Math.ceil((license.expiresAt - Date.now()) / (24 * 60 * 60 * 1000)))}d left)`
124
+ : '';
125
+ console.log(` license tier=${license.tier} valid=${license.valid}${trialNote}${license.degradedReason ? ` reason=${license.degradedReason}` : ''}`);
126
+ console.log(` ngram db db=${formatBytes(ngram.dbBytes)} wal=${formatBytes(ngram.walBytes)} shm=${formatBytes(ngram.shmBytes)} total=${formatBytes(ngram.totalBytes)} (cap=${formatBytes(footprintLimitBytes)}) archives=${ngram.archives}`);
58
127
  for (const [scope, bytes] of Object.entries(totals)) {
59
128
  console.log(` ${scope.padEnd(10)} ${formatBytes(bytes)}`);
60
129
  }
@@ -4,6 +4,7 @@
4
4
  * Invoked via: nexus-prime setup
5
5
  */
6
6
  import { type IDEId } from '../agents/adapters/ide-compat.js';
7
+ import { INSTALL_ARCH_GENERATION } from '../install/manifest.js';
7
8
  export interface WizardOptions {
8
9
  /** Target workspace root (defaults to cwd) */
9
10
  workspaceRoot?: string;
@@ -36,6 +37,20 @@ export declare function configureIDE(ide: IDEId, opts?: WizardOptions): Promise<
36
37
  configPath: string | null;
37
38
  writtenHash: string | null;
38
39
  }>;
40
+ /** Architecture-generation migration step. Idempotent and best-effort: when
41
+ * the install manifest predates INSTALL_ARCH_GENERATION (or carries an older
42
+ * one), we drop stale generated artefacts that the current generation no
43
+ * longer owns. The manifest's path/registration entries are preserved so
44
+ * uninstall can still reverse them. */
45
+ export interface ArchUpgradeResult {
46
+ previousGeneration?: string;
47
+ currentGeneration: typeof INSTALL_ARCH_GENERATION;
48
+ migrated: boolean;
49
+ ngramArchivesPruned: number;
50
+ }
51
+ export declare function runArchitectureUpgrade(opts?: {
52
+ dryRun?: boolean;
53
+ }): ArchUpgradeResult;
39
54
  /** Run the install wizard: detect IDEs and write MCP configs. */
40
55
  export declare function runInstallWizard(opts?: WizardOptions): Promise<WizardResult>;
41
56
  /** Print how to add nexus-prime manually to an MCP client. */
@@ -4,7 +4,7 @@
4
4
  * Invoked via: nexus-prime setup
5
5
  */
6
6
  import { createHash } from 'crypto';
7
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
7
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
8
8
  import { homedir } from 'os';
9
9
  import { fileURLToPath } from 'url';
10
10
  import { dirname, join, resolve } from 'path';
@@ -15,7 +15,8 @@ import { resolveNexusStateDir } from '../engines/runtime-registry.js';
15
15
  import { resolveWorkspaceContext } from '../engines/workspace-resolver.js';
16
16
  import { runPostinstallCleanup } from '../postinstall/cleanup.js';
17
17
  import { openBrowser, printHandoffBanner, withSpinner } from './interactive-setup.js';
18
- import { appendToManifest, recordPath, recordRegistration, recordSetupMarker } from '../install/manifest.js';
18
+ import { appendToManifest, detectArchitectureUpgrade, loadManifest, recordArchitectureGeneration, recordPath, recordRegistration, recordSetupMarker, INSTALL_ARCH_GENERATION, } from '../install/manifest.js';
19
+ import { enumerateNgramArchives } from '../install/state-locator.js';
19
20
  /** Compute sha256 of a file's content. Returns null if the file cannot be read. */
20
21
  export function computeFileHash(filePath) {
21
22
  try {
@@ -255,6 +256,45 @@ function _writeWorkspaceLocalConfigs(callerIde, workspaceRoot) {
255
256
  }
256
257
  }
257
258
  }
259
+ export function runArchitectureUpgrade(opts = {}) {
260
+ const manifest = loadManifest();
261
+ const detection = detectArchitectureUpgrade(manifest);
262
+ if (!detection.requiresMigration) {
263
+ if (manifest.architectureGeneration !== INSTALL_ARCH_GENERATION && !opts.dryRun) {
264
+ // Fresh install or no recorded entries — just stamp the marker.
265
+ appendToManifest((m) => recordArchitectureGeneration(m));
266
+ }
267
+ return {
268
+ previousGeneration: detection.previousGeneration,
269
+ currentGeneration: detection.currentGeneration,
270
+ migrated: false,
271
+ ngramArchivesPruned: 0,
272
+ };
273
+ }
274
+ let ngramArchivesPruned = 0;
275
+ if (!opts.dryRun) {
276
+ // Stale ngram archives from prior generations: keep nothing — the new
277
+ // rotation path archives at most one and uses footprint accounting.
278
+ try {
279
+ const archives = enumerateNgramArchives();
280
+ for (const archive of archives) {
281
+ try {
282
+ rmSync(archive.path, { force: true });
283
+ ngramArchivesPruned += 1;
284
+ }
285
+ catch { /* best-effort */ }
286
+ }
287
+ }
288
+ catch { /* best-effort */ }
289
+ appendToManifest((m) => recordArchitectureGeneration(m));
290
+ }
291
+ return {
292
+ previousGeneration: detection.previousGeneration,
293
+ currentGeneration: detection.currentGeneration,
294
+ migrated: true,
295
+ ngramArchivesPruned,
296
+ };
297
+ }
258
298
  /** Run the install wizard: detect IDEs and write MCP configs. */
259
299
  export async function runInstallWizard(opts = {}) {
260
300
  const workspaceRoot = resolve(opts.workspaceRoot ?? process.cwd());
@@ -263,6 +303,10 @@ export async function runInstallWizard(opts = {}) {
263
303
  const setupMarker = readSetupMarker();
264
304
  const log = (msg) => { if (verbose)
265
305
  process.stdout.write(msg + '\n'); };
306
+ const upgrade = runArchitectureUpgrade({ dryRun });
307
+ if (upgrade.migrated && verbose) {
308
+ log(` [arch] migrated install ${upgrade.previousGeneration ?? 'legacy'} → ${upgrade.currentGeneration} (ngram archives pruned: ${upgrade.ngramArchivesPruned})`);
309
+ }
266
310
  const detected = detectInstalledIDEs(workspaceRoot);
267
311
  if (detected.length === 0) {
268
312
  log(' No supported IDEs detected in this workspace. Skipping MCP config setup.');
@@ -2,17 +2,12 @@ import { loadManifest } from '../install/manifest.js';
2
2
  import { type StopAllNexusProcessesOptions } from '../install/process-cleanup.js';
3
3
  export interface UninstallOptions {
4
4
  dryRun: boolean;
5
- /** Stop daemons and unregister all known clients. Accepted for CLI clarity; uninstall already does this by default. */
6
5
  all?: boolean;
7
- /** Preferred purge flag. */
8
6
  purge?: boolean;
9
- /** Back-compat alias for purge. */
10
7
  purgeAll?: boolean;
11
8
  yes: boolean;
12
9
  workspaceRoot?: string;
13
- /** Skip interactive prompt in tests. */
14
10
  _forceYes?: boolean;
15
- /** Test-only process cleanup injection; production callers should not set this. */
16
11
  _processCleanup?: StopAllNexusProcessesOptions;
17
12
  }
18
13
  export interface UninstallReport {
@@ -1,14 +1,10 @@
1
1
  /**
2
2
  * nexus uninstall — reverse every mutation Nexus Prime made to this machine.
3
3
  *
4
- * `--dry-run` walks the install manifest + heuristics and prints what would
5
- * happen without touching anything.
6
- * `--purge` additionally removes persistent state (memory.db, synapse.db,
7
- * architects.db, logs, caches, worktrees). Requires `--yes`.
8
- *
9
- * The manifest is authoritative where present. Heuristics fill the gap when
10
- * the manifest is missing (e.g. legacy installs). Every destructive step has
11
- * a dry-run preview, and leftovers are always reported.
4
+ * `--dry-run` walks registrations, processes, markers, and filesystem paths.
5
+ * Default uninstall unregisters clients, stops processes, clears markers, removes
6
+ * transient scratch, and clears the install manifest. `--purge` also deletes
7
+ * durable state such as memory, Synapse, Architects, logs, caches, and worktrees.
12
8
  */
13
9
  import * as fs from 'fs';
14
10
  import * as readline from 'readline';
@@ -73,7 +69,6 @@ export async function runUninstall(options) {
73
69
  }
74
70
  const label = dryRun ? '[dry-run]' : '[uninstall]';
75
71
  console.log(`\n${label} Nexus Prime uninstall — workspace: ${workspaceRoot}`);
76
- // Step 1 — reverse IDE registrations before stopping processes so clients do not immediately respawn MCP sessions.
77
72
  const manifest = loadManifest();
78
73
  const unregisterReport = unregisterNexusRegistrations({ workspaceRoot, dryRun, manifest });
79
74
  const removedRegs = unregisterReport.reports.filter((r) => r.removed);
@@ -83,13 +78,11 @@ export async function runUninstall(options) {
83
78
  for (const r of removedRegs) {
84
79
  console.log(` - ${r.filePath} :: ${r.jsonPath.join('.') || '(root)'}`);
85
80
  }
86
- // Step 2 — stop Nexus MCP/daemon processes and remove lockfiles.
87
81
  const processReport = await stopAllNexusProcesses({ ...options._processCleanup, dryRun });
88
82
  report.daemonsKilled = processReport.daemonsKilled;
89
83
  report.lockfilesRemoved = processReport.lockfilesRemoved;
90
84
  report.errors.push(...processReport.errors);
91
85
  console.log(` ${dryRun ? '◦' : '✓'} processes: ${report.daemonsKilled} process(es), ${report.lockfilesRemoved} lockfile(s)`);
92
- // Step 3 — remove setup markers.
93
86
  const markers = new Set([setupMarkerPath, ...manifest.setupMarkers]);
94
87
  for (const marker of markers) {
95
88
  if (!fs.existsSync(marker))
@@ -107,7 +100,6 @@ export async function runUninstall(options) {
107
100
  }
108
101
  }
109
102
  }
110
- // Step 4 — purge filesystem artifacts.
111
103
  const extras = manifest.paths.map((e) => ({ path: e.path, scope: e.scope }));
112
104
  let stateReport = null;
113
105
  let tmpReport = null;
@@ -116,7 +108,6 @@ export async function runUninstall(options) {
116
108
  tmpReport = purgeTmpRoots({ dryRun });
117
109
  }
118
110
  else {
119
- // Default (no --purge-all): only transient tmp dirs. Persistent state stays.
120
111
  tmpReport = purgeTmpRoots({ dryRun });
121
112
  }
122
113
  const totals = [stateReport, tmpReport]
@@ -137,11 +128,9 @@ export async function runUninstall(options) {
137
128
  console.log(` - ${op.path} (${formatBytes(op.bytes)})`);
138
129
  }
139
130
  }
140
- // Step 5 — final manifest clear, but only on full uninstall, not dry-run.
141
- if (!dryRun && purgeAll) {
131
+ if (!dryRun) {
142
132
  clearManifest(installManifestPath);
143
133
  }
144
- // Step 6 — leftover audit.
145
134
  report.leftovers = collectLeftovers(nexusStateDir);
146
135
  if (report.leftovers.length > 0) {
147
136
  console.log(` ! leftovers (still present in state dir):`);
@@ -156,7 +145,8 @@ export async function runUninstall(options) {
156
145
  report.ok = report.errors.length === 0 && (dryRun || (purgeAll ? report.leftovers.length === 0 : true));
157
146
  console.log(`\n${label} done. ${report.ok ? 'OK' : 'completed with issues'}.`);
158
147
  if (!purgeAll && !dryRun) {
159
- console.log(' Persistent state preserved. Re-run with --purge --yes to remove everything.');
148
+ console.log(' Persistent memory preserved. All registrations, processes, manifest, and transient scratch were removed.');
149
+ console.log(' Re-run with --purge --yes to delete durable memory/Synapse/Architects state too.');
160
150
  }
161
151
  return report;
162
152
  }
@@ -1,5 +1,25 @@
1
+ import { DASHBOARD_API_VERSION } from '../contract.js';
2
+ import { INSTALL_ARCH_GENERATION, loadManifest } from '../../install/manifest.js';
1
3
  import { getToolHealthSummary } from '../../agents/adapters/mcp/tool-health.js';
2
4
  export const handleHealthRoutes = async (ctx, req, res, url) => {
5
+ if (req.method === 'GET'
6
+ && (url.pathname === '/api/version' || url.pathname === '/api/dashboard/version')) {
7
+ // Stable version endpoint so external probes (CI smoke, foreign installs
8
+ // sharing port 3377) can identify the running dashboard generation.
9
+ const manifest = (() => { try {
10
+ return loadManifest();
11
+ }
12
+ catch {
13
+ return null;
14
+ } })();
15
+ ctx.respondJson(res, {
16
+ dashboardApiVersion: DASHBOARD_API_VERSION,
17
+ architectureGeneration: INSTALL_ARCH_GENERATION,
18
+ installArchGeneration: manifest?.architectureGeneration ?? null,
19
+ product: 'nexus-prime',
20
+ });
21
+ return true;
22
+ }
3
23
  if (req.method === 'GET' && url.pathname === '/api/license') {
4
24
  const { getSharedLicenseManager } = await import('../../licensing/index.js');
5
25
  ctx.respondJson(res, getSharedLicenseManager().getStatus());
@@ -11,6 +11,9 @@
11
11
  *
12
12
  * Persistence: SQLite (same pattern as memory.db / graph.db)
13
13
  */
14
+ export declare function getNgramWalPath(dbPath: string): string;
15
+ export declare function getNgramShmPath(dbPath: string): string;
16
+ export declare function getNgramFootprintBytes(dbPath: string): number;
14
17
  export interface Posting {
15
18
  docId: string;
16
19
  locMask: number;
@@ -54,6 +57,9 @@ export declare class NgramIndex {
54
57
  private deleteStmt;
55
58
  private lookupStmt;
56
59
  private docExistsStmt;
60
+ private writesSinceCheckpoint;
61
+ private lastCheckpointAt;
62
+ private quotaSkipNoticeShown;
57
63
  private _searchStats;
58
64
  constructor(dbPath?: string, options?: NgramIndexOptions);
59
65
  private rotateOversizeDbIfNeeded;
@@ -64,14 +70,35 @@ export declare class NgramIndex {
64
70
  private warmHashSet;
65
71
  /** Warm the hash set after a deferred init. No-op if already warm. */
66
72
  warmHashSetDeferred(): void;
73
+ /** Run an opportunistic WAL checkpoint. PASSIVE first, escalate to TRUNCATE
74
+ * if the WAL is still over its configured limit. Returns the WAL byte size
75
+ * observed after the attempt so callers can report progress. */
76
+ private checkpointIfNeeded;
77
+ /** Footprint quota check before a write. Returns true if the write should
78
+ * proceed; false if it should be skipped. Throws when NEXUS_NGRAM_STRICT_QUOTA=1
79
+ * so callers can surface hard quota failures during tests/CI. */
80
+ private allowWrite;
67
81
  /** Index a document's text content */
68
82
  addDocument(docId: string, text: string): void;
83
+ /** Trigger a passive checkpoint when either the doc or time interval threshold
84
+ * is hit. Cheap on a healthy DB, prevents WAL growth on bulk indexing. */
85
+ private maybePeriodicCheckpoint;
69
86
  /** Remove a document from the index */
70
87
  removeDocument(docId: string): void;
71
88
  /** Check if a document is already indexed */
72
89
  isIndexed(docId: string): boolean;
73
90
  /** Get count of indexed documents */
74
91
  getDocCount(): number;
92
+ /** Path to the underlying SQLite database file. */
93
+ getDbPath(): string;
94
+ /** Bytes occupied by the .db file alone. */
95
+ getDbBytes(): number;
96
+ /** Bytes occupied by the .db-wal file. */
97
+ getWalBytes(): number;
98
+ /** Bytes occupied by the .db-shm file. */
99
+ getShmBytes(): number;
100
+ /** Total SQLite footprint = db + wal + shm. Use this for quota checks. */
101
+ getSqliteFootprintBytes(): number;
75
102
  /**
76
103
  * Search for documents matching a text query.
77
104
  * Returns candidate document IDs ranked by trigram match count.
@@ -120,8 +147,9 @@ export declare class NgramIndex {
120
147
  optimizeStorage(force?: boolean): void;
121
148
  /**
122
149
  * Operator-focused maintenance for the on-disk ngram DB.
123
- * - Bounds runaway DB growth via rotation (default >= 1GB)
124
- * - Vacuums only when safe (<= vacuumMaxBytes) and either forced or dirty
150
+ * - Bounds runaway DB growth via rotation (default >= 1GB), counting the
151
+ * full SQLite footprint (db + wal + shm) so a runaway WAL triggers it.
152
+ * - Vacuums only when safe (<= vacuumMaxBytes) and either forced or dirty.
125
153
  */
126
154
  maintainBounded(options?: {
127
155
  force?: boolean;
@@ -132,7 +160,13 @@ export declare class NgramIndex {
132
160
  uniqueTrigramCount: number;
133
161
  dbSizeBytes: number;
134
162
  };
135
- /** Close the database connection */
163
+ /** Close the database connection.
164
+ *
165
+ * Always truncates the WAL so the .db-wal sibling can never outgrow the
166
+ * configured cap on shutdown — the failure mode that produced the 84GB
167
+ * WAL on a user machine. VACUUM is only run when the operator has both a
168
+ * small DB and explicitly opts in via NEXUS_NGRAM_VACUUM_ON_CLOSE=1, since
169
+ * blind VACUUM on multi-GB DBs blocks shutdown indefinitely. */
136
170
  close(): void;
137
171
  }
138
172
  /** Get or create the shared NgramIndex instance */