@xynogen/pix-core 0.6.1 → 0.6.3

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 CHANGED
@@ -51,6 +51,21 @@ Pi activates extensions per installed package via each package's `pi.extensions`
51
51
  | `pix-subagent` | `agent` / `agent_control` — planner-driven sub-agents with live widget |
52
52
  | `compaction` | Built into pix-core: replaces pi's built-in context compaction with two levers. **Summary prompt is always pix's** — every compaction (manual `/compact`, threshold, overflow) generates the summary with the current conversation model (no silent routing) from an editable source prompt. **Trigger is pix's when `compaction.triggerPercent > 0`** — after each settled turn pix reads live context usage and compacts at `max(contextWindow × triggerPercent, compaction.minimumTokens)`. The default 100K floor prevents low percentages from compacting too early (for example, 10% of a 300K model waits for 100K rather than 30K); `0` disables pix's trigger and lets pi decide, while pix's summary prompt still applies. After a pix-triggered compaction pix sends a short, visible "resume" user message so the agent continues on its own. Every trigger, resume, and summary run emits a notify line (reason, model, token counts). |
53
53
 
54
+ ## Plan mode
55
+
56
+ Use `/plan` to create, edit, execute, or delete saved plans in `<project>/.pi/plans/`.
57
+ Use `Shift+Tab` or `Ctrl+Alt+P` to toggle plan mode.
58
+ Use `Tab` in an empty prompt to cycle the thinking level.
59
+
60
+ Plan mode keeps the available tools unchanged.
61
+ Only `edit` and `write` are restricted to files inside the current project's `.pi/plans/`.
62
+ The guard rejects other project paths, path traversal outside this directory, and symlink targets or parents.
63
+ The same restrictions apply when a session restores plan mode.
64
+
65
+ Other tools, including shell commands, are not sandboxed by plan mode.
66
+ The visible plan guide asks the agent to explore without changing project files.
67
+ Use `pix-gate` for command restrictions.
68
+
54
69
  ## Install
55
70
 
56
71
  ```bash
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xynogen/pix-core",
3
- "version": "0.6.1",
4
- "description": "Pi extension bundle — installs and activates all core pix-* extensions",
3
+ "version": "0.6.3",
4
+ "description": "Pi extension bundle \u2014 installs and activates all core pix-* extensions",
5
5
  "type": "module",
6
6
  "main": "src/extension.ts",
7
7
  "exports": {
@@ -43,32 +43,32 @@
43
43
  "access": "public"
44
44
  },
45
45
  "dependencies": {
46
- "@xynogen/pix-runtime": "^0.12.2",
47
- "@xynogen/pix-data": "^0.5.3",
48
- "@xynogen/pix-pretty": "^1.29.0",
49
- "@xynogen/pix-welcome": "^0.2.5",
50
- "@xynogen/pix-footer": "^0.2.7",
51
- "@xynogen/pix-diagnostics": "^0.3.5",
52
- "@xynogen/pix-display": "^0.3.0",
53
- "@xynogen/pix-prompts": "^0.5.4",
54
- "@xynogen/pix-skills": "^0.9.1",
55
- "@xynogen/pix-models": "^0.3.0",
56
- "@xynogen/pix-update": "^0.2.4",
57
- "@xynogen/pix-commands": "^0.6.8",
58
- "@xynogen/pix-nudge": "^0.3.2",
59
- "@xynogen/pix-read": "^0.4.2",
60
- "@xynogen/pix-write": "^0.2.7",
61
- "@xynogen/pix-edit": "^0.2.7",
62
- "@xynogen/pix-find": "^0.3.3",
63
- "@xynogen/pix-grep": "^0.3.3",
64
- "@xynogen/pix-ls": "^0.3.3",
65
- "@xynogen/pix-bash": "^0.2.9",
66
- "@xynogen/pix-powershell": "^0.1.0",
67
- "@xynogen/pix-todo": "^0.4.5",
68
- "@xynogen/pix-ask": "^0.3.0",
69
- "@xynogen/pix-optimizer": "^1.3.0",
70
- "@xynogen/pix-gate": "^0.4.0",
71
- "@xynogen/pix-subagent": "^0.10.5"
46
+ "@xynogen/pix-runtime": "^0.13.0",
47
+ "@xynogen/pix-data": "^0.5.4",
48
+ "@xynogen/pix-pretty": "^1.30.0",
49
+ "@xynogen/pix-welcome": "^0.2.7",
50
+ "@xynogen/pix-footer": "^0.2.8",
51
+ "@xynogen/pix-diagnostics": "^0.3.6",
52
+ "@xynogen/pix-display": "^0.3.1",
53
+ "@xynogen/pix-prompts": "^0.5.6",
54
+ "@xynogen/pix-skills": "^0.9.2",
55
+ "@xynogen/pix-models": "^0.3.1",
56
+ "@xynogen/pix-update": "^0.2.5",
57
+ "@xynogen/pix-commands": "^0.6.9",
58
+ "@xynogen/pix-nudge": "^0.3.3",
59
+ "@xynogen/pix-read": "^0.4.3",
60
+ "@xynogen/pix-write": "^0.2.8",
61
+ "@xynogen/pix-edit": "^0.2.8",
62
+ "@xynogen/pix-find": "^0.3.4",
63
+ "@xynogen/pix-grep": "^0.3.4",
64
+ "@xynogen/pix-ls": "^0.3.4",
65
+ "@xynogen/pix-bash": "^0.2.10",
66
+ "@xynogen/pix-powershell": "^0.1.1",
67
+ "@xynogen/pix-todo": "^0.4.6",
68
+ "@xynogen/pix-ask": "^0.3.2",
69
+ "@xynogen/pix-optimizer": "^1.3.2",
70
+ "@xynogen/pix-gate": "^0.4.1",
71
+ "@xynogen/pix-subagent": "^0.10.7"
72
72
  },
73
73
  "peerDependencies": {
74
74
  "@earendil-works/pi-ai": "*",
package/src/compaction.ts CHANGED
@@ -26,7 +26,7 @@
26
26
  */
27
27
 
28
28
  import { complete } from "@earendil-works/pi-ai/compat";
29
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
29
+ import type { ContextEditableContent, ExtensionAPI } from "@earendil-works/pi-coding-agent";
30
30
  import { convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";
31
31
  import { getErrorMessage } from "@xynogen/pix-pretty/utils";
32
32
  import { config } from "@xynogen/pix-runtime/config";
@@ -95,6 +95,43 @@ export function resumeDecisionAfterCompaction(args: {
95
95
  return "resume";
96
96
  }
97
97
 
98
+ // Pi cuts compaction only at message boundaries, so one huge message (a pasted
99
+ // HAR/log, a giant tool result) can't be summarized away: "Nothing to compact".
100
+ // Over threshold we instead trim oversized text to head+tail via a visible,
101
+ // append-only context_edit — the original stays in the session file.
102
+ // ponytail: fixed char limits (~20k tok kept per message); make them config if
103
+ // someone needs to tune per model.
104
+ const MAX_MESSAGE_CHARS = 80_000;
105
+ const KEEP_CHARS = 20_000;
106
+
107
+ type TextOrImage = { type: string; text?: string };
108
+
109
+ /** Head+tail trim of oversized text. Undefined when the text already fits. */
110
+ export function truncateLargeText(text: string): string | undefined {
111
+ if (text.length <= MAX_MESSAGE_CHARS) return undefined;
112
+ const cut = text.length - 2 * KEEP_CHARS;
113
+ return `${text.slice(0, KEEP_CHARS)}\n\n[… ${cut.toLocaleString()} chars trimmed by pix compaction (original kept in session file) …]\n\n${text.slice(-KEEP_CHARS)}`;
114
+ }
115
+
116
+ /** Trimmed copy of user/tool-result content, or undefined when nothing is oversized. */
117
+ export function shrinkContent(
118
+ content: string | TextOrImage[],
119
+ ): { content: string | TextOrImage[]; saved: number } | undefined {
120
+ if (typeof content === "string") {
121
+ const t = truncateLargeText(content);
122
+ return t === undefined ? undefined : { content: t, saved: content.length - t.length };
123
+ }
124
+ let saved = 0;
125
+ const next = content.map((part) => {
126
+ if (part.type !== "text" || typeof part.text !== "string") return part;
127
+ const t = truncateLargeText(part.text);
128
+ if (t === undefined) return part;
129
+ saved += part.text.length - t.length;
130
+ return { ...part, text: t };
131
+ });
132
+ return saved > 0 ? { content: next, saved } : undefined;
133
+ }
134
+
98
135
  // Sent as a user message after a self-triggered compaction so the agent picks
99
136
  // its task back up on its own. Kept short — the freshly written summary already
100
137
  // carries the goal, progress, and next steps. Edit to taste.
@@ -181,6 +218,44 @@ export default function registerCompaction(pi: ExtensionAPI): void {
181
218
  // agent_settled re-fires, and we compact again every turn. Reset by /compact or
182
219
  // a new session; a session-scoped bool is the smallest fix.
183
220
  let triggerDisabled = false;
221
+
222
+ // Runs before agent_settled; committed edits shrink the context the trigger
223
+ // below measures, so a single oversized message no longer blocks compaction.
224
+ pi.on("agent_before_settle", (event, ctx) => {
225
+ const { triggerPercent, minimumTokens } = config(compactionSection);
226
+ if (triggerPercent <= 0) return;
227
+ const usage = ctx.getContextUsage?.();
228
+ if (!usage || usage.tokens === null) return;
229
+ if (
230
+ usage.tokens < compactionThresholdTokens(usage.contextWindow, triggerPercent, minimumTokens)
231
+ )
232
+ return;
233
+
234
+ const edits: typeof event.entries = [];
235
+ let saved = 0;
236
+ for (const { sourceEntry, messages } of event.context.contextEntries) {
237
+ const msg = messages[0];
238
+ const editable =
239
+ sourceEntry.type === "custom_message" ||
240
+ (sourceEntry.type === "message" && (msg?.role === "user" || msg?.role === "toolResult"));
241
+ if (!editable || !msg || !("content" in msg)) continue;
242
+ const shrunk = shrinkContent(msg.content as string | TextOrImage[]);
243
+ if (!shrunk) continue;
244
+ saved += shrunk.saved;
245
+ edits.push({
246
+ type: "context_edit",
247
+ targetId: sourceEntry.id,
248
+ replacement: { content: shrunk.content as ContextEditableContent },
249
+ });
250
+ }
251
+ if (edits.length === 0) return;
252
+ ctx.ui.notify(
253
+ `Compaction: trimmed ${edits.length} oversized message(s), ~${Math.round(saved / 4).toLocaleString()} tok (head+tail kept; originals stay in session file)`,
254
+ "info",
255
+ );
256
+ return { entries: [...event.entries, ...edits] };
257
+ });
258
+
184
259
  pi.on("agent_settled", (_event, ctx) => {
185
260
  if (compacting || triggerDisabled) return;
186
261
  const { triggerPercent, minimumTokens } = config(compactionSection);
@@ -259,7 +334,16 @@ export default function registerCompaction(pi: ExtensionAPI): void {
259
334
  },
260
335
  onError: (err) => {
261
336
  compacting = false;
262
- ctx.ui.notify(`Compaction trigger failed: ${err.message}`, "error");
337
+ // Pi already reported the failure (compaction_end). "Nothing to compact" /
338
+ // "Already compacted" will fail identically next turn, so latch off instead
339
+ // of re-firing every agent_settled. Oversized messages are trimmed in
340
+ // agent_before_settle, so this is now only a backstop.
341
+ if (!/nothing to compact|already compacted/i.test(err.message)) return;
342
+ triggerDisabled = true;
343
+ ctx.ui.notify(
344
+ `Compaction: ${tokens.toLocaleString()} tok but nothing older to summarize. Auto-trigger disabled for this session; start a new session if context keeps growing.`,
345
+ "warning",
346
+ );
263
347
  },
264
348
  });
265
349
  });
package/src/extension.ts CHANGED
@@ -42,7 +42,7 @@ import registerCompaction from "./compaction.ts";
42
42
  import deferNonCoreTools from "./defer-tools.ts";
43
43
  import registerPlanMode from "./plan-mode.ts";
44
44
 
45
- type PixExtension = (pi: ExtensionAPI) => void;
45
+ type PixExtension = (pi: ExtensionAPI) => void | Promise<void>;
46
46
 
47
47
  // Compile-time boundary: every member must accept the Pi host contract.
48
48
  const MEMBERS = [
@@ -89,6 +89,6 @@ const MEMBERS = [
89
89
  registerPlanMode,
90
90
  ] satisfies readonly PixExtension[];
91
91
 
92
- export default function (pi: ExtensionAPI): void {
93
- for (const register of MEMBERS) register(pi);
92
+ export default async function (pi: ExtensionAPI): Promise<void> {
93
+ for (const register of MEMBERS) await register(pi);
94
94
  }
package/src/plan-modal.ts CHANGED
@@ -31,6 +31,16 @@ export type PlanModalResult =
31
31
  | { kind: "toggle" };
32
32
 
33
33
  const LIST_ROWS = 10;
34
+ const relativeTime = new Intl.RelativeTimeFormat("en", { numeric: "always" });
35
+
36
+ export function planAge(updated_at: number | undefined, now = Date.now()): string {
37
+ if (updated_at === undefined || !Number.isFinite(updated_at)) return "";
38
+ const seconds = Math.max(0, Math.floor((now - updated_at) / 1000));
39
+ if (seconds < 60) return "just now";
40
+ if (seconds < 3600) return relativeTime.format(-Math.floor(seconds / 60), "minute");
41
+ if (seconds < 86400) return relativeTime.format(-Math.floor(seconds / 3600), "hour");
42
+ return relativeTime.format(-Math.floor(seconds / 86400), "day");
43
+ }
34
44
 
35
45
  type View = "list" | "plan" | "confirm";
36
46
 
@@ -56,7 +66,16 @@ export class PlanModal {
56
66
  label: "+ New plan",
57
67
  description: "Plan mode on + plan guide in the prompt bar",
58
68
  },
59
- ...plans.map((p, i) => ({ value: String(i), label: p.title, description: p.description })),
69
+ ...plans.map((p, i) => ({
70
+ value: String(i),
71
+ label: p.title,
72
+ description: [
73
+ p.updated_at === undefined ? "" : `Updated ${planAge(p.updated_at)}`,
74
+ p.description,
75
+ ]
76
+ .filter(Boolean)
77
+ .join(" · "),
78
+ })),
60
79
  ];
61
80
  this.list = new SelectList(items, LIST_ROWS, selectListTheme(theme));
62
81
  this.list.onSelect = (item) => {
@@ -164,6 +183,7 @@ export class PlanModal {
164
183
  confirm ? t.fg("warning", t.bold(`Delete ${plan.file}?`)) : title(plan.title),
165
184
  ...(plan.description ? [t.fg("dim", plan.description)] : []),
166
185
  hint(`${this.planDir}/${plan.file}`),
186
+ ...(plan.updated_at === undefined ? [] : [hint(`Updated ${planAge(plan.updated_at)}`)]),
167
187
  ];
168
188
  body = plan.body.split("\n");
169
189
  footer = [
package/src/plan-mode.ts CHANGED
@@ -5,8 +5,8 @@
5
5
  * Tab in an empty prompt cycles the thinking level (Pi's default Shift+Tab action).
6
6
  *
7
7
  * While plan mode is on:
8
- * - active tools shrink to `read` + `write` + `bash` (previous set restored on exit);
9
- * - `write` may only target `.pi/plans/` (tool_call guard, visible block);
8
+ * - available tools stay unchanged;
9
+ * - `edit` and `write` may only target this project's `.pi/plans/`;
10
10
  * - no hidden prompt: "+ New plan" pastes PLAN_GUIDE into the prompt bar as a chip (sent as <paste>).
11
11
  *
12
12
  * Plan file format: YAML-ish frontmatter (`title`, `description`) + markdown
@@ -14,7 +14,7 @@
14
14
  * message — no hidden automation.
15
15
  */
16
16
 
17
- import { mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
17
+ import { mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
18
18
  import { isAbsolute, join, relative, resolve } from "node:path";
19
19
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
20
20
  import { CustomEditor, isToolCallEventType } from "@earendil-works/pi-coding-agent";
@@ -23,19 +23,19 @@ import { chipTag } from "@xynogen/pix-pretty/chips";
23
23
  import { icon } from "@xynogen/pix-pretty/icon-catalog";
24
24
  import { modalOverlayOptions } from "@xynogen/pix-pretty/modal-frame";
25
25
  import { projectDir } from "@xynogen/pix-runtime/paths";
26
+ import { validateOutputPath } from "@xynogen/pix-runtime/safe-path";
26
27
  import { PlanModal, type PlanModalResult } from "./plan-modal.ts";
27
28
 
28
- // ponytail: bash is not sandboxed here; PLAN_GUIDE asks for read-only use and pix-gate
29
- // still guards destructive commands. Add an allowlist guard if the model abuses it.
30
- const PLAN_TOOLS = ["read", "write", "bash"];
29
+ // ponytail: only edit/write paths are guarded here. Other tools are not sandboxed.
30
+ // PLAN_GUIDE asks for read-only use. Use pix-gate for command restrictions.
31
31
  const PLAN_DIR = `${projectDir()}/plans`; // "/" so prompts and hints read the same on Windows
32
32
  const STATE_ENTRY = "pix-plan-mode";
33
33
 
34
34
  // Visible guide: /plan → "+ New plan" pastes this into the prompt bar as a chip for the user
35
35
  // to read, edit, and send. Nothing is injected into the system prompt.
36
36
  export const PLAN_GUIDE = `[PLAN MODE] Write an implementation plan. Do not change project code.
37
- Explore with \`read\` and read-only \`bash\` (git log/status/diff, ls, rg, --help). Never use bash to edit, install, commit, or delete.
38
- Save ONE plan with \`write\` to \`.pi/plans/YYYY-MM-DD-<feature-name>.md\` (writes elsewhere are blocked).
37
+ Use any available tool to explore without changing project files. Never use tools to edit project code, install, commit, or delete.
38
+ Save ONE plan with \`write\` to \`.pi/plans/YYYY-MM-DD-<feature-name>.md\`. Use \`edit\` or \`write\` to revise it. Both tools are restricted to this project's \`.pi/plans/\`.
39
39
 
40
40
  Format:
41
41
  ---
@@ -60,6 +60,7 @@ When done, say: "Plan saved to <path>. Use /plan to run it."
60
60
  The goal follows this guide.`;
61
61
 
62
62
  export interface Plan {
63
+ updated_at?: number;
63
64
  file: string;
64
65
  title: string;
65
66
  description: string;
@@ -85,7 +86,7 @@ export function isPlanPath(cwd: string, path: string): boolean {
85
86
  return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
86
87
  }
87
88
 
88
- function listPlans(cwd: string): Plan[] {
89
+ export function listPlans(cwd: string): Plan[] {
89
90
  const dir = resolve(cwd, PLAN_DIR);
90
91
  let files: string[];
91
92
  try {
@@ -94,9 +95,11 @@ function listPlans(cwd: string): Plan[] {
94
95
  return [];
95
96
  }
96
97
  return files
97
- .sort()
98
- .reverse()
99
- .map((f) => parsePlan(f, readFileSync(join(dir, f), "utf-8")));
98
+ .map((f) => ({
99
+ ...parsePlan(f, readFileSync(join(dir, f), "utf-8")),
100
+ updated_at: statSync(join(dir, f)).mtimeMs,
101
+ }))
102
+ .sort((a, b) => b.updated_at - a.updated_at || b.file.localeCompare(a.file));
100
103
  }
101
104
 
102
105
  /**
@@ -129,16 +132,10 @@ export function modeStatus(
129
132
 
130
133
  export default function registerPlanMode(pi: ExtensionAPI): void {
131
134
  let enabled = false;
132
- let toolsBefore: string[] | undefined;
133
135
 
134
136
  function apply(ctx: ExtensionContext, on: boolean): void {
135
137
  if (on && !enabled) {
136
- toolsBefore = pi.getActiveTools();
137
- pi.setActiveTools(PLAN_TOOLS);
138
138
  mkdirSync(resolve(ctx.cwd, PLAN_DIR), { recursive: true });
139
- } else if (!on && enabled) {
140
- if (toolsBefore) pi.setActiveTools(toolsBefore);
141
- toolsBefore = undefined;
142
139
  }
143
140
  const changed = enabled !== on;
144
141
  enabled = on;
@@ -146,12 +143,12 @@ export default function registerPlanMode(pi: ExtensionAPI): void {
146
143
  "plan",
147
144
  modeStatus(on, (r, t) => ctx.ui.theme.fg(r, t)),
148
145
  );
149
- pi.appendEntry(STATE_ENTRY, { enabled, toolsBefore });
146
+ pi.appendEntry(STATE_ENTRY, { enabled });
150
147
  if (changed) {
151
148
  ctx.ui.notify(
152
149
  on
153
- ? "Plan mode on: read + bash + write (.pi/plans only)."
154
- : "Plan mode off: tools restored.",
150
+ ? "Plan mode on: all tools available. Edit/write only inside this project's .pi/plans/."
151
+ : "Plan mode off: edit/write restrictions removed.",
155
152
  );
156
153
  }
157
154
  }
@@ -230,19 +227,16 @@ export default function registerPlanMode(pi: ExtensionAPI): void {
230
227
 
231
228
  pi.on("tool_call", async (event, ctx) => {
232
229
  if (!enabled) return;
233
- if (!PLAN_TOOLS.includes(event.toolName)) {
230
+ if (!isToolCallEventType("edit", event) && !isToolCallEventType("write", event)) return;
231
+ const path = event.input.path;
232
+ if (typeof path !== "string" || !isPlanPath(ctx.cwd, path)) {
234
233
  return {
235
234
  block: true,
236
- reason:
237
- "Plan mode: only read, bash, and write are allowed. Toggle off with Shift+Tab or ctrl+alt+p.",
238
- };
239
- }
240
- if (isToolCallEventType("write", event) && !isPlanPath(ctx.cwd, event.input.path)) {
241
- return {
242
- block: true,
243
- reason: `Plan mode: write only inside ${PLAN_DIR}/. Got: ${event.input.path}`,
235
+ reason: `Plan mode: ${event.toolName} only inside ${resolve(ctx.cwd, PLAN_DIR)}/. Got: ${path}`,
244
236
  };
245
237
  }
238
+ const checked = await validateOutputPath(resolve(ctx.cwd, path));
239
+ if (!checked.ok) return { block: true, reason: `Plan mode: ${checked.reason}` };
246
240
  });
247
241
 
248
242
  pi.on("session_start", async (_event, ctx) => {
@@ -252,12 +246,8 @@ export default function registerPlanMode(pi: ExtensionAPI): void {
252
246
  (e: { type: string; customType?: string }) =>
253
247
  e.type === "custom" && e.customType === STATE_ENTRY,
254
248
  )
255
- .pop() as { data?: { enabled?: boolean; toolsBefore?: string[] } } | undefined;
256
- if (entry?.data?.enabled) {
257
- toolsBefore = entry.data.toolsBefore;
258
- enabled = true;
259
- pi.setActiveTools(PLAN_TOOLS);
260
- }
249
+ .pop() as { data?: { enabled?: boolean } } | undefined;
250
+ enabled = entry?.data?.enabled === true;
261
251
  ctx.ui.setStatus(
262
252
  "plan",
263
253
  modeStatus(enabled, (r, t) => ctx.ui.theme.fg(r, t)),