@agjs/tsforge 0.1.9 → 0.1.11
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/package.json +1 -1
- package/src/cli.ts +17 -0
- package/src/config/external-plugins.ts +152 -0
- package/src/config/tsforge-config.ts +17 -0
- package/src/loop/loop.types.ts +3 -0
- package/src/loop/run.ts +20 -4
- package/src/loop/session.ts +82 -3
- package/src/render/ansi.ts +4 -0
- package/src/render/render.types.ts +3 -0
- package/src/rule-packs/index.ts +27 -1
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -629,6 +629,7 @@ const HELP = [
|
|
|
629
629
|
" /model [name] list configured models (★ active), or switch to <name>",
|
|
630
630
|
" /sessions list saved sessions (resume one with: tsforge --resume <id>)",
|
|
631
631
|
" /cost rough conversation size (messages + ~tokens)",
|
|
632
|
+
" /metrics token totals + generation rate (tok/s) this session",
|
|
632
633
|
" /exit, /quit leave the session",
|
|
633
634
|
"",
|
|
634
635
|
"Anything else is sent to the agent. It works with its tools; when it stops,",
|
|
@@ -1197,6 +1198,21 @@ async function repl(args: ICliArgs): Promise<number> {
|
|
|
1197
1198
|
break;
|
|
1198
1199
|
}
|
|
1199
1200
|
|
|
1201
|
+
case "metrics": {
|
|
1202
|
+
const m = session.metrics;
|
|
1203
|
+
|
|
1204
|
+
if (m.calls === 0) {
|
|
1205
|
+
process.stdout.write(" no model calls yet\n");
|
|
1206
|
+
} else {
|
|
1207
|
+
process.stdout.write(
|
|
1208
|
+
` ${String(m.calls)} call(s) · ${String(m.promptTokens)} in / ${String(m.completionTokens)} out · ` +
|
|
1209
|
+
`${String(m.lastTokensPerSecond)} tok/s last · ${String(m.avgTokensPerSecond)} tok/s avg\n`
|
|
1210
|
+
);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
break;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1200
1216
|
default:
|
|
1201
1217
|
process.stdout.write(`unknown command: ${line} (try /help)\n`);
|
|
1202
1218
|
}
|
|
@@ -1217,6 +1233,7 @@ async function repl(args: ICliArgs): Promise<number> {
|
|
|
1217
1233
|
elapsedMs: lastElapsedMs,
|
|
1218
1234
|
status: lastStatus,
|
|
1219
1235
|
scope: scopeLabel(session.scope) + (planMode ? " · PLAN" : ""),
|
|
1236
|
+
tokensPerSecond: session.metrics.lastTokensPerSecond,
|
|
1220
1237
|
})
|
|
1221
1238
|
);
|
|
1222
1239
|
process.stdout.write("› ");
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { isRecord } from "../lib/guards";
|
|
3
|
+
import { registerExternalPack } from "../rule-packs";
|
|
4
|
+
import type { IRulePack } from "../rule-packs/rule-packs.types";
|
|
5
|
+
|
|
6
|
+
/** One external plugin entry from tsforge.config.json `plugins`. */
|
|
7
|
+
export interface IExternalPlugin {
|
|
8
|
+
/** Module specifier or path (relative paths resolve against the repo root). */
|
|
9
|
+
readonly path: string;
|
|
10
|
+
/** Named exports to load as rule packs. Omit to load every exported pack. */
|
|
11
|
+
readonly packs?: readonly string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function errMessage(err: unknown): string {
|
|
15
|
+
return err instanceof Error ? err.message : String(err);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Type guard: a well-formed IRulePack (no `as` — every field is checked). */
|
|
19
|
+
export function isRulePack(value: unknown): value is IRulePack {
|
|
20
|
+
if (!isRecord(value)) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (typeof value.id !== "string" || value.id.length === 0) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (typeof value.description !== "string") {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!isRecord(value.rules) || !isRecord(value.rulesConfig)) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const severity of Object.values(value.rulesConfig)) {
|
|
37
|
+
if (severity !== "error" && severity !== "warn") {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Parse the `plugins` config field into validated entries. */
|
|
46
|
+
export function parsePlugins(raw: unknown): IExternalPlugin[] {
|
|
47
|
+
if (!Array.isArray(raw)) {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const plugins: IExternalPlugin[] = [];
|
|
52
|
+
|
|
53
|
+
for (const item of raw) {
|
|
54
|
+
if (
|
|
55
|
+
!isRecord(item) ||
|
|
56
|
+
typeof item.path !== "string" ||
|
|
57
|
+
item.path.length === 0
|
|
58
|
+
) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const packs = Array.isArray(item.packs)
|
|
63
|
+
? item.packs.filter((p): p is string => typeof p === "string")
|
|
64
|
+
: undefined;
|
|
65
|
+
|
|
66
|
+
plugins.push({
|
|
67
|
+
path: item.path,
|
|
68
|
+
...(packs !== undefined && packs.length > 0 ? { packs } : {}),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return plugins;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Collect the candidate exports to validate from a loaded module. */
|
|
76
|
+
function candidateExports(
|
|
77
|
+
mod: Record<string, unknown>,
|
|
78
|
+
names: readonly string[] | undefined
|
|
79
|
+
): unknown[] {
|
|
80
|
+
if (names === undefined) {
|
|
81
|
+
return Object.values(mod);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return names.map((name) => mod[name]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Dynamically import each plugin and collect its valid exported rule packs.
|
|
89
|
+
* Never throws — an unimportable module or an export that is not a valid pack is
|
|
90
|
+
* reported and skipped, so a broken plugin can't take down a run.
|
|
91
|
+
*/
|
|
92
|
+
export async function loadExternalPacks(
|
|
93
|
+
plugins: readonly IExternalPlugin[],
|
|
94
|
+
cwd: string,
|
|
95
|
+
report: (message: string) => void
|
|
96
|
+
): Promise<IRulePack[]> {
|
|
97
|
+
const out: IRulePack[] = [];
|
|
98
|
+
|
|
99
|
+
for (const plugin of plugins) {
|
|
100
|
+
const specifier = plugin.path.startsWith(".")
|
|
101
|
+
? resolve(cwd, plugin.path)
|
|
102
|
+
: plugin.path;
|
|
103
|
+
|
|
104
|
+
let mod: unknown;
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
mod = await import(specifier);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
report(`plugin '${plugin.path}' failed to load: ${errMessage(err)}`);
|
|
110
|
+
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!isRecord(mod)) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const candidate of candidateExports(mod, plugin.packs)) {
|
|
119
|
+
if (isRulePack(candidate)) {
|
|
120
|
+
out.push(candidate);
|
|
121
|
+
report(`plugin '${plugin.path}': loaded pack '${candidate.id}'`);
|
|
122
|
+
} else {
|
|
123
|
+
report(
|
|
124
|
+
`plugin '${plugin.path}': an export is not a valid rule pack — skipped`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Load every configured plugin, register its packs in the rule-pack registry,
|
|
135
|
+
* and return the registered pack ids (to fold into the active pack list so the
|
|
136
|
+
* gate runs them). Never throws.
|
|
137
|
+
*/
|
|
138
|
+
export async function loadAndRegisterPlugins(
|
|
139
|
+
plugins: readonly IExternalPlugin[],
|
|
140
|
+
cwd: string,
|
|
141
|
+
report: (message: string) => void
|
|
142
|
+
): Promise<string[]> {
|
|
143
|
+
const packs = await loadExternalPacks(plugins, cwd, report);
|
|
144
|
+
const ids: string[] = [];
|
|
145
|
+
|
|
146
|
+
for (const pack of packs) {
|
|
147
|
+
registerExternalPack(pack);
|
|
148
|
+
ids.push(pack.id);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return ids;
|
|
152
|
+
}
|
|
@@ -2,6 +2,7 @@ import { join } from "node:path";
|
|
|
2
2
|
import { isRecord } from "../lib/guards";
|
|
3
3
|
import { PACK_REGISTRY } from "../stack-detection";
|
|
4
4
|
import { parseMcpServers, type IMcpServerConfig } from "../mcp";
|
|
5
|
+
import { parsePlugins, type IExternalPlugin } from "./external-plugins";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* User-defined configuration from tsforge.config.json
|
|
@@ -31,6 +32,13 @@ export interface ITsforgeProjectConfig {
|
|
|
31
32
|
* interpolated from the environment at load time. Opt-in: absent ⇒ no MCP.
|
|
32
33
|
*/
|
|
33
34
|
readonly mcpServers?: Readonly<Record<string, IMcpServerConfig>>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* External plugins providing extra rule packs, loaded without recompiling
|
|
38
|
+
* tsforge. Each entry names a module (or relative path) and, optionally, which
|
|
39
|
+
* exported packs to use. Opt-in: absent ⇒ only built-in packs.
|
|
40
|
+
*/
|
|
41
|
+
readonly plugins?: readonly IExternalPlugin[];
|
|
34
42
|
}
|
|
35
43
|
|
|
36
44
|
function warnConfig(msg: string): void {
|
|
@@ -177,6 +185,7 @@ function buildConfigFields(
|
|
|
177
185
|
packs?: { include?: readonly string[]; exclude?: readonly string[] };
|
|
178
186
|
rules?: Record<string, "error" | "warn" | "off">;
|
|
179
187
|
mcpServers?: Record<string, IMcpServerConfig>;
|
|
188
|
+
plugins?: readonly IExternalPlugin[];
|
|
180
189
|
} = {};
|
|
181
190
|
|
|
182
191
|
if (parsed.stack !== undefined) {
|
|
@@ -211,6 +220,14 @@ function buildConfigFields(
|
|
|
211
220
|
}
|
|
212
221
|
}
|
|
213
222
|
|
|
223
|
+
if (parsed.plugins !== undefined) {
|
|
224
|
+
const plugins = parsePlugins(parsed.plugins);
|
|
225
|
+
|
|
226
|
+
if (plugins.length > 0) {
|
|
227
|
+
configFields.plugins = plugins;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
214
231
|
return configFields;
|
|
215
232
|
}
|
|
216
233
|
|
package/src/loop/loop.types.ts
CHANGED
|
@@ -51,6 +51,9 @@ export interface ILoopEvent {
|
|
|
51
51
|
promptTokens?: number;
|
|
52
52
|
completionTokens?: number;
|
|
53
53
|
totalTokens?: number;
|
|
54
|
+
/** For `usage` events: output generation rate (completion tokens / second),
|
|
55
|
+
* measured from the first streamed token to the call's end. */
|
|
56
|
+
tokensPerSecond?: number;
|
|
54
57
|
/** For `usage` (and salvage-warning `tool`) events: whether THIS model call
|
|
55
58
|
* ran with thinking enabled — lets the analyzer correlate malformed-tool-call
|
|
56
59
|
* rate with the thinking mode (see analyze-malformed). */
|
package/src/loop/run.ts
CHANGED
|
@@ -191,20 +191,31 @@ function effectiveParserFor(
|
|
|
191
191
|
return flags.legacyFeedback() ? parseEslintJson : parse;
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
-
/** Detect the stack and fold in tsforge.config.json pack/rule overrides
|
|
195
|
-
|
|
194
|
+
/** Detect the stack and fold in tsforge.config.json pack/rule overrides, plus any
|
|
195
|
+
* rule packs from configured external plugins. */
|
|
196
|
+
async function resolveStackForRun(
|
|
197
|
+
cwd: string,
|
|
198
|
+
report: (message: string) => void
|
|
199
|
+
): Promise<{
|
|
196
200
|
stackProfile: Awaited<ReturnType<typeof detectStack>>;
|
|
197
201
|
ruleOverrides: Readonly<Record<string, "error" | "warn" | "off">>;
|
|
198
202
|
}> {
|
|
199
203
|
const detectedProfile = await detectStack(cwd);
|
|
200
204
|
const { loadTsforgeConfig, resolveActivePacks, normalizeRuleOverrides } =
|
|
201
205
|
await import("../config/tsforge-config");
|
|
206
|
+
const { loadAndRegisterPlugins } = await import("../config/external-plugins");
|
|
202
207
|
const cfg = await loadTsforgeConfig(cwd);
|
|
208
|
+
const activePacks = resolveActivePacks(detectedProfile.packs, cfg);
|
|
209
|
+
const externalIds =
|
|
210
|
+
cfg.plugins === undefined
|
|
211
|
+
? []
|
|
212
|
+
: await loadAndRegisterPlugins(cfg.plugins, cwd, report);
|
|
203
213
|
|
|
204
214
|
return {
|
|
205
215
|
stackProfile: {
|
|
206
216
|
...detectedProfile,
|
|
207
|
-
packs:
|
|
217
|
+
packs:
|
|
218
|
+
externalIds.length > 0 ? [...activePacks, ...externalIds] : activePacks,
|
|
208
219
|
},
|
|
209
220
|
ruleOverrides: normalizeRuleOverrides(cfg),
|
|
210
221
|
};
|
|
@@ -268,7 +279,12 @@ export async function runTask(
|
|
|
268
279
|
});
|
|
269
280
|
|
|
270
281
|
// Detect stack once per run, early; tsforge.config.json may adjust it
|
|
271
|
-
const { stackProfile, ruleOverrides } = await resolveStackForRun(
|
|
282
|
+
const { stackProfile, ruleOverrides } = await resolveStackForRun(
|
|
283
|
+
cwd,
|
|
284
|
+
(message) => {
|
|
285
|
+
report({ kind: "tool", task: task.id, message });
|
|
286
|
+
}
|
|
287
|
+
);
|
|
272
288
|
|
|
273
289
|
report({
|
|
274
290
|
kind: "tool",
|
package/src/loop/session.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
resolveActivePacks,
|
|
27
27
|
} from "../config/tsforge-config";
|
|
28
28
|
import { connectMcpServers } from "../mcp";
|
|
29
|
+
import { loadAndRegisterPlugins } from "../config/external-plugins";
|
|
29
30
|
import { LOOP_LIMITS, RUN_STATUS } from "./loop.constants";
|
|
30
31
|
import type { Reporter } from "./loop.types";
|
|
31
32
|
import { CHAT_SYSTEM, COMPACT_SYSTEM } from "./prompt";
|
|
@@ -113,6 +114,20 @@ export interface ISendResult {
|
|
|
113
114
|
turns: number;
|
|
114
115
|
}
|
|
115
116
|
|
|
117
|
+
/** Cumulative model-call metrics for a session — the basis for `/metrics`. */
|
|
118
|
+
export interface ISessionMetrics {
|
|
119
|
+
/** Number of model calls made. */
|
|
120
|
+
readonly calls: number;
|
|
121
|
+
/** Total prompt (input) tokens billed across all calls. */
|
|
122
|
+
readonly promptTokens: number;
|
|
123
|
+
/** Total completion (output) tokens generated across all calls. */
|
|
124
|
+
readonly completionTokens: number;
|
|
125
|
+
/** Output generation rate averaged over all calls (tokens/second). */
|
|
126
|
+
readonly avgTokensPerSecond: number;
|
|
127
|
+
/** Output generation rate of the most recent call (tokens/second). */
|
|
128
|
+
readonly lastTokensPerSecond: number;
|
|
129
|
+
}
|
|
130
|
+
|
|
116
131
|
export interface ISendOptions {
|
|
117
132
|
/** Caller cancellation (Ctrl-C). */
|
|
118
133
|
signal?: AbortSignal;
|
|
@@ -338,6 +353,15 @@ export class Session {
|
|
|
338
353
|
* size of the context the model last saw (drives the status gauge and, soon,
|
|
339
354
|
* auto-compaction). */
|
|
340
355
|
private lastUsage?: ITokenUsage;
|
|
356
|
+
/** Running totals behind the `metrics` getter. genMs is the summed generation
|
|
357
|
+
* time (first-token→end) so the average rate is tokens/total-gen-seconds. */
|
|
358
|
+
private readonly metricsTotals = {
|
|
359
|
+
calls: 0,
|
|
360
|
+
promptTokens: 0,
|
|
361
|
+
completionTokens: 0,
|
|
362
|
+
genMs: 0,
|
|
363
|
+
lastTokensPerSecond: 0,
|
|
364
|
+
};
|
|
341
365
|
/** Fast check run every few edits while building (e.g. tsc); "" = off. */
|
|
342
366
|
private incrementalCheck: string;
|
|
343
367
|
/** Per-send thinking override, set from ISendOptions for the duration of a
|
|
@@ -434,9 +458,25 @@ export class Session {
|
|
|
434
458
|
// pack selection and rule-severity overrides.
|
|
435
459
|
const detected = await detectStack(cfg.cwd);
|
|
436
460
|
const projectConfig = await loadTsforgeConfig(cfg.cwd);
|
|
461
|
+
const activePacks = resolveActivePacks(detected.packs, projectConfig);
|
|
462
|
+
// Opt-in: load rule packs from external plugins and fold their ids into the
|
|
463
|
+
// active packs so the gate runs them. loadAndRegisterPlugins never throws.
|
|
464
|
+
const externalPackIds =
|
|
465
|
+
projectConfig.plugins === undefined
|
|
466
|
+
? []
|
|
467
|
+
: await loadAndRegisterPlugins(
|
|
468
|
+
projectConfig.plugins,
|
|
469
|
+
cfg.cwd,
|
|
470
|
+
(message) => {
|
|
471
|
+
report({ kind: "tool", task: SESSION_ID, message });
|
|
472
|
+
}
|
|
473
|
+
);
|
|
437
474
|
const stackProfile = {
|
|
438
475
|
...detected,
|
|
439
|
-
packs:
|
|
476
|
+
packs:
|
|
477
|
+
externalPackIds.length > 0
|
|
478
|
+
? [...activePacks, ...externalPackIds]
|
|
479
|
+
: activePacks,
|
|
440
480
|
};
|
|
441
481
|
const ruleOverrides = normalizeRuleOverrides(projectConfig);
|
|
442
482
|
|
|
@@ -490,6 +530,31 @@ export class Session {
|
|
|
490
530
|
return this.lastUsage;
|
|
491
531
|
}
|
|
492
532
|
|
|
533
|
+
/** Cumulative model-call metrics (tokens + generation rate) for this session. */
|
|
534
|
+
get metrics(): ISessionMetrics {
|
|
535
|
+
const t = this.metricsTotals;
|
|
536
|
+
|
|
537
|
+
return {
|
|
538
|
+
calls: t.calls,
|
|
539
|
+
promptTokens: t.promptTokens,
|
|
540
|
+
completionTokens: t.completionTokens,
|
|
541
|
+
avgTokensPerSecond:
|
|
542
|
+
t.genMs > 0 ? Math.round((t.completionTokens / t.genMs) * 1000) : 0,
|
|
543
|
+
lastTokensPerSecond: Math.round(t.lastTokensPerSecond),
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Fold one call's usage + generation time into the running metrics totals. */
|
|
548
|
+
private recordUsage(usage: ITokenUsage, genMs: number): void {
|
|
549
|
+
this.lastUsage = usage;
|
|
550
|
+
this.metricsTotals.calls += 1;
|
|
551
|
+
this.metricsTotals.promptTokens += usage.promptTokens;
|
|
552
|
+
this.metricsTotals.completionTokens += usage.completionTokens;
|
|
553
|
+
this.metricsTotals.genMs += genMs;
|
|
554
|
+
this.metricsTotals.lastTokensPerSecond =
|
|
555
|
+
genMs > 0 ? (usage.completionTokens / genMs) * 1000 : 0;
|
|
556
|
+
}
|
|
557
|
+
|
|
493
558
|
/** The real size of the context the model is currently holding — the prompt
|
|
494
559
|
* tokens of the last call (what auto-compaction watches), 0 before any call. */
|
|
495
560
|
get contextTokens(): number {
|
|
@@ -940,6 +1005,8 @@ export class Session {
|
|
|
940
1005
|
const mcpSchemas = this.ctx.mcpRegistry?.toolSchemas() ?? [];
|
|
941
1006
|
const offeredTools =
|
|
942
1007
|
mcpSchemas.length > 0 ? [...baseTools, ...mcpSchemas] : baseTools;
|
|
1008
|
+
const callStart = performance.now();
|
|
1009
|
+
let firstTokenAt = 0;
|
|
943
1010
|
const res = await this.provider.complete(ctx.messages, {
|
|
944
1011
|
tools: offeredTools,
|
|
945
1012
|
temperature: this.cfg.temperature ?? 0,
|
|
@@ -950,6 +1017,12 @@ export class Session {
|
|
|
950
1017
|
: { thinkingTokenBudget: this.cfg.thinkingTokenBudget }),
|
|
951
1018
|
...(signal === undefined ? {} : { signal }),
|
|
952
1019
|
onToken: (token, channel) => {
|
|
1020
|
+
// Stamp the first token so tokens/sec measures generation rate (excluding
|
|
1021
|
+
// prompt-processing / time-to-first-token), not total wall time.
|
|
1022
|
+
if (firstTokenAt === 0) {
|
|
1023
|
+
firstTokenAt = performance.now();
|
|
1024
|
+
}
|
|
1025
|
+
|
|
953
1026
|
// Stream EVERYTHING live — thinking, the tool calls being written, and
|
|
954
1027
|
// the answer itself (channel `content`), so the user watches the reply
|
|
955
1028
|
// arrive instead of staring at a frozen indicator. The renderer formats
|
|
@@ -960,17 +1033,23 @@ export class Session {
|
|
|
960
1033
|
});
|
|
961
1034
|
|
|
962
1035
|
if (res.usage !== undefined) {
|
|
963
|
-
|
|
1036
|
+
const ended = performance.now();
|
|
1037
|
+
const genMs = firstTokenAt > 0 ? ended - firstTokenAt : ended - callStart;
|
|
1038
|
+
const tps = genMs > 0 ? (res.usage.completionTokens / genMs) * 1000 : 0;
|
|
1039
|
+
|
|
1040
|
+
this.recordUsage(res.usage, genMs);
|
|
964
1041
|
// Logged (not shown) so the --log analyzer can compute tokens-to-solution.
|
|
965
1042
|
// `thinking` records THIS call's mode, so malformed-call rates can be
|
|
966
1043
|
// correlated with it (analyze-malformed).
|
|
967
1044
|
report({
|
|
968
1045
|
kind: "usage",
|
|
969
1046
|
task: SESSION_ID,
|
|
970
|
-
message: `tokens ${res.usage.promptTokens} in / ${res.usage.completionTokens} out`,
|
|
1047
|
+
message: `tokens ${res.usage.promptTokens} in / ${res.usage.completionTokens} out · ${Math.round(tps)} tok/s`,
|
|
971
1048
|
promptTokens: res.usage.promptTokens,
|
|
972
1049
|
completionTokens: res.usage.completionTokens,
|
|
973
1050
|
totalTokens: res.usage.totalTokens,
|
|
1051
|
+
tokensPerSecond: Math.round(tps),
|
|
1052
|
+
ms: Math.round(genMs),
|
|
974
1053
|
...(enableThinking === undefined ? {} : { thinking: enableThinking }),
|
|
975
1054
|
});
|
|
976
1055
|
}
|
package/src/render/ansi.ts
CHANGED
|
@@ -71,6 +71,10 @@ export function renderStatus(
|
|
|
71
71
|
);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
if (info.tokensPerSecond !== undefined && info.tokensPerSecond > 0) {
|
|
75
|
+
bits.push(`${info.tokensPerSecond} tok/s`);
|
|
76
|
+
}
|
|
77
|
+
|
|
74
78
|
bits.push(info.status, info.scope);
|
|
75
79
|
|
|
76
80
|
return `${paint(` ⎯ ${bits.join(" · ")}`, STYLE.dim, color)}\n`;
|
package/src/rule-packs/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { TSESLint } from "@typescript-eslint/utils";
|
|
2
2
|
|
|
3
|
+
import type { IRulePack } from "./rule-packs.types";
|
|
3
4
|
import { bullmqPack } from "./bullmq";
|
|
4
5
|
import { commentHygienePack } from "./comment-hygiene";
|
|
5
6
|
import { codeFlowPack } from "./code-flow";
|
|
@@ -43,6 +44,31 @@ function isRulePackId(id: unknown): id is IRulePackId {
|
|
|
43
44
|
return typeof id === "string" && id in RULE_PACKS;
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
/** Externally-registered rule packs (from tsforge.config.json `plugins`). Kept
|
|
48
|
+
* separate from the built-in RULE_PACKS so a user pack can never shadow a
|
|
49
|
+
* built-in by id; rule-name collisions still fail the build in
|
|
50
|
+
* buildPackEslintConfig. */
|
|
51
|
+
const EXTERNAL_PACKS = new Map<string, IRulePack>();
|
|
52
|
+
|
|
53
|
+
/** Register an external rule pack so its id resolves in buildPackEslintConfig. */
|
|
54
|
+
export function registerExternalPack(pack: IRulePack): void {
|
|
55
|
+
EXTERNAL_PACKS.set(pack.id, pack);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Drop all registered external packs (used by tests for isolation). */
|
|
59
|
+
export function clearExternalPacks(): void {
|
|
60
|
+
EXTERNAL_PACKS.clear();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Resolve a pack id to its definition, built-ins first, then external packs. */
|
|
64
|
+
function lookupPack(packId: string): IRulePack | undefined {
|
|
65
|
+
if (isRulePackId(packId)) {
|
|
66
|
+
return RULE_PACKS[packId];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return EXTERNAL_PACKS.get(packId);
|
|
70
|
+
}
|
|
71
|
+
|
|
46
72
|
/** Apply rule overrides: "off" drops a rule, error/warn replaces its severity. */
|
|
47
73
|
function applyOverrides(
|
|
48
74
|
mergedRulesConfig: Readonly<Record<string, "error" | "warn">>,
|
|
@@ -93,7 +119,7 @@ export function buildPackEslintConfig(
|
|
|
93
119
|
const seenRuleNames = new Set<string>();
|
|
94
120
|
|
|
95
121
|
for (const packId of packIds) {
|
|
96
|
-
const pack =
|
|
122
|
+
const pack = lookupPack(packId);
|
|
97
123
|
|
|
98
124
|
// Skip pack IDs known to stack-detection but absent from RULE_PACKS
|
|
99
125
|
if (pack === undefined) {
|