niahere 0.2.44 → 0.2.46
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 +32 -3
- package/src/chat/identity.ts +21 -0
- package/src/core/consolidator.ts +138 -0
- package/src/core/runner.ts +7 -0
- package/src/core/summarizer.ts +84 -0
- package/src/db/migrations/009_session_summary.ts +7 -0
- package/src/db/models/session.ts +25 -0
package/package.json
CHANGED
package/src/chat/engine.ts
CHANGED
|
@@ -5,11 +5,13 @@ import { existsSync } from "fs";
|
|
|
5
5
|
import { join } from "path";
|
|
6
6
|
import { homedir } from "os";
|
|
7
7
|
import { randomUUID } from "crypto";
|
|
8
|
-
import { buildSystemPrompt } from "./identity";
|
|
8
|
+
import { buildSystemPrompt, getSessionContext } from "./identity";
|
|
9
9
|
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";
|
|
14
|
+
import { summarizeSession } from "../core/summarizer";
|
|
13
15
|
import { log } from "../utils/log";
|
|
14
16
|
|
|
15
17
|
const IDLE_TIMEOUT = 10 * 60 * 1000; // 10 minutes
|
|
@@ -111,7 +113,14 @@ function sessionFileExists(sessionId: string, cwd: string): boolean {
|
|
|
111
113
|
|
|
112
114
|
export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine> {
|
|
113
115
|
const { room, channel, resume, mcpServers } = opts;
|
|
114
|
-
|
|
116
|
+
let systemPrompt = buildSystemPrompt("chat", channel);
|
|
117
|
+
|
|
118
|
+
// Inject recent session summaries for continuity
|
|
119
|
+
const sessionContext = await getSessionContext(room);
|
|
120
|
+
if (sessionContext) {
|
|
121
|
+
systemPrompt += "\n\n" + sessionContext;
|
|
122
|
+
}
|
|
123
|
+
|
|
115
124
|
const cwd = homedir();
|
|
116
125
|
|
|
117
126
|
let sessionId: string | null = null;
|
|
@@ -133,6 +142,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
133
142
|
let longRunningTimer: ReturnType<typeof setTimeout> | null = null;
|
|
134
143
|
let longRunningWarned = false;
|
|
135
144
|
let alive = false;
|
|
145
|
+
let messageCount = 0;
|
|
136
146
|
|
|
137
147
|
function clearIdleTimer() {
|
|
138
148
|
if (idleTimer) {
|
|
@@ -143,12 +153,21 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
143
153
|
|
|
144
154
|
function resetIdleTimer() {
|
|
145
155
|
clearIdleTimer();
|
|
146
|
-
idleTimer = setTimeout(() => {
|
|
156
|
+
idleTimer = setTimeout(async () => {
|
|
147
157
|
if (pending) {
|
|
148
158
|
// Don't tear down while a request is in flight
|
|
149
159
|
log.warn({ room }, "idle timer fired while request pending, skipping teardown");
|
|
150
160
|
return;
|
|
151
161
|
}
|
|
162
|
+
// Memory consolidation + session summary before "sleep"
|
|
163
|
+
if (sessionId && messageCount > 0) {
|
|
164
|
+
consolidateSession(sessionId, room).catch((err) => {
|
|
165
|
+
log.error({ err, room }, "consolidation failed during idle teardown");
|
|
166
|
+
});
|
|
167
|
+
summarizeSession(sessionId, room).catch((err) => {
|
|
168
|
+
log.error({ err, room }, "session summary failed during idle teardown");
|
|
169
|
+
});
|
|
170
|
+
}
|
|
152
171
|
teardown();
|
|
153
172
|
}, IDLE_TIMEOUT);
|
|
154
173
|
}
|
|
@@ -239,6 +258,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
239
258
|
content: pending.userMessage,
|
|
240
259
|
isFromAgent: false,
|
|
241
260
|
});
|
|
261
|
+
messageCount++;
|
|
242
262
|
}
|
|
243
263
|
}
|
|
244
264
|
|
|
@@ -406,6 +426,15 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
406
426
|
},
|
|
407
427
|
|
|
408
428
|
close() {
|
|
429
|
+
// Memory consolidation + session summary on explicit close
|
|
430
|
+
if (sessionId && messageCount > 0 && !pending) {
|
|
431
|
+
consolidateSession(sessionId, room).catch((err) => {
|
|
432
|
+
log.error({ err, room }, "consolidation failed during close");
|
|
433
|
+
});
|
|
434
|
+
summarizeSession(sessionId, room).catch((err) => {
|
|
435
|
+
log.error({ err, room }, "session summary failed during close");
|
|
436
|
+
});
|
|
437
|
+
}
|
|
409
438
|
teardown();
|
|
410
439
|
ActiveEngine.unregister(room).catch(() => {});
|
|
411
440
|
},
|
package/src/chat/identity.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { getPaths } from "../utils/paths";
|
|
|
4
4
|
import { getEnvironmentPrompt, getModePrompt, getChannelPrompt } from "../prompts";
|
|
5
5
|
import { getSkillsSummary } from "../core/skills";
|
|
6
6
|
import { getAgentsSummary } from "../core/agents";
|
|
7
|
+
import { Session } from "../db/models";
|
|
7
8
|
import type { Mode } from "../types";
|
|
8
9
|
|
|
9
10
|
// Re-export for backwards compat
|
|
@@ -43,3 +44,23 @@ export function buildSystemPrompt(mode: Mode = "chat", channel: string = "termin
|
|
|
43
44
|
|
|
44
45
|
return parts.join("\n\n");
|
|
45
46
|
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Load recent session summaries for a room and format as a context block.
|
|
50
|
+
* Returns empty string if no summaries are available.
|
|
51
|
+
*/
|
|
52
|
+
export async function getSessionContext(room: string): Promise<string> {
|
|
53
|
+
try {
|
|
54
|
+
const summaries = await Session.getRecentSummaries(room, 3);
|
|
55
|
+
if (summaries.length === 0) return "";
|
|
56
|
+
|
|
57
|
+
const lines = summaries
|
|
58
|
+
.reverse() // oldest first
|
|
59
|
+
.map((s) => `- (${s.updatedAt}): ${s.summary}`)
|
|
60
|
+
.join("\n");
|
|
61
|
+
|
|
62
|
+
return `## Recent Session Context\nBrief summaries of your last few sessions in this room — use for continuity:\n${lines}`;
|
|
63
|
+
} catch {
|
|
64
|
+
return "";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -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);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session summarizer — generates a brief handoff note when a session ends.
|
|
3
|
+
*
|
|
4
|
+
* Separate from memory consolidation. The consolidator extracts durable facts
|
|
5
|
+
* (memories/rules). The summarizer produces a short, ephemeral context bridge
|
|
6
|
+
* so the next session knows what just happened.
|
|
7
|
+
*
|
|
8
|
+
* Summaries are stored directly in the sessions table (summary column) via SQL.
|
|
9
|
+
* The last few summaries are injected into buildSystemPrompt() so new sessions
|
|
10
|
+
* have continuity without needing full transcript access.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Message, Session } from "../db/models";
|
|
14
|
+
import { buildSystemPrompt } from "../chat/identity";
|
|
15
|
+
import { runJobWithClaude } from "./runner";
|
|
16
|
+
import { log } from "../utils/log";
|
|
17
|
+
import { homedir } from "os";
|
|
18
|
+
import type { SessionMessage } from "../types";
|
|
19
|
+
|
|
20
|
+
/** Track sessions already summarized to prevent double runs. */
|
|
21
|
+
const summarized = new Set<string>();
|
|
22
|
+
|
|
23
|
+
/** Max messages to include (most recent). */
|
|
24
|
+
const MAX_MESSAGES = 30;
|
|
25
|
+
|
|
26
|
+
/** Format transcript for the summarization prompt. */
|
|
27
|
+
function formatTranscript(messages: SessionMessage[]): string {
|
|
28
|
+
const recent = messages.slice(-MAX_MESSAGES);
|
|
29
|
+
return recent
|
|
30
|
+
.map((m) => `[${m.sender}]: ${m.content.slice(0, 1000)}`)
|
|
31
|
+
.join("\n");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Summarize a session and store the result in the sessions table.
|
|
36
|
+
* Called when a chat engine goes idle — produces a context bridge for the next session.
|
|
37
|
+
*/
|
|
38
|
+
export async function summarizeSession(sessionId: string, room: string): Promise<void> {
|
|
39
|
+
if (room.includes("placeholder")) return;
|
|
40
|
+
if (summarized.has(sessionId)) return;
|
|
41
|
+
summarized.add(sessionId);
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const messages = await Message.getBySession(sessionId);
|
|
45
|
+
if (messages.length < 2) return;
|
|
46
|
+
|
|
47
|
+
log.info({ sessionId, room, messageCount: messages.length }, "summarizer: generating session summary");
|
|
48
|
+
|
|
49
|
+
const systemPrompt = buildSystemPrompt("job");
|
|
50
|
+
const transcript = formatTranscript(messages);
|
|
51
|
+
|
|
52
|
+
const jobPrompt = `Job: session-summary (triggered by session idle in ${room})
|
|
53
|
+
|
|
54
|
+
Generate a brief session summary. This will be shown to your future self at the start of the next session for continuity.
|
|
55
|
+
|
|
56
|
+
## Conversation
|
|
57
|
+
${transcript}
|
|
58
|
+
|
|
59
|
+
## Instructions
|
|
60
|
+
Write a 2-4 sentence summary covering:
|
|
61
|
+
- What was discussed or worked on
|
|
62
|
+
- Any decisions made or outcomes reached
|
|
63
|
+
- Anything pending or unresolved
|
|
64
|
+
|
|
65
|
+
Keep it concise — a handoff note, not a report. Output ONLY the summary text.`;
|
|
66
|
+
|
|
67
|
+
const output = await runJobWithClaude(systemPrompt, jobPrompt, homedir());
|
|
68
|
+
|
|
69
|
+
if (output.error) {
|
|
70
|
+
log.error({ sessionId, room, error: output.error }, "summarizer: failed");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const summary = output.agentText.trim();
|
|
75
|
+
if (summary && summary.length > 10 && summary.length < 2000) {
|
|
76
|
+
await Session.setSummary(sessionId, summary);
|
|
77
|
+
log.info({ sessionId, room, summaryChars: summary.length }, "summarizer: saved");
|
|
78
|
+
} else {
|
|
79
|
+
log.warn({ sessionId, room, length: summary.length }, "summarizer: output too short or too long, skipped");
|
|
80
|
+
}
|
|
81
|
+
} catch (err) {
|
|
82
|
+
log.error({ err, sessionId, room }, "summarizer: failed");
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/db/models/session.ts
CHANGED
|
@@ -88,6 +88,31 @@ export async function touch(id: string): Promise<void> {
|
|
|
88
88
|
await sql`UPDATE sessions SET updated_at = NOW() WHERE id = ${id}`;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
export async function setSummary(id: string, summary: string): Promise<void> {
|
|
92
|
+
const sql = getSql();
|
|
93
|
+
await sql`UPDATE sessions SET summary = ${summary} WHERE id = ${id}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function getRecentSummaries(room: string, limit = 3): Promise<Array<{ summary: string; updatedAt: string }>> {
|
|
97
|
+
const sql = getSql();
|
|
98
|
+
// Match summaries from sessions in the same channel (e.g. slack-dm-U...-*)
|
|
99
|
+
// by extracting the room prefix (everything before the last -N index)
|
|
100
|
+
const prefix = room.replace(/-\d+$/, "");
|
|
101
|
+
const rows = await sql`
|
|
102
|
+
SELECT summary, updated_at
|
|
103
|
+
FROM sessions
|
|
104
|
+
WHERE room LIKE ${prefix + "-%"}
|
|
105
|
+
AND summary IS NOT NULL
|
|
106
|
+
AND id != ${""}
|
|
107
|
+
ORDER BY updated_at DESC
|
|
108
|
+
LIMIT ${limit}
|
|
109
|
+
`;
|
|
110
|
+
return rows.map((r) => ({
|
|
111
|
+
summary: String(r.summary),
|
|
112
|
+
updatedAt: String(r.updated_at),
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
|
|
91
116
|
export async function getLatestRoomIndex(prefix: string): Promise<number> {
|
|
92
117
|
const sql = getSql();
|
|
93
118
|
const rows = await sql`
|