Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 2 additions & 2 deletions src/dev-dashboard/ui/vite-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,14 @@ import { listVault, readNote } from "@app/dev-dashboard/lib/obsidian/reader";
import { renderSharePage } from "@app/dev-dashboard/lib/obsidian/share-template";
import { createQaStream, todayLogFile } from "@app/dev-dashboard/lib/qa-sse";
import { configureRetention, getCachedPulse, getSeries, startPulsePolling } from "@app/dev-dashboard/lib/system/poller";
import { getAudioLibrary } from "@app/utils/audio/library";
import { resolveSoundBuffer } from "@app/utils/audio/runner.server";
import { addTodo, completeTodo, deleteTodo, listTodos } from "@app/dev-dashboard/lib/todos/service";
import { killTtyd, listTtyd, renameTtyd, spawnTtyd } from "@app/dev-dashboard/lib/ttyd/manager";
import { fetchWeather } from "@app/dev-dashboard/lib/weather/client";
import logger from "@app/logger";
import { defaultDbPath } from "@app/question/commands/log";
import { openReadModel, queryEntries } from "@app/question/lib/read-model";
import { getAudioLibrary } from "@app/utils/audio/library";
import { resolveSoundBuffer } from "@app/utils/audio/runner.server";
import { SafeJSON } from "@app/utils/json";
import type { Connect } from "vite";

Expand Down
18 changes: 17 additions & 1 deletion 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.
.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 @@ -53,10 +55,24 @@ export function registerConfigCommand(program: Command): void {
next = saveConfig({ soundVolume: Math.max(0, Math.min(1, o.soundVolume)) });
}

if (o.notify === "on" || o.notify === "off") {
if (o.notify !== undefined) {
if (o.notify !== "on" && o.notify !== "off") {
process.stderr.write(`error: --notify expects on|off, got '${o.notify}'\n`);
process.exit(1);
}

next = saveConfig({ sinks: { ...next.sinks, notify: o.notify === "on" } });
}

if (o.obsidian !== undefined) {
if (o.obsidian !== "on" && o.obsidian !== "off") {
process.stderr.write(`error: --obsidian expects on|off, got '${o.obsidian}'\n`);
process.exit(1);
}

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
Expand Up @@ -2,6 +2,8 @@ import { homedir } from "node:os";
import { join } from "node:path";
import { SafeJSON } from "@app/utils/json";
import type { Command } from "commander";
import pc from "picocolors";
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 pc.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
39 changes: 29 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 type { Command } from "commander";
import pc from "picocolors";
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(pc.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,10 @@ 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))
.action((o: { lines?: number }) => watchToday(o));
}
28 changes: 28 additions & 0 deletions src/question/lib/format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import pc from "picocolors";
import type { QaEntry } from "./types";

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

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] ?? ((s: string) => pc.bold(pc.gray(s)));
const head = `${pc.dim(when)} ${pc.bold(pc.cyan(e.project))} ${pc.dim("·")} ${pc.magenta(e.branch ?? "-")} ${tint(`[${e.tag}]`)}`;
const preview = pc.yellow(e.answerMd.split("\n").slice(0, 3).join("\n"));
const sid = e.sessionId && e.sessionId !== "unknown" ? e.sessionId.slice(0, 8) : null;
const resume = sid ? pc.dim(` ↩ ${sid} · tools claude resume ${sid}\n`) : "";
return `${head}\n${pc.green("❯")} ${pc.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
3 changes: 1 addition & 2 deletions src/utils/agent-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,7 @@ export function getAgentRuntimeContext(
project: detectCurrentProject() ?? basename(repoRoot),
repoRoot,
cwd,
isWorktree:
gitDir != null && gitCommonDir != null && resolve(cwd, gitDir) !== resolve(cwd, gitCommonDir),
isWorktree: gitDir != null && gitCommonDir != null && resolve(cwd, gitDir) !== resolve(cwd, gitCommonDir),
worktreePath: null,
branch: gitSync(["rev-parse", "--abbrev-ref", "HEAD"], cwd),
commitSha: gitSync(["rev-parse", "--short", "HEAD"], cwd),
Expand Down
10 changes: 3 additions & 7 deletions src/utils/audio/library.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { existsSync } from "node:fs";
import { type BundledSound, BUNDLED_SOUNDS } from "./assets/manifest";
import { BUNDLED_SOUNDS, type BundledSound } from "./assets/manifest";
import { DING_PRESETS } from "./ding-presets";
import type { SoundChoice } from "./runner.server";

Expand Down Expand Up @@ -57,9 +57,7 @@ export function getAudioLibrary(): AudioLibrary {
return { bundled, synth, default: def };
}

export type ParseSoundResult =
| { ok: true; sound?: SoundChoice; enabled: boolean }
| { ok: false; error: string };
export type ParseSoundResult = { ok: true; sound?: SoundChoice; enabled: boolean } | { ok: false; error: string };

/**
* Parse a `--sound` / dashboard spec into a validated `SoundChoice`.
Expand Down Expand Up @@ -112,9 +110,7 @@ export function parseSoundSpec(spec: string): ParseSoundResult {
/** Human-readable list of every available sound (for `--list-sounds` / errors). */
export function formatAudioLibrary(): string {
const lib = getAudioLibrary();
const bundled = lib.bundled
.map((e) => ` ${e.id}${e.isDefault ? " (default)" : ""} — ${e.label}`)
.join("\n");
const bundled = lib.bundled.map((e) => ` ${e.id}${e.isDefault ? " (default)" : ""} — ${e.label}`).join("\n");
const synth = lib.synth.map((e) => ` ${e.id} — ${e.label}`).join("\n");
return `Available sounds:\n\nBundled (Kenney CC0):\n${bundled}\n\nSynth presets:\n${synth}\n\nAlso valid: custom:/abs/path.wav | off`;
}
Loading