mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
PNS-T08: rigged ask — context engineering evidence pack over transcript memory
HistoryQuery searches transcript files via rg with grep -E fallback. Argv-based execution (shell-safe), keyword extraction with regex-escape and stop word filtering, exit code semantics (0=matches, 1=no matches, 2+=error). AskService assembles structured evidence pack: question echo, rig topology snapshot, transcript excerpts with session provenance, search backend used, and three distinct insufficient states (disabled, no transcript dir, no matches). No LLM, no API keys, no fetch. CLI: rigged ask <rig> "question" [--json]. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
1 parent
95a72af60f
commit
2bb9c1de90
10 files changed
+860
No files matched your search
@@ -0,0 +1,102 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
interface AskRigInfo {
|
||||
name: string;
|
||||
status: string;
|
||||
nodeCount: number;
|
||||
runningCount: number;
|
||||
uptime: string | null;
|
||||
}
|
||||
|
||||
interface AskResult {
|
||||
question: string;
|
||||
rig: AskRigInfo | null;
|
||||
evidence: {
|
||||
backend: string;
|
||||
excerpts: string[];
|
||||
};
|
||||
insufficient: boolean;
|
||||
guidance?: string;
|
||||
}
|
||||
|
||||
export function askCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("ask")
|
||||
.description("Search rig transcript history with a natural language question")
|
||||
.argument("<rig>", "Rig name to search")
|
||||
.argument("<question>", "Question to search for in transcripts")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
rigged ask my-rig "what decisions were made about deployment?"
|
||||
rigged ask my-rig "error handling strategy" --json
|
||||
|
||||
Exit codes:
|
||||
0 Success
|
||||
1 Daemon not running
|
||||
2 Failed to fetch data from daemon`);
|
||||
|
||||
const getDeps = (): StatusDeps => depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
|
||||
cmd.action(async (rig: string, question: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rigged daemon start");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
const res = await client.post<AskResult>("/api/ask", { rig, question });
|
||||
|
||||
if (res.status >= 400) {
|
||||
console.error(`Failed to query rig (HTTP ${res.status}). Check daemon status with: rigged status`);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = res.data;
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(result));
|
||||
return;
|
||||
}
|
||||
|
||||
// Human-readable output
|
||||
console.log(`Question: ${result.question}`);
|
||||
console.log("");
|
||||
|
||||
if (result.rig) {
|
||||
console.log(`Rig: ${result.rig.name} [${result.rig.status}] ${result.rig.runningCount}/${result.rig.nodeCount} nodes uptime: ${result.rig.uptime ?? "—"}`);
|
||||
} else {
|
||||
console.log(`Rig: ${rig} [not found]`);
|
||||
}
|
||||
|
||||
console.log(`Search: ${result.evidence.backend}`);
|
||||
console.log("");
|
||||
|
||||
if (result.guidance) {
|
||||
console.log(result.guidance);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
if (result.evidence.excerpts.length > 0) {
|
||||
console.log(`Evidence (${result.evidence.excerpts.length} matches):`);
|
||||
for (const excerpt of result.evidence.excerpts) {
|
||||
console.log(` ${excerpt}`);
|
||||
}
|
||||
} else if (!result.guidance) {
|
||||
console.log("No transcript evidence found.");
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { captureCommand } from "./commands/capture.js";
|
||||
import { broadcastCommand } from "./commands/broadcast.js";
|
||||
import { configCommand } from "./commands/config.js";
|
||||
import { preflightCommand } from "./commands/preflight.js";
|
||||
import { askCommand } from "./commands/ask.js";
|
||||
import type { LifecycleDeps } from "./daemon-lifecycle.js";
|
||||
|
||||
export interface ProgramDeps {
|
||||
@@ -53,6 +54,7 @@ export interface ProgramDeps {
|
||||
sendDeps?: StatusDeps;
|
||||
captureDeps?: StatusDeps;
|
||||
broadcastDeps?: StatusDeps;
|
||||
askDeps?: StatusDeps;
|
||||
configPath?: string;
|
||||
}
|
||||
|
||||
@@ -87,6 +89,7 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
|
||||
program.addCommand(sendCommand(depsOverride?.sendDeps));
|
||||
program.addCommand(captureCommand(depsOverride?.captureDeps));
|
||||
program.addCommand(broadcastCommand(depsOverride?.broadcastDeps));
|
||||
program.addCommand(askCommand(depsOverride?.askDeps));
|
||||
program.addCommand(configCommand(depsOverride?.configPath));
|
||||
program.addCommand(preflightCommand());
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
|
||||
import http from "node:http";
|
||||
import { Command } from "commander";
|
||||
import { askCommand } from "../src/commands/ask.js";
|
||||
import { DaemonClient } from "../src/client.js";
|
||||
import { STATE_FILE, type LifecycleDeps, type DaemonState } from "../src/daemon-lifecycle.js";
|
||||
import type { StatusDeps } from "../src/commands/status.js";
|
||||
|
||||
function mockLifecycleDeps(): LifecycleDeps {
|
||||
return {
|
||||
spawn: vi.fn(() => ({ pid: 1, unref: vi.fn() }) as never),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
kill: vi.fn(() => true),
|
||||
readFile: vi.fn(() => null),
|
||||
writeFile: vi.fn(),
|
||||
removeFile: vi.fn(),
|
||||
exists: vi.fn(() => false),
|
||||
mkdirp: vi.fn(),
|
||||
openForAppend: vi.fn(() => 3),
|
||||
isProcessAlive: vi.fn(() => true),
|
||||
};
|
||||
}
|
||||
|
||||
function captureLogs(fn: () => Promise<void>): Promise<{ logs: string[]; exitCode: number | undefined }> {
|
||||
return new Promise(async (resolve) => {
|
||||
const logs: string[] = [];
|
||||
const origLog = console.log;
|
||||
const origErr = console.error;
|
||||
const origExitCode = process.exitCode;
|
||||
process.exitCode = undefined;
|
||||
console.log = (...args: unknown[]) => logs.push(args.join(" "));
|
||||
console.error = (...args: unknown[]) => logs.push(args.join(" "));
|
||||
try { await fn(); } finally { console.log = origLog; console.error = origErr; }
|
||||
const exitCode = process.exitCode;
|
||||
process.exitCode = origExitCode;
|
||||
resolve({ logs, exitCode });
|
||||
});
|
||||
}
|
||||
|
||||
function runningDeps(port: number): StatusDeps {
|
||||
return {
|
||||
lifecycleDeps: {
|
||||
...mockLifecycleDeps(),
|
||||
exists: vi.fn((p: string) => p === STATE_FILE),
|
||||
readFile: vi.fn((p: string) => {
|
||||
if (p === STATE_FILE) return JSON.stringify({ pid: 123, port, db: "test.sqlite", startedAt: "2026-03-31T00:00:00Z" } as DaemonState);
|
||||
return null;
|
||||
}),
|
||||
fetch: vi.fn(async () => ({ ok: true })),
|
||||
},
|
||||
clientFactory: (baseUrl) => new DaemonClient(baseUrl),
|
||||
};
|
||||
}
|
||||
|
||||
const EVIDENCE_RESULT = {
|
||||
question: "what about deployment?",
|
||||
rig: { name: "my-rig", status: "running", nodeCount: 2, runningCount: 2, uptime: "1h 30m" },
|
||||
evidence: { backend: "rg", excerpts: ["deployment started", "deployment finished"] },
|
||||
insufficient: false,
|
||||
};
|
||||
|
||||
const INSUFFICIENT_RESULT = {
|
||||
question: "what is the",
|
||||
rig: { name: "my-rig", status: "running", nodeCount: 2, runningCount: 2, uptime: "1h 30m" },
|
||||
evidence: { backend: "rg", excerpts: [] },
|
||||
insufficient: true,
|
||||
guidance: "No useful keywords could be extracted from the question. Try a more specific question.",
|
||||
};
|
||||
|
||||
describe("Ask CLI", () => {
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
let body = "";
|
||||
req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
|
||||
req.on("end", () => {
|
||||
if (req.method !== "POST" || req.url !== "/api/ask") {
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "not found" }));
|
||||
return;
|
||||
}
|
||||
const parsed = JSON.parse(body);
|
||||
if (parsed.rig === "my-rig" && parsed.question.includes("deployment")) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(EVIDENCE_RESULT));
|
||||
} else if (parsed.rig === "my-rig" && parsed.question === "what is the") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(INSUFFICIENT_RESULT));
|
||||
} else {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
question: parsed.question,
|
||||
rig: null,
|
||||
evidence: { backend: "rg", excerpts: [] },
|
||||
insufficient: true,
|
||||
guidance: `Rig '${parsed.rig}' not found. List rigs with: rigged ps`,
|
||||
}));
|
||||
}
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => { server.listen(0, resolve); });
|
||||
port = (server.address() as { port: number }).port;
|
||||
});
|
||||
|
||||
afterAll(() => { server.close(); });
|
||||
|
||||
function makeCmd(): Command {
|
||||
const prog = new Command();
|
||||
prog.exitOverride();
|
||||
prog.addCommand(askCommand(runningDeps(port)));
|
||||
return prog;
|
||||
}
|
||||
|
||||
it("human output shows question, rig status, and excerpts", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rigged", "ask", "my-rig", "what about deployment?"]);
|
||||
});
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("what about deployment?");
|
||||
expect(output).toContain("my-rig");
|
||||
expect(output).toContain("running");
|
||||
expect(output).toContain("deployment started");
|
||||
expect(output).toContain("deployment finished");
|
||||
});
|
||||
|
||||
it("--json prints raw structured result", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rigged", "ask", "my-rig", "what about deployment?", "--json"]);
|
||||
});
|
||||
const output = logs.join("\n");
|
||||
const parsed = JSON.parse(output);
|
||||
expect(parsed.question).toBe("what about deployment?");
|
||||
expect(parsed.rig.name).toBe("my-rig");
|
||||
expect(parsed.evidence.excerpts).toContain("deployment started");
|
||||
});
|
||||
|
||||
it("shows guidance when no data available", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rigged", "ask", "nonexistent-rig", "some question"]);
|
||||
});
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("not found");
|
||||
expect(output).toContain("rigged ps");
|
||||
});
|
||||
|
||||
it("shows insufficient message when keywords empty", async () => {
|
||||
const { logs } = await captureLogs(async () => {
|
||||
await makeCmd().parseAsync(["node", "rigged", "ask", "my-rig", "what is the"]);
|
||||
});
|
||||
const output = logs.join("\n");
|
||||
expect(output).toContain("No useful keywords");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { PsEntry } from "./ps-projection.js";
|
||||
import type { Rig } from "./types.js";
|
||||
import type { SearchResult } from "./history-query.js";
|
||||
|
||||
export interface AskDeps {
|
||||
psProjectionService: { getEntries(): PsEntry[] };
|
||||
rigRepo: { findRigsByName(name: string): Rig[] };
|
||||
historyQuery: { search(rigName: string, question: string): Promise<SearchResult> };
|
||||
transcriptsEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface AskRigInfo {
|
||||
name: string;
|
||||
status: string;
|
||||
nodeCount: number;
|
||||
runningCount: number;
|
||||
uptime: string | null;
|
||||
}
|
||||
|
||||
export interface AskResult {
|
||||
question: string;
|
||||
rig: AskRigInfo | null;
|
||||
evidence: {
|
||||
backend: string;
|
||||
excerpts: string[];
|
||||
};
|
||||
insufficient: boolean;
|
||||
guidance?: string;
|
||||
}
|
||||
|
||||
export class AskService {
|
||||
private readonly deps: AskDeps;
|
||||
|
||||
constructor(deps: AskDeps) {
|
||||
this.deps = deps;
|
||||
}
|
||||
|
||||
async ask(rigName: string, question: string): Promise<AskResult> {
|
||||
// Resolve rig
|
||||
const rigs = this.deps.rigRepo.findRigsByName(rigName);
|
||||
|
||||
if (rigs.length === 0) {
|
||||
return {
|
||||
question,
|
||||
rig: null,
|
||||
evidence: { backend: "rg", excerpts: [] },
|
||||
insufficient: true,
|
||||
guidance: `Rig '${rigName}' not found. List rigs with: rigged ps`,
|
||||
};
|
||||
}
|
||||
|
||||
if (rigs.length > 1) {
|
||||
return {
|
||||
question,
|
||||
rig: null,
|
||||
evidence: { backend: "rg", excerpts: [] },
|
||||
insufficient: true,
|
||||
guidance: `Rig '${rigName}' is ambiguous — ${rigs.length} rigs share that name. Remove duplicates or use a unique name.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Get topology info
|
||||
const entries = this.deps.psProjectionService.getEntries();
|
||||
const psEntry = entries.find((e) => e.name === rigName);
|
||||
const rigInfo: AskRigInfo = psEntry
|
||||
? { name: psEntry.name, status: psEntry.status, nodeCount: psEntry.nodeCount, runningCount: psEntry.runningCount, uptime: psEntry.uptime }
|
||||
: { name: rigName, status: "unknown", nodeCount: 0, runningCount: 0, uptime: null };
|
||||
|
||||
// Check transcripts enabled
|
||||
if (!this.deps.transcriptsEnabled) {
|
||||
return {
|
||||
question,
|
||||
rig: rigInfo,
|
||||
evidence: { backend: "rg", excerpts: [] },
|
||||
insufficient: true,
|
||||
guidance: "Transcripts are disabled. Enable with: rigged config set transcripts.enabled true",
|
||||
};
|
||||
}
|
||||
|
||||
// Search transcripts
|
||||
const searchResult = await this.deps.historyQuery.search(rigName, question);
|
||||
|
||||
let guidance: string | undefined;
|
||||
if (searchResult.insufficient) {
|
||||
if (searchResult.noTranscriptDir) {
|
||||
guidance = `No transcript directory for rig '${rigName}'. Transcripts start automatically on next rigged up.`;
|
||||
} else if (searchResult.error) {
|
||||
// Backend failure
|
||||
guidance = searchResult.error;
|
||||
} else if (searchResult.backend === "none") {
|
||||
// No backend was used (empty keywords)
|
||||
guidance = "No useful keywords could be extracted from the question. Try a more specific question.";
|
||||
} else {
|
||||
// Search ran but found no matches
|
||||
guidance = "No matching transcript evidence found. Try different search terms.";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
question,
|
||||
rig: rigInfo,
|
||||
evidence: {
|
||||
backend: searchResult.backend,
|
||||
excerpts: searchResult.excerpts,
|
||||
},
|
||||
insufficient: searchResult.insufficient,
|
||||
guidance,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export type ExecDep = (cmd: string, args: string[]) => Promise<{ stdout: string; exitCode: number }>;
|
||||
|
||||
export interface SearchResult {
|
||||
backend: "rg" | "grep" | "none";
|
||||
excerpts: string[];
|
||||
insufficient: boolean;
|
||||
noTranscriptDir?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface HistoryQueryOpts {
|
||||
transcriptsRoot: string;
|
||||
exec: ExecDep;
|
||||
}
|
||||
|
||||
const STOP_WORDS = new Set([
|
||||
"the", "is", "was", "are", "were", "been", "being",
|
||||
"have", "has", "had", "having",
|
||||
"does", "did", "doing",
|
||||
"will", "would", "shall", "should",
|
||||
"may", "might", "must", "can", "could",
|
||||
"and", "but", "for", "nor", "not", "yet", "also",
|
||||
"this", "that", "these", "those",
|
||||
"what", "which", "who", "whom", "whose",
|
||||
"where", "when", "why", "how",
|
||||
"all", "each", "every", "both", "few", "more", "most",
|
||||
"other", "some", "such", "than", "too", "very",
|
||||
"its", "his", "her", "our", "your", "their",
|
||||
"with", "from", "into", "about", "between", "through",
|
||||
"during", "before", "after", "above", "below",
|
||||
]);
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
export function extractKeywords(question: string): string[] {
|
||||
const words = question.split(/\s+/).filter(Boolean);
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
|
||||
for (const word of words) {
|
||||
// Strip trailing punctuation for stop-word check, but keep original for escaping
|
||||
const stripped = word.replace(/[?.!,;:]+$/, "");
|
||||
if (stripped.length < 3) continue;
|
||||
if (STOP_WORDS.has(stripped.toLowerCase())) continue;
|
||||
|
||||
const escaped = escapeRegex(word.replace(/[?.!,;:]+$/, ""));
|
||||
if (seen.has(escaped)) continue;
|
||||
seen.add(escaped);
|
||||
result.push(escaped);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function stripAnsi(text: string): string {
|
||||
return text
|
||||
.replace(/\x1b\[(\d*)C/g, (_: string, n: string) => " ".repeat(Math.max(1, Number(n || "1"))))
|
||||
.replace(/\x1b\[(\d*)G/g, (_: string, n: string) => " ".repeat(Math.max(1, Number(n || "1"))))
|
||||
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
|
||||
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
||||
.replace(/\x1b[@-_]/g, "")
|
||||
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "");
|
||||
}
|
||||
|
||||
export class HistoryQuery {
|
||||
private readonly transcriptsRoot: string;
|
||||
private readonly exec: ExecDep;
|
||||
|
||||
constructor(opts: HistoryQueryOpts) {
|
||||
this.transcriptsRoot = opts.transcriptsRoot;
|
||||
this.exec = opts.exec;
|
||||
}
|
||||
|
||||
async search(rigName: string, question: string): Promise<SearchResult> {
|
||||
const rigDir = join(this.transcriptsRoot, rigName);
|
||||
|
||||
if (!existsSync(rigDir)) {
|
||||
return { backend: "rg", excerpts: [], insufficient: true, noTranscriptDir: true };
|
||||
}
|
||||
|
||||
const keywords = extractKeywords(question);
|
||||
if (keywords.length === 0) {
|
||||
return { backend: "none", excerpts: [], insufficient: true };
|
||||
}
|
||||
|
||||
const pattern = keywords.join("|");
|
||||
|
||||
// Try rg first — use -e to avoid dash-led patterns being parsed as flags
|
||||
const rgResult = await this.exec("rg", ["-i", "--no-filename", "-e", pattern, rigDir]);
|
||||
|
||||
if (rgResult.exitCode === 0 || rgResult.exitCode === 1) {
|
||||
const excerpts = this.parseExcerpts(rgResult.stdout);
|
||||
// exit 0 = matches found, exit 1 = no matches → insufficient
|
||||
return { backend: "rg", excerpts, insufficient: excerpts.length === 0 };
|
||||
}
|
||||
|
||||
// rg failed (exit code >= 2), fall back to grep
|
||||
const logFiles = this.getLogFiles(rigDir);
|
||||
if (logFiles.length === 0) {
|
||||
return { backend: "grep", excerpts: [], insufficient: true };
|
||||
}
|
||||
|
||||
// Use -e for grep too — prevents dash-led patterns from being parsed as flags
|
||||
const grepResult = await this.exec("grep", ["-E", "-i", "-h", "-e", pattern, ...logFiles]);
|
||||
|
||||
if (grepResult.exitCode === 0 || grepResult.exitCode === 1) {
|
||||
const excerpts = this.parseExcerpts(grepResult.stdout);
|
||||
return { backend: "grep", excerpts, insufficient: excerpts.length === 0 };
|
||||
}
|
||||
|
||||
// Both backends failed (exit code 2+) — honest error
|
||||
return { backend: "none", excerpts: [], insufficient: true, error: "Search backends (rg, grep) both failed. Check that rg or grep is installed and the transcript directory is readable." };
|
||||
}
|
||||
|
||||
private parseExcerpts(stdout: string): string[] {
|
||||
if (!stdout.trim()) return [];
|
||||
return stdout
|
||||
.split("\n")
|
||||
.filter((line) => line.trim() !== "")
|
||||
.map((line) => stripAnsi(line).trim())
|
||||
.filter((line) => line !== "");
|
||||
}
|
||||
|
||||
private getLogFiles(dir: string): string[] {
|
||||
try {
|
||||
return readdirSync(dir)
|
||||
.filter((f) => f.endsWith(".log"))
|
||||
.map((f) => join(dir, f));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Hono } from "hono";
|
||||
import type { AskService } from "../domain/ask-service.js";
|
||||
|
||||
export const askRoutes = new Hono();
|
||||
|
||||
askRoutes.post("/", async (c) => {
|
||||
const askService = c.get("askService" as never) as AskService;
|
||||
|
||||
const body = await c.req.json<{ rig?: string; question?: string }>();
|
||||
|
||||
if (!body.rig || !body.question) {
|
||||
return c.json({ error: "Missing required fields: rig, question" }, 400);
|
||||
}
|
||||
|
||||
const result = await askService.ask(body.rig, body.question);
|
||||
return c.json(result);
|
||||
});
|
||||
@@ -42,6 +42,8 @@ import type { TranscriptStore } from "./domain/transcript-store.js";
|
||||
import type { SessionTransport } from "./domain/session-transport.js";
|
||||
import { transcriptRoutes } from "./routes/transcripts.js";
|
||||
import { transportRoutes } from "./routes/transport.js";
|
||||
import { askRoutes } from "./routes/ask.js";
|
||||
import type { AskService } from "./domain/ask-service.js";
|
||||
|
||||
export interface AppDeps {
|
||||
rigRepo: RigRepository;
|
||||
@@ -73,6 +75,7 @@ export interface AppDeps {
|
||||
runtimeAdapters?: Record<string, import("./domain/runtime-adapter.js").RuntimeAdapter>;
|
||||
transcriptStore?: TranscriptStore;
|
||||
sessionTransport?: SessionTransport;
|
||||
askService?: AskService;
|
||||
}
|
||||
|
||||
export function createApp(deps: AppDeps): Hono {
|
||||
@@ -159,6 +162,7 @@ export function createApp(deps: AppDeps): Hono {
|
||||
c.set("runtimeAdapters" as never, deps.runtimeAdapters ?? {});
|
||||
c.set("transcriptStore" as never, deps.transcriptStore);
|
||||
c.set("sessionTransport" as never, deps.sessionTransport);
|
||||
c.set("askService" as never, deps.askService);
|
||||
c.set("db" as never, deps.rigRepo.db);
|
||||
await next();
|
||||
});
|
||||
@@ -187,6 +191,7 @@ export function createApp(deps: AppDeps): Hono {
|
||||
app.route("/api/down", downRoutes);
|
||||
app.route("/api/transcripts", transcriptRoutes());
|
||||
app.route("/api/transport", transportRoutes());
|
||||
app.route("/api/ask", askRoutes);
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -52,7 +52,10 @@ import { RigTeardownOrchestrator } from "./domain/rig-teardown.js";
|
||||
import { ResumeMetadataRefresher } from "./domain/resume-metadata-refresher.js";
|
||||
import { TranscriptStore } from "./domain/transcript-store.js";
|
||||
import { SessionTransport } from "./domain/session-transport.js";
|
||||
import { HistoryQuery } from "./domain/history-query.js";
|
||||
import { AskService } from "./domain/ask-service.js";
|
||||
import { createApp, type AppDeps } from "./server.js";
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import nodePath from "node:path";
|
||||
@@ -272,6 +275,32 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
runtimeAdapters: { "claude-code": claudeAdapter, "codex": codexAdapter, "terminal": new (await import("./adapters/terminal-adapter.js")).TerminalAdapter() },
|
||||
transcriptStore,
|
||||
sessionTransport: new SessionTransport({ db, rigRepo, sessionRegistry, tmuxAdapter }),
|
||||
askService: (() => {
|
||||
const psProjectionService = new PsProjectionService({ db });
|
||||
const execDep = (cmd: string, args: string[]): Promise<{ stdout: string; exitCode: number }> =>
|
||||
new Promise((resolve) => {
|
||||
execFile(cmd, args, { maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {
|
||||
if (err && typeof (err as NodeJS.ErrnoException).code === "string" && (err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
resolve({ stdout: "", exitCode: 2 });
|
||||
return;
|
||||
}
|
||||
const exitCode = err ? (err as { code?: number }).code ?? 1 : 0;
|
||||
resolve({ stdout: stdout ?? "", exitCode: typeof exitCode === "number" ? exitCode : 1 });
|
||||
});
|
||||
});
|
||||
const historyQuery = new HistoryQuery({
|
||||
transcriptsRoot: transcriptStore.enabled
|
||||
? (transcriptsPath ?? nodePath.join(os.homedir(), ".rigged", "transcripts"))
|
||||
: nodePath.join(os.homedir(), ".rigged", "transcripts"),
|
||||
exec: execDep,
|
||||
});
|
||||
return new AskService({
|
||||
psProjectionService,
|
||||
rigRepo,
|
||||
historyQuery,
|
||||
transcriptsEnabled: transcriptStore.enabled,
|
||||
});
|
||||
})(),
|
||||
};
|
||||
|
||||
const app = createApp(deps);
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { AskService, type AskDeps, type AskResult } from "../src/domain/ask-service.js";
|
||||
import type { PsEntry } from "../src/domain/ps-projection.js";
|
||||
import type { Rig } from "../src/domain/types.js";
|
||||
import type { SearchResult } from "../src/domain/history-query.js";
|
||||
|
||||
function makeDeps(overrides?: Partial<AskDeps>): AskDeps {
|
||||
return {
|
||||
psProjectionService: {
|
||||
getEntries: vi.fn((): PsEntry[] => [
|
||||
{ rigId: "rig-1", name: "my-rig", nodeCount: 2, runningCount: 2, status: "running", uptime: "1h 30m", latestSnapshot: "5m ago" },
|
||||
]),
|
||||
},
|
||||
rigRepo: {
|
||||
findRigsByName: vi.fn((_name: string): Rig[] => [
|
||||
{ id: "rig-1", name: "my-rig", createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
|
||||
]),
|
||||
},
|
||||
historyQuery: {
|
||||
search: vi.fn(async (): Promise<SearchResult> => ({
|
||||
backend: "rg",
|
||||
excerpts: ["deployment started", "deployment finished"],
|
||||
insufficient: false,
|
||||
})),
|
||||
},
|
||||
transcriptsEnabled: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AskService", () => {
|
||||
it("assembles evidence pack with question, topology, and excerpts", async () => {
|
||||
const deps = makeDeps();
|
||||
const svc = new AskService(deps);
|
||||
const result = await svc.ask("my-rig", "what about deployment?");
|
||||
|
||||
expect(result.question).toBe("what about deployment?");
|
||||
expect(result.rig).toBeDefined();
|
||||
expect(result.rig!.name).toBe("my-rig");
|
||||
expect(result.rig!.status).toBe("running");
|
||||
expect(result.evidence.excerpts).toEqual(["deployment started", "deployment finished"]);
|
||||
expect(result.evidence.backend).toBe("rg");
|
||||
expect(result.insufficient).toBe(false);
|
||||
});
|
||||
|
||||
it("returns guidance when rig is not found", async () => {
|
||||
const deps = makeDeps({
|
||||
rigRepo: {
|
||||
findRigsByName: vi.fn(() => []),
|
||||
},
|
||||
psProjectionService: {
|
||||
getEntries: vi.fn(() => []),
|
||||
},
|
||||
});
|
||||
const svc = new AskService(deps);
|
||||
const result = await svc.ask("nonexistent", "any question");
|
||||
|
||||
expect(result.rig).toBeNull();
|
||||
expect(result.guidance).toContain("not found");
|
||||
});
|
||||
|
||||
it("returns guidance when rig is ambiguous", async () => {
|
||||
const deps = makeDeps({
|
||||
rigRepo: {
|
||||
findRigsByName: vi.fn(() => [
|
||||
{ id: "rig-1", name: "my-rig", createdAt: "2026-01-01", updatedAt: "2026-01-01" },
|
||||
{ id: "rig-2", name: "my-rig", createdAt: "2026-01-02", updatedAt: "2026-01-02" },
|
||||
]),
|
||||
},
|
||||
});
|
||||
const svc = new AskService(deps);
|
||||
const result = await svc.ask("my-rig", "any question");
|
||||
|
||||
expect(result.guidance).toContain("ambiguous");
|
||||
});
|
||||
|
||||
it("returns guidance when transcripts are disabled", async () => {
|
||||
const deps = makeDeps({ transcriptsEnabled: false });
|
||||
const svc = new AskService(deps);
|
||||
const result = await svc.ask("my-rig", "any question");
|
||||
|
||||
expect(result.insufficient).toBe(true);
|
||||
expect(result.guidance).toContain("disabled");
|
||||
});
|
||||
|
||||
it("surfaces insufficient flag from history query", async () => {
|
||||
const deps = makeDeps({
|
||||
historyQuery: {
|
||||
search: vi.fn(async (): Promise<SearchResult> => ({
|
||||
backend: "rg",
|
||||
excerpts: [],
|
||||
insufficient: true,
|
||||
})),
|
||||
},
|
||||
});
|
||||
const svc = new AskService(deps);
|
||||
const result = await svc.ask("my-rig", "what is the");
|
||||
|
||||
expect(result.insufficient).toBe(true);
|
||||
});
|
||||
|
||||
it("handles no transcript directory", async () => {
|
||||
const deps = makeDeps({
|
||||
historyQuery: {
|
||||
search: vi.fn(async (): Promise<SearchResult> => ({
|
||||
backend: "rg",
|
||||
excerpts: [],
|
||||
insufficient: true,
|
||||
noTranscriptDir: true,
|
||||
})),
|
||||
},
|
||||
});
|
||||
const svc = new AskService(deps);
|
||||
const result = await svc.ask("my-rig", "deployment question");
|
||||
|
||||
expect(result.insufficient).toBe(true);
|
||||
expect(result.guidance).toContain("transcript");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { HistoryQuery, extractKeywords, type ExecDep } from "../src/domain/history-query.js";
|
||||
|
||||
describe("HistoryQuery", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "history-query-test-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("extractKeywords", () => {
|
||||
it("splits question, filters stop words and short words, escapes regex", () => {
|
||||
const kw = extractKeywords("What is the deployment strategy?");
|
||||
// "What" => stop word, "is" => stop word, "the" => stop word
|
||||
expect(kw).toContain("deployment");
|
||||
expect(kw).toContain("strategy");
|
||||
expect(kw).not.toContain("What");
|
||||
expect(kw).not.toContain("is");
|
||||
expect(kw).not.toContain("the");
|
||||
});
|
||||
|
||||
it("filters words shorter than 3 chars", () => {
|
||||
const kw = extractKeywords("go to db and fix it");
|
||||
// "go" = 2 chars, "to" = 2 chars, "db" = 2 chars, "it" = 2 chars
|
||||
// "and" is a stop word, "fix" stays
|
||||
expect(kw).toContain("fix");
|
||||
expect(kw).not.toContain("go");
|
||||
expect(kw).not.toContain("to");
|
||||
expect(kw).not.toContain("db");
|
||||
});
|
||||
|
||||
it("regex-escapes special characters", () => {
|
||||
const kw = extractKeywords("search for file.ts and (pattern)");
|
||||
const escaped = kw.find((k) => k.includes("file"));
|
||||
expect(escaped).toBe("file\\.ts");
|
||||
const parenEscaped = kw.find((k) => k.includes("pattern"));
|
||||
expect(parenEscaped).toBe("\\(pattern\\)");
|
||||
});
|
||||
|
||||
it("returns empty array for all-stop-word questions", () => {
|
||||
const kw = extractKeywords("what is the");
|
||||
expect(kw).toEqual([]);
|
||||
});
|
||||
|
||||
it("deduplicates keywords", () => {
|
||||
const kw = extractKeywords("deploy deploy deploy strategy");
|
||||
const deployCount = kw.filter((k) => k === "deploy").length;
|
||||
expect(deployCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("search", () => {
|
||||
it("searches transcripts with rg backend", async () => {
|
||||
const rigDir = join(tmpDir, "my-rig");
|
||||
mkdirSync(rigDir, { recursive: true });
|
||||
writeFileSync(join(rigDir, "dev-impl.log"), "line1 deployment started\nline2 nothing\nline3 deployment done\n");
|
||||
|
||||
const exec: ExecDep = vi.fn(async (_cmd: string, _args: string[]) => ({
|
||||
stdout: "line1 deployment started\nline3 deployment done\n",
|
||||
exitCode: 0,
|
||||
}));
|
||||
|
||||
const hq = new HistoryQuery({ transcriptsRoot: tmpDir, exec });
|
||||
const result = await hq.search("my-rig", "what about deployment?");
|
||||
|
||||
expect(result.backend).toBe("rg");
|
||||
expect(result.excerpts.length).toBeGreaterThan(0);
|
||||
expect(exec).toHaveBeenCalled();
|
||||
const call = (exec as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(call[0]).toBe("rg");
|
||||
});
|
||||
|
||||
it("falls back to grep when rg exits with code >= 2 (not found)", async () => {
|
||||
const rigDir = join(tmpDir, "my-rig");
|
||||
mkdirSync(rigDir, { recursive: true });
|
||||
writeFileSync(join(rigDir, "session.log"), "test error handling\n");
|
||||
|
||||
let callCount = 0;
|
||||
const exec: ExecDep = vi.fn(async (cmd: string, _args: string[]) => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
// rg fails with exit code 2 (error)
|
||||
expect(cmd).toBe("rg");
|
||||
return { stdout: "", exitCode: 2 };
|
||||
}
|
||||
// grep fallback
|
||||
expect(cmd).toBe("grep");
|
||||
return { stdout: "test error handling\n", exitCode: 0 };
|
||||
});
|
||||
|
||||
const hq = new HistoryQuery({ transcriptsRoot: tmpDir, exec });
|
||||
const result = await hq.search("my-rig", "error handling");
|
||||
|
||||
expect(result.backend).toBe("grep");
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
|
||||
it("returns no excerpts when rg exit code 1 (no matches)", async () => {
|
||||
const rigDir = join(tmpDir, "my-rig");
|
||||
mkdirSync(rigDir, { recursive: true });
|
||||
writeFileSync(join(rigDir, "session.log"), "unrelated content\n");
|
||||
|
||||
const exec: ExecDep = vi.fn(async () => ({
|
||||
stdout: "",
|
||||
exitCode: 1,
|
||||
}));
|
||||
|
||||
const hq = new HistoryQuery({ transcriptsRoot: tmpDir, exec });
|
||||
const result = await hq.search("my-rig", "deployment strategy");
|
||||
|
||||
expect(result.excerpts).toEqual([]);
|
||||
expect(result.insufficient).toBe(true);
|
||||
});
|
||||
|
||||
it("returns insufficient for empty keywords", async () => {
|
||||
const rigDir = join(tmpDir, "my-rig");
|
||||
mkdirSync(rigDir, { recursive: true });
|
||||
|
||||
const exec: ExecDep = vi.fn();
|
||||
|
||||
const hq = new HistoryQuery({ transcriptsRoot: tmpDir, exec });
|
||||
const result = await hq.search("my-rig", "what is the");
|
||||
|
||||
expect(result.insufficient).toBe(true);
|
||||
expect(result.excerpts).toEqual([]);
|
||||
expect(exec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns insufficient when transcript directory does not exist", async () => {
|
||||
const exec: ExecDep = vi.fn();
|
||||
|
||||
const hq = new HistoryQuery({ transcriptsRoot: tmpDir, exec });
|
||||
const result = await hq.search("nonexistent-rig", "some query");
|
||||
|
||||
expect(result.insufficient).toBe(true);
|
||||
expect(result.noTranscriptDir).toBe(true);
|
||||
expect(exec).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns error when both rg and grep fail (exit code 2+)", async () => {
|
||||
const rigDir = join(tmpDir, "my-rig");
|
||||
mkdirSync(rigDir, { recursive: true });
|
||||
writeFileSync(join(rigDir, "dev.log"), "some content\n");
|
||||
|
||||
const exec: ExecDep = vi.fn(async () => ({ stdout: "", exitCode: 2 }));
|
||||
|
||||
const hq = new HistoryQuery({ transcriptsRoot: tmpDir, exec });
|
||||
const result = await hq.search("my-rig", "deployment strategy");
|
||||
|
||||
expect(result.insufficient).toBe(true);
|
||||
expect(result.backend).toBe("none");
|
||||
expect(result.error).toContain("both failed");
|
||||
});
|
||||
|
||||
it("strips ANSI from excerpts", async () => {
|
||||
const rigDir = join(tmpDir, "my-rig");
|
||||
mkdirSync(rigDir, { recursive: true });
|
||||
writeFileSync(join(rigDir, "session.log"), "content\n");
|
||||
|
||||
const exec: ExecDep = vi.fn(async () => ({
|
||||
stdout: "\x1b[1mdeployment\x1b[0m started\n\x1b[32mdeployment\x1b[0m finished\n",
|
||||
exitCode: 0,
|
||||
}));
|
||||
|
||||
const hq = new HistoryQuery({ transcriptsRoot: tmpDir, exec });
|
||||
const result = await hq.search("my-rig", "deployment status");
|
||||
|
||||
for (const excerpt of result.excerpts) {
|
||||
expect(excerpt).not.toContain("\x1b[");
|
||||
}
|
||||
expect(result.excerpts).toContain("deployment started");
|
||||
expect(result.excerpts).toContain("deployment finished");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user