opencode-rules-md 0.8.5 → 0.9.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.
Files changed (62) hide show
  1. package/dist/cli.mjs +252 -261
  2. package/dist/src/cli/config.d.ts +11 -0
  3. package/dist/src/cli/config.d.ts.map +1 -1
  4. package/dist/src/cli/config.js +15 -0
  5. package/dist/src/cli/config.js.map +1 -1
  6. package/dist/src/cli/install.d.ts +35 -20
  7. package/dist/src/cli/install.d.ts.map +1 -1
  8. package/dist/src/cli/install.js +50 -131
  9. package/dist/src/cli/install.js.map +1 -1
  10. package/dist/src/cli/main.d.ts +5 -0
  11. package/dist/src/cli/main.d.ts.map +1 -1
  12. package/dist/src/cli/main.js +29 -26
  13. package/dist/src/cli/main.js.map +1 -1
  14. package/dist/src/cli/spawn.d.ts +29 -0
  15. package/dist/src/cli/spawn.d.ts.map +1 -0
  16. package/dist/src/cli/spawn.js +48 -0
  17. package/dist/src/cli/spawn.js.map +1 -0
  18. package/dist/src/cli/status.d.ts +15 -8
  19. package/dist/src/cli/status.d.ts.map +1 -1
  20. package/dist/src/cli/status.js +63 -31
  21. package/dist/src/cli/status.js.map +1 -1
  22. package/dist/src/cli/uninstall.d.ts +5 -2
  23. package/dist/src/cli/uninstall.d.ts.map +1 -1
  24. package/dist/src/cli/uninstall.js +78 -51
  25. package/dist/src/cli/uninstall.js.map +1 -1
  26. package/dist/src/cli/update.d.ts +50 -16
  27. package/dist/src/cli/update.d.ts.map +1 -1
  28. package/dist/src/cli/update.js +156 -124
  29. package/dist/src/cli/update.js.map +1 -1
  30. package/dist/src/rule-discovery.d.ts.map +1 -1
  31. package/dist/src/rule-discovery.js +18 -10
  32. package/dist/src/rule-discovery.js.map +1 -1
  33. package/dist/src/rule-filter.d.ts +9 -0
  34. package/dist/src/rule-filter.d.ts.map +1 -1
  35. package/dist/src/rule-filter.js +48 -11
  36. package/dist/src/rule-filter.js.map +1 -1
  37. package/dist/src/rule-metadata.d.ts +2 -0
  38. package/dist/src/rule-metadata.d.ts.map +1 -1
  39. package/dist/src/rule-metadata.js +4 -0
  40. package/dist/src/rule-metadata.js.map +1 -1
  41. package/dist/src/runtime-context.d.ts +6 -0
  42. package/dist/src/runtime-context.d.ts.map +1 -1
  43. package/dist/src/runtime-context.js +16 -0
  44. package/dist/src/runtime-context.js.map +1 -1
  45. package/dist/src/session-store.d.ts.map +1 -1
  46. package/dist/src/session-store.js +1 -0
  47. package/dist/src/session-store.js.map +1 -1
  48. package/dist/tui/index.js +13 -9
  49. package/dist/tui/index.js.map +4 -4
  50. package/package.json +1 -1
  51. package/src/cli/config.ts +19 -0
  52. package/src/cli/install.ts +66 -174
  53. package/src/cli/main.ts +34 -25
  54. package/src/cli/spawn.ts +76 -0
  55. package/src/cli/status.ts +83 -37
  56. package/src/cli/uninstall.ts +88 -57
  57. package/src/cli/update.ts +182 -148
  58. package/src/rule-discovery.ts +22 -10
  59. package/src/rule-filter.ts +73 -12
  60. package/src/rule-metadata.ts +8 -0
  61. package/src/runtime-context.ts +16 -0
  62. package/src/session-store.ts +1 -0
package/src/cli/update.ts CHANGED
@@ -1,85 +1,183 @@
1
1
  // ---------------------------------------------------------------------------
2
2
  // src/cli/update.ts — `omd update` command implementation.
3
3
  //
4
- // Fetches latest from npm, compares to installed version, purges the
5
- // ~/.cache/opencode/node_modules/opencode-rules-md cache, and prints the
6
- // reinstall instruction when stale. No auto-reinstall.
4
+ // Like install, update is now a thin wrapper around OpenCode's own CLI.
5
+ // We compare the installed version (read from `data['plugin']` — singular —
6
+ // with a backward-compat fallback to the legacy `data['plugins']`) against
7
+ // the latest npm version. When stale we purge the on-disk cache under
8
+ // ~/.cache/opencode/packages/ (the actual location OpenCode uses) and
9
+ // invoke `opencode plugin opencode-rules-md --global --force` to refresh
10
+ // the registration.
11
+ //
12
+ // The cache purge matters because OpenCode caches the resolved package
13
+ // by specifier — once a cached copy exists, `--force` re-registers without
14
+ // re-fetching unless we first remove the stale entry.
7
15
  // ---------------------------------------------------------------------------
8
16
 
9
17
  import { homedir } from 'os';
10
18
  import { join } from 'path';
11
19
  import { fetchLatestVersion, isStale } from './registry.js';
12
- import { loadGlobalConfig, matchesPlugin, normalizePlugin } from './config.js';
13
- import type { CliFs } from './config.js';
20
+ import {
21
+ loadGlobalConfig,
22
+ matchesPlugin,
23
+ readInstalledPlugins,
24
+ type CliFs,
25
+ type LoadedConfig,
26
+ } from './config.js';
27
+ import { spawnOpencodePlugin } from './spawn.js';
28
+
29
+ const CONFIG_BASENAMES = ['opencode', 'tui'] as const;
30
+
31
+ /** Package directory used by OpenCode to cache plugin installs. */
32
+ export const PACKAGES_DIR_BASENAME = ['.cache', 'opencode', 'packages'] as const;
33
+
34
+ /** Exact cache directory name we look for (bare specifier, no version suffix). */
35
+ export const CACHE_DIR_BASENAME = 'opencode-rules-md';
14
36
 
15
- // ─── Constants ───────────────────────────────────────────────────────────────
37
+ /**
38
+ * Resolve the user's home directory, honoring a custom HOME env var.
39
+ * Used by both resolveCachePaths and the config loader.
40
+ */
41
+ export function resolveHome(env: NodeJS.ProcessEnv = process.env): string {
42
+ return env.HOME ?? homedir();
43
+ }
16
44
 
17
- export const CACHE_PATH = join(
18
- homedir(),
19
- '.cache',
20
- 'opencode',
21
- 'node_modules',
22
- 'opencode-rules-md',
23
- );
45
+ /**
46
+ * Return the absolute path of the OpenCode packages cache directory.
47
+ */
48
+ export function resolvePackagesDir(env: NodeJS.ProcessEnv = process.env): string {
49
+ return join(resolveHome(env), ...PACKAGES_DIR_BASENAME);
50
+ }
24
51
 
25
- // ─── Types ───────────────────────────────────────────────────────────────────
52
+ /**
53
+ * Return the cache directories that match the opencode-rules-md prefix.
54
+ *
55
+ * Real-world layout under ~/.cache/opencode/packages/ looks like:
56
+ * opencode-rules-md/
57
+ * opencode-rules-md@latest/
58
+ * some-other-plugin/
59
+ *
60
+ * We glob-match anything starting with `opencode-rules-md` (exact or with
61
+ * an `@<version>` suffix). When an injected fs is provided we list the
62
+ * directory and filter; without one we return the two most common shapes
63
+ * so callers can pre-check existence cheaply.
64
+ */
65
+ export function resolveCachePaths(
66
+ env: NodeJS.ProcessEnv = process.env,
67
+ fs?: CliFs,
68
+ ): string[] {
69
+ const packagesDir = resolvePackagesDir(env);
70
+ if (fs && fs.existsSync(packagesDir)) {
71
+ try {
72
+ const entries = fs.readdirSync(packagesDir);
73
+ return entries
74
+ .filter(
75
+ (name) => name === CACHE_DIR_BASENAME || name.startsWith(`${CACHE_DIR_BASENAME}@`),
76
+ )
77
+ .map((name) => join(packagesDir, name));
78
+ } catch {
79
+ return [];
80
+ }
81
+ }
82
+ // No fs injection — return the conventional candidates. Callers that care
83
+ // about existence should still check with existsSync().
84
+ return [
85
+ join(packagesDir, CACHE_DIR_BASENAME),
86
+ join(packagesDir, `${CACHE_DIR_BASENAME}@latest`),
87
+ ];
88
+ }
26
89
 
27
90
  export interface UpdateOptions {
91
+ /** Injected latest version (test seam). Defaults to a real npm lookup. */
28
92
  latestVersion?: string | null | undefined;
93
+ /** Print the plan without writing or spawning. */
29
94
  dryRun?: boolean;
95
+ /** Injected spawn function (test seam). Defaults to spawnOpencodePlugin. */
96
+ spawn?: typeof spawnOpencodePlugin;
30
97
  }
31
98
 
32
99
  export interface UpdateResult {
33
100
  status: 'stale' | 'current' | 'unreachable';
34
- cachePath: string;
101
+ cachePaths: string[];
102
+ /** Re-install instruction; empty when status === 'current'. */
35
103
  instruction: string;
36
104
  }
37
105
 
38
- // ─── Cache path helpers ───────────────────────────────────────────────────────
106
+ /**
107
+ * Look up the installed specifier across both config files, returning the
108
+ * first one we find. Returns null when neither file registers the plugin.
109
+ */
110
+ function findInstalledSpecifier(loaded: readonly LoadedConfig[]): string | null {
111
+ for (const cfg of loaded) {
112
+ const plugins = readInstalledPlugins(cfg);
113
+ const match = plugins.find((p) => matchesPlugin(p));
114
+ if (match) return match;
115
+ }
116
+ return null;
117
+ }
39
118
 
40
119
  /**
41
- * Resolve the local cache directory, honoring a custom HOME environment.
42
- * Falls back to os.homedir() when HOME is not set, matching the
43
- * resolveConfigDir fallback strategy.
120
+ * Extract the version portion of an `opencode-rules-md@<version>` specifier.
121
+ * Returns null when the specifier has no `@version` segment.
44
122
  */
45
- export function resolveCachePath(env: NodeJS.ProcessEnv = process.env): string {
46
- return join(
47
- env.HOME ?? homedir(),
48
- '.cache',
49
- 'opencode',
50
- 'node_modules',
51
- 'opencode-rules-md',
52
- );
123
+ export function extractVersion(specifier: string): string | null {
124
+ const at = specifier.lastIndexOf('@');
125
+ if (at < 0 || at === specifier.length - 1) return null;
126
+ return specifier.slice(at + 1);
53
127
  }
54
128
 
55
129
  /**
56
- * Read the cached package.json version, if determinable.
57
- * Returns null when the cache is missing or unreadable.
130
+ * Recursively delete a directory and all its contents using the injected fs.
131
+ * Best-effort a failed purge is not fatal; we want the update to keep going.
58
132
  */
59
- function readCacheVersion(fs: CliFs, cachePath: string): string | null {
60
- const pkgPath = join(cachePath, 'package.json');
133
+ export function purgeDirectory(fs: CliFs, dirPath: string): void {
134
+ let entries: string[] = [];
61
135
  try {
62
- if (!fs.existsSync(pkgPath)) {
63
- return null;
64
- }
65
- const pkg = JSON.parse(fs.readFileSync(pkgPath)) as { version?: unknown };
66
- return typeof pkg.version === 'string' ? pkg.version : null;
136
+ entries = fs.readdirSync(dirPath);
67
137
  } catch {
68
- return null;
138
+ return;
69
139
  }
70
- }
71
140
 
72
- // ─── runUpdate ────────────────────────────────────────────────────────────────
141
+ for (const entry of entries) {
142
+ const entryPath = join(dirPath, entry);
143
+ try {
144
+ if (!fs.existsSync(entryPath)) continue;
145
+ // Probe to differentiate empty dirs / non-empty dirs / files. The injected
146
+ // fs interface does not expose stat, so we try readdir first and fall back
147
+ // to unlink for leaves.
148
+ try {
149
+ const subEntries = fs.readdirSync(entryPath);
150
+ if (subEntries.length === 0) {
151
+ fs.rmdirSync(entryPath);
152
+ } else {
153
+ purgeDirectory(fs, entryPath);
154
+ fs.rmdirSync(entryPath);
155
+ }
156
+ } catch {
157
+ // Not a directory (or unreadable) — best-effort unlink.
158
+ fs.unlinkSync(entryPath);
159
+ }
160
+ } catch {
161
+ // best-effort per entry
162
+ }
163
+ }
73
164
 
74
- const CONFIG_BASENAMES = ['opencode', 'tui'] as const;
165
+ try {
166
+ fs.rmdirSync(dirPath);
167
+ } catch {
168
+ // best-effort
169
+ }
170
+ }
75
171
 
76
172
  /**
77
- * Run the update command:
78
- * 1. Fetch latest version from npm (or use provided mock)
79
- * 2. Compare to installed version in config
80
- * 3. If stale: purge cache, print reinstall instruction
81
- * 4. If current: print "already current"
82
- * 5. If unreachable: noop
173
+ * Run the update command.
174
+ *
175
+ * 1. Resolve the latest published version from npm (or use the injected mock).
176
+ * 2. Read the installed version from the user's opencode config.
177
+ * 3. If unreachable, log and return.
178
+ * 4. If current, log and return.
179
+ * 5. Otherwise: purge stale cache directories, then spawn
180
+ * `opencode plugin opencode-rules-md --global --force`.
83
181
  */
84
182
  export const runUpdate = async (
85
183
  fs: CliFs,
@@ -88,133 +186,69 @@ export const runUpdate = async (
88
186
  _error: (s: string) => void,
89
187
  opts: UpdateOptions = {},
90
188
  ): Promise<UpdateResult> => {
91
- // Use provided latestVersion for testing, otherwise fetch from npm
92
- const latest = opts.latestVersion !== undefined
93
- ? opts.latestVersion
94
- : await fetchLatestVersion();
189
+ // 1. Resolve latest.
190
+ const latest = opts.latestVersion !== undefined ? opts.latestVersion : await fetchLatestVersion();
95
191
 
96
- const cachePath = resolveCachePath(env);
192
+ const cachePaths = resolveCachePaths(env, fs);
193
+ const instruction = 'opencode plugin opencode-rules-md --global --force';
194
+ const spawnFn = opts.spawn ?? spawnOpencodePlugin;
97
195
 
196
+ // 2. Unreachable.
98
197
  if (latest === null) {
99
198
  log('omd: could not determine latest version from npm registry');
100
- return {
101
- status: 'unreachable',
102
- cachePath,
103
- instruction: 'npx opencode-rules-md@latest install',
104
- };
199
+ return { status: 'unreachable', cachePaths, instruction };
105
200
  }
106
201
 
107
- // Determine installed version from config (check both configs)
108
- let installedVersion: string | null = null;
109
- for (const basename of CONFIG_BASENAMES) {
110
- const loaded = loadGlobalConfig(fs, env, basename);
111
- const plugins = normalizePlugin(loaded.data['plugins']);
112
- const match = plugins.find(p => matchesPlugin(p));
113
- if (match) {
114
- // Extract version from specifier (e.g. 'opencode-rules-md@2.0.0' -> '2.0.0')
115
- const atIndex = match.lastIndexOf('@');
116
- installedVersion = atIndex >= 0 ? match.slice(atIndex + 1) : null;
117
- break; // use first config that has it
118
- }
119
- }
202
+ // 3. Installed version (read both configs).
203
+ const configs = CONFIG_BASENAMES.map((basename) => loadGlobalConfig(fs, env, basename));
204
+ const installedSpecifier = findInstalledSpecifier(configs);
205
+ const installedVersion = installedSpecifier ? extractVersion(installedSpecifier) : null;
120
206
 
121
- const instruction = `npx opencode-rules-md@latest install`;
122
-
123
- // Check staleness: only stale if installed version differs from latest
207
+ // 4. Current.
124
208
  if (installedVersion !== null && !isStale(installedVersion, latest)) {
125
209
  log(`omd: opencode-rules-md@${installedVersion} is already the latest`);
126
- return {
127
- status: 'current',
128
- cachePath,
129
- instruction,
130
- };
210
+ return { status: 'current', cachePaths, instruction: '' };
131
211
  }
132
212
 
133
- // Before purging, decide whether the cache is actually stale. When the cache
134
- // version is already equal to the latest resolved version, skip the purge
135
- // so we do not delete a valid cache. If the version cannot be determined,
136
- // fall back to purging to be safe.
137
- const cacheVersion = readCacheVersion(fs, cachePath);
138
- const shouldPurge = cacheVersion === null || cacheVersion !== latest;
139
-
213
+ // 5. Stale describe plan.
140
214
  if (opts.dryRun) {
141
- log(`omd: update check (dry-run)`);
215
+ log('omd: update check (dry-run)');
142
216
  log(` latest version: ${latest}`);
143
217
  log(` installed version: ${installedVersion ?? 'not installed'}`);
144
- log(` would purge: ${shouldPurge ? cachePath : '(nothing to purge)'}`);
218
+ log(` would purge: ${cachePaths.join(', ') || '(none found)'}`);
145
219
  log(` would instruct: ${instruction}`);
146
- return {
147
- status: 'stale',
148
- cachePath,
149
- instruction,
150
- };
220
+ return { status: 'stale', cachePaths, instruction };
151
221
  }
152
222
 
153
- // Purge the cache directory using the injected fs (allows test faking)
154
- if (shouldPurge) {
223
+ log(
224
+ installedVersion === null
225
+ ? `omd: opencode-rules-md is not installed; registering latest (${latest})`
226
+ : `omd: opencode-rules-md is stale (installed ${installedVersion}, latest ${latest})`,
227
+ );
228
+
229
+ // Purge each matching cache directory. Best-effort per path.
230
+ for (const cachePath of cachePaths) {
155
231
  try {
156
232
  if (fs.existsSync(cachePath)) {
157
- // Recursively remove contents then the dir itself
158
233
  purgeDirectory(fs, cachePath);
234
+ log(`omd: purged cache ${cachePath}`);
159
235
  }
160
236
  } catch {
161
- // best-effort purge failure is non-fatal
237
+ // ignore individual purge failures
162
238
  }
163
239
  }
164
240
 
165
- log(`omd: opencode-rules-md is stale (latest: ${latest})`);
166
- log(`omd: cache purged at ${cachePath}`);
167
- log(`omd: to reinstall, run:`);
168
- log(` ${instruction}`);
169
-
170
- return {
171
- status: 'stale',
172
- cachePath,
173
- instruction,
174
- };
175
- };
176
-
177
- // ─── purgeDirectory ────────────────────────────────────────────────────────────
178
-
179
- /**
180
- * Recursively delete a directory and all its contents using the given fs.
181
- */
182
- export function purgeDirectory(fs: CliFs, dirPath: string): void {
183
- let entries: string[] = [];
184
- try {
185
- entries = fs.readdirSync(dirPath);
186
- } catch {
187
- return;
188
- }
241
+ // Re-register via OpenCode's CLI. Failure here is fatal surface it.
242
+ const result = await spawnFn(['opencode-rules-md', '--global', '--force'], {
243
+ env: process.env,
244
+ stdio: 'inherit',
245
+ });
189
246
 
190
- for (const entry of entries) {
191
- const entryPath = join(dirPath, entry);
192
- try {
193
- if (fs.existsSync(entryPath)) {
194
- // Check if it's a directory by trying to read it
195
- try {
196
- const subEntries = fs.readdirSync(entryPath);
197
- if (subEntries.length === 0) {
198
- // Empty directory
199
- fs.rmdirSync(entryPath);
200
- } else {
201
- // Non-empty directory — recurse
202
- purgeDirectory(fs, entryPath);
203
- fs.rmdirSync(entryPath);
204
- }
205
- } catch {
206
- // It's a file — unlink it
207
- fs.unlinkSync(entryPath);
208
- }
209
- }
210
- } catch {
211
- // best-effort per entry
212
- }
247
+ if ((result.status ?? 0) !== 0) {
248
+ throw new Error(
249
+ `opencode plugin opencode-rules-md --global --force exited with status ${String(result.status)}`,
250
+ );
213
251
  }
214
252
 
215
- try {
216
- fs.rmdirSync(dirPath);
217
- } catch {
218
- // best-effort
219
- }
220
- }
253
+ return { status: 'stale', cachePaths, instruction: '' };
254
+ };
@@ -173,33 +173,45 @@ export interface DiscoveredRule {
173
173
  export async function discoverRuleFiles(
174
174
  projectDir?: string
175
175
  ): Promise<DiscoveredRule[]> {
176
- const files: DiscoveredRule[] = [];
176
+ // Keyed by relativePath; later insertions shadow earlier ones.
177
+ // Global rules are inserted first (lowest precedence); project-local rules
178
+ // are inserted after and overwrite any global entry sharing the same key.
179
+ const byRelativePath = new Map<string, DiscoveredRule>();
177
180
 
178
- // Discover global rules (recursively)
181
+ // Discover global rules (recursively) — inserted first (lowest precedence).
179
182
  const globalRulesDir = getGlobalRulesDir();
180
183
  if (globalRulesDir) {
181
184
  const globalRules = await scanDirectoryRecursively(
182
185
  globalRulesDir,
183
186
  globalRulesDir
184
187
  );
185
- for (const { filePath, relativePath } of globalRules) {
186
- debugLog(`Discovered global rule: ${relativePath} (${filePath})`);
187
- files.push({ filePath, relativePath });
188
+ for (const rule of globalRules) {
189
+ debugLog(
190
+ `Discovered global rule: ${rule.relativePath} (${rule.filePath})`
191
+ );
192
+ byRelativePath.set(rule.relativePath, rule);
188
193
  }
189
194
  }
190
195
 
191
- // Discover project-local rules (recursively) if project directory is provided
196
+ // Discover project-local rules (recursively) shadow global duplicates.
192
197
  if (projectDir) {
193
198
  const projectRulesDir = path.join(projectDir, '.opencode', 'rules');
194
199
  const projectRules = await scanDirectoryRecursively(
195
200
  projectRulesDir,
196
201
  projectRulesDir
197
202
  );
198
- for (const { filePath, relativePath } of projectRules) {
199
- debugLog(`Discovered project rule: ${relativePath} (${filePath})`);
200
- files.push({ filePath, relativePath });
203
+ for (const rule of projectRules) {
204
+ const shadowed = byRelativePath.has(rule.relativePath);
205
+ debugLog(
206
+ `Discovered project rule: ${rule.relativePath} (${rule.filePath})` +
207
+ (shadowed ? ' [shadows global]' : '')
208
+ );
209
+ byRelativePath.set(rule.relativePath, rule);
201
210
  }
202
211
  }
203
212
 
204
- return files;
213
+ // Map preserves insertion order in JS, so global rules keep their relative
214
+ // order and shadowed slots retain their original position but point at the
215
+ // project file.
216
+ return Array.from(byRelativePath.values());
205
217
  }
@@ -59,12 +59,23 @@ export function toolsMatchAvailable(
59
59
  return requiredTools.some(tool => availableSet.has(tool));
60
60
  }
61
61
 
62
+ /**
63
+ * Rough token estimate using the chars/4 heuristic.
64
+ * No external dependency; accurate enough for budget decisions.
65
+ */
66
+ export function estimateTokens(text: string): number {
67
+ if (!text) return 0;
68
+ return Math.ceil(text.length / 4);
69
+ }
70
+
62
71
  /**
63
72
  * Result of reading and formatting rules
64
73
  */
65
74
  export interface FilterResult {
66
75
  formattedRules: string;
67
76
  matchedPaths: string[];
77
+ /** Estimated token count of formattedRules (0 when empty) */
78
+ tokenEstimate: number;
68
79
  }
69
80
 
70
81
  /**
@@ -91,6 +102,8 @@ export interface RuleFilterContext {
91
102
  os?: string;
92
103
  /** Whether running in CI environment */
93
104
  ci?: boolean;
105
+ /** Optional hard token cap; lowest-priority rules are dropped when exceeded. */
106
+ maxTokens?: number;
94
107
  }
95
108
 
96
109
  /**
@@ -103,16 +116,26 @@ export async function readAndFormatRules(
103
116
  context: RuleFilterContext = {}
104
117
  ): Promise<FilterResult> {
105
118
  if (files.length === 0) {
106
- return { formattedRules: '', matchedPaths: [] };
119
+ return { formattedRules: '', matchedPaths: [], tokenEstimate: 0 };
107
120
  }
108
121
 
109
- const ruleContents: string[] = [];
110
- const matchedPaths: string[] = [];
111
122
  const availableToolSet =
112
123
  context.availableToolIDs && context.availableToolIDs.length > 0
113
124
  ? new Set(context.availableToolIDs)
114
125
  : undefined;
115
126
 
127
+ // Collect matched entries with priority and token count
128
+ type MatchedEntry = {
129
+ filePath: string;
130
+ relativePath: string;
131
+ strippedContent: string;
132
+ priority: number;
133
+ tokenCount: number;
134
+ index: number;
135
+ };
136
+ const entries: MatchedEntry[] = [];
137
+ let entryIndex = 0;
138
+
116
139
  for (const { filePath, relativePath } of files) {
117
140
  // Use cached rule data with mtime-based invalidation
118
141
  const cachedRule = await getCachedRule(filePath);
@@ -250,20 +273,58 @@ export async function readAndFormatRules(
250
273
  );
251
274
  }
252
275
 
253
- // Use cached stripped content for output
254
- // Use relativePath for unique headings instead of just filename
255
- ruleContents.push(`## ${relativePath}\n\n${strippedContent}`);
256
- matchedPaths.push(filePath);
276
+ // Extract priority (default 0 for selection; undefined in metadata means absent)
277
+ const priority = metadata?.priority ?? 0;
278
+
279
+ // Build formatted chunk for token counting
280
+ const formattedChunk = `## ${relativePath}\n\n${strippedContent}`;
281
+ const tokenCount = estimateTokens(formattedChunk);
282
+
283
+ entries.push({ filePath, relativePath, strippedContent, priority, tokenCount, index: entryIndex++ });
257
284
  }
258
285
 
259
- if (ruleContents.length === 0) {
260
- return { formattedRules: '', matchedPaths: [] };
286
+ if (entries.length === 0) {
287
+ return { formattedRules: '', matchedPaths: [], tokenEstimate: 0 };
261
288
  }
262
289
 
290
+ // Determine which entries to include based on budget
291
+ const maxTokens = context.maxTokens;
292
+ const hasValidBudget = typeof maxTokens === 'number' && maxTokens > 0 && Number.isFinite(maxTokens);
293
+
294
+ let survivors: MatchedEntry[];
295
+ if (!hasValidBudget) {
296
+ // No budget: use discovery order (existing behavior)
297
+ survivors = entries;
298
+ } else {
299
+ // Valid budget: stable sort by priority desc, index asc
300
+ const sorted = [...entries].sort((a, b) =>
301
+ (b.priority - a.priority) || (a.index - b.index)
302
+ );
303
+
304
+ // Greedy selection: always keep first (≥1 survivor invariant), then add while within budget
305
+ survivors = [sorted[0]];
306
+ let runningTokens = sorted[0].tokenCount;
307
+ for (let i = 1; i < sorted.length; i++) {
308
+ if (runningTokens + sorted[i].tokenCount <= maxTokens) {
309
+ survivors.push(sorted[i]);
310
+ runningTokens += sorted[i].tokenCount;
311
+ }
312
+ }
313
+ }
314
+
315
+ // Build output from survivors
316
+ const ruleContents = survivors.map(
317
+ entry => `## ${entry.relativePath}\n\n${entry.strippedContent}`
318
+ );
319
+ const matchedPaths = survivors.map(entry => entry.filePath);
320
+
321
+ const formattedRules =
322
+ `# OpenCode Rules\n\nPlease follow the following rules:\n\n` +
323
+ ruleContents.join('\n\n---\n\n');
324
+
263
325
  return {
264
- formattedRules:
265
- `# OpenCode Rules\n\nPlease follow the following rules:\n\n` +
266
- ruleContents.join('\n\n---\n\n'),
326
+ formattedRules,
267
327
  matchedPaths,
328
+ tokenEstimate: estimateTokens(formattedRules),
268
329
  };
269
330
  }
@@ -19,6 +19,8 @@ export interface RuleMetadata {
19
19
  os?: string[];
20
20
  ci?: boolean;
21
21
  match?: 'any' | 'all';
22
+ /** Higher priority is kept first when a token budget is enforced. Default 0. */
23
+ priority?: number;
22
24
  }
23
25
 
24
26
  /**
@@ -36,6 +38,7 @@ interface ParsedFrontmatter {
36
38
  os?: unknown;
37
39
  ci?: unknown;
38
40
  match?: unknown;
41
+ priority?: unknown;
39
42
  }
40
43
 
41
44
  /** Field names in ParsedFrontmatter that are string arrays */
@@ -129,6 +132,11 @@ export function parseRuleMetadata(content: string): RuleMetadata | undefined {
129
132
  metadata.match = parsed.match;
130
133
  }
131
134
 
135
+ // Extract priority (finite numbers only; non-finite or absent becomes undefined, treated as 0 downstream)
136
+ if (typeof parsed.priority === 'number' && Number.isFinite(parsed.priority)) {
137
+ metadata.priority = parsed.priority;
138
+ }
139
+
132
140
  // Return metadata only if it has content
133
141
  return Object.keys(metadata).length > 0 ? metadata : undefined;
134
142
  } catch (error) {
@@ -62,6 +62,18 @@ export function detectCiEnvironment(): boolean {
62
62
  );
63
63
  }
64
64
 
65
+ /**
66
+ * Resolve the active token budget from the OPENCODE_RULES_MAX_TOKENS env var.
67
+ * Returns undefined when the env var is absent, empty, zero, negative, or non-finite.
68
+ * Positive finite numbers enable budget enforcement.
69
+ */
70
+ export function resolveMaxTokens(): number | undefined {
71
+ const raw = process.env.OPENCODE_RULES_MAX_TOKENS;
72
+ if (!raw) return undefined;
73
+ const n = Number(raw);
74
+ return Number.isFinite(n) && n > 0 ? n : undefined;
75
+ }
76
+
65
77
  /**
66
78
  * Build the filter context object used for rule matching.
67
79
  * Assembles runtime information from various sources.
@@ -125,6 +137,10 @@ export async function buildFilterContext(
125
137
  if (gitBranch !== undefined) {
126
138
  context.gitBranch = gitBranch;
127
139
  }
140
+ const maxTokens = resolveMaxTokens();
141
+ if (maxTokens !== undefined) {
142
+ context.maxTokens = maxTokens;
143
+ }
128
144
 
129
145
  debugLog(
130
146
  `Filter context: model=${modelID ?? 'none'}, agent=${agentType ?? 'none'}, ` +
@@ -108,6 +108,7 @@ export class SessionStore {
108
108
 
109
109
  this.upsert(sessionID, s => {
110
110
  s.isCompacting = false;
111
+ s.rulesInjected = false;
111
112
  });
112
113
 
113
114
  return false;