niahere 0.5.8 → 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.8",
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": {
@@ -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 {
@@ -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
+ }