@seamapp/core 0.1.0 → 0.2.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.
- package/migrations/001_seam_init.sql +33 -0
- package/package.json +2 -2
- package/src/deployer.js +51 -0
- package/src/fileResolver.js +102 -0
- package/src/fileWriter.js +7 -6
- package/src/requestHandler.js +71 -23
- package/src/server.js +18 -1
- package/src/snapshotManager.js +71 -8
|
@@ -29,3 +29,36 @@ create table if not exists seam_changes (
|
|
|
29
29
|
);
|
|
30
30
|
|
|
31
31
|
create index if not exists seam_changes_project_idx on seam_changes (project_id, created_at desc);
|
|
32
|
+
|
|
33
|
+
-- Row-level security. The local engine reads/writes these tables with the anon
|
|
34
|
+
-- (publishable) key. If RLS is enabled with no policy, every snapshot insert
|
|
35
|
+
-- fails with 42501. Enable RLS and grant the anon role the access the engine
|
|
36
|
+
-- needs: INSERT + SELECT + UPDATE on snapshots, INSERT + SELECT + UPDATE on changes.
|
|
37
|
+
alter table seam_snapshots enable row level security;
|
|
38
|
+
|
|
39
|
+
drop policy if exists "anon insert snapshots" on seam_snapshots;
|
|
40
|
+
create policy "anon insert snapshots"
|
|
41
|
+
on seam_snapshots for insert to anon with check (true);
|
|
42
|
+
|
|
43
|
+
drop policy if exists "anon select snapshots" on seam_snapshots;
|
|
44
|
+
create policy "anon select snapshots"
|
|
45
|
+
on seam_snapshots for select to anon using (true);
|
|
46
|
+
|
|
47
|
+
drop policy if exists "anon update snapshots" on seam_snapshots;
|
|
48
|
+
create policy "anon update snapshots"
|
|
49
|
+
on seam_snapshots for update to anon using (true) with check (true);
|
|
50
|
+
|
|
51
|
+
alter table seam_changes enable row level security;
|
|
52
|
+
|
|
53
|
+
drop policy if exists "anon insert changes" on seam_changes;
|
|
54
|
+
create policy "anon insert changes"
|
|
55
|
+
on seam_changes for insert to anon with check (true);
|
|
56
|
+
|
|
57
|
+
drop policy if exists "anon select changes" on seam_changes;
|
|
58
|
+
create policy "anon select changes"
|
|
59
|
+
on seam_changes for select to anon using (true);
|
|
60
|
+
|
|
61
|
+
-- The reverse/restore toggle flips a change's status (completed <-> rolled_back).
|
|
62
|
+
drop policy if exists "anon update changes" on seam_changes;
|
|
63
|
+
create policy "anon update changes"
|
|
64
|
+
on seam_changes for update to anon using (true) with check (true);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seamapp/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/server.js",
|
|
6
6
|
"files": [
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@anthropic-ai/sdk": "^0.39.0",
|
|
22
|
-
"@seamapp/graphify-bridge": "
|
|
22
|
+
"@seamapp/graphify-bridge": "^0.2.0",
|
|
23
23
|
"@supabase/supabase-js": "^2.43.0",
|
|
24
24
|
"cors": "^2.8.5",
|
|
25
25
|
"diff": "^5.2.0",
|
package/src/deployer.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Publish the working tree to the live site after a successful apply or rollback.
|
|
6
|
+
*
|
|
7
|
+
* This is the missing half of the loop: Seam edits local files, but the whole
|
|
8
|
+
* point is for the change to GO LIVE, not sit on disk. Configure in
|
|
9
|
+
* seam.config.json:
|
|
10
|
+
*
|
|
11
|
+
* "deploy": { "enabled": true, "command": "vercel", "args": ["--prod","--yes"], "liveUrl": "https://bidwithsmith.com" }
|
|
12
|
+
*
|
|
13
|
+
* Fire-and-forget: never throws into the apply path. Broadcasts its own
|
|
14
|
+
* lifecycle events so the widget can show "Publishing… / Live ✓".
|
|
15
|
+
* DEPLOYING — started
|
|
16
|
+
* DEPLOYED — { url } success
|
|
17
|
+
* DEPLOY_FAILED — { message }
|
|
18
|
+
* DEPLOY_SKIPPED — deploy not configured (silent, informational)
|
|
19
|
+
*/
|
|
20
|
+
export async function runDeploy(config, broadcast, { reason } = {}) {
|
|
21
|
+
const d = config.deploy;
|
|
22
|
+
if (!d || d.enabled === false || !d.command) {
|
|
23
|
+
broadcast?.({ type: 'DEPLOY_SKIPPED', message: 'Auto-deploy is not configured for this project.' });
|
|
24
|
+
return { skipped: true };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const cwd = path.resolve(process.cwd(), config.projectRoot || '.');
|
|
28
|
+
broadcast?.({ type: 'DEPLOYING', message: 'Publishing to your live site…', reason });
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const res = await execa(d.command, d.args || [], {
|
|
32
|
+
cwd,
|
|
33
|
+
timeout: d.timeoutMs || 180_000,
|
|
34
|
+
all: true
|
|
35
|
+
});
|
|
36
|
+
// Pull the production URL out of the CLI output (Vercel prints it).
|
|
37
|
+
const text = res.all || res.stdout || '';
|
|
38
|
+
const urls = text.match(/https?:\/\/[^\s"']+/g) || [];
|
|
39
|
+
const deployUrl = urls[urls.length - 1] || null;
|
|
40
|
+
const url = d.liveUrl || deployUrl;
|
|
41
|
+
broadcast?.({ type: 'DEPLOYED', url, deployUrl, message: url ? `Live at ${url}` : 'Published to your live site.' });
|
|
42
|
+
return { ok: true, url, deployUrl };
|
|
43
|
+
} catch (err) {
|
|
44
|
+
const msg = (err.shortMessage || err.message || 'deploy failed').split('\n')[0];
|
|
45
|
+
broadcast?.({
|
|
46
|
+
type: 'DEPLOY_FAILED',
|
|
47
|
+
message: `Saved locally, but publishing to the live site failed: ${msg}. Your change is on disk and reversible — you can retry the publish.`
|
|
48
|
+
});
|
|
49
|
+
return { ok: false, error: msg };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { queryGraph } from '../../graphify-bridge/src/index.js';
|
|
5
|
+
|
|
6
|
+
// Lazy client: the Anthropic key is loaded by dotenv in server.js's body, which
|
|
7
|
+
// runs AFTER this module is imported. Instantiating at module-load time would
|
|
8
|
+
// capture an undefined key. Build it on first use instead, when env is populated.
|
|
9
|
+
let _client;
|
|
10
|
+
function client() {
|
|
11
|
+
if (!_client) _client = new Anthropic({ apiKey: process.env.SEAM_ANTHROPIC_KEY });
|
|
12
|
+
return _client;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Files Seam can actually edit. Images/video/binaries are excluded.
|
|
16
|
+
const EDITABLE = /\.(html?|jsx?|tsx?|mjs|cjs|css|scss|sass|less|json|jsonc|md|mdx|sql|vue|svelte|astro|py|rb|go|rs|java|php|txt|ya?ml|toml|xml|svg)$/i;
|
|
17
|
+
const SKIP = /(^|[\\/])(node_modules|\.git|graphify-out|dist|build|\.next|\.tmp|coverage)[\\/]/;
|
|
18
|
+
|
|
19
|
+
function readManifest(graphifyOut) {
|
|
20
|
+
const p = path.join(process.cwd(), graphifyOut, 'manifest.json');
|
|
21
|
+
let m;
|
|
22
|
+
try { m = fs.readJsonSync(p); } catch { return []; }
|
|
23
|
+
const files = Array.isArray(m) ? m : Object.keys(m);
|
|
24
|
+
return files.filter(f => EDITABLE.test(f) && !SKIP.test(f));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Natural-language file resolver. Replaces brittle keyword gating: an LLM reads
|
|
29
|
+
* the user's plain-English request plus the project's file list and decides
|
|
30
|
+
* which files to edit — or asks for clarification, or says it can't tell.
|
|
31
|
+
*
|
|
32
|
+
* Graphify is used only as a *ranking hint*, never as a gate. Returns one of:
|
|
33
|
+
* { status: 'resolved', files: [...], reason }
|
|
34
|
+
* { status: 'clarify', question, options: [...] } // options are full, ready-to-send requests
|
|
35
|
+
* { status: 'unclear', message }
|
|
36
|
+
* plus { usage } for token logging.
|
|
37
|
+
*/
|
|
38
|
+
export async function resolveFiles({ request, config }) {
|
|
39
|
+
const allFiles = readManifest(config.graphifyOut);
|
|
40
|
+
if (!allFiles.length) {
|
|
41
|
+
return { status: 'unclear', message: "I can't see any editable files in this project yet. Try rebuilding the graph, then ask again.", usage: null };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Best-effort graph hint — never throws, never gates.
|
|
45
|
+
let hints = [];
|
|
46
|
+
try {
|
|
47
|
+
const nodes = await queryGraph(request, config.graphifyOut);
|
|
48
|
+
hints = [...new Set(nodes.map(n => n.source_file || n.file).filter(Boolean))];
|
|
49
|
+
} catch { /* graph optional */ }
|
|
50
|
+
|
|
51
|
+
const system = `You map a user's plain-English website change request to the file(s) that must be edited.
|
|
52
|
+
|
|
53
|
+
You will get the request and the full list of editable files in the project. A "graph hint" may list files that keyword-matched the request — treat it as a weak suggestion that is often wrong or noisy; trust your own reasoning about what the request means.
|
|
54
|
+
|
|
55
|
+
Reply with ONLY a JSON object, no markdown, exactly one of these shapes:
|
|
56
|
+
{ "status": "resolved", "files": ["relative/path", ...], "reason": "one short sentence" }
|
|
57
|
+
{ "status": "clarify", "question": "a short, friendly question", "options": ["a full restated request", "another full restated request"] }
|
|
58
|
+
{ "status": "unclear", "message": "a friendly sentence telling the user what you'd need to proceed" }
|
|
59
|
+
|
|
60
|
+
Rules:
|
|
61
|
+
- Choose the FEWEST files that satisfy the request. A typical small site change is ONE file.
|
|
62
|
+
- Use ONLY paths that appear verbatim in the file list. Never invent or guess a path.
|
|
63
|
+
- For a static site, visible content (buttons, text, hero, sections, forms, colors) almost always lives in the .html files — prefer those over docs/specs/markdown unless the request is clearly about docs.
|
|
64
|
+
- If the request is ambiguous (could mean two different places/things), return "clarify". Each option MUST be a complete, self-contained request the user could send as-is (e.g. "Change the Book Smith button on the homepage to green").
|
|
65
|
+
- If nothing in the project plausibly matches, or the request is too vague to act on, return "unclear" and suggest how to phrase it (name the page or the visible element).
|
|
66
|
+
- Be forgiving of typos and loose wording — infer intent the way a helpful teammate would.`;
|
|
67
|
+
|
|
68
|
+
const user = [
|
|
69
|
+
`Request: ${request}`,
|
|
70
|
+
hints.length ? `\nGraph hint (may be wrong/noisy): ${hints.join(', ')}` : '',
|
|
71
|
+
`\nEditable files in this project:\n${allFiles.join('\n')}`
|
|
72
|
+
].filter(Boolean).join('\n');
|
|
73
|
+
|
|
74
|
+
let resp;
|
|
75
|
+
try {
|
|
76
|
+
resp = await client().messages.create({
|
|
77
|
+
model: 'claude-haiku-4-5-20251001',
|
|
78
|
+
max_tokens: 1024,
|
|
79
|
+
system,
|
|
80
|
+
messages: [{ role: 'user', content: user }]
|
|
81
|
+
});
|
|
82
|
+
} catch (err) {
|
|
83
|
+
return { status: 'unclear', message: "I couldn't reach the model to interpret your request. Check the Anthropic key and try again.", usage: null };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const raw = (resp.content[0]?.text || '').replace(/```json|```/g, '').trim();
|
|
87
|
+
let out;
|
|
88
|
+
try { out = JSON.parse(raw); }
|
|
89
|
+
catch { return { status: 'unclear', message: "I had trouble understanding that — can you rephrase, naming the page or element you mean?", usage: resp.usage }; }
|
|
90
|
+
|
|
91
|
+
if (out.status === 'resolved') {
|
|
92
|
+
const valid = (out.files || []).filter(f => allFiles.includes(f));
|
|
93
|
+
if (!valid.length) {
|
|
94
|
+
return { status: 'unclear', message: "I couldn't pinpoint which file to change — can you name the page or the visible element (e.g. 'the Book Smith button on the homepage')?", usage: resp.usage };
|
|
95
|
+
}
|
|
96
|
+
return { status: 'resolved', files: valid, reason: out.reason || '', usage: resp.usage };
|
|
97
|
+
}
|
|
98
|
+
if (out.status === 'clarify' && out.question) {
|
|
99
|
+
return { status: 'clarify', question: out.question, options: Array.isArray(out.options) ? out.options.slice(0, 4) : [], usage: resp.usage };
|
|
100
|
+
}
|
|
101
|
+
return { status: 'unclear', message: out.message || "I couldn't tell what you want to change — try naming the page or element.", usage: resp.usage };
|
|
102
|
+
}
|
package/src/fileWriter.js
CHANGED
|
@@ -105,16 +105,17 @@ export async function buildDryRunPreview(changePlan, config) {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
/**
|
|
108
|
-
* Read
|
|
108
|
+
* Read the given files (array of relative path strings) — returns { filepath: content }.
|
|
109
109
|
* Hard-capped at 10 files per COST_AND_SAFETY.md rules.
|
|
110
110
|
*/
|
|
111
|
-
export async function readRelevantFiles(
|
|
111
|
+
export async function readRelevantFiles(filePaths, projectRoot) {
|
|
112
112
|
const files = {};
|
|
113
|
-
const
|
|
114
|
-
for (const
|
|
115
|
-
|
|
113
|
+
const paths = (filePaths || []).slice(0, 10);
|
|
114
|
+
for (const rel of paths) {
|
|
115
|
+
if (!rel) continue;
|
|
116
|
+
const absPath = path.resolve(projectRoot || process.cwd(), rel);
|
|
116
117
|
if (await fs.pathExists(absPath)) {
|
|
117
|
-
files[
|
|
118
|
+
files[rel] = await fs.readFile(absPath, 'utf8');
|
|
118
119
|
}
|
|
119
120
|
}
|
|
120
121
|
return files;
|
package/src/requestHandler.js
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
-
import {
|
|
2
|
+
import { getNodesForFiles, getBlastRadius } from '../../graphify-bridge/src/index.js';
|
|
3
|
+
import { resolveFiles } from './fileResolver.js';
|
|
3
4
|
import { classifyRisk } from './riskClassifier.js';
|
|
4
5
|
import { createSnapshot, logChange, rollbackToToken } from './snapshotManager.js';
|
|
5
6
|
import { applyChangePlan, buildDryRunPreview, readRelevantFiles } from './fileWriter.js';
|
|
6
7
|
import { processAttachments } from './attachmentProcessor.js';
|
|
7
8
|
import { logTokenUsage } from './tokenLogger.js';
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
import { runDeploy } from './deployer.js';
|
|
10
|
+
|
|
11
|
+
// Lazy client: dotenv loads the key in server.js's body, which runs AFTER this
|
|
12
|
+
// module is imported. Build the client on first use so it captures the real key.
|
|
13
|
+
let _client;
|
|
14
|
+
function client() {
|
|
15
|
+
if (!_client) _client = new Anthropic({ apiKey: process.env.SEAM_ANTHROPIC_KEY });
|
|
16
|
+
return _client;
|
|
17
|
+
}
|
|
10
18
|
|
|
11
19
|
const pendingPlans = new Map();
|
|
12
20
|
|
|
@@ -38,43 +46,68 @@ async function _handleChangeRequest({ requestId, request, attachments, config, b
|
|
|
38
46
|
return;
|
|
39
47
|
}
|
|
40
48
|
|
|
41
|
-
broadcast({ type: 'STATUS', status: 'analyzing', requestId, message: '
|
|
49
|
+
broadcast({ type: 'STATUS', status: 'analyzing', requestId, message: 'Understanding your request...' });
|
|
42
50
|
|
|
43
51
|
const processedAttachments = await processAttachments(attachments, config);
|
|
44
52
|
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
53
|
+
// Natural-language file resolution (LLM). Graphify is a hint, not a gate.
|
|
54
|
+
// 15s timeout — a single cheap Haiku call.
|
|
55
|
+
const resolution = await Promise.race([
|
|
56
|
+
resolveFiles({ request, config }),
|
|
57
|
+
timeout(15_000, 'File resolution')
|
|
49
58
|
]);
|
|
50
59
|
|
|
51
|
-
if (
|
|
60
|
+
if (resolution.usage) {
|
|
61
|
+
await logTokenUsage({
|
|
62
|
+
requestId, request, tier: 'resolve',
|
|
63
|
+
inputTokens: resolution.usage.input_tokens,
|
|
64
|
+
outputTokens: resolution.usage.output_tokens,
|
|
65
|
+
status: 'resolved'
|
|
66
|
+
}).catch(() => {});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Ambiguous request → ask the user instead of guessing or failing.
|
|
70
|
+
if (resolution.status === 'clarify') {
|
|
71
|
+
broadcast({
|
|
72
|
+
type: 'CLARIFY', requestId,
|
|
73
|
+
question: resolution.question,
|
|
74
|
+
options: resolution.options || []
|
|
75
|
+
});
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Couldn't tell what to change → guide the user, don't dead-end.
|
|
80
|
+
if (resolution.status !== 'resolved' || !resolution.files?.length) {
|
|
52
81
|
broadcast({
|
|
53
82
|
type: 'FAILED', requestId,
|
|
54
|
-
message: "
|
|
83
|
+
message: resolution.message || "I couldn't tell which file to change — try naming the page or the visible element (e.g. 'the Book Smith button on the homepage')."
|
|
55
84
|
});
|
|
56
85
|
return;
|
|
57
86
|
}
|
|
58
87
|
|
|
88
|
+
const filePaths = resolution.files;
|
|
89
|
+
|
|
90
|
+
// Pull graph nodes for the resolved files so risk/blast-radius still work.
|
|
91
|
+
const graphNodes = await getNodesForFiles(filePaths, config.graphifyOut).catch(() => []);
|
|
59
92
|
const blastRadius = getBlastRadius(graphNodes);
|
|
60
93
|
const tier = classifyRisk(request, graphNodes, blastRadius);
|
|
61
94
|
|
|
62
95
|
broadcast({
|
|
63
96
|
type: 'RISK_ASSESSMENT', requestId, tier, blastRadius,
|
|
64
|
-
affectedFiles:
|
|
97
|
+
affectedFiles: filePaths,
|
|
65
98
|
message: getRiskMessage(tier, blastRadius)
|
|
66
99
|
});
|
|
67
100
|
|
|
68
101
|
if (tier === 'B' || tier === 'C') {
|
|
69
102
|
pendingPlans.set(requestId, {
|
|
70
|
-
request,
|
|
103
|
+
request, filePaths, blastRadius, tier,
|
|
71
104
|
processedAttachments, config,
|
|
72
105
|
state: 'awaiting_confirmation'
|
|
73
106
|
});
|
|
74
107
|
return;
|
|
75
108
|
}
|
|
76
109
|
|
|
77
|
-
await executePlan({ requestId, request,
|
|
110
|
+
await executePlan({ requestId, request, filePaths, processedAttachments, tier, config, broadcast });
|
|
78
111
|
}
|
|
79
112
|
|
|
80
113
|
export async function executeConfirmedPlan(planId, broadcast) {
|
|
@@ -97,7 +130,7 @@ async function _executeConfirmedPlan(planId, broadcast) {
|
|
|
97
130
|
await executePlan({
|
|
98
131
|
requestId: planId,
|
|
99
132
|
request: pending.request,
|
|
100
|
-
|
|
133
|
+
filePaths: pending.filePaths,
|
|
101
134
|
processedAttachments: pending.processedAttachments,
|
|
102
135
|
tier: pending.tier,
|
|
103
136
|
config: pending.config,
|
|
@@ -105,10 +138,10 @@ async function _executeConfirmedPlan(planId, broadcast) {
|
|
|
105
138
|
});
|
|
106
139
|
}
|
|
107
140
|
|
|
108
|
-
async function executePlan({ requestId, request,
|
|
141
|
+
async function executePlan({ requestId, request, filePaths, processedAttachments, tier, config, broadcast }) {
|
|
109
142
|
broadcast({ type: 'STATUS', status: 'planning', requestId, message: 'Planning changes...' });
|
|
110
143
|
|
|
111
|
-
const fileContents = await readRelevantFiles(
|
|
144
|
+
const fileContents = await readRelevantFiles(filePaths, config.projectRoot);
|
|
112
145
|
|
|
113
146
|
// One Claude call — 30s timeout per COST_AND_SAFETY.md
|
|
114
147
|
let claudeResponse;
|
|
@@ -196,9 +229,18 @@ async function _applyApprovedPlan({ requestId, changePlan, fileContents, tier, r
|
|
|
196
229
|
return;
|
|
197
230
|
}
|
|
198
231
|
|
|
232
|
+
// Capture the POST-change contents so the change has a redo target. Without
|
|
233
|
+
// this the after-snapshot is empty and Reverse can't be un-reversed. Reading
|
|
234
|
+
// the just-written files is best-effort — a failure here must not undo a
|
|
235
|
+
// successful change, so we fall back to an empty snapshot (undo still works).
|
|
236
|
+
let afterContents = {};
|
|
237
|
+
try {
|
|
238
|
+
afterContents = await readRelevantFiles(writtenFiles, config.projectRoot);
|
|
239
|
+
} catch { /* redo target unavailable; undo via before-snapshot still works */ }
|
|
240
|
+
|
|
199
241
|
const afterToken = await createSnapshot({
|
|
200
242
|
changePlan, status: 'after', projectId: config.projectId,
|
|
201
|
-
request, tier, fileContents:
|
|
243
|
+
request, tier, fileContents: afterContents, parentToken: beforeToken
|
|
202
244
|
});
|
|
203
245
|
|
|
204
246
|
await logChange({
|
|
@@ -207,6 +249,11 @@ async function _applyApprovedPlan({ requestId, changePlan, fileContents, tier, r
|
|
|
207
249
|
});
|
|
208
250
|
|
|
209
251
|
broadcast({ type: 'COMPLETE', requestId, beforeToken, afterToken, filesChanged: writtenFiles, message: 'Done' });
|
|
252
|
+
|
|
253
|
+
// The whole point of the loop: the change must GO LIVE, not sit on disk.
|
|
254
|
+
// Fire-and-forget so a slow publish never blocks/fails the apply — the deployer
|
|
255
|
+
// broadcasts its own DEPLOYING/DEPLOYED/DEPLOY_FAILED events to the widget.
|
|
256
|
+
runDeploy(config, broadcast, { reason: 'apply' }).catch(() => {});
|
|
210
257
|
}
|
|
211
258
|
|
|
212
259
|
async function callClaude({ request, fileContents, processedAttachments }) {
|
|
@@ -221,7 +268,7 @@ Your job is to return a JSON change plan ONLY — no explanation, no markdown, n
|
|
|
221
268
|
The JSON must be this exact shape:
|
|
222
269
|
{
|
|
223
270
|
"originalRequest": "the user's request verbatim",
|
|
224
|
-
"summary": "
|
|
271
|
+
"summary": "a plain-English confirmation written for a non-technical site owner. Restate what they asked for in their own terms, name the visible on-page element by what it LOOKS LIKE and WHERE it sits (e.g. 'the As Seen On Shark Tank badge in the top-right of the hero photo'), and state its current value vs the new value in plain words (e.g. 'it's green right now — changing it to brown'). NO file names, CSS classes, variable names, hex codes, or code.",
|
|
225
272
|
"changes": [
|
|
226
273
|
{
|
|
227
274
|
"filepath": "relative/path/to/file.tsx",
|
|
@@ -237,7 +284,8 @@ Rules:
|
|
|
237
284
|
- Make the MINIMUM change that satisfies the request
|
|
238
285
|
- Do not refactor unrelated code
|
|
239
286
|
- Do not add features not asked for
|
|
240
|
-
- originalSnippet must be unique enough to find exactly once in the file
|
|
287
|
+
- originalSnippet must be unique enough to find exactly once in the file
|
|
288
|
+
- The "summary" is shown to the user as a comprehension check BEFORE they approve — it must prove you understood them. Describe the element the way they SEE it on the page, not the way it appears in code.`;
|
|
241
289
|
|
|
242
290
|
const messageContent = [];
|
|
243
291
|
|
|
@@ -258,7 +306,7 @@ Rules:
|
|
|
258
306
|
|
|
259
307
|
messageContent.push({ type: 'text', text: userMessage });
|
|
260
308
|
|
|
261
|
-
const response = await client.messages.create({
|
|
309
|
+
const response = await client().messages.create({
|
|
262
310
|
model: 'claude-sonnet-4-20250514',
|
|
263
311
|
max_tokens: 4096,
|
|
264
312
|
system: systemPrompt,
|
|
@@ -286,7 +334,7 @@ Rules:
|
|
|
286
334
|
}
|
|
287
335
|
|
|
288
336
|
function getRiskMessage(tier, blastRadius) {
|
|
289
|
-
if (tier === 'A') return '
|
|
290
|
-
if (tier === 'B') return
|
|
291
|
-
return
|
|
337
|
+
if (tier === 'A') return 'Small, self-contained edit — safe to apply.';
|
|
338
|
+
if (tier === 'B') return 'This touches a few connected parts of your site, so it could affect more than what you asked. Worth a quick review before applying.';
|
|
339
|
+
return 'This edits a central part of your site, so a small change here can ripple further than expected. Read the preview, then apply — your current version is saved, so you can reverse it in one click.';
|
|
292
340
|
}
|
package/src/server.js
CHANGED
|
@@ -10,7 +10,7 @@ import fs from 'fs-extra';
|
|
|
10
10
|
import { fileURLToPath } from 'url';
|
|
11
11
|
|
|
12
12
|
import { handleChangeRequest, executeConfirmedPlan, applyApprovedPlan } from './requestHandler.js';
|
|
13
|
-
import { rollbackToToken, getChangeHistory } from './snapshotManager.js';
|
|
13
|
+
import { rollbackToToken, getChangeHistory, toggleChange } from './snapshotManager.js';
|
|
14
14
|
import { rebuildGraph } from '../../graphify-bridge/src/index.js';
|
|
15
15
|
|
|
16
16
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -129,6 +129,23 @@ app.post('/api/rollback', async (req, res) => {
|
|
|
129
129
|
}
|
|
130
130
|
});
|
|
131
131
|
|
|
132
|
+
// Toggle a change on/off. direction 'undo' restores the before-state,
|
|
133
|
+
// 'redo' restores the after-state — both re-publish and flip the change status.
|
|
134
|
+
app.post('/api/toggle', async (req, res) => {
|
|
135
|
+
const { beforeToken, afterToken, direction } = req.body;
|
|
136
|
+
if (!beforeToken || !afterToken) return res.status(400).json({ error: 'beforeToken and afterToken required' });
|
|
137
|
+
if (activeRequest) return res.status(409).json({ error: 'A change is already in progress.' });
|
|
138
|
+
activeRequest = true;
|
|
139
|
+
res.json({ status: 'toggling' });
|
|
140
|
+
try {
|
|
141
|
+
await toggleChange({ beforeToken, afterToken, direction: direction === 'redo' ? 'redo' : 'undo', config, broadcast });
|
|
142
|
+
} catch (err) {
|
|
143
|
+
broadcast({ type: 'FAILED', message: err.message });
|
|
144
|
+
} finally {
|
|
145
|
+
activeRequest = false;
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
132
149
|
app.get('/api/history', async (req, res) => {
|
|
133
150
|
const history = await getChangeHistory(config.projectId);
|
|
134
151
|
res.json({ history });
|
package/src/snapshotManager.js
CHANGED
|
@@ -91,17 +91,18 @@ export async function logChange({ projectId, request, tier, filesChanged, before
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
/**
|
|
94
|
-
*
|
|
95
|
-
* Returns the
|
|
94
|
+
* Write the files captured in a snapshot back to disk. Pure file restore —
|
|
95
|
+
* no status bookkeeping, no deploy. Returns the restored file paths.
|
|
96
|
+
* Shared by the write-failure rollback and the user-facing toggle.
|
|
96
97
|
*/
|
|
97
|
-
|
|
98
|
-
const snapshot = await loadSnapshot(
|
|
98
|
+
async function restoreFilesFromSnapshot(token, config) {
|
|
99
|
+
const snapshot = await loadSnapshot(token);
|
|
99
100
|
|
|
100
101
|
const { applyChangePlan } = await import('./fileWriter.js');
|
|
101
|
-
const restoredFiles = Object.keys(snapshot.files);
|
|
102
|
+
const restoredFiles = Object.keys(snapshot.files || {});
|
|
102
103
|
|
|
103
|
-
const
|
|
104
|
-
originalRequest: `
|
|
104
|
+
const restorePlan = {
|
|
105
|
+
originalRequest: `Restore ${token}`,
|
|
105
106
|
tier: snapshot.risk_tier,
|
|
106
107
|
changes: restoredFiles.map(filepath => ({
|
|
107
108
|
filepath,
|
|
@@ -110,9 +111,71 @@ export async function rollbackToToken(beforeToken, config, broadcast) {
|
|
|
110
111
|
}))
|
|
111
112
|
};
|
|
112
113
|
|
|
113
|
-
await applyChangePlan(
|
|
114
|
+
await applyChangePlan(restorePlan, config);
|
|
115
|
+
return restoredFiles;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Flip a logged change's status (completed <-> rolled_back) so the History
|
|
120
|
+
* toggle survives reloads. Matched on the change's before/after token pair.
|
|
121
|
+
*/
|
|
122
|
+
async function setChangeStatus({ beforeToken, afterToken, status }) {
|
|
123
|
+
const supabase = getClient();
|
|
124
|
+
const { error } = await supabase
|
|
125
|
+
.from('seam_changes')
|
|
126
|
+
.update({ status })
|
|
127
|
+
.eq('before_token', beforeToken)
|
|
128
|
+
.eq('after_token', afterToken);
|
|
129
|
+
if (error) throw new Error(`setChangeStatus failed: ${error.message}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Full rollback: restore files from a before_token snapshot.
|
|
134
|
+
* Used on a failed write to undo partial changes. Returns restored paths.
|
|
135
|
+
*/
|
|
136
|
+
export async function rollbackToToken(beforeToken, config, broadcast) {
|
|
137
|
+
const restoredFiles = await restoreFilesFromSnapshot(beforeToken, config);
|
|
114
138
|
await markRolledBack(beforeToken);
|
|
115
139
|
|
|
116
140
|
if (broadcast) broadcast({ type: 'STATUS', status: 'rolled_back', message: `Rolled back to ${beforeToken.slice(0, 8)}...` });
|
|
141
|
+
|
|
142
|
+
// A rescind only matters if the live site reverts too. Publish the restored
|
|
143
|
+
// files (fire-and-forget — the deployer broadcasts its own publish events).
|
|
144
|
+
const { runDeploy } = await import('./deployer.js');
|
|
145
|
+
runDeploy(config, broadcast, { reason: 'rollback' }).catch(() => {});
|
|
146
|
+
|
|
117
147
|
return { restoredFiles, beforeToken };
|
|
118
148
|
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Toggle a single change on or off — the reversible on/off switch.
|
|
152
|
+
* direction 'undo' → restore the BEFORE snapshot (the change's pre-state)
|
|
153
|
+
* direction 'redo' → restore the AFTER snapshot (the change's post-state)
|
|
154
|
+
* Both directions re-publish the restored files to the live site and flip the
|
|
155
|
+
* logged change's status so the widget knows which way the toggle points. This
|
|
156
|
+
* is what makes Reverse reversible: every change stores both old and new file
|
|
157
|
+
* state, so flipping it off and back on are symmetric operations.
|
|
158
|
+
*/
|
|
159
|
+
export async function toggleChange({ beforeToken, afterToken, direction, config, broadcast }) {
|
|
160
|
+
const undo = direction !== 'redo';
|
|
161
|
+
const token = undo ? beforeToken : afterToken;
|
|
162
|
+
if (!token) throw new Error(`Cannot ${undo ? 'reverse' : 'restore'} — this change has no ${undo ? 'before' : 'after'} snapshot.`);
|
|
163
|
+
|
|
164
|
+
const restoredFiles = await restoreFilesFromSnapshot(token, config);
|
|
165
|
+
|
|
166
|
+
// Best-effort: flip the logged status so the History button toggles. The
|
|
167
|
+
// files are already restored, so a status-write failure (e.g. a missing
|
|
168
|
+
// "anon update changes" RLS policy) must not fail the whole reverse.
|
|
169
|
+
await setChangeStatus({ beforeToken, afterToken, status: undo ? 'rolled_back' : 'completed' }).catch(() => {});
|
|
170
|
+
|
|
171
|
+
if (broadcast) broadcast({
|
|
172
|
+
type: 'STATUS',
|
|
173
|
+
status: undo ? 'rolled_back' : 'restored',
|
|
174
|
+
message: undo ? `Reversed ${beforeToken.slice(0, 8)}…` : `Restored ${afterToken.slice(0, 8)}…`
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const { runDeploy } = await import('./deployer.js');
|
|
178
|
+
runDeploy(config, broadcast, { reason: direction }).catch(() => {});
|
|
179
|
+
|
|
180
|
+
return { restoredFiles, direction };
|
|
181
|
+
}
|