@andares/pi-toolkits 0.3.0 → 0.4.0
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/README.md +103 -7
- package/package.json +11 -5
- package/src/features/compact/compact.test.ts +661 -0
- package/src/features/compact/constants.ts +13 -0
- package/src/features/compact/index.ts +375 -0
- package/src/features/compact/prompt.ts +238 -0
- package/src/features/compact/store.ts +137 -0
- package/src/features/favorites/index.ts +5 -3
- package/src/features/favorites/patch-cycle.ts +62 -21
- package/src/features/favorites/patch-selector.ts +22 -20
- package/src/features/review/index.ts +19 -54
- package/src/features/review/prompt.ts +6 -12
- package/src/features/review/review.test.ts +5 -24
- package/src/features/review/store.ts +0 -5
- package/src/index.ts +3 -0
- package/src/lib/model-config.ts +44 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compact-model feature — persisted compact-model config store.
|
|
3
|
+
*
|
|
4
|
+
* State lives in `<agent-dir>/pi-toolkits-compact.json` (agent-dir from
|
|
5
|
+
* getAgentDir(), same convention as the review store). The file holds a
|
|
6
|
+
* single model key:
|
|
7
|
+
*
|
|
8
|
+
* { "model": "google/gemini-2.5-flash" }
|
|
9
|
+
*
|
|
10
|
+
* Unset = feature disabled: every compaction (manual /compact, auto-compact
|
|
11
|
+
* threshold, overflow recovery) uses pi's default behavior with the current
|
|
12
|
+
* session model, zero overhead.
|
|
13
|
+
*
|
|
14
|
+
* Robustness mirrors the review store: corrupted/unreadable files fall back
|
|
15
|
+
* to "no model configured" with a console.warn; writes are atomic
|
|
16
|
+
* (tmp + rename) and never throw.
|
|
17
|
+
*/
|
|
18
|
+
import {
|
|
19
|
+
existsSync,
|
|
20
|
+
mkdirSync,
|
|
21
|
+
readFileSync,
|
|
22
|
+
renameSync,
|
|
23
|
+
writeFileSync,
|
|
24
|
+
} from "node:fs";
|
|
25
|
+
import { dirname, join } from "node:path";
|
|
26
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
27
|
+
import { COMPACT_FILE } from "./constants.js";
|
|
28
|
+
|
|
29
|
+
export class CompactStore {
|
|
30
|
+
private model: string | undefined;
|
|
31
|
+
private readonly file: string;
|
|
32
|
+
private loaded = false;
|
|
33
|
+
private readonly listeners = new Set<() => void>();
|
|
34
|
+
|
|
35
|
+
constructor(file: string = join(getAgentDir(), COMPACT_FILE)) {
|
|
36
|
+
this.file = file;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private ensureLoaded(): void {
|
|
40
|
+
if (this.loaded) return;
|
|
41
|
+
this.loaded = true;
|
|
42
|
+
try {
|
|
43
|
+
if (!existsSync(this.file)) return;
|
|
44
|
+
const raw = readFileSync(this.file, "utf8");
|
|
45
|
+
const parsed = JSON.parse(raw) as { model?: unknown };
|
|
46
|
+
if (
|
|
47
|
+
parsed &&
|
|
48
|
+
typeof parsed.model === "string" &&
|
|
49
|
+
parsed.model.includes("/")
|
|
50
|
+
) {
|
|
51
|
+
this.model = parsed.model;
|
|
52
|
+
}
|
|
53
|
+
} catch (error) {
|
|
54
|
+
// pi-lens-ignore: no-console-except-error,console-statement, — deliberate degradation log
|
|
55
|
+
console.warn(
|
|
56
|
+
"[pi-toolkits/compact] Failed to load compact-model config, starting unset:",
|
|
57
|
+
error instanceof Error ? error.message : String(error),
|
|
58
|
+
);
|
|
59
|
+
this.model = undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Atomically persist (tmp + rename). Never throws. */
|
|
64
|
+
save(): void {
|
|
65
|
+
try {
|
|
66
|
+
const dir = dirname(this.file);
|
|
67
|
+
if (!existsSync(dir)) {
|
|
68
|
+
mkdirSync(dir, { recursive: true });
|
|
69
|
+
}
|
|
70
|
+
const tmp = `${this.file}.tmp`;
|
|
71
|
+
writeFileSync(
|
|
72
|
+
tmp,
|
|
73
|
+
`${JSON.stringify({ model: this.model ?? null }, null, 2)}\n`,
|
|
74
|
+
"utf8",
|
|
75
|
+
);
|
|
76
|
+
renameSync(tmp, this.file);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
// pi-lens-ignore: no-console-except-error,console-statement, — deliberate degradation log
|
|
79
|
+
console.warn(
|
|
80
|
+
"[pi-toolkits/compact] Failed to save compact-model config:",
|
|
81
|
+
error instanceof Error ? error.message : String(error),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The configured compact model key ("provider/id"), or undefined. */
|
|
87
|
+
getModel(): string | undefined {
|
|
88
|
+
this.ensureLoaded();
|
|
89
|
+
return this.model;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Set (or clear with undefined) the compact model key and persist. */
|
|
93
|
+
setModel(key: string | undefined): void {
|
|
94
|
+
this.ensureLoaded();
|
|
95
|
+
this.model = key;
|
|
96
|
+
this.save();
|
|
97
|
+
this.notify();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
subscribe(listener: () => void): () => void {
|
|
101
|
+
this.listeners.add(listener);
|
|
102
|
+
return () => {
|
|
103
|
+
this.listeners.delete(listener);
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private notify(): void {
|
|
108
|
+
for (const listener of [...this.listeners]) {
|
|
109
|
+
try {
|
|
110
|
+
listener();
|
|
111
|
+
} catch {
|
|
112
|
+
// A broken listener must not break compact config handling.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Process-wide store shared by the session_before_compact handler and the
|
|
120
|
+
* /compact-model command.
|
|
121
|
+
*
|
|
122
|
+
* Accessed via getCompactStore() (lazy) so tests can swap in an isolated
|
|
123
|
+
* store via setSharedCompactStore().
|
|
124
|
+
*/
|
|
125
|
+
let sharedStore: CompactStore | undefined;
|
|
126
|
+
|
|
127
|
+
export function getCompactStore(): CompactStore {
|
|
128
|
+
if (!sharedStore) {
|
|
129
|
+
sharedStore = new CompactStore();
|
|
130
|
+
}
|
|
131
|
+
return sharedStore;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Test hook — replace the shared store (e.g. with one backed by a temp file). */
|
|
135
|
+
export function setSharedCompactStore(store: CompactStore): void {
|
|
136
|
+
sharedStore = store;
|
|
137
|
+
}
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
import type {
|
|
28
28
|
ExtensionAPI,
|
|
29
29
|
ExtensionContext,
|
|
30
|
+
ModelSelectEvent,
|
|
31
|
+
SessionStartEvent,
|
|
30
32
|
Theme,
|
|
31
33
|
} from "@earendil-works/pi-coding-agent";
|
|
32
34
|
import { FAVORITES_STATUS_KEY } from "./constants.js";
|
|
@@ -68,13 +70,13 @@ export function registerFavorites(pi: ExtensionAPI): void {
|
|
|
68
70
|
// selector. setStatus is idempotent and re-synced on every session_start.
|
|
69
71
|
let latestCtx: ExtensionContext | undefined;
|
|
70
72
|
|
|
71
|
-
pi.on("session_start", (_event, ctx) => {
|
|
73
|
+
pi.on("session_start", (_event: SessionStartEvent, ctx: ExtensionContext) => {
|
|
72
74
|
latestCtx = ctx;
|
|
73
75
|
captureTheme(ctx);
|
|
74
76
|
updateFooterStatus(ctx);
|
|
75
77
|
});
|
|
76
78
|
|
|
77
|
-
pi.on("model_select", (_event, ctx) => {
|
|
79
|
+
pi.on("model_select", (_event: ModelSelectEvent, ctx: ExtensionContext) => {
|
|
78
80
|
captureTheme(ctx);
|
|
79
81
|
updateFooterStatus(ctx);
|
|
80
82
|
});
|
|
@@ -90,7 +92,7 @@ export function registerFavorites(pi: ExtensionAPI): void {
|
|
|
90
92
|
|
|
91
93
|
pi.registerCommand("favorites", {
|
|
92
94
|
description: "List favorited models (add/remove with ctrl+F in /model)",
|
|
93
|
-
handler: async (_args, ctx) => {
|
|
95
|
+
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
94
96
|
captureTheme(ctx);
|
|
95
97
|
const list = getFavoritesStore().list();
|
|
96
98
|
if (list.length === 0) {
|
|
@@ -7,14 +7,16 @@
|
|
|
7
7
|
* one favorite exists, cycling is restricted to favorited models:
|
|
8
8
|
*
|
|
9
9
|
* - candidates come from the same source stock cycling would use (scoped
|
|
10
|
-
* models if any
|
|
10
|
+
* models if any — filtered to currently available ones like stock —
|
|
11
|
+
* else the available-model snapshot), filtered to favorites
|
|
11
12
|
* - if the current model is not a favorite: forward jumps to the first
|
|
12
13
|
* favorite, backward to the last (user-confirmed)
|
|
13
14
|
* - with exactly one favorite, already on it → undefined (stock "only one
|
|
14
15
|
* model available" feedback); otherwise jump to it
|
|
15
|
-
* - the apply logic mirrors
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* - the apply logic mirrors pi 0.87's _cycleScopedModel/_cycleAvailableModel
|
|
17
|
+
* (state swap, session persistence, default-model persistence only when
|
|
18
|
+
* `options.persist` is set, thinking-level re-clamp, model_select emit
|
|
19
|
+
* with source "cycle")
|
|
18
20
|
*
|
|
19
21
|
* Version guard: missing cycleModel → warn and skip; the patch is idempotent
|
|
20
22
|
* across /reload.
|
|
@@ -23,9 +25,17 @@ import {
|
|
|
23
25
|
AgentSession,
|
|
24
26
|
type ModelCycleResult,
|
|
25
27
|
} from "@earendil-works/pi-coding-agent";
|
|
26
|
-
import type { Model } from "@earendil-works/pi-ai";
|
|
27
28
|
import { getFavoritesStore, modelKey } from "./store.js";
|
|
28
29
|
|
|
30
|
+
/**
|
|
31
|
+
* pi's `Model<any>` — the exact type of `ModelCycleResult.model` in
|
|
32
|
+
* pi-coding-agent 0.87. Derived here instead of importing `Model` from
|
|
33
|
+
* @earendil-works/pi-ai, whose 0.87 index.d.ts re-exports it via an
|
|
34
|
+
* extensioned `"./types.ts"` specifier that not every TS resolver substitutes
|
|
35
|
+
* to `types.d.ts` (tsc 5.9 bundler does; other tsserver builds don't).
|
|
36
|
+
*/
|
|
37
|
+
type AnyModel = NonNullable<ModelCycleResult>["model"];
|
|
38
|
+
|
|
29
39
|
/**
|
|
30
40
|
* Structural view of the AgentSession internals the patch touches.
|
|
31
41
|
* AgentSession's members are TS-private (compile-time only), so this view is
|
|
@@ -33,16 +43,21 @@ import { getFavoritesStore, modelKey } from "./store.js";
|
|
|
33
43
|
*/
|
|
34
44
|
interface CycleSessionInternals {
|
|
35
45
|
// pi-lens-ignore: no-any-type, — pi's Model generic requires `any` (constraint is Api); pi itself uses Model<any> everywhere
|
|
36
|
-
_scopedModels: ReadonlyArray<{ model:
|
|
46
|
+
_scopedModels: ReadonlyArray<{ model: AnyModel; thinkingLevel?: string }>;
|
|
37
47
|
// pi-lens-ignore: no-any-type, — same as above
|
|
38
|
-
_modelRuntime: { getAvailableSnapshot():
|
|
48
|
+
_modelRuntime: { getAvailableSnapshot(): AnyModel[] };
|
|
39
49
|
// pi-lens-ignore: no-any-type, — same as above
|
|
40
|
-
model:
|
|
50
|
+
model: AnyModel;
|
|
41
51
|
agent: { state: { model: unknown } };
|
|
42
52
|
sessionManager: { appendModelChange(provider: string, id: string): void };
|
|
43
53
|
settingsManager: {
|
|
44
54
|
setDefaultModelAndProvider(provider: string, id: string): void;
|
|
45
55
|
};
|
|
56
|
+
/**
|
|
57
|
+
* pi 0.87: keeps a persisted default usable when cycling inside a
|
|
58
|
+
* non-empty --models scope.
|
|
59
|
+
*/
|
|
60
|
+
_addPersistedDefaultToNonEmptyScope(model: unknown): void;
|
|
46
61
|
setThinkingLevel(level: unknown): void;
|
|
47
62
|
_getThinkingLevelForModelSwitch(
|
|
48
63
|
explicitLevel?: string,
|
|
@@ -56,8 +71,7 @@ interface CycleSessionInternals {
|
|
|
56
71
|
}
|
|
57
72
|
|
|
58
73
|
interface CycleCandidate {
|
|
59
|
-
|
|
60
|
-
model: Model<any>;
|
|
74
|
+
model: AnyModel;
|
|
61
75
|
thinkingLevel?: string;
|
|
62
76
|
}
|
|
63
77
|
|
|
@@ -81,15 +95,24 @@ function nextCycleIndex(
|
|
|
81
95
|
: (currentIndex - 1 + len) % len;
|
|
82
96
|
}
|
|
83
97
|
|
|
98
|
+
/** Options accepted by pi 0.87's cycleModel (persist gates default-model persistence). */
|
|
99
|
+
interface CycleOptions {
|
|
100
|
+
persist?: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
84
103
|
let patched = false;
|
|
85
104
|
|
|
86
105
|
/** Restrict ctrl+p cycling to favorites. Idempotent; returns false if skipped. */
|
|
87
106
|
export function applyCyclePatch(): boolean {
|
|
88
107
|
if (patched) return true;
|
|
108
|
+
// SAFETY: AgentSession's members are TS-private (compile-time only), so a
|
|
109
|
+
// structural view of the runtime prototype is safe to cast to; guarded by
|
|
110
|
+
// the typeof check below before any patching happens.
|
|
89
111
|
const proto = AgentSession.prototype as unknown as {
|
|
90
112
|
cycleModel(
|
|
91
113
|
this: CycleSessionInternals,
|
|
92
114
|
direction?: "forward" | "backward",
|
|
115
|
+
options?: CycleOptions,
|
|
93
116
|
): Promise<ModelCycleResult | undefined>;
|
|
94
117
|
};
|
|
95
118
|
if (typeof proto.cycleModel !== "function") {
|
|
@@ -105,23 +128,37 @@ export function applyCyclePatch(): boolean {
|
|
|
105
128
|
proto.cycleModel = async function (
|
|
106
129
|
this: CycleSessionInternals,
|
|
107
130
|
direction: "forward" | "backward" = "forward",
|
|
131
|
+
options: CycleOptions = {},
|
|
108
132
|
): Promise<ModelCycleResult | undefined> {
|
|
109
133
|
if (
|
|
110
134
|
!getFavoritesStore().getConfig().cycleOnlyFavorites ||
|
|
111
135
|
!getFavoritesStore().hasAny()
|
|
112
136
|
) {
|
|
113
|
-
return original.call(this, direction);
|
|
137
|
+
return original.call(this, direction, options);
|
|
114
138
|
}
|
|
115
139
|
|
|
140
|
+
// Same source and availability filtering as pi 0.87's stock cycling.
|
|
116
141
|
const scoped = this._scopedModels.length > 0;
|
|
117
|
-
|
|
118
|
-
|
|
142
|
+
let candidates: CycleCandidate[];
|
|
143
|
+
if (scoped) {
|
|
144
|
+
const availableIds = new Set(
|
|
145
|
+
this._modelRuntime
|
|
146
|
+
.getAvailableSnapshot()
|
|
147
|
+
.map((model) => `${model.provider}\0${model.id}`),
|
|
148
|
+
);
|
|
149
|
+
candidates = this._scopedModels
|
|
150
|
+
.filter(
|
|
151
|
+
(sm) => availableIds.has(`${sm.model.provider}\0${sm.model.id}`),
|
|
152
|
+
)
|
|
153
|
+
.map((sm) => ({
|
|
119
154
|
model: sm.model,
|
|
120
155
|
thinkingLevel: sm.thinkingLevel,
|
|
121
|
-
}))
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
156
|
+
}));
|
|
157
|
+
} else {
|
|
158
|
+
candidates = this._modelRuntime
|
|
159
|
+
.getAvailableSnapshot()
|
|
160
|
+
.map((model) => ({ model, thinkingLevel: undefined }));
|
|
161
|
+
}
|
|
125
162
|
const favorites = candidates.filter((candidate) =>
|
|
126
163
|
getFavoritesStore().has(candidate.model),
|
|
127
164
|
);
|
|
@@ -148,10 +185,14 @@ export function applyCyclePatch(): boolean {
|
|
|
148
185
|
: this._getThinkingLevelForModelSwitch();
|
|
149
186
|
this.agent.state.model = next.model;
|
|
150
187
|
this.sessionManager.appendModelChange(next.model.provider, next.model.id);
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
188
|
+
// pi 0.87: only persist the default model when the caller asked for it.
|
|
189
|
+
if (options.persist) {
|
|
190
|
+
this.settingsManager.setDefaultModelAndProvider(
|
|
191
|
+
next.model.provider,
|
|
192
|
+
next.model.id,
|
|
193
|
+
);
|
|
194
|
+
this._addPersistedDefaultToNonEmptyScope(next.model);
|
|
195
|
+
}
|
|
155
196
|
this.setThinkingLevel(thinkingLevel);
|
|
156
197
|
await this._emitModelSelect(next.model, current, "cycle");
|
|
157
198
|
return {
|
|
@@ -58,6 +58,8 @@ interface SelectorPatchTarget {
|
|
|
58
58
|
handleInput(keyData: string): void;
|
|
59
59
|
updateList(): void;
|
|
60
60
|
filterModels(query: string): void;
|
|
61
|
+
/** pi 0.87: default-model badge source. */
|
|
62
|
+
isDefaultModel(model: ModelRef): boolean;
|
|
61
63
|
listContainer: ListContainerLike;
|
|
62
64
|
searchInput: SearchInputLike;
|
|
63
65
|
filteredModels: ModelItem[];
|
|
@@ -110,6 +112,9 @@ let patched = false;
|
|
|
110
112
|
/** Apply the three selector patches. Idempotent; returns false if skipped. */
|
|
111
113
|
export function applyModelSelectorPatches(): boolean {
|
|
112
114
|
if (patched) return true;
|
|
115
|
+
// SAFETY: ModelSelectorComponent's members are TS-private (compile-time
|
|
116
|
+
// only); the structural view matches pi 0.87's prototype shape and every
|
|
117
|
+
// touched method is existence-checked below before any patching happens.
|
|
113
118
|
const proto =
|
|
114
119
|
ModelSelectorComponent.prototype as unknown as SelectorPatchTarget;
|
|
115
120
|
if (
|
|
@@ -146,9 +151,10 @@ export function applyModelSelectorPatches(): boolean {
|
|
|
146
151
|
};
|
|
147
152
|
|
|
148
153
|
// ── 2. List rendering: hint row + bold bright yellow favorites ────────
|
|
149
|
-
// Replicates the built-in updateList layout (
|
|
150
|
-
//
|
|
151
|
-
//
|
|
154
|
+
// Replicates the built-in updateList layout (pi 0.87: fixed-width cursor
|
|
155
|
+
// and ✓ current-marker columns, provider badge, `· default` badge, scroll
|
|
156
|
+
// indicator, empty/error states, refresh status, Model Name footer) with
|
|
157
|
+
// one change: favorited model ids render in the favorite style.
|
|
152
158
|
proto.updateList = function (this: SelectorPatchTarget): void {
|
|
153
159
|
const theme = themeProvider();
|
|
154
160
|
if (!theme) {
|
|
@@ -178,24 +184,20 @@ export function applyModelSelectorPatches(): boolean {
|
|
|
178
184
|
const isCurrent =
|
|
179
185
|
this.currentModel !== undefined &&
|
|
180
186
|
sameModel(item.model, this.currentModel);
|
|
187
|
+
const isDefault = this.isDefaultModel(item.model);
|
|
181
188
|
const isFavorite = getFavoritesStore().has(item.model);
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
: ` ${item.id}`;
|
|
195
|
-
const providerBadge = theme.fg("muted", `[${item.provider}]`);
|
|
196
|
-
const checkmark = isCurrent ? theme.fg("success", " ✓") : "";
|
|
197
|
-
line = `${modelText} ${providerBadge}${checkmark}`;
|
|
198
|
-
}
|
|
189
|
+
const defaultBadge = isDefault
|
|
190
|
+
? theme.fg("muted", " · default")
|
|
191
|
+
: "";
|
|
192
|
+
const cursor = isSelected ? theme.fg("accent", "→ ") : " ";
|
|
193
|
+
const currentMarker = isCurrent ? theme.fg("accent", "✓ ") : " ";
|
|
194
|
+
const modelText = isFavorite
|
|
195
|
+
? styleFavorite(item.id)
|
|
196
|
+
: isSelected
|
|
197
|
+
? theme.fg("accent", item.id)
|
|
198
|
+
: item.id;
|
|
199
|
+
const providerBadge = theme.fg("muted", `[${item.provider}]`);
|
|
200
|
+
const line = `${cursor}${currentMarker}${modelText} ${providerBadge}${defaultBadge}`;
|
|
199
201
|
list.addChild(new Text(line, 0, 0));
|
|
200
202
|
}
|
|
201
203
|
if (startIndex > 0 || endIndex < this.filteredModels.length) {
|
|
@@ -6,10 +6,9 @@
|
|
|
6
6
|
* (full tool access — this is check AND fix, not a read-only audit), runs a
|
|
7
7
|
* review turn, and the session then stays on that model (no auto-restore).
|
|
8
8
|
*
|
|
9
|
-
* Trigger
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* - slash command: /third-review
|
|
9
|
+
* Trigger: the /third-review slash command. (Bare-message keyword matching
|
|
10
|
+
* was deliberately removed — a plain "third-review" message goes to the
|
|
11
|
+
* model as ordinary text, so an accidental send can never start a review.)
|
|
13
12
|
*
|
|
14
13
|
* Review-model config lives in `<agent-dir>/pi-toolkits-review.json`
|
|
15
14
|
* ("provider/id", see store.ts). When no model is configured, or the
|
|
@@ -24,27 +23,17 @@
|
|
|
24
23
|
import type {
|
|
25
24
|
ExtensionAPI,
|
|
26
25
|
ExtensionContext,
|
|
26
|
+
SessionStartEvent,
|
|
27
27
|
Theme,
|
|
28
28
|
} from "@earendil-works/pi-coding-agent";
|
|
29
|
-
import
|
|
29
|
+
import {
|
|
30
|
+
modelKey,
|
|
31
|
+
resolveConfiguredModel,
|
|
32
|
+
type AvailableModel,
|
|
33
|
+
} from "../../lib/model-config.js";
|
|
30
34
|
import { REVIEW_STATUS_KEY } from "./constants.js";
|
|
31
|
-
import { REVIEW_PROMPT
|
|
32
|
-
import { getReviewStore,
|
|
33
|
-
|
|
34
|
-
/** Resolve the configured review model against the available model list. */
|
|
35
|
-
export function resolveConfiguredModel(
|
|
36
|
-
ctx: ExtensionContext,
|
|
37
|
-
key: string | undefined,
|
|
38
|
-
): Model<Api> | undefined {
|
|
39
|
-
if (!key) return undefined;
|
|
40
|
-
const slash = key.indexOf("/");
|
|
41
|
-
if (slash <= 0 || slash === key.length - 1) return undefined;
|
|
42
|
-
const provider = key.slice(0, slash);
|
|
43
|
-
const id = key.slice(slash + 1);
|
|
44
|
-
return ctx.modelRegistry
|
|
45
|
-
.getAvailable()
|
|
46
|
-
.find((m) => m.provider === provider && m.id === id);
|
|
47
|
-
}
|
|
35
|
+
import { REVIEW_PROMPT } from "./prompt.js";
|
|
36
|
+
import { getReviewStore, type ReviewStore } from "./store.js";
|
|
48
37
|
|
|
49
38
|
/**
|
|
50
39
|
* Let the user pick a review model from the available list and persist the
|
|
@@ -56,7 +45,7 @@ export function resolveConfiguredModel(
|
|
|
56
45
|
export async function pickReviewModel(
|
|
57
46
|
ctx: ExtensionContext,
|
|
58
47
|
store: ReviewStore,
|
|
59
|
-
): Promise<
|
|
48
|
+
): Promise<AvailableModel | undefined> {
|
|
60
49
|
// Dialog-capable UI exists in TUI and RPC modes (hasUI); json/print and
|
|
61
50
|
// headless contexts have no dialogs, so fall back to a manual-config hint.
|
|
62
51
|
if (!ctx.hasUI) {
|
|
@@ -76,7 +65,7 @@ export async function pickReviewModel(
|
|
|
76
65
|
}
|
|
77
66
|
const configured = store.getModel();
|
|
78
67
|
// Same display source as the /model selector: name + [provider]/id.
|
|
79
|
-
const options = available.map((m) => `${m.name} (${m.provider}/${m.id})`);
|
|
68
|
+
const options = available.map((m: AvailableModel) => `${m.name} (${m.provider}/${m.id})`);
|
|
80
69
|
const choice = await ctx.ui.select(
|
|
81
70
|
configured
|
|
82
71
|
? `选择评审模型 (当前: ${configured})`
|
|
@@ -154,37 +143,13 @@ export function createReviewRunner(
|
|
|
154
143
|
export function registerReview(pi: ExtensionAPI): void {
|
|
155
144
|
const runReview = createReviewRunner(pi);
|
|
156
145
|
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
pi.on("input", (event, ctx) => {
|
|
161
|
-
if (event.source === "extension") {
|
|
162
|
-
return { action: "continue" };
|
|
163
|
-
}
|
|
164
|
-
if (isReviewTrigger(event.text)) {
|
|
165
|
-
// Fire-and-forget: the runner uses the live ctx proxy, so it is safe
|
|
166
|
-
// after this handler returns. A session switch mid-dialog can make
|
|
167
|
-
// the context assert-inactive; swallow that instead of leaking an
|
|
168
|
-
// unhandled rejection (a fresh extension instance owns the new
|
|
169
|
-
// session anyway).
|
|
170
|
-
void runReview(ctx).catch((error) => {
|
|
171
|
-
// pi-lens-ignore: no-console-except-error,console-statement, — deliberate degradation log
|
|
172
|
-
console.warn(
|
|
173
|
-
"[pi-toolkits/review] third-review aborted:",
|
|
174
|
-
error instanceof Error ? error.message : String(error),
|
|
175
|
-
);
|
|
176
|
-
});
|
|
177
|
-
return { action: "handled" };
|
|
178
|
-
}
|
|
179
|
-
return { action: "continue" };
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
// Slash-command trigger (pi resolves commands before the input event, so
|
|
183
|
-
// the two paths never double-fire).
|
|
146
|
+
// Slash-command trigger — the only entry point (bare-message keyword
|
|
147
|
+
// matching was removed to avoid accidental triggers). The handler awaits
|
|
148
|
+
// the runner, which may block on the model picker and the review turn.
|
|
184
149
|
pi.registerCommand("third-review", {
|
|
185
150
|
description:
|
|
186
151
|
"Summon the configured review model to review the just-completed work (check + fix)",
|
|
187
|
-
handler: async (_args, ctx) => {
|
|
152
|
+
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
188
153
|
await runReview(ctx);
|
|
189
154
|
},
|
|
190
155
|
});
|
|
@@ -195,7 +160,7 @@ export function registerReview(pi: ExtensionAPI): void {
|
|
|
195
160
|
pi.registerCommand("review-model", {
|
|
196
161
|
description:
|
|
197
162
|
"Set or change the review model used by third-review (picker shows the current one)",
|
|
198
|
-
handler: async (_args, ctx) => {
|
|
163
|
+
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
199
164
|
await pickReviewModel(ctx, getReviewStore());
|
|
200
165
|
},
|
|
201
166
|
});
|
|
@@ -213,7 +178,7 @@ export function registerReview(pi: ExtensionAPI): void {
|
|
|
213
178
|
);
|
|
214
179
|
};
|
|
215
180
|
|
|
216
|
-
pi.on("session_start", (_event, ctx) => {
|
|
181
|
+
pi.on("session_start", (_event: SessionStartEvent, ctx: ExtensionContext) => {
|
|
217
182
|
latestCtx = ctx;
|
|
218
183
|
try {
|
|
219
184
|
const t = ctx.ui.theme;
|
|
@@ -8,20 +8,10 @@
|
|
|
8
8
|
* under review.
|
|
9
9
|
*
|
|
10
10
|
* The three focus points the user asked for (本轮需求实现 / 是否有遗漏 /
|
|
11
|
-
* 是否有错误) are spelled out verbatim in the prompt
|
|
11
|
+
* 是否有错误) are spelled out verbatim in the prompt, together with a hard
|
|
12
|
+
* scope constraint: the review must stay within this round's changes.
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
|
-
/** Trigger words for the bare-message form of the third-review command. */
|
|
15
|
-
export const TRIGGER_WORDS: ReadonlySet<string> = new Set([
|
|
16
|
-
"third-review",
|
|
17
|
-
"third review",
|
|
18
|
-
]);
|
|
19
|
-
|
|
20
|
-
/** Whether the input text is exactly a third-review trigger (case/space-insensitive). */
|
|
21
|
-
export function isReviewTrigger(text: string): boolean {
|
|
22
|
-
return TRIGGER_WORDS.has(text.trim().toLowerCase());
|
|
23
|
-
}
|
|
24
|
-
|
|
25
15
|
export const REVIEW_PROMPT = `请以第三方代码评审的身份,对本次会话中刚完成的开发任务进行 Review。
|
|
26
16
|
|
|
27
17
|
本轮需求、开发过程和代码改动都在当前会话上下文中,请结合上下文,并用 read/grep 等工具实际检查仓库代码,不要凭空猜测。
|
|
@@ -31,6 +21,10 @@ export const REVIEW_PROMPT = `请以第三方代码评审的身份,对本次
|
|
|
31
21
|
2. 是否有遗漏:需求中是否有遗漏的功能点、边界情况、异常处理、兼容性、测试等?
|
|
32
22
|
3. 是否有错误:代码中是否存在逻辑错误、潜在 bug、类型/编译错误、安全与性能隐患等?
|
|
33
23
|
|
|
24
|
+
范围强约束:
|
|
25
|
+
- 本次 Review 仅限当前本轮修改内容(本轮需求对应的代码改动),避免扩大范围;
|
|
26
|
+
- 本轮改动范围内确认的问题才直接修复;范围外的问题(历史遗留、无关代码)只记录提示,不要动手修改。
|
|
27
|
+
|
|
34
28
|
要求:
|
|
35
29
|
- 对确认的问题直接修复(write/edit),并简要说明改了什么、为什么改;
|
|
36
30
|
- 无法确认或不宜直接改的问题,列出:位置(文件/行)、问题描述、建议方案;
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
* review feature — unit tests.
|
|
3
3
|
*
|
|
4
4
|
* Covers the logic behind the third-review command:
|
|
5
|
-
* - trigger words (isReviewTrigger): exact bare-message match only
|
|
6
5
|
* - store: set/get persistence round-trip + corrupted-file fallback
|
|
7
6
|
* - modelKey / resolveConfiguredModel: provider/id resolution, incl. ids
|
|
8
7
|
* that themselves contain "/" (e.g. openrouter/openai/gpt-5)
|
|
@@ -35,13 +34,15 @@ import {
|
|
|
35
34
|
import {
|
|
36
35
|
createReviewRunner,
|
|
37
36
|
pickReviewModel,
|
|
38
|
-
resolveConfiguredModel,
|
|
39
37
|
} from "./index.js";
|
|
40
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
modelKey,
|
|
40
|
+
resolveConfiguredModel,
|
|
41
|
+
} from "../../lib/model-config.js";
|
|
42
|
+
import { REVIEW_PROMPT } from "./prompt.js";
|
|
41
43
|
import {
|
|
42
44
|
ReviewStore,
|
|
43
45
|
getReviewStore,
|
|
44
|
-
modelKey,
|
|
45
46
|
setSharedReviewStore,
|
|
46
47
|
} from "./store.js";
|
|
47
48
|
|
|
@@ -121,26 +122,6 @@ function fakePi(switched = true) {
|
|
|
121
122
|
return { pi, setModel, sendUserMessage };
|
|
122
123
|
}
|
|
123
124
|
|
|
124
|
-
// ─── trigger words ─────────────────────────────────────────────
|
|
125
|
-
|
|
126
|
-
describe("isReviewTrigger", () => {
|
|
127
|
-
it("matches the bare trigger words case/space-insensitively", () => {
|
|
128
|
-
expect(isReviewTrigger("third-review")).toBe(true);
|
|
129
|
-
expect(isReviewTrigger(" Third-Review ")).toBe(true);
|
|
130
|
-
expect(isReviewTrigger("third review")).toBe(true);
|
|
131
|
-
expect(isReviewTrigger("THIRD REVIEW")).toBe(true);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it("rejects non-trigger text (no prefix/suffix matching)", () => {
|
|
135
|
-
expect(isReviewTrigger("third-review now")).toBe(false);
|
|
136
|
-
expect(isReviewTrigger("third_review")).toBe(false);
|
|
137
|
-
expect(isReviewTrigger("thirdreview")).toBe(false);
|
|
138
|
-
expect(isReviewTrigger("/third-review")).toBe(false);
|
|
139
|
-
expect(isReviewTrigger("")).toBe(false);
|
|
140
|
-
expect(isReviewTrigger("review the code please")).toBe(false);
|
|
141
|
-
});
|
|
142
|
-
});
|
|
143
|
-
|
|
144
125
|
// ─── model key & resolution ────────────────────────────────────
|
|
145
126
|
|
|
146
127
|
describe("modelKey", () => {
|
|
@@ -22,11 +22,6 @@ import { dirname, join } from "node:path";
|
|
|
22
22
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
23
23
|
import { REVIEW_FILE } from "./constants.js";
|
|
24
24
|
|
|
25
|
-
/** Model identity used as the review-model key: "provider/id". */
|
|
26
|
-
export function modelKey(model: { provider: string; id: string }): string {
|
|
27
|
-
return `${model.provider}/${model.id}`;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
25
|
export class ReviewStore {
|
|
31
26
|
private model: string | undefined;
|
|
32
27
|
private readonly file: string;
|
package/src/index.ts
CHANGED
|
@@ -9,15 +9,18 @@
|
|
|
9
9
|
* - favorites — model favorites (ctrl+F/ctrl+J in /model, favorites-only cycling)
|
|
10
10
|
* - stash — prompt stash (ctrl+alt+y swap / double-press stash-and-clear)
|
|
11
11
|
* - review — third-party code review with a configured review model (third-review)
|
|
12
|
+
* - compact — dedicated compact model for /compact + auto-compact summaries
|
|
12
13
|
*/
|
|
13
14
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
14
15
|
import { registerAsk } from "./features/ask/index.js";
|
|
16
|
+
import { registerCompact } from "./features/compact/index.js";
|
|
15
17
|
import { registerFavorites } from "./features/favorites/index.js";
|
|
16
18
|
import { registerReview } from "./features/review/index.js";
|
|
17
19
|
import { registerStash } from "./features/stash/index.js";
|
|
18
20
|
|
|
19
21
|
export default function piToolkits(pi: ExtensionAPI): void {
|
|
20
22
|
registerAsk(pi);
|
|
23
|
+
registerCompact(pi);
|
|
21
24
|
registerFavorites(pi);
|
|
22
25
|
registerReview(pi);
|
|
23
26
|
registerStash(pi);
|