niahere 0.2.60 → 0.2.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niahere",
3
- "version": "0.2.60",
3
+ "version": "0.2.61",
4
4
  "description": "A personal AI assistant daemon — chat, scheduled jobs, persona system, extensible via skills.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -12,6 +12,7 @@ import { log } from "../utils/log";
12
12
  import { getMcpServers } from "../mcp";
13
13
  import { getNiaHome, getPaths } from "../utils/paths";
14
14
  import { classifyMime, validateAttachment, prepareImage } from "../utils/attachment";
15
+ import { resolveWatchBehavior } from "../utils/watches";
15
16
 
16
17
  /** Strip markdown backticks so sentinel tokens like [NO_REPLY] match even when the LLM wraps them. */
17
18
  function cleanSentinel(text: string): string {
@@ -133,22 +134,36 @@ class SlackChannel implements Channel {
133
134
 
134
135
  let botUserId: string | undefined;
135
136
 
136
- // Watch channels: mtime-based hot-reload from config.yaml
137
- // Keys are always channel_id#channel_name format
137
+ // Watch channels: mtime-based hot-reload from config.yaml AND any watch
138
+ // behavior files referenced by that config. Keys are channel_id#channel_name.
138
139
  let watchCache: Map<string, { name: string; behavior: string }> = new Map();
139
- let watchConfigMtime = 0;
140
+ let watchFilePaths: string[] = [];
141
+ let lastReloadMtime = 0;
142
+
143
+ function maxMtime(paths: string[]): number {
144
+ let max = 0;
145
+ for (const p of paths) {
146
+ try {
147
+ const m = statSync(p).mtimeMs;
148
+ if (m > max) max = m;
149
+ } catch {
150
+ // ignore missing files
151
+ }
152
+ }
153
+ return max;
154
+ }
140
155
 
141
156
  function reloadWatchChannels(): Map<string, { name: string; behavior: string }> {
142
157
  const configPath = getPaths().config;
143
- let mtime = 0;
144
- try { mtime = statSync(configPath).mtimeMs; } catch { return watchCache; }
145
- if (mtime === watchConfigMtime) return watchCache;
158
+ const mtime = maxMtime([configPath, ...watchFilePaths]);
159
+ if (mtime === 0) return watchCache;
160
+ if (mtime === lastReloadMtime) return watchCache;
146
161
 
147
- watchConfigMtime = mtime;
148
162
  resetConfig(); // clear cached config so getConfig() re-reads from disk
149
163
  const cfg = getConfig();
150
164
  const watch = cfg.channels.slack.watch;
151
165
  const fresh = new Map<string, { name: string; behavior: string }>();
166
+ const freshFiles: string[] = [];
152
167
  if (watch) {
153
168
  for (const [key, entry] of Object.entries(watch)) {
154
169
  if (!entry.enabled) continue;
@@ -159,13 +174,17 @@ class SlackChannel implements Channel {
159
174
  }
160
175
  const id = key.slice(0, hashIdx);
161
176
  const name = key.slice(hashIdx + 1);
162
- fresh.set(id, { name, behavior: entry.behavior });
177
+ const resolved = resolveWatchBehavior(entry.behavior, name);
178
+ if (resolved.filePath) freshFiles.push(resolved.filePath);
179
+ fresh.set(id, { name, behavior: resolved.behavior });
163
180
  }
164
181
  }
165
182
  if (fresh.size !== watchCache.size) {
166
183
  log.info({ count: fresh.size }, "slack: watch channels reloaded");
167
184
  }
168
185
  watchCache = fresh;
186
+ watchFilePaths = freshFiles;
187
+ lastReloadMtime = maxMtime([configPath, ...freshFiles]);
169
188
  return watchCache;
170
189
  }
171
190
 
@@ -209,7 +228,10 @@ class SlackChannel implements Channel {
209
228
  const isDm = command.channel_name === "directmessage";
210
229
  const key = isDm ? `dm-${command.user_id}` : await resolveChannelName(app, command.channel_id);
211
230
  const state = await restartChat(key);
212
- log.info({ channel: command.channel_id, key, room: roomName(key, state.roomIndex) }, "new slack session via /nia-new");
231
+ log.info(
232
+ { channel: command.channel_id, key, room: roomName(key, state.roomIndex) },
233
+ "new slack session via /nia-new",
234
+ );
213
235
  await respond("New conversation started.");
214
236
  });
215
237
 
@@ -264,7 +286,12 @@ class SlackChannel implements Channel {
264
286
  if (existsSync(diskPath) && existsSync(metaPath)) {
265
287
  try {
266
288
  const meta = JSON.parse(readFileSync(metaPath, "utf8"));
267
- const entry: CachedFile = { path: diskPath, type: meta.type || attType, mimeType: meta.mimeType || mime, filename: meta.filename || file.name };
289
+ const entry: CachedFile = {
290
+ path: diskPath,
291
+ type: meta.type || attType,
292
+ mimeType: meta.mimeType || mime,
293
+ filename: meta.filename || file.name,
294
+ };
268
295
  fileIndex.set(file.url_private_download, entry);
269
296
  attachments.push(loadCached(entry));
270
297
  continue;
@@ -354,7 +381,10 @@ class SlackChannel implements Channel {
354
381
  const parentMsg = parent.messages?.[0];
355
382
  if (parentMsg && (parentMsg.user === botUserId || parentMsg.bot_id)) {
356
383
  isActiveThread = true;
357
- log.debug({ channel: msg.channel, thread_ts: msg.thread_ts }, "thread parent is bot-authored, activating");
384
+ log.debug(
385
+ { channel: msg.channel, thread_ts: msg.thread_ts },
386
+ "thread parent is bot-authored, activating",
387
+ );
358
388
  }
359
389
  } catch (err) {
360
390
  log.warn({ err, channel: msg.channel, thread_ts: msg.thread_ts }, "failed to check thread parent");
@@ -368,16 +398,19 @@ class SlackChannel implements Channel {
368
398
  const isWatched = !!watchConfig;
369
399
 
370
400
  if (!isDm && !isMention && !isActiveThread && !isWatched) {
371
- log.debug({
372
- channel: msg.channel,
373
- text: (msg.text || "").slice(0, 80),
374
- thread_ts: msg.thread_ts,
375
- isDm,
376
- isMention: !!isMention,
377
- isActiveThread,
378
- activeChats: [...chats.keys()],
379
- reason: !msg.thread_ts ? "no mention in channel" : "no active session for thread",
380
- }, "slack message ignored");
401
+ log.debug(
402
+ {
403
+ channel: msg.channel,
404
+ text: (msg.text || "").slice(0, 80),
405
+ thread_ts: msg.thread_ts,
406
+ isDm,
407
+ isMention: !!isMention,
408
+ isActiveThread,
409
+ activeChats: [...chats.keys()],
410
+ reason: !msg.thread_ts ? "no mention in channel" : "no active session for thread",
411
+ },
412
+ "slack message ignored",
413
+ );
381
414
  return;
382
415
  }
383
416
 
@@ -424,7 +457,7 @@ class SlackChannel implements Channel {
424
457
  }
425
458
 
426
459
  if (!text && (!attachments || attachments.length === 0)) return;
427
- if (!text) text = attachments?.some(a => a.type === "image") ? "What's in this image?" : "Here's a file.";
460
+ if (!text) text = attachments?.some((a) => a.type === "image") ? "What's in this image?" : "Here's a file.";
428
461
 
429
462
  // When replying in a thread, fetch thread context so Nia can see the full conversation
430
463
  if (msg.thread_ts) {
@@ -434,12 +467,11 @@ class SlackChannel implements Channel {
434
467
  ts: msg.thread_ts,
435
468
  limit: 50,
436
469
  });
437
- const priorMessages = (replies.messages || [])
438
- .filter((m: any) => m.ts !== msg.ts); // exclude the triggering message
470
+ const priorMessages = (replies.messages || []).filter((m: any) => m.ts !== msg.ts); // exclude the triggering message
439
471
 
440
472
  const now = new Date();
441
473
  const threadMessages = priorMessages.map((m: any) => {
442
- const sender = m.bot_id ? "bot" : (m.user || "unknown");
474
+ const sender = m.bot_id ? "bot" : m.user || "unknown";
443
475
  const fileHint = m.files?.length ? ` [${m.files.length} file(s) attached]` : "";
444
476
  const age = m.ts ? ` (${relativeTime(new Date(parseFloat(m.ts) * 1000), now)})` : "";
445
477
  return `[${sender}]${age}: ${m.text || "(no text)"}${fileHint}`;
@@ -474,10 +506,21 @@ class SlackChannel implements Channel {
474
506
 
475
507
  // Prepend watch behavior context for watched channels
476
508
  if (watchConfig) {
477
- text = `[Watch mode — #${watchConfig.name}]\nBehavior: ${watchConfig.behavior}\nRespond with [NO_REPLY] if no action needed.\n\n${text}`;
509
+ const behaviorLine = watchConfig.behavior ? `Behavior: ${watchConfig.behavior}\n` : "";
510
+ text = `[Watch mode — #${watchConfig.name}]\n${behaviorLine}Respond with [NO_REPLY] if no action needed.\n\n${text}`;
478
511
  }
479
512
 
480
- log.info({ channel: msg.channel, key, text: text.slice(0, 100), isDm, watched: isWatched, attachments: attachments?.length || 0 }, "slack message received");
513
+ log.info(
514
+ {
515
+ channel: msg.channel,
516
+ key,
517
+ text: text.slice(0, 100),
518
+ isDm,
519
+ watched: isWatched,
520
+ attachments: attachments?.length || 0,
521
+ },
522
+ "slack message received",
523
+ );
481
524
 
482
525
  let state: ChatState;
483
526
  try {
@@ -489,15 +532,20 @@ class SlackChannel implements Channel {
489
532
 
490
533
  withLock(key, async () => {
491
534
  // Add thinking reaction inside the lock so cleanup is guaranteed
492
- await client.reactions.add({ channel: msg.channel, timestamp: msg.ts, name: "thinking_face" })
535
+ await client.reactions
536
+ .add({ channel: msg.channel, timestamp: msg.ts, name: "thinking_face" })
493
537
  .catch((err) => log.debug({ err, channel: msg.channel }, "slack: failed to add thinking reaction"));
494
538
 
495
539
  try {
496
- const { result, messageId } = await state.engine.send(text, {
497
- onActivity(status) {
498
- log.debug({ status }, "slack engine activity");
540
+ const { result, messageId } = await state.engine.send(
541
+ text,
542
+ {
543
+ onActivity(status) {
544
+ log.debug({ status }, "slack engine activity");
545
+ },
499
546
  },
500
- }, attachments);
547
+ attachments,
548
+ );
501
549
 
502
550
  const reply = result.trim();
503
551
 
@@ -529,16 +577,19 @@ class SlackChannel implements Channel {
529
577
  log.error({ err, channel: msg.channel }, "slack message processing failed");
530
578
 
531
579
  if (replyThreadTs) {
532
- await client.chat.postMessage({
533
- channel: msg.channel,
534
- text: `[error] ${errText}`,
535
- thread_ts: replyThreadTs,
536
- }).catch(() => {});
580
+ await client.chat
581
+ .postMessage({
582
+ channel: msg.channel,
583
+ text: `[error] ${errText}`,
584
+ thread_ts: replyThreadTs,
585
+ })
586
+ .catch(() => {});
537
587
  } else {
538
588
  await say(`[error] ${errText}`).catch(() => {});
539
589
  }
540
590
  } finally {
541
- await client.reactions.remove({ channel: msg.channel, timestamp: msg.ts, name: "thinking_face" })
591
+ await client.reactions
592
+ .remove({ channel: msg.channel, timestamp: msg.ts, name: "thinking_face" })
542
593
  .catch((err) => log.debug({ err, channel: msg.channel }, "slack: failed to remove thinking reaction"));
543
594
  }
544
595
  });
@@ -18,8 +18,7 @@ import type {
18
18
  EngineOptions,
19
19
  } from "../types";
20
20
  import { truncate, formatToolUse } from "../utils/format-activity";
21
- import { consolidateSession } from "../core/consolidator";
22
- import { summarizeSession } from "../core/summarizer";
21
+ import { finalizeSession, cancelPending } from "../core/finalizer";
23
22
  import { log } from "../utils/log";
24
23
 
25
24
  const IDLE_TIMEOUT = 10 * 60 * 1000; // 10 minutes
@@ -33,10 +32,7 @@ interface SDKUserMessage {
33
32
  }
34
33
 
35
34
  /** Convert provider-agnostic attachments to Anthropic content blocks. */
36
- export function buildContentBlocks(
37
- text: string,
38
- attachments?: Attachment[],
39
- ): MessageParam["content"] {
35
+ export function buildContentBlocks(text: string, attachments?: Attachment[]): MessageParam["content"] {
40
36
  if (!attachments?.length) return text;
41
37
 
42
38
  const blocks: Array<
@@ -124,19 +120,11 @@ interface PendingResult {
124
120
  function sessionFileExists(sessionId: string, cwd: string): boolean {
125
121
  // SDK stores sessions at ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl
126
122
  const encoded = cwd.replace(/\//g, "-");
127
- const sessionFile = join(
128
- homedir(),
129
- ".claude",
130
- "projects",
131
- encoded,
132
- `${sessionId}.jsonl`,
133
- );
123
+ const sessionFile = join(homedir(), ".claude", "projects", encoded, `${sessionId}.jsonl`);
134
124
  return existsSync(sessionFile);
135
125
  }
136
126
 
137
- export async function createChatEngine(
138
- opts: EngineOptions,
139
- ): Promise<ChatEngine> {
127
+ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine> {
140
128
  const { room, channel, resume, mcpServers } = opts;
141
129
  let systemPrompt = buildSystemPrompt("chat", channel);
142
130
 
@@ -181,22 +169,13 @@ export async function createChatEngine(
181
169
  idleTimer = setTimeout(async () => {
182
170
  if (pending) {
183
171
  // Don't tear down while a request is in flight
184
- log.warn(
185
- { room },
186
- "idle timer fired while request pending, skipping teardown",
187
- );
172
+ log.warn({ room }, "idle timer fired while request pending, skipping teardown");
188
173
  return;
189
174
  }
190
- // Memory consolidation + session summary before "sleep"
175
+ // Enqueue finalization before "sleep"
191
176
  if (sessionId && messageCount > 0) {
192
- consolidateSession(sessionId, room).catch((err) => {
193
- log.error({ err, room }, "consolidation failed during idle teardown");
194
- });
195
- summarizeSession(sessionId, room).catch((err) => {
196
- log.error(
197
- { err, room },
198
- "session summary failed during idle teardown",
199
- );
177
+ finalizeSession(sessionId, room).catch((err) => {
178
+ log.error({ err, room }, "finalization enqueue failed during idle teardown");
200
179
  });
201
180
  }
202
181
  teardown();
@@ -216,10 +195,7 @@ export async function createChatEngine(
216
195
  longRunningTimer = setTimeout(() => {
217
196
  if (pending) {
218
197
  longRunningWarned = true;
219
- log.warn(
220
- { room, elapsed: LONG_RUNNING_WARN / 1000 },
221
- "engine request running for 30+ minutes",
222
- );
198
+ log.warn({ room, elapsed: LONG_RUNNING_WARN / 1000 }, "engine request running for 30+ minutes");
223
199
  }
224
200
  }, LONG_RUNNING_WARN);
225
201
  }
@@ -314,10 +290,7 @@ export async function createChatEngine(
314
290
  if (lines.length > 1) {
315
291
  // Show the last complete line (not the partial one being typed)
316
292
  const completeLine = lines[lines.length - 2]?.trim();
317
- if (
318
- completeLine &&
319
- completeLine !== pending.lastThinkingLine
320
- ) {
293
+ if (completeLine && completeLine !== pending.lastThinkingLine) {
321
294
  pending.lastThinkingLine = completeLine;
322
295
  pending.onActivity?.(truncate(completeLine, 70));
323
296
  }
@@ -445,11 +418,7 @@ export async function createChatEngine(
445
418
  "query stream ended without result, rejecting pending request",
446
419
  );
447
420
  await ActiveEngine.unregister(room).catch(() => {});
448
- pending.reject(
449
- new Error(
450
- `stream ended without result (${partial.length} chars accumulated)`,
451
- ),
452
- );
421
+ pending.reject(new Error(`stream ended without result (${partial.length} chars accumulated)`));
453
422
  pending = null;
454
423
  }
455
424
  } catch (err) {
@@ -476,15 +445,16 @@ export async function createChatEngine(
476
445
  return room;
477
446
  },
478
447
 
479
- async send(
480
- userMessage: string,
481
- callbacks?: SendCallbacks,
482
- attachments?: Attachment[],
483
- ) {
448
+ async send(userMessage: string, callbacks?: SendCallbacks, attachments?: Attachment[]) {
484
449
  // Clear idle timer — engine is not idle while processing a request
485
450
  clearIdleTimer();
486
451
  startLongRunningTimer();
487
452
 
453
+ // Cancel any pending finalization — session is active again
454
+ if (sessionId) {
455
+ cancelPending(sessionId).catch(() => {});
456
+ }
457
+
488
458
  await ActiveEngine.register(room, channel);
489
459
 
490
460
  if (!alive || !stream) {
@@ -524,13 +494,10 @@ export async function createChatEngine(
524
494
  },
525
495
 
526
496
  close() {
527
- // Memory consolidation + session summary on explicit close
497
+ // Enqueue finalization processed by daemon or inline if we are the daemon
528
498
  if (sessionId && messageCount > 0 && !pending) {
529
- consolidateSession(sessionId, room).catch((err) => {
530
- log.error({ err, room }, "consolidation failed during close");
531
- });
532
- summarizeSession(sessionId, room).catch((err) => {
533
- log.error({ err, room }, "session summary failed during close");
499
+ finalizeSession(sessionId, room).catch((err) => {
500
+ log.error({ err, room }, "finalization enqueue failed during close");
534
501
  });
535
502
  }
536
503
  teardown();
package/src/chat/repl.ts CHANGED
@@ -222,11 +222,12 @@ export async function startRepl(mode: ChatMode = "continue", simulateChannel?: s
222
222
  rl.prompt();
223
223
  });
224
224
 
225
- rl.on("close", async () => {
225
+ rl.on("close", () => {
226
226
  console.log(`\n${DIM}bye${RESET}`);
227
227
  engine.close();
228
- await closeDb();
229
- process.exit(0);
228
+ closeDb()
229
+ .catch(() => {})
230
+ .finally(() => process.exit(0));
230
231
  });
231
232
 
232
233
  process.on("SIGINT", () => {
package/src/cli/watch.ts CHANGED
@@ -5,11 +5,15 @@ import { fail, ICON_PASS, ICON_FAIL } from "../utils/cli";
5
5
  const HELP = `Usage: nia watch <command>
6
6
 
7
7
  Commands:
8
- list List watch channels (default)
9
- add <channel_id#name> <behavior> Add a watch channel
10
- remove <channel_id#name> Remove a watch channel
11
- enable <channel_id#name> Enable a watch channel
12
- disable <channel_id#name> Disable a watch channel`;
8
+ list List watch channels (default)
9
+ add <channel_id#name> [behavior] Add a watch channel. If [behavior] is
10
+ omitted, loads watches/<name>/behavior.md
11
+ at runtime. If [behavior] is a single
12
+ word, it names a different file to load.
13
+ Otherwise it is treated as inline prose.
14
+ remove <channel_id#name> Remove a watch channel
15
+ enable <channel_id#name> Enable a watch channel
16
+ disable <channel_id#name> Disable a watch channel`;
13
17
 
14
18
  export function watchCommand(): void {
15
19
  const sub = process.argv[3];
@@ -36,17 +40,18 @@ export function watchCommand(): void {
36
40
  const cfg = val as Record<string, unknown>;
37
41
  const enabled = cfg.enabled !== false;
38
42
  const icon = enabled ? ICON_PASS : ICON_FAIL;
39
- const behavior = typeof cfg.behavior === "string" ? cfg.behavior.slice(0, 80).replace(/\n/g, " ") : "";
40
- console.log(` ${icon} ${key} ${behavior}${behavior.length >= 80 ? "..." : ""}`);
43
+ const rawBehavior = typeof cfg.behavior === "string" ? cfg.behavior : "";
44
+ const behavior = rawBehavior ? rawBehavior.slice(0, 80).replace(/\n/g, " ") : "(default — loads from file)";
45
+ console.log(` ${icon} ${key} ${behavior}${rawBehavior.length > 80 ? "..." : ""}`);
41
46
  }
42
47
  break;
43
48
  }
44
49
 
45
50
  case "add": {
46
51
  const name = process.argv[4];
47
- const behavior = process.argv.slice(5).join(" ");
48
- if (!name || !behavior) {
49
- fail('Usage: nia watch add <channel_id#name> <behavior>');
52
+ const behavior = process.argv.slice(5).join(" ") || undefined;
53
+ if (!name) {
54
+ fail("Usage: nia watch add <channel_id#name> [behavior]");
50
55
  }
51
56
  console.log(addWatchChannel(name, behavior));
52
57
  break;
@@ -50,7 +50,11 @@ async function offerBeadsShellExport(rl: readline.Interface, beadsDir: string):
50
50
  }
51
51
  }
52
52
 
53
- const answer = await ask(rl, `\nAdd BEADS_DIR to ${rcFile.replace(homedir(), "~")} so 'bd' works globally? (y/n)`, "y");
53
+ const answer = await ask(
54
+ rl,
55
+ `\nAdd BEADS_DIR to ${rcFile.replace(homedir(), "~")} so 'bd' works globally? (y/n)`,
56
+ "y",
57
+ );
54
58
  if (answer.toLowerCase() !== "y") return;
55
59
 
56
60
  appendFileSync(rcFile, `\n# Beads global task DB\n${exportLine}\n`);
@@ -198,7 +202,9 @@ export async function runInit(): Promise<void> {
198
202
  Bun.spawn([openCmd, createUrl], { stdio: ["ignore", "ignore", "ignore"] });
199
203
  console.log(" 1. Click 'Create' to create the app");
200
204
  console.log(" 2. Go to 'OAuth & Permissions' → Install to workspace → copy Bot Token (xoxb-...)");
201
- console.log(" 3. Go to 'Basic Information' → 'App-Level Tokens' → create one with connections:write → copy (xapp-...)\n");
205
+ console.log(
206
+ " 3. Go to 'Basic Information' → 'App-Level Tokens' → create one with connections:write → copy (xapp-...)\n",
207
+ );
202
208
 
203
209
  slackBotToken = await ask(rl, "Bot token (xoxb-...)", "");
204
210
  slackAppToken = await ask(rl, "App token (xapp-...)", "");
@@ -217,7 +223,7 @@ export async function runInit(): Promise<void> {
217
223
  const masked = `...${existingGemini.slice(-6)}`;
218
224
  const reconfigure = await ask(rl, `\nGemini API: configured (${masked}). Reconfigure? (y/n)`, "n");
219
225
  if (reconfigure.toLowerCase() === "y") {
220
- geminiApiKey = await ask(rl, "Gemini API key", "") || existingGemini;
226
+ geminiApiKey = (await ask(rl, "Gemini API key", "")) || existingGemini;
221
227
  } else {
222
228
  geminiApiKey = existingGemini;
223
229
  }
@@ -229,7 +235,7 @@ export async function runInit(): Promise<void> {
229
235
  }
230
236
 
231
237
  // Beads task manager
232
- const bdInstalled = await Bun.spawn(["which", "bd"], { stdout: "pipe", stderr: "pipe" }).exited === 0;
238
+ const bdInstalled = (await Bun.spawn(["which", "bd"], { stdout: "pipe", stderr: "pipe" }).exited) === 0;
233
239
  const beadsInitialized = existsSync(`${paths.beadsDir}/.beads`);
234
240
 
235
241
  if (bdInstalled && beadsInitialized) {
@@ -266,7 +272,7 @@ export async function runInit(): Promise<void> {
266
272
  console.log(" \u2713 beads installed");
267
273
  mkdirSync(paths.beadsDir, { recursive: true });
268
274
  const initProc = Bun.spawn(["bd", "init"], { cwd: paths.beadsDir, stdout: "pipe", stderr: "pipe" });
269
- if (await initProc.exited === 0) {
275
+ if ((await initProc.exited) === 0) {
270
276
  console.log(` \u2713 initialized beads at ${paths.beadsDir}`);
271
277
  await offerBeadsShellExport(rl, paths.beadsDir);
272
278
  }
@@ -325,28 +331,45 @@ export async function runInit(): Promise<void> {
325
331
  // User provided a description — generate from scratch
326
332
  console.log(" Generating reference image from description...");
327
333
  const prompt = `Ultra photorealistic portrait: ${visualChoice}. Natural skin texture, DSLR quality, 8k, hyper-detailed.`;
328
- const proc = Bun.spawn([
329
- "python3", GENERATE_SCRIPT,
330
- "--no-reference",
331
- "--api-key", geminiApiKey,
332
- "--aspect-ratio", "9:16",
333
- "--prompt", prompt,
334
- "--output", `${imagesDir}/reference.webp`,
335
- ], { stdout: "pipe", stderr: "pipe" });
334
+ const proc = Bun.spawn(
335
+ [
336
+ "python3",
337
+ GENERATE_SCRIPT,
338
+ "--no-reference",
339
+ "--api-key",
340
+ geminiApiKey,
341
+ "--aspect-ratio",
342
+ "9:16",
343
+ "--prompt",
344
+ prompt,
345
+ "--output",
346
+ `${imagesDir}/reference.webp`,
347
+ ],
348
+ { stdout: "pipe", stderr: "pipe" },
349
+ );
336
350
  const exitCode = await proc.exited;
337
351
  if (exitCode === 0) {
338
352
  console.log(` \u2713 generated reference image at ${imagesDir}/reference.webp`);
339
353
  // Also generate a profile picture
340
354
  console.log(" Generating profile picture...");
341
- const profileProc = Bun.spawn([
342
- "python3", GENERATE_SCRIPT,
343
- "--reference", `${imagesDir}/reference.webp`,
344
- "--api-key", geminiApiKey,
345
- "--aspect-ratio", "1:1",
346
- "--prompt", `Photorealistic close-up portrait of the same person from the reference. Warm slight smile, direct eye contact, soft ambient side lighting, creamy bokeh background, 85mm f/1.8, shallow depth of field. Same face, same style, natural skin texture, DSLR quality, hyper-detailed.`,
347
- "--output", `${imagesDir}/profile.webp`,
348
- ], { stdout: "pipe", stderr: "pipe" });
349
- if (await profileProc.exited === 0) {
355
+ const profileProc = Bun.spawn(
356
+ [
357
+ "python3",
358
+ GENERATE_SCRIPT,
359
+ "--reference",
360
+ `${imagesDir}/reference.webp`,
361
+ "--api-key",
362
+ geminiApiKey,
363
+ "--aspect-ratio",
364
+ "1:1",
365
+ "--prompt",
366
+ `Photorealistic close-up portrait of the same person from the reference. Warm slight smile, direct eye contact, soft ambient side lighting, creamy bokeh background, 85mm f/1.8, shallow depth of field. Same face, same style, natural skin texture, DSLR quality, hyper-detailed.`,
367
+ "--output",
368
+ `${imagesDir}/profile.webp`,
369
+ ],
370
+ { stdout: "pipe", stderr: "pipe" },
371
+ );
372
+ if ((await profileProc.exited) === 0) {
350
373
  console.log(` \u2713 generated profile picture at ${imagesDir}/profile.webp`);
351
374
  }
352
375
  } else {
@@ -373,6 +396,7 @@ export async function runInit(): Promise<void> {
373
396
  // Create directories
374
397
  mkdirSync(home, { recursive: true });
375
398
  mkdirSync(paths.selfDir, { recursive: true });
399
+ mkdirSync(paths.watchesDir, { recursive: true });
376
400
  mkdirSync(`${home}/tmp`, { recursive: true });
377
401
 
378
402
  // Write config.yaml
@@ -423,7 +447,10 @@ export async function runInit(): Promise<void> {
423
447
 
424
448
  if (ownerName) {
425
449
  let ownerContent = loadTemplate("owner.md", vars);
426
- ownerContent = ownerContent.split("\n").filter((l) => !l.match(/\*\*\w+\*\*:\s*$/)).join("\n");
450
+ ownerContent = ownerContent
451
+ .split("\n")
452
+ .filter((l) => !l.match(/\*\*\w+\*\*:\s*$/))
453
+ .join("\n");
427
454
  writeFileSync(selfFile("owner.md"), ownerContent);
428
455
  console.log(` \u2713 wrote ${selfFile("owner.md")}`);
429
456
  }