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.
Files changed (33) hide show
  1. package/cli.js +113 -23
  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/package.json +1 -1
  12. package/default/vite.config.js +46 -0
  13. package/lib/vibe-orchestrator/README.md +76 -0
  14. package/lib/vibe-orchestrator/scripts/fake-claude.mjs +35 -0
  15. package/lib/vibe-orchestrator/scripts/path-guard-hook.mjs +70 -0
  16. package/lib/vibe-orchestrator/scripts/vibe-recover.sh +63 -0
  17. package/lib/vibe-orchestrator/server.js +344 -0
  18. package/lib/vibe-orchestrator/src/attachments.js +98 -0
  19. package/lib/vibe-orchestrator/src/claudeRunner.js +233 -0
  20. package/lib/vibe-orchestrator/src/config.js +227 -0
  21. package/lib/vibe-orchestrator/src/costMirror.js +64 -0
  22. package/lib/vibe-orchestrator/src/costStore.js +209 -0
  23. package/lib/vibe-orchestrator/src/ownerToken.js +113 -0
  24. package/lib/vibe-orchestrator/src/pathGuard.js +114 -0
  25. package/lib/vibe-orchestrator/src/preamble.js +139 -0
  26. package/lib/vibe-orchestrator/src/publisher.js +376 -0
  27. package/lib/vibe-orchestrator/src/recovery.js +199 -0
  28. package/lib/vibe-orchestrator/src/screenshot.js +38 -0
  29. package/lib/vibe-orchestrator/src/sessionManager.js +291 -0
  30. package/lib/vibe-orchestrator/src/streamParser.js +188 -0
  31. package/lib/vibe-orchestrator/src/tokenMeter.js +64 -0
  32. package/package.json +1 -1
  33. package/readme.md +6 -0
@@ -0,0 +1,291 @@
1
+ // sessionManager.js
2
+ //
3
+ // Per-site session registry. One logical session per site survives across
4
+ // prompts: we remember the last claude session_id so the next prompt resumes
5
+ // the same conversation (`claude --resume <id>`), and we carry cumulative token
6
+ // usage forward so the per-session cap spans the whole conversation, not a
7
+ // single turn. Only one run may be in-flight per site at a time.
8
+
9
+ import { EventEmitter } from 'node:events'
10
+ import { runPrompt } from './claudeRunner.js'
11
+ import { resolveSiteDir, config, canRunLive } from './config.js'
12
+ import { composePrompt } from './preamble.js'
13
+ import { persistScreenshot } from './screenshot.js'
14
+ import { persistAttachments } from './attachments.js'
15
+ import { appendCost } from './costStore.js'
16
+ import { mirrorCost } from './costMirror.js'
17
+ import { autoCommit } from './recovery.js'
18
+
19
+ class SessionManager {
20
+ /** @param {object} [deps] - injectable runPrompt / clock / reaper for tests */
21
+ constructor(deps = {}) {
22
+ this.runPrompt = deps.runPrompt || runPrompt
23
+ this.cfg = deps.config || config
24
+ this.now = deps.now || (() => Date.now())
25
+ // HAP-1123: per-turn cost persistence. recordCost writes the durable NDJSON
26
+ // log (system of record); mirrorCost best-effort POSTs the same record to the
27
+ // Mango `vibeCosts` collection. Both injectable so tests stay fs/network-free.
28
+ this.recordCost = deps.recordCost || appendCost
29
+ this.mirrorCost = deps.mirrorCost || mirrorCost
30
+ // HAP-1124: after each run, auto-commit the applied edit to the clone's git so
31
+ // there is always a way back. Injectable so tests stay git-free.
32
+ this.autoCommit = deps.autoCommit || autoCommit
33
+ /** @type {Map<string, object>} site -> session record */
34
+ this.sessions = new Map()
35
+ this._reaper = null
36
+ // Auto-start the idle reaper unless explicitly disabled (tests pass false).
37
+ if (deps.startReaper !== false && this.cfg.sessionIdleMs > 0) this.startReaper()
38
+ }
39
+
40
+ _ensure(site) {
41
+ let s = this.sessions.get(site)
42
+ if (!s) {
43
+ s = {
44
+ site,
45
+ sessionId: null,
46
+ cumulativeTokens: 0,
47
+ status: 'idle', // idle | running | error
48
+ active: null, // current run handle while running
49
+ lastResult: null,
50
+ promptCount: 0,
51
+ lastActivity: this.now(), // ms; bumped by prompts, events, and keepalive touches
52
+ readers: 0, // open SSE connections actively reading this session
53
+ }
54
+ this.sessions.set(site, s)
55
+ }
56
+ return s
57
+ }
58
+
59
+ get(site) {
60
+ return this.sessions.get(site) || null
61
+ }
62
+
63
+ list() {
64
+ return [...this.sessions.values()].map((s) => ({
65
+ site: s.site,
66
+ sessionId: s.sessionId,
67
+ cumulativeTokens: s.cumulativeTokens,
68
+ status: s.status,
69
+ promptCount: s.promptCount,
70
+ }))
71
+ }
72
+
73
+ /** Reset a site's conversation (next prompt starts fresh). */
74
+ reset(site) {
75
+ const s = this.sessions.get(site)
76
+ if (s && s.status === 'running') throw new Error('cannot reset while running')
77
+ this.sessions.delete(site)
78
+ }
79
+
80
+ /**
81
+ * Submit a prompt to a site. Returns the run's EventEmitter immediately;
82
+ * progress events flow through it, and the session record is updated on end.
83
+ *
84
+ * @param {string} site
85
+ * @param {string} prompt
86
+ * @param {object} [opts]
87
+ * @param {object} [opts.pageContext] - per-turn snapshot of what the owner sees
88
+ * @param {Array} [opts.attachments] - { name, mime, dataUrl } files to persist
89
+ * @returns {{ emitter: EventEmitter, done: Promise<object>, abort: ()=>void }}
90
+ */
91
+ submit(site, prompt, opts = {}) {
92
+ if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
93
+ throw new Error('prompt is required')
94
+ }
95
+ if (!canRunLive()) {
96
+ throw new Error('CLAUDE_CODE_OAUTH_TOKEN not set — cannot run live')
97
+ }
98
+ const cwd = resolveSiteDir(site) // throws on bad slug / not allowed
99
+ const s = this._ensure(site)
100
+ if (s.status === 'running') {
101
+ throw new Error(`site ${site} already has a run in progress`)
102
+ }
103
+
104
+ // Remaining budget for this conversation (cap spans all turns).
105
+ const remaining = this.cfg.tokenCap > 0
106
+ ? Math.max(0, this.cfg.tokenCap - s.cumulativeTokens)
107
+ : 0
108
+ if (this.cfg.tokenCap > 0 && remaining === 0) {
109
+ throw new Error(`session token cap (${this.cfg.tokenCap}) reached for ${site}`)
110
+ }
111
+
112
+ // HAP-1111: enrich the prompt with agent orientation (first turn only) and a
113
+ // per-turn snapshot of what the owner is looking at, including a screenshot
114
+ // the agent can Read. Resumed turns already carry the orientation in history.
115
+ const ctx = { ...(opts.pageContext || {}) }
116
+ if (ctx.screenshot) {
117
+ const shot = persistScreenshot(site, ctx.screenshot)
118
+ delete ctx.screenshot
119
+ if (shot) ctx.screenshotPath = shot
120
+ }
121
+ // HAP-1126: persist any pasted/uploaded files (images + PDFs) to temp paths
122
+ // the agent can Read, and hand the absolute paths to the preamble. Passing an
123
+ // array (even empty) sweeps the previous turn's files so temp stays bounded.
124
+ const savedAttachments = persistAttachments(site, opts.attachments)
125
+ if (savedAttachments.length) {
126
+ ctx.attachmentPaths = savedAttachments.map((a) => ({ name: a.name, path: a.path }))
127
+ }
128
+ const composedPrompt = composePrompt({
129
+ prompt,
130
+ pageContext: ctx,
131
+ includePreamble: !s.sessionId,
132
+ })
133
+
134
+ // HAP-1123: remember a short, single-line prompt summary and the page the
135
+ // owner was on so the persisted cost record is human-readable on an invoice.
136
+ const promptSummary = prompt.trim().replace(/\s+/g, ' ').slice(0, 140)
137
+ const pagePath = ctx.route || ctx.url || null
138
+
139
+ const handle = this.runPrompt({
140
+ prompt: composedPrompt,
141
+ cwd,
142
+ oauthToken: this.cfg.oauthToken,
143
+ claudeBin: this.cfg.claudeBin,
144
+ permissionMode: this.cfg.permissionMode,
145
+ model: this.cfg.model,
146
+ resumeSessionId: s.sessionId,
147
+ tokenCap: remaining,
148
+ timeoutMs: this.cfg.runTimeoutMs,
149
+ markupPct: this.cfg.markupPct,
150
+ // HAP-1124: install the server-side PreToolUse path guard for this run.
151
+ pathGuardHook: this.cfg.pathGuardEnabled ? this.cfg.pathGuardHook : '',
152
+ // HAP-1096: pre-approve read-only web tools + grant read access to the temp
153
+ // dir holding the viewport screenshot and any pasted/uploaded attachments.
154
+ allowedTools: this.cfg.allowedTools,
155
+ addDirs: this.cfg.toolExtraDirs,
156
+ })
157
+
158
+ s.status = 'running'
159
+ s.active = handle
160
+ s.promptCount += 1
161
+ s.lastActivity = this.now()
162
+
163
+ // Every streamed event is fresh activity — keep the session warm while it runs.
164
+ if (handle.emitter?.on) handle.emitter.on('event', () => { s.lastActivity = this.now() })
165
+
166
+ handle.done.then((summary) => {
167
+ if (summary.sessionId) s.sessionId = summary.sessionId
168
+ // Accumulate BILLABLE tokens (new input + output), not the full volume that
169
+ // re-counts cached context each turn — keeps the per-session cap meaningful
170
+ // across multiple prompts. Falls back to totalTokens for older summaries.
171
+ if (summary.tokens) {
172
+ s.cumulativeTokens += summary.tokens.billableTokens ?? summary.tokens.totalTokens
173
+ }
174
+ s.status = summary.ok ? 'idle' : 'error'
175
+ s.active = null
176
+ s.lastResult = summary
177
+ s.lastActivity = this.now()
178
+
179
+ // Persist a per-turn cost record (HAP-1123). Best-effort: a logging failure
180
+ // must never surface to the run, and the Mango mirror is fire-and-forget.
181
+ try {
182
+ const tk = summary.tokens || {}
183
+ const entry = {
184
+ ts: this.now(),
185
+ site,
186
+ path: pagePath,
187
+ sessionId: summary.sessionId || s.sessionId || null,
188
+ model: summary.model || this.cfg.model || null,
189
+ costUsd: typeof summary.costUsd === 'number' ? summary.costUsd : null,
190
+ tokens: {
191
+ input: tk.inputTokens,
192
+ output: tk.outputTokens,
193
+ cacheRead: tk.cacheReadTokens,
194
+ total: tk.totalTokens,
195
+ billable: tk.billableTokens,
196
+ },
197
+ promptSummary,
198
+ ok: !!summary.ok,
199
+ }
200
+ const saved = this.recordCost(entry)
201
+ Promise.resolve(this.mirrorCost(saved)).catch(() => {})
202
+ } catch { /* cost logging is never allowed to break a run */ }
203
+
204
+ // HAP-1124: auto-commit any applied edit so history always exists and the
205
+ // tree is never left non-recoverable. Runs even on a non-ok/aborted summary
206
+ // (a partial edit must still be captured). Best-effort + injectable: a git
207
+ // failure must never surface to the run.
208
+ if (this.cfg.autoCommitEnabled !== false) {
209
+ Promise.resolve()
210
+ .then(() => this.autoCommit(site, {
211
+ summary: promptSummary,
212
+ sessionId: summary.sessionId || s.sessionId || null,
213
+ }))
214
+ .catch(() => {})
215
+ }
216
+ }).catch(() => {
217
+ s.status = 'error'
218
+ s.active = null
219
+ s.lastActivity = this.now()
220
+ })
221
+
222
+ return handle
223
+ }
224
+
225
+ /** Abort the in-flight run for a site, if any. */
226
+ abort(site) {
227
+ const s = this.sessions.get(site)
228
+ if (s?.active) s.active.abort()
229
+ }
230
+
231
+ /**
232
+ * Mark a session as freshly active without sending a prompt. Called on the SSE
233
+ * keepalive ping so a session that is being actively read (but not prompted) is
234
+ * not reaped mid-read — the original idle-reaper bug (HAP-1110 item 4): the old
235
+ * code only updated activity on a new message, so the keepalive did not count.
236
+ */
237
+ touch(site) {
238
+ const s = this.sessions.get(site)
239
+ if (s) s.lastActivity = this.now()
240
+ }
241
+
242
+ /** Register an open SSE reader for a site; a session with readers is never reaped. */
243
+ addReader(site) {
244
+ const s = this._ensure(site)
245
+ s.readers += 1
246
+ s.lastActivity = this.now()
247
+ }
248
+
249
+ /** Release an SSE reader and record the disconnect time as activity. */
250
+ removeReader(site) {
251
+ const s = this.sessions.get(site)
252
+ if (!s) return
253
+ s.readers = Math.max(0, s.readers - 1)
254
+ s.lastActivity = this.now()
255
+ }
256
+
257
+ /**
258
+ * Reap idle sessions. A session is reaped only when it is NOT running, has NO
259
+ * open readers, and has been quiet longer than the idle TTL. Running sessions
260
+ * and actively-read sessions are always kept warm.
261
+ * @returns {string[]} the sites that were reaped
262
+ */
263
+ reap() {
264
+ const ttl = this.cfg.sessionIdleMs
265
+ if (!ttl || ttl <= 0) return []
266
+ const now = this.now()
267
+ const reaped = []
268
+ for (const [site, s] of this.sessions) {
269
+ if (s.status === 'running' || s.readers > 0) continue
270
+ if (now - s.lastActivity > ttl) {
271
+ this.sessions.delete(site)
272
+ reaped.push(site)
273
+ }
274
+ }
275
+ return reaped
276
+ }
277
+
278
+ /** Start the periodic idle reaper (no-op if already running). */
279
+ startReaper() {
280
+ if (this._reaper) return
281
+ this._reaper = setInterval(() => this.reap(), this.cfg.reaperIntervalMs)
282
+ if (this._reaper.unref) this._reaper.unref() // never keep the process alive
283
+ }
284
+
285
+ /** Stop the idle reaper. */
286
+ stopReaper() {
287
+ if (this._reaper) { clearInterval(this._reaper); this._reaper = null }
288
+ }
289
+ }
290
+
291
+ export { SessionManager }
@@ -0,0 +1,188 @@
1
+ // streamParser.js
2
+ //
3
+ // Parses Claude Code headless `--output-format stream-json` output into a
4
+ // normalized stream of progress events the front-end can render.
5
+ //
6
+ // Claude Code emits newline-delimited JSON (NDJSON). stdout chunks can split a
7
+ // line anywhere, so we buffer until we see a newline. Each complete line is one
8
+ // JSON object; we normalize the shapes we care about and pass everything else
9
+ // through as `unknown` so nothing is silently dropped.
10
+ //
11
+ // Known top-level message types (Claude Code v2.x):
12
+ // { type: "system", subtype: "init", session_id, model, tools, cwd, ... }
13
+ // { type: "assistant", message: { content: [...], usage: {...} }, session_id }
14
+ // { type: "user", message: { content: [tool_result ...] }, session_id }
15
+ // { type: "result", subtype, total_cost_usd, duration_ms, usage, result, session_id }
16
+ //
17
+ // Pure module: no I/O, no process access. Fully unit-testable without a token.
18
+
19
+ /**
20
+ * @typedef {Object} ProgressEvent
21
+ * @property {string} kind - normalized event kind (see below)
22
+ * @property {number} [ts] - caller-supplied timestamp (parser never reads the clock)
23
+ */
24
+
25
+ const KINDS = Object.freeze({
26
+ SESSION_START: 'session_start',
27
+ ASSISTANT_TEXT: 'assistant_text',
28
+ THINKING: 'thinking',
29
+ TOOL_USE: 'tool_use',
30
+ TOOL_RESULT: 'tool_result',
31
+ RESULT: 'result',
32
+ PARSE_ERROR: 'parse_error',
33
+ UNKNOWN: 'unknown',
34
+ })
35
+
36
+ /** Pull a normalized usage object out of whatever shape it arrives in. */
37
+ function normalizeUsage(usage) {
38
+ if (!usage || typeof usage !== 'object') return null
39
+ const freshInput = usage.input_tokens || 0
40
+ const cacheCreation = usage.cache_creation_input_tokens || 0
41
+ const cacheRead = usage.cache_read_input_tokens || 0
42
+ const input = freshInput + cacheCreation + cacheRead
43
+ const output = usage.output_tokens || 0
44
+ return {
45
+ inputTokens: input, // total input incl. cached context (display)
46
+ outputTokens: output,
47
+ cacheReadTokens: cacheRead,
48
+ totalTokens: input + output, // full token volume (display)
49
+ // What actually counts against the per-session cap: NEW tokens only. The
50
+ // cached context (cache_read) is re-reported on every turn and is cheap
51
+ // already-paid context — summing it across turns balloons the count and
52
+ // would trip the cap on a trivial multi-turn edit. Exclude it.
53
+ billableTokens: freshInput + cacheCreation + output,
54
+ raw: usage,
55
+ }
56
+ }
57
+
58
+ /** Normalize a single already-parsed NDJSON object into 0+ progress events. */
59
+ function normalizeMessage(msg) {
60
+ if (!msg || typeof msg !== 'object') {
61
+ return [{ kind: KINDS.UNKNOWN, raw: msg }]
62
+ }
63
+
64
+ switch (msg.type) {
65
+ case 'system':
66
+ if (msg.subtype === 'init') {
67
+ return [{
68
+ kind: KINDS.SESSION_START,
69
+ sessionId: msg.session_id || null,
70
+ model: msg.model || null,
71
+ tools: Array.isArray(msg.tools) ? msg.tools : [],
72
+ cwd: msg.cwd || null,
73
+ }]
74
+ }
75
+ return [{ kind: KINDS.UNKNOWN, raw: msg }]
76
+
77
+ case 'assistant': {
78
+ const content = msg.message?.content
79
+ const usage = normalizeUsage(msg.message?.usage)
80
+ const events = []
81
+ if (Array.isArray(content)) {
82
+ for (const block of content) {
83
+ if (block.type === 'text' && block.text) {
84
+ events.push({ kind: KINDS.ASSISTANT_TEXT, text: block.text })
85
+ } else if (block.type === 'thinking' && block.thinking) {
86
+ events.push({ kind: KINDS.THINKING, text: block.thinking })
87
+ } else if (block.type === 'tool_use') {
88
+ events.push({
89
+ kind: KINDS.TOOL_USE,
90
+ tool: block.name,
91
+ toolId: block.id,
92
+ input: block.input,
93
+ })
94
+ }
95
+ }
96
+ }
97
+ // An assistant turn with usage but no renderable block still carries token
98
+ // accounting we must not lose — emit a bare usage-only event.
99
+ if (events.length === 0 && usage) {
100
+ events.push({ kind: KINDS.ASSISTANT_TEXT, text: '' })
101
+ }
102
+ // One assistant message == one model API call == one usage report. Attach
103
+ // usage to the FIRST event only so the token meter counts it once per turn,
104
+ // not once per content block (a turn may carry text + tool_use together).
105
+ if (events.length && usage) events[0].usage = usage
106
+ return events
107
+ }
108
+
109
+ case 'user': {
110
+ const content = msg.message?.content
111
+ const events = []
112
+ if (Array.isArray(content)) {
113
+ for (const block of content) {
114
+ if (block.type === 'tool_result') {
115
+ events.push({
116
+ kind: KINDS.TOOL_RESULT,
117
+ toolId: block.tool_use_id,
118
+ isError: !!block.is_error,
119
+ content: block.content,
120
+ })
121
+ }
122
+ }
123
+ }
124
+ return events.length ? events : [{ kind: KINDS.UNKNOWN, raw: msg }]
125
+ }
126
+
127
+ case 'result':
128
+ return [{
129
+ kind: KINDS.RESULT,
130
+ subtype: msg.subtype || null,
131
+ isError: !!msg.is_error,
132
+ durationMs: msg.duration_ms ?? null,
133
+ costUsd: msg.total_cost_usd ?? null,
134
+ usage: normalizeUsage(msg.usage),
135
+ numTurns: msg.num_turns ?? null,
136
+ sessionId: msg.session_id || null,
137
+ result: typeof msg.result === 'string' ? msg.result : null,
138
+ }]
139
+
140
+ default:
141
+ return [{ kind: KINDS.UNKNOWN, raw: msg }]
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Stateful NDJSON line buffer. Feed it raw stdout chunks; it returns the
147
+ * normalized progress events for every *complete* line seen so far.
148
+ */
149
+ class StreamParser {
150
+ constructor() {
151
+ this.buffer = ''
152
+ }
153
+
154
+ /**
155
+ * @param {string|Buffer} chunk
156
+ * @returns {ProgressEvent[]}
157
+ */
158
+ push(chunk) {
159
+ this.buffer += chunk.toString()
160
+ const events = []
161
+ let nl
162
+ while ((nl = this.buffer.indexOf('\n')) !== -1) {
163
+ const line = this.buffer.slice(0, nl).trim()
164
+ this.buffer = this.buffer.slice(nl + 1)
165
+ if (line) events.push(...this._parseLine(line))
166
+ }
167
+ return events
168
+ }
169
+
170
+ /** Flush any trailing partial line at stream end. */
171
+ flush() {
172
+ const line = this.buffer.trim()
173
+ this.buffer = ''
174
+ return line ? this._parseLine(line) : []
175
+ }
176
+
177
+ _parseLine(line) {
178
+ let parsed
179
+ try {
180
+ parsed = JSON.parse(line)
181
+ } catch {
182
+ return [{ kind: KINDS.PARSE_ERROR, raw: line }]
183
+ }
184
+ return normalizeMessage(parsed)
185
+ }
186
+ }
187
+
188
+ export { StreamParser, normalizeMessage, normalizeUsage, KINDS }
@@ -0,0 +1,64 @@
1
+ // tokenMeter.js
2
+ //
3
+ // Cumulative token accounting + per-session cap. Fed the `usage` objects that
4
+ // streamParser attaches to assistant events. Pure/in-memory: one instance per
5
+ // session.
6
+ //
7
+ // The cap counts BILLABLE tokens (new input + cache creation + output), NOT the
8
+ // full `totalTokens` — Claude Code re-reports the whole cached context
9
+ // (cache_read) on every turn, so summing totals across turns balloons the count
10
+ // and would trip the cap on a trivial multi-turn edit. We still track the full
11
+ // volume for display.
12
+
13
+ class TokenMeter {
14
+ /** @param {number} cap - max cumulative BILLABLE tokens before tripping (0 = unlimited) */
15
+ constructor(cap = 0) {
16
+ this.cap = cap > 0 ? cap : 0
17
+ this.inputTokens = 0 // full input incl. re-read cache (display)
18
+ this.outputTokens = 0
19
+ this.cacheReadTokens = 0 // re-read cached context (display / cost breakdown)
20
+ this.billableTokens = 0 // new input + cache creation + output (cap basis)
21
+ }
22
+
23
+ get totalTokens() {
24
+ return this.inputTokens + this.outputTokens
25
+ }
26
+
27
+ /**
28
+ * Record one assistant turn's usage. Returns true if the cap is now exceeded.
29
+ * Accepts the normalized usage from streamParser; falls back to totalTokens
30
+ * when billableTokens is absent (older callers / hand-built usage in tests).
31
+ * @param {{inputTokens?:number, outputTokens?:number, totalTokens?:number, billableTokens?:number}|null} usage
32
+ */
33
+ record(usage) {
34
+ if (!usage) return this.exceeded
35
+ this.inputTokens += usage.inputTokens || 0
36
+ this.outputTokens += usage.outputTokens || 0
37
+ this.cacheReadTokens += usage.cacheReadTokens || 0
38
+ const billable = usage.billableTokens != null
39
+ ? usage.billableTokens
40
+ : (usage.totalTokens != null
41
+ ? usage.totalTokens
42
+ : (usage.inputTokens || 0) + (usage.outputTokens || 0))
43
+ this.billableTokens += billable
44
+ return this.exceeded
45
+ }
46
+
47
+ get exceeded() {
48
+ return this.cap > 0 && this.billableTokens >= this.cap
49
+ }
50
+
51
+ snapshot() {
52
+ return {
53
+ inputTokens: this.inputTokens,
54
+ outputTokens: this.outputTokens,
55
+ cacheReadTokens: this.cacheReadTokens,
56
+ totalTokens: this.totalTokens,
57
+ billableTokens: this.billableTokens,
58
+ cap: this.cap,
59
+ exceeded: this.exceeded,
60
+ }
61
+ }
62
+ }
63
+
64
+ export { TokenMeter }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mango-cms",
3
- "version": "0.3.34",
3
+ "version": "0.3.36",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "exports": {
package/readme.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  Mango is a powerful, code-first CMS built on MongoDB that emphasizes configuration through code. This documentation covers how to configure and use Mango CMS through the `config` folder.
4
4
 
5
+ > **Vibe (in-page AI coding):** to activate the optional ⌘K "edit your live site"
6
+ > experience on a Mango project, follow [docs/VIBE_ACTIVATION.md](docs/VIBE_ACTIVATION.md)
7
+ > — install/upgrade → flip `settings.vibe.enabled` → set `VITE_VIBE_SHELL` → run
8
+ > the bundled orchestrator + gateway behind the infra templates in
9
+ > [default/infra/vibe/](default/infra/vibe/). No source copying required.
10
+
5
11
  ## Table of Contents
6
12
 
7
13
  1. [Collections](#collections)