@hienlh/ppm 0.17.31 → 0.17.32
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 +5 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/package.json +1 -1
- package/src/server/routes/chat.ts +4 -1
- package/src/services/db.service.ts +12 -1
- package/src/services/session-branch.service.ts +43 -10
- package/skills-lock.json +0 -11
- package/spike-memory-region-dump.ps1 +0 -69
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.17.32] - 2026-08-27
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **A forked chat and the chat it was forked from could not both appear in the history list** — the list showed one of the two and hid the other, and which one it hid kept changing: refresh after working in the fork and the original was gone, refresh after working in the original and the fork was gone. Pinning one of them brought both back, which was the only reliable way to reach either. Forking a chat and editing one of your earlier messages both create a new chat behind the scenes, and the history list deliberately folds the versions of an edited message into a single row, so that revising one message five times does not leave five near-identical rows behind. The two kinds were not being told apart, so a fork — a separate conversation you opened in its own tab on purpose — was folded in as though it were just another version of an edited message, and only whichever of the pair had been written to most recently survived the fold. A fork is now recorded as a conversation in its own right and always keeps its own row, whichever side you last used. Edited messages still collapse into one row, with the other versions reachable from the arrows on the message itself. Forks and edits made before this release can no longer be told apart, so all of them are now treated as forks and will each keep a row — a message you had revised several times may therefore show more rows in the list than it used to.
|
|
7
|
+
|
|
3
8
|
## [0.17.31] - 2026-08-27
|
|
4
9
|
|
|
5
10
|
### Added
|
|
@@ -71,4 +71,4 @@ This skill covers the `ppm` CLI, its HTTP API, and its config DB. It does **not*
|
|
|
71
71
|
- Third-party extensions (inspect via `ppm ext list`).
|
|
72
72
|
- The Claude Agent SDK internals (separate skill).
|
|
73
73
|
|
|
74
|
-
<!-- Generated for PPM v0.17.
|
|
74
|
+
<!-- Generated for PPM v0.17.32 at build time. Re-run `ppm export skill --install` to refresh. -->
|
|
@@ -284,4 +284,4 @@ _Base URL: `http://localhost:8080` (default; override via `ppm config set port <
|
|
|
284
284
|
- `ws://<host>/ws/terminal` — PTY terminal multiplexer
|
|
285
285
|
- `ws://<host>/ws/extensions` — extension host channel
|
|
286
286
|
|
|
287
|
-
<!-- Generated from src/server/routes/ for PPM v0.17.
|
|
287
|
+
<!-- Generated from src/server/routes/ for PPM v0.17.32 -->
|
package/package.json
CHANGED
|
@@ -577,7 +577,10 @@ chatRoutes.post("/sessions/:id/fork", async (c) => {
|
|
|
577
577
|
// partial, inconsistent version counts. Re-parent onto the group root.
|
|
578
578
|
const existingGroup = resolveVersionGroup(sourceId, forkOrdinal);
|
|
579
579
|
const versionParent = existingGroup ? existingGroup.ids[0]! : sourceId;
|
|
580
|
-
|
|
580
|
+
// An explicit fork is a separate thread, not another version of this
|
|
581
|
+
// one: recording it as `edit` would let the history list collapse it
|
|
582
|
+
// together with its source and show only whichever was touched last.
|
|
583
|
+
recordBranch(result.sessionId, versionParent, body.messageId, forkOrdinal, isEdit ? "edit" : "fork");
|
|
581
584
|
// Edit versions must show the group root's clean title — codex names a
|
|
582
585
|
// forked thread "<title> (fork)", so without this the suffix compounds
|
|
583
586
|
// ("Hello (fork) (fork)") across repeated edits. Pin the PPM title.
|
|
@@ -3,7 +3,7 @@ import { resolve } from "node:path";
|
|
|
3
3
|
import { mkdirSync, existsSync } from "node:fs";
|
|
4
4
|
import { encrypt, decrypt } from "../lib/account-crypto.ts";
|
|
5
5
|
import { getPpmDir } from "./ppm-dir.ts";
|
|
6
|
-
export const CURRENT_SCHEMA_VERSION =
|
|
6
|
+
export const CURRENT_SCHEMA_VERSION = 37;
|
|
7
7
|
|
|
8
8
|
let db: Database | null = null;
|
|
9
9
|
let dbProfile: string | null = null;
|
|
@@ -837,6 +837,17 @@ function runMigrations(database: Database): void {
|
|
|
837
837
|
try { database.exec("ALTER TABLE session_metadata ADD COLUMN thinking_budget INTEGER"); } catch {}
|
|
838
838
|
database.exec("PRAGMA user_version = 36;");
|
|
839
839
|
}
|
|
840
|
+
|
|
841
|
+
if (current < 37) {
|
|
842
|
+
// Same-tab message edits and explicit new-tab forks both land in
|
|
843
|
+
// session_branches, but only edits are alternate versions of one
|
|
844
|
+
// conversation — a fork is its own thread and must keep its own history row.
|
|
845
|
+
// Existing rows can no longer be told apart, so treat them as forks: showing
|
|
846
|
+
// an extra row is recoverable, hiding a conversation is not.
|
|
847
|
+
try { database.exec("ALTER TABLE session_branches ADD COLUMN kind TEXT"); } catch { /* column exists */ }
|
|
848
|
+
database.exec("UPDATE session_branches SET kind = 'fork' WHERE kind IS NULL");
|
|
849
|
+
database.exec("PRAGMA user_version = 37;");
|
|
850
|
+
}
|
|
840
851
|
}
|
|
841
852
|
|
|
842
853
|
// ---------------------------------------------------------------------------
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { getDb } from "./db.service.ts";
|
|
2
2
|
import type { VersionGroup } from "../types/api.ts";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* How a branch came to exist. `edit` is an alternate version of the same
|
|
6
|
+
* conversation, reachable through the in-message version switcher. `fork` is a
|
|
7
|
+
* deliberate new thread opened in its own tab. Only `edit` nodes collapse in the
|
|
8
|
+
* history list; a fork stays a first-class conversation there.
|
|
9
|
+
*/
|
|
10
|
+
export type BranchKind = "edit" | "fork";
|
|
11
|
+
|
|
4
12
|
/** A node in the edit-message branch tree (one forked session). */
|
|
5
13
|
export interface BranchRow {
|
|
6
14
|
child_id: string;
|
|
@@ -9,6 +17,8 @@ export interface BranchRow {
|
|
|
9
17
|
/** User-message ordinal (1-based) of the divergent message — stable across forks. */
|
|
10
18
|
fork_ordinal: number;
|
|
11
19
|
root_id: string;
|
|
20
|
+
/** Null on rows written before the kind column existed; treated as `fork`. */
|
|
21
|
+
kind: BranchKind | null;
|
|
12
22
|
created_at: string;
|
|
13
23
|
}
|
|
14
24
|
|
|
@@ -20,13 +30,19 @@ export interface BranchRow {
|
|
|
20
30
|
* parent-space fork_msg_id can't be matched against the child's transcript).
|
|
21
31
|
* Idempotent on child_id (INSERT OR REPLACE).
|
|
22
32
|
*/
|
|
23
|
-
export function recordBranch(
|
|
33
|
+
export function recordBranch(
|
|
34
|
+
childId: string,
|
|
35
|
+
parentId: string,
|
|
36
|
+
forkMsgId: string,
|
|
37
|
+
forkOrdinal: number,
|
|
38
|
+
kind: BranchKind = "edit",
|
|
39
|
+
): void {
|
|
24
40
|
const parentRoot = getRootId(parentId);
|
|
25
41
|
const rootId = parentRoot ?? parentId;
|
|
26
42
|
getDb().run(
|
|
27
|
-
`INSERT OR REPLACE INTO session_branches (child_id, parent_id, fork_msg_id, fork_ordinal, root_id)
|
|
28
|
-
VALUES (?, ?, ?, ?, ?)`,
|
|
29
|
-
[childId, parentId, forkMsgId, forkOrdinal, rootId],
|
|
43
|
+
`INSERT OR REPLACE INTO session_branches (child_id, parent_id, fork_msg_id, fork_ordinal, root_id, kind)
|
|
44
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
45
|
+
[childId, parentId, forkMsgId, forkOrdinal, rootId, kind],
|
|
30
46
|
);
|
|
31
47
|
}
|
|
32
48
|
|
|
@@ -171,9 +187,26 @@ export interface GroupableSession {
|
|
|
171
187
|
}
|
|
172
188
|
|
|
173
189
|
/**
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
190
|
+
* Session whose history row this one folds into: climb while every hop is a
|
|
191
|
+
* message edit, and stop at the first explicit fork. A fork therefore anchors
|
|
192
|
+
* its own group, so it never displaces — or gets displaced by — the thread it
|
|
193
|
+
* was forked from. An unclassified (pre-`kind`) row stops the climb too, which
|
|
194
|
+
* errs toward showing a row rather than hiding a conversation.
|
|
195
|
+
*/
|
|
196
|
+
export function getCollapseGroupId(sessionId: string): string {
|
|
197
|
+
let current = sessionId;
|
|
198
|
+
for (let hops = 0; hops < 100; hops++) {
|
|
199
|
+
const row = getBranchRow(current);
|
|
200
|
+
if (!row || row.kind !== "edit") return current;
|
|
201
|
+
current = row.parent_id;
|
|
202
|
+
}
|
|
203
|
+
return current;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Collapse each edit-version group to a single row — its head, the most recently
|
|
208
|
+
* active node (max updatedAt, falling back to createdAt). Pinned sessions are
|
|
209
|
+
* passed through untouched. Caller is responsible for final sort.
|
|
177
210
|
*/
|
|
178
211
|
export function collapseTreesToHeads<T extends GroupableSession>(sessions: T[]): T[] {
|
|
179
212
|
const heads = new Map<string, T>();
|
|
@@ -184,9 +217,9 @@ export function collapseTreesToHeads<T extends GroupableSession>(sessions: T[]):
|
|
|
184
217
|
out.push(s);
|
|
185
218
|
continue;
|
|
186
219
|
}
|
|
187
|
-
const
|
|
188
|
-
const cur = heads.get(
|
|
189
|
-
if (!cur || activity(s) > activity(cur)) heads.set(
|
|
220
|
+
const groupId = getCollapseGroupId(s.id);
|
|
221
|
+
const cur = heads.get(groupId);
|
|
222
|
+
if (!cur || activity(s) > activity(cur)) heads.set(groupId, s);
|
|
190
223
|
}
|
|
191
224
|
out.push(...heads.values());
|
|
192
225
|
return out;
|
package/skills-lock.json
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
# Enumerate committed memory regions of a process via VirtualQueryEx.
|
|
2
|
-
# Reveals allocation shape: many small regions (per-iteration leak) vs few huge (allocator arena).
|
|
3
|
-
param([int]$ProcessId)
|
|
4
|
-
|
|
5
|
-
Add-Type -TypeDefinition @"
|
|
6
|
-
using System;
|
|
7
|
-
using System.Runtime.InteropServices;
|
|
8
|
-
public class MemProbe {
|
|
9
|
-
[StructLayout(LayoutKind.Sequential)]
|
|
10
|
-
public struct MEMORY_BASIC_INFORMATION {
|
|
11
|
-
public IntPtr BaseAddress;
|
|
12
|
-
public IntPtr AllocationBase;
|
|
13
|
-
public uint AllocationProtect;
|
|
14
|
-
public uint Alignment1;
|
|
15
|
-
public IntPtr RegionSize;
|
|
16
|
-
public uint State;
|
|
17
|
-
public uint Protect;
|
|
18
|
-
public uint Type;
|
|
19
|
-
public uint Alignment2;
|
|
20
|
-
}
|
|
21
|
-
[DllImport("kernel32.dll", SetLastError=true)]
|
|
22
|
-
public static extern IntPtr OpenProcess(uint access, bool inherit, int pid);
|
|
23
|
-
[DllImport("kernel32.dll", SetLastError=true)]
|
|
24
|
-
public static extern int VirtualQueryEx(IntPtr h, IntPtr addr, out MEMORY_BASIC_INFORMATION mbi, int len);
|
|
25
|
-
[DllImport("kernel32.dll")]
|
|
26
|
-
public static extern bool CloseHandle(IntPtr h);
|
|
27
|
-
}
|
|
28
|
-
"@
|
|
29
|
-
|
|
30
|
-
$h = [MemProbe]::OpenProcess(0x0400 -bor 0x0010, $false, $ProcessId)
|
|
31
|
-
if ($h -eq [IntPtr]::Zero) { Write-Error "OpenProcess failed: $([ComponentModel.Win32Exception]::new([Runtime.InteropServices.Marshal]::GetLastWin32Error()).Message)"; exit 1 }
|
|
32
|
-
|
|
33
|
-
$size = [Runtime.InteropServices.Marshal]::SizeOf([type][MemProbe+MEMORY_BASIC_INFORMATION])
|
|
34
|
-
$addr = [IntPtr]::Zero
|
|
35
|
-
$regions = New-Object System.Collections.ArrayList
|
|
36
|
-
$max = [Int64]0x7FFFFFFFFFFF
|
|
37
|
-
|
|
38
|
-
while ([Int64]$addr -lt $max) {
|
|
39
|
-
$mbi = New-Object MemProbe+MEMORY_BASIC_INFORMATION
|
|
40
|
-
if ([MemProbe]::VirtualQueryEx($h, $addr, [ref]$mbi, $size) -eq 0) { break }
|
|
41
|
-
$rs = [Int64]$mbi.RegionSize
|
|
42
|
-
if ($rs -le 0) { break }
|
|
43
|
-
# MEM_COMMIT=0x1000, MEM_PRIVATE=0x20000
|
|
44
|
-
if ($mbi.State -eq 0x1000 -and $mbi.Type -eq 0x20000) {
|
|
45
|
-
[void]$regions.Add([pscustomobject]@{ Base=$mbi.BaseAddress; Size=$rs; Protect=$mbi.Protect })
|
|
46
|
-
}
|
|
47
|
-
$addr = [IntPtr]([Int64]$addr + $rs)
|
|
48
|
-
}
|
|
49
|
-
[void][MemProbe]::CloseHandle($h)
|
|
50
|
-
|
|
51
|
-
$total = ($regions | Measure-Object -Property Size -Sum).Sum
|
|
52
|
-
"PID $ProcessId : committed-private regions = $($regions.Count), total = $([math]::Round($total/1GB,2)) GB"
|
|
53
|
-
""
|
|
54
|
-
"--- by protection flag ---"
|
|
55
|
-
$regions | Group-Object Protect | Sort-Object { ($_.Group | Measure-Object Size -Sum).Sum } -Descending |
|
|
56
|
-
Select-Object @{n='Protect';e={'0x{0:X}' -f [int]$_.Name}}, Count,
|
|
57
|
-
@{n='TotalGB';e={[math]::Round((($_.Group|Measure-Object Size -Sum).Sum)/1GB,2)}} |
|
|
58
|
-
Format-Table -AutoSize
|
|
59
|
-
|
|
60
|
-
"--- by region size bucket ---"
|
|
61
|
-
$regions | Group-Object { [math]::Pow(2,[math]::Floor([math]::Log($_.Size,2))) } |
|
|
62
|
-
Sort-Object { ($_.Group | Measure-Object Size -Sum).Sum } -Descending |
|
|
63
|
-
Select-Object -First 12 @{n='BucketMB';e={[math]::Round([double]$_.Name/1MB,3)}}, Count,
|
|
64
|
-
@{n='TotalGB';e={[math]::Round((($_.Group|Measure-Object Size -Sum).Sum)/1GB,2)}} |
|
|
65
|
-
Format-Table -AutoSize
|
|
66
|
-
|
|
67
|
-
"--- top 10 single regions ---"
|
|
68
|
-
$regions | Sort-Object Size -Descending | Select-Object -First 10 @{n='Base';e={'0x{0:X}' -f [Int64]$_.Base}},
|
|
69
|
-
@{n='MB';e={[math]::Round($_.Size/1MB,1)}}, @{n='Protect';e={'0x{0:X}' -f [int]$_.Protect}} | Format-Table -AutoSize
|