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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niahere",
3
- "version": "0.2.44",
3
+ "version": "0.2.46",
4
4
  "description": "A personal AI assistant daemon — scheduled jobs, chat across Telegram and Slack, persona system, and visual identity.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -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
- const systemPrompt = buildSystemPrompt("chat", channel);
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
  },
@@ -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
+ }
@@ -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
+ }
@@ -0,0 +1,7 @@
1
+ import type postgres from "postgres";
2
+
3
+ export const name = "009_session_summary";
4
+
5
+ export async function up(sql: postgres.Sql): Promise<void> {
6
+ await sql`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS summary TEXT`;
7
+ }
@@ -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`