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:
Mike SchwarzandClaude Opus 4.6 committed 2026-04-01 07:10:24 -07:00
1 parent 95a72af60f
commit 2bb9c1de90
10 files changed
+860

No files matched your search

+102
View File
@@ -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;
}
+3
View File
@@ -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());
+155
View File
@@ -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");
});
});
+110
View File
@@ -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,
};
}
}
+138
View File
@@ -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 [];
}
}
}
+17
View File
@@ -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);
});
+5
View File
@@ -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;
}
+29
View File
@@ -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);
+119
View File
@@ -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");
});
});
+182
View File
@@ -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");
});
});
});