@sayknow-cli/coding-agent 0.3.10 → 0.3.12
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 +0 -13
- package/dist/types/build-metadata.d.ts +7 -0
- package/dist/types/capability/mcp.d.ts +3 -1
- package/dist/types/commands/mcp.d.ts +1 -1
- package/dist/types/config/keybindings.d.ts +2 -2
- package/dist/types/modes/acp/acp-event-mapper.d.ts +2 -0
- package/dist/types/modes/components/hook-selector.d.ts +1 -0
- package/dist/types/modes/components/status-line/types.d.ts +6 -0
- package/dist/types/modes/components/status-line.d.ts +1 -0
- package/dist/types/modes/components/welcome.d.ts +1 -0
- package/dist/types/modes/rpc/rpc-client.d.ts +7 -2
- package/dist/types/modes/utils/ui-helpers.d.ts +12 -0
- package/dist/types/notifications/telegram-daemon.d.ts +2 -0
- package/dist/types/runtime-mcp/config-writer.d.ts +4 -3
- package/dist/types/runtime-mcp/types.d.ts +4 -3
- package/dist/types/session/agent-session.d.ts +16 -0
- package/dist/types/session/session-manager.d.ts +1 -0
- package/dist/types/tools/skill.d.ts +9 -8
- package/dist/types/utils/linkedom.d.ts +4 -0
- package/dist/types/utils/pasted-image-path.d.ts +5 -3
- package/package.json +7 -7
- package/scripts/build-binary.ts +2 -43
- package/scripts/compile-args.ts +106 -0
- package/scripts/generate-docs-index.ts +1 -0
- package/src/build-metadata.ts +104 -0
- package/src/capability/mcp.ts +3 -1
- package/src/cli/mcp-cli.ts +108 -26
- package/src/cli.ts +21 -0
- package/src/commands/mcp.ts +8 -8
- package/src/commands/stats.ts +6 -2
- package/src/config/keybindings.ts +2 -2
- package/src/config/mcp-schema.json +21 -17
- package/src/config/model-profiles.ts +23 -23
- package/src/discovery/builtin.ts +14 -0
- package/src/discovery/mcp-json.ts +14 -0
- package/src/eval/js/executor.ts +7 -1
- package/src/internal-urls/docs-index.generated.ts +3 -2
- package/src/internal-urls/skc-protocol.ts +14 -1
- package/src/modes/acp/acp-agent.ts +72 -16
- package/src/modes/acp/acp-client-bridge.ts +27 -5
- package/src/modes/acp/acp-event-mapper.ts +39 -1
- package/src/modes/components/assistant-message.ts +1 -1
- package/src/modes/components/hook-selector.ts +4 -0
- package/src/modes/components/model-selector.ts +17 -6
- package/src/modes/components/session-selector.ts +2 -2
- package/src/modes/components/status-line/presets.ts +5 -19
- package/src/modes/components/status-line/segments.ts +14 -0
- package/src/modes/components/status-line/types.ts +6 -0
- package/src/modes/components/status-line.ts +13 -6
- package/src/modes/components/tool-execution.ts +1 -1
- package/src/modes/components/welcome.ts +4 -1
- package/src/modes/controllers/command-controller.ts +1 -1
- package/src/modes/controllers/event-controller.ts +9 -9
- package/src/modes/controllers/input-controller.ts +8 -2
- package/src/modes/controllers/selector-controller.ts +7 -3
- package/src/modes/interactive-mode.ts +12 -4
- package/src/modes/rpc/rpc-client.ts +30 -6
- package/src/modes/utils/ui-helpers.ts +154 -32
- package/src/notifications/telegram-daemon.ts +28 -2
- package/src/prompts/tools/skill.md +2 -2
- package/src/runtime-mcp/config-writer.ts +4 -3
- package/src/runtime-mcp/config.ts +1 -0
- package/src/runtime-mcp/types.ts +4 -3
- package/src/session/agent-session.ts +117 -3
- package/src/session/session-manager.ts +68 -40
- package/src/skc-runtime/state-runtime.ts +105 -12
- package/src/skc-runtime/workflow-manifest.generated.json +4 -28
- package/src/skc-runtime/workflow-manifest.ts +1 -7
- package/src/slash-commands/builtin-registry.ts +9 -4
- package/src/tools/bash-interactive.ts +9 -4
- package/src/tools/browser/readable.ts +2 -2
- package/src/tools/fetch.ts +5 -5
- package/src/tools/skill.ts +14 -11
- package/src/utils/linkedom.ts +14 -0
- package/src/utils/pasted-image-path.ts +23 -9
- package/src/web/scrapers/arxiv.ts +2 -2
- package/src/web/scrapers/go-pkg.ts +2 -2
- package/src/web/scrapers/iacr.ts +2 -2
- package/src/web/scrapers/readthedocs.ts +2 -2
- package/src/web/scrapers/twitter.ts +3 -2
- package/src/web/scrapers/wikipedia.ts +2 -2
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
type AgentMessage,
|
|
27
27
|
type AgentState,
|
|
28
28
|
type AgentTool,
|
|
29
|
+
assertImagePlaceholdersHavePayload,
|
|
29
30
|
resolveTelemetry,
|
|
30
31
|
type StablePrefixSnapshot,
|
|
31
32
|
ThinkingLevel,
|
|
@@ -151,6 +152,7 @@ import { onAppendOnlyModeChanged } from "../config/settings";
|
|
|
151
152
|
import { RawSseDebugBuffer } from "../debug/raw-sse-buffer";
|
|
152
153
|
import { loadCapability } from "../discovery";
|
|
153
154
|
import { expandApplyPatchToEntries, normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
|
|
155
|
+
import { MAX_EDIT_FILE_BYTES } from "../edit/read-file";
|
|
154
156
|
import { disposeVmContextsByOwner } from "../eval/js/context-manager";
|
|
155
157
|
import {
|
|
156
158
|
disposeKernelSessionsByOwner,
|
|
@@ -360,6 +362,11 @@ export interface AsyncJobSnapshot {
|
|
|
360
362
|
// Types
|
|
361
363
|
// ============================================================================
|
|
362
364
|
|
|
365
|
+
export interface RetainedMemorySample {
|
|
366
|
+
tuiChatChildren?: number;
|
|
367
|
+
tuiCachedRenderBytes?: number;
|
|
368
|
+
}
|
|
369
|
+
|
|
363
370
|
export interface AgentSessionConfig {
|
|
364
371
|
agent: Agent;
|
|
365
372
|
sessionManager: SessionManager;
|
|
@@ -439,6 +446,8 @@ export interface AgentSessionConfig {
|
|
|
439
446
|
* **MUST NOT** dispose it on their own teardown.
|
|
440
447
|
*/
|
|
441
448
|
ownedAsyncJobManager?: AsyncJobManager;
|
|
449
|
+
/** Cheap TUI retained-memory counters; absent for headless sessions. */
|
|
450
|
+
retainedMemorySampler?: () => RetainedMemorySample;
|
|
442
451
|
/**
|
|
443
452
|
* MCPManager whose lifecycle this session owns (top-level sessions that
|
|
444
453
|
* connected plugin-bundle MCP servers). Only the owned manager is
|
|
@@ -1084,6 +1093,68 @@ export function buildContextInjectionSignature(kind: string, parts: readonly str
|
|
|
1084
1093
|
}
|
|
1085
1094
|
return hash.digest("base64url");
|
|
1086
1095
|
}
|
|
1096
|
+
|
|
1097
|
+
const STREAMING_EDIT_FILE_CACHE_MAX_ENTRIES = 16;
|
|
1098
|
+
const STREAMING_EDIT_FILE_CACHE_MAX_TOTAL_BYTES = 32 * 1024 * 1024;
|
|
1099
|
+
|
|
1100
|
+
type StreamingEditFileCacheEntry = {
|
|
1101
|
+
content: string;
|
|
1102
|
+
bytes: number;
|
|
1103
|
+
};
|
|
1104
|
+
|
|
1105
|
+
export class StreamingEditFileCache {
|
|
1106
|
+
#entries = new Map<string, StreamingEditFileCacheEntry>();
|
|
1107
|
+
#totalBytes = 0;
|
|
1108
|
+
|
|
1109
|
+
get(path: string): string | undefined {
|
|
1110
|
+
const entry = this.#entries.get(path);
|
|
1111
|
+
if (entry === undefined) return undefined;
|
|
1112
|
+
this.#entries.delete(path);
|
|
1113
|
+
this.#entries.set(path, entry);
|
|
1114
|
+
return entry.content;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
set(path: string, content: string): void {
|
|
1118
|
+
const bytes = Buffer.byteLength(content, "utf8");
|
|
1119
|
+
if (bytes > MAX_EDIT_FILE_BYTES || bytes > STREAMING_EDIT_FILE_CACHE_MAX_TOTAL_BYTES) {
|
|
1120
|
+
this.delete(path);
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
this.delete(path);
|
|
1125
|
+
while (
|
|
1126
|
+
this.#entries.size >= STREAMING_EDIT_FILE_CACHE_MAX_ENTRIES ||
|
|
1127
|
+
this.#totalBytes + bytes > STREAMING_EDIT_FILE_CACHE_MAX_TOTAL_BYTES
|
|
1128
|
+
) {
|
|
1129
|
+
const oldestPath = this.#entries.keys().next().value;
|
|
1130
|
+
if (oldestPath === undefined) break;
|
|
1131
|
+
this.delete(oldestPath);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
this.#entries.set(path, { content, bytes });
|
|
1135
|
+
this.#totalBytes += bytes;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
delete(path: string): void {
|
|
1139
|
+
const entry = this.#entries.get(path);
|
|
1140
|
+
if (entry === undefined) return;
|
|
1141
|
+
this.#entries.delete(path);
|
|
1142
|
+
this.#totalBytes -= entry.bytes;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
clear(): void {
|
|
1146
|
+
this.#entries.clear();
|
|
1147
|
+
this.#totalBytes = 0;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
has(path: string): boolean {
|
|
1151
|
+
return this.#entries.has(path);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
get totalBytes(): number {
|
|
1155
|
+
return this.#totalBytes;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1087
1158
|
export class AgentSession {
|
|
1088
1159
|
readonly agent: Agent;
|
|
1089
1160
|
readonly sessionManager: SessionManager;
|
|
@@ -1148,6 +1219,7 @@ export class AgentSession {
|
|
|
1148
1219
|
#compactionAbortController: AbortController | undefined = undefined;
|
|
1149
1220
|
#autoCompactionAbortController: AbortController | undefined = undefined;
|
|
1150
1221
|
#resourceSampler: () => EmergencyCompactionSample = () => this.#defaultResourceSample();
|
|
1222
|
+
#retainedMemorySampler: (() => RetainedMemorySample) | undefined;
|
|
1151
1223
|
#prePromptContextCheckPromise: Promise<void> | undefined = undefined;
|
|
1152
1224
|
|
|
1153
1225
|
// Branch summarization state
|
|
@@ -1321,7 +1393,7 @@ export class AgentSession {
|
|
|
1321
1393
|
|
|
1322
1394
|
#streamingEditPrecheckedToolCallIds = new Set<string>();
|
|
1323
1395
|
|
|
1324
|
-
#streamingEditFileCache = new
|
|
1396
|
+
#streamingEditFileCache = new StreamingEditFileCache();
|
|
1325
1397
|
readonly streamingEditDebugCounters = {
|
|
1326
1398
|
guardRuns: 0,
|
|
1327
1399
|
processedChars: 0,
|
|
@@ -1447,6 +1519,7 @@ export class AgentSession {
|
|
|
1447
1519
|
// Power assertions are taken per turn (see #beginInFlight); nothing acquired here.
|
|
1448
1520
|
this.#evalKernelOwnerId = config.evalKernelOwnerId ?? `agent-session:${Snowflake.next()}`;
|
|
1449
1521
|
this.#ownedAsyncJobManager = config.ownedAsyncJobManager;
|
|
1522
|
+
this.#retainedMemorySampler = config.retainedMemorySampler;
|
|
1450
1523
|
this.#ownedMcpManager = config.ownedMcpManager;
|
|
1451
1524
|
this.#scopedModels = config.scopedModels ?? [];
|
|
1452
1525
|
this.#thinkingLevel = config.thinkingLevel;
|
|
@@ -3170,7 +3243,31 @@ export class AgentSession {
|
|
|
3170
3243
|
// which is the optional patch-preview verification gated by
|
|
3171
3244
|
// edit.streamingAbort. Skip the read when the setting is off.
|
|
3172
3245
|
if (this.settings.get("edit.streamingAbort")) {
|
|
3173
|
-
|
|
3246
|
+
// Fire-and-forget async priming: toolcall deltas arrive on the hot
|
|
3247
|
+
// stream path, so never block it on filesystem I/O. The abort-check
|
|
3248
|
+
// path (#maybeAbortStreamingEdit) still falls back to the sync read
|
|
3249
|
+
// if priming hasn't completed by the time removed lines appear.
|
|
3250
|
+
void this.#preCacheFileAsync(streamingEdit.resolvedPath);
|
|
3251
|
+
}
|
|
3252
|
+
}
|
|
3253
|
+
|
|
3254
|
+
#streamingEditPrecachePending = new Set<string>();
|
|
3255
|
+
async #preCacheFileAsync(resolvedPath: string): Promise<void> {
|
|
3256
|
+
if (this.#streamingEditFileCache.has(resolvedPath)) return;
|
|
3257
|
+
if (this.#streamingEditPrecachePending.has(resolvedPath)) return;
|
|
3258
|
+
this.#streamingEditPrecachePending.add(resolvedPath);
|
|
3259
|
+
try {
|
|
3260
|
+
const stat = await fs.promises.stat(resolvedPath);
|
|
3261
|
+
if (stat.size > MAX_EDIT_FILE_BYTES) return;
|
|
3262
|
+
|
|
3263
|
+
const rawText = await fs.promises.readFile(resolvedPath, "utf-8");
|
|
3264
|
+
if (this.#streamingEditFileCache.has(resolvedPath)) return;
|
|
3265
|
+
const { text } = stripBom(rawText);
|
|
3266
|
+
this.#streamingEditFileCache.set(resolvedPath, normalizeToLF(text));
|
|
3267
|
+
} catch {
|
|
3268
|
+
// Don't cache on read errors (including ENOENT) - let the edit tool handle them
|
|
3269
|
+
} finally {
|
|
3270
|
+
this.#streamingEditPrecachePending.delete(resolvedPath);
|
|
3174
3271
|
}
|
|
3175
3272
|
}
|
|
3176
3273
|
|
|
@@ -3178,6 +3275,9 @@ export class AgentSession {
|
|
|
3178
3275
|
if (this.#streamingEditFileCache.has(resolvedPath)) return;
|
|
3179
3276
|
|
|
3180
3277
|
try {
|
|
3278
|
+
const stat = fs.statSync(resolvedPath);
|
|
3279
|
+
if (stat.size > MAX_EDIT_FILE_BYTES) return;
|
|
3280
|
+
|
|
3181
3281
|
const rawText = fs.readFileSync(resolvedPath, "utf-8");
|
|
3182
3282
|
const { text } = stripBom(rawText);
|
|
3183
3283
|
this.#streamingEditFileCache.set(resolvedPath, normalizeToLF(text));
|
|
@@ -5298,6 +5398,7 @@ export class AgentSession {
|
|
|
5298
5398
|
|
|
5299
5399
|
// Expand file-based prompt templates if requested
|
|
5300
5400
|
const expandedText = expandPromptTemplates ? expandPromptTemplate(text, [...this.#promptTemplates]) : text;
|
|
5401
|
+
assertImagePlaceholdersHavePayload(expandedText, options?.images);
|
|
5301
5402
|
const workflowIntentDiff = options?.synthetic ? null : buildWorkflowIntentDiff(expandedText);
|
|
5302
5403
|
|
|
5303
5404
|
// If streaming, queue via steer() or followUp() based on option
|
|
@@ -5784,6 +5885,7 @@ export class AgentSession {
|
|
|
5784
5885
|
}
|
|
5785
5886
|
|
|
5786
5887
|
const expandedText = expandPromptTemplate(text, [...this.#promptTemplates]);
|
|
5888
|
+
assertImagePlaceholdersHavePayload(expandedText, images);
|
|
5787
5889
|
await this.#queueSteer(expandedText, images);
|
|
5788
5890
|
}
|
|
5789
5891
|
|
|
@@ -5800,6 +5902,7 @@ export class AgentSession {
|
|
|
5800
5902
|
}
|
|
5801
5903
|
|
|
5802
5904
|
const expandedText = expandPromptTemplate(text, [...this.#promptTemplates]);
|
|
5905
|
+
assertImagePlaceholdersHavePayload(expandedText, images);
|
|
5803
5906
|
await this.#queueFollowUp(expandedText, images, {
|
|
5804
5907
|
forceOneAtATime: options?.followUpQueuePolicy === "sequential",
|
|
5805
5908
|
});
|
|
@@ -5809,6 +5912,7 @@ export class AgentSession {
|
|
|
5809
5912
|
* Internal: Queue a steering message (already expanded, no extension command check).
|
|
5810
5913
|
*/
|
|
5811
5914
|
async #queueSteer(text: string, images?: ImageContent[]): Promise<void> {
|
|
5915
|
+
assertImagePlaceholdersHavePayload(text, images);
|
|
5812
5916
|
const displayText = text || (images && images.length > 0 ? "[Image]" : "");
|
|
5813
5917
|
this.#steeringMessages.push(this.#createQueuedDisplayEntry(displayText));
|
|
5814
5918
|
const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
|
|
@@ -5840,6 +5944,7 @@ export class AgentSession {
|
|
|
5840
5944
|
* Internal: Queue a follow-up message (already expanded, no extension command check).
|
|
5841
5945
|
*/
|
|
5842
5946
|
async #queueFollowUp(text: string, images?: ImageContent[], options?: { forceOneAtATime?: boolean }): Promise<void> {
|
|
5947
|
+
assertImagePlaceholdersHavePayload(text, images);
|
|
5843
5948
|
const displayText = text || (images && images.length > 0 ? "[Image]" : "");
|
|
5844
5949
|
this.#followUpMessages.push(this.#createQueuedDisplayEntry(displayText));
|
|
5845
5950
|
const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
|
|
@@ -7732,9 +7837,14 @@ export class AgentSession {
|
|
|
7732
7837
|
this.#resourceSampler = sampler;
|
|
7733
7838
|
}
|
|
7734
7839
|
|
|
7840
|
+
setRetainedMemorySampler(sampler: (() => RetainedMemorySample) | undefined): void {
|
|
7841
|
+
this.#retainedMemorySampler = sampler;
|
|
7842
|
+
}
|
|
7843
|
+
|
|
7735
7844
|
#defaultResourceSample(): EmergencyCompactionSample {
|
|
7736
7845
|
let providerBytes = 0;
|
|
7737
7846
|
let imageBytes = 0;
|
|
7847
|
+
const retainedMemory = this.#retainedMemorySampler?.() ?? {};
|
|
7738
7848
|
for (const message of this.state.messages) {
|
|
7739
7849
|
const content = (message as { content?: unknown }).content;
|
|
7740
7850
|
if (typeof content === "string") {
|
|
@@ -7756,6 +7866,10 @@ export class AgentSession {
|
|
|
7756
7866
|
providerBytes,
|
|
7757
7867
|
messageCount: this.state.messages.length,
|
|
7758
7868
|
imageBytes,
|
|
7869
|
+
sessionResidentImageBytes: this.sessionManager.getResidentImageBytes(),
|
|
7870
|
+
materializedResidentBytes: this.#streamingEditFileCache.totalBytes,
|
|
7871
|
+
tuiChatChildren: retainedMemory.tuiChatChildren ?? 0,
|
|
7872
|
+
tuiCachedRenderBytes: retainedMemory.tuiCachedRenderBytes ?? 0,
|
|
7759
7873
|
};
|
|
7760
7874
|
}
|
|
7761
7875
|
|
|
@@ -8071,7 +8185,7 @@ export class AgentSession {
|
|
|
8071
8185
|
attribution: "agent",
|
|
8072
8186
|
timestamp: Date.now(),
|
|
8073
8187
|
});
|
|
8074
|
-
this.#scheduleAgentContinue({
|
|
8188
|
+
this.#scheduleAgentContinue({ skipCompactionCheck: true });
|
|
8075
8189
|
}
|
|
8076
8190
|
|
|
8077
8191
|
/**
|
|
@@ -45,8 +45,6 @@ import {
|
|
|
45
45
|
MemoryBlobStore,
|
|
46
46
|
parseBlobRef,
|
|
47
47
|
ResidentBlobMissingError,
|
|
48
|
-
resolveImageData,
|
|
49
|
-
resolveImageDataUrl,
|
|
50
48
|
resolveResidentImageDataSync,
|
|
51
49
|
resolveResidentImageDataUrlSync,
|
|
52
50
|
resolveTextBlobSync,
|
|
@@ -973,47 +971,46 @@ export async function loadEntriesFromFile(
|
|
|
973
971
|
}
|
|
974
972
|
|
|
975
973
|
/**
|
|
976
|
-
*
|
|
977
|
-
*
|
|
974
|
+
* Convert legacy persisted blob references in loaded entries into resident sentinels.
|
|
975
|
+
* Images then materialize lazily at provider/display/export chokepoints instead of
|
|
976
|
+
* pinning every historical base64 string for the lifetime of a resumed session.
|
|
978
977
|
*/
|
|
979
978
|
function hasImageUrl(value: unknown): value is { image_url: string | { url?: string } } {
|
|
980
979
|
return typeof value === "object" && value !== null && "image_url" in value;
|
|
981
980
|
}
|
|
982
981
|
|
|
983
|
-
|
|
982
|
+
function residentizePersistedBlobRefs(value: unknown, key?: string): void {
|
|
984
983
|
if (Array.isArray(value)) {
|
|
985
|
-
|
|
984
|
+
for (const item of value) residentizePersistedBlobRefs(item, key);
|
|
986
985
|
return;
|
|
987
986
|
}
|
|
988
987
|
|
|
989
988
|
if (typeof value !== "object" || value === null) return;
|
|
990
989
|
|
|
991
990
|
if (isImageBlock(value) && isBlobRef(value.data)) {
|
|
992
|
-
value.data =
|
|
991
|
+
value.data = residentBlobSentinel("imageData", value.data) as unknown as string;
|
|
993
992
|
}
|
|
994
993
|
|
|
995
994
|
if (hasImageUrl(value)) {
|
|
996
995
|
if (typeof value.image_url === "string" && isBlobRef(value.image_url)) {
|
|
997
|
-
value.image_url =
|
|
996
|
+
value.image_url = residentBlobSentinel("imageUrl", value.image_url) as unknown as string;
|
|
998
997
|
} else if (
|
|
999
998
|
typeof value.image_url === "object" &&
|
|
1000
999
|
value.image_url !== null &&
|
|
1001
1000
|
typeof value.image_url.url === "string" &&
|
|
1002
1001
|
isBlobRef(value.image_url.url)
|
|
1003
1002
|
) {
|
|
1004
|
-
value.image_url.url =
|
|
1003
|
+
value.image_url.url = residentBlobSentinel("imageUrl", value.image_url.url) as unknown as string;
|
|
1005
1004
|
}
|
|
1006
1005
|
}
|
|
1007
1006
|
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
}),
|
|
1016
|
-
);
|
|
1007
|
+
for (const [childKey, item] of Object.entries(value)) {
|
|
1008
|
+
if (childKey === "data" && typeof item === "string" && isBlobRef(item) && key !== TEXT_CONTENT_KEY) {
|
|
1009
|
+
(value as Record<string, unknown>)[childKey] = residentBlobSentinel("imageUrl", item);
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
residentizePersistedBlobRefs(item, childKey);
|
|
1013
|
+
}
|
|
1017
1014
|
}
|
|
1018
1015
|
|
|
1019
1016
|
/**
|
|
@@ -1035,28 +1032,13 @@ async function runWithConcurrency(tasks: Array<() => Promise<void>>, limit: numb
|
|
|
1035
1032
|
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
|
1036
1033
|
}
|
|
1037
1034
|
|
|
1038
|
-
async function resolveBlobRefsInEntries(entries: FileEntry[],
|
|
1035
|
+
async function resolveBlobRefsInEntries(entries: FileEntry[], _blobStore: BlobStore): Promise<void> {
|
|
1039
1036
|
const tasks: Array<() => Promise<void>> = [];
|
|
1040
1037
|
|
|
1041
1038
|
for (const entry of entries) {
|
|
1042
1039
|
if (entry.type === "session") continue;
|
|
1043
|
-
|
|
1044
|
-
let contentArray: unknown[] | undefined;
|
|
1045
|
-
if (entry.type === "message" && "content" in entry.message && Array.isArray(entry.message.content)) {
|
|
1046
|
-
contentArray = entry.message.content;
|
|
1047
|
-
} else if (entry.type === "custom_message" && Array.isArray(entry.content)) {
|
|
1048
|
-
contentArray = entry.content;
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
1040
|
tasks.push(async () => {
|
|
1052
|
-
|
|
1053
|
-
for (const block of contentArray) {
|
|
1054
|
-
if (isImageBlock(block) && isBlobRef(block.data)) {
|
|
1055
|
-
block.data = await resolveImageData(blobStore, block.data);
|
|
1056
|
-
}
|
|
1057
|
-
}
|
|
1058
|
-
}
|
|
1059
|
-
await resolvePersistedBlobRefs(entry, blobStore);
|
|
1041
|
+
residentizePersistedBlobRefs(entry);
|
|
1060
1042
|
});
|
|
1061
1043
|
}
|
|
1062
1044
|
|
|
@@ -1314,6 +1296,33 @@ function containsResidentSentinel(value: unknown, seen = new WeakSet<object>()):
|
|
|
1314
1296
|
return false;
|
|
1315
1297
|
}
|
|
1316
1298
|
|
|
1299
|
+
function containsResidentImageSentinel(value: unknown, seen = new WeakSet<object>()): boolean {
|
|
1300
|
+
if (value === null || value === undefined || typeof value !== "object") return false;
|
|
1301
|
+
if (isResidentBlobSentinel(value)) return value.kind === "imageUrl" || value.kind === "imageData";
|
|
1302
|
+
if (seen.has(value)) return false;
|
|
1303
|
+
seen.add(value);
|
|
1304
|
+
if (Array.isArray(value)) return value.some(item => containsResidentImageSentinel(item, seen));
|
|
1305
|
+
for (const child of Object.values(value)) {
|
|
1306
|
+
if (containsResidentImageSentinel(child, seen)) return true;
|
|
1307
|
+
}
|
|
1308
|
+
return false;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
function collectResidentImageRefs(value: unknown, refs: Set<string>, seen = new WeakSet<object>()): void {
|
|
1312
|
+
if (value === null || value === undefined || typeof value !== "object") return;
|
|
1313
|
+
if (isResidentBlobSentinel(value)) {
|
|
1314
|
+
if (value.kind === "imageUrl" || value.kind === "imageData") refs.add(value.ref);
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
if (seen.has(value)) return;
|
|
1318
|
+
seen.add(value);
|
|
1319
|
+
if (Array.isArray(value)) {
|
|
1320
|
+
for (const item of value) collectResidentImageRefs(item, refs, seen);
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
for (const child of Object.values(value)) collectResidentImageRefs(child, refs, seen);
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1317
1326
|
/**
|
|
1318
1327
|
* Recursively truncate large strings in an object for session persistence.
|
|
1319
1328
|
* - Truncates any oversized string fields (key-agnostic)
|
|
@@ -4148,6 +4157,22 @@ export class SessionManager {
|
|
|
4148
4157
|
return entry ? materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), new Map()) : undefined;
|
|
4149
4158
|
}
|
|
4150
4159
|
|
|
4160
|
+
getResidentImageBytes(): number {
|
|
4161
|
+
const refs = new Set<string>();
|
|
4162
|
+
for (const entry of this.#fileEntries) collectResidentImageRefs(entry, refs);
|
|
4163
|
+
let bytes = 0;
|
|
4164
|
+
for (const ref of refs) {
|
|
4165
|
+
const hash = parseBlobRef(ref);
|
|
4166
|
+
if (!hash) continue;
|
|
4167
|
+
try {
|
|
4168
|
+
bytes += fs.statSync(path.join(this.#residentImageBlobStore.dir, hash)).size;
|
|
4169
|
+
} catch (err) {
|
|
4170
|
+
if (!isEnoent(err)) throw err;
|
|
4171
|
+
}
|
|
4172
|
+
}
|
|
4173
|
+
return bytes;
|
|
4174
|
+
}
|
|
4175
|
+
|
|
4151
4176
|
/**
|
|
4152
4177
|
* Get the most recent model role from the current session path.
|
|
4153
4178
|
* Returns undefined if no model change has been recorded.
|
|
@@ -4571,11 +4596,14 @@ export class SessionManager {
|
|
|
4571
4596
|
}
|
|
4572
4597
|
this.#materializedEntriesCachePopulateCount++;
|
|
4573
4598
|
const resolvedTextBlobCache = new Map<string, string>();
|
|
4574
|
-
const
|
|
4575
|
-
|
|
4576
|
-
|
|
4577
|
-
|
|
4578
|
-
|
|
4599
|
+
const sourceEntries = this.#fileEntries.filter((e): e is SessionEntry => e.type !== "session");
|
|
4600
|
+
const materializedEntries = sourceEntries.map(entry =>
|
|
4601
|
+
materializeResidentEntryForReadSync(entry, this.#residentBlobStores(), resolvedTextBlobCache),
|
|
4602
|
+
);
|
|
4603
|
+
if (!sourceEntries.some(entry => containsResidentImageSentinel(entry))) {
|
|
4604
|
+
this.#materializedEntriesCache = materializedEntries;
|
|
4605
|
+
this.#materializedEntriesRevision = this.#entryRevision;
|
|
4606
|
+
}
|
|
4579
4607
|
return materializedEntries;
|
|
4580
4608
|
}
|
|
4581
4609
|
|
|
@@ -242,9 +242,12 @@ function assertSafePathComponent(value: string, label: string): void {
|
|
|
242
242
|
throw new StateCommandError(2, `invalid path component for --${label}: ${value}`);
|
|
243
243
|
}
|
|
244
244
|
}
|
|
245
|
+
function isKnownMode(mode: string): mode is CanonicalSkcWorkflowSkill {
|
|
246
|
+
return KNOWN_MODES.includes(mode);
|
|
247
|
+
}
|
|
245
248
|
|
|
246
249
|
function assertKnownMode(mode: string): asserts mode is CanonicalSkcWorkflowSkill {
|
|
247
|
-
if (!
|
|
250
|
+
if (!isKnownMode(mode)) {
|
|
248
251
|
throw new StateCommandError(2, `unknown --mode: ${mode}. Expected one of: ${KNOWN_MODES.join(", ")}.`);
|
|
249
252
|
}
|
|
250
253
|
}
|
|
@@ -1580,14 +1583,15 @@ async function handleHandoff(
|
|
|
1580
1583
|
if (!calleeRaw) {
|
|
1581
1584
|
throw new StateCommandError(2, "skc state handoff requires --to <callee>");
|
|
1582
1585
|
}
|
|
1583
|
-
|
|
1584
|
-
const callee = calleeRaw
|
|
1586
|
+
assertSafePathComponent(calleeRaw, "to");
|
|
1587
|
+
const callee = calleeRaw;
|
|
1588
|
+
const calleeIsWorkflow = isKnownMode(callee);
|
|
1585
1589
|
if (callee === caller) {
|
|
1586
1590
|
throw new StateCommandError(2, `skc state handoff: --to must differ from caller (both are "${caller}")`);
|
|
1587
1591
|
}
|
|
1588
1592
|
|
|
1589
1593
|
const callerPath = modeStateFile(cwd, caller, sessionId);
|
|
1590
|
-
const calleePath = modeStateFile(cwd, callee, sessionId);
|
|
1594
|
+
const calleePath = calleeIsWorkflow ? modeStateFile(cwd, callee, sessionId) : undefined;
|
|
1591
1595
|
const forced = hasFlag(args, "--force");
|
|
1592
1596
|
const callerRead = await readExistingStateForMutation(callerPath);
|
|
1593
1597
|
if (callerRead.kind === "corrupt" && !forced) {
|
|
@@ -1602,15 +1606,7 @@ async function handleHandoff(
|
|
|
1602
1606
|
`skc state ${caller} handoff: caller is not active (no mode-state file at ${callerPath})`,
|
|
1603
1607
|
);
|
|
1604
1608
|
}
|
|
1605
|
-
const calleeRead = await readExistingStateForMutation(calleePath);
|
|
1606
|
-
if (calleeRead.kind === "corrupt" && !forced) {
|
|
1607
|
-
throw new StateCommandError(
|
|
1608
|
-
2,
|
|
1609
|
-
`existing state for ${callee} is corrupt or tampered (${calleeRead.error}); use --force to overwrite`,
|
|
1610
|
-
);
|
|
1611
|
-
}
|
|
1612
1609
|
const existingCaller = callerRead.kind === "valid" ? callerRead.value : {};
|
|
1613
|
-
const existingCallee = calleeRead.kind === "valid" ? calleeRead.value : {};
|
|
1614
1610
|
|
|
1615
1611
|
const handoffAt = nowIso();
|
|
1616
1612
|
const mutationId = `${caller}:handoff:${callee}:${handoffAt}`;
|
|
@@ -1623,6 +1619,103 @@ async function handleHandoff(
|
|
|
1623
1619
|
nowIso: handoffAt,
|
|
1624
1620
|
mutationId,
|
|
1625
1621
|
});
|
|
1622
|
+
|
|
1623
|
+
// Runtime callees have no native mode-state to clear later, so do not
|
|
1624
|
+
// persist them as active-state entries; the prompt observer tracks them
|
|
1625
|
+
// in memory the same way direct `/skill:<runtime>` invocation does.
|
|
1626
|
+
if (!calleeIsWorkflow) {
|
|
1627
|
+
const normalizedCaller =
|
|
1628
|
+
caller === "deep-interview"
|
|
1629
|
+
? (normalizeDeepInterviewEnvelope(migrateWorkflowState(existingCaller, caller).state) as Record<
|
|
1630
|
+
string,
|
|
1631
|
+
unknown
|
|
1632
|
+
>)
|
|
1633
|
+
: migrateWorkflowState(existingCaller, caller).state;
|
|
1634
|
+
const mergedCallerState: Record<string, unknown> = {
|
|
1635
|
+
...normalizedCaller,
|
|
1636
|
+
skill: caller,
|
|
1637
|
+
version: WORKFLOW_STATE_VERSION,
|
|
1638
|
+
active: false,
|
|
1639
|
+
current_phase: "handoff",
|
|
1640
|
+
handoff_to: callee,
|
|
1641
|
+
handoff_at: handoffAt,
|
|
1642
|
+
updated_at: handoffAt,
|
|
1643
|
+
receipt: callerReceipt,
|
|
1644
|
+
};
|
|
1645
|
+
const force = hasFlag(args, "--force");
|
|
1646
|
+
await beginWorkflowTransactionJournal({
|
|
1647
|
+
cwd,
|
|
1648
|
+
sessionId,
|
|
1649
|
+
mutationId,
|
|
1650
|
+
caller,
|
|
1651
|
+
paths: [callerPath, activeStateFile(cwd, sessionId)],
|
|
1652
|
+
});
|
|
1653
|
+
const callerWrite = await writeJsonAtomic(cwd, callerPath, mergedCallerState, "handoff", {
|
|
1654
|
+
sessionId,
|
|
1655
|
+
skill: caller,
|
|
1656
|
+
mutationId,
|
|
1657
|
+
force,
|
|
1658
|
+
fromPhase: typeof existingCaller.current_phase === "string" ? existingCaller.current_phase : undefined,
|
|
1659
|
+
toPhase: "handoff",
|
|
1660
|
+
});
|
|
1661
|
+
await updateWorkflowTransactionJournal(cwd, sessionId, mutationId, { steps: ["caller-mode-state"] });
|
|
1662
|
+
if (callerWrite.warning) process.stderr.write(`${callerWrite.warning}\n`);
|
|
1663
|
+
const stampedCallerReceipt = isPlainObject(callerWrite.stamped.receipt) ? callerWrite.stamped.receipt : {};
|
|
1664
|
+
await syncSkillActiveState({
|
|
1665
|
+
cwd,
|
|
1666
|
+
skill: caller,
|
|
1667
|
+
active: false,
|
|
1668
|
+
phase: "handoff",
|
|
1669
|
+
sessionId,
|
|
1670
|
+
threadId,
|
|
1671
|
+
turnId,
|
|
1672
|
+
source: "skc-state-cli",
|
|
1673
|
+
hud: buildHudForMode(caller, mergedCallerState),
|
|
1674
|
+
handoff_to: callee,
|
|
1675
|
+
handoff_at: handoffAt,
|
|
1676
|
+
receipt: callerReceipt,
|
|
1677
|
+
});
|
|
1678
|
+
await updateWorkflowTransactionJournal(cwd, sessionId, mutationId, {
|
|
1679
|
+
steps: ["caller-mode-state", "active-state"],
|
|
1680
|
+
});
|
|
1681
|
+
await completeWorkflowTransactionJournal(cwd, sessionId, mutationId);
|
|
1682
|
+
await touchStateActivityMarker(cwd, sessionId, callerPath);
|
|
1683
|
+
return {
|
|
1684
|
+
status: 0,
|
|
1685
|
+
stdout: renderCliWriteReceipt({
|
|
1686
|
+
ok: true,
|
|
1687
|
+
from: caller,
|
|
1688
|
+
to: callee,
|
|
1689
|
+
handoff_at: handoffAt,
|
|
1690
|
+
phases: {
|
|
1691
|
+
from: mergedCallerState.current_phase,
|
|
1692
|
+
},
|
|
1693
|
+
receipts: {
|
|
1694
|
+
from: {
|
|
1695
|
+
mutation_id: stampedCallerReceipt.mutation_id,
|
|
1696
|
+
status: stampedCallerReceipt.status,
|
|
1697
|
+
content_sha256: stampedCallerReceipt.content_sha256,
|
|
1698
|
+
},
|
|
1699
|
+
},
|
|
1700
|
+
paths: {
|
|
1701
|
+
from: callerPath,
|
|
1702
|
+
active_state: activeStateFile(cwd, sessionId),
|
|
1703
|
+
},
|
|
1704
|
+
}),
|
|
1705
|
+
};
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
if (!calleePath) {
|
|
1709
|
+
throw new StateCommandError(2, `skc state handoff failed to resolve workflow callee path for ${callee}`);
|
|
1710
|
+
}
|
|
1711
|
+
const calleeRead = await readExistingStateForMutation(calleePath);
|
|
1712
|
+
if (calleeRead.kind === "corrupt" && !forced) {
|
|
1713
|
+
throw new StateCommandError(
|
|
1714
|
+
2,
|
|
1715
|
+
`existing state for ${callee} is corrupt or tampered (${calleeRead.error}); use --force to overwrite`,
|
|
1716
|
+
);
|
|
1717
|
+
}
|
|
1718
|
+
const existingCallee = calleeRead.kind === "valid" ? calleeRead.value : {};
|
|
1626
1719
|
const calleeReceipt = buildWorkflowStateReceipt({
|
|
1627
1720
|
cwd,
|
|
1628
1721
|
skill: callee,
|
|
@@ -127,15 +127,9 @@
|
|
|
127
127
|
"appliesToVerbs": [
|
|
128
128
|
"handoff"
|
|
129
129
|
],
|
|
130
|
-
"enumValues": [
|
|
131
|
-
"deep-interview",
|
|
132
|
-
"ralplan",
|
|
133
|
-
"ultragoal",
|
|
134
|
-
"team"
|
|
135
|
-
],
|
|
136
130
|
"name": "to",
|
|
137
131
|
"required": true,
|
|
138
|
-
"type": "
|
|
132
|
+
"type": "string"
|
|
139
133
|
},
|
|
140
134
|
{
|
|
141
135
|
"appliesToVerbs": [
|
|
@@ -529,15 +523,9 @@
|
|
|
529
523
|
"appliesToVerbs": [
|
|
530
524
|
"handoff"
|
|
531
525
|
],
|
|
532
|
-
"enumValues": [
|
|
533
|
-
"deep-interview",
|
|
534
|
-
"ralplan",
|
|
535
|
-
"ultragoal",
|
|
536
|
-
"team"
|
|
537
|
-
],
|
|
538
526
|
"name": "to",
|
|
539
527
|
"required": true,
|
|
540
|
-
"type": "
|
|
528
|
+
"type": "string"
|
|
541
529
|
},
|
|
542
530
|
{
|
|
543
531
|
"appliesToVerbs": [
|
|
@@ -913,15 +901,9 @@
|
|
|
913
901
|
"appliesToVerbs": [
|
|
914
902
|
"handoff"
|
|
915
903
|
],
|
|
916
|
-
"enumValues": [
|
|
917
|
-
"deep-interview",
|
|
918
|
-
"ralplan",
|
|
919
|
-
"ultragoal",
|
|
920
|
-
"team"
|
|
921
|
-
],
|
|
922
904
|
"name": "to",
|
|
923
905
|
"required": true,
|
|
924
|
-
"type": "
|
|
906
|
+
"type": "string"
|
|
925
907
|
},
|
|
926
908
|
{
|
|
927
909
|
"appliesToVerbs": [
|
|
@@ -1357,15 +1339,9 @@
|
|
|
1357
1339
|
"appliesToVerbs": [
|
|
1358
1340
|
"handoff"
|
|
1359
1341
|
],
|
|
1360
|
-
"enumValues": [
|
|
1361
|
-
"deep-interview",
|
|
1362
|
-
"ralplan",
|
|
1363
|
-
"ultragoal",
|
|
1364
|
-
"team"
|
|
1365
|
-
],
|
|
1366
1342
|
"name": "to",
|
|
1367
1343
|
"required": true,
|
|
1368
|
-
"type": "
|
|
1344
|
+
"type": "string"
|
|
1369
1345
|
},
|
|
1370
1346
|
{
|
|
1371
1347
|
"appliesToVerbs": [
|
|
@@ -72,13 +72,7 @@ const COMMON_TYPED_ARGS: TypedArgSpec[] = [
|
|
|
72
72
|
{ name: "session-id", type: "string", appliesToVerbs: [...STATE_VERBS, "kickoff", "write-spec", "write-artifact"] },
|
|
73
73
|
{ name: "thread-id", type: "string", appliesToVerbs: ["write", "clear", "handoff"] },
|
|
74
74
|
{ name: "turn-id", type: "string", appliesToVerbs: ["write", "clear", "handoff"] },
|
|
75
|
-
{
|
|
76
|
-
name: "to",
|
|
77
|
-
type: "enum",
|
|
78
|
-
enumValues: [...CANONICAL_SKC_WORKFLOW_SKILLS],
|
|
79
|
-
required: true,
|
|
80
|
-
appliesToVerbs: ["handoff"],
|
|
81
|
-
},
|
|
75
|
+
{ name: "to", type: "string", required: true, appliesToVerbs: ["handoff"] },
|
|
82
76
|
{ name: "replace", type: "boolean", appliesToVerbs: ["write"] },
|
|
83
77
|
{ name: "force", type: "boolean", appliesToVerbs: ["write", "clear", "handoff"] },
|
|
84
78
|
{ name: "skill", type: "enum", enumValues: [...CANONICAL_SKC_WORKFLOW_SKILLS], appliesToVerbs: ["doctor"] },
|