niahere 0.2.62 → 0.2.63

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.
@@ -1,27 +1,13 @@
1
1
  /**
2
- * Memory consolidator — stage 1 of a two-stage memory architecture.
2
+ * Memory consolidator — stage 1 of the two-stage memory pipeline.
3
3
  *
4
- * After a chat session goes idle, this module reflects on the transcript
5
- * and writes CANDIDATE memories to ~/.niahere/self/staging.md. It never
6
- * writes directly to memory.md or rules.md — the nightly memory-promoter
7
- * job handles promotion once candidates are reinforced (count >= 2) and
8
- * pass durability review.
4
+ * After a chat session goes idle, reflects on the transcript and appends
5
+ * CANDIDATE memories to ~/.niahere/self/staging.md. The nightly
6
+ * memory-promoter job handles promotion from staging to memory.md/rules.md.
7
+ * The write-path restriction is enforced by the consolidator prompt, not
8
+ * by tool sandboxing.
9
9
  *
10
- * Architecture:
11
- * chat idle → consolidator → staging.md (candidate log, TTL 14d)
12
- * ↓ nightly promoter (3am)
13
- * ↓ - count >= 2 required
14
- * ↓ - durability review
15
- * memory.md / rules.md
16
- *
17
- * Jobs do NOT flow through this path — job-local learnings live in each
18
- * job's state.md (see runner.ts:buildWorkingMemory). Routing job output
19
- * into global persona memory caused layer violations (transient incidents
20
- * promoted to durable facts).
21
- *
22
- * The consolidator uses the same agent loop as cron jobs — full Nia system
23
- * prompt, full tool access. The write-path restriction is enforced by the
24
- * prompt (the agent is told to only edit staging.md), not by tool sandboxing.
10
+ * See AGENTS.md > "Two-stage memory" for the full architecture.
25
11
  */
26
12
 
27
13
  import { Message } from "../db/models";
@@ -138,12 +124,16 @@ Report a one-line summary of what you did: "staged N new / reinforced M /
138
124
  skipped (trivial session)". No preamble.`;
139
125
  }
140
126
 
141
- /** Run the consolidation agent loop. */
142
127
  async function runConsolidation(transcript: string, source: string): Promise<void> {
143
- await runTask({
128
+ const output = await runTask({
144
129
  name: "consolidator",
145
130
  prompt: buildConsolidationPrompt(transcript, source),
146
131
  });
132
+ // runTask returns {error} on failure instead of throwing; escalate so
133
+ // consolidateSession doesn't mark the session processed on a failed run.
134
+ if (output.error) {
135
+ throw new Error(`consolidator task failed: ${output.error}`);
136
+ }
147
137
  }
148
138
 
149
139
  /**
@@ -178,12 +168,8 @@ export async function consolidateSession(sessionId: string, room: string): Promi
178
168
  }
179
169
  } catch (err) {
180
170
  log.error({ err, sessionId, room }, "consolidator: chat extraction failed");
171
+ throw err;
181
172
  } finally {
182
173
  inFlight.delete(sessionId);
183
174
  }
184
175
  }
185
-
186
- // Job runs no longer flow through the global memory consolidator. Each job
187
- // maintains its own working memory in state.md (see buildWorkingMemory() in
188
- // runner.ts). This separation prevents transient job-local incidents from
189
- // being promoted to durable persona memory.
@@ -1,9 +1,10 @@
1
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from "fs";
1
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from "fs";
2
2
  import { dirname, resolve as pathResolve } from "path";
3
3
  import { fileURLToPath } from "url";
4
4
  import { getPaths } from "../utils/paths";
5
5
  import { getConfig, resetConfig } from "../utils/config";
6
6
  import { log } from "../utils/log";
7
+ import { isRunning, readPid, removePid, writePid } from "../utils/pid";
7
8
  import { ActiveEngine, Job } from "../db/models";
8
9
  import { runMigrations } from "../db/migrate";
9
10
  import { closeDb, getSql } from "../db/connection";
@@ -15,45 +16,7 @@ import { createNiaMcpServer } from "../mcp/server";
15
16
  import { setMcpFactory } from "../mcp";
16
17
  import { processPending, cleanupOldRequests } from "./finalizer";
17
18
 
18
- export function writePid(pid: number): void {
19
- const { pid: pidPath } = getPaths();
20
- mkdirSync(dirname(pidPath), { recursive: true });
21
- writeFileSync(pidPath, String(pid));
22
- }
23
-
24
- export function readPid(): number | null {
25
- const { pid: pidPath } = getPaths();
26
- if (!existsSync(pidPath)) return null;
27
-
28
- try {
29
- return parseInt(readFileSync(pidPath, "utf8").trim(), 10);
30
- } catch {
31
- return null;
32
- }
33
- }
34
-
35
- export function removePid(): void {
36
- const { pid: pidPath } = getPaths();
37
- try {
38
- unlinkSync(pidPath);
39
- } catch {
40
- // Already gone
41
- }
42
- }
43
-
44
- export function isRunning(): boolean {
45
- const pid = readPid();
46
- if (pid === null) return false;
47
-
48
- try {
49
- process.kill(pid, 0);
50
- return true;
51
- } catch {
52
- log.warn({ stalePid: pid }, "removing stale pid file (process not running)");
53
- removePid();
54
- return false;
55
- }
56
- }
19
+ export { isRunning, readPid, removePid, writePid };
57
20
 
58
21
  export function startDaemon(): number {
59
22
  const { daemonLog } = getPaths();
@@ -125,7 +88,7 @@ function waitForExit(timeoutMs: number): void {
125
88
  /** Return PIDs of running daemon processes (excluding ourselves). */
126
89
  export function findDaemonPids(): number[] {
127
90
  try {
128
- const result = Bun.spawnSync(["pgrep", "-f", "src/cli\\.ts run$"]);
91
+ const result = Bun.spawnSync(["pgrep", "-f", "src/cli/index\\.ts run$"]);
129
92
  const stdout = new TextDecoder().decode(result.stdout).trim();
130
93
  if (!stdout) return [];
131
94
  return stdout
@@ -78,15 +78,32 @@ async function processOne(sessionId: string, room: string, messageCount: number)
78
78
  const requestId = claimed[0].id;
79
79
 
80
80
  try {
81
- await Promise.allSettled([consolidateSession(sessionId, room), summarizeSession(sessionId, room)]);
81
+ const [consolidateResult, summarizeResult] = await Promise.allSettled([
82
+ consolidateSession(sessionId, room),
83
+ summarizeSession(sessionId, room),
84
+ ]);
85
+
86
+ const errors: string[] = [];
87
+ if (consolidateResult.status === "rejected") {
88
+ errors.push(`consolidate: ${formatRejection(consolidateResult.reason)}`);
89
+ }
90
+ if (summarizeResult.status === "rejected") {
91
+ errors.push(`summarize: ${formatRejection(summarizeResult.reason)}`);
92
+ }
93
+
94
+ const finalStatus = errors.length === 0 ? "done" : "failed";
82
95
 
83
96
  await sql`
84
97
  UPDATE finalization_requests
85
- SET status = 'done', updated_at = NOW()
98
+ SET status = ${finalStatus}, updated_at = NOW()
86
99
  WHERE id = ${requestId}
87
100
  `;
88
101
 
89
- log.info({ sessionId, room, messageCount }, "finalizer: completed");
102
+ if (errors.length === 0) {
103
+ log.info({ sessionId, room, messageCount }, "finalizer: completed");
104
+ } else {
105
+ log.error({ sessionId, room, messageCount, errors }, "finalizer: completed with task failures");
106
+ }
90
107
  } catch (err) {
91
108
  await sql`
92
109
  UPDATE finalization_requests
@@ -98,6 +115,17 @@ async function processOne(sessionId: string, room: string, messageCount: number)
98
115
  }
99
116
  }
100
117
 
118
+ /** Normalize a Promise rejection reason into a loggable string. */
119
+ function formatRejection(reason: unknown): string {
120
+ if (reason instanceof Error) return reason.message;
121
+ if (typeof reason === "string") return reason;
122
+ try {
123
+ return JSON.stringify(reason);
124
+ } catch {
125
+ return String(reason);
126
+ }
127
+ }
128
+
101
129
  /** Drain all pending finalization requests. Called by daemon on startup and on NOTIFY. */
102
130
  export async function processPending(): Promise<void> {
103
131
  const sql = getSql();
@@ -2,7 +2,7 @@ import { existsSync, statSync } from "fs";
2
2
  import { join } from "path";
3
3
  import { getConfig, readRawConfig } from "../utils/config";
4
4
  import { getPaths } from "../utils/paths";
5
- import { isRunning, readPid } from "./daemon";
5
+ import { isRunning, readPid } from "../utils/pid";
6
6
  import { errMsg } from "../utils/errors";
7
7
  import { localTime } from "../utils/time";
8
8
  import { withRetry } from "../utils/retry";
@@ -123,11 +123,7 @@ export async function runHealthChecks(): Promise<Check[]> {
123
123
  }),
124
124
  );
125
125
  const data = (await resp.json()) as { ok: boolean; error?: string };
126
- results.push(
127
- data.ok
128
- ? "slack: connected"
129
- : `slack: ${data.error || "auth failed"}`,
130
- );
126
+ results.push(data.ok ? "slack: connected" : `slack: ${data.error || "auth failed"}`);
131
127
  if (!data.ok)
132
128
  checks.push({
133
129
  name: "slack",
@@ -159,10 +155,7 @@ export async function runHealthChecks(): Promise<Check[]> {
159
155
  // API keys
160
156
  const geminiKey = config.gemini_api_key;
161
157
  const rawConfig = readRawConfig();
162
- const openaiKey =
163
- typeof rawConfig.openai_api_key === "string"
164
- ? rawConfig.openai_api_key
165
- : null;
158
+ const openaiKey = typeof rawConfig.openai_api_key === "string" ? rawConfig.openai_api_key : null;
166
159
  const apiKeys: string[] = [];
167
160
  if (geminiKey) apiKeys.push("gemini");
168
161
  if (openaiKey) apiKeys.push("openai");
@@ -174,16 +167,11 @@ export async function runHealthChecks(): Promise<Check[]> {
174
167
 
175
168
  // Persona files
176
169
  const personaFiles = ["identity.md", "owner.md", "soul.md"];
177
- const missing = personaFiles.filter(
178
- (f) => !existsSync(join(paths.selfDir, f)),
179
- );
170
+ const missing = personaFiles.filter((f) => !existsSync(join(paths.selfDir, f)));
180
171
  checks.push({
181
172
  name: "persona",
182
173
  status: missing.length === 0 ? "ok" : "warn",
183
- detail:
184
- missing.length === 0
185
- ? "all files present"
186
- : "missing: " + missing.join(", "),
174
+ detail: missing.length === 0 ? "all files present" : "missing: " + missing.join(", "),
187
175
  });
188
176
 
189
177
  // Daemon log
@@ -372,12 +372,6 @@ export async function runJob(job: JobInput, onActivity?: ActivityCallback): Prom
372
372
  };
373
373
  writeState(freshState);
374
374
 
375
- // Job-local learnings live in state.md (injected into the next run's
376
- // prompt), not in global self/memory.md. The global memory consolidator
377
- // only processes chat sessions — routing job output through it created
378
- // layer violations (transient incidents promoted to durable memory).
379
- // See src/core/consolidator.ts and AGENTS.md memory architecture notes.
380
-
381
375
  return result;
382
376
  } catch (err) {
383
377
  const duration_ms = Math.round(performance.now() - startMs);
@@ -1,50 +1,10 @@
1
- import { CronExpressionParser } from "cron-parser";
2
- import { parseDuration } from "../utils/duration";
3
- import type { ScheduleType } from "../types";
4
1
  import { Job } from "../db/models";
5
2
  import { runJob } from "./runner";
6
3
  import { getConfig } from "../utils/config";
7
4
  import { log } from "../utils/log";
5
+ import { computeInitialNextRun, computeNextRun } from "../utils/schedule";
8
6
 
9
- export function computeNextRun(
10
- scheduleType: ScheduleType,
11
- schedule: string,
12
- timezone: string,
13
- lastRunAt?: Date,
14
- ): Date | null {
15
- switch (scheduleType) {
16
- case "cron": {
17
- const expr = CronExpressionParser.parse(schedule, { tz: timezone });
18
- return expr.next().toDate();
19
- }
20
- case "interval": {
21
- const ms = parseDuration(schedule);
22
- const base = lastRunAt || new Date();
23
- return new Date(base.getTime() + ms);
24
- }
25
- case "once":
26
- return null;
27
- }
28
- }
29
-
30
- export function computeInitialNextRun(
31
- scheduleType: ScheduleType,
32
- schedule: string,
33
- timezone: string,
34
- ): Date {
35
- switch (scheduleType) {
36
- case "cron": {
37
- const expr = CronExpressionParser.parse(schedule, { tz: timezone });
38
- return expr.next().toDate();
39
- }
40
- case "interval": {
41
- const ms = parseDuration(schedule);
42
- return new Date(Date.now() + ms);
43
- }
44
- case "once":
45
- return new Date(schedule);
46
- }
47
- }
7
+ export { computeInitialNextRun, computeNextRun };
48
8
 
49
9
  function isWithinActiveHours(): boolean {
50
10
  const config = getConfig();
@@ -96,13 +56,16 @@ async function tick(): Promise<void> {
96
56
  log.info({ job: job.name, type: job.scheduleType }, "scheduler: running job");
97
57
  runningJobs.add(job.name);
98
58
 
99
- runJob(job).then((result) => {
100
- log.info({ job: job.name, status: result.status, duration: result.duration_ms }, "scheduler: job completed");
101
- }).catch((err) => {
102
- log.error({ err, job: job.name }, "scheduler: job failed");
103
- }).finally(() => {
104
- runningJobs.delete(job.name);
105
- });
59
+ runJob(job)
60
+ .then((result) => {
61
+ log.info({ job: job.name, status: result.status, duration: result.duration_ms }, "scheduler: job completed");
62
+ })
63
+ .catch((err) => {
64
+ log.error({ err, job: job.name }, "scheduler: job failed");
65
+ })
66
+ .finally(() => {
67
+ runningJobs.delete(job.name);
68
+ });
106
69
 
107
70
  let nextRun: Date | null = null;
108
71
  try {
@@ -40,14 +40,10 @@ export function scanSkills(): SkillInfo[] {
40
40
  try {
41
41
  meta = (yaml.load(fmMatch[1]) as Record<string, unknown>) || {};
42
42
  } catch (err) {
43
- log.warn(
44
- { err, skill: entry.name, path: skillFile },
45
- "failed to parse skill metadata, skipping",
46
- );
43
+ log.warn({ err, skill: entry.name, path: skillFile }, "failed to parse skill metadata, skipping");
47
44
  continue;
48
45
  }
49
- const name =
50
- (typeof meta.name === "string" ? meta.name : "") || entry.name;
46
+ const name = (typeof meta.name === "string" ? meta.name : "") || entry.name;
51
47
 
52
48
  const key = name.toLowerCase();
53
49
  if (seen.has(key)) continue;
@@ -55,8 +51,7 @@ export function scanSkills(): SkillInfo[] {
55
51
 
56
52
  skills.push({
57
53
  name,
58
- description:
59
- typeof meta.description === "string" ? meta.description : "",
54
+ description: typeof meta.description === "string" ? meta.description : "",
60
55
  source,
61
56
  });
62
57
  }
@@ -72,8 +67,6 @@ export function getSkillNames(): string[] {
72
67
  export function getSkillsSummary(): string {
73
68
  const skills = scanSkills();
74
69
  if (skills.length === 0) return "";
75
- const lines = skills.map((s) =>
76
- s.description ? `- /${s.name}: ${s.description}` : `- /${s.name}`,
77
- );
70
+ const lines = skills.map((s) => (s.description ? `- /${s.name}: ${s.description}` : `- /${s.name}`));
78
71
  return `Available skills:\n${lines.join("\n")}`;
79
72
  }
@@ -26,19 +26,14 @@ const MAX_MESSAGES = 30;
26
26
  /** Format transcript for the summarization prompt. */
27
27
  function formatTranscript(messages: SessionMessage[]): string {
28
28
  const recent = messages.slice(-MAX_MESSAGES);
29
- return recent
30
- .map((m) => `[${m.sender}]: ${m.content.slice(0, 1000)}`)
31
- .join("\n");
29
+ return recent.map((m) => `[${m.sender}]: ${m.content.slice(0, 1000)}`).join("\n");
32
30
  }
33
31
 
34
32
  /**
35
33
  * Summarize a session and store the result in the sessions table.
36
34
  * Called when a chat engine goes idle — produces a context bridge for the next session.
37
35
  */
38
- export async function summarizeSession(
39
- sessionId: string,
40
- room: string,
41
- ): Promise<void> {
36
+ export async function summarizeSession(sessionId: string, room: string): Promise<void> {
42
37
  if (room.includes("placeholder")) return;
43
38
  if (inFlight.has(sessionId)) return;
44
39
 
@@ -51,10 +46,7 @@ export async function summarizeSession(
51
46
 
52
47
  inFlight.add(sessionId);
53
48
 
54
- log.info(
55
- { sessionId, room, messageCount: messages.length },
56
- "summarizer: generating session summary",
57
- );
49
+ log.info({ sessionId, room, messageCount: messages.length }, "summarizer: generating session summary");
58
50
 
59
51
  const transcript = formatTranscript(messages);
60
52
 
@@ -76,8 +68,7 @@ Keep it concise — a handoff note, not a report. Output ONLY the summary text.`
76
68
  const output = await runTask({ name: "summarizer", prompt });
77
69
 
78
70
  if (output.error) {
79
- log.error({ sessionId, room, error: output.error }, "summarizer: failed");
80
- return;
71
+ throw new Error(`summarizer task failed: ${output.error}`);
81
72
  }
82
73
 
83
74
  const summary = output.agentText.trim();
@@ -88,18 +79,13 @@ Keep it concise — a handoff note, not a report. Output ONLY the summary text.`
88
79
  const firstKey = processedCounts.keys().next().value;
89
80
  if (firstKey) processedCounts.delete(firstKey);
90
81
  }
91
- log.info(
92
- { sessionId, room, summaryChars: summary.length },
93
- "summarizer: saved",
94
- );
82
+ log.info({ sessionId, room, summaryChars: summary.length }, "summarizer: saved");
95
83
  } else {
96
- log.warn(
97
- { sessionId, room, length: summary.length },
98
- "summarizer: output too short or too long, skipped",
99
- );
84
+ log.warn({ sessionId, room, length: summary.length }, "summarizer: output too short or too long, skipped");
100
85
  }
101
86
  } catch (err) {
102
87
  log.error({ err, sessionId, room }, "summarizer: failed");
88
+ throw err;
103
89
  } finally {
104
90
  inFlight.delete(sessionId);
105
91
  }
@@ -23,14 +23,3 @@ export async function closeDb(): Promise<void> {
23
23
  _sql = null;
24
24
  }
25
25
  }
26
-
27
- /** Run migrations, execute fn, then close DB. */
28
- export async function withDb<T>(fn: () => Promise<T>): Promise<T> {
29
- const { runMigrations } = await import("./migrate");
30
- await runMigrations();
31
- try {
32
- return await fn();
33
- } finally {
34
- await closeDb();
35
- }
36
- }
@@ -1,6 +1,8 @@
1
1
  import { getSql } from "../connection";
2
2
  import { CronExpressionParser } from "cron-parser";
3
3
  import { parseDuration } from "../../utils/duration";
4
+ import { computeInitialNextRun } from "../../utils/schedule";
5
+ import { getConfig } from "../../utils/config";
4
6
  import type { ScheduleType } from "../../types";
5
7
 
6
8
  /** Validate that a schedule string matches its declared type. Throws on mismatch. */
@@ -10,26 +12,20 @@ function validateSchedule(schedule: string, scheduleType: ScheduleType): void {
10
12
  try {
11
13
  CronExpressionParser.parse(schedule);
12
14
  } catch (err) {
13
- throw new Error(
14
- `Invalid cron expression "${schedule}": ${err instanceof Error ? err.message : err}`,
15
- );
15
+ throw new Error(`Invalid cron expression "${schedule}": ${err instanceof Error ? err.message : err}`);
16
16
  }
17
17
  break;
18
18
  case "interval":
19
19
  try {
20
20
  parseDuration(schedule);
21
21
  } catch (err) {
22
- throw new Error(
23
- `Invalid interval "${schedule}": ${err instanceof Error ? err.message : err}`,
24
- );
22
+ throw new Error(`Invalid interval "${schedule}": ${err instanceof Error ? err.message : err}`);
25
23
  }
26
24
  break;
27
25
  case "once": {
28
26
  const d = new Date(schedule);
29
27
  if (isNaN(d.getTime())) {
30
- throw new Error(
31
- `Invalid timestamp "${schedule}": expected ISO 8601 date`,
32
- );
28
+ throw new Error(`Invalid timestamp "${schedule}": expected ISO 8601 date`);
33
29
  }
34
30
  break;
35
31
  }
@@ -89,9 +85,7 @@ export async function create(
89
85
  validateSchedule(schedule, scheduleType);
90
86
  const existing = await get(name);
91
87
  if (existing) {
92
- throw new Error(
93
- `Job "${name}" already exists. Use \`nia job remove ${name}\` first, or choose a different name.`,
94
- );
88
+ throw new Error(`Job "${name}" already exists. Use \`nia job remove ${name}\` first, or choose a different name.`);
95
89
  }
96
90
  const sql = getSql();
97
91
  await sql`
@@ -141,15 +135,25 @@ export async function update(
141
135
  const model = fields.model !== undefined ? fields.model : existing.model;
142
136
  const stateless = fields.stateless ?? existing.stateless;
143
137
 
144
- if (fields.schedule || fields.scheduleType) {
138
+ const scheduleChanged = fields.schedule !== undefined || fields.scheduleType !== undefined;
139
+ if (scheduleChanged) {
145
140
  validateSchedule(schedule, scheduleType);
146
141
  }
147
142
 
148
- await sql`
149
- UPDATE jobs
150
- SET schedule = ${schedule}, schedule_type = ${scheduleType}, prompt = ${prompt}, enabled = ${enabled}, always = ${always}, agent = ${agent}, model = ${model}, stateless = ${stateless}, updated_at = NOW()
151
- WHERE name = ${name}
152
- `;
143
+ if (scheduleChanged) {
144
+ const nextRun = computeInitialNextRun(scheduleType, schedule, getConfig().timezone);
145
+ await sql`
146
+ UPDATE jobs
147
+ SET schedule = ${schedule}, schedule_type = ${scheduleType}, prompt = ${prompt}, enabled = ${enabled}, always = ${always}, agent = ${agent}, model = ${model}, stateless = ${stateless}, next_run_at = ${nextRun}, updated_at = NOW()
148
+ WHERE name = ${name}
149
+ `;
150
+ } else {
151
+ await sql`
152
+ UPDATE jobs
153
+ SET schedule = ${schedule}, schedule_type = ${scheduleType}, prompt = ${prompt}, enabled = ${enabled}, always = ${always}, agent = ${agent}, model = ${model}, stateless = ${stateless}, updated_at = NOW()
154
+ WHERE name = ${name}
155
+ `;
156
+ }
153
157
  await notifyChange();
154
158
  return true;
155
159
  }
@@ -179,10 +183,7 @@ export async function listDue(): Promise<Job[]> {
179
183
  return rows.map(toJob);
180
184
  }
181
185
 
182
- export async function markRun(
183
- name: string,
184
- nextRunAt: Date | null,
185
- ): Promise<void> {
186
+ export async function markRun(name: string, nextRunAt: Date | null): Promise<void> {
186
187
  const sql = getSql();
187
188
  if (nextRunAt) {
188
189
  await sql`UPDATE jobs SET last_run_at = NOW(), next_run_at = ${nextRunAt}, updated_at = NOW() WHERE name = ${name}`;
@@ -0,0 +1,11 @@
1
+ import { closeDb } from "./connection";
2
+ import { runMigrations } from "./migrate";
3
+
4
+ export async function withDb<T>(fn: () => Promise<T>): Promise<T> {
5
+ await runMigrations();
6
+ try {
7
+ return await fn();
8
+ } finally {
9
+ await closeDb();
10
+ }
11
+ }
package/src/mcp/server.ts CHANGED
@@ -290,7 +290,7 @@ export function createNiaMcpServer() {
290
290
  ),
291
291
  tool(
292
292
  "add_memory",
293
- "Save a concise factual memory for future reference. Proactively save personal facts (travel, schedule), work context (decisions, deadlines), and corrections — don't wait to be asked. RULES: Max 300 chars. One insight per entry. NO raw logs, NO transcripts, NO status dumps.",
293
+ "Save a concise factual memory for future reference. Call this when the user explicitly asks you to remember something, or when a correction needs an immediate durable record. For observations you notice on your own during a session, let the post-session consolidator handle it via staging.md — don't preemptively save here. RULES: Max 300 chars. One insight per entry. NO raw logs, NO transcripts, NO status dumps.",
294
294
  {
295
295
  entry: z.string().max(300).describe("A single concise insight (max 300 chars, no raw logs or transcripts)"),
296
296
  },