@justjoshn/pi-pstack 0.15.36-pi.11 → 0.15.36-pi.13

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/src/mode.ts CHANGED
@@ -13,6 +13,32 @@ export interface ActiveMode {
13
13
  path?: string;
14
14
  }
15
15
 
16
+ /**
17
+ * Cursor's custom-mode lifecycle: a mode is `entered` until a request carries it, then `active`. Leaving or switching
18
+ * away from an `active` mode leaves it `exited` until the next request says so once.
19
+ */
20
+ export interface ModeState {
21
+ active?: ActiveMode & { lifecycle: 'entered' | 'active' };
22
+ exited?: string;
23
+ }
24
+
25
+ const left = (state: ModeState): string | undefined => (state.active?.lifecycle === 'active' ? state.active.skill : state.exited);
26
+
27
+ export function enterMode(state: ModeState, mode: ActiveMode): ModeState {
28
+ const exited = left(state);
29
+ return { active: { ...mode, lifecycle: 'entered' }, ...(exited === undefined ? {} : { exited }) };
30
+ }
31
+
32
+ export function exitMode(state: ModeState): ModeState {
33
+ const exited = left(state);
34
+ return exited === undefined ? {} : { exited };
35
+ }
36
+
37
+ /** What a request leaves behind: the mode it carried is now active, and its exit notice has been sent. */
38
+ export function afterRequest(state: ModeState): ModeState {
39
+ return state.active === undefined ? {} : { active: { ...state.active, lifecycle: 'active' } };
40
+ }
41
+
16
42
  export type ModeCommand = { kind: 'status' } | { kind: 'off' } | { kind: 'set'; skill: string; task: string };
17
43
 
18
44
  export function parseModeArgs(args: string): ModeCommand {
@@ -31,41 +57,53 @@ interface BranchEntry {
31
57
 
32
58
  const SKILL_NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
33
59
 
60
+ /** A stored mode as a skill name only: a session file can come from elsewhere, so a stored path is ignored and a value that is not a plausible skill name (it would reach the system prompt) counts as no mode. */
61
+ function storedSkill(data: unknown): string | undefined {
62
+ const skill = (data as { skill?: unknown } | null)?.skill;
63
+ return typeof skill === 'string' && SKILL_NAME.test(skill) ? skill : undefined;
64
+ }
65
+
34
66
  /**
35
- * The last mode entry on the branch, as a skill name only: a session file can come from elsewhere, so a stored path
36
- * is ignored and a value that is not a plausible skill name (it would reach the system prompt) counts as no mode.
67
+ * Replays the branch into the mode state: each mode entry enters or leaves a mode, and an assistant message after it
68
+ * shows a request carried the mode. Skills are resolved by name, never by a stored path.
37
69
  */
38
- export function modeFromBranch(entries: readonly BranchEntry[]): ActiveMode | undefined {
39
- for (let index = entries.length - 1; index >= 0; index -= 1) {
40
- const entry = entries[index];
41
- if (entry?.type !== 'custom' || entry.customType !== MODE_ENTRY) continue;
42
- const skill = (entry.data as { skill?: unknown } | null)?.skill;
43
- return typeof skill === 'string' && SKILL_NAME.test(skill) ? { skill } : undefined;
70
+ export function modeFromBranch(entries: readonly BranchEntry[], resolve: (skill: string) => ActiveMode = (skill) => ({ skill })): ModeState {
71
+ let state: ModeState = {};
72
+ for (const entry of entries) {
73
+ if (entry.type === 'custom' && entry.customType === MODE_ENTRY) {
74
+ const skill = storedSkill(entry.data);
75
+ state = skill === undefined ? exitMode(state) : enterMode(state, resolve(skill));
76
+ } else if (entry.type === 'message' && (entry as { message?: { role?: string } }).message?.role === 'assistant') state = afterRequest(state);
44
77
  }
45
- return undefined;
78
+ return state;
79
+ }
80
+
81
+ /** Cursor's custom-mode notices for the next request (CLI `custom-modes` module). */
82
+ export function modeNotice(state: ModeState): string | undefined {
83
+ const lines: string[] = [];
84
+ const { active, exited } = state;
85
+ if (exited !== undefined && exited !== active?.skill) lines.push(`The user has exited ${exited} custom mode. Stop following that mode's skill. Continue without those constraints.`);
86
+ if (active?.path === undefined && active !== undefined) lines.push(`Mode ${active.skill} is on, but no loaded skill has that name. Tell the user to run /mode off or /mode <skill>.`);
87
+ else if (active?.path !== undefined && !existsSync(active.path)) lines.push(`Mode ${active.skill} is on, but its skill file ${active.path} no longer exists. Tell the user to run /mode off or /mode <skill>.`);
88
+ else if (active?.lifecycle === 'entered')
89
+ lines.push(`The user has entered ${active.skill} custom mode. Follow the skill instructions in ${JSON.stringify(active.path)} until the user exits or switches modes. Do not drift back to default agent behavior.`);
90
+ else if (active !== undefined)
91
+ lines.push(
92
+ `Reminder: you are still in ${active.skill} mode. Follow that mode's skill on this turn. Do not drop it because the request looks small or you already have context. Do not drift back to default agent behavior. If you cannot state the mode's hard rules, read ${JSON.stringify(active.path)} before acting.`,
93
+ );
94
+ return lines.length === 0 ? undefined : lines.join('\n\n');
46
95
  }
47
96
 
48
97
  /**
49
- * The mode text for a request. Cursor attaches the skill once, on the turn that enters the mode, then sends a reminder
50
- * that names the skill path each turn. Pi rebuilds its prompt per request, so the body rides the first request after
51
- * entry and a reminder follows. A mode whose body was never sent re-sends it, so a restart cannot leave a mode
52
- * unread.
98
+ * The skill a mode keeps in context on every request until the user leaves it, in the block Pi's own /skill:name
99
+ * expansion builds. Cursor keeps the attached skill durable in the conversation; a section keeps it in every request,
100
+ * through compaction and restarts, and Pi re-sends a section only when it changes.
53
101
  */
54
- export function modeSection(mode: ActiveMode, includeBody: boolean): string {
55
- if (mode.path === undefined) return `Mode ${mode.skill} is on, but no loaded skill has that name. Tell the user to run /mode off or /mode <skill>.`;
56
- if (!existsSync(mode.path)) return `Mode ${mode.skill} is on, but its skill file ${mode.path} no longer exists. Tell the user to run /mode off or /mode <skill>.`;
57
- if (!includeBody)
58
- return `Reminder: you are still in ${mode.skill} mode. Follow that mode's skill on this turn. Do not drop it because the request looks small or you already have context. Do not drift back to default agent behavior. If you cannot state the mode's hard rules, read ${JSON.stringify(mode.path)} before acting.`;
59
- // The frontmatter, including `reminder`, stays out of the model's context in a mode (MODE-004).
102
+ export function modeSkill(mode: ActiveMode): string | undefined {
103
+ if (mode.path === undefined || !existsSync(mode.path)) return undefined;
104
+ // The frontmatter stays out of the model's context in a mode (MODE-004).
60
105
  const body = stripFrontmatter(readFileSync(mode.path, 'utf8')).trim();
61
- return [
62
- `The user has entered ${mode.skill} custom mode. Follow the skill instructions in ${JSON.stringify(mode.path)} until the user exits or switches modes. Do not drift back to default agent behavior.`,
63
- `<skill name="${mode.skill}" location="${mode.path}">`,
64
- `References are relative to ${dirname(mode.path)}.`,
65
- '',
66
- body,
67
- '</skill>',
68
- ].join('\n');
106
+ return [`<skill name="${mode.skill}" location="${mode.path}">`, `References are relative to ${dirname(mode.path)}.`, '', body, '</skill>'].join('\n');
69
107
  }
70
108
 
71
109
  // A mode's badge shows the skill's `icon` (default: lightning) and `color`. Icon names map to terminal glyphs and
@@ -108,20 +146,22 @@ function loadedSkills(pi: ExtensionAPI): Map<string, string> {
108
146
  return skills;
109
147
  }
110
148
 
111
- export function registerMode(pi: ExtensionAPI): { section: () => string | undefined } {
112
- let active: ActiveMode | undefined;
113
- // The mode whose body the conversation already carries; cleared on entry, exit, and session change.
114
- let bodySent: string | undefined;
149
+ /** The mode sections of one request: Cursor's notice, and the skill the mode keeps in context. */
150
+ export interface ModeSections {
151
+ pstack_mode?: string;
152
+ pstack_mode_skill?: string;
153
+ }
154
+
155
+ export function registerMode(pi: ExtensionAPI): { sections: () => ModeSections } {
156
+ let state: ModeState = {};
115
157
  const show = (ctx: ExtensionContext) => {
116
158
  if (!ctx.hasUI) return;
117
- const badge = active === undefined ? undefined : modeBadge(active);
159
+ const badge = state.active === undefined ? undefined : modeBadge(state.active);
118
160
  ctx.ui.setStatus(STATUS_KEY, badge === undefined ? undefined : badge.color === undefined ? badge.text : ctx.ui.theme.fg(badge.color, badge.text));
119
161
  };
120
162
  // The skill is looked up by name among loaded skills; a stored path is never read, since it can be stale or crafted.
121
163
  const restore = (ctx: ExtensionContext) => {
122
- const stored = modeFromBranch(ctx.sessionManager.getBranch());
123
- active = stored === undefined ? undefined : (tryResolve(stored.skill) ?? { skill: stored.skill });
124
- bodySent = undefined;
164
+ state = modeFromBranch(ctx.sessionManager.getBranch(), (skill) => tryResolve(skill) ?? { skill });
125
165
  show(ctx);
126
166
  };
127
167
  pi.on('session_start', (_event, ctx) => restore(ctx));
@@ -145,12 +185,12 @@ export function registerMode(pi: ExtensionAPI): { section: () => string | undefi
145
185
 
146
186
  const handle = (args: string, ctx: ExtensionCommandContext) => {
147
187
  const command = parseModeArgs(args);
188
+ const { active } = state;
148
189
  if (command.kind === 'status') return ctx.ui.notify(active === undefined ? 'No mode is on.' : `Mode on: ${active.skill} (${active.path ?? 'no loaded skill has that name'}).`, 'info');
149
- active = command.kind === 'off' ? undefined : resolveSkill(command.skill);
150
- bodySent = undefined;
151
- pi.appendEntry(MODE_ENTRY, active === undefined ? null : { skill: active.skill });
190
+ state = command.kind === 'off' ? exitMode(state) : enterMode(state, resolveSkill(command.skill));
191
+ pi.appendEntry(MODE_ENTRY, state.active === undefined ? null : { skill: state.active.skill });
152
192
  show(ctx);
153
- ctx.ui.notify(active === undefined ? 'Mode off.' : `Mode on: ${active.skill}. /mode off exits.`, 'info');
193
+ ctx.ui.notify(state.active === undefined ? 'Mode off.' : `Mode on: ${state.active.skill}. /mode off exits.`, 'info');
154
194
  if (command.kind === 'set' && command.task !== '') pi.sendUserMessage(command.task, { expandPromptTemplates: true, ...(ctx.isIdle() ? {} : { deliverAs: 'followUp' as const }) });
155
195
  return undefined;
156
196
  };
@@ -168,13 +208,11 @@ export function registerMode(pi: ExtensionAPI): { section: () => string | undefi
168
208
  });
169
209
 
170
210
  return {
171
- section: () => {
172
- if (active === undefined) return undefined;
173
- const includeBody = bodySent !== active.skill;
174
- // Only a request that actually carried the body counts as sent, so a missing skill file cannot silence the body
175
- // after the file comes back.
176
- if (includeBody && active.path !== undefined && existsSync(active.path)) bodySent = active.skill;
177
- return modeSection(active, includeBody);
211
+ sections: () => {
212
+ const notice = modeNotice(state);
213
+ const skill = state.active === undefined ? undefined : modeSkill(state.active);
214
+ state = afterRequest(state);
215
+ return { ...(notice === undefined ? {} : { pstack_mode: notice }), ...(skill === undefined ? {} : { pstack_mode_skill: skill }) };
178
216
  },
179
217
  };
180
218
  }