opencode-quotas 0.0.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 (136) hide show
  1. package/README.md +344 -0
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +3 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/src/cli.d.ts +3 -0
  7. package/dist/src/cli.d.ts.map +1 -0
  8. package/dist/src/cli.js +42 -0
  9. package/dist/src/cli.js.map +1 -0
  10. package/dist/src/constants.d.ts +9 -0
  11. package/dist/src/constants.d.ts.map +1 -0
  12. package/dist/src/constants.js +15 -0
  13. package/dist/src/constants.js.map +1 -0
  14. package/dist/src/defaults.d.ts +3 -0
  15. package/dist/src/defaults.d.ts.map +1 -0
  16. package/dist/src/defaults.js +52 -0
  17. package/dist/src/defaults.js.map +1 -0
  18. package/dist/src/index.d.ts +6 -0
  19. package/dist/src/index.d.ts.map +1 -0
  20. package/dist/src/index.js +265 -0
  21. package/dist/src/index.js.map +1 -0
  22. package/dist/src/interfaces.d.ts +197 -0
  23. package/dist/src/interfaces.d.ts.map +1 -0
  24. package/dist/src/interfaces.js +2 -0
  25. package/dist/src/interfaces.js.map +1 -0
  26. package/dist/src/logger.d.ts +14 -0
  27. package/dist/src/logger.d.ts.map +1 -0
  28. package/dist/src/logger.js +44 -0
  29. package/dist/src/logger.js.map +1 -0
  30. package/dist/src/plugin-state.d.ts +20 -0
  31. package/dist/src/plugin-state.d.ts.map +1 -0
  32. package/dist/src/plugin-state.js +53 -0
  33. package/dist/src/plugin-state.js.map +1 -0
  34. package/dist/src/providers/antigravity/auth.d.ts +12 -0
  35. package/dist/src/providers/antigravity/auth.d.ts.map +1 -0
  36. package/dist/src/providers/antigravity/auth.js +109 -0
  37. package/dist/src/providers/antigravity/auth.js.map +1 -0
  38. package/dist/src/providers/antigravity/index.d.ts +2 -0
  39. package/dist/src/providers/antigravity/index.d.ts.map +1 -0
  40. package/dist/src/providers/antigravity/index.js +2 -0
  41. package/dist/src/providers/antigravity/index.js.map +1 -0
  42. package/dist/src/providers/antigravity/provider.d.ts +34 -0
  43. package/dist/src/providers/antigravity/provider.d.ts.map +1 -0
  44. package/dist/src/providers/antigravity/provider.js +216 -0
  45. package/dist/src/providers/antigravity/provider.js.map +1 -0
  46. package/dist/src/providers/codex.d.ts +4 -0
  47. package/dist/src/providers/codex.d.ts.map +1 -0
  48. package/dist/src/providers/codex.js +242 -0
  49. package/dist/src/providers/codex.js.map +1 -0
  50. package/dist/src/providers/github.d.ts +4 -0
  51. package/dist/src/providers/github.d.ts.map +1 -0
  52. package/dist/src/providers/github.js +139 -0
  53. package/dist/src/providers/github.js.map +1 -0
  54. package/dist/src/quota-cache.d.ts +26 -0
  55. package/dist/src/quota-cache.d.ts.map +1 -0
  56. package/dist/src/quota-cache.js +107 -0
  57. package/dist/src/quota-cache.js.map +1 -0
  58. package/dist/src/registry.d.ts +3 -0
  59. package/dist/src/registry.d.ts.map +1 -0
  60. package/dist/src/registry.js +23 -0
  61. package/dist/src/registry.js.map +1 -0
  62. package/dist/src/services/aggregation-service.d.ts +34 -0
  63. package/dist/src/services/aggregation-service.d.ts.map +1 -0
  64. package/dist/src/services/aggregation-service.js +89 -0
  65. package/dist/src/services/aggregation-service.js.map +1 -0
  66. package/dist/src/services/config-loader.d.ts +25 -0
  67. package/dist/src/services/config-loader.d.ts.map +1 -0
  68. package/dist/src/services/config-loader.js +105 -0
  69. package/dist/src/services/config-loader.js.map +1 -0
  70. package/dist/src/services/history-service.d.ts +15 -0
  71. package/dist/src/services/history-service.d.ts.map +1 -0
  72. package/dist/src/services/history-service.js +99 -0
  73. package/dist/src/services/history-service.js.map +1 -0
  74. package/dist/src/services/prediction-engine.d.ts +47 -0
  75. package/dist/src/services/prediction-engine.d.ts.map +1 -0
  76. package/dist/src/services/prediction-engine.js +94 -0
  77. package/dist/src/services/prediction-engine.js.map +1 -0
  78. package/dist/src/services/quota-service.d.ts +41 -0
  79. package/dist/src/services/quota-service.d.ts.map +1 -0
  80. package/dist/src/services/quota-service.js +257 -0
  81. package/dist/src/services/quota-service.js.map +1 -0
  82. package/dist/src/tools/quotas.d.ts +11 -0
  83. package/dist/src/tools/quotas.d.ts.map +1 -0
  84. package/dist/src/tools/quotas.js +62 -0
  85. package/dist/src/tools/quotas.js.map +1 -0
  86. package/dist/src/ui/progress-bar.d.ts +20 -0
  87. package/dist/src/ui/progress-bar.d.ts.map +1 -0
  88. package/dist/src/ui/progress-bar.js +150 -0
  89. package/dist/src/ui/progress-bar.js.map +1 -0
  90. package/dist/src/ui/quota-table.d.ts +15 -0
  91. package/dist/src/ui/quota-table.d.ts.map +1 -0
  92. package/dist/src/ui/quota-table.js +136 -0
  93. package/dist/src/ui/quota-table.js.map +1 -0
  94. package/dist/src/utils/debug.d.ts +1 -0
  95. package/dist/src/utils/debug.d.ts.map +1 -0
  96. package/dist/src/utils/debug.js +3 -0
  97. package/dist/src/utils/debug.js.map +1 -0
  98. package/dist/src/utils/paths.d.ts +7 -0
  99. package/dist/src/utils/paths.d.ts.map +1 -0
  100. package/dist/src/utils/paths.js +37 -0
  101. package/dist/src/utils/paths.js.map +1 -0
  102. package/dist/src/utils/time.d.ts +3 -0
  103. package/dist/src/utils/time.d.ts.map +1 -0
  104. package/dist/src/utils/time.js +38 -0
  105. package/dist/src/utils/time.js.map +1 -0
  106. package/dist/src/utils/validation.d.ts +6 -0
  107. package/dist/src/utils/validation.d.ts.map +1 -0
  108. package/dist/src/utils/validation.js +66 -0
  109. package/dist/src/utils/validation.js.map +1 -0
  110. package/package.json +42 -0
  111. package/src/cli.ts +53 -0
  112. package/src/constants.ts +17 -0
  113. package/src/defaults.ts +53 -0
  114. package/src/index.ts +338 -0
  115. package/src/interfaces.ts +258 -0
  116. package/src/logger.ts +55 -0
  117. package/src/plugin-state.ts +65 -0
  118. package/src/providers/antigravity/auth.ts +163 -0
  119. package/src/providers/antigravity/index.ts +1 -0
  120. package/src/providers/antigravity/provider.ts +337 -0
  121. package/src/providers/codex.ts +327 -0
  122. package/src/providers/github.ts +161 -0
  123. package/src/quota-cache.ts +157 -0
  124. package/src/registry.ts +30 -0
  125. package/src/services/aggregation-service.ts +116 -0
  126. package/src/services/config-loader.ts +124 -0
  127. package/src/services/history-service.ts +116 -0
  128. package/src/services/prediction-engine.ts +133 -0
  129. package/src/services/quota-service.ts +343 -0
  130. package/src/tools/quotas.ts +78 -0
  131. package/src/ui/progress-bar.ts +204 -0
  132. package/src/ui/quota-table.ts +173 -0
  133. package/src/utils/debug.ts +1 -0
  134. package/src/utils/paths.ts +41 -0
  135. package/src/utils/time.ts +40 -0
  136. package/src/utils/validation.ts +63 -0
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "opencode-quotas",
3
+ "version": "0.0.1",
4
+ "description": "Opencode quota hub plugin for Antigravity and Codex",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "type": "module",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "packageManager": "bun@1.1.20",
15
+ "bin": {
16
+ "opencode-quotas": "./dist/src/cli.js"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "src",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "scripts": {
25
+ "opencode-quotas": "bun src/cli.ts",
26
+ "build": "tsc -p tsconfig.build.json",
27
+ "typecheck": "tsc --noEmit"
28
+ },
29
+ "engines": {
30
+ "node": ">=20"
31
+ },
32
+ "dependencies": {
33
+ "@ai-sdk/provider": "^3.0.2",
34
+ "@opencode-ai/plugin": "^1.1.3"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^22.0.0",
38
+ "js-yaml": "^4.1.1",
39
+ "typescript": "^5.9.3",
40
+ "yaml": "^2.8.2"
41
+ }
42
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env bun
2
+ import { QuotaService } from "./services/quota-service";
3
+ import { HistoryService } from "./services/history-service";
4
+ import { renderQuotaTable } from "./ui/quota-table";
5
+
6
+ async function main() {
7
+ if (process.argv.includes("--no-color")) {
8
+ process.env.OPENCODE_QUOTAS_NO_COLOR = "1";
9
+ }
10
+
11
+ const historyService = new HistoryService();
12
+ await historyService.init();
13
+
14
+ const quotaService = new QuotaService();
15
+ await quotaService.init(process.cwd(), historyService);
16
+
17
+ const config = quotaService.getConfig();
18
+
19
+ // Parse arguments for provider and model filtering
20
+ let providerId: string | undefined;
21
+ let modelId: string | undefined;
22
+
23
+ const providerIdx = process.argv.indexOf("--provider");
24
+ if (providerIdx !== -1 && providerIdx + 1 < process.argv.length) {
25
+ providerId = process.argv[providerIdx + 1];
26
+ }
27
+
28
+ const modelIdx = process.argv.indexOf("--model");
29
+ if (modelIdx !== -1 && modelIdx + 1 < process.argv.length) {
30
+ modelId = process.argv[modelIdx + 1];
31
+ }
32
+
33
+ const filteredResults = await quotaService.getQuotas({ providerId, modelId });
34
+
35
+ if (filteredResults.length === 0) {
36
+ console.log("No active quotas found.");
37
+ return;
38
+ }
39
+
40
+ console.log(""); // Empty line
41
+ console.log("📊 OpenCode Quotas");
42
+ console.log("------------------");
43
+
44
+ renderQuotaTable(filteredResults, {
45
+ progressBarConfig: config.progressBar,
46
+ tableConfig: config.table,
47
+ }).forEach((row) => {
48
+ console.log(row.line);
49
+ });
50
+ console.log(""); // Empty line
51
+ }
52
+
53
+ main().catch(console.error);
@@ -0,0 +1,17 @@
1
+ export const PLUGIN_FOOTER_SIGNATURE = "_Opencode Quotas";
2
+
3
+ // Heuristic patterns for detecting reasoning/thinking blocks
4
+ export const REASONING_PATTERNS = [
5
+ /^<thinking>/i,
6
+ /^<antThinking>/i,
7
+ /^(Thinking|Reasoning|Analysis):\s*(\n|$)/i
8
+ ];
9
+
10
+ // File paths
11
+ export const DEBUG_LOG_FILENAME = "quotas-debug.log";
12
+
13
+ export const SKIP_REASONS = {
14
+ REASONING: "skip:reasoning",
15
+ SUBAGENT: "skip:subagent",
16
+ FOOTER_PRESENT: "skip:footer_present",
17
+ };
@@ -0,0 +1,53 @@
1
+ import { type QuotaConfig } from "./interfaces";
2
+
3
+ // Default configuration for quota rendering and grouping
4
+ export const DEFAULT_CONFIG: QuotaConfig = {
5
+ displayMode: "simple",
6
+ footer: true,
7
+ showFooterTitle: true,
8
+ debug: false,
9
+ progressBar: {
10
+ color: false,
11
+ gradients: [
12
+ { threshold: 0.5, color: "green" },
13
+ { threshold: 0.8, color: "yellow" },
14
+ { threshold: 1.0, color: "red" },
15
+ ],
16
+ },
17
+ table: {
18
+ header: true,
19
+ },
20
+ filterByCurrentModel: false,
21
+ showUnaggregated: false,
22
+ aggregatedGroups: [
23
+ {
24
+ id: "ag-flash",
25
+ name: "Antigravity Flash",
26
+ patterns: ["flash"],
27
+ providerId: "antigravity",
28
+ strategy: "most_critical",
29
+ },
30
+ {
31
+ id: "ag-pro",
32
+ name: "Antigravity Pro",
33
+ patterns: ["pro", "gemini"],
34
+ providerId: "antigravity",
35
+ strategy: "most_critical",
36
+ },
37
+ {
38
+ id: "ag-premium",
39
+ name: "Antigravity Premium",
40
+ patterns: ["claude", "gpt", "o1"],
41
+ providerId: "antigravity",
42
+ strategy: "most_critical",
43
+ },
44
+ {
45
+ id: "codex-smart",
46
+ name: "Codex Usage",
47
+ sources: ["codex-primary", "codex-secondary"],
48
+ strategy: "most_critical",
49
+ },
50
+ ],
51
+ historyMaxAgeHours: 24,
52
+ pollingInterval: 60_000,
53
+ };
package/src/index.ts ADDED
@@ -0,0 +1,338 @@
1
+ import { type Plugin, type Hooks } from "@opencode-ai/plugin";
2
+ import { type AssistantMessage, type UserMessage } from "@opencode-ai/sdk";
3
+ import { QuotaService } from "./services/quota-service";
4
+ import { HistoryService } from "./services/history-service";
5
+ import { renderQuotaTable } from "./ui/quota-table";
6
+ import { type QuotaData } from "./interfaces";
7
+ import { QuotaCache } from "./quota-cache";
8
+ import {
9
+ PLUGIN_FOOTER_SIGNATURE,
10
+ REASONING_PATTERNS,
11
+ SKIP_REASONS,
12
+ } from "./constants";
13
+ import { logger } from "./logger";
14
+ import { getPluginState } from "./plugin-state";
15
+ import { createQuotaTool } from "./tools/quotas";
16
+
17
+ /**
18
+ * Extended message type with additional fields that may be present at runtime.
19
+ * Uses Omit to override required fields that we know may be optional.
20
+ */
21
+ type ExtendedAssistantMessage = Omit<AssistantMessage, "parentID"> & {
22
+ type?: string;
23
+ mode?: string;
24
+ parentID?: string;
25
+ modelID?: string;
26
+ providerID?: string;
27
+ tokens?: {
28
+ input?: number;
29
+ output?: number;
30
+ reasoning?: number;
31
+ cache?: { read?: number; write?: number };
32
+ };
33
+ };
34
+
35
+ /**
36
+ * QuotaHub Plugin for OpenCode.ai
37
+ */
38
+ export const QuotaHubPlugin: Plugin = async ({ client, $, directory }) => {
39
+ const state = getPluginState();
40
+ const historyService = new HistoryService();
41
+ const quotaService = new QuotaService();
42
+
43
+ let quotaCache: QuotaCache | undefined;
44
+ let initPromise: Promise<void> | undefined;
45
+
46
+ // Dedicated initialization function
47
+ const ensureInit = async (): Promise<void> => {
48
+ if (initPromise) return initPromise;
49
+
50
+ initPromise = (async () => {
51
+ try {
52
+ await historyService.init();
53
+ await quotaService.init(directory, historyService);
54
+
55
+ const config = quotaService.getConfig();
56
+ const providers = quotaService.getProviders();
57
+ logger.debug("init:providers", {
58
+ ids: providers.map((p) => p.id),
59
+ count: providers.length,
60
+ });
61
+ quotaCache = new QuotaCache(providers, {
62
+ refreshIntervalMs: config.pollingInterval ?? 60_000,
63
+ historyService,
64
+ debug: !!config.debug,
65
+ });
66
+ quotaCache.start();
67
+ logger.debug("init:complete");
68
+ } catch (e) {
69
+ console.error("Failed to initialize QuotaHubPlugin:", e);
70
+ // Keep the promise but it failed. Future calls will see it as failed.
71
+ throw e;
72
+ }
73
+ })();
74
+
75
+ return initPromise;
76
+ };
77
+
78
+ // Trigger background initialization
79
+ ensureInit().catch(() => {});
80
+
81
+ const hooks: Hooks = {
82
+ /**
83
+ * The platform calls this hook after a text generation is complete.
84
+ * We use it to append quota information to the end of the final assistant message.
85
+ */
86
+ "experimental.text.complete": async (
87
+ input: {
88
+ sessionID: string;
89
+ messageID: string;
90
+ partID: string;
91
+ },
92
+ output: {
93
+ text: string;
94
+ },
95
+ ): Promise<void> => {
96
+ // Ensure initialization is complete before processing
97
+ await ensureInit().catch((e) => {
98
+ logger.error("init:error", { error: e });
99
+ });
100
+ if (!quotaCache) {
101
+ const config = quotaService.getConfig();
102
+ if (config.debug)
103
+ logger.debug("hook:no_cache", {
104
+ messageID: input.messageID,
105
+ });
106
+ return;
107
+ }
108
+
109
+ const cache = quotaCache;
110
+ const config = quotaService.getConfig();
111
+ const debugLog = (msg: string, data?: any) => {
112
+ if (config.debug) logger.debug(msg, data);
113
+ };
114
+
115
+ if (config.footer === false) {
116
+ debugLog("skip:footer_disabled", {
117
+ messageID: input.messageID,
118
+ });
119
+ return;
120
+ }
121
+
122
+ // Log hook invocation
123
+ debugLog("hook:experimental.text.complete", {
124
+ input,
125
+ processed: state.isProcessed(input.messageID),
126
+ });
127
+
128
+ // Fast path check
129
+ if (state.isProcessed(input.messageID)) {
130
+ debugLog("skip:already_processed", {
131
+ messageID: input.messageID,
132
+ });
133
+ return;
134
+ }
135
+
136
+ // Secondary safeguard: check if footer already present
137
+ if (output.text.includes(PLUGIN_FOOTER_SIGNATURE)) {
138
+ debugLog(SKIP_REASONS.FOOTER_PRESENT, {
139
+ messageID: input.messageID,
140
+ });
141
+ state.markProcessed(input.messageID);
142
+ return;
143
+ }
144
+
145
+ debugLog("lock:acquire_start", { messageID: input.messageID });
146
+ // Acquire lock for this message
147
+ const release = await state.acquireLock(input.messageID);
148
+ debugLog("lock:acquired", { messageID: input.messageID });
149
+
150
+ try {
151
+ // After acquiring lock, re-check if processed
152
+ if (state.isProcessed(input.messageID)) {
153
+ debugLog("skip:already_processed_after_lock", {
154
+ messageID: input.messageID,
155
+ });
156
+ return;
157
+ }
158
+
159
+ // Double-check text content in case another process injected it while we waited for lock
160
+ if (output.text.includes(PLUGIN_FOOTER_SIGNATURE)) {
161
+ debugLog("skip:footer_present_after_lock", {
162
+ messageID: input.messageID,
163
+ });
164
+ state.markProcessed(input.messageID);
165
+ return;
166
+ }
167
+
168
+ // Fetch message to check role
169
+ const { data: result } = await client.session.message({
170
+ path: {
171
+ id: input.sessionID,
172
+ messageID: input.messageID,
173
+ },
174
+ });
175
+
176
+ if (!result || result.info.role !== "assistant") {
177
+ debugLog("skip:not_assistant", {
178
+ messageID: input.messageID,
179
+ role: result?.info?.role,
180
+ });
181
+ state.markProcessed(input.messageID);
182
+ return;
183
+ }
184
+
185
+ const assistantMsg = result.info as ExtendedAssistantMessage;
186
+
187
+ // Mark as processed as soon as we've identified it's an assistant message
188
+ // We do this before the quota check to avoid race conditions if no quotas are found.
189
+ state.markProcessed(input.messageID);
190
+
191
+ // Log message details
192
+ debugLog("message:details", {
193
+ id: input.messageID,
194
+ mode: assistantMsg.mode,
195
+ tokens: assistantMsg.tokens,
196
+ type: assistantMsg.type,
197
+ modelID: assistantMsg.modelID,
198
+ providerID: assistantMsg.providerID,
199
+ });
200
+
201
+ // Skip if it's a subagent mode (thinking step), unless it's a whitelisted agent (plan/build)
202
+ if (assistantMsg.mode === "subagent") {
203
+ let allowed = false;
204
+ if (assistantMsg.parentID) {
205
+ try {
206
+ const { data: parentResult } =
207
+ await client.session.message({
208
+ path: {
209
+ id: input.sessionID,
210
+ messageID: assistantMsg.parentID,
211
+ },
212
+ });
213
+
214
+ if (parentResult?.info?.role === "user") {
215
+ const userMsg =
216
+ parentResult.info as UserMessage;
217
+ // Allow plan and build agents even in subagent mode
218
+ if (["plan", "build"].includes(userMsg.agent)) {
219
+ allowed = true;
220
+ debugLog("allow:subagent_exception", {
221
+ agent: userMsg.agent,
222
+ });
223
+ }
224
+ }
225
+ } catch (e) {
226
+ debugLog("error:check_parent_agent", e);
227
+ }
228
+ }
229
+
230
+ if (!allowed) {
231
+ debugLog(SKIP_REASONS.SUBAGENT);
232
+ return;
233
+ }
234
+ }
235
+
236
+ // Skip reasoning messages (explicit mode or type)
237
+ if (
238
+ assistantMsg.mode === "reasoning" ||
239
+ assistantMsg.type === "reasoning"
240
+ ) {
241
+ debugLog(SKIP_REASONS.REASONING);
242
+ return;
243
+ }
244
+
245
+ // Skip if it appears to be a reasoning-only message based on tokens
246
+ const reasoningTokens = assistantMsg.tokens?.reasoning ?? 0;
247
+ const outputTokens = assistantMsg.tokens?.output ?? 0;
248
+ if (
249
+ assistantMsg.tokens &&
250
+ reasoningTokens > 0 &&
251
+ (outputTokens === 0 || outputTokens === reasoningTokens)
252
+ ) {
253
+ debugLog(SKIP_REASONS.REASONING, assistantMsg.tokens);
254
+ return;
255
+ }
256
+
257
+ // Heuristic: Check if text starts with "Thinking:" or similar
258
+ const trimmedText = output.text.trim();
259
+ for (const pattern of REASONING_PATTERNS) {
260
+ if (pattern.test(trimmedText)) {
261
+ debugLog(SKIP_REASONS.REASONING, {
262
+ pattern: pattern.toString(),
263
+ });
264
+ return;
265
+ }
266
+ }
267
+
268
+ const snapshot = cache.getSnapshot();
269
+ const rawResults: QuotaData[] = snapshot.data;
270
+ debugLog("cache:snapshot", {
271
+ fetchedAt: snapshot.fetchedAt?.toISOString(),
272
+ totalCount: rawResults.length,
273
+ hasError: !!snapshot.lastError,
274
+ });
275
+ if (rawResults.length === 0) {
276
+ debugLog("skip:no_cached_quotas", {
277
+ fetchedAt: snapshot.fetchedAt?.toISOString(),
278
+ lastError: snapshot.lastError,
279
+ });
280
+ return;
281
+ }
282
+
283
+ // Process (filter, sort) using the shared service
284
+ const filteredResults = quotaService.processQuotas(rawResults, {
285
+ providerId: assistantMsg.providerID,
286
+ modelId: assistantMsg.modelID,
287
+ });
288
+
289
+ debugLog("quotas:processed", {
290
+ before: rawResults.length,
291
+ after: filteredResults.length,
292
+ providerId: assistantMsg.providerID,
293
+ modelId: assistantMsg.modelID,
294
+ });
295
+
296
+ if (filteredResults.length === 0) {
297
+ debugLog("skip:all_quotas_filtered", {
298
+ providerId: assistantMsg.providerID,
299
+ modelId: assistantMsg.modelID,
300
+ });
301
+ return;
302
+ }
303
+
304
+ const lines = renderQuotaTable(filteredResults, {
305
+ progressBarConfig: config.progressBar,
306
+ tableConfig: config.table,
307
+ }).map((l) => l.line);
308
+
309
+ // Append to message text
310
+ const showMode = config.progressBar?.show ?? "used";
311
+ const modeLabel =
312
+ showMode === "available" ? "(Remaining)" : "(Used)";
313
+ // Build visible header only if enabled in config
314
+ const showTitle = config.showFooterTitle !== false;
315
+ const titleText = showTitle
316
+ ? `${PLUGIN_FOOTER_SIGNATURE} ${modeLabel}_\n`
317
+ : "";
318
+
319
+ // Append table lines (no invisible marker)
320
+ output.text += "\n\n" + titleText + lines.join("\n");
321
+ debugLog("inject:footer", {
322
+ messageID: input.messageID,
323
+ lines: lines.length,
324
+ });
325
+ } finally {
326
+ debugLog("lock:release", { messageID: input.messageID });
327
+ release();
328
+ }
329
+ },
330
+ };
331
+
332
+ // Create the quota tool that can be called by the LLM
333
+ const quotaTool = createQuotaTool(quotaService, () =>
334
+ quotaService.getConfig(),
335
+ );
336
+
337
+ return hooks;
338
+ };