@hanzlaa/rcode 4.4.4 → 4.5.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.
@@ -0,0 +1,237 @@
1
+ /**
2
+ * memory-drift.cjs — pure-heuristic "memory says X, code now does Y" checker (#958).
3
+ *
4
+ * Compares claims in .rcode/memory/project/{stack.md,decisions.md} against the
5
+ * last 10 commits and the current working tree. No LLM calls, no network I/O —
6
+ * git + fs only, target <300ms on a warm repo.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { execSync } = require('child_process');
12
+
13
+ const RECENT_COMMIT_COUNT = 10;
14
+ const STALE_INDEX_DAYS = 30;
15
+
16
+ function safeExec(cmd, cwd) {
17
+ try {
18
+ return execSync(cmd, {
19
+ cwd,
20
+ encoding: 'utf8',
21
+ timeout: 3000,
22
+ stdio: ['ignore', 'pipe', 'ignore'],
23
+ });
24
+ } catch {
25
+ return '';
26
+ }
27
+ }
28
+
29
+ function readFileSafe(p) {
30
+ try {
31
+ return fs.readFileSync(p, 'utf8');
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ function readJsonSafe(text) {
38
+ try {
39
+ return JSON.parse(text);
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Extract backtick-quoted, path-like tokens from markdown — anything with a
47
+ * '/' that isn't a URL or a CLI flag. Strips trailing `:LINE` or `:LINE-LINE`
48
+ * anchors (e.g. `cli/install.js:741-743` -> `cli/install.js`).
49
+ */
50
+ // Generated/build-artifact directories: commonly referenced descriptively in
51
+ // memory but not checked in, so a missing path there isn't drift.
52
+ const GENERATED_PATH_PREFIXES = ['dist/', 'build/', 'node_modules/', 'coverage/', '.next/', 'out/'];
53
+
54
+ function extractPaths(text) {
55
+ const paths = new Set();
56
+ const re = /`([^`]+)`/g;
57
+ let m;
58
+ while ((m = re.exec(text))) {
59
+ let token = m[1].trim();
60
+ if (!token || /\s/.test(token)) continue;
61
+ if (!token.includes('/')) continue;
62
+ if (/^https?:\/\//.test(token)) continue;
63
+ if (token.startsWith('-') || token.startsWith('--')) continue;
64
+ if (token.startsWith('@')) continue; // scoped npm package name, not a path
65
+ if (token.includes('{') || token.includes('}')) continue; // brace-expansion glob, not a literal path
66
+ if (token.startsWith('/rcode') || token.startsWith('/rihal')) continue; // slash-command name, not a path
67
+ token = token.replace(/^\.\//, '');
68
+ token = token.replace(/:\d+(-\d+)?$/, ''); // strip line-number anchor
69
+ if (!token) continue;
70
+ if (GENERATED_PATH_PREFIXES.some((p) => token.startsWith(p))) continue;
71
+ paths.add(token);
72
+ }
73
+ return [...paths];
74
+ }
75
+
76
+ /**
77
+ * Extract npm package names named in a markdown table/list — backtick-quoted
78
+ * tokens that look like package identifiers, not paths or file extensions.
79
+ */
80
+ function extractPackageNames(text) {
81
+ const names = new Set();
82
+ const re = /`(@?[a-zA-Z0-9][\w.-]*(?:\/[\w.-]+)?)`/g;
83
+ let m;
84
+ while ((m = re.exec(text))) {
85
+ const token = m[1];
86
+ if (/\.(js|cjs|mjs|md|json|ts|tsx|jsx|yml|yaml)$/.test(token)) continue;
87
+ if (token.includes('/') && !token.startsWith('@')) continue;
88
+ if (token.includes('.') && !token.startsWith('@')) continue; // e.g. "0001-zero-deps" false positives
89
+ names.add(token);
90
+ }
91
+ return [...names];
92
+ }
93
+
94
+ function depNames(pkgJson) {
95
+ if (!pkgJson) return new Set();
96
+ return new Set([
97
+ ...Object.keys(pkgJson.dependencies || {}),
98
+ ...Object.keys(pkgJson.devDependencies || {}),
99
+ ]);
100
+ }
101
+
102
+ /**
103
+ * a) package.json deps changed in a way that contradicts stack.md:
104
+ * - a package stack.md names by identifier was removed from package.json
105
+ * within the last RECENT_COMMIT_COUNT commits that touched package.json.
106
+ * - stack.md claims "zero runtime dependencies" but package.json currently
107
+ * lists a non-empty "dependencies" (runtime) block.
108
+ */
109
+ function checkDependencyDrift(cwd, stackText, drifts) {
110
+ const pkgPath = path.join(cwd, 'package.json');
111
+ const currentRaw = readFileSafe(pkgPath);
112
+ if (!currentRaw) return;
113
+ const current = readJsonSafe(currentRaw);
114
+ if (!current) return;
115
+
116
+ if (/zero\s+runtime\s+dependenc/i.test(stackText)) {
117
+ const runtimeDeps = Object.keys(current.dependencies || {});
118
+ if (runtimeDeps.length > 0) {
119
+ drifts.push({
120
+ kind: 'dep-contradiction',
121
+ claim: 'stack.md claims "zero runtime dependencies"',
122
+ evidence: `package.json "dependencies" now lists: ${runtimeDeps.join(', ')}`,
123
+ file: 'package.json',
124
+ });
125
+ }
126
+ }
127
+
128
+ const touchCommits = safeExec(
129
+ `git log -n ${RECENT_COMMIT_COUNT} --format=%H -- package.json`,
130
+ cwd
131
+ )
132
+ .trim()
133
+ .split('\n')
134
+ .filter(Boolean);
135
+ if (touchCommits.length === 0) return;
136
+
137
+ // Compare package.json as of the oldest commit in the touched-commit window
138
+ // against the current working tree — not the commit before it, which may
139
+ // not exist (root commit) or predate the window we care about.
140
+ const oldestTouch = touchCommits[touchCommits.length - 1];
141
+ const oldRaw = safeExec(`git show ${oldestTouch}:package.json`, cwd);
142
+ const old = readJsonSafe(oldRaw);
143
+ if (!old) return;
144
+
145
+ const oldDeps = depNames(old);
146
+ const currentDeps = depNames(current);
147
+ const namedInMemory = new Set(extractPackageNames(stackText));
148
+
149
+ for (const dep of oldDeps) {
150
+ if (namedInMemory.has(dep) && !currentDeps.has(dep)) {
151
+ drifts.push({
152
+ kind: 'dep-removed',
153
+ claim: `stack.md names \`${dep}\` as part of the stack`,
154
+ evidence: `package.json no longer lists \`${dep}\` (removed within last ${RECENT_COMMIT_COUNT} commits touching package.json)`,
155
+ file: 'package.json',
156
+ });
157
+ }
158
+ }
159
+ }
160
+
161
+ /**
162
+ * b) files/dirs named in memory (stack.md, decisions.md) no longer exist.
163
+ */
164
+ function checkMissingPaths(cwd, sourceLabel, text, drifts) {
165
+ for (const rel of extractPaths(text)) {
166
+ const full = path.join(cwd, rel);
167
+ if (!fs.existsSync(full)) {
168
+ drifts.push({
169
+ kind: 'missing-path',
170
+ claim: `${sourceLabel} references \`${rel}\``,
171
+ evidence: `\`${rel}\` does not exist in the working tree`,
172
+ file: sourceLabel,
173
+ });
174
+ }
175
+ }
176
+ }
177
+
178
+ /**
179
+ * c) memory INDEX.md older than STALE_INDEX_DAYS days.
180
+ */
181
+ function checkIndexStaleness(cwd, drifts) {
182
+ const indexPath = path.join(cwd, '.rcode', 'memory', 'INDEX.md');
183
+ const text = readFileSafe(indexPath);
184
+ if (!text) return;
185
+
186
+ const m = text.match(/\*\*Last updated:\*\*\s*(\d{4}-\d{2}-\d{2})/);
187
+ if (!m) return;
188
+
189
+ const lastUpdated = new Date(m[1] + 'T00:00:00Z');
190
+ if (Number.isNaN(lastUpdated.getTime())) return;
191
+
192
+ const ageDays = (Date.now() - lastUpdated.getTime()) / (1000 * 60 * 60 * 24);
193
+ if (ageDays > STALE_INDEX_DAYS) {
194
+ drifts.push({
195
+ kind: 'stale-index',
196
+ claim: `INDEX.md "Last updated" is ${m[1]}`,
197
+ evidence: `${Math.floor(ageDays)} days old (threshold: ${STALE_INDEX_DAYS} days)`,
198
+ file: '.rcode/memory/INDEX.md',
199
+ });
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Run all drift heuristics against a project root. Returns {drifts: [...]}.
205
+ * Never throws — a missing/unreadable memory dir yields an empty report.
206
+ *
207
+ * @param {string} cwd - project root to check (defaults to process.cwd())
208
+ */
209
+ function checkDrift(cwd = process.cwd()) {
210
+ const drifts = [];
211
+
212
+ const stackPath = path.join(cwd, '.rcode', 'memory', 'project', 'stack.md');
213
+ const decisionsPath = path.join(cwd, '.rcode', 'memory', 'project', 'decisions.md');
214
+ const stackText = readFileSafe(stackPath);
215
+ const decisionsText = readFileSafe(decisionsPath);
216
+
217
+ try {
218
+ if (stackText) {
219
+ checkDependencyDrift(cwd, stackText, drifts);
220
+ checkMissingPaths(cwd, 'project/stack.md', stackText, drifts);
221
+ }
222
+ if (decisionsText) {
223
+ checkMissingPaths(cwd, 'project/decisions.md', decisionsText, drifts);
224
+ }
225
+ checkIndexStaleness(cwd, drifts);
226
+ } catch {
227
+ // Fail open — drift detection is advisory, never blocking.
228
+ }
229
+
230
+ return { drifts };
231
+ }
232
+
233
+ module.exports = {
234
+ checkDrift,
235
+ extractPaths,
236
+ extractPackageNames,
237
+ };
@@ -0,0 +1,263 @@
1
+ 'use strict';
2
+ /**
3
+ * memory-select.cjs — relevance-ranked memory selector (#958).
4
+ *
5
+ * Scores files under .rcode/memory/ against the current session context
6
+ * (active phase name/goal, git branch, files touched in recent commits,
7
+ * memory file recency) and returns the top-scoring excerpts that fit a
8
+ * token budget. Pure heuristics — no network or LLM calls. Must stay fast
9
+ * (<200ms) since it runs inline in session-start and pre-compact hooks.
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { execSync } = require('child_process');
15
+ const { resolveActivePhase } = require('./state-reader.cjs');
16
+
17
+ const DEFAULT_BUDGET_TOKENS = 1500;
18
+ const CHARS_PER_TOKEN = 4; // rough chars/4 estimate, consistent with rest of the codebase
19
+ const TRUNCATION_MARKER = '\n…(truncated)';
20
+ const STOPWORDS = new Set([
21
+ 'the', 'and', 'for', 'with', 'from', 'this', 'that', 'are', 'was', 'were',
22
+ 'has', 'have', 'will', 'not', 'but', 'you', 'your', 'all', 'can', 'its',
23
+ ]);
24
+
25
+ function estimateTokens(text) {
26
+ return Math.ceil(String(text || '').length / CHARS_PER_TOKEN);
27
+ }
28
+
29
+ function memoryRoot(cwd) {
30
+ return path.join(cwd, '.rcode', 'memory');
31
+ }
32
+
33
+ /** True when .rcode/memory/ exists and contains at least one non-empty .md file. */
34
+ function hasMemory(cwd) {
35
+ return listMemoryFiles(cwd).length > 0;
36
+ }
37
+
38
+ function listMemoryFiles(cwd) {
39
+ const root = memoryRoot(cwd);
40
+ const results = [];
41
+ if (!fs.existsSync(root)) return results;
42
+ const stack = [root];
43
+ while (stack.length) {
44
+ const dir = stack.pop();
45
+ let entries;
46
+ try {
47
+ entries = fs.readdirSync(dir, { withFileTypes: true });
48
+ } catch {
49
+ continue;
50
+ }
51
+ for (const entry of entries) {
52
+ const full = path.join(dir, entry.name);
53
+ if (entry.isDirectory()) {
54
+ stack.push(full);
55
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
56
+ results.push(full);
57
+ }
58
+ }
59
+ }
60
+ return results;
61
+ }
62
+
63
+ function getConfiguredBudget(cwd, fallback) {
64
+ try {
65
+ const { cmdGet } = require('./config.cjs');
66
+ const val = cmdGet(cwd, 'memory_inject_budget');
67
+ const n = val !== null ? parseInt(val, 10) : NaN;
68
+ return Number.isFinite(n) && n > 0 ? n : fallback;
69
+ } catch {
70
+ return fallback;
71
+ }
72
+ }
73
+
74
+ function readGitBranch(cwd) {
75
+ try {
76
+ return execSync('git rev-parse --abbrev-ref HEAD 2>/dev/null', {
77
+ cwd, encoding: 'utf8', timeout: 2000,
78
+ }).trim();
79
+ } catch {
80
+ return '';
81
+ }
82
+ }
83
+
84
+ function readTouchedFiles(cwd) {
85
+ try {
86
+ const out = execSync('git log -5 --name-only --pretty=format: 2>/dev/null', {
87
+ cwd, encoding: 'utf8', timeout: 3000,
88
+ });
89
+ return out.split('\n').map((l) => l.trim()).filter(Boolean);
90
+ } catch {
91
+ return [];
92
+ }
93
+ }
94
+
95
+ function escapeRegExp(s) {
96
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
97
+ }
98
+
99
+ function tokenizeTerms(str) {
100
+ return String(str || '')
101
+ .split(/[^a-zA-Z0-9]+/)
102
+ .map((s) => s.toLowerCase())
103
+ .filter((s) => s.length > 2 && !STOPWORDS.has(s));
104
+ }
105
+
106
+ /** Build the set of query terms + touched paths that describe "current context". */
107
+ function buildQueryContext(cwd, state) {
108
+ const terms = new Set();
109
+ const { activePhase, phaseLabel } = resolveActivePhase(state);
110
+
111
+ if (phaseLabel) tokenizeTerms(phaseLabel).forEach((t) => terms.add(t));
112
+ if (activePhase?.name) tokenizeTerms(activePhase.name).forEach((t) => terms.add(t));
113
+ if (activePhase?.goal) tokenizeTerms(activePhase.goal).forEach((t) => terms.add(t));
114
+
115
+ const branch = readGitBranch(cwd);
116
+ if (branch) tokenizeTerms(branch).forEach((t) => terms.add(t));
117
+
118
+ const touchedPaths = readTouchedFiles(cwd);
119
+ for (const touched of touchedPaths) {
120
+ const base = path.basename(touched, path.extname(touched));
121
+ tokenizeTerms(base).forEach((t) => terms.add(t));
122
+ }
123
+
124
+ return { terms: Array.from(terms), touchedPaths };
125
+ }
126
+
127
+ /**
128
+ * Score one memory file against the query context. Higher is more relevant.
129
+ * Signals: keyword overlap (phase/branch/touched-file terms), path mentions
130
+ * (memory file references a path that was touched recently), and recency
131
+ * of the memory file's own mtime.
132
+ */
133
+ function scoreFile(filePath, content, ctx) {
134
+ const lowerContent = content.toLowerCase();
135
+
136
+ let keywordScore = 0;
137
+ for (const term of ctx.terms) {
138
+ if (!term) continue;
139
+ const re = new RegExp(escapeRegExp(term), 'g');
140
+ const matches = lowerContent.match(re);
141
+ if (matches) keywordScore += Math.min(matches.length, 5);
142
+ }
143
+
144
+ let pathScore = 0;
145
+ for (const touched of ctx.touchedPaths) {
146
+ if (!touched) continue;
147
+ if (lowerContent.includes(touched.toLowerCase())) {
148
+ pathScore += 3;
149
+ continue;
150
+ }
151
+ const base = path.basename(touched, path.extname(touched)).toLowerCase();
152
+ if (base.length > 2 && lowerContent.includes(base)) pathScore += 1;
153
+ }
154
+
155
+ let recencyScore = 0;
156
+ try {
157
+ const stat = fs.statSync(filePath);
158
+ const ageDays = (Date.now() - stat.mtimeMs) / (1000 * 60 * 60 * 24);
159
+ recencyScore = 10 / (1 + Math.max(0, ageDays));
160
+ } catch {
161
+ /* advisory signal only */
162
+ }
163
+
164
+ return keywordScore * 2 + pathScore * 1.5 + recencyScore;
165
+ }
166
+
167
+ /** Truncate content to fit within remainingTokens, leaving room for the marker. */
168
+ function excerptFor(content, remainingTokens) {
169
+ const markerTokens = estimateTokens(TRUNCATION_MARKER);
170
+ const budgetForContent = Math.max(0, remainingTokens - markerTokens);
171
+ const maxChars = budgetForContent * CHARS_PER_TOKEN;
172
+ if (maxChars <= 0) return '';
173
+ return content.slice(0, maxChars).trimEnd() + TRUNCATION_MARKER;
174
+ }
175
+
176
+ /**
177
+ * Select the top-K relevant memory chunks that fit within a token budget.
178
+ *
179
+ * @param {string} cwd - project root (must contain .rcode/)
180
+ * @param {object} [opts]
181
+ * @param {number} [opts.budgetTokens] - explicit override, wins over config/default
182
+ * @param {number} [opts.defaultBudget] - fallback when no config value is set
183
+ * @returns {{chunks: Array<{source:string, excerpt:string, score:number, tokens:number}>, totalTokens: number, budget: number, empty: boolean}}
184
+ */
185
+ function selectMemoryChunks(cwd, opts = {}) {
186
+ const budget = Number.isFinite(opts.budgetTokens) && opts.budgetTokens > 0
187
+ ? opts.budgetTokens
188
+ : getConfiguredBudget(cwd, opts.defaultBudget ?? DEFAULT_BUDGET_TOKENS);
189
+
190
+ const files = listMemoryFiles(cwd);
191
+ if (files.length === 0) {
192
+ return { chunks: [], totalTokens: 0, budget, empty: true };
193
+ }
194
+
195
+ let state = null;
196
+ try {
197
+ const statePath = path.join(cwd, '.rcode', 'state.json');
198
+ if (fs.existsSync(statePath)) state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
199
+ } catch {
200
+ /* advisory — proceed with no phase context */
201
+ }
202
+
203
+ const ctx = buildQueryContext(cwd, state);
204
+
205
+ const scored = [];
206
+ for (const filePath of files) {
207
+ let content;
208
+ try {
209
+ content = fs.readFileSync(filePath, 'utf8');
210
+ } catch {
211
+ continue;
212
+ }
213
+ if (!content.trim()) continue;
214
+ scored.push({ filePath, content, score: scoreFile(filePath, content, ctx) });
215
+ }
216
+
217
+ if (scored.length === 0) {
218
+ return { chunks: [], totalTokens: 0, budget, empty: true };
219
+ }
220
+
221
+ scored.sort((a, b) => b.score - a.score);
222
+
223
+ const chunks = [];
224
+ let used = 0;
225
+ for (const item of scored) {
226
+ if (used >= budget) break;
227
+ const remaining = budget - used;
228
+ const tokens = estimateTokens(item.content);
229
+ const excerpt = tokens <= remaining ? item.content : excerptFor(item.content, remaining);
230
+ if (!excerpt) continue;
231
+ const excerptTokens = estimateTokens(excerpt);
232
+ chunks.push({
233
+ source: path.relative(cwd, item.filePath),
234
+ excerpt,
235
+ score: item.score,
236
+ tokens: excerptTokens,
237
+ });
238
+ used += excerptTokens;
239
+ }
240
+
241
+ return { chunks, totalTokens: used, budget, empty: chunks.length === 0 };
242
+ }
243
+
244
+ /** Render a selection into a single Markdown block, or null when there's nothing to inject. */
245
+ function formatMemoryContext(selection) {
246
+ if (!selection || selection.empty || selection.chunks.length === 0) return null;
247
+ const lines = ['## Relevant memory', ''];
248
+ for (const chunk of selection.chunks) {
249
+ lines.push(`### ${chunk.source}`);
250
+ lines.push(chunk.excerpt.trim());
251
+ lines.push('');
252
+ }
253
+ return lines.join('\n').trim();
254
+ }
255
+
256
+ module.exports = {
257
+ DEFAULT_BUDGET_TOKENS,
258
+ selectMemoryChunks,
259
+ formatMemoryContext,
260
+ estimateTokens,
261
+ hasMemory,
262
+ memoryRoot,
263
+ };