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.
- package/cli.js +57 -0
- 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/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,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
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)
|