@glbgpt/cli 0.1.0-test.5 → 0.1.1
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/README.md +81 -6
- package/dist/src/auth/authClient.d.ts +21 -2
- package/dist/src/auth/authClient.js +62 -2
- package/dist/src/auth/authClient.js.map +1 -1
- package/dist/src/auth/loginFlow.js +5 -2
- package/dist/src/auth/loginFlow.js.map +1 -1
- package/dist/src/auth/loopbackServer.d.ts +2 -1
- package/dist/src/auth/loopbackServer.js +6 -3
- package/dist/src/auth/loopbackServer.js.map +1 -1
- package/dist/src/commands/accountCommand.js +10 -4
- package/dist/src/commands/accountCommand.js.map +1 -1
- package/dist/src/commands/configCommand.d.ts +5 -0
- package/dist/src/commands/configCommand.js +15 -2
- package/dist/src/commands/configCommand.js.map +1 -1
- package/dist/src/commands/execCommand.d.ts +6 -0
- package/dist/src/commands/execCommand.js +64 -9
- package/dist/src/commands/execCommand.js.map +1 -1
- package/dist/src/commands/loginCommand.js +9 -6
- package/dist/src/commands/loginCommand.js.map +1 -1
- package/dist/src/commands/mediaCommand.js +3 -2
- package/dist/src/commands/mediaCommand.js.map +1 -1
- package/dist/src/commands/skillCommand.d.ts +30 -0
- package/dist/src/commands/skillCommand.js +160 -0
- package/dist/src/commands/skillCommand.js.map +1 -0
- package/dist/src/config/configFile.d.ts +12 -0
- package/dist/src/config/configFile.js +12 -0
- package/dist/src/config/configFile.js.map +1 -1
- package/dist/src/config/env.d.ts +18 -0
- package/dist/src/config/env.js +57 -7
- package/dist/src/config/env.js.map +1 -1
- package/dist/src/core/catalog.d.ts +8 -1
- package/dist/src/core/catalog.js +13 -2
- package/dist/src/core/catalog.js.map +1 -1
- package/dist/src/core/contextPrune.d.ts +13 -0
- package/dist/src/core/contextPrune.js +66 -0
- package/dist/src/core/contextPrune.js.map +1 -0
- package/dist/src/core/httpChatBackend.js +22 -5
- package/dist/src/core/httpChatBackend.js.map +1 -1
- package/dist/src/core/runChat.d.ts +9 -5
- package/dist/src/core/runChat.js +10 -7
- package/dist/src/core/runChat.js.map +1 -1
- package/dist/src/core/session.d.ts +11 -0
- package/dist/src/core/session.js +20 -1
- package/dist/src/core/session.js.map +1 -1
- package/dist/src/core/toolRegistry.d.ts +16 -3
- package/dist/src/core/toolRegistry.js +28 -15
- package/dist/src/core/toolRegistry.js.map +1 -1
- package/dist/src/core/turnLoop.d.ts +4 -0
- package/dist/src/core/turnLoop.js +78 -13
- package/dist/src/core/turnLoop.js.map +1 -1
- package/dist/src/core/types.d.ts +21 -0
- package/dist/src/core/updateCheck.d.ts +46 -0
- package/dist/src/core/updateCheck.js +139 -0
- package/dist/src/core/updateCheck.js.map +1 -0
- package/dist/src/exec/media.d.ts +32 -0
- package/dist/src/exec/media.js +70 -0
- package/dist/src/exec/media.js.map +1 -0
- package/dist/src/exec/output.d.ts +16 -0
- package/dist/src/exec/output.js +41 -0
- package/dist/src/exec/output.js.map +1 -1
- package/dist/src/fs/globTool.d.ts +8 -0
- package/dist/src/fs/globTool.js +117 -0
- package/dist/src/fs/globTool.js.map +1 -0
- package/dist/src/fs/grepTool.d.ts +4 -0
- package/dist/src/fs/grepTool.js +146 -0
- package/dist/src/fs/grepTool.js.map +1 -0
- package/dist/src/fs/index.d.ts +8 -0
- package/dist/src/fs/index.js +13 -0
- package/dist/src/fs/index.js.map +1 -0
- package/dist/src/fs/limits.d.ts +34 -0
- package/dist/src/fs/limits.js +74 -0
- package/dist/src/fs/limits.js.map +1 -0
- package/dist/src/fs/listDirTool.d.ts +2 -0
- package/dist/src/fs/listDirTool.js +68 -0
- package/dist/src/fs/listDirTool.js.map +1 -0
- package/dist/src/fs/readFileTool.d.ts +2 -0
- package/dist/src/fs/readFileTool.js +102 -0
- package/dist/src/fs/readFileTool.js.map +1 -0
- package/dist/src/fs/walk.d.ts +11 -0
- package/dist/src/fs/walk.js +38 -0
- package/dist/src/fs/walk.js.map +1 -0
- package/dist/src/fs/workspaceGuard.d.ts +27 -0
- package/dist/src/fs/workspaceGuard.js +96 -0
- package/dist/src/fs/workspaceGuard.js.map +1 -0
- package/dist/src/index.js +47 -3
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcp/mediaTools.d.ts +63 -0
- package/dist/src/mcp/mediaTools.js +385 -0
- package/dist/src/mcp/mediaTools.js.map +1 -0
- package/dist/src/mcp/server.d.ts +6 -2
- package/dist/src/mcp/server.js +35 -11
- package/dist/src/mcp/server.js.map +1 -1
- package/dist/src/media/aiApiClient.d.ts +5 -0
- package/dist/src/media/aiApiClient.js +12 -1
- package/dist/src/media/aiApiClient.js.map +1 -1
- package/dist/src/media/budget.d.ts +42 -0
- package/dist/src/media/budget.js +95 -0
- package/dist/src/media/budget.js.map +1 -0
- package/dist/src/media/executor.d.ts +9 -0
- package/dist/src/media/executor.js +1 -1
- package/dist/src/media/executor.js.map +1 -1
- package/dist/src/media/generateImageTool.d.ts +8 -2
- package/dist/src/media/generateImageTool.js +51 -3
- package/dist/src/media/generateImageTool.js.map +1 -1
- package/dist/src/media/promptSanitizer.d.ts +11 -0
- package/dist/src/media/promptSanitizer.js +33 -0
- package/dist/src/media/promptSanitizer.js.map +1 -0
- package/dist/src/media/taskManager.d.ts +8 -1
- package/dist/src/media/taskManager.js +1 -1
- package/dist/src/media/taskManager.js.map +1 -1
- package/dist/src/tui/App.js +54 -21
- package/dist/src/tui/App.js.map +1 -1
- package/dist/src/tui/ParamEditor.d.ts +11 -2
- package/dist/src/tui/ParamEditor.js +17 -6
- package/dist/src/tui/ParamEditor.js.map +1 -1
- package/dist/src/tui/atPopup.d.ts +8 -0
- package/dist/src/tui/atPopup.js +1 -1
- package/dist/src/tui/atPopup.js.map +1 -1
- package/dist/src/tui/chatController.d.ts +58 -2
- package/dist/src/tui/chatController.js +231 -40
- package/dist/src/tui/chatController.js.map +1 -1
- package/dist/src/tui/models.d.ts +6 -0
- package/dist/src/tui/models.js +14 -6
- package/dist/src/tui/models.js.map +1 -1
- package/dist/src/tui/run.js +55 -16
- package/dist/src/tui/run.js.map +1 -1
- package/dist/src/util/brokenPipe.d.ts +10 -0
- package/dist/src/util/brokenPipe.js +19 -0
- package/dist/src/util/brokenPipe.js.map +1 -0
- package/dist/src/web/index.d.ts +8 -0
- package/dist/src/web/index.js +10 -0
- package/dist/src/web/index.js.map +1 -0
- package/dist/src/web/webFetchTool.d.ts +18 -0
- package/dist/src/web/webFetchTool.js +222 -0
- package/dist/src/web/webFetchTool.js.map +1 -0
- package/dist/src/web/webSearchTool.d.ts +22 -0
- package/dist/src/web/webSearchTool.js +123 -0
- package/dist/src/web/webSearchTool.js.map +1 -0
- package/package.json +3 -2
- package/skills/globalgpt/SKILL.md +99 -0
- package/skills/globalgpt/references/troubleshooting.md +34 -0
- package/skills/globalgpt-coding/SKILL.md +65 -0
- package/skills/globalgpt-coding/references/troubleshooting.md +34 -0
- package/skills/globalgpt-image/SKILL.md +109 -0
- package/skills/globalgpt-image/references/recipes.md +52 -0
- package/skills/globalgpt-image/references/troubleshooting.md +49 -0
- package/skills/globalgpt-video/SKILL.md +109 -0
- package/skills/globalgpt-video/references/recipes.md +50 -0
- package/skills/globalgpt-video/references/troubleshooting.md +49 -0
|
@@ -3,6 +3,15 @@ import { type AttachmentContext } from './attachments.js';
|
|
|
3
3
|
import { Session } from '../core/session.js';
|
|
4
4
|
import type { TurnLoop } from '../core/turnLoop.js';
|
|
5
5
|
import type { TaskEvent } from '../media/taskManager.js';
|
|
6
|
+
/**
|
|
7
|
+
* A styled fragment of one rich-note line. Styles are SEMANTIC (what the text is),
|
|
8
|
+
* not visual — the view maps them to colors in one place, so the whole TUI restyles
|
|
9
|
+
* by editing a single table.
|
|
10
|
+
*/
|
|
11
|
+
export interface RichSpan {
|
|
12
|
+
text: string;
|
|
13
|
+
style?: 'header' | 'accent' | 'dim' | 'warn';
|
|
14
|
+
}
|
|
6
15
|
/** One rendered entry in the conversation timeline. */
|
|
7
16
|
export type TimelineItem = {
|
|
8
17
|
kind: 'user';
|
|
@@ -20,6 +29,9 @@ export type TimelineItem = {
|
|
|
20
29
|
} | {
|
|
21
30
|
kind: 'note';
|
|
22
31
|
text: string;
|
|
32
|
+
} | {
|
|
33
|
+
kind: 'rich';
|
|
34
|
+
lines: RichSpan[][];
|
|
23
35
|
} | {
|
|
24
36
|
kind: 'media';
|
|
25
37
|
taskId: string;
|
|
@@ -42,6 +54,8 @@ export interface ViewState {
|
|
|
42
54
|
queued: QueuedMessage[];
|
|
43
55
|
/** Cumulative session usage (chat tokens are informational; billing is server-side). */
|
|
44
56
|
sessionUsage: SessionUsage;
|
|
57
|
+
/** Tool calls dispatched so far THIS turn (reset to 0 at each turn start); drives the footer counter. */
|
|
58
|
+
toolCallsUsed: number;
|
|
45
59
|
}
|
|
46
60
|
/** A message resolved at Enter time and waiting for the current turn to finish. */
|
|
47
61
|
export interface QueuedMessage {
|
|
@@ -79,10 +93,21 @@ export interface ControllerExtras {
|
|
|
79
93
|
}>>;
|
|
80
94
|
/** One extra status line for `/status` (e.g. plan/balance from the account API). */
|
|
81
95
|
fetchAccountLine?: () => Promise<string | null>;
|
|
96
|
+
/** Known newer published version, when the update check has one (run.tsx wires it). */
|
|
97
|
+
updateNotice?: () => {
|
|
98
|
+
latest: string;
|
|
99
|
+
command: string;
|
|
100
|
+
} | null;
|
|
101
|
+
/** Non-default API base URL in effect, if any (run.tsx wires it) — shown by `/status`. */
|
|
102
|
+
baseUrlOverride?: () => string | null;
|
|
82
103
|
/** Full multi-line account summary for `/account` (email · plan · balance). */
|
|
83
104
|
fetchAccount?: () => Promise<string>;
|
|
84
|
-
/** Recent spend
|
|
85
|
-
fetchTransactions?: () => Promise<
|
|
105
|
+
/** Recent spend rows for `/transactions` (authoritative billing). */
|
|
106
|
+
fetchTransactions?: () => Promise<Array<{
|
|
107
|
+
createdAt: string;
|
|
108
|
+
credits: number;
|
|
109
|
+
model: string;
|
|
110
|
+
}>>;
|
|
86
111
|
/** Re-authenticate in place (`/login`), hot-swapping the token everywhere; returns a status line. */
|
|
87
112
|
reauth?: (mode: 'browser' | 'no-browser') => Promise<string>;
|
|
88
113
|
/** Sign out (`/logout`): clear stored credentials; returns a status line. */
|
|
@@ -103,6 +128,8 @@ export declare class ChatController {
|
|
|
103
128
|
private readonly subscribers;
|
|
104
129
|
/** Index of the assistant item currently collecting deltas, or -1 if none is open. */
|
|
105
130
|
private openAssistant;
|
|
131
|
+
/** The upstream-reported prompt size of the LATEST model call — the pruning trigger signal. */
|
|
132
|
+
private lastPromptTokens;
|
|
106
133
|
/** Abort controller for the in-flight turn (null when idle). */
|
|
107
134
|
private turnAbort;
|
|
108
135
|
/** Serializes busy-time enqueues so rapid submits keep keystroke order. */
|
|
@@ -130,6 +157,17 @@ export declare class ChatController {
|
|
|
130
157
|
* undefined only when `last` was asked for but nothing has been generated yet.
|
|
131
158
|
*/
|
|
132
159
|
resolveMediaReference(ref: string): string | undefined;
|
|
160
|
+
/**
|
|
161
|
+
* Every media reference this session knows about (generated local paths + hosted URLs), for the
|
|
162
|
+
* generate tools' prompt sanitizer: a reference the model pasted into the prompt TEXT is moved
|
|
163
|
+
* back into the reference parameter. Exact-match source of truth — nothing else is ever touched.
|
|
164
|
+
*/
|
|
165
|
+
getKnownMediaRefs(): string[];
|
|
166
|
+
/**
|
|
167
|
+
* Reverse-map a hosted output URL to the local file name the user already knows from the
|
|
168
|
+
* "image ready" line. Display-only: the internal storage hostname must never reach the user.
|
|
169
|
+
*/
|
|
170
|
+
localNameForUrl(url: string): string | undefined;
|
|
133
171
|
/** The session currently being edited (swapped by `/new`); used for persistence. */
|
|
134
172
|
getSession(): Session;
|
|
135
173
|
/**
|
|
@@ -169,6 +207,15 @@ export declare class ChatController {
|
|
|
169
207
|
/** `/resume`: swap in a previously saved session and rebuild the timeline. */
|
|
170
208
|
replaceSession(session: Session, persistedTimeline?: unknown[]): void;
|
|
171
209
|
note(text: string): void;
|
|
210
|
+
/** A loud one-line warning (yellow) — for things that must not blend into gray notes. */
|
|
211
|
+
warn(text: string): void;
|
|
212
|
+
private rich;
|
|
213
|
+
/** One `label: value` row — dim label column, optionally styled value. */
|
|
214
|
+
private static kvRow;
|
|
215
|
+
/** Style a `label: value · label: value` summary line without changing its text. */
|
|
216
|
+
private static kvInline;
|
|
217
|
+
/** A value with a trailing parenthetical (e.g. a balance breakdown) renders the detail dim. */
|
|
218
|
+
private static valueSpans;
|
|
172
219
|
/**
|
|
173
220
|
* Submit a user message and run one turn to completion, updating the timeline as events
|
|
174
221
|
* arrive. `displayText` (when given) is what the transcript shows — e.g. "[Image #1]"
|
|
@@ -182,6 +229,13 @@ export declare class ChatController {
|
|
|
182
229
|
/** Resolve `@…` attachments into a queueable message; null → error already noted. */
|
|
183
230
|
private resolveForSubmit;
|
|
184
231
|
/** Run one turn whose attachments are already resolved. Caller owns the busy flag. */
|
|
232
|
+
/**
|
|
233
|
+
* Trigger-based microcompact (spec 2026-07-19-context-pruning-design.md): runs
|
|
234
|
+
* only at turn boundaries, only when the last model call's prompt size crossed
|
|
235
|
+
* the threshold. Batch-clears in one event so the request prefix stays
|
|
236
|
+
* byte-stable between triggers (upstream prompt-cache friendliness).
|
|
237
|
+
*/
|
|
238
|
+
private maybePruneContext;
|
|
185
239
|
private runResolvedTurn;
|
|
186
240
|
/** Reference-usable images the user attached to the current turn (for auto-reference in generation). */
|
|
187
241
|
getPendingReferenceImages(): string[];
|
|
@@ -199,5 +253,7 @@ export declare class ChatController {
|
|
|
199
253
|
private setState;
|
|
200
254
|
private emit;
|
|
201
255
|
}
|
|
256
|
+
/** Validate a persisted transcript; returns null when absent/unusable (→ derive instead). */
|
|
257
|
+
export declare function restoreTimeline(persisted: unknown[] | undefined): TimelineItem[] | null;
|
|
202
258
|
/** Convert a stored message history into renderable timeline items (for resume). */
|
|
203
259
|
export declare function timelineFromMessages(messages: ChatMessage[]): TimelineItem[];
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { resolveAttachments } from './attachments.js';
|
|
2
2
|
import { parseSlashMedia } from './slashMedia.js';
|
|
3
3
|
import { availableModels } from '../media/manifest.js';
|
|
4
|
-
import { getChatModels,
|
|
4
|
+
import { getChatModels, modelDescription, modelSupportsTools } from './models.js';
|
|
5
5
|
import { SLASH_COMMANDS } from './commands.js';
|
|
6
6
|
import { Session } from '../core/session.js';
|
|
7
7
|
import { newId } from '../core/id.js';
|
|
8
|
+
import { selectPrunable } from '../core/contextPrune.js';
|
|
9
|
+
import { MAX_TOOL_CALLS_PER_TURN } from '../fs/index.js';
|
|
10
|
+
import { PRUNE_TRIGGER_TOKENS } from '../fs/limits.js';
|
|
11
|
+
import { getConfigValueSync } from '../config/configFile.js';
|
|
8
12
|
import path from 'node:path';
|
|
9
13
|
import os from 'node:os';
|
|
10
14
|
/** A saved media path is a video when its extension is a known video container. */
|
|
@@ -17,15 +21,17 @@ function resolveTokenPath(token) {
|
|
|
17
21
|
return path.resolve(expanded);
|
|
18
22
|
}
|
|
19
23
|
// Generated from the single command source so /help can never drift from the popup.
|
|
20
|
-
const
|
|
21
|
-
'Commands:',
|
|
24
|
+
const HELP_LINES = [
|
|
25
|
+
[{ text: 'Commands', style: 'header' }],
|
|
22
26
|
...SLASH_COMMANDS.map((c) => {
|
|
23
|
-
const
|
|
24
|
-
|
|
27
|
+
const cmd = ` /${c.name}${c.args ? ` ${c.args}` : ''}`;
|
|
28
|
+
// Align descriptions; a command longer than the column still gets breathing room.
|
|
29
|
+
return [{ text: cmd.length >= 26 ? `${cmd} ` : cmd.padEnd(26), style: 'accent' }, { text: c.description }];
|
|
25
30
|
}),
|
|
26
|
-
|
|
27
|
-
'
|
|
28
|
-
|
|
31
|
+
[],
|
|
32
|
+
[{ text: 'Attach images: @image (last generated) · @clipboard or Ctrl+V (clipboard) · @<path>.', style: 'dim' }],
|
|
33
|
+
[{ text: 'Type anything else to chat. The model replies in your language.', style: 'dim' }],
|
|
34
|
+
];
|
|
29
35
|
/**
|
|
30
36
|
* Headless controller for the chat TUI: owns the view state and maps the engine's
|
|
31
37
|
* EngineEvent stream into a renderable timeline. Contains no Ink/React so it can be
|
|
@@ -39,6 +45,8 @@ export class ChatController {
|
|
|
39
45
|
subscribers = new Set();
|
|
40
46
|
/** Index of the assistant item currently collecting deltas, or -1 if none is open. */
|
|
41
47
|
openAssistant = -1;
|
|
48
|
+
/** The upstream-reported prompt size of the LATEST model call — the pruning trigger signal. */
|
|
49
|
+
lastPromptTokens = 0;
|
|
42
50
|
/** Abort controller for the in-flight turn (null when idle). */
|
|
43
51
|
turnAbort = null;
|
|
44
52
|
/** Serializes busy-time enqueues so rapid submits keep keystroke order. */
|
|
@@ -62,6 +70,7 @@ export class ChatController {
|
|
|
62
70
|
runningTasks: [],
|
|
63
71
|
queued: [],
|
|
64
72
|
sessionUsage: { promptTokens: 0, completionTokens: 0, turns: 0, mediaCreditsEstimated: 0, mediaSubmits: 0 },
|
|
73
|
+
toolCallsUsed: 0,
|
|
65
74
|
};
|
|
66
75
|
}
|
|
67
76
|
getState() {
|
|
@@ -89,6 +98,32 @@ export class ChatController {
|
|
|
89
98
|
return r;
|
|
90
99
|
return this.generatedUrls.get(resolveTokenPath(r)) ?? r;
|
|
91
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Every media reference this session knows about (generated local paths + hosted URLs), for the
|
|
103
|
+
* generate tools' prompt sanitizer: a reference the model pasted into the prompt TEXT is moved
|
|
104
|
+
* back into the reference parameter. Exact-match source of truth — nothing else is ever touched.
|
|
105
|
+
*/
|
|
106
|
+
getKnownMediaRefs() {
|
|
107
|
+
const refs = new Set();
|
|
108
|
+
for (const [p, url] of this.generatedUrls) {
|
|
109
|
+
refs.add(p);
|
|
110
|
+
refs.add(url);
|
|
111
|
+
}
|
|
112
|
+
if (this.lastMediaUrl)
|
|
113
|
+
refs.add(this.lastMediaUrl);
|
|
114
|
+
return [...refs];
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Reverse-map a hosted output URL to the local file name the user already knows from the
|
|
118
|
+
* "image ready" line. Display-only: the internal storage hostname must never reach the user.
|
|
119
|
+
*/
|
|
120
|
+
localNameForUrl(url) {
|
|
121
|
+
for (const [p, u] of this.generatedUrls) {
|
|
122
|
+
if (u === url)
|
|
123
|
+
return path.basename(p);
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
92
127
|
/** The session currently being edited (swapped by `/new`); used for persistence. */
|
|
93
128
|
getSession() {
|
|
94
129
|
return this.session;
|
|
@@ -131,10 +166,11 @@ export class ChatController {
|
|
|
131
166
|
// A new id so persistence writes a new file instead of overwriting the prior conversation.
|
|
132
167
|
this.session = new Session({ id: newId(), model: this.session.model });
|
|
133
168
|
this.openAssistant = -1;
|
|
169
|
+
this.lastPromptTokens = 0; // the pruning trigger must not carry across sessions
|
|
134
170
|
this.setState({ items: [] });
|
|
135
171
|
}
|
|
136
172
|
showHelp() {
|
|
137
|
-
this.
|
|
173
|
+
this.rich(HELP_LINES);
|
|
138
174
|
}
|
|
139
175
|
/** Track background generation tasks: progress updates the summary bar; completion hits the timeline. */
|
|
140
176
|
handleTaskEvent(e) {
|
|
@@ -160,7 +196,11 @@ export class ChatController {
|
|
|
160
196
|
// so a follow-up question would truthfully get "I can't see it".
|
|
161
197
|
this.session.addSystemNote(`[background task update] Generation task ${e.taskId} completed. ` +
|
|
162
198
|
`Saved locally at ${e.localPath}. Output URL: ${e.outputUrl}.` +
|
|
163
|
-
(isVideo
|
|
199
|
+
(isVideo
|
|
200
|
+
? ''
|
|
201
|
+
: ' The user can attach it to a message with @image so you can view it. To reuse it ' +
|
|
202
|
+
'as a reference in a NEW generation, pass reference_images: ["last"] (image) or ' +
|
|
203
|
+
'start_image: "last" (video) — never paste this URL or path into the prompt text.'));
|
|
164
204
|
}
|
|
165
205
|
else if (e.type === 'task_failed') {
|
|
166
206
|
this.state = { ...this.state, runningTasks };
|
|
@@ -223,7 +263,11 @@ export class ChatController {
|
|
|
223
263
|
async showTasks() {
|
|
224
264
|
const lines = [];
|
|
225
265
|
for (const t of this.state.runningTasks) {
|
|
226
|
-
lines.push(
|
|
266
|
+
lines.push([
|
|
267
|
+
{ text: '⏳ ', style: 'warn' },
|
|
268
|
+
{ text: t.taskId, style: 'accent' },
|
|
269
|
+
{ text: ` · ${t.status} · ${Math.round(t.elapsedMs / 1000)}s`, style: 'dim' },
|
|
270
|
+
]);
|
|
227
271
|
}
|
|
228
272
|
if (this.extras.listRecentTasks) {
|
|
229
273
|
try {
|
|
@@ -231,27 +275,44 @@ export class ChatController {
|
|
|
231
275
|
for (const r of recent.slice(0, 5)) {
|
|
232
276
|
if (this.state.runningTasks.some((t) => t.taskId === r.taskId))
|
|
233
277
|
continue;
|
|
234
|
-
lines.push(
|
|
278
|
+
lines.push([
|
|
279
|
+
{ text: ' ' },
|
|
280
|
+
{ text: r.taskId, style: 'accent' },
|
|
281
|
+
{ text: ` · ${r.model} · ${r.prompt.slice(0, 40)}`, style: 'dim' },
|
|
282
|
+
]);
|
|
235
283
|
}
|
|
236
284
|
}
|
|
237
285
|
catch {
|
|
238
286
|
// Recent history is best-effort.
|
|
239
287
|
}
|
|
240
288
|
}
|
|
241
|
-
|
|
289
|
+
if (lines.length) {
|
|
290
|
+
this.rich([[{ text: 'Tasks', style: 'header' }, { text: ' running and recent', style: 'dim' }], ...lines]);
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
this.note('No tasks yet. Try /image or /video.');
|
|
294
|
+
}
|
|
242
295
|
}
|
|
243
296
|
/** `/status`: session/model basics immediately, account line when it arrives. */
|
|
244
297
|
async showStatus() {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
298
|
+
const lines = [
|
|
299
|
+
ChatController.kvRow('model', this.state.model, 'accent'),
|
|
300
|
+
ChatController.kvRow('conversation', `${this.session.id} (${this.session.messages.length} messages)`),
|
|
301
|
+
ChatController.kvRow('context', `~${this.lastPromptTokens} tokens, ${this.session.prunedToolCallIds.size} old tool results pruned`),
|
|
302
|
+
ChatController.kvRow('running tasks', String(this.state.runningTasks.length)),
|
|
303
|
+
];
|
|
304
|
+
const override = this.extras.baseUrlOverride?.();
|
|
305
|
+
if (override)
|
|
306
|
+
lines.push(ChatController.kvRow('API base URL', `${override} (override — not the default)`, 'warn'));
|
|
307
|
+
const update = this.extras.updateNotice?.();
|
|
308
|
+
if (update)
|
|
309
|
+
lines.push(ChatController.kvRow('update', `${update.latest} available — ${update.command}`, 'accent'));
|
|
310
|
+
this.rich(lines);
|
|
250
311
|
if (this.extras.fetchAccountLine) {
|
|
251
312
|
try {
|
|
252
313
|
const line = await this.extras.fetchAccountLine();
|
|
253
314
|
if (line)
|
|
254
|
-
this.
|
|
315
|
+
this.rich([ChatController.kvInline(line)]);
|
|
255
316
|
}
|
|
256
317
|
catch {
|
|
257
318
|
// Account info is best-effort; the basics above are already shown.
|
|
@@ -260,17 +321,33 @@ export class ChatController {
|
|
|
260
321
|
}
|
|
261
322
|
/** `/models`: list chat + image/video models available (server-driven catalog applied). */
|
|
262
323
|
showModels() {
|
|
263
|
-
const
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
`
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
324
|
+
const lines = [];
|
|
325
|
+
const section = (title, hint) => {
|
|
326
|
+
if (lines.length > 0)
|
|
327
|
+
lines.push([]);
|
|
328
|
+
lines.push([{ text: title, style: 'header' }, { text: ` ${hint}`, style: 'dim' }]);
|
|
329
|
+
};
|
|
330
|
+
const entry = (id, desc, current) => {
|
|
331
|
+
lines.push([
|
|
332
|
+
{ text: current ? ' ▸ ' : ' ', style: 'accent' },
|
|
333
|
+
{ text: id, style: 'accent' },
|
|
334
|
+
...(desc ? [{ text: ` — ${desc}`, style: 'dim' }] : []),
|
|
335
|
+
...(current ? [{ text: ' (current)', style: 'dim' }] : []),
|
|
336
|
+
]);
|
|
337
|
+
};
|
|
338
|
+
const chat = getChatModels();
|
|
339
|
+
section(`Chat models (${chat.length})`, '/model to switch');
|
|
340
|
+
for (const m of chat)
|
|
341
|
+
entry(m.id, modelDescription(m), m.id === this.state.model);
|
|
342
|
+
const image = availableModels().filter((m) => m.type === 'image');
|
|
343
|
+
section(`Image models (${image.length})`, '/image <prompt> -m <id>');
|
|
344
|
+
for (const m of image)
|
|
345
|
+
entry(m.id, m.displayName, false);
|
|
346
|
+
const video = availableModels().filter((m) => m.type === 'video');
|
|
347
|
+
section(`Video models (${video.length})`, '/video <prompt> -m <id>');
|
|
348
|
+
for (const m of video)
|
|
349
|
+
entry(m.id, m.displayName, false);
|
|
350
|
+
this.addItem({ kind: 'rich', lines });
|
|
274
351
|
}
|
|
275
352
|
/** `/account`: full account summary (email · plan · balance). */
|
|
276
353
|
async showAccount() {
|
|
@@ -279,7 +356,13 @@ export class ChatController {
|
|
|
279
356
|
return;
|
|
280
357
|
}
|
|
281
358
|
try {
|
|
282
|
-
|
|
359
|
+
const text = await this.extras.fetchAccount();
|
|
360
|
+
this.rich(text.split('\n').map((line) => {
|
|
361
|
+
const m = /^([^:]+): (.*)$/.exec(line);
|
|
362
|
+
return m
|
|
363
|
+
? [{ text: `${m[1]}: `.padEnd(11), style: 'dim' }, ...ChatController.valueSpans(m[2])]
|
|
364
|
+
: [{ text: line }];
|
|
365
|
+
}));
|
|
283
366
|
}
|
|
284
367
|
catch (err) {
|
|
285
368
|
this.note(err instanceof Error ? err.message : String(err));
|
|
@@ -292,7 +375,19 @@ export class ChatController {
|
|
|
292
375
|
return;
|
|
293
376
|
}
|
|
294
377
|
try {
|
|
295
|
-
|
|
378
|
+
const rows = await this.extras.fetchTransactions();
|
|
379
|
+
if (rows.length === 0) {
|
|
380
|
+
this.note('No transactions yet.');
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
this.rich([
|
|
384
|
+
[{ text: 'Recent transactions', style: 'header' }, { text: ' authoritative billing', style: 'dim' }],
|
|
385
|
+
...rows.map((r) => [
|
|
386
|
+
{ text: ` ${(r.createdAt || '').padEnd(20)}`, style: 'dim' },
|
|
387
|
+
{ text: `${String(r.credits).padStart(6)} cr`, style: 'accent' },
|
|
388
|
+
{ text: ` ${r.model}` },
|
|
389
|
+
]),
|
|
390
|
+
]);
|
|
296
391
|
}
|
|
297
392
|
catch (err) {
|
|
298
393
|
this.note(err instanceof Error ? err.message : String(err));
|
|
@@ -328,6 +423,7 @@ export class ChatController {
|
|
|
328
423
|
replaceSession(session, persistedTimeline) {
|
|
329
424
|
this.session = session;
|
|
330
425
|
this.openAssistant = -1;
|
|
426
|
+
this.lastPromptTokens = 0; // the pruning trigger must not carry across sessions
|
|
331
427
|
this.setState({ model: session.model });
|
|
332
428
|
this.hydrateFromSession(persistedTimeline);
|
|
333
429
|
this.note(`Resumed conversation ${session.id}.`);
|
|
@@ -335,6 +431,39 @@ export class ChatController {
|
|
|
335
431
|
note(text) {
|
|
336
432
|
this.addItem({ kind: 'note', text });
|
|
337
433
|
}
|
|
434
|
+
/** A loud one-line warning (yellow) — for things that must not blend into gray notes. */
|
|
435
|
+
warn(text) {
|
|
436
|
+
this.addItem({ kind: 'rich', lines: [[{ text, style: 'warn' }]] });
|
|
437
|
+
}
|
|
438
|
+
rich(lines) {
|
|
439
|
+
this.addItem({ kind: 'rich', lines });
|
|
440
|
+
}
|
|
441
|
+
/** One `label: value` row — dim label column, optionally styled value. */
|
|
442
|
+
static kvRow(label, value, valueStyle) {
|
|
443
|
+
return [
|
|
444
|
+
{ text: `${label}: `.padEnd(16), style: 'dim' },
|
|
445
|
+
{ text: value, ...(valueStyle ? { style: valueStyle } : {}) },
|
|
446
|
+
];
|
|
447
|
+
}
|
|
448
|
+
/** Style a `label: value · label: value` summary line without changing its text. */
|
|
449
|
+
static kvInline(text) {
|
|
450
|
+
const spans = [];
|
|
451
|
+
text.split(' · ').forEach((part, i) => {
|
|
452
|
+
if (i > 0)
|
|
453
|
+
spans.push({ text: ' · ', style: 'dim' });
|
|
454
|
+
const m = /^([^:]+): (.*)$/.exec(part);
|
|
455
|
+
if (m)
|
|
456
|
+
spans.push({ text: `${m[1]}: `, style: 'dim' }, ...ChatController.valueSpans(m[2]));
|
|
457
|
+
else
|
|
458
|
+
spans.push({ text: part });
|
|
459
|
+
});
|
|
460
|
+
return spans;
|
|
461
|
+
}
|
|
462
|
+
/** A value with a trailing parenthetical (e.g. a balance breakdown) renders the detail dim. */
|
|
463
|
+
static valueSpans(value) {
|
|
464
|
+
const m = /^(.*?)( \(.+\))$/.exec(value);
|
|
465
|
+
return m ? [{ text: m[1] }, { text: m[2], style: 'dim' }] : [{ text: value }];
|
|
466
|
+
}
|
|
338
467
|
/**
|
|
339
468
|
* Submit a user message and run one turn to completion, updating the timeline as events
|
|
340
469
|
* arrive. `displayText` (when given) is what the transcript shows — e.g. "[Image #1]"
|
|
@@ -394,15 +523,48 @@ export class ChatController {
|
|
|
394
523
|
return { content, display, refs: attachments.kind === 'parts' ? attachments.refs : undefined };
|
|
395
524
|
}
|
|
396
525
|
/** Run one turn whose attachments are already resolved. Caller owns the busy flag. */
|
|
526
|
+
/**
|
|
527
|
+
* Trigger-based microcompact (spec 2026-07-19-context-pruning-design.md): runs
|
|
528
|
+
* only at turn boundaries, only when the last model call's prompt size crossed
|
|
529
|
+
* the threshold. Batch-clears in one event so the request prefix stays
|
|
530
|
+
* byte-stable between triggers (upstream prompt-cache friendliness).
|
|
531
|
+
*/
|
|
532
|
+
maybePruneContext() {
|
|
533
|
+
// Cheapest checks first: the in-memory threshold gate skips the sync config
|
|
534
|
+
// read (a disk hit) on every under-threshold turn — the common case.
|
|
535
|
+
if (this.lastPromptTokens <= PRUNE_TRIGGER_TOKENS)
|
|
536
|
+
return;
|
|
537
|
+
if (process.env.GLBGPT_CONTEXT_PRUNING === 'false')
|
|
538
|
+
return;
|
|
539
|
+
if (getConfigValueSync('context-pruning') === 'false')
|
|
540
|
+
return;
|
|
541
|
+
const ids = selectPrunable(this.session.messages, this.session.prunedToolCallIds);
|
|
542
|
+
if (ids.length === 0)
|
|
543
|
+
return;
|
|
544
|
+
const idSet = new Set(ids);
|
|
545
|
+
const clearedBytes = this.session.messages
|
|
546
|
+
.filter((m) => m.role === 'tool' && m.tool_call_id && idSet.has(m.tool_call_id))
|
|
547
|
+
.reduce((sum, m) => sum + (typeof m.content === 'string' ? Buffer.byteLength(m.content, 'utf8') : 0), 0);
|
|
548
|
+
for (const id of ids)
|
|
549
|
+
this.session.prunedToolCallIds.add(id);
|
|
550
|
+
const savedTokens = Math.round(clearedBytes / 4 / 1000);
|
|
551
|
+
this.addItem({
|
|
552
|
+
kind: 'note',
|
|
553
|
+
text: `⚙ Context trimmed: cleared ${ids.length} old tool result${ids.length === 1 ? '' : 's'} (~${savedTokens}k tokens). Re-run tools if that content is needed again.`,
|
|
554
|
+
});
|
|
555
|
+
}
|
|
397
556
|
async runResolvedTurn(msg) {
|
|
398
557
|
// A same-message generate_* call auto-uses these when the model gives no explicit reference.
|
|
399
558
|
this.pendingReferenceImages = msg.refs ?? [];
|
|
559
|
+
this.maybePruneContext();
|
|
400
560
|
try {
|
|
401
561
|
this.addItem({ kind: 'user', text: msg.display });
|
|
402
562
|
this.openAssistant = -1;
|
|
403
563
|
this.turnAbort = new AbortController();
|
|
564
|
+
this.state = { ...this.state, toolCallsUsed: 0 };
|
|
404
565
|
await this.turnLoop.runTurn(this.session, msg.content, (event) => this.onEvent(event), {
|
|
405
566
|
signal: this.turnAbort.signal,
|
|
567
|
+
includeGatedTools: modelSupportsTools(this.session.model),
|
|
406
568
|
});
|
|
407
569
|
}
|
|
408
570
|
catch (err) {
|
|
@@ -445,13 +607,13 @@ export class ChatController {
|
|
|
445
607
|
/** `/cost`: session usage breakdown. Authoritative spend lives in `glbgpt account transactions`. */
|
|
446
608
|
showCost() {
|
|
447
609
|
const u = this.state.sessionUsage;
|
|
448
|
-
this.
|
|
449
|
-
|
|
450
|
-
u.mediaSubmits > 0
|
|
451
|
-
?
|
|
452
|
-
: '
|
|
453
|
-
'authoritative spend
|
|
454
|
-
]
|
|
610
|
+
this.rich([
|
|
611
|
+
ChatController.kvRow('this session', `${u.turns} turn${u.turns === 1 ? '' : 's'} · ${u.promptTokens.toLocaleString()} in / ${u.completionTokens.toLocaleString()} out tokens`),
|
|
612
|
+
ChatController.kvRow('media', u.mediaSubmits > 0
|
|
613
|
+
? `${u.mediaSubmits} task${u.mediaSubmits === 1 ? '' : 's'} submitted · ~${u.mediaCreditsEstimated.toLocaleString()} credits (estimated)`
|
|
614
|
+
: 'none'),
|
|
615
|
+
ChatController.kvRow('authoritative spend', 'glbgpt account transactions', 'accent'),
|
|
616
|
+
]);
|
|
455
617
|
}
|
|
456
618
|
/** Interrupt the in-flight turn (Esc). Partial output is kept; no-op when idle. */
|
|
457
619
|
interrupt() {
|
|
@@ -472,6 +634,13 @@ export class ChatController {
|
|
|
472
634
|
case 'tool_call_begin': {
|
|
473
635
|
// Close any open assistant bubble so post-tool text starts a fresh one.
|
|
474
636
|
this.openAssistant = -1;
|
|
637
|
+
// Past the turn's budget, TurnLoop still emits begin/end for stub calls (it feeds the
|
|
638
|
+
// model a budget-exhausted error instead of dispatching) — clamp so the footer never
|
|
639
|
+
// shows more than the cap it displays (e.g. "18/15").
|
|
640
|
+
this.state = {
|
|
641
|
+
...this.state,
|
|
642
|
+
toolCallsUsed: Math.min(this.state.toolCallsUsed + 1, MAX_TOOL_CALLS_PER_TURN),
|
|
643
|
+
};
|
|
475
644
|
this.addItem({ kind: 'tool', id: event.id, name: event.name, status: 'running', args: event.args });
|
|
476
645
|
break;
|
|
477
646
|
}
|
|
@@ -484,6 +653,7 @@ export class ChatController {
|
|
|
484
653
|
break;
|
|
485
654
|
}
|
|
486
655
|
case 'usage': {
|
|
656
|
+
this.lastPromptTokens = event.promptTokens;
|
|
487
657
|
const u = this.state.sessionUsage;
|
|
488
658
|
this.setState({
|
|
489
659
|
sessionUsage: {
|
|
@@ -546,6 +716,27 @@ function restoreTimelineItem(it) {
|
|
|
546
716
|
case 'note':
|
|
547
717
|
case 'error':
|
|
548
718
|
return typeof o.text === 'string' ? { kind: o.kind, text: o.text } : null;
|
|
719
|
+
case 'rich': {
|
|
720
|
+
if (!Array.isArray(o.lines))
|
|
721
|
+
return null;
|
|
722
|
+
const lines = [];
|
|
723
|
+
for (const line of o.lines) {
|
|
724
|
+
if (!Array.isArray(line))
|
|
725
|
+
return null;
|
|
726
|
+
const spans = [];
|
|
727
|
+
for (const s of line) {
|
|
728
|
+
if (typeof s !== 'object' || s === null || typeof s.text !== 'string')
|
|
729
|
+
return null;
|
|
730
|
+
const style = s.style;
|
|
731
|
+
spans.push({
|
|
732
|
+
text: s.text,
|
|
733
|
+
...(style === 'header' || style === 'accent' || style === 'dim' || style === 'warn' ? { style } : {}),
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
lines.push(spans);
|
|
737
|
+
}
|
|
738
|
+
return { kind: 'rich', lines };
|
|
739
|
+
}
|
|
549
740
|
case 'tool':
|
|
550
741
|
if (typeof o.id !== 'string' || typeof o.name !== 'string')
|
|
551
742
|
return null;
|
|
@@ -573,7 +764,7 @@ function restoreTimelineItem(it) {
|
|
|
573
764
|
}
|
|
574
765
|
}
|
|
575
766
|
/** Validate a persisted transcript; returns null when absent/unusable (→ derive instead). */
|
|
576
|
-
function restoreTimeline(persisted) {
|
|
767
|
+
export function restoreTimeline(persisted) {
|
|
577
768
|
if (!persisted || persisted.length === 0)
|
|
578
769
|
return null;
|
|
579
770
|
const items = persisted.map(restoreTimelineItem).filter((it) => it !== null);
|