@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.
- package/README.md +838 -0
- package/dist/auto-doc.d.ts +8 -0
- package/dist/auto-doc.js +175 -13
- package/dist/auto-escalate.d.ts +45 -0
- package/dist/auto-escalate.js +190 -0
- package/dist/branch-guard.d.ts +33 -0
- package/dist/branch-guard.js +228 -0
- package/dist/changelog-writer.d.ts +73 -0
- package/dist/changelog-writer.js +369 -0
- package/dist/checkpoint.d.ts +55 -0
- package/dist/checkpoint.js +305 -0
- package/dist/commit-validator.d.ts +33 -0
- package/dist/commit-validator.js +315 -0
- package/dist/config-validator.d.ts +48 -0
- package/dist/config-validator.js +347 -0
- package/dist/context-pins.d.ts +45 -0
- package/dist/context-pins.js +240 -0
- package/dist/cost-tracker.d.ts +40 -1
- package/dist/cost-tracker.js +105 -4
- package/dist/dep-guard.d.ts +65 -0
- package/dist/dep-guard.js +316 -0
- package/dist/diff-summary.d.ts +36 -0
- package/dist/diff-summary.js +235 -0
- package/dist/error-lens.d.ts +67 -0
- package/dist/error-lens.js +280 -0
- package/dist/format-on-save.d.ts +35 -0
- package/dist/format-on-save.js +219 -0
- package/dist/git-autocommit.js +186 -26
- package/dist/import-organizer.d.ts +52 -0
- package/dist/import-organizer.js +274 -0
- package/dist/index.d.ts +32 -6
- package/dist/index.js +10151 -1628
- package/dist/injection-shield.d.ts +49 -0
- package/dist/injection-shield.js +205 -0
- package/dist/lint-gate.d.ts +33 -0
- package/dist/lint-gate.js +394 -0
- package/dist/llm-cache.d.ts +56 -0
- package/dist/llm-cache.js +251 -0
- package/dist/loop-breaker.d.ts +43 -0
- package/dist/loop-breaker.js +241 -0
- package/dist/model-router.d.ts +69 -0
- package/dist/model-router.js +198 -0
- package/dist/notify-hub.d.ts +45 -0
- package/dist/notify-hub.js +304 -0
- package/dist/path-guard.d.ts +54 -0
- package/dist/path-guard.js +235 -0
- package/dist/prompt-firewall.d.ts +57 -0
- package/dist/prompt-firewall.js +290 -0
- package/dist/secret-scanner.d.ts +34 -0
- package/dist/secret-scanner.js +409 -0
- package/dist/semver-bump.js +45 -0
- package/dist/session-recap.d.ts +50 -0
- package/dist/session-recap.js +421 -0
- package/dist/shell-check.js +52 -4
- package/dist/spec-linker.d.ts +51 -0
- package/dist/spec-linker.js +541 -0
- package/dist/template-engine.js +19 -1
- package/dist/test-runner-gate.d.ts +37 -0
- package/dist/test-runner-gate.js +356 -0
- package/dist/todo-listener.d.ts +37 -0
- package/dist/todo-listener.js +216 -0
- package/dist/todo-tracker.d.ts +5 -0
- package/dist/todo-tracker.js +441 -0
- package/dist/token-budget.d.ts +40 -0
- package/dist/token-budget.js +254 -0
- package/dist/token-throttle.d.ts +54 -0
- package/dist/token-throttle.js +203 -0
- package/package.json +116 -12
- package/dist/json-path.d.ts +0 -18
- package/dist/json-path.js +0 -15
- package/dist/web-search.d.ts +0 -19
- 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 };
|
package/dist/cost-tracker.d.ts
CHANGED
|
@@ -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 };
|