@rigour-labs/core 6.2.4 → 6.2.5

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 (80) hide show
  1. package/dist/deep/code-context.d.ts +28 -0
  2. package/dist/deep/code-context.js +93 -0
  3. package/dist/deep/code-context.test.d.ts +1 -0
  4. package/dist/deep/code-context.test.js +48 -0
  5. package/dist/deep/code-pass.d.ts +15 -0
  6. package/dist/deep/code-pass.js +44 -0
  7. package/dist/deep/code-review-prompt.d.ts +17 -0
  8. package/dist/deep/code-review-prompt.js +30 -0
  9. package/dist/deep/code-review-prompt.test.d.ts +1 -0
  10. package/dist/deep/code-review-prompt.test.js +20 -0
  11. package/dist/deep/code-verifier.d.ts +19 -0
  12. package/dist/deep/code-verifier.js +48 -0
  13. package/dist/deep/code-verifier.test.d.ts +1 -0
  14. package/dist/deep/code-verifier.test.js +46 -0
  15. package/dist/deep/fact-extractor.d.ts +7 -1
  16. package/dist/deep/fact-extractor.js +16 -8
  17. package/dist/deep/fact-extractor.test.js +34 -1
  18. package/dist/deep/facts-pass.d.ts +24 -0
  19. package/dist/deep/facts-pass.js +69 -0
  20. package/dist/deep/index.d.ts +6 -1
  21. package/dist/deep/index.js +5 -1
  22. package/dist/deep/parse-findings.d.ts +12 -0
  23. package/dist/deep/parse-findings.js +73 -0
  24. package/dist/deep/parse-findings.test.d.ts +1 -0
  25. package/dist/deep/parse-findings.test.js +25 -0
  26. package/dist/deep/prompts.d.ts +1 -1
  27. package/dist/deep/prompts.js +7 -8
  28. package/dist/deep/prompts.test.js +15 -0
  29. package/dist/gates/context.js +7 -0
  30. package/dist/gates/deep-analysis.d.ts +31 -8
  31. package/dist/gates/deep-analysis.js +165 -306
  32. package/dist/gates/deep-analysis.test.d.ts +1 -0
  33. package/dist/gates/deep-analysis.test.js +86 -0
  34. package/dist/gates/deep-runner.d.ts +15 -0
  35. package/dist/gates/deep-runner.js +65 -0
  36. package/dist/gates/dependency.js +4 -0
  37. package/dist/gates/logic-drift.js +7 -1
  38. package/dist/gates/runner.js +5 -40
  39. package/dist/gates/runner.test.js +37 -25
  40. package/dist/gates/scoped-gates.test.d.ts +1 -0
  41. package/dist/gates/scoped-gates.test.js +52 -0
  42. package/dist/gates/style-drift.js +3 -0
  43. package/dist/index.d.ts +6 -1
  44. package/dist/index.js +6 -1
  45. package/dist/inference/http-download.d.ts +22 -0
  46. package/dist/inference/http-download.js +132 -0
  47. package/dist/inference/http-download.test.d.ts +1 -0
  48. package/dist/inference/http-download.test.js +109 -0
  49. package/dist/inference/index.d.ts +5 -1
  50. package/dist/inference/index.js +2 -1
  51. package/dist/inference/llama-engine.d.ts +27 -0
  52. package/dist/inference/llama-engine.js +152 -0
  53. package/dist/inference/llama-engine.test.d.ts +1 -0
  54. package/dist/inference/llama-engine.test.js +51 -0
  55. package/dist/inference/llama-process.d.ts +72 -0
  56. package/dist/inference/llama-process.js +126 -0
  57. package/dist/inference/llama-process.test.d.ts +1 -0
  58. package/dist/inference/llama-process.test.js +61 -0
  59. package/dist/inference/model-download.test.d.ts +1 -0
  60. package/dist/inference/model-download.test.js +73 -0
  61. package/dist/inference/model-manager.d.ts +14 -1
  62. package/dist/inference/model-manager.js +65 -80
  63. package/dist/inference/sidecar-provider.d.ts +19 -10
  64. package/dist/inference/sidecar-provider.js +115 -343
  65. package/dist/templates/universal-config.js +0 -2
  66. package/dist/types/index.d.ts +58 -14
  67. package/dist/types/index.js +9 -2
  68. package/dist/utils/deep-status.d.ts +7 -0
  69. package/dist/utils/deep-status.js +11 -0
  70. package/dist/utils/diff.d.ts +3 -0
  71. package/dist/utils/diff.js +52 -0
  72. package/dist/utils/diff.test.d.ts +1 -0
  73. package/dist/utils/diff.test.js +38 -0
  74. package/dist/utils/scanner.js +13 -2
  75. package/dist/utils/scanner.test.js +22 -8
  76. package/dist/utils/scope.d.ts +15 -0
  77. package/dist/utils/scope.js +51 -0
  78. package/dist/utils/scope.test.d.ts +1 -0
  79. package/dist/utils/scope.test.js +38 -0
  80. package/package.json +6 -6
@@ -4,8 +4,12 @@
4
4
  export type { InferenceProvider, InferenceOptions, DeepFinding, DeepAnalysisResult, ModelTier, ModelInfo } from './types.js';
5
5
  export { MODELS } from './types.js';
6
6
  export { SidecarProvider } from './sidecar-provider.js';
7
+ export type { ActiveModel } from './sidecar-provider.js';
8
+ export { installLlamaEngine, probeBinary, managedEnginePath, LLAMA_RELEASE_TAG } from './llama-engine.js';
9
+ export type { ProbeResult } from './llama-engine.js';
7
10
  export { CloudProvider } from './cloud-provider.js';
8
- export { ensureModel, isModelCached, getModelPath, getModelInfo, downloadModel, getModelsDir } from './model-manager.js';
11
+ export { ensureModel, isModelCached, getModelPath, getModelInfo, downloadModel, getModelsDir, getCachedModel } from './model-manager.js';
12
+ export type { CachedModel } from './model-manager.js';
9
13
  import type { InferenceProvider } from './types.js';
10
14
  import type { DeepOptions } from '../types/index.js';
11
15
  /**
@@ -1,7 +1,8 @@
1
1
  export { MODELS } from './types.js';
2
2
  export { SidecarProvider } from './sidecar-provider.js';
3
+ export { installLlamaEngine, probeBinary, managedEnginePath, LLAMA_RELEASE_TAG } from './llama-engine.js';
3
4
  export { CloudProvider } from './cloud-provider.js';
4
- export { ensureModel, isModelCached, getModelPath, getModelInfo, downloadModel, getModelsDir } from './model-manager.js';
5
+ export { ensureModel, isModelCached, getModelPath, getModelInfo, downloadModel, getModelsDir, getCachedModel } from './model-manager.js';
5
6
  import { SidecarProvider } from './sidecar-provider.js';
6
7
  import { CloudProvider } from './cloud-provider.js';
7
8
  /**
@@ -0,0 +1,27 @@
1
+ import { type HttpGet } from './http-download.js';
2
+ export declare const LLAMA_RELEASE_TAG = "b5604";
3
+ export declare function llamaBinaryName(): string;
4
+ /** Install directory for the pinned release: ~/.rigour/bin/llama-<tag>. */
5
+ export declare function managedEngineDir(): string;
6
+ export declare function managedEnginePath(): string;
7
+ export declare function releaseAssetFor(platformKey: string): string | undefined;
8
+ export interface ProbeResult {
9
+ ok: boolean;
10
+ version?: string;
11
+ error?: string;
12
+ }
13
+ /**
14
+ * Run `<binary> --version` and accept it only if it exits 0. A placeholder
15
+ * script (such as an unbundled brain package) or a binary missing its shared
16
+ * libraries fails here instead of on every inference call.
17
+ */
18
+ export declare function probeBinary(binaryPath: string): Promise<ProbeResult>;
19
+ /** Forget cached probe results, e.g. after installing a new engine. */
20
+ export declare function resetProbeCache(): void;
21
+ /**
22
+ * Download the pinned release for this platform and install its whole bin
23
+ * folder into managedEngineDir(). Returns the verified binary path.
24
+ */
25
+ export declare function installLlamaEngine(onProgress?: (message: string) => void, get?: HttpGet): Promise<string>;
26
+ /** Copy the folder that contains llama-cli (binary + shared libraries) into place. */
27
+ export declare function installBinFolder(extractedRoot: string, installDir: string): Promise<void>;
@@ -0,0 +1,152 @@
1
+ /**
2
+ * llama.cpp engine: install from the pinned GitHub release and verify that a
3
+ * candidate binary actually runs.
4
+ *
5
+ * The release archives link llama-cli against shared libraries next to it
6
+ * (@rpath → @loader_path on macOS), so the whole bin folder is installed; a
7
+ * lone llama-cli fails with "Library not loaded: @rpath/libllama.dylib".
8
+ */
9
+ import os from 'os';
10
+ import path from 'path';
11
+ import fs from 'fs-extra';
12
+ import { RIGOUR_DIR } from '../storage/db.js';
13
+ import { downloadToFile } from './http-download.js';
14
+ import { runProcess } from './llama-process.js';
15
+ export const LLAMA_RELEASE_TAG = 'b5604';
16
+ const LLAMA_RELEASE_BASE = `https://github.com/ggml-org/llama.cpp/releases/download/${LLAMA_RELEASE_TAG}`;
17
+ const PROBE_TIMEOUT_MS = 5_000;
18
+ const EXTRACT_TIMEOUT_MS = 120_000;
19
+ /** Platform → llama.cpp release asset. linux-arm64 has no published build. */
20
+ const LLAMA_RELEASE_ASSETS = {
21
+ 'darwin-arm64': `llama-${LLAMA_RELEASE_TAG}-bin-macos-arm64.zip`,
22
+ 'darwin-x64': `llama-${LLAMA_RELEASE_TAG}-bin-macos-x64.zip`,
23
+ 'linux-x64': `llama-${LLAMA_RELEASE_TAG}-bin-ubuntu-x64.zip`,
24
+ 'win32-x64': `llama-${LLAMA_RELEASE_TAG}-bin-win-cpu-x64.zip`,
25
+ };
26
+ export function llamaBinaryName() {
27
+ return os.platform() === 'win32' ? 'llama-cli.exe' : 'llama-cli';
28
+ }
29
+ /** Install directory for the pinned release: ~/.rigour/bin/llama-<tag>. */
30
+ export function managedEngineDir() {
31
+ return path.join(RIGOUR_DIR, 'bin', `llama-${LLAMA_RELEASE_TAG}`);
32
+ }
33
+ export function managedEnginePath() {
34
+ return path.join(managedEngineDir(), llamaBinaryName());
35
+ }
36
+ export function releaseAssetFor(platformKey) {
37
+ return LLAMA_RELEASE_ASSETS[platformKey];
38
+ }
39
+ const probeCache = new Map();
40
+ /**
41
+ * Run `<binary> --version` and accept it only if it exits 0. A placeholder
42
+ * script (such as an unbundled brain package) or a binary missing its shared
43
+ * libraries fails here instead of on every inference call.
44
+ */
45
+ export function probeBinary(binaryPath) {
46
+ let cached = probeCache.get(binaryPath);
47
+ if (!cached) {
48
+ cached = runProbe(binaryPath);
49
+ probeCache.set(binaryPath, cached);
50
+ }
51
+ return cached;
52
+ }
53
+ /** Forget cached probe results, e.g. after installing a new engine. */
54
+ export function resetProbeCache() {
55
+ probeCache.clear();
56
+ }
57
+ async function runProbe(binaryPath) {
58
+ if (!(await fs.pathExists(binaryPath)))
59
+ return { ok: false, error: 'not found' };
60
+ try {
61
+ const { code, stdout, stderr } = await runProcess(binaryPath, ['--version'], { timeoutMs: PROBE_TIMEOUT_MS });
62
+ const output = `${stdout}\n${stderr}`;
63
+ if (code !== 0)
64
+ return { ok: false, error: lastLine(output) || `exit code ${code}` };
65
+ const version = output.match(/version:\s*([^\s]+)/)?.[1];
66
+ return { ok: true, version };
67
+ }
68
+ catch (error) {
69
+ return { ok: false, error: error?.message ?? String(error) };
70
+ }
71
+ }
72
+ function lastLine(text) {
73
+ return text.trim().split(/\r?\n/).pop()?.trim() ?? '';
74
+ }
75
+ /**
76
+ * Download the pinned release for this platform and install its whole bin
77
+ * folder into managedEngineDir(). Returns the verified binary path.
78
+ */
79
+ export async function installLlamaEngine(onProgress, get) {
80
+ const platformKey = `${os.platform()}-${os.arch()}`;
81
+ const asset = releaseAssetFor(platformKey);
82
+ if (!asset) {
83
+ throw new Error(`No llama.cpp ${LLAMA_RELEASE_TAG} build is published for ${platformKey}. Install llama-cli on PATH or use a cloud provider.`);
84
+ }
85
+ const installDir = managedEngineDir();
86
+ const workDir = `${installDir}.partial`;
87
+ const zipPath = path.join(path.dirname(installDir), asset);
88
+ await fs.ensureDir(path.dirname(installDir));
89
+ await fs.remove(workDir);
90
+ onProgress?.(`⬇ Downloading inference engine (llama.cpp ${LLAMA_RELEASE_TAG})...`);
91
+ try {
92
+ await downloadToFile(`${LLAMA_RELEASE_BASE}/${asset}`, zipPath, { get });
93
+ onProgress?.(' Extracting...');
94
+ await extractZip(zipPath, workDir);
95
+ await installBinFolder(workDir, installDir);
96
+ }
97
+ finally {
98
+ await fs.remove(zipPath).catch(() => undefined);
99
+ await fs.remove(workDir).catch(() => undefined);
100
+ }
101
+ resetProbeCache();
102
+ const binary = managedEnginePath();
103
+ const probe = await probeBinary(binary);
104
+ if (!probe.ok) {
105
+ throw new Error(`Installed llama-cli does not run: ${probe.error}`);
106
+ }
107
+ onProgress?.(`✓ Inference engine ready (llama.cpp ${probe.version ?? LLAMA_RELEASE_TAG})`);
108
+ return binary;
109
+ }
110
+ async function extractZip(zipPath, destDir) {
111
+ await fs.ensureDir(destDir);
112
+ const result = os.platform() === 'win32'
113
+ ? await runProcess('powershell', ['-NoProfile', '-Command', `Expand-Archive -LiteralPath ${psQuote(zipPath)} -DestinationPath ${psQuote(destDir)} -Force`], { timeoutMs: EXTRACT_TIMEOUT_MS })
114
+ : await runProcess('unzip', ['-q', '-o', zipPath, '-d', destDir], { timeoutMs: EXTRACT_TIMEOUT_MS });
115
+ if (result.code !== 0) {
116
+ throw new Error(`Could not extract ${path.basename(zipPath)}: ${lastLine(result.stderr) || `exit code ${result.code}`}`);
117
+ }
118
+ }
119
+ /** PowerShell single-quoted literal: a quote inside is escaped by doubling it. */
120
+ function psQuote(value) {
121
+ return `'${value.replace(/'/g, "''")}'`;
122
+ }
123
+ /** Copy the folder that contains llama-cli (binary + shared libraries) into place. */
124
+ export async function installBinFolder(extractedRoot, installDir) {
125
+ const binary = await findFileRecursive(extractedRoot, llamaBinaryName());
126
+ if (!binary)
127
+ throw new Error(`${llamaBinaryName()} not found in the llama.cpp release archive`);
128
+ await fs.remove(installDir);
129
+ await fs.copy(path.dirname(binary), installDir);
130
+ if (os.platform() !== 'win32') {
131
+ await fs.chmod(path.join(installDir, llamaBinaryName()), 0o755);
132
+ }
133
+ }
134
+ /** Release archives nest the binary at most a few folders deep (build/bin/). */
135
+ const MAX_ARCHIVE_DEPTH = 6;
136
+ async function findFileRecursive(dir, filename, depth = 0) {
137
+ if (depth > MAX_ARCHIVE_DEPTH)
138
+ return null;
139
+ const entries = await fs.readdir(dir, { withFileTypes: true });
140
+ for (const entry of entries) {
141
+ const fullPath = path.join(dir, entry.name);
142
+ if (entry.isDirectory()) {
143
+ const found = await findFileRecursive(fullPath, filename, depth + 1);
144
+ if (found)
145
+ return found;
146
+ }
147
+ else if (entry.name === filename) {
148
+ return fullPath;
149
+ }
150
+ }
151
+ return null;
152
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,51 @@
1
+ import fs from 'fs-extra';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import { installBinFolder, llamaBinaryName, probeBinary, resetProbeCache } from './llama-engine.js';
6
+ const isWindows = process.platform === 'win32';
7
+ async function writeScript(file, body) {
8
+ await fs.outputFile(file, `#!/bin/sh\n${body}\n`);
9
+ await fs.chmod(file, 0o755);
10
+ }
11
+ describe.skipIf(isWindows)('probeBinary', () => {
12
+ let dir;
13
+ beforeEach(async () => {
14
+ dir = await fs.mkdtemp(path.join(os.tmpdir(), 'rigour-engine-'));
15
+ resetProbeCache();
16
+ });
17
+ afterEach(async () => { await fs.remove(dir); });
18
+ it('accepts a binary that reports its version', async () => {
19
+ const bin = path.join(dir, 'llama-cli');
20
+ await writeScript(bin, 'echo "version: 5604 (228f34c9)" >&2; exit 0');
21
+ expect(await probeBinary(bin)).toEqual({ ok: true, version: '5604' });
22
+ });
23
+ it('rejects the placeholder brain script', async () => {
24
+ const bin = path.join(dir, 'rigour-brain');
25
+ await writeScript(bin, 'echo "rigour-brain is a placeholder." >&2; exit 1');
26
+ const result = await probeBinary(bin);
27
+ expect(result.ok).toBe(false);
28
+ expect(result.error).toContain('placeholder');
29
+ });
30
+ it('rejects a missing binary', async () => {
31
+ expect((await probeBinary(path.join(dir, 'nope'))).ok).toBe(false);
32
+ });
33
+ });
34
+ describe('installBinFolder', () => {
35
+ let dir;
36
+ beforeEach(async () => { dir = await fs.mkdtemp(path.join(os.tmpdir(), 'rigour-engine-')); });
37
+ afterEach(async () => { await fs.remove(dir); });
38
+ it('installs the binary together with the shared libraries next to it', async () => {
39
+ const extracted = path.join(dir, 'extract', 'build', 'bin');
40
+ await fs.outputFile(path.join(extracted, llamaBinaryName()), 'bin');
41
+ await fs.outputFile(path.join(extracted, 'libllama.dylib'), 'lib');
42
+ await fs.outputFile(path.join(extracted, 'ggml-metal.metal'), 'metal');
43
+ const installDir = path.join(dir, 'llama-b5604');
44
+ await installBinFolder(path.join(dir, 'extract'), installDir);
45
+ expect((await fs.readdir(installDir)).sort()).toEqual(['ggml-metal.metal', 'libllama.dylib', llamaBinaryName()].sort());
46
+ });
47
+ it('fails clearly when the archive has no llama-cli', async () => {
48
+ await fs.outputFile(path.join(dir, 'extract', 'README.md'), 'x');
49
+ await expect(installBinFolder(path.join(dir, 'extract'), path.join(dir, 'out'))).rejects.toThrow('not found');
50
+ });
51
+ });
@@ -0,0 +1,72 @@
1
+ export interface ProcessResult {
2
+ code: number | null;
3
+ stdout: string;
4
+ stderr: string;
5
+ }
6
+ export interface RunProcessOptions {
7
+ timeoutMs: number;
8
+ env?: NodeJS.ProcessEnv;
9
+ }
10
+ export declare class ProcessTimeoutError extends Error {
11
+ constructor(timeoutMs: number);
12
+ }
13
+ /**
14
+ * Run a binary with stdin closed, collecting stdout/stderr. Rejects on spawn
15
+ * errors and timeouts; a non-zero exit resolves with its code.
16
+ */
17
+ export declare function runProcess(command: string, args: string[], options: RunProcessOptions): Promise<ProcessResult>;
18
+ export interface LlamaArgsInput {
19
+ modelPath: string;
20
+ prompt: string;
21
+ maxTokens: number;
22
+ threads: number;
23
+ temperature: number;
24
+ schemaPath?: string;
25
+ }
26
+ export declare function buildLlamaArgs(input: LlamaArgsInput): string[];
27
+ /**
28
+ * Context window large enough for prompt + output, rounded up to 1024 and
29
+ * clamped to what Qwen2.5-Coder was trained on. The b5604 default is 4096,
30
+ * which silently truncates code-bearing prompts.
31
+ */
32
+ export declare function contextSizeFor(prompt: string, maxTokens: number): number;
33
+ /** Remove llama-cli's end-of-generation marker from stdout. */
34
+ export declare function cleanLlamaOutput(stdout: string): string;
35
+ /** JSON schema matching DeepFinding, used to constrain local generation. */
36
+ export declare const FINDINGS_JSON_SCHEMA: {
37
+ readonly type: "object";
38
+ readonly properties: {
39
+ readonly findings: {
40
+ readonly type: "array";
41
+ readonly items: {
42
+ readonly type: "object";
43
+ readonly properties: {
44
+ readonly category: {
45
+ readonly type: "string";
46
+ };
47
+ readonly severity: {
48
+ readonly type: "string";
49
+ readonly enum: readonly ["critical", "high", "medium", "low", "info"];
50
+ };
51
+ readonly file: {
52
+ readonly type: "string";
53
+ };
54
+ readonly line: {
55
+ readonly type: readonly ["integer", "null"];
56
+ };
57
+ readonly description: {
58
+ readonly type: "string";
59
+ };
60
+ readonly suggestion: {
61
+ readonly type: "string";
62
+ };
63
+ readonly confidence: {
64
+ readonly type: "number";
65
+ };
66
+ };
67
+ readonly required: readonly ["category", "severity", "file", "description", "suggestion", "confidence"];
68
+ };
69
+ };
70
+ };
71
+ readonly required: readonly ["findings"];
72
+ };
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Process runner and argument builder for llama-cli (llama.cpp b5604).
3
+ *
4
+ * Flags verified against b5604:
5
+ * - `-st` applies the model's chat template for one turn, then exits. Without
6
+ * it an instruct model starts an interactive chat and waits on stdin;
7
+ * `-no-cnv` avoids that but treats the prompt as raw text to continue.
8
+ * - `--json-schema-file` constrains output to the findings schema. There is
9
+ * no `--json` flag; b5604 rejects it.
10
+ * - `--log-disable` is NOT used: in b5604 it also suppresses the generated
11
+ * text, so stdout comes back empty.
12
+ * - `--seed` is fixed: llama.cpp otherwise draws a random seed per run, so
13
+ * the same file could pass one CI run and fail the next.
14
+ */
15
+ import { spawn } from 'child_process';
16
+ const MAX_OUTPUT_BYTES = 10 * 1024 * 1024;
17
+ const MIN_CONTEXT = 4096;
18
+ const MAX_CONTEXT = 32768;
19
+ /** Conservative characters-per-token for code and JSON-ish prompts. */
20
+ const CHARS_PER_TOKEN = 3;
21
+ const FIXED_SEED = 42;
22
+ export class ProcessTimeoutError extends Error {
23
+ constructor(timeoutMs) {
24
+ super(`timed out after ${timeoutMs / 1000}s`);
25
+ this.name = 'ProcessTimeoutError';
26
+ }
27
+ }
28
+ /**
29
+ * Run a binary with stdin closed, collecting stdout/stderr. Rejects on spawn
30
+ * errors and timeouts; a non-zero exit resolves with its code.
31
+ */
32
+ export function runProcess(command, args, options) {
33
+ const isWindowsScript = process.platform === 'win32' && /\.(cmd|bat)$/i.test(command);
34
+ const [bin, argv] = isWindowsScript
35
+ ? ['cmd.exe', ['/d', '/s', '/c', [command, ...args].map(quoteCmdArg).join(' ')]]
36
+ : [command, args];
37
+ return new Promise((resolve, reject) => {
38
+ const child = spawn(bin, argv, { stdio: ['ignore', 'pipe', 'pipe'], env: options.env, windowsHide: true });
39
+ const stdout = new BoundedBuffer();
40
+ const stderr = new BoundedBuffer();
41
+ const timer = setTimeout(() => {
42
+ child.kill('SIGKILL');
43
+ reject(new ProcessTimeoutError(options.timeoutMs));
44
+ }, options.timeoutMs);
45
+ child.stdout.on('data', (chunk) => stdout.push(chunk));
46
+ child.stderr.on('data', (chunk) => stderr.push(chunk));
47
+ child.on('error', (error) => {
48
+ clearTimeout(timer);
49
+ reject(error);
50
+ });
51
+ child.on('close', (code) => {
52
+ clearTimeout(timer);
53
+ resolve({ code, stdout: stdout.toString(), stderr: stderr.toString() });
54
+ });
55
+ });
56
+ }
57
+ class BoundedBuffer {
58
+ chunks = [];
59
+ size = 0;
60
+ push(chunk) {
61
+ if (this.size >= MAX_OUTPUT_BYTES)
62
+ return;
63
+ this.chunks.push(chunk);
64
+ this.size += chunk.length;
65
+ }
66
+ toString() {
67
+ return Buffer.concat(this.chunks).toString('utf8');
68
+ }
69
+ }
70
+ function quoteCmdArg(value) {
71
+ return `"${value.replace(/"/g, '\\"')}"`;
72
+ }
73
+ export function buildLlamaArgs(input) {
74
+ const args = [
75
+ '--model', input.modelPath,
76
+ '--prompt', input.prompt,
77
+ '--n-predict', String(input.maxTokens),
78
+ '--threads', String(input.threads),
79
+ '--temp', String(input.temperature),
80
+ '--seed', String(FIXED_SEED),
81
+ '--ctx-size', String(contextSizeFor(input.prompt, input.maxTokens)),
82
+ '--no-display-prompt',
83
+ '-st',
84
+ '--simple-io',
85
+ ];
86
+ if (input.schemaPath)
87
+ args.push('--json-schema-file', input.schemaPath);
88
+ return args;
89
+ }
90
+ /**
91
+ * Context window large enough for prompt + output, rounded up to 1024 and
92
+ * clamped to what Qwen2.5-Coder was trained on. The b5604 default is 4096,
93
+ * which silently truncates code-bearing prompts.
94
+ */
95
+ export function contextSizeFor(prompt, maxTokens) {
96
+ const needed = Math.ceil(prompt.length / CHARS_PER_TOKEN) + maxTokens + 256;
97
+ const rounded = Math.ceil(needed / 1024) * 1024;
98
+ return Math.min(MAX_CONTEXT, Math.max(MIN_CONTEXT, rounded));
99
+ }
100
+ /** Remove llama-cli's end-of-generation marker from stdout. */
101
+ export function cleanLlamaOutput(stdout) {
102
+ return stdout.replace(/\s*\[end of text\]\s*$/, '').trim();
103
+ }
104
+ /** JSON schema matching DeepFinding, used to constrain local generation. */
105
+ export const FINDINGS_JSON_SCHEMA = {
106
+ type: 'object',
107
+ properties: {
108
+ findings: {
109
+ type: 'array',
110
+ items: {
111
+ type: 'object',
112
+ properties: {
113
+ category: { type: 'string' },
114
+ severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low', 'info'] },
115
+ file: { type: 'string' },
116
+ line: { type: ['integer', 'null'] },
117
+ description: { type: 'string' },
118
+ suggestion: { type: 'string' },
119
+ confidence: { type: 'number' },
120
+ },
121
+ required: ['category', 'severity', 'file', 'description', 'suggestion', 'confidence'],
122
+ },
123
+ },
124
+ },
125
+ required: ['findings'],
126
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildLlamaArgs, cleanLlamaOutput, contextSizeFor, runProcess } from './llama-process.js';
3
+ const base = { modelPath: '/m.gguf', prompt: 'hello', maxTokens: 1024, threads: 4, temperature: 0.1 };
4
+ describe('buildLlamaArgs', () => {
5
+ it('uses flags llama.cpp b5604 accepts and that keep generated text on stdout', () => {
6
+ const args = buildLlamaArgs(base);
7
+ expect(args).toContain('-st');
8
+ expect(args).toContain('--simple-io');
9
+ expect(args).toContain('--no-display-prompt');
10
+ expect(args).not.toContain('--json');
11
+ expect(args).not.toContain('--log-disable');
12
+ });
13
+ it('fixes the seed so a run is reproducible', () => {
14
+ const args = buildLlamaArgs(base);
15
+ expect(args[args.indexOf('--seed') + 1]).toBe('42');
16
+ });
17
+ it('constrains output with the findings schema file when given', () => {
18
+ const args = buildLlamaArgs({ ...base, schemaPath: '/schema.json' });
19
+ expect(args.slice(-2)).toEqual(['--json-schema-file', '/schema.json']);
20
+ expect(buildLlamaArgs(base)).not.toContain('--json-schema-file');
21
+ });
22
+ it('passes a context size that fits the prompt', () => {
23
+ const args = buildLlamaArgs({ ...base, prompt: 'x'.repeat(30_000) });
24
+ expect(Number(args[args.indexOf('--ctx-size') + 1])).toBe(contextSizeFor('x'.repeat(30_000), 1024));
25
+ });
26
+ });
27
+ describe('contextSizeFor', () => {
28
+ it('never goes below the 4096 default', () => {
29
+ expect(contextSizeFor('short', 512)).toBe(4096);
30
+ });
31
+ it('grows with the prompt, rounded up to 1024', () => {
32
+ const size = contextSizeFor('x'.repeat(30_000), 1024);
33
+ expect(size).toBeGreaterThanOrEqual(10_000 + 1024);
34
+ expect(size % 1024).toBe(0);
35
+ });
36
+ it('is capped at the model training context', () => {
37
+ expect(contextSizeFor('x'.repeat(1_000_000), 1024)).toBe(32768);
38
+ });
39
+ });
40
+ describe('cleanLlamaOutput', () => {
41
+ it('strips the end-of-generation marker', () => {
42
+ expect(cleanLlamaOutput('{"findings": []} [end of text]\n\n')).toBe('{"findings": []}');
43
+ });
44
+ });
45
+ describe('runProcess', () => {
46
+ it('collects stdout and stderr and returns the exit code', async () => {
47
+ const result = await runProcess(process.execPath, ['-e', 'process.stdout.write("out");process.stderr.write("err");process.exit(3)'], { timeoutMs: 10_000 });
48
+ expect(result).toEqual({ code: 3, stdout: 'out', stderr: 'err' });
49
+ });
50
+ it('runs with stdin closed so an interactive binary cannot wait on it', async () => {
51
+ const result = await runProcess(process.execPath, ['-e', 'process.stdin.on("end",()=>process.stdout.write("eof")).resume()'], { timeoutMs: 10_000 });
52
+ expect(result.stdout).toBe('eof');
53
+ });
54
+ it('kills and rejects on timeout', async () => {
55
+ await expect(runProcess(process.execPath, ['-e', 'setTimeout(()=>{}, 10_000)'], { timeoutMs: 100 }))
56
+ .rejects.toThrow('timed out');
57
+ });
58
+ it('rejects when the binary cannot be spawned', async () => {
59
+ await expect(runProcess('/nonexistent/llama-cli', [], { timeoutMs: 1_000 })).rejects.toThrow();
60
+ });
61
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,73 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import { createHash } from 'crypto';
4
+ import { Readable } from 'stream';
5
+ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
6
+ const home = vi.hoisted(() => {
7
+ const p = require('path');
8
+ const o = require('os');
9
+ return p.join(o.tmpdir(), `rigour-models-${process.pid}-${Date.now()}`);
10
+ });
11
+ vi.mock('../storage/db.js', () => ({ RIGOUR_DIR: home }));
12
+ const { downloadModel, getCachedModel } = await import('./model-manager.js');
13
+ const { FALLBACK_MODELS, MODELS } = await import('./types.js');
14
+ const modelsDir = path.join(home, 'models');
15
+ const PAYLOAD = Buffer.from('gguf-model-bytes');
16
+ const PAYLOAD_SHA = createHash('sha256').update(PAYLOAD).digest('hex');
17
+ function ok(body, sha) {
18
+ return Promise.resolve({ statusCode: 200, headers: { 'x-linked-etag': `"${sha}"` }, body: Readable.from([body]) });
19
+ }
20
+ const unauthorized = Promise.resolve({ statusCode: 401, headers: {}, body: Readable.from([]) });
21
+ describe('downloadModel', () => {
22
+ beforeEach(async () => {
23
+ await fs.remove(home);
24
+ // A fresh version check: no network call to Hugging Face.
25
+ await fs.outputJson(path.join(modelsDir, '.latest_version.json'), { version: '2.0.0', checkedAt: new Date().toISOString() });
26
+ });
27
+ afterAll(async () => { await fs.remove(home); });
28
+ it('falls back to the stock model when the fine-tuned model is unavailable, and says so', async () => {
29
+ const requested = [];
30
+ const get = (url) => {
31
+ requested.push(url);
32
+ return url.includes('rigour-labs/') ? unauthorized : ok(PAYLOAD, PAYLOAD_SHA);
33
+ };
34
+ const messages = [];
35
+ const modelPath = await downloadModel('lite', m => messages.push(m), get);
36
+ expect(requested).toEqual([MODELS.lite.url, FALLBACK_MODELS.lite.url]);
37
+ expect(path.basename(modelPath)).toBe(FALLBACK_MODELS.lite.filename);
38
+ expect(messages.some(m => m.includes('Fine-tuned model unavailable (HTTP 401'))).toBe(true);
39
+ const meta = await fs.readJson(`${modelPath}.meta.json`);
40
+ expect(meta).toMatchObject({ sha256: PAYLOAD_SHA, sizeBytes: PAYLOAD.length, sourceUrl: FALLBACK_MODELS.lite.url });
41
+ });
42
+ it('rejects a checksum mismatch and deletes the partial download', async () => {
43
+ const get = () => ok(PAYLOAD, 'f'.repeat(64));
44
+ await expect(downloadModel('lite', undefined, get)).rejects.toThrow('Checksum mismatch');
45
+ expect(await fs.pathExists(path.join(modelsDir, `${FALLBACK_MODELS.lite.filename}.download`))).toBe(false);
46
+ expect(await fs.pathExists(path.join(modelsDir, FALLBACK_MODELS.lite.filename))).toBe(false);
47
+ });
48
+ });
49
+ describe('getCachedModel', () => {
50
+ beforeEach(async () => { await fs.remove(home); });
51
+ async function cacheFile(filename, sizeBytes) {
52
+ const file = path.join(modelsDir, filename);
53
+ await fs.ensureDir(modelsDir);
54
+ await fs.writeFile(file, '');
55
+ await fs.truncate(file, sizeBytes); // sparse: no real disk use
56
+ await fs.writeJson(`${file}.meta.json`, {
57
+ sha256: 'a'.repeat(64), sizeBytes, verifiedAt: new Date(Date.now() + 1000).toISOString(), sourceUrl: 'https://x',
58
+ });
59
+ }
60
+ it('returns null when nothing is cached', async () => {
61
+ expect(await getCachedModel('deep')).toBeNull();
62
+ });
63
+ it('reports the stock fallback when only it is cached', async () => {
64
+ await cacheFile(FALLBACK_MODELS.deep.filename, FALLBACK_MODELS.deep.sizeBytes);
65
+ const cached = await getCachedModel('deep');
66
+ expect(cached).toMatchObject({ fallback: true, info: { filename: FALLBACK_MODELS.deep.filename } });
67
+ });
68
+ it('ignores a file without verified metadata', async () => {
69
+ await fs.ensureDir(modelsDir);
70
+ await fs.writeFile(path.join(modelsDir, FALLBACK_MODELS.deep.filename), 'partial');
71
+ expect(await getCachedModel('deep')).toBeNull();
72
+ });
73
+ });
@@ -1,3 +1,4 @@
1
+ import { type HttpGet } from './http-download.js';
1
2
  import { type ModelTier, type ModelInfo } from './types.js';
2
3
  export declare function extractSha256FromEtag(etag: string | null): string | null;
3
4
  export declare function hashFileSha256(filePath: string): Promise<string>;
@@ -5,8 +6,20 @@ export declare function hashFileSha256(filePath: string): Promise<string>;
5
6
  * Check if any model for this tier is cached (fine-tuned or fallback).
6
7
  */
7
8
  export declare function isModelCached(tier: ModelTier): Promise<boolean>;
9
+ export interface CachedModel {
10
+ path: string;
11
+ info: ModelInfo;
12
+ /** True when the stock fallback is in use because the fine-tuned model is not cached. */
13
+ fallback: boolean;
14
+ }
15
+ /**
16
+ * The verified model file for a tier, preferring the fine-tuned model over
17
+ * the stock fallback. Null when neither is cached and verified.
18
+ */
19
+ export declare function getCachedModel(tier: ModelTier): Promise<CachedModel | null>;
8
20
  /**
9
21
  * Get the path to a cached model (prefers fine-tuned over fallback).
22
+ * Existence only; use getCachedModel() for a verified file.
10
23
  */
11
24
  export declare function getModelPath(tier: ModelTier): string;
12
25
  /**
@@ -25,7 +38,7 @@ export declare function checkForUpdates(onProgress?: (message: string, percent?:
25
38
  * Download a model from HuggingFace CDN.
26
39
  * Checks for updates first, then tries fine-tuned model, falls back to stock Qwen.
27
40
  */
28
- export declare function downloadModel(tier: ModelTier, onProgress?: (message: string, percent?: number) => void): Promise<string>;
41
+ export declare function downloadModel(tier: ModelTier, onProgress?: (message: string, percent?: number) => void, get?: HttpGet): Promise<string>;
29
42
  /**
30
43
  * Ensure a model is available, downloading if needed.
31
44
  */