niahere 0.3.2 → 0.3.3

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.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "A personal AI assistant daemon — chat, scheduled jobs, persona system, extensible via skills.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Disk-backed cache for Slack file attachments. Slack file URLs expire and
3
+ * require Authorization on download, so we fetch once per (scope, url),
4
+ * write the bytes + metadata to `~/.niahere/tmp/attachments/<scope>/`, and
5
+ * read from disk on subsequent references. Survives daemon restarts via
6
+ * the metadata sidecar.
7
+ */
8
+ import { createHash } from "crypto";
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
10
+ import { join } from "path";
11
+ import type { Attachment, AttachmentType } from "../../types";
12
+ import { classifyMime, prepareImage, validateAttachment } from "../../utils/attachment";
13
+ import { getNiaHome } from "../../utils/paths";
14
+ import { log } from "../../utils/log";
15
+
16
+ interface CachedFile {
17
+ path: string;
18
+ type: AttachmentType;
19
+ mimeType: string;
20
+ filename?: string;
21
+ }
22
+
23
+ function urlHash(url: string): string {
24
+ return createHash("sha256").update(url).digest("hex").slice(0, 16);
25
+ }
26
+
27
+ function safeExtension(filename?: string): string {
28
+ const ext = filename?.split(".").pop();
29
+ return ext && /^[a-zA-Z0-9]{1,16}$/.test(ext) ? ext : "bin";
30
+ }
31
+
32
+ function cacheExtension(filename: string | undefined, mime: string, attType: AttachmentType): string {
33
+ if (attType === "image" && mime !== "image/gif") return "jpg";
34
+ return safeExtension(filename);
35
+ }
36
+
37
+ function loadCached(entry: CachedFile): Attachment {
38
+ return {
39
+ type: entry.type,
40
+ data: readFileSync(entry.path),
41
+ mimeType: entry.mimeType,
42
+ filename: entry.filename,
43
+ sourcePath: entry.path,
44
+ };
45
+ }
46
+
47
+ export class SlackAttachmentCache {
48
+ private readonly attachRoot: string;
49
+ private readonly fileIndex = new Map<string, CachedFile>();
50
+
51
+ constructor(private readonly botToken: string) {
52
+ this.attachRoot = join(getNiaHome(), "tmp", "attachments");
53
+ mkdirSync(this.attachRoot, { recursive: true });
54
+ }
55
+
56
+ async extract(files: any[], scope: string): Promise<Attachment[]> {
57
+ const attachments: Attachment[] = [];
58
+ const scopedDir = this.dirForScope(scope);
59
+
60
+ for (const file of files) {
61
+ const mime = file.mimetype || "application/octet-stream";
62
+ const attType = classifyMime(mime);
63
+ if (!attType) continue;
64
+ if (!file.url_private_download) continue;
65
+
66
+ const indexedKey = `${scope}:${file.url_private_download}`;
67
+ const cached = this.fileIndex.get(indexedKey);
68
+ if (cached && existsSync(cached.path)) {
69
+ attachments.push(loadCached(cached));
70
+ continue;
71
+ }
72
+
73
+ const hash = urlHash(file.url_private_download);
74
+ const ext = cacheExtension(file.name, mime, attType);
75
+ const diskPath = join(scopedDir, `${hash}.${ext}`);
76
+ const metaPath = join(scopedDir, `${hash}.meta.json`);
77
+
78
+ // Re-load from disk if a prior daemon run already wrote this file.
79
+ if (existsSync(diskPath) && existsSync(metaPath)) {
80
+ try {
81
+ const meta = JSON.parse(readFileSync(metaPath, "utf8"));
82
+ const entry: CachedFile = {
83
+ path: diskPath,
84
+ type: meta.type || attType,
85
+ mimeType: meta.mimeType || mime,
86
+ filename: meta.filename || file.name,
87
+ };
88
+ this.fileIndex.set(indexedKey, entry);
89
+ attachments.push(loadCached(entry));
90
+ continue;
91
+ } catch {
92
+ // Corrupt meta — re-download.
93
+ }
94
+ }
95
+
96
+ try {
97
+ const raw = await this.download(file.url_private_download);
98
+ const error = validateAttachment(raw);
99
+ if (error) {
100
+ log.warn({ file: file.name, error }, "skipping slack attachment");
101
+ continue;
102
+ }
103
+ let data = raw;
104
+ let finalMime = mime;
105
+ if (attType === "image") {
106
+ const prepared = await prepareImage(raw, mime);
107
+ data = prepared.data;
108
+ finalMime = prepared.mimeType;
109
+ }
110
+
111
+ writeFileSync(diskPath, data);
112
+ writeFileSync(metaPath, JSON.stringify({ type: attType, mimeType: finalMime, filename: file.name }));
113
+ const entry: CachedFile = { path: diskPath, type: attType, mimeType: finalMime, filename: file.name };
114
+ this.fileIndex.set(indexedKey, entry);
115
+
116
+ attachments.push({
117
+ type: attType,
118
+ data,
119
+ mimeType: finalMime,
120
+ filename: file.name,
121
+ sourcePath: diskPath,
122
+ });
123
+ } catch (err) {
124
+ log.warn({ err, file: file.name }, "failed to download slack file");
125
+ }
126
+ }
127
+ return attachments;
128
+ }
129
+
130
+ private dirForScope(scope: string): string {
131
+ const safeScope = scope.replace(/[^a-zA-Z0-9._-]/g, "_");
132
+ const dir = join(this.attachRoot, safeScope);
133
+ mkdirSync(dir, { recursive: true });
134
+ return dir;
135
+ }
136
+
137
+ private async download(url: string): Promise<Buffer> {
138
+ const resp = await fetch(url, { headers: { Authorization: `Bearer ${this.botToken}` } });
139
+ if (!resp.ok) throw new Error(`Slack file download failed: ${resp.status}`);
140
+ return Buffer.from(await resp.arrayBuffer());
141
+ }
142
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Slack watch channels: hot-reloads `channels.slack.watch` entries from
3
+ * config.yaml (plus any behavior files they reference) on each inbound
4
+ * message, gated by an mtime check so we don't re-parse config on every
5
+ * call. Keyed by `channel_id` (the part before `#` in the config key).
6
+ */
7
+ import { statSync } from "fs";
8
+ import { getConfig, resetConfig } from "../../utils/config";
9
+ import { getPaths } from "../../utils/paths";
10
+ import { log } from "../../utils/log";
11
+ import { resolveWatchBehavior } from "../../utils/watches";
12
+
13
+ export interface WatchEntry {
14
+ name: string;
15
+ behavior: string;
16
+ }
17
+
18
+ function maxMtime(paths: string[]): number {
19
+ let max = 0;
20
+ for (const p of paths) {
21
+ try {
22
+ const m = statSync(p).mtimeMs;
23
+ if (m > max) max = m;
24
+ } catch {
25
+ // missing file — ignore
26
+ }
27
+ }
28
+ return max;
29
+ }
30
+
31
+ export class SlackWatchReloader {
32
+ private cache = new Map<string, WatchEntry>();
33
+ private filePaths: string[] = [];
34
+ private lastReloadMtime = 0;
35
+
36
+ /** Re-parse config + behavior files if any have been modified since the last read. */
37
+ reload(): Map<string, WatchEntry> {
38
+ const configPath = getPaths().config;
39
+ const mtime = maxMtime([configPath, ...this.filePaths]);
40
+ if (mtime === 0) return this.cache;
41
+ if (mtime === this.lastReloadMtime) return this.cache;
42
+
43
+ resetConfig();
44
+ const watch = getConfig().channels.slack.watch;
45
+ const fresh = new Map<string, WatchEntry>();
46
+ const freshFiles: string[] = [];
47
+
48
+ if (watch) {
49
+ for (const [key, entry] of Object.entries(watch)) {
50
+ if (!entry.enabled) continue;
51
+ const hashIdx = key.indexOf("#");
52
+ if (hashIdx === -1) {
53
+ log.warn({ channel: key }, "slack: watch key must use channel_id#name format, skipping");
54
+ continue;
55
+ }
56
+ const id = key.slice(0, hashIdx);
57
+ const name = key.slice(hashIdx + 1);
58
+ const resolved = resolveWatchBehavior(entry.behavior, name);
59
+ if (resolved.filePath) freshFiles.push(resolved.filePath);
60
+ fresh.set(id, { name, behavior: resolved.behavior });
61
+ }
62
+ }
63
+
64
+ if (fresh.size !== this.cache.size) {
65
+ log.info({ count: fresh.size }, "slack: watch channels reloaded");
66
+ }
67
+
68
+ this.cache = fresh;
69
+ this.filePaths = freshFiles;
70
+ this.lastReloadMtime = maxMtime([configPath, ...freshFiles]);
71
+ return this.cache;
72
+ }
73
+ }
@@ -1,18 +1,14 @@
1
1
  import { App } from "@slack/bolt";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "fs";
3
- import { join } from "path";
4
- import { createHash } from "crypto";
5
- import type { Channel, ChatState, Attachment, AttachmentType, Outbound, Recipient } from "../types";
6
- import { getConfig, updateRawConfig, resetConfig } from "../utils/config";
2
+ import type { Channel, ChatState, Attachment, Outbound, Recipient } from "../types";
3
+ import { getConfig, updateRawConfig } from "../utils/config";
7
4
  import { relativeTime } from "../utils/format";
8
5
  import { runMigrations } from "../db/migrate";
9
6
  import { Session, Message } from "../db/models";
10
7
  import { log } from "../utils/log";
11
8
  import { getMcpServers } from "../mcp";
12
- import { getNiaHome, getPaths } from "../utils/paths";
13
- import { classifyMime, validateAttachment, prepareImage } from "../utils/attachment";
14
- import { resolveWatchBehavior } from "../utils/watches";
15
9
  import { chainLock, openChatEngine, rotateRoom } from "./common/chat-session";
10
+ import { SlackAttachmentCache } from "./slack/attachments";
11
+ import { SlackWatchReloader } from "./slack/watch";
16
12
 
17
13
  /** Strip markdown backticks so sentinel tokens like [NO_REPLY] match even when the LLM wraps them. */
18
14
  function cleanSentinel(text: string): string {
@@ -141,59 +137,8 @@ class SlackChannel implements Channel {
141
137
 
142
138
  let botUserId: string | undefined;
143
139
 
144
- // Watch channels: mtime-based hot-reload from config.yaml AND any watch
145
- // behavior files referenced by that config. Keys are channel_id#channel_name.
146
- let watchCache: Map<string, { name: string; behavior: string }> = new Map();
147
- let watchFilePaths: string[] = [];
148
- let lastReloadMtime = 0;
149
-
150
- function maxMtime(paths: string[]): number {
151
- let max = 0;
152
- for (const p of paths) {
153
- try {
154
- const m = statSync(p).mtimeMs;
155
- if (m > max) max = m;
156
- } catch {
157
- // ignore missing files
158
- }
159
- }
160
- return max;
161
- }
162
-
163
- function reloadWatchChannels(): Map<string, { name: string; behavior: string }> {
164
- const configPath = getPaths().config;
165
- const mtime = maxMtime([configPath, ...watchFilePaths]);
166
- if (mtime === 0) return watchCache;
167
- if (mtime === lastReloadMtime) return watchCache;
168
-
169
- resetConfig(); // clear cached config so getConfig() re-reads from disk
170
- const cfg = getConfig();
171
- const watch = cfg.channels.slack.watch;
172
- const fresh = new Map<string, { name: string; behavior: string }>();
173
- const freshFiles: string[] = [];
174
- if (watch) {
175
- for (const [key, entry] of Object.entries(watch)) {
176
- if (!entry.enabled) continue;
177
- const hashIdx = key.indexOf("#");
178
- if (hashIdx === -1) {
179
- log.warn({ channel: key }, "slack: watch key must use channel_id#name format, skipping");
180
- continue;
181
- }
182
- const id = key.slice(0, hashIdx);
183
- const name = key.slice(hashIdx + 1);
184
- const resolved = resolveWatchBehavior(entry.behavior, name);
185
- if (resolved.filePath) freshFiles.push(resolved.filePath);
186
- fresh.set(id, { name, behavior: resolved.behavior });
187
- }
188
- }
189
- if (fresh.size !== watchCache.size) {
190
- log.info({ count: fresh.size }, "slack: watch channels reloaded");
191
- }
192
- watchCache = fresh;
193
- watchFilePaths = freshFiles;
194
- lastReloadMtime = maxMtime([configPath, ...freshFiles]);
195
- return watchCache;
196
- }
140
+ const watchReloader = new SlackWatchReloader();
141
+ const attachmentCache = new SlackAttachmentCache(botToken);
197
142
 
198
143
  // Slash command: /nia
199
144
  app.command("/nia", async ({ command, ack, respond }) => {
@@ -242,135 +187,6 @@ class SlackChannel implements Channel {
242
187
  await respond("New conversation started.");
243
188
  });
244
189
 
245
- // Disk-backed file cache: download once, read from disk on subsequent requests
246
- const attachRoot = join(getNiaHome(), "tmp", "attachments");
247
- mkdirSync(attachRoot, { recursive: true });
248
-
249
- interface CachedFile {
250
- path: string;
251
- type: AttachmentType;
252
- mimeType: string;
253
- filename?: string;
254
- }
255
- const fileIndex = new Map<string, CachedFile>();
256
-
257
- function urlHash(url: string): string {
258
- return createHash("sha256").update(url).digest("hex").slice(0, 16);
259
- }
260
-
261
- function loadCached(entry: CachedFile): Attachment {
262
- return {
263
- type: entry.type,
264
- data: readFileSync(entry.path),
265
- mimeType: entry.mimeType,
266
- filename: entry.filename,
267
- sourcePath: entry.path,
268
- };
269
- }
270
-
271
- async function downloadSlackFile(url: string): Promise<Buffer> {
272
- const resp = await fetch(url, {
273
- headers: { Authorization: `Bearer ${botToken}` },
274
- });
275
- if (!resp.ok) throw new Error(`Slack file download failed: ${resp.status}`);
276
- return Buffer.from(await resp.arrayBuffer());
277
- }
278
-
279
- function cacheDirForScope(scope: string): string {
280
- const safeScope = scope.replace(/[^a-zA-Z0-9._-]/g, "_");
281
- const dir = join(attachRoot, safeScope);
282
- mkdirSync(dir, { recursive: true });
283
- return dir;
284
- }
285
-
286
- function cacheKey(scope: string, url: string): string {
287
- return `${scope}:${url}`;
288
- }
289
-
290
- function safeExtension(filename?: string): string {
291
- const ext = filename?.split(".").pop();
292
- return ext && /^[a-zA-Z0-9]{1,16}$/.test(ext) ? ext : "bin";
293
- }
294
-
295
- function cacheExtension(filename: string | undefined, mime: string, attType: AttachmentType): string {
296
- if (attType === "image" && mime !== "image/gif") return "jpg";
297
- return safeExtension(filename);
298
- }
299
-
300
- async function extractSlackAttachments(files: any[], scope: string): Promise<Attachment[]> {
301
- const attachments: Attachment[] = [];
302
- const scopedAttachDir = cacheDirForScope(scope);
303
- for (const file of files) {
304
- const mime = file.mimetype || "application/octet-stream";
305
- const attType = classifyMime(mime);
306
- if (!attType) continue;
307
- if (!file.url_private_download) continue;
308
-
309
- // Check in-memory index first
310
- const indexedKey = cacheKey(scope, file.url_private_download);
311
- const cached = fileIndex.get(indexedKey);
312
- if (cached && existsSync(cached.path)) {
313
- attachments.push(loadCached(cached));
314
- continue;
315
- }
316
-
317
- // Check disk (survives daemon restarts) — scoped by Slack room/thread.
318
- const hash = urlHash(file.url_private_download);
319
- const ext = cacheExtension(file.name, mime, attType);
320
- const diskPath = join(scopedAttachDir, `${hash}.${ext}`);
321
- const metaPath = join(scopedAttachDir, `${hash}.meta.json`);
322
- if (existsSync(diskPath) && existsSync(metaPath)) {
323
- try {
324
- const meta = JSON.parse(readFileSync(metaPath, "utf8"));
325
- const entry: CachedFile = {
326
- path: diskPath,
327
- type: meta.type || attType,
328
- mimeType: meta.mimeType || mime,
329
- filename: meta.filename || file.name,
330
- };
331
- fileIndex.set(indexedKey, entry);
332
- attachments.push(loadCached(entry));
333
- continue;
334
- } catch {
335
- // Corrupt meta — re-download
336
- }
337
- }
338
-
339
- try {
340
- const data = await downloadSlackFile(file.url_private_download);
341
- const error = validateAttachment(data);
342
- if (error) {
343
- log.warn({ file: file.name, error }, "skipping slack attachment");
344
- continue;
345
- }
346
- let finalData = data;
347
- let finalMime = mime;
348
- if (attType === "image") {
349
- const prepared = await prepareImage(data, mime);
350
- finalData = prepared.data;
351
- finalMime = prepared.mimeType;
352
- }
353
-
354
- // Save file + metadata to disk
355
- writeFileSync(diskPath, finalData);
356
- writeFileSync(metaPath, JSON.stringify({ type: attType, mimeType: finalMime, filename: file.name }));
357
- const entry: CachedFile = { path: diskPath, type: attType, mimeType: finalMime, filename: file.name };
358
- fileIndex.set(indexedKey, entry);
359
-
360
- attachments.push({
361
- type: attType,
362
- data: finalData,
363
- mimeType: finalMime,
364
- filename: file.name,
365
- sourcePath: diskPath,
366
- });
367
- } catch (err) {
368
- log.warn({ err, file: file.name }, "failed to download slack file");
369
- }
370
- }
371
- return attachments;
372
- }
373
-
374
190
  // Handle messages (DMs + @mentions)
375
191
  app.message(async ({ message, say, client }) => {
376
192
  if (message.subtype && message.subtype !== "file_share") return;
@@ -435,7 +251,7 @@ class SlackChannel implements Channel {
435
251
  }
436
252
 
437
253
  // Check if this is a watched channel (hot-reloads from config.yaml via mtime)
438
- const currentWatch = reloadWatchChannels();
254
+ const currentWatch = watchReloader.reload();
439
255
  const watchConfig = currentWatch.get(msg.channel);
440
256
  const isWatched = !!watchConfig;
441
257
 
@@ -497,7 +313,7 @@ class SlackChannel implements Channel {
497
313
  // Download any file attachments
498
314
  let attachments: Attachment[] | undefined;
499
315
  if (hasFiles) {
500
- attachments = await extractSlackAttachments(msg.files!, roomPrefix(key));
316
+ attachments = await attachmentCache.extract(msg.files!, roomPrefix(key));
501
317
  }
502
318
 
503
319
  if (!text && (!attachments || attachments.length === 0)) return;
@@ -534,7 +350,7 @@ class SlackChannel implements Channel {
534
350
  const messagesWithFiles = priorMessages.filter((m: any) => m.files?.length > 0);
535
351
  let threadFilesAdded = 0;
536
352
  for (const m of messagesWithFiles) {
537
- const extracted = await extractSlackAttachments(m.files || [], roomPrefix(key));
353
+ const extracted = await attachmentCache.extract(m.files || [], roomPrefix(key));
538
354
  for (const att of extracted) {
539
355
  attachments.push(att);
540
356
  threadFilesAdded++;
@@ -673,7 +489,7 @@ class SlackChannel implements Channel {
673
489
  }
674
490
 
675
491
  // Initial watch channel load
676
- reloadWatchChannels();
492
+ watchReloader.reload();
677
493
 
678
494
  log.info("slack bot started (Socket Mode)");
679
495
  this.app = app;