mango-cms 0.3.34 → 0.3.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/cli.js +57 -0
  2. package/default/infra/vibe/README.md +43 -0
  3. package/default/infra/vibe/cloudflare.ini.template +26 -0
  4. package/default/infra/vibe/ecosystem.vibe.config.cjs +44 -0
  5. package/default/infra/vibe/nginx-vibe-orchestrator.conf.template +50 -0
  6. package/default/infra/vibe/nginx-vibe-staging.conf.template +73 -0
  7. package/default/infra/vibe/vibe-gateway.service +38 -0
  8. package/default/infra/vibe/vibe-orchestrator.service +44 -0
  9. package/default/infra/vibe/vibe.env.template +24 -0
  10. package/default/mango/config/settings.json +40 -1
  11. package/default/vite.config.js +46 -0
  12. package/lib/vibe-orchestrator/README.md +76 -0
  13. package/lib/vibe-orchestrator/scripts/fake-claude.mjs +35 -0
  14. package/lib/vibe-orchestrator/scripts/path-guard-hook.mjs +70 -0
  15. package/lib/vibe-orchestrator/scripts/vibe-recover.sh +63 -0
  16. package/lib/vibe-orchestrator/server.js +344 -0
  17. package/lib/vibe-orchestrator/src/attachments.js +98 -0
  18. package/lib/vibe-orchestrator/src/claudeRunner.js +233 -0
  19. package/lib/vibe-orchestrator/src/config.js +227 -0
  20. package/lib/vibe-orchestrator/src/costMirror.js +64 -0
  21. package/lib/vibe-orchestrator/src/costStore.js +209 -0
  22. package/lib/vibe-orchestrator/src/ownerToken.js +113 -0
  23. package/lib/vibe-orchestrator/src/pathGuard.js +114 -0
  24. package/lib/vibe-orchestrator/src/preamble.js +139 -0
  25. package/lib/vibe-orchestrator/src/publisher.js +376 -0
  26. package/lib/vibe-orchestrator/src/recovery.js +199 -0
  27. package/lib/vibe-orchestrator/src/screenshot.js +38 -0
  28. package/lib/vibe-orchestrator/src/sessionManager.js +291 -0
  29. package/lib/vibe-orchestrator/src/streamParser.js +188 -0
  30. package/lib/vibe-orchestrator/src/tokenMeter.js +64 -0
  31. package/package.json +1 -1
  32. 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 }