@dashclaw/cli 0.7.6 → 0.8.1
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/README.md +0 -61
- package/bin/dashclaw.js +2 -770
- package/lib/claude/install.js +411 -412
- package/lib/codex/install.js +10 -9
- package/lib/up/index.js +45 -4
- package/package.json +37 -37
- package/lib/code/apply.js +0 -164
- package/lib/code/codex-parser.vendored.js +0 -360
- package/lib/code/ingest-codex.js +0 -244
- package/lib/code/ingest.js +0 -261
- package/lib/code/memo.js +0 -57
- package/lib/code/vendored.js +0 -219
- package/lib/cost.js +0 -108
- package/lib/env.js +0 -69
- package/lib/posture.js +0 -45
package/lib/code/ingest.js
DELETED
|
@@ -1,261 +0,0 @@
|
|
|
1
|
-
// Path B JSONL ingest. Walks `claudeProjectsDir`, posts each .jsonl file to
|
|
2
|
-
// /api/code-sessions/ingest-jsonl with source_host='jsonl'. Per A6 in the
|
|
3
|
-
// goal, the CLI does NOT parse — the server runs the canonical parser.
|
|
4
|
-
//
|
|
5
|
-
// Stream-reads files line-by-line so a large transcript doesn't have to fit
|
|
6
|
-
// in memory all at once. The body always carries raw `jsonl_lines`; when the
|
|
7
|
-
// serialized request exceeds WIRE_COMPRESS_THRESHOLD, postIngest brotli-compresses
|
|
8
|
-
// the whole envelope on the wire (via the `x-dashclaw-encoding: br` header) to fit
|
|
9
|
-
// Vercel's 4.5 MB per-request body limit. Files above MAX_FILE_BYTES are still
|
|
10
|
-
// skipped — compression can't rescue arbitrarily large inputs.
|
|
11
|
-
//
|
|
12
|
-
// Logs per file: { file, posted_lines, status, reason }. NEVER logs raw line
|
|
13
|
-
// content — that would leak the user's transcripts through CI logs.
|
|
14
|
-
|
|
15
|
-
import fs from 'node:fs';
|
|
16
|
-
import path from 'node:path';
|
|
17
|
-
import readline from 'node:readline';
|
|
18
|
-
import zlib from 'node:zlib';
|
|
19
|
-
import { homedir } from 'node:os';
|
|
20
|
-
|
|
21
|
-
// Absolute ceiling. Vercel Hobby's 4.5 MB body limit is the binding constraint,
|
|
22
|
-
// applied to the *compressed* wire body. Brotli q9 compresses our JSONL ~4×, so
|
|
23
|
-
// typical-density sessions fit in one request up to ~15-17 MB raw (measured: a
|
|
24
|
-
// 13.3 MB raw session → ~3.5 MB brotli). Density varies, so a file between that
|
|
25
|
-
// and MAX_FILE_BYTES can still exceed the cap and 413; line-chunked POST is the
|
|
26
|
-
// documented future path for those. Files above MAX_FILE_BYTES are skipped
|
|
27
|
-
// outright with `too_large` — no compression rescues them.
|
|
28
|
-
const MAX_FILE_BYTES = 40 * 1024 * 1024;
|
|
29
|
-
// Serialized-JSON byte size above which postIngest brotli-compresses the request
|
|
30
|
-
// body (custom `x-dashclaw-encoding: br` transport). Below this the plain JSON
|
|
31
|
-
// body fits comfortably and we skip the compress CPU cost for the hot path of
|
|
32
|
-
// small per-session deltas. Set well under Vercel's 4.5 MB cap so the *plain*
|
|
33
|
-
// path is only ever used for bodies that are already safe.
|
|
34
|
-
const WIRE_COMPRESS_THRESHOLD = 3 * 1024 * 1024;
|
|
35
|
-
// Brotli quality for the wire body. q9 is the knee of the size/time curve for
|
|
36
|
-
// our payloads (measured on a 14 MB envelope): ~0.8 s at q9 → 3.57 MB, vs q11's
|
|
37
|
-
// ~13 s for only ~0.1 MB less. q10+ cliffs in time for negligible gain; q9
|
|
38
|
-
// still clears Vercel's 4.5 MB cap with ~0.6 MB headroom.
|
|
39
|
-
const BROTLI_QUALITY = 9;
|
|
40
|
-
|
|
41
|
-
export function defaultClaudeProjectsDir(env = process.env) {
|
|
42
|
-
if (env.CLAUDE_PROJECTS_DIR) return env.CLAUDE_PROJECTS_DIR;
|
|
43
|
-
if (process.platform === 'win32') {
|
|
44
|
-
return path.join(env.USERPROFILE || homedir(), '.claude', 'projects');
|
|
45
|
-
}
|
|
46
|
-
return path.join(env.HOME || homedir(), '.claude', 'projects');
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function listJsonlFiles(rootDir) {
|
|
50
|
-
const out = [];
|
|
51
|
-
let entries;
|
|
52
|
-
try { entries = fs.readdirSync(rootDir, { withFileTypes: true }); }
|
|
53
|
-
catch { return out; }
|
|
54
|
-
for (const e of entries) {
|
|
55
|
-
const p = path.join(rootDir, e.name);
|
|
56
|
-
if (e.isDirectory()) {
|
|
57
|
-
let inner;
|
|
58
|
-
try { inner = fs.readdirSync(p, { withFileTypes: true }); }
|
|
59
|
-
catch { continue; }
|
|
60
|
-
for (const f of inner) {
|
|
61
|
-
if (f.isFile() && f.name.endsWith('.jsonl')) out.push(path.join(p, f.name));
|
|
62
|
-
}
|
|
63
|
-
} else if (e.isFile() && e.name.endsWith('.jsonl')) {
|
|
64
|
-
out.push(p);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
return out;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
async function readLines(filePath) {
|
|
71
|
-
const lines = [];
|
|
72
|
-
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
|
73
|
-
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
74
|
-
for await (const line of rl) {
|
|
75
|
-
if (line) lines.push(line);
|
|
76
|
-
}
|
|
77
|
-
return lines;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Recover the project's real working directory from the transcript itself.
|
|
81
|
-
// Claude Code stamps a `cwd` field on JSONL records; the encoded directory
|
|
82
|
-
// slug (c--projects-dashclaw) is NOT reversible, so this is the only
|
|
83
|
-
// client-side source of a copy-pasteable path. Bounded scan; null when absent.
|
|
84
|
-
export function deriveCwdFromLines(lines, maxScan = 50) {
|
|
85
|
-
for (const line of lines.slice(0, maxScan)) {
|
|
86
|
-
try {
|
|
87
|
-
const rec = JSON.parse(line);
|
|
88
|
-
if (rec && typeof rec.cwd === 'string' && rec.cwd.trim()) return rec.cwd;
|
|
89
|
-
} catch {
|
|
90
|
-
// Non-JSON line — keep scanning; the parser tolerates these too.
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
return null;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export async function buildIngestPayload(filePath, { cwdOverride = null } = {}) {
|
|
97
|
-
const stat = fs.statSync(filePath);
|
|
98
|
-
if (stat.size > MAX_FILE_BYTES) {
|
|
99
|
-
return { tooLarge: true, sizeBytes: stat.size };
|
|
100
|
-
}
|
|
101
|
-
const parent = path.dirname(filePath);
|
|
102
|
-
const slug = path.basename(parent);
|
|
103
|
-
const lines = await readLines(filePath);
|
|
104
|
-
|
|
105
|
-
// Always build the logical body with raw `jsonl_lines`. Wire compression is
|
|
106
|
-
// decided in postIngest (gzip the whole envelope when it's large) rather than
|
|
107
|
-
// here — keeping payload construction independent of transport means the same
|
|
108
|
-
// body serializes identically whether or not it ends up gzipped.
|
|
109
|
-
const body = {
|
|
110
|
-
project: {
|
|
111
|
-
slug,
|
|
112
|
-
cwd: cwdOverride || deriveCwdFromLines(lines),
|
|
113
|
-
source_host: 'jsonl',
|
|
114
|
-
},
|
|
115
|
-
session_uuid: null,
|
|
116
|
-
source_file: filePath,
|
|
117
|
-
source_mtime: stat.mtime.toISOString(),
|
|
118
|
-
tool_use_action_map: {},
|
|
119
|
-
jsonl_lines: lines,
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
return {
|
|
123
|
-
body,
|
|
124
|
-
sizeBytes: stat.size,
|
|
125
|
-
lineCount: lines.length,
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function sleep(ms) {
|
|
130
|
-
return new Promise(resolve => setTimeout(resolve, ms));
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
async function postIngest(baseUrl, apiKey, body, { fetchImpl = fetch, maxRetries = 4 } = {}) {
|
|
134
|
-
const url = baseUrl.replace(/\/+$/, '') + '/api/code-sessions/ingest-jsonl';
|
|
135
|
-
// Decide transport once, outside the retry loop: brotli-compress the JSON
|
|
136
|
-
// envelope when it's large. The custom `x-dashclaw-encoding: br` header tells
|
|
137
|
-
// the server to inflate before parsing. Brotli (not gzip) keeps the wire body
|
|
138
|
-
// under Vercel's 4.5 MB cap for the big sessions that gzip couldn't fit — a
|
|
139
|
-
// 14 MB envelope is ~4.34 MB gzipped (over the cap) but ~3.5 MB brotli q9.
|
|
140
|
-
const json = JSON.stringify(body);
|
|
141
|
-
const jsonBytes = Buffer.byteLength(json, 'utf8');
|
|
142
|
-
const useCompression = jsonBytes > WIRE_COMPRESS_THRESHOLD;
|
|
143
|
-
const requestBody = useCompression
|
|
144
|
-
? zlib.brotliCompressSync(json, {
|
|
145
|
-
params: {
|
|
146
|
-
[zlib.constants.BROTLI_PARAM_QUALITY]: BROTLI_QUALITY,
|
|
147
|
-
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: jsonBytes,
|
|
148
|
-
},
|
|
149
|
-
})
|
|
150
|
-
: json;
|
|
151
|
-
const headers = { 'content-type': 'application/json', 'x-api-key': apiKey };
|
|
152
|
-
if (useCompression) headers['x-dashclaw-encoding'] = 'br';
|
|
153
|
-
let lastStatus = 0;
|
|
154
|
-
let lastPayload = null;
|
|
155
|
-
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
156
|
-
const res = await fetchImpl(url, {
|
|
157
|
-
method: 'POST',
|
|
158
|
-
headers,
|
|
159
|
-
body: requestBody,
|
|
160
|
-
});
|
|
161
|
-
let payload = null;
|
|
162
|
-
try { payload = await res.json(); } catch { /* keep payload null */ }
|
|
163
|
-
if (res.ok) return { status: res.status, ok: true, payload };
|
|
164
|
-
lastStatus = res.status;
|
|
165
|
-
lastPayload = payload;
|
|
166
|
-
// Retry on 429 (rate limit) and 5xx with exponential backoff +
|
|
167
|
-
// honour Retry-After when the server provides it. Anything else is
|
|
168
|
-
// surfaced immediately.
|
|
169
|
-
const retryable = res.status === 429 || (res.status >= 500 && res.status < 600);
|
|
170
|
-
if (!retryable || attempt === maxRetries) break;
|
|
171
|
-
const retryAfter = Number(res.headers?.get?.('retry-after')) || 0;
|
|
172
|
-
const backoffMs = retryAfter > 0
|
|
173
|
-
? Math.min(retryAfter * 1000, 30000)
|
|
174
|
-
: Math.min(1000 * 2 ** attempt, 16000);
|
|
175
|
-
await sleep(backoffMs);
|
|
176
|
-
}
|
|
177
|
-
return { status: lastStatus, ok: false, payload: lastPayload };
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Run the ingest pipeline. Returns a per-file result array. Throws on
|
|
182
|
-
* total config failure; per-file failures are recorded in the result.
|
|
183
|
-
*
|
|
184
|
-
* @param {Object} args
|
|
185
|
-
* @param {string} args.baseUrl
|
|
186
|
-
* @param {string} args.apiKey
|
|
187
|
-
* @param {string} args.projectsDir
|
|
188
|
-
* @param {boolean} [args.dryRun] When true, builds payloads but skips POST.
|
|
189
|
-
* @param {Function} [args.fetchImpl]
|
|
190
|
-
* @param {Object} [args.logger] { info(line), warn(line) }
|
|
191
|
-
*/
|
|
192
|
-
export async function runIngest({
|
|
193
|
-
baseUrl,
|
|
194
|
-
apiKey,
|
|
195
|
-
projectsDir,
|
|
196
|
-
dryRun = false,
|
|
197
|
-
fetchImpl = fetch,
|
|
198
|
-
logger = console,
|
|
199
|
-
}) {
|
|
200
|
-
if (!baseUrl) throw new Error('runIngest: baseUrl is required');
|
|
201
|
-
if (!apiKey && !dryRun) throw new Error('runIngest: apiKey is required for live ingest');
|
|
202
|
-
|
|
203
|
-
const files = listJsonlFiles(projectsDir);
|
|
204
|
-
if (!files.length) {
|
|
205
|
-
logger.info(`No .jsonl files found under ${projectsDir}.`);
|
|
206
|
-
return [];
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const results = [];
|
|
210
|
-
for (const file of files) {
|
|
211
|
-
let payload;
|
|
212
|
-
try {
|
|
213
|
-
payload = await buildIngestPayload(file);
|
|
214
|
-
} catch (err) {
|
|
215
|
-
results.push({ file, status: 'error', reason: 'read_failed:' + err.message });
|
|
216
|
-
logger.warn(` ${file} -> read_failed: ${err.message}`);
|
|
217
|
-
continue;
|
|
218
|
-
}
|
|
219
|
-
if (payload.tooLarge) {
|
|
220
|
-
results.push({ file, status: 'skipped', reason: 'too_large', size_bytes: payload.sizeBytes });
|
|
221
|
-
logger.warn(` ${file} -> skipped (${payload.sizeBytes} bytes > ${MAX_FILE_BYTES})`);
|
|
222
|
-
continue;
|
|
223
|
-
}
|
|
224
|
-
if (dryRun) {
|
|
225
|
-
results.push({
|
|
226
|
-
file,
|
|
227
|
-
status: 'dry_run',
|
|
228
|
-
reason: 'no_post',
|
|
229
|
-
posted_lines: payload.lineCount,
|
|
230
|
-
size_bytes: payload.sizeBytes,
|
|
231
|
-
slug: payload.body.project.slug,
|
|
232
|
-
});
|
|
233
|
-
logger.info(` ${file} -> dry_run (${payload.lineCount} lines, slug=${payload.body.project.slug})`);
|
|
234
|
-
continue;
|
|
235
|
-
}
|
|
236
|
-
// Light throttle between live POSTs so a fresh-disk backfill of
|
|
237
|
-
// hundreds of files doesn't hammer Vercel's per-IP rate limit.
|
|
238
|
-
if (results.length > 0) await sleep(150);
|
|
239
|
-
try {
|
|
240
|
-
const { status, ok, payload: respBody } = await postIngest(baseUrl, apiKey, payload.body, { fetchImpl });
|
|
241
|
-
if (!ok) {
|
|
242
|
-
results.push({ file, status: 'error', reason: 'http_' + status, posted_lines: payload.lineCount });
|
|
243
|
-
logger.warn(` ${file} -> HTTP ${status}`);
|
|
244
|
-
continue;
|
|
245
|
-
}
|
|
246
|
-
const sess = respBody?.session || {};
|
|
247
|
-
results.push({
|
|
248
|
-
file,
|
|
249
|
-
status: sess.skipped ? 'skipped_unchanged' : 'ingested',
|
|
250
|
-
reason: sess.reason || 'ok',
|
|
251
|
-
posted_lines: payload.lineCount,
|
|
252
|
-
session_id: sess.id || null,
|
|
253
|
-
});
|
|
254
|
-
logger.info(` ${file} -> ${sess.skipped ? 'skipped_unchanged' : 'ingested'} (${payload.lineCount} lines)`);
|
|
255
|
-
} catch (err) {
|
|
256
|
-
results.push({ file, status: 'error', reason: 'network:' + err.message });
|
|
257
|
-
logger.warn(` ${file} -> network: ${err.message}`);
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
return results;
|
|
261
|
-
}
|
package/lib/code/memo.js
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
// Fetch the most recent weekly memo for a project.
|
|
2
|
-
|
|
3
|
-
import fs from 'node:fs';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
|
|
6
|
-
async function getMemos(baseUrl, apiKey, projectIdOrSlug, { fetchImpl = fetch }) {
|
|
7
|
-
const url = baseUrl.replace(/\/+$/, '') + '/api/code-sessions/memos?project=' + encodeURIComponent(projectIdOrSlug);
|
|
8
|
-
const res = await fetchImpl(url, {
|
|
9
|
-
headers: { 'x-api-key': apiKey },
|
|
10
|
-
});
|
|
11
|
-
let body = null;
|
|
12
|
-
try { body = await res.json(); } catch { /* keep null */ }
|
|
13
|
-
return { status: res.status, ok: res.ok, body };
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* @param {Object} args
|
|
18
|
-
* @param {string} args.baseUrl
|
|
19
|
-
* @param {string} args.apiKey
|
|
20
|
-
* @param {string} args.project Project slug or id.
|
|
21
|
-
* @param {boolean} [args.save] When true, writes the memo to ./memos/<weekTag>-<slug>.md.
|
|
22
|
-
* @param {Function} [args.fetchImpl]
|
|
23
|
-
* @param {Object} [args.logger]
|
|
24
|
-
*/
|
|
25
|
-
export async function runMemo({ baseUrl, apiKey, project, save = false, fetchImpl = fetch, logger = console }) {
|
|
26
|
-
if (!baseUrl) throw new Error('runMemo: baseUrl is required');
|
|
27
|
-
if (!apiKey) throw new Error('runMemo: apiKey is required');
|
|
28
|
-
if (!project) throw new Error('runMemo: project (slug or id) is required');
|
|
29
|
-
|
|
30
|
-
const { status, ok, body } = await getMemos(baseUrl, apiKey, project, { fetchImpl });
|
|
31
|
-
if (!ok) {
|
|
32
|
-
throw new Error('HTTP ' + status + (body?.error ? ' — ' + body.error : ''));
|
|
33
|
-
}
|
|
34
|
-
const memos = Array.isArray(body?.memos) ? body.memos : [];
|
|
35
|
-
if (!memos.length) {
|
|
36
|
-
logger.info('No memos yet for project ' + project + '.');
|
|
37
|
-
return { saved: false, memo: null };
|
|
38
|
-
}
|
|
39
|
-
// Most recent first (the API already sorts by iso_week_tag DESC, but be
|
|
40
|
-
// defensive in case that contract slips).
|
|
41
|
-
memos.sort((a, b) => String(b.iso_week_tag).localeCompare(String(a.iso_week_tag)));
|
|
42
|
-
const memo = memos[0];
|
|
43
|
-
|
|
44
|
-
logger.info('--- ' + memo.iso_week_tag + ' ' + project + ' ---');
|
|
45
|
-
if (memo.body_md) logger.info(memo.body_md);
|
|
46
|
-
|
|
47
|
-
if (save) {
|
|
48
|
-
const dir = path.join(process.cwd(), 'memos');
|
|
49
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
50
|
-
const safeSlug = String(project).replace(/[^A-Za-z0-9_.-]+/g, '_').slice(0, 80);
|
|
51
|
-
const filePath = path.join(dir, memo.iso_week_tag + '-' + safeSlug + '.md');
|
|
52
|
-
fs.writeFileSync(filePath, memo.body_md || '', 'utf8');
|
|
53
|
-
logger.info('Saved to ' + filePath);
|
|
54
|
-
return { saved: true, memo, filePath };
|
|
55
|
-
}
|
|
56
|
-
return { saved: false, memo };
|
|
57
|
-
}
|
package/lib/code/vendored.js
DELETED
|
@@ -1,219 +0,0 @@
|
|
|
1
|
-
// VENDORED from app/lib/claude-code/optimal-files/merge.js and
|
|
2
|
-
// app/lib/claude-code/optimal-files/bundle.js.
|
|
3
|
-
//
|
|
4
|
-
// The CLI is a sibling package and can't import from app/lib/ without
|
|
5
|
-
// monorepo tooling, but Phase 6's `dashclaw code apply` needs the
|
|
6
|
-
// path-traversal guard and the markdown merge applier locally. Keep this
|
|
7
|
-
// file in sync with the canonical source via
|
|
8
|
-
// `node scripts/sync-cli-vendored-code.mjs`.
|
|
9
|
-
//
|
|
10
|
-
// Source of truth:
|
|
11
|
-
// app/lib/claude-code/optimal-files/merge.js
|
|
12
|
-
// app/lib/claude-code/optimal-files/bundle.js#absolutize
|
|
13
|
-
//
|
|
14
|
-
// Only `applyMerge`, `previewMerge`, the heading/bullet helpers, and the
|
|
15
|
-
// `_ensureInsideProject` guard are vendored. Everything else stays on the
|
|
16
|
-
// server side.
|
|
17
|
-
|
|
18
|
-
import path from 'node:path';
|
|
19
|
-
|
|
20
|
-
// --- _ensureInsideProject (originally from bundle.js#absolutize) ---------
|
|
21
|
-
|
|
22
|
-
export function _ensureInsideProject(projectCwd, candidatePath) {
|
|
23
|
-
if (!projectCwd || !candidatePath) return null;
|
|
24
|
-
let abs;
|
|
25
|
-
if (path.isAbsolute(candidatePath)) {
|
|
26
|
-
abs = path.normalize(candidatePath);
|
|
27
|
-
} else {
|
|
28
|
-
abs = path.normalize(path.join(projectCwd, candidatePath));
|
|
29
|
-
}
|
|
30
|
-
const cwdNorm = path.normalize(projectCwd);
|
|
31
|
-
const ci = process.platform === 'win32';
|
|
32
|
-
const a = ci ? abs.toLowerCase() : abs;
|
|
33
|
-
const b = ci ? cwdNorm.toLowerCase() : cwdNorm;
|
|
34
|
-
if (a !== b && !a.startsWith(b + path.sep)) return null;
|
|
35
|
-
return abs;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// --- markdown merge (originally from merge.js) ---------------------------
|
|
39
|
-
|
|
40
|
-
export function parseMarkdownSections(text) {
|
|
41
|
-
const lines = String(text || '').split(/\r?\n/);
|
|
42
|
-
const sections = [];
|
|
43
|
-
let cur = { level: 0, heading: '__preamble__', headingRaw: '', body: [] };
|
|
44
|
-
for (const line of lines) {
|
|
45
|
-
const m = line.match(/^(#{1,6})\s+(.+?)\s*$/);
|
|
46
|
-
if (m) {
|
|
47
|
-
sections.push(cur);
|
|
48
|
-
cur = { level: m[1].length, heading: normalizeHeading(m[2]), headingRaw: m[2], body: [] };
|
|
49
|
-
} else {
|
|
50
|
-
cur.body.push(line);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
sections.push(cur);
|
|
54
|
-
return sections;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function normalizeHeading(h) {
|
|
58
|
-
return String(h || '')
|
|
59
|
-
.toLowerCase()
|
|
60
|
-
.replace(/[`*_]/g, '')
|
|
61
|
-
.replace(/[^\w\s]+/g, ' ')
|
|
62
|
-
.replace(/\s+/g, ' ')
|
|
63
|
-
.trim();
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function parseBullets(bodyLines) {
|
|
67
|
-
const bullets = [];
|
|
68
|
-
const other = [];
|
|
69
|
-
for (const line of bodyLines) {
|
|
70
|
-
if (/^\s*[-*]\s+/.test(line)) bullets.push({ raw: line, normalized: normalizeBullet(line) });
|
|
71
|
-
else other.push(line);
|
|
72
|
-
}
|
|
73
|
-
return { bullets, other };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function normalizeBullet(line) {
|
|
77
|
-
return String(line || '')
|
|
78
|
-
.replace(/^\s*[-*]\s+/, '')
|
|
79
|
-
.replace(/`[^`]*`/g, ' ')
|
|
80
|
-
.replace(/[^a-z0-9\s]+/gi, ' ')
|
|
81
|
-
.toLowerCase()
|
|
82
|
-
.trim()
|
|
83
|
-
.split(/\s+/)
|
|
84
|
-
.slice(0, 8)
|
|
85
|
-
.join(' ');
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function bulletAlreadyPresent(generatedNorm, existingBullets) {
|
|
89
|
-
if (!generatedNorm) return true;
|
|
90
|
-
for (const b of existingBullets) {
|
|
91
|
-
if (!b.normalized) continue;
|
|
92
|
-
if (b.normalized.startsWith(generatedNorm)) return true;
|
|
93
|
-
if (generatedNorm.startsWith(b.normalized) && b.normalized.length >= 4) return true;
|
|
94
|
-
const aWords = generatedNorm.split(' ');
|
|
95
|
-
const bWords = b.normalized.split(' ');
|
|
96
|
-
let shared = 0;
|
|
97
|
-
for (let i = 0; i < Math.min(aWords.length, bWords.length); i++) {
|
|
98
|
-
if (aWords[i] === bWords[i]) shared++; else break;
|
|
99
|
-
}
|
|
100
|
-
if (shared >= 5) return true;
|
|
101
|
-
}
|
|
102
|
-
return false;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function isFooterSection(section) {
|
|
106
|
-
if (!section || !Array.isArray(section.body)) return false;
|
|
107
|
-
const joined = section.body.join('\n').toLowerCase();
|
|
108
|
-
return (joined.includes('generated by dashclaw') || joined.includes('generated by agentlens')) && joined.includes('review before committing');
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function renderSection(section) {
|
|
112
|
-
return `## ${section.headingRaw}\n${section.body.join('\n')}`.replace(/\n+$/, '\n');
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export function previewMerge(existing, generated) {
|
|
116
|
-
const ex = parseMarkdownSections(existing);
|
|
117
|
-
const gn = parseMarkdownSections(generated);
|
|
118
|
-
const exByHeading = new Map();
|
|
119
|
-
for (const s of ex) {
|
|
120
|
-
if (s.heading === '__preamble__') continue;
|
|
121
|
-
if (!exByHeading.has(s.heading)) exByHeading.set(s.heading, s);
|
|
122
|
-
}
|
|
123
|
-
const appendSections = [];
|
|
124
|
-
const sharedSections = [];
|
|
125
|
-
for (let i = 0; i < gn.length; i++) {
|
|
126
|
-
const s = gn[i];
|
|
127
|
-
if (s.heading === '__preamble__') continue;
|
|
128
|
-
if (isFooterSection(s)) continue;
|
|
129
|
-
if (s.level !== 2) continue;
|
|
130
|
-
const exSection = exByHeading.get(s.heading);
|
|
131
|
-
if (!exSection) {
|
|
132
|
-
appendSections.push({ heading: s.heading, headingRaw: s.headingRaw, bodyText: renderSection(s) });
|
|
133
|
-
} else {
|
|
134
|
-
const exBullets = parseBullets(exSection.body).bullets;
|
|
135
|
-
const genBullets = parseBullets(s.body).bullets;
|
|
136
|
-
const candidates = [];
|
|
137
|
-
for (const gb of genBullets) {
|
|
138
|
-
if (gb.normalized && !bulletAlreadyPresent(gb.normalized, exBullets)) {
|
|
139
|
-
candidates.push({ text: gb.raw.replace(/^\s*[-*]\s+/, '- ') });
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
if (candidates.length) {
|
|
143
|
-
sharedSections.push({
|
|
144
|
-
heading: s.heading,
|
|
145
|
-
headingRaw: s.headingRaw,
|
|
146
|
-
existingBulletCount: exBullets.length,
|
|
147
|
-
candidateBullets: candidates,
|
|
148
|
-
});
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
return {
|
|
153
|
-
appendSections,
|
|
154
|
-
sharedSections,
|
|
155
|
-
newSectionCount: appendSections.length,
|
|
156
|
-
sharedSectionCount: sharedSections.length,
|
|
157
|
-
candidateBulletCount: sharedSections.reduce((acc, s) => acc + s.candidateBullets.length, 0),
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function trimTrailingBlanks(lines) {
|
|
162
|
-
const out = lines.slice();
|
|
163
|
-
while (out.length && out[out.length - 1].trim() === '') out.pop();
|
|
164
|
-
return out;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
export function applyMerge(existing, generated, selection) {
|
|
168
|
-
const preview = previewMerge(existing, generated);
|
|
169
|
-
selection = selection || {};
|
|
170
|
-
const acceptedHeadings = new Set((selection.acceptedHeadings || []).map(h => normalizeHeading(h)));
|
|
171
|
-
const acceptedBulletsByHeading = new Map();
|
|
172
|
-
for (const b of (selection.acceptedBullets || [])) {
|
|
173
|
-
const k = normalizeHeading(b.heading);
|
|
174
|
-
if (!acceptedBulletsByHeading.has(k)) acceptedBulletsByHeading.set(k, []);
|
|
175
|
-
acceptedBulletsByHeading.get(k).push(b.text);
|
|
176
|
-
}
|
|
177
|
-
let merged = String(existing == null ? '' : existing);
|
|
178
|
-
if (merged && !merged.endsWith('\n')) merged += '\n';
|
|
179
|
-
if (acceptedBulletsByHeading.size) {
|
|
180
|
-
const sections = parseMarkdownSections(merged);
|
|
181
|
-
const rebuilt = [];
|
|
182
|
-
for (const s of sections) {
|
|
183
|
-
if (s.heading === '__preamble__') { rebuilt.push(s.body.join('\n')); continue; }
|
|
184
|
-
const headingLine = `${'#'.repeat(s.level)} ${s.headingRaw}`;
|
|
185
|
-
const accepted = acceptedBulletsByHeading.get(s.heading) || [];
|
|
186
|
-
if (accepted.length) {
|
|
187
|
-
const trimmedBody = trimTrailingBlanks(s.body);
|
|
188
|
-
rebuilt.push(headingLine);
|
|
189
|
-
rebuilt.push(trimmedBody.join('\n'));
|
|
190
|
-
rebuilt.push('');
|
|
191
|
-
rebuilt.push('<!-- dashclaw:merged-bullets -->');
|
|
192
|
-
for (const t of accepted) rebuilt.push(t.startsWith('- ') ? t : `- ${t}`);
|
|
193
|
-
rebuilt.push('<!-- /dashclaw:merged-bullets -->');
|
|
194
|
-
rebuilt.push('');
|
|
195
|
-
} else {
|
|
196
|
-
rebuilt.push(headingLine);
|
|
197
|
-
rebuilt.push(s.body.join('\n'));
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
merged = rebuilt.join('\n').replace(/\n+$/, '\n');
|
|
201
|
-
}
|
|
202
|
-
const wholeAdditions = preview.appendSections.filter(s => acceptedHeadings.has(s.heading));
|
|
203
|
-
if (wholeAdditions.length) {
|
|
204
|
-
if (!merged.endsWith('\n')) merged += '\n';
|
|
205
|
-
merged += '\n<!-- dashclaw:merged-sections -->\n\n';
|
|
206
|
-
for (const s of wholeAdditions) {
|
|
207
|
-
merged += s.bodyText.endsWith('\n') ? s.bodyText : s.bodyText + '\n';
|
|
208
|
-
merged += '\n';
|
|
209
|
-
}
|
|
210
|
-
merged += '<!-- /dashclaw:merged-sections -->\n';
|
|
211
|
-
}
|
|
212
|
-
return {
|
|
213
|
-
merged,
|
|
214
|
-
additions: {
|
|
215
|
-
sections: wholeAdditions.map(s => s.headingRaw),
|
|
216
|
-
bulletCount: [...acceptedBulletsByHeading.values()].reduce((acc, l) => acc + l.length, 0),
|
|
217
|
-
},
|
|
218
|
-
};
|
|
219
|
-
}
|
package/lib/cost.js
DELETED
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
// cli/lib/cost.js
|
|
2
|
-
//
|
|
3
|
-
// `dashclaw cost [--lens fleet|claude-code] [--period 7d|30d|90d]` — terminal
|
|
4
|
-
// readback over GET /api/finops/spend (the finops rollups). Defaults:
|
|
5
|
-
// lens=claude-code, period=7d — the wedge user's own Claude Code spend.
|
|
6
|
-
|
|
7
|
-
import { apiRequest } from './api.js';
|
|
8
|
-
|
|
9
|
-
export const VALID_LENSES = ['fleet', 'claude-code'];
|
|
10
|
-
export const VALID_PERIODS = ['7d', '30d', '90d'];
|
|
11
|
-
export const USAGE =
|
|
12
|
-
'Usage: dashclaw cost [--lens fleet|claude-code] [--period 7d|30d|90d]\n' +
|
|
13
|
-
' Defaults: --lens claude-code --period 7d';
|
|
14
|
-
|
|
15
|
-
/** Validate flag values. Returns null when fine, or a usage-bearing message. */
|
|
16
|
-
export function validateCostFlags({ lens, period }) {
|
|
17
|
-
if (!VALID_LENSES.includes(lens)) {
|
|
18
|
-
return `Invalid --lens "${lens}". Valid: ${VALID_LENSES.join(', ')}.\n${USAGE}`;
|
|
19
|
-
}
|
|
20
|
-
if (!VALID_PERIODS.includes(period)) {
|
|
21
|
-
return `Invalid --period "${period}". Valid: ${VALID_PERIODS.join(', ')}.\n${USAGE}`;
|
|
22
|
-
}
|
|
23
|
-
return null;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export async function fetchSpend(config, { lens, period }) {
|
|
27
|
-
return apiRequest(config, 'GET', '/api/finops/spend', { query: { lens, period } });
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const money = (n) => '$' + Number(n || 0).toFixed(2);
|
|
31
|
-
|
|
32
|
-
function table(rows) {
|
|
33
|
-
// rows: [label, value][] — right-pad labels so values align.
|
|
34
|
-
const width = Math.max(...rows.map(([label]) => label.length));
|
|
35
|
-
return rows.map(([label, value]) => ` ${label.padEnd(width + 2)}${value}`).join('\n');
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function formatClaudeCode(data, period) {
|
|
39
|
-
const cs = data.code_sessions || {};
|
|
40
|
-
const total = Number(data.code_total_usd || 0);
|
|
41
|
-
const sessions = Number(cs.session_count || 0);
|
|
42
|
-
if (total === 0 && sessions === 0) {
|
|
43
|
-
return (
|
|
44
|
-
`No Claude Code spend recorded yet for ${period}.\n` +
|
|
45
|
-
'Sessions are captured by the Stop hook (on by default, metadata-only;\n' +
|
|
46
|
-
'opt-out: DASHCLAW_CODE_SESSIONS_ENABLED=0). Run a Claude Code session and check back.'
|
|
47
|
-
);
|
|
48
|
-
}
|
|
49
|
-
const lines = [
|
|
50
|
-
`Claude Code spend — last ${period}`,
|
|
51
|
-
'',
|
|
52
|
-
table([
|
|
53
|
-
['Total', money(total)],
|
|
54
|
-
['Sessions', String(sessions)],
|
|
55
|
-
['Cache saved', money(cs.total_cache_savings_usd)],
|
|
56
|
-
]),
|
|
57
|
-
];
|
|
58
|
-
const projects = Array.isArray(cs.by_project) ? cs.by_project.filter((p) => Number(p.cost_usd || 0) > 0) : [];
|
|
59
|
-
if (projects.length > 0) {
|
|
60
|
-
lines.push('', ' By project:');
|
|
61
|
-
lines.push(table(projects.map((p) => [` ${p.project_name || p.project_id || 'unknown'}`, money(p.cost_usd)])));
|
|
62
|
-
}
|
|
63
|
-
lines.push('', `Summary: ${money(total)} across ${sessions} session(s) in the last ${period}.`);
|
|
64
|
-
return lines.join('\n');
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function formatFleet(data, period) {
|
|
68
|
-
const agent = Number(data.agent?.total_cost_usd || 0);
|
|
69
|
-
const x402 = Number(data.x402?.total_spend_usd || 0);
|
|
70
|
-
const total = Number(data.fleet_total_usd || 0);
|
|
71
|
-
if (total === 0) {
|
|
72
|
-
return (
|
|
73
|
-
`No fleet spend recorded yet for ${period}.\n` +
|
|
74
|
-
'Agent spend lands when governed actions report tokens_in/tokens_out + model.'
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
const lines = [
|
|
78
|
-
`Fleet spend — last ${period}`,
|
|
79
|
-
'',
|
|
80
|
-
table([
|
|
81
|
-
['Agent LLM', money(agent)],
|
|
82
|
-
['x402 purchases', money(x402)],
|
|
83
|
-
['Total', money(total)],
|
|
84
|
-
]),
|
|
85
|
-
'',
|
|
86
|
-
`Summary: ${money(total)} fleet spend in the last ${period} (LLM ${money(agent)} + x402 ${money(x402)}).`,
|
|
87
|
-
];
|
|
88
|
-
return lines.join('\n');
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export function formatSpend(data, { lens, period }) {
|
|
92
|
-
return lens === 'claude-code' ? formatClaudeCode(data, period) : formatFleet(data, period);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Validate, fetch, format. Throws a usage-tagged Error on bad flags so the
|
|
97
|
-
* caller prints usage and exits non-zero without a stack trace.
|
|
98
|
-
*/
|
|
99
|
-
export async function runCost(config, { lens = 'claude-code', period = '7d' } = {}, { fetcher = fetchSpend } = {}) {
|
|
100
|
-
const invalid = validateCostFlags({ lens, period });
|
|
101
|
-
if (invalid) {
|
|
102
|
-
const err = new Error(invalid);
|
|
103
|
-
err.usage = true;
|
|
104
|
-
throw err;
|
|
105
|
-
}
|
|
106
|
-
const data = await fetcher(config, { lens, period });
|
|
107
|
-
return formatSpend(data, { lens, period });
|
|
108
|
-
}
|