niahere 0.3.3 → 0.3.5

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.3",
3
+ "version": "0.3.5",
4
4
  "description": "A personal AI assistant daemon — chat, scheduled jobs, persona system, extensible via skills.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -135,7 +135,10 @@ export class SlackAttachmentCache {
135
135
  }
136
136
 
137
137
  private async download(url: string): Promise<Buffer> {
138
- const resp = await fetch(url, { headers: { Authorization: `Bearer ${this.botToken}` } });
138
+ const resp = await fetch(url, {
139
+ headers: { Authorization: `Bearer ${this.botToken}` },
140
+ signal: AbortSignal.timeout(30_000),
141
+ });
139
142
  if (!resp.ok) throw new Error(`Slack file download failed: ${resp.status}`);
140
143
  return Buffer.from(await resp.arrayBuffer());
141
144
  }
@@ -184,11 +184,7 @@ class TelegramChannel implements Channel {
184
184
  const { result, messageId } = await state.engine.send(text, {}, attachments);
185
185
  const reply = result.trim() || "(no response)";
186
186
  try {
187
- try {
188
- await bot.api.sendMessage(chatId, reply, { parse_mode: "MarkdownV2" });
189
- } catch {
190
- await bot.api.sendMessage(chatId, reply);
191
- }
187
+ await bot.api.sendMessage(chatId, reply);
192
188
  if (messageId) await Message.updateDeliveryStatus(messageId, "sent").catch(() => {});
193
189
  log.info({ chatId, chars: result.length }, "telegram reply sent");
194
190
  } catch (sendErr) {
@@ -266,7 +262,7 @@ class TelegramChannel implements Channel {
266
262
  const file = await this.bot.api.getFile(fileId);
267
263
  const token = getConfig().channels.telegram.bot_token!;
268
264
  const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`;
269
- const resp = await fetch(url);
265
+ const resp = await fetch(url, { signal: AbortSignal.timeout(30_000) });
270
266
  if (!resp.ok) throw new Error(`Download failed: ${resp.status}`);
271
267
  return Buffer.from(await resp.arrayBuffer());
272
268
  }
@@ -33,6 +33,7 @@ async function twilioPost<T = unknown>(creds: TwilioCreds, suffix: string, body:
33
33
  method: "POST",
34
34
  headers: { Authorization: basicAuth(creds), "Content-Type": "application/x-www-form-urlencoded" },
35
35
  body: body.toString(),
36
+ signal: AbortSignal.timeout(15_000),
36
37
  });
37
38
  if (!resp.ok) {
38
39
  const text = await resp.text().catch(() => "");
@@ -27,6 +27,7 @@ import { isRetryableApiError, sleep } from "../utils/retry";
27
27
  import { registerActiveHandle, unregisterActiveHandle } from "../core/active-handles";
28
28
  import { resolveJobPrompt } from "../core/job-prompt";
29
29
  import { getSdkSkillsSetting } from "../core/skills";
30
+ import { getSdkHooks } from "../core/sdk-hooks";
30
31
 
31
32
  const IDLE_TIMEOUT = 10 * 60 * 1000; // 10 minutes
32
33
  const LONG_RUNNING_WARN = 30 * 60 * 1000; // 30 minutes
@@ -332,6 +333,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
332
333
  includePartialMessages: true,
333
334
  settingSources: ["project", "user"],
334
335
  skills: getSdkSkillsSetting(),
336
+ hooks: getSdkHooks(),
335
337
  };
336
338
  const model = resolveSdkModel(contextModel);
337
339
  if (model) {
package/src/core/alive.ts CHANGED
@@ -107,6 +107,7 @@ async function notifyUser(message: string): Promise<void> {
107
107
  method: "POST",
108
108
  headers: { Authorization: `Bearer ${slToken}`, "Content-Type": "application/json" },
109
109
  body: JSON.stringify({ channel: slRecipient, text: message }),
110
+ signal: AbortSignal.timeout(10_000),
110
111
  });
111
112
  if (resp.ok) {
112
113
  log.info("alive: notified user via slack");
@@ -18,6 +18,7 @@ import { log } from "../utils/log";
18
18
  import { isRetryableApiError, sleep } from "../utils/retry";
19
19
  import { registerActiveHandle, unregisterActiveHandle } from "./active-handles";
20
20
  import { getSdkSkillsSetting } from "./skills";
21
+ import { getSdkHooks } from "./sdk-hooks";
21
22
 
22
23
  export { buildWorkingMemory } from "./job-prompt";
23
24
 
@@ -121,6 +122,7 @@ export async function runJobWithClaude(
121
122
  permissionMode: "bypassPermissions",
122
123
  sessionId,
123
124
  skills: getSdkSkillsSetting(),
125
+ hooks: getSdkHooks(),
124
126
  };
125
127
 
126
128
  if (model && model !== "default") {
@@ -0,0 +1,43 @@
1
+ import type {
2
+ HookCallbackMatcher,
3
+ HookEvent,
4
+ HookJSONOutput,
5
+ PreToolUseHookInput,
6
+ } from "@anthropic-ai/claude-agent-sdk";
7
+
8
+ const GH_MERGE_PATTERN = /(?:^|[\s;&|(])gh\s+pr\s+merge(?:\s|$)/;
9
+
10
+ const STAMP_WARNING_CONTEXT = [
11
+ "PreToolUse warning: this Bash command merges a GitHub PR.",
12
+ "If the user only wants to APPROVE the PR (LGTM), use the gh-stamp skill",
13
+ '(`gh pr comment <pr> --body "LGTM, Stamped ✅"`) instead of merging.',
14
+ "Confirm intent before proceeding.",
15
+ ].join(" ");
16
+
17
+ const STAMP_WARNING_MESSAGE =
18
+ "Heads up: about to run `gh pr merge`. Did you mean to STAMP (LGTM approval) instead? See the gh-stamp skill.";
19
+
20
+ const warnOnGhMerge: HookCallbackMatcher = {
21
+ matcher: "Bash",
22
+ hooks: [
23
+ async (input): Promise<HookJSONOutput> => {
24
+ if (input.hook_event_name !== "PreToolUse") return {};
25
+ const command = (input as PreToolUseHookInput).tool_input as { command?: unknown } | undefined;
26
+ const cmd = command?.command;
27
+ if (typeof cmd !== "string" || !GH_MERGE_PATTERN.test(cmd)) return {};
28
+ return {
29
+ systemMessage: STAMP_WARNING_MESSAGE,
30
+ hookSpecificOutput: {
31
+ hookEventName: "PreToolUse",
32
+ additionalContext: STAMP_WARNING_CONTEXT,
33
+ },
34
+ };
35
+ },
36
+ ],
37
+ };
38
+
39
+ export function getSdkHooks(): Partial<Record<HookEvent, HookCallbackMatcher[]>> {
40
+ return {
41
+ PreToolUse: [warnOnGhMerge],
42
+ };
43
+ }
@@ -98,7 +98,10 @@ export async function setSummary(id: string, summary: string): Promise<void> {
98
98
  await sql`UPDATE sessions SET summary = ${summary} WHERE id = ${id}`;
99
99
  }
100
100
 
101
- export async function getRecentSummaries(room: string, limit = 3): Promise<Array<{ summary: string; updatedAt: string }>> {
101
+ export async function getRecentSummaries(
102
+ room: string,
103
+ limit = 3,
104
+ ): Promise<Array<{ summary: string; updatedAt: string }>> {
102
105
  const sql = getSql();
103
106
  // Match summaries from sessions in the same channel (e.g. slack-dm-U...-*)
104
107
  // by extracting the room prefix (everything before the last -N index)
@@ -171,17 +174,16 @@ export async function accumulateMetadata(id: string, resultMeta: Record<string,
171
174
  `;
172
175
  }
173
176
 
177
+ /** Max numeric suffix among rooms matching `${prefix}-N`. Used by rotateRoom() to allocate idx+1 without collisions. */
174
178
  export async function getLatestRoomIndex(prefix: string): Promise<number> {
175
179
  const sql = getSql();
176
180
  const pattern = `^${escapeRegex(prefix)}-\\d+$`;
177
- const rows = await sql`
178
- SELECT room FROM sessions
179
- WHERE room ~ ${pattern}
180
- ORDER BY updated_at DESC
181
- LIMIT 1
182
- `;
183
- if (rows.length === 0) return 0;
184
- const parts = rows[0].room.split("-");
185
- const idx = parseInt(parts[parts.length - 1], 10);
186
- return isNaN(idx) ? 0 : idx;
181
+ const rows = await sql`SELECT room FROM sessions WHERE room ~ ${pattern}`;
182
+ let max = 0;
183
+ for (const row of rows) {
184
+ const parts = (row.room as string).split("-");
185
+ const idx = parseInt(parts[parts.length - 1], 10);
186
+ if (!isNaN(idx) && idx > max) max = idx;
187
+ }
188
+ return max;
187
189
  }