privateboard 0.1.7 → 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
@@ -376,6 +376,77 @@ var init_usage_daily = __esm({
376
376
  }
377
377
  });
378
378
 
379
+ // src/storage/migrations/025_brief_mode.sql
380
+ var brief_mode_default;
381
+ var init_brief_mode = __esm({
382
+ "src/storage/migrations/025_brief_mode.sql"() {
383
+ brief_mode_default = "-- 025_brief_mode \xB7 add `mode` column to briefs.\n--\n-- Briefs now come in two flavors:\n-- \xB7 'research-note' (default \xB7 the existing markdown-body research\n-- dossier with composer-picked components and a spine-rendered\n-- report.html view)\n-- \xB7 'bento' (new \xB7 a single-page infographic with fixed structure \xB7\n-- header band, 3-milestone timeline, ranked-bars / verification /\n-- talking-points sidebars, conclusion band \xB7 rendered by bento.html\n-- from the BentoScaffold JSON persisted in body_json)\n--\n-- Legacy rows pre-dating bento are research-note by definition; the\n-- DEFAULT clause backfills them. The column is NOT NULL so the\n-- pipeline can branch on it without an `?? \"research-note\"` fallback\n-- at every read site.\n--\n-- For 'bento' rows the body_md column is empty (or carries a plain-\n-- text fallback) and body_json holds the structured BentoScaffold; the\n-- spine / components_json / composer_rationale columns are unused (the\n-- bento has fixed shape, the composer doesn't run for it).\n\nALTER TABLE briefs ADD COLUMN mode TEXT NOT NULL DEFAULT 'research-note';\n";
384
+ }
385
+ });
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
+
379
450
  // src/storage/db.ts
380
451
  var db_exports = {};
381
452
  __export(db_exports, {
@@ -457,6 +528,9 @@ var init_db = __esm({
457
528
  init_intensity_brutal_to_terse();
458
529
  init_brief_assets();
459
530
  init_usage_daily();
531
+ init_brief_mode();
532
+ init_memory_metabolism();
533
+ init_memory_metabolism_p2();
460
534
  MIGRATIONS = [
461
535
  { name: "001_init.sql", sql: init_default },
462
536
  { name: "002_default_opus.sql", sql: default_opus_default },
@@ -481,7 +555,10 @@ var init_db = __esm({
481
555
  { name: "021_notes.sql", sql: notes_default },
482
556
  { name: "022_intensity_brutal_to_terse.sql", sql: intensity_brutal_to_terse_default },
483
557
  { name: "023_brief_assets.sql", sql: brief_assets_default },
484
- { name: "024_usage_daily.sql", sql: usage_daily_default }
558
+ { name: "024_usage_daily.sql", sql: usage_daily_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 }
485
562
  ];
486
563
  _db = null;
487
564
  }
@@ -820,6 +897,10 @@ function updateAgent(id, patch) {
820
897
  const json = patch.ability && Object.keys(patch.ability).length > 0 ? JSON.stringify(patch.ability) : null;
821
898
  values.push(json);
822
899
  }
900
+ if (typeof patch.isPinned === "boolean") {
901
+ fields.push("is_pinned = ?");
902
+ values.push(patch.isPinned ? 1 : 0);
903
+ }
823
904
  if (fields.length === 0) return getAgent(id);
824
905
  fields.push("updated_at = ?");
825
906
  values.push(Date.now());
@@ -1764,12 +1845,22 @@ function newId(len = 12) {
1764
1845
  }
1765
1846
 
1766
1847
  // src/storage/memories.ts
1767
- 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";
1768
1849
  var ALLOWED_KINDS = /* @__PURE__ */ new Set(["fact", "observation", "preference", "goal"]);
1769
1850
  var ALLOWED_SOURCES = /* @__PURE__ */ new Set(["extracted", "user_added", "user_pinned"]);
1851
+ var ALLOWED_TIERS = /* @__PURE__ */ new Set(["short", "long"]);
1770
1852
  function mapRow2(row) {
1771
1853
  const kind = ALLOWED_KINDS.has(row.kind) ? row.kind : "fact";
1772
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
+ }
1773
1864
  return {
1774
1865
  id: row.id,
1775
1866
  agentId: row.agent_id,
@@ -1780,13 +1871,20 @@ function mapRow2(row) {
1780
1871
  confidence: row.confidence,
1781
1872
  pinned: row.pinned === 1,
1782
1873
  createdAt: row.created_at,
1783
- 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
1784
1881
  };
1785
1882
  }
1786
- function listMemoriesForAgent(agentId) {
1883
+ function listMemoriesForAgent(agentId, opts = {}) {
1884
+ const where = opts.includeSuperseded ? "WHERE agent_id = ?" : "WHERE agent_id = ? AND superseded_by IS NULL";
1787
1885
  const rows = getDb().prepare(
1788
1886
  `SELECT ${SELECT_COLS2} FROM agent_memories
1789
- WHERE agent_id = ?
1887
+ ${where}
1790
1888
  ORDER BY pinned DESC, created_at DESC`
1791
1889
  ).all(agentId);
1792
1890
  return rows.map(mapRow2);
@@ -1794,8 +1892,52 @@ function listMemoriesForAgent(agentId) {
1794
1892
  function memoriesForContext(agentId, recentCap = 5) {
1795
1893
  const all = listMemoriesForAgent(agentId);
1796
1894
  const pinned = all.filter((m) => m.pinned);
1797
- const recent = all.filter((m) => !m.pinned).slice(0, recentCap);
1798
- 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;
1799
1941
  }
1800
1942
  function insertMemory(input) {
1801
1943
  const db = getDb();
@@ -1847,6 +1989,109 @@ function deleteMemory(id) {
1847
1989
  function isMemoryKind(v) {
1848
1990
  return ALLOWED_KINDS.has(v);
1849
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
+ }
1850
2095
 
1851
2096
  // src/storage/skills.ts
1852
2097
  init_db();
@@ -3677,6 +3922,330 @@ function formatSearchResults(query, results) {
3677
3922
  return lines.join("\n");
3678
3923
  }
3679
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
+
3680
4249
  // src/routes/agents.ts
3681
4250
  function agentSpecModelCandidates() {
3682
4251
  const out = [];
@@ -4025,6 +4594,9 @@ function agentsRouter() {
4025
4594
  if (typeof b.webSearchEnabled === "boolean") {
4026
4595
  patch.webSearchEnabled = b.webSearchEnabled;
4027
4596
  }
4597
+ if (typeof b.isPinned === "boolean") {
4598
+ patch.isPinned = b.isPinned;
4599
+ }
4028
4600
  const updated = updateAgent(id, patch);
4029
4601
  return c.json(updated);
4030
4602
  });
@@ -4115,6 +4687,24 @@ function agentsRouter() {
4115
4687
  if (!ok) return c.json({ error: "not found" }, 404);
4116
4688
  return c.json({ ok: true });
4117
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
+ });
4118
4708
  r.get("/:id/skills", (c) => {
4119
4709
  const id = c.req.param("id");
4120
4710
  const agent = getAgent(id);
@@ -5349,25 +5939,473 @@ function pickedBlock(picked) {
5349
5939
  for (const k of allKnown) if (!set.has(k)) skipped.push(k);
5350
5940
  return [
5351
5941
  ``,
5352
- `\u2500\u2500\u2500 COMPOSER PICKED COMPONENTS \u2500\u2500\u2500`,
5942
+ `\u2500\u2500\u2500 COMPOSER PICKED COMPONENTS \u2500\u2500\u2500`,
5943
+ ``,
5944
+ `The composer (Stage 1.5) picked these components for this brief \u2014 fill ONLY these fields:`,
5945
+ ...[...set].sort().map((k) => ` \xB7 ${k}`),
5946
+ ``,
5947
+ `Skip these components (set their fields to empty/null per the substitute-group rules in the system prompt):`,
5948
+ ...skipped.sort().map((k) => ` \xB7 ${k}`),
5949
+ ``,
5950
+ `\u2500\u2500\u2500 END PICKED \u2500\u2500\u2500`
5951
+ ].join("\n");
5952
+ }
5953
+ function buildScaffoldMessages(opts) {
5954
+ const { room, members, perDirectorSignals, language, picked } = opts;
5955
+ const memberList = members.map((a) => `${a.id} \xB7 ${a.name} (${a.handle}) \u2014 ${a.roleTag}`).join("\n \xB7 ");
5956
+ const signalsBlock = perDirectorSignals.map((d) => {
5957
+ if (!d.signals.length) return `[${d.directorId}] ${d.directorName} \u2014 (no signals)`;
5958
+ const lines = d.signals.map(
5959
+ (s, i) => ` \xB7 ${d.directorId}#${i} [${s.lens}] ${s.text}`
5960
+ ).join("\n");
5961
+ return `[${d.directorId}] ${d.directorName}
5962
+ ${lines}`;
5963
+ }).join("\n\n");
5964
+ const supplementBlock = opts.supplement && opts.supplement.trim() ? [
5965
+ ``,
5966
+ `\u2500\u2500\u2500 SUPPLEMENTARY PERSPECTIVE FROM USER \u2500\u2500\u2500`,
5967
+ ``,
5968
+ `The user has asked you to additionally consider this angle when building the scaffold. Address it explicitly \u2014 work it into the scaffold's findings, divergence, recommendations, and/or new questions wherever it lands most cleanly. Do NOT add a separate section for it; weave it through.`,
5969
+ ``,
5970
+ opts.supplement.trim(),
5971
+ ``,
5972
+ `\u2500\u2500\u2500 END SUPPLEMENT \u2500\u2500\u2500`
5973
+ ].join("\n") : "";
5974
+ return [
5975
+ {
5976
+ role: "system",
5977
+ content: [SCAFFOLD_SYSTEM, "", languageInstruction(language)].join("\n")
5978
+ },
5979
+ {
5980
+ role: "user",
5981
+ content: [
5982
+ `ROOM #${room.number} \xB7 ${room.name}`,
5983
+ `Subject: ${room.subject}`,
5984
+ // `Mode: …` deliberately omitted · see composer.ts for the
5985
+ // same change. Surfacing the room mode here biased the LLM
5986
+ // toward critique-shaped / brainstorm-shaped output even
5987
+ // though the standard scaffold prompt asks for decision-grade
5988
+ // JSON, leading to parseScaffold rejecting every retry.
5989
+ ``,
5990
+ `Directors:`,
5991
+ ` \xB7 ${memberList}`,
5992
+ ``,
5993
+ `\u2500\u2500\u2500 SIGNALS \u2500\u2500\u2500`,
5994
+ ``,
5995
+ signalsBlock || "(no signals extracted)",
5996
+ ``,
5997
+ `\u2500\u2500\u2500 END SIGNALS \u2500\u2500\u2500`,
5998
+ pickedBlock(picked),
5999
+ supplementBlock,
6000
+ ``,
6001
+ `Produce the scaffold now. JSON only.`
6002
+ ].join("\n")
6003
+ }
6004
+ ];
6005
+ }
6006
+ var MAGAZINE_SYSTEM = [
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.',
6008
+ "",
6009
+ "## What a magazine spread is for",
6010
+ "",
6011
+ "Magazine is for the moment when the user wants to share the takeaway as a *piece of editorial content* \u2014 something a reader could scroll on a feed. Not the analysis itself \xB7 the cover line + the numbered tactics + the setup recipe. Not the room's debate \xB7 the published version a stranger could understand cold.",
6012
+ "",
6013
+ 'Editorial register \xB7 magazines lead with personality, not with hedging. Headlines are claim-style, decks have voice, talking points read like "5 tactics that actually work" rather than "considerations". You are writing the cover spread, not the white paper.',
6014
+ "",
6015
+ "## The 8 slots you fill (output is JSON only)",
6016
+ "",
6017
+ '1. **title** \xB7 the cover headline \xB7 serif display \xB7 \u2264 110 chars \xB7 ONE sentence in claim form. Magazine covers state the takeaway with confidence \u2014 quantified or named ("How {chair} {verb} {object}" / "X is the operating system for Y" / "Three commitments that change the trajectory").',
6018
+ "",
6019
+ "2. **kicker** \xB7 the cover deck \xB7 \u2264 200 chars \xB7 1 sentence under the headline. Non-italic register \xB7 subtitle voice. Names the angle / what's new.",
6020
+ "",
6021
+ '3. **source** \xB7 \u2264 80 chars \xB7 the masthead byline \xB7 "From {chair name} \xB7 {date}" / "Issue 01 \xB7 {date}" / "{chair name} presents". Mono small caps register; auto-filled if you skip.',
6022
+ "",
6023
+ `4. **milestones** \xB7 EXACTLY 3 cards \xB7 these become the magazine's middle band \u2014 a "how to set this up in 10 minutes" 3-step recipe. Each card has:`,
6024
+ ' \xB7 `period` \xB7 short step label \xB7 \u2264 24 chars \xB7 "Step 1" / "\u51C6\u5907" / "Phase 2" / "First". Imperative or sequential.',
6025
+ ' \xB7 `title` \xB7 \u2264 60 chars \xB7 what to do at this step. Imperative voice ("Set up environment" / "\u51C6\u5907\u73AF\u5883").',
6026
+ " \xB7 `body` \xB7 2 sentences \xB7 \u2264 220 chars \xB7 the concrete instruction \xB7 how a reader actually does this step.",
6027
+ " \xB7 `callout` \xB7 \u2264 12 chars \xB7 optional anchor numeric \xB7 usually empty in magazine mode (the layout doesn't lean on big numbers in this band).",
6028
+ " \xB7 `tags` \xB7 empty array (`[]`) \xB7 the magazine layout doesn't render tags on the setup band.",
6029
+ "",
6030
+ "5. **rankedBars** \xB7 OPTIONAL \xB7 3-5 ranked entries. Renders only when the room produced a clean ranking. Set to `null` when there's no real ranked-numeric material.",
6031
+ "",
6032
+ `6. **verification** \xB7 MANDATORY for magazine \xB7 these become the dark closing band's "why this matters" pull-list. Provide 4 bullets \xB7 \u2264 140 chars each \xB7 each bullet is a SHORT REASON the takeaway matters \xB7 phrased as a stand-alone declaration ("Saves time \xB7 routine work compresses to minutes" / "Highly personalized \xB7 tailored to your context"). Title is your call (default "Why this matters" / "\u4E3A\u4EC0\u4E48\u8FD9\u5F88\u5F3A\u5927").`,
6033
+ "",
6034
+ `7. **talkingPoints** \xB7 MANDATORY \xB7 5 numbered cards \xB7 \u2264 120 chars each \xB7 these become the magazine's hero card grid \xB7 the "5 tactics" feature. EACH BULLET MUST BEGIN WITH A SHORT PHRASE (the card's title \u2014 what the renderer extracts as the card headline) FOLLOWED BY " \xB7 " (a middle-dot with spaces) AND THEN THE BODY SENTENCE. Example format: "Weekly check-in \xB7 Run /weekly check-in to track key metrics across a personal dashboard." The renderer splits on " \xB7 " to extract title + body. If you can't fit 5, output as many as you have \u22653.`,
6035
+ "",
6036
+ ' Title side \u2264 24 chars \xB7 body side \u2264 100 chars. Imperative voice in the body ("Run X" / "\u4F7F\u7528 X"). Imperative for English magazine voice; in Chinese, lead the body with the verb ("\u4F7F\u7528\u2026" / "\u8FD0\u884C\u2026" / "\u901A\u8FC7\u2026").',
6037
+ "",
6038
+ ` Default talkingPoints title \xB7 "5 tactics" / "5 \u4E2A\u4F8B\u5B50\u770B\u660E\u767D" / "5 ways to use this" \xB7 the LARGE outline numeral on the magazine cover is derived from this section's count.`,
6039
+ "",
6040
+ "8. **conclusion** \xB7 \u2264 100 chars \xB7 ONE sentence \xB7 the cover-line reinforcement. The reader walks away with this single line \xB7 usually a short imperative or claim restatement.",
6041
+ "",
6042
+ "Plus optional **flow** \xB7 usually `null` in magazine mode \xB7 only fill when the room argued a clean transformation arc.",
6043
+ "",
6044
+ "Plus auto **footerTag** \xB7 \u2264 80 chars \xB7 masthead-style caption \xB7 auto-filled if you skip.",
6045
+ "",
6046
+ "## Routing the SIGNALS block into magazine slots",
6047
+ "",
6048
+ " \xB7 **title** \u2190 the strongest claim \xB7 phrased as a magazine cover headline.",
6049
+ " \xB7 **kicker** \u2190 the supporting deck \xB7 \u2264 1 sentence \xB7 what's new about this conclusion.",
6050
+ " \xB7 **milestones** \u2190 if the room argued a setup / how-to flow, route the 3 most important steps. If the room didn't produce a clean recipe, distill the 3 most actionable items into imperative steps.",
6051
+ ' \xB7 **talkingPoints** \u2190 the 5 strongest action / claim entries \xB7 each rewritten in "Title \xB7 Body" form (split with the middle dot + spaces).',
6052
+ " \xB7 **verification** \u2190 4 reasons the takeaway matters \xB7 drawn from the room's evidence + bottom-line material \xB7 phrased as standalone declarations.",
6053
+ " \xB7 **conclusion** \u2190 compressed restatement of the room's anchor.",
6054
+ "",
6055
+ "## Output format",
6056
+ "",
6057
+ "Strict JSON inside a fenced ```json code block. No prose outside the block. The shape is fixed:",
6058
+ "",
6059
+ "```json",
6060
+ "{",
6061
+ ' "title": "Cover-line takeaway in claim form.",',
6062
+ ' "kicker": "1-sentence deck explaining the angle.",',
6063
+ ' "source": "From {chair name} \xB7 {date}",',
6064
+ ' "milestones": [',
6065
+ ' { "period": "Step 1", "title": "Set up environment", "body": "Install the tooling and create the workspace folder.", "callout": "", "tags": [] },',
6066
+ ' { "period": "Step 2", "title": "Initialize and configure", "body": "Run the init command and follow the prompts to wire up your slash commands.", "callout": "", "tags": [] },',
6067
+ ' { "period": "Step 3", "title": "Run and customize", "body": "Run weekly or daily, customize prompts to your context.", "callout": "", "tags": [] }',
6068
+ " ],",
6069
+ ' "rankedBars": null,',
6070
+ ' "verification": {',
6071
+ ' "title": "Why this matters",',
6072
+ ' "bullets": [',
6073
+ ' "Saves time \xB7 routine work compresses to minutes a week.",',
6074
+ ' "Highly personalized \xB7 tailored to the context you provide.",',
6075
+ ' "Beyond coding \xB7 use cases extend well past development tasks.",',
6076
+ ' "Infinite extensibility \xB7 spin up more agents to fit any new need."',
6077
+ " ]",
6078
+ " },",
6079
+ ' "talkingPoints": {',
6080
+ ' "title": "5 tactics",',
6081
+ ' "bullets": [',
6082
+ ' "Weekly check-in \xB7 Run /weekly check-in to track key metrics on a personal dashboard.",',
6083
+ ' "Daily journal \xB7 Run /daily check-in to journal accomplishments and feelings.",',
6084
+ ' "Content research \xB7 Use /newsletter researcher to draft your own briefs in your voice.",',
6085
+ ' "Brain-dump analyzer \xB7 Run /brain dump analysis on raw notes to surface a mind-map.",',
6086
+ ' "Daily brief \xB7 Use /daily brief for a tailored news round-up by your interests."',
6087
+ " ]",
6088
+ " },",
6089
+ ' "conclusion": "One-line takeaway \xB7 \u2264 100 chars.",',
6090
+ ' "flow": null,',
6091
+ ' "footerTag": "Issue 01 \xB7 {date}"',
6092
+ "}",
6093
+ "```",
6094
+ "",
6095
+ "Constraints:",
6096
+ `\xB7 Title MUST be cover-style (a magazine wouldn't run "Analysis of strategic options" \u2014 it would run "How X built the operating system for Y"). State the takeaway, not the topic.`,
6097
+ `\xB7 talkingPoints bullets MUST follow "Title \xB7 Body" with the middle dot + spaces \xB7 the renderer's split is exact.`,
6098
+ "\xB7 Provide 4 verification bullets when the room has the material; 3 acceptable as a floor.",
6099
+ "\xB7 No markdown formatting inside string fields. No bullet characters. No headings. Plain prose only \u2014 the renderer adds visual structure."
6100
+ ].join("\n");
6101
+ function buildMagazineMessages(opts) {
6102
+ const { room, members, perDirectorSignals, language } = opts;
6103
+ const memberList = members.map((a) => `${a.id} \xB7 ${a.name} (${a.handle}) \u2014 ${a.roleTag}`).join("\n \xB7 ");
6104
+ const signalsBlock = perDirectorSignals.map((d) => {
6105
+ if (!d.signals.length) return `[${d.directorId}] ${d.directorName} \u2014 (no signals)`;
6106
+ const lines = d.signals.map((s, i) => ` \xB7 ${d.directorId}#${i} [${s.lens}] ${s.text}`).join("\n");
6107
+ return `[${d.directorId}] ${d.directorName}
6108
+ ${lines}`;
6109
+ }).join("\n\n");
6110
+ const supplementBlock = opts.supplement && opts.supplement.trim() ? [
6111
+ ``,
6112
+ `\u2500\u2500\u2500 SUPPLEMENTARY PERSPECTIVE FROM USER \u2500\u2500\u2500`,
6113
+ ``,
6114
+ `The user has asked you to additionally consider this angle when building the magazine. Surface it in the most fitting slot (most often as one of the talking points or verification bullets).`,
6115
+ ``,
6116
+ opts.supplement.trim(),
6117
+ ``,
6118
+ `\u2500\u2500\u2500 END SUPPLEMENT \u2500\u2500\u2500`
6119
+ ].join("\n") : "";
6120
+ return [
6121
+ {
6122
+ role: "system",
6123
+ content: [MAGAZINE_SYSTEM, "", languageInstruction(language)].join("\n")
6124
+ },
6125
+ {
6126
+ role: "user",
6127
+ content: [
6128
+ `ROOM #${room.number} \xB7 ${room.name}`,
6129
+ `Subject: ${room.subject}`,
6130
+ ``,
6131
+ `Directors:`,
6132
+ ` \xB7 ${memberList}`,
6133
+ ``,
6134
+ `\u2500\u2500\u2500 SIGNALS \u2500\u2500\u2500`,
6135
+ ``,
6136
+ signalsBlock || "(no signals extracted)",
6137
+ ``,
6138
+ `\u2500\u2500\u2500 END SIGNALS \u2500\u2500\u2500`,
6139
+ supplementBlock,
6140
+ ``,
6141
+ `Produce the magazine spread now. JSON only.`
6142
+ ].join("\n")
6143
+ }
6144
+ ];
6145
+ }
6146
+ var NEWSPAPER_SYSTEM = [
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.",
6148
+ "",
6149
+ "## Voice",
6150
+ "",
6151
+ 'Front-page journalism. Headlines are declarative, present-tense, claim-form ("BOARD COMMITS TO TWO-TRACK RELEASE" \u2014 not "An analysis of release strategy" or "What the board decided"). Body prose is lead-paragraph editorial: each paragraph carries one claim with its supporting evidence, sentences are short, transitions are crisp, hedging is minimal.',
6152
+ "",
6153
+ "Newspaper voice is more formal than magazine voice and more confident than research-note voice. Imagine the front page of a serious broadsheet \xB7 The Wall Street Journal, The Financial Times, The Economist if it ran a daily.",
6154
+ "",
6155
+ "## The 8 slots you fill (output is JSON only)",
6156
+ "",
6157
+ '1. **title** \xB7 the front-page banner headline \xB7 \u2264 110 chars \xB7 ALL-CAPS-ABLE claim (the renderer applies uppercase) \xB7 NOT a question, NOT a label, a CLAIM. Examples: "BOARD COMMITS TO TWO-TRACK RELEASE", "MARKETS BRACE FOR Q4 RESET", "REGULATORS MOVE AGAINST DARK PATTERNS".',
6158
+ "",
6159
+ "2. **kicker** \xB7 the subheading deck \xB7 \u2264 200 chars \xB7 1 sentence under the headline \xB7 expands the claim with the angle / what's new.",
6160
+ "",
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.',
6162
+ "",
6163
+ "4. **milestones** \xB7 EXACTLY 3 column-stories \xB7 they distribute across the 3 pages. Each card has:",
6164
+ ' \xB7 `period` \xB7 column section label \xB7 \u2264 24 chars \xB7 "TOP STORY" / "MARKETS" / "POLICY" / "OPS" / "OPINION". Section-banner register. ALL-CAPS-ABLE.',
6165
+ " \xB7 `title` \xB7 column subheading \xB7 \u2264 60 chars \xB7 the column's hook. Question or claim form OK.",
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.',
6168
+ " \xB7 `tags` \xB7 empty array.",
6169
+ "",
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.",
6171
+ "",
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.",
6173
+ "",
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.',
6175
+ "",
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.",
6177
+ "",
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.",
6181
+ "",
6182
+ "Plus auto **footerTag** \xB7 \u2264 80 chars \xB7 masthead-style date caption \xB7 auto-filled if you skip.",
6183
+ "",
6184
+ "## Routing the SIGNALS block into newspaper slots",
6185
+ "",
6186
+ " \xB7 **title** \u2190 the strongest claim \xB7 phrased as a front-page banner headline (declarative, claim-form).",
6187
+ " \xB7 **kicker** \u2190 the supporting deck \xB7 \u2264 1 sentence \xB7 what's new about this conclusion.",
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.",
6189
+ ` \xB7 **verification** \u2190 the room's secondary findings \xB7 each phrased as "Heading: body." with a colon separator.`,
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.",
6192
+ "",
6193
+ "## Output format",
6194
+ "",
6195
+ "Strict JSON inside a fenced ```json code block. No prose outside the block. The shape is fixed:",
6196
+ "",
6197
+ "```json",
6198
+ "{",
6199
+ ' "title": "Banner headline in claim form",',
6200
+ ' "kicker": "1-sentence subdeck explaining the angle.",',
6201
+ ' "source": "From the desk of {chair name} \xB7 {date}",',
6202
+ ' "milestones": [',
6203
+ ' { "period": "TOP STORY", "title": "What the board decided", "body": "4-7 sentence editorial column body explaining the lead story with evidence and so-what.", "callout": "", "tags": [] },',
6204
+ ' { "period": "MARKETS", "title": "Why the market is reading this", "body": "4-7 sentence editorial column body covering the second angle.", "callout": "", "tags": [] },',
6205
+ ' { "period": "POLICY", "title": "Open questions for the regulator", "body": "4-7 sentence editorial column body covering the third angle.", "callout": "", "tags": [] }',
6206
+ " ],",
6207
+ ' "rankedBars": null,',
6208
+ ' "verification": {',
6209
+ ' "title": "More headings",',
6210
+ ' "bullets": [',
6211
+ ` "Q4 outlook: Three commitments anchor the next quarter's plan.",`,
6212
+ ' "Pricing: The pilot programme survives, but caps are tightening.",',
6213
+ ' "Hiring: Two new senior roles open by next board meeting.",',
6214
+ ' "Risk: Compliance review remains the gating constraint."',
6215
+ " ]",
6216
+ " },",
6217
+ ' "talkingPoints": {',
6218
+ ' "title": "From the editorial",',
6219
+ ' "bullets": [',
6220
+ ' "First quotable editorial line that names the takeaway.",',
6221
+ ' "Second quotable line that addresses the obvious objection.",',
6222
+ ' "Third quotable line that sets the next-step expectation."',
6223
+ " ]",
6224
+ " },",
6225
+ ' "conclusion": "One-sentence bottom-line takeaway \xB7 \u2264 100 chars.",',
6226
+ ' "flow": null,',
6227
+ ' "footerTag": "{date} \xB7 Edition 01"',
6228
+ "}",
6229
+ "```",
6230
+ "",
6231
+ "Constraints:",
6232
+ "\xB7 Title MUST be declarative claim-form (newspaper headlines DO NOT ask questions on the front page \xB7 they STATE).",
6233
+ "\xB7 Milestones bodies are LONGER than other modes (4-7 sentences) \xB7 this is a column, not a card.",
6234
+ '\xB7 Verification bullets MUST follow "Heading: body." with a colon separator \xB7 the renderer splits on it.',
6235
+ "\xB7 No markdown formatting inside string fields. No bullet characters. No headings. Plain prose only \u2014 the renderer adds visual structure."
6236
+ ].join("\n");
6237
+ function buildNewspaperMessages(opts) {
6238
+ const { room, members, perDirectorSignals, language } = opts;
6239
+ const memberList = members.map((a) => `${a.id} \xB7 ${a.name} (${a.handle}) \u2014 ${a.roleTag}`).join("\n \xB7 ");
6240
+ const signalsBlock = perDirectorSignals.map((d) => {
6241
+ if (!d.signals.length) return `[${d.directorId}] ${d.directorName} \u2014 (no signals)`;
6242
+ const lines = d.signals.map((s, i) => ` \xB7 ${d.directorId}#${i} [${s.lens}] ${s.text}`).join("\n");
6243
+ return `[${d.directorId}] ${d.directorName}
6244
+ ${lines}`;
6245
+ }).join("\n\n");
6246
+ const supplementBlock = opts.supplement && opts.supplement.trim() ? [
6247
+ ``,
6248
+ `\u2500\u2500\u2500 SUPPLEMENTARY PERSPECTIVE FROM USER \u2500\u2500\u2500`,
5353
6249
  ``,
5354
- `The composer (Stage 1.5) picked these components for this brief \u2014 fill ONLY these fields:`,
5355
- ...[...set].sort().map((k) => ` \xB7 ${k}`),
6250
+ `The user has asked you to additionally consider this angle when building the newspaper. Surface it in the most fitting slot (most often as one of the 3 milestone columns or as a verification headline).`,
5356
6251
  ``,
5357
- `Skip these components (set their fields to empty/null per the substitute-group rules in the system prompt):`,
5358
- ...skipped.sort().map((k) => ` \xB7 ${k}`),
6252
+ opts.supplement.trim(),
5359
6253
  ``,
5360
- `\u2500\u2500\u2500 END PICKED \u2500\u2500\u2500`
5361
- ].join("\n");
6254
+ `\u2500\u2500\u2500 END SUPPLEMENT \u2500\u2500\u2500`
6255
+ ].join("\n") : "";
6256
+ return [
6257
+ {
6258
+ role: "system",
6259
+ content: [NEWSPAPER_SYSTEM, "", languageInstruction(language)].join("\n")
6260
+ },
6261
+ {
6262
+ role: "user",
6263
+ content: [
6264
+ `ROOM #${room.number} \xB7 ${room.name}`,
6265
+ `Subject: ${room.subject}`,
6266
+ ``,
6267
+ `Directors:`,
6268
+ ` \xB7 ${memberList}`,
6269
+ ``,
6270
+ `\u2500\u2500\u2500 SIGNALS \u2500\u2500\u2500`,
6271
+ ``,
6272
+ signalsBlock || "(no signals extracted)",
6273
+ ``,
6274
+ `\u2500\u2500\u2500 END SIGNALS \u2500\u2500\u2500`,
6275
+ supplementBlock,
6276
+ ``,
6277
+ `Produce the newspaper front page now. JSON only.`
6278
+ ].join("\n")
6279
+ }
6280
+ ];
5362
6281
  }
5363
- function buildScaffoldMessages(opts) {
5364
- const { room, members, perDirectorSignals, language, picked } = opts;
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;
5365
6405
  const memberList = members.map((a) => `${a.id} \xB7 ${a.name} (${a.handle}) \u2014 ${a.roleTag}`).join("\n \xB7 ");
5366
6406
  const signalsBlock = perDirectorSignals.map((d) => {
5367
6407
  if (!d.signals.length) return `[${d.directorId}] ${d.directorName} \u2014 (no signals)`;
5368
- const lines = d.signals.map(
5369
- (s, i) => ` \xB7 ${d.directorId}#${i} [${s.lens}] ${s.text}`
5370
- ).join("\n");
6408
+ const lines = d.signals.map((s, i) => ` \xB7 ${d.directorId}#${i} [${s.lens}] ${s.text}`).join("\n");
5371
6409
  return `[${d.directorId}] ${d.directorName}
5372
6410
  ${lines}`;
5373
6411
  }).join("\n\n");
@@ -5375,7 +6413,7 @@ ${lines}`;
5375
6413
  ``,
5376
6414
  `\u2500\u2500\u2500 SUPPLEMENTARY PERSPECTIVE FROM USER \u2500\u2500\u2500`,
5377
6415
  ``,
5378
- `The user has asked you to additionally consider this angle when building the scaffold. Address it explicitly \u2014 work it into the scaffold's findings, divergence, recommendations, and/or new questions wherever it lands most cleanly. Do NOT add a separate section for it; weave it through.`,
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).`,
5379
6417
  ``,
5380
6418
  opts.supplement.trim(),
5381
6419
  ``,
@@ -5384,18 +6422,13 @@ ${lines}`;
5384
6422
  return [
5385
6423
  {
5386
6424
  role: "system",
5387
- content: [SCAFFOLD_SYSTEM, "", languageInstruction(language)].join("\n")
6425
+ content: [PPT_SYSTEM, "", languageInstruction(language)].join("\n")
5388
6426
  },
5389
6427
  {
5390
6428
  role: "user",
5391
6429
  content: [
5392
6430
  `ROOM #${room.number} \xB7 ${room.name}`,
5393
6431
  `Subject: ${room.subject}`,
5394
- // `Mode: …` deliberately omitted · see composer.ts for the
5395
- // same change. Surfacing the room mode here biased the LLM
5396
- // toward critique-shaped / brainstorm-shaped output even
5397
- // though the standard scaffold prompt asks for decision-grade
5398
- // JSON, leading to parseScaffold rejecting every retry.
5399
6432
  ``,
5400
6433
  `Directors:`,
5401
6434
  ` \xB7 ${memberList}`,
@@ -5405,10 +6438,9 @@ ${lines}`;
5405
6438
  signalsBlock || "(no signals extracted)",
5406
6439
  ``,
5407
6440
  `\u2500\u2500\u2500 END SIGNALS \u2500\u2500\u2500`,
5408
- pickedBlock(picked),
5409
6441
  supplementBlock,
5410
6442
  ``,
5411
- `Produce the scaffold now. JSON only.`
6443
+ `Produce the slide deck now. JSON only.`
5412
6444
  ].join("\n")
5413
6445
  }
5414
6446
  ];
@@ -7720,6 +8752,188 @@ function parseScaffold(raw, fallbackTitle, fallbackOriginalQuestion) {
7720
8752
  openQuestions: parseOpenQuestions(parsed.openQuestions)
7721
8753
  };
7722
8754
  }
8755
+ function parseBento(raw, fallbackTitle, fallbackSource, fallbackFooterTag) {
8756
+ const parsed = extractJson3(raw);
8757
+ if (!parsed) return null;
8758
+ const title = clipString(stringField(parsed.title) || fallbackTitle, 110);
8759
+ if (!title) return null;
8760
+ const kicker = clipString(stringField(parsed.kicker), 200);
8761
+ const source = clipString(stringField(parsed.source) || fallbackSource, 80);
8762
+ const milestones = parseBentoMilestones(parsed.milestones);
8763
+ if (milestones.length === 0) return null;
8764
+ while (milestones.length < 3) {
8765
+ milestones.push({ period: "", title: "", body: "", callout: "", tags: [] });
8766
+ }
8767
+ if (milestones.length > 3) milestones.length = 3;
8768
+ const rankedBars = parseBentoRankedBars(parsed.rankedBars);
8769
+ const verification = parseBentoVerification(parsed.verification);
8770
+ const talkingPoints = parseBentoTalkingPoints(parsed.talkingPoints);
8771
+ const conclusion = clipString(stringField(parsed.conclusion), 100);
8772
+ const flow = parseBentoFlow(parsed.flow);
8773
+ const footerTag = clipString(stringField(parsed.footerTag) || fallbackFooterTag, 80);
8774
+ const directorBlock = parsePptDirectorBlock(parsed.directorBlock);
8775
+ return {
8776
+ title,
8777
+ kicker,
8778
+ source,
8779
+ milestones,
8780
+ rankedBars,
8781
+ verification,
8782
+ talkingPoints,
8783
+ conclusion,
8784
+ flow,
8785
+ footerTag,
8786
+ directorBlock
8787
+ };
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
+ }
8849
+ function stringField(v) {
8850
+ return typeof v === "string" ? v.trim() : "";
8851
+ }
8852
+ function clipString(s, max) {
8853
+ if (s.length <= max) return s;
8854
+ return s.slice(0, max - 1).trimEnd() + "\u2026";
8855
+ }
8856
+ function parseBentoMilestones(raw) {
8857
+ if (!Array.isArray(raw)) return [];
8858
+ const out = [];
8859
+ for (const m of raw) {
8860
+ if (!m || typeof m !== "object") continue;
8861
+ const o = m;
8862
+ const title = clipString(stringField(o.title), 60);
8863
+ const body = clipString(stringField(o.body), 220);
8864
+ if (!title || !body) continue;
8865
+ const period = clipString(stringField(o.period), 24);
8866
+ const callout = clipString(stringField(o.callout), 12);
8867
+ const tagsRaw = Array.isArray(o.tags) ? o.tags : [];
8868
+ const tags = tagsRaw.map((t) => typeof t === "string" ? clipString(t.trim(), 16) : "").filter(Boolean).slice(0, 4);
8869
+ out.push({ period, title, body, callout, tags });
8870
+ if (out.length >= 3) break;
8871
+ }
8872
+ return out;
8873
+ }
8874
+ function parseBentoRankedBars(raw) {
8875
+ if (!raw || typeof raw !== "object") return null;
8876
+ const o = raw;
8877
+ const title = clipString(stringField(o.title), 40);
8878
+ if (!title) return null;
8879
+ const entriesRaw = Array.isArray(o.entries) ? o.entries : [];
8880
+ const entries = [];
8881
+ for (const e of entriesRaw) {
8882
+ if (!e || typeof e !== "object") continue;
8883
+ const eo = e;
8884
+ const label = clipString(stringField(eo.label), 40);
8885
+ const value = clipString(stringField(eo.value), 20);
8886
+ if (!label || !value) continue;
8887
+ const ratioRaw = typeof eo.ratio === "number" ? eo.ratio : 0;
8888
+ const ratio = Math.max(0, Math.min(1, Number.isFinite(ratioRaw) ? ratioRaw : 0));
8889
+ entries.push({ label, value, ratio });
8890
+ if (entries.length >= 5) break;
8891
+ }
8892
+ if (entries.length < 2) return null;
8893
+ return { title, entries };
8894
+ }
8895
+ function parseBentoVerification(raw) {
8896
+ if (!raw || typeof raw !== "object") return null;
8897
+ const o = raw;
8898
+ const title = clipString(stringField(o.title), 40);
8899
+ if (!title) return null;
8900
+ const bullets = parseBentoBullets(o.bullets, 140, 5);
8901
+ if (bullets.length === 0) return null;
8902
+ return { title, bullets };
8903
+ }
8904
+ function parseBentoTalkingPoints(raw) {
8905
+ if (!raw || typeof raw !== "object") {
8906
+ return { title: "How to say this", bullets: [] };
8907
+ }
8908
+ const o = raw;
8909
+ const title = clipString(stringField(o.title), 40) || "How to say this";
8910
+ const bullets = parseBentoBullets(o.bullets, 120, 5);
8911
+ return { title, bullets };
8912
+ }
8913
+ function parseBentoBullets(raw, maxChars, maxCount) {
8914
+ if (!Array.isArray(raw)) return [];
8915
+ const out = [];
8916
+ for (const b of raw) {
8917
+ const s = typeof b === "string" ? clipString(b.trim(), maxChars) : "";
8918
+ if (s) out.push(s);
8919
+ if (out.length >= maxCount) break;
8920
+ }
8921
+ return out;
8922
+ }
8923
+ function parseBentoFlow(raw) {
8924
+ if (!raw || typeof raw !== "object") return null;
8925
+ const o = raw;
8926
+ const nodesRaw = Array.isArray(o.nodes) ? o.nodes : [];
8927
+ const nodes = [];
8928
+ for (const n of nodesRaw) {
8929
+ const s = typeof n === "string" ? clipString(n.trim(), 24) : "";
8930
+ if (s) nodes.push(s);
8931
+ if (nodes.length >= 4) break;
8932
+ }
8933
+ if (nodes.length < 2) return null;
8934
+ const caption = clipString(stringField(o.caption), 60);
8935
+ return caption ? { nodes, caption } : { nodes };
8936
+ }
7723
8937
  function parseAppendices(raw) {
7724
8938
  if (!Array.isArray(raw)) return null;
7725
8939
  const out = [];
@@ -8580,7 +9794,7 @@ function recoverStuckClarifyRooms() {
8580
9794
 
8581
9795
  // src/storage/briefs.ts
8582
9796
  init_db();
8583
- var COLS2 = "id, room_id, style, title, body_md, body_json, supplement, spine, components_json, composer_rationale, subject_type, house_style, assets_json, created_at";
9797
+ var COLS2 = "id, room_id, style, title, body_md, body_json, supplement, spine, components_json, composer_rationale, subject_type, house_style, assets_json, mode, created_at";
8584
9798
  function parseAssets(json) {
8585
9799
  if (!json) return null;
8586
9800
  try {
@@ -8761,6 +9975,11 @@ function mapRow7(row) {
8761
9975
  subjectType: row.subject_type,
8762
9976
  houseStyle: row.house_style || "boardroom-default",
8763
9977
  assets: parseAssets(row.assets_json),
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",
8764
9983
  createdAt: row.created_at
8765
9984
  };
8766
9985
  }
@@ -8780,7 +9999,7 @@ function listAllBriefs() {
8780
9999
  const rows = getDb().prepare(
8781
10000
  `SELECT b.id, b.room_id, b.style, b.title, b.body_md, b.body_json,
8782
10001
  b.supplement, b.spine, b.components_json,
8783
- b.composer_rationale, b.subject_type, b.house_style, b.assets_json, b.created_at,
10002
+ b.composer_rationale, b.subject_type, b.house_style, b.assets_json, b.mode, b.created_at,
8784
10003
  r.name AS room_name, r.subject AS room_subject,
8785
10004
  r.number AS room_number, r.status AS room_status
8786
10005
  FROM briefs b
@@ -8806,8 +10025,9 @@ function insertBrief(b) {
8806
10025
  const composerRationale = b.composerRationale && b.composerRationale.trim() ? b.composerRationale.trim() : null;
8807
10026
  const subjectType = b.subjectType && b.subjectType.trim() ? b.subjectType.trim() : null;
8808
10027
  const houseStyle = b.houseStyle && b.houseStyle.trim() ? b.houseStyle.trim() : "boardroom-default";
10028
+ const mode = b.mode === "magazine" || b.mode === "newspaper" || b.mode === "ppt" ? b.mode : "research-note";
8809
10029
  db.prepare(
8810
- `INSERT INTO briefs (${COLS2}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
10030
+ `INSERT INTO briefs (${COLS2}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
8811
10031
  ).run(
8812
10032
  id,
8813
10033
  b.roomId,
@@ -8823,6 +10043,7 @@ function insertBrief(b) {
8823
10043
  houseStyle,
8824
10044
  null,
8825
10045
  // assets_json · filled later by updateBriefAssets
10046
+ mode,
8826
10047
  now
8827
10048
  );
8828
10049
  return getBrief(id);
@@ -8868,12 +10089,22 @@ function updateBriefBody(id, bodyMd, title) {
8868
10089
  function setBriefTitle(id, title) {
8869
10090
  getDb().prepare("UPDATE briefs SET title = ? WHERE id = ?").run(title, id);
8870
10091
  }
10092
+ function updateBriefBodyJson(id, bodyJson, title) {
10093
+ const json = JSON.stringify(bodyJson);
10094
+ if (title !== void 0) {
10095
+ getDb().prepare("UPDATE briefs SET body_json = ?, title = ? WHERE id = ?").run(json, title, id);
10096
+ } else {
10097
+ getDb().prepare("UPDATE briefs SET body_json = ? WHERE id = ?").run(json, id);
10098
+ }
10099
+ }
8871
10100
  function deleteBrief(id) {
8872
10101
  const r = getDb().prepare("DELETE FROM briefs WHERE id = ?").run(id);
8873
10102
  return r.changes > 0;
8874
10103
  }
8875
10104
  function countBriefs() {
8876
- const row = getDb().prepare("SELECT COUNT(*) AS c FROM briefs WHERE body_md IS NOT NULL AND TRIM(body_md) != ''").get();
10105
+ const row = getDb().prepare(
10106
+ "SELECT COUNT(*) AS c FROM briefs WHERE (body_md IS NOT NULL AND TRIM(body_md) != '') OR (body_json IS NOT NULL AND TRIM(body_json) != '' AND TRIM(body_json) != 'null')"
10107
+ ).get();
8877
10108
  return row.c ?? 0;
8878
10109
  }
8879
10110
 
@@ -8982,6 +10213,7 @@ function abortBriefGeneration(briefId) {
8982
10213
  async function generateBrief(opts) {
8983
10214
  const { roomId } = opts;
8984
10215
  const style = opts.style ?? "mckinsey";
10216
+ const mode = opts.mode === "magazine" || opts.mode === "newspaper" || opts.mode === "ppt" ? opts.mode : "research-note";
8985
10217
  const room = getRoom(roomId);
8986
10218
  if (!room) throw new Error(`room not found: ${roomId}`);
8987
10219
  const memberRows = listRoomMembers(roomId);
@@ -8992,7 +10224,8 @@ async function generateBrief(opts) {
8992
10224
  style,
8993
10225
  title: room.subject,
8994
10226
  bodyMd: "",
8995
- supplement: opts.supplement
10227
+ supplement: opts.supplement,
10228
+ mode
8996
10229
  });
8997
10230
  const chairForState = getChairAgent();
8998
10231
  const inferredLang = /[一-鿿]/.test(room.subject || "") ? "zh" : "en";
@@ -9011,6 +10244,7 @@ async function generateBrief(opts) {
9011
10244
  briefId: placeholder.id,
9012
10245
  roomId,
9013
10246
  style,
10247
+ mode,
9014
10248
  members,
9015
10249
  transcript,
9016
10250
  room,
@@ -9154,7 +10388,7 @@ async function runPipeline(args) {
9154
10388
  roomBus.emit(roomId, {
9155
10389
  type: "config-event",
9156
10390
  kind: "brief-started",
9157
- payload: { briefId, style, chairName, language },
10391
+ payload: { briefId, style, chairName, language, mode: args.mode },
9158
10392
  createdAt: Date.now()
9159
10393
  });
9160
10394
  let buf = "";
@@ -9247,6 +10481,26 @@ async function runPipeline(args) {
9247
10481
  `
9248
10482
  );
9249
10483
  }
10484
+ if (args.mode === "magazine" || args.mode === "newspaper" || args.mode === "ppt") {
10485
+ const ok = await runBentoStage({
10486
+ roomId,
10487
+ briefId,
10488
+ chair,
10489
+ chairId,
10490
+ room,
10491
+ members,
10492
+ perDirectorSignals,
10493
+ language,
10494
+ supplement,
10495
+ mode: args.mode,
10496
+ signal: args.signal
10497
+ });
10498
+ if (!ok) {
10499
+ const label = args.mode === "magazine" ? "Magazine" : args.mode === "ppt" ? "Slide deck" : "Newspaper";
10500
+ pipelineError = `${label} writer couldn't structure this room (3 retries failed). Try regenerating, or shorten the conversation.`;
10501
+ }
10502
+ return;
10503
+ }
9250
10504
  const stage1ActualSec = (Date.now() - stage1StartedAt) / 1e3;
9251
10505
  const stage1PredictedMid = (stage1Eta.lo + stage1Eta.hi) / 2;
9252
10506
  let calibration = stage1PredictedMid > 0.5 ? stage1ActualSec / stage1PredictedMid : 1;
@@ -9675,6 +10929,90 @@ async function runStage2(args) {
9675
10929
  }
9676
10930
  return null;
9677
10931
  }
10932
+ async function runBentoStage(args) {
10933
+ const totalSignals = args.perDirectorSignals.reduce(
10934
+ (acc, d) => acc + d.signals.length,
10935
+ 0
10936
+ );
10937
+ if (totalSignals === 0) return false;
10938
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
10939
+ const chairName = args.chair?.name || "Chair";
10940
+ const fallbackSource = `From ${chairName} \xB7 ${today}`;
10941
+ const subjectShort = (args.room.subject || "").slice(0, 60);
10942
+ const fallbackFooterTag = subjectShort ? `${subjectShort} \xB7 ${today}` : today;
10943
+ const buildMessages = args.mode === "newspaper" ? buildNewspaperMessages : args.mode === "ppt" ? buildPptMessages : buildMagazineMessages;
10944
+ const messages = buildMessages({
10945
+ chair: args.chair,
10946
+ room: args.room,
10947
+ members: args.members,
10948
+ perDirectorSignals: args.perDirectorSignals,
10949
+ language: args.language,
10950
+ supplement: args.supplement,
10951
+ fallbackSource,
10952
+ fallbackFooterTag
10953
+ });
10954
+ emitStage(args.roomId, args.briefId, "write", "active", void 0, void 0, { lo: 8, hi: 24 });
10955
+ for (const modelV of stageFlagshipList()) {
10956
+ if (!isModelV(modelV)) continue;
10957
+ for (let attempt = 0; attempt < STAGE_2_RETRIES; attempt++) {
10958
+ try {
10959
+ let buf = "";
10960
+ let totalTokens = 0;
10961
+ for await (const chunk of callLLMStream({
10962
+ modelV,
10963
+ messages,
10964
+ temperature: STAGE_2_TEMPERATURES[attempt] ?? 0.6,
10965
+ maxTokens: 8e3,
10966
+ signal: args.signal
10967
+ })) {
10968
+ if (chunk.type === "text") {
10969
+ buf += chunk.delta;
10970
+ roomBus.emit(args.roomId, {
10971
+ type: "config-event",
10972
+ kind: "brief-token",
10973
+ payload: { briefId: args.briefId, delta: chunk.delta },
10974
+ createdAt: Date.now()
10975
+ });
10976
+ } else if (chunk.type === "usage") {
10977
+ totalTokens = chunk.totalTokens;
10978
+ } else if (chunk.type === "error") {
10979
+ throw new Error(chunk.message);
10980
+ }
10981
+ }
10982
+ if (totalTokens > 0) billChair(args.chairId, { totalTokens });
10983
+ const bento = parseBento(buf, args.room.subject, fallbackSource, fallbackFooterTag);
10984
+ if (bento) {
10985
+ if (attempt > 0) {
10986
+ process.stderr.write(
10987
+ `[brief.${args.mode}] ${modelV} succeeded on retry ${attempt + 1}
10988
+ `
10989
+ );
10990
+ }
10991
+ updateBriefBodyJson(args.briefId, bento, bento.title);
10992
+ emitStage(args.roomId, args.briefId, "write", "done");
10993
+ roomBus.emit(args.roomId, {
10994
+ type: "config-event",
10995
+ kind: "brief-final",
10996
+ payload: { briefId: args.briefId, title: bento.title, modelV },
10997
+ createdAt: Date.now()
10998
+ });
10999
+ return true;
11000
+ }
11001
+ process.stderr.write(
11002
+ `[brief.${args.mode}] ${modelV} attempt ${attempt + 1}/${STAGE_2_RETRIES} produced unparseable bento
11003
+ `
11004
+ );
11005
+ } catch (e) {
11006
+ if (args.signal?.aborted) throw e;
11007
+ process.stderr.write(
11008
+ `[brief.${args.mode}] ${modelV} attempt ${attempt + 1}/${STAGE_2_RETRIES} failed: ${e instanceof Error ? e.message : String(e)}
11009
+ `
11010
+ );
11011
+ }
11012
+ }
11013
+ }
11014
+ return false;
11015
+ }
9678
11016
  function synthesizeDirectorPerspectivesFallback(perDirectorSignals, members) {
9679
11017
  const memberById = new Map(members.map((m) => [m.id, m]));
9680
11018
  const VALID_LENSES = /* @__PURE__ */ new Set([
@@ -9869,10 +11207,17 @@ function buildMethodologyFooter(args) {
9869
11207
 
9870
11208
  // src/routes/briefs.ts
9871
11209
  init_paths();
11210
+ function briefHasBody(b) {
11211
+ if (b.mode === "magazine" || b.mode === "newspaper" || b.mode === "ppt") {
11212
+ const j = b.bodyJson;
11213
+ return !!(j && typeof j === "object" && typeof j.title === "string" && j.title.length > 0);
11214
+ }
11215
+ return !!(b.bodyMd && b.bodyMd.trim().length > 0);
11216
+ }
9872
11217
  function briefsRouter() {
9873
11218
  const r = new Hono3();
9874
11219
  r.get("/", (c) => {
9875
- const briefs = listAllBriefs().filter((b) => !isBriefGenerating(b.id) && b.bodyMd && b.bodyMd.trim());
11220
+ const briefs = listAllBriefs().filter((b) => !isBriefGenerating(b.id) && briefHasBody(b));
9876
11221
  return c.json({ briefs });
9877
11222
  });
9878
11223
  r.get("/count", (c) => {
@@ -9887,7 +11232,7 @@ function briefsRouter() {
9887
11232
  const id = c.req.param("id");
9888
11233
  const b = getBrief(id);
9889
11234
  if (!b) return c.json({ error: "not found" }, 404);
9890
- const hasBody = !!(b.bodyMd && b.bodyMd.trim());
11235
+ const hasBody = briefHasBody(b);
9891
11236
  const generating = isBriefGenerating(id);
9892
11237
  const completed = hasBody && !generating;
9893
11238
  const state = generating ? getBriefGenerationState(id) : null;
@@ -10877,13 +12222,14 @@ function renderLongTermMemoryBlock(agentId, userName) {
10877
12222
  const memories = memoriesForContext(agentId);
10878
12223
  if (memories.length === 0) return "";
10879
12224
  const lines = memories.map((m) => {
10880
- const flag = m.pinned ? " \xB7 pinned" : "";
10881
- 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}`;
10882
12227
  });
12228
+ bumpUsage(memories.map((m) => m.id));
10883
12229
  return [
10884
12230
  "",
10885
12231
  `\u2500\u2500\u2500 WHAT YOU REMEMBER ABOUT ${userName} (cross-room, your own observations) \u2500\u2500\u2500`,
10886
- `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.`,
10887
12233
  ...lines,
10888
12234
  ""
10889
12235
  ].join("\n");
@@ -13016,6 +14362,15 @@ async function extractMemoriesAfterAdjourn(roomId) {
13016
14362
  }
13017
14363
  })
13018
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
+ }
13019
14374
  }
13020
14375
  async function runExtractionForAgent(agent, transcript, userName) {
13021
14376
  const system = [
@@ -14147,13 +15502,13 @@ function resolvePickerModel() {
14147
15502
  var TARGET_CAST_SIZE = 3;
14148
15503
  var LENS_AXES = ["dissent", "rigor", "empathy", "pattern_recall"];
14149
15504
  var LENS_THRESHOLD = 7;
14150
- function clipString(s, max) {
15505
+ function clipString2(s, max) {
14151
15506
  if (s.length <= max) return s;
14152
15507
  return s.slice(0, max).trim();
14153
15508
  }
14154
15509
  function describeDirector(a, recentCount) {
14155
15510
  const ability = a.ability ? Object.entries(a.ability).filter(([, v]) => typeof v === "number" && v >= LENS_THRESHOLD).map(([k, v]) => `${k}:${v}`).join(",") : "";
14156
- const bio = clipString(a.bio || "", 140).replace(/\s+/g, " ");
15511
+ const bio = clipString2(a.bio || "", 140).replace(/\s+/g, " ");
14157
15512
  const tag = (a.roleTag || "director").toLowerCase();
14158
15513
  const recencyTag = recentCount > 0 ? ` \xB7 [seen ${recentCount}/5 recent rooms]` : ` \xB7 [unseen recently]`;
14159
15514
  return `- ${a.handle} \xB7 ${a.name} \xB7 ${tag} \xB7 "${bio}"${ability ? ` \xB7 strong on { ${ability} }` : ""}${recencyTag}`;
@@ -14359,7 +15714,7 @@ async function pickDirectors(opts) {
14359
15714
  const agent = byHandle.get(handle);
14360
15715
  if (!agent) continue;
14361
15716
  if (llmPicks.find((x) => x.agent.id === agent.id)) continue;
14362
- const reason = typeof p.reason === "string" ? clipString(p.reason.trim(), 80) : "";
15717
+ const reason = typeof p.reason === "string" ? clipString2(p.reason.trim(), 80) : "";
14363
15718
  llmPicks.push({ agent, reason });
14364
15719
  if (llmPicks.length >= TARGET_CAST_SIZE) break;
14365
15720
  }
@@ -14374,7 +15729,7 @@ async function pickDirectors(opts) {
14374
15729
  }
14375
15730
  const reasonsByAgent = new Map(llmPicks.map((p) => [p.agent.id, p.reason]));
14376
15731
  const adjusted = enforceDiversity(llmPicks.map((p) => p.agent), candidates);
14377
- const rationale = typeof parsed.rationale === "string" ? clipString(parsed.rationale.trim(), 120) : "covers complementary lenses";
15732
+ const rationale = typeof parsed.rationale === "string" ? clipString2(parsed.rationale.trim(), 120) : "covers complementary lenses";
14378
15733
  return {
14379
15734
  picks: adjusted.map((a) => ({
14380
15735
  agentId: a.id,
@@ -15092,6 +16447,7 @@ function roomsRouter() {
15092
16447
  const explicit = typeof b.style === "string" && b.style ? b.style : null;
15093
16448
  const fromRoom = room.briefStyle && room.briefStyle !== "auto" ? room.briefStyle : null;
15094
16449
  const style = explicit || fromRoom || "mckinsey";
16450
+ const mode = b.mode === "magazine" || b.mode === "newspaper" || b.mode === "ppt" ? b.mode : "research-note";
15095
16451
  abortRoom(id);
15096
16452
  const adjournedAt = Date.now();
15097
16453
  setRoomStatus(id, "adjourned", { adjournedAt });
@@ -15123,7 +16479,7 @@ function roomsRouter() {
15123
16479
  }
15124
16480
  let briefId = null;
15125
16481
  try {
15126
- const result = await generateBrief({ roomId: id, style });
16482
+ const result = await generateBrief({ roomId: id, style, mode });
15127
16483
  briefId = result.briefId;
15128
16484
  } catch (e) {
15129
16485
  const msg = e instanceof Error ? e.message : String(e);
@@ -15172,11 +16528,13 @@ function roomsRouter() {
15172
16528
  const explicit = typeof b.style === "string" && b.style ? b.style : null;
15173
16529
  const fromRoom = room.briefStyle && room.briefStyle !== "auto" ? room.briefStyle : null;
15174
16530
  const style = explicit || fromRoom || "mckinsey";
16531
+ const mode = b.mode === "magazine" || b.mode === "newspaper" || b.mode === "ppt" ? b.mode : "research-note";
15175
16532
  try {
15176
16533
  const result = await generateBrief({
15177
16534
  roomId: id,
15178
16535
  style,
15179
- supplement: supplement || void 0
16536
+ supplement: supplement || void 0,
16537
+ mode
15180
16538
  });
15181
16539
  return c.json({ briefId: result.briefId, status: "generating" });
15182
16540
  } catch (e) {
@@ -15337,7 +16695,7 @@ function usageRouter() {
15337
16695
  init_paths();
15338
16696
 
15339
16697
  // src/version.ts
15340
- var VERSION = "0.1.7";
16698
+ var VERSION = "0.1.9";
15341
16699
 
15342
16700
  // src/server.ts
15343
16701
  function createApp() {
@@ -15469,6 +16827,25 @@ async function main() {
15469
16827
  process.stderr.write(`[boot] clarify recovery failed: ${e instanceof Error ? e.message : String(e)}
15470
16828
  `);
15471
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
+ })();
15472
16849
  const portArg = opts.port ? Number.parseInt(opts.port, 10) : void 0;
15473
16850
  if (portArg !== void 0 && (Number.isNaN(portArg) || portArg < 1 || portArg > 65535)) {
15474
16851
  console.error(`Invalid --port: ${opts.port}`);