@selesai/code 0.13.33 → 0.13.34

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.
@@ -75,13 +75,42 @@ export const DEFAULT_CONFIG_PATH = path.join(
75
75
  AGENT_ROOT,
76
76
  "hermes-memory-config.json",
77
77
  );
78
+ export const DEFAULT_SETTINGS_PATH = path.join(AGENT_ROOT, "settings.json");
79
+ export const HERMES_MEMORY_SETTINGS_KEY = "hermesMemory";
78
80
 
79
- export function loadConfig(configPath = DEFAULT_CONFIG_PATH): MemoryConfig {
81
+ function readConfigObject(configPath: string | undefined): Record<string, unknown> | undefined {
82
+ if (!configPath) return undefined;
80
83
  try {
81
- if (fs.existsSync(configPath)) {
82
- const raw = fs.readFileSync(configPath, "utf-8");
83
- const parsed = JSON.parse(raw);
84
- // Merge: override defaults with user config
84
+ const parsed: unknown = JSON.parse(fs.readFileSync(configPath, "utf-8"));
85
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
86
+ ? parsed as Record<string, unknown>
87
+ : undefined;
88
+ } catch {
89
+ return undefined;
90
+ }
91
+ }
92
+
93
+ function isRecord(value: unknown): value is Record<string, unknown> {
94
+ return typeof value === "object" && value !== null && !Array.isArray(value);
95
+ }
96
+
97
+ /** Read extension settings from settings.json, falling back to the legacy file. */
98
+ export function loadConfig(
99
+ configPath = DEFAULT_CONFIG_PATH,
100
+ settingsPath?: string,
101
+ ): MemoryConfig {
102
+ try {
103
+ const legacyConfig = readConfigObject(configPath);
104
+ const resolvedSettingsPath = settingsPath
105
+ ?? (configPath === DEFAULT_CONFIG_PATH ? DEFAULT_SETTINGS_PATH : undefined);
106
+ const settings = readConfigObject(resolvedSettingsPath);
107
+ const extensionSettings = settings?.[HERMES_MEMORY_SETTINGS_KEY];
108
+ if (legacyConfig || isRecord(extensionSettings)) {
109
+ const parsed: Record<string, unknown> = {
110
+ ...(legacyConfig ?? {}),
111
+ ...(isRecord(extensionSettings) ? extensionSettings : {}),
112
+ };
113
+ // New settings.json values take precedence over the legacy file.
85
114
  const config: MemoryConfig = { ...DEFAULT_CONFIG };
86
115
  const isNonNegativeNumber = (value: unknown): value is number => (
87
116
  typeof value === "number" && Number.isFinite(value) && value >= 0
@@ -156,8 +185,7 @@ export function loadConfig(configPath = DEFAULT_CONFIG_PATH): MemoryConfig {
156
185
  if (normalizedProjectsMemoryDir) config.projectsMemoryDir = normalizedProjectsMemoryDir;
157
186
  }
158
187
  if (
159
- typeof parsed.sessionSearch === "object" &&
160
- parsed.sessionSearch !== null &&
188
+ isRecord(parsed.sessionSearch) &&
161
189
  isSessionSearchVariant(parsed.sessionSearch.variant)
162
190
  ) {
163
191
  config.sessionSearch = { variant: parsed.sessionSearch.variant };
@@ -204,7 +232,7 @@ export function loadConfig(configPath = DEFAULT_CONFIG_PATH): MemoryConfig {
204
232
  return config;
205
233
  }
206
234
  } catch {
207
- // Fall back to defaults on parse error or access issues
235
+ // Fall back to defaults on unexpected parse or access issues.
208
236
  }
209
237
  return { ...DEFAULT_CONFIG };
210
238
  }
@@ -29,12 +29,12 @@ export const DEFAULT_NUDGE_TOOL_CALLS = 15;
29
29
  export const DEFAULT_REVIEW_RECENT_MESSAGES = 0;
30
30
  export const DEFAULT_FLUSH_RECENT_MESSAGES = 0;
31
31
  /**
32
- * A consolidation run pays child-process boot plus a full LLM turn, which
33
- * routinely exceeds 60s — at the old 60s default the auto path was killed
34
- * mid-run on every attempt (#136). Configured values are honored verbatim,
35
- * including lower ones; `loadConfig` warns when a value below this is set.
32
+ * A consolidation run pays child-process boot plus a full LLM turn. Five
33
+ * minutes leaves room for slower models and larger memory stores. Configured
34
+ * values are honored verbatim, including lower ones; `loadConfig` warns when a
35
+ * value below this is set.
36
36
  */
37
- export const DEFAULT_CONSOLIDATION_TIMEOUT_MS = 180000;
37
+ export const DEFAULT_CONSOLIDATION_TIMEOUT_MS = 300000;
38
38
  /** Wall-clock grace after overflow before an automatic consolidation may run. */
39
39
  export const DEFAULT_OVERFLOW_GRACE_MS = 180000;
40
40
  export const DEFAULT_FAILURE_INJECTION_MAX_AGE_DAYS = 7;
@@ -337,64 +337,78 @@ export function registerConsolidateCommand(
337
337
  });
338
338
  }
339
339
 
340
- try {
341
- ctx.ui.notify(
342
- `🔄 Starting memory consolidation for ${targets.length} target${targets.length === 1 ? "" : "s"}...`,
343
- "info",
344
- );
345
- } catch {
346
- // Best-effort only. If the command context is already stale, continue
347
- // with the consolidation work rather than failing before it starts.
348
- }
349
-
350
- for (const item of targets) {
351
- const entries = entriesForTarget(item.store, item.target);
352
-
353
- if (entries.length === 0) {
354
- results.push(`${item.label}: (empty, nothing to consolidate)`);
355
- continue;
340
+ const setStatus = (text?: string) => {
341
+ try {
342
+ ctx.ui.setStatus("pi-hermes-memory:consolidation", text);
343
+ } catch {
344
+ // Best-effort progress feedback only.
356
345
  }
346
+ };
357
347
 
348
+ setStatus("Preparing memory consolidation…");
349
+ try {
358
350
  try {
359
351
  ctx.ui.notify(
360
- `⏳ Consolidating ${item.label}...`,
352
+ `🔄 Starting memory consolidation for ${targets.length} target${targets.length === 1 ? "" : "s"}...`,
361
353
  "info",
362
354
  );
363
355
  } catch {
364
- // Best-effort progress feedback only.
356
+ // Best-effort only. If the command context is already stale, continue
357
+ // with the consolidation work rather than failing before it starts.
365
358
  }
366
359
 
367
- const result = await triggerConsolidation(
368
- pi,
369
- item.store,
370
- item.target,
371
- ctx.signal,
372
- timeoutMs,
373
- item.toolTarget,
374
- llmConfig,
375
- ctx,
376
- dbManager,
377
- activeProjectName,
378
- deps,
379
- );
380
-
381
- if (result.consolidated) {
382
- await item.store.loadFromDisk();
383
- results.push(`${item.label}: ✅ consolidated`);
384
- } else {
385
- results.push(`${item.label}: ❌ ${result.error}`);
360
+ for (const [index, item] of targets.entries()) {
361
+ const entries = entriesForTarget(item.store, item.target);
362
+
363
+ if (entries.length === 0) {
364
+ results.push(`${item.label}: (empty, nothing to consolidate)`);
365
+ continue;
366
+ }
367
+
368
+ setStatus(`Consolidating ${item.label} (${index + 1}/${targets.length})…`);
369
+ try {
370
+ ctx.ui.notify(
371
+ `⏳ Consolidating ${item.label}...`,
372
+ "info",
373
+ );
374
+ } catch {
375
+ // Best-effort progress feedback only.
376
+ }
377
+
378
+ const result = await triggerConsolidation(
379
+ pi,
380
+ item.store,
381
+ item.target,
382
+ ctx.signal,
383
+ timeoutMs,
384
+ item.toolTarget,
385
+ llmConfig,
386
+ ctx,
387
+ dbManager,
388
+ activeProjectName,
389
+ deps,
390
+ );
391
+
392
+ if (result.consolidated) {
393
+ await item.store.loadFromDisk();
394
+ results.push(`${item.label}: ✅ consolidated`);
395
+ } else {
396
+ results.push(`${item.label}: ❌ ${result.error}`);
397
+ }
386
398
  }
387
- }
388
399
 
389
- const summary = `\n 🔄 Memory Consolidation\n ${"─".repeat(30)}\n${results.map((r) => ` ${r}`).join("\n")}`;
400
+ const summary = `\n 🔄 Memory Consolidation\n ${"─".repeat(30)}\n${results.map((r) => ` ${r}`).join("\n")}`;
390
401
 
391
- try {
392
- ctx.ui.notify(summary, "info");
393
- } catch {
394
- // Child consolidation can indirectly trigger a runtime reload/session
395
- // replacement. If that happens, the original command ctx is stale by
396
- // the time we reach the final summary, so the command should exit
397
- // quietly instead of surfacing a stale-ctx error.
402
+ try {
403
+ ctx.ui.notify(summary, "info");
404
+ } catch {
405
+ // Child consolidation can indirectly trigger a runtime reload/session
406
+ // replacement. If that happens, the original command ctx is stale by
407
+ // the time we reach the final summary, so the command should exit
408
+ // quietly instead of surfacing a stale-ctx error.
409
+ }
410
+ } finally {
411
+ setStatus(undefined);
398
412
  }
399
413
  },
400
414
  });
@@ -7,9 +7,11 @@ import { loadConfig } from "../src/config.js";
7
7
  import { AGENT_ROOT } from "../src/paths.js";
8
8
 
9
9
  const TEST_CONFIG_PATH = path.join(os.tmpdir(), `hermes-memory-config-test-${process.pid}.json`);
10
+ const TEST_SETTINGS_PATH = path.join(os.tmpdir(), `hermes-settings-test-${process.pid}.json`);
10
11
 
11
12
  afterEach(() => {
12
13
  fs.rmSync(TEST_CONFIG_PATH, { force: true });
14
+ fs.rmSync(TEST_SETTINGS_PATH, { force: true });
13
15
  });
14
16
 
15
17
  describe("loadConfig", () => {
@@ -30,7 +32,7 @@ describe("loadConfig", () => {
30
32
  assert.strictEqual(config.flushRecentMessages, 0);
31
33
  assert.strictEqual(config.memoryOverflowStrategy, "auto-consolidate");
32
34
  assert.strictEqual(config.autoConsolidate, true);
33
- assert.strictEqual(config.consolidationTimeoutMs, 180000);
35
+ assert.strictEqual(config.consolidationTimeoutMs, 300000);
34
36
  assert.strictEqual(config.overflowGraceMs, 180000);
35
37
  assert.strictEqual(config.autoConsolidationWarnOnFailure, true);
36
38
  assert.strictEqual(config.failureInjectionEnabled, true);
@@ -65,7 +67,7 @@ describe("loadConfig", () => {
65
67
  "a lower configured value must be honored, not clamped",
66
68
  );
67
69
  assert.strictEqual(warnings.length, 1, "a sub-default value should warn once");
68
- assert.match(warnings[0], /60000ms.*below the 180000ms default/);
70
+ assert.match(warnings[0], /60000ms.*below the 300000ms default/);
69
71
  } finally {
70
72
  console.warn = originalWarn;
71
73
  }
@@ -112,6 +114,28 @@ describe("loadConfig", () => {
112
114
  assert.strictEqual(config.reviewEnabled, true);
113
115
  });
114
116
 
117
+ it("loads hermesMemory from settings.json with precedence over the legacy config", () => {
118
+ fs.mkdirSync(path.dirname(TEST_CONFIG_PATH), { recursive: true });
119
+ fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({
120
+ llmThinkingOverride: "high",
121
+ consolidationTimeoutMs: 240000,
122
+ }));
123
+ fs.writeFileSync(TEST_SETTINGS_PATH, JSON.stringify({
124
+ theme: "dark",
125
+ hermesMemory: {
126
+ llmThinkingOverride: "off",
127
+ llmModelOverride: " tokenin/deepseek-v4.1-flash ",
128
+ consolidationTimeoutMs: 300000,
129
+ },
130
+ }));
131
+
132
+ const config = loadConfig(TEST_CONFIG_PATH, TEST_SETTINGS_PATH);
133
+ assert.strictEqual(config.llmThinkingOverride, "off");
134
+ assert.strictEqual(config.llmModelOverride, "tokenin/deepseek-v4.1-flash");
135
+ assert.strictEqual(config.consolidationTimeoutMs, 300000);
136
+ assert.strictEqual(config.memoryMode, "policy-only");
137
+ });
138
+
115
139
  it("only accepts boolean quickCheckOnOpen overrides", () => {
116
140
  fs.mkdirSync(path.dirname(TEST_CONFIG_PATH), { recursive: true });
117
141
  fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({ quickCheckOnOpen: "false" }));
@@ -631,6 +631,7 @@ describe("registerConsolidateCommand", () => {
631
631
  it("includes project memory when a project store is available", async () => {
632
632
  let handler: any;
633
633
  const notifications: string[] = [];
634
+ const statuses: Array<[string, string | undefined]> = [];
634
635
  let projectReloaded = false;
635
636
 
636
637
  const pi = {
@@ -655,7 +656,10 @@ describe("registerConsolidateCommand", () => {
655
656
  registerConsolidateCommand(pi, mockStore, 60000, projectStore, "demo-project");
656
657
  await handler({}, {
657
658
  signal: undefined,
658
- ui: { notify: (message: string) => { notifications.push(message); } },
659
+ ui: {
660
+ notify: (message: string) => { notifications.push(message); },
661
+ setStatus: (key: string, text: string | undefined) => { statuses.push([key, text]); },
662
+ },
659
663
  });
660
664
 
661
665
  assert.strictEqual(execCalls.length, 4, "should consolidate memory, user, failure, and project stores");
@@ -670,6 +674,8 @@ describe("registerConsolidateCommand", () => {
670
674
  assert.ok(projectReloaded, "project store should reload after consolidation");
671
675
  assert.ok(notifications.some((message) => message.includes("Starting memory consolidation")), "should show an initial progress notification");
672
676
  assert.ok(notifications.some((message) => message.includes("⏳ Consolidating memory")), "should show per-target progress");
677
+ assert.ok(statuses.some(([key, text]) => key === "pi-hermes-memory:consolidation" && text?.includes("Consolidating memory")), "should publish busy progress");
678
+ assert.deepStrictEqual(statuses.at(-1), ["pi-hermes-memory:consolidation", undefined], "should clear busy progress when done");
673
679
  const finalNotification = notifications[notifications.length - 1] ?? "";
674
680
  assert.ok(finalNotification.includes("failure: ✅ consolidated"), "final notification should include failure result");
675
681
  assert.ok(finalNotification.includes("project:demo-project: ✅ consolidated"), "final notification should include project result");
@@ -1186,7 +1186,11 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1186
1186
  const deliveredEntry = { ...entry, message: injectedMessage, replyCommand };
1187
1187
  replyTracker.queueTurnContext({ from: entry.from, message: injectedMessage, receivedAt: Date.now() });
1188
1188
  const senderDisplay = entry.from.name || entry.from.id.slice(0, 8);
1189
- const replyInstruction = replyCommand ? `\n\nTo reply, use the intercom tool: ${replyCommand}` : "";
1189
+ // The tool may be an inactive optional capability: say how to activate it, not just its call.
1190
+ const activateHint = pi.getActiveTools().includes("intercom")
1191
+ ? ""
1192
+ : ' (not active yet: call capability_discover with name "intercom" first)';
1193
+ const replyInstruction = replyCommand ? `\n\nTo reply, use the intercom tool${activateHint}: ${replyCommand}` : "";
1190
1194
  const deliveryMetadata = formatInboundDeliveryMetadata(injectedMessage);
1191
1195
  pi.sendMessage(
1192
1196
  {
@@ -41,9 +41,11 @@ esac
41
41
  function makePi() {
42
42
  const handlers = new Map<string, Function[]>();
43
43
  const pi = {
44
- exec: async (cmd: string, args: string[], opts?: { timeout?: number }) =>
44
+ exec: async (cmd: string, args: string[]) =>
45
45
  new Promise((resolve) => {
46
- execFile(cmd, args, { timeout: opts?.timeout }, (error, stdout, stderr) => {
46
+ // The shim runs under the suite's own load: enforcing the extension's production
47
+ // 2s budget here kills it, which the extension reports as a failed probe.
48
+ execFile(cmd, args, (error, stdout, stderr) => {
47
49
  if (error) {
48
50
  // execFile reports non-zero exits as errors; keep the real exit code.
49
51
  const code =
@@ -63,8 +65,14 @@ function makePi() {
63
65
  return { pi: pi as any, handlers };
64
66
  }
65
67
 
68
+ // Registration is deferred off startup (the managed-binary probe may spawn a process),
69
+ // so hook installation can outlast vi.waitFor's 1s default on a loaded machine.
70
+ function waitFor<T>(assertion: () => T | Promise<T>): Promise<T> {
71
+ return vi.waitFor(assertion, { timeout: 5_000 });
72
+ }
73
+
66
74
  async function getToolCall(handlers: Map<string, Function[]>): Promise<Function> {
67
- await vi.waitFor(() => expect(handlers.has("tool_call")).toBe(true));
75
+ await waitFor(() => expect(handlers.has("tool_call")).toBe(true));
68
76
  return handlers.get("tool_call")![0]!;
69
77
  }
70
78
 
@@ -131,7 +139,7 @@ posixOnly("rtk extension", () => {
131
139
  const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
132
140
  const { pi, handlers } = makePi();
133
141
  rtkExtension(pi);
134
- await vi.waitFor(() => expect(warn).toHaveBeenCalledWith(expect.stringContaining("rtk gain failed")));
142
+ await waitFor(() => expect(warn).toHaveBeenCalledWith(expect.stringContaining("rtk gain failed")));
135
143
  expect(handlers.has("tool_call")).toBe(false);
136
144
  } finally {
137
145
  if (previousPath) process.env.PATH = previousPath;
@@ -156,7 +164,7 @@ posixOnly("rtk extension", () => {
156
164
  // Override exec so --version fails.
157
165
  pi.exec = vi.fn(async () => ({ code: 1, stdout: "", stderr: "not found", killed: false }));
158
166
  rtkExtension(pi);
159
- await vi.waitFor(() => expect(warn).toHaveBeenCalledWith(expect.stringContaining("failed --version")));
167
+ await waitFor(() => expect(warn).toHaveBeenCalledWith(expect.stringContaining("failed --version")));
160
168
  expect(handlers.has("tool_call")).toBe(false);
161
169
  });
162
170
 
@@ -166,7 +174,7 @@ posixOnly("rtk extension", () => {
166
174
  ensureToolMock.mockResolvedValue("rtk");
167
175
  pi.exec = vi.fn(async () => ({ code: 0, stdout: "garbage output", stderr: "", killed: false }));
168
176
  rtkExtension(pi);
169
- await vi.waitFor(() => expect(warn).toHaveBeenCalledWith(expect.stringContaining("could not parse version")));
177
+ await waitFor(() => expect(warn).toHaveBeenCalledWith(expect.stringContaining("could not parse version")));
170
178
  expect(handlers.has("tool_call")).toBe(false);
171
179
  });
172
180
 
@@ -176,7 +184,7 @@ posixOnly("rtk extension", () => {
176
184
  ensureToolMock.mockResolvedValue("rtk");
177
185
  pi.exec = vi.fn(async () => ({ code: 0, stdout: " ", stderr: "", killed: false }));
178
186
  rtkExtension(pi);
179
- await vi.waitFor(() => expect(warn).toHaveBeenCalledWith(expect.stringContaining("<empty output>")));
187
+ await waitFor(() => expect(warn).toHaveBeenCalledWith(expect.stringContaining("<empty output>")));
180
188
  expect(handlers.has("tool_call")).toBe(false);
181
189
  });
182
190
 
@@ -186,7 +194,7 @@ posixOnly("rtk extension", () => {
186
194
  ensureToolMock.mockResolvedValue("rtk");
187
195
  pi.exec = vi.fn(async () => ({ code: 0, stdout: "rtk 0.22.0", stderr: "", killed: false }));
188
196
  rtkExtension(pi);
189
- await vi.waitFor(() => expect(warn).toHaveBeenCalledWith(expect.stringContaining("too old")));
197
+ await waitFor(() => expect(warn).toHaveBeenCalledWith(expect.stringContaining("too old")));
190
198
  expect(handlers.has("tool_call")).toBe(false);
191
199
  });
192
200
 
@@ -195,7 +203,7 @@ posixOnly("rtk extension", () => {
195
203
  ensureToolMock.mockRejectedValue(new Error("download failed"));
196
204
  const { pi, handlers } = makePi();
197
205
  rtkExtension(pi);
198
- await vi.waitFor(() => expect(warn).toHaveBeenCalledWith(expect.stringContaining("managed installation failed")));
206
+ await waitFor(() => expect(warn).toHaveBeenCalledWith(expect.stringContaining("managed installation failed")));
199
207
  expect(handlers.has("tool_call")).toBe(false);
200
208
  });
201
209
 
@@ -204,7 +212,7 @@ posixOnly("rtk extension", () => {
204
212
  ensureToolMock.mockRejectedValue("plain string failure");
205
213
  const { pi, handlers } = makePi();
206
214
  rtkExtension(pi);
207
- await vi.waitFor(() => expect(warn).toHaveBeenCalledWith(expect.stringContaining("plain string failure")));
215
+ await waitFor(() => expect(warn).toHaveBeenCalledWith(expect.stringContaining("plain string failure")));
208
216
  expect(handlers.has("tool_call")).toBe(false);
209
217
  });
210
218
 
@@ -213,7 +221,7 @@ posixOnly("rtk extension", () => {
213
221
  ensureToolMock.mockResolvedValue(undefined);
214
222
  const { pi, handlers } = makePi();
215
223
  rtkExtension(pi);
216
- await vi.waitFor(() => expect(warn).toHaveBeenCalledWith(expect.stringContaining("unavailable")));
224
+ await waitFor(() => expect(warn).toHaveBeenCalledWith(expect.stringContaining("unavailable")));
217
225
  expect(handlers.has("tool_call")).toBe(false);
218
226
  });
219
227
 
@@ -225,7 +233,7 @@ posixOnly("rtk extension", () => {
225
233
  throw new Error("spawn ENOENT");
226
234
  });
227
235
  rtkExtension(pi);
228
- await vi.waitFor(() => expect(warn).toHaveBeenCalledWith(expect.stringContaining("verification failed")));
236
+ await waitFor(() => expect(warn).toHaveBeenCalledWith(expect.stringContaining("verification failed")));
229
237
  expect(handlers.has("tool_call")).toBe(false);
230
238
  });
231
239
 
@@ -237,7 +245,7 @@ posixOnly("rtk extension", () => {
237
245
  throw "string failure";
238
246
  });
239
247
  rtkExtension(pi);
240
- await vi.waitFor(() => expect(warn).toHaveBeenCalledWith(expect.stringContaining("string failure")));
248
+ await waitFor(() => expect(warn).toHaveBeenCalledWith(expect.stringContaining("string failure")));
241
249
  expect(handlers.has("tool_call")).toBe(false);
242
250
  });
243
251
 
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { calculateLiveTps, calculateReliableTps, type TpsTiming } from "./tps.ts";
2
+ import { calculateLiveTps, calculateReliableTps, setupTpsTracker, type TpsTiming } from "./tps.ts";
3
3
 
4
4
  function timing(overrides: Partial<TpsTiming> = {}): TpsTiming {
5
5
  return {
@@ -23,6 +23,37 @@ describe("calculateLiveTps", () => {
23
23
  });
24
24
  });
25
25
 
26
+ describe("setupTpsTracker with anthropic-style usage", () => {
27
+ it("ignores the tiny usage.output seeded at message_start while streaming", async () => {
28
+ const handlers = new Map<string, (event: any, ctx: any) => Promise<void>>();
29
+ const statuses: string[] = [];
30
+ const ctx = { ui: { setStatus: (_k: string, v: string) => statuses.push(v), notify: () => {}, theme: { fg: (_c: string, t: string) => t } } };
31
+ setupTpsTracker({ on: (name: string, fn: any) => handlers.set(name, fn) } as any);
32
+
33
+ let now = 0;
34
+ const realNow = performance.now;
35
+ performance.now = () => now;
36
+ try {
37
+ const message = { role: "assistant", provider: "anthropic", model: "claude", usage: { output: 1 } };
38
+ await handlers.get("agent_start")!({}, ctx);
39
+ await handlers.get("message_start")!({ message }, ctx);
40
+ for (let i = 0; i < 20; i++) {
41
+ now += 50;
42
+ await handlers.get("message_update")!({ message, assistantMessageEvent: { type: "text_delta", delta: "x".repeat(40) } }, ctx);
43
+ }
44
+ // 20 deltas * 10 est. tokens over ~1s => ~200 tok/s, not ~1 tok/s
45
+ expect(statuses.at(-1)).toBe("211 tok/s");
46
+
47
+ message.usage.output = 200;
48
+ await handlers.get("message_end")!({ message }, ctx);
49
+ await handlers.get("agent_end")!({}, ctx);
50
+ expect(statuses.at(-1)).toMatch(/^done [1-9]\d* t\/s \(main [1-9]\d* t\/s\)$/);
51
+ } finally {
52
+ performance.now = realNow;
53
+ }
54
+ });
55
+ });
56
+
26
57
  describe("calculateReliableTps", () => {
27
58
  it("uses active stream time for sufficiently sampled output", () => {
28
59
  expect(calculateReliableTps(100, timing())).toEqual({
@@ -230,7 +230,9 @@ export function setupTpsTracker(pi: ExtensionAPI): void {
230
230
  streamStart ??= now;
231
231
  estimatedStreamedTokens += Math.max(0, streamEvent.delta.length / 4);
232
232
  const officialTokens = generatedTokensFromUsage(asRecord(event.message.usage));
233
- const currentTokens = officialTokens > 0 ? officialTokens : estimatedStreamedTokens;
233
+ // Anthropic seeds usage.output (~1) at message_start and only finalizes it at message_delta,
234
+ // so a nonzero official count mid-stream is not authoritative; take whichever is larger.
235
+ const currentTokens = Math.max(officialTokens, estimatedStreamedTokens);
234
236
  const tps = calculateLiveTps(currentTokens, now - streamStart);
235
237
  if (tps !== null) {
236
238
  ctx.ui.setStatus("tps", ctx.ui.theme.fg("accent", `${tps} tok/s`));
package/docs/settings.md CHANGED
@@ -49,8 +49,8 @@ Use `/trust` in interactive mode to save a project trust decision for future ses
49
49
  ### Jev Advisory Routing
50
50
 
51
51
  The bundled `jev-advisory-routing` extension uses the Jev decisions model for bounded opt-in
52
- routing. Every route stays off until it is enabled in `jevAdvisory`; the two routes share one
53
- Jev provider/model pair:
52
+ routing. The host-side routes stay off until they are enabled in `jevAdvisory`; the agent's own
53
+ `ask` route is on by default. All three share one Jev provider/model pair:
54
54
 
55
55
  - `memory` — only after an explicit durable-memory cue (for example “the convention we
56
56
  decided” or “don't repeat the past failure”), Jev chooses one read-only local
@@ -58,6 +58,14 @@ Jev provider/model pair:
58
58
  - `recommendations` — one discovered skill or prompt workflow that fits, or a proportionate
59
59
  verification level. Nothing is loaded, started, or executed: the recommendation is context for the
60
60
  agent, and required project/workflow gates are unchanged.
61
+ - `ask` — the agent-callable `ask_jev` tool (on by default). The agent decides when to call it: it
62
+ passes its own prose, file `paths`, and one `command`, and gets typed choice/score/noul answers back —
63
+ never the file contents or command output, so a verdict about code costs a few lines of context
64
+ instead of the files. Files must be inside the working directory, secret-named files (`.env`,
65
+ `*.pem`, `id_rsa*`, `auth.json`, …) are refused, and the command runs through the bash tool's local
66
+ shell. Whatever the agent passes is sent to Jev. Without Token-In credentials the tool is taken out
67
+ of the agent's loadout before each run (it returns after `/tokenin add`, no reload needed), and a call
68
+ that slips through reads no file and runs no command.
61
69
 
62
70
  | Setting | Type | Default | Description |
63
71
  |---------|------|---------|-------------|
@@ -66,11 +74,15 @@ Jev provider/model pair:
66
74
  | `jevAdvisory.baseUrl` | string | inherited | Base URL override; defaults to any registered model of `provider` |
67
75
  | `jevAdvisory.routes.memory.enabled` | boolean | `false` | Enable the memory-lookup route |
68
76
  | `jevAdvisory.routes.recommendations.enabled` | boolean | `false` | Enable skill/workflow and verification recommendations |
69
- | `<route>.timeoutMs` | number | `8000` | Route request timeout (ms); memory is capped at 750ms |
77
+ | `jevAdvisory.routes.ask.enabled` | boolean | `true` | Offer the agent the `ask_jev` tool; set `false` to turn it off |
78
+ | `<route>.timeoutMs` | number | `8000` | Route request timeout (ms); memory is capped at 750ms; `ask` defaults to `15000` |
70
79
  | `<route>.minConfidence` | number | `0.6` | Below this Jev confidence the route abstains |
71
80
  | `<route>.contextTurns` | number | `4` | Prior user turns sent as recommendation context; memory sends only the current bounded prompt |
72
81
  | `<route>.contextChars` | number | `4000` | Character budget for recommendation context |
73
- | `<route>.payloadBytes` | number | `8192` | Hard cap on the serialized decision request |
82
+ | `<route>.payloadBytes` | number | `8192` | Hard cap on the serialized decision request; `ask` defaults to `32768` |
83
+
84
+ `ask` ignores `minConfidence`, `contextTurns`, and `contextChars`: it returns every confidence to the
85
+ agent, and its context is whatever the agent put in the request.
74
86
 
75
87
  Only idle, top-level, interactive prompts are routed: queued steering/follow-up input, slash
76
88
  commands, and extension-injected turns are skipped, and each turn is routed at most once. The memory
@@ -99,16 +111,28 @@ needed: a compact `capability_catalog` lists them, `capability_discover` activat
99
111
  current run, and `capability_skill_show` loads one skill's full instructions. Set
100
112
  `SELESAI_CAPABILITY_GATEWAY=0` to disable the gateway and keep every tool visible.
101
113
 
102
- Routing has two rungs:
114
+ Routing has three rungs:
103
115
 
104
116
  1. A deterministic router activates a tool when the prompt uniquely matches its name, alias, or
105
117
  discovery summary. Skills are never auto-loaded or auto-selected.
106
118
  2. Default-on Jev tie-breaking: when the deterministic router returns an ambiguous lexical hint
107
119
  among optional tools, the Jev decisions model is asked which of two or three hinted tools (or
108
120
  `none`) should be exposed. Jev sees only the bounded current prompt and each hinted tool's compact
109
- discovery line — never conversation history, tool schemas, or the full catalog. A prompt with no
121
+ discovery line — never conversation history or tool schemas. A prompt with no
110
122
  lexical signal, a unique activation, and a skill-only match never reach Jev. Token-In credentials
111
123
  are required; without them, no Jev request is sent and Selesai prompts you to run `/tokenin add`.
124
+ 3. The agent-driven route: `capability_discover` also takes a `job` instead of a name. Tools and
125
+ skills are separate decisions — a tool is callable code from an extension or MCP server that the
126
+ agent may use many times, a skill is a written procedure it reads once — so one Jev request asks
127
+ two questions, each from catalog metadata only (never a schema or skill body), each allowed to
128
+ answer `none`. A chosen tool is activated for the run and its parameters are returned; a chosen
129
+ skill is named for `capability_skill_show`. Tools are offered first, so only skills can be left out
130
+ of an oversized catalog, and a `none` then says how many were not considered. A skill found in
131
+ several sources is offered once. While Jev has no credential, the `job` route is left out of the
132
+ agent's instructions and the agent is pointed at `capability_catalog` plus an exact `name`.
133
+
134
+ All Jev features share one warning per session, shown the first time one of them actually needs the
135
+ missing credential; a user without a subscription is not warned just for starting a session.
112
136
 
113
137
  | Setting | Type | Default | Description |
114
138
  |---------|------|---------|-------------|
@@ -117,8 +141,9 @@ Routing has two rungs:
117
141
  | `capabilityGateway.routing.jev.model` | string | `"jev-1.13"` | Decisions model the tie-breaker calls |
118
142
  | `capabilityGateway.routing.jev.baseUrl` | string | inherited | Base URL override; defaults to any registered model of `provider` |
119
143
  | `capabilityGateway.routing.jev.timeoutMs` | number | `1000` | Pre-turn request timeout (ms); hard-capped at `2000` |
144
+ | `capabilityGateway.routing.jev.discoverTimeoutMs` | number | `5000` | Timeout (ms) for `capability_discover({ job })`; hard-capped at `15000` |
120
145
  | `capabilityGateway.routing.jev.minConfidence` | number | `0.6` | Below this Jev confidence the tie-breaker abstains |
121
- | `capabilityGateway.routing.jev.payloadBytes` | number | `8192` | Hard cap on the serialized decision request |
146
+ | `capabilityGateway.routing.jev.payloadBytes` | number | `32768` | Hard cap on the serialized decision request; the pre-turn tie-break uses a few hundred bytes of it |
122
147
 
123
148
  This area is independent of `jevAdvisory`: gateway routing reads only
124
149
  `capabilityGateway.routing.jev` and shares just the Jev provider/model deployment identity. A
@@ -362,6 +387,20 @@ When selesai reads extensions from both `~/.selesai/agent/extensions/` and `~/.p
362
387
 
363
388
  Keys are top-level entry names (dir name for packaged extensions, file name for loose `.ts`). Values are `"selesai"` or `"pi"`. See [Shared Host Extensions](shared-host-extensions.md) for the full flow.
364
389
 
390
+ ## Bundled extension settings
391
+
392
+ The `pi-hermes-memory` extension reads its options from the `hermesMemory` object in global `~/.selesai/agent/settings.json`:
393
+
394
+ ```json
395
+ {
396
+ "hermesMemory": {
397
+ "llmThinkingOverride": "off",
398
+ "consolidationTimeoutMs": 300000
399
+ }
400
+ }
401
+ ```
402
+
403
+ These settings load at startup and take precedence over the legacy `hermes-memory-config.json` fallback. `llmModelOverride` is optional and uses `provider/model` format (for example, `tokenin/deepseek-v4.1-flash` if your account has access); leave it unset to use the active model. See the [memory extension configuration reference](../src/extensions/pi-hermes-memory/README.md#configuration) for all options.
365
404
 
366
405
  ## Example
367
406
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selesai/code",
3
- "version": "0.13.33",
3
+ "version": "0.13.34",
4
4
  "description": "Maintained, extension-first Pi coding agent with built-in workflows, subagents, web research, questions, skills, and an enhanced terminal UI.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -55,8 +55,8 @@
55
55
  "clean": "shx rm -rf dist",
56
56
  "dev": "tsx src/cli.ts",
57
57
  "dev:print": "tsx src/cli.ts --print",
58
- "test": "vitest run src/__tests__/model-registry-completion.test.ts src/extensions/jev/decisions.test.ts src/extensions/capability-gateway/routing.test.ts src/core/settings-manager-auto-handoff.test.ts src/core/remote-catalog-provider.test.ts src/extensions/jev-advisory-memory.test.ts src/extensions/jev-advisory-recommendations.test.ts src/extensions/jev-advisory-lifecycle.test.ts src/extensions/undo.test.ts src/extensions/tps.test.ts src/extensions/pi-graft src/extensions/agent-browser.test.ts src/extensions/auto-session-name.test.ts src/extensions/context-compaction-reminder.test.ts src/extensions/copy-turn.test.ts src/extensions/grep-app/index.test.ts src/extensions/inline-skills.test.ts src/extensions/rtk.test.ts src/extensions/handoff-new.test.ts src/extensions/question/tests src/extensions/ponytail/test src/__tests__/tokenin-search.test.ts src/__tests__/tokenin-onboarding.test.ts src/__tests__/model-registry-defaults.test.ts src/core/system-prompt.test.ts src/core/session-format.test.ts test/settings-manager-compaction.test.ts test/suite/agent-session-runtime.test.ts test/suite/agent-session-prompt.test.ts test/suite/agent-session-queue.test.ts test/suite/agent-session-retry-events.test.ts test/suite/agent-session-compaction.test.ts test/suite/agent-session-compaction-model-overrides.test.ts test/suite/agent-session-bash-persistence.test.ts test/suite/agent-session-model-extension.test.ts test/clipboard.test.ts test/clipboard-command.test.ts test/clipboard-image.test.ts test/clipboard-image-bmp-conversion.test.ts test/clipboard-image-native-errors.test.ts src/cli/args.test.ts",
59
- "test:coverage": "vitest run --coverage src/__tests__/model-registry-completion.test.ts src/extensions/jev/decisions.test.ts src/extensions/capability-gateway/routing.test.ts src/core/settings-manager-auto-handoff.test.ts src/core/remote-catalog-provider.test.ts src/extensions/jev-advisory-memory.test.ts src/extensions/jev-advisory-recommendations.test.ts src/extensions/jev-advisory-lifecycle.test.ts src/extensions/undo.test.ts src/extensions/tps.test.ts src/extensions/agent-browser.test.ts src/extensions/auto-session-name.test.ts src/extensions/context-compaction-reminder.test.ts src/extensions/copy-turn.test.ts src/extensions/grep-app/index.test.ts src/extensions/inline-skills.test.ts src/extensions/rtk.test.ts src/extensions/handoff-new.test.ts src/extensions/question/tests src/extensions/ponytail/test src/__tests__/tokenin-search.test.ts src/__tests__/tokenin-onboarding.test.ts src/__tests__/model-registry-defaults.test.ts src/core/system-prompt.test.ts src/core/session-format.test.ts test/settings-manager-compaction.test.ts test/suite/agent-session-runtime.test.ts test/suite/agent-session-prompt.test.ts test/suite/agent-session-queue.test.ts test/suite/agent-session-retry-events.test.ts test/suite/agent-session-compaction.test.ts test/suite/agent-session-compaction-model-overrides.test.ts test/suite/agent-session-bash-persistence.test.ts test/suite/agent-session-model-extension.test.ts test/clipboard.test.ts test/clipboard-command.test.ts test/clipboard-image.test.ts test/clipboard-image-bmp-conversion.test.ts test/clipboard-image-native-errors.test.ts src/cli/args.test.ts",
58
+ "test": "vitest run src/__tests__/model-registry-completion.test.ts src/extensions/jev/decisions.test.ts src/extensions/capability-gateway/routing.test.ts src/core/settings-manager-auto-handoff.test.ts src/core/remote-catalog-provider.test.ts src/extensions/jev-advisory-memory.test.ts src/extensions/jev-advisory-recommendations.test.ts src/extensions/jev-ask-tool.test.ts src/extensions/jev-advisory-lifecycle.test.ts src/extensions/undo.test.ts src/extensions/tps.test.ts src/extensions/pi-graft src/extensions/agent-browser.test.ts src/extensions/auto-session-name.test.ts src/extensions/context-compaction-reminder.test.ts src/extensions/copy-turn.test.ts src/extensions/grep-app/index.test.ts src/extensions/inline-skills.test.ts src/extensions/rtk.test.ts src/extensions/handoff-new.test.ts src/extensions/question/tests src/extensions/ponytail/test src/__tests__/tokenin-search.test.ts src/__tests__/tokenin-onboarding.test.ts src/__tests__/model-registry-defaults.test.ts src/core/system-prompt.test.ts src/core/session-format.test.ts test/settings-manager-compaction.test.ts test/suite/agent-session-runtime.test.ts test/suite/agent-session-prompt.test.ts test/suite/agent-session-queue.test.ts test/suite/agent-session-retry-events.test.ts test/suite/agent-session-compaction.test.ts test/suite/agent-session-compaction-model-overrides.test.ts test/suite/agent-session-bash-persistence.test.ts test/suite/agent-session-model-extension.test.ts test/clipboard.test.ts test/clipboard-command.test.ts test/clipboard-image.test.ts test/clipboard-image-bmp-conversion.test.ts test/clipboard-image-native-errors.test.ts src/cli/args.test.ts",
59
+ "test:coverage": "vitest run --coverage src/__tests__/model-registry-completion.test.ts src/extensions/jev/decisions.test.ts src/extensions/capability-gateway/routing.test.ts src/core/settings-manager-auto-handoff.test.ts src/core/remote-catalog-provider.test.ts src/extensions/jev-advisory-memory.test.ts src/extensions/jev-advisory-recommendations.test.ts src/extensions/jev-ask-tool.test.ts src/extensions/jev-advisory-lifecycle.test.ts src/extensions/undo.test.ts src/extensions/tps.test.ts src/extensions/agent-browser.test.ts src/extensions/auto-session-name.test.ts src/extensions/context-compaction-reminder.test.ts src/extensions/copy-turn.test.ts src/extensions/grep-app/index.test.ts src/extensions/inline-skills.test.ts src/extensions/rtk.test.ts src/extensions/handoff-new.test.ts src/extensions/question/tests src/extensions/ponytail/test src/__tests__/tokenin-search.test.ts src/__tests__/tokenin-onboarding.test.ts src/__tests__/model-registry-defaults.test.ts src/core/system-prompt.test.ts src/core/session-format.test.ts test/settings-manager-compaction.test.ts test/suite/agent-session-runtime.test.ts test/suite/agent-session-prompt.test.ts test/suite/agent-session-queue.test.ts test/suite/agent-session-retry-events.test.ts test/suite/agent-session-compaction.test.ts test/suite/agent-session-compaction-model-overrides.test.ts test/suite/agent-session-bash-persistence.test.ts test/suite/agent-session-model-extension.test.ts test/clipboard.test.ts test/clipboard-command.test.ts test/clipboard-image.test.ts test/clipboard-image-bmp-conversion.test.ts test/clipboard-image-native-errors.test.ts src/cli/args.test.ts",
60
60
  "prepare": "npm run build",
61
61
  "build": "npm run clean && tsgo -p tsconfig.build.json && shx chmod +x dist/cli.js dist/rpc-entry.js && npm run copy-assets",
62
62
  "copy-assets": "shx mkdir -p dist/modes/interactive/theme && shx cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && shx mkdir -p dist/modes/interactive/assets && shx cp src/modes/interactive/assets/*.png dist/modes/interactive/assets/ && shx mkdir -p dist/core/export-html/vendor && shx cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/ && shx cp src/core/export-html/vendor/*.js dist/core/export-html/vendor/ && shx mkdir -p dist/defaults && shx cp src/defaults/* dist/defaults/ && shx mkdir -p dist/extensions && node scripts/copy-extensions.mjs && shx mkdir -p dist/themes && shx cp -r src/themes/. dist/themes/ && shx mkdir -p dist/skills && shx cp -r src/skills/. dist/skills/"