@avocadostudio-ai/orchestrator-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/LICENSE +201 -0
  2. package/dist/agent/agent-context.d.ts +19 -0
  3. package/dist/agent/agent-context.js +67 -0
  4. package/dist/agent/agent-logger.d.ts +5 -0
  5. package/dist/agent/agent-logger.js +22 -0
  6. package/dist/agent/agent-loop-openai.d.ts +8 -0
  7. package/dist/agent/agent-loop-openai.js +172 -0
  8. package/dist/agent/agent-loop.d.ts +56 -0
  9. package/dist/agent/agent-loop.js +167 -0
  10. package/dist/agent/agent-provider.d.ts +28 -0
  11. package/dist/agent/agent-provider.js +63 -0
  12. package/dist/agent/agent-tools.d.ts +28 -0
  13. package/dist/agent/agent-tools.js +899 -0
  14. package/dist/agent/context/editing-guidelines.md +46 -0
  15. package/dist/agent/context/role.md +39 -0
  16. package/dist/agent/integration-prompt.d.ts +9 -0
  17. package/dist/agent/integration-prompt.js +154 -0
  18. package/dist/agent/sites-agent-context.d.ts +12 -0
  19. package/dist/agent/sites-agent-context.js +316 -0
  20. package/dist/agent/sites-agent-shared.d.ts +161 -0
  21. package/dist/agent/sites-agent-shared.js +1101 -0
  22. package/dist/agent/sites-agent-tools.d.ts +18 -0
  23. package/dist/agent/sites-agent-tools.js +1227 -0
  24. package/dist/chat/anthropic-cache.d.ts +20 -0
  25. package/dist/chat/anthropic-cache.js +54 -0
  26. package/dist/chat/anthropic-planner.d.ts +98 -0
  27. package/dist/chat/anthropic-planner.js +1012 -0
  28. package/dist/chat/changelog-coverage-validator.d.ts +37 -0
  29. package/dist/chat/changelog-coverage-validator.js +215 -0
  30. package/dist/chat/chat-pipeline-context.d.ts +211 -0
  31. package/dist/chat/chat-pipeline-context.js +249 -0
  32. package/dist/chat/chat-pipeline-deterministic.d.ts +61 -0
  33. package/dist/chat/chat-pipeline-deterministic.js +407 -0
  34. package/dist/chat/chat-pipeline-image.d.ts +86 -0
  35. package/dist/chat/chat-pipeline-image.js +897 -0
  36. package/dist/chat/chat-pipeline-shared.d.ts +69 -0
  37. package/dist/chat/chat-pipeline-shared.js +212 -0
  38. package/dist/chat/chat-pipeline-translation.d.ts +27 -0
  39. package/dist/chat/chat-pipeline-translation.js +417 -0
  40. package/dist/chat/chat-pipeline-ui.d.ts +14 -0
  41. package/dist/chat/chat-pipeline-ui.js +244 -0
  42. package/dist/chat/chat-pipeline.d.ts +99 -0
  43. package/dist/chat/chat-pipeline.js +3999 -0
  44. package/dist/chat/decomposer.d.ts +21 -0
  45. package/dist/chat/decomposer.js +65 -0
  46. package/dist/chat/gemini-planner.d.ts +70 -0
  47. package/dist/chat/gemini-planner.js +541 -0
  48. package/dist/chat/hallucination-validator.d.ts +36 -0
  49. package/dist/chat/hallucination-validator.js +110 -0
  50. package/dist/chat/locale-strings.d.ts +47 -0
  51. package/dist/chat/locale-strings.js +100 -0
  52. package/dist/chat/plan-json-schema.d.ts +133 -0
  53. package/dist/chat/plan-json-schema.js +112 -0
  54. package/dist/chat/planner-types.d.ts +120 -0
  55. package/dist/chat/planner-types.js +66 -0
  56. package/dist/chat/planner.d.ts +148 -0
  57. package/dist/chat/planner.js +1361 -0
  58. package/dist/chat/prompts.d.ts +67 -0
  59. package/dist/chat/prompts.js +356 -0
  60. package/dist/chat/provider-routing.d.ts +14 -0
  61. package/dist/chat/provider-routing.js +27 -0
  62. package/dist/chat/variation-pipeline.d.ts +135 -0
  63. package/dist/chat/variation-pipeline.js +837 -0
  64. package/dist/chat/vision-alt-generator.d.ts +35 -0
  65. package/dist/chat/vision-alt-generator.js +152 -0
  66. package/dist/cms/adapter.d.ts +62 -0
  67. package/dist/cms/adapter.js +1 -0
  68. package/dist/cms/bootstrap.d.ts +17 -0
  69. package/dist/cms/bootstrap.js +85 -0
  70. package/dist/cms/editor-api-adapter.d.ts +23 -0
  71. package/dist/cms/editor-api-adapter.js +71 -0
  72. package/dist/cms/index.d.ts +4 -0
  73. package/dist/cms/index.js +3 -0
  74. package/dist/cms/json-file-adapter.d.ts +11 -0
  75. package/dist/cms/json-file-adapter.js +62 -0
  76. package/dist/demo-mode.d.ts +59 -0
  77. package/dist/demo-mode.js +201 -0
  78. package/dist/errors.d.ts +67 -0
  79. package/dist/errors.js +129 -0
  80. package/dist/http/chat-stream-resumable.d.ts +108 -0
  81. package/dist/http/chat-stream-resumable.js +290 -0
  82. package/dist/http/chat-stream.d.ts +99 -0
  83. package/dist/http/chat-stream.js +92 -0
  84. package/dist/image/gdrive-client.d.ts +22 -0
  85. package/dist/image/gdrive-client.js +215 -0
  86. package/dist/image/image-helpers.d.ts +95 -0
  87. package/dist/image/image-helpers.js +488 -0
  88. package/dist/index.d.ts +1 -0
  89. package/dist/index.js +1 -0
  90. package/dist/jira/jira-approval.d.ts +22 -0
  91. package/dist/jira/jira-approval.js +51 -0
  92. package/dist/jira/jira-client.d.ts +44 -0
  93. package/dist/jira/jira-client.js +313 -0
  94. package/dist/jira/jira-poller.d.ts +46 -0
  95. package/dist/jira/jira-poller.js +184 -0
  96. package/dist/jira/jira-processor.d.ts +103 -0
  97. package/dist/jira/jira-processor.js +1085 -0
  98. package/dist/jira/jira-types.d.ts +117 -0
  99. package/dist/jira/jira-types.js +38 -0
  100. package/dist/logger.d.ts +12 -0
  101. package/dist/logger.js +28 -0
  102. package/dist/migration/mcp-server-stdio.d.ts +8 -0
  103. package/dist/migration/mcp-server-stdio.js +672 -0
  104. package/dist/migration/migration-prompt.d.ts +7 -0
  105. package/dist/migration/migration-prompt.js +197 -0
  106. package/dist/migration/migration-tools.d.ts +17 -0
  107. package/dist/migration/migration-tools.js +159 -0
  108. package/dist/migration/scrape-cache.d.ts +9 -0
  109. package/dist/migration/scrape-cache.js +19 -0
  110. package/dist/nlp/deterministic-planner-context.d.ts +141 -0
  111. package/dist/nlp/deterministic-planner-context.js +362 -0
  112. package/dist/nlp/deterministic-planner-pages.d.ts +26 -0
  113. package/dist/nlp/deterministic-planner-pages.js +170 -0
  114. package/dist/nlp/deterministic-planner-patches.d.ts +80 -0
  115. package/dist/nlp/deterministic-planner-patches.js +508 -0
  116. package/dist/nlp/deterministic-planner-refs.d.ts +33 -0
  117. package/dist/nlp/deterministic-planner-refs.js +164 -0
  118. package/dist/nlp/deterministic-planner-suggestions.d.ts +49 -0
  119. package/dist/nlp/deterministic-planner-suggestions.js +579 -0
  120. package/dist/nlp/deterministic-planner.d.ts +85 -0
  121. package/dist/nlp/deterministic-planner.js +1631 -0
  122. package/dist/nlp/intent-detection.d.ts +309 -0
  123. package/dist/nlp/intent-detection.js +730 -0
  124. package/dist/nlp/intent-helpers.d.ts +15 -0
  125. package/dist/nlp/intent-helpers.js +243 -0
  126. package/dist/nlp/intent-patterns.d.ts +40 -0
  127. package/dist/nlp/intent-patterns.js +223 -0
  128. package/dist/nlp/plan-normalizer.d.ts +41 -0
  129. package/dist/nlp/plan-normalizer.js +1537 -0
  130. package/dist/ops/destructive-action-gate.d.ts +44 -0
  131. package/dist/ops/destructive-action-gate.js +90 -0
  132. package/dist/ops/ops-engine.d.ts +151 -0
  133. package/dist/ops/ops-engine.js +1394 -0
  134. package/dist/publish/diff-engine.d.ts +18 -0
  135. package/dist/publish/diff-engine.js +305 -0
  136. package/dist/publish/publish-helpers.d.ts +87 -0
  137. package/dist/publish/publish-helpers.js +521 -0
  138. package/dist/publish/publish-target-registry.d.ts +7 -0
  139. package/dist/publish/publish-target-registry.js +61 -0
  140. package/dist/publish/publish-target.d.ts +81 -0
  141. package/dist/publish/publish-target.js +1 -0
  142. package/dist/publish/targets/deploy-hook.d.ts +13 -0
  143. package/dist/publish/targets/deploy-hook.js +123 -0
  144. package/dist/publish/targets/git.d.ts +13 -0
  145. package/dist/publish/targets/git.js +55 -0
  146. package/dist/publish/targets/site-contract.d.ts +19 -0
  147. package/dist/publish/targets/site-contract.js +124 -0
  148. package/dist/state/content-source.d.ts +17 -0
  149. package/dist/state/content-source.js +1 -0
  150. package/dist/state/in-memory-content-source.d.ts +27 -0
  151. package/dist/state/in-memory-content-source.js +51 -0
  152. package/dist/state/session-lock.d.ts +13 -0
  153. package/dist/state/session-lock.js +29 -0
  154. package/dist/state/session-state.d.ts +310 -0
  155. package/dist/state/session-state.js +1083 -0
  156. package/dist/state/sqlite-store-singleton.d.ts +31 -0
  157. package/dist/state/sqlite-store-singleton.js +170 -0
  158. package/dist/state/sqlite-store.d.ts +135 -0
  159. package/dist/state/sqlite-store.js +421 -0
  160. package/dist/telemetry/chat-telemetry.d.ts +105 -0
  161. package/dist/telemetry/chat-telemetry.js +247 -0
  162. package/dist/telemetry/eval-candidate-store.d.ts +50 -0
  163. package/dist/telemetry/eval-candidate-store.js +120 -0
  164. package/dist/telemetry/feedback-store.d.ts +34 -0
  165. package/dist/telemetry/feedback-store.js +76 -0
  166. package/dist/telemetry/jira-telemetry.d.ts +57 -0
  167. package/dist/telemetry/jira-telemetry.js +68 -0
  168. package/dist/telemetry/migration-telemetry.d.ts +35 -0
  169. package/dist/telemetry/migration-telemetry.js +40 -0
  170. package/dist/telemetry/usage.d.ts +24 -0
  171. package/dist/telemetry/usage.js +80 -0
  172. package/dist/tools/builtin-registrations.d.ts +12 -0
  173. package/dist/tools/builtin-registrations.js +33 -0
  174. package/dist/tools/builtins/gdrive-browse.d.ts +3 -0
  175. package/dist/tools/builtins/gdrive-browse.js +68 -0
  176. package/dist/tools/builtins/image-generate.d.ts +3 -0
  177. package/dist/tools/builtins/image-generate.js +211 -0
  178. package/dist/tools/builtins/unsplash-get-by-id.d.ts +23 -0
  179. package/dist/tools/builtins/unsplash-get-by-id.js +119 -0
  180. package/dist/tools/builtins/unsplash-search.d.ts +3 -0
  181. package/dist/tools/builtins/unsplash-search.js +74 -0
  182. package/dist/tools/executor.d.ts +23 -0
  183. package/dist/tools/executor.js +169 -0
  184. package/dist/tools/index.d.ts +5 -0
  185. package/dist/tools/index.js +5 -0
  186. package/dist/tools/registry.d.ts +21 -0
  187. package/dist/tools/registry.js +75 -0
  188. package/dist/tools/runtime.d.ts +27 -0
  189. package/dist/tools/runtime.js +48 -0
  190. package/dist/tools/schema-validator.d.ts +24 -0
  191. package/dist/tools/schema-validator.js +88 -0
  192. package/dist/tools/types.d.ts +86 -0
  193. package/dist/tools/types.js +1 -0
  194. package/dist/variation-images.d.ts +19 -0
  195. package/dist/variation-images.js +12 -0
  196. package/package.json +78 -0
@@ -0,0 +1,1083 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { demoPublishedPages, demoSiteConfig, ensureItemIds, IMAGE_PLACEHOLDER } from "@avocadostudio-ai/shared";
4
+ import { toErrorDetail as _unifiedToErrorDetail } from "../errors.js";
5
+ import { archiveMigratedJson, getStore, readLegacyJson, resolveJsonMigrationTtlDays, sweepStaleMigrations, } from "./sqlite-store-singleton.js";
6
+ import { CHAT_HISTORY_CAP, HISTORY_DEPTH_CAP, RECENT_EDITS_CAP, VERSION_LOG_CAP, PUBLISH_LOG_CAP, } from "./sqlite-store.js";
7
+ // Single source of truth for these caps is `sqlite-store.ts`. Re-exported
8
+ // under the legacy names so existing imports keep working.
9
+ export const HISTORY_DEPTH_MAX = HISTORY_DEPTH_CAP;
10
+ export const VERSION_LOG_MAX = VERSION_LOG_CAP;
11
+ export const CHAT_HISTORY_MAX_TURNS = CHAT_HISTORY_CAP;
12
+ export const PUBLISH_LOG_MAX = PUBLISH_LOG_CAP;
13
+ // ---------------------------------------------------------------------------
14
+ // Session-key helpers
15
+ // ---------------------------------------------------------------------------
16
+ export const DEFAULT_SITE_ID = "avocado-stories";
17
+ export const DEFAULT_SESSION = "dev";
18
+ export function normalizeSiteId(value) {
19
+ if (typeof value !== "string")
20
+ return DEFAULT_SITE_ID;
21
+ const cleaned = value
22
+ .trim()
23
+ .toLowerCase()
24
+ .replace(/[^a-z0-9_-]+/g, "-")
25
+ .replace(/^-+|-+$/g, "");
26
+ return cleaned || DEFAULT_SITE_ID;
27
+ }
28
+ export function normalizeSession(value) {
29
+ if (typeof value !== "string")
30
+ return DEFAULT_SESSION;
31
+ const cleaned = value.trim();
32
+ return cleaned || DEFAULT_SESSION;
33
+ }
34
+ /** Returns true for the default JSON-file site (avocado-stories). */
35
+ export function isLegacySiteId(siteId) {
36
+ return siteId === DEFAULT_SITE_ID || siteId === "default";
37
+ }
38
+ export function scopedSessionKey(session, siteId) {
39
+ const normalizedSession = normalizeSession(session);
40
+ const normalizedSiteId = normalizeSiteId(siteId);
41
+ if (isLegacySiteId(normalizedSiteId)) {
42
+ return normalizedSession;
43
+ }
44
+ return `${normalizedSiteId}::${normalizedSession}`;
45
+ }
46
+ export const continuationChainBySession = new Map();
47
+ // ---------------------------------------------------------------------------
48
+ // State maps
49
+ // ---------------------------------------------------------------------------
50
+ export const publishedPages = new Map();
51
+ for (const page of demoPublishedPages())
52
+ publishedPages.set(page.slug, structuredClone(page));
53
+ export const draftPages = new Map();
54
+ export const historyUndo = new Map();
55
+ export const historyRedo = new Map();
56
+ export const versions = new Map();
57
+ export const recentEdits = new Map();
58
+ export const versionLog = new Map();
59
+ /**
60
+ * Per-session publish-log entries — one row per `POST /publish` attempt.
61
+ * Mirrors the SQLite `publish_log` table. Unlike most other state maps, this
62
+ * one is NOT included in `writeAllStateToStore`'s drop-and-reinsert pass:
63
+ * publish entries support async UPDATE (deploy resolves seconds later) and
64
+ * the SQLite row is the source of truth. Writes go straight through via
65
+ * `pushPublishLogEntry` / `updatePublishLogStatus`; this Map is a read cache.
66
+ */
67
+ export const publishLogBySession = new Map();
68
+ /**
69
+ * Per-ticket slugs touched by an agent run. Survives the execute→publish
70
+ * invocation gap so the publish-mode comment can list exactly what this
71
+ * ticket changed, rather than every page in the shared session. Capped at
72
+ * ISSUE_TOUCHED_MAX entries (oldest dropped by updatedAt).
73
+ */
74
+ export const issueTouchedSlugsByKey = new Map();
75
+ const ISSUE_TOUCHED_MAX = 200;
76
+ export function setIssueTouchedSlugs(issueKey, slugs) {
77
+ if (!issueKey)
78
+ return;
79
+ issueTouchedSlugsByKey.set(issueKey, {
80
+ slugs: Array.from(new Set(slugs.filter((s) => typeof s === "string" && s.length > 0))),
81
+ updatedAt: new Date().toISOString(),
82
+ });
83
+ if (issueTouchedSlugsByKey.size > ISSUE_TOUCHED_MAX) {
84
+ const sorted = Array.from(issueTouchedSlugsByKey.entries())
85
+ .sort((a, b) => a[1].updatedAt.localeCompare(b[1].updatedAt));
86
+ const toDelete = sorted.slice(0, issueTouchedSlugsByKey.size - ISSUE_TOUCHED_MAX);
87
+ for (const [key] of toDelete)
88
+ issueTouchedSlugsByKey.delete(key);
89
+ }
90
+ }
91
+ export function getIssueTouchedSlugs(issueKey) {
92
+ return issueTouchedSlugsByKey.get(issueKey)?.slugs ?? [];
93
+ }
94
+ export const pendingClarificationBySession = new Map();
95
+ export const chatHistoryBySession = new Map();
96
+ export const pendingApprovalPlanBySession = new Map();
97
+ export const imageSourcePreferenceBySession = new Map();
98
+ export const publishStatusBySession = new Map();
99
+ export const siteConfigs = new Map();
100
+ // No hardcoded seed: drafts now hydrate from the live published JSON via
101
+ // `hydrateSiteConfigFromPublished` on first session touch. A hardcoded seed
102
+ // here would silently win over published nav state during the merge,
103
+ // re-introducing the wipe-on-publish bug it originally tried to prevent.
104
+ export let lastPublishedScopedSession;
105
+ export function setLastPublishedScopedSession(key) { lastPublishedScopedSession = key; }
106
+ function parseSessionKey(sessionKey) {
107
+ const sep = sessionKey.indexOf("::");
108
+ if (sep === -1)
109
+ return { session: sessionKey, siteId: DEFAULT_SITE_ID };
110
+ return { siteId: sessionKey.slice(0, sep), session: sessionKey.slice(sep + 2) };
111
+ }
112
+ export function getSessionSummary(sessionKey) {
113
+ const { session, siteId } = parseSessionKey(sessionKey);
114
+ // Read draftPages directly to avoid auto-seeding via getSessionDraft, which
115
+ // would inflate draftPageCount for never-touched sessions.
116
+ const draftPageCount = draftPages.get(sessionKey)?.size ?? 0;
117
+ const version = versions.get(sessionKey) ?? 0;
118
+ const log = versionLog.get(sessionKey);
119
+ const lastMutatedAt = log && log.length > 0 ? log[log.length - 1].at : null;
120
+ return { sessionKey, session, siteId, version, draftPageCount, lastMutatedAt };
121
+ }
122
+ export function listSessionSummaries() {
123
+ // Union of every key seen across draft / version / version-log maps. A
124
+ // session may appear in `versions` (bumped) without `draftPages` (e.g.
125
+ // when only site-config was touched), or vice versa, so we walk all three.
126
+ const keys = new Set();
127
+ for (const k of draftPages.keys())
128
+ keys.add(k);
129
+ for (const k of versions.keys())
130
+ keys.add(k);
131
+ for (const k of versionLog.keys())
132
+ keys.add(k);
133
+ const summaries = Array.from(keys, (key) => getSessionSummary(key));
134
+ // Sort most-recently-mutated first, then by key for deterministic output.
135
+ summaries.sort((a, b) => {
136
+ if (a.lastMutatedAt && b.lastMutatedAt)
137
+ return b.lastMutatedAt.localeCompare(a.lastMutatedAt);
138
+ if (a.lastMutatedAt)
139
+ return -1;
140
+ if (b.lastMutatedAt)
141
+ return 1;
142
+ return a.sessionKey.localeCompare(b.sessionKey);
143
+ });
144
+ return summaries;
145
+ }
146
+ /** Total published pages across all sites (currently just the JSON-file legacy site). */
147
+ export function publishedPageCountGlobal() {
148
+ return publishedPages.size;
149
+ }
150
+ // Track sessions that were just restored so bootstrap doesn't overwrite them
151
+ const recentlyRestoredSessions = new Set();
152
+ export function markRecentlyRestored(session) { recentlyRestoredSessions.add(session); }
153
+ export function consumeRecentlyRestored(session) { return recentlyRestoredSessions.delete(session); }
154
+ // ---------------------------------------------------------------------------
155
+ // Persistence config
156
+ // ---------------------------------------------------------------------------
157
+ /**
158
+ * Legacy JSON state file path. Resolved lazily each call so tests can
159
+ * point to a temp file via `ORCHESTRATOR_STATE_FILE`. Read only at
160
+ * startup, for one-shot migration into SQLite. Once migrated, the file
161
+ * is renamed to `<path>.migrated-<ts>` and swept after
162
+ * `ORCHESTRATOR_JSON_MIGRATION_TTL_DAYS`.
163
+ */
164
+ function resolveStateFilePath() {
165
+ return (process.env.ORCHESTRATOR_STATE_FILE ??
166
+ resolve(process.cwd(), "../../.data/orchestrator-state.json"));
167
+ }
168
+ /**
169
+ * Sentinel session key used to store the global `publishedPages` Map in the
170
+ * per-session `pages` table. Picked so it can never collide with a real
171
+ * session key (which is either `<site>::<session>` or a bare session name).
172
+ */
173
+ const PUBLISHED_GLOBAL_KEY = "__published_global__";
174
+ // ---------------------------------------------------------------------------
175
+ // Utility: error detail extractor — canonical impl in ../errors.ts
176
+ // ---------------------------------------------------------------------------
177
+ export const toErrorDetail = _unifiedToErrorDetail;
178
+ // ---------------------------------------------------------------------------
179
+ // Hero image prop guard (needed by getSessionDraft, setPage, applyPersistedState)
180
+ // ---------------------------------------------------------------------------
181
+ export function ensureHeroImageProps(page) {
182
+ for (const block of page.blocks) {
183
+ const props = block.props;
184
+ if (block.type === "Hero") {
185
+ // Skip imageUrl fallback if the block uses carouselImages instead
186
+ if (typeof props.imageUrl !== "string" || props.imageUrl.length === 0) {
187
+ if (!Array.isArray(props.carouselImages) || props.carouselImages.length === 0) {
188
+ props.imageUrl = IMAGE_PLACEHOLDER;
189
+ }
190
+ }
191
+ if (typeof props.imageAlt !== "string" || props.imageAlt.length === 0) {
192
+ props.imageAlt =
193
+ page.slug === "/pricing"
194
+ ? "Abstract generated illustration for the pricing hero"
195
+ : "Abstract generated illustration for the hero section";
196
+ }
197
+ continue;
198
+ }
199
+ if (block.type === "TwoColumn") {
200
+ if (!Array.isArray(props.left) || props.left.length === 0) {
201
+ // Migrate from legacy flat props if present
202
+ const heading = typeof props.heading === "string" ? props.heading
203
+ : typeof props.title === "string" ? props.title : "Section heading";
204
+ const body = typeof props.body === "string" ? props.body
205
+ : typeof props.description === "string" ? props.description : "Section content";
206
+ props.left = [
207
+ { type: "heading", text: heading },
208
+ { type: "paragraph", text: body }
209
+ ];
210
+ }
211
+ if (!Array.isArray(props.right) || props.right.length === 0) {
212
+ const src = typeof props.imageUrl === "string" ? props.imageUrl : IMAGE_PLACEHOLDER;
213
+ const alt = typeof props.imageAlt === "string" ? props.imageAlt : "Section image";
214
+ props.right = [{ type: "image", src, alt }];
215
+ }
216
+ if (props.variant !== "default" && props.variant !== "accent") {
217
+ props.variant = "default";
218
+ }
219
+ }
220
+ }
221
+ }
222
+ // ---------------------------------------------------------------------------
223
+ // Slug ordering helper
224
+ // ---------------------------------------------------------------------------
225
+ export function orderSlugsHomeFirst(slugs) {
226
+ return slugs.includes("/") ? ["/", ...slugs.filter((route) => route !== "/")] : slugs;
227
+ }
228
+ // ---------------------------------------------------------------------------
229
+ // Accessor functions
230
+ // ---------------------------------------------------------------------------
231
+ /** Resolve monorepo root (orchestrator lives at apps/orchestrator/) */
232
+ function monorepoRoot() {
233
+ return resolve(dirname(new URL(import.meta.url).pathname), "../../../");
234
+ }
235
+ /**
236
+ * Try to read a site's content/pages.json synchronously.
237
+ * Returns parsed PageDoc[] or empty array on failure.
238
+ */
239
+ function readSitePages(siteId) {
240
+ try {
241
+ const pagesPath = resolve(monorepoRoot(), "apps", siteId, "content", "pages.json");
242
+ if (!existsSync(pagesPath))
243
+ return [];
244
+ const raw = readFileSync(pagesPath, "utf-8");
245
+ const parsed = JSON.parse(raw);
246
+ return Array.isArray(parsed) ? parsed : [];
247
+ }
248
+ catch {
249
+ return [];
250
+ }
251
+ }
252
+ /**
253
+ * Try to read a site's content/site-config.json synchronously.
254
+ * Seeds siteConfigs with nav labels, logo, footer, etc.
255
+ */
256
+ function seedSiteConfig(session, siteId) {
257
+ try {
258
+ const configPath = resolve(monorepoRoot(), "apps", siteId, "content", "site-config.json");
259
+ if (!existsSync(configPath))
260
+ return;
261
+ const config = JSON.parse(readFileSync(configPath, "utf-8"));
262
+ if (config && typeof config === "object") {
263
+ siteConfigs.set(session, { ...siteConfigs.get(session), ...config });
264
+ }
265
+ }
266
+ catch { /* ignore */ }
267
+ }
268
+ /**
269
+ * Monorepo-only: backfill the in-memory siteConfig from the bundled
270
+ * apps/site/lib/published-content.json on first session touch.
271
+ *
272
+ * Why: the orch's draft siteConfig can drift narrower than what's actually
273
+ * published (e.g. SQLite outage loses a session's nav state, or a fresh boot
274
+ * of an already-published site has nothing in SQLite). Without this fill-in,
275
+ * the next publish silently wipes nav fields the live site still depends on.
276
+ *
277
+ * SDK consumers wire `getSiteConfig` into `createEditorApiHandler` and the
278
+ * orchestrator picks the live state up through `/publish/diff`'s
279
+ * loadPublishedForDiff path; this helper is only for the bundled apps/site.
280
+ *
281
+ * Exported for tests; production callers omit `publishedPathOverride`.
282
+ */
283
+ export function hydrateSiteConfigFromPublished(session, publishedPathOverride) {
284
+ try {
285
+ // monorepoRoot() resolves to apps/ (the function is named optimistically —
286
+ // see callers in readSitePages/seedSiteConfig). The bundled site lives at
287
+ // apps/site, so resolve from there.
288
+ const publishedPath = publishedPathOverride
289
+ ?? resolve(monorepoRoot(), "site", "lib", "published-content.json");
290
+ if (!existsSync(publishedPath))
291
+ return;
292
+ const parsed = JSON.parse(readFileSync(publishedPath, "utf-8"));
293
+ const published = parsed.siteConfig;
294
+ if (!published || typeof published !== "object")
295
+ return;
296
+ // Existing keys win — protects against clobbering an in-flight edit on
297
+ // restart. Published only backfills keys the draft is missing.
298
+ const existing = siteConfigs.get(session) ?? {};
299
+ const merged = { ...published, ...existing };
300
+ siteConfigs.set(session, merged);
301
+ }
302
+ catch { /* ignore */ }
303
+ }
304
+ export function getSessionDraft(session) {
305
+ let sessionMap = draftPages.get(session);
306
+ if (!sessionMap) {
307
+ sessionMap = new Map();
308
+ if (!session.includes("::")) {
309
+ // Legacy/default sessions — seed from published demo pages
310
+ for (const [slug, page] of publishedPages) {
311
+ const copy = structuredClone(page);
312
+ ensureHeroImageProps(copy);
313
+ ensureItemIds(copy.blocks);
314
+ sessionMap.set(slug, copy);
315
+ }
316
+ }
317
+ else {
318
+ // Site-scoped sessions — seed from the site's content/pages.json on disk.
319
+ // This ensures migrated sites load immediately without waiting for async bootstrap.
320
+ const siteId = session.split("::")[0];
321
+ const sitePages = readSitePages(siteId);
322
+ if (sitePages.length > 0) {
323
+ for (const page of sitePages) {
324
+ const copy = structuredClone(page);
325
+ ensureHeroImageProps(copy);
326
+ ensureItemIds(copy.blocks);
327
+ sessionMap.set(copy.slug, copy);
328
+ }
329
+ seedSiteConfig(session, siteId);
330
+ console.log(`[session-state] Seeded ${session} from apps/${siteId}/content/pages.json (${sitePages.length} pages)`);
331
+ }
332
+ // Monorepo: backfill nav state from the bundled apps/site/lib/published-content.json
333
+ // so we don't silently wipe live nav fields on next publish. The shared
334
+ // `ensureSiteConfigHydrated` gate keeps this idempotent across the
335
+ // multiple entry points that touch a session (getSiteConfig, etc).
336
+ ensureSiteConfigHydrated(session);
337
+ }
338
+ draftPages.set(session, sessionMap);
339
+ }
340
+ return sessionMap;
341
+ }
342
+ export function getHistoryMap(store, session) {
343
+ let bySession = store.get(session);
344
+ if (!bySession) {
345
+ bySession = new Map();
346
+ store.set(session, bySession);
347
+ }
348
+ return bySession;
349
+ }
350
+ export function getPage(session, slug) {
351
+ const sessionDraft = getSessionDraft(session);
352
+ return sessionDraft.get(slug) ?? null;
353
+ }
354
+ export function getSessionPages(session) {
355
+ const draft = getSessionDraft(session);
356
+ const slugs = orderSlugsHomeFirst(Array.from(draft.keys()));
357
+ return slugs.map((slug) => structuredClone(draft.get(slug)));
358
+ }
359
+ // ---------------------------------------------------------------------------
360
+ // Context cache — invalidated on setPage / removePage / bumpVersion
361
+ // ---------------------------------------------------------------------------
362
+ const _contextCache = new Map();
363
+ export function getContextCache(session) {
364
+ return _contextCache.get(session);
365
+ }
366
+ export function setContextCache(session, entry) {
367
+ _contextCache.set(session, entry);
368
+ }
369
+ function invalidateContextCache(session) {
370
+ _contextCache.delete(session);
371
+ }
372
+ export function setPage(session, page) {
373
+ const sessionDraft = getSessionDraft(session);
374
+ ensureHeroImageProps(page);
375
+ ensureItemIds(page.blocks);
376
+ sessionDraft.set(page.slug, page);
377
+ invalidateContextCache(session);
378
+ }
379
+ export function removePage(session, slug) {
380
+ const sessionDraft = getSessionDraft(session);
381
+ sessionDraft.delete(slug);
382
+ invalidateContextCache(session);
383
+ }
384
+ /**
385
+ * Append `snapshot` to an undo/redo stack, evicting the oldest entries
386
+ * once `HISTORY_DEPTH_MAX` is exceeded. Mutates `list` in place.
387
+ */
388
+ export function pushCappedHistory(list, snapshot) {
389
+ list.push(snapshot === null ? null : structuredClone(snapshot));
390
+ if (list.length > HISTORY_DEPTH_MAX) {
391
+ list.splice(0, list.length - HISTORY_DEPTH_MAX);
392
+ }
393
+ }
394
+ export function pushUndo(session, slug, snapshot) {
395
+ const undoMap = getHistoryMap(historyUndo, session);
396
+ const list = undoMap.get(slug) ?? [];
397
+ pushCappedHistory(list, snapshot);
398
+ undoMap.set(slug, list);
399
+ const redoMap = getHistoryMap(historyRedo, session);
400
+ redoMap.set(slug, []);
401
+ }
402
+ export function bumpVersion(session) {
403
+ const current = versions.get(session) ?? 0;
404
+ const next = current + 1;
405
+ versions.set(session, next);
406
+ invalidateContextCache(session);
407
+ return next;
408
+ }
409
+ export function pushRecentEdit(session, entry) {
410
+ const list = recentEdits.get(session) ?? [];
411
+ list.push({ ...entry, at: new Date().toISOString() });
412
+ recentEdits.set(session, list.slice(-RECENT_EDITS_CAP));
413
+ }
414
+ export function pushVersionEntry(session, entry) {
415
+ const list = versionLog.get(session) ?? [];
416
+ list.push({ ...entry, at: new Date().toISOString() });
417
+ versionLog.set(session, list.slice(-VERSION_LOG_MAX));
418
+ }
419
+ export function getVersionLog(session, slug, limit = 50) {
420
+ const list = versionLog.get(session) ?? [];
421
+ const filtered = slug ? list.filter((e) => e.slug === slug) : list;
422
+ return filtered.slice(-limit);
423
+ }
424
+ // ---------------------------------------------------------------------------
425
+ // Publish log
426
+ // ---------------------------------------------------------------------------
427
+ /**
428
+ * Insert a publish-log row. Writes through to SQLite immediately so the row
429
+ * survives a process restart between the initial publish and the later async
430
+ * status update (deploy resolution can take seconds to minutes). Also mirrors
431
+ * into `publishLogBySession` for cheap reads.
432
+ *
433
+ * The `id` field is generated here (UUID) so callers don't need to import
434
+ * crypto. Returns the full materialised entry, including `id` / timestamps.
435
+ */
436
+ export function pushPublishLogEntry(partial) {
437
+ const now = new Date().toISOString();
438
+ const entry = {
439
+ ...partial,
440
+ id: globalThis.crypto.randomUUID(),
441
+ at: now,
442
+ updatedAt: now,
443
+ };
444
+ try {
445
+ getStore().pushPublishLog(entry);
446
+ }
447
+ catch (error) {
448
+ // Persistence failure must not break the publish — log and continue with
449
+ // the in-memory mirror only. The next publish will retry.
450
+ console.error("[publish-log] failed to persist row", error);
451
+ }
452
+ const list = publishLogBySession.get(entry.session) ?? [];
453
+ list.push(entry);
454
+ publishLogBySession.set(entry.session, list.slice(-PUBLISH_LOG_MAX));
455
+ return entry;
456
+ }
457
+ /**
458
+ * Patch an existing publish-log row by `id`. Updates SQLite and the in-memory
459
+ * mirror. Used by `GET /publish/status` to transition `triggered` → `success`
460
+ * or `failed` when Vercel resolves. Returns the updated entry, or null if the
461
+ * id was not found.
462
+ */
463
+ export function updatePublishLogStatusById(id, patch) {
464
+ let updated = null;
465
+ try {
466
+ updated = getStore().updatePublishLogById(id, patch);
467
+ }
468
+ catch (error) {
469
+ console.error("[publish-log] failed to update row", error);
470
+ return null;
471
+ }
472
+ if (!updated)
473
+ return null;
474
+ const list = publishLogBySession.get(updated.session) ?? [];
475
+ const idx = list.findIndex((e) => e.id === id);
476
+ if (idx >= 0)
477
+ list[idx] = updated;
478
+ else
479
+ list.push(updated);
480
+ publishLogBySession.set(updated.session, list.slice(-PUBLISH_LOG_MAX));
481
+ return updated;
482
+ }
483
+ /**
484
+ * Resolve a publish-log row to update when an async deploy status arrives.
485
+ * Prefers an exact `deploymentId` match; falls back to the most recent
486
+ * `triggered` row for the session (covers deploy-hook targets that don't
487
+ * parse a deployment id out of their response).
488
+ */
489
+ export function findPublishLogForStatusUpdate(session, deploymentId) {
490
+ const store = getStore();
491
+ if (deploymentId) {
492
+ const byDeployment = store.getPublishLogByDeploymentId(session, deploymentId);
493
+ if (byDeployment)
494
+ return byDeployment;
495
+ }
496
+ return store.getMostRecentTriggeredPublishLog(session);
497
+ }
498
+ export function listPublishLog(session, limit = 50) {
499
+ const cached = publishLogBySession.get(session);
500
+ if (cached && cached.length > 0)
501
+ return cached.slice(-limit);
502
+ // Cache miss — read through. Cheap (one indexed scan).
503
+ try {
504
+ const fromStore = getStore().listPublishLog(session, limit);
505
+ if (fromStore.length > 0)
506
+ publishLogBySession.set(session, fromStore.slice(-PUBLISH_LOG_MAX));
507
+ return fromStore;
508
+ }
509
+ catch (error) {
510
+ console.error("[publish-log] failed to list rows", error);
511
+ return [];
512
+ }
513
+ }
514
+ export function pushChatHistory(session, userMessage, assistantSummary) {
515
+ const history = chatHistoryBySession.get(session) ?? [];
516
+ // Never store blank turns — an empty/whitespace content block carries no context and
517
+ // is invalid for the planner LLMs (Anthropic rejects the WHOLE request if any history
518
+ // message is empty). A whitespace-only user message must not poison the session. (#33)
519
+ if (userMessage.trim().length > 0)
520
+ history.push({ role: "user", content: userMessage });
521
+ if (assistantSummary.trim().length > 0)
522
+ history.push({ role: "assistant", content: assistantSummary });
523
+ chatHistoryBySession.set(session, history.slice(-CHAT_HISTORY_MAX_TURNS));
524
+ }
525
+ export function getRecentEdits(session, slug) {
526
+ const list = recentEdits.get(session) ?? [];
527
+ return list
528
+ .filter((item) => item.slug === slug)
529
+ .slice(-3)
530
+ .map((item) => ({
531
+ at: item.at,
532
+ summary: item.summary,
533
+ ops: item.ops.map((op) => op.op)
534
+ }));
535
+ }
536
+ // ---------------------------------------------------------------------------
537
+ // Site config accessors
538
+ // ---------------------------------------------------------------------------
539
+ // Tracks sessions that have already been offered a hydration pass this process.
540
+ // Both getSessionDraft and getSiteConfig are entry points users can hit first
541
+ // (the editor fetches /draft/site-config independently of any page request),
542
+ // so we guard the hydrate call here rather than re-checking in each caller.
543
+ const hydratedSessions = new Set();
544
+ function ensureSiteConfigHydrated(session) {
545
+ if (hydratedSessions.has(session))
546
+ return;
547
+ hydratedSessions.add(session);
548
+ // Only the monorepo's bundled apps/site maps to a known published JSON path.
549
+ // SDK consumers expose siteConfig via their /api/editor/pages route; the
550
+ // orch picks that up through publish-diff's loadPublishedForDiff path.
551
+ // The monorepo's bundled demo — bare/default sessions and the editor's
552
+ // avocado-hub::* sessions — all represent the same site. Seed the header
553
+ // (name, logo, nav groups/labels) from the baked demo snapshot so the editor
554
+ // preview matches the live avocado-site.vercel.app header instead of falling
555
+ // back to bare defaults. (The legacy disk-read path resolved the wrong dir and
556
+ // silently returned nothing.) Existing keys win, so an in-flight edit isn't
557
+ // clobbered on re-hydrate. Other site-scoped sessions (e.g. SDK consumers) are
558
+ // left untouched — their config arrives via publish-diff's loadPublishedForDiff.
559
+ const isBundledDemo = !session.includes("::") || session.startsWith("avocado-hub::");
560
+ if (isBundledDemo) {
561
+ const existing = siteConfigs.get(session) ?? {};
562
+ siteConfigs.set(session, { ...demoSiteConfig(), ...existing });
563
+ }
564
+ }
565
+ export function getSiteConfig(session) {
566
+ ensureSiteConfigHydrated(session);
567
+ return siteConfigs.get(session) ?? {};
568
+ }
569
+ export function setSiteConfig(session, config) {
570
+ const existing = siteConfigs.get(session) ?? {};
571
+ siteConfigs.set(session, { ...existing, ...config });
572
+ // A user-initiated write counts as "we know about this session" — skip the
573
+ // published fill-in so it doesn't undo whatever the user just set.
574
+ hydratedSessions.add(session);
575
+ }
576
+ /**
577
+ * Walk the in-memory `siteConfigs` map and return one entry per unique siteId,
578
+ * preferring entries that match `wantedSession` over legacy unscoped ones.
579
+ *
580
+ * The map keys come from `scopedSessionKey()`: either `{siteId}::{session}` or
581
+ * just `{session}` for legacy sites (avocado-stories convention). Owning that
582
+ * parsing here keeps route handlers from having to know about the key format.
583
+ */
584
+ export function listSitesForSession(wantedSession) {
585
+ const byId = new Map();
586
+ for (const [key, config] of siteConfigs.entries()) {
587
+ const sep = key.indexOf("::");
588
+ // Skip bare-session keys (no `::`). They're the internal legacy routing
589
+ // convention for the avocado-stories JSON-file site; surfacing them here
590
+ // would re-add a phantom site in the editor dashboard every time the user
591
+ // tries to delete it.
592
+ if (sep === -1)
593
+ continue;
594
+ const siteId = key.slice(0, sep);
595
+ const session = key.slice(sep + 2);
596
+ const matchesSession = session === wantedSession;
597
+ const existing = byId.get(siteId);
598
+ if (!existing || (matchesSession && !existing.matchesSession)) {
599
+ byId.set(siteId, {
600
+ config: { id: siteId, ...config },
601
+ matchesSession,
602
+ });
603
+ }
604
+ }
605
+ return Array.from(byId.values()).map((entry) => entry.config);
606
+ }
607
+ // ---------------------------------------------------------------------------
608
+ // Persistence helpers
609
+ // ---------------------------------------------------------------------------
610
+ export function nestedPageMapToObject(source) {
611
+ const out = {};
612
+ for (const [session, pages] of source) {
613
+ out[session] = {};
614
+ for (const [slug, page] of pages)
615
+ out[session][slug] = structuredClone(page);
616
+ }
617
+ return out;
618
+ }
619
+ export function nestedHistoryMapToObject(source) {
620
+ const out = {};
621
+ for (const [session, bySlug] of source) {
622
+ out[session] = {};
623
+ for (const [slug, snapshots] of bySlug)
624
+ out[session][slug] = snapshots.map((item) => item === null ? null : structuredClone(item));
625
+ }
626
+ return out;
627
+ }
628
+ export function objectToNestedPageMap(source) {
629
+ const out = new Map();
630
+ if (!source || typeof source !== "object")
631
+ return out;
632
+ for (const [session, pages] of Object.entries(source)) {
633
+ if (!pages || typeof pages !== "object")
634
+ continue;
635
+ const bySlug = new Map();
636
+ for (const [slug, page] of Object.entries(pages)) {
637
+ if (!page || typeof page !== "object")
638
+ continue;
639
+ bySlug.set(slug, page);
640
+ }
641
+ out.set(session, bySlug);
642
+ }
643
+ return out;
644
+ }
645
+ export function objectToNestedHistoryMap(source) {
646
+ const out = new Map();
647
+ if (!source || typeof source !== "object")
648
+ return out;
649
+ for (const [session, bySlugRaw] of Object.entries(source)) {
650
+ if (!bySlugRaw || typeof bySlugRaw !== "object")
651
+ continue;
652
+ const bySlug = new Map();
653
+ for (const [slug, listRaw] of Object.entries(bySlugRaw)) {
654
+ if (!Array.isArray(listRaw))
655
+ continue;
656
+ bySlug.set(slug, listRaw.filter((item) => item === null || (item && typeof item === "object")));
657
+ }
658
+ out.set(session, bySlug);
659
+ }
660
+ return out;
661
+ }
662
+ export function applyPersistedState(parsed) {
663
+ if (Array.isArray(parsed.publishedPages) && parsed.publishedPages.length > 0) {
664
+ publishedPages.clear();
665
+ for (const page of parsed.publishedPages) {
666
+ if (!page || typeof page !== "object" || typeof page.slug !== "string")
667
+ continue;
668
+ ensureHeroImageProps(page);
669
+ publishedPages.set(page.slug, page);
670
+ }
671
+ }
672
+ draftPages.clear();
673
+ for (const [session, bySlug] of objectToNestedPageMap(parsed.draftPages)) {
674
+ for (const page of bySlug.values()) {
675
+ ensureHeroImageProps(page);
676
+ // Legacy JSON predates stable item ids — backfill here so the very first
677
+ // post-migration persist (writeAllStateToStore right after this call)
678
+ // already stores id-carrying drafts instead of waiting for the next boot.
679
+ ensureItemIds(page.blocks);
680
+ }
681
+ draftPages.set(session, bySlug);
682
+ }
683
+ historyUndo.clear();
684
+ for (const [session, bySlug] of objectToNestedHistoryMap(parsed.historyUndo))
685
+ historyUndo.set(session, bySlug);
686
+ historyRedo.clear();
687
+ for (const [session, bySlug] of objectToNestedHistoryMap(parsed.historyRedo))
688
+ historyRedo.set(session, bySlug);
689
+ versions.clear();
690
+ if (parsed.versions && typeof parsed.versions === "object") {
691
+ for (const [session, value] of Object.entries(parsed.versions)) {
692
+ if (typeof value === "number" && Number.isFinite(value))
693
+ versions.set(session, value);
694
+ }
695
+ }
696
+ recentEdits.clear();
697
+ if (parsed.recentEdits && typeof parsed.recentEdits === "object") {
698
+ for (const [session, listRaw] of Object.entries(parsed.recentEdits)) {
699
+ if (!Array.isArray(listRaw))
700
+ continue;
701
+ const list = listRaw
702
+ .filter((entry) => entry && typeof entry === "object")
703
+ .map((entry) => entry);
704
+ recentEdits.set(session, list.slice(-RECENT_EDITS_CAP));
705
+ }
706
+ }
707
+ chatHistoryBySession.clear();
708
+ if (parsed.chatHistory && typeof parsed.chatHistory === "object") {
709
+ for (const [session, listRaw] of Object.entries(parsed.chatHistory)) {
710
+ if (!Array.isArray(listRaw))
711
+ continue;
712
+ const list = listRaw
713
+ .filter((entry) => entry &&
714
+ typeof entry === "object" &&
715
+ (entry.role === "user" || entry.role === "assistant") &&
716
+ typeof entry.content === "string")
717
+ .map((entry) => entry);
718
+ chatHistoryBySession.set(session, list.slice(-CHAT_HISTORY_MAX_TURNS));
719
+ }
720
+ }
721
+ siteConfigs.clear();
722
+ // Reload invalidates the hydration cache — the persisted snapshot is now
723
+ // the new baseline, and any session that still has gaps should get another
724
+ // fill-in pass from the published JSON.
725
+ hydratedSessions.clear();
726
+ // No hardcoded re-seed: `hydrateSiteConfigFromPublished` runs on first
727
+ // session touch and fills missing nav fields from the live published JSON.
728
+ // A static seed here would always win over published nav state during the
729
+ // fill-in merge, re-introducing the wipe-on-publish bug.
730
+ if (parsed.siteConfigs && typeof parsed.siteConfigs === "object") {
731
+ for (const [session, config] of Object.entries(parsed.siteConfigs)) {
732
+ if (config && typeof config === "object") {
733
+ siteConfigs.set(session, config);
734
+ }
735
+ }
736
+ }
737
+ versionLog.clear();
738
+ if (parsed.versionLog && typeof parsed.versionLog === "object") {
739
+ for (const [session, listRaw] of Object.entries(parsed.versionLog)) {
740
+ if (!Array.isArray(listRaw))
741
+ continue;
742
+ const list = listRaw
743
+ .filter((entry) => entry && typeof entry === "object" && typeof entry.version === "number")
744
+ .map((entry) => entry);
745
+ versionLog.set(session, list.slice(-VERSION_LOG_MAX));
746
+ }
747
+ }
748
+ publishLogBySession.clear();
749
+ if (parsed.publishLog && typeof parsed.publishLog === "object") {
750
+ for (const [session, listRaw] of Object.entries(parsed.publishLog)) {
751
+ if (!Array.isArray(listRaw))
752
+ continue;
753
+ const list = listRaw
754
+ .filter((entry) => !!entry &&
755
+ typeof entry === "object" &&
756
+ typeof entry.id === "string" &&
757
+ typeof entry.status === "string");
758
+ publishLogBySession.set(session, list.slice(-PUBLISH_LOG_MAX));
759
+ }
760
+ }
761
+ issueTouchedSlugsByKey.clear();
762
+ if (parsed.issueTouchedSlugs && typeof parsed.issueTouchedSlugs === "object") {
763
+ for (const [issueKey, entryRaw] of Object.entries(parsed.issueTouchedSlugs)) {
764
+ if (!entryRaw || typeof entryRaw !== "object")
765
+ continue;
766
+ const entry = entryRaw;
767
+ if (!Array.isArray(entry.slugs))
768
+ continue;
769
+ const slugs = entry.slugs.filter((s) => typeof s === "string" && s.length > 0);
770
+ const updatedAt = typeof entry.updatedAt === "string" ? entry.updatedAt : new Date().toISOString();
771
+ issueTouchedSlugsByKey.set(issueKey, { slugs, updatedAt });
772
+ }
773
+ }
774
+ }
775
+ const persistStmtsCache = new WeakMap();
776
+ function buildPersistStmts(store) {
777
+ const db = store.db;
778
+ return {
779
+ deletePages: db.prepare("DELETE FROM pages"),
780
+ insertPage: db.prepare("INSERT INTO pages(session, slug, kind, doc) VALUES (?, ?, ?, ?)"),
781
+ deleteHistory: db.prepare("DELETE FROM history"),
782
+ insertHistory: db.prepare("INSERT INTO history(session, slug, direction, seq, snapshot) VALUES (?, ?, ?, ?, ?)"),
783
+ deleteSessions: db.prepare("DELETE FROM sessions"),
784
+ deleteVersionLog: db.prepare("DELETE FROM version_log"),
785
+ insertVersionLog: db.prepare("INSERT INTO version_log(session, seq, entry) VALUES (?, ?, ?)"),
786
+ deleteRecentEdits: db.prepare("DELETE FROM recent_edits"),
787
+ insertRecentEdit: db.prepare("INSERT INTO recent_edits(session, seq, entry) VALUES (?, ?, ?)"),
788
+ deleteChatHistory: db.prepare("DELETE FROM chat_history"),
789
+ insertChatMessage: db.prepare("INSERT INTO chat_history(session, seq, role, content) VALUES (?, ?, ?, ?)"),
790
+ deleteSiteConfigs: db.prepare("DELETE FROM site_configs"),
791
+ deleteIssueTouched: db.prepare("DELETE FROM issue_touched"),
792
+ };
793
+ }
794
+ function getPersistStmts(store) {
795
+ let stmts = persistStmtsCache.get(store);
796
+ if (!stmts) {
797
+ stmts = buildPersistStmts(store);
798
+ persistStmtsCache.set(store, stmts);
799
+ }
800
+ return stmts;
801
+ }
802
+ /**
803
+ * Snapshot every in-memory Map into SQLite inside a single transaction.
804
+ * Writes all sessions on every call, not just the mutated one — acceptable
805
+ * at current scale (state typically well under 1 MB). Per-session
806
+ * write-through is the first optimization if this becomes a hot spot.
807
+ */
808
+ function writeAllStateToStore(store) {
809
+ const s = getPersistStmts(store);
810
+ store.transaction(() => {
811
+ // Pages — drop-all-then-reinsert so deletes propagate.
812
+ s.deletePages.run();
813
+ for (const [slug, page] of publishedPages) {
814
+ s.insertPage.run(PUBLISHED_GLOBAL_KEY, slug, "published", JSON.stringify(page));
815
+ }
816
+ for (const [session, bySlug] of draftPages) {
817
+ for (const [slug, page] of bySlug) {
818
+ s.insertPage.run(session, slug, "draft", JSON.stringify(page));
819
+ }
820
+ }
821
+ // History — drop + reinsert so pops propagate. Trim locally to
822
+ // HISTORY_DEPTH_MAX as defense-in-depth (legacy JSON imports could be
823
+ // deeper). We slice but intentionally DO NOT mutate the caller's Map —
824
+ // pushCappedHistory already bounds new writes in-memory.
825
+ s.deleteHistory.run();
826
+ const writeHistory = (map, direction) => {
827
+ for (const [session, bySlug] of map) {
828
+ for (const [slug, list] of bySlug) {
829
+ const trimmed = list.length > HISTORY_DEPTH_MAX
830
+ ? list.slice(list.length - HISTORY_DEPTH_MAX)
831
+ : list;
832
+ trimmed.forEach((snap, i) => s.insertHistory.run(session, slug, direction, i + 1, snap === null ? null : JSON.stringify(snap)));
833
+ }
834
+ }
835
+ };
836
+ writeHistory(historyUndo, "undo");
837
+ writeHistory(historyRedo, "redo");
838
+ // Version counters
839
+ s.deleteSessions.run();
840
+ for (const [session, v] of versions)
841
+ store.setVersion(session, v);
842
+ // Version log
843
+ s.deleteVersionLog.run();
844
+ for (const [session, list] of versionLog) {
845
+ list.forEach((entry, i) => s.insertVersionLog.run(session, i + 1, JSON.stringify(entry)));
846
+ }
847
+ // Recent edits
848
+ s.deleteRecentEdits.run();
849
+ for (const [session, list] of recentEdits) {
850
+ list.forEach((entry, i) => s.insertRecentEdit.run(session, i + 1, JSON.stringify(entry)));
851
+ }
852
+ // Chat history
853
+ s.deleteChatHistory.run();
854
+ for (const [session, list] of chatHistoryBySession) {
855
+ list.forEach((m, i) => s.insertChatMessage.run(session, i + 1, m.role, m.content));
856
+ }
857
+ // Site configs
858
+ s.deleteSiteConfigs.run();
859
+ for (const [session, cfg] of siteConfigs)
860
+ store.setSiteConfig(session, cfg);
861
+ // Issue-touched
862
+ s.deleteIssueTouched.run();
863
+ for (const [key, row] of issueTouchedSlugsByKey)
864
+ store.setIssueTouched(key, row);
865
+ });
866
+ }
867
+ /**
868
+ * Hydrate the in-memory Maps from a SqliteStore. Caller must have cleared
869
+ * the Maps first if they want authoritative replacement.
870
+ */
871
+ function hydrateMapsFromStore(store) {
872
+ // Published (global) pages live under PUBLISHED_GLOBAL_KEY.
873
+ for (const page of store.listPages(PUBLISHED_GLOBAL_KEY, "published")) {
874
+ ensureHeroImageProps(page);
875
+ publishedPages.set(page.slug, page);
876
+ }
877
+ // Draft pages — walk every session except the sentinel. Backfill stable list
878
+ // item ids on the way in (one-time migration for sessions persisted before
879
+ // item ids existed); `itemIdsBackfilled` triggers a single write-back below so
880
+ // the ids are durable rather than regenerated on every boot.
881
+ let itemIdsBackfilled = false;
882
+ for (const session of store.listDraftSessions()) {
883
+ if (session === PUBLISHED_GLOBAL_KEY)
884
+ continue;
885
+ const bySlug = new Map();
886
+ for (const page of store.listPages(session, "draft")) {
887
+ ensureHeroImageProps(page);
888
+ if (ensureItemIds(page.blocks))
889
+ itemIdsBackfilled = true;
890
+ bySlug.set(page.slug, page);
891
+ }
892
+ if (bySlug.size > 0)
893
+ draftPages.set(session, bySlug);
894
+ }
895
+ // History — need a list of sessions/slugs. We rely on the fact that any
896
+ // session with history also has draft pages, plus a fallback scan via the
897
+ // session-slug pairs already captured in the DB.
898
+ const histRows = store.db
899
+ .prepare(`SELECT session, slug, direction, seq, snapshot FROM history ORDER BY session, slug, direction, seq`)
900
+ .all();
901
+ for (const row of histRows) {
902
+ const target = row.direction === "undo" ? historyUndo : historyRedo;
903
+ let bySlug = target.get(row.session);
904
+ if (!bySlug) {
905
+ bySlug = new Map();
906
+ target.set(row.session, bySlug);
907
+ }
908
+ const list = bySlug.get(row.slug) ?? [];
909
+ list.push(row.snapshot === null ? null : JSON.parse(row.snapshot));
910
+ bySlug.set(row.slug, list);
911
+ }
912
+ // Versions
913
+ const versionRows = store.db
914
+ .prepare("SELECT session, version FROM sessions")
915
+ .all();
916
+ for (const r of versionRows)
917
+ versions.set(r.session, r.version);
918
+ // Version log
919
+ const vlogSessions = store.db
920
+ .prepare("SELECT DISTINCT session FROM version_log")
921
+ .all();
922
+ for (const { session } of vlogSessions) {
923
+ versionLog.set(session, store.listVersionLog(session, undefined, VERSION_LOG_MAX));
924
+ }
925
+ // Recent edits
926
+ const editSessions = store.db
927
+ .prepare("SELECT DISTINCT session FROM recent_edits")
928
+ .all();
929
+ for (const { session } of editSessions) {
930
+ recentEdits.set(session, store.listRecentEdits(session));
931
+ }
932
+ // Chat history
933
+ const chatSessions = store.db
934
+ .prepare("SELECT DISTINCT session FROM chat_history")
935
+ .all();
936
+ for (const { session } of chatSessions) {
937
+ chatHistoryBySession.set(session, store.listChatHistory(session));
938
+ }
939
+ // Site configs
940
+ for (const { session, config } of store.listSiteConfigs()) {
941
+ siteConfigs.set(session, config);
942
+ }
943
+ // Issue-touched
944
+ for (const { issueKey, row } of store.listIssueTouched()) {
945
+ issueTouchedSlugsByKey.set(issueKey, row);
946
+ }
947
+ // Publish log
948
+ for (const session of store.listPublishLogSessions()) {
949
+ publishLogBySession.set(session, store.listPublishLog(session, PUBLISH_LOG_MAX));
950
+ }
951
+ // Write back the one-time list-item-id backfill (all maps are now hydrated, so
952
+ // this full snapshot is complete). Skipped entirely when nothing changed.
953
+ if (itemIdsBackfilled)
954
+ writeAllStateToStore(store);
955
+ }
956
+ /**
957
+ * Persist the current in-memory state to SQLite. Always flushes any pending
958
+ * debounced write first, then runs synchronously (better-sqlite3 is sync).
959
+ * Async signature preserved for callers that want to `await` (publish flow
960
+ * does). Errors are logged and rethrown.
961
+ */
962
+ export async function persistStateNow(logger) {
963
+ if (persistTimer) {
964
+ clearTimeout(persistTimer);
965
+ persistTimer = null;
966
+ }
967
+ try {
968
+ writeAllStateToStore(getStore());
969
+ }
970
+ catch (error) {
971
+ logger.error({ err: toErrorDetail(error) }, "Failed to persist orchestrator state to SQLite");
972
+ throw error;
973
+ }
974
+ }
975
+ /**
976
+ * Schedule a persist with a short debounce so a single request's sync
977
+ * mutation burst coalesces into one transaction — chat turns, agent runs,
978
+ * and /ops calls that land N mutations followed by schedulePersistState
979
+ * write-through exactly once. The window is narrow enough that the worst
980
+ * crash-loss is tens of ms of mutations.
981
+ *
982
+ * Any write error is logged, not propagated — the caller has already
983
+ * returned its response by the time the timer fires.
984
+ */
985
+ let persistTimer = null;
986
+ const PERSIST_DEBOUNCE_MS = 30;
987
+ export function schedulePersistState(logger) {
988
+ if (persistTimer)
989
+ return; // Already scheduled; coalesce.
990
+ persistTimer = setTimeout(() => {
991
+ persistTimer = null;
992
+ try {
993
+ writeAllStateToStore(getStore());
994
+ }
995
+ catch (error) {
996
+ logger.error({ err: toErrorDetail(error) }, "Failed to persist orchestrator state to SQLite");
997
+ }
998
+ }, PERSIST_DEBOUNCE_MS);
999
+ // Don't let the debounce timer keep the process alive on its own.
1000
+ if (typeof persistTimer.unref === "function")
1001
+ persistTimer.unref();
1002
+ }
1003
+ // ---------------------------------------------------------------------------
1004
+ // Ephemeral map eviction — prevents unbounded growth in long-running processes
1005
+ // ---------------------------------------------------------------------------
1006
+ const APPROVAL_PLAN_TTL_MS = 60 * 60 * 1000; // 1 hour — approvals expire if user abandons
1007
+ const PUBLISH_STATUS_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
1008
+ const EPHEMERAL_MAP_CAP = 500; // max entries for maps without timestamps
1009
+ /**
1010
+ * Evict stale/excess entries from ephemeral in-memory maps.
1011
+ * Called on a periodic timer (see index.ts). Safe to call at any time.
1012
+ */
1013
+ export function evictStaleEphemeralMaps() {
1014
+ const now = Date.now();
1015
+ // pendingApprovalPlanBySession: has createdAt — evict by age
1016
+ for (const [key, plan] of pendingApprovalPlanBySession) {
1017
+ if (now - new Date(plan.createdAt).getTime() > APPROVAL_PLAN_TTL_MS) {
1018
+ pendingApprovalPlanBySession.delete(key);
1019
+ }
1020
+ }
1021
+ // publishStatusBySession: has updatedAt — evict by age
1022
+ for (const [key, tracker] of publishStatusBySession) {
1023
+ if (now - new Date(tracker.updatedAt).getTime() > PUBLISH_STATUS_TTL_MS) {
1024
+ publishStatusBySession.delete(key);
1025
+ }
1026
+ }
1027
+ // Maps without timestamps — cap at EPHEMERAL_MAP_CAP, drop oldest-inserted entries
1028
+ for (const map of [continuationChainBySession, pendingClarificationBySession, imageSourcePreferenceBySession]) {
1029
+ if (map.size > EPHEMERAL_MAP_CAP) {
1030
+ const excess = map.size - EPHEMERAL_MAP_CAP;
1031
+ let dropped = 0;
1032
+ for (const key of map.keys()) {
1033
+ if (dropped >= excess)
1034
+ break;
1035
+ map.delete(key);
1036
+ dropped++;
1037
+ }
1038
+ }
1039
+ }
1040
+ }
1041
+ export async function loadStateFromDisk(logger) {
1042
+ let store;
1043
+ try {
1044
+ store = getStore();
1045
+ }
1046
+ catch (error) {
1047
+ logger.error({ err: toErrorDetail(error) }, "Failed to open orchestrator SQLite store");
1048
+ return;
1049
+ }
1050
+ const jsonPath = resolveStateFilePath();
1051
+ // Sweep stale .json.migrated-<ts> siblings first; never blocks startup.
1052
+ void sweepStaleMigrations(jsonPath, resolveJsonMigrationTtlDays(), logger);
1053
+ // If SQLite already has state, just hydrate from it.
1054
+ const sessionsSeeded = store.db
1055
+ .prepare("SELECT COUNT(*) AS n FROM pages")
1056
+ .get().n > 0;
1057
+ if (sessionsSeeded) {
1058
+ hydrateMapsFromStore(store);
1059
+ logger.info({ backend: "sqlite" }, "Loaded persisted orchestrator state");
1060
+ return;
1061
+ }
1062
+ // DB is empty — migrate from legacy JSON if it exists.
1063
+ if (!existsSync(jsonPath)) {
1064
+ logger.info({ backend: "sqlite" }, "No prior orchestrator state; starting fresh");
1065
+ return;
1066
+ }
1067
+ try {
1068
+ const parsed = await readLegacyJson(jsonPath);
1069
+ if (parsed) {
1070
+ applyPersistedState(parsed);
1071
+ writeAllStateToStore(store);
1072
+ const archived = await archiveMigratedJson(jsonPath);
1073
+ logger.info({ file: jsonPath, archived }, "Migrated orchestrator state from JSON to SQLite");
1074
+ }
1075
+ else {
1076
+ logger.info({ file: jsonPath }, "Legacy JSON state file was empty; ignoring");
1077
+ }
1078
+ }
1079
+ catch (error) {
1080
+ logger.error({ err: toErrorDetail(error), file: jsonPath }, "Failed to migrate orchestrator state from JSON");
1081
+ }
1082
+ }
1083
+ export { InMemoryContentSource } from "./in-memory-content-source.js";