moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

354
scripts/stage-0-query.mjs Normal file
View File

@@ -0,0 +1,354 @@
#!/usr/bin/env node
// Stage 0 Dogfood — single-query runner (full-stack cell equivalent).
//
// Brief: PM-Waggle-OS/briefs/2026-04-20-cc-stage-0-dogfood-tasks.md Task 3
// Spec: strategy/2026-04-20-preflight-gate-spec.md §2 (Stage 0)
//
// Minimal-invasive path: reuses the hive-mind CLI for retrieval and calls
// LiteLLM directly for the Qwen inference layer. Emits a single JSON
// artifact per question that downstream Stage 0 report assembly consumes.
//
// Usage:
// node scripts/stage-0-query.mjs \
// --question "<verbatim>" \
// --data-dir "D:/dogfood-exports/2026-04-20/kg-storage" \
// --model qwen3.6-35b-a3b \
// --out preflight-results/stage-0-query-1.json \
// [--limit 15] [--litellm-url http://localhost:4000] [--dry-run]
//
// Environment: LITELLM_BASE_URL, LITELLM_MASTER_KEY — same keys Waggle core
// uses. Embedding provider is configured on the hive-mind side via
// HIVE_MIND_EMBEDDING_PROVIDER=inprocess (we pass it through).
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
// ── Arg parsing ─────────────────────────────────────────────────────────
function parseArgs(argv) {
const out = {
question: undefined,
dataDir: undefined,
model: 'qwen3.6-35b-a3b',
out: undefined,
limit: 15,
litellmUrl: process.env.LITELLM_BASE_URL ?? 'http://localhost:4000',
litellmApiKey: process.env.LITELLM_MASTER_KEY ?? 'sk-waggle-dev',
// Stage 0 fell back from LiteLLM/DashScope to Ollama in the 2026-04-21
// run because DASHSCOPE_API_KEY was not provisioned in the LiteLLM
// container. The backend selector keeps both code paths live so
// future runs (with a provisioned DashScope key) can flip back with
// one flag.
backend: 'litellm',
ollamaUrl: process.env.OLLAMA_URL ?? 'http://localhost:11434',
dryRun: false,
};
for (let i = 0; i < argv.length; i++) {
const flag = argv[i];
const next = argv[i + 1];
switch (flag) {
case '--question': out.question = next; i++; break;
case '--data-dir': out.dataDir = next; i++; break;
case '--model': out.model = next; i++; break;
case '--out': out.out = next; i++; break;
case '--limit': out.limit = Number(next); i++; break;
case '--litellm-url': out.litellmUrl = next; i++; break;
case '--litellm-key': out.litellmApiKey = next; i++; break;
case '--backend': out.backend = next; i++; break;
case '--ollama-url': out.ollamaUrl = next; i++; break;
case '--dry-run': out.dryRun = true; break;
}
}
if (!out.question) throw new Error('--question is required');
if (!out.dataDir) throw new Error('--data-dir is required');
if (!out.out) throw new Error('--out is required');
return out;
}
// ── Retrieval via hive-mind CLI ────────────────────────────────────────
function recallContext(question, dataDir, limit) {
const cliPath = path.resolve(
'D:/Projects/hive-mind/packages/cli/dist/index.js',
);
const env = {
...process.env,
HIVE_MIND_DATA_DIR: dataDir,
HIVE_MIND_EMBEDDING_PROVIDER:
process.env.HIVE_MIND_EMBEDDING_PROVIDER ?? 'inprocess',
};
// Strip FTS5-problematic characters from the query before handing it to
// hive-mind's recall-context. hive-mind's keywordSearch treats a query
// containing `"` as "already quoted by caller" and passes it raw to
// FTS5; embedded literal quotes in a natural-language question trip the
// FTS5 parser and the fallback silently returns zero matches, which
// then collapses the full-stack retrieval to 0 hits. Stripping quotes +
// a couple of other FTS5 operators here keeps the Stage 0 run moving;
// fixing the sanitizer upstream in hive-mind is out of scope per the
// brief (no adapter/search repair in Stage 0 scope).
const searchQuery = question
.replace(/["']/g, ' ')
.replace(/[:*()]/g, ' ')
.replace(/\s+-/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const res = spawnSync(
'node',
[cliPath, 'recall-context', searchQuery, '--limit', String(limit), '--json'],
{ env, encoding: 'utf-8', maxBuffer: 32 * 1024 * 1024 },
);
if (res.status !== 0) {
throw new Error(`hive-mind recall-context exited with ${res.status}: ${res.stderr}`);
}
// Logger writes probe lines as `[hive-mind:...]` to stdout — filter to JSON.
const jsonOnly = res.stdout
.split('\n')
.filter(l => !l.startsWith('[hive-mind'))
.join('\n');
return JSON.parse(jsonOnly);
}
// ── Prompt assembly (mirrors full-stack cell) ──────────────────────────
// Same system prompt as benchmarks/harness/src/cells.ts SYSTEM_EVOLVED,
// adapted for Stage 0's longer-form Q&A (Marko's questions demand dates +
// session titles + multi-fact synthesis, not single-token answers).
const SYSTEM_EVOLVED_STAGE0 = [
'You are answering a question about the users personal history using the',
'memories provided below. Cite specific dates, session titles, and facts',
'directly from the memories — do not generalize.',
'If the memories do not contain the answer, say so explicitly; do NOT',
'fabricate dates, session IDs, entity names, or excerpts that are not in',
'the memories.',
].join(' ');
function buildUserPrompt(question, hits) {
const memoryBlocks = hits.map((h, i) => {
const src = h.from ?? 'personal';
const created = h.created_at ?? '';
const content = (h.content ?? '').replace(/\s+/g, ' ').slice(0, 1200);
return `- [memory:${src}:${h.id}${created ? ` @ ${created}` : ''}] ${content}`;
});
return [
'# Recalled Memories',
memoryBlocks.join('\n'),
'',
`Question: ${question}`,
].join('\n');
}
// ── LiteLLM call ──────────────────────────────────────────────────────
async function callLitellm({ url, apiKey, model, systemPrompt, userPrompt }) {
const started = Date.now();
const res = await fetch(`${url.replace(/\/$/, '')}/v1/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
// Thinking-mode Qwen3.6 burns reasoning tokens against this cap; we
// need enough room for the reasoning pass PLUS the visible answer.
// 16000 observed during Sprint 9 Task 0.5 rerun — the raised
// preview cap (10K/frame) inflates retrieved context, which in
// turn produces longer reasoning passes. 8000 consistently ran
// out mid-synthesis for the Legat-question shape; 16000 gives
// enough headroom for reasoning + a structured final answer.
max_tokens: 16000,
temperature: 0.0,
}),
});
const latencyMs = Date.now() - started;
if (!res.ok) {
const body = await res.text();
throw new Error(`LiteLLM ${res.status}: ${body.slice(0, 500)}`);
}
const body = await res.json();
const choice = body.choices?.[0]?.message ?? {};
// Some LiteLLM routes (notably qwen3.6-…-via-openrouter in thinking
// mode) split the stream into `content` (final answer) and
// `reasoning_content` (chain-of-thought). When max_tokens is reached
// mid-reasoning, `content` comes back empty even though the provider
// charged for the reasoning tokens. Fall back to reasoning_content so
// the caller isn't left with an empty model answer in that degenerate
// case, prefixed with a marker so downstream analysis can tell the
// difference.
const primary = typeof choice.content === 'string' ? choice.content : '';
const reasoning = typeof choice.reasoning_content === 'string' ? choice.reasoning_content : '';
let text = primary;
if (!text && reasoning) {
text = `[reasoning-only — content was empty; reasoning_content surfaced as fallback]\n\n${reasoning}`;
}
const usage = body.usage ?? {};
return {
text,
promptTokens: usage.prompt_tokens ?? 0,
completionTokens: usage.completion_tokens ?? 0,
latencyMs,
};
}
// Ollama native `/api/chat` — used as the Stage-0 fallback when LiteLLM
// providers aren't provisioned with keys. Returns the same shape as
// callLitellm for interchangeable use downstream.
async function callOllama({ url, model, systemPrompt, userPrompt }) {
const started = Date.now();
const res = await fetch(`${url.replace(/\/$/, '')}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
stream: false,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
options: {
temperature: 0.0,
num_predict: 1200,
},
}),
});
const latencyMs = Date.now() - started;
if (!res.ok) {
const body = await res.text();
throw new Error(`Ollama ${res.status}: ${body.slice(0, 500)}`);
}
const body = await res.json();
const text = body.message?.content ?? '';
// Ollama reports `prompt_eval_count` / `eval_count` as token counts.
return {
text,
promptTokens: body.prompt_eval_count ?? 0,
completionTokens: body.eval_count ?? 0,
latencyMs,
};
}
// Pricing per 1M tokens by {backend, model} key. Qwen3.6-35B-A3B priced
// per benchmarks/harness/config/models.json. Local Ollama runs cost $0
// out-of-pocket (CAPEX amortization tracked separately — Stage 0 is too
// small to move the amortized-cost needle).
const PRICING = {
'litellm:qwen3.6-35b-a3b': { input: 0.2, output: 0.8 },
// qwen3.6-35b-a3b-via-openrouter route cost observed 2026-04-21
// during Sprint 9 Task 0 rerun: ~$0.0003 per query at ~190 tokens
// output, which back-solves to roughly the OpenRouter upstream
// provider rate (AtlasCloud). Keeping same $/M-token coefficients as
// the DashScope route — the difference is small enough to stay
// inside the budget alarm either way.
'litellm:qwen3.6-35b-a3b-via-openrouter': { input: 0.3, output: 1.8 },
'ollama:gemma4:31b': { input: 0.0, output: 0.0 },
};
function computeCost(backend, model, promptTokens, completionTokens) {
const rate = PRICING[`${backend}:${model}`] ?? { input: 0, output: 0 };
return (
(promptTokens / 1_000_000) * rate.input +
(completionTokens / 1_000_000) * rate.output
);
}
// ── Main ───────────────────────────────────────────────────────────────
async function main() {
const args = parseArgs(process.argv.slice(2));
const t0 = Date.now();
const retrievalResult = recallContext(args.question, args.dataDir, args.limit);
const retrievalMs = Date.now() - t0;
const hits = retrievalResult.hits ?? [];
const userPrompt = buildUserPrompt(args.question, hits);
let modelAnswer;
let inferenceMs = 0;
let promptTokens = 0;
let completionTokens = 0;
let costUsd = 0;
if (args.dryRun) {
modelAnswer = `DRY_RUN: echoing question — ${args.question}`;
inferenceMs = 0;
} else {
const called = args.backend === 'ollama'
? await callOllama({
url: args.ollamaUrl,
model: args.model,
systemPrompt: SYSTEM_EVOLVED_STAGE0,
userPrompt,
})
: await callLitellm({
url: args.litellmUrl,
apiKey: args.litellmApiKey,
model: args.model,
systemPrompt: SYSTEM_EVOLVED_STAGE0,
userPrompt,
});
modelAnswer = called.text;
inferenceMs = called.latencyMs;
promptTokens = called.promptTokens;
completionTokens = called.completionTokens;
costUsd = computeCost(args.backend, args.model, promptTokens, completionTokens);
}
const result = {
stage: 'stage-0-dogfood',
timestamp: new Date().toISOString(),
question: args.question,
model: args.model,
backend: args.backend,
dataDir: args.dataDir,
retrieval: {
limit: args.limit,
hitCount: hits.length,
durationMs: retrievalMs,
hits: hits.map(h => ({
id: h.id,
source: h.source ?? 'personal',
from: h.from ?? 'personal',
importance: h.importance ?? null,
created_at: h.created_at ?? null,
score: h.score ?? null,
// 500-char preview of each retrieved frame — intentionally NOT the
// full frame content. Stage 0 report will render these (excerpts are
// authorized per brief §Privacy guardrails where Marko's question
// names the content) but the full frame stays on local disk.
preview: (h.content ?? '').replace(/\s+/g, ' ').slice(0, 500),
})),
},
prompt: {
systemPromptLen: SYSTEM_EVOLVED_STAGE0.length,
userPromptLen: userPrompt.length,
},
inference: {
durationMs: inferenceMs,
promptTokens,
completionTokens,
costUsd,
},
modelAnswer,
};
fs.mkdirSync(path.dirname(args.out), { recursive: true });
fs.writeFileSync(args.out, JSON.stringify(result, null, 2) + '\n', 'utf-8');
// Compact stdout so pipelines can `grep '^[stage-0:summary]'`.
console.log(
`[stage-0:summary] hits=${hits.length} retrieval_ms=${retrievalMs} ` +
`inference_ms=${inferenceMs} prompt_tokens=${promptTokens} ` +
`completion_tokens=${completionTokens} cost_usd=${costUsd.toFixed(6)} ` +
`out=${args.out}`,
);
}
main().catch(err => {
console.error('[stage-0:error]', err?.message ?? err);
process.exit(1);
});