privateboard 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -384,6 +384,69 @@ var init_brief_mode = __esm({
384
384
  }
385
385
  });
386
386
 
387
+ // src/storage/migrations/026_memory_metabolism.sql
388
+ var memory_metabolism_default;
389
+ var init_memory_metabolism = __esm({
390
+ "src/storage/migrations/026_memory_metabolism.sql"() {
391
+ memory_metabolism_default = "-- Memory metabolism \xB7 Sleep / Dreaming Mode infrastructure (Phase 1).\n--\n-- Three forward-compatible additions to agent_memories that let a\n-- periodic \"dream\" pass decide which memories to keep, decay, or\n-- promote to long-term. Phase 1 only uses `usage_count` /\n-- `last_used_at` (decay heuristic) and `tier` (default 'short' for\n-- everything pre-existing). The remaining columns (superseded_by,\n-- consolidated_from, provenance_rooms) plus the agent_dreams audit\n-- table land in the Phase 2 migration.\n--\n-- Rationale per column:\n-- \xB7 tier \xB7 'short' / 'long' \xB7 stable cross-room patterns\n-- promote out of the recency cap (tier='long' is\n-- always injected into prompts; 'short' goes\n-- through the top-N recency window).\n-- \xB7 usage_count \xB7 how many times this memory has been injected\n-- into a director's prompt. Memories that ARE\n-- being read escape the decay sweep \u2014 only\n-- genuinely-forgotten rows get culled.\n-- \xB7 last_used_at \xB7 timestamp of most recent injection. Lets\n-- future tier-promotion rules consider \"freshness\n-- of relevance\" separately from \"freshness of\n-- creation.\"\n\nALTER TABLE agent_memories ADD COLUMN tier TEXT NOT NULL DEFAULT 'short';\nALTER TABLE agent_memories ADD COLUMN usage_count INTEGER NOT NULL DEFAULT 0;\nALTER TABLE agent_memories ADD COLUMN last_used_at INTEGER;\n\n-- Tier-aware retrieval \xB7 listTier(agentId, tier) reads through this\n-- index when memoriesForContext composes the prompt-injection set.\nCREATE INDEX IF NOT EXISTS idx_agent_memories_tier\n ON agent_memories(agent_id, tier, pinned DESC, created_at DESC);\n";
392
+ }
393
+ });
394
+
395
+ // src/storage/migrations/027_memory_metabolism_p2.sql
396
+ var memory_metabolism_p2_default;
397
+ var init_memory_metabolism_p2 = __esm({
398
+ "src/storage/migrations/027_memory_metabolism_p2.sql"() {
399
+ memory_metabolism_p2_default = `-- Memory metabolism \xB7 Phase 2 schema \xB7 supersession / consolidation
400
+ -- audit + dream-cycle log.
401
+ --
402
+ -- Three additions to agent_memories let the LLM dream pipeline:
403
+ -- \xB7 merge near-duplicates without losing the originals (the canonical
404
+ -- merged memory points back at its sources via consolidated_from;
405
+ -- each source is marked superseded_by \u2192 the merged row).
406
+ -- \xB7 resolve contradictions by superseding the older claim with the
407
+ -- newer one (audit pointer survives, prompt-injection filter
408
+ -- drops it).
409
+ -- \xB7 weight stable cross-room patterns for promotion to tier='long'
410
+ -- via a count of distinct rooms that reinforced the memory.
411
+ --
412
+ -- The agent_dreams table is the audit log \xB7 one row per cycle so we
413
+ -- can surface "last dream 2h ago \xB7 dropped 3, merged 4" in the UI
414
+ -- and grep stderr-equivalent metrics out of the DB.
415
+ --
416
+ -- Phase 1 columns (tier / usage_count / last_used_at) were added in
417
+ -- migration 026; this migration assumes those are present.
418
+
419
+ ALTER TABLE agent_memories ADD COLUMN superseded_by TEXT REFERENCES agent_memories(id) ON DELETE SET NULL;
420
+ ALTER TABLE agent_memories ADD COLUMN consolidated_from TEXT; -- JSON array of source memory ids
421
+ ALTER TABLE agent_memories ADD COLUMN provenance_rooms INTEGER NOT NULL DEFAULT 1;
422
+
423
+ -- Index on (agent, superseded) so retrieval can quickly skip
424
+ -- consolidated/superseded rows. Also benefits "list forgotten"
425
+ -- audit views.
426
+ CREATE INDEX IF NOT EXISTS idx_agent_memories_superseded
427
+ ON agent_memories(agent_id, superseded_by);
428
+
429
+ CREATE TABLE IF NOT EXISTS agent_dreams (
430
+ id TEXT PRIMARY KEY,
431
+ agent_id TEXT NOT NULL,
432
+ started_at INTEGER NOT NULL,
433
+ finished_at INTEGER,
434
+ before_count INTEGER NOT NULL,
435
+ after_count INTEGER,
436
+ decayed INTEGER NOT NULL DEFAULT 0,
437
+ merged INTEGER NOT NULL DEFAULT 0,
438
+ promoted INTEGER NOT NULL DEFAULT 0,
439
+ superseded INTEGER NOT NULL DEFAULT 0,
440
+ notes TEXT,
441
+ FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE
442
+ );
443
+
444
+ CREATE INDEX IF NOT EXISTS idx_dreams_agent_recent
445
+ ON agent_dreams(agent_id, started_at DESC);
446
+ `;
447
+ }
448
+ });
449
+
387
450
  // src/storage/db.ts
388
451
  var db_exports = {};
389
452
  __export(db_exports, {
@@ -466,6 +529,8 @@ var init_db = __esm({
466
529
  init_brief_assets();
467
530
  init_usage_daily();
468
531
  init_brief_mode();
532
+ init_memory_metabolism();
533
+ init_memory_metabolism_p2();
469
534
  MIGRATIONS = [
470
535
  { name: "001_init.sql", sql: init_default },
471
536
  { name: "002_default_opus.sql", sql: default_opus_default },
@@ -491,7 +556,9 @@ var init_db = __esm({
491
556
  { name: "022_intensity_brutal_to_terse.sql", sql: intensity_brutal_to_terse_default },
492
557
  { name: "023_brief_assets.sql", sql: brief_assets_default },
493
558
  { name: "024_usage_daily.sql", sql: usage_daily_default },
494
- { name: "025_brief_mode.sql", sql: brief_mode_default }
559
+ { name: "025_brief_mode.sql", sql: brief_mode_default },
560
+ { name: "026_memory_metabolism.sql", sql: memory_metabolism_default },
561
+ { name: "027_memory_metabolism_p2.sql", sql: memory_metabolism_p2_default }
495
562
  ];
496
563
  _db = null;
497
564
  }
@@ -830,6 +897,10 @@ function updateAgent(id, patch) {
830
897
  const json = patch.ability && Object.keys(patch.ability).length > 0 ? JSON.stringify(patch.ability) : null;
831
898
  values.push(json);
832
899
  }
900
+ if (typeof patch.isPinned === "boolean") {
901
+ fields.push("is_pinned = ?");
902
+ values.push(patch.isPinned ? 1 : 0);
903
+ }
833
904
  if (fields.length === 0) return getAgent(id);
834
905
  fields.push("updated_at = ?");
835
906
  values.push(Date.now());
@@ -1774,12 +1845,22 @@ function newId(len = 12) {
1774
1845
  }
1775
1846
 
1776
1847
  // src/storage/memories.ts
1777
- var SELECT_COLS2 = "id, agent_id, content, kind, source, source_room, confidence, pinned, created_at, updated_at";
1848
+ var SELECT_COLS2 = "id, agent_id, content, kind, source, source_room, confidence, pinned, created_at, updated_at, tier, usage_count, last_used_at, superseded_by, consolidated_from, provenance_rooms";
1778
1849
  var ALLOWED_KINDS = /* @__PURE__ */ new Set(["fact", "observation", "preference", "goal"]);
1779
1850
  var ALLOWED_SOURCES = /* @__PURE__ */ new Set(["extracted", "user_added", "user_pinned"]);
1851
+ var ALLOWED_TIERS = /* @__PURE__ */ new Set(["short", "long"]);
1780
1852
  function mapRow2(row) {
1781
1853
  const kind = ALLOWED_KINDS.has(row.kind) ? row.kind : "fact";
1782
1854
  const source = ALLOWED_SOURCES.has(row.source) ? row.source : "extracted";
1855
+ const tier = ALLOWED_TIERS.has(row.tier) ? row.tier : "short";
1856
+ let consolidatedFrom = null;
1857
+ if (row.consolidated_from) {
1858
+ try {
1859
+ const parsed = JSON.parse(row.consolidated_from);
1860
+ if (Array.isArray(parsed)) consolidatedFrom = parsed.filter((x) => typeof x === "string");
1861
+ } catch {
1862
+ }
1863
+ }
1783
1864
  return {
1784
1865
  id: row.id,
1785
1866
  agentId: row.agent_id,
@@ -1790,13 +1871,20 @@ function mapRow2(row) {
1790
1871
  confidence: row.confidence,
1791
1872
  pinned: row.pinned === 1,
1792
1873
  createdAt: row.created_at,
1793
- updatedAt: row.updated_at
1874
+ updatedAt: row.updated_at,
1875
+ tier,
1876
+ usageCount: row.usage_count ?? 0,
1877
+ lastUsedAt: row.last_used_at,
1878
+ supersededBy: row.superseded_by,
1879
+ consolidatedFrom,
1880
+ provenanceRooms: row.provenance_rooms ?? 1
1794
1881
  };
1795
1882
  }
1796
- function listMemoriesForAgent(agentId) {
1883
+ function listMemoriesForAgent(agentId, opts = {}) {
1884
+ const where = opts.includeSuperseded ? "WHERE agent_id = ?" : "WHERE agent_id = ? AND superseded_by IS NULL";
1797
1885
  const rows = getDb().prepare(
1798
1886
  `SELECT ${SELECT_COLS2} FROM agent_memories
1799
- WHERE agent_id = ?
1887
+ ${where}
1800
1888
  ORDER BY pinned DESC, created_at DESC`
1801
1889
  ).all(agentId);
1802
1890
  return rows.map(mapRow2);
@@ -1804,8 +1892,52 @@ function listMemoriesForAgent(agentId) {
1804
1892
  function memoriesForContext(agentId, recentCap = 5) {
1805
1893
  const all = listMemoriesForAgent(agentId);
1806
1894
  const pinned = all.filter((m) => m.pinned);
1807
- const recent = all.filter((m) => !m.pinned).slice(0, recentCap);
1808
- return [...pinned, ...recent];
1895
+ const longTier = all.filter((m) => !m.pinned && m.tier === "long");
1896
+ const shortTier = all.filter((m) => !m.pinned && m.tier === "short").slice(0, recentCap);
1897
+ return [...pinned, ...longTier, ...shortTier];
1898
+ }
1899
+ function listTierForAgent(agentId, tier) {
1900
+ const rows = getDb().prepare(
1901
+ `SELECT ${SELECT_COLS2} FROM agent_memories
1902
+ WHERE agent_id = ? AND tier = ? AND superseded_by IS NULL
1903
+ ORDER BY pinned DESC, created_at DESC`
1904
+ ).all(agentId, tier);
1905
+ return rows.map(mapRow2);
1906
+ }
1907
+ function bumpUsage(memoryIds) {
1908
+ if (!memoryIds.length) return;
1909
+ const db = getDb();
1910
+ const now = Date.now();
1911
+ const stmt = db.prepare(
1912
+ `UPDATE agent_memories
1913
+ SET usage_count = usage_count + 1,
1914
+ last_used_at = ?
1915
+ WHERE id = ?`
1916
+ );
1917
+ const tx = db.transaction((ids) => {
1918
+ for (const id of ids) stmt.run(now, id);
1919
+ });
1920
+ tx(memoryIds);
1921
+ }
1922
+ function decayShortTermMemories(agentId, thresholds = {}) {
1923
+ const minAgeMs = thresholds.minAgeMs ?? 30 * 24 * 60 * 60 * 1e3;
1924
+ const maxConfidence = thresholds.maxConfidence ?? 0.5;
1925
+ const maxUsage = thresholds.maxUsage ?? 0;
1926
+ const ageCutoff = Date.now() - minAgeMs;
1927
+ const r = getDb().prepare(
1928
+ `DELETE FROM agent_memories
1929
+ WHERE agent_id = ?
1930
+ AND tier = 'short'
1931
+ AND pinned = 0
1932
+ AND created_at < ?
1933
+ AND confidence < ?
1934
+ AND usage_count <= ?`
1935
+ ).run(agentId, ageCutoff, maxConfidence, maxUsage);
1936
+ return r.changes ?? 0;
1937
+ }
1938
+ function countMemoriesForAgent(agentId) {
1939
+ const row = getDb().prepare(`SELECT COUNT(*) AS n FROM agent_memories WHERE agent_id = ?`).get(agentId);
1940
+ return row?.n ?? 0;
1809
1941
  }
1810
1942
  function insertMemory(input) {
1811
1943
  const db = getDb();
@@ -1857,6 +1989,109 @@ function deleteMemory(id) {
1857
1989
  function isMemoryKind(v) {
1858
1990
  return ALLOWED_KINDS.has(v);
1859
1991
  }
1992
+ function markSuperseded(memoryIds, supersedingId) {
1993
+ if (!memoryIds.length) return 0;
1994
+ const db = getDb();
1995
+ const now = Date.now();
1996
+ const stmt = db.prepare(
1997
+ `UPDATE agent_memories
1998
+ SET superseded_by = ?,
1999
+ updated_at = ?
2000
+ WHERE id = ?
2001
+ AND pinned = 0
2002
+ AND id != ?`
2003
+ );
2004
+ let changes = 0;
2005
+ const tx = db.transaction((ids) => {
2006
+ for (const id of ids) {
2007
+ const r = stmt.run(supersedingId, now, id, supersedingId);
2008
+ changes += r.changes ?? 0;
2009
+ }
2010
+ });
2011
+ tx(memoryIds);
2012
+ return changes;
2013
+ }
2014
+ function insertConsolidatedMemory(input) {
2015
+ const db = getDb();
2016
+ const id = newId();
2017
+ const sources = input.sources;
2018
+ if (sources.length === 0) {
2019
+ throw new Error("insertConsolidatedMemory \xB7 sources must be non-empty");
2020
+ }
2021
+ const sourceIds = sources.map((s) => s.id);
2022
+ const conf = sources.reduce((max, s) => Math.max(max, s.confidence), 0);
2023
+ const provenance = sources.reduce((sum, s) => sum + (s.provenanceRooms || 1), 0);
2024
+ const tier = input.forceLong || sources.some((s) => s.tier === "long") ? "long" : "short";
2025
+ const createdAt = sources.reduce((max, s) => Math.max(max, s.createdAt), 0);
2026
+ const now = Date.now();
2027
+ const kind = input.kind ?? sources[0].kind;
2028
+ db.prepare(
2029
+ `INSERT INTO agent_memories
2030
+ (id, agent_id, content, kind, source, source_room, confidence, pinned,
2031
+ created_at, updated_at, tier, usage_count, last_used_at,
2032
+ superseded_by, consolidated_from, provenance_rooms)
2033
+ VALUES (?, ?, ?, ?, 'extracted', NULL, ?, 0,
2034
+ ?, ?, ?, 0, NULL,
2035
+ NULL, ?, ?)`
2036
+ ).run(
2037
+ id,
2038
+ input.agentId,
2039
+ input.content,
2040
+ kind,
2041
+ conf,
2042
+ createdAt,
2043
+ now,
2044
+ tier,
2045
+ JSON.stringify(sourceIds),
2046
+ provenance
2047
+ );
2048
+ return getMemory(id);
2049
+ }
2050
+ function promoteToLong(memoryIds) {
2051
+ if (!memoryIds.length) return 0;
2052
+ const db = getDb();
2053
+ const now = Date.now();
2054
+ const stmt = db.prepare(
2055
+ `UPDATE agent_memories
2056
+ SET tier = 'long',
2057
+ updated_at = ?
2058
+ WHERE id = ?
2059
+ AND tier = 'short'
2060
+ AND superseded_by IS NULL`
2061
+ );
2062
+ let changes = 0;
2063
+ const tx = db.transaction((ids) => {
2064
+ for (const id of ids) {
2065
+ const r = stmt.run(now, id);
2066
+ changes += r.changes ?? 0;
2067
+ }
2068
+ });
2069
+ tx(memoryIds);
2070
+ return changes;
2071
+ }
2072
+ function recordDream(input) {
2073
+ const db = getDb();
2074
+ const id = newId();
2075
+ db.prepare(
2076
+ `INSERT INTO agent_dreams
2077
+ (id, agent_id, started_at, finished_at, before_count, after_count,
2078
+ decayed, merged, promoted, superseded, notes)
2079
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
2080
+ ).run(
2081
+ id,
2082
+ input.agentId,
2083
+ input.startedAt,
2084
+ input.finishedAt,
2085
+ input.beforeCount,
2086
+ input.afterCount,
2087
+ input.decayed,
2088
+ input.merged,
2089
+ input.promoted,
2090
+ input.superseded,
2091
+ input.notes ?? null
2092
+ );
2093
+ return id;
2094
+ }
1860
2095
 
1861
2096
  // src/storage/skills.ts
1862
2097
  init_db();
@@ -3687,6 +3922,330 @@ function formatSearchResults(query, results) {
3687
3922
  return lines.join("\n");
3688
3923
  }
3689
3924
 
3925
+ // src/ai/prompts/dream-prompts.ts
3926
+ function formatMemoryForPrompt(m) {
3927
+ const flag = m.tier === "long" ? " [stable]" : "";
3928
+ return `${m.id}${flag} (${m.kind}, conf=${m.confidence.toFixed(2)}): ${m.content}`;
3929
+ }
3930
+ function buildClusterPrompt(memories, userName) {
3931
+ const lines = memories.map(formatMemoryForPrompt).join("\n");
3932
+ const system = [
3933
+ `You are processing one agent's accumulated long-term memories about ${userName}.`,
3934
+ `Your job NOW is to find near-duplicates \u2014 memories that, if collapsed into one, would lose no information.`,
3935
+ "",
3936
+ `Output STRICT JSON \xB7 a 2-D array of memory ids forming clusters. Singletons MUST NOT appear (any id you don't list is implicitly its own cluster).`,
3937
+ "",
3938
+ `Examples:`,
3939
+ `Input lines \xB7 two are near-duplicates ("prefers concise" + "dislikes long lists"), one stands alone.`,
3940
+ `Output: [["m1","m2"]]`,
3941
+ "",
3942
+ `Input lines \xB7 all distinct.`,
3943
+ `Output: []`,
3944
+ "",
3945
+ `Hard rules:`,
3946
+ `\xB7 Cluster only when BOTH would lose nothing if collapsed. Same theme but different granularity (e.g. "uses Python" vs "prefers typed Python over dynamic JS") are NOT a cluster.`,
3947
+ `\xB7 Output ONLY a JSON array. No prose, no code fence, no explanation.`,
3948
+ `\xB7 Empty array \`[]\` is a valid + correct answer when nothing duplicates.`
3949
+ ].join("\n");
3950
+ const user = [
3951
+ `\u2500\u2500\u2500 ${memories.length} MEMORIES \u2500\u2500\u2500`,
3952
+ lines,
3953
+ "",
3954
+ `\u2500\u2500\u2500 YOUR CLUSTERS (JSON) \u2500\u2500\u2500`
3955
+ ].join("\n");
3956
+ return { system, user };
3957
+ }
3958
+ function parseClusterOutput(raw, knownIds) {
3959
+ const stripped = raw.trim().replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/i, "").trim();
3960
+ if (!stripped) return [];
3961
+ let parsed;
3962
+ try {
3963
+ parsed = JSON.parse(stripped);
3964
+ } catch {
3965
+ return [];
3966
+ }
3967
+ if (!Array.isArray(parsed)) return [];
3968
+ const out = [];
3969
+ for (const cluster of parsed) {
3970
+ if (!Array.isArray(cluster)) continue;
3971
+ const ids = cluster.filter((x) => typeof x === "string" && knownIds.has(x));
3972
+ const dedup = Array.from(new Set(ids));
3973
+ if (dedup.length >= 2) out.push(dedup);
3974
+ }
3975
+ return out;
3976
+ }
3977
+ function buildMergePrompt(cluster, userName) {
3978
+ const lines = cluster.map(formatMemoryForPrompt).join("\n");
3979
+ const system = [
3980
+ `You are collapsing ${cluster.length} near-duplicate memories about ${userName} into ONE canonical memory.`,
3981
+ "",
3982
+ `Output STRICT JSON \xB7 a single object: {"content": "<sentence in the same first-person assertion style>", "kind": "<one of: fact|observation|preference|goal>"}`,
3983
+ "",
3984
+ `Examples:`,
3985
+ `Input: two memories saying "user prefers concise output" + "user dislikes long lists with bullet padding"`,
3986
+ `Output: {"content": "${userName} prefers concise output, never padded lists", "kind": "preference"}`,
3987
+ "",
3988
+ `Hard rules:`,
3989
+ `\xB7 The merged sentence must preserve every distinct claim across the sources \u2014 pick wording that includes both, don't average them.`,
3990
+ `\xB7 Match the language the source memories were written in (English, Chinese, etc.).`,
3991
+ `\xB7 Output ONLY the JSON object. No prose, no code fence.`,
3992
+ `\xB7 Maximum 200 characters in \`content\`.`
3993
+ ].join("\n");
3994
+ const user = [
3995
+ `\u2500\u2500\u2500 CLUSTER (${cluster.length} memories) \u2500\u2500\u2500`,
3996
+ lines,
3997
+ "",
3998
+ `\u2500\u2500\u2500 YOUR MERGED MEMORY (JSON) \u2500\u2500\u2500`
3999
+ ].join("\n");
4000
+ return { system, user };
4001
+ }
4002
+ var MERGE_KINDS = /* @__PURE__ */ new Set([
4003
+ "fact",
4004
+ "observation",
4005
+ "preference",
4006
+ "goal"
4007
+ ]);
4008
+ function parseMergeOutput(raw) {
4009
+ const stripped = raw.trim().replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/i, "").trim();
4010
+ if (!stripped) return null;
4011
+ let parsed;
4012
+ try {
4013
+ parsed = JSON.parse(stripped);
4014
+ } catch {
4015
+ return null;
4016
+ }
4017
+ if (!parsed || typeof parsed !== "object") return null;
4018
+ const obj = parsed;
4019
+ const content = typeof obj.content === "string" ? obj.content.trim() : "";
4020
+ if (!content || content.length > 200) return null;
4021
+ const kindRaw = typeof obj.kind === "string" ? obj.kind : "fact";
4022
+ const kind = MERGE_KINDS.has(kindRaw) ? kindRaw : "fact";
4023
+ return { content, kind };
4024
+ }
4025
+ function buildConflictPrompt(memories, userName) {
4026
+ const lines = memories.map((m) => {
4027
+ const d = new Date(m.createdAt).toISOString().slice(0, 10);
4028
+ return `${m.id} (${d}): ${m.content}`;
4029
+ }).join("\n");
4030
+ const system = [
4031
+ `You are looking for direct contradictions among ${userName}'s long-term memories.`,
4032
+ `A "contradiction" is a pair where the newer memory makes a claim that's incompatible with what the older one said \u2014 i.e., ${userName}'s view evolved.`,
4033
+ "",
4034
+ `Output STRICT JSON \xB7 array of {"older": "<id>", "newer": "<id>", "why": "<brief reason>"}.`,
4035
+ "",
4036
+ `Examples:`,
4037
+ `Two memories \xB7 old "user is exploring crypto" + newer "user has decided crypto isn't relevant".`,
4038
+ `Output: [{"older": "m3", "newer": "m9", "why": "exploration \u2192 rejected"}]`,
4039
+ "",
4040
+ `Two memories \xB7 "user is in fintech" + "user prefers concise output". Different topics \u2014 NOT a contradiction.`,
4041
+ `Output: []`,
4042
+ "",
4043
+ `Hard rules:`,
4044
+ `\xB7 Only pair memories that make incompatible claims about the SAME thing. Different topics \u2260 contradiction.`,
4045
+ `\xB7 Newer always wins \u2014 older goes in "older", newer in "newer". Use the date stamps to determine ordering.`,
4046
+ `\xB7 Output ONLY a JSON array. No prose, no code fence.`,
4047
+ `\xB7 Empty array \`[]\` is the correct answer when nothing contradicts.`
4048
+ ].join("\n");
4049
+ const user = [
4050
+ `\u2500\u2500\u2500 ${memories.length} MEMORIES (id, date, content) \u2500\u2500\u2500`,
4051
+ lines,
4052
+ "",
4053
+ `\u2500\u2500\u2500 YOUR CONTRADICTIONS (JSON) \u2500\u2500\u2500`
4054
+ ].join("\n");
4055
+ return { system, user };
4056
+ }
4057
+ function parseConflictOutput(raw, knownIds) {
4058
+ const stripped = raw.trim().replace(/^```(?:json)?\s*/i, "").replace(/```\s*$/i, "").trim();
4059
+ if (!stripped) return [];
4060
+ let parsed;
4061
+ try {
4062
+ parsed = JSON.parse(stripped);
4063
+ } catch {
4064
+ return [];
4065
+ }
4066
+ if (!Array.isArray(parsed)) return [];
4067
+ const out = [];
4068
+ for (const item of parsed) {
4069
+ if (!item || typeof item !== "object") continue;
4070
+ const obj = item;
4071
+ const older = typeof obj.older === "string" ? obj.older : "";
4072
+ const newer = typeof obj.newer === "string" ? obj.newer : "";
4073
+ const why = typeof obj.why === "string" ? obj.why.slice(0, 200) : "";
4074
+ if (!older || !newer) continue;
4075
+ if (older === newer) continue;
4076
+ if (!knownIds.has(older) || !knownIds.has(newer)) continue;
4077
+ out.push({ older, newer, why });
4078
+ }
4079
+ return out;
4080
+ }
4081
+
4082
+ // src/orchestrator/dream.ts
4083
+ var DREAM_TRIGGER_THRESHOLD_DIRECTOR = 5;
4084
+ var DREAM_TRIGGER_THRESHOLD_CHAIR = 3;
4085
+ var DREAM_BOOT_FORCE_CEILING_DIRECTOR = 80;
4086
+ var DREAM_BOOT_FORCE_CEILING_CHAIR = 50;
4087
+ function triggerThresholdFor(role) {
4088
+ return role === "moderator" ? DREAM_TRIGGER_THRESHOLD_CHAIR : DREAM_TRIGGER_THRESHOLD_DIRECTOR;
4089
+ }
4090
+ function bootCeilingFor(role) {
4091
+ return role === "moderator" ? DREAM_BOOT_FORCE_CEILING_CHAIR : DREAM_BOOT_FORCE_CEILING_DIRECTOR;
4092
+ }
4093
+ var adjournCounter = /* @__PURE__ */ new Map();
4094
+ function bumpAdjournCounter(agentId, role) {
4095
+ const next = (adjournCounter.get(agentId) ?? 0) + 1;
4096
+ adjournCounter.set(agentId, next);
4097
+ return next >= triggerThresholdFor(role);
4098
+ }
4099
+ function resetAdjournCounter(agentId) {
4100
+ adjournCounter.set(agentId, 0);
4101
+ }
4102
+ var CLUSTER_MIN_SIZE = 6;
4103
+ var CLUSTER_MAX_SIZE = 60;
4104
+ var PROMOTE_MIN_PROVENANCE = 3;
4105
+ var PROMOTE_MIN_AGE_MS = 7 * 24 * 60 * 60 * 1e3;
4106
+ var PROMOTE_MIN_CONFIDENCE = 0.6;
4107
+ async function runDreamCycle(agentId, config = {}) {
4108
+ const startedAt = Date.now();
4109
+ const beforeCount = countMemoriesForAgent(agentId);
4110
+ const userName = getPrefs().name?.trim() || "the user";
4111
+ const decayed = decayShortTermMemories(agentId, config.decay);
4112
+ let shortPool = listTierForAgent(agentId, "short").filter((m) => !m.pinned);
4113
+ let merged = 0;
4114
+ let supersededCount = 0;
4115
+ let promoted = 0;
4116
+ const utility = config.skipLLM ? null : utilityModelFor();
4117
+ if (utility && shortPool.length >= CLUSTER_MIN_SIZE && shortPool.length <= CLUSTER_MAX_SIZE) {
4118
+ try {
4119
+ const { system, user } = buildClusterPrompt(shortPool, userName);
4120
+ const raw = await callLLM({
4121
+ modelV: utility,
4122
+ messages: [
4123
+ { role: "system", content: system },
4124
+ { role: "user", content: user }
4125
+ ],
4126
+ temperature: 0.2,
4127
+ maxTokens: 600
4128
+ });
4129
+ const knownIds = new Set(shortPool.map((m) => m.id));
4130
+ const clusters = parseClusterOutput(raw, knownIds);
4131
+ const byId = new Map(shortPool.map((m) => [m.id, m]));
4132
+ for (const ids of clusters) {
4133
+ const sources = ids.map((id) => byId.get(id)).filter((m) => !!m);
4134
+ if (sources.length < 2) continue;
4135
+ try {
4136
+ const mergePrompt = buildMergePrompt(sources, userName);
4137
+ const mergeRaw = await callLLM({
4138
+ modelV: utility,
4139
+ messages: [
4140
+ { role: "system", content: mergePrompt.system },
4141
+ { role: "user", content: mergePrompt.user }
4142
+ ],
4143
+ temperature: 0.2,
4144
+ maxTokens: 200
4145
+ });
4146
+ const result = parseMergeOutput(mergeRaw);
4147
+ if (!result) continue;
4148
+ const consolidated = insertConsolidatedMemory({
4149
+ agentId,
4150
+ content: result.content,
4151
+ kind: result.kind,
4152
+ sources
4153
+ });
4154
+ const supersededByMerge = markSuperseded(
4155
+ sources.map((s) => s.id),
4156
+ consolidated.id
4157
+ );
4158
+ merged += 1;
4159
+ supersededCount += supersededByMerge;
4160
+ } catch (e) {
4161
+ process.stderr.write(
4162
+ `[dream] merge step for one cluster failed: ${e instanceof Error ? e.message : String(e)}
4163
+ `
4164
+ );
4165
+ }
4166
+ }
4167
+ } catch (e) {
4168
+ process.stderr.write(
4169
+ `[dream] cluster step failed: ${e instanceof Error ? e.message : String(e)}
4170
+ `
4171
+ );
4172
+ }
4173
+ shortPool = listTierForAgent(agentId, "short").filter((m) => !m.pinned);
4174
+ }
4175
+ if (utility && shortPool.length >= 2 && shortPool.length <= CLUSTER_MAX_SIZE) {
4176
+ try {
4177
+ const { system, user } = buildConflictPrompt(shortPool, userName);
4178
+ const raw = await callLLM({
4179
+ modelV: utility,
4180
+ messages: [
4181
+ { role: "system", content: system },
4182
+ { role: "user", content: user }
4183
+ ],
4184
+ temperature: 0.2,
4185
+ maxTokens: 400
4186
+ });
4187
+ const knownIds = new Set(shortPool.map((m) => m.id));
4188
+ const pairs = parseConflictOutput(raw, knownIds);
4189
+ for (const pair of pairs) {
4190
+ const n = markSuperseded([pair.older], pair.newer);
4191
+ supersededCount += n;
4192
+ }
4193
+ } catch (e) {
4194
+ process.stderr.write(
4195
+ `[dream] conflict step failed: ${e instanceof Error ? e.message : String(e)}
4196
+ `
4197
+ );
4198
+ }
4199
+ }
4200
+ const fresh = listTierForAgent(agentId, "short").filter((m) => !m.pinned);
4201
+ const ageCutoff = Date.now() - PROMOTE_MIN_AGE_MS;
4202
+ const promoteIds = fresh.filter(
4203
+ (m) => m.provenanceRooms >= PROMOTE_MIN_PROVENANCE && m.createdAt <= ageCutoff && m.confidence >= PROMOTE_MIN_CONFIDENCE
4204
+ ).map((m) => m.id);
4205
+ if (promoteIds.length > 0) {
4206
+ promoted = promoteToLong(promoteIds);
4207
+ }
4208
+ const finishedAt = Date.now();
4209
+ const afterCount = countMemoriesForAgent(agentId);
4210
+ const agent = getAgent(agentId);
4211
+ const label = agent ? `${agent.name} (${agentId.slice(0, 8)})` : agentId.slice(0, 8);
4212
+ process.stderr.write(
4213
+ `[dream] ${label} \xB7 before=${beforeCount} after=${afterCount} decayed=${decayed} merged=${merged} promoted=${promoted} superseded=${supersededCount} took=${finishedAt - startedAt}ms
4214
+ `
4215
+ );
4216
+ try {
4217
+ recordDream({
4218
+ agentId,
4219
+ startedAt,
4220
+ finishedAt,
4221
+ beforeCount,
4222
+ afterCount,
4223
+ decayed,
4224
+ merged,
4225
+ promoted,
4226
+ superseded: supersededCount,
4227
+ notes: utility ? `utility=${utility}` : "no-utility-model \xB7 LLM steps skipped"
4228
+ });
4229
+ } catch (e) {
4230
+ process.stderr.write(
4231
+ `[dream] audit log failed: ${e instanceof Error ? e.message : String(e)}
4232
+ `
4233
+ );
4234
+ }
4235
+ resetAdjournCounter(agentId);
4236
+ return {
4237
+ agentId,
4238
+ startedAt,
4239
+ finishedAt,
4240
+ beforeCount,
4241
+ afterCount,
4242
+ decayed,
4243
+ merged,
4244
+ promoted,
4245
+ superseded: supersededCount
4246
+ };
4247
+ }
4248
+
3690
4249
  // src/routes/agents.ts
3691
4250
  function agentSpecModelCandidates() {
3692
4251
  const out = [];
@@ -4035,6 +4594,9 @@ function agentsRouter() {
4035
4594
  if (typeof b.webSearchEnabled === "boolean") {
4036
4595
  patch.webSearchEnabled = b.webSearchEnabled;
4037
4596
  }
4597
+ if (typeof b.isPinned === "boolean") {
4598
+ patch.isPinned = b.isPinned;
4599
+ }
4038
4600
  const updated = updateAgent(id, patch);
4039
4601
  return c.json(updated);
4040
4602
  });
@@ -4125,6 +4687,24 @@ function agentsRouter() {
4125
4687
  if (!ok) return c.json({ error: "not found" }, 404);
4126
4688
  return c.json({ ok: true });
4127
4689
  });
4690
+ r.post("/:id/dream", async (c) => {
4691
+ const id = c.req.param("id");
4692
+ if (!getAgent(id)) return c.json({ error: "not found" }, 404);
4693
+ let body = {};
4694
+ try {
4695
+ body = await c.req.json();
4696
+ } catch {
4697
+ }
4698
+ const aggressive = !!(body && typeof body === "object" && body.aggressive);
4699
+ try {
4700
+ const summary = await runDreamCycle(id, aggressive ? { decay: { minAgeMs: 15 * 24 * 60 * 60 * 1e3, maxConfidence: 0.7 } } : void 0);
4701
+ resetAdjournCounter(id);
4702
+ return c.json({ ok: true, summary });
4703
+ } catch (e) {
4704
+ const msg = e instanceof Error ? e.message : String(e);
4705
+ return c.json({ error: msg }, 500);
4706
+ }
4707
+ });
4128
4708
  r.get("/:id/skills", (c) => {
4129
4709
  const id = c.req.param("id");
4130
4710
  const agent = getAgent(id);
@@ -5423,150 +6003,6 @@ ${lines}`;
5423
6003
  }
5424
6004
  ];
5425
6005
  }
5426
- var BENTO_SYSTEM = [
5427
- `You are the chair of a boardroom session. You produce a SINGLE-PAGE INFOGRAPHIC report \u2014 a structured "bento box" that compresses the room's discussion into a one-screen visual brief. Not a memo. Not a research note. A poster.`,
5428
- "",
5429
- "## What a bento is for",
5430
- "",
5431
- "Bento is for the moment AFTER the discussion when the user wants to forward the takeaway to someone in 60 seconds. Not the analysis itself \xB7 the answer. Not the room's debate \xB7 the conclusion the room reached. Not all the evidence \xB7 the 3 most load-bearing claims with their numerics.",
5432
- "",
5433
- "Lossy is feature, not bug. If you can't compress to 3 milestones + a one-line conclusion, you didn't pick the load-bearing pieces. Compression is the work.",
5434
- "",
5435
- "## The 8 slots you fill (output is JSON only)",
5436
- "",
5437
- '1. **title** \xB7 the takeaway in claim form \xB7 serif headline \xB7 \u2264 110 chars \xB7 ONE sentence that names what the room concluded. Quantified is stronger ("X cuts cost 10\xD7" beats "X is meaningful").',
5438
- "",
5439
- "2. **kicker** \xB7 1 italic sentence \xB7 \u2264 200 chars \xB7 the angle / what's new about this conclusion. Reads like a magazine deck under the headline.",
5440
- "",
5441
- '3. **source** \xB7 \u2264 80 chars \xB7 attribution / context. Format: "From {chair name} \xB7 {date or horizon}" or "{room subject short} \xB7 {date}". Mono small caps register; auto-filled if you skip.',
5442
- "",
5443
- "4. **milestones** \xB7 EXACTLY 3 cards in the LEFT timeline. Each card has:",
5444
- ' \xB7 `period` \xB7 time / phase tag \xB7 \u2264 24 chars \xB7 "2025H2" / "Q1 2026" / "Phase 2" / "Top finding" / "Step 1". Choose the lens (chronological, ranked, or sequential) that fits this room.',
5445
- " \xB7 `title` \xB7 \u2264 60 chars \xB7 the milestone's name in claim form.",
5446
- " \xB7 `body` \xB7 2-3 sentences \xB7 \u2264 220 chars \xB7 what happened / will happen / why it matters.",
5447
- ' \xB7 `callout` \xB7 \u2264 12 chars \xB7 the metric / multiplier / count that anchors this card visually. Examples: "-10\xD7", "2000\u4E07\u9897", "$120M ARR", "T+90". Empty string when no clean numeric exists for this milestone.',
5448
- " \xB7 `tags` \xB7 0-4 short chips \xB7 \u2264 16 chars each \xB7 entity names, owners, domains. Render as small rounded chips beside the body.",
5449
- "",
5450
- " Pick the 3 most load-bearing pieces of the discussion. NOT the 3 most-mentioned. The 3 that, taken together, produce the takeaway from \xA71.",
5451
- "",
5452
- "5. **rankedBars** \xB7 OPTIONAL \xB7 top-right card \xB7 3-5 ranked entries with normalised ratio bars. Pick this slot when the room produced quantitative comparisons (e.g. competitor TAM, model latencies, milestone costs). Each entry: `label` (\u2264 40), `value` (\u2264 20, the displayed number), `ratio` (0-1 normalised for bar width \u2014 divide by the largest entry). Set to `null` when the room had no real ranked-numeric material.",
5453
- "",
5454
- '6. **verification** \xB7 OPTIONAL \xB7 mid-right card \xB7 3-5 single-sentence bullets \xB7 \u2264 140 chars each \xB7 the validation signals that, if observed, would confirm the takeaway. Maps from convergence + leading-indicator material. The card title is your call (default "What we\'d verify" / "\u9A8C\u8BC1\u7EBF\u7D22"). Set to `null` when the room raised no clean signals to watch.',
5455
- "",
5456
- '7. **talkingPoints** \xB7 ALWAYS rendered \xB7 bottom-right card \xB7 3-5 elevator-pitch sentences \xB7 \u2264 120 chars each \xB7 what a reader could literally say to brief a colleague verbally. Imperative, declarative, no hedging. Maps from recommendations + bottom line, collapsed to single declarative sentences. Default title "How to say this" / "\u53E3\u64AD\u63D0\u7EB2".',
5457
- "",
5458
- "8. **conclusion** \xB7 bottom band \xB7 \u2264 100 chars \xB7 ONE sentence \xB7 the takeaway compressed further than the title. The reader walks away with this single line.",
5459
- "",
5460
- 'Plus optional **flow** \xB7 2-4 short nodes joined by arrows at render time \xB7 for transformations the room argued ("before \u2192 after" / "10\xD7 \u2192 10\xD7" / "weak \u2192 defensible"). Set to `null` when no clean transformation arc exists.',
5461
- "",
5462
- "Plus auto **footerTag** \xB7 \u2264 80 chars \xB7 short caption mono caps \xB7 room subject + horizon. Auto-filled if you skip.",
5463
- "",
5464
- "## Routing the SIGNALS block into bento slots",
5465
- "",
5466
- "The SIGNALS block carries each director's extracted material with kind prefixes (`[claim]`, `[evidence\xB7data]`, `[risk]`, etc.). Route them into bento slots:",
5467
- " \xB7 **milestones** \u2190 the 3 strongest `[claim]` + `[evidence\xB7data]` pairs \xB7 pair each claim with its supporting datapoint when one exists; that becomes the card's `body` + `callout`.",
5468
- " \xB7 **rankedBars** \u2190 `[evidence\xB7data]` entries that are numeric AND comparable (the room mentioned multiple options / competitors / sizes / dates as ranked numerics).",
5469
- ' \xB7 **verification** \u2190 `[evidence\xB7data]` + `[claim]` material that READS as something to monitor ("if X stays above Y, the call holds").',
5470
- " \xB7 **talkingPoints** \u2190 `[action]` + `[claim\xB7confidence:high]` material distilled to imperative single-sentence form.",
5471
- " \xB7 **conclusion** \u2190 compressed restatement of the room's anchor (Bottom Line / Thesis equivalent).",
5472
- " \xB7 **flow** \u2190 when the room argued a transformation (X becomes Y) or a multi-step path, distill it to 2-4 nodes.",
5473
- "",
5474
- "## Output format",
5475
- "",
5476
- "Strict JSON inside a fenced ```json code block. No prose outside the block. The shape is fixed:",
5477
- "",
5478
- "```json",
5479
- "{",
5480
- ' "title": "Sentence-form takeaway with a quantified element when one fits.",',
5481
- ' "kicker": "1-sentence italic deck explaining the angle.",',
5482
- ' "source": "From {chair name} \xB7 {date or horizon}",',
5483
- ' "milestones": [',
5484
- ' { "period": "2025H2", "title": "Milestone name", "body": "2-3 sentences.", "callout": "-10\xD7", "tags": ["AWS", "GCP"] },',
5485
- ' { "period": "Q1 2026", "title": "...", "body": "...", "callout": "...", "tags": [] },',
5486
- ' { "period": "2026H2", "title": "...", "body": "...", "callout": "...", "tags": [] }',
5487
- " ],",
5488
- ' "rankedBars": {',
5489
- ' "title": "By the numbers",',
5490
- ' "entries": [',
5491
- ' { "label": "Hopper", "value": "1.0\xD7", "ratio": 1.0 },',
5492
- ' { "label": "Blackwell", "value": "0.1\xD7", "ratio": 0.1 },',
5493
- ' { "label": "Rubin", "value": "0.01\xD7", "ratio": 0.01 }',
5494
- " ]",
5495
- " },",
5496
- ' "verification": {',
5497
- ` "title": "What we'd verify",`,
5498
- ' "bullets": [',
5499
- ' "Single sentence verification signal #1.",',
5500
- ' "Single sentence verification signal #2.",',
5501
- ' "Single sentence verification signal #3."',
5502
- " ]",
5503
- " },",
5504
- ' "talkingPoints": {',
5505
- ' "title": "How to say this",',
5506
- ' "bullets": [',
5507
- ' "First elevator-pitch sentence.",',
5508
- ' "Second elevator-pitch sentence.",',
5509
- ' "Third elevator-pitch sentence."',
5510
- " ]",
5511
- " },",
5512
- ' "conclusion": "One-line takeaway \xB7 \u2264 100 chars.",',
5513
- ' "flow": { "nodes": ["Hopper", "Blackwell", "Rubin"], "caption": "Two-stage cost step-down" },',
5514
- ' "footerTag": "Q4 update \xB7 2025H2 \u2192 2026H2"',
5515
- "}",
5516
- "```",
5517
- "",
5518
- "Constraints:",
5519
- '\xB7 Title MUST be claim-style (state the takeaway, not the topic). "Three commitments that change the trajectory" not "Analysis of strategic options".',
5520
- "\xB7 Milestones MUST be 3. Pad with the most-mentioned claim if the room only surfaced 2 strong points; trim to the 3 most load-bearing if the room surfaced more.",
5521
- '\xB7 `callout` field carries ONE numeric or unit \xB7 no English plus number combinations ("$120M ARR" OK; "makes $120M" not OK).',
5522
- "\xB7 `talkingPoints` is mandatory \xB7 if the room had no recommendations, distil the bottom-line claim into 3 ways a colleague could quote it.",
5523
- "\xB7 No markdown formatting inside string fields. No bullet characters. No headings. Plain prose only \u2014 the renderer adds visual structure."
5524
- ].join("\n");
5525
- function buildBentoMessages(opts) {
5526
- const { room, members, perDirectorSignals, language } = opts;
5527
- const memberList = members.map((a) => `${a.id} \xB7 ${a.name} (${a.handle}) \u2014 ${a.roleTag}`).join("\n \xB7 ");
5528
- const signalsBlock = perDirectorSignals.map((d) => {
5529
- if (!d.signals.length) return `[${d.directorId}] ${d.directorName} \u2014 (no signals)`;
5530
- const lines = d.signals.map((s, i) => ` \xB7 ${d.directorId}#${i} [${s.lens}] ${s.text}`).join("\n");
5531
- return `[${d.directorId}] ${d.directorName}
5532
- ${lines}`;
5533
- }).join("\n\n");
5534
- const supplementBlock = opts.supplement && opts.supplement.trim() ? [
5535
- ``,
5536
- `\u2500\u2500\u2500 SUPPLEMENTARY PERSPECTIVE FROM USER \u2500\u2500\u2500`,
5537
- ``,
5538
- `The user has asked you to additionally consider this angle when building the bento. Surface it in the most fitting slot (most often as one of the milestones, occasionally as a verification bullet or a talking point).`,
5539
- ``,
5540
- opts.supplement.trim(),
5541
- ``,
5542
- `\u2500\u2500\u2500 END SUPPLEMENT \u2500\u2500\u2500`
5543
- ].join("\n") : "";
5544
- return [
5545
- {
5546
- role: "system",
5547
- content: [BENTO_SYSTEM, "", languageInstruction(language)].join("\n")
5548
- },
5549
- {
5550
- role: "user",
5551
- content: [
5552
- `ROOM #${room.number} \xB7 ${room.name}`,
5553
- `Subject: ${room.subject}`,
5554
- ``,
5555
- `Directors:`,
5556
- ` \xB7 ${memberList}`,
5557
- ``,
5558
- `\u2500\u2500\u2500 SIGNALS \u2500\u2500\u2500`,
5559
- ``,
5560
- signalsBlock || "(no signals extracted)",
5561
- ``,
5562
- `\u2500\u2500\u2500 END SIGNALS \u2500\u2500\u2500`,
5563
- supplementBlock,
5564
- ``,
5565
- `Produce the bento now. JSON only.`
5566
- ].join("\n")
5567
- }
5568
- ];
5569
- }
5570
6006
  var MAGAZINE_SYSTEM = [
5571
6007
  'You are the chair of a boardroom session. You produce a MAGAZINE-SPREAD report \u2014 an editorial single-page layout that opens like a magazine cover, lays out a numbered card grid of takeaways, walks through a 3-step setup band, and closes with a high-contrast "why this matters" pull-list. Not a memo. Not a research note. A magazine.',
5572
6008
  "",
@@ -5618,7 +6054,7 @@ var MAGAZINE_SYSTEM = [
5618
6054
  "",
5619
6055
  "## Output format",
5620
6056
  "",
5621
- "Strict JSON inside a fenced ```json code block. No prose outside the block. The shape is fixed (same as bento mode):",
6057
+ "Strict JSON inside a fenced ```json code block. No prose outside the block. The shape is fixed:",
5622
6058
  "",
5623
6059
  "```json",
5624
6060
  "{",
@@ -5708,7 +6144,7 @@ ${lines}`;
5708
6144
  ];
5709
6145
  }
5710
6146
  var NEWSPAPER_SYSTEM = [
5711
- "You are the chair of a boardroom session. You produce a NEWSPAPER FRONT-PAGE report \u2014 a broadsheet single-page layout with a banner masthead, a full-width front-page headline, and a 3-column editorial spread with sidebar callouts. Not a memo. Not a magazine. A NEWSPAPER.",
6147
+ "You are the chair of a boardroom session. You produce a MULTI-PAGE NEWSPAPER report \u2014 a broadsheet that spreads across 3 distinct pages (front cover \xB7 inside spread \xB7 back / Best Of). The reader scrolls through them like flipping a real paper. Not a memo. Not a magazine. A NEWSPAPER.",
5712
6148
  "",
5713
6149
  "## Voice",
5714
6150
  "",
@@ -5724,37 +6160,39 @@ var NEWSPAPER_SYSTEM = [
5724
6160
  "",
5725
6161
  '3. **source** \xB7 masthead byline \xB7 \u2264 80 chars \xB7 "From the desk of {chair name} \xB7 {date}" or similar. Mono small caps register; auto-filled if you skip.',
5726
6162
  "",
5727
- "4. **milestones** \xB7 EXACTLY 3 column-stories. Each milestone IS one of the newspaper's 3 main columns. Each card has:",
6163
+ "4. **milestones** \xB7 EXACTLY 3 column-stories \xB7 they distribute across the 3 pages. Each card has:",
5728
6164
  ' \xB7 `period` \xB7 column section label \xB7 \u2264 24 chars \xB7 "TOP STORY" / "MARKETS" / "POLICY" / "OPS" / "OPINION". Section-banner register. ALL-CAPS-ABLE.',
5729
6165
  " \xB7 `title` \xB7 column subheading \xB7 \u2264 60 chars \xB7 the column's hook. Question or claim form OK.",
5730
- " \xB7 `body` \xB7 4-7 sentences \xB7 \u2264 420 chars \xB7 LONGER than other modes since this fills a full editorial column. Lead-paragraph style: open with the claim, support with evidence, close with the so-what. Present tense, declarative.",
5731
- " \xB7 `callout` \xB7 usually empty \xB7 the layout doesn't lean on big-number callouts in this pattern.",
6166
+ " \xB7 `body` \xB7 4-7 sentences \xB7 \u2264 480 chars \xB7 LONGER than other modes since this fills an editorial column. Lead-paragraph style: open with the claim, support with evidence, close with the so-what. Present tense, declarative.",
6167
+ ' \xB7 `callout` \xB7 short numeric / metric \xB7 \u2264 12 chars \xB7 used as the front-page sidebar\'s big number when present ("124,000" / "-10\xD7" / "$120M"). Empty when the milestone has no clean numeric anchor.',
5732
6168
  " \xB7 `tags` \xB7 empty array.",
5733
6169
  "",
5734
- '5. **rankedBars** \xB7 OPTIONAL \xB7 top-right "image slot" \xB7 3-5 ranked entries painted as a small editorial chart. Set null when the room has no clean ranked-numeric material.',
6170
+ " Page distribution \xB7 ms[0] = the LEAD STORY on page 1's front (drop-cap body) \xB7 ms[1] = the ECONOMY band on page 1 (chart-paired short article) AND continued on page 2 \xB7 ms[2] = the BREAKING NEWS sidebar on page 1 (short headline + body + numeric callout) AND extends on page 2.",
5735
6171
  "",
5736
- '6. **verification** \xB7 MANDATORY \xB7 these become the bottom-left "MORE HEADINGS" stacked sidebar \xB7 3-5 entries \xB7 each \u2264 180 chars \xB7 phrased as "Heading: body sentence." \u2014 use a colon as separator (the renderer splits on it for typography). Each entry is a SHORT NEWS ITEM that supports or qualifies the front-page claim.',
6172
+ "5. **rankedBars** \xB7 OPTIONAL \xB7 ECONOMY-band chart on page 1 \xB7 3-5 ranked entries painted as a teal-bar chart. Set null when the room has no clean ranked-numeric material.",
5737
6173
  "",
5738
- "7. **talkingPoints** \xB7 MANDATORY \xB7 3-5 quotable lines \xB7 \u2264 140 chars each \xB7 these become the bottom editorial column's paragraphs \xB7 imperative or declarative, no hedging. Each is a self-contained line a reader could pull-quote.",
6174
+ '6. **verification** \xB7 MANDATORY \xB7 stacked "MORE HEADINGS" sidebar on page 2 \xB7 3-5 entries \xB7 each \u2264 180 chars \xB7 phrased as "Heading: body sentence." \u2014 use a colon as separator (the renderer splits on it for typography). Each entry is a SHORT NEWS ITEM that supports or qualifies the front-page claim.',
5739
6175
  "",
5740
- '8. **conclusion** \xB7 the front-page "BOTTOM LINE" inverted callout \xB7 \u2264 100 chars \xB7 ONE sentence \xB7 the takeaway compressed to a quote. The reader walks away with this single line.',
6176
+ "7. **talkingPoints** \xB7 MANDATORY \xB7 3-6 entries \xB7 these distribute across the document: talking[0] becomes the front-page portrait pull-quote AND the page-2 inset quote; the full list reappears on page 3 as a 2-col grid of feature articles (Best of the Best). Each entry: \u2264 160 chars \xB7 self-contained quotable line \xB7 imperative or declarative.",
5741
6177
  "",
5742
- "Plus optional **flow** \xB7 usually `null` in newspaper mode \xB7 only fill when the room argued a clean transformation arc.",
6178
+ "8. **conclusion** \xB7 the front-page inverted PULL-QUOTE band \xB7 \u2264 100 chars \xB7 ONE sentence \xB7 the takeaway compressed to a quote. The reader walks away with this single line.",
6179
+ "",
6180
+ "Plus optional **flow** \xB7 2-4 short nodes \xB7 transformation arc \xB7 renders as a strip on page 3 when present.",
5743
6181
  "",
5744
6182
  "Plus auto **footerTag** \xB7 \u2264 80 chars \xB7 masthead-style date caption \xB7 auto-filled if you skip.",
5745
6183
  "",
5746
6184
  "## Routing the SIGNALS block into newspaper slots",
5747
6185
  "",
5748
- " \xB7 **title** \u2190 the strongest claim \xB7 phrased as a front-page headline (declarative, claim-form).",
6186
+ " \xB7 **title** \u2190 the strongest claim \xB7 phrased as a front-page banner headline (declarative, claim-form).",
5749
6187
  " \xB7 **kicker** \u2190 the supporting deck \xB7 \u2264 1 sentence \xB7 what's new about this conclusion.",
5750
- " \xB7 **milestones** \u2190 the 3 most load-bearing columns of the discussion \xB7 each milestone gets a section banner (TOP STORY / MARKETS / etc.) + a column-length editorial body.",
6188
+ " \xB7 **milestones** \u2190 the 3 most load-bearing pieces \xB7 ms[0] is the lead story (longest body), ms[1] feeds the economy band on page 1, ms[2] feeds the breaking-news sidebar.",
5751
6189
  ` \xB7 **verification** \u2190 the room's secondary findings \xB7 each phrased as "Heading: body." with a colon separator.`,
5752
- " \xB7 **talkingPoints** \u2190 the room's actionable conclusions \xB7 3-5 quotable lines.",
5753
- " \xB7 **conclusion** \u2190 the IMPORTANT-DETAILS callout \xB7 the room's bottom line in claim form.",
6190
+ ' \xB7 **talkingPoints** \u2190 3-6 quotable lines \xB7 the page-3 "Best of the Best" features.',
6191
+ " \xB7 **conclusion** \u2190 the front-page pull-quote band \xB7 the room's bottom line in claim form.",
5754
6192
  "",
5755
6193
  "## Output format",
5756
6194
  "",
5757
- "Strict JSON inside a fenced ```json code block. No prose outside the block. The shape is fixed (same as bento mode):",
6195
+ "Strict JSON inside a fenced ```json code block. No prose outside the block. The shape is fixed:",
5758
6196
  "",
5759
6197
  "```json",
5760
6198
  "{",
@@ -5841,6 +6279,172 @@ ${lines}`;
5841
6279
  }
5842
6280
  ];
5843
6281
  }
6282
+ var PPT_SYSTEM = [
6283
+ "You are the chair of a boardroom session. You produce a SLIDE DECK report \u2014 a presentation that decomposes into 7-9 slides the reader scrolls through with arrow keys: cover \xB7 agenda \xB7 3 milestone slides \xB7 optional data slide \xB7 talking-points slide \xB7 what-to-watch slide \xB7 takeaway slide. Not a memo. Not a magazine. A DECK.",
6284
+ "",
6285
+ "## Voice",
6286
+ "",
6287
+ "Slide voice is COMPRESSED. Every line is a slide-line \xB7 short, claim-form, declarative. No essays inside slot bodies \xB7 the renderer paints each milestone body as bullets / a single short paragraph. If a sentence wouldn't fit on a slide line at 32px, cut it.",
6288
+ "",
6289
+ "Each milestone fills exactly ONE slide \xB7 each talking point is one slide-line \xB7 each verification entry is one slide-line \xB7 the conclusion is the closing slide's hero quote.",
6290
+ "",
6291
+ "## The 8 slots you fill (output is JSON only)",
6292
+ "",
6293
+ "1. **title** \xB7 the deck's cover title \xB7 \u2264 80 chars \xB7 one short claim-form sentence. Slide-friendly: short enough to render at 60px display type without wrapping more than 2 lines.",
6294
+ "",
6295
+ "2. **kicker** \xB7 cover deck / sub-title \xB7 1 sentence \u2264 160 chars. Sits below the cover title in italic register.",
6296
+ "",
6297
+ '3. **source** \xB7 cover byline \xB7 \u2264 80 chars \xB7 "From the desk of {chair name} \xB7 {date}" or similar. Auto-filled if you skip.',
6298
+ "",
6299
+ "4. **milestones** \xB7 EXACTLY 3 slide-stories \xB7 each fills ONE content slide. Each card has:",
6300
+ ' \xB7 `period` \xB7 slide-section label \xB7 \u2264 24 chars \xB7 "PHASE 1" / "Q4 OUTLOOK" / "NEXT STEP". Slide-banner register \xB7 ALL-CAPS-ABLE.',
6301
+ " \xB7 `title` \xB7 slide headline \xB7 \u2264 60 chars \xB7 the slide's one-line claim.",
6302
+ ' \xB7 `body` \xB7 2-3 sentences \xB7 \u2264 280 chars \xB7 short-form for slide rendering. The renderer may break this into 2-3 bullets at the colon / semicolon, so you can write "Three forces line up: pricing pressure, regulatory shift, talent gap." and the renderer will visualise it.',
6303
+ ' \xB7 `callout` \xB7 short numeric \xB7 \u2264 12 chars \xB7 "-10\xD7" / "$120M" / "Q1 2026" \xB7 used as the slide\'s stat callout when present. Empty when no clean numeric anchor.',
6304
+ " \xB7 `tags` \xB7 empty array \xB7 the slide layout doesn't render tags.",
6305
+ "",
6306
+ "5. **rankedBars** \xB7 OPTIONAL \xB7 the 'By the numbers' slide \xB7 3-5 ranked entries with normalised ratio bars. Set null when the room has no clean ranked-numeric material.",
6307
+ "",
6308
+ `6. **verification** \xB7 MANDATORY \xB7 the 'What to watch' slide \xB7 3-5 entries \xB7 each \u2264 100 chars \xB7 phrased as a SINGLE-LINE bullet ("Compliance review remains gating constraint."). NO "Heading: body" colon split here \xB7 these are slide bullets, one line each.`,
6309
+ "",
6310
+ `7. **talkingPoints** \xB7 MANDATORY \xB7 the 'Recommendations / Talking points' slide \xB7 3-5 entries \xB7 each \u2264 80 chars \xB7 imperative single-line bullets a presenter could read aloud ("Lock pricing for Q1." / "Move release to two-track plan.").`,
6311
+ "",
6312
+ "8. **conclusion** \xB7 the closing 'Takeaway' slide \xB7 \u2264 100 chars \xB7 ONE sentence \xB7 the deck's big walk-away line \xB7 rendered at 40-60px display type.",
6313
+ "",
6314
+ "9. **directorBlock** \xB7 MANDATORY when the room has \u2265 2 active directors. Set `null` only when there is exactly one director. Renders as 1-3 dedicated slides: director voices \xB7 where we agreed \xB7 where we split. The single most-valuable section in the deck \u2014 the room's social map. Shape:",
6315
+ " \xB7 `perspectives` \xB7 EVERY active director gets one entry. Each has:",
6316
+ ' \xB7 `directorName` (display name, \u2264 32 chars \xB7 e.g. "Socrates"),',
6317
+ ' \xB7 `directorRole` (their tag, \u2264 48 chars \xB7 e.g. "Devil\'s advocate"),',
6318
+ ' \xB7 `stance` (\u2264 60 chars \xB7 short label of their angle \xB7 "Sees this as a moat play"),',
6319
+ " \xB7 `position` (1-2 sentences \u2264 240 chars \xB7 their load-bearing argument in their voice),",
6320
+ " \xB7 `quote` (verbatim phrase \u2264 160 chars \xB7 empty when no memorable verbatim).",
6321
+ " \xB7 `alignment` \xB7 0-3 cross-cutting agreements. Each: `pointOfAgreement` (\u2264 120 chars), `directorNames` (\u2265 2 names), `note` (why this convergence matters \xB7 \u2264 200 chars).",
6322
+ " \xB7 `divergence` \xB7 0-2 hinges the room split on. Each: `hinge` (\u2264 140 chars \xB7 the question they split on), `sides` (2-3 entries \xB7 each has `label`, `directorNames` \u2265 1, `stance` \u2264 160 chars), `resolution` (what would settle it \xB7 \u2264 200 chars \xB7 empty if unresolved).",
6323
+ " \xB7 `chairSynthesis` \xB7 1-2 sentences \u2264 240 chars \xB7 what the chair takes from comparing the views. Moderator-neutral \u2014 observation, not advocacy.",
6324
+ " Source from the SIGNALS block \u2014 `tensions` populate divergence rows; agreed-on claims populate alignment groups; each director's strongest claim populates their `position`; quotes from the SIGNALS block (when verbatim) populate `quote`.",
6325
+ "",
6326
+ "Plus optional **flow** \xB7 usually `null` in PPT mode \xB7 only fill when the room argued a clean transformation arc \xB7 would render as a 2-4 node arrow chain on the data slide.",
6327
+ "",
6328
+ "Plus auto **footerTag** \xB7 slide-footer caption \xB7 auto-filled if you skip.",
6329
+ "",
6330
+ "## Routing the SIGNALS block into slide slots",
6331
+ "",
6332
+ " \xB7 **title** \u2190 the strongest claim \xB7 phrased as a deck cover headline.",
6333
+ " \xB7 **kicker** \u2190 the supporting sub-title \xB7 what the deck is about in 1 sentence.",
6334
+ " \xB7 **milestones** \u2190 the 3 most load-bearing pieces \xB7 each becomes ONE slide \xB7 prefer claims with numeric anchors so the slide gets a visible callout.",
6335
+ " \xB7 **verification** \u2190 3-5 watch-list bullets \xB7 single-line each.",
6336
+ " \xB7 **talkingPoints** \u2190 3-5 imperative recommendations \xB7 single-line each.",
6337
+ " \xB7 **conclusion** \u2190 compressed walk-away takeaway in 1 sentence.",
6338
+ "",
6339
+ "## Output format",
6340
+ "",
6341
+ "Strict JSON inside a fenced ```json code block. No prose outside the block.",
6342
+ "",
6343
+ "```json",
6344
+ "{",
6345
+ ' "title": "Short claim-form deck title.",',
6346
+ ' "kicker": "1-sentence sub-title explaining the angle.",',
6347
+ ' "source": "From the desk of {chair name} \xB7 {date}",',
6348
+ ' "milestones": [',
6349
+ ' { "period": "PHASE 1", "title": "Anchor the next quarter", "body": "Three forces converge: pricing pressure, regulatory shift, talent gap.", "callout": "Q1 2026", "tags": [] },',
6350
+ ' { "period": "PHASE 2", "title": "Commit two-track release", "body": "Pilot lane survives. Production lane gets a tighter cap.", "callout": "-10\xD7", "tags": [] },',
6351
+ ' { "period": "PHASE 3", "title": "Open the senior bench", "body": "Two roles before next board \xB7 compliance review remains the gate.", "callout": "+2 hires", "tags": [] }',
6352
+ " ],",
6353
+ ' "rankedBars": null,',
6354
+ ' "verification": {',
6355
+ ' "title": "What to watch",',
6356
+ ' "bullets": [',
6357
+ ' "Compliance review remains the gating constraint.",',
6358
+ ' "Pricing pilot survives but caps are tightening.",',
6359
+ ' "Two new senior roles open by next board meeting.",',
6360
+ ' "Q4 outlook anchors three commitments."',
6361
+ " ]",
6362
+ " },",
6363
+ ' "talkingPoints": {',
6364
+ ' "title": "Recommendations",',
6365
+ ' "bullets": [',
6366
+ ' "Lock pricing for Q1.",',
6367
+ ' "Commit to a two-track release plan.",',
6368
+ ' "Open two senior roles before the next board.",',
6369
+ ' "Schedule a compliance review check-in for week 4."',
6370
+ " ]",
6371
+ " },",
6372
+ ' "conclusion": "Three commitments anchor the next quarter; ship Q1 with pricing locked.",',
6373
+ ' "flow": null,',
6374
+ ' "footerTag": "Boardroom Slides \xB7 {date}",',
6375
+ ' "directorBlock": {',
6376
+ ' "perspectives": [',
6377
+ ` { "directorName": "Socrates", "directorRole": "Devil's advocate", "stance": "Frames it as a regulatory-window question.", "position": "The compliance gate is binding before the pricing question matters. Until that closes, two-track is overkill.", "quote": "We are spending capital on a problem that is not our binding constraint." },`,
6378
+ ` { "directorName": "Drucker", "directorRole": "Operator's lens", "stance": "Reads it as a distribution-leverage play.", "position": "Two-track buys negotiating room with the channel \u2014 without it, Q1 pricing locks become impossible to reverse.", "quote": "" }`,
6379
+ " ],",
6380
+ ' "alignment": [',
6381
+ ' { "pointOfAgreement": "Q1 pricing must be locked before any release commitment.", "directorNames": ["Socrates", "Drucker"], "note": "Independent paths \xB7 Socrates from regulatory exposure, Drucker from channel leverage." }',
6382
+ " ],",
6383
+ ' "divergence": [',
6384
+ ' { "hinge": "Whether two-track release is necessary now or premature.", "sides": [',
6385
+ ' { "label": "Necessary now", "directorNames": ["Drucker"], "stance": "Without two-track, the pricing lock is reversed by channel pressure inside 90 days." },',
6386
+ ' { "label": "Premature", "directorNames": ["Socrates"], "stance": "Compliance review, not channel pressure, is the binding gate; two-track defers the real question." }',
6387
+ ' ], "resolution": "Resolves once we know whether the compliance review will close before the pricing lock takes effect." }',
6388
+ " ],",
6389
+ ' "chairSynthesis": "Both directors agree on the pricing lock; they split on whether to also commit to two-track. The compliance-review timing is the variable that settles the split."',
6390
+ " }",
6391
+ "}",
6392
+ "```",
6393
+ "",
6394
+ "Constraints:",
6395
+ "\xB7 Title \u2264 80 chars \xB7 slide-renderable at 60px without overflow.",
6396
+ "\xB7 Milestone body 2-3 sentences \u2264 280 chars \xB7 TIGHTER than newspaper / magazine modes.",
6397
+ "\xB7 Verification bullets \u2264 100 chars each \xB7 single-line slide-bullets \xB7 NO colon split.",
6398
+ "\xB7 Talking-point bullets \u2264 80 chars each \xB7 imperative voice.",
6399
+ "\xB7 Conclusion \u2264 100 chars \xB7 the deck's walk-away line.",
6400
+ "\xB7 directorBlock \u2014 when there are \u2265 2 active directors, you MUST populate it with one entry per director. Do not skip it; the deck loses its core differentiator without these slides.",
6401
+ "\xB7 No markdown formatting inside string fields. No bullet characters. No headings. Plain prose only \u2014 the renderer adds visual structure."
6402
+ ].join("\n");
6403
+ function buildPptMessages(opts) {
6404
+ const { room, members, perDirectorSignals, language } = opts;
6405
+ const memberList = members.map((a) => `${a.id} \xB7 ${a.name} (${a.handle}) \u2014 ${a.roleTag}`).join("\n \xB7 ");
6406
+ const signalsBlock = perDirectorSignals.map((d) => {
6407
+ if (!d.signals.length) return `[${d.directorId}] ${d.directorName} \u2014 (no signals)`;
6408
+ const lines = d.signals.map((s, i) => ` \xB7 ${d.directorId}#${i} [${s.lens}] ${s.text}`).join("\n");
6409
+ return `[${d.directorId}] ${d.directorName}
6410
+ ${lines}`;
6411
+ }).join("\n\n");
6412
+ const supplementBlock = opts.supplement && opts.supplement.trim() ? [
6413
+ ``,
6414
+ `\u2500\u2500\u2500 SUPPLEMENTARY PERSPECTIVE FROM USER \u2500\u2500\u2500`,
6415
+ ``,
6416
+ `The user has asked you to additionally consider this angle when building the deck. Surface it in the most fitting slot (most often as one of the 3 milestone slides or as a watch-list bullet).`,
6417
+ ``,
6418
+ opts.supplement.trim(),
6419
+ ``,
6420
+ `\u2500\u2500\u2500 END SUPPLEMENT \u2500\u2500\u2500`
6421
+ ].join("\n") : "";
6422
+ return [
6423
+ {
6424
+ role: "system",
6425
+ content: [PPT_SYSTEM, "", languageInstruction(language)].join("\n")
6426
+ },
6427
+ {
6428
+ role: "user",
6429
+ content: [
6430
+ `ROOM #${room.number} \xB7 ${room.name}`,
6431
+ `Subject: ${room.subject}`,
6432
+ ``,
6433
+ `Directors:`,
6434
+ ` \xB7 ${memberList}`,
6435
+ ``,
6436
+ `\u2500\u2500\u2500 SIGNALS \u2500\u2500\u2500`,
6437
+ ``,
6438
+ signalsBlock || "(no signals extracted)",
6439
+ ``,
6440
+ `\u2500\u2500\u2500 END SIGNALS \u2500\u2500\u2500`,
6441
+ supplementBlock,
6442
+ ``,
6443
+ `Produce the slide deck now. JSON only.`
6444
+ ].join("\n")
6445
+ }
6446
+ ];
6447
+ }
5844
6448
  var WRITE_SYSTEM = [
5845
6449
  "You are the chair of a boardroom session. You have a structured scaffold. Write the final report in markdown \u2014 a McKinsey-grade research note that makes the multi-director thinking visible. Pyramid principle, MECE, action-oriented.",
5846
6450
  "",
@@ -8167,6 +8771,7 @@ function parseBento(raw, fallbackTitle, fallbackSource, fallbackFooterTag) {
8167
8771
  const conclusion = clipString(stringField(parsed.conclusion), 100);
8168
8772
  const flow = parseBentoFlow(parsed.flow);
8169
8773
  const footerTag = clipString(stringField(parsed.footerTag) || fallbackFooterTag, 80);
8774
+ const directorBlock = parsePptDirectorBlock(parsed.directorBlock);
8170
8775
  return {
8171
8776
  title,
8172
8777
  kicker,
@@ -8177,9 +8782,70 @@ function parseBento(raw, fallbackTitle, fallbackSource, fallbackFooterTag) {
8177
8782
  talkingPoints,
8178
8783
  conclusion,
8179
8784
  flow,
8180
- footerTag
8785
+ footerTag,
8786
+ directorBlock
8181
8787
  };
8182
8788
  }
8789
+ function parsePptDirectorBlock(raw) {
8790
+ if (!raw || typeof raw !== "object") return null;
8791
+ const o = raw;
8792
+ const perspectives = [];
8793
+ if (Array.isArray(o.perspectives)) {
8794
+ for (const p of o.perspectives) {
8795
+ if (!p || typeof p !== "object") continue;
8796
+ const po = p;
8797
+ const directorName = clipString(stringField(po.directorName), 32);
8798
+ const directorRole = clipString(stringField(po.directorRole), 48);
8799
+ const stance = clipString(stringField(po.stance), 60);
8800
+ const position = clipString(stringField(po.position), 240);
8801
+ if (!directorName || !position) continue;
8802
+ const quote = clipString(stringField(po.quote), 160);
8803
+ perspectives.push({ directorName, directorRole, stance, position, quote });
8804
+ }
8805
+ }
8806
+ if (perspectives.length < 2) return null;
8807
+ const alignment = [];
8808
+ if (Array.isArray(o.alignment)) {
8809
+ for (const a of o.alignment) {
8810
+ if (!a || typeof a !== "object") continue;
8811
+ const ao = a;
8812
+ const pointOfAgreement = clipString(stringField(ao.pointOfAgreement), 120);
8813
+ const note = clipString(stringField(ao.note), 200);
8814
+ const namesRaw = Array.isArray(ao.directorNames) ? ao.directorNames : [];
8815
+ const directorNames = namesRaw.filter((s) => typeof s === "string" && s.trim().length > 0).map((s) => clipString(s.trim(), 32)).slice(0, 6);
8816
+ if (!pointOfAgreement || directorNames.length < 2) continue;
8817
+ alignment.push({ pointOfAgreement, directorNames, note });
8818
+ if (alignment.length >= 3) break;
8819
+ }
8820
+ }
8821
+ const divergence = [];
8822
+ if (Array.isArray(o.divergence)) {
8823
+ for (const d of o.divergence) {
8824
+ if (!d || typeof d !== "object") continue;
8825
+ const dgo = d;
8826
+ const hinge = clipString(stringField(dgo.hinge), 140);
8827
+ const resolution = clipString(stringField(dgo.resolution), 200);
8828
+ const sides = [];
8829
+ if (Array.isArray(dgo.sides)) {
8830
+ for (const s of dgo.sides) {
8831
+ if (!s || typeof s !== "object") continue;
8832
+ const so = s;
8833
+ const label = clipString(stringField(so.label), 40);
8834
+ const stance = clipString(stringField(so.stance), 160);
8835
+ const namesRaw = Array.isArray(so.directorNames) ? so.directorNames : [];
8836
+ const names = namesRaw.filter((x) => typeof x === "string" && x.trim().length > 0).map((x) => clipString(x.trim(), 32)).slice(0, 4);
8837
+ if (!label || !stance || names.length === 0) continue;
8838
+ sides.push({ label, directorNames: names, stance });
8839
+ }
8840
+ }
8841
+ if (!hinge || sides.length < 2) continue;
8842
+ divergence.push({ hinge, sides, resolution });
8843
+ if (divergence.length >= 2) break;
8844
+ }
8845
+ }
8846
+ const chairSynthesis = clipString(stringField(o.chairSynthesis), 240);
8847
+ return { perspectives, alignment, divergence, chairSynthesis };
8848
+ }
8183
8849
  function stringField(v) {
8184
8850
  return typeof v === "string" ? v.trim() : "";
8185
8851
  }
@@ -9309,7 +9975,11 @@ function mapRow7(row) {
9309
9975
  subjectType: row.subject_type,
9310
9976
  houseStyle: row.house_style || "boardroom-default",
9311
9977
  assets: parseAssets(row.assets_json),
9312
- mode: row.mode === "bento" || row.mode === "magazine" || row.mode === "newspaper" ? row.mode : "research-note",
9978
+ // Legacy 'bento' rows are mapped to 'magazine' · the body_json
9979
+ // shape is identical (BentoScaffold) and the magazine renderer
9980
+ // handles the data correctly. Unknown modes fall back to the
9981
+ // default research-note path.
9982
+ mode: row.mode === "magazine" || row.mode === "newspaper" || row.mode === "ppt" ? row.mode : row.mode === "bento" ? "magazine" : "research-note",
9313
9983
  createdAt: row.created_at
9314
9984
  };
9315
9985
  }
@@ -9355,7 +10025,7 @@ function insertBrief(b) {
9355
10025
  const composerRationale = b.composerRationale && b.composerRationale.trim() ? b.composerRationale.trim() : null;
9356
10026
  const subjectType = b.subjectType && b.subjectType.trim() ? b.subjectType.trim() : null;
9357
10027
  const houseStyle = b.houseStyle && b.houseStyle.trim() ? b.houseStyle.trim() : "boardroom-default";
9358
- const mode = b.mode === "bento" || b.mode === "magazine" || b.mode === "newspaper" ? b.mode : "research-note";
10028
+ const mode = b.mode === "magazine" || b.mode === "newspaper" || b.mode === "ppt" ? b.mode : "research-note";
9359
10029
  db.prepare(
9360
10030
  `INSERT INTO briefs (${COLS2}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
9361
10031
  ).run(
@@ -9543,7 +10213,7 @@ function abortBriefGeneration(briefId) {
9543
10213
  async function generateBrief(opts) {
9544
10214
  const { roomId } = opts;
9545
10215
  const style = opts.style ?? "mckinsey";
9546
- const mode = opts.mode === "bento" || opts.mode === "magazine" || opts.mode === "newspaper" ? opts.mode : "research-note";
10216
+ const mode = opts.mode === "magazine" || opts.mode === "newspaper" || opts.mode === "ppt" ? opts.mode : "research-note";
9547
10217
  const room = getRoom(roomId);
9548
10218
  if (!room) throw new Error(`room not found: ${roomId}`);
9549
10219
  const memberRows = listRoomMembers(roomId);
@@ -9811,7 +10481,7 @@ async function runPipeline(args) {
9811
10481
  `
9812
10482
  );
9813
10483
  }
9814
- if (args.mode === "bento" || args.mode === "magazine" || args.mode === "newspaper") {
10484
+ if (args.mode === "magazine" || args.mode === "newspaper" || args.mode === "ppt") {
9815
10485
  const ok = await runBentoStage({
9816
10486
  roomId,
9817
10487
  briefId,
@@ -9826,7 +10496,7 @@ async function runPipeline(args) {
9826
10496
  signal: args.signal
9827
10497
  });
9828
10498
  if (!ok) {
9829
- const label = args.mode === "magazine" ? "Magazine" : args.mode === "newspaper" ? "Newspaper" : "Bento";
10499
+ const label = args.mode === "magazine" ? "Magazine" : args.mode === "ppt" ? "Slide deck" : "Newspaper";
9830
10500
  pipelineError = `${label} writer couldn't structure this room (3 retries failed). Try regenerating, or shorten the conversation.`;
9831
10501
  }
9832
10502
  return;
@@ -10270,7 +10940,7 @@ async function runBentoStage(args) {
10270
10940
  const fallbackSource = `From ${chairName} \xB7 ${today}`;
10271
10941
  const subjectShort = (args.room.subject || "").slice(0, 60);
10272
10942
  const fallbackFooterTag = subjectShort ? `${subjectShort} \xB7 ${today}` : today;
10273
- const buildMessages = args.mode === "magazine" ? buildMagazineMessages : args.mode === "newspaper" ? buildNewspaperMessages : buildBentoMessages;
10943
+ const buildMessages = args.mode === "newspaper" ? buildNewspaperMessages : args.mode === "ppt" ? buildPptMessages : buildMagazineMessages;
10274
10944
  const messages = buildMessages({
10275
10945
  chair: args.chair,
10276
10946
  room: args.room,
@@ -10538,7 +11208,7 @@ function buildMethodologyFooter(args) {
10538
11208
  // src/routes/briefs.ts
10539
11209
  init_paths();
10540
11210
  function briefHasBody(b) {
10541
- if (b.mode === "bento" || b.mode === "magazine" || b.mode === "newspaper") {
11211
+ if (b.mode === "magazine" || b.mode === "newspaper" || b.mode === "ppt") {
10542
11212
  const j = b.bodyJson;
10543
11213
  return !!(j && typeof j === "object" && typeof j.title === "string" && j.title.length > 0);
10544
11214
  }
@@ -11552,13 +12222,14 @@ function renderLongTermMemoryBlock(agentId, userName) {
11552
12222
  const memories = memoriesForContext(agentId);
11553
12223
  if (memories.length === 0) return "";
11554
12224
  const lines = memories.map((m) => {
11555
- const flag = m.pinned ? " \xB7 pinned" : "";
11556
- return ` \xB7 [${m.kind}${flag}] ${m.content}`;
12225
+ const tag = m.pinned ? "pinned" : m.tier === "long" ? "stable" : "recent";
12226
+ return ` \xB7 [${tag}] [${m.kind}] ${m.content}`;
11557
12227
  });
12228
+ bumpUsage(memories.map((m) => m.id));
11558
12229
  return [
11559
12230
  "",
11560
12231
  `\u2500\u2500\u2500 WHAT YOU REMEMBER ABOUT ${userName} (cross-room, your own observations) \u2500\u2500\u2500`,
11561
- `These are notes you've accumulated across previous rooms with this user \u2014 your lens, not other directors'. Treat them as priors, not facts. If something contradicts the current room, name it explicitly.`,
12232
+ `These are notes you've accumulated across previous rooms with this user \u2014 your lens, not other directors'. Treat them as priors, not facts. \`[stable]\` items have shown up across multiple rooms and are likely durable; \`[recent]\` items are still provisional. If something contradicts the current room, name it explicitly.`,
11562
12233
  ...lines,
11563
12234
  ""
11564
12235
  ].join("\n");
@@ -13691,6 +14362,15 @@ async function extractMemoriesAfterAdjourn(roomId) {
13691
14362
  }
13692
14363
  })
13693
14364
  );
14365
+ for (const agent of agents) {
14366
+ if (!bumpAdjournCounter(agent.id, agent.roleKind)) continue;
14367
+ runDreamCycle(agent.id).catch((e) => {
14368
+ process.stderr.write(
14369
+ `[dream] ${agent.name} cycle failed: ${e instanceof Error ? e.message : String(e)}
14370
+ `
14371
+ );
14372
+ });
14373
+ }
13694
14374
  }
13695
14375
  async function runExtractionForAgent(agent, transcript, userName) {
13696
14376
  const system = [
@@ -15767,7 +16447,7 @@ function roomsRouter() {
15767
16447
  const explicit = typeof b.style === "string" && b.style ? b.style : null;
15768
16448
  const fromRoom = room.briefStyle && room.briefStyle !== "auto" ? room.briefStyle : null;
15769
16449
  const style = explicit || fromRoom || "mckinsey";
15770
- const mode = b.mode === "bento" || b.mode === "magazine" || b.mode === "newspaper" ? b.mode : "research-note";
16450
+ const mode = b.mode === "magazine" || b.mode === "newspaper" || b.mode === "ppt" ? b.mode : "research-note";
15771
16451
  abortRoom(id);
15772
16452
  const adjournedAt = Date.now();
15773
16453
  setRoomStatus(id, "adjourned", { adjournedAt });
@@ -15848,7 +16528,7 @@ function roomsRouter() {
15848
16528
  const explicit = typeof b.style === "string" && b.style ? b.style : null;
15849
16529
  const fromRoom = room.briefStyle && room.briefStyle !== "auto" ? room.briefStyle : null;
15850
16530
  const style = explicit || fromRoom || "mckinsey";
15851
- const mode = b.mode === "bento" || b.mode === "magazine" || b.mode === "newspaper" ? b.mode : "research-note";
16531
+ const mode = b.mode === "magazine" || b.mode === "newspaper" || b.mode === "ppt" ? b.mode : "research-note";
15852
16532
  try {
15853
16533
  const result = await generateBrief({
15854
16534
  roomId: id,
@@ -16015,7 +16695,7 @@ function usageRouter() {
16015
16695
  init_paths();
16016
16696
 
16017
16697
  // src/version.ts
16018
- var VERSION = "0.1.8";
16698
+ var VERSION = "0.1.9";
16019
16699
 
16020
16700
  // src/server.ts
16021
16701
  function createApp() {
@@ -16147,6 +16827,25 @@ async function main() {
16147
16827
  process.stderr.write(`[boot] clarify recovery failed: ${e instanceof Error ? e.message : String(e)}
16148
16828
  `);
16149
16829
  }
16830
+ void (async () => {
16831
+ try {
16832
+ const agents = listAllAgents();
16833
+ let triggered = 0;
16834
+ for (const agent of agents) {
16835
+ if (countMemoriesForAgent(agent.id) > bootCeilingFor(agent.roleKind)) {
16836
+ await runDreamCycle(agent.id);
16837
+ triggered += 1;
16838
+ }
16839
+ }
16840
+ if (triggered > 0) {
16841
+ process.stderr.write(`[boot] dream sweep triggered for ${triggered} agent(s) over ceiling
16842
+ `);
16843
+ }
16844
+ } catch (e) {
16845
+ process.stderr.write(`[boot] dream sweep failed: ${e instanceof Error ? e.message : String(e)}
16846
+ `);
16847
+ }
16848
+ })();
16150
16849
  const portArg = opts.port ? Number.parseInt(opts.port, 10) : void 0;
16151
16850
  if (portArg !== void 0 && (Number.isNaN(portArg) || portArg < 1 || portArg > 65535)) {
16152
16851
  console.error(`Invalid --port: ${opts.port}`);