pi-m3fix 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +118 -0
- package/package.json +48 -0
- package/src/args.ts +98 -0
- package/src/index.ts +122 -0
- package/src/repair.ts +226 -0
- package/src/session-find.ts +47 -0
- package/src/target-model.ts +133 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hypernewbie
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# pi-m3fix
|
|
2
|
+
|
|
3
|
+
[](https://github.com/hypernewbie/pi-m3fix/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/pi-m3fix)
|
|
5
|
+
|
|
6
|
+
Pi extension for repairing session files affected by flattened reasoning blocks.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
From npm (recommended, versioned):
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pi install npm:pi-m3fix
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Pin to a specific version:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pi install npm:pi-m3fix@0.2.1
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
From GitHub, latest `main`:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pi install git:github.com/hypernewbie/pi-m3fix
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
From GitHub, pinned to a released tag:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pi install git:github.com/hypernewbie/pi-m3fix@v0.2.1
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`v0.1.0` was broken (see [CHANGELOG.md](CHANGELOG.md)) and has been removed. Do not install it.
|
|
35
|
+
|
|
36
|
+
Local development checkout:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pi install /path/to/pi-m3fix
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Restart Pi or run `/reload`, then use `/m3fix`.
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
/m3fix [partial-session-id|session-file] [options]
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Examples:
|
|
51
|
+
|
|
52
|
+
```text
|
|
53
|
+
/m3fix --dry-run
|
|
54
|
+
/m3fix --force-live
|
|
55
|
+
/m3fix 019f311c --provider m3 --api anthropic-messages --model MiniMax-M3
|
|
56
|
+
/m3fix /absolute/path/to/session.jsonl --dry-run
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Options:
|
|
60
|
+
|
|
61
|
+
- `--dry-run`, `-n` — show changes without writing.
|
|
62
|
+
- `--force-live` — repair the loaded session and reload it from disk.
|
|
63
|
+
- `--provider <id>` — override the target provider.
|
|
64
|
+
- `--api <api>` — override the target API.
|
|
65
|
+
- `--model <id>` — override the target model.
|
|
66
|
+
- `--no-relabel` — skip provider/API/model relabeling.
|
|
67
|
+
- `--no-unflatten` — skip text-to-thinking repair.
|
|
68
|
+
- `--allow-empty-signature` — force the signature/unflatten repair even if compat metadata is missing/unproven for the current model. Use this for custom providers (e.g. MiniMax M3 through an anthropic-messages-compatible endpoint) that tolerate empty thinking signatures but don't have `compat.allowEmptySignature: true` registered.
|
|
69
|
+
|
|
70
|
+
## Behavior
|
|
71
|
+
|
|
72
|
+
For assistant messages, `/m3fix` can:
|
|
73
|
+
|
|
74
|
+
1. Set `provider`, `api`, and `model` to the selected target model.
|
|
75
|
+
2. Clear stale `thinkingSignature` values on non-redacted thinking blocks.
|
|
76
|
+
3. Convert active-context text blocks back into thinking blocks.
|
|
77
|
+
|
|
78
|
+
Active context is computed from Pi's session tree using the current leaf and compaction metadata, so branched sessions are handled correctly.
|
|
79
|
+
|
|
80
|
+
Signature clearing and text-to-thinking repair only run for compatible Anthropic Messages reasoning models. "Compatible" is determined like this:
|
|
81
|
+
|
|
82
|
+
- If you pass an explicit `--provider`/`--api`/`--model` target with `--api anthropic-messages`, the repair runs — this is treated as an informed opt-in, same as the original `underp.py` script, and does not require `compat.allowEmptySignature` to be registered.
|
|
83
|
+
- If you rely on the currently selected model (no explicit target), the repair only runs if that model's registry entry has `compat.allowEmptySignature: true`. Otherwise it's skipped with a warning, unless you pass `--allow-empty-signature` to force it.
|
|
84
|
+
|
|
85
|
+
If the repair is skipped, `provider`/`api`/`model` relabeling and signature blanking still happen, but leaked-reasoning text blocks are **not** converted back into `thinking` blocks — leaving corrupted context that the model can imitate on the next turn. If your model output starts degrading right after `/m3fix`, check the notification for a "does not have compat.allowEmptySignature=true" warning and re-run with `--allow-empty-signature` or explicit `--provider/--api/--model` flags.
|
|
86
|
+
|
|
87
|
+
Redacted thinking blocks are never modified.
|
|
88
|
+
|
|
89
|
+
## Safety
|
|
90
|
+
|
|
91
|
+
- Creates a one-time `.bak2` backup before writing.
|
|
92
|
+
- Writes through a temporary file and atomic rename.
|
|
93
|
+
- Re-opens the repaired file with Pi's `SessionManager` to validate it.
|
|
94
|
+
- Refuses to modify the loaded session unless `--force-live` is supplied.
|
|
95
|
+
|
|
96
|
+
## Development
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
npm install
|
|
100
|
+
npm test
|
|
101
|
+
npm run typecheck
|
|
102
|
+
npm pack --dry-run
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Releasing (maintainers)
|
|
106
|
+
|
|
107
|
+
CI publishes to npm via [OIDC trusted publishing](https://docs.npmjs.com/trusted-publishers/) — no npm token involved. To cut a release:
|
|
108
|
+
|
|
109
|
+
1. Bump `version` in `package.json`.
|
|
110
|
+
2. Commit, then tag `vX.Y.Z` matching that version and push both:
|
|
111
|
+
```bash
|
|
112
|
+
git tag -a vX.Y.Z -m "vX.Y.Z"
|
|
113
|
+
git push origin main
|
|
114
|
+
git push origin vX.Y.Z
|
|
115
|
+
```
|
|
116
|
+
3. CI runs tests, verifies the tag matches `package.json`'s version, then publishes via OIDC. Provenance is generated automatically.
|
|
117
|
+
|
|
118
|
+
The npm package's Trusted Publisher is configured to only accept publishes from this repo's `.github/workflows/ci.yml` workflow. Publishing access via classic/granular tokens is disabled on npmjs.com ("Require two-factor authentication and disallow tokens"), so a compromised or misconfigured token cannot publish a release.
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-m3fix",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Pi extension that repairs flattened reasoning blocks in compacted sessions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi-extension",
|
|
9
|
+
"session-repair",
|
|
10
|
+
"m3"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "https://github.com/hypernewbie/pi-m3fix.git"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/hypernewbie/pi-m3fix",
|
|
18
|
+
"bugs": "https://github.com/hypernewbie/pi-m3fix/issues",
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "vitest --run",
|
|
24
|
+
"typecheck": "tsc --noEmit"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
28
|
+
"@earendil-works/pi-ai": "*"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@earendil-works/pi-coding-agent": "^0.80.3",
|
|
32
|
+
"@earendil-works/pi-ai": "^0.80.3",
|
|
33
|
+
"@types/node": "^24.12.4",
|
|
34
|
+
"tsx": "^4.23.0",
|
|
35
|
+
"typescript": "^5.9.3",
|
|
36
|
+
"vitest": "^4.1.9"
|
|
37
|
+
},
|
|
38
|
+
"pi": {
|
|
39
|
+
"extensions": [
|
|
40
|
+
"./src/index.ts"
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"src/",
|
|
45
|
+
"README.md",
|
|
46
|
+
"LICENSE"
|
|
47
|
+
]
|
|
48
|
+
}
|
package/src/args.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
export interface M3FixOptions {
|
|
2
|
+
query?: string;
|
|
3
|
+
dryRun: boolean;
|
|
4
|
+
forceLive: boolean;
|
|
5
|
+
noRelabel: boolean;
|
|
6
|
+
noUnflatten: boolean;
|
|
7
|
+
allowEmptySignature: boolean;
|
|
8
|
+
target?: {
|
|
9
|
+
provider?: string;
|
|
10
|
+
api?: string;
|
|
11
|
+
model?: string;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const TARGET_FLAGS = new Set(["--provider", "--api", "--model"]);
|
|
16
|
+
|
|
17
|
+
export function parseCommandArgs(input: string): M3FixOptions {
|
|
18
|
+
const options: M3FixOptions = {
|
|
19
|
+
dryRun: false,
|
|
20
|
+
forceLive: false,
|
|
21
|
+
noRelabel: false,
|
|
22
|
+
noUnflatten: false,
|
|
23
|
+
allowEmptySignature: false,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const positional: string[] = [];
|
|
27
|
+
const tokens = tokenize(input);
|
|
28
|
+
|
|
29
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
30
|
+
const token = tokens[i];
|
|
31
|
+
if (token === "--dry-run" || token === "-n") {
|
|
32
|
+
options.dryRun = true;
|
|
33
|
+
} else if (token === "--force-live") {
|
|
34
|
+
options.forceLive = true;
|
|
35
|
+
} else if (token === "--no-relabel") {
|
|
36
|
+
options.noRelabel = true;
|
|
37
|
+
} else if (token === "--no-unflatten") {
|
|
38
|
+
options.noUnflatten = true;
|
|
39
|
+
} else if (token === "--allow-empty-signature") {
|
|
40
|
+
options.allowEmptySignature = true;
|
|
41
|
+
} else if (TARGET_FLAGS.has(token)) {
|
|
42
|
+
const value = tokens[++i];
|
|
43
|
+
if (value === undefined) {
|
|
44
|
+
throw new Error(`Missing value for ${token}`);
|
|
45
|
+
}
|
|
46
|
+
options.target ??= {};
|
|
47
|
+
if (token === "--provider") options.target.provider = value;
|
|
48
|
+
if (token === "--api") options.target.api = value;
|
|
49
|
+
if (token === "--model") options.target.model = value;
|
|
50
|
+
} else if (token.startsWith("-")) {
|
|
51
|
+
throw new Error(`Unknown flag: ${token}`);
|
|
52
|
+
} else {
|
|
53
|
+
positional.push(token);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (positional.length > 1) {
|
|
58
|
+
throw new Error("Expected at most one positional argument (session UUID or path)");
|
|
59
|
+
}
|
|
60
|
+
options.query = positional[0];
|
|
61
|
+
|
|
62
|
+
return options;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function tokenize(input: string): string[] {
|
|
66
|
+
const tokens: string[] = [];
|
|
67
|
+
let current = "";
|
|
68
|
+
let inQuotes = false;
|
|
69
|
+
let quoteChar = "";
|
|
70
|
+
|
|
71
|
+
for (const char of input) {
|
|
72
|
+
if (inQuotes) {
|
|
73
|
+
if (char === quoteChar) {
|
|
74
|
+
inQuotes = false;
|
|
75
|
+
tokens.push(current);
|
|
76
|
+
current = "";
|
|
77
|
+
} else {
|
|
78
|
+
current += char;
|
|
79
|
+
}
|
|
80
|
+
} else if (char === '"' || char === "'") {
|
|
81
|
+
inQuotes = true;
|
|
82
|
+
quoteChar = char;
|
|
83
|
+
} else if (/\s/.test(char)) {
|
|
84
|
+
if (current.length > 0) {
|
|
85
|
+
tokens.push(current);
|
|
86
|
+
current = "";
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
current += char;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (current.length > 0) {
|
|
94
|
+
tokens.push(current);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return tokens;
|
|
98
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { parseCommandArgs } from "./args.ts";
|
|
3
|
+
import { findSessionFiles } from "./session-find.ts";
|
|
4
|
+
import { resolveTargetModel } from "./target-model.ts";
|
|
5
|
+
import { repairSessionFile } from "./repair.ts";
|
|
6
|
+
|
|
7
|
+
export default function piM3FixExtension(pi: ExtensionAPI) {
|
|
8
|
+
pi.registerCommand("m3fix", {
|
|
9
|
+
description: "Repair flattened reasoning blocks in a Pi session JSONL file",
|
|
10
|
+
handler: async (args, ctx) => {
|
|
11
|
+
try {
|
|
12
|
+
await runM3Fix(args, ctx);
|
|
13
|
+
} catch (error) {
|
|
14
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
15
|
+
if (ctx.hasUI) {
|
|
16
|
+
ctx.ui.notify(`m3fix failed: ${message}`, "error");
|
|
17
|
+
} else {
|
|
18
|
+
console.error(`m3fix failed: ${message}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function runM3Fix(args: string, ctx: ExtensionCommandContext) {
|
|
26
|
+
const options = parseCommandArgs(args);
|
|
27
|
+
const currentSessionFile = ctx.sessionManager.getSessionFile();
|
|
28
|
+
|
|
29
|
+
const matches = await findSessionFiles({
|
|
30
|
+
query: options.query,
|
|
31
|
+
currentSessionFile,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if (matches.length === 0) {
|
|
35
|
+
notify(ctx, "No persisted session file matched the query.", "error");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let selectedFile: string;
|
|
40
|
+
if (matches.length === 1) {
|
|
41
|
+
selectedFile = matches[0].file;
|
|
42
|
+
} else if (ctx.hasUI) {
|
|
43
|
+
const choice = await ctx.ui.select(
|
|
44
|
+
"Multiple sessions matched. Pick one:",
|
|
45
|
+
matches.map((m) => m.file),
|
|
46
|
+
);
|
|
47
|
+
if (!choice) {
|
|
48
|
+
notify(ctx, "No session selected.", "warning");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
selectedFile = choice;
|
|
52
|
+
} else {
|
|
53
|
+
notify(ctx, `Multiple sessions matched: ${matches.map((m) => m.file).join(", ")}`, "error");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const resolved = resolveTargetModel({
|
|
58
|
+
currentModel: ctx.model,
|
|
59
|
+
explicit: options.target,
|
|
60
|
+
forceAllowEmptySignature: options.allowEmptySignature,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
if (!resolved) {
|
|
64
|
+
notify(ctx, "Could not resolve target model. Use --provider/--api/--model.", "error");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const { target, compat } = resolved;
|
|
69
|
+
|
|
70
|
+
// Safety: refuse to modify the currently loaded session unless forced
|
|
71
|
+
if (selectedFile === currentSessionFile && !options.forceLive) {
|
|
72
|
+
notify(
|
|
73
|
+
ctx,
|
|
74
|
+
"Refusing to modify the currently loaded session file. Use --force-live, or /quit and run again.",
|
|
75
|
+
"error",
|
|
76
|
+
);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!compat.compatible && !options.noUnflatten) {
|
|
81
|
+
notify(ctx, `${compat.reason} Skipping signature blank/unflatten.`, "warning");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const result = await repairSessionFile(selectedFile, {
|
|
85
|
+
target,
|
|
86
|
+
dryRun: options.dryRun,
|
|
87
|
+
noRelabel: options.noRelabel,
|
|
88
|
+
noUnflatten: options.noUnflatten,
|
|
89
|
+
allowEmptySignature: compat.compatible,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const mode = options.dryRun ? "Would" : "Did";
|
|
93
|
+
notify(
|
|
94
|
+
ctx,
|
|
95
|
+
`${mode} relabel ${result.stats.relabeled}, blank ${result.stats.blanked}, unflatten ${result.stats.unflattened} blocks (${result.stats.activeAssistantTurns} turns).`,
|
|
96
|
+
result.changed ? "info" : "warning",
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
if (result.backupPath) {
|
|
100
|
+
notify(ctx, `Backup: ${result.backupPath}`, "info");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!options.dryRun && result.changed && selectedFile === currentSessionFile) {
|
|
104
|
+
notify(ctx, "Session file repaired. Reloading from disk...", "info");
|
|
105
|
+
const switchResult = await ctx.switchSession(selectedFile, {
|
|
106
|
+
withSession: async (newCtx) => {
|
|
107
|
+
notify(newCtx, "Repaired session reloaded.", "info");
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
if (switchResult.cancelled) {
|
|
111
|
+
notify(ctx, "Session reload was cancelled. /quit and re-run pi, or /resume to load the repaired file.", "warning");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function notify(ctx: ExtensionCommandContext, message: string, level: "info" | "warning" | "error") {
|
|
117
|
+
if (ctx.hasUI) {
|
|
118
|
+
ctx.ui.notify(message, level);
|
|
119
|
+
} else {
|
|
120
|
+
console.log(`[${level}] ${message}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
package/src/repair.ts
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { readFile, writeFile, rename } from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { SessionManager, type SessionEntry, type SessionMessageEntry } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { TargetModel } from "./target-model.ts";
|
|
5
|
+
|
|
6
|
+
export interface RepairStats {
|
|
7
|
+
relabeled: number;
|
|
8
|
+
blanked: number;
|
|
9
|
+
unflattened: number;
|
|
10
|
+
activeAssistantTurns: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface RepairResult {
|
|
14
|
+
changed: boolean;
|
|
15
|
+
stats: RepairStats;
|
|
16
|
+
backupPath?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RepairOptions {
|
|
20
|
+
target: TargetModel;
|
|
21
|
+
dryRun?: boolean;
|
|
22
|
+
noRelabel?: boolean;
|
|
23
|
+
noUnflatten?: boolean;
|
|
24
|
+
allowEmptySignature?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function repairSessionFile(
|
|
28
|
+
sessionFile: string,
|
|
29
|
+
options: RepairOptions,
|
|
30
|
+
): Promise<RepairResult> {
|
|
31
|
+
const sm = await SessionManager.open(sessionFile);
|
|
32
|
+
const activeIds = new Set<string>();
|
|
33
|
+
let lastActiveAssistantId: string | undefined;
|
|
34
|
+
|
|
35
|
+
for (const entry of buildActiveEntries(sm)) {
|
|
36
|
+
activeIds.add(entry.id);
|
|
37
|
+
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
38
|
+
lastActiveAssistantId = entry.id;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const raw = await readFile(sessionFile, "utf8");
|
|
43
|
+
const lines = raw.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
|
44
|
+
const stats: RepairStats = {
|
|
45
|
+
relabeled: 0,
|
|
46
|
+
blanked: 0,
|
|
47
|
+
unflattened: 0,
|
|
48
|
+
activeAssistantTurns: 0,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
let changed = false;
|
|
52
|
+
const outputLines: string[] = [];
|
|
53
|
+
|
|
54
|
+
for (const line of lines) {
|
|
55
|
+
const entry = JSON.parse(line) as SessionEntry;
|
|
56
|
+
|
|
57
|
+
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
58
|
+
const wasChanged = transformAssistantEntry(
|
|
59
|
+
entry as SessionMessageEntry,
|
|
60
|
+
activeIds,
|
|
61
|
+
lastActiveAssistantId,
|
|
62
|
+
options,
|
|
63
|
+
stats,
|
|
64
|
+
);
|
|
65
|
+
if (wasChanged) changed = true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
outputLines.push(JSON.stringify(entry));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (options.dryRun || !changed) {
|
|
72
|
+
return { changed, stats };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const backupPath = `${sessionFile}.bak2`;
|
|
76
|
+
if (!existsSync(backupPath)) {
|
|
77
|
+
await writeFile(backupPath, raw, "utf8");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const tempPath = `${sessionFile}.tmp-${Date.now()}`;
|
|
81
|
+
await writeFile(tempPath, outputLines.join("\n") + "\n", "utf8");
|
|
82
|
+
await rename(tempPath, sessionFile);
|
|
83
|
+
|
|
84
|
+
await SessionManager.open(sessionFile);
|
|
85
|
+
|
|
86
|
+
return { changed, stats, backupPath };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function buildActiveEntries(sm: SessionManager): SessionEntry[] {
|
|
90
|
+
const allEntries = sm.getEntries();
|
|
91
|
+
const byId = new Map<string, SessionEntry>();
|
|
92
|
+
for (const entry of allEntries) {
|
|
93
|
+
byId.set(entry.id, entry);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const leafId = sm.getLeafId();
|
|
97
|
+
let leaf: SessionEntry | undefined;
|
|
98
|
+
if (leafId) {
|
|
99
|
+
leaf = byId.get(leafId);
|
|
100
|
+
}
|
|
101
|
+
if (!leaf) {
|
|
102
|
+
leaf = allEntries[allEntries.length - 1];
|
|
103
|
+
}
|
|
104
|
+
if (!leaf) {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const path: SessionEntry[] = [];
|
|
109
|
+
let current: SessionEntry | undefined = leaf;
|
|
110
|
+
while (current) {
|
|
111
|
+
path.push(current);
|
|
112
|
+
current = current.parentId ? byId.get(current.parentId) : undefined;
|
|
113
|
+
}
|
|
114
|
+
path.reverse();
|
|
115
|
+
|
|
116
|
+
let compaction: SessionEntry & { type: "compaction"; firstKeptEntryId?: string } | undefined;
|
|
117
|
+
let compactionIdx = -1;
|
|
118
|
+
for (let i = 0; i < path.length; i++) {
|
|
119
|
+
const entry = path[i];
|
|
120
|
+
if (entry.type === "compaction") {
|
|
121
|
+
compaction = entry as SessionEntry & { type: "compaction"; firstKeptEntryId?: string };
|
|
122
|
+
compactionIdx = i;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!compaction) {
|
|
127
|
+
return path;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const active: SessionEntry[] = [];
|
|
131
|
+
let foundFirstKept = false;
|
|
132
|
+
for (let i = 0; i < compactionIdx; i++) {
|
|
133
|
+
const entry = path[i];
|
|
134
|
+
if (entry.id === compaction.firstKeptEntryId) {
|
|
135
|
+
foundFirstKept = true;
|
|
136
|
+
}
|
|
137
|
+
if (foundFirstKept) {
|
|
138
|
+
active.push(entry);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
for (let i = compactionIdx + 1; i < path.length; i++) {
|
|
142
|
+
active.push(path[i]);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return active;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function transformAssistantEntry(
|
|
149
|
+
entry: SessionMessageEntry,
|
|
150
|
+
activeIds: Set<string>,
|
|
151
|
+
lastActiveAssistantId: string | undefined,
|
|
152
|
+
options: RepairOptions,
|
|
153
|
+
stats: RepairStats,
|
|
154
|
+
): boolean {
|
|
155
|
+
let changed = false;
|
|
156
|
+
const message = entry.message as unknown as Record<string, unknown>;
|
|
157
|
+
const content = Array.isArray(message.content) ? (message.content as Array<Record<string, unknown>>) : [];
|
|
158
|
+
|
|
159
|
+
if (!options.noRelabel) {
|
|
160
|
+
if (message.provider !== options.target.provider) {
|
|
161
|
+
message.provider = options.target.provider;
|
|
162
|
+
changed = true;
|
|
163
|
+
}
|
|
164
|
+
if (message.api !== options.target.api) {
|
|
165
|
+
message.api = options.target.api;
|
|
166
|
+
changed = true;
|
|
167
|
+
}
|
|
168
|
+
if (message.model !== options.target.model) {
|
|
169
|
+
message.model = options.target.model;
|
|
170
|
+
changed = true;
|
|
171
|
+
}
|
|
172
|
+
if (changed) {
|
|
173
|
+
stats.relabeled++;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for (const block of content) {
|
|
178
|
+
if (block.type === "thinking") {
|
|
179
|
+
if (block.redacted === true) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (block.thinkingSignature !== "" && block.thinkingSignature !== undefined) {
|
|
183
|
+
block.thinkingSignature = "";
|
|
184
|
+
stats.blanked++;
|
|
185
|
+
changed = true;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const isActive = activeIds.has(entry.id);
|
|
191
|
+
const isLastActiveAssistant = entry.id === lastActiveAssistantId;
|
|
192
|
+
const hasThinking = content.some((block) => block.type === "thinking");
|
|
193
|
+
|
|
194
|
+
if (
|
|
195
|
+
isActive &&
|
|
196
|
+
!isLastActiveAssistant &&
|
|
197
|
+
!options.noUnflatten &&
|
|
198
|
+
options.allowEmptySignature &&
|
|
199
|
+
!hasThinking
|
|
200
|
+
) {
|
|
201
|
+
const newContent: Array<Record<string, unknown>> = [];
|
|
202
|
+
let convertedThisTurn = false;
|
|
203
|
+
|
|
204
|
+
for (const block of content) {
|
|
205
|
+
if (block.type === "text" && typeof block.text === "string" && block.text.trim().length > 0) {
|
|
206
|
+
newContent.push({
|
|
207
|
+
type: "thinking",
|
|
208
|
+
thinking: block.text,
|
|
209
|
+
thinkingSignature: "",
|
|
210
|
+
});
|
|
211
|
+
stats.unflattened++;
|
|
212
|
+
convertedThisTurn = true;
|
|
213
|
+
changed = true;
|
|
214
|
+
} else {
|
|
215
|
+
newContent.push(block);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (convertedThisTurn) {
|
|
220
|
+
message.content = newContent;
|
|
221
|
+
stats.activeAssistantTurns++;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return changed;
|
|
226
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
export interface SessionFindOptions {
|
|
6
|
+
query?: string;
|
|
7
|
+
currentSessionFile?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SessionFindResult {
|
|
11
|
+
file: string;
|
|
12
|
+
matchedBy: "current" | "path" | "search";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function findSessionFile(options: SessionFindOptions): Promise<SessionFindResult | undefined> {
|
|
16
|
+
const results = await findSessionFiles(options);
|
|
17
|
+
return results[0];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function findSessionFiles(options: SessionFindOptions): Promise<SessionFindResult[]> {
|
|
21
|
+
// No query -> current session file
|
|
22
|
+
if (!options.query) {
|
|
23
|
+
if (!options.currentSessionFile) {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
return [{ file: options.currentSessionFile, matchedBy: "current" }];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Absolute path
|
|
30
|
+
const resolved = resolve(options.query);
|
|
31
|
+
if (existsSync(resolved)) {
|
|
32
|
+
return [{ file: resolved, matchedBy: "path" }];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Search by partial UUID / filename across all sessions
|
|
36
|
+
const sessions = await SessionManager.listAll();
|
|
37
|
+
const query = options.query.toLowerCase();
|
|
38
|
+
|
|
39
|
+
return sessions
|
|
40
|
+
.filter((session) => {
|
|
41
|
+
const file = session.path.toLowerCase();
|
|
42
|
+
const id = session.id?.toLowerCase() ?? "";
|
|
43
|
+
const name = session.name?.toLowerCase() ?? "";
|
|
44
|
+
return file.includes(query) || id.includes(query) || name.includes(query);
|
|
45
|
+
})
|
|
46
|
+
.map((session) => ({ file: session.path, matchedBy: "search" as const }));
|
|
47
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { Model } from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
3
|
+
export interface TargetModel {
|
|
4
|
+
provider: string;
|
|
5
|
+
api: string;
|
|
6
|
+
model: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface CompatibilityResult {
|
|
10
|
+
compatible: boolean;
|
|
11
|
+
reason: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function resolveTargetModel(options: {
|
|
15
|
+
currentModel?: Model<any>;
|
|
16
|
+
explicit?: { provider?: string; api?: string; model?: string };
|
|
17
|
+
forceAllowEmptySignature?: boolean;
|
|
18
|
+
}): { target: TargetModel; source: "current" | "explicit"; compat: CompatibilityResult } | undefined {
|
|
19
|
+
if (options.forceAllowEmptySignature) {
|
|
20
|
+
const target = resolveTarget(options);
|
|
21
|
+
if (!target) return undefined;
|
|
22
|
+
return {
|
|
23
|
+
target: target.value,
|
|
24
|
+
source: target.source,
|
|
25
|
+
compat: {
|
|
26
|
+
compatible: true,
|
|
27
|
+
reason: "--allow-empty-signature forced the repair regardless of compat metadata.",
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Explicit overrides take precedence. If the caller explicitly names a target,
|
|
33
|
+
// trust them the same way underp.py does: don't gate on registry metadata that
|
|
34
|
+
// may not exist for custom/proxy providers.
|
|
35
|
+
if (options.explicit && (options.explicit.provider || options.explicit.api || options.explicit.model)) {
|
|
36
|
+
const target: TargetModel = {
|
|
37
|
+
provider: options.explicit.provider ?? options.currentModel?.provider ?? "",
|
|
38
|
+
api: options.explicit.api ?? options.currentModel?.api ?? "",
|
|
39
|
+
model: options.explicit.model ?? options.currentModel?.id ?? "",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
if (!target.provider || !target.api || !target.model) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
target,
|
|
48
|
+
source: "explicit",
|
|
49
|
+
compat: checkExplicitCompat(target),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Fall back to current model, and require registry-confirmed compat since we
|
|
54
|
+
// have no explicit instruction from the caller.
|
|
55
|
+
const model = options.currentModel;
|
|
56
|
+
if (!model) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const target: TargetModel = {
|
|
61
|
+
provider: model.provider,
|
|
62
|
+
api: model.api,
|
|
63
|
+
model: model.id,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
target,
|
|
68
|
+
source: "current",
|
|
69
|
+
compat: checkModelCompat(model),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function resolveTarget(options: {
|
|
74
|
+
currentModel?: Model<any>;
|
|
75
|
+
explicit?: { provider?: string; api?: string; model?: string };
|
|
76
|
+
}): { value: TargetModel; source: "current" | "explicit" } | undefined {
|
|
77
|
+
if (options.explicit && (options.explicit.provider || options.explicit.api || options.explicit.model)) {
|
|
78
|
+
const target: TargetModel = {
|
|
79
|
+
provider: options.explicit.provider ?? options.currentModel?.provider ?? "",
|
|
80
|
+
api: options.explicit.api ?? options.currentModel?.api ?? "",
|
|
81
|
+
model: options.explicit.model ?? options.currentModel?.id ?? "",
|
|
82
|
+
};
|
|
83
|
+
if (!target.provider || !target.api || !target.model) return undefined;
|
|
84
|
+
return { value: target, source: "explicit" };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const model = options.currentModel;
|
|
88
|
+
if (!model) return undefined;
|
|
89
|
+
return {
|
|
90
|
+
value: { provider: model.provider, api: model.api, model: model.id },
|
|
91
|
+
source: "current",
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function checkExplicitCompat(target: TargetModel): CompatibilityResult {
|
|
96
|
+
// We only know the API string here; empty-signature repair is only defined for
|
|
97
|
+
// anthropic-messages. Registry compat metadata is not consulted for explicit
|
|
98
|
+
// targets because custom/proxy providers frequently omit it even when the
|
|
99
|
+
// backend tolerates empty signatures. Use --no-unflatten to opt out.
|
|
100
|
+
if (target.api !== "anthropic-messages") {
|
|
101
|
+
return {
|
|
102
|
+
compatible: false,
|
|
103
|
+
reason: `Target API is "${target.api}". The signature/unflatten repair only applies to anthropic-messages models.`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
compatible: true,
|
|
109
|
+
reason: "Target API is anthropic-messages. Explicit target trusted; pass --no-unflatten to skip signature/unflatten repair.",
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function checkModelCompat(model: Model<any>): CompatibilityResult {
|
|
114
|
+
if (model.api !== "anthropic-messages") {
|
|
115
|
+
return {
|
|
116
|
+
compatible: false,
|
|
117
|
+
reason: `Current model API is "${model.api}". The signature/unflatten repair only applies to anthropic-messages models.`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const compat = (model.compat ?? {}) as { allowEmptySignature?: boolean };
|
|
122
|
+
if (compat.allowEmptySignature !== true) {
|
|
123
|
+
return {
|
|
124
|
+
compatible: false,
|
|
125
|
+
reason: `Current model ${model.provider}/${model.id} does not have compat.allowEmptySignature=true. Pass --allow-empty-signature to force the repair anyway.`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
compatible: true,
|
|
131
|
+
reason: `Current model ${model.provider}/${model.id} supports empty thinking signatures.`,
|
|
132
|
+
};
|
|
133
|
+
}
|