diffowl 0.3.0 → 0.3.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/dist/cli.js CHANGED
@@ -112,7 +112,10 @@ function findConfigPath() {
112
112
  return join(process.cwd(), CONFIG_FILENAME);
113
113
  }
114
114
  async function loadConfig() {
115
- const configPath = findConfigPath();
115
+ return loadConfigFromRoot(dirname(findConfigPath()));
116
+ }
117
+ async function loadConfigFromRoot(root) {
118
+ const configPath = join(root, CONFIG_FILENAME);
116
119
  if (!existsSync(configPath)) {
117
120
  return parseConfigInput({});
118
121
  }
@@ -213,6 +216,9 @@ async function ensureServer(port) {
213
216
  return baseUrl;
214
217
  }
215
218
  }
219
+ if (await stopUnhealthyServerListener(port)) {
220
+ await waitUntilPortFree(port);
221
+ }
216
222
  await spawnServer(port);
217
223
  for (let i = 0; i < MAX_RETRIES; i++) {
218
224
  await sleep(STARTUP_WAIT_MS / MAX_RETRIES);
@@ -323,6 +329,19 @@ async function stopManagedServer() {
323
329
  }
324
330
  return true;
325
331
  }
332
+ async function stopUnhealthyServerListener(port) {
333
+ const listenerPid = await findOpencodeListenerPid(port);
334
+ if (listenerPid === null) {
335
+ return false;
336
+ }
337
+ try {
338
+ process.kill(listenerPid, "SIGTERM");
339
+ } catch {
340
+ return false;
341
+ }
342
+ await cleanupPidFile();
343
+ return true;
344
+ }
326
345
  async function cleanupPidFile() {
327
346
  const pidFile = join2(getDiffOwlDir(), "server.pid");
328
347
  if (!existsSync2(pidFile)) {
@@ -376,12 +395,12 @@ async function findOpencodeListenerPidWindows(port) {
376
395
  async function waitUntilPortFree(port) {
377
396
  const deadline = Date.now() + PORT_RELEASE_WAIT_MS;
378
397
  while (Date.now() < deadline) {
379
- if (!await isServerRunning(port)) {
398
+ if (await findOpencodeListenerPid(port) === null) {
380
399
  return;
381
400
  }
382
401
  await sleep(PORT_RELEASE_POLL_MS);
383
402
  }
384
- if (await isServerRunning(port)) {
403
+ if (await findOpencodeListenerPid(port) !== null) {
385
404
  throw new Error(
386
405
  `OpenCode server on port ${port} did not stop within ${PORT_RELEASE_WAIT_MS}ms. Retry: diffowl server stop && diffowl server start`
387
406
  );
@@ -1005,6 +1024,59 @@ function listAvailableModels(payload) {
1005
1024
  ).sort();
1006
1025
  }
1007
1026
 
1027
+ // src/review/usage.ts
1028
+ function parseAssistantUsage(info) {
1029
+ if (!info || typeof info !== "object") return void 0;
1030
+ const value = info;
1031
+ if (value["role"] !== "assistant") return void 0;
1032
+ const tokens = parseUsageTokens(value["tokens"]);
1033
+ if (!tokens) return void 0;
1034
+ const cost = typeof value["cost"] === "number" ? value["cost"] : null;
1035
+ return { tokens, cost };
1036
+ }
1037
+ function aggregateReviewUsage(entries) {
1038
+ if (entries.length === 0) return void 0;
1039
+ const tokens = {
1040
+ input: 0,
1041
+ output: 0,
1042
+ reasoning: 0,
1043
+ cache: { read: 0, write: 0 }
1044
+ };
1045
+ let costSum = 0;
1046
+ let hasCost = false;
1047
+ for (const entry of entries) {
1048
+ tokens.input += entry.tokens.input;
1049
+ tokens.output += entry.tokens.output;
1050
+ tokens.reasoning += entry.tokens.reasoning;
1051
+ tokens.cache.read += entry.tokens.cache.read;
1052
+ tokens.cache.write += entry.tokens.cache.write;
1053
+ if (entry.cost !== null) {
1054
+ costSum += entry.cost;
1055
+ hasCost = true;
1056
+ }
1057
+ }
1058
+ return { tokens, cost: hasCost ? costSum : null };
1059
+ }
1060
+ function parseUsageTokens(value) {
1061
+ if (!value || typeof value !== "object") return void 0;
1062
+ const tokens = value;
1063
+ const cache = tokens["cache"];
1064
+ if (!cache || typeof cache !== "object") return void 0;
1065
+ const cacheValue = cache;
1066
+ if (typeof tokens["input"] !== "number" || typeof tokens["output"] !== "number" || typeof tokens["reasoning"] !== "number" || typeof cacheValue["read"] !== "number" || typeof cacheValue["write"] !== "number") {
1067
+ return void 0;
1068
+ }
1069
+ return {
1070
+ input: tokens["input"],
1071
+ output: tokens["output"],
1072
+ reasoning: tokens["reasoning"],
1073
+ cache: {
1074
+ read: cacheValue["read"],
1075
+ write: cacheValue["write"]
1076
+ }
1077
+ };
1078
+ }
1079
+
1008
1080
  // src/opencode/client.ts
1009
1081
  var ReviewCancelledError = class extends Error {
1010
1082
  name = "ReviewCancelledError";
@@ -1012,6 +1084,18 @@ var ReviewCancelledError = class extends Error {
1012
1084
  function isReviewCancellation(error) {
1013
1085
  return error instanceof ReviewCancelledError;
1014
1086
  }
1087
+ function resolveReviewPrompts(options) {
1088
+ const user = options.userPrompt ?? buildReviewPrompt(
1089
+ options.target,
1090
+ options.config.rules,
1091
+ options.config.include,
1092
+ options.config.exclude,
1093
+ options.localContext,
1094
+ options.depth
1095
+ );
1096
+ const system = options.systemPrompt ?? REVIEW_AGENT_PROMPT;
1097
+ return { system, user };
1098
+ }
1015
1099
  function normalizeOpenCodeEvent(event, expectedSessionId) {
1016
1100
  if (!event || typeof event !== "object") return void 0;
1017
1101
  const payload = event.payload;
@@ -1093,11 +1177,13 @@ function normalizeAssistantMessage(info, expectedSessionId) {
1093
1177
  if (value["role"] !== "assistant" || typeof value["sessionID"] !== "string" || typeof value["id"] !== "string" || expectedSessionId !== void 0 && value["sessionID"] !== expectedSessionId) {
1094
1178
  return void 0;
1095
1179
  }
1180
+ const usage = parseAssistantUsage(value);
1096
1181
  return {
1097
1182
  type: "assistant-message",
1098
1183
  sessionId: value["sessionID"],
1099
1184
  messageId: value["id"],
1100
- ...value["error"] ? { error: new Error(describeSessionError(value["error"]) || "Review failed") } : {}
1185
+ ...value["error"] ? { error: new Error(describeSessionError(value["error"]) || "Review failed") } : {},
1186
+ ...usage ? { usage } : {}
1101
1187
  };
1102
1188
  }
1103
1189
  async function runReview(options) {
@@ -1133,14 +1219,14 @@ async function runReview(options) {
1133
1219
  const tools = await buildToolPolicy(client, depth);
1134
1220
  recordTiming(timings, onProgress, "tool-policy", "OpenCode tool policy", toolPolicyStart);
1135
1221
  const promptStart = performance.now();
1136
- const prompt = buildReviewPrompt(
1222
+ const { system, user: prompt } = resolveReviewPrompts({
1137
1223
  target,
1138
- config.rules,
1139
- config.include,
1140
- config.exclude,
1141
- localContext,
1142
- depth
1143
- );
1224
+ config,
1225
+ depth,
1226
+ ...localContext !== void 0 ? { localContext } : {},
1227
+ ...options.systemPrompt !== void 0 ? { systemPrompt: options.systemPrompt } : {},
1228
+ ...options.userPrompt !== void 0 ? { userPrompt: options.userPrompt } : {}
1229
+ });
1144
1230
  recordTiming(timings, onProgress, "prompt-build", "Review prompt build", promptStart);
1145
1231
  const parts = config.model.split("/");
1146
1232
  const providerID = parts[0];
@@ -1166,6 +1252,7 @@ async function runReview(options) {
1166
1252
  })
1167
1253
  );
1168
1254
  recordTiming(timings, onProgress, "event-stream", "OpenCode event stream connection", eventStart);
1255
+ const usageByMessageId = /* @__PURE__ */ new Map();
1169
1256
  const responsePromise = handledAwaitable(
1170
1257
  new Promise((resolve2, reject) => {
1171
1258
  const assistantMessageIds = /* @__PURE__ */ new Set();
@@ -1222,6 +1309,9 @@ async function runReview(options) {
1222
1309
  break;
1223
1310
  case "assistant-message": {
1224
1311
  assistantMessageIds.add(normalized.messageId);
1312
+ if (normalized.usage) {
1313
+ usageByMessageId.set(normalized.messageId, normalized.usage);
1314
+ }
1225
1315
  const text = textPartsByMessageId.get(normalized.messageId);
1226
1316
  settlement.acceptAssistantMessage({ text, error: normalized.error });
1227
1317
  break;
@@ -1284,7 +1374,7 @@ async function runReview(options) {
1284
1374
  path: { id: sessionId },
1285
1375
  ...directoryOptions,
1286
1376
  body: {
1287
- system: REVIEW_AGENT_PROMPT,
1377
+ system,
1288
1378
  model: { providerID, modelID },
1289
1379
  tools,
1290
1380
  ...reasoning.variant ? { variant: reasoning.variant } : {},
@@ -1304,9 +1394,16 @@ async function runReview(options) {
1304
1394
  const report = parseStructuredReview(raw);
1305
1395
  recordTiming(timings, onProgress, "parse-review", "Review JSON parsing", parseStart);
1306
1396
  const diagnostics = [...report.diagnostics ?? [], ...reasoning.diagnostics];
1397
+ const usage = aggregateReviewUsage([...usageByMessageId.values()]);
1307
1398
  return {
1308
- report: { ...report, ...diagnostics.length > 0 ? { diagnostics } : {}, timings },
1309
- sessionId
1399
+ report: {
1400
+ ...report,
1401
+ ...diagnostics.length > 0 ? { diagnostics } : {},
1402
+ timings,
1403
+ ...usage ? { usage } : {}
1404
+ },
1405
+ sessionId,
1406
+ ...usage ? { usage } : {}
1310
1407
  };
1311
1408
  } finally {
1312
1409
  signal?.removeEventListener("abort", cancelReview);
@@ -1515,13 +1612,6 @@ function getOpenCodeFailureGuidance(message) {
1515
1612
  if (normalized.includes("timed out") || normalized.includes("timeout")) {
1516
1613
  return ["Retry with less context: diffowl review --depth shallow"];
1517
1614
  }
1518
- if (normalized.includes("node_module_version") || normalized.includes("better-sqlite3") && normalized.includes("compiled against a different node.js version")) {
1519
- return [
1520
- "Native module ABI mismatch. Rebuild for your active Node: pnpm rebuild better-sqlite3",
1521
- "Reinstall the hook so it uses the same Node as the CLI: diffowl hook install",
1522
- "Compare the required NODE_MODULE_VERSION with the Hook Node ABI from `diffowl hook install`."
1523
- ];
1524
- }
1525
1615
  if (normalized.includes("session_message.seq") || normalized.includes("not null constraint failed") && normalized.includes("seq")) {
1526
1616
  return [
1527
1617
  "OpenCode server version may be stale. Check: diffowl server status",
@@ -1562,7 +1652,7 @@ import {
1562
1652
  writeFileSync,
1563
1653
  writeSync
1564
1654
  } from "fs";
1565
- import { dirname as dirname2, join as join3 } from "path";
1655
+ import { basename, dirname as dirname2, join as join3 } from "path";
1566
1656
  import { fileURLToPath } from "url";
1567
1657
  import { execa as execa2 } from "execa";
1568
1658
  import { z as z4 } from "zod";
@@ -1599,10 +1689,16 @@ async function getHooksDir() {
1599
1689
  }
1600
1690
  return hooksDir;
1601
1691
  }
1602
- async function installHook() {
1692
+ async function getHookPath() {
1603
1693
  const hooksDir = await getHooksDir();
1604
- await mkdir2(hooksDir, { recursive: true });
1605
- const hookPath = join3(hooksDir, "post-commit");
1694
+ if (basename(hooksDir) === "_" && basename(dirname2(hooksDir)) === ".husky") {
1695
+ return join3(dirname2(hooksDir), "post-commit");
1696
+ }
1697
+ return join3(hooksDir, "post-commit");
1698
+ }
1699
+ async function installHook() {
1700
+ const hookPath = await getHookPath();
1701
+ await mkdir2(dirname2(hookPath), { recursive: true });
1606
1702
  const command = await resolveHookCommand();
1607
1703
  if (existsSync3(hookPath)) {
1608
1704
  const existing = await readFile4(hookPath, "utf-8");
@@ -1619,8 +1715,7 @@ ${hookSection}` : generateHookScript(command);
1619
1715
  return hookPath;
1620
1716
  }
1621
1717
  async function uninstallHook() {
1622
- const hooksDir = await getHooksDir();
1623
- const hookPath = join3(hooksDir, "post-commit");
1718
+ const hookPath = await getHookPath();
1624
1719
  if (!existsSync3(hookPath)) return false;
1625
1720
  const content = await readFile4(hookPath, "utf-8");
1626
1721
  if (!content.includes(HOOK_MARKER)) return false;
@@ -1633,8 +1728,7 @@ async function uninstallHook() {
1633
1728
  return true;
1634
1729
  }
1635
1730
  async function isHookInstalled() {
1636
- const hooksDir = await getHooksDir();
1637
- const hookPath = join3(hooksDir, "post-commit");
1731
+ const hookPath = await getHookPath();
1638
1732
  if (!existsSync3(hookPath)) return false;
1639
1733
  const content = await readFile4(hookPath, "utf-8");
1640
1734
  return content.includes(HOOK_MARKER);
@@ -1732,9 +1826,6 @@ function isHookQueueStopFailure(message) {
1732
1826
  if (normalized.includes("server is not running") || normalized.includes("failed to start opencode server") || normalized.includes("econnrefused") || normalized.includes("connection refused")) {
1733
1827
  return true;
1734
1828
  }
1735
- if (normalized.includes("node_module_version") || normalized.includes("better-sqlite3") && normalized.includes("compiled against a different node.js version")) {
1736
- return true;
1737
- }
1738
1829
  if (normalized.includes("opencode not found") || normalized.includes("opencode: command not found") || normalized.includes("enoent") && normalized.includes("opencode")) {
1739
1830
  return true;
1740
1831
  }
@@ -1970,13 +2061,12 @@ function isHookReviewLockActive(lockFile) {
1970
2061
  }
1971
2062
  }
1972
2063
  async function checkHookStale() {
1973
- let hooksDir;
2064
+ let hookPath;
1974
2065
  try {
1975
- hooksDir = await getHooksDir();
2066
+ hookPath = await getHookPath();
1976
2067
  } catch {
1977
2068
  return { installed: false, stale: false, reason: "Not a git repository" };
1978
2069
  }
1979
- const hookPath = join3(hooksDir, "post-commit");
1980
2070
  if (!existsSync3(hookPath)) {
1981
2071
  return { installed: false, stale: false, reason: "No post-commit hook found" };
1982
2072
  }
@@ -2125,57 +2215,64 @@ function shellQuote(value) {
2125
2215
 
2126
2216
  // src/git/diff.ts
2127
2217
  import { execa as execa3 } from "execa";
2128
- import { basename, extname } from "path";
2218
+ import { basename as basename2, extname } from "path";
2129
2219
  var MAX_DIFF_OUTPUT_BYTES = 2 * 1024 * 1024;
2130
- async function getResolvedCommitDiff(commit) {
2131
- const raw = await collectGitDiff([
2132
- "-c",
2133
- "diff.noprefix=false",
2134
- "-c",
2135
- "diff.mnemonicprefix=false",
2136
- "show",
2137
- "--format=",
2138
- "--diff-merges=combined",
2139
- "--stat",
2140
- "--patch",
2141
- commit
2142
- ]);
2220
+ async function getResolvedCommitDiff(commit, cwd) {
2221
+ const raw = await collectGitDiff(
2222
+ [
2223
+ "-c",
2224
+ "diff.noprefix=false",
2225
+ "-c",
2226
+ "diff.mnemonicprefix=false",
2227
+ "show",
2228
+ "--format=",
2229
+ "--diff-merges=combined",
2230
+ "--stat",
2231
+ "--patch",
2232
+ commit
2233
+ ],
2234
+ cwd
2235
+ );
2143
2236
  return parseDiff(raw.stdout, raw.diagnostics);
2144
2237
  }
2145
- async function resolveCommitRef(ref) {
2238
+ async function resolveCommitRef(ref, cwd) {
2146
2239
  const trimmed = ref.trim();
2147
2240
  if (trimmed === "") {
2148
2241
  throw new Error("Commit ref must not be empty.");
2149
2242
  }
2150
2243
  try {
2151
- const { stdout } = await execa3("git", [
2152
- "rev-parse",
2153
- "--verify",
2154
- "--quiet",
2155
- "--end-of-options",
2156
- `${trimmed}^{commit}`
2157
- ]);
2244
+ const { stdout } = await execa3(
2245
+ "git",
2246
+ ["rev-parse", "--verify", "--quiet", "--end-of-options", `${trimmed}^{commit}`],
2247
+ cwd ? { cwd } : {}
2248
+ );
2158
2249
  return stdout.trim();
2159
2250
  } catch {
2160
2251
  throw new Error(`Invalid commit ref: ${ref}`);
2161
2252
  }
2162
2253
  }
2163
- async function getStagedDiff() {
2164
- const raw = await collectGitDiff([
2165
- "-c",
2166
- "diff.noprefix=false",
2167
- "-c",
2168
- "diff.mnemonicprefix=false",
2169
- "diff",
2170
- "--staged",
2171
- "--stat",
2172
- "--patch"
2173
- ]);
2254
+ async function getStagedDiff(cwd) {
2255
+ const raw = await collectGitDiff(
2256
+ [
2257
+ "-c",
2258
+ "diff.noprefix=false",
2259
+ "-c",
2260
+ "diff.mnemonicprefix=false",
2261
+ "diff",
2262
+ "--staged",
2263
+ "--stat",
2264
+ "--patch"
2265
+ ],
2266
+ cwd
2267
+ );
2174
2268
  return parseDiff(raw.stdout, raw.diagnostics);
2175
2269
  }
2176
- async function collectGitDiff(args) {
2270
+ async function collectGitDiff(args, cwd) {
2177
2271
  try {
2178
- const { stdout } = await execa3("git", args, { maxBuffer: MAX_DIFF_OUTPUT_BYTES });
2272
+ const { stdout } = await execa3("git", args, {
2273
+ maxBuffer: MAX_DIFF_OUTPUT_BYTES,
2274
+ ...cwd ? { cwd } : {}
2275
+ });
2179
2276
  return { stdout, diagnostics: [] };
2180
2277
  } catch (err) {
2181
2278
  if (isMaxBufferError(err)) {
@@ -2418,7 +2515,7 @@ var DOC_BASENAME_PATTERNS = [
2418
2515
  /^TODO/i
2419
2516
  ];
2420
2517
  function isDocFile(path) {
2421
- const base = basename(path);
2518
+ const base = basename2(path);
2422
2519
  const extension = extname(base).toLowerCase();
2423
2520
  if (DOC_EXTENSIONS.has(extension)) return true;
2424
2521
  if (extension) return false;
@@ -2429,7 +2526,7 @@ function isDocOnlyDiff(diff) {
2429
2526
  }
2430
2527
 
2431
2528
  // src/review/context.ts
2432
- import { basename as basename3, dirname as dirname3, extname as extname5, join as join6 } from "path";
2529
+ import { basename as basename4, dirname as dirname3, extname as extname5, join as join6 } from "path";
2433
2530
  import picomatch from "picomatch";
2434
2531
 
2435
2532
  // src/review/ast/index.ts
@@ -2629,7 +2726,7 @@ function isCodePath(path) {
2629
2726
  }
2630
2727
 
2631
2728
  // src/review/context-references.ts
2632
- import { basename as basename2, extname as extname3 } from "path";
2729
+ import { basename as basename3, extname as extname3 } from "path";
2633
2730
  var MAX_REFERENCES_PER_TERM = 8;
2634
2731
  var MAX_REFERENCE_TERMS = 8;
2635
2732
  var MAX_REFERENCE_LINE_CHARS = 220;
@@ -2644,7 +2741,7 @@ async function buildReferenceContexts(source, changedFiles, skippedFiles, diagno
2644
2741
  ...skippedFiles.map((file) => file.path)
2645
2742
  ]);
2646
2743
  for (const file of changedFiles) {
2647
- terms.add(basename2(file.file.path, extname3(file.file.path)));
2744
+ terms.add(basename3(file.file.path, extname3(file.file.path)));
2648
2745
  for (const symbol of file.symbols.slice(0, 4)) {
2649
2746
  terms.add(symbol);
2650
2747
  }
@@ -3063,24 +3160,24 @@ async function loadReviewSnapshot(root, target) {
3063
3160
  return {
3064
3161
  root,
3065
3162
  target,
3066
- diff: await getStagedDiff(),
3163
+ diff: await getStagedDiff(root),
3067
3164
  source: createGitContextSource(root, { kind: "staged" })
3068
3165
  };
3069
3166
  case "commit": {
3070
- const sha = await resolveCommitRef(target.ref);
3167
+ const sha = await resolveCommitRef(target.ref, root);
3071
3168
  return {
3072
3169
  root,
3073
3170
  target,
3074
- diff: await getResolvedCommitDiff(sha),
3171
+ diff: await getResolvedCommitDiff(sha, root),
3075
3172
  source: createGitContextSource(root, { kind: "commit", sha })
3076
3173
  };
3077
3174
  }
3078
3175
  case "last-commit": {
3079
- const sha = await resolveCommitRef("HEAD");
3176
+ const sha = await resolveCommitRef("HEAD", root);
3080
3177
  return {
3081
3178
  root,
3082
3179
  target,
3083
- diff: await getResolvedCommitDiff(sha),
3180
+ diff: await getResolvedCommitDiff(sha, root),
3084
3181
  source: createGitContextSource(root, { kind: "commit", sha })
3085
3182
  };
3086
3183
  }
@@ -3186,7 +3283,7 @@ async function buildRelatedFileContexts(source, files) {
3186
3283
  return related;
3187
3284
  }
3188
3285
  function shouldReviewFile(path, config) {
3189
- if (LOCKFILE_EXCLUDES.has(basename3(path))) return false;
3286
+ if (LOCKFILE_EXCLUDES.has(basename4(path))) return false;
3190
3287
  const include = config.include.length > 0 ? config.include : ["**/*"];
3191
3288
  if (!include.some((pattern) => picomatch.isMatch(path, pattern))) {
3192
3289
  return false;
@@ -3307,7 +3404,7 @@ function getChangedLinesByFile(rawDiff) {
3307
3404
  function testCandidates(path) {
3308
3405
  const dir = dirname3(path);
3309
3406
  const ext = extname5(path);
3310
- const base = basename3(path, ext);
3407
+ const base = basename4(path, ext);
3311
3408
  return [
3312
3409
  join6(dir, `${base}.test${ext}`),
3313
3410
  join6(dir, `${base}.spec${ext}`),
@@ -3334,6 +3431,29 @@ function addUniqueDiagnostics(target, diagnostics) {
3334
3431
  }
3335
3432
  }
3336
3433
 
3434
+ // src/review/filters.ts
3435
+ function filterFindingsByConfidence(findings, minConfidence) {
3436
+ const levels = ["low", "medium", "high"];
3437
+ const minIndex = levels.indexOf(minConfidence);
3438
+ const kept = findings.filter((finding) => {
3439
+ const index = levels.indexOf(finding.confidence.toLowerCase());
3440
+ return index >= minIndex;
3441
+ });
3442
+ return { findings: kept, dropped: findings.length - kept.length };
3443
+ }
3444
+ function filterFindingsByChangedFiles(findings, changedFiles) {
3445
+ const kept = [];
3446
+ const suppressed = [];
3447
+ for (const finding of findings) {
3448
+ if (changedFiles.has(finding.file)) {
3449
+ kept.push(finding);
3450
+ } else {
3451
+ suppressed.push(finding);
3452
+ }
3453
+ }
3454
+ return { findings: kept, suppressed };
3455
+ }
3456
+
3337
3457
  // src/review/formatter.ts
3338
3458
  import chalk from "chalk";
3339
3459
  import { writeFile as writeFile5, mkdir as mkdir3 } from "fs/promises";
@@ -3560,7 +3680,7 @@ function formatExcludedCandidateSummary(belowConfidence, outsideChangedFiles) {
3560
3680
 
3561
3681
  // src/review/report-path.ts
3562
3682
  import { readFile as readFile6, readdir as readdir2 } from "fs/promises";
3563
- import { basename as basename4, isAbsolute, join as join8, resolve } from "path";
3683
+ import { basename as basename5, isAbsolute, join as join8, resolve } from "path";
3564
3684
  function resolveReviewReportPath(report) {
3565
3685
  if (isAbsolute(report)) return report;
3566
3686
  if (report.includes("/") || report.includes("\\")) {
@@ -3574,7 +3694,7 @@ async function listReviewReportPaths() {
3574
3694
  listMarkdownFiles(reviews),
3575
3695
  listMarkdownFiles(join8(reviews, "resolved"))
3576
3696
  ]);
3577
- return entries.flat().filter((path) => basename4(path) !== "latest.md").sort((a, b) => basename4(b).localeCompare(basename4(a)));
3697
+ return entries.flat().filter((path) => basename5(path) !== "latest.md").sort((a, b) => basename5(b).localeCompare(basename5(a)));
3578
3698
  }
3579
3699
  function canSelectReviewInteractively(inputIsTTY, outputIsTTY) {
3580
3700
  return inputIsTTY === true && outputIsTTY === true;
@@ -3609,7 +3729,6 @@ import { createHash as createHash2 } from "crypto";
3609
3729
  // src/state/db.ts
3610
3730
  import { mkdir as mkdir4 } from "fs/promises";
3611
3731
  import { join as join9 } from "path";
3612
- import Database from "better-sqlite3";
3613
3732
 
3614
3733
  // src/state/migrations/001-initial-schema.ts
3615
3734
  var MIGRATION_001_INITIAL_SCHEMA = `
@@ -3681,6 +3800,117 @@ CREATE TABLE finding_events (
3681
3800
  CREATE INDEX idx_finding_events_finding_id ON finding_events(finding_id);
3682
3801
  `;
3683
3802
 
3803
+ // src/state/sqlite.ts
3804
+ var sqliteModule;
3805
+ async function openSqliteDatabase(path) {
3806
+ const { DatabaseSync } = await loadSqliteModule();
3807
+ return new NodeSqliteDatabase(new DatabaseSync(path));
3808
+ }
3809
+ async function loadSqliteModule() {
3810
+ sqliteModule ??= importNodeSqliteWithoutWarning();
3811
+ return sqliteModule;
3812
+ }
3813
+ async function importNodeSqliteWithoutWarning() {
3814
+ const emitWarning = process.emitWarning;
3815
+ process.emitWarning = function suppressNodeSqliteExperimentalWarning(warning, ...args) {
3816
+ const message = typeof warning === "string" ? warning : warning.message;
3817
+ if (message.includes("SQLite is an experimental feature")) {
3818
+ return;
3819
+ }
3820
+ emitWarning.call(process, warning, ...args);
3821
+ };
3822
+ try {
3823
+ const nodeSqlite = ["node", "sqlite"].join(":");
3824
+ return await import(nodeSqlite);
3825
+ } finally {
3826
+ process.emitWarning = emitWarning;
3827
+ }
3828
+ }
3829
+ var NodeSqliteDatabase = class {
3830
+ constructor(db) {
3831
+ this.db = db;
3832
+ }
3833
+ db;
3834
+ #open = true;
3835
+ #transactionDepth = 0;
3836
+ get open() {
3837
+ return this.#open;
3838
+ }
3839
+ exec(sql) {
3840
+ this.db.exec(sql);
3841
+ }
3842
+ prepare(sql) {
3843
+ return new NodeSqliteStatement(this.db.prepare(sql));
3844
+ }
3845
+ pragma(sql, options = {}) {
3846
+ const rows = this.prepare(`PRAGMA ${sql}`).all();
3847
+ if (!options.simple) {
3848
+ return rows;
3849
+ }
3850
+ const first = rows[0];
3851
+ if (!first || typeof first !== "object") {
3852
+ return void 0;
3853
+ }
3854
+ return Object.values(first)[0];
3855
+ }
3856
+ transaction(fn) {
3857
+ return ((...args) => {
3858
+ const depth = this.#transactionDepth;
3859
+ const savepoint = `diffowl_tx_${depth}`;
3860
+ this.#transactionDepth++;
3861
+ try {
3862
+ this.exec(depth === 0 ? "BEGIN" : `SAVEPOINT ${savepoint}`);
3863
+ const result = fn(...args);
3864
+ this.exec(depth === 0 ? "COMMIT" : `RELEASE SAVEPOINT ${savepoint}`);
3865
+ return result;
3866
+ } catch (error) {
3867
+ try {
3868
+ this.exec(depth === 0 ? "ROLLBACK" : `ROLLBACK TO SAVEPOINT ${savepoint}`);
3869
+ if (depth > 0) {
3870
+ this.exec(`RELEASE SAVEPOINT ${savepoint}`);
3871
+ }
3872
+ } catch {
3873
+ }
3874
+ throw error;
3875
+ } finally {
3876
+ this.#transactionDepth--;
3877
+ }
3878
+ });
3879
+ }
3880
+ close() {
3881
+ this.db.close();
3882
+ this.#open = false;
3883
+ }
3884
+ };
3885
+ var NodeSqliteStatement = class {
3886
+ constructor(statement) {
3887
+ this.statement = statement;
3888
+ }
3889
+ statement;
3890
+ get(...params) {
3891
+ return normalizeRow(this.namedStatement.get(...params));
3892
+ }
3893
+ all(...params) {
3894
+ return this.namedStatement.all(...params).map(normalizeRow);
3895
+ }
3896
+ run(...params) {
3897
+ const result = this.namedStatement.run(...params);
3898
+ return {
3899
+ changes: Number(result.changes),
3900
+ lastInsertRowid: result.lastInsertRowid
3901
+ };
3902
+ }
3903
+ get namedStatement() {
3904
+ return this.statement;
3905
+ }
3906
+ };
3907
+ function normalizeRow(row) {
3908
+ if (!row || typeof row !== "object" || Object.getPrototypeOf(row) !== null) {
3909
+ return row;
3910
+ }
3911
+ return { ...row };
3912
+ }
3913
+
3684
3914
  // src/state/types.ts
3685
3915
  import { randomUUID } from "crypto";
3686
3916
  var CURRENT_SCHEMA_VERSION = 1;
@@ -3708,7 +3938,7 @@ function getStateDbPath(diffOwlDir) {
3708
3938
  async function openStateDatabase(diffOwlDir) {
3709
3939
  await mkdir4(diffOwlDir, { recursive: true });
3710
3940
  const path = getStateDbPath(diffOwlDir);
3711
- const db = new Database(path);
3941
+ const db = await openSqliteDatabase(path);
3712
3942
  try {
3713
3943
  configureDatabase(db);
3714
3944
  assertCompatibleSchema(db);
@@ -4647,7 +4877,8 @@ function buildReviewJsonDocument(input) {
4647
4877
  below_confidence: input.suppressed.belowConfidence
4648
4878
  },
4649
4879
  diagnostics: input.review.diagnostics,
4650
- timings: input.timings ?? input.review.timings
4880
+ timings: input.timings ?? input.review.timings,
4881
+ ...input.usage !== void 0 ? { usage: input.usage } : {}
4651
4882
  };
4652
4883
  }
4653
4884
  function renderReviewJsonDocument(document) {
@@ -5110,13 +5341,13 @@ function toFindingDetail(db, finding) {
5110
5341
 
5111
5342
  // src/cli.ts
5112
5343
  import { readFile as readFile7 } from "fs/promises";
5113
- import { basename as basename5, dirname as dirname4 } from "path";
5344
+ import { basename as basename6, dirname as dirname4 } from "path";
5114
5345
  import { execa as execa5 } from "execa";
5115
5346
 
5116
5347
  // package.json
5117
5348
  var package_default = {
5118
5349
  name: "diffowl",
5119
- version: "0.3.0",
5350
+ version: "0.3.1",
5120
5351
  description: "Local AI code review agent powered by OpenCode",
5121
5352
  keywords: [
5122
5353
  "ai",
@@ -5142,14 +5373,14 @@ var package_default = {
5142
5373
  ],
5143
5374
  type: "module",
5144
5375
  scripts: {
5145
- "check:native-runtime": "node scripts/check-native-runtime.mjs",
5146
- prebuild: "pnpm run check:native-runtime",
5376
+ "check:runtime": "node scripts/check-runtime.mjs",
5377
+ prebuild: "pnpm run check:runtime",
5147
5378
  build: "tsup",
5148
- predev: "pnpm run check:native-runtime",
5379
+ predev: "pnpm run check:runtime",
5149
5380
  dev: "tsup --watch",
5150
- pretest: "pnpm run check:native-runtime",
5381
+ pretest: "pnpm run check:runtime",
5151
5382
  test: "vitest run",
5152
- pretypecheck: "pnpm run check:native-runtime",
5383
+ pretypecheck: "pnpm run check:runtime",
5153
5384
  typecheck: "tsc --noEmit",
5154
5385
  lint: "oxlint . && pnpm run typecheck",
5155
5386
  format: "oxfmt --write .",
@@ -5159,7 +5390,6 @@ var package_default = {
5159
5390
  },
5160
5391
  dependencies: {
5161
5392
  "@opencode-ai/sdk": "^1.15.11",
5162
- "better-sqlite3": "^12.10.0",
5163
5393
  chalk: "^5.6.2",
5164
5394
  commander: "^14.0.3",
5165
5395
  execa: "^9.6.1",
@@ -5169,7 +5399,6 @@ var package_default = {
5169
5399
  zod: "^4.4.3"
5170
5400
  },
5171
5401
  devDependencies: {
5172
- "@types/better-sqlite3": "^7.6.13",
5173
5402
  "@types/node": "^25.9.1",
5174
5403
  "@types/picomatch": "^4.0.3",
5175
5404
  oxfmt: "^0.52.0",
@@ -5179,14 +5408,9 @@ var package_default = {
5179
5408
  vitest: "^4.1.7"
5180
5409
  },
5181
5410
  engines: {
5182
- node: ">=22.14.0 <23"
5411
+ node: ">=22.14.0"
5183
5412
  },
5184
- packageManager: "pnpm@10.15.1",
5185
- pnpm: {
5186
- onlyBuiltDependencies: [
5187
- "better-sqlite3"
5188
- ]
5189
- }
5413
+ packageManager: "pnpm@10.15.1"
5190
5414
  };
5191
5415
 
5192
5416
  // src/cli.ts
@@ -5512,7 +5736,8 @@ program.command("review", { isDefault: true }).description("Review the last comm
5512
5736
  belowConfidence: confidenceFilter.dropped
5513
5737
  },
5514
5738
  verbose,
5515
- timings: [...timings, ...report.timings ?? []]
5739
+ timings: [...timings, ...report.timings ?? []],
5740
+ usage: reviewResult.usage ?? null
5516
5741
  });
5517
5742
  } else {
5518
5743
  console.log(colorizeMarkdown(markdown));
@@ -5603,9 +5828,9 @@ async function selectReviewInteractively() {
5603
5828
  }
5604
5829
  console.log(chalk3.bold("\nSelect a review:\n"));
5605
5830
  for (const [index, report] of reports.entries()) {
5606
- const resolved = basename5(dirname4(report)) === "resolved";
5831
+ const resolved = basename6(dirname4(report)) === "resolved";
5607
5832
  console.log(
5608
- ` ${chalk3.cyan(`${index + 1}.`)} ${basename5(report)}${resolved ? chalk3.dim(" (resolved)") : ""}`
5833
+ ` ${chalk3.cyan(`${index + 1}.`)} ${basename6(report)}${resolved ? chalk3.dim(" (resolved)") : ""}`
5609
5834
  );
5610
5835
  }
5611
5836
  const rl = createInterface({
@@ -6031,27 +6256,6 @@ async function loadConfigOrExit() {
6031
6256
  process.exit(1);
6032
6257
  }
6033
6258
  }
6034
- function filterFindingsByConfidence(findings, minConfidence) {
6035
- const levels = ["low", "medium", "high"];
6036
- const minIndex = levels.indexOf(minConfidence);
6037
- const kept = findings.filter((f) => {
6038
- const idx = levels.indexOf(f.confidence.toLowerCase());
6039
- return idx >= minIndex;
6040
- });
6041
- return { findings: kept, dropped: findings.length - kept.length };
6042
- }
6043
- function filterFindingsByChangedFiles(findings, changedFiles) {
6044
- const kept = [];
6045
- const suppressed = [];
6046
- for (const finding of findings) {
6047
- if (changedFiles.has(finding.file)) {
6048
- kept.push(finding);
6049
- } else {
6050
- suppressed.push(finding);
6051
- }
6052
- }
6053
- return { findings: kept, suppressed };
6054
- }
6055
6259
  function buildDocOnlySkipMarkdown(diff) {
6056
6260
  const lines = [];
6057
6261
  lines.push("### Summary");
@@ -6133,7 +6337,8 @@ async function emitReviewJsonSuccess(input) {
6133
6337
  belowConfidence: input.suppressed.belowConfidence
6134
6338
  },
6135
6339
  verbose: input.verbose,
6136
- ...input.timings ? { timings: input.timings } : {}
6340
+ ...input.timings ? { timings: input.timings } : {},
6341
+ ...input.usage !== void 0 ? { usage: input.usage } : {}
6137
6342
  });
6138
6343
  writeReviewJsonSuccess(document);
6139
6344
  }