Files
waggle-os/scripts/stage-0-query.mjs
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

355 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);
});