composto-ai 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,23 +1,23 @@
1
1
  # Composto
2
2
 
3
- **Causal memory layer for coding agents. Catches the bug your agent is about to reintroduce.**
3
+ **Token-efficient code context for AI agents. Your file's full structure in a fraction of the tokens, with its causal history baked in.**
4
4
 
5
- Composto is a repo-local graph of your git history that your AI coding agent consults before every edit. When a file was reverted recently, has a fix cluster in its history, or was last touched by someone who left the team, Composto surfaces that signal as in-context guidance before the agent writes the code. Hook-enforced on Claude Code, Cursor, and Gemini CLI. Local-first, MIT.
5
+ Composto compresses any source file into a Health-Aware IR that keeps exactly what your agent needs signatures, types, control flow, dependencies at 60-95% fewer tokens than raw code. On top of that, it surfaces the file's causal history (what historically changed and broke alongside the code you're touching) as advisory context. Local-first, MIT. Works with Claude Code, Cursor, and Gemini CLI.
6
6
 
7
7
  ```
8
- $ composto impact src/memory/signals/hotspot.ts
9
-
10
- verdict: medium
11
- score: 0.52
12
- confidence: 0.50
13
- signals:
14
- revert_match ■■■■■■■■■■ strength=1.00 precision=1.00
15
- hotspot ■ strength=0.10 precision=0.54
16
- fix_ratio ■ strength=0.07 precision=0.54
17
- author_churn · strength=0.00 precision=0.16
18
-
19
- # This file was touched by a Revert commit in history.
20
- # blastradius remembers. Your LLM couldn't.
8
+ $ composto ir src/memory/confidence.ts L1
9
+
10
+ USE:./types.js
11
+ OUT INTERFACE:ConfidenceContext
12
+ OUT INTERFACE:ScoreAndConfidence
13
+ FN:calibrationFactor(signals: Signal[])
14
+ GUARD:[firing.length === 0 → 1.0, avg < 20 → 0.3, avg < 100 → 0.6]
15
+ FN:historyFactor(totalCommits: number)
16
+ GUARD:[totalCommits < 50 → 0.2, totalCommits < 200 → 0.5, totalCommits < 1000 → 0.8]
17
+ OUT FN:computeScoreAndConfidence(signals: Signal[], ctx: ConfidenceContext)
18
+
19
+ # 541 tokens of raw code 230 tokens of IR (57% fewer). Structure intact:
20
+ # every signature, dependency, and decision threshold survives.
21
21
  ```
22
22
 
23
23
  ---
@@ -45,9 +45,9 @@ composto impact src/auth/login.ts
45
45
  composto index --status # diagnostics: schema, freshness, calibration
46
46
  ```
47
47
 
48
- ### Also in the box: AST compression tools
48
+ ### The core: token-efficient structural context
49
49
 
50
- Composto also ships a tree-sitter based AST compressor (about 89% token savings) and a smart context packer for bug-fix tasks. These are separate from the causal layer but live in the same binary.
50
+ Composto's spine is a tree-sitter based AST compressor and a smart context packer. Compress any file to IR, or pack a whole directory into a token budget:
51
51
 
52
52
  ```bash
53
53
  composto ir src/app.ts # compress a file to IR (L0/L1/L2/L3)
@@ -57,6 +57,10 @@ composto benchmark . # see compression stats
57
57
 
58
58
  See the [IR Layers](#ir-layers), [Health-Aware IR](#health-aware-ir), and [Context Budget](#context-budget) sections below for details.
59
59
 
60
+ ### On top: causal history as advisory context
61
+
62
+ Composto also indexes your git history and surfaces what historically changed and broke alongside the file you're editing — advisory context the agent weighs, not a gate. See [Causal context](#causal-context) below.
63
+
60
64
  ### MCP plugin (Claude Code, Cursor, Claude Desktop)
61
65
 
62
66
  The MCP server is bundled inside `composto-ai`. Install the package globally first, then register the server with your client:
@@ -188,27 +192,25 @@ composto index --status # diagnostics: schema, freshness, calibration
188
192
 
189
193
  ---
190
194
 
191
- ## BlastRadius
195
+ ## Causal context
192
196
 
193
- Beyond compression, Composto indexes your repo's git history into a local SQLite graph and exposes it as a queryable risk surface. Before your agent edits a file, it can ask: *"has this region been reverted? who fixed the last similar bug? is the last author still around?"* — signals no LLM can infer from current code alone.
197
+ On top of compression, Composto indexes your repo's git history into a local SQLite graph and surfaces what the current code can't tell you: *"has this region been reverted? does it have a fix cluster? what files historically changed and broke alongside it?"* — context no LLM can infer from the file alone. It's delivered as **advisory context the agent weighs**, not a hard gate.
194
198
 
195
- Four signals per query: `revert_match`, `hotspot`, `fix_ratio`, `author_churn`. Verdict is `low` / `medium` / `high` / `unknown`; when confidence is low the tool returns `unknown` rather than guessing. Precision is repo-calibrated (self-validation over the repo's own fix history).
199
+ Signals per query: `revert_match`, `hotspot`, `fix_ratio`, `author_churn`, `cochange`. The tool returns `unknown` when confidence is low rather than guessing.
196
200
 
197
201
  ```
198
- verdict: high
199
- score: 1.00
200
- confidence: 0.30
201
- signals:
202
- revert_match ■■■■■■■■■■ strength=1.00 precision=0.50
203
- hotspot · strength=0.00 precision=0.30
204
- ...
202
+ $ composto impact src/auth/login.ts
203
+
204
+ revert_match ■■■■■■■■■■ this file was touched by a Revert commit
205
+ cochange ■■■■■ historically co-changed with session.ts, token.ts in fixes
206
+ hotspot ■ 14 changes in the last 90 days
205
207
  ```
206
208
 
207
- **Where we are, honestly.** The v2.1 time-travel backtest (rewinds the DB to each pre-fix snapshot) shows `revert_match` carrying most of the product's value it clears the ship gate on picomatch (precision 0.65, recall 0.78 on the `medium|high` band). Signal-attributed precision (excluding `revert_match`) is weaker: the three non-revert signals are alive but need calibration work. See [docs/blastradius-proof-v2.md](docs/blastradius-proof-v2.md) for numbers on all four band combinations across two public repos + the per-signal diagnostic behind them.
209
+ **Where we are, honestly.** A 4-repo time-travel backtest (fastify, express, got, flask each rewound to pre-fix snapshots) shows the causal layer is a **high-recall, advisory-grade** signal: on mature repos it recovers 67-80% of the files a fix actually touches. Precision is modest (~0.55) these signals point you at *candidates*, they don't certify them, which is exactly why Composto surfaces them as context for the agent to judge rather than as a blocking verdict. Recall scales with git history, so the value grows as your repo matures (a young repo gets little until it accumulates fix history).
208
210
 
209
- The honest framing: **BlastRadius is a bug-history memory layer that agents query before editing.** "This file was reverted three weeks ago" is the primary promise v1 delivers. The other signals expand the query surface calibration work on `hotspot` and `fix_ratio` is the open follow-on.
211
+ The honest framing: **causal context is a high-recall memory layer agents consult before editing** "these files have a history of breaking together" not a precision gate. The compression core works unconditionally; the causal layer adds repo-specific memory on top.
210
212
 
211
- Feature-flagged via `COMPOSTO_BLASTRADIUS=1` during the beta. Available as both CLI (`composto impact`, `composto index`) and MCP tool (`composto_blastradius`).
213
+ Available as CLI (`composto impact`, `composto index`) and MCP tool (`composto_blastradius`, gated by `COMPOSTO_BLASTRADIUS=1` during beta).
212
214
 
213
215
  ---
214
216
 
@@ -307,14 +309,14 @@ Hotspot files get full detail. Everything else gets structure. Budget is never e
307
309
  ## Stats
308
310
 
309
311
  ```
310
- Overall compression: 89.2%
311
- L0 compression: 97.5%
312
- AST engine: 51/51 files (0 regex fallback)
312
+ L1 compression: ~81% fewer tokens (full IR, structure preserved)
313
+ L0 compression: ~97% fewer tokens (structure map)
314
+ Token counts: verified against a real BPE tokenizer, not estimates
315
+ AST engine: AST-parsed, 0 regex fallback
313
316
  Languages: TypeScript, JavaScript, Python, Go, Rust
314
- Tests: 251 passing
315
- BlastRadius v2.1: precision 0.65, recall 0.78 (picomatch, time-travel,
316
- medium|high band). Honest signal-attributed numbers
317
- in docs/blastradius-proof-v2.md.
317
+ Causal layer: high-recall advisory (0.67-0.80 recall on mature repos,
318
+ time-travel backtest across 4 public repos); precision
319
+ ~0.55, surfaced as context not a gate.
318
320
  ```
319
321
 
320
322
  ---
package/dist/index.js CHANGED
@@ -843,8 +843,9 @@ function emitTier1(node) {
843
843
  const outPrefix = exported ? "OUT " : "";
844
844
  switch (node.type) {
845
845
  case "import_statement": {
846
- const text = collapseText(node.text, 80);
847
- return `USE:${text}`;
846
+ const source = node.childForFieldName("source")?.text;
847
+ if (source) return `USE:${source.slice(1, -1)}`;
848
+ return `USE:${collapseText(node.text, 80)}`;
848
849
  }
849
850
  case "function_declaration": {
850
851
  const name = node.childForFieldName("name")?.text ?? "anonymous";
@@ -974,9 +975,30 @@ function emitTier3(node) {
974
975
  }
975
976
  if (node.parent?.type === "statement_block") return null;
976
977
  const vt = value.type;
977
- if (vt === "number" || vt === "true" || vt === "false") return null;
978
- if (vt === "object" || vt === "array") return null;
979
- if (vt === "new_expression" || vt === "call_expression") return null;
978
+ if (vt === "number" || vt === "true" || vt === "false") {
979
+ return `VAR:${name} = ${value.text}`;
980
+ }
981
+ if (vt === "array") {
982
+ return `VAR:${name}[${value.namedChildCount}]`;
983
+ }
984
+ if (vt === "object") {
985
+ const keys = [];
986
+ for (let i = 0; i < value.namedChildCount; i++) {
987
+ const member = value.namedChild(i);
988
+ keys.push(member.childForFieldName("key")?.text ?? member.text);
989
+ if (keys.length >= 6) break;
990
+ }
991
+ const more = value.namedChildCount > keys.length ? ", ..." : "";
992
+ return `VAR:${name}{${collapseText(keys.join(", "), 50)}${more}}`;
993
+ }
994
+ if (vt === "new_expression") {
995
+ const ctor = value.childForFieldName("constructor")?.text ?? "?";
996
+ return `VAR:${name} = new ${ctor}(...)`;
997
+ }
998
+ if (vt === "call_expression") {
999
+ const callee = value.childForFieldName("function")?.text ?? "?";
1000
+ return `VAR:${name} = ${collapseText(callee, 40)}(...)`;
1001
+ }
980
1002
  const valText = value.text.replace(/"[^"]*"/g, '""').replace(/'[^']*'/g, "''").replace(/`[^`]*`/g, "``");
981
1003
  return `VAR:${name} = ${collapseText(valText, 50)}`;
982
1004
  }
@@ -1137,29 +1159,27 @@ async function astWalkIR(code, filePath) {
1137
1159
  }
1138
1160
  const merged = [];
1139
1161
  let guardBlock = [];
1162
+ const guardValue = (ret) => ret.replace(/^RET\s+/, "");
1163
+ const flushGuards = () => {
1164
+ if (guardBlock.length === 0) return;
1165
+ if (guardBlock.length < 3) {
1166
+ for (const g of guardBlock) merged.push(`${g.indent}IF:${g.cond} \u2192 ${g.ret}`);
1167
+ } else {
1168
+ const entries = guardBlock.map((g) => `${g.cond} \u2192 ${guardValue(g.ret)}`);
1169
+ merged.push(`${guardBlock[0].indent}GUARD:[${entries.join(", ")}]`);
1170
+ }
1171
+ guardBlock = [];
1172
+ };
1140
1173
  for (const line of pass1) {
1141
- const guardMatch = line.match(/^(\s*)IF:(.+?) \u2192 RET/);
1174
+ const guardMatch = line.match(/^(\s*)IF:(.+?) \u2192 (.+)$/);
1142
1175
  if (guardMatch) {
1143
- guardBlock.push(guardMatch[2].trim());
1176
+ guardBlock.push({ indent: guardMatch[1], cond: guardMatch[2].trim(), ret: guardMatch[3].trim() });
1144
1177
  continue;
1145
1178
  }
1146
- if (guardBlock.length > 0) {
1147
- if (guardBlock.length < 3) {
1148
- for (const g of guardBlock) merged.push(` IF:${g} \u2192 RET`);
1149
- } else {
1150
- merged.push(` GUARD:[${guardBlock.join(", ")}]`);
1151
- }
1152
- guardBlock = [];
1153
- }
1179
+ flushGuards();
1154
1180
  merged.push(line);
1155
1181
  }
1156
- if (guardBlock.length > 0) {
1157
- if (guardBlock.length < 3) {
1158
- for (const g of guardBlock) merged.push(` IF:${g} \u2192 RET`);
1159
- } else {
1160
- merged.push(` GUARD:[${guardBlock.join(", ")}]`);
1161
- }
1162
- }
1182
+ flushGuards();
1163
1183
  return merged.join("\n");
1164
1184
  }
1165
1185
 
@@ -2105,13 +2125,38 @@ function computeAuthorChurn(db, filePath) {
2105
2125
  return { ...base, strength };
2106
2126
  }
2107
2127
 
2128
+ // src/memory/signals/cochange.ts
2129
+ var SATURATION_DEGREE = 10;
2130
+ var FALLBACK_PRECISION5 = 0.3;
2131
+ function computeCochange(db, filePath) {
2132
+ const row = db.prepare(`
2133
+ SELECT COUNT(DISTINCT ft2.file_path) AS degree
2134
+ FROM file_touches ft1
2135
+ JOIN commits c ON c.sha = ft1.commit_sha AND c.is_fix = 1
2136
+ JOIN file_touches ft2 ON ft2.commit_sha = ft1.commit_sha AND ft2.file_path != ft1.file_path
2137
+ WHERE ft1.file_path = ?
2138
+ `).get(filePath);
2139
+ const degree = row?.degree ?? 0;
2140
+ const strength = Math.min(1, degree / SATURATION_DEGREE);
2141
+ const cal = getCalibration(db, "cochange", FALLBACK_PRECISION5);
2142
+ return {
2143
+ type: "cochange",
2144
+ strength,
2145
+ precision: cal.precision,
2146
+ sample_size: cal.sampleSize,
2147
+ evidence: [],
2148
+ cochange_degree: degree
2149
+ };
2150
+ }
2151
+
2108
2152
  // src/memory/signals/index.ts
2109
2153
  function collectSignals(db, _repoPath, filePath) {
2110
2154
  return [
2111
2155
  computeRevertMatch(db, filePath),
2112
2156
  computeHotspot(db, filePath),
2113
2157
  computeFixRatio(db, filePath),
2114
- computeAuthorChurn(db, filePath)
2158
+ computeAuthorChurn(db, filePath),
2159
+ computeCochange(db, filePath)
2115
2160
  ];
2116
2161
  }
2117
2162
 
@@ -2150,18 +2195,29 @@ function historyFactor(totalCommits) {
2150
2195
  if (totalCommits < 1e3) return 0.8;
2151
2196
  return 1;
2152
2197
  }
2198
+ var DEFAULT_COCHANGE_FLOOR = 1;
2199
+ function cochangeFloor() {
2200
+ const v = Number(process.env.COMPOSTO_COCHANGE_FLOOR);
2201
+ return Number.isFinite(v) && v >= 0 && v <= 1 ? v : DEFAULT_COCHANGE_FLOOR;
2202
+ }
2153
2203
  function computeScoreAndConfidence(signals, ctx) {
2204
+ const cochange = signals.find((s) => s.type === "cochange");
2205
+ const averaged = signals.filter((s) => s.type !== "cochange");
2154
2206
  let num = 0;
2155
2207
  let den = 0;
2156
- for (const s of signals) {
2208
+ for (const s of averaged) {
2157
2209
  if (s.strength <= 0 || s.precision <= 0) continue;
2158
2210
  num += s.strength * s.precision;
2159
2211
  den += s.precision;
2160
2212
  }
2161
- const score = den === 0 ? 0 : num / den;
2213
+ let score = den === 0 ? 0 : num / den;
2214
+ if (cochange) {
2215
+ const floor = cochangeFloor();
2216
+ score *= floor + (1 - floor) * cochange.strength;
2217
+ }
2162
2218
  const confidence = Math.min(
2163
- coverageFactor(signals),
2164
- calibrationFactor(signals),
2219
+ coverageFactor(averaged),
2220
+ calibrationFactor(averaged),
2165
2221
  freshnessFactor(ctx),
2166
2222
  historyFactor(ctx.totalCommits)
2167
2223
  );
@@ -3593,7 +3649,16 @@ function renderSummary(s) {
3593
3649
  }
3594
3650
 
3595
3651
  // src/index.ts
3652
+ import { createRequire } from "module";
3596
3653
  import { join as join13, resolve as resolve2 } from "path";
3654
+ var PKG_VERSION = (() => {
3655
+ try {
3656
+ const req = createRequire(import.meta.url);
3657
+ return req("../package.json").version;
3658
+ } catch {
3659
+ return "0.0.0";
3660
+ }
3661
+ })();
3597
3662
  async function readStdin() {
3598
3663
  if (process.stdin.isTTY) return "";
3599
3664
  const chunks = [];
@@ -3754,10 +3819,11 @@ switch (command) {
3754
3819
  break;
3755
3820
  }
3756
3821
  case "version":
3757
- console.log("composto v0.4.2");
3822
+ console.log(`composto v${PKG_VERSION}`);
3758
3823
  break;
3759
3824
  default:
3760
- console.log("composto v0.4.2 \u2014 less tokens, more insight\n");
3825
+ console.log(`composto v${PKG_VERSION} \u2014 less tokens, more insight
3826
+ `);
3761
3827
  console.log("Commands:");
3762
3828
  console.log(" scan [path] Scan codebase for issues");
3763
3829
  console.log(" trends [path] Analyze codebase health trends");
@@ -5,7 +5,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
6
  import { z } from "zod";
7
7
  import { readFileSync as readFileSync4 } from "fs";
8
- import { resolve as resolve2, relative as relative2, join as join6 } from "path";
8
+ import { fileURLToPath as fileURLToPath3 } from "url";
9
+ import { resolve as resolve2, relative as relative2, join as join6, dirname as dirname5 } from "path";
9
10
 
10
11
  // src/ir/structure.ts
11
12
  var CLASSIFIERS = [
@@ -650,8 +651,9 @@ function emitTier1(node) {
650
651
  const outPrefix = exported ? "OUT " : "";
651
652
  switch (node.type) {
652
653
  case "import_statement": {
653
- const text = collapseText(node.text, 80);
654
- return `USE:${text}`;
654
+ const source = node.childForFieldName("source")?.text;
655
+ if (source) return `USE:${source.slice(1, -1)}`;
656
+ return `USE:${collapseText(node.text, 80)}`;
655
657
  }
656
658
  case "function_declaration": {
657
659
  const name = node.childForFieldName("name")?.text ?? "anonymous";
@@ -781,9 +783,30 @@ function emitTier3(node) {
781
783
  }
782
784
  if (node.parent?.type === "statement_block") return null;
783
785
  const vt = value.type;
784
- if (vt === "number" || vt === "true" || vt === "false") return null;
785
- if (vt === "object" || vt === "array") return null;
786
- if (vt === "new_expression" || vt === "call_expression") return null;
786
+ if (vt === "number" || vt === "true" || vt === "false") {
787
+ return `VAR:${name} = ${value.text}`;
788
+ }
789
+ if (vt === "array") {
790
+ return `VAR:${name}[${value.namedChildCount}]`;
791
+ }
792
+ if (vt === "object") {
793
+ const keys = [];
794
+ for (let i = 0; i < value.namedChildCount; i++) {
795
+ const member = value.namedChild(i);
796
+ keys.push(member.childForFieldName("key")?.text ?? member.text);
797
+ if (keys.length >= 6) break;
798
+ }
799
+ const more = value.namedChildCount > keys.length ? ", ..." : "";
800
+ return `VAR:${name}{${collapseText(keys.join(", "), 50)}${more}}`;
801
+ }
802
+ if (vt === "new_expression") {
803
+ const ctor = value.childForFieldName("constructor")?.text ?? "?";
804
+ return `VAR:${name} = new ${ctor}(...)`;
805
+ }
806
+ if (vt === "call_expression") {
807
+ const callee = value.childForFieldName("function")?.text ?? "?";
808
+ return `VAR:${name} = ${collapseText(callee, 40)}(...)`;
809
+ }
787
810
  const valText = value.text.replace(/"[^"]*"/g, '""').replace(/'[^']*'/g, "''").replace(/`[^`]*`/g, "``");
788
811
  return `VAR:${name} = ${collapseText(valText, 50)}`;
789
812
  }
@@ -944,29 +967,27 @@ async function astWalkIR(code, filePath) {
944
967
  }
945
968
  const merged = [];
946
969
  let guardBlock = [];
970
+ const guardValue = (ret) => ret.replace(/^RET\s+/, "");
971
+ const flushGuards = () => {
972
+ if (guardBlock.length === 0) return;
973
+ if (guardBlock.length < 3) {
974
+ for (const g of guardBlock) merged.push(`${g.indent}IF:${g.cond} \u2192 ${g.ret}`);
975
+ } else {
976
+ const entries = guardBlock.map((g) => `${g.cond} \u2192 ${guardValue(g.ret)}`);
977
+ merged.push(`${guardBlock[0].indent}GUARD:[${entries.join(", ")}]`);
978
+ }
979
+ guardBlock = [];
980
+ };
947
981
  for (const line of pass1) {
948
- const guardMatch = line.match(/^(\s*)IF:(.+?) \u2192 RET/);
982
+ const guardMatch = line.match(/^(\s*)IF:(.+?) \u2192 (.+)$/);
949
983
  if (guardMatch) {
950
- guardBlock.push(guardMatch[2].trim());
984
+ guardBlock.push({ indent: guardMatch[1], cond: guardMatch[2].trim(), ret: guardMatch[3].trim() });
951
985
  continue;
952
986
  }
953
- if (guardBlock.length > 0) {
954
- if (guardBlock.length < 3) {
955
- for (const g of guardBlock) merged.push(` IF:${g} \u2192 RET`);
956
- } else {
957
- merged.push(` GUARD:[${guardBlock.join(", ")}]`);
958
- }
959
- guardBlock = [];
960
- }
987
+ flushGuards();
961
988
  merged.push(line);
962
989
  }
963
- if (guardBlock.length > 0) {
964
- if (guardBlock.length < 3) {
965
- for (const g of guardBlock) merged.push(` IF:${g} \u2192 RET`);
966
- } else {
967
- merged.push(` GUARD:[${guardBlock.join(", ")}]`);
968
- }
969
- }
990
+ flushGuards();
970
991
  return merged.join("\n");
971
992
  }
972
993
 
@@ -1908,13 +1929,38 @@ function computeAuthorChurn(db, filePath) {
1908
1929
  return { ...base, strength };
1909
1930
  }
1910
1931
 
1932
+ // src/memory/signals/cochange.ts
1933
+ var SATURATION_DEGREE = 10;
1934
+ var FALLBACK_PRECISION5 = 0.3;
1935
+ function computeCochange(db, filePath) {
1936
+ const row = db.prepare(`
1937
+ SELECT COUNT(DISTINCT ft2.file_path) AS degree
1938
+ FROM file_touches ft1
1939
+ JOIN commits c ON c.sha = ft1.commit_sha AND c.is_fix = 1
1940
+ JOIN file_touches ft2 ON ft2.commit_sha = ft1.commit_sha AND ft2.file_path != ft1.file_path
1941
+ WHERE ft1.file_path = ?
1942
+ `).get(filePath);
1943
+ const degree = row?.degree ?? 0;
1944
+ const strength = Math.min(1, degree / SATURATION_DEGREE);
1945
+ const cal = getCalibration(db, "cochange", FALLBACK_PRECISION5);
1946
+ return {
1947
+ type: "cochange",
1948
+ strength,
1949
+ precision: cal.precision,
1950
+ sample_size: cal.sampleSize,
1951
+ evidence: [],
1952
+ cochange_degree: degree
1953
+ };
1954
+ }
1955
+
1911
1956
  // src/memory/signals/index.ts
1912
1957
  function collectSignals(db, _repoPath, filePath) {
1913
1958
  return [
1914
1959
  computeRevertMatch(db, filePath),
1915
1960
  computeHotspot(db, filePath),
1916
1961
  computeFixRatio(db, filePath),
1917
- computeAuthorChurn(db, filePath)
1962
+ computeAuthorChurn(db, filePath),
1963
+ computeCochange(db, filePath)
1918
1964
  ];
1919
1965
  }
1920
1966
 
@@ -1953,18 +1999,29 @@ function historyFactor(totalCommits) {
1953
1999
  if (totalCommits < 1e3) return 0.8;
1954
2000
  return 1;
1955
2001
  }
2002
+ var DEFAULT_COCHANGE_FLOOR = 1;
2003
+ function cochangeFloor() {
2004
+ const v = Number(process.env.COMPOSTO_COCHANGE_FLOOR);
2005
+ return Number.isFinite(v) && v >= 0 && v <= 1 ? v : DEFAULT_COCHANGE_FLOOR;
2006
+ }
1956
2007
  function computeScoreAndConfidence(signals, ctx) {
2008
+ const cochange = signals.find((s) => s.type === "cochange");
2009
+ const averaged = signals.filter((s) => s.type !== "cochange");
1957
2010
  let num = 0;
1958
2011
  let den = 0;
1959
- for (const s of signals) {
2012
+ for (const s of averaged) {
1960
2013
  if (s.strength <= 0 || s.precision <= 0) continue;
1961
2014
  num += s.strength * s.precision;
1962
2015
  den += s.precision;
1963
2016
  }
1964
- const score = den === 0 ? 0 : num / den;
2017
+ let score = den === 0 ? 0 : num / den;
2018
+ if (cochange) {
2019
+ const floor = cochangeFloor();
2020
+ score *= floor + (1 - floor) * cochange.strength;
2021
+ }
1965
2022
  const confidence = Math.min(
1966
- coverageFactor(signals),
1967
- calibrationFactor(signals),
2023
+ coverageFactor(averaged),
2024
+ calibrationFactor(averaged),
1968
2025
  freshnessFactor(ctx),
1969
2026
  historyFactor(ctx.totalCommits)
1970
2027
  );
@@ -2381,9 +2438,22 @@ var MemoryAPI = class {
2381
2438
 
2382
2439
  // src/mcp/server.ts
2383
2440
  var ALL_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".py", ".go", ".rs"];
2441
+ var PKG_VERSION = (() => {
2442
+ try {
2443
+ const pkgPath = join6(
2444
+ dirname5(fileURLToPath3(import.meta.url)),
2445
+ "..",
2446
+ "..",
2447
+ "package.json"
2448
+ );
2449
+ return JSON.parse(readFileSync4(pkgPath, "utf-8")).version;
2450
+ } catch {
2451
+ return "0.0.0";
2452
+ }
2453
+ })();
2384
2454
  var server = new McpServer({
2385
2455
  name: "composto",
2386
- version: "0.4.2"
2456
+ version: PKG_VERSION
2387
2457
  });
2388
2458
  server.tool(
2389
2459
  "composto_ir",
@@ -364,13 +364,38 @@ function computeAuthorChurn(db, filePath) {
364
364
  return { ...base, strength };
365
365
  }
366
366
 
367
+ // src/memory/signals/cochange.ts
368
+ var SATURATION_DEGREE = 10;
369
+ var FALLBACK_PRECISION5 = 0.3;
370
+ function computeCochange(db, filePath) {
371
+ const row = db.prepare(`
372
+ SELECT COUNT(DISTINCT ft2.file_path) AS degree
373
+ FROM file_touches ft1
374
+ JOIN commits c ON c.sha = ft1.commit_sha AND c.is_fix = 1
375
+ JOIN file_touches ft2 ON ft2.commit_sha = ft1.commit_sha AND ft2.file_path != ft1.file_path
376
+ WHERE ft1.file_path = ?
377
+ `).get(filePath);
378
+ const degree = row?.degree ?? 0;
379
+ const strength = Math.min(1, degree / SATURATION_DEGREE);
380
+ const cal = getCalibration(db, "cochange", FALLBACK_PRECISION5);
381
+ return {
382
+ type: "cochange",
383
+ strength,
384
+ precision: cal.precision,
385
+ sample_size: cal.sampleSize,
386
+ evidence: [],
387
+ cochange_degree: degree
388
+ };
389
+ }
390
+
367
391
  // src/memory/signals/index.ts
368
392
  function collectSignals(db, _repoPath, filePath) {
369
393
  return [
370
394
  computeRevertMatch(db, filePath),
371
395
  computeHotspot(db, filePath),
372
396
  computeFixRatio(db, filePath),
373
- computeAuthorChurn(db, filePath)
397
+ computeAuthorChurn(db, filePath),
398
+ computeCochange(db, filePath)
374
399
  ];
375
400
  }
376
401
 
@@ -409,18 +434,29 @@ function historyFactor(totalCommits) {
409
434
  if (totalCommits < 1e3) return 0.8;
410
435
  return 1;
411
436
  }
437
+ var DEFAULT_COCHANGE_FLOOR = 1;
438
+ function cochangeFloor() {
439
+ const v = Number(process.env.COMPOSTO_COCHANGE_FLOOR);
440
+ return Number.isFinite(v) && v >= 0 && v <= 1 ? v : DEFAULT_COCHANGE_FLOOR;
441
+ }
412
442
  function computeScoreAndConfidence(signals, ctx) {
443
+ const cochange = signals.find((s) => s.type === "cochange");
444
+ const averaged = signals.filter((s) => s.type !== "cochange");
413
445
  let num = 0;
414
446
  let den = 0;
415
- for (const s of signals) {
447
+ for (const s of averaged) {
416
448
  if (s.strength <= 0 || s.precision <= 0) continue;
417
449
  num += s.strength * s.precision;
418
450
  den += s.precision;
419
451
  }
420
- const score = den === 0 ? 0 : num / den;
452
+ let score = den === 0 ? 0 : num / den;
453
+ if (cochange) {
454
+ const floor = cochangeFloor();
455
+ score *= floor + (1 - floor) * cochange.strength;
456
+ }
421
457
  const confidence = Math.min(
422
- coverageFactor(signals),
423
- calibrationFactor(signals),
458
+ coverageFactor(averaged),
459
+ calibrationFactor(averaged),
424
460
  freshnessFactor(ctx),
425
461
  historyFactor(ctx.totalCommits)
426
462
  );
@@ -283,11 +283,31 @@ function validateAuthorChurn(db) {
283
283
  const hits = db.prepare(`SELECT COUNT(*) AS n FROM fix_links`).get().n;
284
284
  return { total, hits };
285
285
  }
286
+ function validateCochange(db) {
287
+ const total = db.prepare(`
288
+ SELECT COUNT(DISTINCT ft1.file_path) AS n
289
+ FROM file_touches ft1
290
+ JOIN commits c ON c.sha = ft1.commit_sha AND c.is_fix = 1
291
+ JOIN file_touches ft2 ON ft2.commit_sha = ft1.commit_sha AND ft2.file_path != ft1.file_path
292
+ `).get().n;
293
+ const hits = db.prepare(`
294
+ SELECT COUNT(DISTINCT ft1.file_path) AS n
295
+ FROM file_touches ft1
296
+ JOIN commits c ON c.sha = ft1.commit_sha AND c.is_fix = 1
297
+ JOIN file_touches ft2 ON ft2.commit_sha = ft1.commit_sha AND ft2.file_path != ft1.file_path
298
+ WHERE ft1.file_path IN (
299
+ SELECT ft.file_path FROM file_touches ft
300
+ JOIN fix_links fl ON fl.suspected_break_sha = ft.commit_sha
301
+ )
302
+ `).get().n;
303
+ return { total, hits };
304
+ }
286
305
  var VALIDATORS = {
287
306
  revert_match: validateRevertMatch,
288
307
  hotspot: validateHotspot,
289
308
  fix_ratio: validateFixRatio,
290
- author_churn: validateAuthorChurn
309
+ author_churn: validateAuthorChurn,
310
+ cochange: validateCochange
291
311
  };
292
312
  function refreshCalibration(db, headSha) {
293
313
  const now = Math.floor(Date.now() / 1e3);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "composto-ai",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Proactive AI team companion — less tokens, more insight",
5
5
  "type": "module",
6
6
  "bin": {