niahere 0.2.44 → 0.2.45
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/chat/engine.ts +16 -1
- package/src/core/consolidator.ts +138 -0
- package/src/core/runner.ts +7 -0
package/package.json
CHANGED
package/src/chat/engine.ts
CHANGED
|
@@ -10,6 +10,7 @@ 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 { consolidateSession } from "../core/consolidator";
|
|
13
14
|
import { log } from "../utils/log";
|
|
14
15
|
|
|
15
16
|
const IDLE_TIMEOUT = 10 * 60 * 1000; // 10 minutes
|
|
@@ -133,6 +134,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
133
134
|
let longRunningTimer: ReturnType<typeof setTimeout> | null = null;
|
|
134
135
|
let longRunningWarned = false;
|
|
135
136
|
let alive = false;
|
|
137
|
+
let messageCount = 0;
|
|
136
138
|
|
|
137
139
|
function clearIdleTimer() {
|
|
138
140
|
if (idleTimer) {
|
|
@@ -143,12 +145,18 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
143
145
|
|
|
144
146
|
function resetIdleTimer() {
|
|
145
147
|
clearIdleTimer();
|
|
146
|
-
idleTimer = setTimeout(() => {
|
|
148
|
+
idleTimer = setTimeout(async () => {
|
|
147
149
|
if (pending) {
|
|
148
150
|
// Don't tear down while a request is in flight
|
|
149
151
|
log.warn({ room }, "idle timer fired while request pending, skipping teardown");
|
|
150
152
|
return;
|
|
151
153
|
}
|
|
154
|
+
// Memory consolidation — "hippocampal replay" before sleep
|
|
155
|
+
if (sessionId && messageCount > 0) {
|
|
156
|
+
consolidateSession(sessionId, room).catch((err) => {
|
|
157
|
+
log.error({ err, room }, "consolidation failed during idle teardown");
|
|
158
|
+
});
|
|
159
|
+
}
|
|
152
160
|
teardown();
|
|
153
161
|
}, IDLE_TIMEOUT);
|
|
154
162
|
}
|
|
@@ -239,6 +247,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
239
247
|
content: pending.userMessage,
|
|
240
248
|
isFromAgent: false,
|
|
241
249
|
});
|
|
250
|
+
messageCount++;
|
|
242
251
|
}
|
|
243
252
|
}
|
|
244
253
|
|
|
@@ -406,6 +415,12 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
406
415
|
},
|
|
407
416
|
|
|
408
417
|
close() {
|
|
418
|
+
// Memory consolidation on explicit close (skip if mid-turn — transcript is incomplete)
|
|
419
|
+
if (sessionId && messageCount > 0 && !pending) {
|
|
420
|
+
consolidateSession(sessionId, room).catch((err) => {
|
|
421
|
+
log.error({ err, room }, "consolidation failed during close");
|
|
422
|
+
});
|
|
423
|
+
}
|
|
409
424
|
teardown();
|
|
410
425
|
ActiveEngine.unregister(room).catch(() => {});
|
|
411
426
|
},
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory consolidator — "hippocampal replay" for Nia.
|
|
3
|
+
*
|
|
4
|
+
* After a chat session goes idle or a job completes, this module reviews
|
|
5
|
+
* what happened and saves memories worth keeping.
|
|
6
|
+
*
|
|
7
|
+
* This decouples memory formation from task execution: during a conversation,
|
|
8
|
+
* the agent focuses on the task. Afterward, a background pass extracts what's
|
|
9
|
+
* worth remembering — just like the brain consolidates memories during sleep.
|
|
10
|
+
*
|
|
11
|
+
* The consolidator uses the same agent loop as cron jobs — full Nia system
|
|
12
|
+
* prompt, full tool access, same runner. It's just a specialized job.
|
|
13
|
+
*
|
|
14
|
+
* Research basis:
|
|
15
|
+
* - LangChain: "background" memory formation avoids latency + competing optimization pressures
|
|
16
|
+
* - Mem0: LLM-driven extraction with ADD/UPDATE/NOOP decisions against existing memories
|
|
17
|
+
* - Cognitive science: hippocampal replay consolidates experiences after the fact, not during
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { Message } from "../db/models";
|
|
21
|
+
import { buildSystemPrompt } from "../chat/identity";
|
|
22
|
+
import { runJobWithClaude } from "./runner";
|
|
23
|
+
import { log } from "../utils/log";
|
|
24
|
+
import { homedir } from "os";
|
|
25
|
+
import type { SessionMessage } from "../types";
|
|
26
|
+
|
|
27
|
+
/** Track sessions already consolidated to prevent double runs. */
|
|
28
|
+
const consolidated = new Set<string>();
|
|
29
|
+
|
|
30
|
+
/** Max messages to include in transcript (most recent). Keeps prompt size bounded. */
|
|
31
|
+
const MAX_TRANSCRIPT_MESSAGES = 50;
|
|
32
|
+
|
|
33
|
+
/** Rooms to skip (placeholder sessions). */
|
|
34
|
+
function shouldSkip(room: string): boolean {
|
|
35
|
+
return room.includes("placeholder");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Format conversation transcript for the extraction prompt. Cap to recent messages. */
|
|
39
|
+
function formatTranscript(messages: SessionMessage[]): string {
|
|
40
|
+
const recent = messages.slice(-MAX_TRANSCRIPT_MESSAGES);
|
|
41
|
+
const skipped = messages.length - recent.length;
|
|
42
|
+
const prefix = skipped > 0 ? `[...${skipped} earlier messages omitted]\n\n` : "";
|
|
43
|
+
|
|
44
|
+
return prefix + recent
|
|
45
|
+
.map((m) => `[${m.sender}] (${m.createdAt}): ${m.content.slice(0, 2000)}`)
|
|
46
|
+
.join("\n\n");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Build the extraction prompt from a conversation transcript. */
|
|
50
|
+
function buildConsolidationPrompt(transcript: string, source: string): string {
|
|
51
|
+
return `Job: memory-consolidation (triggered by ${source})
|
|
52
|
+
|
|
53
|
+
You just finished a session. It has gone idle.
|
|
54
|
+
Your task: review the transcript below and save anything worth keeping for future sessions.
|
|
55
|
+
|
|
56
|
+
## Transcript
|
|
57
|
+
${transcript}
|
|
58
|
+
|
|
59
|
+
## Instructions
|
|
60
|
+
1. First, read your existing memories (read_memory tool) and rules (read rules.md) to avoid duplicates
|
|
61
|
+
2. Review the transcript for things worth persisting. Use the RIGHT tool for each:
|
|
62
|
+
|
|
63
|
+
**Use add_memory for FACTS** (nouns — things that are true):
|
|
64
|
+
- People: names, roles, orgs, relationships
|
|
65
|
+
- Decisions: what was decided or agreed on
|
|
66
|
+
- Technical facts: system details, API quirks, config gotchas
|
|
67
|
+
- Patterns: recurring issues, user behaviors, workflow tendencies
|
|
68
|
+
- Events: travel, deadlines, incidents, milestones with dates
|
|
69
|
+
|
|
70
|
+
**Use add_rule for INSTRUCTIONS** (verbs — how to behave):
|
|
71
|
+
- User corrected your tone, format, or approach
|
|
72
|
+
- User said "from now on" / "always" / "never" / "stop doing X"
|
|
73
|
+
- User expressed a preference about how you communicate or work
|
|
74
|
+
|
|
75
|
+
3. Skip anything already in existing memories or rules (no duplicates)
|
|
76
|
+
4. Skip small talk, greetings, conversational filler
|
|
77
|
+
5. Skip transient state ("currently working on X")
|
|
78
|
+
6. Quality over quantity — saving nothing is fine if the conversation was trivial
|
|
79
|
+
7. If existing memories are outdated based on new info, note what should be updated
|
|
80
|
+
|
|
81
|
+
Do NOT message the user about this. Save silently and report a brief summary of what you saved.`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Run the consolidation agent loop. */
|
|
85
|
+
async function runConsolidation(transcript: string, source: string): Promise<void> {
|
|
86
|
+
const systemPrompt = buildSystemPrompt("job");
|
|
87
|
+
const jobPrompt = buildConsolidationPrompt(transcript, source);
|
|
88
|
+
const output = await runJobWithClaude(systemPrompt, jobPrompt, homedir());
|
|
89
|
+
|
|
90
|
+
if (output.error) {
|
|
91
|
+
log.error({ source, error: output.error }, "consolidator: extraction failed");
|
|
92
|
+
} else {
|
|
93
|
+
log.info({ source, resultChars: output.agentText.length }, "consolidator: done");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Consolidate a chat session's conversation into memories.
|
|
99
|
+
* Called when a chat engine goes idle or is explicitly closed.
|
|
100
|
+
*/
|
|
101
|
+
export async function consolidateSession(sessionId: string, room: string): Promise<void> {
|
|
102
|
+
if (shouldSkip(room)) return;
|
|
103
|
+
if (consolidated.has(sessionId)) return;
|
|
104
|
+
consolidated.add(sessionId);
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const messages = await Message.getBySession(sessionId);
|
|
108
|
+
if (messages.length < 2) return;
|
|
109
|
+
|
|
110
|
+
log.info({ sessionId, room, messageCount: messages.length }, "consolidator: extracting memories from chat");
|
|
111
|
+
|
|
112
|
+
const transcript = formatTranscript(messages);
|
|
113
|
+
await runConsolidation(transcript, `chat session idle — ${room}`);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
log.error({ err, sessionId, room }, "consolidator: chat extraction failed");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Consolidate a job run's output into memories.
|
|
121
|
+
* Called after a job completes in the runner.
|
|
122
|
+
*/
|
|
123
|
+
export async function consolidateJobRun(jobName: string, jobPrompt: string, result: string): Promise<void> {
|
|
124
|
+
// Skip if the job itself is the consolidator (prevent infinite loop)
|
|
125
|
+
if (jobName === "memory-consolidation") return;
|
|
126
|
+
|
|
127
|
+
const transcript = `[job-prompt]: ${jobPrompt}\n\n[job-result]: ${result}`;
|
|
128
|
+
|
|
129
|
+
// Skip trivial results
|
|
130
|
+
if (result.length < 50) return;
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
log.info({ jobName, resultChars: result.length }, "consolidator: extracting memories from job");
|
|
134
|
+
await runConsolidation(transcript, `job run — ${jobName}`);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
log.error({ err, jobName }, "consolidator: job extraction failed");
|
|
137
|
+
}
|
|
138
|
+
}
|
package/src/core/runner.ts
CHANGED
|
@@ -257,6 +257,13 @@ export async function runJob(job: JobInput, onActivity?: ActivityCallback): Prom
|
|
|
257
257
|
};
|
|
258
258
|
writeState(freshState);
|
|
259
259
|
|
|
260
|
+
// Memory consolidation — review what the job learned (fire-and-forget)
|
|
261
|
+
if (ok && result.result) {
|
|
262
|
+
import("./consolidator").then(({ consolidateJobRun }) => {
|
|
263
|
+
consolidateJobRun(job.name, jobPrompt, result.result).catch(() => {});
|
|
264
|
+
}).catch(() => {});
|
|
265
|
+
}
|
|
266
|
+
|
|
260
267
|
return result;
|
|
261
268
|
} catch (err) {
|
|
262
269
|
const duration_ms = Math.round(performance.now() - startMs);
|