karajan-code 3.1.0 → 3.3.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 +12 -4
- package/package.json +6 -1
- package/packages/hu-board/package.json +3 -1
- package/packages/hu-board/public/index.html +1 -0
- package/packages/hu-board/public/styles.css +32 -0
- package/packages/hu-board/public/utils/board-view.js +21 -1
- package/packages/hu-board/public/utils/formatters.js +68 -0
- package/packages/hu-board/public/wiki.html +32 -0
- package/packages/hu-board/public/wiki.js +53 -0
- package/packages/hu-board/src/db.js +137 -0
- package/packages/hu-board/src/format.js +124 -0
- package/packages/hu-board/src/plan-mutations.js +4 -4
- package/packages/hu-board/src/routes/api.js +16 -1
- package/packages/hu-board/src/routes/rag.js +1 -1
- package/packages/hu-board/src/routes/wiki.js +43 -0
- package/packages/hu-board/src/run-tracker.js +1 -1
- package/packages/hu-board/src/server.js +4 -2
- package/packages/hu-board/src/sync.js +1 -1
- package/scripts/git-hooks/post-merge +13 -2
- package/src/agents/aider-agent.js +42 -1
- package/src/agents/claude-agent.js +14 -3
- package/src/agents/codex-agent.js +44 -7
- package/src/agents/gemini-agent.js +37 -1
- package/src/agents/opencode-agent.js +34 -1
- package/src/audit/ai-slop-findings.js +102 -0
- package/src/audit/deterministic-summary.js +18 -0
- package/src/brain/standby-scheduler.js +10 -92
- package/src/brain/standby-store.js +12 -168
- package/src/checks/ai-trash.js +34 -0
- package/src/checks/qmd.js +41 -0
- package/src/checks/squeezr.js +36 -0
- package/src/cli/register-meta.js +21 -0
- package/src/cli/register-pipeline.js +3 -0
- package/src/commands/audit.js +6 -4
- package/src/commands/board.js +34 -0
- package/src/commands/doctor.js +6 -0
- package/src/commands/init.js +73 -8
- package/src/commands/qmd.js +86 -0
- package/src/commands/resume.js +6 -45
- package/src/commands/run.js +6 -50
- package/src/git/hu-snapshot.js +10 -105
- package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +27 -0
- package/src/orchestrator/drivers/run-hu-batch.js +5 -1
- package/src/orchestrator/flow-runner.js +10 -2
- package/src/orchestrator/hu-sub-pipeline.js +82 -8
- package/src/plan/plan-hu-ops.js +16 -338
- package/src/plan/plan-id.js +4 -45
- package/src/plan/plan-validation.js +4 -82
- package/src/rag/vec-store.js +17 -253
- package/src/roles/audit-role.js +11 -1
- package/src/session/journal/summary-writer.js +55 -1
- package/src/utils/ai-trash.js +48 -0
- package/src/utils/atomic-write.js +6 -43
- package/src/utils/budget.js +38 -9
- package/src/utils/cli-ask-question.js +76 -0
- package/src/utils/doc-links.js +2 -0
- package/src/utils/os-detect.js +10 -0
- package/src/utils/paths.js +14 -123
- package/src/utils/port-check.js +6 -45
- package/src/utils/process.js +4 -188
- package/src/utils/qmd-collection.js +72 -0
- package/src/utils/qmd-detect.js +18 -0
- package/src/utils/qmd-install.js +37 -0
- package/src/utils/run-registry.js +9 -116
- package/src/utils/shared-paths.js +12 -41
- package/src/utils/squeezr-detect.js +18 -0
- package/src/utils/squeezr-install.js +37 -0
- package/src/utils/telemetry.js +43 -0
|
@@ -17,13 +17,13 @@
|
|
|
17
17
|
import { readFileSync, existsSync, readdirSync, mkdirSync, openSync } from 'node:fs';
|
|
18
18
|
import { join, dirname } from 'node:path';
|
|
19
19
|
import { homedir } from 'node:os';
|
|
20
|
-
import { writeJsonAtomicSync } from '
|
|
20
|
+
import { writeJsonAtomicSync } from '@karajan/core/atomic-write';
|
|
21
21
|
import { spawn } from 'node:child_process';
|
|
22
22
|
import { trackRun, untrack } from './run-tracker.js';
|
|
23
23
|
import { getHuBoardRunsDir } from './db.js';
|
|
24
24
|
import { fileURLToPath } from 'node:url';
|
|
25
|
-
import { updateHuStatus, certifyAllHus, updateHu } from '
|
|
26
|
-
import { validateBlockedByChange } from '
|
|
25
|
+
import { updateHuStatus, certifyAllHus, updateHu } from '@karajan/core/plan-hu-ops';
|
|
26
|
+
import { validateBlockedByChange } from '@karajan/core/plan-validation';
|
|
27
27
|
import { syncPlanFile } from './sync.js';
|
|
28
28
|
import { getDb } from './db.js';
|
|
29
29
|
|
|
@@ -230,7 +230,7 @@ export async function revertHuFromSnapshot({ planId, huId, projectId }) {
|
|
|
230
230
|
const projectDir = plan.projectDir;
|
|
231
231
|
if (!projectDir) return { ok: false, error: 'plan sin projectDir' };
|
|
232
232
|
|
|
233
|
-
const { restoreHuSnapshot } = await import('
|
|
233
|
+
const { restoreHuSnapshot } = await import('@karajan/core/hu-snapshot');
|
|
234
234
|
const restore = await restoreHuSnapshot({ projectDir, huId });
|
|
235
235
|
if (!restore.ok) return { ok: false, error: `restore falló: ${restore.error}` };
|
|
236
236
|
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
listPlanIdsForProject,
|
|
23
23
|
updateStoryStatus,
|
|
24
24
|
setProjectIsTest,
|
|
25
|
+
getProjectCost,
|
|
25
26
|
} from '../db.js';
|
|
26
27
|
import { fullScan } from '../sync.js';
|
|
27
28
|
import { setHuStatus, setHuFields, markPlanReady, runPlan, renameProject, revertHuFromSnapshot } from '../plan-mutations.js';
|
|
@@ -285,6 +286,20 @@ router.get('/stories/:id', (req, res) => {
|
|
|
285
286
|
}
|
|
286
287
|
});
|
|
287
288
|
|
|
289
|
+
/**
|
|
290
|
+
* GET /api/projects/:id/cost - Aggregated $ cost for a project (KJC-TSK-0516).
|
|
291
|
+
* Sums stories.cost_usd, groups by plan_id, returns 404 if project missing.
|
|
292
|
+
*/
|
|
293
|
+
router.get('/projects/:id/cost', (req, res) => {
|
|
294
|
+
try {
|
|
295
|
+
const cost = getProjectCost(req.params.id);
|
|
296
|
+
if (!cost) return res.status(404).json({ error: 'Project not found' });
|
|
297
|
+
res.json(cost);
|
|
298
|
+
} catch (err) {
|
|
299
|
+
res.status(500).json({ error: err.message });
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
288
303
|
/**
|
|
289
304
|
* GET /api/projects/:id/sessions - Sessions for a project.
|
|
290
305
|
*/
|
|
@@ -1325,7 +1340,7 @@ router.post('/rag/query', async (req, res) => {
|
|
|
1325
1340
|
return res.status(400).json({ error: "'text' is required (non-empty string)" });
|
|
1326
1341
|
}
|
|
1327
1342
|
try {
|
|
1328
|
-
const { openVecStore, countChunks } = await import('
|
|
1343
|
+
const { openVecStore, countChunks } = await import('@karajan/core/vec-store');
|
|
1329
1344
|
const { makeEmbedder } = await import('../../../../src/rag/embedders/factory.js');
|
|
1330
1345
|
const { query } = await import('../../../../src/rag/retriever.js');
|
|
1331
1346
|
const db = openVecStore({ dim: 768 });
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { Router } from "express";
|
|
6
6
|
import { existsSync, statSync } from "node:fs";
|
|
7
7
|
import Database from "better-sqlite3";
|
|
8
|
-
import { dbPath } from "
|
|
8
|
+
import { dbPath } from "@karajan/core/vec-store";
|
|
9
9
|
import { readConfig } from "../config-yaml.js";
|
|
10
10
|
|
|
11
11
|
const router = Router();
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// KJC-TSK-0508 — HU Board Wiki panel route.
|
|
2
|
+
// POST /api/wiki/query → spawns `qmd query --format json` against the local
|
|
3
|
+
// per-project QMD wiki (registered by `kj init` via KJC-TSK-0506). Returns
|
|
4
|
+
// 503 with installable:true so the front renders an "Install QMD" CTA
|
|
5
|
+
// instead of a generic error when the binary is missing.
|
|
6
|
+
import { Router } from "express";
|
|
7
|
+
import { runCommand } from "@karajan/core/process";
|
|
8
|
+
|
|
9
|
+
const router = Router();
|
|
10
|
+
const QMD_NOT_INSTALLED = /command not found|ENOENT|not recognized as/i;
|
|
11
|
+
|
|
12
|
+
router.post("/query", async (req, res) => {
|
|
13
|
+
const raw = req.body?.query;
|
|
14
|
+
const query = typeof raw === "string" ? raw.trim() : "";
|
|
15
|
+
if (!query) return res.status(400).json({ ok: false, error: "query is required" });
|
|
16
|
+
const limit = Math.min(Math.max(parseInt(req.body?.limit, 10) || 10, 1), 50);
|
|
17
|
+
const collection = req.body?.collection ? String(req.body.collection).trim() : null;
|
|
18
|
+
const fast = req.body?.fast === true;
|
|
19
|
+
|
|
20
|
+
const args = ["query", query, "--format", "json", "-n", String(limit)];
|
|
21
|
+
if (collection) args.push("-c", collection);
|
|
22
|
+
if (fast) args.push("--no-rerank");
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const out = await runCommand("qmd", args, { timeout: 120_000 });
|
|
26
|
+
if (out.exitCode === 127 || QMD_NOT_INSTALLED.test(out.stderr || "")) {
|
|
27
|
+
return res.status(503).json({ ok: false, error: "qmd not installed", installable: true });
|
|
28
|
+
}
|
|
29
|
+
if (out.exitCode !== 0) {
|
|
30
|
+
const msg = (out.stderr || out.stdout || `qmd exit ${out.exitCode}`).trim();
|
|
31
|
+
return res.status(500).json({ ok: false, error: msg });
|
|
32
|
+
}
|
|
33
|
+
let results;
|
|
34
|
+
try { results = JSON.parse(out.stdout || "[]"); }
|
|
35
|
+
catch { return res.status(500).json({ ok: false, error: "qmd output is not valid JSON" }); }
|
|
36
|
+
res.json({ ok: true, results, count: Array.isArray(results) ? results.length : 0 });
|
|
37
|
+
} catch (err) {
|
|
38
|
+
if (err?.code === "ENOENT") return res.status(503).json({ ok: false, error: "qmd not installed", installable: true });
|
|
39
|
+
res.status(500).json({ ok: false, error: err?.message || String(err) });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export default router;
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* se borran como cleanup (responsabilidad de run-registry).
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { listActiveRuns as fsList } from "
|
|
19
|
+
import { listActiveRuns as fsList } from "@karajan/core/run-registry";
|
|
20
20
|
|
|
21
21
|
const runs = new Map();
|
|
22
22
|
let reaperTimer = null;
|
|
@@ -10,13 +10,14 @@ import { fullScan, startWatcher } from './sync.js';
|
|
|
10
10
|
import apiRoutes from './routes/api.js';
|
|
11
11
|
import pipelineRoutes from './routes/pipeline.js';
|
|
12
12
|
import ragRoutes from './routes/rag.js';
|
|
13
|
+
import wikiRoutes from './routes/wiki.js';
|
|
13
14
|
import { authMiddleware } from './auth.js';
|
|
14
15
|
import { getOrCreateToken, getTokenPath } from './token-store.js';
|
|
15
16
|
import { reapZombieSessions } from './zombie-reaper.js';
|
|
16
17
|
import { reapZombieHus } from './hu-zombie-reaper.js';
|
|
17
18
|
import { setHuStatus as setHuStatusPlanMutation, setHuFailResult as setHuFailResultPlanMutation } from './plan-mutations.js';
|
|
18
19
|
import { cleanupEphemeralProjects } from './ephemeral-cleaner.js';
|
|
19
|
-
import { findAvailablePort as findAvailablePortBase } from '
|
|
20
|
+
import { findAvailablePort as findAvailablePortBase } from '@karajan/core/port-check';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Path to the PID file the CLI's `kj board start` / `kj board stop`
|
|
@@ -237,7 +238,7 @@ async function main() {
|
|
|
237
238
|
// - cooldownUntil > now → re-programa setTimeout que dispara en cooldown
|
|
238
239
|
// Idempotente (lockfile per session). Cero polling.
|
|
239
240
|
try {
|
|
240
|
-
const { reconcileAll } = await import('
|
|
241
|
+
const { reconcileAll } = await import('@karajan/core/standby-scheduler');
|
|
241
242
|
const { spawn } = await import('node:child_process');
|
|
242
243
|
const { join, dirname } = await import('node:path');
|
|
243
244
|
const { fileURLToPath } = await import('node:url');
|
|
@@ -300,6 +301,7 @@ async function main() {
|
|
|
300
301
|
app.use('/api', buildRateLimiter(), authMiddleware(), apiRoutes);
|
|
301
302
|
app.use('/api/pipeline', authMiddleware(), pipelineRoutes);
|
|
302
303
|
app.use('/api/rag', authMiddleware(), ragRoutes);
|
|
304
|
+
app.use('/api/wiki', authMiddleware(), wikiRoutes);
|
|
303
305
|
|
|
304
306
|
// SPA fallback: serve index.html for non-API, non-static routes
|
|
305
307
|
app.get('/{*splat}', (_req, res) => {
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
removeTombstone,
|
|
15
15
|
} from './db.js';
|
|
16
16
|
import { publish as publishEvent } from './event-bus.js';
|
|
17
|
-
import { getSharedPlansDir, SHARED_DIR_NAME, isSharedPath } from '
|
|
17
|
+
import { getSharedPlansDir, SHARED_DIR_NAME, isSharedPath } from '@karajan/core/shared-paths';
|
|
18
18
|
import { readConfig } from './config-yaml.js';
|
|
19
19
|
|
|
20
20
|
/**
|
|
@@ -3,5 +3,16 @@
|
|
|
3
3
|
# never serves stale code. Installed by `kj rag install-hooks`. The marker
|
|
4
4
|
# in this comment is how the installer tells kj-managed hooks from user hooks.
|
|
5
5
|
set -euo pipefail
|
|
6
|
-
if
|
|
7
|
-
kj rag index --since auto >/dev/null 2>&1 || true
|
|
6
|
+
if command -v kj >/dev/null 2>&1; then
|
|
7
|
+
kj rag index --since auto >/dev/null 2>&1 || true
|
|
8
|
+
fi
|
|
9
|
+
|
|
10
|
+
# KJC-TSK-0507 — Refresh the per-project QMD semantic wiki when the merge
|
|
11
|
+
# touched indexable folders (docs/, .reviews/, .karajan/plans/). Background
|
|
12
|
+
# so it never blocks the user's `git pull` / `git merge`.
|
|
13
|
+
if command -v qmd >/dev/null 2>&1; then
|
|
14
|
+
if git diff-tree -r --name-only ORIG_HEAD HEAD 2>/dev/null \
|
|
15
|
+
| grep -qE '^(docs/|\.reviews/|\.karajan/plans/)'; then
|
|
16
|
+
(qmd update >/dev/null 2>&1 || true) &
|
|
17
|
+
fi
|
|
18
|
+
fi
|
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
import { BaseAgent } from "./base-agent.js";
|
|
2
2
|
import { resolveBin } from "./resolve-bin.js";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Extract token usage from Aider's stdout. Two shapes are supported:
|
|
6
|
+
*
|
|
7
|
+
* 1. LiteLLM passthrough — an OpenAI-style JSON `usage` block surfaces
|
|
8
|
+
* `prompt_tokens_details.cached_tokens` for prompt-cache hits.
|
|
9
|
+
* 2. Aider's native footer "Tokens: <sent> sent, <received> received
|
|
10
|
+
* [, <cached> cache hit]." (supports comma-separated and `k` suffix).
|
|
11
|
+
*
|
|
12
|
+
* Returns `{tokens_in, tokens_out, cached_tokens}` or null when no usage
|
|
13
|
+
* is found (= unmeasured; callers leave the fields undefined).
|
|
14
|
+
*/
|
|
15
|
+
export function extractAiderUsage(text) {
|
|
16
|
+
if (!text) return null;
|
|
17
|
+
for (const line of text.split("\n")) {
|
|
18
|
+
const t = line.trim();
|
|
19
|
+
if (!t.startsWith("{")) continue;
|
|
20
|
+
let obj;
|
|
21
|
+
try { obj = JSON.parse(t); } catch { continue; }
|
|
22
|
+
const u = obj?.usage ?? (typeof obj?.prompt_tokens === "number" ? obj : null);
|
|
23
|
+
if (!u || typeof u.prompt_tokens !== "number") continue;
|
|
24
|
+
return {
|
|
25
|
+
tokens_in: u.prompt_tokens,
|
|
26
|
+
tokens_out: u.completion_tokens ?? 0,
|
|
27
|
+
cached_tokens: u.prompt_tokens_details?.cached_tokens ?? 0
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const m = text.match(/Tokens:\s*([\d.,]+k?)\s*sent,\s*([\d.,]+k?)\s*received(?:,\s*([\d.,]+k?)\s*cache\s*hit)?/i);
|
|
31
|
+
if (!m) return null;
|
|
32
|
+
const parse = (s) => {
|
|
33
|
+
const c = s.replaceAll(",", "");
|
|
34
|
+
return c.toLowerCase().endsWith("k")
|
|
35
|
+
? Math.round(parseFloat(c.slice(0, -1)) * 1000)
|
|
36
|
+
: Number(c);
|
|
37
|
+
};
|
|
38
|
+
const sent = parse(m[1]);
|
|
39
|
+
const recv = parse(m[2]);
|
|
40
|
+
if (!Number.isFinite(sent) || !Number.isFinite(recv)) return null;
|
|
41
|
+
return { tokens_in: sent, tokens_out: recv, cached_tokens: m[3] ? parse(m[3]) : 0 };
|
|
42
|
+
}
|
|
43
|
+
|
|
4
44
|
export class AiderAgent extends BaseAgent {
|
|
5
45
|
async runTask(task) {
|
|
6
46
|
const role = task.role || "coder";
|
|
@@ -32,6 +72,7 @@ export class AiderAgent extends BaseAgent {
|
|
|
32
72
|
silenceTimeoutMs: task.silenceTimeoutMs,
|
|
33
73
|
timeout: task.timeoutMs
|
|
34
74
|
});
|
|
35
|
-
|
|
75
|
+
const usage = extractAiderUsage(res.stdout) ?? extractAiderUsage(res.stderr);
|
|
76
|
+
return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode, ...usage };
|
|
36
77
|
}
|
|
37
78
|
}
|
|
@@ -68,8 +68,16 @@ function collectAssistantText(obj) {
|
|
|
68
68
|
/**
|
|
69
69
|
* Extract usage metrics from stream-json/json NDJSON output.
|
|
70
70
|
* Looks for the "result" line which contains total_cost_usd,
|
|
71
|
-
* usage.input_tokens/output_tokens,
|
|
72
|
-
*
|
|
71
|
+
* usage.input_tokens/output_tokens, cache_read_input_tokens,
|
|
72
|
+
* cache_creation_input_tokens, and modelUsage.
|
|
73
|
+
*
|
|
74
|
+
* `cached_tokens` aggregates both cache_read_input_tokens (tokens served
|
|
75
|
+
* from Anthropic's automatic prompt cache) and cache_creation_input_tokens
|
|
76
|
+
* (tokens just written to the cache during this call). The sum represents
|
|
77
|
+
* the total volume of input that benefited from prompt-caching machinery.
|
|
78
|
+
*
|
|
79
|
+
* Returns an object with tokens_in, tokens_out, cached_tokens, cost_usd,
|
|
80
|
+
* model or null if not found.
|
|
73
81
|
*/
|
|
74
82
|
function extractUsageFromStreamJson(raw) {
|
|
75
83
|
const events = parseClaudeEvents(raw);
|
|
@@ -79,11 +87,14 @@ function extractUsageFromStreamJson(raw) {
|
|
|
79
87
|
|
|
80
88
|
const tokens_in = obj.usage?.input_tokens ?? 0;
|
|
81
89
|
const tokens_out = obj.usage?.output_tokens ?? 0;
|
|
90
|
+
const cache_read = obj.usage?.cache_read_input_tokens ?? 0;
|
|
91
|
+
const cache_creation = obj.usage?.cache_creation_input_tokens ?? 0;
|
|
92
|
+
const cached_tokens = cache_read + cache_creation;
|
|
82
93
|
const cost_usd = obj.total_cost_usd ?? undefined;
|
|
83
94
|
const modelUsage = obj.modelUsage;
|
|
84
95
|
const model = modelUsage ? Object.keys(modelUsage)[0] || null : null;
|
|
85
96
|
|
|
86
|
-
return { tokens_in, tokens_out, cost_usd, model };
|
|
97
|
+
return { tokens_in, tokens_out, cached_tokens, cost_usd, model };
|
|
87
98
|
}
|
|
88
99
|
return null;
|
|
89
100
|
}
|
|
@@ -2,18 +2,55 @@ import { BaseAgent } from "./base-agent.js";
|
|
|
2
2
|
import { resolveBin } from "./resolve-bin.js";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* Try to extract an OpenAI-style `usage` JSON block from codex stdout.
|
|
6
|
+
*
|
|
7
|
+
* OpenAI's Chat Completions API exposes prompt-cache hits via
|
|
8
|
+
* `usage.prompt_tokens_details.cached_tokens` (since Aug 2024). Codex
|
|
9
|
+
* CLI doesn't surface this in its default footer, but newer flags or a
|
|
10
|
+
* wrapper may emit a JSON `usage` line. We scan stdout line-by-line and
|
|
11
|
+
* accept the first parseable object that carries `prompt_tokens`.
|
|
12
|
+
*
|
|
13
|
+
* Returns `{tokens_in, tokens_out, cached_tokens}` or null when absent.
|
|
14
|
+
*/
|
|
15
|
+
function extractJsonUsage(stdout) {
|
|
16
|
+
for (const line of stdout.split("\n")) {
|
|
17
|
+
const trimmed = line.trim();
|
|
18
|
+
if (!trimmed.startsWith("{")) continue;
|
|
19
|
+
let obj;
|
|
20
|
+
try { obj = JSON.parse(trimmed); } catch { continue; }
|
|
21
|
+
const usage = obj?.usage ?? (typeof obj?.prompt_tokens === "number" ? obj : null);
|
|
22
|
+
if (!usage || typeof usage.prompt_tokens !== "number") continue;
|
|
23
|
+
return {
|
|
24
|
+
tokens_in: usage.prompt_tokens,
|
|
25
|
+
tokens_out: usage.completion_tokens ?? 0,
|
|
26
|
+
cached_tokens: usage.prompt_tokens_details?.cached_tokens ?? 0
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Extract token usage from Codex CLI stdout. Two shapes are supported:
|
|
34
|
+
*
|
|
35
|
+
* 1. OpenAI-style JSON `usage` block (preferred):
|
|
36
|
+
* `{"usage":{"prompt_tokens":N,"completion_tokens":M,
|
|
37
|
+
* "prompt_tokens_details":{"cached_tokens":C}}}`
|
|
38
|
+
* → `{tokens_in:N, tokens_out:M, cached_tokens:C}`.
|
|
39
|
+
*
|
|
40
|
+
* 2. Legacy "tokens used\n<number>" footer (codex's default total,
|
|
41
|
+
* no cache visibility) → `{tokens_in:0, tokens_out, cached_tokens:0}`.
|
|
42
|
+
*
|
|
43
|
+
* Returns null when neither shape is found.
|
|
10
44
|
*/
|
|
11
45
|
function extractCodexTokens(stdout) {
|
|
12
|
-
|
|
46
|
+
if (!stdout) return null;
|
|
47
|
+
const json = extractJsonUsage(stdout);
|
|
48
|
+
if (json) return json;
|
|
49
|
+
const match = stdout.match(/tokens?\s+used\s*\n\s*([\d,]+)/i);
|
|
13
50
|
if (!match) return null;
|
|
14
51
|
const total = Number(match[1].replaceAll(/,/g, ""));
|
|
15
52
|
if (!Number.isFinite(total) || total <= 0) return null;
|
|
16
|
-
return { tokens_in: 0, tokens_out: total };
|
|
53
|
+
return { tokens_in: 0, tokens_out: total, cached_tokens: 0 };
|
|
17
54
|
}
|
|
18
55
|
|
|
19
56
|
export class CodexAgent extends BaseAgent {
|
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
import { BaseAgent } from "./base-agent.js";
|
|
2
2
|
import { resolveBin } from "./resolve-bin.js";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Extract Gemini's `usageMetadata` from stdout, including
|
|
6
|
+
* `cachedContentTokenCount` (Gemini's context-caching API metric,
|
|
7
|
+
* https://ai.google.dev/api/caching).
|
|
8
|
+
*
|
|
9
|
+
* The gemini CLI emits `usageMetadata` as part of its JSON response
|
|
10
|
+
* shape (today: only `--output-format json`, used in reviewTask; in
|
|
11
|
+
* plain runTask mode no usage is surfaced). When absent, returns null
|
|
12
|
+
* so the caller leaves `cached_tokens` undefined (= unmeasured,
|
|
13
|
+
* distinct from a measured 0).
|
|
14
|
+
*/
|
|
15
|
+
export function extractGeminiUsage(text) {
|
|
16
|
+
if (!text) return null;
|
|
17
|
+
const candidates = [];
|
|
18
|
+
for (const line of text.split("\n")) {
|
|
19
|
+
const t = line.trim();
|
|
20
|
+
if (t.startsWith("{") || t.startsWith("[")) candidates.push(t);
|
|
21
|
+
}
|
|
22
|
+
candidates.push(text.trim());
|
|
23
|
+
for (const candidate of candidates) {
|
|
24
|
+
let obj;
|
|
25
|
+
try { obj = JSON.parse(candidate); } catch { continue; }
|
|
26
|
+
const meta = obj?.usageMetadata
|
|
27
|
+
?? obj?.usage_metadata
|
|
28
|
+
?? obj?.response?.usageMetadata;
|
|
29
|
+
if (!meta || typeof meta.promptTokenCount !== "number") continue;
|
|
30
|
+
return {
|
|
31
|
+
tokens_in: meta.promptTokenCount,
|
|
32
|
+
tokens_out: meta.candidatesTokenCount ?? 0,
|
|
33
|
+
cached_tokens: meta.cachedContentTokenCount ?? 0
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
4
39
|
export class GeminiAgent extends BaseAgent {
|
|
5
40
|
async runTask(task) {
|
|
6
41
|
const role = task.role || "coder";
|
|
@@ -33,6 +68,7 @@ export class GeminiAgent extends BaseAgent {
|
|
|
33
68
|
silenceTimeoutMs: task.silenceTimeoutMs,
|
|
34
69
|
timeout: task.timeoutMs
|
|
35
70
|
});
|
|
36
|
-
|
|
71
|
+
const usage = extractGeminiUsage(res.stdout) ?? extractGeminiUsage(res.stderr);
|
|
72
|
+
return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode, ...usage };
|
|
37
73
|
}
|
|
38
74
|
}
|
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
import { BaseAgent } from "./base-agent.js";
|
|
2
2
|
import { resolveBin } from "./resolve-bin.js";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Extract OpenAI-style `usage` from OpenCode stdout. With `--format json`
|
|
6
|
+
* (used in reviewTask) the CLI emits a JSON document; with LiteLLM-backed
|
|
7
|
+
* runs that document carries `usage.prompt_tokens_details.cached_tokens`.
|
|
8
|
+
*
|
|
9
|
+
* Accepts the whole payload, individual lines, or array elements with a
|
|
10
|
+
* nested `usage` object. Returns `{tokens_in, tokens_out, cached_tokens}`
|
|
11
|
+
* or null (= unmeasured).
|
|
12
|
+
*/
|
|
13
|
+
export function extractOpenCodeUsage(text) {
|
|
14
|
+
if (!text) return null;
|
|
15
|
+
const candidates = [text.trim()];
|
|
16
|
+
for (const line of text.split("\n")) {
|
|
17
|
+
const t = line.trim();
|
|
18
|
+
if (t.startsWith("{") || t.startsWith("[")) candidates.push(t);
|
|
19
|
+
}
|
|
20
|
+
for (const candidate of candidates) {
|
|
21
|
+
let obj;
|
|
22
|
+
try { obj = JSON.parse(candidate); } catch { continue; }
|
|
23
|
+
const usage = obj?.usage
|
|
24
|
+
?? obj?.result?.usage
|
|
25
|
+
?? (Array.isArray(obj) ? obj.find((e) => e?.usage)?.usage : null);
|
|
26
|
+
if (!usage || typeof usage.prompt_tokens !== "number") continue;
|
|
27
|
+
return {
|
|
28
|
+
tokens_in: usage.prompt_tokens,
|
|
29
|
+
tokens_out: usage.completion_tokens ?? 0,
|
|
30
|
+
cached_tokens: usage.prompt_tokens_details?.cached_tokens ?? 0
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
4
36
|
export class OpenCodeAgent extends BaseAgent {
|
|
5
37
|
async runTask(task) {
|
|
6
38
|
const role = task.role || "coder";
|
|
@@ -34,6 +66,7 @@ export class OpenCodeAgent extends BaseAgent {
|
|
|
34
66
|
silenceTimeoutMs: task.silenceTimeoutMs,
|
|
35
67
|
timeout: task.timeoutMs
|
|
36
68
|
});
|
|
37
|
-
|
|
69
|
+
const usage = extractOpenCodeUsage(res.stdout) ?? extractOpenCodeUsage(res.stderr);
|
|
70
|
+
return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode, ...usage };
|
|
38
71
|
}
|
|
39
72
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// AI-slop tells detector — KJC-TSK-0503. Deterministic, offline,
|
|
2
|
+
// regex-based scanner over JS/TS source. Five categories:
|
|
3
|
+
// tautologic-comments, banner-separators, boilerplate-docstrings,
|
|
4
|
+
// magic-fallbacks, redundant-jsdoc. Score 100 = clean.
|
|
5
|
+
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
const FILE_EXTS = new Set([".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"]);
|
|
10
|
+
const IGNORE_DIRS = new Set([
|
|
11
|
+
"node_modules", "dist", "build", "coverage",
|
|
12
|
+
".git", ".karajan", "public", ".next", ".nuxt", ".vercel", ".cache",
|
|
13
|
+
]);
|
|
14
|
+
const VERB = "(?:returns?|gets?|sets?|fetches?|loads?|saves?|creates?|builds?|makes?|computes?|handles?|checks?|validates?)";
|
|
15
|
+
const LINE_PATTERNS = {
|
|
16
|
+
"banner-separators": /\/[/*]\s*[=*\-_#~]{5,}/,
|
|
17
|
+
"magic-fallbacks": /(\?\?|\|\|)\s*(?:""|''|null|undefined)\s*(?:\)|;|,|$)/,
|
|
18
|
+
"redundant-jsdoc": new RegExp(`@param\\s+\\{[^}]+\\}\\s+(\\w+)\\s+(?:-\\s*)?(?:the\\s+)?\\1\\s+(?:parameter|argument|value)`, "i"),
|
|
19
|
+
};
|
|
20
|
+
const TAUT_RE = new RegExp(`^\\s*//\\s*${VERB}\\s+(?:the\\s+|a\\s+|an\\s+)?(\\w+)\\s*\\.?\\s*$`, "i");
|
|
21
|
+
const DOC_RE = new RegExp(`^\\s*/\\*\\*\\s*${VERB}\\s+(?:the\\s+|a\\s+|an\\s+)?(\\w+)\\s*\\.?\\s*\\*/\\s*$`, "i");
|
|
22
|
+
const DECL_RE = /^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|const|let|var|class)\s+(\w+)/;
|
|
23
|
+
|
|
24
|
+
export const CATEGORIES = Object.freeze([
|
|
25
|
+
"tautologic-comments", "banner-separators", "boilerplate-docstrings",
|
|
26
|
+
"magic-fallbacks", "redundant-jsdoc",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
function nextDecl(lines, from) {
|
|
30
|
+
for (let j = from; j < Math.min(from + 3, lines.length); j++) {
|
|
31
|
+
const m = lines[j]?.match(DECL_RE);
|
|
32
|
+
if (m) return m[1];
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const stem = (a, b) => { const al = a.toLowerCase(), bl = b.toLowerCase(); return al === bl || al.includes(bl) || bl.includes(al); };
|
|
37
|
+
|
|
38
|
+
export function scanText(relPath, text) {
|
|
39
|
+
const out = [];
|
|
40
|
+
const lines = text.split(/\r?\n/);
|
|
41
|
+
for (let i = 0; i < lines.length; i++) {
|
|
42
|
+
const line = lines[i];
|
|
43
|
+
const lineNo = i + 1;
|
|
44
|
+
for (const [category, re] of Object.entries(LINE_PATTERNS)) {
|
|
45
|
+
if (re.test(line)) out.push({ path: relPath, line: lineNo, category, snippet: line.trim().slice(0, 100) });
|
|
46
|
+
}
|
|
47
|
+
const t = line.match(TAUT_RE);
|
|
48
|
+
if (t) {
|
|
49
|
+
const decl = nextDecl(lines, i + 1);
|
|
50
|
+
if (decl && stem(decl, t[1])) out.push({ path: relPath, line: lineNo, category: "tautologic-comments", snippet: line.trim().slice(0, 100) });
|
|
51
|
+
}
|
|
52
|
+
const d = line.match(DOC_RE);
|
|
53
|
+
if (d) {
|
|
54
|
+
const decl = nextDecl(lines, i + 1);
|
|
55
|
+
if (decl && stem(decl, d[1])) out.push({ path: relPath, line: lineNo, category: "boilerplate-docstrings", snippet: line.trim().slice(0, 100) });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function listSourceFiles(root) {
|
|
62
|
+
const out = [];
|
|
63
|
+
const stack = [root];
|
|
64
|
+
while (stack.length) {
|
|
65
|
+
const dir = stack.pop();
|
|
66
|
+
let entries;
|
|
67
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
68
|
+
for (const e of entries) {
|
|
69
|
+
const full = path.join(dir, e.name);
|
|
70
|
+
if (e.isDirectory()) {
|
|
71
|
+
if (IGNORE_DIRS.has(e.name) || e.name.startsWith(".")) continue;
|
|
72
|
+
stack.push(full);
|
|
73
|
+
} else if (FILE_EXTS.has(path.extname(e.name))) out.push(full);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function collectAiSlop(rootDir, opts = {}) {
|
|
80
|
+
const maxLOC = Number.isFinite(opts.maxLOC) ? opts.maxLOC : 200_000;
|
|
81
|
+
const maxWorst = Number.isFinite(opts.maxWorst) ? opts.maxWorst : 10;
|
|
82
|
+
try {
|
|
83
|
+
const findings = [];
|
|
84
|
+
let totalLOC = 0, filesScanned = 0;
|
|
85
|
+
for (const file of listSourceFiles(rootDir)) {
|
|
86
|
+
if (totalLOC >= maxLOC) break;
|
|
87
|
+
let content;
|
|
88
|
+
try { content = fs.readFileSync(file, "utf-8"); } catch { continue; }
|
|
89
|
+
findings.push(...scanText(path.relative(rootDir, file) || file, content));
|
|
90
|
+
totalLOC += content.split(/\r?\n/).length;
|
|
91
|
+
filesScanned++;
|
|
92
|
+
}
|
|
93
|
+
const byCategory = Object.fromEntries(CATEGORIES.map((c) => [c, 0]));
|
|
94
|
+
for (const f of findings) byCategory[f.category]++;
|
|
95
|
+
const density = totalLOC > 0 ? (findings.length / totalLOC) * 100 : 0;
|
|
96
|
+
const score = Math.max(0, Math.min(100, Math.round(100 - density * 100)));
|
|
97
|
+
const worst = findings.slice().sort((a, b) => (a.path === b.path ? a.line - b.line : a.path.localeCompare(b.path))).slice(0, maxWorst);
|
|
98
|
+
return { available: true, filesScanned, totalLOC, total: findings.length, score, byCategory, worst };
|
|
99
|
+
} catch (err) {
|
|
100
|
+
return { available: false, reason: err?.message || String(err) };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -41,11 +41,28 @@ export function formatDeterministicSummary(ctx) {
|
|
|
41
41
|
if (ctx.circularDeps) lines.push(...formatCircularDepsBlock(ctx.circularDeps));
|
|
42
42
|
if (ctx.deadExports) lines.push(...formatDeadExportsBlock(ctx.deadExports));
|
|
43
43
|
if (ctx.injectionFindings) lines.push(...formatInjectionBlock(ctx.injectionFindings));
|
|
44
|
+
if (ctx.aiSlop) lines.push(...formatAiSlopBlock(ctx.aiSlop));
|
|
44
45
|
if (ctx.webperf) lines.push(...formatWebperfBlock(ctx.webperf));
|
|
45
46
|
|
|
46
47
|
return lines.join("\n");
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
function formatAiSlopBlock(slop) {
|
|
51
|
+
if (!slop.available) return ["### AI-slop tells (deterministic)", `- Status: not available — ${slop.reason || "scan failed"}`, ""];
|
|
52
|
+
const lines = ["### AI-slop tells (deterministic)", `- Files scanned: ${slop.filesScanned ?? 0}`, `- Score: ${slop.score}/100 (100 = clean)`, `- Findings: ${slop.total ?? 0}`];
|
|
53
|
+
if ((slop.total ?? 0) > 0) {
|
|
54
|
+
for (const [cat, n] of Object.entries(slop.byCategory || {})) {
|
|
55
|
+
if (n > 0) lines.push(` - ${cat}: ${n}`);
|
|
56
|
+
}
|
|
57
|
+
if (Array.isArray(slop.worst) && slop.worst.length > 0) {
|
|
58
|
+
lines.push(" - Worst offenders:");
|
|
59
|
+
for (const f of slop.worst) lines.push(` - ${f.path}:${f.line} [${f.category}] ${f.snippet}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
lines.push("");
|
|
63
|
+
return lines;
|
|
64
|
+
}
|
|
65
|
+
|
|
49
66
|
function formatInjectionBlock(inj) {
|
|
50
67
|
if (!inj.available) return ["### Prompt-injection scan", `- Status: not available — ${inj.reason || "scan failed"}`, ""];
|
|
51
68
|
const lines = ["### Prompt-injection scan (specs / domain / onboarding)", `- Files scanned: ${inj.scanned ?? 0}`, `- Findings: ${inj.total ?? 0}`];
|
|
@@ -278,5 +295,6 @@ export function deterministicContextHasFindings(ctx) {
|
|
|
278
295
|
if (ctx.circularDeps?.available && (ctx.circularDeps.total ?? 0) > 0) return true;
|
|
279
296
|
if (ctx.deadExports?.available && (ctx.deadExports.total ?? 0) > 0) return true;
|
|
280
297
|
if (ctx.growthDelta && (Math.abs(ctx.growthDelta.lines || 0) > 100 || Math.abs(ctx.growthDelta.deps || 0) > 0)) return true;
|
|
298
|
+
if (ctx.aiSlop?.available && (ctx.aiSlop.total ?? 0) > 0) return true;
|
|
281
299
|
return false;
|
|
282
300
|
}
|