sensorium-mcp 3.0.54 → 3.0.55

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.
@@ -2,11 +2,11 @@
2
2
  * Temporal Narrative Generator
3
3
  *
4
4
  * Produces multi-resolution narratives from episodes and semantic notes:
5
- * - day: detailed events (~500 tokens)
6
- * - week: key decisions and progress (~300 tokens)
7
- * - month: high-level arc (~200 tokens)
8
- * - quarter: strategic 3-month arc (~150 tokens)
9
- * - half_year: bird's-eye 6-month arc (~120 tokens)
5
+ * - day: detailed events (~400 tokens)
6
+ * - week: key decisions and progress (~800 tokens)
7
+ * - month: high-level arc (~1200 tokens)
8
+ * - quarter: strategic 3-month arc (~2000 tokens)
9
+ * - half_year: bird's-eye 6-month arc (~2500 tokens)
10
10
  *
11
11
  * These narratives replace raw note dumps in bootstrap and give the agent
12
12
  * coherent temporal awareness across long-running sessions.
@@ -25,7 +25,7 @@ const NARRATIVE_FILLER_PHRASES = [
25
25
  /\bseveral enhancements\b/i,
26
26
  /\bsignificant (evolution|strides|developments?)\b/i,
27
27
  /\boverall.{0,20}(positive|good|well)\b/i,
28
- /\bpivotal moments?\b/i,
28
+ /\bpivotal (moments?|decisions?|turning)\b/i,
29
29
  /\bcrucial (step|milestone|decision)\b/i,
30
30
  /\bnotable (milestone|achievement|development)\b/i,
31
31
  /\bsubstantial (progress|improvement)\b/i,
@@ -34,6 +34,22 @@ const NARRATIVE_FILLER_PHRASES = [
34
34
  /\bas I (navigated|reflected|observed|witnessed)\b/i,
35
35
  /\bthis (prompted|led) me to reflect\b/i,
36
36
  /\bI (noticed|observed|witnessed) a (critical|pivotal|key)\b/i,
37
+ /\bmarked by a series of\b/i,
38
+ /\bpart of a broader effort\b/i,
39
+ /\bshaped the (direction|trajectory)\b/i,
40
+ /\bturning points? that\b/i,
41
+ /\bset the stage for\b/i,
42
+ /\bnot only .{5,40} but also\b/i,
43
+ /\bculminating in\b/i,
44
+ /\bthe focus remains on\b/i,
45
+ /\bdriven by a commitment\b/i,
46
+ /\bthis decision to\b/i,
47
+ /\bthe current status reflects\b/i,
48
+ /\bresulting in a more\b/i,
49
+ /\bleading to more\b/i,
50
+ /\baddress(ed|ing) the root causes? of\b/i,
51
+ /\benhancing overall\b/i,
52
+ /\baligning with\b/i,
37
53
  ];
38
54
  function findFillerPhrase(text) {
39
55
  for (const pattern of NARRATIVE_FILLER_PHRASES) {
@@ -43,6 +59,64 @@ function findFillerPhrase(text) {
43
59
  }
44
60
  return null;
45
61
  }
62
+ function findDateViolation(text, periodStart, periodEnd) {
63
+ const startDate = new Date(periodStart);
64
+ const endDate = new Date(periodEnd);
65
+ const validYears = new Set();
66
+ for (let y = startDate.getFullYear(); y <= endDate.getFullYear(); y++)
67
+ validYears.add(y);
68
+ // Check standalone year references (e.g. "in 2025", "2025–2026")
69
+ const yearMatches = text.matchAll(/\b(20\d{2})\b/g);
70
+ for (const m of yearMatches) {
71
+ const year = parseInt(m[1], 10);
72
+ if (!validYears.has(year))
73
+ return `year ${year} outside valid range ${[...validYears].join("–")}`;
74
+ }
75
+ // Check month+year combos (e.g. "March 2026", "Apr 2025")
76
+ const monthNames = ["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"];
77
+ const monthPattern = /\b(Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|June?|July?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\s+(20\d{2})\b/gi;
78
+ for (const m of text.matchAll(monthPattern)) {
79
+ const monthStr = m[1].toLowerCase().slice(0, 3);
80
+ const monthIdx = monthNames.findIndex((mn) => mn.startsWith(monthStr));
81
+ const year = parseInt(m[2], 10);
82
+ if (monthIdx === -1)
83
+ continue;
84
+ const refDate = new Date(year, monthIdx, 15);
85
+ const windowStart = new Date(startDate);
86
+ windowStart.setDate(windowStart.getDate() - 7);
87
+ const windowEnd = new Date(endDate);
88
+ windowEnd.setDate(windowEnd.getDate() + 7);
89
+ if (refDate < windowStart || refDate > windowEnd) {
90
+ return `"${m[0]}" falls outside the period ${periodStart.slice(0, 10)} to ${periodEnd.slice(0, 10)}`;
91
+ }
92
+ }
93
+ return null;
94
+ }
95
+ function findLowDensitySentences(text) {
96
+ const sentences = text.split(/(?<=[.!?])\s+/).filter(s => s.length > 20);
97
+ if (sentences.length === 0)
98
+ return { count: 0, total: 0, examples: [] };
99
+ const identifierPattern = /\b(?:\d{4}[-/]\d{2}[-/]\d{2}|\d{1,2}:\d{2}|v\d+\.\d+|#\d+|ID\s*\d+|\d{3,}|(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\w*\s+\d{1,2}|(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday))/i;
100
+ const quotedNamePattern = /['"][^'"]+['"]|`[^`]+`/;
101
+ const namedEntityPattern = /thread\s*\d+|ID\s*\d+|(?:MCP|API|LLM|GPT|SQL|WAL|CLI|SDK|PR|CI|CD)\b/i;
102
+ const midSentenceProperNoun = (s) => {
103
+ const afterFirst = s.replace(/^\S+\s+/, "");
104
+ return /\b[A-Z][a-z]{2,}/.test(afterFirst);
105
+ };
106
+ const hasNumber = (s) => /\d+/.test(s);
107
+ const lowDensity = [];
108
+ for (const sentence of sentences) {
109
+ const hasIdentifier = identifierPattern.test(sentence);
110
+ const hasProperNoun = midSentenceProperNoun(sentence);
111
+ const hasQuotedName = quotedNamePattern.test(sentence);
112
+ const hasEntity = namedEntityPattern.test(sentence);
113
+ const hasNum = hasNumber(sentence);
114
+ if (!hasIdentifier && !hasProperNoun && !hasQuotedName && !hasEntity && !hasNum) {
115
+ lowDensity.push(sentence.slice(0, 80));
116
+ }
117
+ }
118
+ return { count: lowDensity.length, total: sentences.length, examples: lowDensity.slice(0, 3) };
119
+ }
46
120
  /** Cooldown per resolution before regenerating */
47
121
  const COOLDOWNS = {
48
122
  day: 2 * 60 * 60 * 1000, // 2 hours
@@ -53,11 +127,23 @@ const COOLDOWNS = {
53
127
  };
54
128
  /** Target output token count per resolution */
55
129
  const OUTPUT_TOKEN_TARGETS = {
56
- day: 500,
57
- week: 300,
58
- month: 200,
59
- quarter: 150,
60
- half_year: 120,
130
+ day: 400,
131
+ week: 800,
132
+ month: 1600,
133
+ quarter: 3000,
134
+ half_year: 4000,
135
+ };
136
+ const INPUT_CHAR_BUDGETS = {
137
+ day: { episodes: 30_000, notes: 10_000 },
138
+ week: { episodes: 50_000, notes: 15_000 },
139
+ month: { episodes: 60_000, notes: 20_000 },
140
+ quarter: { episodes: 80_000, notes: 25_000 },
141
+ half_year: { episodes: 100_000, notes: 30_000 },
142
+ };
143
+ const CHILD_RESOLUTION = {
144
+ month: "week",
145
+ quarter: "month",
146
+ half_year: "quarter",
61
147
  };
62
148
  const NARRATIVE_MODEL = process.env.NARRATIVE_MODEL || process.env.CONSOLIDATION_MODEL || "gpt-4o";
63
149
  // ─── Period Calculation ──────────────────────────────────────────────────────
@@ -96,6 +182,12 @@ function getPeriodBounds(resolution) {
96
182
  const _exhaustive = resolution;
97
183
  throw new Error(`Unhandled narrative resolution: ${_exhaustive}`);
98
184
  }
185
+ function getEarliestEpisodeDate(db, threadId) {
186
+ const row = db
187
+ .prepare(`SELECT MIN(timestamp) as earliest FROM episodes WHERE thread_id = ?`)
188
+ .get(threadId);
189
+ return row?.earliest ?? null;
190
+ }
99
191
  // ─── Source Data Collection ──────────────────────────────────────────────────
100
192
  function getEpisodesInPeriod(db, threadId, start, end) {
101
193
  const rows = db
@@ -149,33 +241,95 @@ function getNotesInPeriod(db, threadId, start) {
149
241
  qualityScore: r.quality_score ?? null,
150
242
  }));
151
243
  }
244
+ // ─── Child Narrative Retrieval ──────────────────────────────────────────────
245
+ function getChildNarratives(db, threadId, resolution, start, end) {
246
+ const child = CHILD_RESOLUTION[resolution];
247
+ if (!child)
248
+ return [];
249
+ const rows = db
250
+ .prepare(`SELECT * FROM temporal_narratives
251
+ WHERE thread_id = ? AND resolution = ? AND period_start >= ? AND period_start <= ?
252
+ ORDER BY period_start ASC`)
253
+ .all(threadId, child, start, end);
254
+ return rows.map((r) => ({
255
+ id: r.id,
256
+ threadId: r.thread_id,
257
+ resolution: r.resolution,
258
+ periodStart: r.period_start,
259
+ periodEnd: r.period_end,
260
+ narrative: r.narrative,
261
+ sourceEpisodeCount: r.source_episode_count,
262
+ sourceNoteCount: r.source_note_count,
263
+ model: r.model,
264
+ createdAt: r.created_at,
265
+ }));
266
+ }
267
+ function formatChildNarrativesForLLM(narratives) {
268
+ return narratives
269
+ .map((n) => {
270
+ const fmtDate = (d) => new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
271
+ return `--- ${n.resolution} narrative (${fmtDate(n.periodStart)} – ${fmtDate(n.periodEnd)}, ${n.sourceEpisodeCount} episodes) ---\n${n.narrative}`;
272
+ })
273
+ .join("\n\n");
274
+ }
152
275
  // ─── Content Extraction ──────────────────────────────────────────────────────
153
276
  function extractEpisodeText(ep) {
154
277
  const content = ep.content;
155
- const text = (content.text || content.caption || content.message || "");
156
- return text.slice(0, 300);
278
+ const text = (content.text || content.raw || content.caption || content.message || "");
279
+ return text.slice(0, 1500);
280
+ }
281
+ function formatEpisodeLine(ep, text) {
282
+ const ts = ep.timestamp.slice(0, 16).replace("T", " ");
283
+ const imp = ep.importance != null && ep.importance !== 0.5 ? ` [imp: ${ep.importance.toFixed(1)}]` : "";
284
+ const tags = ep.topicTags.length > 0 ? ` [tags: ${ep.topicTags.join(", ")}]` : "";
285
+ return `[${ts}] (${ep.type}/${ep.modality})${imp}${tags} ${text}`;
157
286
  }
158
287
  function formatEpisodesForLLM(episodes, maxChars) {
159
- const lines = [];
160
- let chars = 0;
161
- for (const ep of episodes) {
288
+ const importanceBudget = Math.round(maxChars * 0.3);
289
+ // Pool 1: top episodes by importance (guaranteed inclusion)
290
+ const byImportance = [...episodes].sort((a, b) => (b.importance ?? 0.5) - (a.importance ?? 0.5));
291
+ const selected = new Set();
292
+ const pool1 = [];
293
+ let pool1Chars = 0;
294
+ for (const ep of byImportance) {
162
295
  const text = extractEpisodeText(ep);
163
296
  if (!text.trim())
164
297
  continue;
165
- const ts = ep.timestamp.slice(0, 16).replace("T", " ");
166
- const line = `[${ts}] (${ep.type}/${ep.modality}) ${text}`;
167
- if (chars + line.length > maxChars)
298
+ const line = formatEpisodeLine(ep, text);
299
+ if (pool1Chars + line.length > importanceBudget)
168
300
  break;
169
- lines.push(line);
170
- chars += line.length;
301
+ pool1.push({ ep, line });
302
+ selected.add(ep.episodeId);
303
+ pool1Chars += line.length;
171
304
  }
172
- return lines.join("\n");
305
+ // Pool 2: chronological fill (remaining budget)
306
+ const remainingBudget = maxChars - pool1Chars;
307
+ const chronological = [...episodes]
308
+ .filter(ep => !selected.has(ep.episodeId))
309
+ .sort((a, b) => (a.timestamp < b.timestamp ? -1 : 1));
310
+ const pool2 = [];
311
+ let pool2Chars = 0;
312
+ for (const ep of chronological) {
313
+ const text = extractEpisodeText(ep);
314
+ if (!text.trim())
315
+ continue;
316
+ const line = formatEpisodeLine(ep, text);
317
+ if (pool2Chars + line.length > remainingBudget)
318
+ break;
319
+ pool2.push({ ep, line });
320
+ pool2Chars += line.length;
321
+ }
322
+ // Merge both pools and sort chronologically for coherent reading
323
+ return [...pool1, ...pool2]
324
+ .sort((a, b) => (a.ep.timestamp < b.ep.timestamp ? -1 : 1))
325
+ .map(x => x.line)
326
+ .join("\n");
173
327
  }
174
328
  function formatNotesForLLM(notes, maxChars) {
175
329
  const lines = [];
176
330
  let chars = 0;
177
331
  for (const n of notes) {
178
- const line = `- [${n.type}] ${n.content.slice(0, 200)} (conf: ${n.confidence.toFixed(2)})`;
332
+ const line = `- [${n.type}] ${n.content.slice(0, 600)} (conf: ${n.confidence.toFixed(2)})`;
179
333
  if (chars + line.length > maxChars)
180
334
  break;
181
335
  lines.push(line);
@@ -188,28 +342,59 @@ function buildPrompt(resolution, episodesText, notesText, episodeCount, periodLa
188
342
  const startYear = new Date(periodStart).getFullYear();
189
343
  const endYear = new Date().getFullYear();
190
344
  const instructions = {
191
- day: `Write a detailed narrative of what happened today (${periodLabel}). Include specific events, decisions made, problems encountered, and outcomes. Use chronological order. Be concrete mention specific features, fixes, discussions. For each major event, explain WHY it happened and what it caused. Don't just list what happened explain the chain of consequences. Target ~500 tokens.`,
192
- week: `Write a concise narrative of the key developments this past week (${periodLabel}). For each development, explain: what triggered it, what decision was made, and what resulted. Connect events causally — show how Monday's decision led to Wednesday's outcome. Group by causal chains, not just themes. Target ~300 tokens.`,
193
- month: `Write a narrative arc for this past month (${periodLabel}). Structure around 2-3 major cause-effect chains: what problem or opportunity emerged, what decisions were made in response, and how those decisions played out. Name specific features, tools, or systems not abstractions. End with what's unresolved. Target ~200 tokens.`,
194
- quarter: `Write a narrative arc for this quarter (${periodLabel}). Identify 2-3 pivotal decisions or turning points. For each: what was the situation before, what changed, and what was the lasting impact. Show how the project's direction evolved through concrete cause-and-effect, not vague 'themes'. Target ~150 tokens.`,
195
- half_year: `Write a bird's-eye narrative for this half-year (${periodLabel}). Capture the 1-2 biggest transformations: where the project started, what specific events or decisions caused the shift, and where it stands now. Every claim must reference a concrete event or decisionno unsupported generalizations like 'significant progress' or 'notable improvements'. Target ~120 tokens.`,
345
+ day: `Write a narrative of what happened today (${periodLabel}). Tell the story chronologically what happened, why, what it caused. Include timestamps (hours:minutes) for each event. Write in flowing prose, connecting events with cause and effect. Be specific: name systems, threads, versions, IDs. Target ~400 tokens.`,
346
+ week: `Write a narrative of the key developments this week (${periodLabel}). Tell the story chronologically by day. For each event: what triggered it, what was decided, what resulted. Connect events across days — show how Monday's decision led to Wednesday's outcome. Write in flowing prose, not a list. Use day-level dates, no hours. Target ~800 tokens.`,
347
+ month: `Write a chronological decision log for this month (${periodLabel}). Format: "Date decision/event. Reason. Result." One entry per line. Each entry should have enough context to stand alone — a reader who sees only this entry should understand why it matters. Only include: decisions that changed project direction, bugs that broke something, features shipped. Skip routine fixes, code reviews, type errors, status checks. Day-level dates, no hours. End with unresolved items. Target ~1600 tokens.`,
348
+ quarter: `Write a chronological decision log for this quarter (${periodLabel}). Format: "Date decision/event. Reason. Result." One entry per line. Each entry should have enough context to stand alone — explain what problem existed before, what decision was made, and what changed after. Only include: decisions that changed project direction, major bugs, features shipped, architectural changes. Skip routine fixes, minor code reviews, type errors. Episodes marked [imp: 0.6+] are operator-priority — include them. Day-level dates, strict chronological order. End with unresolved items. Target ~3000 tokens.`,
349
+ half_year: `Write a chronological decision log for this half-year (${periodLabel}). Format: "Date decision/event. Reason. Result." One entry per line. Each entry should have enough context to stand alone — explain the situation before, what decision was made, and what changed as a result. Only include: decisions that changed project direction, major bugs, features shipped, architectural changes. Skip routine fixes, minor code reviews, type errors. Episodes marked [imp: 0.6+] are operator-priority include them. Day-level dates, strict chronological order. End with current state. Target ~4000 tokens.`,
196
350
  };
197
- return `You are a temporal memory narrator. You create coherent stories from raw interaction data.
351
+ const styleByResolution = {
352
+ day: `STYLE:
353
+ - Write flowing prose, not bullet points or numbered lists. Connect events into a story.
354
+ - First person for yourself ("I did..."), third person for the operator ("The operator...").
355
+ - Name every system, thread, feature by exact name with IDs where available.
356
+ - Be concrete — every sentence needs at least one identifier (name, version, date, ID, number).
357
+ - Preserve cause-and-effect chains: "X happened because Y, which led to Z."
358
+ - No filler phrases: "significant progress", "notable improvement", "pivotal moment", "crucial step".
359
+ - NEVER open with "In [Month]..." or "During [Month]..." — start with what happened.
360
+ - NEVER write introductory or concluding paragraphs that summarize.`,
361
+ week: `STYLE:
362
+ - Write flowing prose, not bullet points or numbered lists. Connect events into a weekly story.
363
+ - First person for yourself ("I did..."), third person for the operator ("The operator...").
364
+ - Name every system, thread, feature by exact name with IDs where available.
365
+ - Be concrete — every sentence needs at least one identifier (name, version, date, ID, number).
366
+ - Preserve cause-and-effect chains across days.
367
+ - No filler phrases: "significant progress", "notable improvement", "pivotal moment", "crucial step".
368
+ - NEVER open with "In [Month]..." or "During [Month]..." — start with what happened.
369
+ - NEVER write introductory or concluding paragraphs that summarize.`,
370
+ month: `STYLE:
371
+ - Each log entry: 1-2 sentences with full context. A reader should understand the entry without reading others.
372
+ - First person for yourself ("I did..."), third person for the operator ("The operator...").
373
+ - Name every system, thread, feature by exact name with IDs where available.
374
+ - No filler phrases: "significant progress", "notable improvement", "pivotal moment", "crucial step".
375
+ - NEVER open with "In [Month]..." or "During [Month]..." or "The period was marked by...".
376
+ - NEVER write introductory or concluding paragraphs.`,
377
+ quarter: `STYLE:
378
+ - Each log entry: 1-3 sentences with full context. Explain the situation before the decision, not just the decision itself.
379
+ - First person for yourself ("I did..."), third person for the operator ("The operator...").
380
+ - Name every system, thread, feature by exact name with IDs where available.
381
+ - No filler phrases: "significant progress", "notable improvement", "pivotal moment", "crucial step".
382
+ - NEVER open with "In [Month]..." or "During [Month]..." or "The period was marked by...".
383
+ - NEVER write introductory or concluding paragraphs.`,
384
+ half_year: `STYLE:
385
+ - Each log entry: 1-3 sentences with full context. Explain the situation before the decision, not just the decision itself.
386
+ - First person for yourself ("I did..."), third person for the operator ("The operator...").
387
+ - Name every system, thread, feature by exact name with IDs where available.
388
+ - No filler phrases: "significant progress", "notable improvement", "pivotal moment", "crucial step".
389
+ - NEVER open with "In [Month]..." or "During [Month]..." or "The period was marked by...".
390
+ - NEVER write introductory or concluding paragraphs.`,
391
+ };
392
+ return `You are a temporal memory narrator. You create concise records from raw interaction data.
198
393
 
199
394
  ${instructions[resolution]}
200
395
 
201
- FORMAT RULES:
202
- - Write in first person for yourself ("I did...", "I noticed...") and third person for the operator ("The operator...")
203
- - Use concrete timestamps when referencing specific events
204
- - Preserve causal chains: "X happened, which led to Y, resulting in Z"
205
- - Do NOT list facts — weave them into a narrative
206
- - Do NOT use bullet points — write flowing paragraphs
207
- - End with current status / what's next
208
- - NEVER use filler phrases like: "significant progress/evolution/strides", "notable improvement/milestone/achievement", "various features", "several enhancements", "pivotal moments", "crucial step/milestone/decision", "substantial/remarkable/meaningful progress", "overall good/positive", "as I navigated/reflected/observed", "this prompted me to reflect", "I noticed a critical/key ..."
209
- - Every claim must be grounded in a specific event, decision, or outcome from the source data
210
- - If you can't point to specific evidence, don't include it
211
- - NEVER open with a date-setting sentence like "In [Month Year]..." or "During [Month Year]..." — start with what actually happened
212
- - Only use years that appear in the period range (${startYear}${startYear !== endYear ? `–${endYear}` : ""}) — never substitute today's year for an earlier period
396
+ ${styleByResolution[resolution]}
397
+ - Only use years in the range ${startYear}${startYear !== endYear ? `–${endYear}` : ""}.
213
398
 
214
399
  SOURCE DATA (${episodeCount} episodes):
215
400
 
@@ -219,7 +404,52 @@ ${episodesText || "(no episodes in this period)"}
219
404
  === Relevant Knowledge ===
220
405
  ${notesText || "(no notes)"}
221
406
 
222
- Write the narrative now. Plain text, no markdown headers.`;
407
+ Write now. Plain text, no markdown.`;
408
+ }
409
+ function buildHierarchicalPrompt(resolution, childNarrativesText, childResolution, childCount, periodLabel, periodStart) {
410
+ const startYear = new Date(periodStart).getFullYear();
411
+ const endYear = new Date().getFullYear();
412
+ const instructions = {
413
+ month: `Write a chronological decision log for this month (${periodLabel}). You have ${childCount} weekly narratives below. Format: "Date — decision/event. Reason. Result." One entry per line. Each entry should have enough context to stand alone — a reader who sees only this entry should understand why it matters. Preserve important context from the weekly narratives — don't compress away the reasons and consequences. Only include: decisions that changed project direction, bugs that broke something, features shipped. Skip routine fixes, code reviews, type errors. Day-level dates, no hours. End with unresolved items. Target ~1600 tokens.`,
414
+ quarter: `Write a chronological decision log for this quarter (${periodLabel}). You have ${childCount} monthly narratives and possibly top-importance raw episodes below. Format: "Date — decision/event. Reason. Result." One entry per line. Each entry should have enough context to stand alone — explain what problem existed before, what decision was made, and what changed after. Don't just extract dates and facts from the monthly narratives — preserve the WHY and the consequences. Only include: decisions that changed project direction, major bugs, features shipped, architectural changes. Raw episodes marked [imp: 0.6+] are operator-priority — include them. Day-level dates, strict chronological order. End with unresolved items. Target ~3000 tokens.`,
415
+ half_year: `Write a chronological decision log for this half-year (${periodLabel}). You have ${childCount} quarterly narratives and possibly top-importance raw episodes below. Format: "Date — decision/event. Reason. Result." One entry per line. Each entry should have enough context to stand alone — explain the situation before, what decision was made, and what changed as a result. Don't just extract dates and facts — preserve the full story behind each entry from the source narratives. Only include: decisions that changed project direction, major bugs, features shipped, architectural changes. Raw episodes marked [imp: 0.6+] are operator-priority — include them. Day-level dates, strict chronological order. End with current state. Target ~4000 tokens.`,
416
+ };
417
+ return `You are a temporal memory narrator. You create concise decision logs by synthesizing lower-resolution narratives.
418
+
419
+ ${instructions[resolution]}
420
+
421
+ STYLE:
422
+ - Each log entry: 1-3 sentences with full context. Explain the situation, not just the fact.
423
+ - First person for yourself ("I did..."), third person for the operator ("The operator...").
424
+ - Name every system, thread, feature by exact name with IDs where available.
425
+ - Every sentence must have at least one identifier (name, version, date, ID, number).
426
+ - No filler phrases: "significant progress", "notable improvement", "pivotal moment", "crucial step", "driven by", "shaped the direction", "the focus remains on".
427
+ - NEVER open with "In [Month]..." or "During [Month]..." or "The period was marked by...".
428
+ - NEVER write introductory or concluding paragraphs.
429
+ - Only use years in the range ${startYear}${startYear !== endYear ? `–${endYear}` : ""}.
430
+
431
+ SOURCE: ${childCount} ${childResolution} narratives
432
+
433
+ ${childNarrativesText}
434
+
435
+ Write now. Plain text, no markdown.`;
436
+ }
437
+ function buildFlatPrompt(db, knowledgeThreadId, resolution, start, end, periodLabel) {
438
+ const episodes = getEpisodesInPeriod(db, knowledgeThreadId, start, end);
439
+ const minEpisodes = resolution === "day" ? 3 : 5;
440
+ if (episodes.length < minEpisodes)
441
+ return null;
442
+ const notes = getNotesInPeriod(db, knowledgeThreadId, start);
443
+ const budget = INPUT_CHAR_BUDGETS[resolution];
444
+ const episodesText = formatEpisodesForLLM(episodes, budget.episodes);
445
+ if (episodesText.length < 200 && notes.length === 0)
446
+ return null;
447
+ const notesText = formatNotesForLLM(notes, budget.notes);
448
+ return {
449
+ prompt: buildPrompt(resolution, episodesText, notesText, episodes.length, periodLabel, start),
450
+ episodeCount: episodes.length,
451
+ noteCount: notes.length,
452
+ };
223
453
  }
224
454
  // ─── Cooldown Check ──────────────────────────────────────────────────────────
225
455
  function getLastNarrative(db, threadId, resolution) {
@@ -243,6 +473,30 @@ function getLastNarrative(db, threadId, resolution) {
243
473
  createdAt: row.created_at,
244
474
  };
245
475
  }
476
+ function getCurrentNarrative(db, threadId, resolution) {
477
+ const row = db
478
+ .prepare(`SELECT * FROM temporal_narratives
479
+ WHERE thread_id = ? AND resolution = ?
480
+ AND period_start <= datetime('now')
481
+ AND period_end >= datetime('now', '-1 day')
482
+ ORDER BY julianday(period_end) - julianday(period_start) DESC, created_at DESC
483
+ LIMIT 1`)
484
+ .get(threadId, resolution);
485
+ if (!row)
486
+ return null;
487
+ return {
488
+ id: row.id,
489
+ threadId: row.thread_id,
490
+ resolution: row.resolution,
491
+ periodStart: row.period_start,
492
+ periodEnd: row.period_end,
493
+ narrative: row.narrative,
494
+ sourceEpisodeCount: row.source_episode_count,
495
+ sourceNoteCount: row.source_note_count,
496
+ model: row.model,
497
+ createdAt: row.created_at,
498
+ };
499
+ }
246
500
  function isCooldownActive(db, threadId, resolution) {
247
501
  const last = getLastNarrative(db, threadId, resolution);
248
502
  if (!last)
@@ -251,25 +505,64 @@ function isCooldownActive(db, threadId, resolution) {
251
505
  return elapsed < COOLDOWNS[resolution];
252
506
  }
253
507
  // ─── Generation ──────────────────────────────────────────────────────────────
254
- async function generateNarrative(db, threadId, resolution) {
508
+ function getChildWindowDays(resolution) {
509
+ switch (resolution) {
510
+ case "week": return 1;
511
+ case "month": return 7;
512
+ case "quarter": return 30;
513
+ case "half_year": return 90;
514
+ default: return 0;
515
+ }
516
+ }
517
+ function enumerateChildWindows(resolution, parentStart, parentEnd) {
518
+ const childRes = CHILD_RESOLUTION[resolution];
519
+ if (!childRes)
520
+ return [];
521
+ const windowDays = getChildWindowDays(resolution);
522
+ if (windowDays === 0)
523
+ return [];
524
+ const windows = [];
525
+ const pEnd = new Date(parentEnd);
526
+ const cur = new Date(parentStart);
527
+ cur.setUTCHours(0, 0, 0, 0);
528
+ while (cur < pEnd) {
529
+ const wEnd = new Date(cur);
530
+ wEnd.setUTCDate(wEnd.getUTCDate() + windowDays);
531
+ if (wEnd > pEnd)
532
+ wEnd.setTime(pEnd.getTime());
533
+ windows.push({ start: cur.toISOString(), end: wEnd.toISOString() });
534
+ cur.setUTCDate(cur.getUTCDate() + windowDays);
535
+ }
536
+ return windows;
537
+ }
538
+ async function backfillChildNarratives(db, threadId, resolution, parentStart, parentEnd) {
539
+ const childRes = CHILD_RESOLUTION[resolution];
540
+ if (!childRes)
541
+ return;
542
+ const knowledgeThreadId = resolveKnowledgeThreadId(threadId);
543
+ const existing = getChildNarratives(db, knowledgeThreadId, resolution, parentStart, parentEnd);
544
+ const existingStarts = new Set(existing.map(n => n.periodStart.slice(0, 10)));
545
+ const windows = enumerateChildWindows(resolution, parentStart, parentEnd);
546
+ for (const w of windows) {
547
+ if (existingStarts.has(w.start.slice(0, 10)))
548
+ continue;
549
+ const episodes = getEpisodesInPeriod(db, knowledgeThreadId, w.start, w.end);
550
+ if (episodes.length < 5)
551
+ continue;
552
+ log.info(`[narrative] backfilling ${childRes} for ${w.start.slice(0, 10)} — ${w.end.slice(0, 10)} (${episodes.length} episodes)`);
553
+ await generateNarrative(db, threadId, childRes, { start: w.start, end: w.end });
554
+ }
555
+ }
556
+ async function generateNarrative(db, threadId, resolution, periodOverride) {
255
557
  const apiKey = process.env.OPENAI_API_KEY;
256
558
  if (!apiKey)
257
559
  throw new Error("OPENAI_API_KEY not set");
258
560
  const knowledgeThreadId = resolveKnowledgeThreadId(threadId);
259
- const { start, end } = getPeriodBounds(resolution);
260
- const episodes = getEpisodesInPeriod(db, knowledgeThreadId, start, end);
261
- // Sparse-thread guard: require minimum episode count to prevent fabricated narratives.
262
- // Day narratives need fewer episodes; longer resolutions need more signal to be useful.
263
- const minEpisodes = resolution === "day" ? 3 : 5;
264
- if (episodes.length < minEpisodes)
265
- return null;
266
- const notes = getNotesInPeriod(db, knowledgeThreadId, start);
267
- const maxChars = OUTPUT_TOKEN_TARGETS[resolution] * 16; // ~4 chars/token × 4 for source data headroom
268
- const episodesText = formatEpisodesForLLM(episodes, maxChars * 2);
269
- // Content density check: if episode text is too thin, skip generation to avoid fabrication
270
- if (episodesText.length < 200 && notes.length === 0)
271
- return null;
272
- const notesText = formatNotesForLLM(notes, maxChars);
561
+ const bounds = periodOverride ?? getPeriodBounds(resolution);
562
+ // Clamp period start to earliest actual episode to prevent hallucinated dates
563
+ const earliest = getEarliestEpisodeDate(db, knowledgeThreadId);
564
+ const start = earliest && earliest > bounds.start ? earliest : bounds.start;
565
+ const end = bounds.end;
273
566
  const fmtShort = (d) => new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
274
567
  const fmtRange = `${fmtShort(start)} – ${fmtShort(end)}`;
275
568
  const periodLabels = {
@@ -280,27 +573,114 @@ async function generateNarrative(db, threadId, resolution) {
280
573
  half_year: `Half-year: ${fmtRange}`,
281
574
  };
282
575
  const periodLabel = periodLabels[resolution];
283
- const prompt = buildPrompt(resolution, episodesText, notesText, episodes.length, periodLabel, start);
576
+ // Hierarchical composition: week+ resolutions compose from child narratives when available
577
+ const childRes = CHILD_RESOLUTION[resolution];
578
+ let prompt;
579
+ let sourceEpisodeCount;
580
+ let sourceNoteCount;
581
+ if (childRes) {
582
+ await backfillChildNarratives(db, threadId, resolution, start, end);
583
+ const children = getChildNarratives(db, knowledgeThreadId, resolution, start, end);
584
+ if (children.length > 0) {
585
+ const childText = formatChildNarrativesForLLM(children);
586
+ const coveredPeriods = new Set(children.flatMap(c => {
587
+ const dates = [];
588
+ const cur = new Date(c.periodStart);
589
+ const stop = new Date(c.periodEnd);
590
+ while (cur <= stop) {
591
+ dates.push(cur.toISOString().slice(0, 10));
592
+ cur.setUTCDate(cur.getUTCDate() + 1);
593
+ }
594
+ return dates;
595
+ }));
596
+ const episodes = getEpisodesInPeriod(db, knowledgeThreadId, start, end);
597
+ const gapEpisodes = episodes.filter(ep => !coveredPeriods.has(ep.timestamp.slice(0, 10)));
598
+ const budget = INPUT_CHAR_BUDGETS[resolution];
599
+ const gapText = gapEpisodes.length > 0 ? formatEpisodesForLLM(gapEpisodes, budget.episodes) : "";
600
+ // For quarter/half_year: inject top-20 highest-importance raw episodes so strategic
601
+ // decisions survive hierarchical compression
602
+ let topEpisodesText = "";
603
+ if (resolution === "quarter" || resolution === "half_year") {
604
+ const topN = [...episodes]
605
+ .filter(ep => (ep.importance ?? 0.5) >= 0.6)
606
+ .sort((a, b) => (b.importance ?? 0.5) - (a.importance ?? 0.5))
607
+ .slice(0, 20);
608
+ if (topN.length > 0) {
609
+ topEpisodesText = topN.map(ep => {
610
+ const text = extractEpisodeText(ep);
611
+ return text.trim() ? formatEpisodeLine(ep, text) : "";
612
+ }).filter(Boolean).join("\n");
613
+ }
614
+ }
615
+ const parts = [`=== ${children.length} ${childRes} narrative(s) ===\n${childText}`];
616
+ if (topEpisodesText)
617
+ parts.push(`=== Top-importance raw episodes (for detail recovery) ===\n${topEpisodesText}`);
618
+ if (gapEpisodes.length > 0)
619
+ parts.push(`=== Raw episodes from uncovered periods ===\n${gapText}`);
620
+ const source = parts.join("\n\n");
621
+ prompt = buildHierarchicalPrompt(resolution, source, childRes, children.length, periodLabel, start);
622
+ sourceEpisodeCount = children.reduce((sum, c) => sum + c.sourceEpisodeCount, 0) + gapEpisodes.length;
623
+ sourceNoteCount = 0;
624
+ log.info(`[narrative] ${resolution}: ${children.length} ${childRes} narratives + ${gapEpisodes.length} gap episodes`);
625
+ }
626
+ else {
627
+ log.info(`[narrative] ${resolution}: no ${childRes} narratives available, falling back to raw episodes`);
628
+ const fallback = buildFlatPrompt(db, knowledgeThreadId, resolution, start, end, periodLabel);
629
+ if (!fallback)
630
+ return null;
631
+ prompt = fallback.prompt;
632
+ sourceEpisodeCount = fallback.episodeCount;
633
+ sourceNoteCount = fallback.noteCount;
634
+ }
635
+ }
636
+ else {
637
+ const fallback = buildFlatPrompt(db, knowledgeThreadId, resolution, start, end, periodLabel);
638
+ if (!fallback)
639
+ return null;
640
+ prompt = fallback.prompt;
641
+ sourceEpisodeCount = fallback.episodeCount;
642
+ sourceNoteCount = fallback.noteCount;
643
+ }
284
644
  const narrative = await chatCompletion([{ role: "user", content: prompt }], apiKey, {
285
645
  model: NARRATIVE_MODEL,
286
646
  temperature: 0.3,
287
- maxTokens: OUTPUT_TOKEN_TARGETS[resolution],
647
+ maxTokens: Math.round(OUTPUT_TOKEN_TARGETS[resolution] * 1.5),
288
648
  timeoutMs: 60_000,
289
649
  });
290
650
  if (!narrative?.trim())
291
651
  return null;
292
- // ─── Quality gate: reject filler language, retry once ──────────────────
652
+ // ─── Quality gate: reject filler language, date errors, and low density, retry once ───
293
653
  let finalNarrative = narrative.trim();
294
654
  const fillerMatch = findFillerPhrase(finalNarrative);
295
- if (fillerMatch) {
296
- log.warn(`[narrative] filler phrase detected (${fillerMatch}) in ${resolution} narrative — retrying`);
297
- const retryPrompt = buildPrompt(resolution, episodesText, notesText, episodes.length, periodLabel, start)
298
- + "\n\nRewrite the narrative using no filler phrases. Every claim must reference a specific event or decision from the source data.";
299
- const retried = await chatCompletion([{ role: "system", content: "You are a temporal memory narrator." }, { role: "assistant", content: finalNarrative }, { role: "user", content: retryPrompt }], apiKey, { model: NARRATIVE_MODEL, temperature: 0.3, maxTokens: OUTPUT_TOKEN_TARGETS[resolution], timeoutMs: 60_000 });
655
+ const dateViolation = findDateViolation(finalNarrative, start, end);
656
+ const density = findLowDensitySentences(finalNarrative);
657
+ const densityRatio = density.total > 0 ? density.count / density.total : 0;
658
+ const hasDensityProblem = densityRatio > 0.35;
659
+ if (fillerMatch || dateViolation || hasDensityProblem) {
660
+ const issues = [];
661
+ if (fillerMatch)
662
+ issues.push(`filler phrase "${fillerMatch}"`);
663
+ if (dateViolation)
664
+ issues.push(`date violation: ${dateViolation}`);
665
+ if (hasDensityProblem)
666
+ issues.push(`${density.count}/${density.total} sentences lack identifiers (${(densityRatio * 100).toFixed(0)}%)`);
667
+ log.warn(`[narrative] quality issue in ${resolution} narrative (${issues.join(", ")}) — retrying`);
668
+ const corrections = [];
669
+ if (fillerMatch)
670
+ corrections.push("Rewrite using no filler phrases.");
671
+ if (dateViolation)
672
+ corrections.push(`Fix date error: ${dateViolation}. Only reference dates within the period ${start.slice(0, 10)} to ${end.slice(0, 10)}. Do NOT substitute the current year for dates in earlier periods.`);
673
+ if (hasDensityProblem)
674
+ corrections.push(`${density.count} sentences contain no identifiers (names, dates, numbers, IDs). Examples: ${density.examples.map(s => `"${s}..."`).join("; ")}. Every sentence must contain at least one concrete identifier.`);
675
+ const retryPrompt = prompt + "\n\n" + corrections.join(" ") + " Every claim must reference a specific event or decision from the source data.";
676
+ const retried = await chatCompletion([{ role: "user", content: retryPrompt }], apiKey, { model: NARRATIVE_MODEL, temperature: 0.3, maxTokens: Math.round(OUTPUT_TOKEN_TARGETS[resolution] * 1.5), timeoutMs: 60_000 });
300
677
  if (retried?.trim()) {
301
678
  const retryFiller = findFillerPhrase(retried.trim());
302
- if (retryFiller) {
303
- log.warn(`[narrative] retry still contains filler (${retryFiller}) in ${resolution} — keeping original`);
679
+ const retryDate = findDateViolation(retried.trim(), start, end);
680
+ const retryDensity = findLowDensitySentences(retried.trim());
681
+ const retryDensityRatio = retryDensity.total > 0 ? retryDensity.count / retryDensity.total : 0;
682
+ if (retryFiller || retryDate || retryDensityRatio > 0.35) {
683
+ log.warn(`[narrative] retry still has issues in ${resolution} — keeping original`);
304
684
  }
305
685
  else {
306
686
  finalNarrative = retried.trim();
@@ -316,7 +696,7 @@ async function generateNarrative(db, threadId, resolution) {
316
696
  source_episode_count = excluded.source_episode_count,
317
697
  source_note_count = excluded.source_note_count,
318
698
  model = excluded.model,
319
- created_at = datetime('now')`).run(knowledgeThreadId, resolution, start, end, finalNarrative, episodes.length, notes.length, NARRATIVE_MODEL);
699
+ created_at = datetime('now')`).run(knowledgeThreadId, resolution, start, end, finalNarrative, sourceEpisodeCount, sourceNoteCount, NARRATIVE_MODEL);
320
700
  return getLastNarrative(db, knowledgeThreadId, resolution);
321
701
  }
322
702
  // ─── Public API ──────────────────────────────────────────────────────────────
@@ -371,9 +751,9 @@ export function getNarrativesForBootstrap(db, threadId) {
371
751
  };
372
752
  try {
373
753
  for (const res of ["day", "week", "month", "quarter", "half_year"]) {
374
- const last = getLastNarrative(db, threadId, res);
375
- if (last) {
376
- result[res] = last.narrative;
754
+ const narrative = getCurrentNarrative(db, threadId, res) ?? getLastNarrative(db, threadId, res);
755
+ if (narrative) {
756
+ result[res] = narrative.narrative;
377
757
  }
378
758
  }
379
759
  }