@dashclaw/cli 0.7.6 → 0.8.0
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/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-codex.js
DELETED
|
@@ -1,244 +0,0 @@
|
|
|
1
|
-
// cli/lib/code/ingest-codex.js
|
|
2
|
-
//
|
|
3
|
-
// Codex JSONL backfill. Walks `codexSessionsDir` (default ~/.codex/sessions),
|
|
4
|
-
// parses each rollout file via app/lib/codex/parser.js, and either:
|
|
5
|
-
//
|
|
6
|
-
// (a) writes the normalized session JSON to an --out directory (default
|
|
7
|
-
// ~/.dashclaw/codex-sessions/) for later server upload, or
|
|
8
|
-
// (b) POSTs it to --endpoint <url> directly.
|
|
9
|
-
//
|
|
10
|
-
// Phase 3 ships option (a) — local-only. Option (b) becomes useful once
|
|
11
|
-
// the server-side ingest route accepts source_host='codex-jsonl' (deferred
|
|
12
|
-
// until the AgentLens absorption lands, to avoid editing files already
|
|
13
|
-
// being modified by another agent).
|
|
14
|
-
//
|
|
15
|
-
// Output JSON shape is the raw parseCodexSessionFile return value. The
|
|
16
|
-
// server, when it grows codex ingestion, will deserialize it and persist.
|
|
17
|
-
//
|
|
18
|
-
// Logs per file: { file, status, reason?, session_uuid?, turns?, tokens? }.
|
|
19
|
-
// NEVER logs raw message text — could leak user transcripts.
|
|
20
|
-
|
|
21
|
-
import fs from 'node:fs';
|
|
22
|
-
import path from 'node:path';
|
|
23
|
-
import { homedir } from 'node:os';
|
|
24
|
-
|
|
25
|
-
// Vendored: the CLI is published without app/, so it can't import the app's
|
|
26
|
-
// TypeScript parser directly. See codex-parser.vendored.js.
|
|
27
|
-
import { parseCodexSessionFile } from './codex-parser.vendored.js';
|
|
28
|
-
|
|
29
|
-
const MAX_FILE_BYTES = 100 * 1024 * 1024; // codex rollouts can grow large
|
|
30
|
-
|
|
31
|
-
export function defaultCodexSessionsDir(env = process.env) {
|
|
32
|
-
if (env.CODEX_SESSIONS_DIR) return env.CODEX_SESSIONS_DIR;
|
|
33
|
-
const root = process.platform === 'win32'
|
|
34
|
-
? (env.USERPROFILE || homedir())
|
|
35
|
-
: (env.HOME || homedir());
|
|
36
|
-
// Codex defaults to <codex_home>/sessions; codex_home defaults to ~/.codex.
|
|
37
|
-
return path.join(env.CODEX_HOME || path.join(root, '.codex'), 'sessions');
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function defaultCodexOutDir(env = process.env) {
|
|
41
|
-
if (env.DASHCLAW_CODEX_OUT_DIR) return env.DASHCLAW_CODEX_OUT_DIR;
|
|
42
|
-
const root = process.platform === 'win32'
|
|
43
|
-
? (env.USERPROFILE || homedir())
|
|
44
|
-
: (env.HOME || homedir());
|
|
45
|
-
return path.join(root, '.dashclaw', 'codex-sessions');
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function listJsonlFiles(rootDir) {
|
|
49
|
-
const out = [];
|
|
50
|
-
let entries;
|
|
51
|
-
try { entries = fs.readdirSync(rootDir, { withFileTypes: true }); }
|
|
52
|
-
catch { return out; }
|
|
53
|
-
for (const e of entries) {
|
|
54
|
-
const p = path.join(rootDir, e.name);
|
|
55
|
-
if (e.isDirectory()) {
|
|
56
|
-
// Recurse one level — codex doesn't currently nest, but archived
|
|
57
|
-
// sessions sometimes land in `<sessions>/archived/`.
|
|
58
|
-
let inner;
|
|
59
|
-
try { inner = fs.readdirSync(p, { withFileTypes: true }); }
|
|
60
|
-
catch { continue; }
|
|
61
|
-
for (const f of inner) {
|
|
62
|
-
if (f.isFile() && f.name.endsWith('.jsonl')) {
|
|
63
|
-
out.push(path.join(p, f.name));
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
} else if (e.isFile() && e.name.endsWith('.jsonl')) {
|
|
67
|
-
out.push(p);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
return out;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function bytesOf(filePath) {
|
|
74
|
-
try { return fs.statSync(filePath).size; }
|
|
75
|
-
catch { return 0; }
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Build the per-session output JSON. Stable shape that the server can
|
|
80
|
-
* consume once codex ingestion lands.
|
|
81
|
-
*/
|
|
82
|
-
function buildOutputPayload(session) {
|
|
83
|
-
return {
|
|
84
|
-
parser_version: session.parserVersion,
|
|
85
|
-
agent_kind: session.agentKind,
|
|
86
|
-
session_uuid: session.sessionUuid,
|
|
87
|
-
cwd: session.cwd,
|
|
88
|
-
cli_version: session.cliVersion,
|
|
89
|
-
originator: session.originator,
|
|
90
|
-
model_provider: session.modelProvider,
|
|
91
|
-
model_primary: session.modelPrimary,
|
|
92
|
-
started_at: session.startedAt,
|
|
93
|
-
ended_at: session.endedAt,
|
|
94
|
-
git_branch: session.gitBranch,
|
|
95
|
-
git_commit: session.gitCommit,
|
|
96
|
-
git_repo_url: session.gitRepoUrl,
|
|
97
|
-
jsonl_records: session.jsonlRecords,
|
|
98
|
-
skipped_lines: session.skippedLines,
|
|
99
|
-
response_items: session.responseItems,
|
|
100
|
-
event_messages: session.eventMessages,
|
|
101
|
-
compacted_items: session.compactedItems,
|
|
102
|
-
tool_call_count: session.toolCallCount,
|
|
103
|
-
turn_count: session.turnCount,
|
|
104
|
-
totals: session.totals,
|
|
105
|
-
turns: session.turns,
|
|
106
|
-
// Messages and tool uses are large — keep them in the payload so the
|
|
107
|
-
// server can persist them when ready. CLI does not log them.
|
|
108
|
-
messages: session.messages,
|
|
109
|
-
tool_uses: session.toolUses,
|
|
110
|
-
source: {
|
|
111
|
-
host: 'codex-jsonl',
|
|
112
|
-
file: session.sourceFile,
|
|
113
|
-
mtime: session.sourceMtime,
|
|
114
|
-
},
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
async function writeLocalOut({ outDir, payload }) {
|
|
119
|
-
fs.mkdirSync(outDir, { recursive: true });
|
|
120
|
-
const id = payload.session_uuid || `unknown-${Date.now()}`;
|
|
121
|
-
const target = path.join(outDir, `${id}.json`);
|
|
122
|
-
fs.writeFileSync(target, JSON.stringify(payload, null, 2) + '\n');
|
|
123
|
-
return target;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
async function postPayload({ endpoint, apiKey, payload, timeoutMs = 10000 }) {
|
|
127
|
-
const url = new URL(endpoint);
|
|
128
|
-
const body = JSON.stringify(payload);
|
|
129
|
-
const headers = {
|
|
130
|
-
'content-type': 'application/json',
|
|
131
|
-
'x-api-key': apiKey,
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
const { request: httpReq } = await import('node:http');
|
|
135
|
-
const { request: httpsReq } = await import('node:https');
|
|
136
|
-
const lib = url.protocol === 'https:' ? httpsReq : httpReq;
|
|
137
|
-
|
|
138
|
-
return new Promise((resolve) => {
|
|
139
|
-
const req = lib({
|
|
140
|
-
method: 'POST',
|
|
141
|
-
host: url.hostname,
|
|
142
|
-
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
|
143
|
-
path: url.pathname + url.search,
|
|
144
|
-
headers: { ...headers, 'content-length': Buffer.byteLength(body).toString() },
|
|
145
|
-
}, (res) => {
|
|
146
|
-
let chunks = '';
|
|
147
|
-
res.setEncoding('utf8');
|
|
148
|
-
res.on('data', (c) => { chunks += c; });
|
|
149
|
-
res.on('end', () => {
|
|
150
|
-
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
151
|
-
resolve({ status: 'ingested', http: res.statusCode });
|
|
152
|
-
} else {
|
|
153
|
-
resolve({ status: 'error', reason: `http_${res.statusCode}`, detail: chunks.slice(0, 200) });
|
|
154
|
-
}
|
|
155
|
-
});
|
|
156
|
-
});
|
|
157
|
-
req.on('error', (err) => resolve({ status: 'error', reason: 'network', detail: err.message }));
|
|
158
|
-
req.setTimeout(timeoutMs, () => { req.destroy(); resolve({ status: 'error', reason: 'timeout' }); });
|
|
159
|
-
req.write(body);
|
|
160
|
-
req.end();
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Top-level orchestration. Returns an array of result objects, one per
|
|
166
|
-
* jsonl file found in `sessionsDir`.
|
|
167
|
-
*/
|
|
168
|
-
export async function runCodexIngest({
|
|
169
|
-
sessionsDir = defaultCodexSessionsDir(),
|
|
170
|
-
outDir = defaultCodexOutDir(),
|
|
171
|
-
endpoint = null, // when set, POST instead of write-local
|
|
172
|
-
apiKey = null,
|
|
173
|
-
dryRun = false,
|
|
174
|
-
logger = console,
|
|
175
|
-
} = {}) {
|
|
176
|
-
if (!fs.existsSync(sessionsDir)) {
|
|
177
|
-
logger.warn?.(`codex ingest: sessions dir does not exist: ${sessionsDir}`);
|
|
178
|
-
return [];
|
|
179
|
-
}
|
|
180
|
-
const files = listJsonlFiles(sessionsDir);
|
|
181
|
-
if (files.length === 0) {
|
|
182
|
-
logger.info?.(`codex ingest: no .jsonl files in ${sessionsDir}`);
|
|
183
|
-
return [];
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const results = [];
|
|
187
|
-
for (const file of files) {
|
|
188
|
-
const size = bytesOf(file);
|
|
189
|
-
if (size === 0) {
|
|
190
|
-
results.push({ file, status: 'skipped', reason: 'empty' });
|
|
191
|
-
continue;
|
|
192
|
-
}
|
|
193
|
-
if (size > MAX_FILE_BYTES) {
|
|
194
|
-
results.push({ file, status: 'skipped', reason: 'too_large', bytes: size });
|
|
195
|
-
continue;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
let session;
|
|
199
|
-
try {
|
|
200
|
-
session = await parseCodexSessionFile(file);
|
|
201
|
-
} catch (err) {
|
|
202
|
-
results.push({ file, status: 'error', reason: 'parse', detail: err.message });
|
|
203
|
-
continue;
|
|
204
|
-
}
|
|
205
|
-
const payload = buildOutputPayload(session);
|
|
206
|
-
|
|
207
|
-
if (dryRun) {
|
|
208
|
-
results.push({
|
|
209
|
-
file,
|
|
210
|
-
status: 'dry_run',
|
|
211
|
-
session_uuid: session.sessionUuid,
|
|
212
|
-
turns: session.turnCount,
|
|
213
|
-
tokens: session.totals,
|
|
214
|
-
});
|
|
215
|
-
continue;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
if (endpoint) {
|
|
219
|
-
const httpResult = await postPayload({ endpoint, apiKey, payload });
|
|
220
|
-
results.push({
|
|
221
|
-
file,
|
|
222
|
-
...httpResult,
|
|
223
|
-
session_uuid: session.sessionUuid,
|
|
224
|
-
turns: session.turnCount,
|
|
225
|
-
});
|
|
226
|
-
} else {
|
|
227
|
-
try {
|
|
228
|
-
const target = await writeLocalOut({ outDir, payload });
|
|
229
|
-
results.push({
|
|
230
|
-
file,
|
|
231
|
-
status: 'written_local',
|
|
232
|
-
session_uuid: session.sessionUuid,
|
|
233
|
-
out: target,
|
|
234
|
-
turns: session.turnCount,
|
|
235
|
-
tokens: session.totals,
|
|
236
|
-
});
|
|
237
|
-
} catch (err) {
|
|
238
|
-
results.push({ file, status: 'error', reason: 'write', detail: err.message });
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
return results;
|
|
244
|
-
}
|
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
|
-
}
|