pi-declaw 0.2.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/ARCHITECTURE.md +90 -0
- package/GLOSSARY.md +48 -0
- package/LICENSE +201 -0
- package/NOTICE +30 -0
- package/PLUGIN_GUIDE.md +142 -0
- package/README.md +161 -0
- package/SOURCES.md +139 -0
- package/index.ts +2 -0
- package/licenses/asd-ste100-skill-LICENSE +21 -0
- package/licenses/i-have-adhd-LICENSE +21 -0
- package/licenses/speak-like-you-eat-LICENSE +9 -0
- package/licenses/squirrel-mode-LICENSE +21 -0
- package/package.json +63 -0
- package/playground/README.md +64 -0
- package/playground/app.js +123 -0
- package/playground/assets/declaw-e.png +0 -0
- package/playground/assets/declaw-favicon.png +0 -0
- package/playground/assets/declaw-wordmark-charcoal.png +0 -0
- package/playground/assets/declaw-wordmark-white.png +0 -0
- package/playground/assets/geist.woff2 +0 -0
- package/playground/assets/licenses/Claudish-MIT.txt +21 -0
- package/playground/assets/licenses/Geist-NOTICE.txt +4 -0
- package/playground/assets/licenses/Geist-OFL.txt +93 -0
- package/playground/assets/licenses/asd-ste100-skill-LICENSE +21 -0
- package/playground/assets/licenses/i-have-adhd-LICENSE +21 -0
- package/playground/assets/licenses/paseo-plain-LICENSE +211 -0
- package/playground/assets/licenses/squirrel-mode-LICENSE +21 -0
- package/playground/build.ts +180 -0
- package/playground/credits.html +14 -0
- package/playground/diff.ts +65 -0
- package/playground/evidence.css +1 -0
- package/playground/index.html +96 -0
- package/playground/inline-diff.ts +131 -0
- package/playground/markdown.ts +239 -0
- package/playground/samples.json +244 -0
- package/playground/serve.ts +21 -0
- package/playground/styles.css +30 -0
- package/playground/unified.ts +136 -0
- package/src/adapters/command.ts +203 -0
- package/src/adapters/model.ts +53 -0
- package/src/adapters/pi.ts +56 -0
- package/src/adapters/settings.ts +117 -0
- package/src/application/rewrite.ts +39 -0
- package/src/domain/preservation.ts +62 -0
- package/src/domain/rewrite.ts +55 -0
- package/src/domain/styles.ts +169 -0
- package/src/extension.ts +50 -0
- package/src/plugin-api.ts +88 -0
- package/src/plugins/built-in/asd-ste100/plugin.ts +19 -0
- package/src/plugins/built-in/asd-ste100/prompt.ts +24 -0
- package/src/plugins/built-in/catalog.ts +17 -0
- package/src/plugins/built-in/i-have-adhd/plugin.ts +19 -0
- package/src/plugins/built-in/i-have-adhd/prompt.ts +20 -0
- package/src/plugins/built-in/index.ts +17 -0
- package/src/plugins/built-in/paseo-plain/plugin.ts +26 -0
- package/src/plugins/built-in/paseo-plain/upstream/Claudish-MIT.txt +21 -0
- package/src/plugins/built-in/paseo-plain/upstream/LICENSE +211 -0
- package/src/plugins/built-in/paseo-plain/upstream/NOTICE +16 -0
- package/src/plugins/built-in/paseo-plain/upstream/prompt.ts +351 -0
- package/src/plugins/built-in/paseo-plain/upstream/rewriter.ts +46 -0
- package/src/plugins/built-in/shared/adapted-rules.ts +28 -0
- package/src/plugins/built-in/shared/json-payload.ts +5 -0
- package/src/plugins/built-in/speak-like-you-eat/payload.ts +5 -0
- package/src/plugins/built-in/speak-like-you-eat/plugin.ts +26 -0
- package/src/plugins/built-in/speak-like-you-eat/prompt.ts +4 -0
- package/src/plugins/built-in/speak-like-you-eat/upstream/model-rewrite.ts +19 -0
- package/src/plugins/built-in/squirrel-mode/plugin.ts +19 -0
- package/src/plugins/built-in/squirrel-mode/prompt.ts +24 -0
- package/src/plugins/built-in/terse/plugin.ts +18 -0
- package/src/plugins/built-in/terse/prompt.ts +9 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { BorderedLoader } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { StyleCatalog } from "../domain/styles.ts";
|
|
4
|
+
import { abortable, ENTRY_TYPE, MAX_INPUT_CHARS, PlainError, POLICY_VERSION, REWRITE_THINKING, TIMEOUT_MS, type PlainEntry } from "../domain/rewrite.ts";
|
|
5
|
+
import { completedText, latestAnswer } from "./pi.ts";
|
|
6
|
+
import { executeRewrite } from "../application/rewrite.ts";
|
|
7
|
+
import { chooseModel, completeRewrite, supportsLowThinking } from "./model.ts";
|
|
8
|
+
import { readModelChoice, readPluginStatuses, readStyleChoice, savePluginStatuses, saveStyleChoice } from "./settings.ts";
|
|
9
|
+
import { isStyleId } from "../domain/styles.ts";
|
|
10
|
+
|
|
11
|
+
export interface DeclawCommandDependencies {
|
|
12
|
+
readonly pi: ExtensionAPI;
|
|
13
|
+
readonly catalog: StyleCatalog;
|
|
14
|
+
readonly settingsPath: string;
|
|
15
|
+
readonly stylePath: string;
|
|
16
|
+
readonly pluginPath: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface DeclawCommandController {
|
|
20
|
+
readonly command: Parameters<ExtensionAPI["registerCommand"]>[1];
|
|
21
|
+
readonly cancel: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type Result = { text: string } | { error: string } | { cancelled: true } | { stale: true };
|
|
25
|
+
|
|
26
|
+
/** Pi command adapter. It translates UI/session effects into application ports. */
|
|
27
|
+
export function createDeclawCommand({ pi, catalog, settingsPath, stylePath, pluginPath }: DeclawCommandDependencies): DeclawCommandController {
|
|
28
|
+
let active: AbortController | undefined;
|
|
29
|
+
let pluginStatuses: Record<string, "active" | "disabled"> = {};
|
|
30
|
+
let pluginStatusesLoaded = false;
|
|
31
|
+
|
|
32
|
+
const syncPluginStatuses = async (repair = false) => {
|
|
33
|
+
if (!pluginStatusesLoaded) {
|
|
34
|
+
try {
|
|
35
|
+
pluginStatuses = await readPluginStatuses(pluginPath);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (!repair) throw error;
|
|
38
|
+
pluginStatuses = {};
|
|
39
|
+
}
|
|
40
|
+
pluginStatusesLoaded = true;
|
|
41
|
+
}
|
|
42
|
+
for (const record of catalog.listPlugins()) {
|
|
43
|
+
const status = pluginStatuses[record.plugin.id];
|
|
44
|
+
if (status && status !== record.status) catalog.setPluginStatus(record.plugin.id, status);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const cancel = () => active?.abort();
|
|
49
|
+
const command: Parameters<ExtensionAPI["registerCommand"]>[1] = {
|
|
50
|
+
description: "Rewrite the latest answer; /declaw style selects a style, /declaw model selects its model",
|
|
51
|
+
getArgumentCompletions: (prefix: string) => {
|
|
52
|
+
const items = [
|
|
53
|
+
{ value: "model", label: "model", description: "Choose the rewrite model (low thinking)" },
|
|
54
|
+
{ value: "style", label: "style", description: "Choose the saved rewrite style" },
|
|
55
|
+
{ value: "list", label: "list", description: "List installed Declaw plugins and statuses" },
|
|
56
|
+
{ value: "manage", label: "manage", description: "Enable or disable Declaw plugins" },
|
|
57
|
+
...catalog.activeStyleIds().map((id) => ({ value: id, label: id, description: `Rewrite once with ${catalog.get(id)!.style.name}` })),
|
|
58
|
+
].filter((item) => item.value.startsWith(prefix));
|
|
59
|
+
return items.length ? items : null;
|
|
60
|
+
},
|
|
61
|
+
handler: async (args, ctx) => {
|
|
62
|
+
if (ctx.mode !== "tui") {
|
|
63
|
+
if (ctx.hasUI) ctx.ui.notify("/declaw currently requires Pi's terminal UI.", "warning");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const command = args.trim();
|
|
67
|
+
if (command && command !== "model" && command !== "style" && command !== "list" && command !== "manage" && !isStyleId(command)) {
|
|
68
|
+
ctx.ui.notify("Use /declaw to rewrite, /declaw style, /declaw model, /declaw list, or /declaw manage.", "info");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (active || !ctx.isIdle()) {
|
|
72
|
+
ctx.ui.notify("Wait for the current request to finish, then use /declaw.", "info");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const controller = new AbortController();
|
|
76
|
+
active = controller;
|
|
77
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
78
|
+
let timedOut = false;
|
|
79
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
80
|
+
try {
|
|
81
|
+
await syncPluginStatuses(command === "manage");
|
|
82
|
+
if (command === "model") {
|
|
83
|
+
await chooseModel(ctx, settingsPath, controller.signal);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (command === "list") {
|
|
87
|
+
const lines = catalog.listPlugins().map(({ plugin, status }) => {
|
|
88
|
+
const styles = plugin.styles.map((style) => `${style.name} (${style.id})`).join(", ");
|
|
89
|
+
return `${plugin.name} [${plugin.id}] · ${status} · ${styles}`;
|
|
90
|
+
});
|
|
91
|
+
ctx.ui.notify(lines.join("\n") || "No Declaw plugins are installed.", "info");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (command === "manage") {
|
|
95
|
+
const leafId = ctx.sessionManager.getLeafId();
|
|
96
|
+
const plugins = catalog.listPlugins();
|
|
97
|
+
const labels = plugins.map(({ plugin, status }) => `${plugin.name} (${plugin.id}) · ${status}`);
|
|
98
|
+
const selection = await ctx.ui.select("Declaw plugins", labels, { signal: controller.signal });
|
|
99
|
+
if (!selection || controller.signal.aborted || !ctx.isIdle() ||
|
|
100
|
+
ctx.sessionManager.getSessionId() !== sessionId || ctx.sessionManager.getLeafId() !== leafId) return;
|
|
101
|
+
const selected = plugins[labels.indexOf(selection)];
|
|
102
|
+
if (!selected) return;
|
|
103
|
+
const status = selected.status === "active" ? "disabled" : "active";
|
|
104
|
+
catalog.setPluginStatus(selected.plugin.id, status);
|
|
105
|
+
pluginStatuses[selected.plugin.id] = status;
|
|
106
|
+
await savePluginStatuses(pluginPath, pluginStatuses);
|
|
107
|
+
if (!controller.signal.aborted) ctx.ui.notify(`${selected.plugin.name} is now ${status}.`, "info");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (command === "style") {
|
|
111
|
+
const leafId = ctx.sessionManager.getLeafId();
|
|
112
|
+
const current = await readStyleChoice(stylePath, catalog).catch(() => undefined);
|
|
113
|
+
if (controller.signal.aborted) return;
|
|
114
|
+
const styleIds = catalog.activeStyleIds();
|
|
115
|
+
const labels = styleIds.map((id) => `${catalog.get(id)!.style.name} (${id})${id === current ? " (current)" : ""}`);
|
|
116
|
+
const selection = await ctx.ui.select("Declaw rewrite style", labels, { signal: controller.signal });
|
|
117
|
+
if (!selection || controller.signal.aborted || !ctx.isIdle() ||
|
|
118
|
+
ctx.sessionManager.getSessionId() !== sessionId || ctx.sessionManager.getLeafId() !== leafId) return;
|
|
119
|
+
const style = styleIds[labels.indexOf(selection)];
|
|
120
|
+
if (!style) return;
|
|
121
|
+
await saveStyleChoice(stylePath, style, catalog);
|
|
122
|
+
if (!controller.signal.aborted) ctx.ui.notify(`/declaw now uses ${catalog.get(style)!.style.name}. Model settings are unchanged.`, "info");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const source = latestAnswer(ctx.sessionManager.getBranch());
|
|
126
|
+
if (!source) {
|
|
127
|
+
ctx.ui.notify("No completed assistant answer is available to rewrite.", "warning");
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (source.text.length > MAX_INPUT_CHARS) {
|
|
131
|
+
ctx.ui.notify("This answer is too long for /declaw (32,000 characters maximum).", "warning");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const style = isStyleId(command) && command ? command : await readStyleChoice(stylePath, catalog);
|
|
135
|
+
const styleRecord = catalog.get(style);
|
|
136
|
+
if (!styleRecord) {
|
|
137
|
+
ctx.ui.notify(`The Declaw style ${style} is unavailable or disabled. Run /declaw style or /declaw manage.`, "warning");
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const choice = await readModelChoice(settingsPath);
|
|
141
|
+
if (controller.signal.aborted) return;
|
|
142
|
+
const model = ctx.modelRegistry.find(choice.provider, choice.modelId);
|
|
143
|
+
if (!model) {
|
|
144
|
+
ctx.ui.notify(`The /declaw model ${choice.provider}/${choice.modelId} is unavailable. Run /declaw model to choose another.`, "warning");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (!ctx.modelRegistry.hasConfiguredAuth(model)) {
|
|
148
|
+
ctx.ui.notify("Sign in to the selected provider with /login, or choose another model with /declaw model.", "warning");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (!supportsLowThinking(model)) {
|
|
152
|
+
ctx.ui.notify("The selected model lacks low thinking support. Run /declaw model to choose another.", "warning");
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
timeout = setTimeout(() => { timedOut = true; controller.abort(); }, TIMEOUT_MS);
|
|
156
|
+
const result = await ctx.ui.custom<Result | undefined>((tui, theme, _keys, done) => {
|
|
157
|
+
const loader = new BorderedLoader(tui, theme, `Rewriting with ${styleRecord.style.name} · ${model.id} · ${REWRITE_THINKING} thinking…`);
|
|
158
|
+
loader.onAbort = () => controller.abort();
|
|
159
|
+
const work = executeRewrite(source, controller.signal, {
|
|
160
|
+
complete: async (masked, requestSignal) => {
|
|
161
|
+
requestSignal.throwIfAborted();
|
|
162
|
+
const response = await completeRewrite(ctx.modelRegistry, model, masked, requestSignal, style, catalog);
|
|
163
|
+
const text = completedText(response);
|
|
164
|
+
if (text === undefined) throw new PlainError("The model did not return a complete rewrite. The original is unchanged.");
|
|
165
|
+
return text;
|
|
166
|
+
},
|
|
167
|
+
isCurrent: (candidate) => ctx.sessionManager.getSessionId() === sessionId && ctx.isIdle() &&
|
|
168
|
+
latestAnswer(ctx.sessionManager.getBranch())?.id === candidate.id,
|
|
169
|
+
publish: ({ sourceEntryId, text }) => pi.appendEntry<PlainEntry>(ENTRY_TYPE, {
|
|
170
|
+
version: POLICY_VERSION, sourceEntryId, text,
|
|
171
|
+
model: `${model.provider}/${model.id}`, thinkingLevel: REWRITE_THINKING,
|
|
172
|
+
style, styleName: styleRecord.style.name, stylePlugin: styleRecord.pluginId,
|
|
173
|
+
styleVersion: styleRecord.pluginVersion,
|
|
174
|
+
}),
|
|
175
|
+
});
|
|
176
|
+
void abortable(work, controller.signal).then(
|
|
177
|
+
(accepted) => done(accepted.kind === "accepted" ? { text: accepted.text } : { stale: true }),
|
|
178
|
+
(error: unknown) => done(controller.signal.aborted ? { cancelled: true } :
|
|
179
|
+
{ error: error instanceof PlainError ? error.message : "The rewrite failed. The original is unchanged." }),
|
|
180
|
+
);
|
|
181
|
+
return loader;
|
|
182
|
+
});
|
|
183
|
+
if (controller.signal.aborted || !result || "cancelled" in result || "stale" in result) {
|
|
184
|
+
if (active === controller && !controller.signal.aborted) controller.abort();
|
|
185
|
+
if (timedOut) ctx.ui.notify("The rewrite timed out after 60 seconds. The original is unchanged.", "warning");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if ("error" in result) {
|
|
189
|
+
ctx.ui.notify(result.error, "warning");
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (!controller.signal.aborted) ctx.ui.notify(error instanceof PlainError ? error.message : "The /declaw request failed. The original is unchanged.", "warning");
|
|
194
|
+
} finally {
|
|
195
|
+
clearTimeout(timeout);
|
|
196
|
+
controller.abort();
|
|
197
|
+
if (active === controller) active = undefined;
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
return { command, cancel };
|
|
203
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { PlainError } from "../domain/rewrite.ts";
|
|
4
|
+
import { isolatedRequest } from "./pi.ts";
|
|
5
|
+
import { readModelChoice, saveModelChoice } from "./settings.ts";
|
|
6
|
+
import type { StyleCatalog, StyleId } from "../domain/styles.ts";
|
|
7
|
+
|
|
8
|
+
/** Exclude non-reasoning models and explicit remappings away from low. */
|
|
9
|
+
export function supportsLowThinking(model: Model<Api>): boolean {
|
|
10
|
+
return model.reasoning && (model.thinkingLevelMap?.low === undefined || model.thinkingLevelMap.low === "low");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function chooseModel(ctx: ExtensionCommandContext, settingsPath: string, signal: AbortSignal): Promise<void> {
|
|
14
|
+
const models = ctx.modelRegistry.getAvailable()
|
|
15
|
+
.filter((model) => supportsLowThinking(model) && ctx.modelRegistry.hasConfiguredAuth(model))
|
|
16
|
+
.sort((a, b) => `${a.provider}/${a.id}`.localeCompare(`${b.provider}/${b.id}`));
|
|
17
|
+
if (models.length === 0) {
|
|
18
|
+
ctx.ui.notify("No authenticated models with low thinking support are available. Use /login or refresh the model catalog.", "warning");
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
// The picker must also be usable to repair an invalid settings file.
|
|
22
|
+
const current = await readModelChoice(settingsPath).catch(() => undefined);
|
|
23
|
+
if (signal.aborted) return;
|
|
24
|
+
const labels = models.map((model) => `${model.provider}/${model.id}` +
|
|
25
|
+
(model.provider === current?.provider && model.id === current.modelId ? " (current)" : ""));
|
|
26
|
+
const selection = await ctx.ui.select("Plain rewrite model · low thinking", labels, { signal });
|
|
27
|
+
if (!selection || signal.aborted) return;
|
|
28
|
+
const model = models[labels.indexOf(selection)];
|
|
29
|
+
if (!model) return;
|
|
30
|
+
await saveModelChoice(settingsPath, { provider: model.provider, modelId: model.id });
|
|
31
|
+
if (!signal.aborted) ctx.ui.notify(`/declaw now uses ${model.provider}/${model.id} with low thinking. Main agent settings are unchanged.`, "info");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Use Pi's provider-neutral low-thinking conversion without borrowing main-agent context. */
|
|
35
|
+
export async function completeRewrite(
|
|
36
|
+
registry: ExtensionContext["modelRegistry"], model: Model<Api>, masked: string, signal: AbortSignal,
|
|
37
|
+
style?: StyleId, catalog?: StyleCatalog,
|
|
38
|
+
) {
|
|
39
|
+
signal.throwIfAborted();
|
|
40
|
+
const provider = registry.getProvider(model.provider);
|
|
41
|
+
if (!provider) throw new PlainError("The selected provider is unavailable. Run /declaw model to choose another.");
|
|
42
|
+
// Resolves Pi's existing auth, including OAuth refresh, model headers, base URL and ambient env.
|
|
43
|
+
const auth = await registry.getApiKeyAndHeaders(model);
|
|
44
|
+
signal.throwIfAborted();
|
|
45
|
+
if (!auth.ok) throw new PlainError("Authentication for the /declaw model failed. Check /login or choose another model with /declaw model.");
|
|
46
|
+
const { context, options } = isolatedRequest(masked, model, signal, style, catalog);
|
|
47
|
+
return provider.streamSimple(auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model, context, {
|
|
48
|
+
...options,
|
|
49
|
+
apiKey: auth.apiKey,
|
|
50
|
+
headers: auth.headers,
|
|
51
|
+
env: auth.env,
|
|
52
|
+
}).result();
|
|
53
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { Api, AssistantMessage, Context, Model, SimpleStreamOptions } from "@earendil-works/pi-ai";
|
|
3
|
+
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { buildStyleRequest, DEFAULT_STYLE_ID, type StyleCatalog, type StyleId } from "../domain/styles.ts";
|
|
5
|
+
import { BUILTIN_CATALOG } from "../plugins/built-in/catalog.ts";
|
|
6
|
+
|
|
7
|
+
/** Composed default prompt exposed for integration diagnostics and compatibility. */
|
|
8
|
+
export const SYSTEM_PROMPT = buildStyleRequest("", DEFAULT_STYLE_ID, BUILTIN_CATALOG).system;
|
|
9
|
+
|
|
10
|
+
/** Pi's message shape is normalized at the adapter edge. */
|
|
11
|
+
export function completedText(message: AssistantMessage): string | undefined {
|
|
12
|
+
if (message.stopReason !== "stop" || message.errorMessage ||
|
|
13
|
+
message.content.some((part) => part.type === "toolCall")) return undefined;
|
|
14
|
+
const text = message.content.filter((part) => part.type === "text").map((part) => part.text).join("\n\n");
|
|
15
|
+
return text.trim() ? text : undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Reads only the latest assistant message; a failed latest answer is authoritative. */
|
|
19
|
+
export function latestAnswer(branch: readonly SessionEntry[]): { id: string; text: string } | undefined {
|
|
20
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
21
|
+
const entry = branch[i];
|
|
22
|
+
if (entry.type !== "message" || entry.message.role !== "assistant") continue;
|
|
23
|
+
const text = completedText(entry.message);
|
|
24
|
+
return text === undefined ? undefined : { id: entry.id, text };
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Converts the domain rewrite request into Pi's isolated, tool-free provider context. */
|
|
30
|
+
export function isolatedRequest(
|
|
31
|
+
maskedAnswer: string,
|
|
32
|
+
model: Model<Api>,
|
|
33
|
+
signal: AbortSignal,
|
|
34
|
+
style: StyleId = DEFAULT_STYLE_ID,
|
|
35
|
+
catalog: StyleCatalog = BUILTIN_CATALOG,
|
|
36
|
+
): { context: Context; options: SimpleStreamOptions } {
|
|
37
|
+
const request = buildStyleRequest(maskedAnswer, style, catalog);
|
|
38
|
+
const context: Context = {
|
|
39
|
+
systemPrompt: request.system,
|
|
40
|
+
messages: [{ role: "user", content: request.user, timestamp: Date.now() }],
|
|
41
|
+
tools: [],
|
|
42
|
+
};
|
|
43
|
+
return {
|
|
44
|
+
context,
|
|
45
|
+
options: {
|
|
46
|
+
signal,
|
|
47
|
+
sessionId: randomUUID(),
|
|
48
|
+
cacheRetention: "none" as const,
|
|
49
|
+
reasoning: "low",
|
|
50
|
+
maxRetries: 0,
|
|
51
|
+
transport: "sse",
|
|
52
|
+
timeoutMs: 60_000,
|
|
53
|
+
maxTokens: Math.min(16_384, model.maxTokens),
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { PlainError, REWRITE_MODEL, REWRITE_PROVIDER } from "../domain/rewrite.ts";
|
|
5
|
+
import { DEFAULT_STYLE_ID, isStyleId, type PluginStatus, type StyleCatalog, type StyleId } from "../domain/styles.ts";
|
|
6
|
+
import { BUILTIN_CATALOG } from "../plugins/built-in/catalog.ts";
|
|
7
|
+
|
|
8
|
+
export interface ModelChoice {
|
|
9
|
+
provider: string;
|
|
10
|
+
modelId: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_MODEL: ModelChoice = { provider: REWRITE_PROVIDER, modelId: REWRITE_MODEL };
|
|
14
|
+
|
|
15
|
+
export async function readModelChoice(path: string): Promise<ModelChoice> {
|
|
16
|
+
let contents: string;
|
|
17
|
+
try {
|
|
18
|
+
contents = await readFile(path, "utf8");
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { ...DEFAULT_MODEL };
|
|
21
|
+
throw new PlainError("Could not read /declaw settings. Check the permissions on its settings file.");
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const value = JSON.parse(contents);
|
|
25
|
+
if (value?.version !== 1 || typeof value.provider !== "string" || !value.provider.trim() ||
|
|
26
|
+
typeof value.modelId !== "string" || !value.modelId.trim()) throw new Error("invalid-settings");
|
|
27
|
+
return { provider: value.provider, modelId: value.modelId };
|
|
28
|
+
} catch {
|
|
29
|
+
throw new PlainError("The /declaw settings file is invalid. Run /declaw model to choose and save a model again.");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function readStyleChoice(path: string, catalog: StyleCatalog = BUILTIN_CATALOG): Promise<StyleId> {
|
|
34
|
+
let contents: string;
|
|
35
|
+
try {
|
|
36
|
+
contents = await readFile(path, "utf8");
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return DEFAULT_STYLE_ID;
|
|
39
|
+
throw new PlainError("Could not read /declaw style settings. Check the permissions on its style file.");
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const value = JSON.parse(contents);
|
|
43
|
+
if (value?.version !== 1 || typeof value.style !== "string" || !isStyleId(value.style) ||
|
|
44
|
+
!catalog.has(value.style, { includeDisabled: true })) {
|
|
45
|
+
throw new Error("invalid-style");
|
|
46
|
+
}
|
|
47
|
+
return value.style;
|
|
48
|
+
} catch {
|
|
49
|
+
throw new PlainError("The /declaw style settings file is invalid. Run /declaw style to choose and save a style again.");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function saveStyleChoice(path: string, style: StyleId, catalog: StyleCatalog = BUILTIN_CATALOG): Promise<void> {
|
|
54
|
+
if (!isStyleId(style) || !catalog.has(style, { includeDisabled: true })) throw new PlainError("Unknown /declaw style. Run /declaw style to choose an available style.");
|
|
55
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
56
|
+
try {
|
|
57
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
58
|
+
await writeFile(temporary, JSON.stringify({ version: 1, style }, null, 2) + "\n", { mode: 0o600, flag: "wx" });
|
|
59
|
+
await rename(temporary, path);
|
|
60
|
+
} catch {
|
|
61
|
+
throw new PlainError("Could not save the /declaw style choice. Check the permissions on its settings directory.");
|
|
62
|
+
} finally {
|
|
63
|
+
await rm(temporary, { force: true }).catch(() => {});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type PluginStatuses = Record<string, PluginStatus>;
|
|
68
|
+
|
|
69
|
+
export async function readPluginStatuses(path: string): Promise<PluginStatuses> {
|
|
70
|
+
let contents: string;
|
|
71
|
+
try {
|
|
72
|
+
contents = await readFile(path, "utf8");
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
75
|
+
throw new PlainError("Could not read /declaw plugin settings. Check the permissions on its settings file.");
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const value = JSON.parse(contents);
|
|
79
|
+
if (value?.version !== 1 || value.plugins === null || typeof value.plugins !== "object" || Array.isArray(value.plugins)) {
|
|
80
|
+
throw new Error("invalid-plugin-settings");
|
|
81
|
+
}
|
|
82
|
+
const statuses: PluginStatuses = {};
|
|
83
|
+
for (const [id, status] of Object.entries(value.plugins)) {
|
|
84
|
+
if (!isStyleId(id) || (status !== "active" && status !== "disabled")) throw new Error("invalid-plugin-settings");
|
|
85
|
+
statuses[id] = status as PluginStatus;
|
|
86
|
+
}
|
|
87
|
+
return statuses;
|
|
88
|
+
} catch {
|
|
89
|
+
throw new PlainError("The /declaw plugin settings file is invalid. Run /declaw manage to repair it.");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function savePluginStatuses(path: string, statuses: PluginStatuses): Promise<void> {
|
|
94
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
95
|
+
try {
|
|
96
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
97
|
+
await writeFile(temporary, JSON.stringify({ version: 1, plugins: statuses }, null, 2) + "\n", { mode: 0o600, flag: "wx" });
|
|
98
|
+
await rename(temporary, path);
|
|
99
|
+
} catch {
|
|
100
|
+
throw new PlainError("Could not save the /declaw plugin settings. Check the permissions on its settings directory.");
|
|
101
|
+
} finally {
|
|
102
|
+
await rm(temporary, { force: true }).catch(() => {});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function saveModelChoice(path: string, choice: ModelChoice): Promise<void> {
|
|
107
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
108
|
+
try {
|
|
109
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
110
|
+
await writeFile(temporary, JSON.stringify({ version: 1, ...choice }, null, 2) + "\n", { mode: 0o600, flag: "wx" });
|
|
111
|
+
await rename(temporary, path);
|
|
112
|
+
} catch {
|
|
113
|
+
throw new PlainError("Could not save the /declaw model choice. Check the permissions on its settings directory.");
|
|
114
|
+
} finally {
|
|
115
|
+
await rm(temporary, { force: true }).catch(() => {});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { rewriteAnswer } from "../domain/rewrite.ts";
|
|
2
|
+
|
|
3
|
+
export interface SourceAnswer {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly text: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface PublishedRewrite {
|
|
9
|
+
readonly sourceEntryId: string;
|
|
10
|
+
readonly text: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface RewriteExecutionPorts {
|
|
14
|
+
/** Model adapter. It receives masked text and must honor cancellation. */
|
|
15
|
+
complete: (maskedAnswer: string, signal: AbortSignal) => Promise<string>;
|
|
16
|
+
/** Application adapter decides whether the source is still publishable. */
|
|
17
|
+
isCurrent: (source: SourceAnswer) => boolean;
|
|
18
|
+
/** Display/session adapter persists an accepted display projection. */
|
|
19
|
+
publish: (rewrite: PublishedRewrite) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type RewriteExecutionResult =
|
|
23
|
+
| { kind: "accepted"; sourceEntryId: string; text: string }
|
|
24
|
+
| { kind: "stale" };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Application use case for the effectful rewrite boundary.
|
|
28
|
+
* Validation happens before publication; stale work is discarded without a write.
|
|
29
|
+
*/
|
|
30
|
+
export async function executeRewrite(
|
|
31
|
+
source: SourceAnswer,
|
|
32
|
+
signal: AbortSignal,
|
|
33
|
+
ports: RewriteExecutionPorts,
|
|
34
|
+
): Promise<RewriteExecutionResult> {
|
|
35
|
+
const text = await rewriteAnswer(source.text, (maskedAnswer) => ports.complete(maskedAnswer, signal));
|
|
36
|
+
if (signal.aborted || !ports.isCurrent(source)) return { kind: "stale" };
|
|
37
|
+
ports.publish({ sourceEntryId: source.id, text });
|
|
38
|
+
return { kind: "accepted", sourceEntryId: source.id, text };
|
|
39
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Adapted from scowalt/paseo-plain, commit 35e63ee2e5f17a5bad40a8772c84fa5f21a8678f.
|
|
2
|
+
// Copyright 2026 scowalt. Apache-2.0: src/plugins/built-in/paseo-plain/upstream/LICENSE; notices: upstream/NOTICE.
|
|
3
|
+
// Local change: distinguish English make/go prose from standalone or explicitly run commands.
|
|
4
|
+
// One pass over prose: never run these expressions over generated placeholders.
|
|
5
|
+
const technical = /(`+)[^\n]*?\1|(?:^|(?<=\s))(?:sudo\s+)?(?:npm|npx|pnpm|bunx?|yarn|git|gh|curl|wget|ssh|docker|kubectl|paseo|systemctl|apt|brew|python3?|node|bash|pwsh|pip3?|cargo|dotnet)\s+[^\n]+|(?:^ {0,3}(?:\$ )?|(?<=\b[Rr]an )|(?<=\b[Rr]un )|(?<=\b[Ee]xecute )|(?<=\b[Ee]xecuted ))(?:make|go)\s+[^\n]+|^\s*(?:Error|TypeError|ReferenceError|SyntaxError|fatal):[^\n]+|\[[^\]\n]*\]\([^\n)]*\)|https?:\/\/[^\s<>]+|(?:[A-Za-z]:[\\/]|~\/|\.{1,2}\/|\/)[^\s<>"'`]+|\b(?:[\w.@-]+[\/\\])+[\w.@/-]+|\b[\w.-]+\.(?:json|ya?ml|toml|tsx?|jsx?|py|md|sh|ps1|go|rs|log|txt|sql|css|html)\b|"[^"\n]+"|“[^”\n]+”|(?<!\w)'[^'\n]+'(?!\w)|(?<!\w)[+-]?\d+(?:[.,:]\d+)*(?:%|\b)/gm;
|
|
6
|
+
|
|
7
|
+
/** Copy protected spans exactly; reject damaged, duplicated, or reordered placeholders. */
|
|
8
|
+
export async function rewriteText(original: string, complete: (masked: string) => Promise<string>): Promise<string> {
|
|
9
|
+
const parts: string[] = [];
|
|
10
|
+
const tokens: string[] = [];
|
|
11
|
+
// Deterministic, source-derived tokens keep this policy core free of randomness.
|
|
12
|
+
// Avoid a literal collision if the source already contains a KEEP-shaped token.
|
|
13
|
+
let nonce = [...original].reduce((hash, character) =>
|
|
14
|
+
((hash * 31) + character.codePointAt(0)!) >>> 0, 0);
|
|
15
|
+
const protect = (text: string) => {
|
|
16
|
+
parts.push(text);
|
|
17
|
+
let token: string;
|
|
18
|
+
do { token = `⟦KEEP_${nonce.toString(36)}_${tokens.length}⟧`; nonce = (nonce + 1) >>> 0; }
|
|
19
|
+
while (original.includes(token));
|
|
20
|
+
tokens.push(token);
|
|
21
|
+
return token;
|
|
22
|
+
};
|
|
23
|
+
const lines = original.split(/(?<=\n)/);
|
|
24
|
+
let prose = '', masked = '';
|
|
25
|
+
const maskProse = (text: string) => text.split(/(?<=\n)/).map(line => {
|
|
26
|
+
// Markdown list markers are structure. Leave them editable so a model can
|
|
27
|
+
// reflow or renumber a list without moving a protected factual number.
|
|
28
|
+
const marker = /^ {0,3}\d+[.)][ \t]+/.exec(line);
|
|
29
|
+
if (!marker) return line.replace(technical, protect);
|
|
30
|
+
return marker[0] + line.slice(marker[0].length).replace(technical, protect);
|
|
31
|
+
}).join('');
|
|
32
|
+
const flush = () => { masked += maskProse(prose); prose = ''; };
|
|
33
|
+
for (let i = 0; i < lines.length; i++) {
|
|
34
|
+
const opening = /^ {0,3}(`{3,}|~{3,})/.exec(lines[i]);
|
|
35
|
+
if (!opening) { prose += lines[i]; continue; }
|
|
36
|
+
flush();
|
|
37
|
+
const fence = opening[1];
|
|
38
|
+
const closing = new RegExp(`^ {0,3}${fence[0]}{${fence.length},}[ \\t]*(?:\\r?\\n)?$`);
|
|
39
|
+
let block = lines[i];
|
|
40
|
+
while (++i < lines.length) {
|
|
41
|
+
block += lines[i];
|
|
42
|
+
if (closing.test(lines[i])) break;
|
|
43
|
+
}
|
|
44
|
+
// Keep the final line break outside the placeholder, preserving paragraph structure.
|
|
45
|
+
const newline = block.endsWith('\n') ? '\n' : '';
|
|
46
|
+
masked += protect(newline ? block.slice(0, -1) : block) + newline;
|
|
47
|
+
}
|
|
48
|
+
flush();
|
|
49
|
+
const rewritten = await complete(masked);
|
|
50
|
+
if (!rewritten.trim() || rewritten.length > 64000) throw new Error('empty-or-oversize');
|
|
51
|
+
const found = rewritten.match(/⟦KEEP_[^⟧]+⟧/g) ?? [];
|
|
52
|
+
if (JSON.stringify(found) !== JSON.stringify(tokens)) throw new Error('preservation');
|
|
53
|
+
// Ordered Markdown markers are structure, not numeric content. Models may
|
|
54
|
+
// reflow prose into a list; factual numbers remain protected and checked.
|
|
55
|
+
const proseOnly = rewritten.replace(/⟦KEEP_[^⟧]+⟧/g, 'protected');
|
|
56
|
+
const withoutListMarkers = proseOnly.replace(/^ {0,3}\d+[.)][ \t]+/gm, '');
|
|
57
|
+
if (new RegExp(technical.source, technical.flags).test(withoutListMarkers) || /^ {0,3}(`{3,}|~{3,})/m.test(proseOnly)) throw new Error('preservation');
|
|
58
|
+
let result = rewritten;
|
|
59
|
+
for (let i = 0; i < tokens.length; i++) result = result.replace(tokens[i], () => parts[i]);
|
|
60
|
+
if (/⟦KEEP_[^⟧]+⟧/.test(result)) throw new Error('preservation');
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { rewriteText } from "./preservation.ts";
|
|
2
|
+
import { REWRITE_POLICY_VERSION } from "./styles.ts";
|
|
3
|
+
|
|
4
|
+
export const ENTRY_TYPE = "plain-rewrite";
|
|
5
|
+
export const POLICY_VERSION = REWRITE_POLICY_VERSION;
|
|
6
|
+
export const REWRITE_PROVIDER = "openai-codex";
|
|
7
|
+
export const REWRITE_MODEL = "gpt-5.6-luna";
|
|
8
|
+
export const REWRITE_THINKING = "low" as const;
|
|
9
|
+
export const MAX_INPUT_CHARS = 32_000;
|
|
10
|
+
export const TIMEOUT_MS = 60_000;
|
|
11
|
+
|
|
12
|
+
export interface PlainEntry {
|
|
13
|
+
version: typeof POLICY_VERSION;
|
|
14
|
+
sourceEntryId: string;
|
|
15
|
+
text: string;
|
|
16
|
+
model: string;
|
|
17
|
+
thinkingLevel?: typeof REWRITE_THINKING;
|
|
18
|
+
style?: string;
|
|
19
|
+
/** Snapshot metadata keeps historical entries readable after plugin removal. */
|
|
20
|
+
styleName?: string;
|
|
21
|
+
stylePlugin?: string;
|
|
22
|
+
styleVersion?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class PlainError extends Error {}
|
|
26
|
+
|
|
27
|
+
/** Pure application operation: the model gateway returns text, not a Pi message. */
|
|
28
|
+
export async function rewriteAnswer(text: string, complete: (masked: string) => Promise<string>): Promise<string> {
|
|
29
|
+
if (text.length > MAX_INPUT_CHARS) {
|
|
30
|
+
throw new PlainError("This answer is too long for /declaw (32,000 characters maximum).");
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const rewritten = await rewriteText(text, complete);
|
|
34
|
+
if (rewritten.trim() === text.trim()) {
|
|
35
|
+
throw new PlainError("The model returned unchanged text, so no duplicate was added. Try /declaw again if you want another attempt.");
|
|
36
|
+
}
|
|
37
|
+
return rewritten;
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (error instanceof PlainError) throw error;
|
|
40
|
+
if (error instanceof Error && ["preservation", "empty-or-oversize"].includes(error.message)) {
|
|
41
|
+
throw new PlainError("The rewrite failed the exact-text checks and was discarded. The original is unchanged.");
|
|
42
|
+
}
|
|
43
|
+
throw new PlainError("The rewrite request failed. Check /login or choose another model with /declaw model.");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Stop waiting even if a provider ignores cancellation; still observe any late rejection. */
|
|
48
|
+
export function abortable<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const cancel = () => reject(signal.reason ?? new Error("Cancelled"));
|
|
51
|
+
if (signal.aborted) cancel();
|
|
52
|
+
else signal.addEventListener("abort", cancel, { once: true });
|
|
53
|
+
work.then(resolve, reject).finally(() => signal.removeEventListener("abort", cancel));
|
|
54
|
+
});
|
|
55
|
+
}
|