pi-tinychat 0.1.0 → 0.1.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 -21
- package/README.md +139 -139
- package/bin/pi-tinychat.js +111 -111
- package/extension/plan.ts +281 -240
- package/package.json +68 -68
- package/public/app.js +3749 -3614
- package/public/favicon.svg +15 -15
- package/public/i18n.js +197 -169
- package/public/index.html +226 -226
- package/public/markdown.js +244 -244
- package/public/style.css +2814 -2762
- package/public/theme-init.js +18 -18
- package/src/compaction.js +139 -139
- package/src/errors.js +13 -13
- package/src/live.js +133 -133
- package/src/pi-path.js +70 -70
- package/src/pi-types.d.ts +85 -85
- package/src/pi.js +196 -196
- package/src/plan.js +91 -90
- package/src/server.js +910 -910
- package/src/sessions.js +256 -256
package/extension/plan.ts
CHANGED
|
@@ -1,240 +1,281 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Plan - minimal implementation plan tool for Pi TinyChat.
|
|
3
|
-
*
|
|
4
|
-
* The plan is stored in Pi's session JSONL twice: a custom entry is appended
|
|
5
|
-
* after every successful mutation so the Web UI can update immediately, and
|
|
6
|
-
* the tool result `details` keeps the plan readable by older sessions. When
|
|
7
|
-
* the branch changes, state is rebuilt from the active branch.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { StringEnum } from "@earendil-works/pi-ai";
|
|
11
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
-
import { Type } from "typebox";
|
|
13
|
-
|
|
14
|
-
const STATUSES = ["pending", "in_progress", "done", "cancelled"] as const;
|
|
15
|
-
type PlanStatus = (typeof STATUSES)[number];
|
|
16
|
-
|
|
17
|
-
const PLAN_STATE_ENTRY = "tiny_plan_state";
|
|
18
|
-
const LEGACY_PLAN_STATE_ENTRY = "dock_plan_state";
|
|
19
|
-
const LEGACY_PLAN_TOOL = "dock_plan";
|
|
20
|
-
|
|
21
|
-
interface PlanItem {
|
|
22
|
-
id: string;
|
|
23
|
-
text: string;
|
|
24
|
-
status: PlanStatus;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
interface PlanDetails {
|
|
28
|
-
title?: string;
|
|
29
|
-
items: PlanItem[];
|
|
30
|
-
nextId: number;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
return
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
id:
|
|
203
|
-
text
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Plan - minimal implementation plan tool for Pi TinyChat.
|
|
3
|
+
*
|
|
4
|
+
* The plan is stored in Pi's session JSONL twice: a custom entry is appended
|
|
5
|
+
* after every successful mutation so the Web UI can update immediately, and
|
|
6
|
+
* the tool result `details` keeps the plan readable by older sessions. When
|
|
7
|
+
* the branch changes, state is rebuilt from the active branch.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
11
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { Type } from "typebox";
|
|
13
|
+
|
|
14
|
+
const STATUSES = ["pending", "in_progress", "done", "cancelled"] as const;
|
|
15
|
+
type PlanStatus = (typeof STATUSES)[number];
|
|
16
|
+
|
|
17
|
+
const PLAN_STATE_ENTRY = "tiny_plan_state";
|
|
18
|
+
const LEGACY_PLAN_STATE_ENTRY = "dock_plan_state";
|
|
19
|
+
const LEGACY_PLAN_TOOL = "dock_plan";
|
|
20
|
+
|
|
21
|
+
interface PlanItem {
|
|
22
|
+
id: string;
|
|
23
|
+
text: string;
|
|
24
|
+
status: PlanStatus;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface PlanDetails {
|
|
28
|
+
title?: string;
|
|
29
|
+
items: PlanItem[];
|
|
30
|
+
nextId: number;
|
|
31
|
+
note?: string;
|
|
32
|
+
action: string;
|
|
33
|
+
error?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface PlanState {
|
|
37
|
+
title?: string;
|
|
38
|
+
items: PlanItem[];
|
|
39
|
+
nextId: number;
|
|
40
|
+
note?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const ItemParam = Type.Object({
|
|
44
|
+
id: Type.Optional(Type.String({ description: "Existing stable item id (omit for a new item)" })),
|
|
45
|
+
text: Type.Optional(Type.String({ description: "Item text" })),
|
|
46
|
+
status: Type.Optional(StringEnum(STATUSES)),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const PlanParams = Type.Object({
|
|
50
|
+
action: StringEnum(["set", "add", "update", "remove", "list", "clear", "note"] as const),
|
|
51
|
+
title: Type.Optional(Type.String({ description: "Plan title (for set)" })),
|
|
52
|
+
items: Type.Optional(
|
|
53
|
+
Type.Array(ItemParam, {
|
|
54
|
+
description: "Initial ordered plan (for set). Keep existing ids to preserve status.",
|
|
55
|
+
}),
|
|
56
|
+
),
|
|
57
|
+
text: Type.Optional(Type.String({ description: "Item text (for add/update)" })),
|
|
58
|
+
id: Type.Optional(Type.String({ description: "Item id (for update/remove)" })),
|
|
59
|
+
status: Type.Optional(StringEnum(STATUSES)),
|
|
60
|
+
note: Type.Optional(
|
|
61
|
+
Type.String({
|
|
62
|
+
description:
|
|
63
|
+
"One short progress sentence for the user, in their language. Shown in the main screen. Include with set/update at phase changes, or use action=note for a mid-step update.",
|
|
64
|
+
}),
|
|
65
|
+
),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const MARKS: Record<PlanStatus, string> = {
|
|
69
|
+
done: "[x]",
|
|
70
|
+
in_progress: "[>]",
|
|
71
|
+
pending: "[ ]",
|
|
72
|
+
cancelled: "[-]",
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function clonePlan(plan: PlanState): PlanState {
|
|
76
|
+
return {
|
|
77
|
+
...(plan.title ? { title: plan.title } : {}),
|
|
78
|
+
...(plan.note ? { note: plan.note } : {}),
|
|
79
|
+
items: plan.items.map((item) => ({ ...item })),
|
|
80
|
+
nextId: plan.nextId,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function normalizeNote(value: unknown): string | undefined {
|
|
85
|
+
if (typeof value !== "string") return undefined;
|
|
86
|
+
const note = value.replace(/\s+/g, " ").trim();
|
|
87
|
+
return note || undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function formatPlan(plan: PlanState): string {
|
|
91
|
+
const lines = plan.title ? [plan.title] : [];
|
|
92
|
+
for (const item of plan.items) {
|
|
93
|
+
lines.push(`${MARKS[item.status]} ${item.id}: ${item.text}`);
|
|
94
|
+
}
|
|
95
|
+
if (!lines.length) return "No plan";
|
|
96
|
+
if (!plan.items.length) lines.push("No items");
|
|
97
|
+
return lines.join("\n");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function validStatus(value: unknown): PlanStatus {
|
|
101
|
+
return STATUSES.includes(value as PlanStatus) ? (value as PlanStatus) : "pending";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function planFromDetails(details: unknown): PlanState | null {
|
|
105
|
+
if (!details || typeof details !== "object") return null;
|
|
106
|
+
const value = details as {
|
|
107
|
+
title?: unknown;
|
|
108
|
+
items?: unknown;
|
|
109
|
+
nextId?: unknown;
|
|
110
|
+
note?: unknown;
|
|
111
|
+
};
|
|
112
|
+
if (!Array.isArray(value.items)) return null;
|
|
113
|
+
const items: PlanItem[] = [];
|
|
114
|
+
for (const raw of value.items) {
|
|
115
|
+
if (!raw || typeof raw !== "object") continue;
|
|
116
|
+
const item = raw as { id?: unknown; text?: unknown; status?: unknown };
|
|
117
|
+
if (typeof item.id !== "string" || typeof item.text !== "string") continue;
|
|
118
|
+
items.push({ id: item.id, text: item.text, status: validStatus(item.status) });
|
|
119
|
+
}
|
|
120
|
+
const plan: PlanState = {
|
|
121
|
+
items,
|
|
122
|
+
nextId: typeof value.nextId === "number" && value.nextId > 0 ? value.nextId : 1,
|
|
123
|
+
};
|
|
124
|
+
if (typeof value.title === "string" && value.title.trim()) plan.title = value.title.trim();
|
|
125
|
+
const note = normalizeNote(value.note);
|
|
126
|
+
if (note) plan.note = note;
|
|
127
|
+
return plan;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export default function (pi: ExtensionAPI) {
|
|
131
|
+
let plan: PlanState = { items: [], nextId: 1 };
|
|
132
|
+
|
|
133
|
+
const reconstruct = (ctx: ExtensionContext) => {
|
|
134
|
+
plan = { items: [], nextId: 1 };
|
|
135
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
136
|
+
if (
|
|
137
|
+
entry.type === "custom" &&
|
|
138
|
+
(entry.customType === PLAN_STATE_ENTRY || entry.customType === LEGACY_PLAN_STATE_ENTRY)
|
|
139
|
+
) {
|
|
140
|
+
const restored = planFromDetails(entry.data);
|
|
141
|
+
if (restored) plan = restored;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (entry.type !== "message") continue;
|
|
145
|
+
const message = entry.message;
|
|
146
|
+
if (
|
|
147
|
+
message.role !== "toolResult" ||
|
|
148
|
+
(message.toolName !== "tiny_plan" && message.toolName !== LEGACY_PLAN_TOOL)
|
|
149
|
+
) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const restored = planFromDetails(message.details);
|
|
153
|
+
if (restored) plan = restored;
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
pi.on("session_start", async (_event, ctx) => reconstruct(ctx));
|
|
158
|
+
pi.on("session_tree", async (_event, ctx) => reconstruct(ctx));
|
|
159
|
+
|
|
160
|
+
pi.registerTool({
|
|
161
|
+
name: "tiny_plan",
|
|
162
|
+
label: "Plan",
|
|
163
|
+
description:
|
|
164
|
+
"Track a multi-step implementation plan and the current focus. For any task that will need more than a few tool calls—including investigation, design, or debugging—call set with a short provisional plan before the first substantive tool call; include the steps you can foresee through final verification and refine the plan in place as you learn. Keep the current item granularity; do not split items into micro-steps. Then keep it current while working: when a step starts mark it in_progress, and as soon as it finishes mark it done and the next step in_progress. Never leave the first step in_progress during later work and never mark several steps done together at the end. Keep exactly one item in_progress. Include a one-sentence note in the user's language when you start or finish a step, and use action=note for a meaningful mid-step update; the note is shown in the main screen, so do not repeat the plan or narrate every tool call. Use update for one item, add only for a genuinely new step discovered later, remove to delete one, and clear when finished. Reuse item ids. Continue the same plan through follow-up or steering messages. When the previous work is complete and a new independent task begins, call set to replace the old plan. Do not clear the plan just because a new user message arrived.",
|
|
165
|
+
promptSnippet: "Track a multi-step plan, progress and the current focus",
|
|
166
|
+
promptGuidelines: [
|
|
167
|
+
"Before the first substantive tool call for a multi-step task, including investigation or design, create a short provisional plan in one tiny_plan set call.",
|
|
168
|
+
"Update tiny_plan immediately when a step starts or finishes; never batch completed steps into the final call.",
|
|
169
|
+
"Include a one-sentence note in the user's language at phase changes; use action=note for a mid-step update and keep it brief.",
|
|
170
|
+
],
|
|
171
|
+
executionMode: "sequential",
|
|
172
|
+
parameters: PlanParams,
|
|
173
|
+
|
|
174
|
+
async execute(_toolCallId, params) {
|
|
175
|
+
const snapshot = () => clonePlan(plan);
|
|
176
|
+
const applyNote = (value: string | undefined) => {
|
|
177
|
+
if (value === undefined) return;
|
|
178
|
+
const note = normalizeNote(value);
|
|
179
|
+
if (note) plan.note = note;
|
|
180
|
+
else delete plan.note;
|
|
181
|
+
};
|
|
182
|
+
const fail = (message: string, action: string) => ({
|
|
183
|
+
content: [{ type: "text" as const, text: `Error: ${message}` }],
|
|
184
|
+
details: { ...snapshot(), action, error: message } as PlanDetails,
|
|
185
|
+
});
|
|
186
|
+
const result = (action: string, text: string) => {
|
|
187
|
+
if (action !== "list") pi.appendEntry(PLAN_STATE_ENTRY, clonePlan(plan));
|
|
188
|
+
return {
|
|
189
|
+
content: [{ type: "text" as const, text }],
|
|
190
|
+
details: { ...snapshot(), action } as PlanDetails,
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
switch (params.action) {
|
|
195
|
+
case "set": {
|
|
196
|
+
const incoming = params.items ?? [];
|
|
197
|
+
const existing = new Map(plan.items.map((item) => [item.id, item]));
|
|
198
|
+
const next: PlanItem[] = [];
|
|
199
|
+
let nextId = plan.nextId;
|
|
200
|
+
const seen = new Set<string>();
|
|
201
|
+
for (const raw of incoming) {
|
|
202
|
+
const current = raw.id ? existing.get(raw.id) : undefined;
|
|
203
|
+
const text = raw.text ?? current?.text;
|
|
204
|
+
if (!text || !text.trim()) return fail("Every item needs text", "set");
|
|
205
|
+
const id = current ? current.id : `p${nextId++}`;
|
|
206
|
+
if (seen.has(id)) return fail(`Duplicate item id: ${id}`, "set");
|
|
207
|
+
seen.add(id);
|
|
208
|
+
next.push({
|
|
209
|
+
id,
|
|
210
|
+
text: text.trim(),
|
|
211
|
+
status: raw.status ?? current?.status ?? "pending",
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
const note = normalizeNote(params.note);
|
|
215
|
+
plan = {
|
|
216
|
+
...(params.title?.trim() ? { title: params.title.trim() } : {}),
|
|
217
|
+
...(note ? { note } : {}),
|
|
218
|
+
items: next,
|
|
219
|
+
nextId,
|
|
220
|
+
};
|
|
221
|
+
return result("set", formatPlan(plan));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
case "add": {
|
|
225
|
+
if (!plan.items.length) {
|
|
226
|
+
return fail(
|
|
227
|
+
"Create the plan first with action=set, then use add only for a new step",
|
|
228
|
+
"add",
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
if (!params.text?.trim()) return fail("text required for add", "add");
|
|
232
|
+
const item: PlanItem = {
|
|
233
|
+
id: `p${plan.nextId++}`,
|
|
234
|
+
text: params.text.trim(),
|
|
235
|
+
status: params.status ?? "pending",
|
|
236
|
+
};
|
|
237
|
+
plan.items.push(item);
|
|
238
|
+
applyNote(params.note);
|
|
239
|
+
return result("add", `Added ${item.id}: ${item.text}\n${formatPlan(plan)}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
case "update": {
|
|
243
|
+
if (!params.id) return fail("id required for update", "update");
|
|
244
|
+
const item = plan.items.find((candidate) => candidate.id === params.id);
|
|
245
|
+
if (!item) return fail(`Item ${params.id} not found`, "update");
|
|
246
|
+
if (params.text?.trim()) item.text = params.text.trim();
|
|
247
|
+
if (params.status) item.status = params.status;
|
|
248
|
+
applyNote(params.note);
|
|
249
|
+
return result("update", formatPlan(plan));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
case "remove": {
|
|
253
|
+
if (!params.id) return fail("id required for remove", "remove");
|
|
254
|
+
const index = plan.items.findIndex((candidate) => candidate.id === params.id);
|
|
255
|
+
if (index === -1) return fail(`Item ${params.id} not found`, "remove");
|
|
256
|
+
plan.items.splice(index, 1);
|
|
257
|
+
applyNote(params.note);
|
|
258
|
+
return result("remove", formatPlan(plan));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
case "clear": {
|
|
262
|
+
plan = { items: [], nextId: 1 };
|
|
263
|
+
return result("clear", "Plan cleared");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
case "note": {
|
|
267
|
+
const note = normalizeNote(params.note);
|
|
268
|
+
if (!note) return fail("note required for note", "note");
|
|
269
|
+
plan.note = note;
|
|
270
|
+
return result("note", `Note: ${note}`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
case "list":
|
|
274
|
+
return result("list", formatPlan(plan));
|
|
275
|
+
|
|
276
|
+
default:
|
|
277
|
+
return fail(`Unknown action: ${params.action}`, "list");
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
}
|