mango-cms 0.3.34 → 0.3.36
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/cli.js +113 -23
- package/default/infra/vibe/README.md +43 -0
- package/default/infra/vibe/cloudflare.ini.template +26 -0
- package/default/infra/vibe/ecosystem.vibe.config.cjs +44 -0
- package/default/infra/vibe/nginx-vibe-orchestrator.conf.template +50 -0
- package/default/infra/vibe/nginx-vibe-staging.conf.template +73 -0
- package/default/infra/vibe/vibe-gateway.service +38 -0
- package/default/infra/vibe/vibe-orchestrator.service +44 -0
- package/default/infra/vibe/vibe.env.template +24 -0
- package/default/mango/config/settings.json +40 -1
- package/default/package.json +1 -1
- package/default/vite.config.js +46 -0
- package/lib/vibe-orchestrator/README.md +76 -0
- package/lib/vibe-orchestrator/scripts/fake-claude.mjs +35 -0
- package/lib/vibe-orchestrator/scripts/path-guard-hook.mjs +70 -0
- package/lib/vibe-orchestrator/scripts/vibe-recover.sh +63 -0
- package/lib/vibe-orchestrator/server.js +344 -0
- package/lib/vibe-orchestrator/src/attachments.js +98 -0
- package/lib/vibe-orchestrator/src/claudeRunner.js +233 -0
- package/lib/vibe-orchestrator/src/config.js +227 -0
- package/lib/vibe-orchestrator/src/costMirror.js +64 -0
- package/lib/vibe-orchestrator/src/costStore.js +209 -0
- package/lib/vibe-orchestrator/src/ownerToken.js +113 -0
- package/lib/vibe-orchestrator/src/pathGuard.js +114 -0
- package/lib/vibe-orchestrator/src/preamble.js +139 -0
- package/lib/vibe-orchestrator/src/publisher.js +376 -0
- package/lib/vibe-orchestrator/src/recovery.js +199 -0
- package/lib/vibe-orchestrator/src/screenshot.js +38 -0
- package/lib/vibe-orchestrator/src/sessionManager.js +291 -0
- package/lib/vibe-orchestrator/src/streamParser.js +188 -0
- package/lib/vibe-orchestrator/src/tokenMeter.js +64 -0
- package/package.json +1 -1
- package/readme.md +6 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// costStore.js
|
|
2
|
+
//
|
|
3
|
+
// Durable per-turn cost log for the vibe assistant (HAP-1123). Append-only NDJSON
|
|
4
|
+
// so it survives orchestrator restarts and stays queryable without standing up a
|
|
5
|
+
// DB inside this dependency-free service. One line per completed turn:
|
|
6
|
+
//
|
|
7
|
+
// { ts, site, path, sessionId, model, costUsd, tokens:{input,output,cacheRead,
|
|
8
|
+
// total,billable}, promptSummary, ok }
|
|
9
|
+
//
|
|
10
|
+
// The CLI's reported `total_cost_usd` is the source of truth for `costUsd`
|
|
11
|
+
// (captured in claudeRunner). When the CLI omits a cost we fall back to a
|
|
12
|
+
// model→price table (estimateCostUsd) so a record is never cost-less.
|
|
13
|
+
//
|
|
14
|
+
// Pure of the clock: callers pass `ts`. fs is touched only on append/read so the
|
|
15
|
+
// rollup math stays unit-testable against an injected path.
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs'
|
|
18
|
+
import path from 'node:path'
|
|
19
|
+
import { config } from './config.js'
|
|
20
|
+
|
|
21
|
+
/** Default NDJSON log location. Override with VIBE_COST_LOG (absolute path). */
|
|
22
|
+
function defaultLogPath() {
|
|
23
|
+
return process.env.VIBE_COST_LOG || path.join(config.stagingRoot, '.vibe-costs.ndjson')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Per-MTok USD prices (input / output) used ONLY when the CLI omits a cost. These
|
|
27
|
+
// are coarse list prices for sanity, not billing truth — the CLI's
|
|
28
|
+
// total_cost_usd is authoritative and is what we persist when present.
|
|
29
|
+
const PRICE_TABLE = [
|
|
30
|
+
{ match: /opus/i, inPerMTok: 15, outPerMTok: 75 },
|
|
31
|
+
{ match: /sonnet/i, inPerMTok: 3, outPerMTok: 15 },
|
|
32
|
+
{ match: /haiku/i, inPerMTok: 0.8, outPerMTok: 4 },
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Estimate a turn's cost from tokens when the CLI didn't report one. Cache-read
|
|
37
|
+
* input is billed at the same coarse input rate here (good enough for a fallback;
|
|
38
|
+
* the CLI's real number already accounts for cache discounts when available).
|
|
39
|
+
* @returns {number|null} USD, or null when the model is unknown
|
|
40
|
+
*/
|
|
41
|
+
function estimateCostUsd({ model, inputTokens = 0, outputTokens = 0 } = {}) {
|
|
42
|
+
if (!model) return null
|
|
43
|
+
const row = PRICE_TABLE.find((r) => r.match.test(model))
|
|
44
|
+
if (!row) return null
|
|
45
|
+
return (inputTokens / 1e6) * row.inPerMTok + (outputTokens / 1e6) * row.outPerMTok
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function num(v) {
|
|
49
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : 0
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Apply the client-facing markup to a raw cost. `pct` is a percentage (100 ⇒ 2×).
|
|
54
|
+
* Returns null when the cost is absent so a missing cost never becomes $0.00.
|
|
55
|
+
* @param {number|null} cost raw USD
|
|
56
|
+
* @param {number} pct markup percent
|
|
57
|
+
*/
|
|
58
|
+
function applyMarkup(cost, pct) {
|
|
59
|
+
if (typeof cost !== 'number' || !Number.isFinite(cost)) return null
|
|
60
|
+
return cost * (1 + num(pct) / 100)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Normalize a raw record into the durable shape. Coerces token numbers and
|
|
65
|
+
* back-fills `costUsd` from the price table when absent. `ts` defaults to 0 so
|
|
66
|
+
* the module never reads the clock — real callers pass Date.now().
|
|
67
|
+
*/
|
|
68
|
+
function normalizeEntry(raw = {}) {
|
|
69
|
+
const t = raw.tokens || {}
|
|
70
|
+
const tokens = {
|
|
71
|
+
input: num(t.input ?? t.inputTokens),
|
|
72
|
+
output: num(t.output ?? t.outputTokens),
|
|
73
|
+
cacheRead: num(t.cacheRead ?? t.cacheReadTokens),
|
|
74
|
+
total: num(t.total ?? t.totalTokens),
|
|
75
|
+
billable: num(t.billable ?? t.billableTokens),
|
|
76
|
+
}
|
|
77
|
+
let costUsd = typeof raw.costUsd === 'number' && Number.isFinite(raw.costUsd) ? raw.costUsd : null
|
|
78
|
+
let costEstimated = false
|
|
79
|
+
if (costUsd == null) {
|
|
80
|
+
const est = estimateCostUsd({ model: raw.model, inputTokens: tokens.input, outputTokens: tokens.output })
|
|
81
|
+
if (est != null) { costUsd = est; costEstimated = true }
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
ts: num(raw.ts),
|
|
85
|
+
site: raw.site || null,
|
|
86
|
+
path: raw.path || null,
|
|
87
|
+
sessionId: raw.sessionId || null,
|
|
88
|
+
model: raw.model || null,
|
|
89
|
+
costUsd,
|
|
90
|
+
costEstimated,
|
|
91
|
+
tokens,
|
|
92
|
+
promptSummary: raw.promptSummary || '',
|
|
93
|
+
ok: raw.ok !== false,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Append one cost record as an NDJSON line. Creates the parent dir if needed.
|
|
99
|
+
* Returns the normalized record. Throws on fs failure — callers wrap this in a
|
|
100
|
+
* best-effort try/catch so cost logging never breaks a run.
|
|
101
|
+
*/
|
|
102
|
+
function appendCost(raw, logPath = defaultLogPath()) {
|
|
103
|
+
const entry = normalizeEntry(raw)
|
|
104
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true })
|
|
105
|
+
fs.appendFileSync(logPath, JSON.stringify(entry) + '\n')
|
|
106
|
+
return entry
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Read all records, tolerating partial/corrupt trailing lines. */
|
|
110
|
+
function readAll(logPath = defaultLogPath()) {
|
|
111
|
+
let text
|
|
112
|
+
try {
|
|
113
|
+
text = fs.readFileSync(logPath, 'utf8')
|
|
114
|
+
} catch (e) {
|
|
115
|
+
if (e.code === 'ENOENT') return []
|
|
116
|
+
throw e
|
|
117
|
+
}
|
|
118
|
+
const out = []
|
|
119
|
+
for (const line of text.split('\n')) {
|
|
120
|
+
const s = line.trim()
|
|
121
|
+
if (!s) continue
|
|
122
|
+
try { out.push(JSON.parse(s)) } catch { /* skip a torn line */ }
|
|
123
|
+
}
|
|
124
|
+
return out
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Read records filtered to the [from, to] ms window (inclusive). */
|
|
128
|
+
function readCosts({ from, to, site } = {}, logPath = defaultLogPath()) {
|
|
129
|
+
return readAll(logPath).filter((r) => {
|
|
130
|
+
if (typeof from === 'number' && num(r.ts) < from) return false
|
|
131
|
+
if (typeof to === 'number' && num(r.ts) > to) return false
|
|
132
|
+
if (site && r.site !== site) return false
|
|
133
|
+
return true
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** UTC YYYY-MM-DD for a ms timestamp. */
|
|
138
|
+
function dayKey(ts) {
|
|
139
|
+
return new Date(num(ts)).toISOString().slice(0, 10)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function emptyBucket() {
|
|
143
|
+
return { count: 0, costUsd: 0, billedUsd: 0, inputTokens: 0, outputTokens: 0, totalTokens: 0 }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function addTo(bucket, r, pct) {
|
|
147
|
+
bucket.count += 1
|
|
148
|
+
bucket.costUsd += num(r.costUsd)
|
|
149
|
+
bucket.billedUsd += num(applyMarkup(num(r.costUsd), pct))
|
|
150
|
+
const t = r.tokens || {}
|
|
151
|
+
bucket.inputTokens += num(t.input)
|
|
152
|
+
bucket.outputTokens += num(t.output)
|
|
153
|
+
bucket.totalTokens += num(t.total)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Sum a date window into a rollup the board can invoice from. Groups by UTC day,
|
|
158
|
+
* by session, and by model. `costUsd` is the RAW API cost (CLI's total_cost_usd);
|
|
159
|
+
* `billedUsd` is that cost with the client-facing markup applied (HAP-1123 board
|
|
160
|
+
* request). The rollup carries both plus the `markupPct` used, so the board sees
|
|
161
|
+
* raw and client-billed side by side.
|
|
162
|
+
*
|
|
163
|
+
* @param {{from?,to?,site?,markupPct?}} [opts] markupPct defaults to config
|
|
164
|
+
* @returns {{
|
|
165
|
+
* from, to, markupPct, count, totalCostUsd, totalBilledUsd, totalTokens,
|
|
166
|
+
* byDay: Object, bySession: Object, byModel: Object
|
|
167
|
+
* }}
|
|
168
|
+
*/
|
|
169
|
+
function rollup({ from, to, site, markupPct } = {}, logPath = defaultLogPath()) {
|
|
170
|
+
const pct = num(markupPct != null ? markupPct : config.markupPct)
|
|
171
|
+
const rows = readCosts({ from, to, site }, logPath)
|
|
172
|
+
const byDay = {}
|
|
173
|
+
const bySession = {}
|
|
174
|
+
const byModel = {}
|
|
175
|
+
let totalCostUsd = 0
|
|
176
|
+
let totalTokens = 0
|
|
177
|
+
for (const r of rows) {
|
|
178
|
+
totalCostUsd += num(r.costUsd)
|
|
179
|
+
totalTokens += num(r.tokens?.total)
|
|
180
|
+
const d = dayKey(r.ts)
|
|
181
|
+
;(byDay[d] ||= emptyBucket()) && addTo(byDay[d], r, pct)
|
|
182
|
+
const sid = r.sessionId || 'unknown'
|
|
183
|
+
;(bySession[sid] ||= emptyBucket()) && addTo(bySession[sid], r, pct)
|
|
184
|
+
const m = r.model || 'unknown'
|
|
185
|
+
;(byModel[m] ||= emptyBucket()) && addTo(byModel[m], r, pct)
|
|
186
|
+
}
|
|
187
|
+
const round = (n) => Math.round(n * 1e6) / 1e6
|
|
188
|
+
totalCostUsd = round(totalCostUsd)
|
|
189
|
+
const totalBilledUsd = round(num(applyMarkup(totalCostUsd, pct)))
|
|
190
|
+
for (const b of [...Object.values(byDay), ...Object.values(bySession), ...Object.values(byModel)]) {
|
|
191
|
+
b.costUsd = round(b.costUsd)
|
|
192
|
+
b.billedUsd = round(b.billedUsd)
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
from: typeof from === 'number' ? from : null,
|
|
196
|
+
to: typeof to === 'number' ? to : null,
|
|
197
|
+
site: site || null,
|
|
198
|
+
markupPct: pct,
|
|
199
|
+
count: rows.length,
|
|
200
|
+
totalCostUsd,
|
|
201
|
+
totalBilledUsd,
|
|
202
|
+
totalTokens,
|
|
203
|
+
byDay,
|
|
204
|
+
bySession,
|
|
205
|
+
byModel,
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export { appendCost, readCosts, readAll, rollup, normalizeEntry, estimateCostUsd, applyMarkup, defaultLogPath }
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// ownerToken.js
|
|
2
|
+
//
|
|
3
|
+
// Real per-owner auth for the orchestrator (HAP-1109). Replaces the old
|
|
4
|
+
// optional shared-secret gate, which failed OPEN when unset and could not tell
|
|
5
|
+
// one caller from another.
|
|
6
|
+
//
|
|
7
|
+
// A vibe owner token is a compact, dependency-free, JWT-like string:
|
|
8
|
+
//
|
|
9
|
+
// base64url(JSON(payload)) + "." + base64url(HMAC_SHA256(part1, secret))
|
|
10
|
+
//
|
|
11
|
+
// payload = { v:1, sub:<memberId>, roles:[...], site:<slug>, iat, exp } (epoch seconds)
|
|
12
|
+
//
|
|
13
|
+
// The token is MINTED server-side by Mango from an authenticated admin session
|
|
14
|
+
// (mango/endpoints -> mango/helpers/vibeToken.js, same format) and VERIFIED here
|
|
15
|
+
// with a secret shared only between Mango and the orchestrator
|
|
16
|
+
// (VIBE_ORCH_TOKEN_SECRET). The signature proves the claims were issued by Mango;
|
|
17
|
+
// a non-owner cannot forge an admin role without the secret, and the client-side
|
|
18
|
+
// role flag is no longer trusted. The Claude OAuth token is never involved.
|
|
19
|
+
|
|
20
|
+
import crypto from 'node:crypto'
|
|
21
|
+
|
|
22
|
+
const TOKEN_VERSION = 1
|
|
23
|
+
|
|
24
|
+
function b64urlEncode(buf) {
|
|
25
|
+
return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function b64urlDecode(str) {
|
|
29
|
+
const pad = str.length % 4 === 0 ? '' : '='.repeat(4 - (str.length % 4))
|
|
30
|
+
return Buffer.from(str.replace(/-/g, '+').replace(/_/g, '/') + pad, 'base64')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sign(part, secret) {
|
|
34
|
+
return b64urlEncode(crypto.createHmac('sha256', secret).update(part).digest())
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Constant-time string compare that never short-circuits on length. */
|
|
38
|
+
function safeEqual(a, b) {
|
|
39
|
+
const ab = Buffer.from(String(a))
|
|
40
|
+
const bb = Buffer.from(String(b))
|
|
41
|
+
if (ab.length !== bb.length) {
|
|
42
|
+
// Still run a comparison to keep timing flat, then fail.
|
|
43
|
+
crypto.timingSafeEqual(ab, ab)
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
return crypto.timingSafeEqual(ab, bb)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Mint an owner token. Used by tests/tooling; the production minter lives in
|
|
51
|
+
* Mango (mango/helpers/vibeToken.js) and MUST keep this exact format.
|
|
52
|
+
*
|
|
53
|
+
* @param {Object} opts
|
|
54
|
+
* @param {string} opts.secret shared HMAC secret (required)
|
|
55
|
+
* @param {string} opts.sub member id
|
|
56
|
+
* @param {string[]} [opts.roles] member roles
|
|
57
|
+
* @param {string} [opts.site] site slug the token is scoped to
|
|
58
|
+
* @param {number} [opts.ttlSeconds=600] lifetime
|
|
59
|
+
* @param {number} [opts.now] epoch seconds (testing override)
|
|
60
|
+
*/
|
|
61
|
+
function mintOwnerToken({ secret, sub, roles = [], site = '', ttlSeconds = 600, now } = {}) {
|
|
62
|
+
if (!secret) throw new Error('mintOwnerToken: secret required')
|
|
63
|
+
if (!sub) throw new Error('mintOwnerToken: sub (member id) required')
|
|
64
|
+
const iat = Number.isFinite(now) ? Math.floor(now) : Math.floor(Date.now() / 1000)
|
|
65
|
+
const payload = { v: TOKEN_VERSION, sub: String(sub), roles: Array.isArray(roles) ? roles : [], site, iat, exp: iat + ttlSeconds }
|
|
66
|
+
const part = b64urlEncode(JSON.stringify(payload))
|
|
67
|
+
return `${part}.${sign(part, secret)}`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Verify an owner token. Returns { ok, payload } or { ok:false, reason, status }.
|
|
72
|
+
* status is the HTTP status the caller should surface (401 vs 403).
|
|
73
|
+
*
|
|
74
|
+
* @param {string} token
|
|
75
|
+
* @param {Object} opts
|
|
76
|
+
* @param {string} opts.secret shared HMAC secret
|
|
77
|
+
* @param {string} [opts.site] if set, token.site must match
|
|
78
|
+
* @param {string[]} [opts.ownerRoles] roles that count as owner (default ['admin'])
|
|
79
|
+
* @param {number} [opts.now] epoch seconds (testing override)
|
|
80
|
+
*/
|
|
81
|
+
function verifyOwnerToken(token, { secret, site, ownerRoles = ['admin'], now } = {}) {
|
|
82
|
+
if (!secret) return { ok: false, reason: 'owner auth not configured', status: 503 }
|
|
83
|
+
if (!token || typeof token !== 'string') return { ok: false, reason: 'missing token', status: 401 }
|
|
84
|
+
|
|
85
|
+
const dot = token.indexOf('.')
|
|
86
|
+
if (dot <= 0 || dot === token.length - 1) return { ok: false, reason: 'malformed token', status: 401 }
|
|
87
|
+
const part = token.slice(0, dot)
|
|
88
|
+
const sig = token.slice(dot + 1)
|
|
89
|
+
|
|
90
|
+
if (!safeEqual(sig, sign(part, secret))) return { ok: false, reason: 'bad signature', status: 401 }
|
|
91
|
+
|
|
92
|
+
let payload
|
|
93
|
+
try {
|
|
94
|
+
payload = JSON.parse(b64urlDecode(part).toString('utf8'))
|
|
95
|
+
} catch {
|
|
96
|
+
return { ok: false, reason: 'unparseable payload', status: 401 }
|
|
97
|
+
}
|
|
98
|
+
if (!payload || payload.v !== TOKEN_VERSION) return { ok: false, reason: 'unsupported token version', status: 401 }
|
|
99
|
+
|
|
100
|
+
const nowSec = Number.isFinite(now) ? Math.floor(now) : Math.floor(Date.now() / 1000)
|
|
101
|
+
if (typeof payload.exp !== 'number' || nowSec >= payload.exp) return { ok: false, reason: 'token expired', status: 401 }
|
|
102
|
+
if (typeof payload.iat === 'number' && nowSec + 60 < payload.iat) return { ok: false, reason: 'token not yet valid', status: 401 }
|
|
103
|
+
|
|
104
|
+
if (site && payload.site && payload.site !== site) return { ok: false, reason: 'token scoped to a different site', status: 403 }
|
|
105
|
+
|
|
106
|
+
const roles = Array.isArray(payload.roles) ? payload.roles : []
|
|
107
|
+
const isOwner = ownerRoles.some((r) => roles.includes(r))
|
|
108
|
+
if (!isOwner) return { ok: false, reason: 'not an owner', status: 403 }
|
|
109
|
+
|
|
110
|
+
return { ok: true, payload }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export { mintOwnerToken, verifyOwnerToken, TOKEN_VERSION }
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// pathGuard.js
|
|
2
|
+
//
|
|
3
|
+
// Server-side denylist for vibe edits (HAP-1124, protection #1). The in-page
|
|
4
|
+
// assistant must never be able to edit the Vibe system itself — the ⌘K drawer
|
|
5
|
+
// and its client, the orchestrator service, the staging spine, the settings /
|
|
6
|
+
// secrets build, the vibe Mango endpoints, and the deploy/CI scripts. If it
|
|
7
|
+
// could, a single prompt could brick the very thing the owner uses to recover.
|
|
8
|
+
//
|
|
9
|
+
// This is PURE and prompt-independent: the preamble also tells the agent which
|
|
10
|
+
// files are off-limits (good UX), but enforcement lives in the PreToolUse hook
|
|
11
|
+
// (`scripts/path-guard-hook.mjs`) which imports `isProtectedPath` from here and
|
|
12
|
+
// blocks the tool BEFORE the edit lands — regardless of what the prompt says.
|
|
13
|
+
//
|
|
14
|
+
// Patterns are matched against a POSIX, repo-root-relative path. Supported glob
|
|
15
|
+
// tokens: `**` (any run of path segments, including none), `*` (any run within a
|
|
16
|
+
// single segment). Everything else is matched literally.
|
|
17
|
+
|
|
18
|
+
const PROTECTED_PATTERNS = [
|
|
19
|
+
// ⌘K palette / drawer UI and its orchestrator client
|
|
20
|
+
'src/components/layout/vibeAssistant.vue',
|
|
21
|
+
'src/components/layout/publishDialog.vue',
|
|
22
|
+
'src/helpers/orchestrator.js',
|
|
23
|
+
|
|
24
|
+
// The orchestrator service itself (entire tree)
|
|
25
|
+
'orchestrator/**',
|
|
26
|
+
|
|
27
|
+
// Staging spine — the overlay + build config that make the clone real
|
|
28
|
+
'mango/helpers/staging.mjs',
|
|
29
|
+
'vite.config.js',
|
|
30
|
+
'vite.staging.config.js',
|
|
31
|
+
|
|
32
|
+
// Settings / secrets and the settings build
|
|
33
|
+
'mango/config/settings.json',
|
|
34
|
+
'mango/config/settings.local.json',
|
|
35
|
+
'build-settings.mjs',
|
|
36
|
+
'mango/helpers/build-settings.mjs',
|
|
37
|
+
|
|
38
|
+
// Vibe server endpoints: owner-token mint + cost ingest
|
|
39
|
+
'mango/helpers/vibeToken.js',
|
|
40
|
+
'mango/helpers/vibeCosts.js',
|
|
41
|
+
|
|
42
|
+
// Deploy / CI / process management
|
|
43
|
+
'mango/helpers/deploy.sh',
|
|
44
|
+
'mango/helpers/update.sh',
|
|
45
|
+
'.github/**',
|
|
46
|
+
'nginx/**',
|
|
47
|
+
'**/*.nginx',
|
|
48
|
+
'**/nginx.conf',
|
|
49
|
+
'**/ecosystem.config.js',
|
|
50
|
+
'**/ecosystem.config.cjs',
|
|
51
|
+
'**/pm2.config.js',
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
/** Clear, owner-facing message surfaced when an edit is blocked. */
|
|
55
|
+
const PROTECTED_MESSAGE =
|
|
56
|
+
'That file is part of the Vibe system and is protected — I can’t edit it.'
|
|
57
|
+
|
|
58
|
+
/** Escape a string for literal use inside a RegExp. */
|
|
59
|
+
function escapeRe(s) {
|
|
60
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Compile a glob pattern (with `**` and `*`) into an anchored RegExp. */
|
|
64
|
+
function globToRe(glob) {
|
|
65
|
+
let re = ''
|
|
66
|
+
let i = 0
|
|
67
|
+
while (i < glob.length) {
|
|
68
|
+
const c = glob[i]
|
|
69
|
+
if (c === '*' && glob[i + 1] === '*') {
|
|
70
|
+
// `**/` ⇒ optional run of segments; bare `**` ⇒ anything.
|
|
71
|
+
if (glob[i + 2] === '/') { re += '(?:.*/)?'; i += 3 } else { re += '.*'; i += 2 }
|
|
72
|
+
} else if (c === '*') {
|
|
73
|
+
re += '[^/]*'
|
|
74
|
+
i += 1
|
|
75
|
+
} else {
|
|
76
|
+
re += escapeRe(c)
|
|
77
|
+
i += 1
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return new RegExp(`^${re}$`)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const COMPILED = PROTECTED_PATTERNS.map(globToRe)
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Normalize an arbitrary path into a POSIX, root-relative form with no leading
|
|
87
|
+
* `./` and no trailing slash. Backslashes are treated as separators.
|
|
88
|
+
* @param {string} p
|
|
89
|
+
* @returns {string}
|
|
90
|
+
*/
|
|
91
|
+
function normalizeRel(p) {
|
|
92
|
+
let s = String(p || '').trim().replace(/\\/g, '/')
|
|
93
|
+
// Collapse any `a/b/../c` etc. without touching the filesystem.
|
|
94
|
+
const out = []
|
|
95
|
+
for (const seg of s.split('/')) {
|
|
96
|
+
if (seg === '' || seg === '.') continue
|
|
97
|
+
if (seg === '..') out.pop()
|
|
98
|
+
else out.push(seg)
|
|
99
|
+
}
|
|
100
|
+
return out.join('/')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* True when a repo-root-relative path targets a protected Vibe-system file.
|
|
105
|
+
* @param {string} relPath - path relative to the staging clone root
|
|
106
|
+
* @returns {boolean}
|
|
107
|
+
*/
|
|
108
|
+
function isProtectedPath(relPath) {
|
|
109
|
+
const rel = normalizeRel(relPath)
|
|
110
|
+
if (!rel) return false
|
|
111
|
+
return COMPILED.some((re) => re.test(rel))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export { isProtectedPath, normalizeRel, PROTECTED_PATTERNS, PROTECTED_MESSAGE }
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// preamble.js
|
|
2
|
+
//
|
|
3
|
+
// Builds the full prompt sent to the in-page Vibe Assistant agent (HAP-1111).
|
|
4
|
+
// Without this the agent sees little more than the raw instruction. Here we give
|
|
5
|
+
// it (a) durable orientation — who it is, where things live, the one hard rule —
|
|
6
|
+
// injected once at the start of a conversation, and (b) a per-turn snapshot of
|
|
7
|
+
// what the owner is looking at (route, theme, selection, clicked element,
|
|
8
|
+
// console errors, a screenshot the agent can Read).
|
|
9
|
+
|
|
10
|
+
const NEVER_EDIT = 'mango/config/settings.json'
|
|
11
|
+
const MAX_SELECTION = 400
|
|
12
|
+
const MAX_HTML = 600
|
|
13
|
+
const MAX_ERROR = 300
|
|
14
|
+
const MAX_ERRORS = 10
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Static orientation for the agent. Injected once per conversation (resumed
|
|
18
|
+
* turns already carry it in history). Plain, declarative, no fluff.
|
|
19
|
+
* @returns {string}
|
|
20
|
+
*/
|
|
21
|
+
function contextPreamble() {
|
|
22
|
+
return [
|
|
23
|
+
'You are the Vibe Assistant — an in-page coding assistant embedded in a live',
|
|
24
|
+
'website. The site is a Mango + Vue 3 project, and you are editing a private',
|
|
25
|
+
'staging clone of it. Your edits hot-reload: the owner sees them in the page',
|
|
26
|
+
'within a few seconds of you saving a file.',
|
|
27
|
+
'',
|
|
28
|
+
'Where things live:',
|
|
29
|
+
' • src/components/pages/ — one Vue component per route (the page views)',
|
|
30
|
+
' • src/components/layout/ — shared chrome (header, footer, nav, drawers)',
|
|
31
|
+
' • src/components/ — reusable building blocks',
|
|
32
|
+
' • src/helpers/ — JS helpers (data, the Mango client, utilities)',
|
|
33
|
+
' • src/assets/, src/style/ — styling and static assets',
|
|
34
|
+
' • mango/ — server config and CMS (Mango)',
|
|
35
|
+
'',
|
|
36
|
+
'How to work:',
|
|
37
|
+
' • Make the smallest change that satisfies the request, then stop.',
|
|
38
|
+
' • Explain what you changed in plain language the owner can understand —',
|
|
39
|
+
' no jargon and no code diffs in your reply.',
|
|
40
|
+
' • Prefer editing an existing component over creating a new one.',
|
|
41
|
+
' • When the owner says "this" or "here", use the page context below',
|
|
42
|
+
' (route, selection, clicked element, screenshot) to find the target.',
|
|
43
|
+
'',
|
|
44
|
+
`HARD RULE: NEVER edit ${NEVER_EDIT}. It holds secrets and environment`,
|
|
45
|
+
'config that must not change. If a task seems to require it, stop and explain.',
|
|
46
|
+
'',
|
|
47
|
+
'PROTECTED FILES: the Vibe system itself is off-limits — the ⌘K drawer and its',
|
|
48
|
+
'client, the orchestrator service, the staging spine (staging.mjs, vite config),',
|
|
49
|
+
'the settings build, and the deploy/CI scripts. These edits are also blocked',
|
|
50
|
+
'server-side, so if a request needs one, stop and explain rather than trying.',
|
|
51
|
+
'',
|
|
52
|
+
'FETCHING FROM THE WEB: use the WebFetch and WebSearch tools for anything on the',
|
|
53
|
+
'internet. Shell network commands (curl, wget) are intentionally disabled and',
|
|
54
|
+
'will always fail — do NOT retry them; reach for WebFetch instead. WebFetch works',
|
|
55
|
+
'on ANY url, not just pages: to find a site\'s brand colors, WebFetch its',
|
|
56
|
+
'site.webmanifest / manifest.json (the theme_color field) and the <link rel=',
|
|
57
|
+
'"stylesheet"> CSS asset urls from its HTML, then read the hex values there —',
|
|
58
|
+
'fetching the homepage alone only returns the pre-render shell of a JS app. If a',
|
|
59
|
+
'value is still unclear, just ask the owner; they may have already pasted the',
|
|
60
|
+
'logo or color as an attachment above.',
|
|
61
|
+
].join('\n')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function truncate(value, n) {
|
|
65
|
+
const s = String(value)
|
|
66
|
+
return s.length > n ? `${s.slice(0, n)}…` : s
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Render a per-turn page-context object into a readable text block. Returns ''
|
|
71
|
+
* when there is nothing useful to say.
|
|
72
|
+
* @param {object} [ctx]
|
|
73
|
+
* @returns {string}
|
|
74
|
+
*/
|
|
75
|
+
function formatPageContext(ctx) {
|
|
76
|
+
if (!ctx || typeof ctx !== 'object') return ''
|
|
77
|
+
const lines = []
|
|
78
|
+
const push = (label, val) => {
|
|
79
|
+
if (val === undefined || val === null || val === '') return
|
|
80
|
+
lines.push(` • ${label}: ${typeof val === 'string' ? val : JSON.stringify(val)}`)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
push('URL', ctx.url)
|
|
84
|
+
push('Route', ctx.route)
|
|
85
|
+
push('Page title', ctx.title)
|
|
86
|
+
push('Theme', ctx.theme)
|
|
87
|
+
if (ctx.viewport && ctx.viewport.w) push('Viewport', `${ctx.viewport.w}×${ctx.viewport.h}`)
|
|
88
|
+
push('Site owner', ctx.owner)
|
|
89
|
+
if (ctx.selection) push('Selected text', truncate(ctx.selection, MAX_SELECTION))
|
|
90
|
+
|
|
91
|
+
if (ctx.clickTarget && (ctx.clickTarget.selector || ctx.clickTarget.html)) {
|
|
92
|
+
push('Clicked element (selector)', ctx.clickTarget.selector)
|
|
93
|
+
if (ctx.clickTarget.html) push('Clicked element (HTML)', truncate(ctx.clickTarget.html, MAX_HTML))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const errs = Array.isArray(ctx.consoleErrors) ? ctx.consoleErrors.filter(Boolean) : []
|
|
97
|
+
if (errs.length) {
|
|
98
|
+
lines.push(' • Recent console errors the owner is seeing:')
|
|
99
|
+
for (const e of errs.slice(-MAX_ERRORS)) lines.push(` - ${truncate(String(e), MAX_ERROR)}`)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (ctx.screenshotPath) {
|
|
103
|
+
push('Viewport screenshot', `${ctx.screenshotPath} (Read this image to see what the owner sees)`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// HAP-1126: files the owner pasted/uploaded with this turn. Claude Code reads
|
|
107
|
+
// images AND PDFs natively, so just hand it the absolute paths.
|
|
108
|
+
const atts = Array.isArray(ctx.attachmentPaths) ? ctx.attachmentPaths.filter(Boolean) : []
|
|
109
|
+
if (atts.length) {
|
|
110
|
+
lines.push(' • Files the owner attached (Read each — images and PDFs are supported):')
|
|
111
|
+
for (const a of atts) {
|
|
112
|
+
const label = a && a.name ? `${a.name} → ` : ''
|
|
113
|
+
lines.push(` - ${label}${a && a.path ? a.path : a}`)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!lines.length) return ''
|
|
118
|
+
return ['Current page context (what the owner is looking at right now):', ...lines].join('\n')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Compose the final prompt: orientation (first turn only) + page context +
|
|
123
|
+
* the owner's request.
|
|
124
|
+
* @param {object} opts
|
|
125
|
+
* @param {string} opts.prompt
|
|
126
|
+
* @param {object} [opts.pageContext]
|
|
127
|
+
* @param {boolean} [opts.includePreamble=true]
|
|
128
|
+
* @returns {string}
|
|
129
|
+
*/
|
|
130
|
+
function composePrompt({ prompt, pageContext, includePreamble = true } = {}) {
|
|
131
|
+
const parts = []
|
|
132
|
+
if (includePreamble) parts.push(contextPreamble())
|
|
133
|
+
const ctxBlock = formatPageContext(pageContext)
|
|
134
|
+
if (ctxBlock) parts.push(ctxBlock)
|
|
135
|
+
parts.push(`Owner request:\n${String(prompt || '').trim()}`)
|
|
136
|
+
return parts.join('\n\n')
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export { contextPreamble, formatPageContext, composePrompt }
|