@gajae-code/coding-agent 0.10.0 → 0.10.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/CHANGELOG.md +13 -0
- package/dist/types/cli.d.ts +1 -0
- package/dist/types/config/settings.d.ts +2 -0
- package/dist/types/coordinator/contract.d.ts +1 -1
- package/dist/types/coordinator-mcp/policy.d.ts +3 -0
- package/dist/types/coordinator-mcp/server.d.ts +17 -5
- package/dist/types/coordinator-mcp/session-reaper.d.ts +53 -0
- package/dist/types/extensibility/extensions/types.d.ts +3 -1
- package/dist/types/modes/components/status-line/types.d.ts +1 -1
- package/dist/types/modes/components/status-line.d.ts +0 -10
- package/dist/types/modes/print-mode.d.ts +3 -4
- package/dist/types/modes/rpc/rpc-client.d.ts +7 -1
- package/dist/types/modes/rpc/rpc-mode.d.ts +2 -1
- package/dist/types/modes/rpc/rpc-types.d.ts +17 -1
- package/dist/types/modes/utils/context-usage.d.ts +4 -19
- package/dist/types/session/agent-session.d.ts +14 -1
- package/dist/types/session/context-estimation.d.ts +31 -0
- package/package.json +7 -7
- package/src/cli.ts +1 -0
- package/src/config/settings.ts +96 -45
- package/src/coordinator/contract.ts +1 -0
- package/src/coordinator-mcp/policy.ts +30 -0
- package/src/coordinator-mcp/server.ts +294 -66
- package/src/coordinator-mcp/session-reaper.ts +129 -0
- package/src/extensibility/extensions/runner.ts +1 -1
- package/src/extensibility/extensions/types.ts +3 -1
- package/src/internal-urls/docs-index.generated.ts +4 -4
- package/src/main.ts +3 -5
- package/src/modes/acp/acp-agent.ts +2 -2
- package/src/modes/components/footer.ts +3 -3
- package/src/modes/components/status-line/segments.ts +13 -6
- package/src/modes/components/status-line/types.ts +1 -1
- package/src/modes/components/status-line.ts +4 -171
- package/src/modes/controllers/extension-ui-controller.ts +1 -1
- package/src/modes/interactive-mode.ts +16 -0
- package/src/modes/print-mode.ts +228 -92
- package/src/modes/rpc/rpc-client.ts +58 -1
- package/src/modes/rpc/rpc-mode.ts +286 -85
- package/src/modes/rpc/rpc-types.ts +15 -1
- package/src/modes/shared/agent-wire/command-dispatch.ts +33 -1
- package/src/modes/shared/agent-wire/command-validation.ts +10 -0
- package/src/modes/shared/agent-wire/scopes.ts +1 -0
- package/src/modes/utils/context-usage.ts +56 -92
- package/src/session/agent-session.ts +287 -85
- package/src/session/context-estimation.ts +91 -0
- package/src/slash-commands/builtin-registry.ts +12 -2
- package/src/slash-commands/helpers/context-report.ts +26 -4
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
type AgentTool,
|
|
29
29
|
assertImagePlaceholdersHavePayload,
|
|
30
30
|
canContinuePersistedHistory,
|
|
31
|
+
type ResolvedThinkingLevel,
|
|
31
32
|
resolveTelemetry,
|
|
32
33
|
type StablePrefixSnapshot,
|
|
33
34
|
ThinkingLevel,
|
|
@@ -279,6 +280,7 @@ import { buildWorkflowIntentDiff, WORKFLOW_INTENT_DIFF_CUSTOM_TYPE } from "../wo
|
|
|
279
280
|
import { buildWorkspaceTree, type WorkspaceTree } from "../workspace-tree";
|
|
280
281
|
import type { AuthStorage } from "./auth-storage";
|
|
281
282
|
import type { ClientBridge, ClientBridgePermissionOption, ClientBridgePermissionOutcome } from "./client-bridge";
|
|
283
|
+
import { computeNonMessageTokens } from "./context-estimation";
|
|
282
284
|
import {
|
|
283
285
|
type ContributionPrepOptions,
|
|
284
286
|
type ContributionPrepResult,
|
|
@@ -531,6 +533,12 @@ export interface RoleModelCycleResult {
|
|
|
531
533
|
role: string;
|
|
532
534
|
}
|
|
533
535
|
|
|
536
|
+
export interface DefaultModelSelectionResult {
|
|
537
|
+
readonly provider: string;
|
|
538
|
+
readonly modelId: string;
|
|
539
|
+
readonly thinkingLevel: ResolvedThinkingLevel;
|
|
540
|
+
}
|
|
541
|
+
|
|
534
542
|
/** Session statistics for /session command */
|
|
535
543
|
export interface SessionStats {
|
|
536
544
|
sessionFile: string | undefined;
|
|
@@ -1181,6 +1189,7 @@ export class AgentSession {
|
|
|
1181
1189
|
#scopedModels: ScopedModelSelection[];
|
|
1182
1190
|
#thinkingLevel: ThinkingLevel | undefined;
|
|
1183
1191
|
#activeModelProfile: string | undefined;
|
|
1192
|
+
#defaultModelSelectionTail: Promise<void> = Promise.resolve();
|
|
1184
1193
|
#promptTemplates: PromptTemplate[];
|
|
1185
1194
|
#slashCommands: FileSlashCommand[];
|
|
1186
1195
|
|
|
@@ -1233,6 +1242,11 @@ export class AgentSession {
|
|
|
1233
1242
|
#resourceSampler: () => EmergencyCompactionSample = () => this.#defaultResourceSample();
|
|
1234
1243
|
#retainedMemorySampler: (() => RetainedMemorySample) | undefined;
|
|
1235
1244
|
#prePromptContextCheckPromise: Promise<void> | undefined = undefined;
|
|
1245
|
+
/** Display-only context snapshot; pre-prompt compaction estimates deliberately remain uncached. */
|
|
1246
|
+
#contextUsageCache: { key: string; value: ContextUsage } | undefined;
|
|
1247
|
+
#contextUsageMessageIds = new WeakMap<AgentMessage, number>();
|
|
1248
|
+
#nextContextUsageMessageId = 0;
|
|
1249
|
+
#contextUsageEstimateCount = 0;
|
|
1236
1250
|
|
|
1237
1251
|
// Branch summarization state
|
|
1238
1252
|
#branchSummaryAbortController: AbortController | undefined = undefined;
|
|
@@ -2210,6 +2224,7 @@ export class AgentSession {
|
|
|
2210
2224
|
(this.#planCompactAbortPending || this.#silentAbortPending)
|
|
2211
2225
|
) {
|
|
2212
2226
|
(event.message as AssistantMessage).errorMessage = SILENT_ABORT_MARKER;
|
|
2227
|
+
this.agent.touchContext();
|
|
2213
2228
|
this.#planCompactAbortPending = false;
|
|
2214
2229
|
this.#silentAbortPending = false;
|
|
2215
2230
|
}
|
|
@@ -5921,7 +5936,7 @@ export class AgentSession {
|
|
|
5921
5936
|
reload: async () => {
|
|
5922
5937
|
await this.reload();
|
|
5923
5938
|
},
|
|
5924
|
-
getSystemPrompt: () => this.systemPrompt,
|
|
5939
|
+
getSystemPrompt: () => [...this.systemPrompt],
|
|
5925
5940
|
};
|
|
5926
5941
|
}
|
|
5927
5942
|
|
|
@@ -6986,6 +7001,54 @@ export class AgentSession {
|
|
|
6986
7001
|
await this.#syncEditToolModeAfterModelChange(previousEditMode);
|
|
6987
7002
|
}
|
|
6988
7003
|
|
|
7004
|
+
async setDefaultModelSelection(
|
|
7005
|
+
model: Model,
|
|
7006
|
+
thinkingLevel: ThinkingLevel | undefined,
|
|
7007
|
+
): Promise<DefaultModelSelectionResult> {
|
|
7008
|
+
const predecessor = this.#defaultModelSelectionTail;
|
|
7009
|
+
const transaction = Promise.withResolvers<void>();
|
|
7010
|
+
this.#defaultModelSelectionTail = transaction.promise;
|
|
7011
|
+
try {
|
|
7012
|
+
await predecessor;
|
|
7013
|
+
if (thinkingLevel === ThinkingLevel.Inherit) {
|
|
7014
|
+
throw new Error("Default model selection cannot inherit a thinking level");
|
|
7015
|
+
}
|
|
7016
|
+
const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
|
|
7017
|
+
if (!apiKey) {
|
|
7018
|
+
throw new Error(`No API key for ${model.provider}/${model.id}`);
|
|
7019
|
+
}
|
|
7020
|
+
const resolvedLevel = resolveThinkingLevelForModel(model, thinkingLevel);
|
|
7021
|
+
const effectiveLevel =
|
|
7022
|
+
resolvedLevel ??
|
|
7023
|
+
resolveThinkingLevelForModel(model, model.thinking?.defaultLevel ?? this.thinkingLevel) ??
|
|
7024
|
+
ThinkingLevel.Off;
|
|
7025
|
+
await this.waitForIdle();
|
|
7026
|
+
const previousDefaultModelRole = this.settings.getGlobal("modelRoles")?.default;
|
|
7027
|
+
await this.settings.setGlobalModelRoleAndFlush(
|
|
7028
|
+
"default",
|
|
7029
|
+
formatModelSelectorValue(`${model.provider}/${model.id}`, effectiveLevel),
|
|
7030
|
+
);
|
|
7031
|
+
try {
|
|
7032
|
+
const previousThinkingLevel = this.thinkingLevel;
|
|
7033
|
+
await this.setModelTemporary(model, effectiveLevel);
|
|
7034
|
+
if (this.thinkingLevel === previousThinkingLevel) {
|
|
7035
|
+
this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
|
|
7036
|
+
}
|
|
7037
|
+
this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, "default");
|
|
7038
|
+
} catch (error) {
|
|
7039
|
+
await this.settings.setGlobalModelRoleAndFlush("default", previousDefaultModelRole).catch(rollbackError => {
|
|
7040
|
+
logger.warn("Failed to restore durable default model selection after live apply failure", {
|
|
7041
|
+
error: String(rollbackError),
|
|
7042
|
+
});
|
|
7043
|
+
});
|
|
7044
|
+
throw error;
|
|
7045
|
+
}
|
|
7046
|
+
return { provider: model.provider, modelId: model.id, thinkingLevel: effectiveLevel };
|
|
7047
|
+
} finally {
|
|
7048
|
+
transaction.resolve();
|
|
7049
|
+
}
|
|
7050
|
+
}
|
|
7051
|
+
|
|
6989
7052
|
/**
|
|
6990
7053
|
* Cycle to next/previous model.
|
|
6991
7054
|
* Uses scoped models (from --models flag) if available, otherwise all available models.
|
|
@@ -10369,18 +10432,24 @@ export class AgentSession {
|
|
|
10369
10432
|
attribution: "agent",
|
|
10370
10433
|
timestamp: incomingTimestamp,
|
|
10371
10434
|
};
|
|
10372
|
-
|
|
10373
|
-
|
|
10374
|
-
|
|
10375
|
-
|
|
10376
|
-
|
|
10377
|
-
|
|
10378
|
-
|
|
10379
|
-
|
|
10380
|
-
|
|
10435
|
+
const announceIncoming = () => {
|
|
10436
|
+
this.#emitIrcObservation(incomingRecord);
|
|
10437
|
+
this.#forwardIrcRelayToMain({
|
|
10438
|
+
observationId: incomingObservationId,
|
|
10439
|
+
from: args.from,
|
|
10440
|
+
to: this.#agentId ?? "?",
|
|
10441
|
+
body: args.message,
|
|
10442
|
+
kind: "message",
|
|
10443
|
+
timestamp: incomingTimestamp,
|
|
10444
|
+
});
|
|
10445
|
+
};
|
|
10381
10446
|
|
|
10382
10447
|
if (!awaitReply) {
|
|
10448
|
+
args.signal?.throwIfAborted();
|
|
10449
|
+
// Volatile session acceptance happens before any recipient or main-UI
|
|
10450
|
+
// observation, and before this delivery reports success to its sender.
|
|
10383
10451
|
this.#queueBackgroundExchangeInjection([incomingRecord]);
|
|
10452
|
+
announceIncoming();
|
|
10384
10453
|
return { replyText: null };
|
|
10385
10454
|
}
|
|
10386
10455
|
|
|
@@ -10388,33 +10457,46 @@ export class AgentSession {
|
|
|
10388
10457
|
from: args.from,
|
|
10389
10458
|
message: args.message,
|
|
10390
10459
|
});
|
|
10391
|
-
|
|
10460
|
+
// Generate the reply before accepting or surfacing the exchange. Provider
|
|
10461
|
+
// failures and sender aborts therefore leave no accepted IRC batch or UI
|
|
10462
|
+
// observation. The deferred roster claim is committed only after the pair
|
|
10463
|
+
// below is accepted.
|
|
10464
|
+
const { replyText, commitRosterClaim, releaseRosterClaim } = await this.runEphemeralTurn({
|
|
10392
10465
|
promptText: incomingPrompt,
|
|
10393
10466
|
signal: args.signal,
|
|
10467
|
+
deferRosterCommit: true,
|
|
10394
10468
|
});
|
|
10469
|
+
try {
|
|
10470
|
+
const replyObservationId = crypto.randomUUID();
|
|
10471
|
+
const replyRecord: CustomMessage = {
|
|
10472
|
+
role: "custom",
|
|
10473
|
+
customType: "irc:autoreply",
|
|
10474
|
+
content: `[IRC you → \`${args.from}\` (auto)]\n\n${replyText}`,
|
|
10475
|
+
display: true,
|
|
10476
|
+
details: { observationId: replyObservationId, to: args.from, reply: replyText },
|
|
10477
|
+
attribution: "agent",
|
|
10478
|
+
timestamp: Date.now(),
|
|
10479
|
+
};
|
|
10480
|
+
// Accept the ordered pair as one volatile batch before committing its
|
|
10481
|
+
// roster claim, notifying either UI, or resolving the sender delivery.
|
|
10482
|
+
args.signal?.throwIfAborted();
|
|
10483
|
+
this.#queueBackgroundExchangeInjection([incomingRecord, replyRecord]);
|
|
10484
|
+
commitRosterClaim?.();
|
|
10485
|
+
announceIncoming();
|
|
10486
|
+
this.#emitIrcObservation(replyRecord);
|
|
10487
|
+
this.#forwardIrcRelayToMain({
|
|
10488
|
+
observationId: replyObservationId,
|
|
10489
|
+
from: this.#agentId ?? "?",
|
|
10490
|
+
to: args.from,
|
|
10491
|
+
body: replyText,
|
|
10492
|
+
kind: "reply",
|
|
10493
|
+
timestamp: replyRecord.timestamp,
|
|
10494
|
+
});
|
|
10395
10495
|
|
|
10396
|
-
|
|
10397
|
-
|
|
10398
|
-
|
|
10399
|
-
|
|
10400
|
-
content: `[IRC you → \`${args.from}\` (auto)]\n\n${replyText}`,
|
|
10401
|
-
display: true,
|
|
10402
|
-
details: { observationId: replyObservationId, to: args.from, reply: replyText },
|
|
10403
|
-
attribution: "agent",
|
|
10404
|
-
timestamp: Date.now(),
|
|
10405
|
-
};
|
|
10406
|
-
void this.#emitSessionEvent({ type: "irc_message", message: replyRecord });
|
|
10407
|
-
this.#forwardIrcRelayToMain({
|
|
10408
|
-
observationId: replyObservationId,
|
|
10409
|
-
from: this.#agentId ?? "?",
|
|
10410
|
-
to: args.from,
|
|
10411
|
-
body: replyText,
|
|
10412
|
-
kind: "reply",
|
|
10413
|
-
timestamp: replyRecord.timestamp,
|
|
10414
|
-
});
|
|
10415
|
-
this.#queueBackgroundExchangeInjection([incomingRecord, replyRecord]);
|
|
10416
|
-
|
|
10417
|
-
return { replyText };
|
|
10496
|
+
return { replyText };
|
|
10497
|
+
} finally {
|
|
10498
|
+
releaseRosterClaim?.();
|
|
10499
|
+
}
|
|
10418
10500
|
}
|
|
10419
10501
|
|
|
10420
10502
|
/**
|
|
@@ -10454,7 +10536,17 @@ export class AgentSession {
|
|
|
10454
10536
|
attribution: "agent",
|
|
10455
10537
|
timestamp: args.timestamp,
|
|
10456
10538
|
};
|
|
10457
|
-
|
|
10539
|
+
try {
|
|
10540
|
+
mainSession.emitIrcRelayObservation(relayRecord);
|
|
10541
|
+
} catch (error) {
|
|
10542
|
+
logger.warn("Failed to forward IRC relay observation", { error: String(error) });
|
|
10543
|
+
}
|
|
10544
|
+
}
|
|
10545
|
+
|
|
10546
|
+
#emitIrcObservation(message: CustomMessage): void {
|
|
10547
|
+
void this.#emitSessionEvent({ type: "irc_message", message }).catch(error => {
|
|
10548
|
+
logger.warn("Failed to emit IRC observation", { error: String(error) });
|
|
10549
|
+
});
|
|
10458
10550
|
}
|
|
10459
10551
|
|
|
10460
10552
|
/**
|
|
@@ -10462,7 +10554,7 @@ export class AgentSession {
|
|
|
10462
10554
|
* Does not persist the record to history. Public so other sessions can forward.
|
|
10463
10555
|
*/
|
|
10464
10556
|
emitIrcRelayObservation(record: CustomMessage): void {
|
|
10465
|
-
|
|
10557
|
+
this.#emitIrcObservation(record);
|
|
10466
10558
|
}
|
|
10467
10559
|
|
|
10468
10560
|
emitSubagentSteerObservation(args: { from: string; to: string; body: string; timestamp?: number }): void {
|
|
@@ -10592,8 +10684,16 @@ export class AgentSession {
|
|
|
10592
10684
|
promptText: string;
|
|
10593
10685
|
onTextDelta?: (delta: string) => void;
|
|
10594
10686
|
signal?: AbortSignal;
|
|
10595
|
-
|
|
10687
|
+
/** Defer the successful roster-claim commit until the caller accepts its exchange. */
|
|
10688
|
+
deferRosterCommit?: boolean;
|
|
10689
|
+
}): Promise<{
|
|
10690
|
+
replyText: string;
|
|
10691
|
+
assistantMessage: AssistantMessage;
|
|
10692
|
+
commitRosterClaim?: () => void;
|
|
10693
|
+
releaseRosterClaim?: () => void;
|
|
10694
|
+
}> {
|
|
10596
10695
|
const rosterClaim = this.#claimIrcRosterCandidate();
|
|
10696
|
+
let rosterClaimDeferred = false;
|
|
10597
10697
|
try {
|
|
10598
10698
|
const model = this.model;
|
|
10599
10699
|
if (!model) {
|
|
@@ -10662,12 +10762,31 @@ export class AgentSession {
|
|
|
10662
10762
|
if (!assistantMessage) {
|
|
10663
10763
|
throw new Error("Ephemeral turn ended without a final message");
|
|
10664
10764
|
}
|
|
10765
|
+
if (rosterClaim && args.deferRosterCommit) {
|
|
10766
|
+
rosterClaimDeferred = true;
|
|
10767
|
+
let rosterClaimSettled = false;
|
|
10768
|
+
const settleRosterClaim = (commit: boolean) => {
|
|
10769
|
+
if (rosterClaimSettled) return;
|
|
10770
|
+
rosterClaimSettled = true;
|
|
10771
|
+
if (commit) {
|
|
10772
|
+
this.#commitIrcRosterClaim(rosterClaim.token, rosterClaim.epoch);
|
|
10773
|
+
} else {
|
|
10774
|
+
this.#releaseIrcRosterClaim(rosterClaim.token, rosterClaim.epoch);
|
|
10775
|
+
}
|
|
10776
|
+
};
|
|
10777
|
+
return {
|
|
10778
|
+
replyText: dedupeIrcReply(replyText.trim()),
|
|
10779
|
+
assistantMessage,
|
|
10780
|
+
commitRosterClaim: () => settleRosterClaim(true),
|
|
10781
|
+
releaseRosterClaim: () => settleRosterClaim(false),
|
|
10782
|
+
};
|
|
10783
|
+
}
|
|
10665
10784
|
if (rosterClaim) {
|
|
10666
10785
|
this.#commitIrcRosterClaim(rosterClaim.token, rosterClaim.epoch);
|
|
10667
10786
|
}
|
|
10668
10787
|
return { replyText: dedupeIrcReply(replyText.trim()), assistantMessage };
|
|
10669
10788
|
} finally {
|
|
10670
|
-
if (rosterClaim) {
|
|
10789
|
+
if (rosterClaim && !rosterClaimDeferred) {
|
|
10671
10790
|
this.#releaseIrcRosterClaim(rosterClaim.token, rosterClaim.epoch);
|
|
10672
10791
|
}
|
|
10673
10792
|
}
|
|
@@ -11408,43 +11527,92 @@ export class AgentSession {
|
|
|
11408
11527
|
const contextWindow = model.contextWindow ?? 0;
|
|
11409
11528
|
if (contextWindow <= 0) return undefined;
|
|
11410
11529
|
|
|
11530
|
+
const cacheKey = this.#contextUsageCacheKey(model, contextWindow);
|
|
11531
|
+
if (this.#contextUsageCache?.key === cacheKey) return { ...this.#contextUsageCache.value };
|
|
11532
|
+
this.#contextUsageEstimateCount++;
|
|
11533
|
+
|
|
11411
11534
|
// After compaction, the last assistant usage reflects pre-compaction context size.
|
|
11412
11535
|
// We can only trust usage from an assistant that responded after the latest compaction.
|
|
11413
11536
|
// If no such assistant exists, context token count is unknown until the next LLM response.
|
|
11414
11537
|
const branchEntries = this.sessionManager.getBranch();
|
|
11415
11538
|
const latestCompaction = getLatestCompactionEntry(branchEntries);
|
|
11539
|
+
const boundaryTs = latestCompaction ? new Date(latestCompaction.timestamp).getTime() : 0;
|
|
11540
|
+
const anchor = this.#findAnchorableUsageIndex(this.messages, boundaryTs);
|
|
11541
|
+
let value: ContextUsage;
|
|
11542
|
+
if (latestCompaction && !anchor) {
|
|
11543
|
+
value = { tokens: null, contextWindow, percent: null, source: "unknown" };
|
|
11544
|
+
} else {
|
|
11545
|
+
const estimate = this.#estimateContextTokens(boundaryTs, anchor);
|
|
11546
|
+
value = {
|
|
11547
|
+
tokens: estimate.tokens,
|
|
11548
|
+
contextWindow,
|
|
11549
|
+
percent: (estimate.tokens / contextWindow) * 100,
|
|
11550
|
+
source: estimate.anchored ? "provider_anchor" : "heuristic",
|
|
11551
|
+
};
|
|
11552
|
+
}
|
|
11416
11553
|
|
|
11417
|
-
|
|
11418
|
-
|
|
11419
|
-
|
|
11420
|
-
|
|
11421
|
-
|
|
11422
|
-
|
|
11423
|
-
|
|
11424
|
-
|
|
11425
|
-
|
|
11426
|
-
|
|
11427
|
-
|
|
11428
|
-
|
|
11429
|
-
|
|
11430
|
-
|
|
11431
|
-
|
|
11432
|
-
|
|
11433
|
-
|
|
11554
|
+
this.#contextUsageCache = { key: cacheKey, value };
|
|
11555
|
+
return { ...value };
|
|
11556
|
+
}
|
|
11557
|
+
|
|
11558
|
+
getContextUsageObservabilityForTests(): { estimateCount: number } {
|
|
11559
|
+
return { estimateCount: this.#contextUsageEstimateCount };
|
|
11560
|
+
}
|
|
11561
|
+
|
|
11562
|
+
#contextUsageCacheKey(model: Model, contextWindow: number): string {
|
|
11563
|
+
const messages = this.messages;
|
|
11564
|
+
const lastMessage = messages[messages.length - 1];
|
|
11565
|
+
// Entry and leaf revisions change whenever the active branch changes, avoiding getBranch() on warm reads.
|
|
11566
|
+
const revision = this.sessionManager.revisionSnapshot();
|
|
11567
|
+
return `${this.agent.contextRevision}|${model.id}|${contextWindow}|${messages.length}|${this.#contextUsageMessageFingerprint(lastMessage)}|${revision.entry}:${revision.leaf}|${this.#computeContextUsageNonMessageInputsKey()}`;
|
|
11568
|
+
}
|
|
11569
|
+
|
|
11570
|
+
#contextUsageMessageFingerprint(message: AgentMessage | undefined): string {
|
|
11571
|
+
if (!message) return "";
|
|
11572
|
+
let messageId = this.#contextUsageMessageIds.get(message);
|
|
11573
|
+
if (messageId === undefined) {
|
|
11574
|
+
messageId = ++this.#nextContextUsageMessageId;
|
|
11575
|
+
this.#contextUsageMessageIds.set(message, messageId);
|
|
11576
|
+
}
|
|
11577
|
+
|
|
11578
|
+
const role = message.role;
|
|
11579
|
+
const timestamp = typeof message.timestamp === "number" ? message.timestamp : "";
|
|
11580
|
+
let contentLength = 0;
|
|
11581
|
+
let blockCount = 0;
|
|
11582
|
+
const record = message as {
|
|
11583
|
+
content?: unknown;
|
|
11584
|
+
command?: unknown;
|
|
11585
|
+
output?: unknown;
|
|
11586
|
+
summary?: unknown;
|
|
11587
|
+
stopReason?: unknown;
|
|
11588
|
+
usage?: Usage;
|
|
11589
|
+
};
|
|
11434
11590
|
|
|
11435
|
-
|
|
11436
|
-
|
|
11591
|
+
if (typeof record.command === "string") contentLength += record.command.length;
|
|
11592
|
+
if (typeof record.output === "string") contentLength += record.output.length;
|
|
11593
|
+
if (typeof record.summary === "string") contentLength += record.summary.length;
|
|
11594
|
+
if (typeof record.content === "string") {
|
|
11595
|
+
contentLength += record.content.length;
|
|
11596
|
+
} else if (Array.isArray(record.content)) {
|
|
11597
|
+
blockCount = record.content.length;
|
|
11598
|
+
for (const block of record.content) {
|
|
11599
|
+
if (!block || typeof block !== "object") continue;
|
|
11600
|
+
const content = block as { text?: unknown; thinking?: unknown; name?: unknown };
|
|
11601
|
+
if (typeof content.text === "string") contentLength += content.text.length;
|
|
11602
|
+
if (typeof content.thinking === "string") contentLength += content.thinking.length;
|
|
11603
|
+
if (typeof content.name === "string") contentLength += content.name.length;
|
|
11437
11604
|
}
|
|
11438
11605
|
}
|
|
11606
|
+
const stopReason = typeof record.stopReason === "string" ? record.stopReason : "";
|
|
11607
|
+
const usageTokens = record.usage ? calculateContextTokens(record.usage) : 0;
|
|
11608
|
+
return `${messageId}:${role}:${timestamp}:${contentLength}:${blockCount}:${stopReason}:${usageTokens}`;
|
|
11609
|
+
}
|
|
11439
11610
|
|
|
11440
|
-
|
|
11441
|
-
const
|
|
11442
|
-
|
|
11443
|
-
|
|
11444
|
-
|
|
11445
|
-
contextWindow,
|
|
11446
|
-
percent,
|
|
11447
|
-
};
|
|
11611
|
+
#computeContextUsageNonMessageInputsKey(): string {
|
|
11612
|
+
const systemPrompt = this.systemPrompt;
|
|
11613
|
+
let systemPromptLengths = "";
|
|
11614
|
+
for (const part of systemPrompt) systemPromptLengths += `${part.length},`;
|
|
11615
|
+
return `${systemPrompt.length}:${systemPromptLengths}|${this.agent.state.tools.length}|${this.skills.length}`;
|
|
11448
11616
|
}
|
|
11449
11617
|
|
|
11450
11618
|
async fetchUsageReports(signal?: AbortSignal): Promise<UsageReport[] | null> {
|
|
@@ -11459,10 +11627,19 @@ export class AgentSession {
|
|
|
11459
11627
|
/**
|
|
11460
11628
|
* Estimate context tokens from messages, using the last assistant usage when available.
|
|
11461
11629
|
*/
|
|
11462
|
-
#estimateContextTokens(
|
|
11630
|
+
#estimateContextTokens(
|
|
11631
|
+
boundaryTs: number,
|
|
11632
|
+
anchor: { index: number; usage: Usage } | undefined,
|
|
11633
|
+
): {
|
|
11463
11634
|
tokens: number;
|
|
11635
|
+
anchored: boolean;
|
|
11464
11636
|
} {
|
|
11465
|
-
return this.#estimateContextTokensWith(
|
|
11637
|
+
return this.#estimateContextTokensWith(
|
|
11638
|
+
message => this.#estimateMessageDisplayTokens(message),
|
|
11639
|
+
false,
|
|
11640
|
+
boundaryTs,
|
|
11641
|
+
anchor,
|
|
11642
|
+
);
|
|
11466
11643
|
}
|
|
11467
11644
|
|
|
11468
11645
|
/** Count inline image blocks in a message (for bucketing the fixed image token estimate). */
|
|
@@ -11493,6 +11670,24 @@ export class AgentSession {
|
|
|
11493
11670
|
return usage;
|
|
11494
11671
|
}
|
|
11495
11672
|
|
|
11673
|
+
/** Find the newest positive successful usage anchor after a compaction boundary. */
|
|
11674
|
+
#findAnchorableUsageIndex(
|
|
11675
|
+
messages: readonly AgentMessage[],
|
|
11676
|
+
boundaryTs: number,
|
|
11677
|
+
): { index: number; usage: Usage } | undefined {
|
|
11678
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
11679
|
+
const message = messages[i];
|
|
11680
|
+
const usage = this.#anchorableAssistantUsage(message);
|
|
11681
|
+
if (!usage) continue;
|
|
11682
|
+
if (boundaryTs > 0) {
|
|
11683
|
+
const timestamp = message.timestamp;
|
|
11684
|
+
if (typeof timestamp !== "number" || !Number.isFinite(timestamp) || timestamp <= boundaryTs) continue;
|
|
11685
|
+
}
|
|
11686
|
+
return { index: i, usage };
|
|
11687
|
+
}
|
|
11688
|
+
return undefined;
|
|
11689
|
+
}
|
|
11690
|
+
|
|
11496
11691
|
/**
|
|
11497
11692
|
* Observed heuristic→actual token correction for the compaction keep window
|
|
11498
11693
|
* (Finding 7). Compares the provider's real prompt tokens against the chars/4
|
|
@@ -11537,39 +11732,45 @@ export class AgentSession {
|
|
|
11537
11732
|
|
|
11538
11733
|
#estimateContextTokensForCompaction(pendingMessages: readonly AgentMessage[]): {
|
|
11539
11734
|
tokens: number;
|
|
11735
|
+
anchored: boolean;
|
|
11540
11736
|
} {
|
|
11541
|
-
const estimate = this.#estimateContextTokensWith(
|
|
11737
|
+
const estimate = this.#estimateContextTokensWith(
|
|
11738
|
+
message => this.#estimateMessageCompactionDeltaTokens(message),
|
|
11739
|
+
true,
|
|
11740
|
+
);
|
|
11542
11741
|
return {
|
|
11543
11742
|
tokens: estimate.tokens + this.#estimateMessagesCompactionDeltaTokens(pendingMessages),
|
|
11743
|
+
anchored: estimate.anchored,
|
|
11544
11744
|
};
|
|
11545
11745
|
}
|
|
11546
11746
|
|
|
11547
|
-
#estimateContextTokensWith(
|
|
11747
|
+
#estimateContextTokensWith(
|
|
11748
|
+
estimateMessage: (message: AgentMessage) => number,
|
|
11749
|
+
inflateFixed = false,
|
|
11750
|
+
boundaryTs?: number,
|
|
11751
|
+
knownAnchor?: { index: number; usage: Usage } | undefined,
|
|
11752
|
+
): {
|
|
11548
11753
|
tokens: number;
|
|
11754
|
+
anchored: boolean;
|
|
11549
11755
|
} {
|
|
11550
11756
|
const messages = this.messages;
|
|
11551
|
-
|
|
11552
|
-
|
|
11553
|
-
|
|
11554
|
-
|
|
11555
|
-
|
|
11556
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
11557
|
-
const usage = this.#anchorableAssistantUsage(messages[i]);
|
|
11558
|
-
if (usage) {
|
|
11559
|
-
lastUsage = usage;
|
|
11560
|
-
lastUsageIndex = i;
|
|
11561
|
-
break;
|
|
11562
|
-
}
|
|
11757
|
+
let anchor = knownAnchor;
|
|
11758
|
+
if (boundaryTs === undefined) {
|
|
11759
|
+
const latestCompaction = getLatestCompactionEntry(this.sessionManager.getBranch());
|
|
11760
|
+
boundaryTs = latestCompaction ? new Date(latestCompaction.timestamp).getTime() : 0;
|
|
11761
|
+
anchor = this.#findAnchorableUsageIndex(messages, boundaryTs);
|
|
11563
11762
|
}
|
|
11564
11763
|
|
|
11565
|
-
if (!
|
|
11566
|
-
// No usage data - estimate
|
|
11567
|
-
|
|
11764
|
+
if (!anchor) {
|
|
11765
|
+
// No usage data - estimate the full provider request.
|
|
11766
|
+
const fixedTokens = computeNonMessageTokens(this);
|
|
11767
|
+
let estimated = inflateFixed ? Math.ceil(fixedTokens * this.#compactionDeltaInflation) : fixedTokens;
|
|
11568
11768
|
for (const message of messages) {
|
|
11569
11769
|
estimated += estimateMessage(message);
|
|
11570
11770
|
}
|
|
11571
11771
|
return {
|
|
11572
11772
|
tokens: estimated,
|
|
11773
|
+
anchored: false,
|
|
11573
11774
|
};
|
|
11574
11775
|
}
|
|
11575
11776
|
|
|
@@ -11577,14 +11778,15 @@ export class AgentSession {
|
|
|
11577
11778
|
// tokens: the next request replays the anchor assistant's own output
|
|
11578
11779
|
// (text/reasoning/tool calls), so dropping it undercounts the very tokens
|
|
11579
11780
|
// a large-reasoning turn just added (Sol xhigh emits tens of thousands).
|
|
11580
|
-
const usageTokens = calculateContextTokens(
|
|
11781
|
+
const usageTokens = calculateContextTokens(anchor.usage);
|
|
11581
11782
|
let trailingTokens = 0;
|
|
11582
|
-
for (let i =
|
|
11783
|
+
for (let i = anchor.index + 1; i < messages.length; i++) {
|
|
11583
11784
|
trailingTokens += estimateMessage(messages[i]);
|
|
11584
11785
|
}
|
|
11585
11786
|
|
|
11586
11787
|
return {
|
|
11587
11788
|
tokens: usageTokens + trailingTokens,
|
|
11789
|
+
anchored: true,
|
|
11588
11790
|
};
|
|
11589
11791
|
}
|
|
11590
11792
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-neutral fixed-context (non-message) token estimation.
|
|
3
|
+
*
|
|
4
|
+
* Lives in the session layer so load-bearing session/compaction behavior
|
|
5
|
+
* (`AgentSession` context estimates) never depends on a presentation module.
|
|
6
|
+
* UI surfaces (`modes/utils/context-usage.ts`, status line) import from here
|
|
7
|
+
* as well, so every surface reports the same numbers.
|
|
8
|
+
*/
|
|
9
|
+
import { estimateTextTokensHeuristic } from "@gajae-code/agent-core/compaction";
|
|
10
|
+
import type { Skill } from "../extensibility/skills";
|
|
11
|
+
import type { Tool } from "../tools";
|
|
12
|
+
import type { AgentSession } from "./agent-session";
|
|
13
|
+
|
|
14
|
+
export function estimateSkillsTokens(skills: readonly Skill[]): number {
|
|
15
|
+
const fragments: string[] = [];
|
|
16
|
+
for (const skill of skills) {
|
|
17
|
+
// "- name: description\n" wire framing tokenizes ~identically to the
|
|
18
|
+
// concatenated form, so encode each piece separately and sum.
|
|
19
|
+
fragments.push(skill.name, skill.description);
|
|
20
|
+
}
|
|
21
|
+
return estimateTextTokensHeuristic(fragments);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function estimateToolSchemaTokens(
|
|
25
|
+
tools: ReadonlyArray<Pick<Tool, "name" | "description" | "parameters">>,
|
|
26
|
+
): number {
|
|
27
|
+
const fragments: string[] = [];
|
|
28
|
+
for (const tool of tools) {
|
|
29
|
+
fragments.push(tool.name, tool.description);
|
|
30
|
+
try {
|
|
31
|
+
fragments.push(JSON.stringify(tool.parameters ?? {}));
|
|
32
|
+
} catch {
|
|
33
|
+
// Schema may contain functions or cycles; ignore.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return estimateTextTokensHeuristic(fragments);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Compute just the NON-MESSAGE token total: system prompt (with its skills
|
|
41
|
+
* section subtracted, since skills are tokenized separately) + system context
|
|
42
|
+
* (the rest of the system-prompt array) + tools + skills.
|
|
43
|
+
*
|
|
44
|
+
* Exposed so callers like `StatusLineComponent` can cache the non-message
|
|
45
|
+
* total separately from the message total. Non-message inputs (skills,
|
|
46
|
+
* tools, system prompt) change rarely; the message list grows on every
|
|
47
|
+
* streaming turn. Splitting the two lets the caller refresh each on its own
|
|
48
|
+
* cadence — non-message recomputed only when the inputs identity changes,
|
|
49
|
+
* messages walked incrementally as new entries append.
|
|
50
|
+
*/
|
|
51
|
+
export function computeNonMessageTokens(session: AgentSession): number {
|
|
52
|
+
const parts = computeNonMessageBreakdown(session);
|
|
53
|
+
return (
|
|
54
|
+
parts.systemPromptTokens + parts.systemContextTokens + parts.rulesTokens + parts.toolsTokens + parts.skillsTokens
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Shared helper for the four non-message token totals. Single source of truth
|
|
60
|
+
* for `computeNonMessageTokens` (session estimates + status-line incremental
|
|
61
|
+
* cache) and `computeContextBreakdown` (/context panel). The split avoids
|
|
62
|
+
* drift between the surfaces — they MUST report the same numbers.
|
|
63
|
+
*/
|
|
64
|
+
export function computeNonMessageBreakdown(session: AgentSession): {
|
|
65
|
+
rulesTokens: number;
|
|
66
|
+
skillsTokens: number;
|
|
67
|
+
toolsTokens: number;
|
|
68
|
+
systemContextTokens: number;
|
|
69
|
+
systemPromptTokens: number;
|
|
70
|
+
} {
|
|
71
|
+
const skillsTokens = estimateSkillsTokens(session.skills ?? []);
|
|
72
|
+
const toolsTokens = estimateToolSchemaTokens(session.agent?.state?.tools ?? []);
|
|
73
|
+
const systemPromptParts = session.systemPrompt ?? [];
|
|
74
|
+
const rulesTokens = estimateRulesTokens(systemPromptParts);
|
|
75
|
+
const systemContextTokens = estimateTextTokensHeuristic(systemPromptParts.slice(1));
|
|
76
|
+
const systemPromptTokens = Math.max(
|
|
77
|
+
0,
|
|
78
|
+
estimateTextTokensHeuristic(systemPromptParts[0] ?? "") - skillsTokens - rulesTokens,
|
|
79
|
+
);
|
|
80
|
+
return { rulesTokens, skillsTokens, toolsTokens, systemContextTokens, systemPromptTokens };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function estimateRulesTokens(systemPromptParts: readonly string[]): number {
|
|
84
|
+
const fragments: string[] = [];
|
|
85
|
+
for (const part of systemPromptParts) {
|
|
86
|
+
for (const match of part.matchAll(/<rules>[\s\S]*?<\/rules>/g)) {
|
|
87
|
+
fragments.push(match[0]);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return fragments.length === 0 ? 0 : estimateTextTokensHeuristic(fragments);
|
|
91
|
+
}
|
|
@@ -3,7 +3,15 @@ import * as path from "node:path";
|
|
|
3
3
|
import { ThinkingLevel } from "@gajae-code/agent-core";
|
|
4
4
|
import { type Model, modelsAreEqual } from "@gajae-code/ai";
|
|
5
5
|
import { getOAuthProviders } from "@gajae-code/ai/utils/oauth";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
isPetMode,
|
|
8
|
+
isUnderTerminalMultiplexer,
|
|
9
|
+
PET_MODE_IDS,
|
|
10
|
+
PET_SKIN_IDS,
|
|
11
|
+
PET_SKINS,
|
|
12
|
+
Spacer,
|
|
13
|
+
Text,
|
|
14
|
+
} from "@gajae-code/tui";
|
|
7
15
|
import { setProjectDir } from "@gajae-code/utils";
|
|
8
16
|
import { jobElapsedMs } from "../async";
|
|
9
17
|
import { materializeActiveModelProfileAssignments } from "../config/model-profile-activation";
|
|
@@ -579,7 +587,9 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
579
587
|
}
|
|
580
588
|
if (arg !== "off" && !GajaePetWidget.pixelProtocol()) {
|
|
581
589
|
ctx.showStatus(
|
|
582
|
-
|
|
590
|
+
isUnderTerminalMultiplexer()
|
|
591
|
+
? "Gajae pet: graphics are suppressed inside tmux/screen/zellij — escapes are not forwarded end-to-end. Run gjc outside the multiplexer in a sixel/kitty-graphics terminal, or set PI_FORCE_IMAGE_PROTOCOL=sixel when your multiplexer+client chain renders sixel."
|
|
592
|
+
: "Gajae pet needs a sixel/kitty-graphics terminal (Windows Terminal 1.22+, kitty, Ghostty, WezTerm)",
|
|
583
593
|
{ dim: true },
|
|
584
594
|
);
|
|
585
595
|
} else if (isPetMode(arg)) {
|