@yeaft/webchat-agent 0.1.763 → 0.1.766

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.
@@ -0,0 +1,165 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * yeaft-stats — CLI entry for tool-call usage statistics.
4
+ *
5
+ * Reads `~/.yeaft/stats/tool-usage.json` and renders a table (default),
6
+ * lists tools that have never been called (`--unused`), or dumps raw
7
+ * JSON (`--json`).
8
+ *
9
+ * Usage:
10
+ * yeaft-stats # ranked table by call count (desc)
11
+ * yeaft-stats --unused # tools registered but never called
12
+ * yeaft-stats --json # raw snapshot (pipe-friendly)
13
+ * yeaft-stats --reset # delete the stats file
14
+ * yeaft-stats --yeaft-dir=/path
15
+ * yeaft-stats --help
16
+ */
17
+
18
+ import { homedir } from 'node:os';
19
+ import { join } from 'node:path';
20
+ import { ToolUsageStats } from '../unify/stats/tool-usage.js';
21
+ import { allTools } from '../unify/tools/index.js';
22
+ import { formatMs, formatPct, formatLastCalled } from '../unify/stats/format.js';
23
+
24
+ function parseArgs(argv) {
25
+ const opts = {
26
+ unused: false,
27
+ json: false,
28
+ reset: false,
29
+ yeaftDir: null,
30
+ help: false,
31
+ };
32
+ for (const arg of argv.slice(2)) {
33
+ if (arg === '--unused') opts.unused = true;
34
+ else if (arg === '--json') opts.json = true;
35
+ else if (arg === '--reset') opts.reset = true;
36
+ else if (arg === '--help' || arg === '-h') opts.help = true;
37
+ else if (arg.startsWith('--yeaft-dir=')) opts.yeaftDir = arg.slice('--yeaft-dir='.length);
38
+ }
39
+ return opts;
40
+ }
41
+
42
+ function printHelp() {
43
+ console.log(`yeaft-stats — tool-call usage statistics
44
+
45
+ Usage:
46
+ yeaft-stats Ranked table by call count (desc)
47
+ yeaft-stats --unused Tools registered but never called
48
+ yeaft-stats --json Raw JSON snapshot (pipe-friendly)
49
+ yeaft-stats --reset Delete the stats file
50
+ yeaft-stats --yeaft-dir=/path
51
+ yeaft-stats --help Show this message`);
52
+ }
53
+
54
+ function padRight(s, n) {
55
+ const str = String(s);
56
+ return str.length >= n ? str : str + ' '.repeat(n - str.length);
57
+ }
58
+
59
+ function padLeft(s, n) {
60
+ const str = String(s);
61
+ return str.length >= n ? str : ' '.repeat(n - str.length) + str;
62
+ }
63
+
64
+ function printTable(snapshot) {
65
+ const entries = Object.entries(snapshot)
66
+ .sort((a, b) => b[1].callCount - a[1].callCount);
67
+ if (entries.length === 0) {
68
+ console.log('(no tool calls recorded yet)');
69
+ return;
70
+ }
71
+ const cols = [
72
+ { key: 'name', label: 'name', width: 28, align: 'l' },
73
+ { key: 'calls', label: 'calls', width: 7, align: 'r' },
74
+ { key: 'errors', label: 'errors', width: 7, align: 'r' },
75
+ { key: 'errRate', label: 'err%', width: 6, align: 'r' },
76
+ { key: 'p50', label: 'p50', width: 8, align: 'r' },
77
+ { key: 'p95', label: 'p95', width: 8, align: 'r' },
78
+ { key: 'avg', label: 'avg', width: 8, align: 'r' },
79
+ { key: 'last', label: 'last', width: 12, align: 'l' },
80
+ ];
81
+ const header = cols.map(c => (c.align === 'r' ? padLeft(c.label, c.width) : padRight(c.label, c.width))).join(' ');
82
+ console.log(header);
83
+ console.log('-'.repeat(header.length));
84
+ for (const [name, rec] of entries) {
85
+ const row = [
86
+ padRight(name.slice(0, cols[0].width), cols[0].width),
87
+ padLeft(rec.callCount, cols[1].width),
88
+ padLeft(rec.errorCount, cols[2].width),
89
+ padLeft(formatPct(rec.errorRate), cols[3].width),
90
+ padLeft(formatMs(rec.p50Ms), cols[4].width),
91
+ padLeft(formatMs(rec.p95Ms), cols[5].width),
92
+ padLeft(formatMs(rec.avgMs), cols[6].width),
93
+ padRight(formatLastCalled(rec.lastCalledAt), cols[7].width),
94
+ ].join(' ');
95
+ console.log(row);
96
+ }
97
+ }
98
+
99
+ async function getRegisteredToolNames() {
100
+ // Pull names directly from the static `allTools` export. MCP/skill
101
+ // dynamic tools aren't covered here — the unused list only flags
102
+ // built-in tools that have a definition in the source tree.
103
+ try {
104
+ if (Array.isArray(allTools)) {
105
+ return allTools
106
+ .filter(t => t && typeof t.name === 'string' && t.name)
107
+ .map(t => t.name);
108
+ }
109
+ } catch {
110
+ // fall through
111
+ }
112
+ return [];
113
+ }
114
+
115
+ async function main() {
116
+ const opts = parseArgs(process.argv);
117
+ if (opts.help) {
118
+ printHelp();
119
+ process.exit(0);
120
+ }
121
+
122
+ const yeaftDir = opts.yeaftDir || join(homedir(), '.yeaft');
123
+ const statsPath = join(yeaftDir, 'stats', 'tool-usage.json');
124
+ const stats = new ToolUsageStats({ path: statsPath });
125
+
126
+ if (opts.reset) {
127
+ await stats.reset();
128
+ console.log(`reset: ${statsPath}`);
129
+ process.exit(0);
130
+ }
131
+
132
+ stats.loadSync();
133
+ const snapshot = stats.snapshot();
134
+
135
+ if (opts.json) {
136
+ console.log(JSON.stringify({ path: statsPath, tools: snapshot }, null, 2));
137
+ process.exit(0);
138
+ }
139
+
140
+ if (opts.unused) {
141
+ const registered = await getRegisteredToolNames();
142
+ if (registered.length === 0) {
143
+ console.error('(could not enumerate registered tools — pass --json for raw stats)');
144
+ process.exit(1);
145
+ }
146
+ const unused = stats.getRegisteredButUncalled(registered);
147
+ console.log(`Registered: ${registered.length} Called at least once: ${registered.length - unused.length} Unused: ${unused.length}`);
148
+ console.log('-'.repeat(60));
149
+ if (unused.length === 0) {
150
+ console.log('(every registered tool has been called at least once)');
151
+ } else {
152
+ for (const name of unused) console.log(name);
153
+ }
154
+ process.exit(0);
155
+ }
156
+
157
+ console.log(`stats: ${statsPath}`);
158
+ console.log('');
159
+ printTable(snapshot);
160
+ }
161
+
162
+ main().catch(err => {
163
+ console.error(`yeaft-stats failed: ${err && err.message ? err.message : err}`);
164
+ process.exit(1);
165
+ });
@@ -36,7 +36,7 @@ import { sendToServer, flushMessageBuffer } from './buffer.js';
36
36
  import { handleRestartAgent, handleUpgradeAgent } from './upgrade.js';
37
37
  import { loadMcpServers, updateMcpConfig } from '../mcp.js';
38
38
  import { getLlmConfig, updateLlmConfig, getUnifySettings, updateUnifySettings, getSearchSettings, updateSearchSettings, fetchTavilyUsage } from '../unify/config-api.js';
39
- import { handleUnifyGroupChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyLoadMoreHistory, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyAbortTurn, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyFetchSummaryHistory, handleUnifyFeatureCrud, handleUnifyListGroups, handleUnifyCreateGroup, handleUnifyRenameGroup, handleUnifyUpdateGroup, handleUnifyArchiveGroup, handleUnifyDeleteGroup, handleUnifyAddMember, handleUnifyRemoveMember, handleUnifySetDefaultVp, handleUnifyDreamTrigger, broadcastLanguageChange } from '../unify/web-bridge.js';
39
+ import { handleUnifyGroupChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyLoadMoreHistory, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyAbortTurn, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyListGroups, handleUnifyCreateGroup, handleUnifyRenameGroup, handleUnifyUpdateGroup, handleUnifyArchiveGroup, handleUnifyDeleteGroup, handleUnifyAddMember, handleUnifyRemoveMember, handleUnifySetDefaultVp, handleUnifyDreamTrigger, handleUnifyFetchToolStats, broadcastLanguageChange } from '../unify/web-bridge.js';
40
40
 
41
41
  export async function handleMessage(msg) {
42
42
  switch (msg.type) {
@@ -460,14 +460,6 @@ export async function handleMessage(msg) {
460
460
  handleUnifyVpRead(msg);
461
461
  break;
462
462
 
463
- // R6 G1a — feature summary history + feature affiliation CRUD.
464
- case 'unify_fetch_summary_history':
465
- await handleUnifyFetchSummaryHistory(msg);
466
- break;
467
- case 'unify_feature_crud':
468
- await handleUnifyFeatureCrud(msg);
469
- break;
470
-
471
463
  // task-334m: Group CRUD + D1 seed wiring (§Δ10 334m + R6 §Δ31.2).
472
464
  // All handlers reply via `group_crud_result`; mutating ops additionally
473
465
  // emit `group_roster_changed` (add/remove/default) or
@@ -505,6 +497,11 @@ export async function handleMessage(msg) {
505
497
  await handleUnifyDreamTrigger(msg);
506
498
  break;
507
499
 
500
+ // 2026-05-13: per-tool call counters for the Unify debug drawer.
501
+ case 'unify_fetch_tool_stats':
502
+ await handleUnifyFetchToolStats(msg);
503
+ break;
504
+
508
505
  // Expert roles definition (for ExpertPanel detail view)
509
506
  case 'get_expert_roles': {
510
507
  const { getExpertRolesDefinition } = await import('../expert-roles.js');
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.763",
3
+ "version": "0.1.766",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "yeaft-agent": "./cli.js",
9
- "yeaft": "./unify/cli.js"
9
+ "yeaft": "./unify/cli.js",
10
+ "yeaft-stats": "./bin/yeaft-stats.js"
10
11
  },
11
12
  "scripts": {
12
13
  "start": "node index.js",
package/unify/cli.js CHANGED
@@ -454,6 +454,22 @@ async function runREPL(config, args) {
454
454
  console.log(` Cold messages: ${conversationStore.countCold()}`);
455
455
  console.log(` Registered tools: ${toolRegistry.size}`);
456
456
  console.log(` Loaded skills: ${skillManager.size}`);
457
+ // 2026-05-13: per-tool call counters. Rendered inline so the
458
+ // REPL gives a one-shot snapshot — the `yeaft-stats` CLI is
459
+ // the dedicated entry for richer rendering / --unused mode.
460
+ if (session.toolStats && typeof session.toolStats.snapshot === 'function') {
461
+ const snap = session.toolStats.snapshot();
462
+ const entries = Object.entries(snap).sort((a, b) => b[1].callCount - a[1].callCount);
463
+ if (entries.length === 0) {
464
+ console.log(` Tool calls: (none recorded yet)`);
465
+ } else {
466
+ console.log(` Tool calls (top 10 by count):`);
467
+ for (const [name, rec] of entries.slice(0, 10)) {
468
+ const errPct = rec.callCount > 0 ? ((rec.errorCount / rec.callCount) * 100).toFixed(1) : '0';
469
+ console.log(` ${name.padEnd(28)} ${String(rec.callCount).padStart(5)} calls ${errPct.padStart(5)}% err p50=${rec.p50Ms}ms p95=${rec.p95Ms}ms`);
470
+ }
471
+ }
472
+ }
457
473
  break;
458
474
  }
459
475
 
@@ -21,7 +21,6 @@ const FILES = {
21
21
  extractUser: 'extract-user.md',
22
22
  extractVp: 'extract-vp.md',
23
23
  extractGroup: 'extract-group.md',
24
- extractFeature: 'extract-feature.md',
25
24
  extractTopic: 'extract-topic.md',
26
25
  // H2.e — per-scope summary compression
27
26
  summarizeScope: 'summarize-scope.md',
@@ -32,6 +31,10 @@ const FILES = {
32
31
  * extraction template name. Unknown scopes fall back to `extractTopic`
33
32
  * (the most generic template) so we never throw at extraction time.
34
33
  *
34
+ * (2026-05-13: `extractFeature` was removed along with the Feature
35
+ * system; legacy `feature/*` scopes — if any remain in old data — fall
36
+ * through to `extractTopic` here, which is harmless.)
37
+ *
35
38
  * @param {string} scope
36
39
  * @returns {keyof typeof FILES}
37
40
  */
@@ -40,7 +43,6 @@ export function extractTemplateForScope(scope) {
40
43
  if (scope === 'user') return 'extractUser';
41
44
  if (scope.startsWith('vp/')) return 'extractVp';
42
45
  if (scope.startsWith('group/')) return 'extractGroup';
43
- if (scope.startsWith('feature/')) return 'extractFeature';
44
46
  if (scope.startsWith('topic/')) return 'extractTopic';
45
47
  return 'extractTopic';
46
48
  }
@@ -77,7 +77,11 @@ export function buildRunDreamOpts(session, onProgress) {
77
77
 
78
78
  /**
79
79
  * Translate a group-store message record (id, from, role, text, ...) into
80
- * the shape runDream expects (id, role, body, vpId, author, featureId).
80
+ * the shape runDream expects (id, role, body, vpId, author).
81
+ *
82
+ * (2026-05-13: legacy `m.meta.featureId` propagation was dropped along
83
+ * with the Feature system. Historical messages on disk may still carry
84
+ * it, but it no longer influences dream scoping.)
81
85
  *
82
86
  * @param {Object} m
83
87
  */
@@ -91,9 +95,6 @@ function translateGroupMessage(m) {
91
95
  if (role === 'assistant' && m.from && m.from !== 'user') {
92
96
  out.vpId = m.from;
93
97
  }
94
- if (m.meta && typeof m.meta === 'object' && m.meta.featureId) {
95
- out.featureId = m.meta.featureId;
96
- }
97
98
  return out;
98
99
  }
99
100
 
@@ -6,9 +6,9 @@
6
6
  *
7
7
  * 1. **Hard rules** (this module, no LLM): everything we can determine
8
8
  * from message metadata. Always include the active group, every VP
9
- * that spoke as an assistant in the diff, every feature referenced
10
- * via `featureId`, and `user` (so painted-over user-profile signals
11
- * can't be missed).
9
+ * that spoke as an assistant in the diff, and `user` (so painted-over
10
+ * user-profile signals can't be missed). (Feature scope was dropped
11
+ * 2026-05-13 along with the rest of the Feature system.)
12
12
  *
13
13
  * 2. **Soft classification** (LLM, two passes):
14
14
  * Pass-1: high-recall — does the diff carry user-profile signal?
@@ -17,9 +17,9 @@
17
17
  * it to an exact existing path or propose
18
18
  * a new ≤2-level path.
19
19
  *
20
- * VP / group / feature are deliberately NOT asked of the LLM —
21
- * Hard Rules already cover them, and giving the LLM a chance to
22
- * drop a structurally-required scope would weaken the contract.
20
+ * VP / group are deliberately NOT asked of the LLM — Hard Rules
21
+ * already cover them, and giving the LLM a chance to drop a
22
+ * structurally-required scope would weaken the contract.
23
23
  *
24
24
  * `user_profile_signals === true` does not need Pass-2 either: the
25
25
  * Hard Rule already added `user` and Apply itself decides whether
@@ -75,10 +75,7 @@ export function applyHardRules({ groupId, messages }) {
75
75
  const vp = m.vpId || (m.author && /^vp:(.+)$/.exec(m.author)?.[1]);
76
76
  if (vp && /^[A-Za-z0-9_\-.一-鿿]+$/.test(vp)) add(`vp/${vp}`);
77
77
  }
78
- // Active feature: explicit metadata.
79
- if (m.featureId && /^[A-Za-z0-9_\-.一-鿿]+$/.test(m.featureId)) {
80
- add(`feature/${m.featureId}`);
81
- }
78
+ // (Active feature scope was dropped 2026-05-13 with the Feature system.)
82
79
  }
83
80
 
84
81
  return Array.from(out.values());
package/unify/engine.js CHANGED
@@ -253,32 +253,17 @@ export class Engine {
253
253
  /** @type {string|null} */
254
254
  #yeaftDir;
255
255
 
256
+ /** @type {import('./stats/tool-usage.js').ToolUsageStats|null} — per-tool call/latency counters */
257
+ #toolStats = null;
258
+
256
259
  /** @type {object|null} — Config override for internal tasks (recall, consolidation, dream) using fastModel */
257
260
  #fastConfig;
258
261
 
259
262
  /** @type {((agentId: string, evt: object) => void) | null} */
260
263
  #subAgentEventSink = null;
261
264
 
262
- /**
263
- * PR-4 current-featureId accessor.
264
- *
265
- * The feature arc lives in the per-VP web-bridge (`runVpTurn` creates
266
- * one per turn) so the engine itself does NOT own the featureId. To
267
- * let sub-agents inherit their parent's featureId without coupling the
268
- * engine to FeatureArc, the bridge plugs in a thin accessor right
269
- * after creating the arc — `engine.setCurrentFeatureIdAccessor(() =>
270
- * arc.getFeatureId() || null)`. The engine surfaces the result via
271
- * `parentEngineDeps.getCurrentFeatureId` (read lazily at sub-agent
272
- * event-emit time, so a feature that opens mid-turn still tags the
273
- * sub-agent's later events).
274
- *
275
- * The accessor is cleared (set to null) by the bridge in the same
276
- * `finally` block that clears the per-turn AbortController, so a
277
- * stale arc reference can't leak into the next turn.
278
- *
279
- * @type {(() => (string|null)) | null}
280
- */
281
- #currentFeatureIdAccessor = null;
265
+ // (removed 2026-05-13) `#currentFeatureIdAccessor` — sub-agent
266
+ // feature-inheritance plumbing that went with the Feature system.
282
267
 
283
268
  /**
284
269
  * task-325a — abort state.
@@ -347,7 +332,7 @@ export class Engine {
347
332
  * yeaftDir?: string,
348
333
  * }} params
349
334
  */
350
- constructor({ adapter, trace, config, conversationStore, memoryIndex, amsRegistry, toolRegistry, skillManager, mcpManager, yeaftDir }) {
335
+ constructor({ adapter, trace, config, conversationStore, memoryIndex, amsRegistry, toolRegistry, skillManager, mcpManager, yeaftDir, toolStats = null }) {
351
336
  this.#adapter = adapter;
352
337
  this.#trace = trace;
353
338
  this.#config = config;
@@ -360,6 +345,7 @@ export class Engine {
360
345
  this.#skillManager = skillManager || null;
361
346
  this.#mcpManager = mcpManager || null;
362
347
  this.#yeaftDir = yeaftDir || null;
348
+ this.#toolStats = toolStats || null;
363
349
 
364
350
  // PR-L: tool history reflection log. Keyed by traceId so distinct
365
351
  // engine instances don't stomp on each other's jsonl files. When
@@ -516,7 +502,6 @@ export class Engine {
516
502
  * @param {{
517
503
  * groupId?: string,
518
504
  * ownVpId?: string|null,
519
- * featureId?: string,
520
505
  * summaries: { user?: string, group?: string, vp?: string },
521
506
  * recallEntries: object[],
522
507
  * }} args
@@ -562,7 +547,6 @@ export class Engine {
562
547
  const scopes = buildRelevantScopes({
563
548
  groupId: args.groupId,
564
549
  vpId: ownVpId,
565
- featureId: args.featureId,
566
550
  });
567
551
 
568
552
  return { ams, groupKey, ownVpId, scopes, snapshotBlock };
@@ -753,9 +737,21 @@ export class Engine {
753
737
  // VP-aware tool). Undefined when running in non-group / no-VP flows.
754
738
  router: vpCtx?.router,
755
739
  senderVpId: vpCtx?.senderVpId,
740
+ // Active VP persona — surfaced so tools like `StartPlan` can read
741
+ // the optional `planInstruction` override without re-reading
742
+ // role.md. Mirrors the symmetry already present in
743
+ // `parentEngineDeps.parentVpPersona` below — sub-agents inherit it
744
+ // through the parent deps; tools at this level read it directly.
745
+ // Null in non-VP / test contexts.
746
+ vpPersona: vpCtx?.vpPersona || null,
756
747
  inboundEnvelope: vpCtx?.inboundEnvelope,
757
748
  taskId: vpCtx?.taskId,
758
749
  taskMembers: vpCtx?.taskMembers,
750
+ // TodoWrite per-VP cache hooks. Threaded from web-bridge so each
751
+ // VP keeps its own todo list (see todo-write.js, web-bridge.js).
752
+ // Null in non-VP / test contexts — tools tolerate missing slots.
753
+ getCurrentTodos: vpCtx?.getCurrentTodos || null,
754
+ setCurrentTodos: vpCtx?.setCurrentTodos || null,
759
755
  // task-707: tool-callable end-turn signal. The engine threads this
760
756
  // setter when constructing toolCtx so a tool (e.g. route_forward)
761
757
  // can mark "after this batch, end the turn — do NOT call adapter
@@ -776,13 +772,6 @@ export class Engine {
776
772
  parentVpPersona: vpCtx?.vpPersona || null,
777
773
  onEvent: this.#subAgentEventSink || null,
778
774
  language: this.#config?.language || 'en',
779
- // PR-4: lazy accessor so the sub-agent runner can stamp the
780
- // parent's active featureId on every forwarded event. Read at
781
- // emit-time (NOT at spawn-time) so a feature that opens AFTER
782
- // the sub-agent starts still tags later events.
783
- getCurrentFeatureId: () => {
784
- try { return this.#currentFeatureIdAccessor?.() || null; } catch { return null; }
785
- },
786
775
  },
787
776
  };
788
777
  }
@@ -799,36 +788,6 @@ export class Engine {
799
788
  this.#subAgentEventSink = typeof sink === 'function' ? sink : null;
800
789
  }
801
790
 
802
- /**
803
- * PR-4 — install / clear the per-turn current-featureId accessor.
804
- *
805
- * Called by `runVpTurn` in web-bridge.js: right after `arc =
806
- * createFeatureArc(...)` it passes `() => arc.getFeatureId() || null`,
807
- * and clears it (passes null) in the `finally` so a stale arc
808
- * reference can't leak into the next turn. The engine reads it
809
- * lazily via `parentEngineDeps.getCurrentFeatureId` so sub-agents
810
- * inherit whatever featureId is live at the moment they emit.
811
- *
812
- * Concurrency note: callers are expected to serialize per-VP turns
813
- * (web-bridge does — `runVpTurn` runs one turn at a time per VP and
814
- * its `finally` clears the accessor before the next turn lands).
815
- * If two installs collide (which today would be a bug, not by
816
- * design), we warn so it's visible in logs.
817
- *
818
- * @param {(() => (string|null)) | null} fn
819
- */
820
- setCurrentFeatureIdAccessor(fn) {
821
- const next = typeof fn === 'function' ? fn : null;
822
- if (next && this.#currentFeatureIdAccessor) {
823
- // Canary: a prior accessor is still installed when we're about
824
- // to overwrite it. Today's lifecycle (per-turn install + finally
825
- // clear) means this should never fire; if it does, two turns
826
- // are racing on the same engine.
827
- console.warn('[Engine] setCurrentFeatureIdAccessor: overwriting non-null accessor — concurrent turns on the same VP engine?');
828
- }
829
- this.#currentFeatureIdAccessor = next;
830
- }
831
-
832
791
  /**
833
792
  * Perform memory recall for a given prompt.
834
793
  *
@@ -839,7 +798,7 @@ export class Engine {
839
798
  * without injection.
840
799
  *
841
800
  * @param {string} prompt
842
- * @param {{ groupId?: string, vpId?: string, featureId?: string }} [ctx]
801
+ * @param {{ groupId?: string, vpId?: string }} [ctx]
843
802
  * @returns {Promise<{ profile: string, entries: object[], formatted: string }|null>}
844
803
  */
845
804
  async #recallMemory(prompt, ctx = {}) {
@@ -850,7 +809,6 @@ export class Engine {
850
809
  userMsg: prompt,
851
810
  groupId: ctx.groupId,
852
811
  vpId: ctx.vpId,
853
- featureId: ctx.featureId,
854
812
  });
855
813
  memory.profile = result.profile || '';
856
814
  memory.entries = result.entries || [];
@@ -1063,7 +1021,7 @@ export class Engine {
1063
1021
  * string-prompt shape (no regression for existing callers).
1064
1022
  * @yields {EngineEvent}
1065
1023
  */
1066
- async *query({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement, userAlreadyPersisted = false } = {}) {
1024
+ async *query({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null } = {}) {
1067
1025
  if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
1068
1026
  yield {
1069
1027
  type: 'error',
@@ -1122,7 +1080,7 @@ export class Engine {
1122
1080
  const runSignal = abortCtrl.signal;
1123
1081
 
1124
1082
  try {
1125
- yield* this.#runQuery({ prompt: effectivePrompt, promptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement, userAlreadyPersisted });
1083
+ yield* this.#runQuery({ prompt: effectivePrompt, promptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement, userAlreadyPersisted, getCurrentTodos, setCurrentTodos });
1126
1084
  } finally {
1127
1085
  if (signal) {
1128
1086
  try { signal.removeEventListener('abort', onExternalAbort); } catch { /* ignore */ }
@@ -1140,7 +1098,7 @@ export class Engine {
1140
1098
  * in a try/finally without indenting the whole loop.
1141
1099
  * @private
1142
1100
  */
1143
- async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement, userAlreadyPersisted = false }) {
1101
+ async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null }) {
1144
1102
 
1145
1103
  // ─── Pre-query: FTS5 Memory Recall + AMS snapshot ─────
1146
1104
  // Memory has a SINGLE render outlet now (DESIGN-PROMPT §3 ③):
@@ -1159,9 +1117,6 @@ export class Engine {
1159
1117
  vpId: vpPersona && typeof vpPersona === 'object' && typeof vpPersona.vpId === 'string'
1160
1118
  ? vpPersona.vpId
1161
1119
  : (typeof senderVpId === 'string' ? senderVpId : undefined),
1162
- featureId: typeof inboundEnvelope === 'object' && inboundEnvelope
1163
- ? inboundEnvelope.featureId
1164
- : undefined,
1165
1120
  });
1166
1121
  recallEntryCount = recallResult && Array.isArray(recallResult.entries)
1167
1122
  ? recallResult.entries.length
@@ -1191,13 +1146,9 @@ export class Engine {
1191
1146
  && typeof vpPersona.vpId === 'string'
1192
1147
  ? vpPersona.vpId
1193
1148
  : (typeof senderVpId === 'string' ? senderVpId : null);
1194
- const featureIdForAms = typeof inboundEnvelope === 'object' && inboundEnvelope
1195
- ? inboundEnvelope.featureId
1196
- : undefined;
1197
1149
  const amsContext = this.#prepareAms({
1198
1150
  groupId,
1199
1151
  ownVpId: ownVpIdForAms,
1200
- featureId: featureIdForAms,
1201
1152
  summaries,
1202
1153
  recallEntries: recallResult ? (recallResult.entries || []) : [],
1203
1154
  });
@@ -1206,17 +1157,10 @@ export class Engine {
1206
1157
  }
1207
1158
 
1208
1159
  // ─── Active Scope (DESIGN-PROMPT §3 ④) ──────────────────────
1209
- // Structured per-turn scope summary: feature + group + vp + envelope
1210
- // routing info. Long-form scope content lives in AMS — this block
1211
- // carries only IDs + tiny labels. featureId is allowed to be null
1212
- // (T4 Scope Tagging is a placeholder; not every turn lives in a
1213
- // feature — DESIGN-PROMPT §5.1).
1160
+ // Structured per-turn scope summary: group + vp + envelope routing
1161
+ // info. Long-form scope content lives in AMS — this block carries
1162
+ // only IDs + tiny labels. (Feature scope retired 2026-05-13.)
1214
1163
  const activeScope = {
1215
- featureId: featureIdForAms || null,
1216
- featureTitle: typeof inboundEnvelope === 'object' && inboundEnvelope
1217
- && typeof inboundEnvelope.featureTitle === 'string'
1218
- ? inboundEnvelope.featureTitle
1219
- : '',
1220
1164
  groupId: groupId || '',
1221
1165
  vpId: ownVpIdForAms || '',
1222
1166
  envelope: inboundEnvelope || null,
@@ -1228,12 +1172,6 @@ export class Engine {
1228
1172
  vpPersona,
1229
1173
  activeScope,
1230
1174
  groupAnnouncement,
1231
- // taskCtx is not currently wired by the query loop. The legacy
1232
- // task-context sub-block (renderTaskCtx, task-334e/334n contract) is
1233
- // retained behind a feature flag for callers that still build it
1234
- // upstream — it'll be folded into Active Scope or retired in a
1235
- // dedicated PR alongside the task-334n cleanup.
1236
- taskCtx: undefined,
1237
1175
  });
1238
1176
 
1239
1177
  // ─── Compact summary as messages-array head (DESIGN-PROMPT §4.3) ─
@@ -1901,6 +1839,8 @@ export class Engine {
1901
1839
  taskMembers,
1902
1840
  vpPersona,
1903
1841
  contextWindow: currentContextWindow,
1842
+ getCurrentTodos,
1843
+ setCurrentTodos,
1904
1844
  requestEndTurn: (reason) => {
1905
1845
  // First call wins — preserve the kind/reason of the first tool
1906
1846
  // that asked to end the turn. Late callers (a second
@@ -2012,6 +1952,20 @@ export class Engine {
2012
1952
  isError,
2013
1953
  };
2014
1954
 
1955
+ // 2026-05-13: feed the per-tool counters. Stays best-effort — a
1956
+ // stats sink that throws shouldn't crash the engine. `record`
1957
+ // already swallows internal write errors.
1958
+ if (this.#toolStats && typeof this.#toolStats.record === 'function') {
1959
+ try {
1960
+ this.#toolStats.record({
1961
+ name: tc.name,
1962
+ durationMs: toolDurationMs,
1963
+ isError,
1964
+ errorMessage: isError && typeof output === 'string' ? output.slice(0, 500) : null,
1965
+ });
1966
+ } catch { /* swallow */ }
1967
+ }
1968
+
2015
1969
  // Log tool to debug trace
2016
1970
  this.#trace.logTool(turnId, {
2017
1971
  toolName: tc.name,
@@ -225,19 +225,20 @@ export function formatPickedForInjection(picked) {
225
225
  */
226
226
 
227
227
  /**
228
- * Build the canonical scope list for a given (groupId, vpId, featureId).
228
+ * Build the canonical scope list for a given (groupId, vpId).
229
229
  * Always includes 'user'. The order is significant — preflow.js's scope
230
230
  * filter accepts/rejects by membership, and the formatter renders in
231
231
  * order.
232
232
  *
233
- * @param {{groupId?: string, vpId?: string, featureId?: string, extra?: string[]}} ctx
233
+ * (2026-05-13: `featureId` scope dropped along with the Feature system.)
234
+ *
235
+ * @param {{groupId?: string, vpId?: string, extra?: string[]}} ctx
234
236
  * @returns {string[]}
235
237
  */
236
- export function buildRelevantScopes({ groupId, vpId, featureId, extra } = {}) {
238
+ export function buildRelevantScopes({ groupId, vpId, extra } = {}) {
237
239
  const scopes = ['user'];
238
240
  if (groupId) scopes.push(`group/${groupId}`);
239
241
  if (vpId) scopes.push(`vp/${vpId}`);
240
- if (featureId) scopes.push(`feature/${featureId}`);
241
242
  if (Array.isArray(extra)) {
242
243
  for (const s of extra) {
243
244
  if (s && !scopes.includes(s)) scopes.push(s);
@@ -273,7 +274,6 @@ export function runMemoryPreflow(index, opts) {
273
274
  const relevantScopes = buildRelevantScopes({
274
275
  groupId: opts.groupId,
275
276
  vpId: opts.vpId,
276
- featureId: opts.featureId,
277
277
  extra: opts.extraScopes,
278
278
  });
279
279