@avocadostudio-ai/orchestrator-core 0.3.2 → 0.4.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 (88) hide show
  1. package/dist/chat/anthropic-planner.d.ts +8 -0
  2. package/dist/chat/anthropic-planner.js +166 -12
  3. package/dist/chat/chat-pipeline-translation.d.ts +13 -0
  4. package/dist/chat/chat-pipeline-translation.js +109 -45
  5. package/dist/chat/chat-pipeline.d.ts +1 -1
  6. package/dist/chat/chat-pipeline.js +312 -54
  7. package/dist/chat/gemini-planner.d.ts +2 -0
  8. package/dist/chat/gemini-planner.js +2 -1
  9. package/dist/chat/planner-types.d.ts +15 -0
  10. package/dist/chat/planner-types.js +2 -2
  11. package/dist/chat/planner.d.ts +12 -0
  12. package/dist/chat/planner.js +16 -2
  13. package/dist/chat/prompts.d.ts +5 -0
  14. package/dist/chat/prompts.js +92 -9
  15. package/dist/chat/translation-chunking.d.ts +124 -0
  16. package/dist/chat/translation-chunking.js +371 -0
  17. package/dist/checks/field-walk.d.ts +42 -0
  18. package/dist/checks/field-walk.js +198 -0
  19. package/dist/checks/index.d.ts +5 -0
  20. package/dist/checks/index.js +4 -0
  21. package/dist/checks/page-weight.d.ts +22 -0
  22. package/dist/checks/page-weight.js +200 -0
  23. package/dist/checks/rules-draft.d.ts +2 -0
  24. package/dist/checks/rules-draft.js +439 -0
  25. package/dist/checks/run-checks.d.ts +42 -0
  26. package/dist/checks/run-checks.js +159 -0
  27. package/dist/checks/session-runner.d.ts +19 -0
  28. package/dist/checks/session-runner.js +99 -0
  29. package/dist/checks/types.d.ts +109 -0
  30. package/dist/checks/types.js +1 -0
  31. package/dist/cms/adapter.d.ts +74 -1
  32. package/dist/cms/adapter.js +1 -0
  33. package/dist/cms/index.d.ts +1 -1
  34. package/dist/cms/index.js +1 -1
  35. package/dist/cms/media-sources.d.ts +29 -1
  36. package/dist/cms/media-sources.js +188 -7
  37. package/dist/durable/durable-store-singleton.d.ts +37 -0
  38. package/dist/durable/durable-store-singleton.js +179 -0
  39. package/dist/durable/finding-impact.d.ts +30 -0
  40. package/dist/durable/finding-impact.js +53 -0
  41. package/dist/durable/in-memory-durable-store.d.ts +203 -0
  42. package/dist/durable/in-memory-durable-store.js +363 -0
  43. package/dist/durable/index.d.ts +5 -0
  44. package/dist/durable/index.js +4 -0
  45. package/dist/durable/pending-plan-store.d.ts +28 -0
  46. package/dist/durable/pending-plan-store.js +156 -0
  47. package/dist/durable/sqlite-durable-store.d.ts +71 -0
  48. package/dist/durable/sqlite-durable-store.js +631 -0
  49. package/dist/durable/types.d.ts +265 -0
  50. package/dist/durable/types.js +1 -0
  51. package/dist/handler/create-orchestrator.d.ts +4 -0
  52. package/dist/handler/create-orchestrator.js +283 -32
  53. package/dist/http/audio-actions.d.ts +1 -1
  54. package/dist/http/checks-actions.d.ts +39 -0
  55. package/dist/http/checks-actions.js +122 -0
  56. package/dist/http/history-actions.d.ts +44 -1
  57. package/dist/http/history-actions.js +122 -0
  58. package/dist/http/image-generate-actions.d.ts +2 -2
  59. package/dist/http/ops-actions.d.ts +2 -2
  60. package/dist/http/publish-actions.d.ts +15 -4
  61. package/dist/http/publish-actions.js +3 -3
  62. package/dist/http/restore-actions.d.ts +3 -3
  63. package/dist/http/screenshot-actions.d.ts +2 -2
  64. package/dist/http/session-actions.d.ts +1 -1
  65. package/dist/http/telemetry-feedback-actions.d.ts +2 -2
  66. package/dist/http/unsplash-actions.d.ts +2 -2
  67. package/dist/http/variations-actions.d.ts +2 -2
  68. package/dist/index.d.ts +9 -2
  69. package/dist/index.js +28 -1
  70. package/dist/nlp/deterministic-planner-context.d.ts +16 -0
  71. package/dist/nlp/deterministic-planner-context.js +33 -7
  72. package/dist/nlp/intent-detection.d.ts +16 -0
  73. package/dist/nlp/intent-detection.js +15 -1
  74. package/dist/nlp/plan-normalizer.js +66 -32
  75. package/dist/ops/destructive-action-gate.js +7 -2
  76. package/dist/ops/ops-engine.d.ts +12 -1
  77. package/dist/ops/ops-engine.js +41 -14
  78. package/dist/publish/publish-helpers.d.ts +12 -2
  79. package/dist/publish/publish-helpers.js +10 -3
  80. package/dist/publish/publish-selection.d.ts +84 -0
  81. package/dist/publish/publish-selection.js +113 -0
  82. package/dist/publish/publish-target-registry.js +1 -1
  83. package/dist/publish/publish-target.d.ts +1 -1
  84. package/dist/publish/targets/git.js +2 -2
  85. package/dist/state/session-state.js +8 -1
  86. package/dist/state/site-assets.d.ts +41 -0
  87. package/dist/state/site-assets.js +40 -0
  88. package/package.json +3 -3
@@ -167,3 +167,125 @@ export function historyRestoreAction(body, log) {
167
167
  }
168
168
  };
169
169
  }
170
+ /**
171
+ * Throw away selected changes by rolling each affected page back to the state
172
+ * it held immediately before the earliest change selected on it.
173
+ *
174
+ * The version log is a per-page timeline, not a stack of independent patches:
175
+ * an entry records the page *after* the change, so "undo this one change and
176
+ * keep the later ones" is not a question the stored data can answer. What it
177
+ * can answer is "put this page back the way it was before change N", which is
178
+ * what discarding means here — and it is why the response names every later
179
+ * entry that goes with it. The caller shows that list before asking the user
180
+ * to confirm; this function reports it again for whatever actually happened.
181
+ *
182
+ * Selecting several entries on one page collapses to its earliest. Selecting
183
+ * entries across pages rolls each page back independently — untouched pages
184
+ * keep their edits.
185
+ *
186
+ * Like a restore, a discard is itself an edit: the current page goes onto the
187
+ * undo stack first, so a discard can be undone.
188
+ */
189
+ export function historyDiscardAction(body, log) {
190
+ if (!body.session)
191
+ return badRequest("session is required");
192
+ const requested = Array.isArray(body.versions)
193
+ ? Array.from(new Set(body.versions.filter((v) => typeof v === "number" && Number.isFinite(v))))
194
+ : [];
195
+ if (requested.length === 0)
196
+ return badRequest("versions must be a non-empty array of version numbers");
197
+ const session = scopedSessionKey(body.session, body.siteId);
198
+ const entries = versionLog.get(session) ?? [];
199
+ const byVersion = new Map(entries.map((entry) => [entry.version, entry]));
200
+ const skipped = [];
201
+ /*
202
+ * Collapse to one rollback per page. A selection of three entries on the
203
+ * same page is one rollback to before the earliest of them; running three
204
+ * would have the second and third restore snapshots the first already
205
+ * invalidated.
206
+ */
207
+ const earliestBySlug = new Map();
208
+ for (const version of requested) {
209
+ const entry = byVersion.get(version);
210
+ if (!entry) {
211
+ skipped.push({ version, reason: "not in this session's version log" });
212
+ continue;
213
+ }
214
+ const current = earliestBySlug.get(entry.slug);
215
+ if (current === undefined || version < current)
216
+ earliestBySlug.set(entry.slug, version);
217
+ }
218
+ const discarded = [];
219
+ let previewVersion = versions.get(session) ?? 0;
220
+ let navigateToSlug;
221
+ for (const [slug, fromVersion] of earliestBySlug) {
222
+ /*
223
+ * The state before `fromVersion` is the snapshot of the newest earlier
224
+ * entry on the same page. An entry with no snapshot is a legacy row that
225
+ * cannot restore anything, so it does not count as the prior state.
226
+ */
227
+ const prior = entries
228
+ .filter((entry) => entry.slug === slug && entry.version < fromVersion && entry.snapshot !== undefined)
229
+ .at(-1);
230
+ if (!prior) {
231
+ /*
232
+ * Nothing recorded before this change — the log was capped, or this is
233
+ * the page's first entry. Guessing would mean inventing a state the
234
+ * page never had, so the entry stays and the caller is told why.
235
+ */
236
+ skipped.push({
237
+ version: fromVersion,
238
+ reason: "no recorded page state before this change"
239
+ });
240
+ continue;
241
+ }
242
+ const alsoDiscarded = entries
243
+ .filter((entry) => entry.slug === slug && entry.version >= fromVersion)
244
+ .map((entry) => entry.version)
245
+ .filter((version) => version !== fromVersion);
246
+ // Filtered above, so this only maps the impossible `undefined` onto the
247
+ // `null` that means "the page did not exist at this version".
248
+ const snapshot = prior.snapshot ?? null;
249
+ // pushUndo also clears redo, which is right: a discard starts a new branch.
250
+ pushUndo(session, slug, getPage(session, slug));
251
+ if (snapshot === null)
252
+ removePage(session, slug);
253
+ else
254
+ setPage(session, structuredClone(snapshot));
255
+ previewVersion = bumpVersion(session);
256
+ const count = alsoDiscarded.length + 1;
257
+ pushVersionEntry(session, {
258
+ version: previewVersion,
259
+ slug,
260
+ summary: `Discarded ${count} change${count === 1 ? "" : "s"} — back to v${prior.version}`,
261
+ opTypes: [],
262
+ opCount: 0,
263
+ source: "restore",
264
+ snapshot: snapshot === null ? null : structuredClone(snapshot)
265
+ });
266
+ discarded.push({ slug, fromVersion, toVersion: snapshot === null ? null : prior.version, alsoDiscarded });
267
+ navigateToSlug = slug;
268
+ }
269
+ if (discarded.length === 0) {
270
+ return {
271
+ code: 400,
272
+ body: { error: "nothing could be discarded", skipped }
273
+ };
274
+ }
275
+ schedulePersistState(log);
276
+ const touched = discarded[discarded.length - 1].slug;
277
+ const undoList = getHistoryMap(historyUndo, session).get(touched) ?? [];
278
+ const redoList = getHistoryMap(historyRedo, session).get(touched) ?? [];
279
+ return {
280
+ code: 200,
281
+ body: {
282
+ status: "applied",
283
+ previewVersion,
284
+ discarded,
285
+ skipped,
286
+ ...(navigateToSlug ? { navigateToSlug } : {}),
287
+ canUndo: undoList.length > 0,
288
+ canRedo: redoList.length > 0
289
+ }
290
+ };
291
+ }
@@ -23,8 +23,8 @@
23
23
  * Each function returns the status code and body to send, or emits frames;
24
24
  * neither Fastify nor `Response` appears in this file.
25
25
  */
26
- import type { Logger } from "../logger.js";
27
- import type { ActionResult } from "./history-actions.js";
26
+ import type { Logger } from "../logger.ts";
27
+ import type { ActionResult } from "./history-actions.ts";
28
28
  export type { ActionResult };
29
29
  /**
30
30
  * The one seam that decides where a generated image is written and what URL
@@ -17,8 +17,8 @@
17
17
  * own again.
18
18
  */
19
19
  import type { BlockManifest, Operation } from "@avocadostudio-ai/shared";
20
- import { type SkippedOperation } from "../ops/ops-engine.js";
21
- import type { ActionResult } from "./history-actions.js";
20
+ import { type SkippedOperation } from "../ops/ops-engine.ts";
21
+ import type { ActionResult } from "./history-actions.ts";
22
22
  export type { ActionResult };
23
23
  /**
24
24
  * Never mutates state, so it skips undo snapshotting, the version bump and the
@@ -16,10 +16,10 @@
16
16
  * neither Fastify nor `Response` appears in this file.
17
17
  */
18
18
  import type { PageDoc, SiteConfig } from "@avocadostudio-ai/shared";
19
- import type { PublishLogStatus } from "../state/session-state.js";
20
- import { refreshPublishStatusFromVercel } from "../publish/publish-helpers.js";
21
- import type { Logger } from "../logger.js";
22
- import type { ActionResult } from "./history-actions.js";
19
+ import type { PublishLogStatus } from "../state/session-state.ts";
20
+ import { refreshPublishStatusFromVercel } from "../publish/publish-helpers.ts";
21
+ import type { Logger } from "../logger.ts";
22
+ import type { ActionResult } from "./history-actions.ts";
23
23
  export type { ActionResult };
24
24
  /**
25
25
  * What "the site as it is currently live" resolves to.
@@ -35,6 +35,16 @@ export type PublishedContent = {
35
35
  pages: PageDoc[];
36
36
  siteConfig?: SiteConfig | null;
37
37
  };
38
+ /**
39
+ * Where the published side came from, in descending order of trust.
40
+ *
41
+ * A diff can be computed from any of them — a wrong diff only misreports. A
42
+ * *partial* publish cannot: it merges the unselected pages back out of this
43
+ * baseline and ships them, so a baseline that is really the startup demo seed
44
+ * would overwrite the live site with demo content. `"memory"` therefore
45
+ * disqualifies a subset publish; see `publish-selection.ts`.
46
+ */
47
+ export type PublishedPagesSource = "site" | "file" | "memory";
38
48
  /**
39
49
  * Where the published side comes from. The monorepo answers it from the site
40
50
  * app; a library-mode consumer answers it from its CMS adapter, whose
@@ -65,6 +75,7 @@ export declare function loadPublishedForDiff(opts: {
65
75
  }): Promise<{
66
76
  pages: PageDoc[];
67
77
  siteConfig: SiteConfig | null;
78
+ source: PublishedPagesSource;
68
79
  }>;
69
80
  /**
70
81
  * The default source: the four-step monorepo chain above, unchanged, so the
@@ -91,7 +91,7 @@ export async function loadPublishedForDiff(opts) {
91
91
  }
92
92
  // Hot path: remote returned both pages and siteConfig.
93
93
  if (remotePages && remoteSiteConfig) {
94
- return { pages: remotePages, siteConfig: remoteSiteConfig };
94
+ return { pages: remotePages, siteConfig: remoteSiteConfig, source: "site" };
95
95
  }
96
96
  // Either the remote didn't run, didn't include siteConfig (older SDK or
97
97
  // site dev not yet restarted), or didn't include pages. Read the JSON file
@@ -101,9 +101,9 @@ export async function loadPublishedForDiff(opts) {
101
101
  const pages = remotePages ?? fromFile.pages;
102
102
  const siteConfig = remoteSiteConfig ?? fromFile.siteConfig;
103
103
  if (pages)
104
- return { pages, siteConfig };
104
+ return { pages, siteConfig, source: remotePages ? "site" : "file" };
105
105
  logger.warn("publish/diff: falling back to in-memory publishedPages — diff may be inaccurate");
106
- return { pages: Array.from(publishedPages.values()), siteConfig };
106
+ return { pages: Array.from(publishedPages.values()), siteConfig, source: "memory" };
107
107
  }
108
108
  /**
109
109
  * The default source: the four-step monorepo chain above, unchanged, so the
@@ -14,9 +14,9 @@
14
14
  * function returns the status code and body to send; neither Fastify nor
15
15
  * `Response` appears in this file.
16
16
  */
17
- import { listRestoreSnapshots, loadPublishedSnapshotFromCommit, deletePublishSnapshot } from "../publish/publish-helpers.js";
18
- import type { Logger } from "../logger.js";
19
- import type { ActionResult } from "./history-actions.js";
17
+ import { listRestoreSnapshots, loadPublishedSnapshotFromCommit, deletePublishSnapshot } from "../publish/publish-helpers.ts";
18
+ import type { Logger } from "../logger.ts";
19
+ import type { ActionResult } from "./history-actions.ts";
20
20
  export type { ActionResult };
21
21
  /**
22
22
  * The three git-backed helpers, injectable so the actions are testable.
@@ -24,8 +24,8 @@
24
24
  * page. So the path is declarable: per request, per registered site, or via
25
25
  * `createOrchestrator({ draftPath })`.
26
26
  */
27
- import type { Logger } from "../logger.js";
28
- import type { ActionResult } from "./history-actions.js";
27
+ import type { Logger } from "../logger.ts";
28
+ import type { ActionResult } from "./history-actions.ts";
29
29
  export type { ActionResult };
30
30
  export type ScreenshotParams = {
31
31
  session?: string;
@@ -17,7 +17,7 @@
17
17
  * embedding its own editor, and it would answer a question no embedded caller
18
18
  * should be asking.
19
19
  */
20
- import type { ActionResult } from "./history-actions.js";
20
+ import type { ActionResult } from "./history-actions.ts";
21
21
  export type { ActionResult };
22
22
  /**
23
23
  * The caller's bound session and a summary of its state.
@@ -15,8 +15,8 @@
15
15
  * status code and body to send; neither Fastify nor `Response` appears in this
16
16
  * file.
17
17
  */
18
- import type { FeedbackStore } from "../telemetry/feedback-store.js";
19
- import type { ActionResult } from "./history-actions.js";
18
+ import type { FeedbackStore } from "../telemetry/feedback-store.ts";
19
+ import type { ActionResult } from "./history-actions.ts";
20
20
  export type { ActionResult };
21
21
  /**
22
22
  * The store is a parameter, not a module singleton.
@@ -15,8 +15,8 @@
15
15
  * orchestrator runtime is ready, and why this file needs no collaborators
16
16
  * beyond an access key and a `fetch`.
17
17
  */
18
- import type { Logger } from "../logger.js";
19
- import type { ActionResult } from "./history-actions.js";
18
+ import type { Logger } from "../logger.ts";
19
+ import type { ActionResult } from "./history-actions.ts";
20
20
  export type { ActionResult };
21
21
  /**
22
22
  * Raw query values, straight off the wire.
@@ -17,8 +17,8 @@
17
17
  * wire bytes for callers that don't already have an SSE writer. Neither Fastify
18
18
  * nor `Response` appears in this file.
19
19
  */
20
- import { type VariationRequestBody, type VariationPipelineContext, type VariationResult, type VariationImageUpdate } from "../chat/variation-pipeline.js";
21
- import type { ActionResult } from "./history-actions.js";
20
+ import { type VariationRequestBody, type VariationPipelineContext, type VariationResult, type VariationImageUpdate } from "../chat/variation-pipeline.ts";
21
+ import type { ActionResult } from "./history-actions.ts";
22
22
  export type { ActionResult };
23
23
  export type { VariationRequestBody, VariationPipelineContext };
24
24
  export type ParsedVariationRequest = {
package/dist/index.d.ts CHANGED
@@ -1,4 +1,11 @@
1
1
  export { createOrchestrator, type CreateOrchestratorConfig, type OrchestratorHandler } from "./handler/create-orchestrator.ts";
2
2
  export type { OrchestratorAuth, AuthContext } from "./handler/auth.ts";
3
- export type { CmsAdapter, CmsCapabilities, CmsInlineAsset, CmsPublishContext, CmsPublishResult, CmsPerspective, CmsReadOptions, CmsMediaItem, CmsMediaPage, CmsMediaQuery, ResolvedCapabilities } from "./cms/adapter.ts";
4
- export { jsonFileAdapter, editorApiAdapter, resolveCapabilities, cmsMediaSource, cmsMediaLabel, type JsonFileAdapterOptions, type EditorApiAdapterOptions, type CmsMediaSource, type CmsMediaSourceConfig } from "./cms/index.ts";
3
+ export type { CmsAdapter, CmsCapabilities, CmsInlineAsset, CmsPublishContext, CmsPublishResult, CmsPerspective, CmsReadOptions, CmsMediaItem, CmsMediaPage, CmsMediaQuery, CmsMediaUpload, ResolvedCapabilities } from "./cms/adapter.ts";
4
+ export { jsonFileAdapter, editorApiAdapter, resolveCapabilities, cmsMediaSource, cmsMediaUploader, cmsMediaLabel, type JsonFileAdapterOptions, type EditorApiAdapterOptions, type CmsMediaSource, type CmsMediaUploader, type CmsMediaSourceConfig } from "./cms/index.ts";
5
+ export { registerPublishTarget, selectPublishTarget, getPublishTarget, listPublishTargets } from "./publish/publish-target-registry.ts";
6
+ export type { PublishTarget, PublishContext, PublishOutcome, PublishStatus, PublishResult } from "./publish/publish-target.ts";
7
+ export { SqliteDurableStore, InMemoryDurableStore, getDurableStore, resetDurableStore, setDurableStore, durableStoreIsEphemeral, type SqliteDurableStoreOptions, type InMemoryDurableStoreOptions, type DurableStore, type FindingInput, type FindingRecord, type FindingQuery, type FindingSeverity, type FindingStatus, type FindingEvidence, type CheckRunInput, type CheckRunRecord, type CheckRunPatch, type CheckRunTrigger, type MemoryInput, type MemoryRecord, type MemoryQuery, type MemoryScope, type MemoryKind, type MemorySource, type MemoryStatus, type CorrectionInput, type CorrectionRecord, type CorrectionQuery, type CorrectionOutcome, type ProposalInput, type ProposalRecord, type ProposalQuery, type ProposalStatus } from "./durable/index.ts";
8
+ export { runDraftChecks, runChecksForSession, scheduleChecksAfterApply, scheduleChecksAfterPublish, cancelScheduledChecks, fingerprintFor, DRAFT_RULES, walkPageFields, fieldText, type RunChecksArgs, type CheckRule, type CheckContext, type RuleFinding, type FieldEntry, type SiteView } from "./checks/index.ts";
9
+ export { runChecksAction, listFindingsAction, listCheckRunsAction, updateFindingAction, type RunChecksParams, type ListFindingsParams, type UpdateFindingParams, type ChecksScope } from "./http/checks-actions.ts";
10
+ export { durableHealth, noteDurableFailure, isDiscardInFlight } from "./durable/durable-store-singleton.ts";
11
+ export { loadPendingPlan, savePendingPlan, clearPendingPlan, peekPendingPlan } from "./durable/pending-plan-store.ts";
package/dist/index.js CHANGED
@@ -18,4 +18,31 @@
18
18
  //
19
19
  // The Fastify HTTP wrapper lives in apps/orchestrator and imports from here.
20
20
  export { createOrchestrator } from "./handler/create-orchestrator.js";
21
- export { jsonFileAdapter, editorApiAdapter, resolveCapabilities, cmsMediaSource, cmsMediaLabel } from "./cms/index.js";
21
+ export { jsonFileAdapter, editorApiAdapter, resolveCapabilities, cmsMediaSource, cmsMediaUploader, cmsMediaLabel } from "./cms/index.js";
22
+ // The publish-target plugin point. `docs-site/integration/publishing.mdx` has
23
+ // documented this as the way to publish somewhere we do not ship a target for
24
+ // since before the package had an export map, and told the reader to import it
25
+ // from `"./publish/publish-target-registry.js"` — a relative path into *this*
26
+ // repository, pasted into their file. It cannot resolve from anywhere, and
27
+ // there was no specifier that could: the symbol was not exported here, so a
28
+ // registry consumer had no reachable way to register a target at all.
29
+ //
30
+ // The interface was always meant to be public — `architecture.mdx` calls it the
31
+ // integration point for "S3, GitLab Pages, Netlify, a CMS API, a custom CI/CD
32
+ // pipeline". Only the export was missing.
33
+ export { registerPublishTarget, selectPublishTarget, getPublishTarget, listPublishTargets } from "./publish/publish-target-registry.js";
34
+ // The durable substrate for findings, memory, corrections and proposals. It is
35
+ // public because the implementation is meant to be replaceable: a library-mode
36
+ // host on a serverless platform has no persistent disk for a SQLite file, and
37
+ // every record here would otherwise die with the process. `DurableStore` is the
38
+ // seam a Neon- or Turso-backed store fills — which is only true if a consumer
39
+ // installing from the registry can reach the type.
40
+ export { SqliteDurableStore, InMemoryDurableStore, getDurableStore, resetDurableStore, setDurableStore, durableStoreIsEphemeral } from "./durable/index.js";
41
+ // The draft-tier checks: pure functions of a PageDoc and the block manifest,
42
+ // producing findings into the durable store. Public so an integrator can run
43
+ // them (or add a rule of their own) against a site we do not host.
44
+ export { runDraftChecks, runChecksForSession, scheduleChecksAfterApply, scheduleChecksAfterPublish, cancelScheduledChecks, fingerprintFor, DRAFT_RULES, walkPageFields, fieldText } from "./checks/index.js";
45
+ // The checks HTTP surface, mounted by both transports.
46
+ export { runChecksAction, listFindingsAction, listCheckRunsAction, updateFindingAction } from "./http/checks-actions.js";
47
+ export { durableHealth, noteDurableFailure, isDiscardInFlight } from "./durable/durable-store-singleton.js";
48
+ export { loadPendingPlan, savePendingPlan, clearPendingPlan, peekPendingPlan } from "./durable/pending-plan-store.js";
@@ -46,6 +46,22 @@ export declare function selectedBlockSnapshot(args: {
46
46
  selectedEditablePath: string | null;
47
47
  selectedEditableValue: {} | null;
48
48
  } | null;
49
+ /**
50
+ * How an item in a list prop gets named to the planner, in precedence order.
51
+ *
52
+ * A list whose items match none of these reaches the planner as a bare count,
53
+ * and the planner then cannot answer "update the FAQ entry about freezing" —
54
+ * it knows there are three entries and nothing about any of them. It says so,
55
+ * correctly, and asks which one. Three of the twelve list fields in the block
56
+ * registry were in that state: `FAQAccordion.items` names its question `q`,
57
+ * `Testimonials.items` has `author`/`quote`, and `Gallery.images` has
58
+ * `caption`/`alt`. None of those were listed here.
59
+ *
60
+ * `arrayPropLabelKeys` is asserted against the block registry by a test, so a
61
+ * new block with a differently-named item field fails there rather than
62
+ * silently costing the planner a clarification round-trip.
63
+ */
64
+ export declare const ARRAY_PROP_LABEL_KEYS: readonly ["label", "title", "heading", "question", "q", "name", "author", "caption", "alt", "quote", "value", "text"];
49
65
  export declare function arrayPropLengths(props: Record<string, unknown>): Record<string, {
50
66
  length: number;
51
67
  labels?: string[];
@@ -184,6 +184,35 @@ export function selectedBlockSnapshot(args) {
184
184
  // ---------------------------------------------------------------------------
185
185
  // Array prop metadata
186
186
  // ---------------------------------------------------------------------------
187
+ /**
188
+ * How an item in a list prop gets named to the planner, in precedence order.
189
+ *
190
+ * A list whose items match none of these reaches the planner as a bare count,
191
+ * and the planner then cannot answer "update the FAQ entry about freezing" —
192
+ * it knows there are three entries and nothing about any of them. It says so,
193
+ * correctly, and asks which one. Three of the twelve list fields in the block
194
+ * registry were in that state: `FAQAccordion.items` names its question `q`,
195
+ * `Testimonials.items` has `author`/`quote`, and `Gallery.images` has
196
+ * `caption`/`alt`. None of those were listed here.
197
+ *
198
+ * `arrayPropLabelKeys` is asserted against the block registry by a test, so a
199
+ * new block with a differently-named item field fails there rather than
200
+ * silently costing the planner a clarification round-trip.
201
+ */
202
+ export const ARRAY_PROP_LABEL_KEYS = [
203
+ "label",
204
+ "title",
205
+ "heading",
206
+ "question",
207
+ "q",
208
+ "name",
209
+ "author",
210
+ "caption",
211
+ "alt",
212
+ "quote",
213
+ "value",
214
+ "text"
215
+ ];
187
216
  export function arrayPropLengths(props) {
188
217
  const out = {};
189
218
  for (const [key, value] of Object.entries(props)) {
@@ -192,13 +221,10 @@ export function arrayPropLengths(props) {
192
221
  const labels = [];
193
222
  for (const item of value) {
194
223
  if (typeof item === "object" && item !== null) {
195
- const labelValue = item.label ??
196
- item.title ??
197
- item.heading ??
198
- item.question ??
199
- item.name;
200
- if (typeof labelValue === "string")
201
- labels.push(labelValue);
224
+ const record = item;
225
+ const labelKey = ARRAY_PROP_LABEL_KEYS.find((candidate) => typeof record[candidate] === "string");
226
+ if (labelKey)
227
+ labels.push(record[labelKey]);
202
228
  }
203
229
  }
204
230
  out[key] = labels.length > 0 ? { length: value.length, labels } : { length: value.length };
@@ -282,12 +282,28 @@ export declare function adviceResponse(args: {
282
282
  };
283
283
  export declare function plannerMessageWithPendingContext(session: string, message: string): string;
284
284
  /** Build the site context lines without wrapping in a message. Returns null if empty. */
285
+ /** How many documents ride along in a planner request. */
286
+ export declare const SITE_CONTEXT_DOCUMENT_CAP = 60;
285
287
  export declare function buildSiteContextBlock(args?: {
286
288
  sitePurpose?: string;
287
289
  siteHosting?: string;
288
290
  businessContext?: ChatRequestBody["businessContext"];
289
291
  siteContext?: ChatRequestBody["siteContext"];
290
292
  pageDirectory?: string;
293
+ /**
294
+ * The site's downloadable documents, so the planner can link one.
295
+ *
296
+ * Without it, "link the winter menu" is unanswerable: the model has the
297
+ * page list and the block schemas and no idea the site holds fifteen PDFs,
298
+ * so it invents a plausible path — which is exactly how a link to a
299
+ * misspelled filename gets written in the first place. Listed by path and
300
+ * name, because the path is what goes in the link and the name is what the
301
+ * user said.
302
+ */
303
+ documents?: Array<{
304
+ path: string;
305
+ name?: string;
306
+ }>;
291
307
  }): string | null;
292
308
  export declare function withSiteContext(message: string, args?: {
293
309
  sitePurpose?: string;
@@ -492,6 +492,8 @@ function normalizeConstraintList(value) {
492
492
  return [];
493
493
  }
494
494
  /** Build the site context lines without wrapping in a message. Returns null if empty. */
495
+ /** How many documents ride along in a planner request. */
496
+ export const SITE_CONTEXT_DOCUMENT_CAP = 60;
495
497
  export function buildSiteContextBlock(args) {
496
498
  const businessContext = parseJsonObjectMaybe(args?.businessContext);
497
499
  const siteContext = parseJsonObjectMaybe(args?.siteContext);
@@ -516,7 +518,19 @@ export function buildSiteContextBlock(args) {
516
518
  constraints.length > 0 ? `Constraints: ${constraints.join("; ")}` : null,
517
519
  siteName ? `Site name: ${siteName}` : null,
518
520
  pageTemplates.length > 0 ? `Page templates:\n${pageTemplates.join("\n")}` : null,
519
- args?.pageDirectory ? `Pages:\n${args.pageDirectory}` : null
521
+ args?.pageDirectory ? `Pages:\n${args.pageDirectory}` : null,
522
+ /*
523
+ * Capped. A media library can hold hundreds of files and this rides in
524
+ * every planner request; past the cap the model sees a partial list, which
525
+ * makes it worse at finding an obscure document but never wrong about the
526
+ * ones it does see.
527
+ */
528
+ args?.documents && args.documents.length > 0
529
+ ? `Documents the site hosts (link by path, never invent one):\n${args.documents
530
+ .slice(0, SITE_CONTEXT_DOCUMENT_CAP)
531
+ .map((d) => (d.name ? `- ${d.name} — ${d.path}` : `- ${d.path}`))
532
+ .join("\n")}`
533
+ : null
520
534
  ].filter((line) => Boolean(line));
521
535
  if (lines.length === 0)
522
536
  return null;
@@ -1,4 +1,4 @@
1
- import { allowedBlockTypes, blockSchemas, declaredDefaultPropsForType, defaultPropsForType as sharedDefaultPropsForType, getBlockMeta } from "@avocadostudio-ai/shared";
1
+ import { allowedBlockTypes, blockAcceptsProp, blockListItemAcceptsKey, blockSchemas, declaredDefaultPropsForType, defaultPropsForType as sharedDefaultPropsForType } from "@avocadostudio-ai/shared";
2
2
  import { extractRouteMentions, firstRouteMention, normalizeRouteCandidate, parseCreatePageRequest } from "./intent-helpers.js";
3
3
  // ---------------------------------------------------------------------------
4
4
  // Prop-name aliasing, asked of the registry rather than of a literal
@@ -18,23 +18,14 @@ import { extractRouteMentions, firstRouteMention, normalizeRouteCandidate, parse
18
18
  * wrong about its own job; the first link was answering a question about a name
19
19
  * instead of about a schema.
20
20
  *
21
- * So ask the registry. A rename now requires positive evidence in both
22
- * directions: the block cannot take the key the planner used, and can take the
23
- * one we would rewrite it to. Every other case — unknown block type, a block
24
- * that accepts both, a block that accepts neither — leaves the value alone,
25
- * which is the answer that loses no data.
21
+ * So ask the registry — `blockAcceptsProp`, which lives in `shared` because the
22
+ * planner prompt needs the same answer before it tells the model a prop name is
23
+ * wrong. A rename requires positive evidence in both directions: the block
24
+ * cannot take the key the planner used, and can take the one we would rewrite
25
+ * it to. Every other case — unknown block type, a block that accepts both, a
26
+ * block that accepts neither — leaves the value alone, which is the answer that
27
+ * loses no data.
26
28
  */
27
- function blockAcceptsProp(blockType, prop) {
28
- if (!blockType)
29
- return false;
30
- const meta = getBlockMeta(blockType);
31
- if (meta?.fields && prop in meta.fields)
32
- return true;
33
- // Manifest-registered blocks may carry a schema richer than their derived
34
- // meta, so the schema gets the second look rather than the first refusal.
35
- const shape = blockSchemas[blockType]?.shape;
36
- return Boolean(shape && prop in shape);
37
- }
38
29
  /*
39
30
  * Avocado's own `autoplay` / `loop` / `striped` are string enums ("true" /
40
31
  * "false"), not booleans, so a model that emits a real boolean has to be
@@ -86,16 +77,11 @@ function shouldAliasProp(blockType, from, to) {
86
77
  return !blockAcceptsProp(blockType, from) && blockAcceptsProp(blockType, to);
87
78
  }
88
79
  /*
89
- * The same question for a key inside a list item. `listFields[key].itemFields`
90
- * is the declared shape; a block with no declared list metadata answers "no"
91
- * to both halves and is therefore left alone.
80
+ * The same question for a key inside a list item — also shared, for the same
81
+ * reason. A block with no declared list metadata answers "no" to both halves
82
+ * and is therefore left alone.
92
83
  */
93
- function listItemAcceptsKey(blockType, listKey, itemKey) {
94
- if (!blockType)
95
- return false;
96
- const itemFields = getBlockMeta(blockType)?.listFields?.[listKey]?.itemFields;
97
- return Boolean(itemFields && itemKey in itemFields);
98
- }
84
+ const listItemAcceptsKey = blockListItemAcceptsKey;
99
85
  function shouldAliasItemKey(blockType, listKey, from, to) {
100
86
  return (!listItemAcceptsKey(blockType, listKey, from) && listItemAcceptsKey(blockType, listKey, to));
101
87
  }
@@ -711,6 +697,19 @@ export function normalizePlanCandidate(input, args) {
711
697
  return args.currentPage.blocks[idx - 1]?.id;
712
698
  };
713
699
  const usedBlockIds = new Set((args?.currentPage?.blocks ?? []).map((b) => b.id));
700
+ /**
701
+ * Block ids this pass renamed, old → new.
702
+ *
703
+ * Renaming an `add_block` id is only safe if the rest of the plan follows it.
704
+ * A plan that adds `b_hero_x` and then updates `b_hero_x` means the block it
705
+ * just added; leave the reference pointing at the colliding name and the
706
+ * update lands on the *existing* block instead — content written over the
707
+ * wrong block, which is the failure mode this repo keeps re-shipping. Ops are
708
+ * normalized in order, so only ops after the rename are rewritten: a
709
+ * reference that came *before* the add_block can only have meant the block
710
+ * that was already there.
711
+ */
712
+ const renamedBlockIds = new Map();
714
713
  let createdPageSlug;
715
714
  let droppedPageLevelUpdate = false;
716
715
  // itemIds already claimed by a remove_item freeze in THIS plan — used to spot
@@ -972,6 +971,19 @@ export function normalizePlanCandidate(input, args) {
972
971
  raw.afterBlockId =
973
972
  raw.after_block_id ?? raw.after ?? raw.insertAfterId ?? beforeToAfter(raw.beforeId ?? raw.insertBeforeId);
974
973
  }
974
+ // Follow any add_block id an earlier op in this plan had to uniquify. Runs
975
+ // after the alias resolution above so `after_block_id` and friends are
976
+ // already folded into the canonical keys. See `renamedBlockIds`.
977
+ if (renamedBlockIds.size > 0) {
978
+ for (const key of ["blockId", "afterBlockId", "newBlockId"]) {
979
+ const value = raw[key];
980
+ if (typeof value === "string") {
981
+ const renamed = renamedBlockIds.get(value);
982
+ if (renamed)
983
+ raw[key] = renamed;
984
+ }
985
+ }
986
+ }
975
987
  if (!raw.afterPageSlug) {
976
988
  raw.afterPageSlug =
977
989
  raw.afterPageSlug ??
@@ -1246,15 +1258,37 @@ export function normalizePlanCandidate(input, args) {
1246
1258
  block.props = {};
1247
1259
  }
1248
1260
  }
1249
- if ((!block.id || typeof block.id !== "string") && typeof block.type === "string") {
1250
- let fallbackId = `b_${String(block.type).toLowerCase()}_${Date.now()}`;
1261
+ /*
1262
+ * The model authors this id. Nothing in the `add_block` prompt says how
1263
+ * to build one, so it free-associates from the page and the block type —
1264
+ * `b_featuregrid_wellness` for a FeatureGrid on /avocado-wellness. That is
1265
+ * a near-deterministic function of the prompt, so the same request asked
1266
+ * twice produces the same id twice, and the second one dies in the ops
1267
+ * engine on "Block id … already exists", taking the whole plan down with
1268
+ * it: a committing apply is all-or-nothing.
1269
+ *
1270
+ * `usedBlockIds` is seeded from the page's existing blocks and knew this
1271
+ * all along. It was consulted only when the model *omitted* an id, which
1272
+ * is the one case that cannot collide; a supplied id went through
1273
+ * untouched and was never even registered, so two ops in one plan could
1274
+ * pick the same name. Uniquify instead — a fourth FeatureGrid is a
1275
+ * legitimate reading of "populate this page", and only the label was
1276
+ * wrong. The engine's own check stays as the backstop and must never be
1277
+ * relaxed into an overwrite.
1278
+ */
1279
+ const suppliedId = typeof block.id === "string" && block.id.length > 0 ? block.id : null;
1280
+ const idStem = suppliedId ?? (typeof block.type === "string" ? `b_${String(block.type).toLowerCase()}_${Date.now()}` : null);
1281
+ if (idStem) {
1282
+ let candidate = idStem;
1251
1283
  let sfx = 0;
1252
- while (usedBlockIds.has(fallbackId)) {
1284
+ while (usedBlockIds.has(candidate)) {
1253
1285
  sfx++;
1254
- fallbackId = `b_${String(block.type).toLowerCase()}_${Date.now()}_${sfx}`;
1286
+ candidate = `${idStem}_${sfx}`;
1255
1287
  }
1256
- usedBlockIds.add(fallbackId);
1257
- block.id = fallbackId;
1288
+ usedBlockIds.add(candidate);
1289
+ if (suppliedId && candidate !== suppliedId)
1290
+ renamedBlockIds.set(suppliedId, candidate);
1291
+ block.id = candidate;
1258
1292
  }
1259
1293
  raw.block = block;
1260
1294
  // Remap heading→title for non-Hero blocks (LLMs often confuse heading/title)
@@ -24,12 +24,17 @@ function slugsTouchedByOps(ops) {
24
24
  set.add(op.toPageSlug);
25
25
  continue;
26
26
  }
27
+ // rename_page: one page, under two names. Counting both slugs made every
28
+ // rename look like a cross-page plan and held it for approval — the same
29
+ // miscount duplicate_page and duplicate_block are special-cased for above.
30
+ if (op.op === "rename_page") {
31
+ set.add(typeof op.newPageSlug === "string" ? op.newPageSlug : op.pageSlug);
32
+ continue;
33
+ }
27
34
  if ("pageSlug" in op && typeof op.pageSlug === "string")
28
35
  set.add(op.pageSlug);
29
36
  if (op.op === "duplicate_block" && typeof op.toPageSlug === "string")
30
37
  set.add(op.toPageSlug);
31
- if (op.op === "rename_page" && typeof op.newPageSlug === "string")
32
- set.add(op.newPageSlug);
33
38
  }
34
39
  return Array.from(set);
35
40
  }