@wrongstack/plugins 0.277.1 → 0.280.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 (72) hide show
  1. package/README.md +838 -0
  2. package/dist/auto-doc.d.ts +8 -0
  3. package/dist/auto-doc.js +175 -13
  4. package/dist/auto-escalate.d.ts +45 -0
  5. package/dist/auto-escalate.js +190 -0
  6. package/dist/branch-guard.d.ts +33 -0
  7. package/dist/branch-guard.js +228 -0
  8. package/dist/changelog-writer.d.ts +73 -0
  9. package/dist/changelog-writer.js +369 -0
  10. package/dist/checkpoint.d.ts +55 -0
  11. package/dist/checkpoint.js +305 -0
  12. package/dist/commit-validator.d.ts +33 -0
  13. package/dist/commit-validator.js +315 -0
  14. package/dist/config-validator.d.ts +48 -0
  15. package/dist/config-validator.js +347 -0
  16. package/dist/context-pins.d.ts +45 -0
  17. package/dist/context-pins.js +240 -0
  18. package/dist/cost-tracker.d.ts +40 -1
  19. package/dist/cost-tracker.js +105 -4
  20. package/dist/dep-guard.d.ts +65 -0
  21. package/dist/dep-guard.js +316 -0
  22. package/dist/diff-summary.d.ts +36 -0
  23. package/dist/diff-summary.js +235 -0
  24. package/dist/error-lens.d.ts +67 -0
  25. package/dist/error-lens.js +280 -0
  26. package/dist/format-on-save.d.ts +35 -0
  27. package/dist/format-on-save.js +219 -0
  28. package/dist/git-autocommit.js +186 -26
  29. package/dist/import-organizer.d.ts +52 -0
  30. package/dist/import-organizer.js +274 -0
  31. package/dist/index.d.ts +32 -6
  32. package/dist/index.js +10151 -1628
  33. package/dist/injection-shield.d.ts +49 -0
  34. package/dist/injection-shield.js +205 -0
  35. package/dist/lint-gate.d.ts +33 -0
  36. package/dist/lint-gate.js +394 -0
  37. package/dist/llm-cache.d.ts +56 -0
  38. package/dist/llm-cache.js +251 -0
  39. package/dist/loop-breaker.d.ts +43 -0
  40. package/dist/loop-breaker.js +241 -0
  41. package/dist/model-router.d.ts +69 -0
  42. package/dist/model-router.js +198 -0
  43. package/dist/notify-hub.d.ts +45 -0
  44. package/dist/notify-hub.js +304 -0
  45. package/dist/path-guard.d.ts +54 -0
  46. package/dist/path-guard.js +235 -0
  47. package/dist/prompt-firewall.d.ts +57 -0
  48. package/dist/prompt-firewall.js +290 -0
  49. package/dist/secret-scanner.d.ts +34 -0
  50. package/dist/secret-scanner.js +409 -0
  51. package/dist/semver-bump.js +45 -0
  52. package/dist/session-recap.d.ts +50 -0
  53. package/dist/session-recap.js +421 -0
  54. package/dist/shell-check.js +52 -4
  55. package/dist/spec-linker.d.ts +51 -0
  56. package/dist/spec-linker.js +541 -0
  57. package/dist/template-engine.js +19 -1
  58. package/dist/test-runner-gate.d.ts +37 -0
  59. package/dist/test-runner-gate.js +356 -0
  60. package/dist/todo-listener.d.ts +37 -0
  61. package/dist/todo-listener.js +216 -0
  62. package/dist/todo-tracker.d.ts +5 -0
  63. package/dist/todo-tracker.js +441 -0
  64. package/dist/token-budget.d.ts +40 -0
  65. package/dist/token-budget.js +254 -0
  66. package/dist/token-throttle.d.ts +54 -0
  67. package/dist/token-throttle.js +203 -0
  68. package/package.json +116 -12
  69. package/dist/json-path.d.ts +0 -18
  70. package/dist/json-path.js +0 -15
  71. package/dist/web-search.d.ts +0 -19
  72. package/dist/web-search.js +0 -15
@@ -0,0 +1,347 @@
1
+ import { statSync, readFileSync } from 'fs';
2
+
3
+ // src/config-validator/index.ts
4
+ var state = {
5
+ invocations: 0,
6
+ filesChecked: 0,
7
+ problemsFound: 0,
8
+ lastProblem: null,
9
+ hookUnregister: null
10
+ };
11
+ var DEFAULT_EXTENSIONS = [".json", ".jsonc", ".yaml", ".yml", ".toml"];
12
+ var DEFAULTS = {
13
+ enabled: true,
14
+ extensions: DEFAULT_EXTENSIONS,
15
+ maxFileBytes: 1048576
16
+ };
17
+ function readConfig(raw) {
18
+ if (!raw || typeof raw !== "object") return { ...DEFAULTS, extensions: [...DEFAULT_EXTENSIONS] };
19
+ const r = raw;
20
+ return {
21
+ enabled: r["enabled"] !== false,
22
+ extensions: Array.isArray(r["extensions"]) ? r["extensions"].filter((e) => typeof e === "string" && e.startsWith(".")).map((e) => e.toLowerCase()) : [...DEFAULT_EXTENSIONS],
23
+ maxFileBytes: typeof r["maxFileBytes"] === "number" && r["maxFileBytes"] >= 1024 ? r["maxFileBytes"] : DEFAULTS.maxFileBytes
24
+ };
25
+ }
26
+ function stripJsonc(text) {
27
+ let out = "";
28
+ let inString = false;
29
+ let inLine = false;
30
+ let inBlock = false;
31
+ for (let i = 0; i < text.length; i++) {
32
+ const ch = text[i];
33
+ const next = text[i + 1];
34
+ if (inLine) {
35
+ if (ch === "\n") {
36
+ inLine = false;
37
+ out += ch;
38
+ }
39
+ continue;
40
+ }
41
+ if (inBlock) {
42
+ if (ch === "*" && next === "/") {
43
+ inBlock = false;
44
+ i += 1;
45
+ }
46
+ continue;
47
+ }
48
+ if (inString) {
49
+ out += ch;
50
+ if (ch === "\\") {
51
+ out += next ?? "";
52
+ i += 1;
53
+ } else if (ch === '"') {
54
+ inString = false;
55
+ }
56
+ continue;
57
+ }
58
+ if (ch === '"') {
59
+ inString = true;
60
+ out += ch;
61
+ continue;
62
+ }
63
+ if (ch === "/" && next === "/") {
64
+ inLine = true;
65
+ i += 1;
66
+ continue;
67
+ }
68
+ if (ch === "/" && next === "*") {
69
+ inBlock = true;
70
+ i += 1;
71
+ continue;
72
+ }
73
+ out += ch;
74
+ }
75
+ return out.replace(/,\s*([}\]])/g, "$1");
76
+ }
77
+ function positionToLineCol(text, pos) {
78
+ const upTo = text.slice(0, pos);
79
+ const line = upTo.split("\n").length;
80
+ const col = pos - upTo.lastIndexOf("\n");
81
+ return { line, col };
82
+ }
83
+ function validateJson(text, isJsonc, fileName) {
84
+ const source = isJsonc ? stripJsonc(text) : text;
85
+ try {
86
+ const parsed = JSON.parse(source);
87
+ if (/(^|[/\\])package\.json$/i.test(fileName) && parsed && typeof parsed === "object") {
88
+ const p = parsed;
89
+ const problems = [];
90
+ if (typeof p["name"] !== "string" || !p["name"]) {
91
+ problems.push('package.json: "name" is missing or not a string');
92
+ }
93
+ if ("version" in p && typeof p["version"] !== "string") {
94
+ problems.push('package.json: "version" is not a string');
95
+ }
96
+ for (const key of ["dependencies", "devDependencies", "peerDependencies"]) {
97
+ if (key in p && (typeof p[key] !== "object" || p[key] === null || Array.isArray(p[key]))) {
98
+ problems.push(`package.json: "${key}" must be an object`);
99
+ }
100
+ }
101
+ return problems;
102
+ }
103
+ return [];
104
+ } catch (err) {
105
+ const message = err instanceof Error ? err.message : String(err);
106
+ const posMatch = /position (\d+)/.exec(message);
107
+ if (posMatch?.[1]) {
108
+ const { line, col } = positionToLineCol(source, Number(posMatch[1]));
109
+ return [`JSON parse error at line ${line}, column ${col}: ${message}`];
110
+ }
111
+ const snippetMatch = /(?:\.\.\.)?"([\s\S]{4,120})" is not valid JSON/.exec(message);
112
+ if (snippetMatch?.[1]) {
113
+ const idx = source.indexOf(snippetMatch[1]);
114
+ if (idx >= 0) {
115
+ const { line } = positionToLineCol(source, idx);
116
+ return [`JSON parse error near line ${line}: ${message.split("\n")[0]}`];
117
+ }
118
+ }
119
+ return [`JSON parse error: ${message.split("\n")[0]}`];
120
+ }
121
+ }
122
+ function validateYaml(text) {
123
+ const problems = [];
124
+ const lines = text.split("\n");
125
+ const stack = [];
126
+ for (let i = 0; i < lines.length; i++) {
127
+ const line = lines[i];
128
+ const lineNo = i + 1;
129
+ if (/^\s*(#|$)/.test(line)) continue;
130
+ const indent = /^([ \t]*)/.exec(line)?.[1] ?? "";
131
+ if (indent.includes(" ")) {
132
+ problems.push(`YAML: tab character in indentation at line ${lineNo} (YAML requires spaces)`);
133
+ continue;
134
+ }
135
+ if (/^---\s*$/.test(line.trim())) {
136
+ stack.length = 0;
137
+ continue;
138
+ }
139
+ const level = indent.length;
140
+ if (line.trim().startsWith("-")) {
141
+ while (stack.length > 0 && stack[stack.length - 1].level > level) {
142
+ stack.pop();
143
+ }
144
+ continue;
145
+ }
146
+ const keyMatch = /^ *([^\s#][^:]*?):(?:\s|$)/.exec(line);
147
+ if (keyMatch) {
148
+ const key = (keyMatch[1] ?? "").trim();
149
+ while (stack.length > 0 && stack[stack.length - 1].level > level) {
150
+ stack.pop();
151
+ }
152
+ let top = stack[stack.length - 1];
153
+ if (!top || top.level < level) {
154
+ top = { level, keys: /* @__PURE__ */ new Set() };
155
+ stack.push(top);
156
+ }
157
+ if (top.keys.has(key)) {
158
+ problems.push(`YAML: duplicate key "${key}" at line ${lineNo}`);
159
+ }
160
+ top.keys.add(key);
161
+ }
162
+ const doubleQuotes = (line.match(/(?<!\\)"/g) ?? []).length;
163
+ if (doubleQuotes % 2 === 1) {
164
+ problems.push(`YAML: possibly unclosed double quote at line ${lineNo}`);
165
+ }
166
+ }
167
+ return problems;
168
+ }
169
+ function validateToml(text) {
170
+ const problems = [];
171
+ const lines = text.split("\n");
172
+ const tables = /* @__PURE__ */ new Set();
173
+ const keysInTable = /* @__PURE__ */ new Map();
174
+ let currentTable = "";
175
+ for (let i = 0; i < lines.length; i++) {
176
+ const line = lines[i].trim();
177
+ const lineNo = i + 1;
178
+ if (line === "" || line.startsWith("#")) continue;
179
+ const tableMatch = /^\[\[?([^\]]+)\]\]?$/.exec(line);
180
+ if (tableMatch) {
181
+ const isArrayTable = line.startsWith("[[");
182
+ currentTable = (tableMatch[1] ?? "").trim();
183
+ if (isArrayTable) {
184
+ keysInTable.set(currentTable, /* @__PURE__ */ new Set());
185
+ } else {
186
+ if (tables.has(currentTable)) {
187
+ problems.push(`TOML: duplicate table [${currentTable}] at line ${lineNo}`);
188
+ }
189
+ tables.add(currentTable);
190
+ }
191
+ continue;
192
+ }
193
+ const keyMatch = /^([A-Za-z0-9_."'-]+)\s*=/.exec(line);
194
+ if (keyMatch?.[1]) {
195
+ const key = keyMatch[1];
196
+ const seen = keysInTable.get(currentTable) ?? /* @__PURE__ */ new Set();
197
+ if (seen.has(key)) {
198
+ problems.push(
199
+ `TOML: duplicate key "${key}" in [${currentTable || "root"}] at line ${lineNo}`
200
+ );
201
+ }
202
+ seen.add(key);
203
+ keysInTable.set(currentTable, seen);
204
+ }
205
+ }
206
+ return problems;
207
+ }
208
+ function validateFile(path, text) {
209
+ const lower = path.toLowerCase();
210
+ if (lower.endsWith(".json")) return validateJson(text, false, path);
211
+ if (lower.endsWith(".jsonc")) return validateJson(text, true, path);
212
+ if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return validateYaml(text);
213
+ if (lower.endsWith(".toml")) return validateToml(text);
214
+ return [];
215
+ }
216
+ var plugin = {
217
+ name: "config-validator",
218
+ version: "0.1.0",
219
+ description: "Validates JSON/JSONC/YAML/TOML files right after write/edit and reports syntax problems in the same turn",
220
+ apiVersion: "^0.1.10",
221
+ capabilities: { tools: true, hooks: true },
222
+ defaultConfig: { ...DEFAULTS },
223
+ configSchema: {
224
+ type: "object",
225
+ properties: {
226
+ enabled: { type: "boolean", default: true, description: "Master switch." },
227
+ extensions: {
228
+ type: "array",
229
+ items: { type: "string" },
230
+ default: DEFAULT_EXTENSIONS,
231
+ description: "File extensions to validate (with leading dot)."
232
+ },
233
+ maxFileBytes: {
234
+ type: "number",
235
+ minimum: 1024,
236
+ default: 1048576,
237
+ description: "Files larger than this are skipped."
238
+ }
239
+ }
240
+ },
241
+ setup(api) {
242
+ state.invocations = 0;
243
+ state.filesChecked = 0;
244
+ state.problemsFound = 0;
245
+ state.lastProblem = null;
246
+ if (state.hookUnregister) {
247
+ try {
248
+ state.hookUnregister();
249
+ } catch {
250
+ }
251
+ state.hookUnregister = null;
252
+ }
253
+ const cfg = readConfig(api.config.extensions?.["config-validator"]);
254
+ const hook = (input) => {
255
+ if (!cfg.enabled) return;
256
+ state.invocations += 1;
257
+ const ti = input.toolInput ?? {};
258
+ const raw = ti["path"] ?? ti["file_path"] ?? ti["filePath"];
259
+ if (typeof raw !== "string" || raw.length === 0) return;
260
+ const lower = raw.toLowerCase();
261
+ if (!cfg.extensions.some((ext) => lower.endsWith(ext))) return;
262
+ let text;
263
+ try {
264
+ if (statSync(raw).size > cfg.maxFileBytes) return;
265
+ text = readFileSync(raw, "utf-8");
266
+ } catch {
267
+ return;
268
+ }
269
+ state.filesChecked += 1;
270
+ api.metrics.counter("files_checked");
271
+ const problems = validateFile(raw, text);
272
+ if (problems.length === 0) return;
273
+ state.problemsFound += problems.length;
274
+ state.lastProblem = {
275
+ path: raw,
276
+ problem: problems[0],
277
+ when: (/* @__PURE__ */ new Date()).toISOString()
278
+ };
279
+ api.metrics.counter("problems", problems.length);
280
+ return {
281
+ additionalContext: `config-validator: "${raw}" has ${problems.length} problem(s) after this ${input.toolName ?? "edit"}:
282
+ ` + problems.slice(0, 10).map((p) => ` - ${p}`).join("\n") + (problems.length > 10 ? `
283
+ \u2026 and ${problems.length - 10} more` : "") + "\nFix these before moving on \u2014 downstream tools will fail on this file."
284
+ };
285
+ };
286
+ state.hookUnregister = api.registerHook("PostToolUse", "write|edit", hook);
287
+ api.tools.register({
288
+ name: "config_validator_status",
289
+ description: "Reports config-validator state: watched extensions and counters (files checked, problems found).",
290
+ inputSchema: { type: "object", properties: {} },
291
+ permission: "auto",
292
+ category: "Diagnostics",
293
+ mutating: false,
294
+ async execute() {
295
+ return {
296
+ ok: true,
297
+ enabled: cfg.enabled,
298
+ extensions: cfg.extensions,
299
+ counters: {
300
+ invocations: state.invocations,
301
+ filesChecked: state.filesChecked,
302
+ problemsFound: state.problemsFound
303
+ },
304
+ lastProblem: state.lastProblem
305
+ };
306
+ }
307
+ });
308
+ api.log.info("config-validator plugin loaded", {
309
+ version: "0.1.0",
310
+ enabled: cfg.enabled,
311
+ extensions: cfg.extensions
312
+ });
313
+ },
314
+ teardown(api) {
315
+ if (state.hookUnregister) {
316
+ try {
317
+ state.hookUnregister();
318
+ } catch {
319
+ }
320
+ state.hookUnregister = null;
321
+ }
322
+ const final = {
323
+ invocations: state.invocations,
324
+ filesChecked: state.filesChecked,
325
+ problemsFound: state.problemsFound
326
+ };
327
+ state.invocations = 0;
328
+ state.filesChecked = 0;
329
+ state.problemsFound = 0;
330
+ state.lastProblem = null;
331
+ api.log.info("config-validator: teardown complete", { final });
332
+ },
333
+ async health() {
334
+ return {
335
+ ok: true,
336
+ message: `config-validator: ${state.filesChecked} file(s) checked, ${state.problemsFound} problem(s) found`,
337
+ counters: {
338
+ invocations: state.invocations,
339
+ filesChecked: state.filesChecked,
340
+ problemsFound: state.problemsFound
341
+ }
342
+ };
343
+ }
344
+ };
345
+ var config_validator_default = plugin;
346
+
347
+ export { config_validator_default as default, validateFile, validateJson, validateToml, validateYaml };
@@ -0,0 +1,45 @@
1
+ import { Plugin } from '@wrongstack/core';
2
+
3
+ /**
4
+ * context-pins plugin — pin durable facts into the system prompt.
5
+ *
6
+ * Long sessions lose important constraints to compaction ("the API
7
+ * base URL is X", "never touch the legacy folder", "the user prefers
8
+ * Turkish"). This plugin gives the agent (and the user) three tools:
9
+ *
10
+ * - `pin_add` — pin a short fact (optionally with a label)
11
+ * - `pin_remove` — remove a pin by id or label
12
+ * - `pin_list` — list all pins
13
+ *
14
+ * Every pinned fact is injected into the system prompt on every
15
+ * request via a `SystemPromptContributor`, so pins survive context
16
+ * compaction by construction. Pins persist across sessions in a JSON
17
+ * file (default: `<projectDir>/context-pins.json`, seeded by the CLI
18
+ * host the same way `todo-tracker` gets its path).
19
+ *
20
+ * Config (`config.extensions['context-pins']`):
21
+ *
22
+ * ```jsonc
23
+ * {
24
+ * "enabled": true,
25
+ * "filePath": "", // where pins persist; empty = in-memory only
26
+ * "maxPins": 20, // hard cap so the prompt block stays small
27
+ * "maxPinChars": 500 // per-pin length cap
28
+ * }
29
+ * ```
30
+ *
31
+ * Toggle off with `{ "name": "context-pins", "enabled": false }` in
32
+ * `config.plugins`, or `"enabled": false` in the options above.
33
+ *
34
+ * @public
35
+ */
36
+
37
+ interface Pin {
38
+ id: string;
39
+ label: string | null;
40
+ text: string;
41
+ createdAt: string;
42
+ }
43
+ declare const plugin: Plugin;
44
+
45
+ export { type Pin, plugin as default };
@@ -0,0 +1,240 @@
1
+ import { readFileSync, mkdirSync, writeFileSync } from 'fs';
2
+ import { dirname } from 'path';
3
+
4
+ // src/context-pins/index.ts
5
+ var state = {
6
+ pins: [],
7
+ nextId: 1,
8
+ adds: 0,
9
+ removals: 0,
10
+ persistErrors: 0,
11
+ contributorUnregister: null
12
+ };
13
+ var DEFAULTS = {
14
+ enabled: true,
15
+ filePath: "",
16
+ maxPins: 20,
17
+ maxPinChars: 500
18
+ };
19
+ function readConfig(raw) {
20
+ if (!raw || typeof raw !== "object") return { ...DEFAULTS };
21
+ const r = raw;
22
+ return {
23
+ enabled: r["enabled"] !== false,
24
+ filePath: typeof r["filePath"] === "string" ? r["filePath"] : DEFAULTS.filePath,
25
+ maxPins: typeof r["maxPins"] === "number" && r["maxPins"] >= 1 && r["maxPins"] <= 100 ? r["maxPins"] : DEFAULTS.maxPins,
26
+ maxPinChars: typeof r["maxPinChars"] === "number" && r["maxPinChars"] >= 20 ? r["maxPinChars"] : DEFAULTS.maxPinChars
27
+ };
28
+ }
29
+ function loadPins(filePath) {
30
+ if (!filePath) return { pins: [], nextId: 1 };
31
+ try {
32
+ const raw = JSON.parse(readFileSync(filePath, "utf-8"));
33
+ const pins = Array.isArray(raw.pins) ? raw.pins.filter(
34
+ (p) => !!p && typeof p === "object" && typeof p.id === "string" && typeof p.text === "string"
35
+ ) : [];
36
+ const nextId = typeof raw.nextId === "number" && raw.nextId >= 1 ? raw.nextId : pins.length + 1;
37
+ return { pins, nextId };
38
+ } catch {
39
+ return { pins: [], nextId: 1 };
40
+ }
41
+ }
42
+ function persistPins(filePath) {
43
+ if (!filePath) return true;
44
+ try {
45
+ mkdirSync(dirname(filePath), { recursive: true });
46
+ writeFileSync(filePath, JSON.stringify({ pins: state.pins, nextId: state.nextId }, null, 2));
47
+ return true;
48
+ } catch {
49
+ state.persistErrors += 1;
50
+ return false;
51
+ }
52
+ }
53
+ var plugin = {
54
+ name: "context-pins",
55
+ version: "0.1.0",
56
+ description: "Pin durable facts into the system prompt (pin_add/pin_remove/pin_list) \u2014 pins survive compaction and persist across sessions",
57
+ apiVersion: "^0.1.10",
58
+ capabilities: { tools: true },
59
+ defaultConfig: { ...DEFAULTS },
60
+ configSchema: {
61
+ type: "object",
62
+ properties: {
63
+ enabled: { type: "boolean", default: true, description: "Master switch." },
64
+ filePath: {
65
+ type: "string",
66
+ default: "",
67
+ description: "JSON file where pins persist across sessions. Empty = in-memory only. Seeded by the host to <projectDir>/context-pins.json."
68
+ },
69
+ maxPins: {
70
+ type: "number",
71
+ minimum: 1,
72
+ maximum: 100,
73
+ default: 20,
74
+ description: "Maximum number of concurrent pins."
75
+ },
76
+ maxPinChars: {
77
+ type: "number",
78
+ minimum: 20,
79
+ default: 500,
80
+ description: "Per-pin text length cap (chars)."
81
+ }
82
+ }
83
+ },
84
+ setup(api) {
85
+ state.adds = 0;
86
+ state.removals = 0;
87
+ state.persistErrors = 0;
88
+ if (state.contributorUnregister) {
89
+ try {
90
+ state.contributorUnregister();
91
+ } catch {
92
+ }
93
+ state.contributorUnregister = null;
94
+ }
95
+ const cfg = readConfig(api.config.extensions?.["context-pins"]);
96
+ const loaded = loadPins(cfg.filePath);
97
+ state.pins = loaded.pins.slice(0, cfg.maxPins);
98
+ state.nextId = loaded.nextId;
99
+ if (cfg.enabled) {
100
+ state.contributorUnregister = api.registerSystemPromptContributor(async () => {
101
+ if (state.pins.length === 0) return [];
102
+ const lines = state.pins.map((p) => `- ${p.label ? `[${p.label}] ` : ""}${p.text}`);
103
+ return [
104
+ {
105
+ type: "text",
106
+ text: "[pinned_context]\nThe user or agent pinned these facts \u2014 they remain true regardless of conversation age:\n" + lines.join("\n")
107
+ }
108
+ ];
109
+ });
110
+ }
111
+ api.tools.register({
112
+ name: "pin_add",
113
+ description: "Pin a short durable fact into the system prompt so it survives context compaction. Use for constraints, decisions, and preferences that must not be forgotten.",
114
+ inputSchema: {
115
+ type: "object",
116
+ properties: {
117
+ text: { type: "string", description: "The fact to pin (short and declarative)." },
118
+ label: { type: "string", description: 'Optional short label, e.g. "api" or "style".' }
119
+ },
120
+ required: ["text"]
121
+ },
122
+ permission: "auto",
123
+ category: "Memory",
124
+ mutating: true,
125
+ async execute(input) {
126
+ if (!cfg.enabled) return { ok: false, error: "context-pins is disabled" };
127
+ const text = String(input.text ?? "").trim();
128
+ if (!text) return { ok: false, error: "pin text must not be empty" };
129
+ if (state.pins.length >= cfg.maxPins) {
130
+ return {
131
+ ok: false,
132
+ error: `pin limit reached (${cfg.maxPins}). Remove a pin first with pin_remove.`
133
+ };
134
+ }
135
+ const pin = {
136
+ id: `pin-${state.nextId++}`,
137
+ label: typeof input.label === "string" && input.label.trim() ? input.label.trim() : null,
138
+ text: text.slice(0, cfg.maxPinChars),
139
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
140
+ };
141
+ state.pins.push(pin);
142
+ state.adds += 1;
143
+ api.metrics.counter("adds");
144
+ const persisted = persistPins(cfg.filePath);
145
+ return { ok: true, pin, persisted, totalPins: state.pins.length };
146
+ }
147
+ });
148
+ api.tools.register({
149
+ name: "pin_remove",
150
+ description: "Remove a pinned fact by its id (pin-N) or label.",
151
+ inputSchema: {
152
+ type: "object",
153
+ properties: {
154
+ id: { type: "string", description: "Pin id (pin-N) or label to remove." }
155
+ },
156
+ required: ["id"]
157
+ },
158
+ permission: "auto",
159
+ category: "Memory",
160
+ mutating: true,
161
+ async execute(input) {
162
+ if (!cfg.enabled) return { ok: false, error: "context-pins is disabled" };
163
+ const key = String(input.id ?? "").trim();
164
+ const before = state.pins.length;
165
+ state.pins = state.pins.filter((p) => p.id !== key && p.label !== key);
166
+ const removed = before - state.pins.length;
167
+ if (removed === 0) return { ok: false, error: `no pin matches "${key}"` };
168
+ state.removals += removed;
169
+ api.metrics.counter("removals", removed);
170
+ const persisted = persistPins(cfg.filePath);
171
+ return { ok: true, removed, persisted, totalPins: state.pins.length };
172
+ }
173
+ });
174
+ api.tools.register({
175
+ name: "pin_list",
176
+ description: "List all pinned facts currently injected into the system prompt.",
177
+ inputSchema: { type: "object", properties: {} },
178
+ permission: "auto",
179
+ category: "Memory",
180
+ mutating: false,
181
+ async execute() {
182
+ return {
183
+ ok: true,
184
+ enabled: cfg.enabled,
185
+ pins: state.pins,
186
+ totalPins: state.pins.length,
187
+ maxPins: cfg.maxPins,
188
+ filePath: cfg.filePath || null,
189
+ counters: {
190
+ adds: state.adds,
191
+ removals: state.removals,
192
+ persistErrors: state.persistErrors
193
+ }
194
+ };
195
+ }
196
+ });
197
+ api.log.info("context-pins plugin loaded", {
198
+ version: "0.1.0",
199
+ enabled: cfg.enabled,
200
+ pinsLoaded: state.pins.length,
201
+ filePath: cfg.filePath || null
202
+ });
203
+ },
204
+ teardown(api) {
205
+ if (state.contributorUnregister) {
206
+ try {
207
+ state.contributorUnregister();
208
+ } catch {
209
+ }
210
+ state.contributorUnregister = null;
211
+ }
212
+ const final = {
213
+ pins: state.pins.length,
214
+ adds: state.adds,
215
+ removals: state.removals,
216
+ persistErrors: state.persistErrors
217
+ };
218
+ state.pins = [];
219
+ state.nextId = 1;
220
+ state.adds = 0;
221
+ state.removals = 0;
222
+ state.persistErrors = 0;
223
+ api.log.info("context-pins: teardown complete", { final });
224
+ },
225
+ async health() {
226
+ return {
227
+ ok: state.persistErrors === 0,
228
+ message: `context-pins: ${state.pins.length} pin(s) active, ${state.adds} add(s), ${state.removals} removal(s), ${state.persistErrors} persist error(s)`,
229
+ counters: {
230
+ pins: state.pins.length,
231
+ adds: state.adds,
232
+ removals: state.removals,
233
+ persistErrors: state.persistErrors
234
+ }
235
+ };
236
+ }
237
+ };
238
+ var context_pins_default = plugin;
239
+
240
+ export { context_pins_default as default };
@@ -3,12 +3,51 @@ import { Plugin } from '@wrongstack/core';
3
3
  /**
4
4
  * cost-tracker plugin — Tracks LLM token usage and cost per session.
5
5
  *
6
+ * Config surface (`config.extensions['cost-tracker']`):
7
+ *
8
+ * ```jsonc
9
+ * {
10
+ * "budgetLimit": 10, // USD; 0 = no limit
11
+ * "warningThreshold": 80, // percent of budget before warning
12
+ * "pricingOverrides": { // user-supplied per-model rates (USD/1M tokens)
13
+ * "gpt-4o": { "input": 5.0, "output": 15.0 },
14
+ * "claude-3-5-sonnet": { "input": 3.0, "output": 15.0 }
15
+ * }
16
+ * }
17
+ * ```
18
+ *
19
+ * Pricing lookup chain (first match wins, all keys lowercased):
20
+ *
21
+ * | Priority | Source | Populated by |
22
+ * |----------|-------------------------------------|--------------------------------------|
23
+ * | 1 | `pricingOverrides[model]` | User config (highest priority) |
24
+ * | 2 | `bundledFromRegistry[model]` | `api.modelsRegistry` (models.dev) |
25
+ * | 3 | `PRICING[model]` | Bundled baseline (updated per release)|
26
+ * | 4 | `DEFAULT_PRICING` | Last-resort fallback for unknown models |
27
+ *
6
28
  * Tools registered:
7
29
  * - cost_summary: Show token usage breakdown by model
8
30
  * - cost_reset: Reset tracking counters
9
31
  * - cost_export: Export cost report as JSON or CSV
32
+ *
33
+ * @public
10
34
  */
11
35
 
36
+ /**
37
+ * Per-model pricing in USD per 1 million tokens.
38
+ *
39
+ * Used by `pricingOverrides` (user config), `bundledFromRegistry`
40
+ * (models.dev), and the bundled `PRICING` table. All three share
41
+ * the same shape so the lookup chain can fall through uniformly.
42
+ *
43
+ * @public
44
+ */
45
+ interface ModelPricing {
46
+ /** Cost per 1M input (prompt) tokens in USD. */
47
+ input: number;
48
+ /** Cost per 1M output (completion) tokens in USD. */
49
+ output: number;
50
+ }
12
51
  declare const plugin: Plugin;
13
52
 
14
- export { plugin as default };
53
+ export { type ModelPricing, plugin as default };