@seamapp/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/migrations/001_seam_init.sql +31 -0
- package/migrations/002_seam_tickets.sql +15 -0
- package/package.json +32 -0
- package/src/attachmentProcessor.js +133 -0
- package/src/fileWriter.js +121 -0
- package/src/requestHandler.js +292 -0
- package/src/riskClassifier.js +29 -0
- package/src/server.js +153 -0
- package/src/snapshotManager.js +118 -0
- package/src/tokenLogger.js +37 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
-- Seam v1 schema
|
|
2
|
+
-- Run in Supabase SQL editor or via `npx seam migrate`
|
|
3
|
+
|
|
4
|
+
create table if not exists seam_snapshots (
|
|
5
|
+
id uuid primary key default gen_random_uuid(),
|
|
6
|
+
session_token text not null unique,
|
|
7
|
+
project_id text not null,
|
|
8
|
+
created_at timestamptz default now(),
|
|
9
|
+
files jsonb not null,
|
|
10
|
+
change_request text,
|
|
11
|
+
risk_tier text check (risk_tier in ('A', 'B', 'C')),
|
|
12
|
+
status text check (status in ('before', 'after', 'rolled_back')),
|
|
13
|
+
parent_token text
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
create index if not exists seam_snapshots_project_idx on seam_snapshots (project_id, created_at desc);
|
|
17
|
+
create index if not exists seam_snapshots_token_idx on seam_snapshots (session_token);
|
|
18
|
+
|
|
19
|
+
create table if not exists seam_changes (
|
|
20
|
+
id uuid primary key default gen_random_uuid(),
|
|
21
|
+
project_id text not null,
|
|
22
|
+
created_at timestamptz default now(),
|
|
23
|
+
request text not null,
|
|
24
|
+
risk_tier text,
|
|
25
|
+
files_changed text[],
|
|
26
|
+
before_token text references seam_snapshots(session_token),
|
|
27
|
+
after_token text references seam_snapshots(session_token),
|
|
28
|
+
status text check (status in ('completed', 'rolled_back', 'failed'))
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
create index if not exists seam_changes_project_idx on seam_changes (project_id, created_at desc);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
-- Seam v1 — visitor ticket table
|
|
2
|
+
-- Visitors submit feedback/bugs via SeamTicketWidget (production mode)
|
|
3
|
+
-- Dashboard reads from this table under the Tickets tab
|
|
4
|
+
|
|
5
|
+
create table if not exists seam_tickets (
|
|
6
|
+
id uuid primary key default gen_random_uuid(),
|
|
7
|
+
project_id text not null,
|
|
8
|
+
created_at timestamptz default now(),
|
|
9
|
+
description text not null,
|
|
10
|
+
page_url text,
|
|
11
|
+
status text check (status in ('open', 'in_progress', 'resolved')) default 'open'
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
create index if not exists seam_tickets_project_idx on seam_tickets (project_id, created_at desc);
|
|
15
|
+
create index if not exists seam_tickets_status_idx on seam_tickets (status, created_at desc);
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@seamapp/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./src/server.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"src/",
|
|
8
|
+
"migrations/"
|
|
9
|
+
],
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=20.0.0"
|
|
12
|
+
},
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"start": "node src/server.js",
|
|
18
|
+
"test": "vitest run"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@anthropic-ai/sdk": "^0.39.0",
|
|
22
|
+
"@seamapp/graphify-bridge": "*",
|
|
23
|
+
"@supabase/supabase-js": "^2.43.0",
|
|
24
|
+
"cors": "^2.8.5",
|
|
25
|
+
"diff": "^5.2.0",
|
|
26
|
+
"dotenv": "^16.4.5",
|
|
27
|
+
"express": "^4.19.2",
|
|
28
|
+
"fs-extra": "^11.2.0",
|
|
29
|
+
"multer": "^1.4.5-lts.1",
|
|
30
|
+
"ws": "^8.17.1"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seam Attachment Processor
|
|
3
|
+
* Categorizes uploaded files and prepares them for the change pipeline
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from 'fs-extra';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
|
|
9
|
+
const IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
|
|
10
|
+
const VIDEO_TYPES = ['video/mp4', 'video/mov', 'video/quicktime', 'video/webm'];
|
|
11
|
+
const DOCUMENT_TYPES = ['application/pdf', 'text/plain', 'text/markdown'];
|
|
12
|
+
const CODE_EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js', '.css', '.scss', '.json', '.md', '.html', '.py', '.sh'];
|
|
13
|
+
|
|
14
|
+
export async function processAttachments(rawFiles, config) {
|
|
15
|
+
const processed = [];
|
|
16
|
+
|
|
17
|
+
for (const file of rawFiles) {
|
|
18
|
+
const ext = path.extname(file.originalname).toLowerCase();
|
|
19
|
+
const mime = file.mimetype;
|
|
20
|
+
|
|
21
|
+
if (IMAGE_TYPES.includes(mime)) {
|
|
22
|
+
// Images = vision context by default (reference screenshots, mockups, logos)
|
|
23
|
+
// UI allows user to override to 'asset_drop' before submitting
|
|
24
|
+
const base64 = await fs.readFile(file.path, 'base64');
|
|
25
|
+
processed.push({
|
|
26
|
+
role: 'vision',
|
|
27
|
+
filename: file.originalname,
|
|
28
|
+
mediaType: mime,
|
|
29
|
+
base64,
|
|
30
|
+
size: file.size,
|
|
31
|
+
tempPath: file.path
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
} else if (VIDEO_TYPES.includes(mime)) {
|
|
35
|
+
// Videos = always asset drops (too large for Claude context)
|
|
36
|
+
processed.push({
|
|
37
|
+
role: 'asset',
|
|
38
|
+
filename: file.originalname,
|
|
39
|
+
mediaType: mime,
|
|
40
|
+
tempPath: file.path,
|
|
41
|
+
size: file.size,
|
|
42
|
+
suggestedPath: `/public/videos/${file.originalname}`
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
} else if (DOCUMENT_TYPES.includes(mime)) {
|
|
46
|
+
// Documents = text extracted and added as Claude context
|
|
47
|
+
let textContent = '';
|
|
48
|
+
if (mime === 'text/plain' || mime === 'text/markdown') {
|
|
49
|
+
textContent = await fs.readFile(file.path, 'utf8');
|
|
50
|
+
} else if (mime === 'application/pdf') {
|
|
51
|
+
// PDF text extraction placeholder — implement with pdf-parse in full build
|
|
52
|
+
textContent = '[PDF content — extract with pdf-parse in full build]';
|
|
53
|
+
}
|
|
54
|
+
processed.push({
|
|
55
|
+
role: 'context',
|
|
56
|
+
filename: file.originalname,
|
|
57
|
+
textContent,
|
|
58
|
+
size: file.size,
|
|
59
|
+
tempPath: file.path
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
} else if (CODE_EXTENSIONS.includes(ext)) {
|
|
63
|
+
// Code/config files = code drop — placed directly in the project
|
|
64
|
+
const textContent = await fs.readFile(file.path, 'utf8');
|
|
65
|
+
processed.push({
|
|
66
|
+
role: 'code_drop',
|
|
67
|
+
filename: file.originalname,
|
|
68
|
+
textContent,
|
|
69
|
+
size: file.size,
|
|
70
|
+
tempPath: file.path,
|
|
71
|
+
suggestedPath: inferCodeDropPath(file.originalname, config)
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
} else {
|
|
75
|
+
// Unknown type — treat as generic asset
|
|
76
|
+
processed.push({
|
|
77
|
+
role: 'asset',
|
|
78
|
+
filename: file.originalname,
|
|
79
|
+
mediaType: mime,
|
|
80
|
+
tempPath: file.path,
|
|
81
|
+
size: file.size,
|
|
82
|
+
suggestedPath: `/public/${file.originalname}`
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return processed;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Suggests a destination path for code drop files based on extension
|
|
92
|
+
*/
|
|
93
|
+
function inferCodeDropPath(filename, config) {
|
|
94
|
+
const ext = path.extname(filename).toLowerCase();
|
|
95
|
+
const root = config.projectRoot || './';
|
|
96
|
+
|
|
97
|
+
const pathMap = {
|
|
98
|
+
'.tsx': `${root}src/components/${filename}`,
|
|
99
|
+
'.ts': `${root}src/lib/${filename}`,
|
|
100
|
+
'.jsx': `${root}src/components/${filename}`,
|
|
101
|
+
'.js': `${root}src/lib/${filename}`,
|
|
102
|
+
'.css': `${root}src/styles/${filename}`,
|
|
103
|
+
'.scss': `${root}src/styles/${filename}`,
|
|
104
|
+
'.json': `${root}${filename}`,
|
|
105
|
+
'.md': `${root}docs/${filename}`,
|
|
106
|
+
'.py': `${root}scripts/${filename}`,
|
|
107
|
+
'.sh': `${root}scripts/${filename}`,
|
|
108
|
+
'.html': `${root}public/${filename}`
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
return pathMap[ext] || `${root}${filename}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Prepares asset drop confirmation payload for the UI
|
|
116
|
+
* UI shows this to the user before writing
|
|
117
|
+
*/
|
|
118
|
+
export function buildAssetDropConfirmation(attachment) {
|
|
119
|
+
return {
|
|
120
|
+
filename: attachment.filename,
|
|
121
|
+
size: formatBytes(attachment.size),
|
|
122
|
+
suggestedPath: attachment.suggestedPath,
|
|
123
|
+
role: attachment.role,
|
|
124
|
+
requiresConfirmation: true,
|
|
125
|
+
message: `This file will be written to ${attachment.suggestedPath}. Confirm placement?`
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function formatBytes(bytes) {
|
|
130
|
+
if (bytes < 1024) return bytes + ' B';
|
|
131
|
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
132
|
+
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
133
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { createPatch } from 'diff';
|
|
4
|
+
|
|
5
|
+
const FORBIDDEN_PATTERNS = [
|
|
6
|
+
/^node_modules(\/|\\|$)/,
|
|
7
|
+
/^\.git(\/|\\|$)/,
|
|
8
|
+
/^\.env/,
|
|
9
|
+
/^seam\.config\.json$/,
|
|
10
|
+
/(\/|\\)(node_modules|\.git)(\/|\\|$)/
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export function validateWritePath(filepath, projectRoot) {
|
|
14
|
+
// Normalize to absolute
|
|
15
|
+
const abs = path.resolve(projectRoot, filepath);
|
|
16
|
+
const root = path.resolve(projectRoot);
|
|
17
|
+
|
|
18
|
+
// Ensure the resolved path stays inside the project root
|
|
19
|
+
const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;
|
|
20
|
+
if (!abs.startsWith(rootWithSep) && abs !== root) {
|
|
21
|
+
throw new Error(`Path "${filepath}" is outside project root — write forbidden`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Check against forbidden patterns using forward-slash normalized relative path
|
|
25
|
+
const rel = path.relative(root, abs).replace(/\\/g, '/');
|
|
26
|
+
if (FORBIDDEN_PATTERNS.some(p => p.test(rel))) {
|
|
27
|
+
throw new Error(`Path "${filepath}" matches a forbidden pattern — write forbidden`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function applyPatchToContent(originalContent, originalSnippet, replacementSnippet) {
|
|
32
|
+
if (!originalContent.includes(originalSnippet)) {
|
|
33
|
+
const preview = originalSnippet.length > 60 ? originalSnippet.slice(0, 60) + '...' : originalSnippet;
|
|
34
|
+
throw new Error(`Original snippet not found in file: "${preview}"`);
|
|
35
|
+
}
|
|
36
|
+
const updated = originalContent.replace(originalSnippet, replacementSnippet);
|
|
37
|
+
if (updated.includes(originalSnippet)) {
|
|
38
|
+
const preview = originalSnippet.length > 60 ? originalSnippet.slice(0, 60) + '...' : originalSnippet;
|
|
39
|
+
throw new Error(`Original snippet appears more than once — cannot apply change unambiguously: "${preview}"`);
|
|
40
|
+
}
|
|
41
|
+
return updated;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function buildDiff(filepath, before, after) {
|
|
45
|
+
return createPatch(filepath, before, after, 'before', 'after');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Apply a change plan to disk.
|
|
50
|
+
* For 'restore' type (rollback), replacementSnippet is the full file content.
|
|
51
|
+
* Returns the list of written file paths.
|
|
52
|
+
* Throws on any write failure — caller must rollback already-written files.
|
|
53
|
+
*/
|
|
54
|
+
export async function applyChangePlan(changePlan, config, opts = {}) {
|
|
55
|
+
const projectRoot = path.resolve(process.cwd(), config.projectRoot || '.');
|
|
56
|
+
const writtenFiles = [];
|
|
57
|
+
|
|
58
|
+
for (const change of changePlan.changes) {
|
|
59
|
+
validateWritePath(change.filepath, projectRoot);
|
|
60
|
+
const absPath = path.resolve(projectRoot, change.filepath);
|
|
61
|
+
|
|
62
|
+
if (change.type === 'create' || change.type === 'restore') {
|
|
63
|
+
await fs.outputFile(absPath, change.replacementSnippet, 'utf8');
|
|
64
|
+
|
|
65
|
+
} else if (change.type === 'modify') {
|
|
66
|
+
const existing = await fs.readFile(absPath, 'utf8');
|
|
67
|
+
const updated = applyPatchToContent(existing, change.originalSnippet, change.replacementSnippet);
|
|
68
|
+
await fs.writeFile(absPath, updated, 'utf8');
|
|
69
|
+
|
|
70
|
+
} else if (change.type === 'asset_place') {
|
|
71
|
+
validateWritePath(change.destinationPath, projectRoot);
|
|
72
|
+
await fs.outputFile(path.resolve(projectRoot, change.destinationPath), change.replacementSnippet);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
writtenFiles.push(change.type === 'asset_place' ? change.destinationPath : change.filepath);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return writtenFiles;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Build a dry-run preview: diff for each modified file.
|
|
83
|
+
* Does NOT write anything. Returns array of { filepath, diff } objects.
|
|
84
|
+
*/
|
|
85
|
+
export async function buildDryRunPreview(changePlan, config) {
|
|
86
|
+
const projectRoot = path.resolve(process.cwd(), config.projectRoot || '.');
|
|
87
|
+
const previews = [];
|
|
88
|
+
|
|
89
|
+
for (const change of changePlan.changes) {
|
|
90
|
+
if (change.type === 'modify') {
|
|
91
|
+
const absPath = path.resolve(projectRoot, change.filepath);
|
|
92
|
+
const existing = await fs.readFile(absPath, 'utf8').catch(() => null);
|
|
93
|
+
if (existing === null) {
|
|
94
|
+
previews.push({ filepath: change.filepath, diff: `[file not found — will be created or skipped]` });
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const updated = applyPatchToContent(existing, change.originalSnippet, change.replacementSnippet);
|
|
98
|
+
previews.push({ filepath: change.filepath, diff: buildDiff(change.filepath, existing, updated) });
|
|
99
|
+
} else {
|
|
100
|
+
previews.push({ filepath: change.filepath, diff: `[${change.type}] ${change.destinationPath || change.filepath}` });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return previews;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Read only the files referenced by graph nodes — returns { filepath: content }.
|
|
109
|
+
* Hard-capped at 10 files per COST_AND_SAFETY.md rules.
|
|
110
|
+
*/
|
|
111
|
+
export async function readRelevantFiles(graphNodes, projectRoot) {
|
|
112
|
+
const files = {};
|
|
113
|
+
const nodes = graphNodes.slice(0, 10);
|
|
114
|
+
for (const node of nodes) {
|
|
115
|
+
const absPath = path.resolve(projectRoot || process.cwd(), node.file);
|
|
116
|
+
if (await fs.pathExists(absPath)) {
|
|
117
|
+
files[node.file] = await fs.readFile(absPath, 'utf8');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return files;
|
|
121
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
+
import { queryGraph, getBlastRadius } from '../../graphify-bridge/src/index.js';
|
|
3
|
+
import { classifyRisk } from './riskClassifier.js';
|
|
4
|
+
import { createSnapshot, logChange, rollbackToToken } from './snapshotManager.js';
|
|
5
|
+
import { applyChangePlan, buildDryRunPreview, readRelevantFiles } from './fileWriter.js';
|
|
6
|
+
import { processAttachments } from './attachmentProcessor.js';
|
|
7
|
+
import { logTokenUsage } from './tokenLogger.js';
|
|
8
|
+
|
|
9
|
+
const client = new Anthropic({ apiKey: process.env.SEAM_ANTHROPIC_KEY });
|
|
10
|
+
|
|
11
|
+
const pendingPlans = new Map();
|
|
12
|
+
|
|
13
|
+
function timeout(ms, label) {
|
|
14
|
+
return new Promise((_, reject) =>
|
|
15
|
+
setTimeout(() => reject(new Error(`TIMEOUT: ${label} exceeded ${ms}ms`)), ms)
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function handleChangeRequest({ request, attachments, config, broadcast }) {
|
|
20
|
+
const requestId = crypto.randomUUID();
|
|
21
|
+
|
|
22
|
+
// 60-second hard wall for entire request
|
|
23
|
+
try {
|
|
24
|
+
await Promise.race([
|
|
25
|
+
_handleChangeRequest({ requestId, request, attachments, config, broadcast }),
|
|
26
|
+
timeout(60_000, 'Total request wall time')
|
|
27
|
+
]);
|
|
28
|
+
} catch (err) {
|
|
29
|
+
broadcast({ type: 'FAILED', requestId, message: err.message });
|
|
30
|
+
pendingPlans.delete(requestId);
|
|
31
|
+
pendingPlans.delete(requestId + '-apply');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function _handleChangeRequest({ requestId, request, attachments, config, broadcast }) {
|
|
36
|
+
if (request.length > 2000) {
|
|
37
|
+
broadcast({ type: 'FAILED', requestId, message: 'Request text is too long (max 2000 characters). Please be more concise.' });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
broadcast({ type: 'STATUS', status: 'analyzing', requestId, message: 'Reading the graph...' });
|
|
42
|
+
|
|
43
|
+
const processedAttachments = await processAttachments(attachments, config);
|
|
44
|
+
|
|
45
|
+
// Graph query — 5s timeout per COST_AND_SAFETY.md
|
|
46
|
+
const graphNodes = await Promise.race([
|
|
47
|
+
queryGraph(request, config.graphifyOut),
|
|
48
|
+
timeout(5_000, 'Graph query')
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
if (!graphNodes.length) {
|
|
52
|
+
broadcast({
|
|
53
|
+
type: 'FAILED', requestId,
|
|
54
|
+
message: "Seam couldn't find the relevant files in your codebase graph. Try rebuilding the graph (Settings → Rebuild Graph) or be more specific about what you want to change."
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const blastRadius = getBlastRadius(graphNodes);
|
|
60
|
+
const tier = classifyRisk(request, graphNodes, blastRadius);
|
|
61
|
+
|
|
62
|
+
broadcast({
|
|
63
|
+
type: 'RISK_ASSESSMENT', requestId, tier, blastRadius,
|
|
64
|
+
affectedFiles: graphNodes.map(n => n.file),
|
|
65
|
+
message: getRiskMessage(tier, blastRadius)
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (tier === 'B' || tier === 'C') {
|
|
69
|
+
pendingPlans.set(requestId, {
|
|
70
|
+
request, graphNodes, blastRadius, tier,
|
|
71
|
+
processedAttachments, config,
|
|
72
|
+
state: 'awaiting_confirmation'
|
|
73
|
+
});
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
await executePlan({ requestId, request, graphNodes, processedAttachments, tier, config, broadcast });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function executeConfirmedPlan(planId, broadcast) {
|
|
81
|
+
try {
|
|
82
|
+
await Promise.race([
|
|
83
|
+
_executeConfirmedPlan(planId, broadcast),
|
|
84
|
+
timeout(60_000, 'executeConfirmedPlan wall time')
|
|
85
|
+
]);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
broadcast({ type: 'FAILED', message: err.message });
|
|
88
|
+
pendingPlans.delete(planId);
|
|
89
|
+
pendingPlans.delete(planId + '-apply');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function _executeConfirmedPlan(planId, broadcast) {
|
|
94
|
+
const pending = pendingPlans.get(planId);
|
|
95
|
+
if (!pending) throw new Error('Plan not found or already executed');
|
|
96
|
+
pendingPlans.delete(planId);
|
|
97
|
+
await executePlan({
|
|
98
|
+
requestId: planId,
|
|
99
|
+
request: pending.request,
|
|
100
|
+
graphNodes: pending.graphNodes,
|
|
101
|
+
processedAttachments: pending.processedAttachments,
|
|
102
|
+
tier: pending.tier,
|
|
103
|
+
config: pending.config,
|
|
104
|
+
broadcast
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function executePlan({ requestId, request, graphNodes, processedAttachments, tier, config, broadcast }) {
|
|
109
|
+
broadcast({ type: 'STATUS', status: 'planning', requestId, message: 'Planning changes...' });
|
|
110
|
+
|
|
111
|
+
const fileContents = await readRelevantFiles(graphNodes, config.projectRoot);
|
|
112
|
+
|
|
113
|
+
// One Claude call — 30s timeout per COST_AND_SAFETY.md
|
|
114
|
+
let claudeResponse;
|
|
115
|
+
try {
|
|
116
|
+
claudeResponse = await Promise.race([
|
|
117
|
+
callClaude({ request, fileContents, processedAttachments }),
|
|
118
|
+
timeout(30_000, 'Claude response')
|
|
119
|
+
]);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
throw new Error(`Claude didn't respond in time. Try simplifying your request or try again in a moment.`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const { changePlan, usage } = claudeResponse;
|
|
125
|
+
|
|
126
|
+
// Log tokens immediately
|
|
127
|
+
await logTokenUsage({
|
|
128
|
+
requestId, request, tier,
|
|
129
|
+
inputTokens: usage.input_tokens,
|
|
130
|
+
outputTokens: usage.output_tokens,
|
|
131
|
+
status: 'planned'
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Dry-run preview
|
|
135
|
+
const previews = await buildDryRunPreview(changePlan, config);
|
|
136
|
+
broadcast({ type: 'DRY_RUN', requestId, changePlan, previews, message: 'Preview ready — review and apply' });
|
|
137
|
+
|
|
138
|
+
if (tier !== 'A') {
|
|
139
|
+
pendingPlans.set(requestId + '-apply', { changePlan, fileContents, config, tier, request });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
await _applyApprovedPlan({ requestId, changePlan, fileContents, tier, request, config, broadcast });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function applyApprovedPlan({ requestId, broadcast }) {
|
|
147
|
+
try {
|
|
148
|
+
await Promise.race([
|
|
149
|
+
_applyApprovedPlanOuter({ requestId, broadcast }),
|
|
150
|
+
timeout(60_000, 'applyApprovedPlan wall time')
|
|
151
|
+
]);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
broadcast({ type: 'FAILED', message: err.message });
|
|
154
|
+
pendingPlans.delete(requestId + '-apply');
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function _applyApprovedPlanOuter({ requestId, broadcast }) {
|
|
159
|
+
const key = requestId + '-apply';
|
|
160
|
+
const pending = pendingPlans.get(key);
|
|
161
|
+
if (!pending) throw new Error(`No pending plan for requestId ${requestId}`);
|
|
162
|
+
pendingPlans.delete(key);
|
|
163
|
+
await _applyApprovedPlan({ requestId, ...pending, broadcast });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function _applyApprovedPlan({ requestId, changePlan, fileContents, tier, request, config, broadcast }) {
|
|
167
|
+
broadcast({ type: 'STATUS', status: 'snapshotting', requestId, message: 'Saving snapshot...' });
|
|
168
|
+
|
|
169
|
+
// Circuit breaker: snapshot must succeed before any write
|
|
170
|
+
let beforeToken;
|
|
171
|
+
try {
|
|
172
|
+
beforeToken = await createSnapshot({
|
|
173
|
+
changePlan, status: 'before', projectId: config.projectId,
|
|
174
|
+
request, tier, fileContents
|
|
175
|
+
});
|
|
176
|
+
} catch (err) {
|
|
177
|
+
broadcast({
|
|
178
|
+
type: 'FAILED', requestId,
|
|
179
|
+
message: "Couldn't save a snapshot — Seam stopped before making any changes to protect your codebase. Check your Supabase connection."
|
|
180
|
+
});
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
broadcast({ type: 'STATUS', status: 'writing', requestId, message: 'Applying changes...' });
|
|
185
|
+
|
|
186
|
+
let writtenFiles;
|
|
187
|
+
try {
|
|
188
|
+
writtenFiles = await applyChangePlan(changePlan, config);
|
|
189
|
+
} catch (err) {
|
|
190
|
+
// Write failed — rollback any partial writes using the before snapshot
|
|
191
|
+
await rollbackToToken(beforeToken, config, broadcast);
|
|
192
|
+
broadcast({
|
|
193
|
+
type: 'FAILED', requestId,
|
|
194
|
+
message: `Write failed on ${err.filepath || 'a file'}. All changes from this request have been rolled back. Your codebase is unchanged.`
|
|
195
|
+
});
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const afterToken = await createSnapshot({
|
|
200
|
+
changePlan, status: 'after', projectId: config.projectId,
|
|
201
|
+
request, tier, fileContents: {}, parentToken: beforeToken
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
await logChange({
|
|
205
|
+
projectId: config.projectId, request, tier,
|
|
206
|
+
filesChanged: writtenFiles, beforeToken, afterToken, status: 'completed'
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
broadcast({ type: 'COMPLETE', requestId, beforeToken, afterToken, filesChanged: writtenFiles, message: 'Done' });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function callClaude({ request, fileContents, processedAttachments }) {
|
|
213
|
+
const systemPrompt = `You are Seam, a senior software engineer making precise, minimal changes to a codebase.
|
|
214
|
+
You will receive:
|
|
215
|
+
- The user's change request
|
|
216
|
+
- The contents of only the relevant files (identified by a knowledge graph)
|
|
217
|
+
- Any attachments the user has provided
|
|
218
|
+
|
|
219
|
+
Your job is to return a JSON change plan ONLY — no explanation, no markdown, no preamble.
|
|
220
|
+
|
|
221
|
+
The JSON must be this exact shape:
|
|
222
|
+
{
|
|
223
|
+
"originalRequest": "the user's request verbatim",
|
|
224
|
+
"summary": "one sentence: what this change does",
|
|
225
|
+
"changes": [
|
|
226
|
+
{
|
|
227
|
+
"filepath": "relative/path/to/file.tsx",
|
|
228
|
+
"type": "modify | create | asset_place",
|
|
229
|
+
"originalSnippet": "exact text to find and replace (for modify)",
|
|
230
|
+
"replacementSnippet": "the new text",
|
|
231
|
+
"destinationPath": "for asset_place type only"
|
|
232
|
+
}
|
|
233
|
+
]
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
Rules:
|
|
237
|
+
- Make the MINIMUM change that satisfies the request
|
|
238
|
+
- Do not refactor unrelated code
|
|
239
|
+
- Do not add features not asked for
|
|
240
|
+
- originalSnippet must be unique enough to find exactly once in the file`;
|
|
241
|
+
|
|
242
|
+
const messageContent = [];
|
|
243
|
+
|
|
244
|
+
for (const att of processedAttachments.filter(a => a.role === 'vision')) {
|
|
245
|
+
messageContent.push({ type: 'image', source: { type: 'base64', media_type: att.mediaType, data: att.base64 } });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const docContext = processedAttachments
|
|
249
|
+
.filter(a => a.role === 'context')
|
|
250
|
+
.map(a => `--- Attached document: ${a.filename} ---\n${a.textContent}`)
|
|
251
|
+
.join('\n\n');
|
|
252
|
+
|
|
253
|
+
const userMessage = [
|
|
254
|
+
`Change request: ${request}`,
|
|
255
|
+
docContext ? `\nAttached context:\n${docContext}` : '',
|
|
256
|
+
`\nRelevant files:\n${JSON.stringify(fileContents, null, 2)}`
|
|
257
|
+
].filter(Boolean).join('\n');
|
|
258
|
+
|
|
259
|
+
messageContent.push({ type: 'text', text: userMessage });
|
|
260
|
+
|
|
261
|
+
const response = await client.messages.create({
|
|
262
|
+
model: 'claude-sonnet-4-20250514',
|
|
263
|
+
max_tokens: 4096,
|
|
264
|
+
system: systemPrompt,
|
|
265
|
+
messages: [{ role: 'user', content: messageContent }]
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const raw = response.content[0].text;
|
|
269
|
+
let changePlan;
|
|
270
|
+
try {
|
|
271
|
+
changePlan = JSON.parse(raw.replace(/```json|```/g, '').trim());
|
|
272
|
+
} catch {
|
|
273
|
+
throw new Error('Seam received an unexpected response and stopped to be safe. No files were changed.');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (!Array.isArray(changePlan?.changes)) {
|
|
277
|
+
throw new Error('Claude returned a malformed change plan. No files were changed.');
|
|
278
|
+
}
|
|
279
|
+
for (const change of changePlan.changes) {
|
|
280
|
+
if (typeof change.filepath !== 'string' || change.filepath.startsWith('/') || change.filepath.includes('..')) {
|
|
281
|
+
throw new Error(`Claude returned an unsafe file path: "${change.filepath}". No files were changed.`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return { changePlan, usage: response.usage };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function getRiskMessage(tier, blastRadius) {
|
|
289
|
+
if (tier === 'A') return 'Safe change — ready to apply.';
|
|
290
|
+
if (tier === 'B') return `Logic change — affects ${blastRadius.affectedNodes} file(s) across ${blastRadius.communities} section(s). Review before applying.`;
|
|
291
|
+
return `High risk — touches ${blastRadius.godNodes.length || 'core'} critical module(s) across ${blastRadius.communities} section(s). Read carefully.`;
|
|
292
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const TIER_C_KEYWORDS = ['auth', 'authenticat', 'login', 'schema', 'database', 'db', 'migration',
|
|
2
|
+
'routing', 'middleware', 'env', 'environment', 'config', 'layout', 'root', 'core'];
|
|
3
|
+
const TIER_B_KEYWORDS = ['delete', 'remove', 'drop'];
|
|
4
|
+
|
|
5
|
+
export function classifyRisk(request, nodes, blastRadius) {
|
|
6
|
+
const req = request.toLowerCase();
|
|
7
|
+
|
|
8
|
+
// Tier C: semantic keywords — word-boundary match to avoid "debug"→"db", "score"→"core"
|
|
9
|
+
const tierCRe = new RegExp(`\\b(${TIER_C_KEYWORDS.join('|')})\\b`);
|
|
10
|
+
if (tierCRe.test(req)) return 'C';
|
|
11
|
+
|
|
12
|
+
// Tier C: structural signals
|
|
13
|
+
if (blastRadius.godNodes.length > 0) return 'C';
|
|
14
|
+
if (blastRadius.communities >= 3) return 'C';
|
|
15
|
+
if (blastRadius.affectedNodes >= 9) return 'C';
|
|
16
|
+
if (blastRadius.maxInboundEdges >= 11) return 'C';
|
|
17
|
+
|
|
18
|
+
// Tier B: semantic keywords — word-boundary match
|
|
19
|
+
const tierBRe = new RegExp(`\\b(${TIER_B_KEYWORDS.join('|')})\\b`);
|
|
20
|
+
if (tierBRe.test(req)) return 'B';
|
|
21
|
+
|
|
22
|
+
// Tier B: structural signals
|
|
23
|
+
if (blastRadius.affectedNodes >= 2) return 'B';
|
|
24
|
+
if (blastRadius.communities >= 2) return 'B';
|
|
25
|
+
if (blastRadius.maxInboundEdges >= 3) return 'B';
|
|
26
|
+
|
|
27
|
+
// Tier A: everything else
|
|
28
|
+
return 'A';
|
|
29
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import dotenv from 'dotenv';
|
|
2
|
+
dotenv.config({ path: '.env.seam' });
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import cors from 'cors';
|
|
5
|
+
import { WebSocketServer } from 'ws';
|
|
6
|
+
import { createServer } from 'http';
|
|
7
|
+
import multer from 'multer';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import fs from 'fs-extra';
|
|
10
|
+
import { fileURLToPath } from 'url';
|
|
11
|
+
|
|
12
|
+
import { handleChangeRequest, executeConfirmedPlan, applyApprovedPlan } from './requestHandler.js';
|
|
13
|
+
import { rollbackToToken, getChangeHistory } from './snapshotManager.js';
|
|
14
|
+
import { rebuildGraph } from '../../graphify-bridge/src/index.js';
|
|
15
|
+
|
|
16
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
|
|
18
|
+
if (process.env.NODE_ENV === 'production') {
|
|
19
|
+
console.error('[Seam] ❌ Seam will not run in production. Exiting.');
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const configPath = path.join(process.cwd(), 'seam.config.json');
|
|
24
|
+
if (!await fs.pathExists(configPath)) {
|
|
25
|
+
console.error('[Seam] ❌ seam.config.json not found. Run npx seam init first.');
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const config = await fs.readJson(configPath);
|
|
30
|
+
const PORT = config.seamServer?.port || 7337;
|
|
31
|
+
|
|
32
|
+
const app = express();
|
|
33
|
+
const allowedOrigins = config.seamServer?.allowedOrigins ?? [
|
|
34
|
+
'http://localhost:3000',
|
|
35
|
+
'http://localhost:5173',
|
|
36
|
+
'http://127.0.0.1:3000',
|
|
37
|
+
'http://127.0.0.1:5173',
|
|
38
|
+
];
|
|
39
|
+
app.use(cors({ origin: allowedOrigins }));
|
|
40
|
+
app.use(express.json({ limit: '50mb' }));
|
|
41
|
+
|
|
42
|
+
const upload = multer({
|
|
43
|
+
dest: path.join(process.cwd(), '.seam/uploads/'),
|
|
44
|
+
limits: { fileSize: 25 * 1024 * 1024 }
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const server = createServer(app);
|
|
48
|
+
const wss = new WebSocketServer({ server });
|
|
49
|
+
const clients = new Set();
|
|
50
|
+
|
|
51
|
+
wss.on('connection', (ws) => {
|
|
52
|
+
clients.add(ws);
|
|
53
|
+
ws.on('close', () => clients.delete(ws));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export function broadcast(event) {
|
|
57
|
+
const msg = JSON.stringify(event);
|
|
58
|
+
clients.forEach(ws => { if (ws.readyState === 1) ws.send(msg); });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
app.get('/api/health', (req, res) => {
|
|
62
|
+
res.json({ status: 'ok', version: '0.1.0', project: config.projectId });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// One active request at a time — reject concurrent requests
|
|
66
|
+
let activeRequest = false;
|
|
67
|
+
|
|
68
|
+
app.post('/api/request', upload.array('attachments', 10), async (req, res) => {
|
|
69
|
+
if (activeRequest) {
|
|
70
|
+
return res.status(409).json({
|
|
71
|
+
error: 'A change is already in progress. Wait for it to complete or fail.'
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const { request } = req.body;
|
|
75
|
+
const attachments = req.files || [];
|
|
76
|
+
if (!request?.trim()) return res.status(400).json({ error: 'Request text is required' });
|
|
77
|
+
|
|
78
|
+
activeRequest = true;
|
|
79
|
+
res.json({ status: 'accepted', message: 'Request received — analyzing...' });
|
|
80
|
+
try {
|
|
81
|
+
await handleChangeRequest({ request: request.trim(), attachments, config, broadcast });
|
|
82
|
+
} finally {
|
|
83
|
+
activeRequest = false;
|
|
84
|
+
// Clean up multer temp files
|
|
85
|
+
for (const file of attachments) {
|
|
86
|
+
fs.unlink(file.path).catch(() => {});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
app.post('/api/apply', async (req, res) => {
|
|
92
|
+
const { planId } = req.body;
|
|
93
|
+
if (!planId) return res.status(400).json({ error: 'planId required' });
|
|
94
|
+
if (activeRequest) return res.status(409).json({ error: 'A change is already in progress.' });
|
|
95
|
+
activeRequest = true;
|
|
96
|
+
res.json({ status: 'applying' });
|
|
97
|
+
try {
|
|
98
|
+
await executeConfirmedPlan(planId, broadcast);
|
|
99
|
+
} finally {
|
|
100
|
+
activeRequest = false;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
app.post('/api/apply-plan', async (req, res) => {
|
|
105
|
+
const { requestId } = req.body;
|
|
106
|
+
if (!requestId) return res.status(400).json({ error: 'requestId required' });
|
|
107
|
+
if (activeRequest) return res.status(409).json({ error: 'A change is already in progress.' });
|
|
108
|
+
activeRequest = true;
|
|
109
|
+
res.json({ status: 'applying' });
|
|
110
|
+
try {
|
|
111
|
+
await applyApprovedPlan({ requestId, broadcast });
|
|
112
|
+
} finally {
|
|
113
|
+
activeRequest = false;
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
app.post('/api/rollback', async (req, res) => {
|
|
118
|
+
const { beforeToken } = req.body;
|
|
119
|
+
if (!beforeToken) return res.status(400).json({ error: 'beforeToken required' });
|
|
120
|
+
if (activeRequest) return res.status(409).json({ error: 'A change is already in progress.' });
|
|
121
|
+
activeRequest = true;
|
|
122
|
+
res.json({ status: 'rolling_back' });
|
|
123
|
+
try {
|
|
124
|
+
await rollbackToToken(beforeToken, config, broadcast);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
broadcast({ type: 'FAILED', message: err.message });
|
|
127
|
+
} finally {
|
|
128
|
+
activeRequest = false;
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
app.get('/api/history', async (req, res) => {
|
|
133
|
+
const history = await getChangeHistory(config.projectId);
|
|
134
|
+
res.json({ history });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
app.post('/api/graph/rebuild', async (req, res) => {
|
|
138
|
+
res.json({ status: 'rebuilding' });
|
|
139
|
+
broadcast({ type: 'STATUS', status: 'rebuilding_graph' });
|
|
140
|
+
try {
|
|
141
|
+
await rebuildGraph(config.graphifyOut);
|
|
142
|
+
broadcast({ type: 'STATUS', status: 'graph_ready' });
|
|
143
|
+
} catch (err) {
|
|
144
|
+
broadcast({ type: 'FAILED', message: `Graph rebuild failed: ${err.message}` });
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
server.listen(PORT, () => {
|
|
149
|
+
console.log(`\n⚡ Seam running on localhost:${PORT}`);
|
|
150
|
+
console.log(` Project: ${config.projectId}`);
|
|
151
|
+
console.log(` Graph: ${config.graphifyOut}/graph.json`);
|
|
152
|
+
console.log(` Panel: Open your app and press Cmd+Shift+S\n`);
|
|
153
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { createClient } from '@supabase/supabase-js';
|
|
2
|
+
|
|
3
|
+
function getClient() {
|
|
4
|
+
const url = process.env.SEAM_SUPABASE_URL;
|
|
5
|
+
const key = process.env.SEAM_SUPABASE_ANON_KEY;
|
|
6
|
+
if (!url || !key) throw new Error('Missing SEAM_SUPABASE_URL or SEAM_SUPABASE_ANON_KEY');
|
|
7
|
+
return createClient(url, key);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Save a snapshot to Supabase.
|
|
12
|
+
* fileContents: { [filepath]: string } — only the files Seam touched.
|
|
13
|
+
* Returns the session_token UUID.
|
|
14
|
+
*/
|
|
15
|
+
export async function createSnapshot({ changePlan, status, projectId, request, tier, fileContents, parentToken }) {
|
|
16
|
+
const supabase = getClient();
|
|
17
|
+
const token = crypto.randomUUID();
|
|
18
|
+
|
|
19
|
+
const { error } = await supabase.from('seam_snapshots').insert({
|
|
20
|
+
session_token: token,
|
|
21
|
+
project_id: projectId,
|
|
22
|
+
status,
|
|
23
|
+
risk_tier: tier,
|
|
24
|
+
change_request: request,
|
|
25
|
+
files: fileContents || {},
|
|
26
|
+
parent_token: parentToken || null
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (error) throw new Error(`Snapshot failed: ${error.message}`);
|
|
30
|
+
return token;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Load a snapshot by token.
|
|
35
|
+
*/
|
|
36
|
+
export async function loadSnapshot(token) {
|
|
37
|
+
const supabase = getClient();
|
|
38
|
+
const { data, error } = await supabase
|
|
39
|
+
.from('seam_snapshots')
|
|
40
|
+
.select('*')
|
|
41
|
+
.eq('session_token', token)
|
|
42
|
+
.single();
|
|
43
|
+
|
|
44
|
+
if (error) throw new Error(`Could not load snapshot ${token}: ${error.message}`);
|
|
45
|
+
return data;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Mark a snapshot as rolled_back.
|
|
50
|
+
*/
|
|
51
|
+
export async function markRolledBack(token) {
|
|
52
|
+
const supabase = getClient();
|
|
53
|
+
const { error } = await supabase
|
|
54
|
+
.from('seam_snapshots')
|
|
55
|
+
.update({ status: 'rolled_back' })
|
|
56
|
+
.eq('session_token', token);
|
|
57
|
+
if (error) throw new Error(`markRolledBack failed: ${error.message}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Get the last 20 change history entries for a project.
|
|
62
|
+
*/
|
|
63
|
+
export async function getChangeHistory(projectId) {
|
|
64
|
+
const supabase = getClient();
|
|
65
|
+
const { data, error } = await supabase
|
|
66
|
+
.from('seam_changes')
|
|
67
|
+
.select('*')
|
|
68
|
+
.eq('project_id', projectId)
|
|
69
|
+
.order('created_at', { ascending: false })
|
|
70
|
+
.limit(20);
|
|
71
|
+
|
|
72
|
+
if (error) throw new Error(`History fetch failed: ${error.message}`);
|
|
73
|
+
return data || [];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Log a completed change to seam_changes.
|
|
78
|
+
*/
|
|
79
|
+
export async function logChange({ projectId, request, tier, filesChanged, beforeToken, afterToken, status }) {
|
|
80
|
+
const supabase = getClient();
|
|
81
|
+
const { error } = await supabase.from('seam_changes').insert({
|
|
82
|
+
project_id: projectId,
|
|
83
|
+
request,
|
|
84
|
+
risk_tier: tier,
|
|
85
|
+
files_changed: filesChanged,
|
|
86
|
+
before_token: beforeToken,
|
|
87
|
+
after_token: afterToken,
|
|
88
|
+
status
|
|
89
|
+
});
|
|
90
|
+
if (error) throw new Error(`logChange failed: ${error.message}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Full rollback: restore files from a before_token snapshot.
|
|
95
|
+
* Returns the list of restored file paths.
|
|
96
|
+
*/
|
|
97
|
+
export async function rollbackToToken(beforeToken, config, broadcast) {
|
|
98
|
+
const snapshot = await loadSnapshot(beforeToken);
|
|
99
|
+
|
|
100
|
+
const { applyChangePlan } = await import('./fileWriter.js');
|
|
101
|
+
const restoredFiles = Object.keys(snapshot.files);
|
|
102
|
+
|
|
103
|
+
const rollbackPlan = {
|
|
104
|
+
originalRequest: `Rollback to ${beforeToken}`,
|
|
105
|
+
tier: snapshot.risk_tier,
|
|
106
|
+
changes: restoredFiles.map(filepath => ({
|
|
107
|
+
filepath,
|
|
108
|
+
type: 'restore',
|
|
109
|
+
replacementSnippet: snapshot.files[filepath]
|
|
110
|
+
}))
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
await applyChangePlan(rollbackPlan, config);
|
|
114
|
+
await markRolledBack(beforeToken);
|
|
115
|
+
|
|
116
|
+
if (broadcast) broadcast({ type: 'STATUS', status: 'rolled_back', message: `Rolled back to ${beforeToken.slice(0, 8)}...` });
|
|
117
|
+
return { restoredFiles, beforeToken };
|
|
118
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
const LOG_PATH = path.join(process.cwd(), '.seam', 'logs', 'token_usage.log');
|
|
5
|
+
|
|
6
|
+
// Sonnet pricing (per-million tokens as of 2025)
|
|
7
|
+
const INPUT_COST_PER_TOKEN = 3.00 / 1_000_000;
|
|
8
|
+
const OUTPUT_COST_PER_TOKEN = 15.00 / 1_000_000;
|
|
9
|
+
|
|
10
|
+
export function calcCost(inputTokens, outputTokens) {
|
|
11
|
+
return (inputTokens * INPUT_COST_PER_TOKEN) + (outputTokens * OUTPUT_COST_PER_TOKEN);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function formatCost(usd) {
|
|
15
|
+
return '$' + usd.toFixed(4);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Append one usage line to .seam/logs/token_usage.log.
|
|
20
|
+
* Called after every Claude API call (or failed attempt).
|
|
21
|
+
*/
|
|
22
|
+
export async function logTokenUsage({ requestId, request, tier, inputTokens, outputTokens, status }) {
|
|
23
|
+
await fs.ensureFile(LOG_PATH);
|
|
24
|
+
const cost = calcCost(inputTokens || 0, outputTokens || 0);
|
|
25
|
+
const line = JSON.stringify({
|
|
26
|
+
ts: new Date().toISOString(),
|
|
27
|
+
requestId,
|
|
28
|
+
request: (request || '').slice(0, 80),
|
|
29
|
+
tier: tier || '—',
|
|
30
|
+
inputTokens: inputTokens || 0,
|
|
31
|
+
outputTokens: outputTokens || 0,
|
|
32
|
+
costUsd: cost,
|
|
33
|
+
status
|
|
34
|
+
}) + '\n';
|
|
35
|
+
await fs.appendFile(LOG_PATH, line, 'utf8');
|
|
36
|
+
return { inputTokens, outputTokens, cost };
|
|
37
|
+
}
|