opencode-rules-md 0.8.6 → 0.9.1

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 (47) hide show
  1. package/README.md +9 -9
  2. package/dist/src/cli/config.d.ts +1 -1
  3. package/dist/src/cli/config.js.map +1 -1
  4. package/dist/src/cli/install.d.ts.map +1 -1
  5. package/dist/src/cli/install.js.map +1 -1
  6. package/dist/src/cli/main.d.ts.map +1 -1
  7. package/dist/src/cli/main.js.map +1 -1
  8. package/dist/src/cli/registry.d.ts.map +1 -1
  9. package/dist/src/cli/spawn.js.map +1 -1
  10. package/dist/src/cli/status.d.ts.map +1 -1
  11. package/dist/src/cli/status.js.map +1 -1
  12. package/dist/src/cli/uninstall.d.ts +1 -1
  13. package/dist/src/cli/uninstall.d.ts.map +1 -1
  14. package/dist/src/cli/uninstall.js.map +1 -1
  15. package/dist/src/cli/update.d.ts +1 -1
  16. package/dist/src/cli/update.d.ts.map +1 -1
  17. package/dist/src/cli/update.js.map +1 -1
  18. package/dist/src/index.d.ts +2 -1
  19. package/dist/src/index.d.ts.map +1 -1
  20. package/dist/src/rule-discovery.d.ts.map +1 -1
  21. package/dist/src/rule-discovery.js +18 -10
  22. package/dist/src/rule-discovery.js.map +1 -1
  23. package/dist/src/rule-filter.d.ts +9 -0
  24. package/dist/src/rule-filter.d.ts.map +1 -1
  25. package/dist/src/rule-filter.js +48 -11
  26. package/dist/src/rule-filter.js.map +1 -1
  27. package/dist/src/rule-metadata.d.ts +2 -0
  28. package/dist/src/rule-metadata.d.ts.map +1 -1
  29. package/dist/src/rule-metadata.js +4 -0
  30. package/dist/src/rule-metadata.js.map +1 -1
  31. package/dist/src/runtime-context.d.ts +6 -0
  32. package/dist/src/runtime-context.d.ts.map +1 -1
  33. package/dist/src/runtime-context.js +16 -0
  34. package/dist/src/runtime-context.js.map +1 -1
  35. package/dist/src/runtime.d.ts.map +1 -1
  36. package/dist/src/session-store.d.ts.map +1 -1
  37. package/dist/src/session-store.js +1 -0
  38. package/dist/src/session-store.js.map +1 -1
  39. package/dist/tui/index.d.ts.map +1 -1
  40. package/dist/tui/index.js +13 -9
  41. package/dist/tui/index.js.map +4 -4
  42. package/package.json +14 -17
  43. package/src/rule-discovery.ts +22 -10
  44. package/src/rule-filter.ts +73 -12
  45. package/src/rule-metadata.ts +8 -0
  46. package/src/runtime-context.ts +16 -0
  47. package/src/session-store.ts +1 -0
@@ -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;