niahere 0.3.1 → 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.1",
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,56 @@
1
+ /**
2
+ * Shared chat-engine lifecycle helpers used by the message-driven
3
+ * channels (telegram, slack, sms, whatsapp). Each channel keeps its
4
+ * own `Map<senderKey, ChatState>`; these helpers cover the bits that
5
+ * were copy-pasted between them:
6
+ *
7
+ * - resolve the latest room index for a prefix and open a fresh engine
8
+ * - rotate to a new room (for `/reset` / `/new` / `/restart`), persisting
9
+ * a placeholder session so the new index survives daemon restarts
10
+ * - chain work onto a per-sender lock so messages from the same sender
11
+ * don't race
12
+ *
13
+ * The caller supplies a builder lambda for the EngineOptions so channels
14
+ * that need room-aware fields (e.g. Slack's per-room `mcpServers`) can
15
+ * compute them with the resolved room name. Channels with static options
16
+ * just ignore the `room` argument in their builder.
17
+ */
18
+ import { createChatEngine } from "../../chat/engine";
19
+ import { Session } from "../../db/models";
20
+ import { log } from "../../utils/log";
21
+ import type { ChatState } from "../../types";
22
+ import type { EngineOptions } from "../../types/engine";
23
+
24
+ type EngineFactory = (room: string) => Omit<EngineOptions, "room" | "resume">;
25
+
26
+ /** Open (or resume) a chat engine for `prefix`. The resulting ChatState is the caller's to cache. */
27
+ export async function openChatEngine(prefix: string, buildOpts: EngineFactory): Promise<ChatState> {
28
+ const roomIndex = await Session.getLatestRoomIndex(prefix);
29
+ const room = `${prefix}-${roomIndex}`;
30
+ const opts = buildOpts(room);
31
+ log.info({ channel: opts.channel, room }, "chat-session: opening engine");
32
+ const engine = await createChatEngine({ ...opts, room, resume: true });
33
+ return { engine, roomIndex, lock: Promise.resolve() };
34
+ }
35
+
36
+ /** Rotate to a fresh room. Closes `prev` if supplied, persists a placeholder Session so the index survives restarts. */
37
+ export async function rotateRoom(
38
+ prefix: string,
39
+ prev: ChatState | undefined,
40
+ buildOpts: EngineFactory,
41
+ ): Promise<ChatState> {
42
+ if (prev) prev.engine.close();
43
+ const prevIdx = await Session.getLatestRoomIndex(prefix);
44
+ const roomIndex = prevIdx + 1;
45
+ const room = `${prefix}-${roomIndex}`;
46
+ await Session.create(`placeholder-${room}`, room);
47
+ const opts = buildOpts(room);
48
+ log.info({ channel: opts.channel, room }, "chat-session: rotated room");
49
+ const engine = await createChatEngine({ ...opts, room, resume: false });
50
+ return { engine, roomIndex, lock: Promise.resolve() };
51
+ }
52
+
53
+ /** Serialize `fn` onto `state.lock`. Both success and failure forward so a thrown error doesn't poison the chain. */
54
+ export function chainLock(state: ChatState, fn: () => Promise<void>): void {
55
+ state.lock = state.lock.then(fn, fn);
56
+ }
@@ -22,7 +22,7 @@
22
22
  * - consult.ts — escape hatch to Claude for memory-aware reasoning
23
23
  */
24
24
  import type { ServerWebSocket } from "bun";
25
- import type { Channel, PhoneConfig, TwilioConfig } from "../../types";
25
+ import type { Channel, Outbound, PhoneConfig, TwilioConfig } from "../../types";
26
26
  import { getConfig } from "../../utils/config";
27
27
  import { log } from "../../utils/log";
28
28
  import { getChannel } from "../registry";
@@ -66,7 +66,7 @@ const HARD_MAX_MINUTES = 30;
66
66
  const WS_PATH = "/twilio/voice/stream";
67
67
 
68
68
  class PhoneChannel implements Channel {
69
- name = "phone";
69
+ name = "phone" as const;
70
70
  private readonly phone: PhoneConfig;
71
71
  private readonly twilio: TwilioConfig;
72
72
  private readonly pending = new Map<string, PendingCall>();
@@ -92,7 +92,7 @@ class PhoneChannel implements Channel {
92
92
 
93
93
  server.registerHttp("/twilio/voice/incoming", (req, ctx) => this.handleIncoming(req, ctx.params));
94
94
  server.registerHttp("/twilio/voice/outbound", (req, ctx) => this.handleOutboundTwiml(req, ctx.params));
95
- server.registerHttp("/twilio/voice/status", (req, ctx) => this.handleStatus(ctx.params), {
95
+ server.registerHttp("/twilio/voice/status", (_req, ctx) => this.handleStatus(ctx.params), {
96
96
  dedupOn: "CallSid",
97
97
  });
98
98
 
@@ -127,6 +127,15 @@ class PhoneChannel implements Channel {
127
127
  // it running here would block SMS/WhatsApp if they're also bound to it.
128
128
  }
129
129
 
130
+ /**
131
+ * Phone is voice-only — agent-initiated text/media doesn't have a sensible
132
+ * delivery shape over Twilio Voice. Callers that want a text notification
133
+ * about a call should target a text channel (telegram, slack, whatsapp).
134
+ */
135
+ async deliver(_out: Outbound): Promise<void> {
136
+ throw new Error("phone: text/media delivery is not supported — use a text channel or placeCall() for voice");
137
+ }
138
+
130
139
  // --- Outbound entrypoint (used by the place_call MCP tool and CLI test) ---
131
140
 
132
141
  async placeCall(opts: {
@@ -186,7 +195,7 @@ class PhoneChannel implements Channel {
186
195
  if (!allowed) {
187
196
  log.warn({ from, callSid }, "phone: rejecting unauthorized caller");
188
197
  getChannel("telegram")
189
- ?.sendMessage?.(`Phone: rejected call from ${from} (CallSid ${callSid})`)
198
+ ?.deliver({ text: `Phone: rejected call from ${from} (CallSid ${callSid})` })
190
199
  .catch(() => {});
191
200
  return twimlResponse(sayAndHangupTwiML("Sorry, this line is not currently accepting calls. Goodbye."));
192
201
  }
@@ -47,8 +47,8 @@ export function buildPhoneTools(ctx: ToolContextOpts): PhoneToolDefinition[] {
47
47
  handler: async (args) => {
48
48
  const text = String(args.text || "").slice(0, 1000);
49
49
  const tg = getChannel("telegram");
50
- if (!tg || !tg.sendMessage) return "telegram unavailable";
51
- await tg.sendMessage(`[Phone] ${text}`);
50
+ if (!tg) return "telegram unavailable";
51
+ await tg.deliver({ text: `[Phone] ${text}` });
52
52
  return "sent";
53
53
  },
54
54
  },
@@ -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
+ }