composto-ai 0.4.0 → 0.6.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 +79 -6
- package/dist/index.js +883 -84
- package/dist/mcp/server.js +54 -37
- package/dist/memory/api.js +53 -200
- package/dist/memory/worker.js +94 -59
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,4 +1,73 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res) => function __init() {
|
|
5
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
6
|
+
};
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// src/memory/git.ts
|
|
13
|
+
var git_exports = {};
|
|
14
|
+
__export(git_exports, {
|
|
15
|
+
countCommits: () => countCommits,
|
|
16
|
+
isAncestor: () => isAncestor,
|
|
17
|
+
isShallowRepo: () => isShallowRepo,
|
|
18
|
+
logRange: () => logRange,
|
|
19
|
+
resolveSinceBoundary: () => resolveSinceBoundary,
|
|
20
|
+
revListCount: () => revListCount,
|
|
21
|
+
revParseHead: () => revParseHead
|
|
22
|
+
});
|
|
23
|
+
import { execSync as execSync2 } from "child_process";
|
|
24
|
+
function run(cwd, cmd, timeoutMs = 1e4) {
|
|
25
|
+
return execSync2(cmd, { cwd, encoding: "utf-8", timeout: timeoutMs }).trim();
|
|
26
|
+
}
|
|
27
|
+
function revParseHead(cwd) {
|
|
28
|
+
return run(cwd, "git rev-parse HEAD");
|
|
29
|
+
}
|
|
30
|
+
function isShallowRepo(cwd) {
|
|
31
|
+
return run(cwd, "git rev-parse --is-shallow-repository") === "true";
|
|
32
|
+
}
|
|
33
|
+
function revListCount(cwd, from, to) {
|
|
34
|
+
if (from === to) return 0;
|
|
35
|
+
const out = run(cwd, `git rev-list --count ${from}..${to}`);
|
|
36
|
+
return parseInt(out, 10);
|
|
37
|
+
}
|
|
38
|
+
function isAncestor(cwd, ancestor, descendant) {
|
|
39
|
+
try {
|
|
40
|
+
execSync2(`git merge-base --is-ancestor ${ancestor} ${descendant}`, {
|
|
41
|
+
cwd,
|
|
42
|
+
stdio: "ignore"
|
|
43
|
+
});
|
|
44
|
+
return true;
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function countCommits(cwd) {
|
|
50
|
+
const out = run(cwd, "git rev-list --count HEAD");
|
|
51
|
+
return parseInt(out, 10);
|
|
52
|
+
}
|
|
53
|
+
function resolveSinceBoundary(cwd, since) {
|
|
54
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(since)) {
|
|
55
|
+
throw new Error(`--since must be YYYY-MM-DD (got "${since}")`);
|
|
56
|
+
}
|
|
57
|
+
const out = run(cwd, `git rev-list -n 1 --before='${since}T23:59:59' HEAD`);
|
|
58
|
+
return out || null;
|
|
59
|
+
}
|
|
60
|
+
function logRange(cwd, from, to, timeoutMs = 6e4) {
|
|
61
|
+
const range = from ? `${from}..${to}` : to;
|
|
62
|
+
const fmt = "--format=%x1e%H%x00%P%x00%an%x00%at%x00%s%x00%b%x1f";
|
|
63
|
+
const cmd = `git log ${fmt} --numstat --no-renames ${range}`;
|
|
64
|
+
return execSync2(cmd, { cwd, encoding: "utf-8", timeout: timeoutMs, maxBuffer: 256 * 1024 * 1024 });
|
|
65
|
+
}
|
|
66
|
+
var init_git = __esm({
|
|
67
|
+
"src/memory/git.ts"() {
|
|
68
|
+
"use strict";
|
|
69
|
+
}
|
|
70
|
+
});
|
|
2
71
|
|
|
3
72
|
// src/cli/commands.ts
|
|
4
73
|
import { readFileSync as readFileSync4 } from "fs";
|
|
@@ -1717,7 +1786,25 @@ function openDatabase(path) {
|
|
|
1717
1786
|
}
|
|
1718
1787
|
|
|
1719
1788
|
// src/memory/schema.ts
|
|
1720
|
-
var CURRENT_VERSION =
|
|
1789
|
+
var CURRENT_VERSION = 3;
|
|
1790
|
+
var V2_SQL = `
|
|
1791
|
+
CREATE INDEX IF NOT EXISTS idx_ft_file_commit ON file_touches(file_path, commit_sha);
|
|
1792
|
+
`;
|
|
1793
|
+
var V3_SQL = `
|
|
1794
|
+
CREATE TABLE IF NOT EXISTS hook_invocations (
|
|
1795
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1796
|
+
timestamp INTEGER NOT NULL,
|
|
1797
|
+
platform TEXT NOT NULL,
|
|
1798
|
+
event TEXT NOT NULL,
|
|
1799
|
+
file_path TEXT,
|
|
1800
|
+
verdict TEXT,
|
|
1801
|
+
score REAL,
|
|
1802
|
+
confidence REAL,
|
|
1803
|
+
latency_ms INTEGER NOT NULL,
|
|
1804
|
+
cache_hit INTEGER NOT NULL
|
|
1805
|
+
);
|
|
1806
|
+
CREATE INDEX IF NOT EXISTS idx_hi_timestamp ON hook_invocations(timestamp);
|
|
1807
|
+
`;
|
|
1721
1808
|
var V1_SQL = `
|
|
1722
1809
|
CREATE TABLE IF NOT EXISTS index_state (
|
|
1723
1810
|
key TEXT PRIMARY KEY,
|
|
@@ -1805,7 +1892,9 @@ function runMigrations(db) {
|
|
|
1805
1892
|
if (current >= CURRENT_VERSION) return;
|
|
1806
1893
|
db.exec("BEGIN");
|
|
1807
1894
|
try {
|
|
1808
|
-
db.exec(V1_SQL);
|
|
1895
|
+
if (current < 1) db.exec(V1_SQL);
|
|
1896
|
+
if (current < 2) db.exec(V2_SQL);
|
|
1897
|
+
if (current < 3) db.exec(V3_SQL);
|
|
1809
1898
|
db.pragma(`user_version = ${CURRENT_VERSION}`);
|
|
1810
1899
|
db.exec("COMMIT");
|
|
1811
1900
|
} catch (err) {
|
|
@@ -1814,39 +1903,8 @@ function runMigrations(db) {
|
|
|
1814
1903
|
}
|
|
1815
1904
|
}
|
|
1816
1905
|
|
|
1817
|
-
// src/memory/git.ts
|
|
1818
|
-
import { execSync as execSync2 } from "child_process";
|
|
1819
|
-
function run(cwd, cmd, timeoutMs = 1e4) {
|
|
1820
|
-
return execSync2(cmd, { cwd, encoding: "utf-8", timeout: timeoutMs }).trim();
|
|
1821
|
-
}
|
|
1822
|
-
function revParseHead(cwd) {
|
|
1823
|
-
return run(cwd, "git rev-parse HEAD");
|
|
1824
|
-
}
|
|
1825
|
-
function isShallowRepo(cwd) {
|
|
1826
|
-
return run(cwd, "git rev-parse --is-shallow-repository") === "true";
|
|
1827
|
-
}
|
|
1828
|
-
function revListCount(cwd, from, to) {
|
|
1829
|
-
if (from === to) return 0;
|
|
1830
|
-
const out = run(cwd, `git rev-list --count ${from}..${to}`);
|
|
1831
|
-
return parseInt(out, 10);
|
|
1832
|
-
}
|
|
1833
|
-
function isAncestor(cwd, ancestor, descendant) {
|
|
1834
|
-
try {
|
|
1835
|
-
execSync2(`git merge-base --is-ancestor ${ancestor} ${descendant}`, {
|
|
1836
|
-
cwd,
|
|
1837
|
-
stdio: "ignore"
|
|
1838
|
-
});
|
|
1839
|
-
return true;
|
|
1840
|
-
} catch {
|
|
1841
|
-
return false;
|
|
1842
|
-
}
|
|
1843
|
-
}
|
|
1844
|
-
function countCommits(cwd) {
|
|
1845
|
-
const out = run(cwd, "git rev-list --count HEAD");
|
|
1846
|
-
return parseInt(out, 10);
|
|
1847
|
-
}
|
|
1848
|
-
|
|
1849
1906
|
// src/memory/freshness.ts
|
|
1907
|
+
init_git();
|
|
1850
1908
|
function ensureFresh(db, repoPath) {
|
|
1851
1909
|
const head = revParseHead(repoPath);
|
|
1852
1910
|
const row = db.prepare("SELECT value FROM index_state WHERE key = 'last_indexed_sha'").get();
|
|
@@ -1947,13 +2005,19 @@ function computeRevertMatch(db, filePath) {
|
|
|
1947
2005
|
};
|
|
1948
2006
|
}
|
|
1949
2007
|
|
|
2008
|
+
// src/memory/signals/db-clock.ts
|
|
2009
|
+
function getDbMaxTimestamp(db) {
|
|
2010
|
+
const row = db.prepare("SELECT MAX(timestamp) AS ts FROM commits").get();
|
|
2011
|
+
return row?.ts ?? null;
|
|
2012
|
+
}
|
|
2013
|
+
|
|
1950
2014
|
// src/memory/signals/hotspot.ts
|
|
1951
2015
|
var WINDOW_SECONDS = 90 * 86400;
|
|
1952
2016
|
var SATURATION_TOUCHES = 30;
|
|
1953
2017
|
var FALLBACK_PRECISION2 = 0.3;
|
|
1954
2018
|
function computeHotspot(db, filePath) {
|
|
1955
|
-
const
|
|
1956
|
-
const lowerBound =
|
|
2019
|
+
const anchor = getDbMaxTimestamp(db) ?? Math.floor(Date.now() / 1e3);
|
|
2020
|
+
const lowerBound = anchor - WINDOW_SECONDS;
|
|
1957
2021
|
const row = db.prepare(`
|
|
1958
2022
|
SELECT COUNT(*) AS n
|
|
1959
2023
|
FROM file_touches ft
|
|
@@ -2011,38 +2075,12 @@ function computeFixRatio(db, filePath) {
|
|
|
2011
2075
|
};
|
|
2012
2076
|
}
|
|
2013
2077
|
|
|
2014
|
-
// src/memory/signals/coverage-decline.ts
|
|
2015
|
-
var FALLBACK_PRECISION4 = 0.3;
|
|
2016
|
-
function computeCoverageDecline(db, repoPath, filePath) {
|
|
2017
|
-
const cal = getCalibration(db, "coverage_decline", FALLBACK_PRECISION4);
|
|
2018
|
-
let strength = 0;
|
|
2019
|
-
try {
|
|
2020
|
-
const entries = getGitLog(repoPath, 200);
|
|
2021
|
-
const trends = {
|
|
2022
|
-
hotspots: detectHotspots(entries, { threshold: 10, fixRatioThreshold: 0.5 }),
|
|
2023
|
-
decaySignals: detectDecay(entries),
|
|
2024
|
-
inconsistencies: detectInconsistencies(entries)
|
|
2025
|
-
};
|
|
2026
|
-
const health = computeHealthFromTrends(filePath, trends);
|
|
2027
|
-
if (health.coverageTrend === "down") strength = 1;
|
|
2028
|
-
} catch {
|
|
2029
|
-
strength = 0;
|
|
2030
|
-
}
|
|
2031
|
-
return {
|
|
2032
|
-
type: "coverage_decline",
|
|
2033
|
-
strength,
|
|
2034
|
-
precision: cal.precision,
|
|
2035
|
-
sample_size: cal.sampleSize,
|
|
2036
|
-
evidence: []
|
|
2037
|
-
};
|
|
2038
|
-
}
|
|
2039
|
-
|
|
2040
2078
|
// src/memory/signals/author-churn.ts
|
|
2041
2079
|
var WINDOW_SECONDS2 = 90 * 86400;
|
|
2042
2080
|
var INACTIVE_THRESHOLD = 5;
|
|
2043
|
-
var
|
|
2081
|
+
var FALLBACK_PRECISION4 = 0.3;
|
|
2044
2082
|
function computeAuthorChurn(db, filePath) {
|
|
2045
|
-
const cal = getCalibration(db, "author_churn",
|
|
2083
|
+
const cal = getCalibration(db, "author_churn", FALLBACK_PRECISION4);
|
|
2046
2084
|
const base = {
|
|
2047
2085
|
type: "author_churn",
|
|
2048
2086
|
precision: cal.precision,
|
|
@@ -2058,8 +2096,8 @@ function computeAuthorChurn(db, filePath) {
|
|
|
2058
2096
|
LIMIT 1
|
|
2059
2097
|
`).get(filePath);
|
|
2060
2098
|
if (!lastTouch) return { ...base, strength: 0 };
|
|
2061
|
-
const
|
|
2062
|
-
const lowerBound =
|
|
2099
|
+
const anchor = getDbMaxTimestamp(db) ?? Math.floor(Date.now() / 1e3);
|
|
2100
|
+
const lowerBound = anchor - WINDOW_SECONDS2;
|
|
2063
2101
|
const activity = db.prepare(`SELECT COUNT(*) AS n FROM commits WHERE author = ? AND timestamp >= ?`).get(lastTouch.author, lowerBound);
|
|
2064
2102
|
let strength = 0;
|
|
2065
2103
|
if (activity.n === 0) strength = 1;
|
|
@@ -2068,12 +2106,11 @@ function computeAuthorChurn(db, filePath) {
|
|
|
2068
2106
|
}
|
|
2069
2107
|
|
|
2070
2108
|
// src/memory/signals/index.ts
|
|
2071
|
-
function collectSignals(db,
|
|
2109
|
+
function collectSignals(db, _repoPath, filePath) {
|
|
2072
2110
|
return [
|
|
2073
2111
|
computeRevertMatch(db, filePath),
|
|
2074
2112
|
computeHotspot(db, filePath),
|
|
2075
2113
|
computeFixRatio(db, filePath),
|
|
2076
|
-
computeCoverageDecline(db, repoPath, filePath),
|
|
2077
2114
|
computeAuthorChurn(db, filePath)
|
|
2078
2115
|
];
|
|
2079
2116
|
}
|
|
@@ -2236,6 +2273,9 @@ var WorkerPool = class {
|
|
|
2236
2273
|
}
|
|
2237
2274
|
};
|
|
2238
2275
|
|
|
2276
|
+
// src/memory/api.ts
|
|
2277
|
+
init_git();
|
|
2278
|
+
|
|
2239
2279
|
// src/memory/detectors.ts
|
|
2240
2280
|
function detectSquashed(db) {
|
|
2241
2281
|
const row = db.prepare(`
|
|
@@ -2404,6 +2444,24 @@ var MemoryAPI = class {
|
|
|
2404
2444
|
});
|
|
2405
2445
|
return this.bootstrapPromise;
|
|
2406
2446
|
}
|
|
2447
|
+
// bootstrapFromBoundary indexes only commits between fromSha and HEAD.
|
|
2448
|
+
// Used by `composto index --since=YYYY-MM-DD` to bound work on huge repos.
|
|
2449
|
+
// Pass fromSha=null to index the full history (same as bootstrapIfNeeded).
|
|
2450
|
+
async bootstrapFromBoundary(fromSha) {
|
|
2451
|
+
if (this.bootstrapPromise) return this.bootstrapPromise;
|
|
2452
|
+
const head = revParseHead(this.repoPath);
|
|
2453
|
+
const range = { from: fromSha, to: head };
|
|
2454
|
+
this.bootstrapPromise = this.pool.runIngest({ dbPath: this.dbPath, repoPath: this.repoPath, range }).then(() => {
|
|
2455
|
+
this.log.info("bootstrap_done", { through: range.to, from: range.from });
|
|
2456
|
+
}).catch((err) => {
|
|
2457
|
+
this.log.error("bootstrap_failed", { message: err.message });
|
|
2458
|
+
this.failures.recordFailure("ingest_failure");
|
|
2459
|
+
throw err;
|
|
2460
|
+
}).finally(() => {
|
|
2461
|
+
this.bootstrapPromise = null;
|
|
2462
|
+
});
|
|
2463
|
+
return this.bootstrapPromise;
|
|
2464
|
+
}
|
|
2407
2465
|
async blastradius(input) {
|
|
2408
2466
|
const start = Date.now();
|
|
2409
2467
|
if (this.failures.isDisabled()) {
|
|
@@ -2567,7 +2625,7 @@ function statFileSize(path) {
|
|
|
2567
2625
|
function runScan(projectPath) {
|
|
2568
2626
|
const adapter = new CLIAdapter();
|
|
2569
2627
|
const config = loadConfig(projectPath);
|
|
2570
|
-
console.log("composto v0.4.
|
|
2628
|
+
console.log("composto v0.4.2 \u2014 scanning...\n");
|
|
2571
2629
|
const files = collectFiles(projectPath, [".ts", ".tsx", ".js", ".jsx"]);
|
|
2572
2630
|
console.log(` Found ${files.length} files
|
|
2573
2631
|
`);
|
|
@@ -2594,7 +2652,7 @@ function runScan(projectPath) {
|
|
|
2594
2652
|
function runTrends(projectPath) {
|
|
2595
2653
|
const adapter = new CLIAdapter();
|
|
2596
2654
|
const config = loadConfig(projectPath);
|
|
2597
|
-
console.log("composto v0.4.
|
|
2655
|
+
console.log("composto v0.4.2 \u2014 trend analysis...\n");
|
|
2598
2656
|
const entries = getGitLog(projectPath, 100);
|
|
2599
2657
|
if (entries.length === 0) {
|
|
2600
2658
|
console.log(" No git history found.\n");
|
|
@@ -2636,7 +2694,7 @@ async function runIR(projectPath, filePath, layer) {
|
|
|
2636
2694
|
}
|
|
2637
2695
|
var ALL_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".py", ".go", ".rs"];
|
|
2638
2696
|
async function runBenchmark(projectPath) {
|
|
2639
|
-
console.log("composto v0.4.
|
|
2697
|
+
console.log("composto v0.4.2 \u2014 benchmark\n");
|
|
2640
2698
|
const files = collectFiles(projectPath, ALL_EXTENSIONS);
|
|
2641
2699
|
console.log(` ${files.length} files
|
|
2642
2700
|
`);
|
|
@@ -2683,7 +2741,7 @@ async function runBenchmarkQuality(projectPath, filePath) {
|
|
|
2683
2741
|
}
|
|
2684
2742
|
const code = readFileSync4(filePath, "utf-8");
|
|
2685
2743
|
const relPath = relative2(projectPath, filePath);
|
|
2686
|
-
console.log("composto v0.4.
|
|
2744
|
+
console.log("composto v0.4.2 \u2014 quality benchmark\n");
|
|
2687
2745
|
console.log(` File: ${relPath}
|
|
2688
2746
|
`);
|
|
2689
2747
|
console.log(" Sending to Claude Haiku...\n");
|
|
@@ -2713,8 +2771,8 @@ ${result.ir.response}
|
|
|
2713
2771
|
}
|
|
2714
2772
|
}
|
|
2715
2773
|
async function runContext(projectPath, budget, target) {
|
|
2716
|
-
const header = target ? `composto v0.4.
|
|
2717
|
-
` : `composto v0.4.
|
|
2774
|
+
const header = target ? `composto v0.4.2 \u2014 context (target: ${target}, budget: ${budget} tokens)
|
|
2775
|
+
` : `composto v0.4.2 \u2014 context (budget: ${budget} tokens)
|
|
2718
2776
|
`;
|
|
2719
2777
|
console.log(header);
|
|
2720
2778
|
const files = collectFiles(projectPath, ALL_EXTENSIONS);
|
|
@@ -2812,18 +2870,60 @@ async function runImpact(projectPath, file, opts = {}) {
|
|
|
2812
2870
|
await api.close();
|
|
2813
2871
|
}
|
|
2814
2872
|
}
|
|
2815
|
-
async function runIndex(projectPath) {
|
|
2873
|
+
async function runIndex(projectPath, options = {}) {
|
|
2874
|
+
const { resolveSinceBoundary: resolveSinceBoundary2 } = await Promise.resolve().then(() => (init_git(), git_exports));
|
|
2816
2875
|
const dbPath = join6(projectPath, ".composto", "memory.db");
|
|
2817
2876
|
const api = new MemoryAPI({ dbPath, repoPath: projectPath });
|
|
2877
|
+
const Database2 = (await import("better-sqlite3")).default;
|
|
2878
|
+
const probeDb = new Database2(dbPath, { readonly: true, fileMustExist: false });
|
|
2879
|
+
const start = Date.now();
|
|
2880
|
+
let stopProgress = () => {
|
|
2881
|
+
};
|
|
2818
2882
|
try {
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2883
|
+
if (options.since) {
|
|
2884
|
+
const fromSha = resolveSinceBoundary2(projectPath, options.since);
|
|
2885
|
+
console.log(`composto: indexing commits since ${options.since}${fromSha ? ` (boundary ${fromSha.slice(0, 8)})` : " (whole history \u2014 date predates first commit)"}...`);
|
|
2886
|
+
stopProgress = startProgressPoller(probeDb, start);
|
|
2887
|
+
await api.bootstrapFromBoundary(fromSha);
|
|
2888
|
+
} else {
|
|
2889
|
+
console.log("composto: bootstrapping memory index...");
|
|
2890
|
+
stopProgress = startProgressPoller(probeDb, start);
|
|
2891
|
+
await api.bootstrapIfNeeded();
|
|
2892
|
+
}
|
|
2893
|
+
stopProgress();
|
|
2894
|
+
const total = readIndexedTotal(probeDb);
|
|
2895
|
+
const elapsed = Date.now() - start;
|
|
2896
|
+
const rate = total > 0 && elapsed > 0 ? Math.round(total * 1e3 / elapsed) : 0;
|
|
2897
|
+
console.log(`composto: index ready \u2014 ${total.toLocaleString()} commits in ${(elapsed / 1e3).toFixed(1)}s (${rate.toLocaleString()} commits/sec)`);
|
|
2823
2898
|
} finally {
|
|
2899
|
+
stopProgress();
|
|
2900
|
+
probeDb.close();
|
|
2824
2901
|
await api.close();
|
|
2825
2902
|
}
|
|
2826
2903
|
}
|
|
2904
|
+
function readIndexedTotal(db) {
|
|
2905
|
+
try {
|
|
2906
|
+
const row = db.prepare("SELECT value FROM index_state WHERE key = 'indexed_commits_total'").get();
|
|
2907
|
+
return row ? parseInt(row.value, 10) : 0;
|
|
2908
|
+
} catch {
|
|
2909
|
+
return 0;
|
|
2910
|
+
}
|
|
2911
|
+
}
|
|
2912
|
+
function startProgressPoller(db, start) {
|
|
2913
|
+
let last = -1;
|
|
2914
|
+
const interval = setInterval(() => {
|
|
2915
|
+
const total = readIndexedTotal(db);
|
|
2916
|
+
if (total === last) return;
|
|
2917
|
+
last = total;
|
|
2918
|
+
const elapsed = Date.now() - start;
|
|
2919
|
+
const rate = total > 0 && elapsed > 0 ? Math.round(total * 1e3 / elapsed) : 0;
|
|
2920
|
+
process.stdout.write(` indexed ${total.toLocaleString()} commits (${rate.toLocaleString()}/sec) [${(elapsed / 1e3).toFixed(1)}s]\r`);
|
|
2921
|
+
}, 1500);
|
|
2922
|
+
return () => {
|
|
2923
|
+
clearInterval(interval);
|
|
2924
|
+
process.stdout.write("\n");
|
|
2925
|
+
};
|
|
2926
|
+
}
|
|
2827
2927
|
async function runIndexStatus(projectPath) {
|
|
2828
2928
|
const dbPath = join6(projectPath, ".composto", "memory.db");
|
|
2829
2929
|
const s = collectStatus(dbPath);
|
|
@@ -2852,8 +2952,633 @@ async function runIndexStatus(projectPath) {
|
|
|
2852
2952
|
console.log(` Integrity check: ${s.integrityOk ? "OK" : "FAIL"}`);
|
|
2853
2953
|
}
|
|
2854
2954
|
|
|
2955
|
+
// src/cli/init.ts
|
|
2956
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "fs";
|
|
2957
|
+
import { homedir } from "os";
|
|
2958
|
+
import { dirname as dirname5, join as join7 } from "path";
|
|
2959
|
+
var CURSOR_RULES_MDC = `---
|
|
2960
|
+
description: Composto MCP usage \u2014 tells the agent when to prefer composto_blastradius / composto_context / composto_ir / composto_scan over built-in file tools.
|
|
2961
|
+
alwaysApply: true
|
|
2962
|
+
---
|
|
2963
|
+
|
|
2964
|
+
# Composto MCP \u2014 when to use
|
|
2965
|
+
|
|
2966
|
+
This project has the \`composto\` MCP server registered. Prefer Composto tools over Cursor's built-in tools in the situations below.
|
|
2967
|
+
|
|
2968
|
+
## Before editing any existing file under \`src/\`, \`lib/\`, or \`app/\`
|
|
2969
|
+
|
|
2970
|
+
Call \`composto_blastradius\` with the target file path **first**, before proposing any edit.
|
|
2971
|
+
|
|
2972
|
+
- If \`verdict\` is \`high\`: surface the risk to the user before changing the file. Mention the strongest signal (e.g. "this file was reverted the last time it was touched", "high fix-ratio in recent history").
|
|
2973
|
+
- If \`verdict\` is \`medium\`: note it briefly and proceed.
|
|
2974
|
+
- If \`verdict\` is \`low\` or \`unknown\`: proceed without comment.
|
|
2975
|
+
|
|
2976
|
+
Skip this on test files, generated code, lockfiles, and anything in \`node_modules/\` or \`dist/\`.
|
|
2977
|
+
|
|
2978
|
+
## When the user says "fix this bug in X", "why does X return wrong value", or "trace X"
|
|
2979
|
+
|
|
2980
|
+
Call \`composto_context\` with \`target: "<symbol-or-file>"\` and \`budget: 4000\` instead of opening 5 files individually. The target file comes back as raw code; surrounding files come back as compressed IR. One round-trip, full picture.
|
|
2981
|
+
|
|
2982
|
+
## When you only need to know what a file does (not its exact code)
|
|
2983
|
+
|
|
2984
|
+
Call \`composto_ir\` with \`layer: "L1"\` instead of \`read_file\`. ~89% fewer tokens, same understanding. Use \`read_file\` only when you need exact strings, regexes, or formatting.
|
|
2985
|
+
|
|
2986
|
+
## Before staging commits / when reviewing diffs
|
|
2987
|
+
|
|
2988
|
+
Call \`composto_scan\` on the changed paths to catch hardcoded secrets, debug artifacts, and stray \`console.log\` calls before the user commits.
|
|
2989
|
+
|
|
2990
|
+
## Don't
|
|
2991
|
+
|
|
2992
|
+
- Don't call \`composto_benchmark\` unless the user explicitly asks about token savings.
|
|
2993
|
+
- Don't run \`composto_blastradius\` on every read \u2014 only before edits.
|
|
2994
|
+
- Don't compress a file the user explicitly asked to see in full.
|
|
2995
|
+
`;
|
|
2996
|
+
function ensureDir(filePath) {
|
|
2997
|
+
mkdirSync4(dirname5(filePath), { recursive: true });
|
|
2998
|
+
}
|
|
2999
|
+
function writeJsonMerged(filePath, patch, result, relPath) {
|
|
3000
|
+
ensureDir(filePath);
|
|
3001
|
+
if (existsSync4(filePath)) {
|
|
3002
|
+
const existing = JSON.parse(readFileSync5(filePath, "utf-8"));
|
|
3003
|
+
const mergedServers = {
|
|
3004
|
+
...existing.mcpServers ?? {},
|
|
3005
|
+
...patch.mcpServers ?? {}
|
|
3006
|
+
};
|
|
3007
|
+
const merged = { ...existing, ...patch, mcpServers: mergedServers };
|
|
3008
|
+
writeFileSync2(filePath, JSON.stringify(merged, null, 2) + "\n");
|
|
3009
|
+
result.merged.push(relPath);
|
|
3010
|
+
} else {
|
|
3011
|
+
writeFileSync2(filePath, JSON.stringify(patch, null, 2) + "\n");
|
|
3012
|
+
result.written.push(relPath);
|
|
3013
|
+
}
|
|
3014
|
+
}
|
|
3015
|
+
function writeFileSkipIfExists(filePath, content, result, relPath) {
|
|
3016
|
+
ensureDir(filePath);
|
|
3017
|
+
if (existsSync4(filePath)) {
|
|
3018
|
+
result.skipped.push(relPath);
|
|
3019
|
+
return;
|
|
3020
|
+
}
|
|
3021
|
+
writeFileSync2(filePath, content);
|
|
3022
|
+
result.written.push(relPath);
|
|
3023
|
+
}
|
|
3024
|
+
function mergeHookArray(existingHooks, newEntry, dedupKey) {
|
|
3025
|
+
const arr = Array.isArray(existingHooks) ? [...existingHooks] : [];
|
|
3026
|
+
const key = dedupKey(newEntry);
|
|
3027
|
+
if (arr.some((e) => dedupKey(e) === key)) return arr;
|
|
3028
|
+
arr.push(newEntry);
|
|
3029
|
+
return arr;
|
|
3030
|
+
}
|
|
3031
|
+
function readJsonIfExists(filePath) {
|
|
3032
|
+
if (!existsSync4(filePath)) return {};
|
|
3033
|
+
try {
|
|
3034
|
+
const parsed = JSON.parse(readFileSync5(filePath, "utf-8"));
|
|
3035
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
3036
|
+
} catch {
|
|
3037
|
+
return {};
|
|
3038
|
+
}
|
|
3039
|
+
}
|
|
3040
|
+
function writeCursorHooks(projectPath, result) {
|
|
3041
|
+
const hooksPath = join7(projectPath, ".cursor", "hooks.json");
|
|
3042
|
+
const relPath = ".cursor/hooks.json";
|
|
3043
|
+
const existed = existsSync4(hooksPath);
|
|
3044
|
+
const existing = readJsonIfExists(hooksPath);
|
|
3045
|
+
const existingHooks = existing.hooks ?? {};
|
|
3046
|
+
const compostoEntry = {
|
|
3047
|
+
matcher: "Edit|Write",
|
|
3048
|
+
command: "composto hook cursor pretooluse"
|
|
3049
|
+
};
|
|
3050
|
+
const preToolUse = mergeHookArray(
|
|
3051
|
+
existingHooks.preToolUse,
|
|
3052
|
+
compostoEntry,
|
|
3053
|
+
(e) => e?.command ?? ""
|
|
3054
|
+
);
|
|
3055
|
+
const merged = {
|
|
3056
|
+
...existing,
|
|
3057
|
+
version: existing.version ?? 1,
|
|
3058
|
+
hooks: { ...existingHooks, preToolUse }
|
|
3059
|
+
};
|
|
3060
|
+
ensureDir(hooksPath);
|
|
3061
|
+
writeFileSync2(hooksPath, JSON.stringify(merged, null, 2) + "\n");
|
|
3062
|
+
if (existed) result.merged.push(relPath);
|
|
3063
|
+
else result.written.push(relPath);
|
|
3064
|
+
}
|
|
3065
|
+
function initCursor(projectPath, result) {
|
|
3066
|
+
writeJsonMerged(
|
|
3067
|
+
join7(projectPath, ".cursor", "mcp.json"),
|
|
3068
|
+
{ mcpServers: { composto: { command: "composto-mcp" } } },
|
|
3069
|
+
result,
|
|
3070
|
+
".cursor/mcp.json"
|
|
3071
|
+
);
|
|
3072
|
+
writeFileSkipIfExists(
|
|
3073
|
+
join7(projectPath, ".cursor", "rules", "composto.mdc"),
|
|
3074
|
+
CURSOR_RULES_MDC,
|
|
3075
|
+
result,
|
|
3076
|
+
".cursor/rules/composto.mdc"
|
|
3077
|
+
);
|
|
3078
|
+
writeCursorHooks(projectPath, result);
|
|
3079
|
+
}
|
|
3080
|
+
function initClaudeCode(projectPath, result) {
|
|
3081
|
+
const settingsPath = join7(projectPath, ".claude", "settings.json");
|
|
3082
|
+
const relPath = ".claude/settings.json";
|
|
3083
|
+
const existed = existsSync4(settingsPath);
|
|
3084
|
+
const existing = readJsonIfExists(settingsPath);
|
|
3085
|
+
const mcpServers = {
|
|
3086
|
+
...existing.mcpServers ?? {},
|
|
3087
|
+
composto: { command: "composto-mcp" }
|
|
3088
|
+
};
|
|
3089
|
+
const compostoHookEntry = {
|
|
3090
|
+
matcher: "Edit|Write|MultiEdit",
|
|
3091
|
+
hooks: [
|
|
3092
|
+
{ type: "command", command: "composto hook claude-code pretooluse" }
|
|
3093
|
+
]
|
|
3094
|
+
};
|
|
3095
|
+
const existingHooks = existing.hooks ?? {};
|
|
3096
|
+
const preToolUse = mergeHookArray(
|
|
3097
|
+
existingHooks.PreToolUse,
|
|
3098
|
+
compostoHookEntry,
|
|
3099
|
+
(e) => e?.hooks?.[0]?.command ?? ""
|
|
3100
|
+
);
|
|
3101
|
+
const merged = {
|
|
3102
|
+
...existing,
|
|
3103
|
+
mcpServers,
|
|
3104
|
+
hooks: { ...existingHooks, PreToolUse: preToolUse }
|
|
3105
|
+
};
|
|
3106
|
+
ensureDir(settingsPath);
|
|
3107
|
+
writeFileSync2(settingsPath, JSON.stringify(merged, null, 2) + "\n");
|
|
3108
|
+
if (existed) result.merged.push(relPath);
|
|
3109
|
+
else result.written.push(relPath);
|
|
3110
|
+
}
|
|
3111
|
+
function initGeminiCli(_projectPath, result, options) {
|
|
3112
|
+
const settingsPath = options.geminiSettingsPath ?? join7(homedir(), ".gemini", "settings.json");
|
|
3113
|
+
const relPath = settingsPath;
|
|
3114
|
+
try {
|
|
3115
|
+
const existed = existsSync4(settingsPath);
|
|
3116
|
+
const existing = readJsonIfExists(settingsPath);
|
|
3117
|
+
const mcpServers = {
|
|
3118
|
+
...existing.mcpServers ?? {},
|
|
3119
|
+
composto: { command: "composto-mcp" }
|
|
3120
|
+
};
|
|
3121
|
+
const compostoHookEntry = {
|
|
3122
|
+
matcher: "edit_file|write_file|replace",
|
|
3123
|
+
hooks: [
|
|
3124
|
+
{ type: "command", command: "composto hook gemini-cli beforetool" }
|
|
3125
|
+
]
|
|
3126
|
+
};
|
|
3127
|
+
const existingHooks = existing.hooks ?? {};
|
|
3128
|
+
const beforeTool = mergeHookArray(
|
|
3129
|
+
existingHooks.BeforeTool,
|
|
3130
|
+
compostoHookEntry,
|
|
3131
|
+
(e) => e?.hooks?.[0]?.command ?? ""
|
|
3132
|
+
);
|
|
3133
|
+
const merged = {
|
|
3134
|
+
...existing,
|
|
3135
|
+
mcpServers,
|
|
3136
|
+
hooks: { ...existingHooks, BeforeTool: beforeTool }
|
|
3137
|
+
};
|
|
3138
|
+
ensureDir(settingsPath);
|
|
3139
|
+
writeFileSync2(settingsPath, JSON.stringify(merged, null, 2) + "\n");
|
|
3140
|
+
if (existed) result.merged.push(relPath);
|
|
3141
|
+
else result.written.push(relPath);
|
|
3142
|
+
} catch (err) {
|
|
3143
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
3144
|
+
result.skipped.push(`${settingsPath} (write failed: ${reason})`);
|
|
3145
|
+
}
|
|
3146
|
+
}
|
|
3147
|
+
function runInit(projectPath, options) {
|
|
3148
|
+
const client = options.client ?? "cursor";
|
|
3149
|
+
const result = { client, written: [], skipped: [], merged: [] };
|
|
3150
|
+
if (client === "claude-code") initClaudeCode(projectPath, result);
|
|
3151
|
+
else if (client === "gemini-cli") initGeminiCli(projectPath, result, options);
|
|
3152
|
+
else initCursor(projectPath, result);
|
|
3153
|
+
return result;
|
|
3154
|
+
}
|
|
3155
|
+
|
|
3156
|
+
// src/cli/hook/api-deps.ts
|
|
3157
|
+
var defaultDeps = {
|
|
3158
|
+
makeApi(opts) {
|
|
3159
|
+
return new MemoryAPI(opts);
|
|
3160
|
+
}
|
|
3161
|
+
};
|
|
3162
|
+
|
|
3163
|
+
// src/cli/hook/extract.ts
|
|
3164
|
+
var FILE_TOOLS = {
|
|
3165
|
+
Edit: ["file_path"],
|
|
3166
|
+
Write: ["file_path"],
|
|
3167
|
+
MultiEdit: ["file_path"],
|
|
3168
|
+
edit_file: ["path", "file_path"],
|
|
3169
|
+
write_file: ["path", "file_path"],
|
|
3170
|
+
replace: ["path", "file_path"]
|
|
3171
|
+
};
|
|
3172
|
+
function extractFilePath(inv) {
|
|
3173
|
+
const name = inv.tool_name;
|
|
3174
|
+
if (typeof name !== "string") return null;
|
|
3175
|
+
const candidates = FILE_TOOLS[name];
|
|
3176
|
+
if (!candidates) return null;
|
|
3177
|
+
const input = inv.tool_input;
|
|
3178
|
+
if (!input || typeof input !== "object") return null;
|
|
3179
|
+
for (const field of candidates) {
|
|
3180
|
+
const v = input[field];
|
|
3181
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
3182
|
+
}
|
|
3183
|
+
return null;
|
|
3184
|
+
}
|
|
3185
|
+
|
|
3186
|
+
// src/cli/hook/format.ts
|
|
3187
|
+
function formatBlastRadiusContext(filePath, res, opts = {}) {
|
|
3188
|
+
const firing = res.signals.filter((s) => s.strength > 0).map((s) => `${s.type}=${s.strength.toFixed(2)}`).join(", ");
|
|
3189
|
+
const hint = opts.hint ?? "this file's bug history may be relevant to your edit. See composto_blastradius for detail.";
|
|
3190
|
+
return [
|
|
3191
|
+
`<composto_blastradius>`,
|
|
3192
|
+
` file: ${filePath}`,
|
|
3193
|
+
` verdict: ${res.verdict}`,
|
|
3194
|
+
` score: ${res.score.toFixed(2)} confidence: ${res.confidence.toFixed(2)}`,
|
|
3195
|
+
firing ? ` firing_signals: ${firing}` : ` firing_signals: (none)`,
|
|
3196
|
+
` hint: ${hint}`,
|
|
3197
|
+
`</composto_blastradius>`
|
|
3198
|
+
].join("\n");
|
|
3199
|
+
}
|
|
3200
|
+
|
|
3201
|
+
// src/cli/hook/adapters/claude-code.ts
|
|
3202
|
+
import { join as join8 } from "path";
|
|
3203
|
+
var EMPTY_META = {
|
|
3204
|
+
filePath: null,
|
|
3205
|
+
verdict: null,
|
|
3206
|
+
score: null,
|
|
3207
|
+
confidence: null
|
|
3208
|
+
};
|
|
3209
|
+
function passthrough(filePath = null) {
|
|
3210
|
+
return {
|
|
3211
|
+
envelope: { hookSpecificOutput: {} },
|
|
3212
|
+
metadata: { ...EMPTY_META, filePath }
|
|
3213
|
+
};
|
|
3214
|
+
}
|
|
3215
|
+
async function runClaudeCodeHook(opts, deps = defaultDeps) {
|
|
3216
|
+
let payload;
|
|
3217
|
+
try {
|
|
3218
|
+
payload = JSON.parse(opts.stdin);
|
|
3219
|
+
} catch {
|
|
3220
|
+
return passthrough();
|
|
3221
|
+
}
|
|
3222
|
+
if (typeof payload !== "object" || payload === null) return passthrough();
|
|
3223
|
+
const filePath = extractFilePath(payload);
|
|
3224
|
+
if (!filePath) return passthrough();
|
|
3225
|
+
try {
|
|
3226
|
+
const dbPath = join8(opts.cwd, ".composto", "memory.db");
|
|
3227
|
+
const api = deps.makeApi({ dbPath, repoPath: opts.cwd });
|
|
3228
|
+
try {
|
|
3229
|
+
const res = await api.blastradius({ file: filePath });
|
|
3230
|
+
if (!res || res.verdict === "low") {
|
|
3231
|
+
return {
|
|
3232
|
+
envelope: { hookSpecificOutput: {} },
|
|
3233
|
+
metadata: {
|
|
3234
|
+
filePath,
|
|
3235
|
+
verdict: res ? res.verdict : null,
|
|
3236
|
+
score: res ? res.score : null,
|
|
3237
|
+
confidence: res ? res.confidence : null
|
|
3238
|
+
}
|
|
3239
|
+
};
|
|
3240
|
+
}
|
|
3241
|
+
const body = formatBlastRadiusContext(filePath, res);
|
|
3242
|
+
return {
|
|
3243
|
+
envelope: {
|
|
3244
|
+
hookSpecificOutput: {
|
|
3245
|
+
hookEventName: "PreToolUse",
|
|
3246
|
+
additionalContext: body
|
|
3247
|
+
}
|
|
3248
|
+
},
|
|
3249
|
+
metadata: {
|
|
3250
|
+
filePath,
|
|
3251
|
+
verdict: res.verdict,
|
|
3252
|
+
score: res.score,
|
|
3253
|
+
confidence: res.confidence
|
|
3254
|
+
}
|
|
3255
|
+
};
|
|
3256
|
+
} finally {
|
|
3257
|
+
await api.close();
|
|
3258
|
+
}
|
|
3259
|
+
} catch {
|
|
3260
|
+
return passthrough(filePath);
|
|
3261
|
+
}
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3264
|
+
// src/cli/hook/adapters/cursor.ts
|
|
3265
|
+
import { join as join9 } from "path";
|
|
3266
|
+
var CURSOR_HINT = "this file's bug history suggests high risk \u2014 ask the user to confirm before editing.";
|
|
3267
|
+
var EMPTY_META2 = {
|
|
3268
|
+
filePath: null,
|
|
3269
|
+
verdict: null,
|
|
3270
|
+
score: null,
|
|
3271
|
+
confidence: null
|
|
3272
|
+
};
|
|
3273
|
+
function passthrough2(filePath = null) {
|
|
3274
|
+
return { envelope: {}, metadata: { ...EMPTY_META2, filePath } };
|
|
3275
|
+
}
|
|
3276
|
+
async function runCursorHook(opts, deps = defaultDeps) {
|
|
3277
|
+
let payload;
|
|
3278
|
+
try {
|
|
3279
|
+
payload = JSON.parse(opts.stdin);
|
|
3280
|
+
} catch {
|
|
3281
|
+
return passthrough2();
|
|
3282
|
+
}
|
|
3283
|
+
if (typeof payload !== "object" || payload === null) return passthrough2();
|
|
3284
|
+
const filePath = extractFilePath(payload);
|
|
3285
|
+
if (!filePath) return passthrough2();
|
|
3286
|
+
try {
|
|
3287
|
+
const dbPath = join9(opts.cwd, ".composto", "memory.db");
|
|
3288
|
+
const api = deps.makeApi({ dbPath, repoPath: opts.cwd });
|
|
3289
|
+
try {
|
|
3290
|
+
const res = await api.blastradius({ file: filePath });
|
|
3291
|
+
if (!res || res.verdict !== "high") {
|
|
3292
|
+
return {
|
|
3293
|
+
envelope: {},
|
|
3294
|
+
metadata: {
|
|
3295
|
+
filePath,
|
|
3296
|
+
verdict: res ? res.verdict : null,
|
|
3297
|
+
score: res ? res.score : null,
|
|
3298
|
+
confidence: res ? res.confidence : null
|
|
3299
|
+
}
|
|
3300
|
+
};
|
|
3301
|
+
}
|
|
3302
|
+
return {
|
|
3303
|
+
envelope: {
|
|
3304
|
+
permissionDecision: "deny",
|
|
3305
|
+
permissionDecisionReason: formatBlastRadiusContext(filePath, res, { hint: CURSOR_HINT })
|
|
3306
|
+
},
|
|
3307
|
+
metadata: {
|
|
3308
|
+
filePath,
|
|
3309
|
+
verdict: res.verdict,
|
|
3310
|
+
score: res.score,
|
|
3311
|
+
confidence: res.confidence
|
|
3312
|
+
}
|
|
3313
|
+
};
|
|
3314
|
+
} finally {
|
|
3315
|
+
await api.close();
|
|
3316
|
+
}
|
|
3317
|
+
} catch {
|
|
3318
|
+
return passthrough2(filePath);
|
|
3319
|
+
}
|
|
3320
|
+
}
|
|
3321
|
+
|
|
3322
|
+
// src/cli/hook/adapters/gemini-cli.ts
|
|
3323
|
+
import { join as join10 } from "path";
|
|
3324
|
+
var EMPTY_META3 = {
|
|
3325
|
+
filePath: null,
|
|
3326
|
+
verdict: null,
|
|
3327
|
+
score: null,
|
|
3328
|
+
confidence: null
|
|
3329
|
+
};
|
|
3330
|
+
function passthrough3(filePath = null) {
|
|
3331
|
+
return {
|
|
3332
|
+
envelope: { hookSpecificOutput: {} },
|
|
3333
|
+
metadata: { ...EMPTY_META3, filePath }
|
|
3334
|
+
};
|
|
3335
|
+
}
|
|
3336
|
+
async function runGeminiCliHook(opts, deps = defaultDeps) {
|
|
3337
|
+
let payload;
|
|
3338
|
+
try {
|
|
3339
|
+
payload = JSON.parse(opts.stdin);
|
|
3340
|
+
} catch {
|
|
3341
|
+
return passthrough3();
|
|
3342
|
+
}
|
|
3343
|
+
if (typeof payload !== "object" || payload === null) return passthrough3();
|
|
3344
|
+
const filePath = extractFilePath(payload);
|
|
3345
|
+
if (!filePath) return passthrough3();
|
|
3346
|
+
try {
|
|
3347
|
+
const dbPath = join10(opts.cwd, ".composto", "memory.db");
|
|
3348
|
+
const api = deps.makeApi({ dbPath, repoPath: opts.cwd });
|
|
3349
|
+
try {
|
|
3350
|
+
const res = await api.blastradius({ file: filePath });
|
|
3351
|
+
if (!res || res.verdict === "low") {
|
|
3352
|
+
return {
|
|
3353
|
+
envelope: { hookSpecificOutput: {} },
|
|
3354
|
+
metadata: {
|
|
3355
|
+
filePath,
|
|
3356
|
+
verdict: res ? res.verdict : null,
|
|
3357
|
+
score: res ? res.score : null,
|
|
3358
|
+
confidence: res ? res.confidence : null
|
|
3359
|
+
}
|
|
3360
|
+
};
|
|
3361
|
+
}
|
|
3362
|
+
const body = formatBlastRadiusContext(filePath, res);
|
|
3363
|
+
return {
|
|
3364
|
+
envelope: {
|
|
3365
|
+
hookSpecificOutput: {
|
|
3366
|
+
hookEventName: "BeforeTool",
|
|
3367
|
+
additionalContext: body
|
|
3368
|
+
}
|
|
3369
|
+
},
|
|
3370
|
+
metadata: {
|
|
3371
|
+
filePath,
|
|
3372
|
+
verdict: res.verdict,
|
|
3373
|
+
score: res.score,
|
|
3374
|
+
confidence: res.confidence
|
|
3375
|
+
}
|
|
3376
|
+
};
|
|
3377
|
+
} finally {
|
|
3378
|
+
await api.close();
|
|
3379
|
+
}
|
|
3380
|
+
} catch {
|
|
3381
|
+
return passthrough3(filePath);
|
|
3382
|
+
}
|
|
3383
|
+
}
|
|
3384
|
+
|
|
3385
|
+
// src/cli/hook/dispatcher.ts
|
|
3386
|
+
async function runHookDispatch(opts, deps = defaultDeps) {
|
|
3387
|
+
const p = opts.platform;
|
|
3388
|
+
const e = opts.event;
|
|
3389
|
+
const hookOpts = { stdin: opts.stdin, cwd: opts.cwd };
|
|
3390
|
+
switch (p) {
|
|
3391
|
+
case "claude-code":
|
|
3392
|
+
if (e === "pretooluse") return runClaudeCodeHook(hookOpts, deps);
|
|
3393
|
+
throw new Error(`unknown event for claude-code: ${e}`);
|
|
3394
|
+
case "cursor":
|
|
3395
|
+
if (e === "pretooluse") return runCursorHook(hookOpts, deps);
|
|
3396
|
+
throw new Error(`unknown event for cursor: ${e}`);
|
|
3397
|
+
case "gemini-cli":
|
|
3398
|
+
if (e === "beforetool") return runGeminiCliHook(hookOpts, deps);
|
|
3399
|
+
throw new Error(`unknown event for gemini-cli: ${e}`);
|
|
3400
|
+
default: {
|
|
3401
|
+
const _exhaustive = p;
|
|
3402
|
+
throw new Error(`unknown platform: ${_exhaustive}`);
|
|
3403
|
+
}
|
|
3404
|
+
}
|
|
3405
|
+
}
|
|
3406
|
+
|
|
3407
|
+
// src/cli/stats.ts
|
|
3408
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync5, writeFileSync as writeFileSync3 } from "fs";
|
|
3409
|
+
import { join as join12 } from "path";
|
|
3410
|
+
|
|
3411
|
+
// src/memory/telemetry/hook-invocations.ts
|
|
3412
|
+
import { existsSync as existsSync5 } from "fs";
|
|
3413
|
+
import { dirname as dirname6, join as join11 } from "path";
|
|
3414
|
+
var SEVEN_DAYS_SEC = 7 * 24 * 60 * 60;
|
|
3415
|
+
var DISABLE_MARKER_NAME = "telemetry-disabled";
|
|
3416
|
+
function dbDirectory(db) {
|
|
3417
|
+
const name = db.name;
|
|
3418
|
+
if (typeof name !== "string" || name.length === 0 || name === ":memory:") {
|
|
3419
|
+
return null;
|
|
3420
|
+
}
|
|
3421
|
+
return dirname6(name);
|
|
3422
|
+
}
|
|
3423
|
+
function isTelemetryDisabled(db) {
|
|
3424
|
+
const dir = dbDirectory(db);
|
|
3425
|
+
if (!dir) return false;
|
|
3426
|
+
try {
|
|
3427
|
+
return existsSync5(join11(dir, DISABLE_MARKER_NAME));
|
|
3428
|
+
} catch {
|
|
3429
|
+
return false;
|
|
3430
|
+
}
|
|
3431
|
+
}
|
|
3432
|
+
function recordInvocation(db, record) {
|
|
3433
|
+
try {
|
|
3434
|
+
if (isTelemetryDisabled(db)) return;
|
|
3435
|
+
const stmt = db.prepare(
|
|
3436
|
+
`INSERT INTO hook_invocations
|
|
3437
|
+
(timestamp, platform, event, file_path, verdict, score, confidence, latency_ms, cache_hit)
|
|
3438
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
3439
|
+
);
|
|
3440
|
+
stmt.run(
|
|
3441
|
+
record.timestamp,
|
|
3442
|
+
record.platform,
|
|
3443
|
+
record.event,
|
|
3444
|
+
record.filePath,
|
|
3445
|
+
record.verdict,
|
|
3446
|
+
record.score,
|
|
3447
|
+
record.confidence,
|
|
3448
|
+
record.latencyMs,
|
|
3449
|
+
record.cacheHit ? 1 : 0
|
|
3450
|
+
);
|
|
3451
|
+
} catch {
|
|
3452
|
+
}
|
|
3453
|
+
}
|
|
3454
|
+
function percentile(sortedAsc, p) {
|
|
3455
|
+
if (sortedAsc.length === 0) return 0;
|
|
3456
|
+
if (sortedAsc.length === 1) return sortedAsc[0];
|
|
3457
|
+
const rank = Math.min(
|
|
3458
|
+
sortedAsc.length - 1,
|
|
3459
|
+
Math.max(0, Math.ceil(p / 100 * sortedAsc.length) - 1)
|
|
3460
|
+
);
|
|
3461
|
+
return sortedAsc[rank];
|
|
3462
|
+
}
|
|
3463
|
+
function recentSummary(db, opts = {}) {
|
|
3464
|
+
const now = opts.now ?? Math.floor(Date.now() / 1e3);
|
|
3465
|
+
const since = opts.since ?? now - SEVEN_DAYS_SEC;
|
|
3466
|
+
const empty = {
|
|
3467
|
+
windowStart: since,
|
|
3468
|
+
windowEnd: now,
|
|
3469
|
+
total: 0,
|
|
3470
|
+
byVerdict: {},
|
|
3471
|
+
byPlatform: {},
|
|
3472
|
+
latencyP50: 0,
|
|
3473
|
+
latencyP95: 0,
|
|
3474
|
+
cacheHitRate: 0
|
|
3475
|
+
};
|
|
3476
|
+
let rows;
|
|
3477
|
+
try {
|
|
3478
|
+
rows = db.prepare(
|
|
3479
|
+
`SELECT platform, verdict, latency_ms, cache_hit
|
|
3480
|
+
FROM hook_invocations
|
|
3481
|
+
WHERE timestamp >= ? AND timestamp <= ?`
|
|
3482
|
+
).all(since, now);
|
|
3483
|
+
} catch {
|
|
3484
|
+
return empty;
|
|
3485
|
+
}
|
|
3486
|
+
if (rows.length === 0) return empty;
|
|
3487
|
+
const byVerdict = {};
|
|
3488
|
+
const byPlatform = {};
|
|
3489
|
+
const latencies = [];
|
|
3490
|
+
let cacheHits = 0;
|
|
3491
|
+
for (const r of rows) {
|
|
3492
|
+
const v = r.verdict == null || r.verdict === "" ? "passthrough" : r.verdict;
|
|
3493
|
+
byVerdict[v] = (byVerdict[v] ?? 0) + 1;
|
|
3494
|
+
byPlatform[r.platform] = (byPlatform[r.platform] ?? 0) + 1;
|
|
3495
|
+
latencies.push(r.latency_ms);
|
|
3496
|
+
if (r.cache_hit === 1) cacheHits++;
|
|
3497
|
+
}
|
|
3498
|
+
latencies.sort((a, b) => a - b);
|
|
3499
|
+
return {
|
|
3500
|
+
windowStart: since,
|
|
3501
|
+
windowEnd: now,
|
|
3502
|
+
total: rows.length,
|
|
3503
|
+
byVerdict,
|
|
3504
|
+
byPlatform,
|
|
3505
|
+
latencyP50: percentile(latencies, 50),
|
|
3506
|
+
latencyP95: percentile(latencies, 95),
|
|
3507
|
+
cacheHitRate: rows.length > 0 ? cacheHits / rows.length : 0
|
|
3508
|
+
};
|
|
3509
|
+
}
|
|
3510
|
+
|
|
3511
|
+
// src/cli/stats.ts
|
|
3512
|
+
var DISABLE_NOTICE = "Composto telemetry disabled. Delete .composto/telemetry-disabled to re-enable.";
|
|
3513
|
+
function runStats(opts) {
|
|
3514
|
+
const composstoDir = join12(opts.cwd, ".composto");
|
|
3515
|
+
if (opts.disable) {
|
|
3516
|
+
mkdirSync5(composstoDir, { recursive: true });
|
|
3517
|
+
writeFileSync3(join12(composstoDir, "telemetry-disabled"), "");
|
|
3518
|
+
return { action: "disabled", output: DISABLE_NOTICE };
|
|
3519
|
+
}
|
|
3520
|
+
const dbPath = join12(composstoDir, "memory.db");
|
|
3521
|
+
if (!existsSync6(dbPath)) {
|
|
3522
|
+
const msg = "No .composto/memory.db yet \u2014 run `composto index` or trigger a hook first.";
|
|
3523
|
+
if (opts.json) {
|
|
3524
|
+
return {
|
|
3525
|
+
action: "printed",
|
|
3526
|
+
output: JSON.stringify({ total: 0, note: msg }, null, 2)
|
|
3527
|
+
};
|
|
3528
|
+
}
|
|
3529
|
+
return { action: "printed", output: msg };
|
|
3530
|
+
}
|
|
3531
|
+
const db = openDatabase(dbPath);
|
|
3532
|
+
try {
|
|
3533
|
+
runMigrations(db);
|
|
3534
|
+
const summary = recentSummary(db);
|
|
3535
|
+
return {
|
|
3536
|
+
action: "printed",
|
|
3537
|
+
output: opts.json ? JSON.stringify(summary, null, 2) : renderSummary(summary)
|
|
3538
|
+
};
|
|
3539
|
+
} finally {
|
|
3540
|
+
db.close();
|
|
3541
|
+
}
|
|
3542
|
+
}
|
|
3543
|
+
function pct(n, total) {
|
|
3544
|
+
if (total === 0) return "0%";
|
|
3545
|
+
return `${Math.round(n / total * 100)}%`;
|
|
3546
|
+
}
|
|
3547
|
+
function renderSummary(s) {
|
|
3548
|
+
const lines = [];
|
|
3549
|
+
lines.push(`hook invocations (last 7d): ${s.total}`);
|
|
3550
|
+
if (s.total === 0) {
|
|
3551
|
+
lines.push(" no hook firings recorded yet.");
|
|
3552
|
+
return lines.join("\n");
|
|
3553
|
+
}
|
|
3554
|
+
const verdictOrder = ["low", "medium", "high", "unknown", "passthrough"];
|
|
3555
|
+
const verdictKeys = [
|
|
3556
|
+
...verdictOrder.filter((k) => k in s.byVerdict),
|
|
3557
|
+
...Object.keys(s.byVerdict).filter((k) => !verdictOrder.includes(k))
|
|
3558
|
+
];
|
|
3559
|
+
const verdictParts = verdictKeys.map(
|
|
3560
|
+
(k) => `${k} ${pct(s.byVerdict[k], s.total)}`
|
|
3561
|
+
);
|
|
3562
|
+
lines.push(` by verdict: ${verdictParts.join(" / ")}`);
|
|
3563
|
+
const platformParts = Object.entries(s.byPlatform).map(([k, v]) => `${k} ${v}`);
|
|
3564
|
+
lines.push(` by platform: ${platformParts.join(", ")}`);
|
|
3565
|
+
lines.push(` latency: p50 ${s.latencyP50}ms, p95 ${s.latencyP95}ms`);
|
|
3566
|
+
lines.push(
|
|
3567
|
+
` cache: hit rate ${Math.round(s.cacheHitRate * 100)}% (cache feature deferred \u2014 see Phase 1 plan)`
|
|
3568
|
+
);
|
|
3569
|
+
return lines.join("\n");
|
|
3570
|
+
}
|
|
3571
|
+
|
|
2855
3572
|
// src/index.ts
|
|
2856
|
-
import { resolve as resolve2 } from "path";
|
|
3573
|
+
import { join as join13, resolve as resolve2 } from "path";
|
|
3574
|
+
async function readStdin() {
|
|
3575
|
+
if (process.stdin.isTTY) return "";
|
|
3576
|
+
const chunks = [];
|
|
3577
|
+
for await (const chunk of process.stdin) {
|
|
3578
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
3579
|
+
}
|
|
3580
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
3581
|
+
}
|
|
2857
3582
|
var args = process.argv.slice(2);
|
|
2858
3583
|
var command = args[0];
|
|
2859
3584
|
switch (command) {
|
|
@@ -2925,15 +3650,85 @@ switch (command) {
|
|
|
2925
3650
|
if (args.includes("--status")) {
|
|
2926
3651
|
await runIndexStatus(resolve2("."));
|
|
2927
3652
|
} else {
|
|
2928
|
-
|
|
3653
|
+
const sinceArg = args.find((a) => a.startsWith("--since="))?.slice("--since=".length);
|
|
3654
|
+
await runIndex(resolve2("."), { since: sinceArg });
|
|
2929
3655
|
}
|
|
2930
3656
|
break;
|
|
2931
3657
|
}
|
|
3658
|
+
case "init": {
|
|
3659
|
+
const valid = ["cursor", "claude-code", "gemini-cli"];
|
|
3660
|
+
const clientArg = args.find((a) => a.startsWith("--client="))?.slice("--client=".length);
|
|
3661
|
+
if (clientArg && !valid.includes(clientArg)) {
|
|
3662
|
+
console.error(`Unknown --client=${clientArg}. Valid: ${valid.join(", ")}`);
|
|
3663
|
+
process.exit(1);
|
|
3664
|
+
}
|
|
3665
|
+
const result = runInit(resolve2("."), { client: clientArg });
|
|
3666
|
+
console.log(`composto init \u2014 configured for ${result.client}
|
|
3667
|
+
`);
|
|
3668
|
+
for (const f of result.written) console.log(` wrote ${f}`);
|
|
3669
|
+
for (const f of result.merged) console.log(` merged ${f}`);
|
|
3670
|
+
for (const f of result.skipped) console.log(` skipped ${f} (already exists)`);
|
|
3671
|
+
console.log("\nRestart your AI client and check that 'composto' MCP is green.");
|
|
3672
|
+
console.log(
|
|
3673
|
+
"Composto collects local-only hook telemetry to help you monitor agent behavior. Disable with `composto stats --disable` at any time."
|
|
3674
|
+
);
|
|
3675
|
+
break;
|
|
3676
|
+
}
|
|
3677
|
+
case "hook": {
|
|
3678
|
+
const hookStart = Date.now();
|
|
3679
|
+
try {
|
|
3680
|
+
const platform = args[1];
|
|
3681
|
+
const event = args[2];
|
|
3682
|
+
if (!platform || !event) {
|
|
3683
|
+
console.log('{"hookSpecificOutput":{}}');
|
|
3684
|
+
break;
|
|
3685
|
+
}
|
|
3686
|
+
const stdin = await readStdin();
|
|
3687
|
+
const result = await runHookDispatch({
|
|
3688
|
+
platform,
|
|
3689
|
+
event,
|
|
3690
|
+
stdin,
|
|
3691
|
+
cwd: process.cwd()
|
|
3692
|
+
});
|
|
3693
|
+
console.log(JSON.stringify(result.envelope));
|
|
3694
|
+
try {
|
|
3695
|
+
const dbPath = join13(process.cwd(), ".composto", "memory.db");
|
|
3696
|
+
const db = openDatabase(dbPath);
|
|
3697
|
+
try {
|
|
3698
|
+
runMigrations(db);
|
|
3699
|
+
recordInvocation(db, {
|
|
3700
|
+
timestamp: Math.floor(Date.now() / 1e3),
|
|
3701
|
+
platform,
|
|
3702
|
+
event,
|
|
3703
|
+
filePath: result.metadata.filePath,
|
|
3704
|
+
verdict: result.metadata.verdict,
|
|
3705
|
+
score: result.metadata.score,
|
|
3706
|
+
confidence: result.metadata.confidence,
|
|
3707
|
+
latencyMs: Date.now() - hookStart,
|
|
3708
|
+
cacheHit: false
|
|
3709
|
+
});
|
|
3710
|
+
} finally {
|
|
3711
|
+
db.close();
|
|
3712
|
+
}
|
|
3713
|
+
} catch {
|
|
3714
|
+
}
|
|
3715
|
+
} catch {
|
|
3716
|
+
console.log('{"hookSpecificOutput":{}}');
|
|
3717
|
+
}
|
|
3718
|
+
break;
|
|
3719
|
+
}
|
|
3720
|
+
case "stats": {
|
|
3721
|
+
const json = args.includes("--json");
|
|
3722
|
+
const disable = args.includes("--disable");
|
|
3723
|
+
const res = runStats({ cwd: resolve2("."), json, disable });
|
|
3724
|
+
console.log(res.output);
|
|
3725
|
+
break;
|
|
3726
|
+
}
|
|
2932
3727
|
case "version":
|
|
2933
|
-
console.log("composto v0.4.
|
|
3728
|
+
console.log("composto v0.4.2");
|
|
2934
3729
|
break;
|
|
2935
3730
|
default:
|
|
2936
|
-
console.log("composto v0.4.
|
|
3731
|
+
console.log("composto v0.4.2 \u2014 less tokens, more insight\n");
|
|
2937
3732
|
console.log("Commands:");
|
|
2938
3733
|
console.log(" scan [path] Scan codebase for issues");
|
|
2939
3734
|
console.log(" trends [path] Analyze codebase health trends");
|
|
@@ -2943,8 +3738,12 @@ switch (command) {
|
|
|
2943
3738
|
console.log(" context [path] --budget N Smart context within token budget");
|
|
2944
3739
|
console.log(" context [path] --target <symbol> Target file as raw, surrounding as IR");
|
|
2945
3740
|
console.log(" impact <file> Show historical blast radius for a file");
|
|
2946
|
-
console.log(" index
|
|
3741
|
+
console.log(" index [--since=YYYY-MM-DD] Build or refresh the memory index (--since bounds work for huge repos)");
|
|
2947
3742
|
console.log(" index --status Show memory index diagnostics");
|
|
3743
|
+
console.log(" init [--client=<name>] Configure Composto MCP + hooks for an AI client");
|
|
3744
|
+
console.log(" (clients: cursor, claude-code, gemini-cli)");
|
|
3745
|
+
console.log(" hook <platform> <event> Run BlastRadius hook (reads tool JSON from stdin)");
|
|
3746
|
+
console.log(" stats [--json] [--disable] Show hook telemetry (last 7d); --disable opts out");
|
|
2948
3747
|
console.log(" version Show version");
|
|
2949
3748
|
break;
|
|
2950
3749
|
}
|