niahere 0.5.7 → 0.5.9

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.5.7",
3
+ "version": "0.5.9",
4
4
  "description": "A personal AI assistant daemon — chat, scheduled jobs, persona system, extensible via skills.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -90,6 +90,15 @@ function attribute(modelUsage: unknown): Record<string, unknown> | undefined {
90
90
  * stay backend-agnostic.
91
91
  */
92
92
  export class SdkNormalizer implements Normalizer {
93
+ /**
94
+ * The SDK's `apiKeySource` only describes where an *API key* came from: it
95
+ * reports "none" for every OAuth path, so it cannot tell a configured token
96
+ * from the CLI's inherited login. Nia resolved the credential itself, so it
97
+ * records that too — the SDK's value is kept because it is genuinely
98
+ * informative when an API key is in play.
99
+ */
100
+ constructor(private readonly credential?: string) {}
101
+
93
102
  private apiKeySource: string | undefined;
94
103
  private accumulatedThinking = "";
95
104
  private lastThinkingLine = "";
@@ -217,6 +226,7 @@ export class SdkNormalizer implements Normalizer {
217
226
  session_id: msg.session_id,
218
227
  subtype: msg.subtype,
219
228
  api_key_source: this.apiKeySource,
229
+ credential: this.credential,
220
230
  usage: msg.usage,
221
231
  model_usage: attribute(msg.modelUsage),
222
232
  },
@@ -128,7 +128,7 @@ class ClaudeSession implements AgentSession {
128
128
  while (true) {
129
129
  if (!this.iterator || !this.stream) this.startQuery();
130
130
  this.stream!.push(text, attachments);
131
- const normalizer = new SdkNormalizer();
131
+ const normalizer = new SdkNormalizer(resolveClaudeCredential(getConfig()).kind);
132
132
  let retry = false;
133
133
 
134
134
  while (true) {
@@ -0,0 +1,79 @@
1
+ /**
2
+ * What counts as a reply, and what is just the model talking to itself.
3
+ *
4
+ * Two related jobs live here on purpose, because splitting them is how the
5
+ * codebase ended up with two sentinel parsers that disagreed:
6
+ *
7
+ * - `shouldSuppressReply` is the cross-channel guard. Control artifacts have
8
+ * escaped as real messages before — `nia send --help` once DM'd the flag
9
+ * itself — so every channel checks the same list.
10
+ * - `decideWatchReply` is the richer judgement a watch turn needs: it prefers
11
+ * a schema's answer, and can tell a bare sentinel from one tangled up with
12
+ * content.
13
+ */
14
+
15
+ export const WATCH_JUDGEMENT_SCHEMA: Record<string, unknown> = {
16
+ type: "object",
17
+ properties: {
18
+ reply: {
19
+ type: ["string", "null"],
20
+ description: "The message to post in the channel, or null to stay silent. Most turns are null.",
21
+ },
22
+ },
23
+ required: ["reply"],
24
+ additionalProperties: false,
25
+ };
26
+
27
+ /** Strip markdown fencing so a sentinel matches even when the model wraps it.
28
+ * Underscores are left alone — the sentinel contains one. */
29
+ export function cleanControlReply(text: string): string {
30
+ return text.replace(/[`*]/g, "").trim();
31
+ }
32
+
33
+ /** Outputs that are control artifacts rather than anything a person should see. */
34
+ const CONTROL_ARTIFACTS = new Set(["[NO_REPLY]", "--help", "-h"]);
35
+
36
+ /**
37
+ * Exact matches only. "Use `--help` to see the flags" is a real answer and must
38
+ * survive; a reply that is nothing but `--help` is the CLI leaking.
39
+ */
40
+ export function shouldSuppressReply(text: string): boolean {
41
+ const cleaned = cleanControlReply(text);
42
+ return !cleaned || CONTROL_ARTIFACTS.has(cleaned);
43
+ }
44
+
45
+ export interface WatchDecision {
46
+ /** Whether to post at all. */
47
+ send: boolean;
48
+ /** What to post. Empty when staying quiet. */
49
+ text: string;
50
+ /** Which path decided, so a drop in schema coverage is visible in the log. */
51
+ source: "structured" | "sentinel";
52
+ /** Set only on the sentinel path, when the model emitted both a sentinel and content. */
53
+ ambiguous?: boolean;
54
+ }
55
+
56
+ /**
57
+ * `structured` wins when it carries the `reply` key the schema requires —
58
+ * including an explicit null, which is a decision, not an absence.
59
+ *
60
+ * The sentinel fallback matches `[NO_REPLY]` anywhere, not just exactly: a watch
61
+ * turn that says both is confused, and staying quiet is the safer reading. That
62
+ * is deliberately looser than `shouldSuppressReply`, which guards ordinary
63
+ * replies where a substring match would swallow real answers.
64
+ */
65
+ export function decideWatchReply(structured: unknown, raw: string): WatchDecision {
66
+ if (structured && typeof structured === "object" && "reply" in structured) {
67
+ const reply = (structured as { reply: unknown }).reply;
68
+ const text = typeof reply === "string" ? reply.trim() : "";
69
+ return { send: text.length > 0, text, source: "structured" };
70
+ }
71
+
72
+ const trimmed = raw.trim();
73
+ const cleaned = cleanControlReply(trimmed);
74
+ if (!trimmed || cleaned.includes("[NO_REPLY]") || CONTROL_ARTIFACTS.has(cleaned)) {
75
+ const exact = !trimmed || CONTROL_ARTIFACTS.has(cleaned);
76
+ return { send: false, text: "", source: "sentinel", ambiguous: !exact };
77
+ }
78
+ return { send: true, text: trimmed, source: "sentinel" };
79
+ }
@@ -12,7 +12,7 @@ import { ChatSessions, chainLock } from "./common/chat-session";
12
12
  import { SlackAttachmentCache } from "./slack/attachments";
13
13
  import { SlackWatchReloader } from "./slack/watch";
14
14
 
15
- import { decideWatchReply } from "./common/watch-judgement";
15
+ import { decideWatchReply, shouldSuppressReply } from "./common/reply";
16
16
  import { createTurnPump } from "./common/coalesce";
17
17
 
18
18
  const logActivity = (status: string) => log.debug({ status }, "slack engine activity");
@@ -304,7 +304,8 @@ class SlackChannel implements Channel {
304
304
  const { result } = await state.engine.send(subcommand, {
305
305
  onActivity: logActivity,
306
306
  });
307
- await respond(result.trim() || "(no response)");
307
+ if (shouldSuppressReply(result)) return;
308
+ await respond(result.trim());
308
309
  } catch (err) {
309
310
  const errText = errMsg(err);
310
311
  await respond(`[error] ${errText}`);
@@ -23,6 +23,8 @@ import { errMsg, ignore } from "../utils/errors";
23
23
  import { sendMessage as twilioSendMessage } from "./twilio/rest";
24
24
  import { getTwilioServer } from "./twilio/server";
25
25
  import { ChatSessions, chainLock } from "./common/chat-session";
26
+ import { Message } from "../db/models";
27
+ import { shouldSuppressReply } from "./common/reply";
26
28
  import { ackTwiml, deliveryStatusAck, isAllowedSender } from "./twilio/shared";
27
29
 
28
30
  class SmsChannel implements Channel {
@@ -105,8 +107,13 @@ class SmsChannel implements Channel {
105
107
  // Twilio's ~15s webhook timeout when the engine takes longer.
106
108
  chainLock(state, async () => {
107
109
  try {
108
- const { result } = await state.engine.send(body);
109
- const reply = result.trim() || "(no response)";
110
+ const { result, messageId } = await state.engine.send(body);
111
+ const reply = result.trim();
112
+ if (shouldSuppressReply(reply)) {
113
+ if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "sent"), "record suppressed delivery status");
114
+ log.info({ from }, "sms: agent chose not to reply");
115
+ return;
116
+ }
110
117
  await this.sendTo(from, reply);
111
118
  } catch (err) {
112
119
  log.error({ err, from }, "sms: engine error");
@@ -12,6 +12,7 @@ import { getMcpServers } from "../mcp";
12
12
  import { classifyMime, validateAttachment, prepareImage } from "../utils/attachment";
13
13
  import { getNiaHome } from "../utils/paths";
14
14
  import { ChatSessions, chainLock } from "./common/chat-session";
15
+ import { shouldSuppressReply } from "./common/reply";
15
16
 
16
17
  function safeExtension(filename?: string): string {
17
18
  const ext = filename?.split(".").pop();
@@ -187,7 +188,12 @@ class TelegramChannel implements Channel {
187
188
 
188
189
  try {
189
190
  const { result, messageId } = await state.engine.send(text, {}, attachments);
190
- const reply = result.trim() || "(no response)";
191
+ const reply = result.trim();
192
+ if (shouldSuppressReply(reply)) {
193
+ if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "sent"), "record suppressed delivery status");
194
+ log.info({ chatId }, "telegram: agent chose not to reply");
195
+ return;
196
+ }
191
197
  try {
192
198
  await bot.api.sendMessage(chatId, reply);
193
199
  if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "sent"), "record sent delivery status");
@@ -21,6 +21,7 @@ import { getMcpServers } from "../mcp";
21
21
  import { Message } from "../db/models";
22
22
  import { runMigrations } from "../db/migrate";
23
23
  import { ChatSessions, chainLock } from "./common/chat-session";
24
+ import { shouldSuppressReply } from "./common/reply";
24
25
  import { ackTwiml, deliveryStatusAck, isAllowedSender } from "./twilio/shared";
25
26
  import type { Attachment, Channel, ChatState, Outbound, TwilioConfig, WhatsappConfig, PhoneConfig } from "../types";
26
27
  import { getConfig } from "../utils/config";
@@ -201,7 +202,12 @@ class WhatsAppChannel implements Channel {
201
202
 
202
203
  try {
203
204
  const { result, messageId } = await state.engine.send(userText || "(media only)", {}, attachments);
204
- const reply = result.trim() || "(no response)";
205
+ const reply = result.trim();
206
+ if (shouldSuppressReply(reply)) {
207
+ if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "sent"), "record suppressed delivery status");
208
+ log.info({ from }, "whatsapp: agent chose not to reply");
209
+ return;
210
+ }
205
211
  try {
206
212
  await this.sendTextTo(from, reply);
207
213
  if (messageId) await ignore(Message.updateDeliveryStatus(messageId, "sent"), "record sent delivery status");
@@ -13,6 +13,7 @@ import { log } from "../utils/log";
13
13
  import { asError, errMsg, ignore } from "../utils/errors";
14
14
  import { registerActiveHandle, unregisterActiveHandle } from "../core/active-handles";
15
15
  import { resolveJobPrompt } from "../core/job-prompt";
16
+ import { initialDeliveryStatus } from "../core/delivery";
16
17
  import { truncate } from "../utils/format-activity";
17
18
  import { resolveChain, ChainCursor, describeEntry, providerHealth, type AgentSession, type FailoverScope } from "../agent";
18
19
  import { scopeOf, parseFailure } from "../agent/failure";
@@ -337,7 +338,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
337
338
  sender: "nia",
338
339
  content: ev.text,
339
340
  isFromAgent: true,
340
- deliveryStatus: "pending" as const,
341
+ deliveryStatus: initialDeliveryStatus(channel),
341
342
  metadata: ev.metadata,
342
343
  };
343
344
  try {
@@ -7,6 +7,7 @@ import { getAgentsSummary } from "../core/agents";
7
7
  import { getEmployeesSummary } from "../core/employees";
8
8
  import { Session } from "../db/models";
9
9
  import type { Mode } from "../types";
10
+ import { splitMemory } from "../utils/memory-window";
10
11
 
11
12
  export { type SkillInfo } from "../core/skills";
12
13
 
@@ -18,11 +19,18 @@ function loadFile(dir: string, name: string): string {
18
19
 
19
20
  export function loadIdentity(): string {
20
21
  const { selfDir } = getPaths();
21
- const files = ["identity.md", "owner.md", "soul.md", "rules.md", "memory.md"];
22
- return files
23
- .map((f) => loadFile(selfDir, f))
24
- .filter(Boolean)
25
- .join("\n\n");
22
+ // Rules load whole: they are verbs, and an unloaded rule is simply not
23
+ // followed. Memory is nouns — the newest and the curated sections load, the
24
+ // rest is one `search_memory` call away.
25
+ const always = ["identity.md", "owner.md", "soul.md", "rules.md"];
26
+ const parts = always.map((f) => loadFile(selfDir, f)).filter(Boolean);
27
+
28
+ const memory = loadFile(selfDir, "memory.md");
29
+ if (memory) {
30
+ const { loaded, pointer } = splitMemory(memory);
31
+ parts.push(loaded + pointer);
32
+ }
33
+ return parts.filter(Boolean).join("\n\n");
26
34
  }
27
35
 
28
36
  export function buildSystemPrompt(mode: Mode = "chat", channel: string = "terminal"): string {
@@ -1,28 +1,38 @@
1
1
  import { getConfig, updateRawConfig } from "../utils/config";
2
2
  import { getPaths } from "../utils/paths";
3
3
  import { errMsg } from "../utils/errors";
4
- import { fail, ICON_PASS, ICON_FAIL } from "../utils/cli";
4
+ import { fail, ICON_PASS, ICON_FAIL, parseArgs } from "../utils/cli";
5
5
  import { log } from "../utils/log";
6
6
 
7
+ const SEND_USAGE = "Usage: nia send [-c channel] [--to <slack-channel-id>] [--thread <ts>] <message>";
8
+
9
+ export interface SendArgs {
10
+ channel?: string;
11
+ toChannelId?: string;
12
+ threadTs?: string;
13
+ message: string;
14
+ help: boolean;
15
+ }
16
+
17
+ /** Extracted so the parse is testable — `nia send --help` used to DM the flag. */
18
+ export function parseSendArgs(args: string[] = process.argv.slice(3)): SendArgs {
19
+ const parsed = parseArgs(args);
20
+ return {
21
+ channel: parsed.getString("channel") || parsed.getString("c"),
22
+ toChannelId: parsed.getString("to"),
23
+ threadTs: parsed.getString("thread"),
24
+ message: parsed.positional.join(" "),
25
+ help: parsed.help,
26
+ };
27
+ }
28
+
7
29
  export async function sendCommand(): Promise<void> {
8
- const args = process.argv.slice(3);
9
- let channel: string | undefined;
10
- let toChannelId: string | undefined;
11
- let threadTs: string | undefined;
12
- const msgParts: string[] = [];
13
- for (let i = 0; i < args.length; i++) {
14
- if ((args[i] === "--channel" || args[i] === "-c") && args[i + 1]) {
15
- channel = args[++i];
16
- } else if (args[i] === "--to" && args[i + 1]) {
17
- toChannelId = args[++i];
18
- } else if (args[i] === "--thread" && args[i + 1]) {
19
- threadTs = args[++i];
20
- } else {
21
- msgParts.push(args[i]);
22
- }
30
+ let { channel, toChannelId, threadTs, message, help } = parseSendArgs();
31
+ if (help) {
32
+ console.log(SEND_USAGE);
33
+ return;
23
34
  }
24
- const message = msgParts.join(" ");
25
- if (!message) fail("Usage: nia send [-c channel] [--to <slack-channel-id>] [--thread <ts>] <message>");
35
+ if (!message) fail(SEND_USAGE);
26
36
 
27
37
  // --to implies slack channel
28
38
  if (toChannelId) channel = channel || "slack";
@@ -0,0 +1,60 @@
1
+ import type { Check } from "../types/health";
2
+
3
+ /**
4
+ * Whether a reply has a delivery to confirm.
5
+ *
6
+ * Every agent reply was written as `pending` and only channel code ever cleared
7
+ * it — so `nia run`, the REPL and jobs, which print to stdout and are done,
8
+ * left rows pending forever. 23 of the 30 stuck rows on the mini were that,
9
+ * oldest from March, which made the genuinely undelivered ones invisible among
10
+ * them.
11
+ */
12
+ const DELIVERING_CHANNELS = new Set(["slack", "telegram", "sms", "whatsapp", "phone"]);
13
+
14
+ /** Channels whose replies go nowhere but the screen. Unknown names are assumed
15
+ * to deliver: a false alarm is cheaper than a message quietly lost. */
16
+ const LOCAL_CHANNELS = new Set(["terminal", "system", "test"]);
17
+
18
+ export function awaitsDelivery(channel: string): boolean {
19
+ if (DELIVERING_CHANNELS.has(channel)) return true;
20
+ return !LOCAL_CHANNELS.has(channel);
21
+ }
22
+
23
+ export function initialDeliveryStatus(channel: string): "pending" | "sent" {
24
+ return awaitsDelivery(channel) ? "pending" : "sent";
25
+ }
26
+
27
+ /** Longer than any real send. Slack/Twilio calls finish in seconds. */
28
+ export const STUCK_AFTER_MS = 10 * 60 * 1000;
29
+
30
+ export interface PendingRow {
31
+ room: string;
32
+ createdAt: string;
33
+ }
34
+
35
+ /**
36
+ * Report replies that were written but never confirmed.
37
+ *
38
+ * Deliberately reports rather than resends. The row says a send was *started*;
39
+ * it cannot say whether the channel API completed before the process died. A
40
+ * retry would double-send to a real person on every crash that happened after
41
+ * delivery — so this surfaces the problem and leaves the judgement to someone
42
+ * who can check.
43
+ */
44
+ export function auditDelivery(rows: PendingRow[], now: number = Date.now()): Check {
45
+ const stuck = rows.filter((r) => {
46
+ const t = Date.parse(r.createdAt);
47
+ return Number.isFinite(t) && now - t > STUCK_AFTER_MS;
48
+ });
49
+ if (stuck.length === 0) {
50
+ return { name: "delivery", status: "ok", detail: "no messages awaiting confirmation" };
51
+ }
52
+ const oldest = stuck.reduce((a, b) => (Date.parse(a.createdAt) <= Date.parse(b.createdAt) ? a : b));
53
+ const days = Math.floor((now - Date.parse(oldest.createdAt)) / 86_400_000);
54
+ const age = days >= 1 ? `${days}d` : `${Math.round((now - Date.parse(oldest.createdAt)) / 3_600_000)}h`;
55
+ return {
56
+ name: "delivery",
57
+ status: "warn",
58
+ detail: `${stuck.length} repl${stuck.length === 1 ? "y" : "ies"} never confirmed sent, oldest ${age} in ${oldest.room}`,
59
+ };
60
+ }
@@ -9,10 +9,11 @@ import { withRetry } from "../utils/retry";
9
9
  import { codexAvailable, codexModelSlugs } from "../agent/catalog";
10
10
  import { providerHealth } from "../agent/health";
11
11
  import { claudeAuthStatus, codexAuthStatus, type AuthStatus } from "../agent/auth";
12
+ import { auditDelivery } from "./delivery";
12
13
  import { IMPLEMENTED, describeRef, planChain, resolveModel, type ModelRef } from "../agent/models";
13
14
 
14
- export type CheckStatus = "ok" | "warn" | "fail";
15
- export type Check = { name: string; status: CheckStatus; detail: string };
15
+ export type { Check, CheckStatus } from "../types/health";
16
+ import type { Check } from "../types/health";
16
17
 
17
18
  /** Past this, the chain is not falling back — it has moved. */
18
19
  export const FAILOVER_INCIDENT_MS = 60 * 60 * 1000;
@@ -268,6 +269,15 @@ export async function runHealthChecks(): Promise<Check[]> {
268
269
  const primary = auth.find((a) => a.provider === plan[0]?.provider);
269
270
  checks.push(auditFailover(providerHealth.fallbackStreakMs(), providerHealth.lastServer(), primary));
270
271
 
272
+ // Replies written but never confirmed sent. 25 code paths write this column
273
+ // and, until now, nothing read it.
274
+ try {
275
+ const { Message } = await import("../db/models");
276
+ checks.push(auditDelivery(await Message.listPendingDeliveries()));
277
+ } catch (err) {
278
+ checks.push({ name: "delivery", status: "warn", detail: errMsg(err) });
279
+ }
280
+
271
281
  // API keys
272
282
  const geminiKey = config.gemini_api_key;
273
283
  const rawConfig = readRawConfig();
@@ -0,0 +1,20 @@
1
+ import type postgres from "postgres";
2
+
3
+ export const name = "018_resolve_local_pending";
4
+
5
+ /**
6
+ * Replies from `nia run`, the REPL and jobs were written as pending and never
7
+ * confirmed, because those paths print to stdout and have no delivery to
8
+ * confirm. They are not undelivered; they were delivered to a terminal.
9
+ *
10
+ * Resolving them is what makes the genuinely stuck rows visible — they were
11
+ * hidden among 23 artifacts going back to March.
12
+ */
13
+ export async function up(sql: postgres.Sql): Promise<void> {
14
+ await sql`
15
+ UPDATE messages SET delivery_status = 'sent'
16
+ WHERE is_from_agent
17
+ AND delivery_status = 'pending'
18
+ AND (room = 'terminal' OR room LIKE 'cli-run%' OR room LIKE '_system/%' OR room LIKE 'debug-%' OR room LIKE '%-debug-%')
19
+ `;
20
+ }
@@ -154,3 +154,14 @@ export async function getRoomStats(): Promise<RoomStats[]> {
154
154
  lastActivity: r.last_activity ? String(r.last_activity) : null,
155
155
  }));
156
156
  }
157
+
158
+ /** Replies still marked pending — written, never confirmed sent. */
159
+ export async function listPendingDeliveries(limit = 200): Promise<{ room: string; createdAt: string }[]> {
160
+ const sql = getSql();
161
+ const rows = await sql`
162
+ SELECT room, created_at FROM messages
163
+ WHERE is_from_agent AND delivery_status = 'pending'
164
+ ORDER BY created_at ASC LIMIT ${limit}
165
+ `;
166
+ return rows.map((r) => ({ room: String(r.room), createdAt: String(r.created_at) }));
167
+ }
@@ -4,17 +4,23 @@ import { join } from "path";
4
4
  import { getPaths } from "../../utils/paths";
5
5
  import { scanAgents } from "../../core/agents";
6
6
  import { listEmployeesForMcp } from "../../core/employees";
7
- import { readMemory as readMemoryUtil, addMemory as addMemoryUtil } from "../../utils/memory";
7
+ import { readMemory as readMemoryUtil, addMemory as addMemoryUtil, searchMemory as searchMemoryUtil } from "../../utils/memory";
8
+ import { findSecret } from "../../utils/secrets";
8
9
 
9
10
  export function addRule(rule: string): string {
11
+ const trimmed = rule.trim();
12
+ if (!trimmed) return "Rejected: empty rule.";
13
+ const secret = findSecret(trimmed);
14
+ if (secret) return `Rejected: looks like a ${secret}. Rules load into every session's prompt — put credentials in config.yaml.`;
10
15
  const { selfDir } = getPaths();
11
16
  const rulesPath = join(selfDir, "rules.md");
12
- const line = `\n- ${rule}\n`;
17
+ const line = `\n- ${trimmed}\n`;
13
18
  appendFileSync(rulesPath, line, "utf8");
14
19
  return `Rule added to rules.md. Takes effect on next new session.`;
15
20
  }
16
21
 
17
22
  export const readMemory = readMemoryUtil;
23
+ export const searchMemory = searchMemoryUtil;
18
24
  export const addMemory = addMemoryUtil;
19
25
 
20
26
  export function listAgents(): string {
@@ -210,6 +210,16 @@ export const NIA_TOOLS: NiaTool[] = [
210
210
  schema: { rule: z.string().describe("The rule to add (e.g. 'stamp updates: 1-2 lines max, no preamble')") },
211
211
  handler: (args) => handlers.addRule(args.rule),
212
212
  },
213
+ {
214
+ name: "search_memory",
215
+ description:
216
+ "Search all durable memory, including older entries that are not in your prompt. Only the newest and the curated sections are loaded each session, so check here before concluding something was never recorded.",
217
+ schema: {
218
+ query: z.string().describe("Text to look for in saved memories"),
219
+ limit: z.number().optional().describe("Max matches to return (default 20)"),
220
+ },
221
+ handler: (args) => handlers.searchMemory(args.query, args.limit),
222
+ },
213
223
  {
214
224
  name: "read_memory",
215
225
  description:
@@ -0,0 +1,9 @@
1
+ export type CheckStatus = "ok" | "warn" | "fail";
2
+
3
+ /** One health finding. Lives here rather than beside the checks so a module can
4
+ * return a Check without importing the aggregator that calls it. */
5
+ export interface Check {
6
+ name: string;
7
+ status: CheckStatus;
8
+ detail: string;
9
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Load the memory that earns its place in every prompt, and make the rest
3
+ * reachable.
4
+ *
5
+ * `memory.md` reached 37.7 KB and, with `rules.md`, 54% of the chat system
6
+ * prompt — paid on every turn, on every channel, forever. But the file is not
7
+ * uniform: hand-organised topical sections (`## Personal`, `## Nia
8
+ * Architecture`) are curated and durable, while `## Promoted <date>` sections
9
+ * are an append log where recency is the best available proxy for relevance.
10
+ *
11
+ * So the topical sections always load, the newest dated ones load until a byte
12
+ * budget is spent, and everything else stays one `search_memory` call away.
13
+ * Rules are deliberately untouched: rules are verbs and an unloaded rule is
14
+ * simply not followed, where memory is nouns and can be fetched on demand.
15
+ */
16
+
17
+ /** Roughly a third of what memory.md had grown to. */
18
+ export const MEMORY_BUDGET_BYTES = 12_000;
19
+
20
+ const DATED = /\d{4}-\d{2}-\d{2}/;
21
+
22
+ interface Section {
23
+ heading: string;
24
+ body: string;
25
+ dated: boolean;
26
+ }
27
+
28
+ function parseSections(text: string): { preamble: string; sections: Section[] } {
29
+ const lines = text.split("\n");
30
+ const preamble: string[] = [];
31
+ const sections: Section[] = [];
32
+ let current: Section | null = null;
33
+
34
+ for (const line of lines) {
35
+ const m = /^##\s+(.*)$/.exec(line);
36
+ if (m) {
37
+ if (current) sections.push(current);
38
+ current = { heading: m[1]!.trim(), body: "", dated: DATED.test(m[1]!) };
39
+ continue;
40
+ }
41
+ if (current) current.body += line + "\n";
42
+ else preamble.push(line);
43
+ }
44
+ if (current) sections.push(current);
45
+ return { preamble: preamble.join("\n").trimEnd(), sections };
46
+ }
47
+
48
+ const render = (s: Section) => `## ${s.heading}\n${s.body}`.trimEnd();
49
+
50
+ export interface MemorySplit {
51
+ /** What goes into the system prompt. */
52
+ loaded: string;
53
+ /** What was held back. */
54
+ deferred: string;
55
+ deferredSections: number;
56
+ /** A line telling the model the rest exists. Empty when nothing was held. */
57
+ pointer: string;
58
+ }
59
+
60
+ export function splitMemory(text: string, budgetBytes: number = MEMORY_BUDGET_BYTES): MemorySplit {
61
+ const { preamble, sections } = parseSections(text);
62
+ const topical = sections.filter((s) => !s.dated);
63
+ const datedNewestFirst = sections.filter((s) => s.dated).reverse();
64
+
65
+ const head = [preamble, ...topical.map(render)].filter(Boolean).join("\n\n");
66
+ let used = head.length;
67
+ const keep: Section[] = [];
68
+ for (const s of datedNewestFirst) {
69
+ const cost = render(s).length + 2;
70
+ if (used + cost > budgetBytes && keep.length > 0) break;
71
+ keep.push(s);
72
+ used += cost;
73
+ }
74
+
75
+ const keptSet = new Set(keep);
76
+ const held = sections.filter((s) => s.dated && !keptSet.has(s));
77
+ // Restore document order for whatever is loaded.
78
+ const loadedDated = sections.filter((s) => keptSet.has(s));
79
+
80
+ const loaded = [head, ...loadedDated.map(render)].filter(Boolean).join("\n\n");
81
+ const deferred = held.map(render).join("\n\n");
82
+ const entries = held.reduce((n, s) => n + (s.body.match(/^- /gm)?.length ?? 0), 0);
83
+
84
+ return {
85
+ loaded,
86
+ deferred,
87
+ deferredSections: held.length,
88
+ pointer: held.length
89
+ ? `\n\n_(${entries} older memories from ${held.length} earlier dates are not shown. Use the \`search_memory\` tool to look them up before assuming something is not recorded.)_`
90
+ : "",
91
+ };
92
+ }
93
+
94
+ export interface MemoryHit {
95
+ section: string;
96
+ entry: string;
97
+ }
98
+
99
+ /** Plain substring search. At this size an exact scan beats an index that has
100
+ * to be kept in sync, and it cannot hallucinate a near-match. */
101
+ export function searchMemoryText(text: string, query: string, limit = 20): MemoryHit[] {
102
+ const needle = query.trim().toLowerCase();
103
+ if (!needle) return [];
104
+ const { preamble, sections } = parseSections(text);
105
+ const scan: { section: string; body: string }[] = [
106
+ { section: "(top)", body: preamble },
107
+ ...sections.map((s) => ({ section: s.heading, body: s.body })),
108
+ ];
109
+
110
+ const hits: MemoryHit[] = [];
111
+ for (const { section, body } of scan) {
112
+ for (const line of body.split("\n")) {
113
+ if (!line.trim().startsWith("- ")) continue;
114
+ if (line.toLowerCase().includes(needle)) {
115
+ hits.push({ section, entry: line.trim() });
116
+ if (hits.length >= limit) return hits;
117
+ }
118
+ }
119
+ }
120
+ return hits;
121
+ }
@@ -8,6 +8,8 @@
8
8
  import { existsSync, readFileSync, appendFileSync, writeFileSync } from "fs";
9
9
  import { join } from "path";
10
10
  import { getPaths } from "./paths";
11
+ import { findSecret } from "./secrets";
12
+ import { searchMemoryText } from "./memory-window";
11
13
 
12
14
  export function readMemory(): string {
13
15
  const { selfDir } = getPaths();
@@ -31,6 +33,8 @@ export function addMemory(entry: string): string {
31
33
  if (trimmed.includes("[Thread context]") || trimmed.includes("[Current messag"))
32
34
  return "Rejected: no raw conversation transcripts.";
33
35
  if (trimmed.split("\n").length > 5) return "Rejected: too many lines. One concise insight per memory.";
36
+ const secret = findSecret(trimmed);
37
+ if (secret) return `Rejected: looks like a ${secret}. Durable memory loads into every session's prompt — put credentials in config.yaml.`;
34
38
 
35
39
  const { selfDir } = getPaths();
36
40
  const memoryPath = join(selfDir, "memory.md");
@@ -47,3 +51,13 @@ export function addMemory(entry: string): string {
47
51
  }
48
52
  return `Memory saved.`;
49
53
  }
54
+
55
+ /** Search all durable memory, including entries not loaded into this prompt. */
56
+ export function searchMemory(query: string, limit = 20): string {
57
+ const { selfDir } = getPaths();
58
+ const path = join(selfDir, "memory.md");
59
+ if (!existsSync(path)) return "No memory file yet.";
60
+ const hits = searchMemoryText(readFileSync(path, "utf8"), query, limit);
61
+ if (hits.length === 0) return `No memory matches "${query}".`;
62
+ return hits.map((h) => `[${h.section}] ${h.entry}`).join("\n");
63
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Credential detection for anything about to be written into durable memory.
3
+ *
4
+ * `memory.md` and `rules.md` load into every session's system prompt, so a key
5
+ * that lands there has permanent, maximum-exposure blast radius — it is handed
6
+ * to every model, on every backend, on every turn, until someone notices and
7
+ * edits the file. `addMemory()` checked length and line count; `addRule()`
8
+ * validated nothing at all and appended straight to disk.
9
+ *
10
+ * Patterns are anchored on the issuer's own format rather than on words like
11
+ * "key" or "token", so discussing credentials stays possible and pasting one
12
+ * does not.
13
+ */
14
+
15
+ export interface SecretPattern {
16
+ name: string;
17
+ pattern: RegExp;
18
+ }
19
+
20
+ export const SECRET_PATTERNS: SecretPattern[] = [
21
+ { name: "Anthropic API key", pattern: /\bsk-ant-api\d{2}-[A-Za-z0-9_-]{20,}/ },
22
+ { name: "Claude OAuth token", pattern: /\bsk-ant-oat\d{2}-[A-Za-z0-9_-]{20,}/ },
23
+ { name: "OpenAI key", pattern: /\bsk-(?:proj|svcacct|admin)?-?[A-Za-z0-9_-]{32,}/ },
24
+ { name: "Slack bot/user token", pattern: /\bxox[abpsr]-[A-Za-z0-9-]{10,}/ },
25
+ { name: "Slack app token", pattern: /\bxapp-\d-[A-Za-z0-9-]{10,}/ },
26
+ { name: "GitHub token", pattern: /\bgh[pousr]_[A-Za-z0-9]{20,}/ },
27
+ { name: "AWS access key id", pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/ },
28
+ { name: "database URL with password", pattern: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:[^\s@/]+@/i },
29
+ { name: "bearer credential", pattern: /\bBearer\s+[A-Za-z0-9._~+/-]{20,}/ },
30
+ { name: "private key block", pattern: /-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----/ },
31
+ { name: "Google API key", pattern: /\bAIza[A-Za-z0-9_-]{30,}/ },
32
+ { name: "Twilio auth", pattern: /\bSK[a-f0-9]{32}\b/ },
33
+ ];
34
+
35
+ /** The name of the first credential found, or null. */
36
+ export function findSecret(text: string): string | null {
37
+ for (const { name, pattern } of SECRET_PATTERNS) {
38
+ if (pattern.test(text)) return name;
39
+ }
40
+ return null;
41
+ }
@@ -1,61 +0,0 @@
1
- /**
2
- * A watch turn answers one question: say something, or stay quiet.
3
- *
4
- * That answer used to travel as a `[NO_REPLY]` sentinel inside prose, which
5
- * means parsing a decision out of an answer that was free to phrase it any way
6
- * it liked. It mostly worked — and 47 times it produced the sentinel *and*
7
- * content, where the code has to guess which the model meant.
8
- *
9
- * A schema removes the guess. The sentinel path stays as the fallback, because
10
- * a backend can decline to produce structured output and a watch that stops
11
- * deciding is worse than one that decides the old way.
12
- */
13
-
14
- export const WATCH_JUDGEMENT_SCHEMA: Record<string, unknown> = {
15
- type: "object",
16
- properties: {
17
- reply: {
18
- type: ["string", "null"],
19
- description: "The message to post in the channel, or null to stay silent. Most turns are null.",
20
- },
21
- },
22
- required: ["reply"],
23
- additionalProperties: false,
24
- };
25
-
26
- export interface WatchDecision {
27
- /** Whether to post at all. */
28
- send: boolean;
29
- /** What to post. Empty when staying quiet. */
30
- text: string;
31
- /** Which path decided, so a drop in schema coverage is visible in the log. */
32
- source: "structured" | "sentinel";
33
- /** Set only on the sentinel path, when the model emitted both a sentinel and content. */
34
- ambiguous?: boolean;
35
- }
36
-
37
- /** Strip markdown fencing so a sentinel matches even when the model wraps it.
38
- * Underscores are left alone — the sentinel contains one. */
39
- export function cleanSentinel(text: string): string {
40
- return text.replace(/[`*]/g, "").trim();
41
- }
42
-
43
- /**
44
- * `structured` wins when it carries the `reply` key the schema requires —
45
- * including an explicit null, which is a decision, not an absence.
46
- */
47
- export function decideWatchReply(structured: unknown, raw: string): WatchDecision {
48
- if (structured && typeof structured === "object" && "reply" in structured) {
49
- const reply = (structured as { reply: unknown }).reply;
50
- const text = typeof reply === "string" ? reply.trim() : "";
51
- return { send: text.length > 0, text, source: "structured" };
52
- }
53
-
54
- const trimmed = raw.trim();
55
- const cleaned = cleanSentinel(trimmed);
56
- if (!trimmed || cleaned.includes("[NO_REPLY]")) {
57
- const exact = !trimmed || cleaned === "[NO_REPLY]";
58
- return { send: false, text: "", source: "sentinel", ambiguous: !exact };
59
- }
60
- return { send: true, text: trimmed, source: "sentinel" };
61
- }