@rigour-labs/core 6.2.3 → 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.
- package/dist/deep/code-context.d.ts +28 -0
- package/dist/deep/code-context.js +93 -0
- package/dist/deep/code-context.test.d.ts +1 -0
- package/dist/deep/code-context.test.js +48 -0
- package/dist/deep/code-pass.d.ts +15 -0
- package/dist/deep/code-pass.js +44 -0
- package/dist/deep/code-review-prompt.d.ts +17 -0
- package/dist/deep/code-review-prompt.js +30 -0
- package/dist/deep/code-review-prompt.test.d.ts +1 -0
- package/dist/deep/code-review-prompt.test.js +20 -0
- package/dist/deep/code-verifier.d.ts +19 -0
- package/dist/deep/code-verifier.js +48 -0
- package/dist/deep/code-verifier.test.d.ts +1 -0
- package/dist/deep/code-verifier.test.js +46 -0
- package/dist/deep/fact-extractor.d.ts +7 -1
- package/dist/deep/fact-extractor.js +16 -8
- package/dist/deep/fact-extractor.test.js +34 -1
- package/dist/deep/facts-pass.d.ts +24 -0
- package/dist/deep/facts-pass.js +69 -0
- package/dist/deep/index.d.ts +6 -1
- package/dist/deep/index.js +5 -1
- package/dist/deep/parse-findings.d.ts +12 -0
- package/dist/deep/parse-findings.js +73 -0
- package/dist/deep/parse-findings.test.d.ts +1 -0
- package/dist/deep/parse-findings.test.js +25 -0
- package/dist/deep/prompts.d.ts +1 -1
- package/dist/deep/prompts.js +7 -8
- package/dist/deep/prompts.test.js +15 -0
- package/dist/gates/context.js +7 -0
- package/dist/gates/deep-analysis.d.ts +31 -8
- package/dist/gates/deep-analysis.js +165 -306
- package/dist/gates/deep-analysis.test.d.ts +1 -0
- package/dist/gates/deep-analysis.test.js +86 -0
- package/dist/gates/deep-runner.d.ts +15 -0
- package/dist/gates/deep-runner.js +65 -0
- package/dist/gates/dependency.js +4 -0
- package/dist/gates/hallucinated-imports/index.js +13 -9
- package/dist/gates/hallucinated-imports.test.js +10 -0
- package/dist/gates/js-style-context.d.ts +3 -0
- package/dist/gates/js-style-context.js +65 -0
- package/dist/gates/js-style-context.test.d.ts +1 -0
- package/dist/gates/js-style-context.test.js +35 -0
- package/dist/gates/logic-drift-git-base.d.ts +7 -0
- package/dist/gates/logic-drift-git-base.js +41 -0
- package/dist/gates/logic-drift.d.ts +3 -3
- package/dist/gates/logic-drift.js +39 -7
- package/dist/gates/logic-drift.test.d.ts +1 -0
- package/dist/gates/logic-drift.test.js +45 -0
- package/dist/gates/runner.js +5 -40
- package/dist/gates/runner.test.js +37 -25
- package/dist/gates/scoped-gates.test.d.ts +1 -0
- package/dist/gates/scoped-gates.test.js +52 -0
- package/dist/gates/security-command-execution.d.ts +5 -0
- package/dist/gates/security-command-execution.js +123 -0
- package/dist/gates/security-patterns-data.js +1 -17
- package/dist/gates/security-patterns.d.ts +0 -5
- package/dist/gates/security-patterns.js +11 -19
- package/dist/gates/security-patterns.test.js +37 -2
- package/dist/gates/style-drift.d.ts +4 -4
- package/dist/gates/style-drift.js +15 -16
- package/dist/gates/style-drift.test.d.ts +1 -0
- package/dist/gates/style-drift.test.js +26 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +6 -1
- package/dist/inference/http-download.d.ts +22 -0
- package/dist/inference/http-download.js +132 -0
- package/dist/inference/http-download.test.d.ts +1 -0
- package/dist/inference/http-download.test.js +109 -0
- package/dist/inference/index.d.ts +5 -1
- package/dist/inference/index.js +2 -1
- package/dist/inference/llama-engine.d.ts +27 -0
- package/dist/inference/llama-engine.js +152 -0
- package/dist/inference/llama-engine.test.d.ts +1 -0
- package/dist/inference/llama-engine.test.js +51 -0
- package/dist/inference/llama-process.d.ts +72 -0
- package/dist/inference/llama-process.js +126 -0
- package/dist/inference/llama-process.test.d.ts +1 -0
- package/dist/inference/llama-process.test.js +61 -0
- package/dist/inference/model-download.test.d.ts +1 -0
- package/dist/inference/model-download.test.js +73 -0
- package/dist/inference/model-manager.d.ts +14 -1
- package/dist/inference/model-manager.js +65 -80
- package/dist/inference/sidecar-provider.d.ts +19 -10
- package/dist/inference/sidecar-provider.js +115 -343
- package/dist/templates/universal-config.js +0 -2
- package/dist/types/index.d.ts +58 -14
- package/dist/types/index.js +9 -2
- package/dist/utils/deep-status.d.ts +7 -0
- package/dist/utils/deep-status.js +11 -0
- package/dist/utils/diff.d.ts +3 -0
- package/dist/utils/diff.js +52 -0
- package/dist/utils/diff.test.d.ts +1 -0
- package/dist/utils/diff.test.js +38 -0
- package/dist/utils/scanner.js +13 -2
- package/dist/utils/scanner.test.js +22 -8
- package/dist/utils/scope.d.ts +15 -0
- package/dist/utils/scope.js +51 -0
- package/dist/utils/scope.test.d.ts +1 -0
- package/dist/utils/scope.test.js +38 -0
- package/package.json +6 -6
|
@@ -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
|
*/
|
|
@@ -6,6 +6,7 @@ import path from 'path';
|
|
|
6
6
|
import fs from 'fs-extra';
|
|
7
7
|
import { createHash } from 'crypto';
|
|
8
8
|
import { RIGOUR_DIR } from '../storage/db.js';
|
|
9
|
+
import { downloadToFile } from './http-download.js';
|
|
9
10
|
import { MODELS, FALLBACK_MODELS, VERSION_CHECK_URL, BUNDLED_MODEL_VERSION, updateModelVersion } from './types.js';
|
|
10
11
|
const MODELS_DIR = path.join(RIGOUR_DIR, 'models');
|
|
11
12
|
const VERSION_CACHE_PATH = path.join(MODELS_DIR, '.latest_version.json');
|
|
@@ -76,13 +77,26 @@ async function isFileCached(model) {
|
|
|
76
77
|
* Check if any model for this tier is cached (fine-tuned or fallback).
|
|
77
78
|
*/
|
|
78
79
|
export async function isModelCached(tier) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
return (await getCachedModel(tier)) !== null;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The verified model file for a tier, preferring the fine-tuned model over
|
|
84
|
+
* the stock fallback. Null when neither is cached and verified.
|
|
85
|
+
*/
|
|
86
|
+
export async function getCachedModel(tier) {
|
|
87
|
+
const primary = MODELS[tier];
|
|
88
|
+
if (await isFileCached(primary)) {
|
|
89
|
+
return { path: path.join(MODELS_DIR, primary.filename), info: primary, fallback: false };
|
|
90
|
+
}
|
|
91
|
+
const fallback = FALLBACK_MODELS[tier];
|
|
92
|
+
if (fallback.url !== primary.url && await isFileCached(fallback)) {
|
|
93
|
+
return { path: path.join(MODELS_DIR, fallback.filename), info: fallback, fallback: true };
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
83
96
|
}
|
|
84
97
|
/**
|
|
85
98
|
* Get the path to a cached model (prefers fine-tuned over fallback).
|
|
99
|
+
* Existence only; use getCachedModel() for a verified file.
|
|
86
100
|
*/
|
|
87
101
|
export function getModelPath(tier) {
|
|
88
102
|
const primary = path.join(MODELS_DIR, MODELS[tier].filename);
|
|
@@ -97,84 +111,55 @@ export function getModelInfo(tier) {
|
|
|
97
111
|
return MODELS[tier];
|
|
98
112
|
}
|
|
99
113
|
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
|
|
103
|
-
async function streamToDisk(response, tempPath, model, onProgress) {
|
|
104
|
-
const contentLength = parseInt(response.headers.get('content-length') || '0', 10);
|
|
105
|
-
const reader = response.body?.getReader();
|
|
106
|
-
if (!reader)
|
|
107
|
-
throw new Error('No response body');
|
|
108
|
-
const writeStream = fs.createWriteStream(tempPath);
|
|
109
|
-
const hash = createHash('sha256');
|
|
110
|
-
let downloaded = 0;
|
|
111
|
-
let lastPct = 0;
|
|
112
|
-
while (true) {
|
|
113
|
-
const { done, value } = await reader.read();
|
|
114
|
-
if (done)
|
|
115
|
-
break;
|
|
116
|
-
const chunk = Buffer.from(value);
|
|
117
|
-
writeStream.write(chunk);
|
|
118
|
-
hash.update(chunk);
|
|
119
|
-
downloaded += value.length;
|
|
120
|
-
if (contentLength > 0) {
|
|
121
|
-
const pct = Math.round((downloaded / contentLength) * 100);
|
|
122
|
-
if (pct >= lastPct + 5) {
|
|
123
|
-
lastPct = pct;
|
|
124
|
-
onProgress?.(`Downloading ${model.name}: ${pct}%`, pct);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
writeStream.end();
|
|
129
|
-
await new Promise((resolve, reject) => {
|
|
130
|
-
writeStream.on('finish', resolve);
|
|
131
|
-
writeStream.on('error', reject);
|
|
132
|
-
});
|
|
133
|
-
return { sha256: hash.digest('hex'), downloaded };
|
|
134
|
-
}
|
|
135
|
-
/**
|
|
136
|
-
* Verify SHA256 against ETag, allowing LFS OID mismatches
|
|
137
|
-
* if the download size is reasonable.
|
|
114
|
+
* Reject a download whose SHA-256 differs from the one the server published.
|
|
115
|
+
* Hugging Face publishes the LFS object's SHA-256, which is the content hash,
|
|
116
|
+
* so any mismatch means a corrupt or tampered file.
|
|
138
117
|
*/
|
|
139
|
-
function verifySha256(expectedSha256, actualSha256,
|
|
118
|
+
function verifySha256(expectedSha256, actualSha256, model) {
|
|
140
119
|
if (!expectedSha256 || actualSha256 === expectedSha256)
|
|
141
120
|
return;
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
121
|
+
throw new Error(`Checksum mismatch for ${model.name}: expected ${expectedSha256}, got ${actualSha256}`);
|
|
122
|
+
}
|
|
123
|
+
function progressReporter(model, onProgress) {
|
|
124
|
+
let lastPct = -5;
|
|
125
|
+
return (downloaded, total) => {
|
|
126
|
+
const size = total ?? model.sizeBytes;
|
|
127
|
+
const pct = Math.min(99, Math.round((downloaded / size) * 100));
|
|
128
|
+
if (pct >= lastPct + 5) {
|
|
129
|
+
lastPct = pct;
|
|
130
|
+
onProgress?.(`Downloading ${model.name}: ${pct}%`, pct);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
149
133
|
}
|
|
150
134
|
/**
|
|
151
|
-
* Download a
|
|
135
|
+
* Download a model to `<file>.download`, verify it, then move it into place
|
|
136
|
+
* with its metadata. A partial file survives network errors so the next run
|
|
137
|
+
* resumes it; a checksum mismatch deletes it.
|
|
152
138
|
*/
|
|
153
|
-
async function downloadFromUrl(
|
|
139
|
+
async function downloadFromUrl(model, onProgress, get) {
|
|
154
140
|
const destPath = path.join(MODELS_DIR, model.filename);
|
|
155
141
|
const tempPath = destPath + '.download';
|
|
142
|
+
const { sha256, bytes, etag } = await downloadToFile(model.url, tempPath, {
|
|
143
|
+
onProgress: progressReporter(model, onProgress),
|
|
144
|
+
get,
|
|
145
|
+
});
|
|
156
146
|
try {
|
|
157
|
-
|
|
158
|
-
if (!response.ok) {
|
|
159
|
-
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
160
|
-
}
|
|
161
|
-
const expectedSha = extractSha256FromEtag(response.headers.get('etag'));
|
|
162
|
-
const { sha256, downloaded } = await streamToDisk(response, tempPath, model, onProgress);
|
|
163
|
-
verifySha256(expectedSha, sha256, downloaded, model);
|
|
164
|
-
fs.renameSync(tempPath, destPath);
|
|
165
|
-
await writeModelMeta(model.filename, {
|
|
166
|
-
sha256, sizeBytes: downloaded,
|
|
167
|
-
verifiedAt: new Date().toISOString(),
|
|
168
|
-
sourceUrl: model.url,
|
|
169
|
-
sourceEtag: response.headers.get('etag') || undefined,
|
|
170
|
-
});
|
|
171
|
-
onProgress?.(`Model ${model.name} ready`, 100);
|
|
172
|
-
return destPath;
|
|
147
|
+
verifySha256(extractSha256FromEtag(etag), sha256, model);
|
|
173
148
|
}
|
|
174
149
|
catch (error) {
|
|
175
|
-
fs.
|
|
150
|
+
await fs.remove(tempPath);
|
|
176
151
|
throw error;
|
|
177
152
|
}
|
|
153
|
+
await fs.move(tempPath, destPath, { overwrite: true });
|
|
154
|
+
await writeModelMeta(model.filename, {
|
|
155
|
+
sha256,
|
|
156
|
+
sizeBytes: bytes,
|
|
157
|
+
verifiedAt: new Date().toISOString(),
|
|
158
|
+
sourceUrl: model.url,
|
|
159
|
+
sourceEtag: etag || undefined,
|
|
160
|
+
});
|
|
161
|
+
onProgress?.(`Model ${model.name} ready`, 100);
|
|
162
|
+
return destPath;
|
|
178
163
|
}
|
|
179
164
|
/**
|
|
180
165
|
* Check HuggingFace for a newer model version (like antivirus signature updates).
|
|
@@ -236,25 +221,25 @@ export async function checkForUpdates(onProgress) {
|
|
|
236
221
|
* Download a model from HuggingFace CDN.
|
|
237
222
|
* Checks for updates first, then tries fine-tuned model, falls back to stock Qwen.
|
|
238
223
|
*/
|
|
239
|
-
export async function downloadModel(tier, onProgress) {
|
|
224
|
+
export async function downloadModel(tier, onProgress, get) {
|
|
240
225
|
fs.ensureDirSync(MODELS_DIR);
|
|
241
226
|
// Check for newer model version (non-blocking, cached 24h)
|
|
242
227
|
await checkForUpdates(onProgress);
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
228
|
+
const cached = await getCachedModel(tier);
|
|
229
|
+
if (cached) {
|
|
230
|
+
onProgress?.(`Model ${cached.info.name} already cached`, 100);
|
|
231
|
+
return cached.path;
|
|
246
232
|
}
|
|
247
233
|
const model = MODELS[tier];
|
|
248
234
|
onProgress?.(`Downloading ${model.name} (${model.sizeHuman})...`, 0);
|
|
249
235
|
try {
|
|
250
|
-
return await downloadFromUrl(
|
|
236
|
+
return await downloadFromUrl(model, onProgress, get);
|
|
251
237
|
}
|
|
252
238
|
catch (error) {
|
|
253
|
-
// Fine-tuned model not available — try stock fallback
|
|
254
239
|
const fallback = FALLBACK_MODELS[tier];
|
|
255
240
|
if (fallback && fallback.url !== model.url) {
|
|
256
|
-
onProgress?.(`Fine-tuned model unavailable
|
|
257
|
-
return downloadFromUrl(
|
|
241
|
+
onProgress?.(`Fine-tuned model unavailable (${error?.message ?? 'unknown error'}); using stock ${fallback.name}`, 0);
|
|
242
|
+
return downloadFromUrl(fallback, onProgress, get);
|
|
258
243
|
}
|
|
259
244
|
throw error;
|
|
260
245
|
}
|
|
@@ -263,9 +248,9 @@ export async function downloadModel(tier, onProgress) {
|
|
|
263
248
|
* Ensure a model is available, downloading if needed.
|
|
264
249
|
*/
|
|
265
250
|
export async function ensureModel(tier, onProgress) {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
251
|
+
const cached = await getCachedModel(tier);
|
|
252
|
+
if (cached)
|
|
253
|
+
return cached.path;
|
|
269
254
|
return downloadModel(tier, onProgress);
|
|
270
255
|
}
|
|
271
256
|
/**
|
|
@@ -1,23 +1,32 @@
|
|
|
1
1
|
import type { InferenceProvider, InferenceOptions, ModelTier } from './types.js';
|
|
2
|
+
export interface ActiveModel {
|
|
3
|
+
name: string;
|
|
4
|
+
filename: string;
|
|
5
|
+
fallback: boolean;
|
|
6
|
+
}
|
|
2
7
|
export declare class SidecarProvider implements InferenceProvider {
|
|
3
8
|
readonly name = "sidecar";
|
|
4
9
|
private binaryPath;
|
|
5
|
-
private
|
|
10
|
+
private model;
|
|
11
|
+
private schemaPath;
|
|
6
12
|
private tier;
|
|
7
13
|
private threads;
|
|
8
14
|
constructor(tier?: ModelTier, threads?: number);
|
|
15
|
+
/** True when a llama-cli that actually runs is installed. */
|
|
9
16
|
isAvailable(): Promise<boolean>;
|
|
17
|
+
/** The llama-cli this provider would use and its version, or null if none runs. */
|
|
18
|
+
findEngine(): Promise<{
|
|
19
|
+
path: string;
|
|
20
|
+
version?: string;
|
|
21
|
+
} | null>;
|
|
22
|
+
/** Install the engine and model for this tier without running inference. */
|
|
23
|
+
prepare(onProgress?: (message: string) => void): Promise<void>;
|
|
10
24
|
setup(onProgress?: (message: string) => void): Promise<void>;
|
|
25
|
+
/** The model file setup() loaded, or null before setup. */
|
|
26
|
+
getActiveModel(): ActiveModel | null;
|
|
11
27
|
analyze(prompt: string, options?: InferenceOptions): Promise<string>;
|
|
12
28
|
dispose(): void;
|
|
13
|
-
|
|
14
|
-
private getPlatformPackageName;
|
|
29
|
+
/** First candidate binary that exists, is executable and passes the probe. */
|
|
15
30
|
private resolveBinaryPath;
|
|
16
|
-
|
|
17
|
-
* Download llama-cli directly from llama.cpp GitHub releases.
|
|
18
|
-
* Extracts the binary to ~/.rigour/bin/llama-cli
|
|
19
|
-
*/
|
|
20
|
-
private downloadLlamaCli;
|
|
21
|
-
/** Legacy: install via npm brain package */
|
|
22
|
-
private installSidecarBinary;
|
|
31
|
+
private candidateBinaries;
|
|
23
32
|
}
|