sensorium-mcp 3.0.54 → 3.0.56

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.
Files changed (42) hide show
  1. package/dist/data/file-storage.d.ts +2 -1
  2. package/dist/data/file-storage.d.ts.map +1 -1
  3. package/dist/data/file-storage.js +24 -6
  4. package/dist/data/file-storage.js.map +1 -1
  5. package/dist/data/memory/narrative.d.ts +5 -5
  6. package/dist/data/memory/narrative.d.ts.map +1 -1
  7. package/dist/data/memory/narrative.js +475 -71
  8. package/dist/data/memory/narrative.js.map +1 -1
  9. package/dist/response-builders.d.ts.map +1 -1
  10. package/dist/response-builders.js +2 -1
  11. package/dist/response-builders.js.map +1 -1
  12. package/dist/services/agent-spawn.service.d.ts.map +1 -1
  13. package/dist/services/agent-spawn.service.js +22 -14
  14. package/dist/services/agent-spawn.service.js.map +1 -1
  15. package/dist/services/dispatcher/lock.d.ts +17 -2
  16. package/dist/services/dispatcher/lock.d.ts.map +1 -1
  17. package/dist/services/dispatcher/lock.js +125 -13
  18. package/dist/services/dispatcher/lock.js.map +1 -1
  19. package/dist/services/dispatcher/poller.d.ts.map +1 -1
  20. package/dist/services/dispatcher/poller.js +9 -8
  21. package/dist/services/dispatcher/poller.js.map +1 -1
  22. package/dist/services/keeper.service.d.ts.map +1 -1
  23. package/dist/services/keeper.service.js +7 -1
  24. package/dist/services/keeper.service.js.map +1 -1
  25. package/dist/services/process.service.d.ts.map +1 -1
  26. package/dist/services/process.service.js +32 -9
  27. package/dist/services/process.service.js.map +1 -1
  28. package/dist/services/self-update.service.d.ts.map +1 -1
  29. package/dist/services/self-update.service.js +2 -26
  30. package/dist/services/self-update.service.js.map +1 -1
  31. package/dist/telegram.d.ts.map +1 -1
  32. package/dist/telegram.js +19 -8
  33. package/dist/telegram.js.map +1 -1
  34. package/dist/tools/session-tools.js +2 -4
  35. package/dist/tools/session-tools.js.map +1 -1
  36. package/dist/tools/wait/poll-loop.d.ts +1 -1
  37. package/dist/tools/wait/poll-loop.d.ts.map +1 -1
  38. package/dist/tools/wait/poll-loop.js +15 -0
  39. package/dist/tools/wait/poll-loop.js.map +1 -1
  40. package/package.json +2 -1
  41. package/templates/azure-devops-securevault.default.md +12 -0
  42. package/templates/md-to-pdf.default.md +36 -0
@@ -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,63 @@ 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 decision no 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 narrative overview of this quarter (${periodLabel}) organized by initiative. Group by subsystem (e.g. "Dashboard", "Memory system", "Narrative pipeline", "Session management"). For each initiative, write 1-2 flowing paragraphs that tell the STORY of how it evolved: what the state was at the start of the quarter, what problems appeared, what decisions were made and why, and where it ended up. Reference specific dates, versions, and IDs as evidence within the prose — but do NOT use a list or log format. The reader should understand the arc of each initiative from a connected narrative, not from scanning dated entries. Include initiatives where direction changed, major bugs appeared, features shipped, or architecture shifted. Episodes marked [imp: 0.6+] are operator-priority — weave them in. End with a short "Unresolved" section. Target ~3000 tokens.`,
349
+ half_year: `Write a high-level evolution summary for this half-year (${periodLabel}). Do NOT repeat the quarterly grouped changelog — instead, synthesize it into 5-8 thematic arcs that show HOW the project evolved. For each arc: what the system looked like at the start, what forces drove change, and where it ended up. Example arcs: "Memory system evolution", "Session management maturity", "Narrative pipeline development". Within each arc, reference specific dates and versions as evidence, but the focus is the trajectory, not individual events. End with a "Current state" section: what works, what's fragile, what's next. 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
+ - Start each initiative with its name on its own line, then write 1-2 prose paragraphs. NO dated entries, NO log format.
379
+ - Write flowing connected narrative — "The memory system entered the quarter with X problem. In April, Y happened because Z, which led to W by May."
380
+ - First person for yourself ("I did..."), third person for the operator ("The operator...").
381
+ - Name every system, thread, feature by exact name with IDs where available.
382
+ - Reference dates and versions naturally within prose, not as entry prefixes.
383
+ - Show cross-month cause-effect — how one month's decisions shaped the next.
384
+ - No filler phrases: "significant progress", "notable improvement", "pivotal moment", "crucial step".
385
+ - NEVER open with "In [Month]..." or "During [Month]..." or "The period was marked by...".
386
+ - NEVER write introductory or concluding paragraphs.`,
387
+ half_year: `STYLE:
388
+ - Write in thematic arc paragraphs, not a chronological list. Each arc = 1-2 paragraphs.
389
+ - First person for yourself ("I did..."), third person for the operator ("The operator...").
390
+ - Name every system, thread, feature by exact name with IDs where available.
391
+ - Within each arc, reference specific dates and versions as evidence of the trajectory.
392
+ - No filler phrases: "significant progress", "notable improvement", "pivotal moment", "crucial step".
393
+ - NEVER open with "In [Month]..." or "During [Month]..." or "The period was marked by...".
394
+ - NEVER write an introductory paragraph. Start directly with the first arc.`,
395
+ };
396
+ return `You are a temporal memory narrator. You create concise records from raw interaction data.
198
397
 
199
398
  ${instructions[resolution]}
200
399
 
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
400
+ ${styleByResolution[resolution]}
401
+ - Only use years in the range ${startYear}${startYear !== endYear ? `–${endYear}` : ""}.
213
402
 
214
403
  SOURCE DATA (${episodeCount} episodes):
215
404
 
@@ -219,7 +408,72 @@ ${episodesText || "(no episodes in this period)"}
219
408
  === Relevant Knowledge ===
220
409
  ${notesText || "(no notes)"}
221
410
 
222
- Write the narrative now. Plain text, no markdown headers.`;
411
+ Write now. Plain text, no markdown.`;
412
+ }
413
+ function buildHierarchicalPrompt(resolution, childNarrativesText, childResolution, childCount, periodLabel, periodStart) {
414
+ const startYear = new Date(periodStart).getFullYear();
415
+ const endYear = new Date().getFullYear();
416
+ const instructions = {
417
+ 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.`,
418
+ quarter: `Write a narrative overview of this quarter (${periodLabel}) organized by initiative. You have ${childCount} monthly narratives and possibly top-importance raw episodes below. Group by subsystem (e.g. "Dashboard", "Memory system", "Narrative pipeline", "Session management"). For each initiative, write 1-2 flowing paragraphs that tell the STORY of how it evolved: what the state was at the start, what problems appeared, what decisions were made and why, and where it ended up. Reference specific dates, versions, and IDs within the prose — but do NOT use a list or log format. Synthesize the monthly narratives into a connected story, don't just re-list their entries. The reader should understand each initiative's arc from prose, not from scanning dated lines. Raw episodes marked [imp: 0.6+] are operator-priority — weave them in. End with a short "Unresolved" section. Target ~3000 tokens.`,
419
+ half_year: `Write a high-level evolution summary for this half-year (${periodLabel}). You have ${childCount} quarterly changelogs and possibly top-importance raw episodes below. Do NOT repeat the quarterly grouped changelogs — instead, synthesize them into 5-8 thematic arcs that show HOW the project evolved over 6 months. For each arc: what the system looked like at the start of the period, what forces drove change (operator feedback, bugs, scaling needs), and where it ended up. Example arcs: "Memory system evolution", "Session management maturity", "Narrative pipeline development". Within each arc, reference specific dates and versions as evidence, but the focus is the trajectory — not individual events. End with a "Current state" section: what works, what's fragile, what's next. Target ~4000 tokens.`,
420
+ };
421
+ const styleByResolution = {
422
+ month: `STYLE:
423
+ - Each log entry: 1-3 sentences with full context. Explain the situation, not just the fact.
424
+ - First person for yourself ("I did..."), third person for the operator ("The operator...").
425
+ - Name every system, thread, feature by exact name with IDs where available.
426
+ - Every sentence must have at least one identifier (name, version, date, ID, number).
427
+ - No filler phrases: "significant progress", "notable improvement", "pivotal moment", "crucial step", "driven by", "shaped the direction", "the focus remains on".
428
+ - NEVER open with "In [Month]..." or "During [Month]..." or "The period was marked by...".
429
+ - NEVER write introductory or concluding paragraphs.`,
430
+ quarter: `STYLE:
431
+ - Start each initiative with its name on its own line, then write 1-2 prose paragraphs. NO dated entries, NO log format.
432
+ - Write flowing connected narrative that tells the story of each initiative's evolution.
433
+ - First person for yourself ("I did..."), third person for the operator ("The operator...").
434
+ - Name every system, thread, feature by exact name with IDs where available.
435
+ - Reference dates and versions naturally within prose, not as entry prefixes.
436
+ - No filler phrases: "significant progress", "notable improvement", "pivotal moment", "crucial step", "driven by", "shaped the direction", "the focus remains on".
437
+ - NEVER open with "In [Month]..." or "During [Month]..." or "The period was marked by...".
438
+ - NEVER write introductory or concluding paragraphs.`,
439
+ half_year: `STYLE:
440
+ - Write in thematic arc paragraphs. Each arc = 1-2 paragraphs showing system evolution.
441
+ - First person for yourself ("I did..."), third person for the operator ("The operator...").
442
+ - Name every system, thread, feature by exact name with IDs where available.
443
+ - Reference dates and versions as evidence of the trajectory within prose.
444
+ - No filler phrases: "significant progress", "notable improvement", "pivotal moment", "crucial step", "driven by", "shaped the direction", "the focus remains on".
445
+ - NEVER open with "In [Month]..." or "During [Month]..." or "The period was marked by...".
446
+ - NEVER write introductory or concluding paragraphs.`,
447
+ };
448
+ return `You are a temporal memory narrator. You synthesize lower-resolution narratives into higher-level summaries.
449
+
450
+ ${instructions[resolution]}
451
+
452
+ ${styleByResolution[resolution] || ""}
453
+ - Only use years in the range ${startYear}${startYear !== endYear ? `–${endYear}` : ""}.
454
+
455
+ SOURCE: ${childCount} ${childResolution} narratives
456
+
457
+ ${childNarrativesText}
458
+
459
+ Write now. Plain text, no markdown.`;
460
+ }
461
+ function buildFlatPrompt(db, knowledgeThreadId, resolution, start, end, periodLabel) {
462
+ const episodes = getEpisodesInPeriod(db, knowledgeThreadId, start, end);
463
+ const minEpisodes = resolution === "day" ? 3 : 5;
464
+ if (episodes.length < minEpisodes)
465
+ return null;
466
+ const notes = getNotesInPeriod(db, knowledgeThreadId, start);
467
+ const budget = INPUT_CHAR_BUDGETS[resolution];
468
+ const episodesText = formatEpisodesForLLM(episodes, budget.episodes);
469
+ if (episodesText.length < 200 && notes.length === 0)
470
+ return null;
471
+ const notesText = formatNotesForLLM(notes, budget.notes);
472
+ return {
473
+ prompt: buildPrompt(resolution, episodesText, notesText, episodes.length, periodLabel, start),
474
+ episodeCount: episodes.length,
475
+ noteCount: notes.length,
476
+ };
223
477
  }
224
478
  // ─── Cooldown Check ──────────────────────────────────────────────────────────
225
479
  function getLastNarrative(db, threadId, resolution) {
@@ -243,6 +497,30 @@ function getLastNarrative(db, threadId, resolution) {
243
497
  createdAt: row.created_at,
244
498
  };
245
499
  }
500
+ function getCurrentNarrative(db, threadId, resolution) {
501
+ const row = db
502
+ .prepare(`SELECT * FROM temporal_narratives
503
+ WHERE thread_id = ? AND resolution = ?
504
+ AND period_start <= datetime('now')
505
+ AND period_end >= datetime('now', '-1 day')
506
+ ORDER BY julianday(period_end) - julianday(period_start) DESC, created_at DESC
507
+ LIMIT 1`)
508
+ .get(threadId, resolution);
509
+ if (!row)
510
+ return null;
511
+ return {
512
+ id: row.id,
513
+ threadId: row.thread_id,
514
+ resolution: row.resolution,
515
+ periodStart: row.period_start,
516
+ periodEnd: row.period_end,
517
+ narrative: row.narrative,
518
+ sourceEpisodeCount: row.source_episode_count,
519
+ sourceNoteCount: row.source_note_count,
520
+ model: row.model,
521
+ createdAt: row.created_at,
522
+ };
523
+ }
246
524
  function isCooldownActive(db, threadId, resolution) {
247
525
  const last = getLastNarrative(db, threadId, resolution);
248
526
  if (!last)
@@ -251,25 +529,64 @@ function isCooldownActive(db, threadId, resolution) {
251
529
  return elapsed < COOLDOWNS[resolution];
252
530
  }
253
531
  // ─── Generation ──────────────────────────────────────────────────────────────
254
- async function generateNarrative(db, threadId, resolution) {
532
+ function getChildWindowDays(resolution) {
533
+ switch (resolution) {
534
+ case "week": return 1;
535
+ case "month": return 7;
536
+ case "quarter": return 30;
537
+ case "half_year": return 90;
538
+ default: return 0;
539
+ }
540
+ }
541
+ function enumerateChildWindows(resolution, parentStart, parentEnd) {
542
+ const childRes = CHILD_RESOLUTION[resolution];
543
+ if (!childRes)
544
+ return [];
545
+ const windowDays = getChildWindowDays(resolution);
546
+ if (windowDays === 0)
547
+ return [];
548
+ const windows = [];
549
+ const pEnd = new Date(parentEnd);
550
+ const cur = new Date(parentStart);
551
+ cur.setUTCHours(0, 0, 0, 0);
552
+ while (cur < pEnd) {
553
+ const wEnd = new Date(cur);
554
+ wEnd.setUTCDate(wEnd.getUTCDate() + windowDays);
555
+ if (wEnd > pEnd)
556
+ wEnd.setTime(pEnd.getTime());
557
+ windows.push({ start: cur.toISOString(), end: wEnd.toISOString() });
558
+ cur.setUTCDate(cur.getUTCDate() + windowDays);
559
+ }
560
+ return windows;
561
+ }
562
+ async function backfillChildNarratives(db, threadId, resolution, parentStart, parentEnd) {
563
+ const childRes = CHILD_RESOLUTION[resolution];
564
+ if (!childRes)
565
+ return;
566
+ const knowledgeThreadId = resolveKnowledgeThreadId(threadId);
567
+ const existing = getChildNarratives(db, knowledgeThreadId, resolution, parentStart, parentEnd);
568
+ const existingStarts = new Set(existing.map(n => n.periodStart.slice(0, 10)));
569
+ const windows = enumerateChildWindows(resolution, parentStart, parentEnd);
570
+ for (const w of windows) {
571
+ if (existingStarts.has(w.start.slice(0, 10)))
572
+ continue;
573
+ const episodes = getEpisodesInPeriod(db, knowledgeThreadId, w.start, w.end);
574
+ if (episodes.length < 5)
575
+ continue;
576
+ log.info(`[narrative] backfilling ${childRes} for ${w.start.slice(0, 10)} — ${w.end.slice(0, 10)} (${episodes.length} episodes)`);
577
+ await generateNarrative(db, threadId, childRes, { start: w.start, end: w.end });
578
+ }
579
+ }
580
+ async function generateNarrative(db, threadId, resolution, periodOverride) {
255
581
  const apiKey = process.env.OPENAI_API_KEY;
256
582
  if (!apiKey)
257
583
  throw new Error("OPENAI_API_KEY not set");
258
584
  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);
585
+ const bounds = periodOverride ?? getPeriodBounds(resolution);
586
+ // Clamp period start to earliest actual episode to prevent hallucinated dates
587
+ const earliest = getEarliestEpisodeDate(db, knowledgeThreadId);
588
+ const start = earliest && earliest > bounds.start ? earliest : bounds.start;
589
+ const end = bounds.end;
273
590
  const fmtShort = (d) => new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
274
591
  const fmtRange = `${fmtShort(start)} – ${fmtShort(end)}`;
275
592
  const periodLabels = {
@@ -280,27 +597,114 @@ async function generateNarrative(db, threadId, resolution) {
280
597
  half_year: `Half-year: ${fmtRange}`,
281
598
  };
282
599
  const periodLabel = periodLabels[resolution];
283
- const prompt = buildPrompt(resolution, episodesText, notesText, episodes.length, periodLabel, start);
600
+ // Hierarchical composition: week+ resolutions compose from child narratives when available
601
+ const childRes = CHILD_RESOLUTION[resolution];
602
+ let prompt;
603
+ let sourceEpisodeCount;
604
+ let sourceNoteCount;
605
+ if (childRes) {
606
+ await backfillChildNarratives(db, threadId, resolution, start, end);
607
+ const children = getChildNarratives(db, knowledgeThreadId, resolution, start, end);
608
+ if (children.length > 0) {
609
+ const childText = formatChildNarrativesForLLM(children);
610
+ const coveredPeriods = new Set(children.flatMap(c => {
611
+ const dates = [];
612
+ const cur = new Date(c.periodStart);
613
+ const stop = new Date(c.periodEnd);
614
+ while (cur <= stop) {
615
+ dates.push(cur.toISOString().slice(0, 10));
616
+ cur.setUTCDate(cur.getUTCDate() + 1);
617
+ }
618
+ return dates;
619
+ }));
620
+ const episodes = getEpisodesInPeriod(db, knowledgeThreadId, start, end);
621
+ const gapEpisodes = episodes.filter(ep => !coveredPeriods.has(ep.timestamp.slice(0, 10)));
622
+ const budget = INPUT_CHAR_BUDGETS[resolution];
623
+ const gapText = gapEpisodes.length > 0 ? formatEpisodesForLLM(gapEpisodes, budget.episodes) : "";
624
+ // For quarter/half_year: inject top-20 highest-importance raw episodes so strategic
625
+ // decisions survive hierarchical compression
626
+ let topEpisodesText = "";
627
+ if (resolution === "quarter" || resolution === "half_year") {
628
+ const topN = [...episodes]
629
+ .filter(ep => (ep.importance ?? 0.5) >= 0.6)
630
+ .sort((a, b) => (b.importance ?? 0.5) - (a.importance ?? 0.5))
631
+ .slice(0, 20);
632
+ if (topN.length > 0) {
633
+ topEpisodesText = topN.map(ep => {
634
+ const text = extractEpisodeText(ep);
635
+ return text.trim() ? formatEpisodeLine(ep, text) : "";
636
+ }).filter(Boolean).join("\n");
637
+ }
638
+ }
639
+ const parts = [`=== ${children.length} ${childRes} narrative(s) ===\n${childText}`];
640
+ if (topEpisodesText)
641
+ parts.push(`=== Top-importance raw episodes (for detail recovery) ===\n${topEpisodesText}`);
642
+ if (gapEpisodes.length > 0)
643
+ parts.push(`=== Raw episodes from uncovered periods ===\n${gapText}`);
644
+ const source = parts.join("\n\n");
645
+ prompt = buildHierarchicalPrompt(resolution, source, childRes, children.length, periodLabel, start);
646
+ sourceEpisodeCount = children.reduce((sum, c) => sum + c.sourceEpisodeCount, 0) + gapEpisodes.length;
647
+ sourceNoteCount = 0;
648
+ log.info(`[narrative] ${resolution}: ${children.length} ${childRes} narratives + ${gapEpisodes.length} gap episodes`);
649
+ }
650
+ else {
651
+ log.info(`[narrative] ${resolution}: no ${childRes} narratives available, falling back to raw episodes`);
652
+ const fallback = buildFlatPrompt(db, knowledgeThreadId, resolution, start, end, periodLabel);
653
+ if (!fallback)
654
+ return null;
655
+ prompt = fallback.prompt;
656
+ sourceEpisodeCount = fallback.episodeCount;
657
+ sourceNoteCount = fallback.noteCount;
658
+ }
659
+ }
660
+ else {
661
+ const fallback = buildFlatPrompt(db, knowledgeThreadId, resolution, start, end, periodLabel);
662
+ if (!fallback)
663
+ return null;
664
+ prompt = fallback.prompt;
665
+ sourceEpisodeCount = fallback.episodeCount;
666
+ sourceNoteCount = fallback.noteCount;
667
+ }
284
668
  const narrative = await chatCompletion([{ role: "user", content: prompt }], apiKey, {
285
669
  model: NARRATIVE_MODEL,
286
670
  temperature: 0.3,
287
- maxTokens: OUTPUT_TOKEN_TARGETS[resolution],
671
+ maxTokens: Math.round(OUTPUT_TOKEN_TARGETS[resolution] * 1.5),
288
672
  timeoutMs: 60_000,
289
673
  });
290
674
  if (!narrative?.trim())
291
675
  return null;
292
- // ─── Quality gate: reject filler language, retry once ──────────────────
676
+ // ─── Quality gate: reject filler language, date errors, and low density, retry once ───
293
677
  let finalNarrative = narrative.trim();
294
678
  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 });
679
+ const dateViolation = findDateViolation(finalNarrative, start, end);
680
+ const density = findLowDensitySentences(finalNarrative);
681
+ const densityRatio = density.total > 0 ? density.count / density.total : 0;
682
+ const hasDensityProblem = densityRatio > 0.35;
683
+ if (fillerMatch || dateViolation || hasDensityProblem) {
684
+ const issues = [];
685
+ if (fillerMatch)
686
+ issues.push(`filler phrase "${fillerMatch}"`);
687
+ if (dateViolation)
688
+ issues.push(`date violation: ${dateViolation}`);
689
+ if (hasDensityProblem)
690
+ issues.push(`${density.count}/${density.total} sentences lack identifiers (${(densityRatio * 100).toFixed(0)}%)`);
691
+ log.warn(`[narrative] quality issue in ${resolution} narrative (${issues.join(", ")}) — retrying`);
692
+ const corrections = [];
693
+ if (fillerMatch)
694
+ corrections.push("Rewrite using no filler phrases.");
695
+ if (dateViolation)
696
+ 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.`);
697
+ if (hasDensityProblem)
698
+ 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.`);
699
+ const retryPrompt = prompt + "\n\n" + corrections.join(" ") + " Every claim must reference a specific event or decision from the source data.";
700
+ 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
701
  if (retried?.trim()) {
301
702
  const retryFiller = findFillerPhrase(retried.trim());
302
- if (retryFiller) {
303
- log.warn(`[narrative] retry still contains filler (${retryFiller}) in ${resolution} — keeping original`);
703
+ const retryDate = findDateViolation(retried.trim(), start, end);
704
+ const retryDensity = findLowDensitySentences(retried.trim());
705
+ const retryDensityRatio = retryDensity.total > 0 ? retryDensity.count / retryDensity.total : 0;
706
+ if (retryFiller || retryDate || retryDensityRatio > 0.35) {
707
+ log.warn(`[narrative] retry still has issues in ${resolution} — keeping original`);
304
708
  }
305
709
  else {
306
710
  finalNarrative = retried.trim();
@@ -316,7 +720,7 @@ async function generateNarrative(db, threadId, resolution) {
316
720
  source_episode_count = excluded.source_episode_count,
317
721
  source_note_count = excluded.source_note_count,
318
722
  model = excluded.model,
319
- created_at = datetime('now')`).run(knowledgeThreadId, resolution, start, end, finalNarrative, episodes.length, notes.length, NARRATIVE_MODEL);
723
+ created_at = datetime('now')`).run(knowledgeThreadId, resolution, start, end, finalNarrative, sourceEpisodeCount, sourceNoteCount, NARRATIVE_MODEL);
320
724
  return getLastNarrative(db, knowledgeThreadId, resolution);
321
725
  }
322
726
  // ─── Public API ──────────────────────────────────────────────────────────────
@@ -371,9 +775,9 @@ export function getNarrativesForBootstrap(db, threadId) {
371
775
  };
372
776
  try {
373
777
  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;
778
+ const narrative = getCurrentNarrative(db, threadId, res) ?? getLastNarrative(db, threadId, res);
779
+ if (narrative) {
780
+ result[res] = narrative.narrative;
377
781
  }
378
782
  }
379
783
  }