@sudodevstudio/astro-ai 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -0
  3. package/dist/agent/agent-fallback.d.ts +50 -0
  4. package/dist/agent/agent-fallback.d.ts.map +1 -0
  5. package/dist/agent/agent-fallback.js +31 -0
  6. package/dist/agent/agent-fallback.js.map +1 -0
  7. package/dist/agent/cli-agent-fallback.d.ts +79 -0
  8. package/dist/agent/cli-agent-fallback.d.ts.map +1 -0
  9. package/dist/agent/cli-agent-fallback.js +1056 -0
  10. package/dist/agent/cli-agent-fallback.js.map +1 -0
  11. package/dist/integration/index.d.ts +28 -0
  12. package/dist/integration/index.d.ts.map +1 -0
  13. package/dist/integration/index.js +389 -0
  14. package/dist/integration/index.js.map +1 -0
  15. package/dist/resolver/astro-resolver.d.ts +28 -0
  16. package/dist/resolver/astro-resolver.d.ts.map +1 -0
  17. package/dist/resolver/astro-resolver.js +835 -0
  18. package/dist/resolver/astro-resolver.js.map +1 -0
  19. package/dist/resolver/types.d.ts +39 -0
  20. package/dist/resolver/types.d.ts.map +1 -0
  21. package/dist/resolver/types.js +2 -0
  22. package/dist/resolver/types.js.map +1 -0
  23. package/dist/shared/protocol.d.ts +150 -0
  24. package/dist/shared/protocol.d.ts.map +1 -0
  25. package/dist/shared/protocol.js +28 -0
  26. package/dist/shared/protocol.js.map +1 -0
  27. package/dist/shared/selection-context.d.ts +75 -0
  28. package/dist/shared/selection-context.d.ts.map +1 -0
  29. package/dist/shared/selection-context.js +2 -0
  30. package/dist/shared/selection-context.js.map +1 -0
  31. package/dist/shared/visual-components.d.ts +15 -0
  32. package/dist/shared/visual-components.d.ts.map +1 -0
  33. package/dist/shared/visual-components.js +2 -0
  34. package/dist/shared/visual-components.js.map +1 -0
  35. package/dist/toolbar/action-model.d.ts +20 -0
  36. package/dist/toolbar/action-model.d.ts.map +1 -0
  37. package/dist/toolbar/action-model.js +49 -0
  38. package/dist/toolbar/action-model.js.map +1 -0
  39. package/dist/toolbar/app.d.ts +3 -0
  40. package/dist/toolbar/app.d.ts.map +1 -0
  41. package/dist/toolbar/app.js +274 -0
  42. package/dist/toolbar/app.js.map +1 -0
  43. package/dist/toolbar/chat-drawer.d.ts +111 -0
  44. package/dist/toolbar/chat-drawer.d.ts.map +1 -0
  45. package/dist/toolbar/chat-drawer.js +1575 -0
  46. package/dist/toolbar/chat-drawer.js.map +1 -0
  47. package/dist/toolbar/chat-windows.d.ts +62 -0
  48. package/dist/toolbar/chat-windows.d.ts.map +1 -0
  49. package/dist/toolbar/chat-windows.js +287 -0
  50. package/dist/toolbar/chat-windows.js.map +1 -0
  51. package/dist/toolbar/contextual-actions.d.ts +24 -0
  52. package/dist/toolbar/contextual-actions.d.ts.map +1 -0
  53. package/dist/toolbar/contextual-actions.js +371 -0
  54. package/dist/toolbar/contextual-actions.js.map +1 -0
  55. package/dist/toolbar/diagnostic-actions.d.ts +22 -0
  56. package/dist/toolbar/diagnostic-actions.d.ts.map +1 -0
  57. package/dist/toolbar/diagnostic-actions.js +127 -0
  58. package/dist/toolbar/diagnostic-actions.js.map +1 -0
  59. package/dist/toolbar/layers.d.ts +26 -0
  60. package/dist/toolbar/layers.d.ts.map +1 -0
  61. package/dist/toolbar/layers.js +31 -0
  62. package/dist/toolbar/layers.js.map +1 -0
  63. package/dist/toolbar/overlay.d.ts +40 -0
  64. package/dist/toolbar/overlay.d.ts.map +1 -0
  65. package/dist/toolbar/overlay.js +619 -0
  66. package/dist/toolbar/overlay.js.map +1 -0
  67. package/dist/visual/capability-resolver.d.ts +11 -0
  68. package/dist/visual/capability-resolver.d.ts.map +1 -0
  69. package/dist/visual/capability-resolver.js +69 -0
  70. package/dist/visual/capability-resolver.js.map +1 -0
  71. package/dist/visual/command-engine.d.ts +10 -0
  72. package/dist/visual/command-engine.d.ts.map +1 -0
  73. package/dist/visual/command-engine.js +199 -0
  74. package/dist/visual/command-engine.js.map +1 -0
  75. package/dist/visual/commands.d.ts +36 -0
  76. package/dist/visual/commands.d.ts.map +1 -0
  77. package/dist/visual/commands.js +2 -0
  78. package/dist/visual/commands.js.map +1 -0
  79. package/dist/visual/patch-transactions.d.ts +57 -0
  80. package/dist/visual/patch-transactions.d.ts.map +1 -0
  81. package/dist/visual/patch-transactions.js +259 -0
  82. package/dist/visual/patch-transactions.js.map +1 -0
  83. package/dist/vite/build-ai-plugin.d.ts +11 -0
  84. package/dist/vite/build-ai-plugin.d.ts.map +1 -0
  85. package/dist/vite/build-ai-plugin.js +45 -0
  86. package/dist/vite/build-ai-plugin.js.map +1 -0
  87. package/package.json +85 -0
  88. package/src/agent/agent-fallback.ts +95 -0
  89. package/src/agent/cli-agent-fallback.ts +1382 -0
  90. package/src/integration/index.ts +475 -0
  91. package/src/resolver/astro-resolver.ts +1024 -0
  92. package/src/resolver/types.ts +47 -0
  93. package/src/shared/protocol.ts +181 -0
  94. package/src/shared/selection-context.ts +91 -0
  95. package/src/shared/visual-components.ts +16 -0
  96. package/src/toolbar/action-model.ts +67 -0
  97. package/src/toolbar/app.ts +294 -0
  98. package/src/toolbar/chat-drawer.ts +1742 -0
  99. package/src/toolbar/chat-windows.ts +331 -0
  100. package/src/toolbar/contextual-actions.ts +401 -0
  101. package/src/toolbar/diagnostic-actions.ts +151 -0
  102. package/src/toolbar/layers.ts +32 -0
  103. package/src/toolbar/overlay.ts +638 -0
  104. package/src/visual/capability-resolver.ts +83 -0
  105. package/src/visual/command-engine.ts +250 -0
  106. package/src/visual/commands.ts +37 -0
  107. package/src/visual/patch-transactions.ts +270 -0
  108. package/src/vite/build-ai-plugin.ts +46 -0
@@ -0,0 +1,83 @@
1
+ import type { VisualCapabilities } from '../shared/selection-context.js';
2
+ import type { VisualComponentDefinition } from '../shared/visual-components.js';
3
+ import type { SourceNodeRecord } from '../resolver/types.js';
4
+
5
+ export class VisualCapabilityResolver {
6
+ readonly #components: Map<string, VisualComponentDefinition>;
7
+
8
+ constructor(components: VisualComponentDefinition[] = []) {
9
+ this.#components = new Map(components.map((component) => [component.name, component]));
10
+ }
11
+
12
+ resolve(node: SourceNodeRecord): VisualCapabilities {
13
+ const previous = node.previousSiblingId;
14
+ const next = node.nextSiblingId;
15
+ const reorderable = previous !== undefined || next !== undefined;
16
+ const nodeType = node.componentName ?? node.tagName ?? '*';
17
+ const allowedParentSlots = [...this.#components.values()].flatMap((component) =>
18
+ (component.slots ?? []).flatMap((slot) =>
19
+ slot.accepts === undefined || slot.accepts.includes('*') || slot.accepts.includes(nodeType)
20
+ ? [`${component.name}:${slot.name}`]
21
+ : []
22
+ )
23
+ );
24
+
25
+ return {
26
+ editableText:
27
+ node.textRange !== undefined &&
28
+ (node.sourceKind === 'literal-source' || node.sourceKind === 'repeated-template') &&
29
+ !node.hydratedIsland,
30
+ movable: (reorderable || allowedParentSlots.length > 0) && !node.hydratedIsland,
31
+ reorderable: reorderable && !node.hydratedIsland,
32
+ removable: node.instrumentable && !node.hydratedIsland,
33
+ editableProps: node.hydratedIsland ? [] : this.#editableProps(node),
34
+ allowedParentSlots,
35
+ sourceKind: node.sourceKind,
36
+ dataProvenance: node.dataProvenance,
37
+ ...(node.repeatContext === undefined
38
+ ? {}
39
+ : { repeatContext: node.repeatContext }),
40
+ reorderTargets: {
41
+ ...(previous === undefined ? {} : { previous }),
42
+ ...(next === undefined ? {} : { next }),
43
+ },
44
+ };
45
+ }
46
+
47
+ #editableProps(node: SourceNodeRecord) {
48
+ const definition = node.componentName === undefined
49
+ ? undefined
50
+ : this.#components.get(node.componentName);
51
+ return node.literalProps.flatMap((prop) => {
52
+ const propDefinition = definition?.props?.[prop.name];
53
+ if (definition !== undefined && propDefinition === undefined) return [];
54
+ return [
55
+ {
56
+ ...toEditableProp(prop),
57
+ ...(propDefinition?.values === undefined
58
+ ? {}
59
+ : { allowedValues: propDefinition.values }),
60
+ ...(propDefinition === undefined ? {} : { control: propDefinition.control }),
61
+ },
62
+ ];
63
+ });
64
+ }
65
+
66
+ acceptsSlot(parentComponent: string, slotName: string, childType: string): boolean {
67
+ const slot = this.#components.get(parentComponent)?.slots?.find(({ name }) => name === slotName);
68
+ return slot !== undefined && (slot.accepts === undefined || slot.accepts.includes('*') || slot.accepts.includes(childType));
69
+ }
70
+
71
+ slotsFor(component: string): NonNullable<VisualComponentDefinition['slots']> {
72
+ return this.#components.get(component)?.slots ?? [];
73
+ }
74
+ }
75
+
76
+ function toEditableProp({ name, type, value, allowedValues }: SourceNodeRecord['literalProps'][number]) {
77
+ return {
78
+ name,
79
+ type,
80
+ value,
81
+ ...(allowedValues === undefined ? {} : { allowedValues }),
82
+ };
83
+ }
@@ -0,0 +1,250 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+
4
+ import { parse as parseAstro } from '@astrojs/compiler-rs';
5
+ import { parse as parseJavaScript } from '@babel/parser';
6
+
7
+ import type { AstroResolver } from '../resolver/astro-resolver.js';
8
+ import type { SourceNodeRecord, SourceProp } from '../resolver/types.js';
9
+ import type { DeterministicVisualCommand } from './commands.js';
10
+ import {
11
+ PatchTransactionStore,
12
+ type PatchTransactionSummary,
13
+ } from './patch-transactions.js';
14
+
15
+ export class VisualCommandEngine {
16
+ readonly #resolver: AstroResolver;
17
+ readonly transactions: PatchTransactionStore;
18
+
19
+ constructor(
20
+ resolver: AstroResolver,
21
+ transactions = new PatchTransactionStore(resolver),
22
+ ) {
23
+ this.#resolver = resolver;
24
+ this.transactions = transactions;
25
+ }
26
+
27
+ async execute(command: DeterministicVisualCommand): Promise<PatchTransactionSummary> {
28
+ if (command.kind === 'insert-literal-element') return this.#insertLiteralElement(command);
29
+ const node = this.#resolver.requireNode(command.nodeId);
30
+ const before = await this.#readFreshSource(node);
31
+ const capabilities = this.#resolver.resolveSelection(node.nodeId, '').capabilities;
32
+ let after: string;
33
+
34
+ switch (command.kind) {
35
+ case 'edit-literal-text':
36
+ if (!capabilities.editableText || typeof command.text !== 'string') {
37
+ throw new Error('Rendered or generated text cannot be overwritten safely.');
38
+ }
39
+ after = editLiteralText(node, before, command.text);
40
+ break;
41
+ case 'reorder-sibling':
42
+ if (!capabilities.reorderable) {
43
+ throw new Error('This node cannot be reordered safely.');
44
+ }
45
+ after = this.#reorderSibling(node, before, command.direction);
46
+ break;
47
+ case 'set-literal-prop': {
48
+ const editableProp = capabilities.editableProps.find(
49
+ (candidate) => candidate.name === command.prop,
50
+ );
51
+ if (editableProp === undefined) {
52
+ throw new Error(`Prop “${command.prop}” is not a proven-safe literal prop.`);
53
+ }
54
+ after = setLiteralProp(
55
+ node,
56
+ before,
57
+ command.prop,
58
+ command.value,
59
+ editableProp.allowedValues,
60
+ );
61
+ break;
62
+ }
63
+ case 'remove-source-node':
64
+ if (!capabilities.removable) throw new Error('This source node cannot be removed safely.');
65
+ after = replaceRange(before, node.range.start, node.range.end, '');
66
+ break;
67
+ case 'move-to-slot':
68
+ after = this.#moveToSlot(node, before, command.targetNodeId, command.slot);
69
+ break;
70
+ default:
71
+ throw new Error('Unsupported visual operation; route it to the agent fallback.');
72
+ }
73
+
74
+ assertValidSource(after, node);
75
+ const transaction = await this.transactions.commit(command.kind, node.filePath, before, after);
76
+ // Populate the resolver once immediately so a second local command can use
77
+ // the stable selection. Vite's subsequent transform hits the source-hash
78
+ // cache instead of parsing the same file again.
79
+ this.#resolver.indexFile(node.filePath, after);
80
+ return transaction;
81
+ }
82
+
83
+ async #insertLiteralElement(command: Extract<DeterministicVisualCommand, { kind: 'insert-literal-element' }>): Promise<PatchTransactionSummary> {
84
+ if (!/^[a-z][a-z0-9-]*$/.test(command.tag)) throw new Error('Only native lowercase element names can be inserted deterministically.');
85
+ const file = this.#resolver.toAbsoluteProjectFile(command.file);
86
+ const before = await readFile(file, 'utf8');
87
+ const zone = this.#resolver.findInsertionPoints(file).find((candidate) =>
88
+ candidate.parentNodeId === command.parentNodeId && candidate.slot === command.slot
89
+ );
90
+ if (zone === undefined) throw new Error('The requested insertion zone is no longer available.');
91
+ const slotAttribute = command.slot === undefined ? '' : ` slot=${JSON.stringify(command.slot)}`;
92
+ const markup = `<${command.tag}${slotAttribute}>${escapeJsxText(command.text)}</${command.tag}>`;
93
+ const after = replaceRange(before, zone.offset, zone.offset, `${zone.offset === 0 ? '' : '\n'}${markup}`);
94
+ assertValidFile(after, file);
95
+ const transaction = await this.transactions.commit(command.kind, file, before, after);
96
+ this.#resolver.indexFile(file, after);
97
+ return transaction;
98
+ }
99
+
100
+ async #readFreshSource(node: SourceNodeRecord): Promise<string> {
101
+ const source = await readFile(node.filePath, 'utf8');
102
+ const sourceHash = createHash('sha256').update(source).digest('hex');
103
+ if (sourceHash !== node.sourceHash) {
104
+ throw new Error('The source changed after selection; inspect the element again.');
105
+ }
106
+ return source;
107
+ }
108
+
109
+ #reorderSibling(
110
+ node: SourceNodeRecord,
111
+ source: string,
112
+ direction: 'previous' | 'next',
113
+ ): string {
114
+ if (direction !== 'previous' && direction !== 'next') {
115
+ throw new Error('Unknown sibling reorder direction.');
116
+ }
117
+ const siblingId = direction === 'previous'
118
+ ? node.previousSiblingId
119
+ : node.nextSiblingId;
120
+ if (siblingId === undefined) throw new Error(`This node cannot move ${direction}.`);
121
+ const sibling = this.#resolver.requireNode(siblingId);
122
+ if (
123
+ sibling.filePath !== node.filePath ||
124
+ sibling.siblingGroupId === undefined ||
125
+ sibling.siblingGroupId !== node.siblingGroupId
126
+ ) {
127
+ throw new Error('The target is not a compatible source sibling.');
128
+ }
129
+
130
+ const left = node.range.start < sibling.range.start ? node : sibling;
131
+ const right = left === node ? sibling : node;
132
+ const separator = source.slice(left.range.end, right.range.start);
133
+ if (separator.trim() !== '') {
134
+ throw new Error('Reordering across expressions or comments is not safe.');
135
+ }
136
+
137
+ return replaceRange(
138
+ source,
139
+ left.range.start,
140
+ right.range.end,
141
+ `${source.slice(right.range.start, right.range.end)}${separator}${source.slice(left.range.start, left.range.end)}`,
142
+ );
143
+ }
144
+
145
+ #moveToSlot(node: SourceNodeRecord, source: string, targetNodeId: string, slot: string): string {
146
+ const target = this.#resolver.requireNode(targetNodeId);
147
+ const childType = node.componentName ?? node.tagName ?? '';
148
+ if (
149
+ target.filePath !== node.filePath || target.componentName === undefined || target.selfClosing
150
+ || !this.#resolver.acceptsSlot(target.componentName, slot, childType)
151
+ || (target.range.start >= node.range.start && target.range.end <= node.range.end)
152
+ ) throw new Error('The target is not a compatible declared source slot.');
153
+ if (node.literalProps.some(({ name }) => name === 'slot')) throw new Error('The node already declares a slot assignment.');
154
+ const opening = source.slice(node.openingRange.start, node.openingRange.end);
155
+ const insertion = opening.endsWith('/>') ? opening.length - 2 : opening.length - 1;
156
+ const moved = `${opening.slice(0, insertion)} slot=${JSON.stringify(slot)}${opening.slice(insertion)}${source.slice(node.openingRange.end, node.range.end)}`;
157
+ const without = replaceRange(source, node.range.start, node.range.end, '');
158
+ let targetOffset = target.range.end - `</${target.componentName}>`.length;
159
+ if (node.range.start < targetOffset) targetOffset -= node.range.end - node.range.start;
160
+ return replaceRange(without, targetOffset, targetOffset, `\n${moved}\n`);
161
+ }
162
+ }
163
+
164
+ function editLiteralText(node: SourceNodeRecord, source: string, text: string): string {
165
+ if (node.textRange === undefined || node.dataProvenance.kind !== 'literal') {
166
+ throw new Error('Rendered or generated text cannot be overwritten safely.');
167
+ }
168
+ const current = source.slice(node.textRange.start, node.textRange.end);
169
+ const leading = current.match(/^\s*/)?.[0] ?? '';
170
+ const trailing = current.match(/\s*$/)?.[0] ?? '';
171
+ return replaceRange(
172
+ source,
173
+ node.textRange.start,
174
+ node.textRange.end,
175
+ `${leading}${escapeJsxText(text)}${trailing}`,
176
+ );
177
+ }
178
+
179
+ function setLiteralProp(
180
+ node: SourceNodeRecord,
181
+ source: string,
182
+ name: string,
183
+ value: string | number | boolean,
184
+ allowedValues: Array<string | number | boolean> | undefined,
185
+ ): string {
186
+ const prop = node.literalProps.find((candidate) => candidate.name === name);
187
+ if (prop === undefined) throw new Error(`Prop “${name}” is not a safe literal prop.`);
188
+ if (typeof value !== prop.type) {
189
+ throw new Error(`Prop “${name}” requires a ${prop.type} value.`);
190
+ }
191
+ if (allowedValues !== undefined && !allowedValues.includes(value)) {
192
+ throw new Error(`Prop “${name}” does not accept that value.`);
193
+ }
194
+
195
+ return replaceRange(source, prop.range.start, prop.range.end, serializeProp(prop, value));
196
+ }
197
+
198
+ function serializeProp(prop: SourceProp, value: string | number | boolean): string {
199
+ if (prop.syntax === 'shorthand') {
200
+ return value === true ? prop.name : `${prop.name}={false}`;
201
+ }
202
+ if (prop.syntax === 'quoted') return JSON.stringify(value);
203
+ return `{${typeof value === 'string' ? JSON.stringify(value) : String(value)}}`;
204
+ }
205
+
206
+ function escapeJsxText(value: string): string {
207
+ return value
208
+ .replaceAll('&', '&amp;')
209
+ .replaceAll('<', '&lt;')
210
+ .replaceAll('>', '&gt;')
211
+ .replaceAll('{', '&#123;');
212
+ }
213
+
214
+ function replaceRange(source: string, start: number, end: number, value: string): string {
215
+ return `${source.slice(0, start)}${value}${source.slice(end)}`;
216
+ }
217
+
218
+ function assertValidSource(source: string, node: SourceNodeRecord): void {
219
+ if (node.sourceLanguage === 'astro') {
220
+ const result = parseAstro(source);
221
+ const error = result.diagnostics.find((diagnostic) => diagnostic.severity === 'error');
222
+ if (error !== undefined) {
223
+ throw new Error(`Source transformation made ${node.source.file} invalid: ${error.text}`);
224
+ }
225
+ return;
226
+ }
227
+
228
+ try {
229
+ parseJavaScript(source, {
230
+ sourceType: 'unambiguous',
231
+ plugins: node.sourceLanguage === 'tsx' ? ['jsx', 'typescript'] : ['jsx'],
232
+ });
233
+ } catch (error) {
234
+ const detail = error instanceof Error ? error.message : 'Unknown parser error.';
235
+ throw new Error(`Source transformation made ${node.source.file} invalid: ${detail}`);
236
+ }
237
+ }
238
+
239
+ function assertValidFile(source: string, file: string): void {
240
+ if (file.endsWith('.astro')) {
241
+ const error = parseAstro(source).diagnostics.find((diagnostic) => diagnostic.severity === 'error');
242
+ if (error !== undefined) throw new Error(`Source transformation made ${file} invalid: ${error.text}`);
243
+ return;
244
+ }
245
+ try {
246
+ parseJavaScript(source, { sourceType: 'unambiguous', plugins: file.endsWith('.tsx') ? ['jsx', 'typescript'] : ['jsx'] });
247
+ } catch (error) {
248
+ throw new Error(`Source transformation made ${file} invalid: ${error instanceof Error ? error.message : 'Unknown parser error.'}`);
249
+ }
250
+ }
@@ -0,0 +1,37 @@
1
+ export type EditLiteralTextCommand = {
2
+ kind: 'edit-literal-text';
3
+ nodeId: string;
4
+ text: string;
5
+ };
6
+
7
+ export type ReorderSiblingCommand = {
8
+ kind: 'reorder-sibling';
9
+ nodeId: string;
10
+ direction: 'previous' | 'next';
11
+ };
12
+
13
+ export type SetLiteralPropCommand = {
14
+ kind: 'set-literal-prop';
15
+ nodeId: string;
16
+ prop: string;
17
+ value: string | number | boolean;
18
+ };
19
+
20
+ export type RemoveSourceNodeCommand = { kind: 'remove-source-node'; nodeId: string };
21
+ export type MoveToSlotCommand = { kind: 'move-to-slot'; nodeId: string; targetNodeId: string; slot: string };
22
+ export type InsertLiteralElementCommand = {
23
+ kind: 'insert-literal-element';
24
+ file: string;
25
+ parentNodeId?: string;
26
+ slot?: string;
27
+ tag: string;
28
+ text: string;
29
+ };
30
+
31
+ export type DeterministicVisualCommand =
32
+ | EditLiteralTextCommand
33
+ | ReorderSiblingCommand
34
+ | SetLiteralPropCommand
35
+ | RemoveSourceNodeCommand
36
+ | MoveToSlotCommand
37
+ | InsertLiteralElementCommand;
@@ -0,0 +1,270 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+
5
+ import type { AstroResolver } from '../resolver/astro-resolver.js';
6
+
7
+ export type PatchTransactionKind = 'edit-literal-text' | 'reorder-sibling' | 'set-literal-prop' | 'remove-source-node' | 'move-to-slot' | 'insert-literal-element' | 'agent';
8
+ export type ProposedFileChange = { file: string; before?: string; after?: string };
9
+ export type PatchConflict = { file: string; reason: string; recoveryFile?: string };
10
+ export type PatchTransactionHooks = {
11
+ beforeApply?(absoluteFiles: string[]): void | Promise<void>;
12
+ afterApply?(absoluteFiles: string[]): void | Promise<void>;
13
+ historyFile?: string | false;
14
+ onHistoryWarning?(message: string): void;
15
+ maxRecoveryFiles?: number;
16
+ };
17
+ type StoredFileChange = ProposedFileChange;
18
+ export type PatchTransaction = { id: string; kind: PatchTransactionKind; changes: StoredFileChange[]; createdAt: string };
19
+ export type PatchTransactionSummary = {
20
+ id: string;
21
+ kind: PatchTransactionKind;
22
+ file: string;
23
+ files: string[];
24
+ createdAt: string;
25
+ diff: string;
26
+ conflicts?: PatchConflict[];
27
+ };
28
+ export type PatchHistoryState = { canUndo: boolean; canRedo: boolean; undoLabel?: PatchTransactionKind; redoLabel?: PatchTransactionKind };
29
+ type PersistedHistory = { version: 1; undo: PatchTransaction[]; redo: PatchTransaction[] };
30
+ const DEFAULT_MAX_RECOVERY_FILES = 20;
31
+
32
+ export class PatchTransactionStore {
33
+ readonly #resolver: AstroResolver;
34
+ readonly #hooks: PatchTransactionHooks;
35
+ readonly #historyFile: string | false;
36
+ readonly #undoStack: PatchTransaction[] = [];
37
+ readonly #redoStack: PatchTransaction[] = [];
38
+ readonly #readyPromise: Promise<void>;
39
+
40
+ constructor(resolver: AstroResolver, hooks: PatchTransactionHooks = {}) {
41
+ this.#resolver = resolver;
42
+ this.#hooks = hooks;
43
+ this.#historyFile = hooks.historyFile === undefined
44
+ ? join(resolver.projectRoot, '.astro', 'astro-ai', 'transactions.json')
45
+ : hooks.historyFile;
46
+ this.#readyPromise = this.#load();
47
+ }
48
+
49
+ ready(): Promise<void> { return this.#readyPromise; }
50
+
51
+ commit(kind: PatchTransactionKind, file: string, before: string, after: string): Promise<PatchTransactionSummary> {
52
+ return this.commitBatch(kind, [{ file, before, after }]);
53
+ }
54
+
55
+ async commitBatch(kind: PatchTransactionKind, proposed: ProposedFileChange[]): Promise<PatchTransactionSummary> {
56
+ await this.ready();
57
+ const normalized = proposed.filter(({ before, after }) => before !== after).map(({ file, before, after }) => ({
58
+ file: this.#resolver.toProjectPath(file),
59
+ ...(before === undefined ? {} : { before }),
60
+ ...(after === undefined ? {} : { after }),
61
+ }));
62
+ if (normalized.length === 0) throw new Error('The operation would not change the source.');
63
+
64
+ const changes: StoredFileChange[] = [];
65
+ const conflicts: PatchConflict[] = [];
66
+ for (const change of normalized) {
67
+ const current = await readOptional(this.#resolver.toAbsoluteProjectFile(change.file));
68
+ const merged = reconcileChange(change.before, change.after, current);
69
+ if (!merged.ok) {
70
+ conflicts.push({ file: change.file, reason: merged.reason });
71
+ continue;
72
+ }
73
+ changes.push({ file: change.file, ...(current === undefined ? {} : { before: current }), ...(merged.after === undefined ? {} : { after: merged.after }) });
74
+ }
75
+ if (conflicts.length > 0) await this.#writeRecovery(normalized, conflicts);
76
+ if (changes.length === 0) {
77
+ throw new Error(`No files were applied because the source changed during the agent run. The generated diff was saved for recovery. Conflicts: ${conflicts.map(({ file }) => file).join(', ')}`);
78
+ }
79
+
80
+ await this.#applyWithBoundary(changes, 'after', 'before');
81
+ const transaction: PatchTransaction = { id: randomUUID(), kind, changes, createdAt: new Date().toISOString() };
82
+ this.#undoStack.push(transaction);
83
+ this.#redoStack.length = 0;
84
+ await this.#persist();
85
+ return summarize(transaction, conflicts);
86
+ }
87
+
88
+ async undo(): Promise<PatchTransactionSummary> {
89
+ await this.ready();
90
+ const skipped: PatchConflict[] = [];
91
+ while (this.#undoStack.length > 0) {
92
+ const transaction = this.#undoStack.pop();
93
+ if (transaction === undefined) break;
94
+ const { compatible, conflicts } = await this.#compatibleChanges(transaction.changes, 'after');
95
+ skipped.push(...conflicts);
96
+ if (compatible.length === 0) continue;
97
+ await this.#applyWithBoundary(compatible, 'before', 'after');
98
+ const applied = { ...transaction, changes: compatible };
99
+ this.#redoStack.push(applied);
100
+ await this.#persist();
101
+ return summarize(applied, skipped);
102
+ }
103
+ await this.#persist();
104
+ if (skipped.length > 0) throw new Error('Undo skipped externally modified transactions; no older compatible transaction remains.');
105
+ throw new Error('There is no visual operation to undo.');
106
+ }
107
+
108
+ async redo(): Promise<PatchTransactionSummary> {
109
+ await this.ready();
110
+ const skipped: PatchConflict[] = [];
111
+ while (this.#redoStack.length > 0) {
112
+ const transaction = this.#redoStack.pop();
113
+ if (transaction === undefined) break;
114
+ const { compatible, conflicts } = await this.#compatibleChanges(transaction.changes, 'before');
115
+ skipped.push(...conflicts);
116
+ if (compatible.length === 0) continue;
117
+ await this.#applyWithBoundary(compatible, 'after', 'before');
118
+ const applied = { ...transaction, changes: compatible };
119
+ this.#undoStack.push(applied);
120
+ await this.#persist();
121
+ return summarize(applied, skipped);
122
+ }
123
+ await this.#persist();
124
+ if (skipped.length > 0) throw new Error('Redo skipped externally modified transactions; no older compatible transaction remains.');
125
+ throw new Error('There is no visual operation to redo.');
126
+ }
127
+
128
+ state(): PatchHistoryState {
129
+ const undo = this.#undoStack.at(-1);
130
+ const redo = this.#redoStack.at(-1);
131
+ return { canUndo: undo !== undefined, canRedo: redo !== undefined, ...(undo === undefined ? {} : { undoLabel: undo.kind }), ...(redo === undefined ? {} : { redoLabel: redo.kind }) };
132
+ }
133
+
134
+ async #compatibleChanges(changes: StoredFileChange[], expectedKey: 'before' | 'after'): Promise<{ compatible: StoredFileChange[]; conflicts: PatchConflict[] }> {
135
+ const compatible: StoredFileChange[] = [];
136
+ const conflicts: PatchConflict[] = [];
137
+ for (const change of changes) {
138
+ const current = await readOptional(this.#resolver.toAbsoluteProjectFile(change.file));
139
+ if (current === change[expectedKey]) compatible.push(change);
140
+ else conflicts.push({ file: change.file, reason: 'The file was changed outside this transaction and was left untouched.' });
141
+ }
142
+ return { compatible, conflicts };
143
+ }
144
+
145
+ async #applyWithRollback(changes: StoredFileChange[], targetKey: 'before' | 'after', rollbackKey: 'before' | 'after'): Promise<void> {
146
+ const applied: StoredFileChange[] = [];
147
+ try {
148
+ for (const change of changes) { await this.#write(change.file, change[targetKey]); applied.push(change); }
149
+ } catch (error) {
150
+ for (const change of applied.reverse()) await this.#write(change.file, change[rollbackKey]);
151
+ throw error;
152
+ }
153
+ }
154
+
155
+ async #applyWithBoundary(changes: StoredFileChange[], targetKey: 'before' | 'after', rollbackKey: 'before' | 'after'): Promise<void> {
156
+ const absoluteFiles = changes.map(({ file }) => this.#resolver.toAbsoluteProjectFile(file));
157
+ await this.#hooks.beforeApply?.(absoluteFiles);
158
+ try { await this.#applyWithRollback(changes, targetKey, rollbackKey); }
159
+ finally { await this.#hooks.afterApply?.(absoluteFiles); }
160
+ }
161
+
162
+ async #write(projectFile: string, content: string | undefined): Promise<void> {
163
+ const absoluteFile = this.#resolver.toAbsoluteProjectFile(projectFile);
164
+ if (content === undefined) {
165
+ await rm(absoluteFile, { force: true });
166
+ if (isSourceFile(absoluteFile)) this.#resolver.removeFile(absoluteFile);
167
+ return;
168
+ }
169
+ await mkdir(dirname(absoluteFile), { recursive: true });
170
+ await writeFile(absoluteFile, content, 'utf8');
171
+ // Vite's watcher is the single re-indexing path, avoiding a duplicate parse.
172
+ }
173
+
174
+ async #load(): Promise<void> {
175
+ if (this.#historyFile === false) return;
176
+ try {
177
+ const parsed = JSON.parse(await readFile(this.#historyFile, 'utf8')) as PersistedHistory;
178
+ if (parsed.version !== 1 || !Array.isArray(parsed.undo) || !Array.isArray(parsed.redo)) {
179
+ this.#hooks.onHistoryWarning?.(`Ignored invalid visual transaction history at ${this.#historyFile}. Delete the file to reset undo history.`);
180
+ return;
181
+ }
182
+ this.#undoStack.push(...parsed.undo.slice(-50));
183
+ this.#redoStack.push(...parsed.redo.slice(-50));
184
+ } catch (error) {
185
+ if (isNotFound(error)) return;
186
+ const detail = error instanceof Error ? error.message : 'unknown read error';
187
+ this.#hooks.onHistoryWarning?.(`Could not load visual transaction history at ${this.#historyFile}: ${detail}. Delete the file to reset undo history.`);
188
+ }
189
+ }
190
+
191
+ async #persist(): Promise<void> {
192
+ if (this.#historyFile === false) return;
193
+ await mkdir(dirname(this.#historyFile), { recursive: true });
194
+ const temporary = `${this.#historyFile}.${process.pid}.tmp`;
195
+ await writeFile(temporary, JSON.stringify({ version: 1, undo: this.#undoStack.slice(-50), redo: this.#redoStack.slice(-50) }), 'utf8');
196
+ await rename(temporary, this.#historyFile);
197
+ }
198
+
199
+ async #writeRecovery(changes: StoredFileChange[], conflicts: PatchConflict[]): Promise<void> {
200
+ if (this.#historyFile === false) return;
201
+ const recovery = join(dirname(this.#historyFile), 'recovery', `${Date.now()}-${randomUUID()}.diff`);
202
+ await mkdir(dirname(recovery), { recursive: true });
203
+ await writeFile(recovery, renderDiff(changes), 'utf8');
204
+ await pruneRecoveryDirectory(dirname(recovery), this.#hooks.maxRecoveryFiles ?? DEFAULT_MAX_RECOVERY_FILES);
205
+ for (const conflict of conflicts) conflict.recoveryFile = recovery;
206
+ }
207
+ }
208
+
209
+ export async function pruneRecoveryDirectory(
210
+ directory: string,
211
+ maximumFiles = DEFAULT_MAX_RECOVERY_FILES,
212
+ ): Promise<void> {
213
+ const entries = await readdir(directory, { withFileTypes: true });
214
+ const stale = entries
215
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.diff'))
216
+ .map(({ name }) => name)
217
+ .sort((left, right) => right.localeCompare(left))
218
+ .slice(Math.max(0, maximumFiles));
219
+ await Promise.all(stale.map((name) => rm(join(directory, name), { force: true })));
220
+ }
221
+
222
+ type Reconciled = { ok: true; after?: string } | { ok: false; reason: string };
223
+ function reconcileChange(before: string | undefined, after: string | undefined, current: string | undefined): Reconciled {
224
+ if (current === before) return { ok: true, ...(after === undefined ? {} : { after }) };
225
+ if (before === undefined || after === undefined || current === undefined) return { ok: false, reason: 'The file was created, removed, or replaced externally.' };
226
+ const merged = mergeSingleRegion(before, after, current);
227
+ return merged === undefined ? { ok: false, reason: 'The generated edit overlaps an external edit.' } : { ok: true, after: merged };
228
+ }
229
+
230
+ /** Applies a generated single-region edit when the same source region remains unique in current text. */
231
+ export function mergeSingleRegion(base: string, generated: string, current: string): string | undefined {
232
+ let prefix = 0;
233
+ while (prefix < base.length && prefix < generated.length && base[prefix] === generated[prefix]) prefix += 1;
234
+ let suffix = 0;
235
+ while (suffix < base.length - prefix && suffix < generated.length - prefix && base[base.length - 1 - suffix] === generated[generated.length - 1 - suffix]) suffix += 1;
236
+ const removed = base.slice(prefix, base.length - suffix);
237
+ const inserted = generated.slice(prefix, generated.length - suffix);
238
+ if (removed === '') {
239
+ const left = base.slice(Math.max(0, prefix - 32), prefix);
240
+ const right = base.slice(prefix, Math.min(base.length, prefix + 32));
241
+ const anchor = `${left}${right}`;
242
+ const index = current.indexOf(anchor);
243
+ if (anchor === '' || index < 0 || current.indexOf(anchor, index + 1) >= 0) return undefined;
244
+ return `${current.slice(0, index + left.length)}${inserted}${current.slice(index + left.length)}`;
245
+ }
246
+ const index = current.indexOf(removed);
247
+ if (index < 0 || current.indexOf(removed, index + 1) >= 0) return undefined;
248
+ return `${current.slice(0, index)}${inserted}${current.slice(index + removed.length)}`;
249
+ }
250
+
251
+ function isSourceFile(file: string): boolean { return /\.(?:astro|jsx?|tsx?)$/.test(file); }
252
+ async function readOptional(file: string): Promise<string | undefined> { try { return await readFile(file, 'utf8'); } catch (error) { if (isNotFound(error)) return undefined; throw error; } }
253
+ function isNotFound(error: unknown): boolean { return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT'; }
254
+
255
+ function summarize(transaction: PatchTransaction, conflicts: PatchConflict[] = []): PatchTransactionSummary {
256
+ const files = transaction.changes.map(({ file }) => file);
257
+ return { id: transaction.id, kind: transaction.kind, file: files[0] ?? '', files, createdAt: transaction.createdAt, diff: renderDiff(transaction.changes), ...(conflicts.length === 0 ? {} : { conflicts }) };
258
+ }
259
+
260
+ export function renderDiff(changes: StoredFileChange[]): string {
261
+ return changes.map(({ file, before = '', after = '' }) => {
262
+ const beforeLines = before.split('\n');
263
+ const afterLines = after.split('\n');
264
+ let prefix = 0;
265
+ while (prefix < beforeLines.length && prefix < afterLines.length && beforeLines[prefix] === afterLines[prefix]) prefix += 1;
266
+ let suffix = 0;
267
+ while (suffix < beforeLines.length - prefix && suffix < afterLines.length - prefix && beforeLines[beforeLines.length - 1 - suffix] === afterLines[afterLines.length - 1 - suffix]) suffix += 1;
268
+ return [`--- a/${file}`, `+++ b/${file}`, `@@ ${prefix + 1} @@`, ...beforeLines.slice(prefix, beforeLines.length - suffix).map((line) => `-${line}`), ...afterLines.slice(prefix, afterLines.length - suffix).map((line) => `+${line}`)].join('\n');
269
+ }).join('\n\n');
270
+ }