niahere 0.2.42 → 0.2.44
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/channels/slack.ts +19 -14
- package/src/channels/telegram.ts +12 -7
- package/src/chat/engine.ts +57 -7
- package/src/chat/identity.ts +1 -1
- package/src/cli/index.ts +15 -0
- package/src/commands/backup.ts +146 -0
- package/src/db/migrations/008_message_delivery_status.ts +10 -0
- package/src/db/models/message.ts +37 -4
- package/src/prompts/environment.md +96 -25
- package/src/types/engine.ts +2 -0
package/package.json
CHANGED
package/src/channels/slack.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { Channel, ChatState, Attachment, AttachmentType } from "../types";
|
|
|
7
7
|
import { getConfig, updateRawConfig, resetConfig } from "../utils/config";
|
|
8
8
|
import { relativeTime } from "../utils/format";
|
|
9
9
|
import { runMigrations } from "../db/migrate";
|
|
10
|
-
import { Session } from "../db/models";
|
|
10
|
+
import { Session, Message } from "../db/models";
|
|
11
11
|
import { log } from "../utils/log";
|
|
12
12
|
import { getMcpServers } from "../mcp";
|
|
13
13
|
import { getNiaHome, getPaths } from "../utils/paths";
|
|
@@ -493,7 +493,7 @@ class SlackChannel implements Channel {
|
|
|
493
493
|
.catch((err) => log.debug({ err, channel: msg.channel }, "slack: failed to add thinking reaction"));
|
|
494
494
|
|
|
495
495
|
try {
|
|
496
|
-
const { result } = await state.engine.send(text, {
|
|
496
|
+
const { result, messageId } = await state.engine.send(text, {
|
|
497
497
|
onActivity(status) {
|
|
498
498
|
log.debug({ status }, "slack engine activity");
|
|
499
499
|
},
|
|
@@ -504,22 +504,27 @@ class SlackChannel implements Channel {
|
|
|
504
504
|
// [NO_REPLY] or empty = agent chose not to respond (thread judgement)
|
|
505
505
|
if (!reply || cleanSentinel(reply) === "[NO_REPLY]") {
|
|
506
506
|
log.info({ channel: msg.channel, key }, "slack: agent chose not to reply");
|
|
507
|
+
if (messageId) await Message.updateDeliveryStatus(messageId, "sent").catch(() => {});
|
|
507
508
|
return;
|
|
508
509
|
}
|
|
509
510
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
511
|
+
try {
|
|
512
|
+
if (replyThreadTs) {
|
|
513
|
+
await client.chat.postMessage({
|
|
514
|
+
channel: msg.channel,
|
|
515
|
+
text: reply,
|
|
516
|
+
thread_ts: replyThreadTs,
|
|
517
|
+
});
|
|
518
|
+
} else {
|
|
519
|
+
await say(reply);
|
|
520
|
+
}
|
|
521
|
+
if (messageId) await Message.updateDeliveryStatus(messageId, "sent").catch(() => {});
|
|
522
|
+
log.info({ channel: msg.channel, key, chars: reply.length }, "slack reply sent");
|
|
523
|
+
} catch (sendErr) {
|
|
524
|
+
if (messageId) await Message.updateDeliveryStatus(messageId, "failed").catch(() => {});
|
|
525
|
+
throw sendErr;
|
|
518
526
|
}
|
|
519
|
-
|
|
520
|
-
log.info({ channel: msg.channel, key, chars: reply.length }, "slack reply sent");
|
|
521
527
|
} catch (err) {
|
|
522
|
-
|
|
523
528
|
const errText = err instanceof Error ? err.message : String(err);
|
|
524
529
|
log.error({ err, channel: msg.channel }, "slack message processing failed");
|
|
525
530
|
|
|
@@ -530,7 +535,7 @@ class SlackChannel implements Channel {
|
|
|
530
535
|
thread_ts: replyThreadTs,
|
|
531
536
|
}).catch(() => {});
|
|
532
537
|
} else {
|
|
533
|
-
await say(`[error] ${errText}`);
|
|
538
|
+
await say(`[error] ${errText}`).catch(() => {});
|
|
534
539
|
}
|
|
535
540
|
} finally {
|
|
536
541
|
await client.reactions.remove({ channel: msg.channel, timestamp: msg.ts, name: "thinking_face" })
|
package/src/channels/telegram.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { createChatEngine } from "../chat/engine";
|
|
|
3
3
|
import type { Channel, ChatState, Attachment } from "../types";
|
|
4
4
|
import { getConfig, updateRawConfig } from "../utils/config";
|
|
5
5
|
import { runMigrations } from "../db/migrate";
|
|
6
|
-
import { Session } from "../db/models";
|
|
6
|
+
import { Session, Message } from "../db/models";
|
|
7
7
|
import { log } from "../utils/log";
|
|
8
8
|
import { getMcpServers } from "../mcp";
|
|
9
9
|
import { classifyMime, validateAttachment, prepareImage } from "../utils/attachment";
|
|
@@ -136,16 +136,21 @@ class TelegramChannel implements Channel {
|
|
|
136
136
|
bot.api.sendChatAction(chatId, "typing").catch(() => {});
|
|
137
137
|
|
|
138
138
|
try {
|
|
139
|
-
const { result } = await state.engine.send(text, {}, attachments);
|
|
139
|
+
const { result, messageId } = await state.engine.send(text, {}, attachments);
|
|
140
140
|
|
|
141
141
|
const reply = result.trim() || "(no response)";
|
|
142
142
|
try {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
143
|
+
try {
|
|
144
|
+
await bot.api.sendMessage(chatId, reply, { parse_mode: "MarkdownV2" });
|
|
145
|
+
} catch {
|
|
146
|
+
await bot.api.sendMessage(chatId, reply);
|
|
147
|
+
}
|
|
148
|
+
if (messageId) await Message.updateDeliveryStatus(messageId, "sent").catch(() => {});
|
|
149
|
+
log.info({ chatId, chars: result.length }, "telegram reply sent");
|
|
150
|
+
} catch (sendErr) {
|
|
151
|
+
if (messageId) await Message.updateDeliveryStatus(messageId, "failed").catch(() => {});
|
|
152
|
+
throw sendErr;
|
|
146
153
|
}
|
|
147
|
-
|
|
148
|
-
log.info({ chatId, chars: result.length }, "telegram reply sent");
|
|
149
154
|
} catch (err) {
|
|
150
155
|
const errText = err instanceof Error ? err.message : String(err);
|
|
151
156
|
log.error({ err, chatId }, "telegram message processing failed");
|
package/src/chat/engine.ts
CHANGED
|
@@ -10,8 +10,10 @@ import { getAgentDefinitions } from "../core/agents";
|
|
|
10
10
|
import { Session, Message, ActiveEngine } from "../db/models";
|
|
11
11
|
import type { Attachment, SendResult, StreamCallback, ActivityCallback, SendCallbacks, ChatEngine, EngineOptions } from "../types";
|
|
12
12
|
import { truncate, formatToolUse } from "../utils/format-activity";
|
|
13
|
+
import { log } from "../utils/log";
|
|
13
14
|
|
|
14
15
|
const IDLE_TIMEOUT = 10 * 60 * 1000; // 10 minutes
|
|
16
|
+
const LONG_RUNNING_WARN = 30 * 60 * 1000; // 30 minutes
|
|
15
17
|
|
|
16
18
|
interface SDKUserMessage {
|
|
17
19
|
type: "user";
|
|
@@ -128,20 +130,50 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
128
130
|
let queryHandle: Query | null = null;
|
|
129
131
|
let pending: PendingResult | null = null;
|
|
130
132
|
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
133
|
+
let longRunningTimer: ReturnType<typeof setTimeout> | null = null;
|
|
134
|
+
let longRunningWarned = false;
|
|
131
135
|
let alive = false;
|
|
132
136
|
|
|
137
|
+
function clearIdleTimer() {
|
|
138
|
+
if (idleTimer) {
|
|
139
|
+
clearTimeout(idleTimer);
|
|
140
|
+
idleTimer = null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
133
144
|
function resetIdleTimer() {
|
|
134
|
-
|
|
145
|
+
clearIdleTimer();
|
|
135
146
|
idleTimer = setTimeout(() => {
|
|
147
|
+
if (pending) {
|
|
148
|
+
// Don't tear down while a request is in flight
|
|
149
|
+
log.warn({ room }, "idle timer fired while request pending, skipping teardown");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
136
152
|
teardown();
|
|
137
153
|
}, IDLE_TIMEOUT);
|
|
138
154
|
}
|
|
139
155
|
|
|
140
|
-
function
|
|
141
|
-
if (
|
|
142
|
-
clearTimeout(
|
|
143
|
-
|
|
156
|
+
function clearLongRunningTimer() {
|
|
157
|
+
if (longRunningTimer) {
|
|
158
|
+
clearTimeout(longRunningTimer);
|
|
159
|
+
longRunningTimer = null;
|
|
144
160
|
}
|
|
161
|
+
longRunningWarned = false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function startLongRunningTimer() {
|
|
165
|
+
clearLongRunningTimer();
|
|
166
|
+
longRunningTimer = setTimeout(() => {
|
|
167
|
+
if (pending) {
|
|
168
|
+
longRunningWarned = true;
|
|
169
|
+
log.warn({ room, elapsed: LONG_RUNNING_WARN / 1000 }, "engine request running for 30+ minutes");
|
|
170
|
+
}
|
|
171
|
+
}, LONG_RUNNING_WARN);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function teardown() {
|
|
175
|
+
clearIdleTimer();
|
|
176
|
+
clearLongRunningTimer();
|
|
145
177
|
if (stream) {
|
|
146
178
|
stream.end();
|
|
147
179
|
stream = null;
|
|
@@ -285,31 +317,44 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
285
317
|
const costUsd = (message as any).total_cost_usd as number;
|
|
286
318
|
const turns = (message as any).num_turns as number;
|
|
287
319
|
|
|
320
|
+
let messageId: number | undefined;
|
|
288
321
|
if (sessionId && resultText) {
|
|
289
|
-
await Message.save({
|
|
322
|
+
messageId = await Message.save({
|
|
290
323
|
sessionId,
|
|
291
324
|
room,
|
|
292
325
|
sender: "nia",
|
|
293
326
|
content: resultText,
|
|
294
327
|
isFromAgent: true,
|
|
328
|
+
deliveryStatus: "pending",
|
|
295
329
|
});
|
|
296
330
|
await Session.touch(sessionId);
|
|
297
331
|
}
|
|
298
332
|
|
|
299
333
|
await ActiveEngine.unregister(room);
|
|
300
|
-
|
|
334
|
+
clearLongRunningTimer();
|
|
335
|
+
pending.resolve({ result: resultText, costUsd, turns, messageId });
|
|
301
336
|
pending = null;
|
|
302
337
|
resetIdleTimer();
|
|
303
338
|
} else {
|
|
304
339
|
const errors = (message as any).errors;
|
|
305
340
|
const errorText = `[error] ${errors?.join(", ") || "unknown error"}`;
|
|
306
341
|
await ActiveEngine.unregister(room);
|
|
342
|
+
clearLongRunningTimer();
|
|
307
343
|
pending.resolve({ result: errorText, costUsd: 0, turns: 0 });
|
|
308
344
|
pending = null;
|
|
309
345
|
resetIdleTimer();
|
|
310
346
|
}
|
|
311
347
|
}
|
|
312
348
|
}
|
|
349
|
+
|
|
350
|
+
// Stream ended without a result — subprocess exited or was killed
|
|
351
|
+
if (pending) {
|
|
352
|
+
const partial = pending.accumulatedText;
|
|
353
|
+
log.error({ room, partialChars: partial.length }, "query stream ended without result, rejecting pending request");
|
|
354
|
+
await ActiveEngine.unregister(room).catch(() => {});
|
|
355
|
+
pending.reject(new Error(`stream ended without result (${partial.length} chars accumulated)`));
|
|
356
|
+
pending = null;
|
|
357
|
+
}
|
|
313
358
|
} catch (err) {
|
|
314
359
|
if (pending) {
|
|
315
360
|
await ActiveEngine.unregister(room).catch(() => {});
|
|
@@ -317,6 +362,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
317
362
|
pending = null;
|
|
318
363
|
}
|
|
319
364
|
} finally {
|
|
365
|
+
clearLongRunningTimer();
|
|
320
366
|
alive = false;
|
|
321
367
|
stream = null;
|
|
322
368
|
queryHandle = null;
|
|
@@ -334,6 +380,10 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
334
380
|
},
|
|
335
381
|
|
|
336
382
|
async send(userMessage: string, callbacks?: SendCallbacks, attachments?: Attachment[]) {
|
|
383
|
+
// Clear idle timer — engine is not idle while processing a request
|
|
384
|
+
clearIdleTimer();
|
|
385
|
+
startLongRunningTimer();
|
|
386
|
+
|
|
337
387
|
await ActiveEngine.register(room, channel);
|
|
338
388
|
|
|
339
389
|
if (!alive || !stream) {
|
package/src/chat/identity.ts
CHANGED
|
@@ -17,7 +17,7 @@ function loadFile(dir: string, name: string): string {
|
|
|
17
17
|
|
|
18
18
|
export function loadIdentity(): string {
|
|
19
19
|
const { selfDir } = getPaths();
|
|
20
|
-
const files = ["identity.md", "owner.md", "soul.md", "rules.md"];
|
|
20
|
+
const files = ["identity.md", "owner.md", "soul.md", "rules.md", "memory.md"];
|
|
21
21
|
return files.map((f) => loadFile(selfDir, f)).filter(Boolean).join("\n\n");
|
|
22
22
|
}
|
|
23
23
|
|
package/src/cli/index.ts
CHANGED
|
@@ -433,6 +433,12 @@ switch (command) {
|
|
|
433
433
|
process.exit(exitCode);
|
|
434
434
|
}
|
|
435
435
|
|
|
436
|
+
case "backup": {
|
|
437
|
+
const { backupCommand } = await import("../commands/backup");
|
|
438
|
+
await backupCommand();
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
|
|
436
442
|
case "validate": {
|
|
437
443
|
const { validateConfig } = await import("../commands/validate");
|
|
438
444
|
const result = validateConfig();
|
|
@@ -444,6 +450,15 @@ switch (command) {
|
|
|
444
450
|
case "update": {
|
|
445
451
|
const { version: currentVersion } = await import("../../package.json");
|
|
446
452
|
console.log(`Current: v${currentVersion}`);
|
|
453
|
+
// Auto-backup before update
|
|
454
|
+
try {
|
|
455
|
+
const { createBackup } = await import("../commands/backup");
|
|
456
|
+
console.log("Backing up...");
|
|
457
|
+
await createBackup(true);
|
|
458
|
+
console.log("✓ pre-update backup created");
|
|
459
|
+
} catch (err) {
|
|
460
|
+
console.log(`⚠ backup skipped: ${errMsg(err)}`);
|
|
461
|
+
}
|
|
447
462
|
console.log("Updating...");
|
|
448
463
|
const install = Bun.spawn(["npm", "i", "-g", "niahere@latest"], { stdio: ["ignore", "inherit", "inherit"] });
|
|
449
464
|
const installExit = await install.exited;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "fs";
|
|
2
|
+
import { join, basename } from "path";
|
|
3
|
+
import { getNiaHome } from "../utils/paths";
|
|
4
|
+
import { getConfig } from "../utils/config";
|
|
5
|
+
|
|
6
|
+
const MAX_BACKUPS = 10;
|
|
7
|
+
|
|
8
|
+
function getBackupDir(): string {
|
|
9
|
+
const dir = join(getNiaHome(), "backups");
|
|
10
|
+
mkdirSync(dir, { recursive: true });
|
|
11
|
+
return dir;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function humanDate(): string {
|
|
15
|
+
const now = new Date();
|
|
16
|
+
const y = now.getFullYear();
|
|
17
|
+
const m = String(now.getMonth() + 1).padStart(2, "0");
|
|
18
|
+
const d = String(now.getDate()).padStart(2, "0");
|
|
19
|
+
return `${y}-${m}-${d}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function formatSize(bytes: number): string {
|
|
23
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
24
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
25
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function pruneOldBackups(dir: string): void {
|
|
29
|
+
const files = readdirSync(dir)
|
|
30
|
+
.filter((f) => f.startsWith("niahere-") && f.endsWith(".tar.gz"))
|
|
31
|
+
.map((f) => ({ name: f, mtime: statSync(join(dir, f)).mtimeMs }))
|
|
32
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
33
|
+
|
|
34
|
+
for (const file of files.slice(MAX_BACKUPS)) {
|
|
35
|
+
unlinkSync(join(dir, file.name));
|
|
36
|
+
console.log(` pruned old backup: ${file.name}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function createBackup(silent = false): Promise<string> {
|
|
41
|
+
const home = getNiaHome();
|
|
42
|
+
const backupDir = getBackupDir();
|
|
43
|
+
const filename = `niahere-${humanDate()}-${Math.floor(Date.now() / 1000)}.tar.gz`;
|
|
44
|
+
const outPath = join(backupDir, filename);
|
|
45
|
+
|
|
46
|
+
// Directories/files to include (relative to home)
|
|
47
|
+
const includes: string[] = [];
|
|
48
|
+
if (existsSync(join(home, "config.yaml"))) includes.push("config.yaml");
|
|
49
|
+
if (existsSync(join(home, "self"))) includes.push("self");
|
|
50
|
+
if (existsSync(join(home, "agents"))) includes.push("agents");
|
|
51
|
+
if (existsSync(join(home, "skills"))) includes.push("skills");
|
|
52
|
+
|
|
53
|
+
// Database dump
|
|
54
|
+
const config = getConfig();
|
|
55
|
+
const dbUrl = config.database_url;
|
|
56
|
+
let dbDumped = false;
|
|
57
|
+
if (dbUrl) {
|
|
58
|
+
const dumpPath = join(home, "tmp", "db-backup.sql");
|
|
59
|
+
mkdirSync(join(home, "tmp"), { recursive: true });
|
|
60
|
+
const pg = Bun.spawn(["pg_dump", dbUrl, "-f", dumpPath], {
|
|
61
|
+
stdout: "pipe",
|
|
62
|
+
stderr: "pipe",
|
|
63
|
+
});
|
|
64
|
+
const exitCode = await pg.exited;
|
|
65
|
+
if (exitCode === 0 && existsSync(dumpPath)) {
|
|
66
|
+
// Copy to a relative path for tar
|
|
67
|
+
const relDump = "db-backup.sql";
|
|
68
|
+
const { copyFileSync } = await import("fs");
|
|
69
|
+
copyFileSync(dumpPath, join(home, relDump));
|
|
70
|
+
includes.push(relDump);
|
|
71
|
+
dbDumped = true;
|
|
72
|
+
} else if (!silent) {
|
|
73
|
+
const stderr = await new Response(pg.stderr).text();
|
|
74
|
+
console.log(` ⚠ db dump skipped: ${stderr.trim() || `exit ${exitCode}`}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (includes.length === 0) {
|
|
79
|
+
console.log("Nothing to back up.");
|
|
80
|
+
return "";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Create tar.gz
|
|
84
|
+
const tar = Bun.spawn(["tar", "czf", outPath, ...includes], {
|
|
85
|
+
cwd: home,
|
|
86
|
+
stdout: "pipe",
|
|
87
|
+
stderr: "pipe",
|
|
88
|
+
});
|
|
89
|
+
const tarExit = await tar.exited;
|
|
90
|
+
if (tarExit !== 0) {
|
|
91
|
+
const stderr = await new Response(tar.stderr).text();
|
|
92
|
+
throw new Error(`tar failed: ${stderr.trim()}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Clean up temp db dump
|
|
96
|
+
if (dbDumped) {
|
|
97
|
+
try { unlinkSync(join(home, "db-backup.sql")); } catch {}
|
|
98
|
+
try { unlinkSync(join(home, "tmp", "db-backup.sql")); } catch {}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const size = statSync(outPath).size;
|
|
102
|
+
if (!silent) {
|
|
103
|
+
console.log(`✓ backup created: ${filename} (${formatSize(size)})`);
|
|
104
|
+
if (dbDumped) console.log(" includes: files + database");
|
|
105
|
+
else console.log(" includes: files only (no database)");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
pruneOldBackups(backupDir);
|
|
109
|
+
|
|
110
|
+
return outPath;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function listBackups(): void {
|
|
114
|
+
const dir = getBackupDir();
|
|
115
|
+
const files = readdirSync(dir)
|
|
116
|
+
.filter((f) => f.startsWith("niahere-") && f.endsWith(".tar.gz"))
|
|
117
|
+
.map((f) => {
|
|
118
|
+
const stat = statSync(join(dir, f));
|
|
119
|
+
return { name: f, size: stat.size, mtime: stat.mtimeMs };
|
|
120
|
+
})
|
|
121
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
122
|
+
|
|
123
|
+
if (files.length === 0) {
|
|
124
|
+
console.log("No backups found.");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
console.log(`${files.length} backup(s) in ${dir}:\n`);
|
|
129
|
+
for (const f of files) {
|
|
130
|
+
const date = new Date(f.mtime).toLocaleString();
|
|
131
|
+
console.log(` ${f.name} ${formatSize(f.size)} ${date}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function backupCommand(): Promise<void> {
|
|
136
|
+
const sub = process.argv[3];
|
|
137
|
+
if (sub === "list") {
|
|
138
|
+
listBackups();
|
|
139
|
+
} else if (!sub) {
|
|
140
|
+
await createBackup();
|
|
141
|
+
} else {
|
|
142
|
+
console.log("Usage:");
|
|
143
|
+
console.log(" nia backup — create a backup");
|
|
144
|
+
console.log(" nia backup list — list existing backups");
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type postgres from "postgres";
|
|
2
|
+
|
|
3
|
+
export const name = "008_message_delivery_status";
|
|
4
|
+
|
|
5
|
+
export async function up(sql: postgres.Sql): Promise<void> {
|
|
6
|
+
await sql`
|
|
7
|
+
ALTER TABLE messages
|
|
8
|
+
ADD COLUMN IF NOT EXISTS delivery_status TEXT DEFAULT 'sent'
|
|
9
|
+
`;
|
|
10
|
+
}
|
package/src/db/models/message.ts
CHANGED
|
@@ -1,12 +1,45 @@
|
|
|
1
1
|
import { getSql } from "../connection";
|
|
2
2
|
import type { SaveMessageParams, RoomStats, RecentMessage, SearchResult, SessionMessage } from "../../types";
|
|
3
3
|
|
|
4
|
-
export
|
|
4
|
+
export type DeliveryStatus = "pending" | "sent" | "failed";
|
|
5
|
+
|
|
6
|
+
export async function save(params: SaveMessageParams & { deliveryStatus?: DeliveryStatus }): Promise<number> {
|
|
5
7
|
const sql = getSql();
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
const status = params.deliveryStatus || "sent";
|
|
9
|
+
const rows = await sql`
|
|
10
|
+
INSERT INTO messages (session_id, room, sender, content, is_from_agent, delivery_status)
|
|
11
|
+
VALUES (${params.sessionId}, ${params.room}, ${params.sender}, ${params.content}, ${params.isFromAgent}, ${status})
|
|
12
|
+
RETURNING id
|
|
9
13
|
`;
|
|
14
|
+
return rows[0].id;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function updateDeliveryStatus(id: number, status: DeliveryStatus): Promise<void> {
|
|
18
|
+
const sql = getSql();
|
|
19
|
+
await sql`UPDATE messages SET delivery_status = ${status} WHERE id = ${id}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function getUndelivered(room?: string): Promise<Array<{ id: number; room: string; content: string; createdAt: string }>> {
|
|
23
|
+
const sql = getSql();
|
|
24
|
+
const rows = room
|
|
25
|
+
? await sql`
|
|
26
|
+
SELECT id, room, content, created_at
|
|
27
|
+
FROM messages
|
|
28
|
+
WHERE delivery_status = 'failed' AND is_from_agent = true AND room = ${room}
|
|
29
|
+
ORDER BY created_at ASC
|
|
30
|
+
`
|
|
31
|
+
: await sql`
|
|
32
|
+
SELECT id, room, content, created_at
|
|
33
|
+
FROM messages
|
|
34
|
+
WHERE delivery_status = 'failed' AND is_from_agent = true
|
|
35
|
+
ORDER BY created_at ASC
|
|
36
|
+
`;
|
|
37
|
+
return rows.map((r) => ({
|
|
38
|
+
id: r.id,
|
|
39
|
+
room: r.room,
|
|
40
|
+
content: r.content,
|
|
41
|
+
createdAt: String(r.created_at),
|
|
42
|
+
}));
|
|
10
43
|
}
|
|
11
44
|
|
|
12
45
|
export async function getRecent(limit = 20, room?: string): Promise<RecentMessage[]> {
|
|
@@ -92,31 +92,102 @@ Your persona files live in {{selfDir}}/:
|
|
|
92
92
|
- `identity.md` — your personality and voice
|
|
93
93
|
- `owner.md` — info about who runs you
|
|
94
94
|
- `soul.md` — how you work
|
|
95
|
-
- `rules.md` — behavioral
|
|
96
|
-
- `memory.md` —
|
|
95
|
+
- `rules.md` — behavioral instructions (loaded into every session automatically)
|
|
96
|
+
- `memory.md` — facts and context (loaded into every session automatically)
|
|
97
97
|
|
|
98
98
|
### Rules vs Memory
|
|
99
99
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
**
|
|
111
|
-
-
|
|
112
|
-
-
|
|
113
|
-
|
|
114
|
-
###
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
100
|
+
The difference is simple: **rules are instructions, memories are facts.**
|
|
101
|
+
|
|
102
|
+
**Rules** = verbs. They change your behavior. They tell you to do or not do something.
|
|
103
|
+
- Start with: do / don't / always / never / keep / avoid / when X then Y
|
|
104
|
+
- Test: "If I ignore this, my response is **wrong**"
|
|
105
|
+
- Tool: `add_rule`
|
|
106
|
+
- Loaded: every session, always
|
|
107
|
+
|
|
108
|
+
**Memory** = nouns. They give you context. They tell you something is true.
|
|
109
|
+
- Start with: a name, date, or factual statement
|
|
110
|
+
- Test: "If I don't know this, my response is **uninformed** but not wrong"
|
|
111
|
+
- Tool: `add_memory`
|
|
112
|
+
- Loaded: every session, always
|
|
113
|
+
|
|
114
|
+
### The decision flowchart
|
|
115
|
+
|
|
116
|
+
Ask yourself one question: **"Is this telling me HOW to act, or WHAT is true?"**
|
|
117
|
+
|
|
118
|
+
| Signal | → | Where |
|
|
119
|
+
|--------|---|-------|
|
|
120
|
+
| "From now on..." / "Always..." / "Never..." / "Stop doing..." | → | **Rule** |
|
|
121
|
+
| "I prefer..." / "I like when you..." / "Do it like this..." | → | **Rule** (it's a behavioral preference = instruction) |
|
|
122
|
+
| "I'm traveling to Delhi on the 21st" | → | **Memory** |
|
|
123
|
+
| "We use Postgres, not MySQL" / "The deploy is on Friday" | → | **Memory** |
|
|
124
|
+
| "Last time X broke because of Y" | → | **Memory** (fact about past) |
|
|
125
|
+
| "Don't do X again, it broke last time" | → | **Rule** (instruction) + **Memory** (the incident) |
|
|
126
|
+
| User corrects your formatting/tone/length | → | **Rule** (you need to change behavior) |
|
|
127
|
+
| User mentions a person, project, deadline | → | **Memory** |
|
|
128
|
+
|
|
129
|
+
### Good vs bad entries
|
|
130
|
+
|
|
131
|
+
**Good rules** — specific, actionable, earns its token cost every session:
|
|
132
|
+
- "Stamp/standup job output: 1-2 lines max, no preamble"
|
|
133
|
+
- "In Slack channels, keep replies under 3 paragraphs"
|
|
134
|
+
- "Never send code blocks in Telegram — they render badly"
|
|
135
|
+
- "When Aman says 'ship it', commit and push without asking"
|
|
136
|
+
|
|
137
|
+
**Bad rules** — vague, redundant, or one-time:
|
|
138
|
+
- "Be helpful" (already in your identity)
|
|
139
|
+
- "Use good formatting" (too vague to act on)
|
|
140
|
+
- "Send the report to #general today" (one-time task, not a rule)
|
|
141
|
+
|
|
142
|
+
**Good memories** — dated, one fact, useful across sessions:
|
|
143
|
+
- "2026-03-21: Aman traveling to Delhi, back 2026-03-28"
|
|
144
|
+
- "Kay.ai is the main work project — ask.kay.ai is the product URL"
|
|
145
|
+
- "Aman prefers debugging via terminal, not Slack"
|
|
146
|
+
- "2026-03-13: Postgres went down, Telegram sends failed — DNS issue"
|
|
147
|
+
|
|
148
|
+
**Bad memories** — raw logs, transient state, duplicates:
|
|
149
|
+
- Pasting full error logs or stack traces
|
|
150
|
+
- "Currently working on X" (stale by next session)
|
|
151
|
+
- Anything already in rules.md or identity.md
|
|
152
|
+
|
|
153
|
+
### When to save (be proactive)
|
|
154
|
+
|
|
155
|
+
Rules and memories don't only come from the user telling you things. You should also generate them from your own reasoning, observations, and experience. **Think of yourself as learning, not just recording.**
|
|
156
|
+
|
|
157
|
+
#### From the user (explicit)
|
|
158
|
+
|
|
159
|
+
| You notice... | Save as |
|
|
160
|
+
|---------------|---------|
|
|
161
|
+
| User says "from now on" / "always" / "stop doing X" | **Rule** |
|
|
162
|
+
| User corrects your tone, format, length, or approach | **Rule** |
|
|
163
|
+
| User mentions a preference about how you communicate | **Rule** |
|
|
164
|
+
| User shares travel plans, schedule, personal facts | **Memory** |
|
|
165
|
+
| User mentions people, projects, deadlines, decisions | **Memory** |
|
|
166
|
+
| User corrects a factual misunderstanding | **Memory** |
|
|
167
|
+
| Both behavior change AND a fact behind it | **Rule** + **Memory** |
|
|
168
|
+
|
|
169
|
+
#### From your own thinking (self-generated)
|
|
170
|
+
|
|
171
|
+
You are not a passive recorder. Reflect on your own experience and save learnings:
|
|
172
|
+
|
|
173
|
+
| You realize... | Save as |
|
|
174
|
+
|----------------|---------|
|
|
175
|
+
| A tool or approach failed — you should avoid it next time | **Rule** ("Don't use X for Y — it fails because Z") |
|
|
176
|
+
| You found a better way to do something after trial and error | **Rule** ("For X, use Y approach instead of Z") |
|
|
177
|
+
| A job keeps erroring the same way — there's a pattern | **Rule** (the workaround) + **Memory** (the incident pattern) |
|
|
178
|
+
| You notice the user always ignores or rejects a certain kind of response | **Rule** (stop doing that) |
|
|
179
|
+
| You discover how a system works (API quirk, config gotcha, infra detail) | **Memory** |
|
|
180
|
+
| You learn who someone is, what team they're on, what they work on | **Memory** |
|
|
181
|
+
| You notice a pattern in when/how the user communicates | **Memory** |
|
|
182
|
+
| A job succeeded in an unusual way worth remembering | **Memory** |
|
|
183
|
+
| You figure out the relationship between projects, services, or people | **Memory** |
|
|
184
|
+
|
|
185
|
+
**The key principle:** if you'd want to know this at the start of your next session, save it now. Don't assume future-you will figure it out again — you won't have the same context.
|
|
186
|
+
|
|
187
|
+
### Hygiene
|
|
188
|
+
|
|
189
|
+
- **Before adding:** call `read_memory` / check rules.md — don't duplicate
|
|
190
|
+
- **Update > add:** if a memory or rule already covers the topic, update it instead
|
|
191
|
+
- **Date memories:** always include the date so stale entries are obvious
|
|
192
|
+
- **Remove stale entries:** travel plans that passed, deadlines that shipped, incidents that are resolved
|
|
193
|
+
- **Keep rules lean:** every rule costs tokens in every session — max ~20 rules, each must earn its place
|
package/src/types/engine.ts
CHANGED