Explorar o código

feat(cli): add quiet mode, daemon file logging, and --brief flag

- buildPipelineConfig accepts quiet? and logFile?, assembles sinks
  and onStreamProgress callback for heartbeat logging
- write next, draft, daemon: add -q/--quiet option
- daemon: creates inkos.log (JSON Lines) via createWriteStream,
  logStream.end() on shutdown
- book create: replace --context/--context-file with --brief <path>
  for clearer semantics (creative brief file)
- write rewrite: restore from snapshot-0 for chapter 1 (was skipped)
Ma hai 7 meses
pai
achega
0c5e00c807

+ 8 - 7
packages/cli/src/commands/book.ts

@@ -1,8 +1,8 @@
 import { Command } from "commander";
-import { access } from "node:fs/promises";
-import { join } from "node:path";
+import { access, readFile } from "node:fs/promises";
+import { join, resolve } from "node:path";
 import { PipelineRunner, StateManager, type BookConfig } from "@actalk/inkos-core";
-import { loadConfig, buildPipelineConfig, findProjectRoot, resolveContext, resolveBookId, log, logError } from "../utils.js";
+import { loadConfig, buildPipelineConfig, findProjectRoot, resolveBookId, log, logError } from "../utils.js";
 
 export const bookCommand = new Command("book")
   .description("Manage books");
@@ -15,8 +15,7 @@ bookCommand
   .option("--platform <platform>", "Target platform", "tomato")
   .option("--target-chapters <n>", "Target chapter count", "200")
   .option("--chapter-words <n>", "Words per chapter", "3000")
-  .option("--context <text>", "External context / instructions (natural language)")
-  .option("--context-file <path>", "Read external context from file")
+  .option("--brief <path>", "Path to creative brief file (.md/.txt) — Architect builds from your ideas instead of generating from scratch")
   .option("--json", "Output JSON")
   .action(async (opts) => {
     try {
@@ -61,9 +60,11 @@ bookCommand
 
       if (!opts.json) log(`Creating book "${book.title}" (${book.genre} / ${book.platform})...`);
 
-      const context = await resolveContext(opts);
+      const brief = opts.brief
+        ? await readFile(resolve(opts.brief), "utf-8")
+        : undefined;
 
-      const pipeline = new PipelineRunner(buildPipelineConfig(config, root, { externalContext: context }));
+      const pipeline = new PipelineRunner(buildPipelineConfig(config, root, { externalContext: brief }));
 
       await pipeline.initBook(book);
 

+ 11 - 2
packages/cli/src/commands/daemon.ts

@@ -1,6 +1,7 @@
 import { Command } from "commander";
 import { Scheduler } from "@actalk/inkos-core";
 import { loadConfig, findProjectRoot, buildPipelineConfig, log, logError } from "../utils.js";
+import { createWriteStream, type WriteStream } from "node:fs";
 import { writeFile, readFile, unlink } from "node:fs/promises";
 import { join } from "node:path";
 
@@ -8,7 +9,9 @@ const PID_FILE = "inkos.pid";
 
 export const upCommand = new Command("up")
   .description("Start the InkOS daemon (autonomous mode)")
-  .action(async () => {
+  .option("-q, --quiet", "Suppress console output")
+  .action(async (opts) => {
+    let logStream: WriteStream | undefined;
     try {
       const config = await loadConfig();
       const root = findProjectRoot();
@@ -32,8 +35,12 @@ export const upCommand = new Command("up")
       // Write PID file
       await writeFile(pidPath, String(process.pid), "utf-8");
 
+      // File logging for daemon
+      const logPath = join(root, "inkos.log");
+      logStream = createWriteStream(logPath, { flags: "a" });
+
       const scheduler = new Scheduler({
-        ...buildPipelineConfig(config, root),
+        ...buildPipelineConfig(config, root, { logFile: logStream, quiet: opts.quiet }),
         radarCron: config.daemon.schedule.radarCron,
         writeCron: config.daemon.schedule.writeCron,
         maxConcurrentBooks: config.daemon.maxConcurrentBooks,
@@ -54,6 +61,7 @@ export const upCommand = new Command("up")
       const shutdown = async () => {
         log("\nShutting down daemon...");
         scheduler.stop();
+        logStream?.end();
         try {
           await unlink(pidPath);
         } catch {
@@ -71,6 +79,7 @@ export const upCommand = new Command("up")
       // Keep process alive
       await new Promise(() => {});
     } catch (e) {
+      logStream?.end();
       logError(`Failed to start daemon: ${e}`);
       process.exit(1);
     }

+ 2 - 1
packages/cli/src/commands/draft.ts

@@ -9,6 +9,7 @@ export const draftCommand = new Command("draft")
   .option("--context <text>", "Creative guidance (natural language)")
   .option("--context-file <path>", "Read guidance from file")
   .option("--json", "Output JSON")
+  .option("-q, --quiet", "Suppress console output")
   .action(async (bookIdArg: string | undefined, opts) => {
     try {
       const config = await loadConfig();
@@ -16,7 +17,7 @@ export const draftCommand = new Command("draft")
       const bookId = await resolveBookId(bookIdArg, root);
       const context = await resolveContext(opts);
 
-      const pipeline = new PipelineRunner(buildPipelineConfig(config, root));
+      const pipeline = new PipelineRunner(buildPipelineConfig(config, root, { quiet: opts.quiet }));
 
       const wordCount = opts.words ? parseInt(opts.words, 10) : undefined;
 

+ 9 - 9
packages/cli/src/commands/write.ts

@@ -17,6 +17,7 @@ writeCommand
   .option("--context <text>", "Creative guidance (natural language)")
   .option("--context-file <path>", "Read guidance from file")
   .option("--json", "Output JSON")
+  .option("-q, --quiet", "Suppress console output")
   .action(async (bookIdArg: string | undefined, opts) => {
     try {
       const config = await loadConfig();
@@ -24,7 +25,7 @@ writeCommand
       const bookId = await resolveBookId(bookIdArg, root);
       const context = await resolveContext(opts);
 
-      const pipeline = new PipelineRunner(buildPipelineConfig(config, root, { externalContext: context }));
+      const pipeline = new PipelineRunner(buildPipelineConfig(config, root, { externalContext: context, quiet: opts.quiet }));
 
       const count = parseInt(opts.count, 10);
       const wordCount = opts.words ? parseInt(opts.words, 10) : undefined;
@@ -137,14 +138,13 @@ writeCommand
         if (!opts.json) log(`Removed later chapter: ${f}`);
       }
 
-      // Restore state to previous chapter's end-state
-      if (chapter > 1) {
-        const restored = await state.restoreState(bookId, chapter - 1);
-        if (restored) {
-          if (!opts.json) log(`State restored from chapter ${chapter - 1} snapshot.`);
-        } else {
-          if (!opts.json) log(`Warning: no snapshot for chapter ${chapter - 1}. Using current state.`);
-        }
+      // Restore state to previous chapter's end-state (chapter 1 uses snapshot-0 from initBook)
+      const restoreFrom = chapter - 1;
+      const restored = await state.restoreState(bookId, restoreFrom);
+      if (restored) {
+        if (!opts.json) log(`State restored from chapter ${restoreFrom} snapshot.`);
+      } else {
+        if (!opts.json) log(`Warning: no snapshot for chapter ${restoreFrom}. Using current state.`);
       }
 
       if (!opts.json) log(`Regenerating chapter ${chapter}...`);

+ 28 - 2
packages/cli/src/utils.ts

@@ -2,7 +2,7 @@ import { readFile, access } from "node:fs/promises";
 import { homedir } from "node:os";
 import { join, resolve } from "node:path";
 import { config as loadEnv } from "dotenv";
-import { createLLMClient, StateManager, type ProjectConfig, ProjectConfigSchema, type PipelineConfig } from "@actalk/inkos-core";
+import { createLLMClient, StateManager, createLogger, createStderrSink, createJsonLineSink, type ProjectConfig, ProjectConfigSchema, type PipelineConfig, type LogSink } from "@actalk/inkos-core";
 
 export const GLOBAL_CONFIG_DIR = join(homedir(), ".inkos");
 export const GLOBAL_ENV_PATH = join(GLOBAL_CONFIG_DIR, ".env");
@@ -90,8 +90,32 @@ export function createClient(config: ProjectConfig) {
 export function buildPipelineConfig(
   config: ProjectConfig,
   root: string,
-  extra?: Partial<Pick<PipelineConfig, "notifyChannels" | "radarSources" | "externalContext">>,
+  extra?: Partial<Pick<PipelineConfig, "notifyChannels" | "radarSources" | "externalContext">> & {
+    readonly quiet?: boolean;
+    readonly logFile?: NodeJS.WritableStream;
+  },
 ): PipelineConfig {
+  const sinks: LogSink[] = [];
+  if (!extra?.quiet) {
+    sinks.push(createStderrSink({ minLevel: "info" }));
+  }
+  if (extra?.logFile) {
+    sinks.push(createJsonLineSink(extra.logFile));
+  }
+
+  const hasLogging = sinks.length > 0;
+  const logger = hasLogging ? createLogger({ tag: "inkos", sinks }) : undefined;
+
+  const onStreamProgress = hasLogging
+    ? (progress: { readonly elapsedMs: number; readonly totalChars: number; readonly chineseChars: number; readonly status: string }) => {
+        if (progress.status === "streaming") {
+          logger?.info(
+            `streaming ${Math.round(progress.elapsedMs / 1000)}s, ${progress.totalChars} chars (${progress.chineseChars} CJK)`,
+          );
+        }
+      }
+    : undefined;
+
   return {
     client: createLLMClient(config.llm),
     model: config.llm.model,
@@ -101,6 +125,8 @@ export function buildPipelineConfig(
     notifyChannels: extra?.notifyChannels ?? config.notify,
     radarSources: extra?.radarSources,
     externalContext: extra?.externalContext,
+    logger,
+    onStreamProgress,
   };
 }