@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@agjs/tsforge",
3
3
  "type": "module",
4
- "version": "0.1.9",
4
+ "version": "0.1.11",
5
5
  "license": "MIT",
6
6
  "description": "TypeScript coding harness with a deterministic gate, stack-aware guardrails, and stream-level correction.",
7
7
  "repository": {
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
 
@@ -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
- async function resolveStackForRun(cwd: string): Promise<{
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: resolveActivePacks(detectedProfile.packs, cfg),
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(cwd);
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",
@@ -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: resolveActivePacks(detected.packs, projectConfig),
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
- this.lastUsage = res.usage;
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
  }
@@ -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`;
@@ -18,4 +18,7 @@ export interface IStatusInfo {
18
18
  status: string;
19
19
  /** Editable scope label. */
20
20
  scope: string;
21
+ /** Output generation rate of the last model call (tokens/second); omitted or
22
+ * 0 before the first call. */
23
+ tokensPerSecond?: number;
21
24
  }
@@ -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 = isRulePackId(packId) ? RULE_PACKS[packId] : undefined;
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) {