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 +1 -1
- package/src/channels/slack.ts +89 -38
- package/src/chat/engine.ts +20 -53
- package/src/chat/repl.ts +4 -3
- package/src/cli/watch.ts +15 -10
- package/src/commands/init.ts +50 -23
- package/src/core/daemon.ts +49 -4
- package/src/core/finalizer.ts +125 -0
- package/src/db/migrations/014_finalization_requests.ts +21 -0
- package/src/mcp/server.ts +30 -101
- package/src/mcp/tools.ts +23 -69
- package/src/types/config.ts +8 -1
- package/src/types/paths.ts +1 -0
- package/src/utils/config.ts +48 -44
- package/src/utils/paths.ts +1 -0
- package/src/utils/watches.ts +68 -0
package/package.json
CHANGED
package/src/channels/slack.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
if (mtime ===
|
|
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
|
-
|
|
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(
|
|
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 = {
|
|
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(
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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" :
|
|
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
|
-
|
|
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(
|
|
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
|
|
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(
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
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
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
|
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
|
});
|
package/src/chat/engine.ts
CHANGED
|
@@ -18,8 +18,7 @@ import type {
|
|
|
18
18
|
EngineOptions,
|
|
19
19
|
} from "../types";
|
|
20
20
|
import { truncate, formatToolUse } from "../utils/format-activity";
|
|
21
|
-
import {
|
|
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
|
-
//
|
|
175
|
+
// Enqueue finalization before "sleep"
|
|
191
176
|
if (sessionId && messageCount > 0) {
|
|
192
|
-
|
|
193
|
-
log.error({ err, room }, "
|
|
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
|
-
//
|
|
497
|
+
// Enqueue finalization — processed by daemon or inline if we are the daemon
|
|
528
498
|
if (sessionId && messageCount > 0 && !pending) {
|
|
529
|
-
|
|
530
|
-
log.error({ err, room }, "
|
|
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",
|
|
225
|
+
rl.on("close", () => {
|
|
226
226
|
console.log(`\n${DIM}bye${RESET}`);
|
|
227
227
|
engine.close();
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
9
|
-
add <channel_id#name>
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
40
|
-
|
|
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
|
|
49
|
-
fail(
|
|
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;
|
package/src/commands/init.ts
CHANGED
|
@@ -50,7 +50,11 @@ async function offerBeadsShellExport(rl: readline.Interface, beadsDir: string):
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
const answer = await ask(
|
|
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(
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
|
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
|
}
|