@hienlh/ppm 0.17.45 → 0.17.47
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 +28 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/cli-reference.md +4 -4
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/bun.lock +10 -10
- package/dist/web/assets/{ai-resource-editor-bKRP47RQ.js → ai-resource-editor-BywFZ8kq.js} +1 -1
- package/dist/web/assets/{audio-preview-DbvnEkRv.js → audio-preview-0l5TJLgd.js} +1 -1
- package/dist/web/assets/{chat-tab-B7M7IENk.js → chat-tab-DhE2XceO.js} +6 -6
- package/dist/web/assets/{code-editor-CyDEePNK.js → code-editor-BfuUP1De.js} +2 -2
- package/dist/web/assets/{conflict-editor-BDCVX1rJ.js → conflict-editor-B47yD0OS.js} +1 -1
- package/dist/web/assets/{csv-preview-DVkvCs_r.js → csv-preview-C7xm3SBD.js} +1 -1
- package/dist/web/assets/{database-viewer-DBhqh6Tz.js → database-viewer-C7IEdTpP.js} +1 -1
- package/dist/web/assets/{diff-viewer-Cz3oQoYm.js → diff-viewer-DRAU_SI7.js} +1 -1
- package/dist/web/assets/{docx-preview-Dyq1oJUo.js → docx-preview-BK7hQo6W.js} +1 -1
- package/dist/web/assets/{extension-webview-Cm1IeJIL.js → extension-webview-JK1Z7erV.js} +1 -1
- package/dist/web/assets/{git-log-panel-CLuZ_pOz.js → git-log-panel-D9Obmd3x.js} +1 -1
- package/dist/web/assets/{glide-data-grid-Br1FTMc6.js → glide-data-grid-cWxDaC9T.js} +1 -1
- package/dist/web/assets/{group-chat-tab-DammsouM.js → group-chat-tab-8lgr5lig.js} +1 -1
- package/dist/web/assets/{image-preview-CtOwlF9d.js → image-preview-BDJ5dspF.js} +1 -1
- package/dist/web/assets/index-6_hleyhK.css +2 -0
- package/dist/web/assets/{index-DD6twTiS.js → index-BN3MQIrY.js} +3 -3
- package/dist/web/assets/{keybindings-store-BzKxDV_N.js → keybindings-store-BohTqglo.js} +1 -1
- package/dist/web/assets/{markdown-renderer-MZwGAeBK.js → markdown-renderer-CzfDFcb9.js} +1 -1
- package/dist/web/assets/{markdown-renderer-CknfFAEU.js → markdown-renderer-DgfXiX1E.js} +1 -1
- package/dist/web/assets/{notification-store-CHFDRvsf.js → notification-store-D7YxkIs7.js} +1 -1
- package/dist/web/assets/{pdf-preview-DSitNXFC.js → pdf-preview-Bg5Df9YY.js} +1 -1
- package/dist/web/assets/{postgres-viewer-f91idZlH.js → postgres-viewer-C0ibwn7l.js} +1 -1
- package/dist/web/assets/{settings-tab-C-4pUp32.js → settings-tab-B0SUPLm8.js} +1 -1
- package/dist/web/assets/{sql-query-editor-BdS6aIkt.js → sql-query-editor-B14enbgV.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-BVBZkOPi.js → sqlite-viewer-DL3s7Xcm.js} +1 -1
- package/dist/web/assets/{system-monitor-tab-D9CDMKZJ.js → system-monitor-tab-Bw3Cy1dg.js} +1 -1
- package/dist/web/assets/{terminal-tab-Bij4L1XF.js → terminal-tab-Bz8t4LSC.js} +1 -1
- package/dist/web/assets/{tool-cards-Dg6f_lKl.js → tool-cards-DOrdvoz5.js} +2 -2
- package/dist/web/assets/{use-monaco-theme-PSDuGfjN.js → use-monaco-theme-I6u-7ZEM.js} +1 -1
- package/dist/web/assets/{video-preview-oW5BA0vt.js → video-preview-CI_4NIE7.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/package.json +3 -3
- package/packages/vscode-compat/node_modules/.bin/tsc.bunx +0 -0
- package/packages/vscode-compat/node_modules/.bin/tsc.exe +0 -0
- package/packages/vscode-compat/node_modules/.bin/tsserver.bunx +0 -0
- package/packages/vscode-compat/node_modules/.bin/tsserver.exe +0 -0
- package/scripts/check-ppm-dir-usage.sh +0 -0
- package/scripts/install.sh +0 -0
- package/scripts/release.sh +0 -0
- package/src/providers/claude-agent-sdk.ts +138 -4
- package/src/providers/image-limit-detection.ts +30 -0
- package/src/server/routes/accounts.ts +6 -2
- package/src/server/routes/chat.ts +32 -23
- package/src/server/routes/upgrade.ts +28 -12
- package/src/server/ws/chat.ts +108 -11
- package/src/services/account-selector.service.ts +98 -11
- package/src/services/claude-usage.service.ts +29 -3
- package/src/services/db.service.ts +46 -3
- package/src/services/subprocess-retention.ts +63 -0
- package/src/services/transcript-images-file.ts +144 -0
- package/src/services/transcript-images.ts +129 -66
- package/src/services/upgrade.service.ts +38 -0
- package/src/shared/turn-usage.ts +15 -1
- package/src/web/components/chat/chat-tab.tsx +3 -2
- package/src/web/components/chat/message-action-bar.tsx +0 -3
- package/src/web/components/chat/message-list.tsx +0 -1
- package/src/web/components/chat/session-debug-dialog.tsx +51 -13
- package/src/web/components/chat/turn-change-rollup.tsx +1 -3
- package/src/web/hooks/use-usage.ts +8 -3
- package/dist/web/assets/index-DxvtmNdJ.css +0 -2
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long a clientless session may keep its SDK subprocess alive.
|
|
3
|
+
*
|
|
4
|
+
* A disconnect is usually a tab refresh, a phone switching apps or a laptop sleeping, and the
|
|
5
|
+
* client is back within seconds. Killing the subprocess on the spot forces the next message
|
|
6
|
+
* down the resume path, which replays the whole transcript and pays cache-write rates for a
|
|
7
|
+
* prefix that was already cached — the reason the subprocess is kept at all.
|
|
8
|
+
*
|
|
9
|
+
* What bounds that generosity is not the disconnect but the prompt cache: once the cache
|
|
10
|
+
* behind the prefix has expired, the subprocess protects nothing and is only holding memory.
|
|
11
|
+
* These decisions live here, apart from the WebSocket wiring, so the timing they encode can
|
|
12
|
+
* be tested without standing up a session.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Anthropic's default prompt-cache lifetime. A prefix not re-sent within this window has to
|
|
17
|
+
* be written to the cache again, so a subprocess older than this is worth nothing.
|
|
18
|
+
*
|
|
19
|
+
* Named here rather than inlined because the retention window is *derived* from it — the two
|
|
20
|
+
* were previously equal by coincidence, which read as if the teardown delay were arbitrary.
|
|
21
|
+
*/
|
|
22
|
+
export const PROMPT_CACHE_TTL_MS = 5 * 60_000;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Milliseconds until a session's prompt cache lapses.
|
|
26
|
+
*
|
|
27
|
+
* Measured from the last completed turn, because that is when the cache was last written —
|
|
28
|
+
* not from the disconnect, which says nothing about the cache. Returns 0 once the window has
|
|
29
|
+
* already passed, meaning the subprocess can go immediately.
|
|
30
|
+
*/
|
|
31
|
+
export function cacheReleaseDelayMs(
|
|
32
|
+
lastTurnEndedAt: number | undefined,
|
|
33
|
+
now: number,
|
|
34
|
+
ttlMs: number = PROMPT_CACHE_TTL_MS,
|
|
35
|
+
): number {
|
|
36
|
+
// No completed turn means nothing has been cached, so there is nothing to protect.
|
|
37
|
+
if (lastTurnEndedAt == null) return 0;
|
|
38
|
+
return Math.max(0, lastTurnEndedAt + ttlMs - now);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface WarmIdleSession {
|
|
42
|
+
sessionId: string;
|
|
43
|
+
/** When the session's last client left. Absent is treated as "longest idle". */
|
|
44
|
+
idleSince?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Which held subprocesses to release when too many sessions are holding one.
|
|
49
|
+
*
|
|
50
|
+
* The grace period costs one Claude Code process per abandoned session, and PPM is built to
|
|
51
|
+
* be reachable from several devices, so abandoned sessions accumulate. Eviction takes the
|
|
52
|
+
* longest-idle sessions first: their caches are the closest to expiring, so their
|
|
53
|
+
* subprocesses are the ones worth the least.
|
|
54
|
+
*
|
|
55
|
+
* Returns session ids in eviction order; empty when the cap is not exceeded.
|
|
56
|
+
*/
|
|
57
|
+
export function selectWarmIdleEvictions(sessions: WarmIdleSession[], cap: number): string[] {
|
|
58
|
+
if (cap < 0 || sessions.length <= cap) return [];
|
|
59
|
+
const byIdleAscending = [...sessions].sort(
|
|
60
|
+
(a, b) => (a.idleSince ?? 0) - (b.idleSince ?? 0),
|
|
61
|
+
);
|
|
62
|
+
return byIdleAscending.slice(0, sessions.length - cap).map((s) => s.sessionId);
|
|
63
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { closeSync, openSync, readSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
auditImageLine,
|
|
4
|
+
emptyImageAudit,
|
|
5
|
+
stripImageLine,
|
|
6
|
+
type ImageScopeOpts,
|
|
7
|
+
type StripMode,
|
|
8
|
+
type TranscriptImageAudit,
|
|
9
|
+
} from "./transcript-images.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* File-level image auditing and removal that never holds a transcript in memory.
|
|
13
|
+
*
|
|
14
|
+
* The in-memory helpers take the whole transcript as one string, which forces callers to
|
|
15
|
+
* refuse anything large — and a transcript only becomes worth cleaning once it *is* large,
|
|
16
|
+
* so that cap turned the feature off exactly where it was needed. Reading a line at a time
|
|
17
|
+
* removes the cap and the memory spike together: peak usage is one record, not one file.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Copy `src` over `dest`, truncating it, without allocating either file in memory.
|
|
22
|
+
*
|
|
23
|
+
* `openSync(dest, "w")` truncates and writes through the existing inode, so a CLI holding
|
|
24
|
+
* the path open in append mode keeps writing to the file callers can still see.
|
|
25
|
+
*/
|
|
26
|
+
function copyOverInPlace(src: string, dest: string): void {
|
|
27
|
+
const buf = Buffer.allocUnsafe(1 << 20);
|
|
28
|
+
const from = openSync(src, "r");
|
|
29
|
+
const to = openSync(dest, "w");
|
|
30
|
+
try {
|
|
31
|
+
for (;;) {
|
|
32
|
+
const n = readSync(from, buf, 0, buf.length, null);
|
|
33
|
+
if (n === 0) break;
|
|
34
|
+
writeSync(to, buf, 0, n);
|
|
35
|
+
}
|
|
36
|
+
} finally {
|
|
37
|
+
closeSync(from);
|
|
38
|
+
closeSync(to);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Feed a file to a callback one newline-delimited record at a time.
|
|
44
|
+
*
|
|
45
|
+
* `terminated` says whether the record was followed by a newline in the source. A rewrite has
|
|
46
|
+
* to reproduce that exactly: the CLI appends to this file, and a transcript left without its
|
|
47
|
+
* final newline would have the next record glued onto the last one, corrupting both.
|
|
48
|
+
*/
|
|
49
|
+
async function forEachLine(
|
|
50
|
+
path: string,
|
|
51
|
+
onLine: (line: string, terminated: boolean) => void,
|
|
52
|
+
): Promise<void> {
|
|
53
|
+
const reader = Bun.file(path).stream().getReader();
|
|
54
|
+
const decoder = new TextDecoder();
|
|
55
|
+
let carry = "";
|
|
56
|
+
try {
|
|
57
|
+
for (;;) {
|
|
58
|
+
const { done, value } = await reader.read();
|
|
59
|
+
if (done) break;
|
|
60
|
+
const parts = (carry + decoder.decode(value, { stream: true })).split("\n");
|
|
61
|
+
carry = parts.pop() ?? "";
|
|
62
|
+
for (const line of parts) onLine(line, true);
|
|
63
|
+
}
|
|
64
|
+
} finally {
|
|
65
|
+
reader.releaseLock();
|
|
66
|
+
}
|
|
67
|
+
if (carry) onLine(carry, false);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function auditTranscriptImagesFile(path: string): Promise<TranscriptImageAudit> {
|
|
71
|
+
const audit = emptyImageAudit();
|
|
72
|
+
await forEachLine(path, (line) => auditImageLine(audit, line));
|
|
73
|
+
return audit;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface StripFileResult {
|
|
77
|
+
removed: number;
|
|
78
|
+
bytesFreed: number;
|
|
79
|
+
remaining: TranscriptImageAudit;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Rewrite a transcript with its image payloads replaced, keeping the same inode.
|
|
84
|
+
*
|
|
85
|
+
* The rewrite goes to a sibling file first so a crash midway cannot leave a half-written
|
|
86
|
+
* transcript, and is then copied back over the original rather than renamed into place: the
|
|
87
|
+
* CLI holds this path open in append mode, and a rename would leave it writing to an inode
|
|
88
|
+
* nothing reads any more.
|
|
89
|
+
*
|
|
90
|
+
* Returns without touching the original when there was nothing to remove, so a no-op strip
|
|
91
|
+
* cannot change the file's mtime.
|
|
92
|
+
*/
|
|
93
|
+
export async function stripTranscriptImagesFile(
|
|
94
|
+
path: string,
|
|
95
|
+
mode: StripMode,
|
|
96
|
+
opts: ImageScopeOpts = {},
|
|
97
|
+
): Promise<StripFileResult> {
|
|
98
|
+
const tmp = `${path}.strip-${process.pid}`;
|
|
99
|
+
let removed = 0;
|
|
100
|
+
let bytesFreed = 0;
|
|
101
|
+
const remaining = emptyImageAudit();
|
|
102
|
+
// The rewrite replaces the whole file, so anything appended after this point would be lost.
|
|
103
|
+
// Callers are expected to stop the session first; this catches the case where they did not,
|
|
104
|
+
// because the damage is silent — a dropped record breaks the parentUuid chain and the UI
|
|
105
|
+
// renders the conversation as empty from that point back.
|
|
106
|
+
const before = statSync(path);
|
|
107
|
+
|
|
108
|
+
const writer = Bun.file(tmp).writer();
|
|
109
|
+
try {
|
|
110
|
+
await forEachLine(path, (line, terminated) => {
|
|
111
|
+
const r = stripImageLine(line, mode, opts);
|
|
112
|
+
removed += r.removed;
|
|
113
|
+
bytesFreed += r.bytesFreed;
|
|
114
|
+
auditImageLine(remaining, r.line);
|
|
115
|
+
// Reproduce the source's separators byte for byte, trailing newline included.
|
|
116
|
+
writer.write(terminated ? r.line + "\n" : r.line);
|
|
117
|
+
});
|
|
118
|
+
await writer.end();
|
|
119
|
+
|
|
120
|
+
if (removed === 0) {
|
|
121
|
+
unlinkSync(tmp);
|
|
122
|
+
return { removed, bytesFreed, remaining };
|
|
123
|
+
}
|
|
124
|
+
// Guard against writing a truncated transcript over a good one.
|
|
125
|
+
if (statSync(tmp).size === 0 && before.size > 0) {
|
|
126
|
+
unlinkSync(tmp);
|
|
127
|
+
throw new Error("Refusing to replace transcript with an empty rewrite");
|
|
128
|
+
}
|
|
129
|
+
const after = statSync(path);
|
|
130
|
+
if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) {
|
|
131
|
+
unlinkSync(tmp);
|
|
132
|
+
throw new Error(
|
|
133
|
+
"Transcript changed while it was being rewritten — the session is still running. " +
|
|
134
|
+
"Nothing was modified; stop the session and try again.",
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
copyOverInPlace(tmp, path);
|
|
138
|
+
unlinkSync(tmp);
|
|
139
|
+
return { removed, bytesFreed, remaining };
|
|
140
|
+
} catch (e) {
|
|
141
|
+
try { unlinkSync(tmp); } catch { /* already gone */ }
|
|
142
|
+
throw e;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -9,16 +9,25 @@ import { base64ByteLength, imagePlaceholderText } from "../shared/tool-result-co
|
|
|
9
9
|
* MANY_IMAGE_DIMENSION_LIMIT on a side and reports that it removed them — so those payloads
|
|
10
10
|
* cost bandwidth and produce an error while contributing nothing to the model's context.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* the
|
|
12
|
+
* Images live in two places, and they are not equally safe to remove. A tool result can be
|
|
13
|
+
* produced again by re-reading the file it came from, so those are removed by default. An
|
|
14
|
+
* image the user attached to a message usually exists nowhere else, so it is left alone
|
|
15
|
+
* unless the caller opts in — which it must be able to do, because a single oversized
|
|
16
|
+
* attachment is enough to make every later turn of the session fail outright.
|
|
15
17
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
18
|
+
* A tool result's payload sits at `message.content[].content[]`; an attachment sits directly
|
|
19
|
+
* at `message.content[]`. A record also carries an image-shaped echo at the top-level
|
|
20
|
+
* `toolUseResult` field, but that one holds no base64 and is not part of an API request, so
|
|
21
|
+
* it is left as the CLI wrote it.
|
|
19
22
|
*/
|
|
20
23
|
|
|
21
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* Per-image dimension cap the API applies once a request carries several images.
|
|
26
|
+
*
|
|
27
|
+
* The cap is a maximum, so an image measuring exactly this many pixels is already over it.
|
|
28
|
+
* Comparisons here are `>=` rather than `>` for that reason: treating 2000px as acceptable
|
|
29
|
+
* hides the one image the API is rejecting and makes a cleanup look like a no-op.
|
|
30
|
+
*/
|
|
22
31
|
export const MANY_IMAGE_DIMENSION_LIMIT = 2000;
|
|
23
32
|
|
|
24
33
|
/** Base64 characters decoded to inspect a header — a JPEG frame can sit behind EXIF. */
|
|
@@ -26,10 +35,20 @@ const HEADER_CHARS = 4096;
|
|
|
26
35
|
|
|
27
36
|
export type StripMode = "oversized" | "all";
|
|
28
37
|
|
|
38
|
+
/** Which of the two image locations a scan or strip should cover. */
|
|
39
|
+
export interface ImageScopeOpts {
|
|
40
|
+
/**
|
|
41
|
+
* Also cover images the user attached to their own messages. Off by default: the payload
|
|
42
|
+
* in the transcript is typically the only remaining copy, so removing it is lossy in a way
|
|
43
|
+
* removing a tool result is not.
|
|
44
|
+
*/
|
|
45
|
+
includeAttachments?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
29
48
|
export interface TranscriptImageAudit {
|
|
30
|
-
/** Images found in
|
|
49
|
+
/** Images found in scope. */
|
|
31
50
|
total: number;
|
|
32
|
-
/** How many
|
|
51
|
+
/** How many are at or over the dimension cap and are therefore already being rejected. */
|
|
33
52
|
oversized: number;
|
|
34
53
|
/** Decoded size of all of them. */
|
|
35
54
|
bytes: number;
|
|
@@ -37,6 +56,10 @@ export interface TranscriptImageAudit {
|
|
|
37
56
|
oversizedBytes: number;
|
|
38
57
|
/** Longest side seen, or 0 when no dimensions could be read. */
|
|
39
58
|
largestSide: number;
|
|
59
|
+
/** Of `total`, how many are user attachments rather than tool results. */
|
|
60
|
+
attachments: number;
|
|
61
|
+
/** Of `oversized`, how many are user attachments — these need `includeAttachments` to clear. */
|
|
62
|
+
oversizedAttachments: number;
|
|
40
63
|
}
|
|
41
64
|
|
|
42
65
|
export interface StripResult {
|
|
@@ -74,13 +97,26 @@ function visitImages(node: unknown, visit: Visitor): void {
|
|
|
74
97
|
for (const value of Object.values(node)) visitImages(value, visit);
|
|
75
98
|
}
|
|
76
99
|
|
|
77
|
-
/**
|
|
78
|
-
|
|
100
|
+
/**
|
|
101
|
+
* Images carried by one record, in whichever of the two locations are in scope.
|
|
102
|
+
*
|
|
103
|
+
* The visitor is told which location an image came from so callers can count and report
|
|
104
|
+
* attachments separately — a user needs to know when the image blocking their session is one
|
|
105
|
+
* only they can authorise removing.
|
|
106
|
+
*/
|
|
107
|
+
function visitRecordImages(
|
|
108
|
+
record: unknown,
|
|
109
|
+
visit: (image: ImageBlock, replace: (block: unknown) => void, isAttachment: boolean) => void,
|
|
110
|
+
): void {
|
|
79
111
|
const content = (record as { message?: { content?: unknown } } | null)?.message?.content;
|
|
80
112
|
if (!Array.isArray(content)) return;
|
|
81
|
-
for (
|
|
82
|
-
|
|
83
|
-
|
|
113
|
+
for (let i = 0; i < content.length; i++) {
|
|
114
|
+
const block = content[i];
|
|
115
|
+
if ((block as { type?: unknown } | null)?.type === "tool_result") {
|
|
116
|
+
visitImages((block as { content?: unknown }).content, (img, replace) => visit(img, replace, false));
|
|
117
|
+
} else if (isImageBlock(block)) {
|
|
118
|
+
visit(block, (next) => { content[i] = next; }, true);
|
|
119
|
+
}
|
|
84
120
|
}
|
|
85
121
|
}
|
|
86
122
|
|
|
@@ -99,76 +135,103 @@ function longestSide(data: string): number {
|
|
|
99
135
|
return size ? Math.max(size.w, size.h) : 0;
|
|
100
136
|
}
|
|
101
137
|
|
|
102
|
-
|
|
103
|
-
|
|
138
|
+
/** A zeroed audit, ready to accumulate records into. */
|
|
139
|
+
export function emptyImageAudit(): TranscriptImageAudit {
|
|
140
|
+
return {
|
|
104
141
|
total: 0,
|
|
105
142
|
oversized: 0,
|
|
106
143
|
bytes: 0,
|
|
107
144
|
oversizedBytes: 0,
|
|
108
145
|
largestSide: 0,
|
|
146
|
+
attachments: 0,
|
|
147
|
+
oversizedAttachments: 0,
|
|
109
148
|
};
|
|
149
|
+
}
|
|
110
150
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
audit.bytes += bytes;
|
|
125
|
-
if (side > audit.largestSide) audit.largestSide = side;
|
|
126
|
-
if (side > MANY_IMAGE_DIMENSION_LIMIT) {
|
|
127
|
-
audit.oversized++;
|
|
128
|
-
audit.oversizedBytes += bytes;
|
|
129
|
-
}
|
|
130
|
-
});
|
|
151
|
+
/**
|
|
152
|
+
* Fold one JSONL record into a running audit.
|
|
153
|
+
*
|
|
154
|
+
* Exposed per line so a transcript can be measured while it streams; a session large enough
|
|
155
|
+
* to be worth auditing is exactly the one too large to hold in memory as a single string.
|
|
156
|
+
*/
|
|
157
|
+
export function auditImageLine(audit: TranscriptImageAudit, line: string): void {
|
|
158
|
+
if (!line) return;
|
|
159
|
+
let record: unknown;
|
|
160
|
+
try {
|
|
161
|
+
record = JSON.parse(line);
|
|
162
|
+
} catch {
|
|
163
|
+
return;
|
|
131
164
|
}
|
|
165
|
+
visitRecordImages(record, (image, _replace, isAttachment) => {
|
|
166
|
+
const data = imageData(image);
|
|
167
|
+
const bytes = base64ByteLength(data);
|
|
168
|
+
const side = longestSide(data);
|
|
169
|
+
audit.total++;
|
|
170
|
+
audit.bytes += bytes;
|
|
171
|
+
if (isAttachment) audit.attachments++;
|
|
172
|
+
if (side > audit.largestSide) audit.largestSide = side;
|
|
173
|
+
if (side >= MANY_IMAGE_DIMENSION_LIMIT) {
|
|
174
|
+
audit.oversized++;
|
|
175
|
+
audit.oversizedBytes += bytes;
|
|
176
|
+
if (isAttachment) audit.oversizedAttachments++;
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
}
|
|
132
180
|
|
|
181
|
+
export function auditTranscriptImages(text: string): TranscriptImageAudit {
|
|
182
|
+
const audit = emptyImageAudit();
|
|
183
|
+
for (const line of text.split("\n")) auditImageLine(audit, line);
|
|
133
184
|
return audit;
|
|
134
185
|
}
|
|
135
186
|
|
|
136
187
|
/**
|
|
137
|
-
*
|
|
188
|
+
* Rewrite one JSONL record with its in-scope image payloads replaced by placeholder text.
|
|
138
189
|
*
|
|
139
|
-
* `oversized`
|
|
140
|
-
*
|
|
190
|
+
* `oversized` keeps an image whose dimensions cannot be read, since removing something that
|
|
191
|
+
* may well be within the cap would lose context for no benefit. Attachments are kept unless
|
|
192
|
+
* `includeAttachments` is set — see the note at the top of this file.
|
|
141
193
|
*/
|
|
142
|
-
export function
|
|
194
|
+
export function stripImageLine(
|
|
195
|
+
line: string,
|
|
196
|
+
mode: StripMode,
|
|
197
|
+
opts: ImageScopeOpts = {},
|
|
198
|
+
): { line: string; removed: number; bytesFreed: number } {
|
|
199
|
+
if (!line) return { line, removed: 0, bytesFreed: 0 };
|
|
200
|
+
let record: unknown;
|
|
201
|
+
try {
|
|
202
|
+
record = JSON.parse(line);
|
|
203
|
+
} catch {
|
|
204
|
+
// A line that does not parse is left byte-for-byte as it was found.
|
|
205
|
+
return { line, removed: 0, bytesFreed: 0 };
|
|
206
|
+
}
|
|
207
|
+
|
|
143
208
|
let removed = 0;
|
|
144
209
|
let bytesFreed = 0;
|
|
145
|
-
|
|
210
|
+
visitRecordImages(record, (image, replace, isAttachment) => {
|
|
211
|
+
if (isAttachment && !opts.includeAttachments) return;
|
|
212
|
+
const data = imageData(image);
|
|
213
|
+
if (mode === "oversized" && longestSide(data) < MANY_IMAGE_DIMENSION_LIMIT) return;
|
|
214
|
+
const bytes = base64ByteLength(data);
|
|
215
|
+
replace({ type: "text", text: imagePlaceholderText(bytes || undefined) });
|
|
216
|
+
removed++;
|
|
217
|
+
bytesFreed += bytes;
|
|
218
|
+
});
|
|
146
219
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
let record: unknown;
|
|
150
|
-
try {
|
|
151
|
-
record = JSON.parse(line);
|
|
152
|
-
} catch {
|
|
153
|
-
// A line that does not parse is left byte-for-byte as it was found.
|
|
154
|
-
return line;
|
|
155
|
-
}
|
|
220
|
+
return removed > 0 ? { line: JSON.stringify(record), removed, bytesFreed } : { line, removed: 0, bytesFreed: 0 };
|
|
221
|
+
}
|
|
156
222
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
changed = true;
|
|
170
|
-
return JSON.stringify(record);
|
|
223
|
+
export function stripTranscriptImages(
|
|
224
|
+
text: string,
|
|
225
|
+
mode: StripMode,
|
|
226
|
+
opts: ImageScopeOpts = {},
|
|
227
|
+
): StripResult {
|
|
228
|
+
let removed = 0;
|
|
229
|
+
let bytesFreed = 0;
|
|
230
|
+
const lines = text.split("\n").map((line) => {
|
|
231
|
+
const r = stripImageLine(line, mode, opts);
|
|
232
|
+
removed += r.removed;
|
|
233
|
+
bytesFreed += r.bytesFreed;
|
|
234
|
+
return r.line;
|
|
171
235
|
});
|
|
172
|
-
|
|
173
|
-
return { text: changed ? lines.join("\n") : text, removed, bytesFreed };
|
|
236
|
+
return { text: removed > 0 ? lines.join("\n") : text, removed, bytesFreed };
|
|
174
237
|
}
|
|
@@ -57,6 +57,44 @@ export async function checkForUpdate(): Promise<{
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
const LATEST_VERSION_CACHE_MS = 300_000; // 5min
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build a cached, in-flight-deduped "what is the newest published version"
|
|
64
|
+
* check. Every open UI polls the upgrade status once a minute, so an uncached
|
|
65
|
+
* check would turn into one registry request per client per minute.
|
|
66
|
+
*
|
|
67
|
+
* The TTL throttles *attempts*, not just successes: a machine that is offline
|
|
68
|
+
* or being rate-limited would otherwise re-enter a doomed 10s fetch on every
|
|
69
|
+
* poll. A failed attempt keeps the last known good version rather than
|
|
70
|
+
* forgetting it.
|
|
71
|
+
*/
|
|
72
|
+
export function createLatestVersionCheck(
|
|
73
|
+
checkFn: typeof checkForUpdate = checkForUpdate,
|
|
74
|
+
ttlMs = LATEST_VERSION_CACHE_MS,
|
|
75
|
+
): () => Promise<string | null> {
|
|
76
|
+
let cache: { at: number; version: string | null } | null = null;
|
|
77
|
+
let inflight: Promise<string | null> | null = null;
|
|
78
|
+
|
|
79
|
+
const remember = (version: string | null) => {
|
|
80
|
+
cache = { at: Date.now(), version: version ?? cache?.version ?? null };
|
|
81
|
+
return cache.version;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return function getLatestVersion() {
|
|
85
|
+
if (cache && Date.now() - cache.at < ttlMs) return Promise.resolve(cache.version);
|
|
86
|
+
if (inflight) return inflight;
|
|
87
|
+
inflight = checkFn()
|
|
88
|
+
.then((r) => remember(r.latest))
|
|
89
|
+
.catch(() => remember(null))
|
|
90
|
+
.finally(() => { inflight = null; });
|
|
91
|
+
return inflight;
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Newest published version — shared cache for the upgrade status endpoint. */
|
|
96
|
+
export const getLatestPublishedVersion = createLatestVersionCheck();
|
|
97
|
+
|
|
60
98
|
/** Resolve npm binary next to the running node runtime, falling back to bare "npm".
|
|
61
99
|
* Same rationale as bun: autostart may not have npm's bin dir on $PATH. */
|
|
62
100
|
function resolveNpmBin(): string {
|
package/src/shared/turn-usage.ts
CHANGED
|
@@ -39,6 +39,13 @@ export interface TurnUsage {
|
|
|
39
39
|
coldStart: boolean;
|
|
40
40
|
/** Why the previous subprocess went away, when PPM knows. */
|
|
41
41
|
coldReason?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Account that served the turn. The prompt cache is scoped per account, so a turn's cost
|
|
44
|
+
* is only comparable to turns on the same account — a session that moved accounts shows
|
|
45
|
+
* a cold prefix here for a reason that has nothing to do with the transcript.
|
|
46
|
+
*/
|
|
47
|
+
accountId?: string;
|
|
48
|
+
accountLabel?: string;
|
|
42
49
|
}
|
|
43
50
|
|
|
44
51
|
/** Total prefix replayed to the API this turn, cached or not. */
|
|
@@ -59,7 +66,7 @@ export function uncachedPrefixTokens(u: TurnUsage): number {
|
|
|
59
66
|
*/
|
|
60
67
|
export function buildTurnUsage(
|
|
61
68
|
modelUsage: Record<string, ModelUsageLike> | undefined,
|
|
62
|
-
opts: { coldReason?: string } = {},
|
|
69
|
+
opts: { coldReason?: string; accountId?: string; accountLabel?: string } = {},
|
|
63
70
|
): TurnUsage | undefined {
|
|
64
71
|
if (!modelUsage) return undefined;
|
|
65
72
|
const entries = Object.entries(modelUsage);
|
|
@@ -104,6 +111,8 @@ export function buildTurnUsage(
|
|
|
104
111
|
cacheHitRate: prefix > 0 ? cacheReadTokens / prefix : 0,
|
|
105
112
|
coldStart: !!opts.coldReason,
|
|
106
113
|
...(opts.coldReason && { coldReason: opts.coldReason }),
|
|
114
|
+
...(opts.accountId && { accountId: opts.accountId }),
|
|
115
|
+
...(opts.accountLabel && { accountLabel: opts.accountLabel }),
|
|
107
116
|
};
|
|
108
117
|
}
|
|
109
118
|
|
|
@@ -129,6 +138,10 @@ export interface TurnCostVerdict {
|
|
|
129
138
|
}
|
|
130
139
|
|
|
131
140
|
const COLD_REASON_TEXT: Record<string, string> = {
|
|
141
|
+
cache_expired: "the session's prompt cache had already lapsed, so PPM released its subprocess",
|
|
142
|
+
idle_timeout: "the session sat idle with no tab open long enough for PPM to release its subprocess",
|
|
143
|
+
warm_idle_cap: "too many idle sessions were holding a subprocess, so this one was released early",
|
|
144
|
+
// Retained for turns recorded before the teardown moved to the idle timer.
|
|
132
145
|
tab_closed: "PPM shut the session's subprocess down when the last tab disconnected",
|
|
133
146
|
set_model: "the model was changed, which restarts the session",
|
|
134
147
|
stream_ended: "the session's subprocess had already exited",
|
|
@@ -204,6 +217,7 @@ export function formatTurnUsageLog(u: TurnUsage): string {
|
|
|
204
217
|
: "";
|
|
205
218
|
return [
|
|
206
219
|
`model=${u.model}`,
|
|
220
|
+
...(u.accountLabel || u.accountId ? [`account=${u.accountLabel ?? u.accountId}`] : []),
|
|
207
221
|
`cold=${u.coldStart ? (u.coldReason ?? "yes") : "no"}`,
|
|
208
222
|
`in=${fmtTokens(u.inputTokens)}`,
|
|
209
223
|
`cacheRead=${fmtTokens(u.cacheReadTokens)}`,
|
|
@@ -68,9 +68,10 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
68
68
|
const updateTab = useTabStore((s) => s.updateTab);
|
|
69
69
|
const version = useSettingsStore((s) => s.version);
|
|
70
70
|
|
|
71
|
-
// Usage runs independently — auto-refreshes on interval
|
|
71
|
+
// Usage runs independently — auto-refreshes on interval. Scoped to this session so the
|
|
72
|
+
// account shown is the one bound to it, not whichever session ran most recently.
|
|
72
73
|
const { usageInfo, usageLoading, lastFetchedAt, refreshUsage } =
|
|
73
|
-
useUsage(projectName, providerId);
|
|
74
|
+
useUsage(projectName, providerId, sessionId ?? undefined);
|
|
74
75
|
|
|
75
76
|
// Draft auto-save/restore
|
|
76
77
|
const { draft, draftLoading, saveDraft, clearDraft } = useDraft(projectName, sessionId);
|
|
@@ -67,13 +67,11 @@ function CopyButton({ content }: { content: string }) {
|
|
|
67
67
|
export function MessageActionBar({
|
|
68
68
|
timestamp,
|
|
69
69
|
content,
|
|
70
|
-
accountLabel,
|
|
71
70
|
className,
|
|
72
71
|
children,
|
|
73
72
|
}: {
|
|
74
73
|
timestamp: string;
|
|
75
74
|
content: string;
|
|
76
|
-
accountLabel?: string;
|
|
77
75
|
className?: string;
|
|
78
76
|
children?: ReactNode;
|
|
79
77
|
}) {
|
|
@@ -87,7 +85,6 @@ export function MessageActionBar({
|
|
|
87
85
|
<time title={fullDate(timestamp)} className="pr-1 tabular-nums">
|
|
88
86
|
{formatRelativeDate(timestamp)}
|
|
89
87
|
</time>
|
|
90
|
-
{accountLabel && <span className="px-1 truncate max-w-40">via {accountLabel}</span>}
|
|
91
88
|
<CopyButton content={content} />
|
|
92
89
|
{children}
|
|
93
90
|
</div>
|
|
@@ -525,7 +525,6 @@ const MessageBubble = memo(function MessageBubble({ message, isStreaming, isLast
|
|
|
525
525
|
<TurnChangeRollup
|
|
526
526
|
timestamp={message.timestamp}
|
|
527
527
|
content={turnCopyText ?? assistantMessageText(message)}
|
|
528
|
-
accountLabel={message.accountLabel}
|
|
529
528
|
changes={turnChanges}
|
|
530
529
|
onJumpToEdit={onJumpToEdit}
|
|
531
530
|
/>
|