@oh-my-pi/pi-coding-agent 16.1.16 → 16.1.17
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 +30 -0
- package/dist/cli.js +2749 -2726
- package/dist/types/auto-thinking/classifier.d.ts +4 -2
- package/dist/types/config/model-discovery.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +4 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +22 -7
- package/dist/types/extensibility/plugins/loader.d.ts +16 -9
- package/dist/types/extensibility/tool-event-input.d.ts +2 -0
- package/dist/types/hindsight/content.d.ts +2 -10
- package/dist/types/modes/components/chat-transcript-builder.d.ts +2 -0
- package/dist/types/modes/components/custom-editor.d.ts +3 -0
- package/dist/types/modes/components/status-line/types.d.ts +1 -0
- package/dist/types/modes/controllers/command-controller.d.ts +2 -0
- package/dist/types/modes/controllers/input-controller.d.ts +14 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +11 -0
- package/dist/types/session/agent-session.d.ts +6 -6
- package/dist/types/session/provider-image-budget.d.ts +3 -0
- package/dist/types/session/session-context.d.ts +6 -5
- package/dist/types/task/parallel.d.ts +4 -0
- package/dist/types/thinking.d.ts +8 -1
- package/dist/types/tiny/title-client.d.ts +2 -2
- package/dist/types/utils/tools-manager.d.ts +2 -0
- package/package.json +12 -12
- package/src/auto-thinking/classifier.ts +7 -2
- package/src/cli/profile-alias.ts +38 -7
- package/src/cli/usage-cli.ts +5 -1
- package/src/config/model-discovery.ts +59 -8
- package/src/config/model-registry.ts +74 -3
- package/src/discovery/omp-extension-roots.ts +1 -3
- package/src/extensibility/extensions/wrapper.ts +3 -2
- package/src/extensibility/hooks/tool-wrapper.ts +4 -3
- package/src/extensibility/plugins/legacy-pi-compat.ts +40 -8
- package/src/extensibility/plugins/loader.ts +71 -23
- package/src/extensibility/plugins/marketplace/manager.ts +134 -0
- package/src/extensibility/tool-event-input.ts +57 -0
- package/src/hindsight/content.ts +21 -12
- package/src/mcp/tool-bridge.ts +27 -2
- package/src/mnemopi/state.ts +5 -2
- package/src/modes/components/agent-transcript-viewer.ts +193 -41
- package/src/modes/components/chat-transcript-builder.ts +6 -0
- package/src/modes/components/custom-editor.test.ts +18 -1
- package/src/modes/components/custom-editor.ts +77 -45
- package/src/modes/components/hook-editor.ts +15 -2
- package/src/modes/components/settings-selector.ts +2 -2
- package/src/modes/components/status-line/component.ts +52 -8
- package/src/modes/components/status-line/segments.ts +5 -1
- package/src/modes/components/status-line/types.ts +1 -0
- package/src/modes/components/welcome.ts +12 -14
- package/src/modes/controllers/command-controller.ts +16 -5
- package/src/modes/controllers/input-controller.ts +115 -3
- package/src/modes/controllers/selector-controller.ts +19 -1
- package/src/modes/interactive-mode.ts +3 -3
- package/src/modes/utils/ui-helpers.ts +3 -3
- package/src/sdk.ts +8 -10
- package/src/session/agent-session.ts +193 -49
- package/src/session/provider-image-budget.ts +86 -0
- package/src/session/session-context.ts +14 -7
- package/src/session/session-storage.ts +24 -2
- package/src/session/snapcompact-inline.ts +19 -3
- package/src/slash-commands/builtin-registry.ts +0 -22
- package/src/slash-commands/helpers/usage-report.ts +9 -1
- package/src/task/parallel.ts +6 -1
- package/src/thinking.ts +9 -2
- package/src/tiny/title-client.ts +75 -21
- package/src/utils/tools-manager.ts +67 -10
package/src/hindsight/content.ts
CHANGED
|
@@ -189,6 +189,22 @@ export interface RetentionTranscript {
|
|
|
189
189
|
* Messages are tag-stripped before framing to break the recall→retain loop.
|
|
190
190
|
* Returns `{ transcript: null }` when nothing meaningful survives.
|
|
191
191
|
*/
|
|
192
|
+
function formatRetentionMessages(messages: HindsightMessage[]): RetentionTranscript {
|
|
193
|
+
const parts: string[] = [];
|
|
194
|
+
for (const msg of messages) {
|
|
195
|
+
const content = stripMemoryTags(msg.content).trim();
|
|
196
|
+
if (!hasSubstantiveContent(content)) continue;
|
|
197
|
+
parts.push(`[role: ${msg.role}]\n${content}\n[${msg.role}:end]`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (parts.length === 0) return { transcript: null, messageCount: 0 };
|
|
201
|
+
|
|
202
|
+
const transcript = parts.join("\n\n");
|
|
203
|
+
if (transcript.trim().length < 10) return { transcript: null, messageCount: 0 };
|
|
204
|
+
|
|
205
|
+
return { transcript, messageCount: parts.length };
|
|
206
|
+
}
|
|
207
|
+
|
|
192
208
|
export function prepareRetentionTranscript(
|
|
193
209
|
messages: HindsightMessage[],
|
|
194
210
|
retainFullWindow = false,
|
|
@@ -210,17 +226,10 @@ export function prepareRetentionTranscript(
|
|
|
210
226
|
targetMessages = messages.slice(lastUserIdx);
|
|
211
227
|
}
|
|
212
228
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const content = stripMemoryTags(msg.content).trim();
|
|
216
|
-
if (!hasSubstantiveContent(content)) continue;
|
|
217
|
-
parts.push(`[role: ${msg.role}]\n${content}\n[${msg.role}:end]`);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
if (parts.length === 0) return { transcript: null, messageCount: 0 };
|
|
221
|
-
|
|
222
|
-
const transcript = parts.join("\n\n");
|
|
223
|
-
if (transcript.trim().length < 10) return { transcript: null, messageCount: 0 };
|
|
229
|
+
return formatRetentionMessages(targetMessages);
|
|
230
|
+
}
|
|
224
231
|
|
|
225
|
-
|
|
232
|
+
/** Format only user-authored messages for memory fact/entity extraction. */
|
|
233
|
+
export function prepareUserRetentionTranscript(messages: HindsightMessage[]): RetentionTranscript {
|
|
234
|
+
return formatRetentionMessages(messages.filter(message => message.role === "user"));
|
|
226
235
|
}
|
package/src/mcp/tool-bridge.ts
CHANGED
|
@@ -58,6 +58,31 @@ function normalizeToolArgs(value: unknown): MCPToolArgs {
|
|
|
58
58
|
return value as MCPToolArgs;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
function isUnusedOptionalPlaceholder(value: unknown): boolean {
|
|
62
|
+
return (
|
|
63
|
+
value === undefined ||
|
|
64
|
+
value === "" ||
|
|
65
|
+
(typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length === 0)
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function omitUnusedOptionalArgs(args: MCPToolArgs, inputSchema: MCPToolDefinition["inputSchema"]): MCPToolArgs {
|
|
70
|
+
const properties = inputSchema.properties;
|
|
71
|
+
if (!properties) return args;
|
|
72
|
+
|
|
73
|
+
let cleaned: MCPToolArgs | undefined;
|
|
74
|
+
const required = new Set(inputSchema.required ?? []);
|
|
75
|
+
for (const [key, value] of Object.entries(args)) {
|
|
76
|
+
if (required.has(key) || !Object.hasOwn(properties, key) || !isUnusedOptionalPlaceholder(value)) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
cleaned ??= { ...args };
|
|
80
|
+
delete cleaned[key];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return cleaned ?? args;
|
|
84
|
+
}
|
|
85
|
+
|
|
61
86
|
/** Details included in MCP tool results for rendering */
|
|
62
87
|
export interface MCPToolDetails {
|
|
63
88
|
/** Server name */
|
|
@@ -261,7 +286,7 @@ export class MCPTool implements CustomTool<TSchema, MCPToolDetails> {
|
|
|
261
286
|
signal?: AbortSignal,
|
|
262
287
|
): Promise<CustomToolResult<MCPToolDetails>> {
|
|
263
288
|
throwIfAborted(signal);
|
|
264
|
-
const args = normalizeToolArgs(params);
|
|
289
|
+
const args = omitUnusedOptionalArgs(normalizeToolArgs(params), this.tool.inputSchema);
|
|
265
290
|
const provider = this.connection._source?.provider;
|
|
266
291
|
const providerName = this.connection._source?.providerName;
|
|
267
292
|
|
|
@@ -360,7 +385,7 @@ export class DeferredMCPTool implements CustomTool<TSchema, MCPToolDetails> {
|
|
|
360
385
|
signal?: AbortSignal,
|
|
361
386
|
): Promise<CustomToolResult<MCPToolDetails>> {
|
|
362
387
|
throwIfAborted(signal);
|
|
363
|
-
const args = normalizeToolArgs(params);
|
|
388
|
+
const args = omitUnusedOptionalArgs(normalizeToolArgs(params), this.tool.inputSchema);
|
|
364
389
|
const provider = this.#fallbackProvider;
|
|
365
390
|
const providerName = this.#fallbackProviderName;
|
|
366
391
|
|
package/src/mnemopi/state.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
composeRecallQuery,
|
|
10
10
|
formatCurrentTime,
|
|
11
11
|
prepareRetentionTranscript,
|
|
12
|
+
prepareUserRetentionTranscript,
|
|
12
13
|
truncateRecallQuery,
|
|
13
14
|
} from "../hindsight/content";
|
|
14
15
|
import { extractMessages } from "../hindsight/transcript";
|
|
@@ -352,6 +353,7 @@ export class MnemopiSessionState {
|
|
|
352
353
|
async retainMessages(messages: Array<{ role: string; content: string }>, sourceId: string): Promise<void> {
|
|
353
354
|
const { transcript, messageCount } = prepareRetentionTranscript(messages, true);
|
|
354
355
|
if (!transcript) return;
|
|
356
|
+
const { transcript: extractText } = prepareUserRetentionTranscript(messages);
|
|
355
357
|
this.rememberInScope(transcript, {
|
|
356
358
|
source: "coding-agent-transcript",
|
|
357
359
|
importance: 0.65,
|
|
@@ -362,8 +364,9 @@ export class MnemopiSessionState {
|
|
|
362
364
|
cwd: this.session.sessionManager.getCwd(),
|
|
363
365
|
},
|
|
364
366
|
scope: "bank",
|
|
365
|
-
extract:
|
|
366
|
-
extractEntities:
|
|
367
|
+
extract: extractText !== null,
|
|
368
|
+
extractEntities: extractText !== null,
|
|
369
|
+
extractText,
|
|
367
370
|
veracity: "unknown",
|
|
368
371
|
memoryType: "episode",
|
|
369
372
|
});
|
|
@@ -7,16 +7,11 @@
|
|
|
7
7
|
* compositing into the live transcript's scrollback. It renders a parked
|
|
8
8
|
* subagent / advisor / collab-guest transcript that has no live in-view session.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* Local agents re-read the whole session file whenever its size or mtime changes
|
|
17
|
-
* (covering SessionManager's in-place rewrites, not just appends). Collab guests
|
|
18
|
-
* keep the incremental byte cursor the host's capped `readTranscript` requires
|
|
19
|
-
* and rebuild components from the accumulated entries.
|
|
10
|
+
* Local transcripts tail append-only growth: unchanged file identity plus stable
|
|
11
|
+
* sentinels means only newly appended JSONL is parsed and rendered. Rewrites,
|
|
12
|
+
* truncation, rotation, or sentinel drift fall back to a full rebuild so changed
|
|
13
|
+
* historical entries cannot leave stale components behind. Collab guests use the
|
|
14
|
+
* same append path over the host's byte-capped transcript reads.
|
|
20
15
|
*/
|
|
21
16
|
import * as fs from "node:fs";
|
|
22
17
|
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
|
@@ -64,6 +59,56 @@ export interface AgentTranscriptViewerDeps {
|
|
|
64
59
|
/** How often to re-stat a file-backed transcript for growth (advisor/live tail). */
|
|
65
60
|
const POLL_MS = 250;
|
|
66
61
|
|
|
62
|
+
const SENTINEL_BYTES = 4096;
|
|
63
|
+
|
|
64
|
+
interface LocalTranscriptSentinel {
|
|
65
|
+
offset: number;
|
|
66
|
+
bytes: Buffer;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface LocalTranscriptState {
|
|
70
|
+
path: string;
|
|
71
|
+
dev: number;
|
|
72
|
+
ino: number;
|
|
73
|
+
size: number;
|
|
74
|
+
mtimeMs: number;
|
|
75
|
+
offset: number;
|
|
76
|
+
pending: string;
|
|
77
|
+
sentinels: LocalTranscriptSentinel[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function readFileRangeSync(file: string, offset: number, length: number): Buffer {
|
|
81
|
+
if (length <= 0) return Buffer.alloc(0);
|
|
82
|
+
const fd = fs.openSync(file, "r");
|
|
83
|
+
try {
|
|
84
|
+
const buffer = Buffer.alloc(length);
|
|
85
|
+
const bytesRead = fs.readSync(fd, buffer, 0, length, offset);
|
|
86
|
+
return bytesRead === length ? buffer : buffer.subarray(0, bytesRead);
|
|
87
|
+
} finally {
|
|
88
|
+
fs.closeSync(fd);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function sentinelOffsets(size: number): number[] {
|
|
93
|
+
if (size <= 0) return [];
|
|
94
|
+
const length = Math.min(SENTINEL_BYTES, size);
|
|
95
|
+
return [...new Set([0, Math.max(0, Math.floor((size - length) / 2)), Math.max(0, size - length)])];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function sentinelsFromBuffer(buffer: Buffer): LocalTranscriptSentinel[] {
|
|
99
|
+
const size = buffer.byteLength;
|
|
100
|
+
const length = Math.min(SENTINEL_BYTES, size);
|
|
101
|
+
return sentinelOffsets(size).map(offset => ({
|
|
102
|
+
offset,
|
|
103
|
+
bytes: Buffer.from(buffer.subarray(offset, offset + length)),
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function sentinelsFromFile(file: string, size: number): LocalTranscriptSentinel[] {
|
|
108
|
+
const length = Math.min(SENTINEL_BYTES, size);
|
|
109
|
+
return sentinelOffsets(size).map(offset => ({ offset, bytes: readFileRangeSync(file, offset, length) }));
|
|
110
|
+
}
|
|
111
|
+
|
|
67
112
|
function statusBadge(status: AgentStatus): string {
|
|
68
113
|
switch (status) {
|
|
69
114
|
case "running":
|
|
@@ -85,10 +130,9 @@ export class AgentTranscriptViewer implements Component {
|
|
|
85
130
|
#notice: string | undefined;
|
|
86
131
|
#expanded = false;
|
|
87
132
|
|
|
88
|
-
|
|
89
|
-
#
|
|
133
|
+
#localState: LocalTranscriptState | undefined;
|
|
134
|
+
#localUnavailable = "";
|
|
90
135
|
// Remote transcript state (incremental; the host caps each read).
|
|
91
|
-
#remoteEntries: SessionMessageEntry[] = [];
|
|
92
136
|
#remoteBytes = 0;
|
|
93
137
|
#remoteFetchInFlight = false;
|
|
94
138
|
#remoteToken = 0;
|
|
@@ -145,7 +189,7 @@ export class AgentTranscriptViewer implements Component {
|
|
|
145
189
|
// Transcript loading
|
|
146
190
|
// ========================================================================
|
|
147
191
|
|
|
148
|
-
/**
|
|
192
|
+
/** Refresh the transcript from a local file or remote host. */
|
|
149
193
|
#refresh(): void {
|
|
150
194
|
if (this.#disposed) return;
|
|
151
195
|
if (this.deps.remote) {
|
|
@@ -154,39 +198,132 @@ export class AgentTranscriptViewer implements Component {
|
|
|
154
198
|
}
|
|
155
199
|
const sessionFile = this.deps.registry.get(this.deps.agentId)?.sessionFile;
|
|
156
200
|
if (!sessionFile) {
|
|
157
|
-
|
|
158
|
-
this.#lastSignature = "none";
|
|
159
|
-
this.#rebuild([]);
|
|
160
|
-
}
|
|
201
|
+
this.#clearLocal("none");
|
|
161
202
|
return;
|
|
162
203
|
}
|
|
163
|
-
let
|
|
204
|
+
let stat: fs.Stats;
|
|
164
205
|
try {
|
|
165
|
-
|
|
166
|
-
// Include the path: a different file with the same size/mtime must not alias.
|
|
167
|
-
signature = `${sessionFile}:${stat.size}:${stat.mtimeMs}`;
|
|
206
|
+
stat = fs.statSync(sessionFile);
|
|
168
207
|
} catch {
|
|
169
|
-
|
|
170
|
-
// clear stale content once instead of freezing on it forever.
|
|
171
|
-
if (this.#lastSignature !== "missing") {
|
|
172
|
-
this.#lastSignature = "missing";
|
|
173
|
-
this.#model = undefined;
|
|
174
|
-
this.#rebuild([]);
|
|
175
|
-
}
|
|
208
|
+
this.#clearLocal("missing");
|
|
176
209
|
return;
|
|
177
210
|
}
|
|
178
|
-
|
|
179
|
-
|
|
211
|
+
const state = this.#localState;
|
|
212
|
+
if (state && this.#canAppendLocal(sessionFile, stat, state)) {
|
|
213
|
+
if (stat.size === state.size && stat.mtimeMs === state.mtimeMs) return;
|
|
214
|
+
if (stat.size > state.size) {
|
|
215
|
+
this.#appendLocal(sessionFile, stat, state);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
this.#loadLocalFull(sessionFile, stat);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
#clearLocal(reason: string): void {
|
|
223
|
+
if (!this.#localState && this.#localUnavailable === reason) return;
|
|
224
|
+
this.#localState = undefined;
|
|
225
|
+
this.#localUnavailable = reason;
|
|
226
|
+
this.#model = undefined;
|
|
227
|
+
this.#rebuild([]);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
#canAppendLocal(sessionFile: string, stat: fs.Stats, state: LocalTranscriptState): boolean {
|
|
231
|
+
if (state.path !== sessionFile || state.dev !== stat.dev || state.ino !== stat.ino || stat.size < state.size)
|
|
232
|
+
return false;
|
|
233
|
+
for (const sentinel of state.sentinels) {
|
|
234
|
+
let current: Buffer;
|
|
235
|
+
try {
|
|
236
|
+
current = readFileRangeSync(sessionFile, sentinel.offset, sentinel.bytes.byteLength);
|
|
237
|
+
} catch (err) {
|
|
238
|
+
// The file can be unlinked/rotated between statSync and this read.
|
|
239
|
+
// Treat as not-appendable so #refresh falls back to a guarded full load.
|
|
240
|
+
logger.debug("transcript viewer: sentinel read failed", { err: String(err) });
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
if (!current.equals(sentinel.bytes)) return false;
|
|
244
|
+
}
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
#loadLocalFull(sessionFile: string, stat: fs.Stats): void {
|
|
249
|
+
let data: Buffer;
|
|
180
250
|
try {
|
|
181
|
-
|
|
251
|
+
data = fs.readFileSync(sessionFile);
|
|
182
252
|
} catch (err) {
|
|
183
|
-
// Leave #
|
|
253
|
+
// Leave #localState unchanged so a transient read error retries next poll.
|
|
184
254
|
logger.debug("transcript viewer: read failed", { err: String(err) });
|
|
185
255
|
return;
|
|
186
256
|
}
|
|
187
|
-
this
|
|
257
|
+
// The file may have grown between the earlier `statSync` and this read.
|
|
258
|
+
// Anchor the tail cursor to what we actually consumed so the next poll's
|
|
259
|
+
// `#appendLocal` never re-renders bytes already in the rebuilt transcript;
|
|
260
|
+
// re-stat for mtime/identity so the post-read clock matches what's on disk.
|
|
261
|
+
let post: fs.Stats;
|
|
262
|
+
try {
|
|
263
|
+
post = fs.statSync(sessionFile);
|
|
264
|
+
} catch {
|
|
265
|
+
post = stat;
|
|
266
|
+
}
|
|
267
|
+
// A reader that opens the file mid-append sees a trailing partial line
|
|
268
|
+
// (no terminating newline). Carry those bytes as `pending` so the next
|
|
269
|
+
// poll's `#appendLocal` joins them with the completion bytes instead of
|
|
270
|
+
// parsing a headless line fragment and dropping the entry.
|
|
271
|
+
const text = data.toString("utf-8");
|
|
272
|
+
const lastNewline = text.lastIndexOf("\n");
|
|
273
|
+
const complete = lastNewline >= 0 ? text.slice(0, lastNewline + 1) : "";
|
|
274
|
+
const pending = lastNewline >= 0 ? text.slice(lastNewline + 1) : text;
|
|
275
|
+
this.#localUnavailable = "";
|
|
276
|
+
this.#localState = {
|
|
277
|
+
path: sessionFile,
|
|
278
|
+
dev: post.dev,
|
|
279
|
+
ino: post.ino,
|
|
280
|
+
size: data.byteLength,
|
|
281
|
+
mtimeMs: post.mtimeMs,
|
|
282
|
+
offset: data.byteLength,
|
|
283
|
+
pending,
|
|
284
|
+
sentinels: sentinelsFromBuffer(data),
|
|
285
|
+
};
|
|
188
286
|
this.#model = undefined;
|
|
189
|
-
this.#rebuild(this.#extractMessages(parseSessionEntries(
|
|
287
|
+
this.#rebuild(this.#extractMessages(parseSessionEntries(complete)));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
#appendLocal(sessionFile: string, stat: fs.Stats, state: LocalTranscriptState): void {
|
|
291
|
+
let chunk: string;
|
|
292
|
+
try {
|
|
293
|
+
chunk = readFileRangeSync(sessionFile, state.offset, stat.size - state.offset).toString("utf-8");
|
|
294
|
+
} catch (err) {
|
|
295
|
+
logger.debug("transcript viewer: tail read failed", { err: String(err) });
|
|
296
|
+
this.#loadLocalFull(sessionFile, stat);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const combined = state.pending + chunk;
|
|
300
|
+
const lastNewline = combined.lastIndexOf("\n");
|
|
301
|
+
const complete = lastNewline >= 0 ? combined.slice(0, lastNewline + 1) : "";
|
|
302
|
+
const previousModel = this.#model;
|
|
303
|
+
const parsed = complete ? this.#extractMessages(parseSessionEntries(complete)) : [];
|
|
304
|
+
let sentinels: LocalTranscriptSentinel[];
|
|
305
|
+
try {
|
|
306
|
+
sentinels = sentinelsFromFile(sessionFile, stat.size);
|
|
307
|
+
} catch (err) {
|
|
308
|
+
// File unlinked/rotated mid-poll: fall back to a guarded full reload
|
|
309
|
+
// instead of letting the open escape the poll timer.
|
|
310
|
+
logger.debug("transcript viewer: sentinel recompute failed", { err: String(err) });
|
|
311
|
+
this.#loadLocalFull(sessionFile, stat);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
this.#localState = {
|
|
315
|
+
...state,
|
|
316
|
+
size: stat.size,
|
|
317
|
+
mtimeMs: stat.mtimeMs,
|
|
318
|
+
offset: stat.size,
|
|
319
|
+
pending: lastNewline >= 0 ? combined.slice(lastNewline + 1) : combined,
|
|
320
|
+
sentinels,
|
|
321
|
+
};
|
|
322
|
+
if (parsed.length > 0) {
|
|
323
|
+
this.#append(parsed);
|
|
324
|
+
} else if (this.#model !== previousModel) {
|
|
325
|
+
this.deps.requestRender();
|
|
326
|
+
}
|
|
190
327
|
}
|
|
191
328
|
|
|
192
329
|
#fetchRemote(): void {
|
|
@@ -209,9 +346,13 @@ export class AgentTranscriptViewer implements Component {
|
|
|
209
346
|
return;
|
|
210
347
|
}
|
|
211
348
|
if (result.newSize < fromByte) {
|
|
212
|
-
// Host transcript rotated/truncated —
|
|
349
|
+
// Host transcript rotated/truncated — drop the stale rendered rows
|
|
350
|
+
// before restarting; otherwise the post-rotation fetch would stack
|
|
351
|
+
// new content under the pre-rotation history.
|
|
213
352
|
this.#remoteBytes = 0;
|
|
214
|
-
this.#
|
|
353
|
+
this.#hasRemoteData = false;
|
|
354
|
+
this.#model = undefined;
|
|
355
|
+
this.#rebuild([]);
|
|
215
356
|
this.#fetchRemote();
|
|
216
357
|
return;
|
|
217
358
|
}
|
|
@@ -222,10 +363,14 @@ export class AgentTranscriptViewer implements Component {
|
|
|
222
363
|
if (lastNewline >= 0) {
|
|
223
364
|
const completeChunk = result.text.slice(0, lastNewline + 1);
|
|
224
365
|
this.#remoteBytes = fromByte + Buffer.byteLength(completeChunk, "utf-8");
|
|
366
|
+
const previousModel = this.#model;
|
|
225
367
|
const parsed = this.#extractMessages(parseSessionEntries(completeChunk));
|
|
226
368
|
if (parsed.length > 0) {
|
|
227
|
-
this.#
|
|
228
|
-
|
|
369
|
+
this.#append(parsed);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (this.#model !== previousModel) {
|
|
373
|
+
this.deps.requestRender();
|
|
229
374
|
return;
|
|
230
375
|
}
|
|
231
376
|
}
|
|
@@ -257,6 +402,11 @@ export class AgentTranscriptViewer implements Component {
|
|
|
257
402
|
this.deps.requestRender();
|
|
258
403
|
}
|
|
259
404
|
|
|
405
|
+
#append(entries: SessionMessageEntry[]): void {
|
|
406
|
+
this.#builder.append(entries);
|
|
407
|
+
this.deps.requestRender();
|
|
408
|
+
}
|
|
409
|
+
|
|
260
410
|
// ========================================================================
|
|
261
411
|
// Input
|
|
262
412
|
// ========================================================================
|
|
@@ -455,8 +605,10 @@ export class AgentTranscriptViewer implements Component {
|
|
|
455
605
|
}
|
|
456
606
|
|
|
457
607
|
#placeholder(): string {
|
|
458
|
-
if (this.deps.remote
|
|
459
|
-
|
|
608
|
+
if (this.deps.remote) {
|
|
609
|
+
if (this.#remoteUnavailable) return "Transcript lives on the host — not available.";
|
|
610
|
+
return this.#hasRemoteData ? "No messages yet." : "Loading transcript from host…";
|
|
611
|
+
}
|
|
460
612
|
if (!this.deps.registry.get(this.deps.agentId)?.sessionFile) return "No session file available yet.";
|
|
461
613
|
return "No messages yet.";
|
|
462
614
|
}
|
|
@@ -103,6 +103,12 @@ export class ChatTranscriptBuilder {
|
|
|
103
103
|
if (this.#readArgs.size === 0 && this.#pendingTools.size === 0) this.#flushPendingUsage();
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/** Append newly persisted entries without rebuilding already rendered rows. */
|
|
107
|
+
append(entries: SessionMessageEntry[]): void {
|
|
108
|
+
for (const entry of entries) this.#appendChatMessage(entry.message);
|
|
109
|
+
if (this.#readArgs.size === 0 && this.#pendingTools.size === 0) this.#flushPendingUsage();
|
|
110
|
+
}
|
|
111
|
+
|
|
106
112
|
/** Toggle tool-output expansion across every expandable component. */
|
|
107
113
|
setExpanded(expanded: boolean): void {
|
|
108
114
|
this.#expanded = expanded;
|
|
@@ -4,6 +4,7 @@ import { getEditorTheme, initTheme } from "../theme/theme";
|
|
|
4
4
|
import {
|
|
5
5
|
CustomEditor,
|
|
6
6
|
extractBracketedImagePastePaths,
|
|
7
|
+
extractBracketedPastePaths,
|
|
7
8
|
SPACE_HOLD_MECHANICAL_RUN,
|
|
8
9
|
SPACE_HOLD_RELEASE_MS,
|
|
9
10
|
SPACE_REPEAT_MAX_GAP_MS,
|
|
@@ -73,7 +74,7 @@ describe("CustomEditor placeholder decoration", () => {
|
|
|
73
74
|
});
|
|
74
75
|
});
|
|
75
76
|
|
|
76
|
-
describe("CustomEditor bracketed
|
|
77
|
+
describe("CustomEditor bracketed path paste", () => {
|
|
77
78
|
it("leaves a pasted bare .png filename on the normal text path", () => {
|
|
78
79
|
expect(extractBracketedImagePastePaths(bracketedPaste("icon-photo-default.png"))).toBeUndefined();
|
|
79
80
|
});
|
|
@@ -86,6 +87,22 @@ describe("CustomEditor bracketed image-path paste", () => {
|
|
|
86
87
|
"C:\\Users\\me\\icon-photo-default.png",
|
|
87
88
|
]);
|
|
88
89
|
});
|
|
90
|
+
|
|
91
|
+
it("extracts explicit non-image paths without classifying them as image paths", () => {
|
|
92
|
+
expect(extractBracketedPastePaths(bracketedPaste("/tmp/report.csv"))).toEqual(["/tmp/report.csv"]);
|
|
93
|
+
expect(extractBracketedImagePastePaths(bracketedPaste("/tmp/report.csv"))).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("routes non-image path pastes through the file-path hook", async () => {
|
|
97
|
+
const { editor } = makeEditor();
|
|
98
|
+
const pasted = Promise.withResolvers<string>();
|
|
99
|
+
editor.onPasteFilePath = path => pasted.resolve(path);
|
|
100
|
+
|
|
101
|
+
editor.handleInput(bracketedPaste("/tmp/report.csv"));
|
|
102
|
+
|
|
103
|
+
expect(await pasted.promise).toBe("/tmp/report.csv");
|
|
104
|
+
expect(editor.getText()).toBe("");
|
|
105
|
+
});
|
|
89
106
|
});
|
|
90
107
|
|
|
91
108
|
describe("CustomEditor space-hold push-to-talk", () => {
|
|
@@ -62,7 +62,6 @@ function buildMatchKeys(keys: readonly KeyId[]): Set<string> {
|
|
|
62
62
|
const BRACKETED_PASTE_START = "\x1b[200~";
|
|
63
63
|
const BRACKETED_PASTE_END = "\x1b[201~";
|
|
64
64
|
const BRACKETED_IMAGE_PATH_REGEX = /\.(?:png|jpe?g|gif|webp)$/i;
|
|
65
|
-
const BRACKETED_IMAGE_PATH_BOUNDARY_REGEX = /\.(?:png|jpe?g|gif|webp)(?=$|["']?\s)/gi;
|
|
66
65
|
const SHELL_ESCAPED_PATH_CHAR_REGEX = /\\([\\\s'"()[\]{}&;<>|?*!$`])/g;
|
|
67
66
|
const URI_SCHEME_REGEX = /^[a-z][a-z0-9+.-]*:/i;
|
|
68
67
|
const FILE_URI_REGEX = /^file:\/\//i;
|
|
@@ -100,19 +99,7 @@ function isPastedPathSeparator(char: string | undefined): boolean {
|
|
|
100
99
|
return char === undefined || char === " " || char === "\t" || char === "\r" || char === "\n";
|
|
101
100
|
}
|
|
102
101
|
|
|
103
|
-
function
|
|
104
|
-
const quote = payload[segmentStart];
|
|
105
|
-
const afterExtension = payload[extensionEnd];
|
|
106
|
-
if (quote === '"' || quote === "'") {
|
|
107
|
-
return afterExtension === quote && isPastedPathSeparator(payload[extensionEnd + 1])
|
|
108
|
-
? extensionEnd + 1
|
|
109
|
-
: undefined;
|
|
110
|
-
}
|
|
111
|
-
if (isPastedPathSeparator(afterExtension)) return extensionEnd;
|
|
112
|
-
return undefined;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function normalizePastedImagePath(path: string): string {
|
|
102
|
+
function normalizePastedPath(path: string): string {
|
|
116
103
|
const trimmed = path.trim();
|
|
117
104
|
const first = trimmed[0];
|
|
118
105
|
const last = trimmed[trimmed.length - 1];
|
|
@@ -121,13 +108,60 @@ function normalizePastedImagePath(path: string): string {
|
|
|
121
108
|
return unquoted.replace(SHELL_ESCAPED_PATH_CHAR_REGEX, "$1");
|
|
122
109
|
}
|
|
123
110
|
|
|
124
|
-
function
|
|
111
|
+
function isExplicitPastedPath(path: string): boolean {
|
|
125
112
|
if (WINDOWS_DRIVE_PATH_REGEX.test(path) || FILE_URI_REGEX.test(path)) return true;
|
|
126
113
|
if (URI_SCHEME_REGEX.test(path)) return false;
|
|
127
114
|
return path.includes("/") || path.includes("\\");
|
|
128
115
|
}
|
|
129
116
|
|
|
130
|
-
|
|
117
|
+
function isImagePath(path: string): boolean {
|
|
118
|
+
return BRACKETED_IMAGE_PATH_REGEX.test(path);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function splitPastedPathSegments(payload: string): string[] | undefined {
|
|
122
|
+
const segments: string[] = [];
|
|
123
|
+
let segment = "";
|
|
124
|
+
let quote: string | undefined;
|
|
125
|
+
let escaped = false;
|
|
126
|
+
|
|
127
|
+
for (let i = 0; i < payload.length; i++) {
|
|
128
|
+
const char = payload[i];
|
|
129
|
+
if (escaped) {
|
|
130
|
+
segment += char;
|
|
131
|
+
escaped = false;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (char === "\\") {
|
|
135
|
+
segment += char;
|
|
136
|
+
escaped = true;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (quote) {
|
|
140
|
+
segment += char;
|
|
141
|
+
if (char === quote) quote = undefined;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (char === '"' || char === "'") {
|
|
145
|
+
segment += char;
|
|
146
|
+
quote = char;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (isPastedPathSeparator(char)) {
|
|
150
|
+
if (segment) {
|
|
151
|
+
segments.push(segment);
|
|
152
|
+
segment = "";
|
|
153
|
+
}
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
segment += char;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (escaped || quote) return undefined;
|
|
160
|
+
if (segment) segments.push(segment);
|
|
161
|
+
return segments.length > 0 ? segments : undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function extractBracketedPastePaths(data: string): string[] | undefined {
|
|
131
165
|
if (!data.startsWith(BRACKETED_PASTE_START)) return undefined;
|
|
132
166
|
const endIndex = data.indexOf(BRACKETED_PASTE_END, BRACKETED_PASTE_START.length);
|
|
133
167
|
if (endIndex === -1 || endIndex + BRACKETED_PASTE_END.length !== data.length) return undefined;
|
|
@@ -135,33 +169,23 @@ export function extractBracketedImagePastePaths(data: string): string[] | undefi
|
|
|
135
169
|
const pasted = data.slice(BRACKETED_PASTE_START.length, endIndex).trim();
|
|
136
170
|
if (!pasted) return undefined;
|
|
137
171
|
|
|
172
|
+
const segments = splitPastedPathSegments(pasted);
|
|
173
|
+
if (!segments) return undefined;
|
|
174
|
+
|
|
138
175
|
const paths: string[] = [];
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
let match = BRACKETED_IMAGE_PATH_BOUNDARY_REGEX.exec(pasted);
|
|
143
|
-
match;
|
|
144
|
-
match = BRACKETED_IMAGE_PATH_BOUNDARY_REGEX.exec(pasted)
|
|
145
|
-
) {
|
|
146
|
-
const extensionEnd = match.index + match[0].length;
|
|
147
|
-
const boundaryEnd = imagePathBoundaryEnd(pasted, segmentStart, extensionEnd);
|
|
148
|
-
if (boundaryEnd === undefined) continue;
|
|
149
|
-
|
|
150
|
-
const path = normalizePastedImagePath(pasted.slice(segmentStart, boundaryEnd));
|
|
151
|
-
if (!path || !BRACKETED_IMAGE_PATH_REGEX.test(path) || !isExplicitPastedImagePath(path)) return undefined;
|
|
176
|
+
for (const segment of segments) {
|
|
177
|
+
const path = normalizePastedPath(segment);
|
|
178
|
+
if (!path || !isExplicitPastedPath(path)) return undefined;
|
|
152
179
|
paths.push(path);
|
|
153
|
-
|
|
154
|
-
segmentStart = boundaryEnd;
|
|
155
|
-
while (segmentStart < pasted.length && isPastedPathSeparator(pasted[segmentStart])) {
|
|
156
|
-
segmentStart++;
|
|
157
|
-
}
|
|
158
|
-
BRACKETED_IMAGE_PATH_BOUNDARY_REGEX.lastIndex = segmentStart;
|
|
159
180
|
}
|
|
160
|
-
|
|
161
|
-
if (paths.length === 0 || segmentStart !== pasted.length) return undefined;
|
|
162
181
|
return paths;
|
|
163
182
|
}
|
|
164
183
|
|
|
184
|
+
export function extractBracketedImagePastePaths(data: string): string[] | undefined {
|
|
185
|
+
const paths = extractBracketedPastePaths(data);
|
|
186
|
+
return paths?.every(isImagePath) ? paths : undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
165
189
|
export function extractBracketedImagePastePath(data: string): string | undefined {
|
|
166
190
|
const paths = extractBracketedImagePastePaths(data);
|
|
167
191
|
return paths?.length === 1 ? paths[0] : undefined;
|
|
@@ -294,6 +318,8 @@ export class CustomEditor extends Editor {
|
|
|
294
318
|
onPasteImage?: () => Promise<boolean>;
|
|
295
319
|
/** Called when a bracketed paste contains one or more image-file paths. */
|
|
296
320
|
onPasteImagePath?: (path: string) => void | Promise<void>;
|
|
321
|
+
/** Called when a bracketed paste contains one or more non-image file paths. */
|
|
322
|
+
onPasteFilePath?: (path: string) => void | Promise<void>;
|
|
297
323
|
/** Called when the configured raw text-paste shortcut is pressed. */
|
|
298
324
|
onPasteTextRaw?: () => void;
|
|
299
325
|
/** Called when the configured dequeue shortcut is pressed. */
|
|
@@ -479,14 +505,20 @@ export class CustomEditor extends Editor {
|
|
|
479
505
|
return;
|
|
480
506
|
}
|
|
481
507
|
|
|
482
|
-
const
|
|
483
|
-
if (
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
508
|
+
const pastedPaths = extractBracketedPastePaths(data);
|
|
509
|
+
if (pastedPaths) {
|
|
510
|
+
const canHandlePaths = pastedPaths.every(path =>
|
|
511
|
+
isImagePath(path) ? this.onPasteImagePath !== undefined : this.onPasteFilePath !== undefined,
|
|
512
|
+
);
|
|
513
|
+
if (canHandlePaths) {
|
|
514
|
+
void (async () => {
|
|
515
|
+
for (const path of pastedPaths) {
|
|
516
|
+
if (isImagePath(path)) await this.onPasteImagePath?.(path);
|
|
517
|
+
else await this.onPasteFilePath?.(path);
|
|
518
|
+
}
|
|
519
|
+
})();
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
490
522
|
}
|
|
491
523
|
|
|
492
524
|
const parsedKey = parseKey(data);
|