@elytracms/studio 0.0.43 → 0.0.44

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 (100) hide show
  1. package/dist/components/app-views/studio-config.d.ts +12 -0
  2. package/dist/components/app-views/studio-config.js.map +1 -1
  3. package/dist/components/assets/assets-collection-view-source.d.ts +13 -1
  4. package/dist/components/assets/assets-collection-view-source.js +42 -51
  5. package/dist/components/assets/assets-collection-view-source.js.map +1 -1
  6. package/dist/components/cms/cms-store.js +9 -11
  7. package/dist/components/cms/cms-store.js.map +1 -1
  8. package/dist/components/cms/detail-editor.js +110 -338
  9. package/dist/components/cms/detail-editor.js.map +1 -1
  10. package/dist/components/cms/document-staging.d.ts +8 -3
  11. package/dist/components/cms/document-staging.js +81 -21
  12. package/dist/components/cms/document-staging.js.map +1 -1
  13. package/dist/components/cms/use-compare-selection.d.ts +27 -0
  14. package/dist/components/cms/use-compare-selection.js +29 -0
  15. package/dist/components/cms/use-compare-selection.js.map +1 -0
  16. package/dist/components/cms/use-discard-controller.d.ts +44 -0
  17. package/dist/components/cms/use-discard-controller.js +51 -0
  18. package/dist/components/cms/use-discard-controller.js.map +1 -0
  19. package/dist/components/cms/use-embed-inspector-host.d.ts +12 -0
  20. package/dist/components/cms/use-embed-inspector-host.js +17 -0
  21. package/dist/components/cms/use-embed-inspector-host.js.map +1 -0
  22. package/dist/components/cms/use-field-focus.d.ts +38 -0
  23. package/dist/components/cms/use-field-focus.js +70 -0
  24. package/dist/components/cms/use-field-focus.js.map +1 -0
  25. package/dist/components/cms/use-live-url.d.ts +41 -0
  26. package/dist/components/cms/use-live-url.js +55 -0
  27. package/dist/components/cms/use-live-url.js.map +1 -0
  28. package/dist/components/cms/use-publish-controller.d.ts +49 -0
  29. package/dist/components/cms/use-publish-controller.js +102 -0
  30. package/dist/components/cms/use-publish-controller.js.map +1 -0
  31. package/dist/components/cms/use-published-perspective.d.ts +67 -0
  32. package/dist/components/cms/use-published-perspective.js +67 -0
  33. package/dist/components/cms/use-published-perspective.js.map +1 -0
  34. package/dist/components/cms/use-restore-version.d.ts +57 -0
  35. package/dist/components/cms/use-restore-version.js +66 -0
  36. package/dist/components/cms/use-restore-version.js.map +1 -0
  37. package/dist/components/cms/use-save-mode.d.ts +35 -0
  38. package/dist/components/cms/use-save-mode.js +54 -0
  39. package/dist/components/cms/use-save-mode.js.map +1 -0
  40. package/dist/components/cms/version-compare.js +13 -16
  41. package/dist/components/cms/version-compare.js.map +1 -1
  42. package/dist/components/cms/versions-pane.js +21 -19
  43. package/dist/components/cms/versions-pane.js.map +1 -1
  44. package/dist/components/components/transfer-dialog.d.ts +11 -6
  45. package/dist/components/components/transfer-dialog.js +98 -25
  46. package/dist/components/components/transfer-dialog.js.map +1 -1
  47. package/dist/components/components/transfer.d.ts +74 -8
  48. package/dist/components/components/transfer.js +87 -2
  49. package/dist/components/components/transfer.js.map +1 -1
  50. package/dist/components/rail-store-status.d.ts +7 -5
  51. package/dist/components/rail-store-status.js +12 -9
  52. package/dist/components/rail-store-status.js.map +1 -1
  53. package/dist/components/rail-view-source.d.ts +111 -0
  54. package/dist/components/rail-view-source.js +139 -0
  55. package/dist/components/rail-view-source.js.map +1 -0
  56. package/dist/components/redirects/redirects-collection-view-source.d.ts +13 -1
  57. package/dist/components/redirects/redirects-collection-view-source.js +42 -70
  58. package/dist/components/redirects/redirects-collection-view-source.js.map +1 -1
  59. package/dist/components/section-state.d.ts +12 -3
  60. package/dist/components/section-state.js +8 -4
  61. package/dist/components/section-state.js.map +1 -1
  62. package/dist/components/users/users-collection-view-source.d.ts +13 -1
  63. package/dist/components/users/users-collection-view-source.js +60 -79
  64. package/dist/components/users/users-collection-view-source.js.map +1 -1
  65. package/dist/lib/ai/anthropic-provider.d.ts +29 -0
  66. package/dist/lib/ai/anthropic-provider.js +59 -0
  67. package/dist/lib/ai/anthropic-provider.js.map +1 -0
  68. package/dist/lib/ai/apply-content.d.ts +9 -0
  69. package/dist/lib/ai/apply-content.js +170 -0
  70. package/dist/lib/ai/apply-content.js.map +1 -0
  71. package/dist/lib/ai/config.d.ts +58 -0
  72. package/dist/lib/ai/config.js +134 -0
  73. package/dist/lib/ai/config.js.map +1 -0
  74. package/dist/lib/ai/fake-provider.d.ts +34 -0
  75. package/dist/lib/ai/fake-provider.js +31 -0
  76. package/dist/lib/ai/fake-provider.js.map +1 -0
  77. package/dist/lib/ai/index.d.ts +22 -0
  78. package/dist/lib/ai/index.js +23 -0
  79. package/dist/lib/ai/index.js.map +1 -0
  80. package/dist/lib/ai/knowledge.d.ts +39 -0
  81. package/dist/lib/ai/knowledge.js +60 -0
  82. package/dist/lib/ai/knowledge.js.map +1 -0
  83. package/dist/lib/ai/provenance.d.ts +20 -0
  84. package/dist/lib/ai/provenance.js +15 -0
  85. package/dist/lib/ai/provenance.js.map +1 -0
  86. package/dist/lib/ai/route.d.ts +24 -0
  87. package/dist/lib/ai/route.js +90 -0
  88. package/dist/lib/ai/route.js.map +1 -0
  89. package/dist/lib/ai/shape.d.ts +35 -0
  90. package/dist/lib/ai/shape.js +113 -0
  91. package/dist/lib/ai/shape.js.map +1 -0
  92. package/dist/lib/ai/types.d.ts +227 -0
  93. package/dist/lib/ai/types.js +2 -0
  94. package/dist/lib/ai/types.js.map +1 -0
  95. package/dist/lib/i18n/messages.en.js +4 -2
  96. package/dist/lib/i18n/messages.en.js.map +1 -1
  97. package/dist/lib/workspace/index.d.ts +9 -0
  98. package/dist/lib/workspace/index.js +11 -0
  99. package/dist/lib/workspace/index.js.map +1 -1
  100. package/package.json +8 -2
@@ -0,0 +1,58 @@
1
+ /**
2
+ * `defineStudio({ ai })` resolution + the capability gate (EC-331).
3
+ *
4
+ * House validation-first style (mirrors `resolveWorkspaceConfig`): never throws —
5
+ * every failure is a structured, path-located {@link AiConfigIssue}; `ok` is false
6
+ * when any error-severity issue is present. The serializable parts (`enabled`,
7
+ * `limits`) are Zod-checked; the `provider` is live code, so it is validated by the
8
+ * **capability gate** (tool calling + structured output required) instead of a shape
9
+ * parse — the same discipline the workspace config uses for opaque `components`.
10
+ *
11
+ * Runs when the AI subsystem boots (the route factory / future chat UI call it), not
12
+ * on every studio mount — so an unconfigured studio never imports this module and
13
+ * never pulls the AI SDK. When `enabled: false`, the gate is skipped (nothing loads).
14
+ */
15
+ import { z } from 'zod';
16
+ import type { AiLimits, AiProvider, KnowledgeSource, StudioAiConfig } from './types';
17
+ export declare const AI_CONFIG_ISSUE_CODES: readonly ["invalid-config-shape", "missing-provider", "provider-missing-capabilities", "provider-not-callable"];
18
+ export type AiConfigIssueCode = (typeof AI_CONFIG_ISSUE_CODES)[number];
19
+ /** A structured, path-located `ai`-config issue (never thrown — collected). */
20
+ export interface AiConfigIssue {
21
+ code: AiConfigIssueCode;
22
+ severity: 'error' | 'warning';
23
+ message: string;
24
+ path: ReadonlyArray<string | number>;
25
+ }
26
+ /** The serializable, JSON-validatable subset of the limits block. */
27
+ export declare const aiLimitsSchema: z.ZodObject<{
28
+ maxRequestsPerMinute: z.ZodOptional<z.ZodNumber>;
29
+ maxRepairAttempts: z.ZodOptional<z.ZodNumber>;
30
+ }, z.core.$strict>;
31
+ export interface ResolvedAiConfig {
32
+ /** Whether the feature is active (present + not `enabled: false`). */
33
+ enabled: boolean;
34
+ provider: AiProvider;
35
+ knowledge: KnowledgeSource[];
36
+ limits: AiLimits;
37
+ }
38
+ export interface ResolveAiConfigResult {
39
+ /** True when no error-severity issue is present. */
40
+ ok: boolean;
41
+ /** The resolved config; present only when `ok`. */
42
+ resolved?: ResolvedAiConfig;
43
+ issues: AiConfigIssue[];
44
+ }
45
+ /**
46
+ * The capability gate: a provider must declare tool calling + structured output.
47
+ * Returns the blocking issues (empty when the provider satisfies the gate).
48
+ */
49
+ export declare function assertProviderCapabilities(provider: AiProvider): AiConfigIssue[];
50
+ /**
51
+ * Resolve + validate a `defineStudio({ ai })` block. Deterministic; never throws.
52
+ * The capability gate runs only when the feature is enabled (a disabled block loads
53
+ * nothing). A `null`/`undefined` block resolves to a disabled, ok result — callers
54
+ * treat that as "feature absent".
55
+ */
56
+ export declare function resolveAiConfig(config: StudioAiConfig | null | undefined): ResolveAiConfigResult;
57
+ /** True when a resolved-config result has no error-severity issues. */
58
+ export declare function isAiConfigValid(result: ResolveAiConfigResult): boolean;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * `defineStudio({ ai })` resolution + the capability gate (EC-331).
3
+ *
4
+ * House validation-first style (mirrors `resolveWorkspaceConfig`): never throws —
5
+ * every failure is a structured, path-located {@link AiConfigIssue}; `ok` is false
6
+ * when any error-severity issue is present. The serializable parts (`enabled`,
7
+ * `limits`) are Zod-checked; the `provider` is live code, so it is validated by the
8
+ * **capability gate** (tool calling + structured output required) instead of a shape
9
+ * parse — the same discipline the workspace config uses for opaque `components`.
10
+ *
11
+ * Runs when the AI subsystem boots (the route factory / future chat UI call it), not
12
+ * on every studio mount — so an unconfigured studio never imports this module and
13
+ * never pulls the AI SDK. When `enabled: false`, the gate is skipped (nothing loads).
14
+ */
15
+ import { z } from 'zod';
16
+ // --- Structured issues ------------------------------------------------------------
17
+ export const AI_CONFIG_ISSUE_CODES = [
18
+ 'invalid-config-shape',
19
+ 'missing-provider',
20
+ 'provider-missing-capabilities',
21
+ 'provider-not-callable',
22
+ ];
23
+ // --- Serializable shape (Zod) -----------------------------------------------------
24
+ /** The serializable, JSON-validatable subset of the limits block. */
25
+ export const aiLimitsSchema = z
26
+ .object({
27
+ maxRequestsPerMinute: z.number().int().positive().optional(),
28
+ maxRepairAttempts: z.number().int().nonnegative().optional(),
29
+ })
30
+ .strict();
31
+ /** Non-throwing structural test that a value looks like an {@link AiProvider}. */
32
+ function looksLikeProvider(value) {
33
+ return (typeof value === 'object' &&
34
+ value !== null &&
35
+ typeof value.id === 'string' &&
36
+ typeof value.capabilities === 'function' &&
37
+ typeof value.generateStructured === 'function');
38
+ }
39
+ /**
40
+ * The capability gate: a provider must declare tool calling + structured output.
41
+ * Returns the blocking issues (empty when the provider satisfies the gate).
42
+ */
43
+ export function assertProviderCapabilities(provider) {
44
+ const issues = [];
45
+ let caps;
46
+ try {
47
+ caps = provider.capabilities();
48
+ }
49
+ catch {
50
+ return [
51
+ {
52
+ code: 'provider-not-callable',
53
+ severity: 'error',
54
+ message: `AI provider "${provider.id}" threw while reporting its capabilities.`,
55
+ path: ['ai', 'provider', 'capabilities'],
56
+ },
57
+ ];
58
+ }
59
+ const missing = [];
60
+ if (!caps?.toolCalling)
61
+ missing.push('tool calling');
62
+ if (!caps?.structuredOutput)
63
+ missing.push('structured output');
64
+ if (missing.length > 0) {
65
+ issues.push({
66
+ code: 'provider-missing-capabilities',
67
+ severity: 'error',
68
+ message: `AI provider "${provider.id}" is missing required capabilities: ${missing.join(', ')}. Tool calling and structured output are required.`,
69
+ path: ['ai', 'provider'],
70
+ });
71
+ }
72
+ return issues;
73
+ }
74
+ /**
75
+ * Resolve + validate a `defineStudio({ ai })` block. Deterministic; never throws.
76
+ * The capability gate runs only when the feature is enabled (a disabled block loads
77
+ * nothing). A `null`/`undefined` block resolves to a disabled, ok result — callers
78
+ * treat that as "feature absent".
79
+ */
80
+ export function resolveAiConfig(config) {
81
+ const issues = [];
82
+ if (!config) {
83
+ return { ok: true, issues };
84
+ }
85
+ // Serializable shape: limits (Zod). Shape failure is terminal for that field only.
86
+ let limits = {};
87
+ if (config.limits !== undefined) {
88
+ const parsed = aiLimitsSchema.safeParse(config.limits);
89
+ if (!parsed.success) {
90
+ for (const issue of parsed.error.issues) {
91
+ issues.push({
92
+ code: 'invalid-config-shape',
93
+ severity: 'error',
94
+ message: issue.message,
95
+ path: ['ai', 'limits', ...issue.path.map((p) => (typeof p === 'symbol' ? String(p) : p))],
96
+ });
97
+ }
98
+ }
99
+ else {
100
+ limits = parsed.data;
101
+ }
102
+ }
103
+ const enabled = config.enabled !== false;
104
+ // Provider presence + capability gate (live code — validated, not shape-parsed).
105
+ if (!looksLikeProvider(config.provider)) {
106
+ issues.push({
107
+ code: 'missing-provider',
108
+ severity: 'error',
109
+ message: 'defineStudio({ ai }) requires a `provider` with `id`, `capabilities()`, and `generateStructured()`.',
110
+ path: ['ai', 'provider'],
111
+ });
112
+ }
113
+ else if (enabled) {
114
+ issues.push(...assertProviderCapabilities(config.provider));
115
+ }
116
+ const ok = !issues.some((i) => i.severity === 'error');
117
+ if (!ok)
118
+ return { ok, issues };
119
+ return {
120
+ ok,
121
+ issues,
122
+ resolved: {
123
+ enabled,
124
+ provider: config.provider,
125
+ knowledge: config.knowledge ?? [],
126
+ limits,
127
+ },
128
+ };
129
+ }
130
+ /** True when a resolved-config result has no error-severity issues. */
131
+ export function isAiConfigValid(result) {
132
+ return result.ok;
133
+ }
134
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../../../src/lib/ai/config.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `defineStudio({ ai })` resolution + the capability gate (EC-331).\n *\n * House validation-first style (mirrors `resolveWorkspaceConfig`): never throws —\n * every failure is a structured, path-located {@link AiConfigIssue}; `ok` is false\n * when any error-severity issue is present. The serializable parts (`enabled`,\n * `limits`) are Zod-checked; the `provider` is live code, so it is validated by the\n * **capability gate** (tool calling + structured output required) instead of a shape\n * parse — the same discipline the workspace config uses for opaque `components`.\n *\n * Runs when the AI subsystem boots (the route factory / future chat UI call it), not\n * on every studio mount — so an unconfigured studio never imports this module and\n * never pulls the AI SDK. When `enabled: false`, the gate is skipped (nothing loads).\n */\nimport { z } from 'zod'\nimport type {\n AiLimits,\n AiProvider,\n KnowledgeSource,\n StudioAiConfig,\n} from './types'\n\n// --- Structured issues ------------------------------------------------------------\n\nexport const AI_CONFIG_ISSUE_CODES = [\n 'invalid-config-shape',\n 'missing-provider',\n 'provider-missing-capabilities',\n 'provider-not-callable',\n] as const\nexport type AiConfigIssueCode = (typeof AI_CONFIG_ISSUE_CODES)[number]\n\n/** A structured, path-located `ai`-config issue (never thrown — collected). */\nexport interface AiConfigIssue {\n code: AiConfigIssueCode\n severity: 'error' | 'warning'\n message: string\n path: ReadonlyArray<string | number>\n}\n\n// --- Serializable shape (Zod) -----------------------------------------------------\n\n/** The serializable, JSON-validatable subset of the limits block. */\nexport const aiLimitsSchema = z\n .object({\n maxRequestsPerMinute: z.number().int().positive().optional(),\n maxRepairAttempts: z.number().int().nonnegative().optional(),\n })\n .strict()\n\n// --- Resolution -------------------------------------------------------------------\n\nexport interface ResolvedAiConfig {\n /** Whether the feature is active (present + not `enabled: false`). */\n enabled: boolean\n provider: AiProvider\n knowledge: KnowledgeSource[]\n limits: AiLimits\n}\n\nexport interface ResolveAiConfigResult {\n /** True when no error-severity issue is present. */\n ok: boolean\n /** The resolved config; present only when `ok`. */\n resolved?: ResolvedAiConfig\n issues: AiConfigIssue[]\n}\n\n/** Non-throwing structural test that a value looks like an {@link AiProvider}. */\nfunction looksLikeProvider(value: unknown): value is AiProvider {\n return (\n typeof value === 'object' &&\n value !== null &&\n typeof (value as AiProvider).id === 'string' &&\n typeof (value as AiProvider).capabilities === 'function' &&\n typeof (value as AiProvider).generateStructured === 'function'\n )\n}\n\n/**\n * The capability gate: a provider must declare tool calling + structured output.\n * Returns the blocking issues (empty when the provider satisfies the gate).\n */\nexport function assertProviderCapabilities(provider: AiProvider): AiConfigIssue[] {\n const issues: AiConfigIssue[] = []\n let caps\n try {\n caps = provider.capabilities()\n } catch {\n return [\n {\n code: 'provider-not-callable',\n severity: 'error',\n message: `AI provider \"${provider.id}\" threw while reporting its capabilities.`,\n path: ['ai', 'provider', 'capabilities'],\n },\n ]\n }\n const missing: string[] = []\n if (!caps?.toolCalling) missing.push('tool calling')\n if (!caps?.structuredOutput) missing.push('structured output')\n if (missing.length > 0) {\n issues.push({\n code: 'provider-missing-capabilities',\n severity: 'error',\n message: `AI provider \"${provider.id}\" is missing required capabilities: ${missing.join(\n ', ',\n )}. Tool calling and structured output are required.`,\n path: ['ai', 'provider'],\n })\n }\n return issues\n}\n\n/**\n * Resolve + validate a `defineStudio({ ai })` block. Deterministic; never throws.\n * The capability gate runs only when the feature is enabled (a disabled block loads\n * nothing). A `null`/`undefined` block resolves to a disabled, ok result — callers\n * treat that as \"feature absent\".\n */\nexport function resolveAiConfig(config: StudioAiConfig | null | undefined): ResolveAiConfigResult {\n const issues: AiConfigIssue[] = []\n if (!config) {\n return { ok: true, issues }\n }\n\n // Serializable shape: limits (Zod). Shape failure is terminal for that field only.\n let limits: AiLimits = {}\n if (config.limits !== undefined) {\n const parsed = aiLimitsSchema.safeParse(config.limits)\n if (!parsed.success) {\n for (const issue of parsed.error.issues) {\n issues.push({\n code: 'invalid-config-shape',\n severity: 'error',\n message: issue.message,\n path: ['ai', 'limits', ...issue.path.map((p) => (typeof p === 'symbol' ? String(p) : p))],\n })\n }\n } else {\n limits = parsed.data\n }\n }\n\n const enabled = config.enabled !== false\n\n // Provider presence + capability gate (live code — validated, not shape-parsed).\n if (!looksLikeProvider(config.provider)) {\n issues.push({\n code: 'missing-provider',\n severity: 'error',\n message:\n 'defineStudio({ ai }) requires a `provider` with `id`, `capabilities()`, and `generateStructured()`.',\n path: ['ai', 'provider'],\n })\n } else if (enabled) {\n issues.push(...assertProviderCapabilities(config.provider))\n }\n\n const ok = !issues.some((i) => i.severity === 'error')\n if (!ok) return { ok, issues }\n\n return {\n ok,\n issues,\n resolved: {\n enabled,\n provider: config.provider,\n knowledge: config.knowledge ?? [],\n limits,\n },\n }\n}\n\n/** True when a resolved-config result has no error-severity issues. */\nexport function isAiConfigValid(result: ResolveAiConfigResult): boolean {\n return result.ok\n}\n"]}
@@ -0,0 +1,34 @@
1
+ /**
2
+ * FakeProvider (EC-331): a deterministic, fixture-driven {@link AiProvider} that
3
+ * implements the same interface as the Anthropic provider, so playground / demo /
4
+ * tests run the WHOLE loop (config gate → shape → generate → validate → repair →
5
+ * patch) without a key. Determinism is a project principle: the same fixtures always
6
+ * yield the same result. Imports no AI SDK.
7
+ *
8
+ * For the repair loop, pass `objects: [invalidDoc, validDoc]` — the provider returns
9
+ * them in call order (the last is reused once the script is exhausted). Or pass
10
+ * `respond` to compute the object from the request.
11
+ */
12
+ import type { AiProvider, AiProviderCapabilities, GenerateStructuredRequest, StreamChatRequest } from './types';
13
+ export interface FakeProviderOptions {
14
+ /** Provider id (default `fake`). */
15
+ id?: string;
16
+ /** Model id recorded in provenance (default `fake-model`). */
17
+ model?: string;
18
+ /** Reported capabilities (default: both true). Set to test the capability gate. */
19
+ capabilities?: AiProviderCapabilities;
20
+ /** Scripted objects returned in call order; the last entry is reused when exhausted. */
21
+ objects?: unknown[];
22
+ /** Compute the object from the request + zero-based call index (overrides `objects`). */
23
+ respond?: (request: GenerateStructuredRequest, callIndex: number) => unknown;
24
+ /** Fixed text `streamChat` streams back (default: echoes the last user message). */
25
+ streamText?: string;
26
+ }
27
+ /** A FakeProvider that also records the requests it received, for assertions. */
28
+ export interface FakeProvider extends AiProvider {
29
+ /** Every `generateStructured` request received, in order. */
30
+ readonly calls: GenerateStructuredRequest[];
31
+ /** Every `streamChat` request received, in order. */
32
+ readonly streamCalls: StreamChatRequest[];
33
+ }
34
+ export declare function createFakeProvider(options?: FakeProviderOptions): FakeProvider;
@@ -0,0 +1,31 @@
1
+ export function createFakeProvider(options = {}) {
2
+ const calls = [];
3
+ const streamCalls = [];
4
+ const objects = options.objects ?? [];
5
+ let index = 0;
6
+ return {
7
+ id: options.id ?? 'fake',
8
+ model: options.model ?? 'fake-model',
9
+ calls,
10
+ streamCalls,
11
+ capabilities: () => options.capabilities ?? { toolCalling: true, structuredOutput: true },
12
+ generateStructured(request) {
13
+ calls.push(request);
14
+ const callIndex = index;
15
+ index += 1;
16
+ const object = options.respond
17
+ ? options.respond(request, callIndex)
18
+ : objects.length > 0
19
+ ? objects[Math.min(callIndex, objects.length - 1)]
20
+ : {};
21
+ return Promise.resolve({ object, usage: { inputTokens: 0, outputTokens: 0 } });
22
+ },
23
+ streamChat(request) {
24
+ streamCalls.push(request);
25
+ const last = request.messages.filter((m) => m.role === 'user').at(-1);
26
+ const text = options.streamText ?? last?.content ?? '';
27
+ return Promise.resolve(new Response(text, { headers: { 'content-type': 'text/plain; charset=utf-8' } }));
28
+ },
29
+ };
30
+ }
31
+ //# sourceMappingURL=fake-provider.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fake-provider.js","sourceRoot":"","sources":["../../../src/lib/ai/fake-provider.ts"],"names":[],"mappings":"AAyCA,MAAM,UAAU,kBAAkB,CAAC,UAA+B,EAAE;IAClE,MAAM,KAAK,GAAgC,EAAE,CAAA;IAC7C,MAAM,WAAW,GAAwB,EAAE,CAAA;IAC3C,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,IAAI,EAAE,CAAA;IACrC,IAAI,KAAK,GAAG,CAAC,CAAA;IAEb,OAAO;QACL,EAAE,EAAE,OAAO,CAAC,EAAE,IAAI,MAAM;QACxB,KAAK,EAAE,OAAO,CAAC,KAAK,IAAI,YAAY;QACpC,KAAK;QACL,WAAW;QACX,YAAY,EAAE,GAA2B,EAAE,CACzC,OAAO,CAAC,YAAY,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,gBAAgB,EAAE,IAAI,EAAE;QAEvE,kBAAkB,CAAC,OAAO;YACxB,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,CAAA;YACnB,MAAM,SAAS,GAAG,KAAK,CAAA;YACvB,KAAK,IAAI,CAAC,CAAA;YACV,MAAM,MAAM,GAAG,OAAO,CAAC,OAAO;gBAC5B,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,OAAO,EAAE,SAAS,CAAC;gBACrC,CAAC,CAAC,OAAO,CAAC,MAAM,GAAG,CAAC;oBAClB,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;oBAClD,CAAC,CAAC,EAAE,CAAA;YACR,OAAO,OAAO,CAAC,OAAO,CAAC,EAAE,MAAM,EAAE,KAAK,EAAE,EAAE,WAAW,EAAE,CAAC,EAAE,YAAY,EAAE,CAAC,EAAE,EAAE,CAAC,CAAA;QAChF,CAAC;QAED,UAAU,CAAC,OAAO;YAChB,WAAW,CAAC,IAAI,CAAC,OAAO,CAAC,CAAA;YACzB,MAAM,IAAI,GAAG,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAA;YACrE,MAAM,IAAI,GAAG,OAAO,CAAC,UAAU,IAAI,IAAI,EAAE,OAAO,IAAI,EAAE,CAAA;YACtD,OAAO,OAAO,CAAC,OAAO,CACpB,IAAI,QAAQ,CAAC,IAAI,EAAE,EAAE,OAAO,EAAE,EAAE,cAAc,EAAE,2BAA2B,EAAE,EAAE,CAAC,CACjF,CAAA;QACH,CAAC;KACF,CAAA;AACH,CAAC","sourcesContent":["/**\n * FakeProvider (EC-331): a deterministic, fixture-driven {@link AiProvider} that\n * implements the same interface as the Anthropic provider, so playground / demo /\n * tests run the WHOLE loop (config gate → shape → generate → validate → repair →\n * patch) without a key. Determinism is a project principle: the same fixtures always\n * yield the same result. Imports no AI SDK.\n *\n * For the repair loop, pass `objects: [invalidDoc, validDoc]` — the provider returns\n * them in call order (the last is reused once the script is exhausted). Or pass\n * `respond` to compute the object from the request.\n */\nimport type {\n AiProvider,\n AiProviderCapabilities,\n GenerateStructuredRequest,\n StreamChatRequest,\n} from './types'\n\nexport interface FakeProviderOptions {\n /** Provider id (default `fake`). */\n id?: string\n /** Model id recorded in provenance (default `fake-model`). */\n model?: string\n /** Reported capabilities (default: both true). Set to test the capability gate. */\n capabilities?: AiProviderCapabilities\n /** Scripted objects returned in call order; the last entry is reused when exhausted. */\n objects?: unknown[]\n /** Compute the object from the request + zero-based call index (overrides `objects`). */\n respond?: (request: GenerateStructuredRequest, callIndex: number) => unknown\n /** Fixed text `streamChat` streams back (default: echoes the last user message). */\n streamText?: string\n}\n\n/** A FakeProvider that also records the requests it received, for assertions. */\nexport interface FakeProvider extends AiProvider {\n /** Every `generateStructured` request received, in order. */\n readonly calls: GenerateStructuredRequest[]\n /** Every `streamChat` request received, in order. */\n readonly streamCalls: StreamChatRequest[]\n}\n\nexport function createFakeProvider(options: FakeProviderOptions = {}): FakeProvider {\n const calls: GenerateStructuredRequest[] = []\n const streamCalls: StreamChatRequest[] = []\n const objects = options.objects ?? []\n let index = 0\n\n return {\n id: options.id ?? 'fake',\n model: options.model ?? 'fake-model',\n calls,\n streamCalls,\n capabilities: (): AiProviderCapabilities =>\n options.capabilities ?? { toolCalling: true, structuredOutput: true },\n\n generateStructured(request) {\n calls.push(request)\n const callIndex = index\n index += 1\n const object = options.respond\n ? options.respond(request, callIndex)\n : objects.length > 0\n ? objects[Math.min(callIndex, objects.length - 1)]\n : {}\n return Promise.resolve({ object, usage: { inputTokens: 0, outputTokens: 0 } })\n },\n\n streamChat(request) {\n streamCalls.push(request)\n const last = request.messages.filter((m) => m.role === 'user').at(-1)\n const text = options.streamText ?? last?.content ?? ''\n return Promise.resolve(\n new Response(text, { headers: { 'content-type': 'text/plain; charset=utf-8' } }),\n )\n },\n }\n}\n"]}
@@ -0,0 +1,22 @@
1
+ /**
2
+ * `@elytracms/studio/lib/ai` — the headless AI platform seam (EC-331, phase 1).
3
+ *
4
+ * Public surface: the config gate, the provider adapters (Anthropic + Fake), the
5
+ * knowledge mechanism, the target-shape derivation, the `applyContentWithShape`
6
+ * engine, the server-route factory, and the provenance seam. No chat UI here.
7
+ *
8
+ * Bundle discipline: nothing exported here statically imports `ai` / `@ai-sdk/*` —
9
+ * only `anthropic-provider` touches them, via dynamic `import()`. So importing this
10
+ * barrel never eagerly loads the AI SDK, and a studio without an `ai` config block
11
+ * (which only references `StudioAiConfig` as a type) reaches none of it.
12
+ */
13
+ export declare const PACKAGE = "@elytracms/studio/lib/ai";
14
+ export * from './types';
15
+ export * from './config';
16
+ export * from './provenance';
17
+ export * from './knowledge';
18
+ export * from './shape';
19
+ export * from './apply-content';
20
+ export * from './anthropic-provider';
21
+ export * from './fake-provider';
22
+ export * from './route';
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `@elytracms/studio/lib/ai` — the headless AI platform seam (EC-331, phase 1).
3
+ *
4
+ * Public surface: the config gate, the provider adapters (Anthropic + Fake), the
5
+ * knowledge mechanism, the target-shape derivation, the `applyContentWithShape`
6
+ * engine, the server-route factory, and the provenance seam. No chat UI here.
7
+ *
8
+ * Bundle discipline: nothing exported here statically imports `ai` / `@ai-sdk/*` —
9
+ * only `anthropic-provider` touches them, via dynamic `import()`. So importing this
10
+ * barrel never eagerly loads the AI SDK, and a studio without an `ai` config block
11
+ * (which only references `StudioAiConfig` as a type) reaches none of it.
12
+ */
13
+ export const PACKAGE = '@elytracms/studio/lib/ai';
14
+ export * from './types';
15
+ export * from './config';
16
+ export * from './provenance';
17
+ export * from './knowledge';
18
+ export * from './shape';
19
+ export * from './apply-content';
20
+ export * from './anthropic-provider';
21
+ export * from './fake-provider';
22
+ export * from './route';
23
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/lib/ai/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,OAAO,GAAG,0BAA0B,CAAA;AAEjD,cAAc,SAAS,CAAA;AACvB,cAAc,UAAU,CAAA;AACxB,cAAc,cAAc,CAAA;AAC5B,cAAc,aAAa,CAAA;AAC3B,cAAc,SAAS,CAAA;AACvB,cAAc,iBAAiB,CAAA;AAC/B,cAAc,sBAAsB,CAAA;AACpC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,SAAS,CAAA","sourcesContent":["/**\n * `@elytracms/studio/lib/ai` — the headless AI platform seam (EC-331, phase 1).\n *\n * Public surface: the config gate, the provider adapters (Anthropic + Fake), the\n * knowledge mechanism, the target-shape derivation, the `applyContentWithShape`\n * engine, the server-route factory, and the provenance seam. No chat UI here.\n *\n * Bundle discipline: nothing exported here statically imports `ai` / `@ai-sdk/*` —\n * only `anthropic-provider` touches them, via dynamic `import()`. So importing this\n * barrel never eagerly loads the AI SDK, and a studio without an `ai` config block\n * (which only references `StudioAiConfig` as a type) reaches none of it.\n */\nexport const PACKAGE = '@elytracms/studio/lib/ai'\n\nexport * from './types'\nexport * from './config'\nexport * from './provenance'\nexport * from './knowledge'\nexport * from './shape'\nexport * from './apply-content'\nexport * from './anthropic-provider'\nexport * from './fake-provider'\nexport * from './route'\n"]}
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Knowledge mechanism (EC-331): `ai.knowledge: KnowledgeSource[]` resolution +
3
+ * assembly. Knowledge is a MECHANISM, not a storage policy — a source is an inline
4
+ * literal (markdown versioned with the repo), an async function, or a
5
+ * `fromCollection('<id>')` helper for studio-editable prose (content is DATA; only
6
+ * the mechanism is code). `resolveKnowledge` flattens all three kinds into a
7
+ * deterministic item list; `assembleKnowledgeBlock` renders the context block the
8
+ * engine injects. Knowledge is DATA, never instructions (see the engine's stance).
9
+ */
10
+ import type { KnowledgeCollectionReader, KnowledgeCollectionSource, KnowledgeFn, KnowledgeItem, KnowledgeSource, ResolvedKnowledge } from './types';
11
+ /** `fromCollection('faq')` — sugar for a studio-editable knowledge source. */
12
+ export declare function fromCollection(collectionId: string, options?: {
13
+ field?: string;
14
+ }): KnowledgeCollectionSource;
15
+ /** A `fromCollection(...)` source. */
16
+ export declare function isKnowledgeCollectionSource(source: KnowledgeSource): source is KnowledgeCollectionSource;
17
+ /** A knowledge function source. */
18
+ export declare function isKnowledgeFn(source: KnowledgeSource): source is KnowledgeFn;
19
+ /** An inline knowledge literal. */
20
+ export declare function isKnowledgeItem(source: KnowledgeSource): source is KnowledgeItem;
21
+ export interface ResolveKnowledgeOptions {
22
+ /**
23
+ * Reads prose out of a `fromCollection` source. Wired by the studio/starter-kit to
24
+ * the operations layer. When absent, collection sources resolve to nothing (the
25
+ * mechanism is present, but there is no reader to pull the editable content).
26
+ */
27
+ readCollection?: KnowledgeCollectionReader;
28
+ }
29
+ /**
30
+ * Resolve `KnowledgeSource[]` into a flat, ordered {@link ResolvedKnowledge}.
31
+ * Deterministic per input; never throws — a source that produces nothing simply
32
+ * contributes no items. Order is preserved (config order = context order).
33
+ */
34
+ export declare function resolveKnowledge(sources: readonly KnowledgeSource[] | undefined, options?: ResolveKnowledgeOptions): Promise<ResolvedKnowledge>;
35
+ /**
36
+ * Render resolved knowledge into a markdown context block for the model. Empty
37
+ * knowledge yields an empty string (the engine then omits the block entirely).
38
+ */
39
+ export declare function assembleKnowledgeBlock(resolved: ResolvedKnowledge): string;
@@ -0,0 +1,60 @@
1
+ /** `fromCollection('faq')` — sugar for a studio-editable knowledge source. */
2
+ export function fromCollection(collectionId, options = {}) {
3
+ return {
4
+ kind: 'collection',
5
+ collectionId,
6
+ ...(options.field !== undefined ? { field: options.field } : {}),
7
+ };
8
+ }
9
+ /** A `fromCollection(...)` source. */
10
+ export function isKnowledgeCollectionSource(source) {
11
+ return (typeof source === 'object' &&
12
+ source !== null &&
13
+ source.kind === 'collection');
14
+ }
15
+ /** A knowledge function source. */
16
+ export function isKnowledgeFn(source) {
17
+ return typeof source === 'function';
18
+ }
19
+ /** An inline knowledge literal. */
20
+ export function isKnowledgeItem(source) {
21
+ return (typeof source === 'object' &&
22
+ source !== null &&
23
+ !isKnowledgeCollectionSource(source) &&
24
+ typeof source.content === 'string');
25
+ }
26
+ function toItems(value) {
27
+ return Array.isArray(value) ? value : [value];
28
+ }
29
+ /**
30
+ * Resolve `KnowledgeSource[]` into a flat, ordered {@link ResolvedKnowledge}.
31
+ * Deterministic per input; never throws — a source that produces nothing simply
32
+ * contributes no items. Order is preserved (config order = context order).
33
+ */
34
+ export async function resolveKnowledge(sources, options = {}) {
35
+ const items = [];
36
+ for (const source of sources ?? []) {
37
+ if (isKnowledgeFn(source)) {
38
+ items.push(...toItems(await source()));
39
+ }
40
+ else if (isKnowledgeCollectionSource(source)) {
41
+ if (options.readCollection)
42
+ items.push(...(await options.readCollection(source)));
43
+ }
44
+ else if (isKnowledgeItem(source)) {
45
+ items.push(source);
46
+ }
47
+ }
48
+ return { items: items.filter((i) => i.content.trim().length > 0) };
49
+ }
50
+ /**
51
+ * Render resolved knowledge into a markdown context block for the model. Empty
52
+ * knowledge yields an empty string (the engine then omits the block entirely).
53
+ */
54
+ export function assembleKnowledgeBlock(resolved) {
55
+ if (resolved.items.length === 0)
56
+ return '';
57
+ const sections = resolved.items.map((item) => item.title ? `## ${item.title}\n${item.content.trim()}` : item.content.trim());
58
+ return `# Project knowledge\n\n${sections.join('\n\n')}`;
59
+ }
60
+ //# sourceMappingURL=knowledge.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"knowledge.js","sourceRoot":"","sources":["../../../src/lib/ai/knowledge.ts"],"names":[],"mappings":"AAkBA,8EAA8E;AAC9E,MAAM,UAAU,cAAc,CAC5B,YAAoB,EACpB,UAA8B,EAAE;IAEhC,OAAO;QACL,IAAI,EAAE,YAAY;QAClB,YAAY;QACZ,GAAG,CAAC,OAAO,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,OAAO,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACjE,CAAA;AACH,CAAC;AAED,sCAAsC;AACtC,MAAM,UAAU,2BAA2B,CACzC,MAAuB;IAEvB,OAAO,CACL,OAAO,MAAM,KAAK,QAAQ;QAC1B,MAAM,KAAK,IAAI;QACd,MAAoC,CAAC,IAAI,KAAK,YAAY,CAC5D,CAAA;AACH,CAAC;AAED,mCAAmC;AACnC,MAAM,UAAU,aAAa,CAAC,MAAuB;IACnD,OAAO,OAAO,MAAM,KAAK,UAAU,CAAA;AACrC,CAAC;AAED,mCAAmC;AACnC,MAAM,UAAU,eAAe,CAAC,MAAuB;IACrD,OAAO,CACL,OAAO,MAAM,KAAK,QAAQ;QAC1B,MAAM,KAAK,IAAI;QACf,CAAC,2BAA2B,CAAC,MAAM,CAAC;QACpC,OAAQ,MAAwB,CAAC,OAAO,KAAK,QAAQ,CACtD,CAAA;AACH,CAAC;AAED,SAAS,OAAO,CAAC,KAAsC;IACrD,OAAO,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAA;AAC/C,CAAC;AAWD;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,gBAAgB,CACpC,OAA+C,EAC/C,UAAmC,EAAE;IAErC,MAAM,KAAK,GAAoB,EAAE,CAAA;IACjC,KAAK,MAAM,MAAM,IAAI,OAAO,IAAI,EAAE,EAAE,CAAC;QACnC,IAAI,aAAa,CAAC,MAAM,CAAC,EAAE,CAAC;YAC1B,KAAK,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,MAAM,MAAM,EAAE,CAAC,CAAC,CAAA;QACxC,CAAC;aAAM,IAAI,2BAA2B,CAAC,MAAM,CAAC,EAAE,CAAC;YAC/C,IAAI,OAAO,CAAC,cAAc;gBAAE,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,OAAO,CAAC,cAAc,CAAC,MAAM,CAAC,CAAC,CAAC,CAAA;QACnF,CAAC;aAAM,IAAI,eAAe,CAAC,MAAM,CAAC,EAAE,CAAC;YACnC,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;QACpB,CAAC;IACH,CAAC;IACD,OAAO,EAAE,KAAK,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC,EAAE,CAAA;AACpE,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,sBAAsB,CAAC,QAA2B;IAChE,IAAI,QAAQ,CAAC,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAA;IAC1C,MAAM,QAAQ,GAAG,QAAQ,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAC3C,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,IAAI,CAAC,KAAK,KAAK,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,CAC9E,CAAA;IACD,OAAO,0BAA0B,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAA;AAC1D,CAAC","sourcesContent":["/**\n * Knowledge mechanism (EC-331): `ai.knowledge: KnowledgeSource[]` resolution +\n * assembly. Knowledge is a MECHANISM, not a storage policy — a source is an inline\n * literal (markdown versioned with the repo), an async function, or a\n * `fromCollection('<id>')` helper for studio-editable prose (content is DATA; only\n * the mechanism is code). `resolveKnowledge` flattens all three kinds into a\n * deterministic item list; `assembleKnowledgeBlock` renders the context block the\n * engine injects. Knowledge is DATA, never instructions (see the engine's stance).\n */\nimport type {\n KnowledgeCollectionReader,\n KnowledgeCollectionSource,\n KnowledgeFn,\n KnowledgeItem,\n KnowledgeSource,\n ResolvedKnowledge,\n} from './types'\n\n/** `fromCollection('faq')` — sugar for a studio-editable knowledge source. */\nexport function fromCollection(\n collectionId: string,\n options: { field?: string } = {},\n): KnowledgeCollectionSource {\n return {\n kind: 'collection',\n collectionId,\n ...(options.field !== undefined ? { field: options.field } : {}),\n }\n}\n\n/** A `fromCollection(...)` source. */\nexport function isKnowledgeCollectionSource(\n source: KnowledgeSource,\n): source is KnowledgeCollectionSource {\n return (\n typeof source === 'object' &&\n source !== null &&\n (source as KnowledgeCollectionSource).kind === 'collection'\n )\n}\n\n/** A knowledge function source. */\nexport function isKnowledgeFn(source: KnowledgeSource): source is KnowledgeFn {\n return typeof source === 'function'\n}\n\n/** An inline knowledge literal. */\nexport function isKnowledgeItem(source: KnowledgeSource): source is KnowledgeItem {\n return (\n typeof source === 'object' &&\n source !== null &&\n !isKnowledgeCollectionSource(source) &&\n typeof (source as KnowledgeItem).content === 'string'\n )\n}\n\nfunction toItems(value: KnowledgeItem | KnowledgeItem[]): KnowledgeItem[] {\n return Array.isArray(value) ? value : [value]\n}\n\nexport interface ResolveKnowledgeOptions {\n /**\n * Reads prose out of a `fromCollection` source. Wired by the studio/starter-kit to\n * the operations layer. When absent, collection sources resolve to nothing (the\n * mechanism is present, but there is no reader to pull the editable content).\n */\n readCollection?: KnowledgeCollectionReader\n}\n\n/**\n * Resolve `KnowledgeSource[]` into a flat, ordered {@link ResolvedKnowledge}.\n * Deterministic per input; never throws — a source that produces nothing simply\n * contributes no items. Order is preserved (config order = context order).\n */\nexport async function resolveKnowledge(\n sources: readonly KnowledgeSource[] | undefined,\n options: ResolveKnowledgeOptions = {},\n): Promise<ResolvedKnowledge> {\n const items: KnowledgeItem[] = []\n for (const source of sources ?? []) {\n if (isKnowledgeFn(source)) {\n items.push(...toItems(await source()))\n } else if (isKnowledgeCollectionSource(source)) {\n if (options.readCollection) items.push(...(await options.readCollection(source)))\n } else if (isKnowledgeItem(source)) {\n items.push(source)\n }\n }\n return { items: items.filter((i) => i.content.trim().length > 0) }\n}\n\n/**\n * Render resolved knowledge into a markdown context block for the model. Empty\n * knowledge yields an empty string (the engine then omits the block entirely).\n */\nexport function assembleKnowledgeBlock(resolved: ResolvedKnowledge): string {\n if (resolved.items.length === 0) return ''\n const sections = resolved.items.map((item) =>\n item.title ? `## ${item.title}\\n${item.content.trim()}` : item.content.trim(),\n )\n return `# Project knowledge\\n\\n${sections.join('\\n\\n')}`\n}\n"]}
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Provenance seam (EC-331). AI-authored content carries an origin marker the
3
+ * draft-write path records so an assistant-authored draft is attributable in
4
+ * history — distinct from a human edit.
5
+ *
6
+ * There is no document-level or draft-level origin field in the store yet (today
7
+ * only the retired project-`VersionRecord` has `origin: 'edit' | 'import' |
8
+ * 'rollback'`, and the active per-document `DocumentVersionRecord` carries only
9
+ * `label`/`createdAt`). Phase 1 defines the seam minimally: the engine attaches
10
+ * this marker to its result; wiring it into `documents.upsert` /
11
+ * `DocumentVersionRecord` (and any UI) is deferred to EC-199. No behaviour here.
12
+ */
13
+ import type { AiProvenance, AiProvider } from './types';
14
+ /** The origin discriminator for assistant-authored writes. */
15
+ export declare const AI_ORIGIN: "assistant";
16
+ /**
17
+ * Build the provenance marker for an engine run. `at` defaults to now (ISO); a
18
+ * fixed clock can be injected for deterministic tests.
19
+ */
20
+ export declare function aiProvenance(provider: AiProvider, at?: string): AiProvenance;
@@ -0,0 +1,15 @@
1
+ /** The origin discriminator for assistant-authored writes. */
2
+ export const AI_ORIGIN = 'assistant';
3
+ /**
4
+ * Build the provenance marker for an engine run. `at` defaults to now (ISO); a
5
+ * fixed clock can be injected for deterministic tests.
6
+ */
7
+ export function aiProvenance(provider, at = new Date().toISOString()) {
8
+ return {
9
+ origin: AI_ORIGIN,
10
+ provider: provider.id,
11
+ ...(provider.model !== undefined ? { model: provider.model } : {}),
12
+ at,
13
+ };
14
+ }
15
+ //# sourceMappingURL=provenance.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"provenance.js","sourceRoot":"","sources":["../../../src/lib/ai/provenance.ts"],"names":[],"mappings":"AAcA,8DAA8D;AAC9D,MAAM,CAAC,MAAM,SAAS,GAAG,WAAoB,CAAA;AAE7C;;;GAGG;AACH,MAAM,UAAU,YAAY,CAAC,QAAoB,EAAE,KAAa,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;IACtF,OAAO;QACL,MAAM,EAAE,SAAS;QACjB,QAAQ,EAAE,QAAQ,CAAC,EAAE;QACrB,GAAG,CAAC,QAAQ,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAClE,EAAE;KACH,CAAA;AACH,CAAC","sourcesContent":["/**\n * Provenance seam (EC-331). AI-authored content carries an origin marker the\n * draft-write path records so an assistant-authored draft is attributable in\n * history — distinct from a human edit.\n *\n * There is no document-level or draft-level origin field in the store yet (today\n * only the retired project-`VersionRecord` has `origin: 'edit' | 'import' |\n * 'rollback'`, and the active per-document `DocumentVersionRecord` carries only\n * `label`/`createdAt`). Phase 1 defines the seam minimally: the engine attaches\n * this marker to its result; wiring it into `documents.upsert` /\n * `DocumentVersionRecord` (and any UI) is deferred to EC-199. No behaviour here.\n */\nimport type { AiProvenance, AiProvider } from './types'\n\n/** The origin discriminator for assistant-authored writes. */\nexport const AI_ORIGIN = 'assistant' as const\n\n/**\n * Build the provenance marker for an engine run. `at` defaults to now (ISO); a\n * fixed clock can be injected for deterministic tests.\n */\nexport function aiProvenance(provider: AiProvider, at: string = new Date().toISOString()): AiProvenance {\n return {\n origin: AI_ORIGIN,\n provider: provider.id,\n ...(provider.model !== undefined ? { model: provider.model } : {}),\n at,\n }\n}\n"]}
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Server-route factory (EC-331): a streaming chat/completion endpoint the consuming
3
+ * studio app mounts. Key custody = host route (never the browser, never Convex): the
4
+ * `provider` holds the host-env key and this handler is a THIN model proxy — NO data
5
+ * access, NO service credentials.
6
+ *
7
+ * Framework-agnostic on purpose: it returns a Web `Request → Response` handler the
8
+ * host wires into whatever server entry it has (a TanStack Start server route, a
9
+ * Convex `httpAction`, a plain fetch handler). The current demo studio builds as a
10
+ * pure client SPA with server functions disabled (EC-234), so actually mounting this
11
+ * is an EC-199 integration step; phase 1 ships the handler + its contract.
12
+ *
13
+ * Session-gated: `getSession` (host-wired, reading its own auth — this module never
14
+ * touches `lib/auth`) must return a session for authenticated callers; anonymous
15
+ * callers are rejected. Plus a soft, in-memory per-project rate counter (spend guard).
16
+ */
17
+ import type { AiRouteHandler, AiRouteHandlerOptions } from './types';
18
+ /**
19
+ * Build the mounted route handler. The returned function is a plain
20
+ * `(Request) => Promise<Response>` — session-gated, soft-rate-limited, and a thin
21
+ * proxy to `provider.streamChat`. Never throws to the caller: every failure is a
22
+ * structured JSON response with an appropriate status.
23
+ */
24
+ export declare function createAiRouteHandler(options: AiRouteHandlerOptions): AiRouteHandler;
@@ -0,0 +1,90 @@
1
+ function jsonResponse(value, status) {
2
+ return new Response(JSON.stringify(value), {
3
+ status,
4
+ headers: { 'content-type': 'application/json' },
5
+ });
6
+ }
7
+ function isChatMessage(value) {
8
+ if (typeof value !== 'object' || value === null)
9
+ return false;
10
+ const m = value;
11
+ return ((m.role === 'system' || m.role === 'user' || m.role === 'assistant') &&
12
+ typeof m.content === 'string');
13
+ }
14
+ /** Parse a request body into a {@link StreamChatRequest}, or `null` when malformed. */
15
+ function parseChatBody(body) {
16
+ if (typeof body !== 'object' || body === null)
17
+ return null;
18
+ const { messages, system } = body;
19
+ if (!Array.isArray(messages) || messages.length === 0 || !messages.every(isChatMessage)) {
20
+ return null;
21
+ }
22
+ return {
23
+ messages: messages,
24
+ ...(typeof system === 'string' ? { system } : {}),
25
+ };
26
+ }
27
+ /**
28
+ * Build the mounted route handler. The returned function is a plain
29
+ * `(Request) => Promise<Response>` — session-gated, soft-rate-limited, and a thin
30
+ * proxy to `provider.streamChat`. Never throws to the caller: every failure is a
31
+ * structured JSON response with an appropriate status.
32
+ */
33
+ export function createAiRouteHandler(options) {
34
+ const maxPerMinute = options.limits?.maxRequestsPerMinute;
35
+ const buckets = new Map();
36
+ const projectKey = options.projectKey ?? (() => 'global');
37
+ function rateLimited(key) {
38
+ if (maxPerMinute === undefined || maxPerMinute <= 0)
39
+ return false;
40
+ const now = Date.now();
41
+ const bucket = buckets.get(key);
42
+ if (!bucket || now - bucket.windowStart >= 60_000) {
43
+ buckets.set(key, { windowStart: now, count: 1 });
44
+ return false;
45
+ }
46
+ bucket.count += 1;
47
+ return bucket.count > maxPerMinute;
48
+ }
49
+ return async (request) => {
50
+ // 1. Session gate — reject anonymous callers (the route holds spend, not data).
51
+ let session;
52
+ try {
53
+ session = await options.getSession(request);
54
+ }
55
+ catch {
56
+ return jsonResponse({ error: 'unauthorized' }, 401);
57
+ }
58
+ if (!session)
59
+ return jsonResponse({ error: 'unauthorized' }, 401);
60
+ // 2. Method.
61
+ if (request.method !== 'POST')
62
+ return jsonResponse({ error: 'method-not-allowed' }, 405);
63
+ // 3. Soft per-project rate / spend counter.
64
+ if (rateLimited(projectKey(request)))
65
+ return jsonResponse({ error: 'rate-limited' }, 429);
66
+ // 4. Body → chat request.
67
+ let body;
68
+ try {
69
+ body = await request.json();
70
+ }
71
+ catch {
72
+ return jsonResponse({ error: 'invalid-json' }, 400);
73
+ }
74
+ const chat = parseChatBody(body);
75
+ if (!chat) {
76
+ return jsonResponse({ error: 'invalid-request', message: 'expected { messages: AiChatMessage[] }' }, 400);
77
+ }
78
+ // 5. Thin proxy to the provider stream.
79
+ if (!options.provider.streamChat) {
80
+ return jsonResponse({ error: 'provider-cannot-stream' }, 501);
81
+ }
82
+ try {
83
+ return await options.provider.streamChat(chat);
84
+ }
85
+ catch {
86
+ return jsonResponse({ error: 'provider-error' }, 502);
87
+ }
88
+ };
89
+ }
90
+ //# sourceMappingURL=route.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"route.js","sourceRoot":"","sources":["../../../src/lib/ai/route.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Server-route factory (EC-331): a streaming chat/completion endpoint the consuming\n * studio app mounts. Key custody = host route (never the browser, never Convex): the\n * `provider` holds the host-env key and this handler is a THIN model proxy — NO data\n * access, NO service credentials.\n *\n * Framework-agnostic on purpose: it returns a Web `Request → Response` handler the\n * host wires into whatever server entry it has (a TanStack Start server route, a\n * Convex `httpAction`, a plain fetch handler). The current demo studio builds as a\n * pure client SPA with server functions disabled (EC-234), so actually mounting this\n * is an EC-199 integration step; phase 1 ships the handler + its contract.\n *\n * Session-gated: `getSession` (host-wired, reading its own auth — this module never\n * touches `lib/auth`) must return a session for authenticated callers; anonymous\n * callers are rejected. Plus a soft, in-memory per-project rate counter (spend guard).\n */\nimport type {\n AiChatMessage,\n AiRouteHandler,\n AiRouteHandlerOptions,\n AiSession,\n StreamChatRequest,\n} from './types'\n\nfunction jsonResponse(value: unknown, status: number): Response {\n return new Response(JSON.stringify(value), {\n status,\n headers: { 'content-type': 'application/json' },\n })\n}\n\ninterface RateBucket {\n windowStart: number\n count: number\n}\n\nfunction isChatMessage(value: unknown): value is AiChatMessage {\n if (typeof value !== 'object' || value === null) return false\n const m = value as { role?: unknown; content?: unknown }\n return (\n (m.role === 'system' || m.role === 'user' || m.role === 'assistant') &&\n typeof m.content === 'string'\n )\n}\n\n/** Parse a request body into a {@link StreamChatRequest}, or `null` when malformed. */\nfunction parseChatBody(body: unknown): StreamChatRequest | null {\n if (typeof body !== 'object' || body === null) return null\n const { messages, system } = body as { messages?: unknown; system?: unknown }\n if (!Array.isArray(messages) || messages.length === 0 || !messages.every(isChatMessage)) {\n return null\n }\n return {\n messages: messages as AiChatMessage[],\n ...(typeof system === 'string' ? { system } : {}),\n }\n}\n\n/**\n * Build the mounted route handler. The returned function is a plain\n * `(Request) => Promise<Response>` — session-gated, soft-rate-limited, and a thin\n * proxy to `provider.streamChat`. Never throws to the caller: every failure is a\n * structured JSON response with an appropriate status.\n */\nexport function createAiRouteHandler(options: AiRouteHandlerOptions): AiRouteHandler {\n const maxPerMinute = options.limits?.maxRequestsPerMinute\n const buckets = new Map<string, RateBucket>()\n const projectKey = options.projectKey ?? (() => 'global')\n\n function rateLimited(key: string): boolean {\n if (maxPerMinute === undefined || maxPerMinute <= 0) return false\n const now = Date.now()\n const bucket = buckets.get(key)\n if (!bucket || now - bucket.windowStart >= 60_000) {\n buckets.set(key, { windowStart: now, count: 1 })\n return false\n }\n bucket.count += 1\n return bucket.count > maxPerMinute\n }\n\n return async (request: Request): Promise<Response> => {\n // 1. Session gate — reject anonymous callers (the route holds spend, not data).\n let session: AiSession | null\n try {\n session = await options.getSession(request)\n } catch {\n return jsonResponse({ error: 'unauthorized' }, 401)\n }\n if (!session) return jsonResponse({ error: 'unauthorized' }, 401)\n\n // 2. Method.\n if (request.method !== 'POST') return jsonResponse({ error: 'method-not-allowed' }, 405)\n\n // 3. Soft per-project rate / spend counter.\n if (rateLimited(projectKey(request))) return jsonResponse({ error: 'rate-limited' }, 429)\n\n // 4. Body → chat request.\n let body: unknown\n try {\n body = await request.json()\n } catch {\n return jsonResponse({ error: 'invalid-json' }, 400)\n }\n const chat = parseChatBody(body)\n if (!chat) {\n return jsonResponse(\n { error: 'invalid-request', message: 'expected { messages: AiChatMessage[] }' },\n 400,\n )\n }\n\n // 5. Thin proxy to the provider stream.\n if (!options.provider.streamChat) {\n return jsonResponse({ error: 'provider-cannot-stream' }, 501)\n }\n try {\n return await options.provider.streamChat(chat)\n } catch {\n return jsonResponse({ error: 'provider-error' }, 502)\n }\n }\n}\n"]}