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 +1 -1
- package/src/agent/backends/claude-normalize.ts +10 -0
- package/src/agent/backends/claude.ts +1 -1
- package/src/channels/common/reply.ts +79 -0
- package/src/channels/slack.ts +3 -2
- package/src/channels/sms.ts +9 -2
- package/src/channels/telegram.ts +7 -1
- package/src/channels/whatsapp.ts +7 -1
- package/src/chat/engine.ts +2 -1
- package/src/chat/identity.ts +13 -5
- package/src/cli/channels.ts +28 -18
- package/src/core/delivery.ts +60 -0
- package/src/core/health.ts +12 -2
- package/src/db/migrations/018_resolve_local_pending.ts +20 -0
- package/src/db/models/message.ts +11 -0
- package/src/mcp/tools/misc.ts +8 -2
- package/src/mcp/tools/table.ts +10 -0
- package/src/types/health.ts +9 -0
- package/src/utils/memory-window.ts +121 -0
- package/src/utils/memory.ts +14 -0
- package/src/utils/secrets.ts +41 -0
- package/src/channels/common/watch-judgement.ts +0 -61
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/src/channels/slack.ts
CHANGED
|
@@ -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/
|
|
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
|
-
|
|
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}`);
|
package/src/channels/sms.ts
CHANGED
|
@@ -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()
|
|
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");
|
package/src/channels/telegram.ts
CHANGED
|
@@ -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()
|
|
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");
|
package/src/channels/whatsapp.ts
CHANGED
|
@@ -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()
|
|
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");
|
package/src/chat/engine.ts
CHANGED
|
@@ -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:
|
|
341
|
+
deliveryStatus: initialDeliveryStatus(channel),
|
|
341
342
|
metadata: ev.metadata,
|
|
342
343
|
};
|
|
343
344
|
try {
|
package/src/chat/identity.ts
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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 {
|
package/src/cli/channels.ts
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/core/health.ts
CHANGED
|
@@ -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
|
|
15
|
-
|
|
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
|
+
}
|
package/src/db/models/message.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/mcp/tools/misc.ts
CHANGED
|
@@ -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- ${
|
|
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 {
|
package/src/mcp/tools/table.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/utils/memory.ts
CHANGED
|
@@ -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
|
-
}
|