zames_pro 2.82.49 → 2.82.50
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/CHANGELOG.md +39 -0
- package/dist/agent-loop.js +11 -1
- package/dist/attach-flow.js +7 -3
- package/dist/attach-refs.js +8 -0
- package/dist/attachments.js +9 -0
- package/dist/browser-chats.js +8 -1
- package/dist/browser-pure.js +8 -2
- package/dist/config-commands.js +4 -1
- package/dist/config-live.js +10 -0
- package/dist/config.js +12 -0
- package/dist/context-command.js +6 -0
- package/dist/context.js +8 -1
- package/dist/diff.js +52 -9
- package/dist/glm-chats.js +18 -1
- package/dist/glm-net-capture.js +23 -5
- package/dist/goal.js +6 -1
- package/dist/i18n/self-review.js +4 -0
- package/dist/i18n.js +7 -1
- package/dist/index.js +87 -98
- package/dist/input/layout.js +32 -7
- package/dist/limits-command.js +42 -0
- package/dist/mcp.js +15 -2
- package/dist/model-command.js +21 -0
- package/dist/path-token.js +35 -8
- package/dist/permissions.js +32 -3
- package/dist/provider-command.js +29 -0
- package/dist/provider-handoff.js +18 -1
- package/dist/queue-command.js +15 -0
- package/dist/qwen-chats.js +13 -6
- package/dist/qwen-net-capture.js +13 -6
- package/dist/resume-command.js +38 -0
- package/dist/send-telemetry.js +15 -3
- package/dist/send-throttle.js +3 -1
- package/dist/status-command.js +68 -0
- package/dist/tool-summary.js +8 -1
- package/dist/tools.js +4 -1
- package/dist/tui/log-window.js +41 -4
- package/dist/undo.js +11 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -47,6 +47,13 @@ import { askOperatorConfirm, } from './run-task.js';
|
|
|
47
47
|
import { closeWeb } from './web.js';
|
|
48
48
|
import { assertCommandInsideRoot, runShell, killAllBackground, } from './shell.js';
|
|
49
49
|
import { saveSession, loadLastSession, readSession, listSessions, sessionsDir, loadHistory, saveHistory, isInternalChat, } from './sessions.js';
|
|
50
|
+
import { parseModelIntent, emptyModelListMessage } from './model-command.js';
|
|
51
|
+
import { parseResumeId, filterSessions, resumeProviderRefusal, } from './resume-command.js';
|
|
52
|
+
import { formatLimits, limitsEmptyMessage } from './limits-command.js';
|
|
53
|
+
import { renderContextReport } from './context-command.js';
|
|
54
|
+
import { renderQueueView } from './queue-command.js';
|
|
55
|
+
import { parseProviderIntent, providerUsageMessage, } from './provider-command.js';
|
|
56
|
+
import { formatStatus } from './status-command.js';
|
|
50
57
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
51
58
|
const NL = String.fromCharCode(10);
|
|
52
59
|
// The last final assistant message, for the /copy command. Module-level
|
|
@@ -1735,19 +1742,18 @@ async function main() {
|
|
|
1735
1742
|
if (ed.busy) {
|
|
1736
1743
|
const q = mod.commands.parseQueueCommand(text);
|
|
1737
1744
|
if (q) {
|
|
1738
|
-
|
|
1739
|
-
|
|
1745
|
+
const view = renderQueueView(q.sub, pendingQueue.length, pendingQueue);
|
|
1746
|
+
if (view.kind === 'cleared') {
|
|
1740
1747
|
pendingQueue.length = 0;
|
|
1741
|
-
ed.printAbove(theme.system(t('msg.queue_cleared') + ' (' + n + ')'));
|
|
1748
|
+
ed.printAbove(theme.system(t('msg.queue_cleared') + ' (' + view.n + ')'));
|
|
1742
1749
|
}
|
|
1743
|
-
else if (
|
|
1750
|
+
else if (view.kind === 'empty') {
|
|
1744
1751
|
ed.printAbove(theme.dim(t('msg.queue_empty')));
|
|
1745
1752
|
}
|
|
1746
|
-
else {
|
|
1753
|
+
else if (view.kind === 'list') {
|
|
1747
1754
|
ed.printAbove(theme.system(t('msg.queue_title')));
|
|
1748
|
-
for (const line of
|
|
1755
|
+
for (const line of view.lines)
|
|
1749
1756
|
ed.printAbove(theme.assistant(line));
|
|
1750
|
-
}
|
|
1751
1757
|
ed.printAbove(theme.dim(t('msg.queue_cleared_hint')));
|
|
1752
1758
|
}
|
|
1753
1759
|
return;
|
|
@@ -2616,8 +2622,8 @@ async function main() {
|
|
|
2616
2622
|
continue;
|
|
2617
2623
|
}
|
|
2618
2624
|
if (lower === '/provider' || lower.startsWith('/provider ')) {
|
|
2619
|
-
const
|
|
2620
|
-
if (
|
|
2625
|
+
const intent = parseProviderIntent(trimmed.slice('/provider'.length), config.provider);
|
|
2626
|
+
if (intent.kind === 'status') {
|
|
2621
2627
|
const model = providerModelOf(config.provider) || '(default)';
|
|
2622
2628
|
console.log(theme.system(t('provider.status', {
|
|
2623
2629
|
v: config.provider,
|
|
@@ -2626,7 +2632,7 @@ async function main() {
|
|
|
2626
2632
|
})));
|
|
2627
2633
|
continue;
|
|
2628
2634
|
}
|
|
2629
|
-
if (
|
|
2635
|
+
if (intent.kind === 'login') {
|
|
2630
2636
|
try {
|
|
2631
2637
|
await browser.waitForLogin();
|
|
2632
2638
|
console.log(theme.system(t('provider.login_done')));
|
|
@@ -2636,9 +2642,10 @@ async function main() {
|
|
|
2636
2642
|
}
|
|
2637
2643
|
continue;
|
|
2638
2644
|
}
|
|
2639
|
-
if (
|
|
2645
|
+
if (intent.kind === 'switch') {
|
|
2646
|
+
const arg = intent.provider;
|
|
2640
2647
|
const from = config.provider;
|
|
2641
|
-
if (
|
|
2648
|
+
if (intent.same) {
|
|
2642
2649
|
console.log(theme.system(t('provider.switched', { v: arg })));
|
|
2643
2650
|
continue;
|
|
2644
2651
|
}
|
|
@@ -2780,7 +2787,7 @@ async function main() {
|
|
|
2780
2787
|
}
|
|
2781
2788
|
continue;
|
|
2782
2789
|
}
|
|
2783
|
-
console.error(theme.warn(t
|
|
2790
|
+
console.error(theme.warn(providerUsageMessage(t)));
|
|
2784
2791
|
continue;
|
|
2785
2792
|
}
|
|
2786
2793
|
if (lower === '/model' || lower.startsWith('/model ')) {
|
|
@@ -2788,10 +2795,9 @@ async function main() {
|
|
|
2788
2795
|
// `/model` is available to ANY provider that exposes a model picker
|
|
2789
2796
|
// (Qwen, GLM). DeepSeek has none, so the command is hidden from the
|
|
2790
2797
|
// hints there and only explains itself when typed by hand.
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
})));
|
|
2798
|
+
const intent = parseModelIntent(config.provider, arg, t);
|
|
2799
|
+
if (intent.kind === 'unsupported') {
|
|
2800
|
+
console.log(theme.system(intent.message));
|
|
2795
2801
|
continue;
|
|
2796
2802
|
}
|
|
2797
2803
|
const section = providerConfigSection(config.provider);
|
|
@@ -2800,10 +2806,10 @@ async function main() {
|
|
|
2800
2806
|
continue;
|
|
2801
2807
|
const curModel = String(sectionObj.model || '');
|
|
2802
2808
|
const qb = browser;
|
|
2803
|
-
if (
|
|
2809
|
+
if (intent.kind === 'list') {
|
|
2804
2810
|
const models = qb.listModels ? await qb.listModels() : [];
|
|
2805
2811
|
if (!models.length) {
|
|
2806
|
-
console.log(theme.system(t
|
|
2812
|
+
console.log(theme.system(emptyModelListMessage(t)));
|
|
2807
2813
|
continue;
|
|
2808
2814
|
}
|
|
2809
2815
|
// In the TUI, open a proper SELECT LIST instead of dumping text.
|
|
@@ -2862,10 +2868,10 @@ async function main() {
|
|
|
2862
2868
|
console.log(theme.system(t('model.list', { v: lines })));
|
|
2863
2869
|
continue;
|
|
2864
2870
|
}
|
|
2865
|
-
sectionObj.model =
|
|
2871
|
+
sectionObj.model = intent.model;
|
|
2866
2872
|
if (typeof qb.model === 'string')
|
|
2867
|
-
qb.model =
|
|
2868
|
-
applyModelContextLimit(
|
|
2873
|
+
qb.model = intent.model;
|
|
2874
|
+
applyModelContextLimit(intent.model);
|
|
2869
2875
|
if (editor) {
|
|
2870
2876
|
editor.setContextLimit(effectiveContextLimit());
|
|
2871
2877
|
editor.refreshStatus();
|
|
@@ -2874,7 +2880,10 @@ async function main() {
|
|
|
2874
2880
|
writeConfigValue('home', section + '.model', arg);
|
|
2875
2881
|
}
|
|
2876
2882
|
catch { }
|
|
2877
|
-
console.log(theme.system(t('model.set', {
|
|
2883
|
+
console.log(theme.system(t('model.set', {
|
|
2884
|
+
provider: providerLabel(config.provider),
|
|
2885
|
+
v: intent.model,
|
|
2886
|
+
})));
|
|
2878
2887
|
continue;
|
|
2879
2888
|
}
|
|
2880
2889
|
if (lower === '/chat') {
|
|
@@ -2898,9 +2907,7 @@ async function main() {
|
|
|
2898
2907
|
.toLowerCase();
|
|
2899
2908
|
// /sessions <query> filters by title or workdir (substring). Sorted by
|
|
2900
2909
|
// recency already (listSessions).
|
|
2901
|
-
const all = listSessions()
|
|
2902
|
-
(s.title || '').toLowerCase().includes(filter) ||
|
|
2903
|
-
(s.workdir || '').toLowerCase().includes(filter));
|
|
2910
|
+
const all = filterSessions(listSessions(), filter);
|
|
2904
2911
|
console.log(theme.system(t('sessions.dir', { v: sessionsDir() })));
|
|
2905
2912
|
if (!all.length) {
|
|
2906
2913
|
console.log(theme.system(t('sessions.none')));
|
|
@@ -2961,11 +2968,19 @@ async function main() {
|
|
|
2961
2968
|
continue;
|
|
2962
2969
|
}
|
|
2963
2970
|
if (lower.startsWith('/resume-id ')) {
|
|
2964
|
-
const id = trimmed.slice('/resume-id'.length)
|
|
2971
|
+
const id = parseResumeId(trimmed.slice('/resume-id'.length));
|
|
2965
2972
|
if (!id) {
|
|
2966
2973
|
console.error(theme.error(t('sessions.resume_id_usage')));
|
|
2967
2974
|
continue;
|
|
2968
2975
|
}
|
|
2976
|
+
// A chat id only exists on the backend that created it, so opening a
|
|
2977
|
+
// session saved by ANOTHER provider fails deep inside the browser with a
|
|
2978
|
+
// confusing error. Refuse up front and name the right /provider command.
|
|
2979
|
+
const refusal = resumeProviderRefusal(readSession(id), config.provider, t);
|
|
2980
|
+
if (refusal) {
|
|
2981
|
+
console.error(theme.error(refusal));
|
|
2982
|
+
continue;
|
|
2983
|
+
}
|
|
2969
2984
|
if (editor)
|
|
2970
2985
|
editor.lock(t('msg.input_locked'));
|
|
2971
2986
|
try {
|
|
@@ -3121,24 +3136,23 @@ async function main() {
|
|
|
3121
3136
|
}
|
|
3122
3137
|
if (lower === '/queue' || lower.startsWith('/queue ')) {
|
|
3123
3138
|
const q = mod.commands.parseQueueCommand(trimmed);
|
|
3124
|
-
|
|
3139
|
+
const view = renderQueueView(q ? q.sub : null, pendingQueue.length, pendingQueue);
|
|
3140
|
+
if (view.kind === 'usage') {
|
|
3125
3141
|
console.error(theme.error(t('msg.queue_usage')));
|
|
3126
3142
|
continue;
|
|
3127
3143
|
}
|
|
3128
|
-
if (
|
|
3129
|
-
const n = pendingQueue.length;
|
|
3144
|
+
if (view.kind === 'cleared') {
|
|
3130
3145
|
pendingQueue.length = 0;
|
|
3131
|
-
console.log(theme.system(t('msg.queue_cleared') + ' (' + n + ')'));
|
|
3146
|
+
console.log(theme.system(t('msg.queue_cleared') + ' (' + view.n + ')'));
|
|
3132
3147
|
continue;
|
|
3133
3148
|
}
|
|
3134
|
-
if (
|
|
3149
|
+
if (view.kind === 'empty') {
|
|
3135
3150
|
console.log(theme.dim(t('msg.queue_empty')));
|
|
3136
3151
|
continue;
|
|
3137
3152
|
}
|
|
3138
3153
|
console.log(theme.system(t('msg.queue_title')));
|
|
3139
|
-
for (const line of
|
|
3154
|
+
for (const line of view.lines)
|
|
3140
3155
|
console.log(theme.assistant(line));
|
|
3141
|
-
}
|
|
3142
3156
|
console.log(theme.dim(t('msg.queue_cleared_hint')));
|
|
3143
3157
|
continue;
|
|
3144
3158
|
}
|
|
@@ -3388,51 +3402,32 @@ async function main() {
|
|
|
3388
3402
|
continue;
|
|
3389
3403
|
}
|
|
3390
3404
|
if (lower === '/status') {
|
|
3391
|
-
const yes = t('common.yes');
|
|
3392
|
-
const no = t('common.no');
|
|
3393
|
-
console.log(theme.system(t('status.workdir', { v: currentWorkdir })));
|
|
3394
|
-
console.log(theme.system(t('status.review_mode', {
|
|
3395
|
-
v: reviewMode ? reviewMode.snapName : t('status.review_none'),
|
|
3396
|
-
})));
|
|
3397
|
-
if (reviewMode) {
|
|
3398
|
-
console.log(theme.system(t('status.orig_dir', { v: reviewMode.originalWorkdir })));
|
|
3399
|
-
}
|
|
3400
|
-
console.log(theme.system(t('status.chat', { v: currentChatId || t('common.none') })));
|
|
3401
|
-
console.log(theme.system(t('status.fresh_next', { v: freshChatNext ? yes : no })));
|
|
3402
|
-
console.log(theme.system(t('status.prompt_next', { v: sendSystemPromptNext ? yes : no })));
|
|
3403
|
-
console.log(theme.system(t('status.last_chat', {
|
|
3404
|
-
v: loadLastChat(currentWorkdir) || t('common.none'),
|
|
3405
|
-
})));
|
|
3406
|
-
console.log(theme.system(t('status.sessions', { v: sessionsDir() })));
|
|
3407
|
-
console.log(theme.system(t('status.dev', {
|
|
3408
|
-
v: devMode ? t('common.on') : t('common.off'),
|
|
3409
|
-
})));
|
|
3410
|
-
console.log(theme.system(t('status.max_iter', { v: currentMaxIter() })));
|
|
3411
|
-
console.log(theme.system(t('status.headless', { v: headless ? yes : no })));
|
|
3412
|
-
console.log(theme.system(t('status.debug', { v: debug ? yes : no })));
|
|
3413
3405
|
const statusToggles = browser.getToggleStatesSync();
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3406
|
+
const statusLines = formatStatus({
|
|
3407
|
+
workdir: currentWorkdir,
|
|
3408
|
+
reviewMode: reviewMode ? reviewMode.snapName : null,
|
|
3409
|
+
origDir: reviewMode ? reviewMode.originalWorkdir : null,
|
|
3410
|
+
chatId: currentChatId,
|
|
3411
|
+
freshNext: freshChatNext,
|
|
3412
|
+
promptNext: sendSystemPromptNext,
|
|
3413
|
+
lastChat: loadLastChat(currentWorkdir),
|
|
3414
|
+
sessionsDir: sessionsDir(),
|
|
3415
|
+
devMode,
|
|
3416
|
+
maxIterations: currentMaxIter(),
|
|
3417
|
+
headless,
|
|
3418
|
+
debug,
|
|
3419
|
+
deepThinking: statusToggles.deepThinking,
|
|
3420
|
+
webSearch: statusToggles.webSearch,
|
|
3421
|
+
undo: config.undo.enabled,
|
|
3422
|
+
checkpoint: config.checkpoint.enabled,
|
|
3423
|
+
transcriptFile: transcript.file,
|
|
3424
|
+
goal: sessionGoal,
|
|
3425
|
+
localeName: localeDisplayName(currentLocale),
|
|
3426
|
+
mcpToolCount: mcpPool ? mcpPool.status().toolCount : null,
|
|
3427
|
+
tokenUsage: browser.getLastTokenUsage(),
|
|
3428
|
+
}, t);
|
|
3429
|
+
for (const line of statusLines)
|
|
3430
|
+
console.log(theme.system(line));
|
|
3436
3431
|
continue;
|
|
3437
3432
|
}
|
|
3438
3433
|
if (lower === '/config' || lower.startsWith('/config ')) {
|
|
@@ -3456,7 +3451,12 @@ async function main() {
|
|
|
3456
3451
|
locale: currentLocale,
|
|
3457
3452
|
context: ctx,
|
|
3458
3453
|
}).length;
|
|
3459
|
-
console.log(theme.system(
|
|
3454
|
+
console.log(theme.system(renderContextReport({
|
|
3455
|
+
agents: ctx.agents,
|
|
3456
|
+
memory: ctx.memory,
|
|
3457
|
+
skills: ctx.skills,
|
|
3458
|
+
commands: ctx.commands,
|
|
3459
|
+
}, { systemPromptChars: sysLen }, t)));
|
|
3460
3460
|
}
|
|
3461
3461
|
catch (e) {
|
|
3462
3462
|
console.error(theme.error(e.message));
|
|
@@ -3563,29 +3563,18 @@ async function main() {
|
|
|
3563
3563
|
const logsDir = config.transcript.dir;
|
|
3564
3564
|
const events = readSendEvents(logsDir, config.provider);
|
|
3565
3565
|
if (!events.length) {
|
|
3566
|
-
console.log(theme.system(
|
|
3566
|
+
console.log(theme.system(limitsEmptyMessage(sendLogPath(logsDir, config.provider), t)));
|
|
3567
3567
|
}
|
|
3568
3568
|
else {
|
|
3569
3569
|
const st = summarizeSends(events);
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
console.log('
|
|
3570
|
+
for (const line of formatLimits(st, {
|
|
3571
|
+
providerLabel: providerLabel(config.provider),
|
|
3572
|
+
intervalMs: browser.sendIntervalMs(),
|
|
3573
|
+
adaptive: providerAdaptiveThrottle(config.provider),
|
|
3574
|
+
}, t)) {
|
|
3575
|
+
// The title is printed bare; the counters are indented under it.
|
|
3576
|
+
console.log(line.kind === 'title' ? theme.system(line.text) : ' ' + line.text);
|
|
3577
3577
|
}
|
|
3578
|
-
console.log(' ' +
|
|
3579
|
-
t('limits.rate', {
|
|
3580
|
-
n: String(st.rateLimits),
|
|
3581
|
-
sec: String(Math.round(st.avgBlockMs / 1000)),
|
|
3582
|
-
}));
|
|
3583
|
-
const adaptive = providerAdaptiveThrottle(config.provider);
|
|
3584
|
-
console.log(' ' +
|
|
3585
|
-
t('limits.interval', {
|
|
3586
|
-
sec: String(Math.round(browser.sendIntervalMs() / 1000)),
|
|
3587
|
-
adaptive: adaptive ? t('limits.adaptive_on') : '',
|
|
3588
|
-
}));
|
|
3589
3578
|
}
|
|
3590
3579
|
continue;
|
|
3591
3580
|
}
|
package/dist/input/layout.js
CHANGED
|
@@ -37,7 +37,12 @@ export function countPasteLines(raw) {
|
|
|
37
37
|
.join(NL)
|
|
38
38
|
.split(CR)
|
|
39
39
|
.join(NL);
|
|
40
|
-
|
|
40
|
+
// Only the SINGLE last newline is a terminator (it does not open a new
|
|
41
|
+
// line); any newline before it ends a real - possibly BLANK - line. The
|
|
42
|
+
// old /\n+$/ stripped ALL trailing newlines, so a paste of "a\n\n\n"
|
|
43
|
+
// counted as ONE line: it was inserted verbatim instead of collapsing into
|
|
44
|
+
// a `[Pasted lines#N]` marker and flooded the input line.
|
|
45
|
+
const trimmed = normalized.endsWith(NL) ? normalized.slice(0, -1) : normalized;
|
|
41
46
|
if (trimmed === '')
|
|
42
47
|
return 1;
|
|
43
48
|
return trimmed.split(NL).length;
|
|
@@ -197,15 +202,23 @@ export function wrapToWidth(s, maxCols) {
|
|
|
197
202
|
// separator, so `'aaa bbb'` at width 7 became `'aaabbb'` and an indented
|
|
198
203
|
// warning lost its indent.
|
|
199
204
|
const words = raw.split(/ +/).filter((w) => w !== '');
|
|
200
|
-
|
|
205
|
+
// An INDENT deeper than the whole width can never be reproduced within
|
|
206
|
+
// the margin: keeping it on the first line glued `width` MORE columns to
|
|
207
|
+
// it (a pasted stack-trace line pushed the terminal into horizontal
|
|
208
|
+
// scroll). It is clipped so every produced line fits.
|
|
209
|
+
let indent = raw.slice(0, raw.length - raw.trimStart().length);
|
|
210
|
+
if (visLen(indent) > width)
|
|
211
|
+
indent = truncateToWidth(indent, width - 1);
|
|
212
|
+
const avail = Math.max(1, width - visLen(indent));
|
|
201
213
|
let line = indent;
|
|
202
214
|
let lineW = visLen(indent);
|
|
203
215
|
let first = true;
|
|
204
216
|
for (let i = 0; i < words.length; i++) {
|
|
205
217
|
const word = words[i];
|
|
206
218
|
const w = visLen(word);
|
|
207
|
-
// Hard-split a word
|
|
208
|
-
|
|
219
|
+
// Hard-split a word that does not fit the REMAINING columns on this
|
|
220
|
+
// line (the indent, plus any earlier word, consumes part of it).
|
|
221
|
+
if (w > avail) {
|
|
209
222
|
if (!first) {
|
|
210
223
|
out.push(line);
|
|
211
224
|
line = '';
|
|
@@ -213,12 +226,14 @@ export function wrapToWidth(s, maxCols) {
|
|
|
213
226
|
}
|
|
214
227
|
let piece = '';
|
|
215
228
|
let pieceW = 0;
|
|
229
|
+
let room = width - lineW;
|
|
216
230
|
for (const ch of word) {
|
|
217
231
|
const cw = charWidth(ch.codePointAt(0));
|
|
218
|
-
if (pieceW + cw >
|
|
232
|
+
if (pieceW + cw > room) {
|
|
219
233
|
out.push(line + piece);
|
|
220
234
|
line = '';
|
|
221
235
|
lineW = 0;
|
|
236
|
+
room = width;
|
|
222
237
|
piece = '';
|
|
223
238
|
pieceW = 0;
|
|
224
239
|
}
|
|
@@ -382,11 +397,21 @@ export const CONTEXT_YELLOW_PCT = 50;
|
|
|
382
397
|
export const CONTEXT_RED_PCT = 80;
|
|
383
398
|
// The color role for a context fill level, as a stable string so it can be
|
|
384
399
|
// unit-tested without a terminal: 'ok' | 'warn' | 'high'. Null tokens -> null.
|
|
400
|
+
// A usable context window: a finite POSITIVE number. The denominator comes
|
|
401
|
+
// from config (`ui.contextLimit` / a provider section), so a 0 / NaN /
|
|
402
|
+
// negative / Infinity value reaches these functions; dividing by it rendered
|
|
403
|
+
// `Infinity%` / `NaN%` (or a fake 0.0% and even 'high') on a real token
|
|
404
|
+
// count. An unusable window falls back to the default one.
|
|
405
|
+
function usableLimit(limit) {
|
|
406
|
+
return typeof limit === 'number' && Number.isFinite(limit) && limit > 0
|
|
407
|
+
? limit
|
|
408
|
+
: CONTEXT_LIMIT;
|
|
409
|
+
}
|
|
385
410
|
export function tokenStatusLevel(tokens, limit = CONTEXT_LIMIT) {
|
|
386
411
|
if (typeof tokens !== 'number' || !Number.isFinite(tokens) || tokens < 0) {
|
|
387
412
|
return null;
|
|
388
413
|
}
|
|
389
|
-
const pct = (tokens / limit) * 100;
|
|
414
|
+
const pct = (tokens / usableLimit(limit)) * 100;
|
|
390
415
|
if (pct >= CONTEXT_RED_PCT)
|
|
391
416
|
return 'high';
|
|
392
417
|
if (pct >= CONTEXT_YELLOW_PCT)
|
|
@@ -399,7 +424,7 @@ export function formatTokenStatus(tokens, limit = CONTEXT_LIMIT) {
|
|
|
399
424
|
}
|
|
400
425
|
const n = Math.round(tokens);
|
|
401
426
|
const compact = formatCompactTokens(n);
|
|
402
|
-
const pct = Math.max(0, (n / limit) * 100);
|
|
427
|
+
const pct = Math.max(0, (n / usableLimit(limit)) * 100);
|
|
403
428
|
const pctStr = pct >= 10 ? String(Math.round(pct)) : pct.toFixed(1);
|
|
404
429
|
// "ctx:" marks this as the USED context size, so the operator does not have
|
|
405
430
|
// to guess what "302k · 30%" means.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { translate } from './i18n.js';
|
|
2
|
+
/**
|
|
3
|
+
* The lines of `/limits` for an aggregate. `intervalMs` is the ACTIVE browser's
|
|
4
|
+
* send interval and `adaptive` whether the pause is derived adaptively.
|
|
5
|
+
*/
|
|
6
|
+
export function formatLimits(stats, opts, t = translate('en')) {
|
|
7
|
+
const out = [];
|
|
8
|
+
out.push({
|
|
9
|
+
kind: 'title',
|
|
10
|
+
text: t('limits.title', { provider: opts.providerLabel }),
|
|
11
|
+
});
|
|
12
|
+
const windows = [
|
|
13
|
+
['10m', stats.last10m],
|
|
14
|
+
['30m', stats.last30m],
|
|
15
|
+
['60m', stats.last60m],
|
|
16
|
+
];
|
|
17
|
+
for (const [label, n] of windows) {
|
|
18
|
+
out.push({
|
|
19
|
+
kind: 'window',
|
|
20
|
+
text: t('limits.window', { label, n: String(n) }),
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
out.push({
|
|
24
|
+
kind: 'rate',
|
|
25
|
+
text: t('limits.rate', {
|
|
26
|
+
n: String(stats.rateLimits),
|
|
27
|
+
sec: String(Math.round(stats.avgBlockMs / 1000)),
|
|
28
|
+
}),
|
|
29
|
+
});
|
|
30
|
+
out.push({
|
|
31
|
+
kind: 'interval',
|
|
32
|
+
text: t('limits.interval', {
|
|
33
|
+
sec: String(Math.round(opts.intervalMs / 1000)),
|
|
34
|
+
adaptive: opts.adaptive ? t('limits.adaptive_on') : '',
|
|
35
|
+
}),
|
|
36
|
+
});
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
/** The text for an EMPTY telemetry file (nothing recorded yet). */
|
|
40
|
+
export function limitsEmptyMessage(path, t = translate('en')) {
|
|
41
|
+
return t('limits.none', { path });
|
|
42
|
+
}
|
package/dist/mcp.js
CHANGED
|
@@ -44,7 +44,14 @@ export function hardenPlaywrightArgs(command, args) {
|
|
|
44
44
|
const hay = (String(command || '') + ' ' + args.join(' ')).toLowerCase();
|
|
45
45
|
if (!hay.includes('playwright') || !hay.includes('mcp'))
|
|
46
46
|
return args;
|
|
47
|
-
|
|
47
|
+
// The profile flag may sit in the COMMAND itself: a config like
|
|
48
|
+
// `{command: "npx --user-data-dir=/tmp/x", args: ["@playwright/mcp"]}` is
|
|
49
|
+
// legitimate, and checking only `args` appended `--isolated` next to the
|
|
50
|
+
// profile — the server then has BOTH an on-disk profile and an in-memory
|
|
51
|
+
// one and picks unpredictably (the live agent's profile is the very thing
|
|
52
|
+
// this guard exists to protect).
|
|
53
|
+
const all = [String(command || ''), ...args];
|
|
54
|
+
if (all.some((a) =>
|
|
48
55
|
// A PREFIX match treated `--isolated=false` as the isolated flag, so
|
|
49
56
|
// the hardener added nothing and the MCP browser opened the agent's
|
|
50
57
|
// live profile anyway. Only a flag EXPLICITLY ON, or a value-taking
|
|
@@ -53,10 +60,16 @@ export function hardenPlaywrightArgs(command, args) {
|
|
|
53
60
|
/^--isolated=(?!false$)/.test(a) ||
|
|
54
61
|
a === '--user-data-dir' ||
|
|
55
62
|
a.startsWith('--user-data-dir=') ||
|
|
63
|
+
a.endsWith('--user-data-dir') ||
|
|
64
|
+
a.includes('--user-data-dir=') ||
|
|
56
65
|
a === '--extension' ||
|
|
57
66
|
a.startsWith('--extension=') ||
|
|
67
|
+
a.endsWith('--extension') ||
|
|
68
|
+
a.includes('--extension=') ||
|
|
58
69
|
a === '--cdp-endpoint' ||
|
|
59
|
-
a.startsWith('--cdp-endpoint=')
|
|
70
|
+
a.startsWith('--cdp-endpoint=') ||
|
|
71
|
+
a.endsWith('--cdp-endpoint') ||
|
|
72
|
+
a.includes('--cdp-endpoint='))) {
|
|
60
73
|
return args;
|
|
61
74
|
}
|
|
62
75
|
return [...args, '--isolated'];
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { providerHasModelPicker } from './providers.js';
|
|
2
|
+
import { translate } from './i18n.js';
|
|
3
|
+
/**
|
|
4
|
+
* Decide what /model should do from the command argument and the provider.
|
|
5
|
+
* The argument is the text after "/model" (trimmed here again); an empty
|
|
6
|
+
* argument means "list".
|
|
7
|
+
*/
|
|
8
|
+
export function parseModelIntent(provider, arg, t = translate('en')) {
|
|
9
|
+
if (!providerHasModelPicker(provider)) {
|
|
10
|
+
return {
|
|
11
|
+
kind: 'unsupported',
|
|
12
|
+
message: t('model.unsupported', { provider }),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
const model = String(arg ?? '').trim();
|
|
16
|
+
return model ? { kind: 'set', model } : { kind: 'list' };
|
|
17
|
+
}
|
|
18
|
+
/** The text for an EMPTY model list (a provider that answered with nothing). */
|
|
19
|
+
export function emptyModelListMessage(t = translate('en')) {
|
|
20
|
+
return t('model.list', { v: '(none)' });
|
|
21
|
+
}
|
package/dist/path-token.js
CHANGED
|
@@ -107,35 +107,62 @@ export function extractAtFileRefs(text) {
|
|
|
107
107
|
continue;
|
|
108
108
|
}
|
|
109
109
|
const prev = i > 0 ? chars[i - 1] : '';
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
|
|
110
|
+
// The at-sign must START a reference: at the very beginning, after
|
|
111
|
+
// whitespace/a bracket/a quote, or right after a LIST SEPARATOR (a comma
|
|
112
|
+
// or a semicolon). A second at-sign WITHOUT a separator (`@a.ts@b.ts`) is
|
|
113
|
+
// an email/decorator shape and is skipped — the glued path cannot be
|
|
114
|
+
// inlined anyway.
|
|
115
|
+
if (prev && !isRefBoundary(prev) && prev !== ',' && prev !== ';') {
|
|
115
116
|
i++;
|
|
116
117
|
continue;
|
|
117
118
|
}
|
|
118
119
|
let j = i + 1;
|
|
119
120
|
let tok = '';
|
|
121
|
+
// The token is rejected WHOLE when it contains an at-sign that is NOT a
|
|
122
|
+
// list separator — `@Component` / `user@host` are a decorator and an
|
|
123
|
+
// email. Trimming the prefix at that at-sign would otherwise inline a
|
|
124
|
+
// bogus path.
|
|
125
|
+
let hadInnerAt = false;
|
|
126
|
+
// A token that contains an at-sign (`@Component`, `user@host`) is a
|
|
127
|
+
// decorator / an email, NOT a file reference. The scan used to stop at
|
|
128
|
+
// that at-sign and hand the PREFIX (`Component` … no) or, worse, the part
|
|
129
|
+
// BEFORE it to the inliner. Now the whole token is rejected instead.
|
|
130
|
+
const isRef = (t) => t.length > 0 && !t.includes('@');
|
|
120
131
|
while (j < n && !isRefEnd(chars[j])) {
|
|
121
132
|
// A COMMA directly followed by another `@` starts the NEXT reference:
|
|
122
133
|
// `@a.ts,@b.ts` is a list, and scanning to whitespace made the whole
|
|
123
134
|
// `a.ts,@b.ts` one path, so neither file was inlined.
|
|
124
135
|
if (chars[j] === ',' && chars[j + 1] === '@')
|
|
125
136
|
break;
|
|
137
|
+
// A SEMICOLON or a second at-sign also separates list items. Scanning
|
|
138
|
+
// to whitespace made `a.ts;@b.ts` ONE path whose extension consumed the
|
|
139
|
+
// separator, so `looksLikeToken` failed and BOTH files were dropped.
|
|
140
|
+
if ((chars[j] === ';' || chars[j] === '@') && chars[j + 1] === '@')
|
|
141
|
+
break;
|
|
142
|
+
// An at-sign followed by an ordinary char is part of the token (an
|
|
143
|
+
// email/decorator) — remember it so the token is rejected below.
|
|
144
|
+
if (chars[j] === '@')
|
|
145
|
+
hadInnerAt = true;
|
|
126
146
|
tok += chars[j];
|
|
127
147
|
j++;
|
|
128
148
|
}
|
|
129
149
|
const cleaned = stripRefPunct(tok);
|
|
130
|
-
if (
|
|
150
|
+
if (!hadInnerAt &&
|
|
151
|
+
isRef(cleaned) &&
|
|
152
|
+
looksLikeToken(cleaned) &&
|
|
153
|
+
!seen.has(cleaned)) {
|
|
131
154
|
seen.add(cleaned);
|
|
132
155
|
out.push(cleaned);
|
|
133
156
|
}
|
|
134
157
|
// The break above leaves `j` ON the comma; step past it so the next `@`
|
|
135
158
|
// is seen as the start of a reference. When the scan ended on a
|
|
136
159
|
// terminator instead, continue AFTER it.
|
|
137
|
-
|
|
138
|
-
|
|
160
|
+
// The scan stopped ON a separator that split the token (`,`, `;` or a
|
|
161
|
+
// second at-sign): resume AT it so the next part is scanned as its own
|
|
162
|
+
// reference. Continuing past it dropped the second file of a list.
|
|
163
|
+
const sep = j < n ? chars[j] : '';
|
|
164
|
+
if (j > i + 1 && (sep === ',' || sep === ';' || sep === '@'))
|
|
165
|
+
i = j;
|
|
139
166
|
else
|
|
140
167
|
i = j > i + 1 ? j : i + 1;
|
|
141
168
|
}
|
package/dist/permissions.js
CHANGED
|
@@ -156,14 +156,43 @@ export function decidePermission(policy, tool, args) {
|
|
|
156
156
|
// the natural shape for execFile-style MCP tools). Accepting only strings
|
|
157
157
|
// let a `command` deny be bypassed by passing the words as an array.
|
|
158
158
|
const commands = [];
|
|
159
|
-
|
|
160
|
-
|
|
159
|
+
// Collect a value as a command string: a string is one command, an ARRAY
|
|
160
|
+
// (argv) is joined into one line so a rule can match the words in order.
|
|
161
|
+
const addCommand = (v) => {
|
|
161
162
|
if (typeof v === 'string')
|
|
162
163
|
commands.push(v);
|
|
163
164
|
else if (Array.isArray(v)) {
|
|
164
|
-
|
|
165
|
+
const parts = v.filter((x) => typeof x === 'string');
|
|
166
|
+
if (parts.length)
|
|
167
|
+
commands.push(parts.join(' '));
|
|
165
168
|
}
|
|
169
|
+
};
|
|
170
|
+
for (const k of COMMAND_KEYS) {
|
|
171
|
+
if (k in a)
|
|
172
|
+
addCommand(a[k]);
|
|
166
173
|
}
|
|
174
|
+
// MCP tools (and a model that nests its payload) send the command one or
|
|
175
|
+
// more levels down — `{args: {command}}`, `{input: {params: {cmd}}}`.
|
|
176
|
+
// Checking only the TOP level let a `command` deny rule be bypassed by
|
|
177
|
+
// wrapping the same words in an object, so nested objects and arrays are
|
|
178
|
+
// scanned too (depth-bounded like the path scan).
|
|
179
|
+
const walkCommands = (v, depth) => {
|
|
180
|
+
if (depth > 4 || v === null || v === undefined)
|
|
181
|
+
return;
|
|
182
|
+
if (Array.isArray(v)) {
|
|
183
|
+
for (const x of v)
|
|
184
|
+
walkCommands(x, depth + 1);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (typeof v !== 'object')
|
|
188
|
+
return;
|
|
189
|
+
for (const [key, val] of Object.entries(v)) {
|
|
190
|
+
if (COMMAND_KEYS.includes(key))
|
|
191
|
+
addCommand(val);
|
|
192
|
+
walkCommands(val, depth + 1);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
walkCommands(a, 0);
|
|
167
196
|
const paths = collectPathCandidates(tool, args);
|
|
168
197
|
for (const r of policy.rules) {
|
|
169
198
|
// An UNPARSABLE regex in any field of the rule makes the rule fail
|