@getripple/core 1.0.5 → 1.0.7

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/graph.js CHANGED
@@ -57,15 +57,12 @@ const ts_morph_1 = require("ts-morph");
57
57
  const types_1 = require("./types");
58
58
  const normalizer_1 = require("./normalizer");
59
59
  const adapters_1 = require("./adapters");
60
- // ────────────────────────────────────────────────────────────────────────────
61
- // PERSISTENCE AND GENERATED CONTEXT
62
- // ────────────────────────────────────────────────────────────────────────────
63
60
  /**
64
61
  * Handles durable project state:
65
62
  * - .ripple/history.json for human-auditable change history
66
63
  * - .ripple/.cache/graph.cache.json for fast startup
67
- * - .ripple/.cache/context*.json and focus files for AI agents
68
- * - .ripple/WORKFLOW.md for agent operating rules
64
+ * - optional .ripple/.cache/context*.json and focus files for file-based agents
65
+ * - optional .ripple/WORKFLOW.md for agent operating rules
69
66
  */
70
67
  class GraphPersistence {
71
68
  /**
@@ -76,7 +73,7 @@ class GraphPersistence {
76
73
  this.patternCache = null;
77
74
  }
78
75
  constructor(workspaceRoot) {
79
- this.contextGenerationEnabled = true;
76
+ this.contextGenerationMode = "full";
80
77
  this.patternCache = null;
81
78
  this.workspaceRoot = workspaceRoot;
82
79
  const rippleDir = path.join(workspaceRoot, ".ripple");
@@ -189,34 +186,20 @@ class GraphPersistence {
189
186
  * Restores graph nodes from cache and returns files whose content hash changed
190
187
  * since the cache was written. Stale files are repaired by the scan phase.
191
188
  */
192
- loadCache(graph) {
189
+ loadCache(graph, options = {}) {
193
190
  try {
194
191
  if (!fs.existsSync(this.cachePath)) {
195
192
  return [];
196
193
  }
197
194
  const raw = fs.readFileSync(this.cachePath, "utf8");
198
195
  const data = JSON.parse(raw);
196
+ const checkHashes = options.checkHashes ?? true;
199
197
  const staleFiles = [];
200
198
  const loadedFilePaths = new Set();
201
199
  Object.entries(data.files).forEach(([filePath, node]) => {
202
200
  if (!fs.existsSync(filePath)) {
203
201
  return;
204
202
  }
205
- const currentContent = (() => {
206
- try {
207
- return fs.readFileSync(filePath, "utf8");
208
- }
209
- catch {
210
- return null;
211
- }
212
- })();
213
- if (currentContent === null) {
214
- return;
215
- }
216
- const currentHash = crypto
217
- .createHash("sha1")
218
- .update(currentContent)
219
- .digest("hex");
220
203
  const fileNode = {
221
204
  path: node.path,
222
205
  imports: new Set(node.imports),
@@ -230,7 +213,7 @@ class GraphPersistence {
230
213
  };
231
214
  graph.files.set(filePath, fileNode);
232
215
  loadedFilePaths.add(filePath);
233
- if (currentHash !== node.hash) {
216
+ if (checkHashes && this.cachedFileIsStale(filePath, node.hash)) {
234
217
  staleFiles.push(filePath);
235
218
  }
236
219
  });
@@ -263,6 +246,18 @@ class GraphPersistence {
263
246
  return [];
264
247
  }
265
248
  }
249
+ cachedFileIsStale(filePath, cachedHash) {
250
+ try {
251
+ const currentHash = crypto
252
+ .createHash("sha1")
253
+ .update(fs.readFileSync(filePath, "utf8"))
254
+ .digest("hex");
255
+ return currentHash !== cachedHash;
256
+ }
257
+ catch {
258
+ return false;
259
+ }
260
+ }
266
261
  toProjectPath(filePath) {
267
262
  return path.relative(this.workspaceRoot, filePath).split(path.sep).join("/");
268
263
  }
@@ -582,7 +577,7 @@ class GraphPersistence {
582
577
  * - focus files and WORKFLOW.md for targeted agent workflows
583
578
  */
584
579
  generateContext(graph, history) {
585
- if (!this.contextGenerationEnabled) {
580
+ if (this.contextGenerationMode !== "full") {
586
581
  return;
587
582
  }
588
583
  try {
@@ -1509,7 +1504,7 @@ ${ambiguousBasenameExamples.map(e => `- ${e}`).join("\n")}` : ""}
1509
1504
  * debounced separately, but the target focus file should be fresh immediately.
1510
1505
  */
1511
1506
  generateSingleFocus(graph, filePath) {
1512
- if (!this.contextGenerationEnabled) {
1507
+ if (this.contextGenerationMode === "lean") {
1513
1508
  return;
1514
1509
  }
1515
1510
  try {
@@ -1617,11 +1612,20 @@ function isPythonIdentifier(value) {
1617
1612
  // ────────────────────────────────────────────────────────────────────────────
1618
1613
  class GraphEngine {
1619
1614
  /**
1620
- * Disables writing .ripple/ context files while keeping the in-memory graph
1621
- * available for editor features.
1615
+ * Chooses how much file-based context Ripple writes for the active interface.
1616
+ *
1617
+ * full — VS Code/file-based agent mode; writes WORKFLOW.md and focus files.
1618
+ * lean — CLI gate/check mode; writes only durable history and graph cache.
1619
+ * on-demand — MCP mode; skips broad dumps but allows targeted focus writes.
1620
+ */
1621
+ setContextGenerationMode(mode) {
1622
+ this.persistence.contextGenerationMode = mode;
1623
+ }
1624
+ /**
1625
+ * Backwards-compatible switch used by older callers and tests.
1622
1626
  */
1623
1627
  setContextGeneration(enabled) {
1624
- this.persistence.contextGenerationEnabled = enabled;
1628
+ this.setContextGenerationMode(enabled ? "full" : "lean");
1625
1629
  }
1626
1630
  constructor(workspaceRoot) {
1627
1631
  // Scans rebuild a large part of the graph. File-system events that arrive
@@ -1632,6 +1636,7 @@ class GraphEngine {
1632
1636
  this.pendingDeletes = new Set();
1633
1637
  this.pendingFullRescan = false;
1634
1638
  this.sessionNewFiles = new Set();
1639
+ this.focusKeyCache = new Map();
1635
1640
  this.workspaceRoot = workspaceRoot;
1636
1641
  this.graph = new types_1.SystemGraph();
1637
1642
  this.history = new types_1.HistoryLog();
@@ -1648,6 +1653,29 @@ class GraphEngine {
1648
1653
  invalidateAdapterSupport() {
1649
1654
  this.adapterSupportCache = undefined;
1650
1655
  }
1656
+ invalidateFocusKeyCache() {
1657
+ this.focusKeyCache.clear();
1658
+ this.focusBaseKeyCounts = undefined;
1659
+ }
1660
+ focusKeyForPath(filePath) {
1661
+ const cached = this.focusKeyCache.get(filePath);
1662
+ if (cached) {
1663
+ return cached;
1664
+ }
1665
+ if (!this.focusBaseKeyCounts) {
1666
+ this.focusBaseKeyCounts = new Map();
1667
+ this.graph.files.forEach((_, graphFilePath) => {
1668
+ const baseKey = getBaseKey(graphFilePath);
1669
+ this.focusBaseKeyCounts.set(baseKey, (this.focusBaseKeyCounts.get(baseKey) ?? 0) + 1);
1670
+ });
1671
+ }
1672
+ const baseKey = getBaseKey(filePath);
1673
+ const focusKey = (this.focusBaseKeyCounts.get(baseKey) ?? 0) > 1
1674
+ ? `${baseKey}-${crypto.createHash("sha1").update(filePath).digest("hex").slice(0, 6)}`
1675
+ : baseKey;
1676
+ this.focusKeyCache.set(filePath, focusKey);
1677
+ return focusKey;
1678
+ }
1651
1679
  createProject() {
1652
1680
  return new ts_morph_1.Project({
1653
1681
  compilerOptions: {
@@ -1745,6 +1773,7 @@ class GraphEngine {
1745
1773
  this.graph.files.clear();
1746
1774
  this.graph.symbols.clear();
1747
1775
  this.project = this.createProject();
1776
+ this.invalidateFocusKeyCache();
1748
1777
  this.sessionNewFiles.clear();
1749
1778
  let scanned = 0;
1750
1779
  const total = validFiles.length * 2;
@@ -1794,6 +1823,61 @@ class GraphEngine {
1794
1823
  await this.rebuildFromDisk(onProgress, priorityFile);
1795
1824
  }
1796
1825
  }
1826
+ /**
1827
+ * Fast path for live agent gates. It trusts the existing graph cache instead
1828
+ * of hash-checking every source file, then only parses candidate files that
1829
+ * are missing from the cache. If no cache exists, it falls back to the normal
1830
+ * scan so first-time use remains correct.
1831
+ */
1832
+ async fastCheckScan(candidateFiles = [], onProgress) {
1833
+ this.isScanning = true;
1834
+ this.invalidateAdapterSupport();
1835
+ this.graph.files.clear();
1836
+ this.graph.symbols.clear();
1837
+ this.project = this.createProject();
1838
+ this.invalidateFocusKeyCache();
1839
+ const normalizedCandidates = candidateFiles
1840
+ .map((filePath) => this.resolveFilePath(filePath))
1841
+ .filter((filePath, index, values) => values.indexOf(filePath) === index &&
1842
+ !shouldIgnore(filePath) &&
1843
+ fs.existsSync(filePath) &&
1844
+ /\.(ts|tsx|js|jsx|py)$/i.test(filePath) &&
1845
+ !filePath.endsWith(".d.ts"));
1846
+ this.persistence.loadCache(this.graph, { checkHashes: false });
1847
+ this.isScanning = false;
1848
+ if (this.graph.files.size === 0) {
1849
+ await this.initialScan(onProgress, normalizedCandidates[0]);
1850
+ return;
1851
+ }
1852
+ let scanned = 0;
1853
+ const total = normalizedCandidates.length * 2;
1854
+ normalizedCandidates
1855
+ .filter((filePath) => !this.graph.files.has(filePath))
1856
+ .forEach((filePath) => {
1857
+ try {
1858
+ this.ensureFileNode(filePath);
1859
+ this.parseImportsAndExports(filePath, false);
1860
+ }
1861
+ catch {
1862
+ console.warn("[Ripple] Fast check parse error:", filePath);
1863
+ }
1864
+ scanned++;
1865
+ onProgress?.(scanned, total);
1866
+ });
1867
+ normalizedCandidates
1868
+ .filter((filePath) => this.graph.files.has(filePath))
1869
+ .forEach((filePath) => {
1870
+ try {
1871
+ this.parseCallsOnly(filePath);
1872
+ }
1873
+ catch {
1874
+ console.warn("[Ripple] Fast check call parse error:", filePath);
1875
+ }
1876
+ scanned++;
1877
+ onProgress?.(scanned, total);
1878
+ });
1879
+ this.processPendingChanges();
1880
+ }
1797
1881
  // ── INITIAL SCAN ──────────────────────────────────────────────────────────
1798
1882
  /**
1799
1883
  * Starts from cache when possible and repairs only stale or newly discovered
@@ -3067,6 +3151,7 @@ class GraphEngine {
3067
3151
  changeCount: 0,
3068
3152
  hash: "",
3069
3153
  });
3154
+ this.invalidateFocusKeyCache();
3070
3155
  }
3071
3156
  return this.graph.files.get(filePath);
3072
3157
  }
@@ -3118,7 +3203,7 @@ class GraphEngine {
3118
3203
  return "safe";
3119
3204
  }
3120
3205
  focusPathFor(filePath) {
3121
- return `.ripple/.cache/focus/${makeFocusKey(filePath, this.graph)}.json`;
3206
+ return `.ripple/.cache/focus/${this.focusKeyForPath(filePath)}.json`;
3122
3207
  }
3123
3208
  dependencyLinkFor(filePath) {
3124
3209
  const node = this.graph.files.get(filePath);
@@ -3241,7 +3326,10 @@ class GraphEngine {
3241
3326
  return Array.from(this.graph.files.get(filePath)?.imports ?? []);
3242
3327
  }
3243
3328
  focusKeyForFile(filePath) {
3244
- return makeFocusKey(filePath, this.graph);
3329
+ return this.focusKeyForPath(filePath);
3330
+ }
3331
+ writeFileFocus(filePath) {
3332
+ this.persistence.generateSingleFocus(this.graph, this.resolveFilePath(filePath));
3245
3333
  }
3246
3334
  symbolImpact(symbolId) {
3247
3335
  return Array.from(this.graph.symbols.get(symbolId)?.calledBy ?? []);
@@ -4059,7 +4147,7 @@ class GraphEngine {
4059
4147
  return {
4060
4148
  filePath: resolvedPath,
4061
4149
  projectPath: this.toProjectPath(resolvedPath),
4062
- focusKey: makeFocusKey(resolvedPath, this.graph),
4150
+ focusKey: this.focusKeyForPath(resolvedPath),
4063
4151
  focusPath: this.focusPathFor(resolvedPath),
4064
4152
  modificationRisk: this.modificationRiskFor(node),
4065
4153
  imports,