Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/question/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ export function registerConfigCommand(program: Command): void {
.option("--sound [spec]", "synth:<preset> | bundled:<file> | custom:<path> | off")
.option("--sound-volume <n>", "0..1", (v) => Number.parseFloat(v))
.option("--notify <onoff>", "on|off")
.option("--obsidian <onoff>", "on|off")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
.option("--obsidian-vault <path>", "set the Obsidian vault override")
.option("--list-sounds", "list every available sound (bundled + synth) and exit")
.action(
(o: {
sound?: string | boolean;
soundVolume?: number;
notify?: string;
obsidian?: string;
obsidianVault?: string;
listSounds?: boolean;
}) => {
Expand Down Expand Up @@ -57,6 +59,10 @@ export function registerConfigCommand(program: Command): void {
next = saveConfig({ sinks: { ...next.sinks, notify: o.notify === "on" } });
}

if (o.obsidian === "on" || o.obsidian === "off") {
next = saveConfig({ sinks: { ...next.sinks, obsidian: o.obsidian === "on" } });
}

if (o.obsidianVault) {
setVaultRoot(o.obsidianVault);
}
Expand Down
13 changes: 4 additions & 9 deletions src/question/commands/log.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { homedir } from "node:os";
import { join } from "node:path";
import { SafeJSON } from "@app/utils/json";
import chalk from "chalk";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
import type { Command } from "commander";
import { formatQaEntry } from "../lib/format";
import { openReadModel, type QueryOpts, queryEntries } from "../lib/read-model";

export function defaultDbPath(): string {
Expand All @@ -13,17 +15,10 @@ export function renderDigest(opts: QueryOpts & { dbPath: string }): string {
try {
const rows = queryEntries(db, opts);
if (rows.length === 0) {
return "No questions recorded.";
return chalk.dim("No questions recorded.");
}

return rows
.map((r) => {
const when = new Date(r.ts).toISOString().slice(0, 16).replace("T", " ");
const head = `${when} ${r.project} · ${r.branch ?? "-"} [${r.tag}]`;
const preview = r.answerMd.split("\n").slice(0, 3).join("\n");
return `${head}\n❯ ${r.question}\n${preview}\n`;
})
.join("\n");
return rows.map(formatQaEntry).join("\n");
} finally {
db.close();
}
Expand Down
41 changes: 31 additions & 10 deletions src/question/commands/tail.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,33 @@
import { FileTailer } from "@app/utils/fs/file-tailer";
import chalk from "chalk";
import type { Command } from "commander";
import { formatQaEntry } from "../lib/format";
import { logFilePathFor } from "../lib/log-store";
import { openReadModel, queryEntries } from "../lib/read-model";
import type { QaEntry } from "../lib/types";
import { defaultDbPath } from "./log";

function watchToday(opts: { lines?: number }): void {
const backlog = opts.lines ?? 10;

// Backlog: last N entries oldest→newest (chronological, like `tail -f`),
// rendered identically to `tools question log` via the shared formatter.
if (backlog > 0) {
const db = openReadModel(defaultDbPath());
try {
const rows = queryEntries(db, { limit: backlog });
if (rows.length > 0) {
process.stdout.write(`${[...rows].reverse().map(formatQaEntry).join("\n")}\n`);
}
} finally {
db.close();
}
}

function watchToday(): void {
const file = logFilePathFor({ ts: Date.now() });
process.stdout.write(`tailing ${file} (Ctrl-C to stop)\n`);
process.stdout.write(chalk.dim(`\n— live: tailing ${file} (Ctrl-C to stop) —\n`));
const t = new FileTailer<QaEntry>(file, {
onLine: (e) => {
const when = new Date(e.ts).toISOString().slice(11, 16);
const preview = e.answerMd.split("\n").slice(0, 3).join("\n");
process.stdout.write(
`\n${when} ${e.project} · ${e.branch ?? "-"} [${e.tag}]\n❯ ${e.question}\n${preview}\n`
);
},
onLine: (e) => process.stdout.write(`\n${formatQaEntry(e)}`),
});
t.start();
process.on("SIGINT", () => {
Expand All @@ -23,5 +37,12 @@ function watchToday(): void {
}

export function registerTailCommand(program: Command): void {
program.command("tail").alias("answers").description("Live feed of Q→A as they are recorded").action(watchToday);
program
.command("tail")
.alias("answers")
.description("Live feed of Q→A as they are recorded (with last-N backlog)")
.option("-n, --lines <n>", "show the last N entries before tailing (0 = none)", (v) =>
Number.parseInt(v, 10)
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
.action((o: { lines?: number }) => watchToday(o));
}
30 changes: 30 additions & 0 deletions src/question/lib/format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import chalk from "chalk";
import type { QaEntry } from "./types";

// chalk auto-detects stdout: full color on a TTY, no-ops when piped/redirected,
// so digests are colorful in a terminal and clean ANSI-free for `| tools json`.
const TAG_TINT: Record<string, (s: string) => string> = {
question: chalk.bold.blue,
action: chalk.bold.yellow,
directive: chalk.bold.green,
};

type FormattableEntry = Pick<
QaEntry,
"ts" | "project" | "branch" | "tag" | "question" | "answerMd" | "sessionId"
>;

/**
* Single source of truth for one Q→A entry's terminal rendering. Used by both
* `tools question log` (newest-first digest) and `tools question tail` (live
* feed) so they are visually identical per entry.
*/
export function formatQaEntry(e: FormattableEntry): string {
const when = new Date(e.ts).toISOString().slice(0, 16).replace("T", " ");
const tint = TAG_TINT[e.tag] ?? chalk.bold.gray;
const head = `${chalk.dim(when)} ${chalk.cyan.bold(e.project)} ${chalk.dim("·")} ${chalk.magenta(e.branch ?? "-")} ${tint(`[${e.tag}]`)}`;
const preview = chalk.yellow(e.answerMd.split("\n").slice(0, 3).join("\n"));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const sid = e.sessionId && e.sessionId !== "unknown" ? e.sessionId.slice(0, 8) : null;
const resume = sid ? chalk.dim(` ↩ ${sid} · tools claude resume ${sid}\n`) : "";
return `${head}\n${chalk.green("❯")} ${chalk.bold(e.question)}\n${preview}\n${resume}`;
}
7 changes: 6 additions & 1 deletion src/question/lib/sinks/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@ import { registerSink, type Sink } from "./registry-exports";

export function formatNotification(e: QaEntry): { title: string; message: string } {
return {
title: `${e.project} · ${e.tag}`,
// Same header shape as `tools question log`/`tail`: project · branch [tag].
title: `${e.project} · ${e.branch ?? "-"} [${e.tag}]`,
message: `❯ ${e.question}\n\n${e.answerMd}`,
};
}

export const notificationSink: Sink = {
name: "notification",
// Telegram = cold `await import(@app/telegram-bot)` + HTTP; the 2s default
// fan-out budget gets truncated by the CLI's process.exit(0) before the
// send completes (system banner is fast, telegram isn't). Give it room.
timeoutMs: 9000,
isEnabled: (c) => c.sinks.notify,
emit: async (entry) => {
const { title, message } = formatNotification(entry);
Expand Down
2 changes: 1 addition & 1 deletion src/question/lib/sinks/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export async function runFanOut(
});
return Promise.all(
enabled.map(async (s): Promise<SinkResult> => {
const t = timeout(timeoutMs);
const t = timeout(s.timeoutMs ?? timeoutMs);
try {
await Promise.race([Promise.resolve(s.emit(entry, config)), t.promise]);
return { name: s.name, ok: true };
Expand Down
4 changes: 4 additions & 0 deletions src/question/lib/sinks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ export interface Sink {
name: string;
isEnabled(config: QuestionConfig): boolean;
emit(entry: QaEntry, config: QuestionConfig): Promise<void> | void;
/** Optional per-sink fan-out budget (ms). Network sinks (Telegram does a
* cold dynamic import + HTTP) need more than the 2s default or they get
* cut off by process.exit(0) before delivery. */
timeoutMs?: number;
}

// SinkResult is defined once, canonically, in ../types (Task 2 — RecordResult.sinks uses it).
Expand Down
Loading