@yeaft/webchat-agent 1.0.639 → 1.0.641
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/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +102 -102
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- package/yeaft/attachments.js +22 -9
- package/yeaft/engine.js +95 -574
- package/yeaft/history-window.js +132 -5
- package/yeaft/query-progress.js +15 -2
- package/yeaft/response-presentation.js +27 -5
- package/yeaft/sessions/project-doc.js +29 -6
- package/yeaft/stats/tool-usage.js +90 -22
- package/yeaft/tools/git-read.js +7 -5
- package/yeaft/tools/view-image.js +54 -33
- package/yeaft/tools/web-fetch.js +65 -57
- package/yeaft/tools/web-search.js +116 -124
- package/yeaft/web-research.js +169 -0
package/yeaft/history-window.js
CHANGED
|
@@ -565,6 +565,130 @@ function truncateToolResultsForModel(messages, options = {}) {
|
|
|
565
565
|
});
|
|
566
566
|
}
|
|
567
567
|
|
|
568
|
+
const TOOL_COMPACT_MARKER = '\n[tool result compacted for context budget; middle omitted]\n';
|
|
569
|
+
const MIN_TOOL_SAMPLE_BYTES = 64;
|
|
570
|
+
const TOOL_ERROR_FIELDS = new Set(['error', 'code', 'errorEffect', 'failureType', 'status']);
|
|
571
|
+
const BINARY_PART_TYPES = new Set(['image', 'input_image', 'document', 'input_file']);
|
|
572
|
+
|
|
573
|
+
function sampleToolText(text, retainedBytes) {
|
|
574
|
+
const bytes = Buffer.from(text, 'utf8');
|
|
575
|
+
if (bytes.length <= retainedBytes + Buffer.byteLength(TOOL_COMPACT_MARKER)) return text;
|
|
576
|
+
let headEnd = Math.floor(retainedBytes / 2);
|
|
577
|
+
let tailStart = bytes.length - (retainedBytes - headEnd);
|
|
578
|
+
// UTF-8 boundaries also keep CJK and astral characters intact. The marker is
|
|
579
|
+
// charged together with both samples by estimateContentTokens below.
|
|
580
|
+
while (headEnd > 0 && (bytes[headEnd] & 0xc0) === 0x80) headEnd -= 1;
|
|
581
|
+
while (tailStart < bytes.length && (bytes[tailStart] & 0xc0) === 0x80) tailStart += 1;
|
|
582
|
+
return bytes.subarray(0, headEnd).toString('utf8') + TOOL_COMPACT_MARKER
|
|
583
|
+
+ bytes.subarray(tailStart).toString('utf8');
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function sampleToolJson(value, retainedBytes) {
|
|
587
|
+
if (typeof value === 'string') return sampleToolText(value, retainedBytes);
|
|
588
|
+
if (!value || typeof value !== 'object' || BINARY_PART_TYPES.has(value.type)) return value;
|
|
589
|
+
if (Array.isArray(value)) return value.map(item => sampleToolJson(item, retainedBytes));
|
|
590
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
591
|
+
key, TOOL_ERROR_FIELDS.has(key) ? item : sampleToolJson(item, retainedBytes),
|
|
592
|
+
]));
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function toolTextSampler(text) {
|
|
596
|
+
// Keep structured output parseable, including JSON inside text content parts
|
|
597
|
+
// and JSON string scalars. Only string leaves are sampled; flags, exit codes
|
|
598
|
+
// and Bash error markers stay whole. Unsafe numeric round trips and deeply
|
|
599
|
+
// nested structures stay intact for the existing protocol-unit budget fitter.
|
|
600
|
+
if (/^\s*[\[{"]/.test(text)) {
|
|
601
|
+
try {
|
|
602
|
+
const value = JSON.parse(text, (_key, item, context) => {
|
|
603
|
+
if (typeof item === 'number' && context?.source !== JSON.stringify(item)) {
|
|
604
|
+
throw new RangeError('JSON numeric representation would change');
|
|
605
|
+
}
|
|
606
|
+
return item;
|
|
607
|
+
});
|
|
608
|
+
const pending = [{ value, depth: 0 }];
|
|
609
|
+
while (pending.length > 0) {
|
|
610
|
+
const next = pending.pop();
|
|
611
|
+
if (!next.value || typeof next.value !== 'object') continue;
|
|
612
|
+
if (next.depth >= 64) return () => text;
|
|
613
|
+
for (const item of Object.values(next.value)) pending.push({ value: item, depth: next.depth + 1 });
|
|
614
|
+
}
|
|
615
|
+
return bytes => JSON.stringify(sampleToolJson(value, bytes));
|
|
616
|
+
} catch (error) {
|
|
617
|
+
// A valid but unsafe/deep JSON value must not crash sampling or silently
|
|
618
|
+
// change facts. Only invalid JSON falls through to ordinary text sampling.
|
|
619
|
+
if (!(error instanceof SyntaxError)) return () => text;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return bytes => sampleToolText(text, bytes);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function toolContentSampler(content) {
|
|
626
|
+
// Content parts keep their ordering, metadata and binary payloads. In
|
|
627
|
+
// particular, do not collapse mixed text/image/document output into JSON.
|
|
628
|
+
if (Array.isArray(content) && content.some(part => part && typeof part === 'object'
|
|
629
|
+
&& (['text', 'input_text', 'output_text'].includes(part.type) || BINARY_PART_TYPES.has(part.type)))) {
|
|
630
|
+
const samplers = content.map(part => {
|
|
631
|
+
if (typeof part === 'string') return toolTextSampler(part);
|
|
632
|
+
if (part && ['text', 'input_text', 'output_text'].includes(part.type)
|
|
633
|
+
&& typeof part.text === 'string') {
|
|
634
|
+
const sample = toolTextSampler(part.text);
|
|
635
|
+
return bytes => ({ ...part, text: sample(bytes) });
|
|
636
|
+
}
|
|
637
|
+
return () => part;
|
|
638
|
+
});
|
|
639
|
+
return bytes => samplers.map(sample => sample(bytes));
|
|
640
|
+
}
|
|
641
|
+
return toolTextSampler(serializeJsonValue(content));
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function compactToolContentToBudget(content, tokenBudget) {
|
|
645
|
+
const sample = toolContentSampler(content);
|
|
646
|
+
const minimum = sample(MIN_TOOL_SAMPLE_BYTES);
|
|
647
|
+
if (estimateContentTokens(minimum) > tokenBudget) return minimum;
|
|
648
|
+
// Search measured content cost, not tokenBudget * chars/bytes. JSON escaping,
|
|
649
|
+
// CJK and multipart framing otherwise make small budgets easy to overshoot.
|
|
650
|
+
let low = MIN_TOOL_SAMPLE_BYTES;
|
|
651
|
+
let high = Math.max(low, Buffer.byteLength(serializeJsonValue(content), 'utf8'));
|
|
652
|
+
let fitted = minimum;
|
|
653
|
+
while (low < high) {
|
|
654
|
+
const mid = Math.ceil((low + high) / 2);
|
|
655
|
+
const candidate = sample(mid);
|
|
656
|
+
if (estimateContentTokens(candidate) <= tokenBudget) {
|
|
657
|
+
low = mid;
|
|
658
|
+
fitted = candidate;
|
|
659
|
+
} else {
|
|
660
|
+
high = mid - 1;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return fitted;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function compactCurrentToolResults(messages, messageBudget) {
|
|
667
|
+
const out = messages.slice();
|
|
668
|
+
let excess = estimateMessagesTokens(out) - messageBudget;
|
|
669
|
+
let compactedToolResults = 0;
|
|
670
|
+
let compactedToolResultTokensSaved = 0;
|
|
671
|
+
// Protect the complete newest tool batch, not merely its last result. All
|
|
672
|
+
// parallel results are evidence for the next assistant continuation.
|
|
673
|
+
let latestEnd = out.length - 1;
|
|
674
|
+
while (latestEnd >= 0 && out[latestEnd]?.role !== 'tool') latestEnd -= 1;
|
|
675
|
+
let latestStart = latestEnd;
|
|
676
|
+
while (latestStart > 0 && out[latestStart - 1]?.role === 'tool') latestStart -= 1;
|
|
677
|
+
for (let index = 0; index < latestStart && excess > 0; index += 1) {
|
|
678
|
+
const message = out[index];
|
|
679
|
+
if (message?.role !== 'tool') continue;
|
|
680
|
+
const before = estimateContentTokens(message.content);
|
|
681
|
+
const content = compactToolContentToBudget(message.content, Math.max(0, before - excess));
|
|
682
|
+
const saved = before - estimateContentTokens(content);
|
|
683
|
+
if (saved <= 0) continue;
|
|
684
|
+
out[index] = { ...message, content };
|
|
685
|
+
excess -= saved;
|
|
686
|
+
compactedToolResults += 1;
|
|
687
|
+
compactedToolResultTokensSaved += saved;
|
|
688
|
+
}
|
|
689
|
+
return { messages: out, compactedToolResults, compactedToolResultTokensSaved };
|
|
690
|
+
}
|
|
691
|
+
|
|
568
692
|
const HISTORY_SOURCE_INDEX = Symbol('historySourceIndex');
|
|
569
693
|
|
|
570
694
|
function withHistorySourceIndexes(messages, offset = 0) {
|
|
@@ -1139,8 +1263,9 @@ export function buildHistoryBuckets(snapshot, options = {}) {
|
|
|
1139
1263
|
* Fit one provider-request copy to the actual model window. The caller tells
|
|
1140
1264
|
* us where current-turn rows begin; only the prefix is subject to the history
|
|
1141
1265
|
* budget. If the complete request is still too large, old history disappears
|
|
1142
|
-
* first
|
|
1143
|
-
*
|
|
1266
|
+
* first. Under current-turn pressure, sample old tool-result text before
|
|
1267
|
+
* evicting disposable protocol units; the latest tool batch stays lossless
|
|
1268
|
+
* until the final budget fallback. The source and transcript are never mutated.
|
|
1144
1269
|
*
|
|
1145
1270
|
* @param {Array<object>} messages
|
|
1146
1271
|
* @param {{ contextWindow:number, systemTokens?:number, toolSchemaTokens?:number,
|
|
@@ -1167,9 +1292,9 @@ export function fitProviderRequestToContext(messages, options = {}) {
|
|
|
1167
1292
|
const historySource = source.slice(0, split);
|
|
1168
1293
|
const currentSource = source.slice(split);
|
|
1169
1294
|
|
|
1170
|
-
let current = pairSanitize(
|
|
1171
|
-
|
|
1172
|
-
|
|
1295
|
+
let current = pairSanitize(currentSource.map(message => ({ ...message })));
|
|
1296
|
+
const compact = compactCurrentToolResults(current, messageBudget);
|
|
1297
|
+
current = compact.messages;
|
|
1173
1298
|
if (estimateMessagesTokens(current) > messageBudget || current.length > messageCap) {
|
|
1174
1299
|
const fitted = [];
|
|
1175
1300
|
if (current.length > 0 && messageBudget >= 2 && messageCap > 0) {
|
|
@@ -1222,6 +1347,8 @@ export function fitProviderRequestToContext(messages, options = {}) {
|
|
|
1222
1347
|
currentMessagesAfter: current.length,
|
|
1223
1348
|
droppedHistoryMessages: historySource.length - history.length,
|
|
1224
1349
|
droppedCurrentMessages: currentSource.length - current.length,
|
|
1350
|
+
compactedToolResults: compact.compactedToolResults,
|
|
1351
|
+
compactedToolResultTokensSaved: compact.compactedToolResultTokensSaved,
|
|
1225
1352
|
},
|
|
1226
1353
|
};
|
|
1227
1354
|
}
|
package/yeaft/query-progress.js
CHANGED
|
@@ -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
|
|
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(
|
|
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:
|
|
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}
|
|
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
|
|
32
|
-
|
|
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'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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.
|
|
264
|
-
*
|
|
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;
|
package/yeaft/tools/git-read.js
CHANGED
|
@@ -54,16 +54,18 @@ function errorOutput(message, operation) {
|
|
|
54
54
|
});
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
// Some strict-schema providers require every property. Treat
|
|
58
|
-
// placeholders as omitted
|
|
59
|
-
//
|
|
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'
|
|
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
|
|
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
|
},
|