@yeaft/webchat-agent 1.0.639 → 1.0.640

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.639",
3
+ "version": "1.0.640",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -312,9 +312,16 @@ export function resolvePersistedAttachmentPath(attachmentPath, opts = {}) {
312
312
  }
313
313
  }
314
314
 
315
- /** Shared bounded read for model history and browser preview. Never follows symlinks. */
316
- function readPersistedAttachment(att, opts, maxBytes) {
317
- const absPath = resolvePersistedAttachmentPath(att?.path, opts);
315
+ /**
316
+ * Read a bounded, owner-scoped attachment for host consumers, never via symlinks.
317
+ * Ownership is derived from opts, not the supplied path or attachment metadata.
318
+ * @param {string} attachmentPath
319
+ * @param {{cwd?:string, legacyRoot?:string, yeaftDir?:string, sessionId?:string}} opts
320
+ * @param {number} maxBytes
321
+ * @returns {{path:string, buffer:Buffer, modified:string}}
322
+ */
323
+ export function readPersistedAttachmentFile(attachmentPath, opts, maxBytes) {
324
+ const absPath = resolvePersistedAttachmentPath(attachmentPath, opts);
318
325
  if (!absPath) throw new Error('file missing or outside this Session / 文件缺失或不属于本会话');
319
326
  const fd = openSync(absPath, constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0));
320
327
  try {
@@ -328,17 +335,23 @@ function readPersistedAttachment(att, opts, maxBytes) {
328
335
  if (!read) throw new Error('image changed while reading');
329
336
  offset += read;
330
337
  }
331
- return {
332
- data: buffer.toString('base64'),
333
- mimeType: att.mimeType || 'image/png',
334
- filename: att.name || basename(absPath),
335
- bytes: buffer.length,
336
- };
338
+ return { path: absPath, buffer, modified: stat.mtime.toISOString() };
337
339
  } finally {
338
340
  closeSync(fd);
339
341
  }
340
342
  }
341
343
 
344
+ /** Shared bounded read for model history and browser preview. Never follows symlinks. */
345
+ function readPersistedAttachment(att, opts, maxBytes) {
346
+ const { path, buffer } = readPersistedAttachmentFile(att?.path, opts, maxBytes);
347
+ return {
348
+ data: buffer.toString('base64'),
349
+ mimeType: att.mimeType || 'image/png',
350
+ filename: att.name || basename(path),
351
+ bytes: buffer.length,
352
+ };
353
+ }
354
+
342
355
  /** Short-lived web preview; missing/denied/oversized images have no preview. */
343
356
  export function persistedAttachmentPreviewPayload(att, opts = {}) {
344
357
  if (!att?.isImage || !att.path) return null;
package/yeaft/engine.js CHANGED
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { randomUUID, createHash } from 'crypto';
21
21
  import { QueryProgress, progressReminder } from './query-progress.js';
22
+ import { createWebResearchState } from './web-research.js';
22
23
  import { promises as fsp } from 'fs';
23
24
  import { join, resolve as resolvePath } from 'path';
24
25
  import { buildSystemPrompt, buildWorkerPrompt } from './prompts.js';
@@ -30,6 +31,7 @@ import {
30
31
  pickProjectDocFile,
31
32
  selectProjectDocContext,
32
33
  projectDocPathHintsFromToolCall,
34
+ projectDocToolCallNeedsWriteReview,
33
35
  projectDocWriteScopesNeedingReload,
34
36
  DEFAULT_PROJECT_DOC_MAX_BYTES,
35
37
  } from './sessions/project-doc.js';
@@ -1444,6 +1446,7 @@ export class Engine {
1444
1446
  effortDecision: snapshotEffortDecision(vpCtx?.effortDecision),
1445
1447
  requestIdentity: vpCtx?.requestIdentity,
1446
1448
  queryTurnId: vpCtx?.queryTurnId,
1449
+ webResearch: vpCtx?.webResearch,
1447
1450
  historyBeforeSeq: vpCtx?.historyBeforeSeq,
1448
1451
  yeaftDir: this.#yeaftDir,
1449
1452
  managedCliReady: this.#managedCliReady,
@@ -2596,6 +2599,9 @@ export class Engine {
2596
2599
  const queryDuplicateSuppressions = new Map();
2597
2600
  const queryValidationFailures = new Map();
2598
2601
  const fileReadObservations = new Map();
2602
+ // Success reuse and in-flight sharing must not cross user turns, Sessions,
2603
+ // or child Engines. Never copy this ledger into parentEngineDeps.
2604
+ const webResearch = createWebResearchState();
2599
2605
  let duplicateReminderAwaitingResponse = false;
2600
2606
  const queryNumber = (this.#__queryCounter = (this.#__queryCounter || 0) + 1);
2601
2607
 
@@ -4295,6 +4301,7 @@ export class Engine {
4295
4301
  effortDecision: snapshotEffortDecision(requestEffortDecision),
4296
4302
  requestIdentity,
4297
4303
  queryTurnId,
4304
+ webResearch,
4298
4305
  historyBeforeSeq: Number.isFinite(persistedQueryUser?.seq) ? persistedQueryUser.seq : parseSeqFromId(persistedQueryUser?.id),
4299
4306
  router,
4300
4307
  senderVpId,
@@ -4602,15 +4609,31 @@ export class Engine {
4602
4609
  : this.#tools.has(tc.name);
4603
4610
  const toolProjectDocPathHints = projectDocPathHintsFromToolCall(tc.name, tc.input);
4604
4611
  const readOnlyTool = hasTool ? isReadOnlyTool(this, tc.name, tc.input) : false;
4605
- const missingProjectDocScopes = hasTool && !readOnlyTool
4606
- ? projectDocWriteScopesNeedingReload(projectDocContext, toolProjectDocPathHints)
4612
+ const projectDocWrite = hasTool
4613
+ && projectDocToolCallNeedsWriteReview(tc.name, tc.input, readOnlyTool);
4614
+ const missingProjectDocScopes = projectDocWrite
4615
+ // Shell code is opaque: cwd alone cannot identify its write targets.
4616
+ // Load all omitted rules rather than interpreting command words as paths.
4617
+ ? projectDocWriteScopesNeedingReload(projectDocContext, tc.name === 'Bash' ? [] : toolProjectDocPathHints)
4607
4618
  : new Set();
4608
4619
  // A changed rule source was not present in the request that generated
4609
4620
  // this write, even when its scope label is unchanged. Never replay it.
4610
- const freshProjectDocSource = hasTool && !readOnlyTool
4621
+ const freshProjectDocSource = projectDocWrite
4611
4622
  ? this.#getProjectDocBlock(workDir) : projectDocSource;
4612
4623
  const projectDocSourceChanged = freshProjectDocSource !== projectDocSource;
4613
- const needsProjectDocReload = missingProjectDocScopes.size > 0 || projectDocSourceChanged;
4624
+ const nextProjectDocPathHints = [...new Set([
4625
+ ...projectDocLoadedPathHints, ...toolProjectDocPathHints,
4626
+ ])];
4627
+ const reloadedProjectDocContext = missingProjectDocScopes.size > 0 || projectDocSourceChanged
4628
+ ? selectProjectDocContext(freshProjectDocSource, {
4629
+ prompt, messages, pathHints: nextProjectDocPathHints,
4630
+ language: this.#config.language || 'en',
4631
+ forcedScopes: [...projectDocContext.selectedScopes, ...missingProjectDocScopes],
4632
+ })
4633
+ : projectDocContext;
4634
+ // Scope bookkeeping (including core sections already shown) is not a
4635
+ // new instruction. Only stop a write when its model-visible rules change.
4636
+ const needsProjectDocReload = reloadedProjectDocContext.text !== projectDocContext.text;
4614
4637
 
4615
4638
  if (abortSkipped) {
4616
4639
  output = `Skipped ${tc.name} because the turn was aborted before this tool started.`;
@@ -4659,22 +4682,15 @@ export class Engine {
4659
4682
  yield { type: 'tool_end', id: tc.id, name: tc.name, output, isError: true, suppressed: true, threadId: this.currentThreadId };
4660
4683
  } else if (needsProjectDocReload) {
4661
4684
  projectDocSource = freshProjectDocSource;
4662
- projectDocLoadedPathHints = [...new Set([
4663
- ...projectDocLoadedPathHints,
4664
- ...toolProjectDocPathHints,
4665
- ])];
4666
- const previouslySelectedProjectDocScopes = new Set(projectDocContext.selectedScopes);
4667
- projectDocContext = selectProjectDocContext(projectDocSource, {
4668
- prompt,
4669
- messages,
4670
- pathHints: projectDocLoadedPathHints,
4671
- language: this.#config.language || 'en',
4672
- forcedScopes: [...previouslySelectedProjectDocScopes, ...missingProjectDocScopes],
4673
- });
4685
+ projectDocLoadedPathHints = nextProjectDocPathHints;
4686
+ projectDocContext = reloadedProjectDocContext;
4674
4687
  const zh = String(this.#config?.language || '').toLowerCase().startsWith('zh');
4688
+ const target = toolProjectDocPathHints.length > 0
4689
+ ? (zh ? `路径 ${toolProjectDocPathHints.join(', ')}` : toolProjectDocPathHints.join(', '))
4690
+ : (zh ? `${tc.name} 写操作` : `the ${tc.name} write`);
4675
4691
  const notice = zh
4676
- ? `项目规则已针对路径 ${toolProjectDocPathHints.join(', ')} 重新加载。先复核新载入的规则,再重新提交写操作;本次调用未执行。`
4677
- : `Project rules were reloaded for ${toolProjectDocPathHints.join(', ')}. Review the newly loaded rules before resubmitting the write; this call was not executed.`;
4692
+ ? `项目规则已针对${target}重新加载。先复核新载入的规则,再重新提交写操作;本次调用未执行。`
4693
+ : `Project rules were reloaded for ${target}. Review the newly loaded rules before resubmitting the write; this call was not executed.`;
4678
4694
  promptNotices = [notice];
4679
4695
  systemPrompt = buildCurrentSystemPrompt();
4680
4696
  output = notice;
@@ -26,6 +26,18 @@ function resultHash(output) {
26
26
  return createHash('sha256').update(typeof output === 'string' ? output : JSON.stringify(output) || '').digest('hex');
27
27
  }
28
28
 
29
+ /** Web advice/reuse annotations are not new evidence; raw output stays intact. */
30
+ function observationOutput(call, output) {
31
+ if (call.name !== 'WebFetch' && call.name !== 'WebSearch') return output;
32
+ try {
33
+ const value = typeof output === 'string' ? JSON.parse(output.replace(/^Error:\s*/, '')) : output;
34
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return output;
35
+ const { researchGuidance, relatedPriorQuery, convergenceHint, reused, reuseReason,
36
+ priorQuery, priorAuthorizationFailure, hint, ...evidence } = value;
37
+ return evidence;
38
+ } catch { return output; }
39
+ }
40
+
29
41
  function failureKind(output, call, readOnly) {
30
42
  const text = typeof output === 'string' ? output : JSON.stringify(output) || '';
31
43
  const errorText = text.replace(/^Error:\s*/, '');
@@ -143,10 +155,11 @@ export class QueryProgress {
143
155
  // change duplicate detection, replay protection or loop-guard decisions.
144
156
  if (pendingOutcome) this.#pendingOutcomes.set(call.id, { stats, isError, executed });
145
157
  const key = `${call.name}:${argsHashOf(call.input)}`;
146
- const hash = resultHash(output);
158
+ const observation = observationOutput(call, output);
159
+ const hash = resultHash(observation);
147
160
  if (isError) {
148
161
  const prior = this.#failures.get(key);
149
- const kind = failureKind(output, call, readOnly);
162
+ const kind = failureKind(observation, call, readOnly);
150
163
  if (executed && !readOnly) {
151
164
  // A partial mutation may have changed even a previously unreadable
152
165
  // resource. Permit verification, never a replay of the mutation itself.
@@ -20,16 +20,20 @@ export const PRESENTATION_SCHEMA = obj({
20
20
  block('diagram', { nodes: arr(obj({ id: str(40), label: str(160) }, ['id', 'label']), 12), edges: arr(obj({ from: str(40), to: str(40), label: str(120) }, ['from', 'to']), 24, 0) }, ['nodes', 'edges']),
21
21
  block('image', { url: str(2000), alt: str(240), caption: str(1000) }, ['url', 'alt']),
22
22
  block('link', { url: str(2000), text: str(1000) }, ['url', 'text']),
23
- block('map', { points: arr(obj({ label: str(160), latitude: { type: 'number', minimum: -90, maximum: 90 }, longitude: { type: 'number', minimum: -180, maximum: 180 }, detail: str(1000) }, ['label', 'latitude', 'longitude']), 20), embed: obj({ provider: { type: 'string', enum: ['openstreetmap', 'google'] }, url: str(2000) }, ['provider']) }, ['points']),
23
+ block('map', { points: arr(obj({ label: str(160), latitude: { type: 'number', minimum: -90, maximum: 90 }, longitude: { type: 'number', minimum: -180, maximum: 180 }, detail: str(1000) }, ['label', 'latitude', 'longitude']), 20), embed: { anyOf: [
24
+ obj({ provider: { type: 'string', enum: ['openstreetmap'] }, url: str(2000) }, ['provider']),
25
+ obj({ provider: { type: 'string', enum: ['google'] }, url: str(2000) }, ['provider', 'url']),
26
+ ] } }, ['points']),
24
27
  ] }, 12),
25
28
  }, ['summary', 'blocks']);
26
29
 
27
- export const PRESENT_RESPONSE_DESCRIPTION = `Publish a rich user-visible response inside this turn: cards, metrics, comparison tables, bar/line charts, timelines, relationship diagrams, images, artifact links or maps. Cards may include an optional image {url,alt,fit:cover|contain} and source/action url; use contain for weather icons or team logos and cover for photography. Optional: use normal text for simple answers. Supply a self-contained summary for copying, search, model history and older clients; do not repeat the whole rich answer afterwards. Use only verified data, mark uncertainty, and never fabricate chart values or coordinates. No HTML/JavaScript, arbitrary SVG, styling or executable content. Image/link URLs must be credential-free HTTP(S); images load automatically in the reader’s browser without a referrer. Links open in the owning in-app browser by default; the reader can explicitly choose a new tab. Maps default to offline coordinate sketches. Set map.embed={provider:openstreetmap} for the dedicated OSM street-map embed, or {provider:google,url} with a verified official https://www.google.com/maps/embed?pb=... share URL (not a homepage or API-key URL). Embed availability depends on the reader's network; coordinates remain available. Never invent routes, ETAs, weather or scores; attach verified image sources and dated data. phase=progress continues work; phase=final is a delivery intent, not proof of task success. Runtime still owns persistence, cancellation and turn termination. Validation failure publishes nothing; correct the payload or answer in text.`;
30
+ export const PRESENT_RESPONSE_DESCRIPTION = `Publish a rich user-visible response inside this turn: cards, metrics, comparison tables, bar/line charts, timelines, relationship diagrams, images, artifact links or maps. Cards may include an optional image {url,alt,fit:cover|contain} and source/action url; use contain for weather icons or team logos and cover for photography. Optional: use normal text for simple answers. Supply a self-contained summary for copying, search, model history and older clients; do not repeat the whole rich answer afterwards. Use only verified data, mark uncertainty, and never fabricate chart values or coordinates. No HTML/JavaScript, arbitrary SVG, styling or executable content. Image/link URLs must be credential-free HTTP(S); images load automatically in the reader’s browser without a referrer. Links open in the owning in-app browser by default; the reader can explicitly choose a new tab. Maps default to offline coordinate sketches. Set map.embed={provider:openstreetmap} without a URL; runtime generates the dedicated OSM street-map embed, or {provider:google,url} with a verified official https://www.google.com/maps/embed?pb=... share URL (not a homepage or API-key URL). Embed availability depends on the reader's network; coordinates remain available. Never invent routes, ETAs, weather or scores; attach verified image sources and dated data. phase=progress continues work; phase=final is a delivery intent, not proof of task success. Runtime still owns persistence, cancellation and turn termination. Validation failure publishes nothing; correct the payload or answer in text.`;
28
31
 
29
32
  function check(value, schema, path) {
30
33
  if (schema.anyOf) {
31
- const candidate = schema.anyOf.find(s => value?.type === s.properties.type.enum[0]);
32
- if (!candidate) throw new Error(`${path}: unsupported block type`);
34
+ const key = schema.anyOf[0].properties.type ? 'type' : 'provider';
35
+ const candidate = schema.anyOf.find(s => s.properties[key].enum.includes(value?.[key]));
36
+ if (!candidate) throw new Error(`${path}.${key}: unsupported value`);
33
37
  return check(value, candidate, path);
34
38
  }
35
39
  if (schema.type === 'object') {
@@ -87,7 +91,25 @@ export function safePresentationUrl(value) {
87
91
 
88
92
  /** Only official map share embeds are allowed. API keys/configuration never enter transcripts. */
89
93
  export function normalizeMapEmbed(embed) {
90
- if (embed?.provider === 'openstreetmap' && embed.url === undefined) return { provider: 'openstreetmap' };
94
+ if (embed?.provider === 'openstreetmap') {
95
+ if (embed.url !== undefined) {
96
+ // Compatibility for official legacy OSM embeds. Never reuse a model URL:
97
+ // normalize it to provider-only so the reader generates the bounds/marker.
98
+ const url = new URL(safePresentationUrl(embed.url));
99
+ const keys = [...url.searchParams.keys()];
100
+ const tuple = (name, count) => {
101
+ const parts = (url.searchParams.get(name) || '').split(',');
102
+ return parts.length === count && parts.every(part => part.trim() && Number.isFinite(Number(part)));
103
+ };
104
+ if (url.origin !== 'https://www.openstreetmap.org' || url.pathname !== '/export/embed.html' || url.hash
105
+ || new Set(keys).size !== keys.length || keys.some(key => !['bbox', 'layer', 'marker'].includes(key))
106
+ || !tuple('bbox', 4) || (url.searchParams.has('marker') && !tuple('marker', 2))
107
+ || (url.searchParams.has('layer') && url.searchParams.get('layer') !== 'mapnik')) {
108
+ throw new Error('map: invalid legacy OpenStreetMap embed URL; use provider-only openstreetmap');
109
+ }
110
+ }
111
+ return { provider: 'openstreetmap' };
112
+ }
91
113
  if (embed?.provider === 'google') {
92
114
  const url = new URL(safePresentationUrl(embed.url));
93
115
  const params = [...url.searchParams];
@@ -201,22 +201,45 @@ export function selectProjectDocContext(text, {
201
201
  };
202
202
  }
203
203
 
204
- /** Extract concrete workspace path hints from a tool call input. */
204
+ // These tools manage runtime state, not project files. Do not confuse their
205
+ // side effects (or an Agent's target cwd) with a write in this workspace.
206
+ const PROJECT_DOC_LIFECYCLE_TOOLS = new Set([
207
+ 'Skill', 'EnterWorktree', 'ExitWorktree', 'AskUser', 'PresentResponse',
208
+ 'DiscoverTools', 'HistorySearch', 'ListTasks', 'ReadTaskLog', 'WaitTask',
209
+ 'CancelTask', 'SpawnAgent', 'Agent', 'UpdateAgent', 'PromptAgent', 'SendMessage',
210
+ 'WaitAgent', 'CloseAgent', 'ListAgents', 'RouteForward', 'CreateWorkItem', 'JsReplReset',
211
+ ]);
212
+
213
+ /** Project-rule review is separate from tool caching/concurrency metadata. */
214
+ export function projectDocToolCallNeedsWriteReview(toolName, input = {}, readOnly = false) {
215
+ if (readOnly || PROJECT_DOC_LIFECYCLE_TOOLS.has(toolName)) return false;
216
+ if (toolName === 'NotebookEdit') return input?.action !== 'read';
217
+ if (toolName === 'ImageGeneration') return Boolean(input?.output_path);
218
+ if (toolName === 'Bash') {
219
+ // Deliberately exact: no compound commands, substitutions, redirects or
220
+ // arbitrary options. Everything else remains a potential write.
221
+ const command = typeof input?.command === 'string' ? input.command.trim() : '';
222
+ return !/^(?:pwd(?:[ \t]+-[LP])?|git[ \t]+status(?:[ \t]+--short)?)$/.test(command);
223
+ }
224
+ // FileWrite/FileEdit/ApplyPatch and unknown extension tools fail closed.
225
+ return true;
226
+ }
227
+
228
+ /** Extract concrete workspace path hints, never shell code or lifecycle inputs. */
205
229
  export function projectDocPathHintsFromToolCall(toolName, input = {}) {
230
+ if (PROJECT_DOC_LIFECYCLE_TOOLS.has(toolName)) return [];
206
231
  const hints = [];
207
232
  for (const key of ['file_path', 'path', 'cwd', 'workDir', 'notebook_path', 'output_path']) {
208
233
  if (typeof input?.[key] === 'string' && input[key].trim()) hints.push(input[key].trim());
209
234
  }
210
- if (toolName === 'Bash' && typeof input?.command === 'string' && input.command.trim()) {
211
- hints.push(input.command.trim());
212
- }
213
235
  if (toolName === 'ApplyPatch' && typeof input?.patch === 'string') {
214
- for (const match of input.patch.matchAll(/^\+\+\+\s+(?:b\/)?(.+)$/gm)) {
236
+ // Include deletion targets too; +++ /dev/null has no destination path.
237
+ for (const match of input.patch.matchAll(/^(?:---|\+\+\+)\s+(?:[ab]\/)?(.+)$/gm)) {
215
238
  const path = match[1]?.trim();
216
239
  if (path && path !== '/dev/null') hints.push(path);
217
240
  }
218
241
  }
219
- return hints;
242
+ return [...new Set(hints)];
220
243
  }
221
244
 
222
245
  export function projectDocWriteScopesNeedingReload(projectDocContext, pathHints) {
@@ -19,7 +19,8 @@
19
19
  * totalDurationMs: number,
20
20
  * durations: number[], // ring of last N durations for p50/p95
21
21
  * lastCalledAt: ISO8601 string,
22
- * lastError: string | null
22
+ * lastError: string | null,
23
+ * recentOutcomes: (null | string)[] // last 100: null=success, safe reason=error
23
24
  * }
24
25
  * }
25
26
  * }
@@ -44,6 +45,62 @@ const DEFAULT_RING_SIZE = 100;
44
45
  const DEFAULT_FLUSH_EVERY_N = 20;
45
46
  const DEFAULT_FLUSH_INTERVAL_MS = 30_000;
46
47
  const SCHEMA_VERSION = 1;
48
+ const RECENT_WINDOW_SIZE = 100;
49
+
50
+ // Fixed vocabulary: never persist arbitrary messages, paths, URLs or provider
51
+ // strings as category keys. Unknown structured codes fall back to a category.
52
+ const ERROR_REASONS = new Set([
53
+ 'invalid_arguments', 'git_cwd_outside_repository', 'git_exit_unconfirmed',
54
+ 'git_timeout', 'git_output_limit', 'git_failed', 'git_filter_inspection_failed',
55
+ 'git_execution_error', 'bash_exit_nonzero', 'exit_nonzero',
56
+ 'timeout', 'cancelled', 'permission', 'not_found', 'network', 'rate_limit', 'conflict', 'http_error', 'other',
57
+ ]);
58
+
59
+ function safeReason(value) {
60
+ return typeof value === 'string' && ERROR_REASONS.has(value) ? value : null;
61
+ }
62
+
63
+ function errorReason(errorMessage, errorCode) {
64
+ const explicit = safeReason(errorCode);
65
+ if (explicit) return explicit;
66
+ const text = typeof errorMessage === 'string' ? errorMessage.slice(0, 500) : '';
67
+ let code;
68
+ try {
69
+ const parsed = JSON.parse(text);
70
+ code = parsed?.code ?? parsed?.errorCode ?? parsed?.failureType ?? parsed?.error?.code;
71
+ } catch {
72
+ // Engine output may end mid-JSON. Only accept a complete allowlisted code
73
+ // value; do not turn a truncated/free-form value into a category key.
74
+ code = text.match(/"(?:code|errorCode|failureType)"\s*:\s*"([A-Za-z0-9_-]{1,64})"/)?.[1];
75
+ }
76
+ const structured = safeReason(code);
77
+ if (structured) return structured;
78
+ if (/timed?\s*out|timeout|ETIMEDOUT/i.test(text)) return 'timeout';
79
+ if (/abort|cancelled|canceled/i.test(text)) return 'cancelled';
80
+ if (/rate.?limit|too many requests|\b429\b/i.test(text)) return 'rate_limit';
81
+ if (/EACCES|EPERM|permission denied|access denied|forbidden|unauthorized|\b40[13]\b/i.test(text)) return 'permission';
82
+ if (/ENOENT|not found|does not exist|\b404\b/i.test(text)) return 'not_found';
83
+ if (/ECONN|ENOTFOUND|EAI_AGAIN|fetch failed|network|socket/i.test(text)) return 'network';
84
+ if (/already exists|uncommitted changes|conflict/i.test(text)) return 'conflict';
85
+ if (/HTTP\s*(?:error[: ]*)?[45]\d\d/i.test(text)) return 'http_error';
86
+ return 'other';
87
+ }
88
+
89
+ function readToolRecord(rec, ringSize) {
90
+ return {
91
+ callCount: Number(rec.callCount) || 0,
92
+ errorCount: Number(rec.errorCount) || 0,
93
+ totalDurationMs: Number(rec.totalDurationMs) || 0,
94
+ durations: Array.isArray(rec.durations) ? rec.durations.slice(-ringSize) : [],
95
+ lastCalledAt: typeof rec.lastCalledAt === 'string' ? rec.lastCalledAt : null,
96
+ lastError: typeof rec.lastError === 'string' ? rec.lastError : null,
97
+ // Old durations/lastError cannot tell us which calls failed. Never infer
98
+ // recent outcomes from lifetime counters on legacy files.
99
+ recentOutcomes: Array.isArray(rec.recentOutcomes)
100
+ ? rec.recentOutcomes.slice(-RECENT_WINDOW_SIZE).map(value => value === null ? null : safeReason(value) || 'other')
101
+ : [],
102
+ };
103
+ }
47
104
 
48
105
  function defaultStatsPath() {
49
106
  return join(homedir(), '.yeaft', 'stats', 'tool-usage.json');
@@ -57,6 +114,7 @@ function emptyToolRecord() {
57
114
  durations: [],
58
115
  lastCalledAt: null,
59
116
  lastError: null,
117
+ recentOutcomes: [],
60
118
  };
61
119
  }
62
120
 
@@ -116,14 +174,7 @@ export class ToolUsageStats {
116
174
  if (parsed && typeof parsed === 'object' && parsed.tools && typeof parsed.tools === 'object') {
117
175
  for (const [name, rec] of Object.entries(parsed.tools)) {
118
176
  if (!rec || typeof rec !== 'object') continue;
119
- this.#tools[name] = {
120
- callCount: Number(rec.callCount) || 0,
121
- errorCount: Number(rec.errorCount) || 0,
122
- totalDurationMs: Number(rec.totalDurationMs) || 0,
123
- durations: Array.isArray(rec.durations) ? rec.durations.slice(-this.#ringSize) : [],
124
- lastCalledAt: typeof rec.lastCalledAt === 'string' ? rec.lastCalledAt : null,
125
- lastError: typeof rec.lastError === 'string' ? rec.lastError : null,
126
- };
177
+ this.#tools[name] = readToolRecord(rec, this.#ringSize);
127
178
  }
128
179
  }
129
180
  } catch {
@@ -143,14 +194,7 @@ export class ToolUsageStats {
143
194
  if (parsed && typeof parsed === 'object' && parsed.tools && typeof parsed.tools === 'object') {
144
195
  for (const [name, rec] of Object.entries(parsed.tools)) {
145
196
  if (!rec || typeof rec !== 'object') continue;
146
- this.#tools[name] = {
147
- callCount: Number(rec.callCount) || 0,
148
- errorCount: Number(rec.errorCount) || 0,
149
- totalDurationMs: Number(rec.totalDurationMs) || 0,
150
- durations: Array.isArray(rec.durations) ? rec.durations.slice(-this.#ringSize) : [],
151
- lastCalledAt: typeof rec.lastCalledAt === 'string' ? rec.lastCalledAt : null,
152
- lastError: typeof rec.lastError === 'string' ? rec.lastError : null,
153
- };
197
+ this.#tools[name] = readToolRecord(rec, this.#ringSize);
154
198
  }
155
199
  }
156
200
  } catch {
@@ -162,9 +206,12 @@ export class ToolUsageStats {
162
206
  * Record a single tool execution. Updates in-memory counters and
163
207
  * may trigger a throttled persist.
164
208
  *
165
- * @param {{ name: string, durationMs?: number, isError?: boolean, errorMessage?: string }} args
209
+ * `errorCode` is optional structured metadata; `isError` alone controls
210
+ * failure accounting. Category extraction never changes that outcome.
211
+ *
212
+ * @param {{ name: string, durationMs?: number, isError?: boolean, errorMessage?: string|null, errorCode?: string|null }} args
166
213
  */
167
- record({ name, durationMs = 0, isError = false, errorMessage = null } = {}) {
214
+ record({ name, durationMs = 0, isError = false, errorMessage = null, errorCode = null } = {}) {
168
215
  if (typeof name !== 'string' || !name) return;
169
216
  const dur = Math.max(0, Number(durationMs) || 0);
170
217
  let rec = this.#tools[name];
@@ -179,6 +226,10 @@ export class ToolUsageStats {
179
226
  rec.durations.splice(0, rec.durations.length - this.#ringSize);
180
227
  }
181
228
  rec.lastCalledAt = new Date().toISOString();
229
+ rec.recentOutcomes.push(isError ? errorReason(errorMessage, errorCode) : null);
230
+ if (rec.recentOutcomes.length > RECENT_WINDOW_SIZE) {
231
+ rec.recentOutcomes.splice(0, rec.recentOutcomes.length - RECENT_WINDOW_SIZE);
232
+ }
182
233
  if (isError) {
183
234
  rec.errorCount += 1;
184
235
  if (typeof errorMessage === 'string' && errorMessage) {
@@ -260,10 +311,12 @@ export class ToolUsageStats {
260
311
  }
261
312
 
262
313
  /**
263
- * Snapshot for CLI/UI rendering. Computes p50/p95/avg/errorRate per
264
- * tool from the in-memory rings.
314
+ * Snapshot for CLI/UI rendering. Existing counters/errorRate remain lifetime
315
+ * values; recent is based only on recorded outcomes, never historical totals.
316
+ * Its reason counters sum to recent.errorCount and its rate is null when
317
+ * there are no samples (including legacy files with no recentOutcomes).
265
318
  *
266
- * @returns {Record<string, {callCount: number, errorCount: number, errorRate: number, avgMs: number, p50Ms: number, p95Ms: number, lastCalledAt: string|null, lastError: string|null}>}
319
+ * @returns {Record<string, {callCount: number, errorCount: number, errorRate: number, avgMs: number, p50Ms: number, p95Ms: number, lastCalledAt: string|null, lastError: string|null, recent: {windowSize: number, callCount: number, errorCount: number, errorRate: number|null, errorReasons: Record<string, number>}}> }
267
320
  */
268
321
  snapshot() {
269
322
  /** @type {Record<string, any>} */
@@ -271,6 +324,14 @@ export class ToolUsageStats {
271
324
  for (const [name, rec] of Object.entries(this.#tools)) {
272
325
  const sorted = rec.durations.slice().sort((a, b) => a - b);
273
326
  const avg = rec.callCount > 0 ? rec.totalDurationMs / rec.callCount : 0;
327
+ const errorReasons = Object.create(null);
328
+ let recentErrors = 0;
329
+ for (const reason of rec.recentOutcomes) {
330
+ if (reason === null) continue;
331
+ recentErrors += 1;
332
+ errorReasons[reason] = (errorReasons[reason] || 0) + 1;
333
+ }
334
+ const recentCalls = rec.recentOutcomes.length;
274
335
  out[name] = {
275
336
  callCount: rec.callCount,
276
337
  errorCount: rec.errorCount,
@@ -280,6 +341,13 @@ export class ToolUsageStats {
280
341
  p95Ms: Math.round(percentile(sorted, 95)),
281
342
  lastCalledAt: rec.lastCalledAt,
282
343
  lastError: rec.lastError,
344
+ recent: {
345
+ windowSize: RECENT_WINDOW_SIZE,
346
+ callCount: recentCalls,
347
+ errorCount: recentErrors,
348
+ errorRate: recentCalls > 0 ? recentErrors / recentCalls : null,
349
+ errorReasons,
350
+ },
283
351
  };
284
352
  }
285
353
  return out;
@@ -54,16 +54,18 @@ function errorOutput(message, operation) {
54
54
  });
55
55
  }
56
56
 
57
- // Some strict-schema providers require every property. Treat their empty
58
- // placeholders as omitted, without accepting unknown keys or non-default
59
- // arguments belonging to another operation.
57
+ // Some strict-schema providers require every property. Treat empty
58
+ // placeholders as omitted. limit is harmless catalogue metadata outside log:
59
+ // providers may fill any schema-valid value, not just the default of 20.
60
+ // Unknown keys and substantive revision/path fields remain operation-specific.
60
61
  function normalizeInput(input) {
61
62
  const result = { ...input };
62
63
  for (const key of ['cwd', 'base', 'head', 'revision', 'paths', 'limit']) {
63
64
  if (result[key] === null || result[key] === undefined
64
65
  || (['cwd', 'base', 'head', 'revision'].includes(key) && result[key] === '')
65
66
  || (key === 'paths' && Array.isArray(result[key]) && result[key].length === 0)
66
- || (key === 'limit' && result.operation !== 'log' && result[key] === DEFAULT_LOG_LIMIT)) {
67
+ || (key === 'limit' && result.operation !== 'log'
68
+ && Number.isInteger(result[key]) && result[key] >= 1 && result[key] <= MAX_LOG_LIMIT)) {
67
69
  delete result[key];
68
70
  }
69
71
  }
@@ -297,7 +299,7 @@ GitRead 不 fetch、不写 Git 状态、不创建 worktree。它禁用 pager、e
297
299
  items: { type: 'string', minLength: 1, maxLength: MAX_VALUE_LENGTH },
298
300
  description: 'diff/show only; empty/omitted means all paths',
299
301
  },
300
- limit: { type: 'integer', minimum: 1, maximum: MAX_LOG_LIMIT, description: 'log only; default 20. Empty optional fields are ignored; omit fields for other operations.' },
302
+ limit: { type: 'integer', minimum: 1, maximum: MAX_LOG_LIMIT, description: 'Maximum log entries, default 20. Ignored by status/diff/show for strict-schema compatibility. Empty optional fields are treated as omitted.' },
301
303
  },
302
304
  required: ['operation'],
303
305
  },