pi-qq 0.1.4 → 0.1.6

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
@@ -4,13 +4,11 @@ Ask quick side questions about your current [pi](https://pi.dev) session without
4
4
 
5
5
  `pi-qq` adds `/qq <question>` plus an **alt+q** / **Option+Q** shortcut that toggles `/qq ` in the editor. The answer appears ephemerally in a bottom overlay, using the current session as read-only context, and nothing is added to the main conversation.
6
6
 
7
- ![pi-qq bottom overlay answering a quick question](https://cdn.jsdelivr.net/npm/pi-qq/docs/screenshot.png)
8
-
9
7
  ## Why try it?
10
8
 
11
9
  - **A real side channel:** ask `/qq why are we changing this file?` while the main agent keeps working. The answer shows in a bottom overlay and does not enter the main transcript.
12
- - **Context-aware, intentionally constrained:** `/qq` passes a read-only clone of the main session, treats ambiguous references like “this”, “that”, “we”, and “the plan” as references to the active session, gives the side call no tools, and keeps no `/qq` history.
13
- - **Fast, low-friction UX:** press **alt+q** / **Option+Q** to toggle `/qq `, submit your question, then use **Esc** to cancel/dismiss or **↑/↓** to scroll longer answers.
10
+ - **Context-aware, intentionally constrained:** `/qq` passes read-only main-session context, treats ambiguous references like “this”, “that”, “we”, and “the plan” as references to the active session, gives the side call no tools, and keeps no `/qq` history.
11
+ - **Fast, low-friction UX:** `/qq` uses recent context by default, automatically switches to broader context for retrospective questions, and supports explicit `--recent` / `--full` modes. Press **alt+q** / **Option+Q** to toggle `/qq `, then use **Esc** to cancel/dismiss or **↑/↓** to scroll longer answers.
14
12
 
15
13
  ## Demo
16
14
 
@@ -41,8 +39,17 @@ After installing, run `/reload` in pi or restart the session.
41
39
 
42
40
  ```text
43
41
  /qq <question>
42
+ /qq --recent <question>
43
+ /qq --full <question>
44
44
  ```
45
45
 
46
+ By default, `/qq` chooses a context mode automatically:
47
+
48
+ - **Recent mode** for immediate questions like `what did we do in the last turn?`, `what are we doing right now?`, or `why are we changing this?`.
49
+ - **Full mode** for retrospective questions like `summarize this session`, `what did we decide so far?`, or `what did we try earlier?`.
50
+
51
+ Use `--recent` when you want the fastest answer from the latest messages. Use `--full` when you explicitly want broader session context.
52
+
46
53
  ### Shortcut
47
54
 
48
55
  Press **alt+q** / **Option+Q** to toggle `/qq ` at the front of the editor:
@@ -60,7 +67,9 @@ Press **alt+q** / **Option+Q** to toggle `/qq ` at the front of the editor:
60
67
  ## Design constraints
61
68
 
62
69
  - The main transcript is never polluted by `/qq` questions or answers.
63
- - The side call receives the current session as read-only context.
70
+ - The side call receives read-only main-session context.
71
+ - Recent mode sends only the latest messages for speed; full mode sends broader bounded context.
72
+ - Large text parts are clipped, thinking blocks are removed, and images are omitted from the side-call context for speed.
64
73
  - The side call has no tools.
65
74
  - `/qq` stores no quick-question history; each call is independent except for the main-session context.
66
75
  - The system prompt biases answers toward concise, direct responses.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-qq",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Pi extension. Ask context-aware side questions with /qq or alt+q without polluting the main transcript.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -25,7 +25,6 @@
25
25
  "qq.ts",
26
26
  "qq-ui.ts",
27
27
  "prompts/",
28
- "docs/",
29
28
  "README.md",
30
29
  "LICENSE",
31
30
  "NOTICE"
@@ -33,8 +32,7 @@
33
32
  "pi": {
34
33
  "extensions": [
35
34
  "./index.ts"
36
- ],
37
- "image": "https://cdn.jsdelivr.net/npm/pi-qq/docs/screenshot.png"
35
+ ]
38
36
  },
39
37
  "peerDependencies": {
40
38
  "@earendil-works/pi-ai": "*",
@@ -2,7 +2,7 @@ You answer quick questions about the user's main pi session.
2
2
 
3
3
  Default ambiguous references to the main session. "This", "that", "it", "we", "the plan", "the code", "the issue", and "what you were doing" refer to the primary conversation unless the user clearly says otherwise.
4
4
 
5
- Treat the primary conversation as background only. Do not continue prior work, resume tool calls, or start a task. Answer only the quick question.
5
+ Treat the primary conversation as background only. You may receive recent context or broader bounded context. Do not continue prior work, resume tool calls, or start a task. Answer only the quick question.
6
6
 
7
7
  Optimize for speed and brevity. Answer in one short sentence by default. Use up to 3 terse bullets only when necessary. No preamble. No restating the question. No summary. If uncertain or missing context, say so in one short sentence.
8
8
 
package/qq.ts CHANGED
@@ -31,15 +31,26 @@ export const QQ_PREFIX = `/${QQ_COMMAND_NAME} `;
31
31
  export const QQ_STATE_KEY = Symbol.for("pi-qq:qq");
32
32
 
33
33
  const MSG_REQUIRES_INTERACTIVE = "/qq requires interactive mode";
34
- const MSG_USAGE = "Usage: /qq <question>";
34
+ const MSG_USAGE = "Usage: /qq [--recent|--full] <question>";
35
35
  const MSG_NO_MODEL = "/qq requires an active model";
36
36
  const ERR_EMPTY_RESPONSE = "/qq returned no text content.";
37
+ const RECENT_CONTEXT_MESSAGE_LIMIT = 12;
38
+ const FULL_CONTEXT_HEAD_MESSAGE_LIMIT = 4;
39
+ const FULL_CONTEXT_TAIL_MESSAGE_LIMIT = 80;
40
+ const MAX_TEXT_CHARS_PER_PART = 4_000;
37
41
 
38
42
  const errMisconfigured = (label: string, err: string) => `/qq model (${label}) is misconfigured: ${err}`;
39
43
  const errNoApiKey = (label: string) => `/qq model (${label}) has no API key available.`;
40
44
  const errCallFailed = (err: string | undefined) => `/qq call failed: ${err ?? "unknown error"}`;
41
45
  const errCallThrew = (msg: string) => `/qq call threw: ${msg}`;
42
46
 
47
+ type QqContextMode = "recent" | "full";
48
+
49
+ interface ParsedQqArgs {
50
+ question: string;
51
+ mode: QqContextMode;
52
+ }
53
+
43
54
  interface QqState {
44
55
  snapshots: Map<string, { messages: Message[] }>;
45
56
  }
@@ -77,11 +88,128 @@ export function invalidateSnapshot(ctx: ExtensionContext): void {
77
88
 
78
89
  export function assistantMessageText(msg: AssistantMessage): string {
79
90
  return msg.content
80
- .filter((c): c is { type: "text"; text: string } => c.type === "text")
81
- .map((c) => c.text)
91
+ .filter((content): content is { type: "text"; text: string } => content.type === "text")
92
+ .map((content) => content.text)
82
93
  .join("\n");
83
94
  }
84
95
 
96
+ function clipText(text: string): string {
97
+ if (text.length <= MAX_TEXT_CHARS_PER_PART) return text;
98
+ return `${text.slice(0, MAX_TEXT_CHARS_PER_PART)}\n[…truncated for /qq speed…]`;
99
+ }
100
+
101
+ function trimContentArray<T extends Array<{ type: string }>>(content: T): T {
102
+ return content
103
+ .filter((part) => part.type !== "thinking")
104
+ .map((part) => {
105
+ if (part.type === "text" && "text" in part && typeof part.text === "string") {
106
+ return { ...part, text: clipText(part.text) };
107
+ }
108
+ if (part.type === "image") {
109
+ return { type: "text", text: "[image omitted for /qq speed]" };
110
+ }
111
+ return part;
112
+ }) as T;
113
+ }
114
+
115
+ function trimUserContent(content: UserMessage["content"]): UserMessage["content"] {
116
+ if (typeof content === "string") return clipText(content);
117
+ return trimContentArray(content);
118
+ }
119
+
120
+ function trimMessageForContext(message: Message): Message {
121
+ if (message.role === "assistant") {
122
+ return { ...message, content: trimContentArray(message.content) };
123
+ }
124
+ if (message.role === "user") {
125
+ return { ...message, content: trimUserContent(message.content) };
126
+ }
127
+ return { ...message, content: trimContentArray(message.content) };
128
+ }
129
+
130
+ function selectRecentContextMessages(messages: Message[]): Message[] {
131
+ return messages.slice(-RECENT_CONTEXT_MESSAGE_LIMIT).map(trimMessageForContext);
132
+ }
133
+
134
+ function selectFullContextMessages(messages: Message[]): Message[] {
135
+ if (messages.length <= FULL_CONTEXT_HEAD_MESSAGE_LIMIT + FULL_CONTEXT_TAIL_MESSAGE_LIMIT) {
136
+ return messages.map(trimMessageForContext);
137
+ }
138
+ return [
139
+ ...messages.slice(0, FULL_CONTEXT_HEAD_MESSAGE_LIMIT),
140
+ ...messages.slice(-FULL_CONTEXT_TAIL_MESSAGE_LIMIT),
141
+ ].map(trimMessageForContext);
142
+ }
143
+
144
+ function selectContextMessages(messages: Message[], mode: QqContextMode): Message[] {
145
+ return mode === "full" ? selectFullContextMessages(messages) : selectRecentContextMessages(messages);
146
+ }
147
+
148
+ function parseQqArgs(args: string): ParsedQqArgs {
149
+ const trimmed = args.trim();
150
+ if (trimmed.startsWith("--recent ")) {
151
+ return { mode: "recent", question: trimmed.slice("--recent ".length).trim() };
152
+ }
153
+ if (trimmed === "--recent") {
154
+ return { mode: "recent", question: "" };
155
+ }
156
+ if (trimmed.startsWith("--full ")) {
157
+ return { mode: "full", question: trimmed.slice("--full ".length).trim() };
158
+ }
159
+ if (trimmed === "--full") {
160
+ return { mode: "full", question: "" };
161
+ }
162
+ return { mode: detectContextMode(trimmed), question: trimmed };
163
+ }
164
+
165
+ function includesAny(text: string, phrases: string[]): boolean {
166
+ return phrases.some((phrase) => text.includes(phrase));
167
+ }
168
+
169
+ function detectContextMode(question: string): QqContextMode {
170
+ const normalized = question.toLowerCase();
171
+
172
+ const recentPhrases = [
173
+ "last turn",
174
+ "previous turn",
175
+ "last message",
176
+ "latest",
177
+ "just now",
178
+ "what did we just",
179
+ "right now",
180
+ "current",
181
+ "currently",
182
+ "most recent",
183
+ "the last thing",
184
+ ];
185
+ if (includesAny(normalized, recentPhrases)) {
186
+ return "recent";
187
+ }
188
+
189
+ const fullPhrases = [
190
+ "entire session",
191
+ "whole session",
192
+ "this session",
193
+ "from the beginning",
194
+ "full context",
195
+ "so far",
196
+ "overall",
197
+ "earlier",
198
+ "previously",
199
+ "at the start",
200
+ "originally",
201
+ "what have we done",
202
+ "what did we decide",
203
+ "summarize",
204
+ "recap",
205
+ ];
206
+ if (includesAny(normalized, fullPhrases)) {
207
+ return "full";
208
+ }
209
+
210
+ return "recent";
211
+ }
212
+
85
213
  export interface QqExecResult {
86
214
  ok: boolean;
87
215
  answer?: string;
@@ -92,23 +220,26 @@ export interface QqExecResult {
92
220
  aborted?: boolean;
93
221
  }
94
222
 
95
- function readBranchMessages(ctx: ExtensionContext): Message[] {
223
+ function readBranchMessages(ctx: ExtensionContext, mode: QqContextMode): Message[] {
96
224
  const cached = getSnapshot(ctx);
97
- if (cached) return cached.messages;
225
+ if (cached) return selectContextMessages(cached.messages, mode);
98
226
 
99
227
  const branch = ctx.sessionManager.getBranch() as SessionEntry[];
100
- const agentMessages = branch
101
- .filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message")
102
- .map((entry) => entry.message);
103
- return convertToLlm(agentMessages);
228
+ const messageEntries = branch.filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message");
229
+ const selectedEntries = mode === "recent" ? messageEntries.slice(-RECENT_CONTEXT_MESSAGE_LIMIT) : messageEntries;
230
+ return selectContextMessages(
231
+ convertToLlm(selectedEntries.map((entry) => entry.message)),
232
+ mode,
233
+ );
104
234
  }
105
235
 
106
- function buildQqMessages(ctx: ExtensionContext, userMessage: UserMessage): Message[] {
107
- return [...readBranchMessages(ctx), userMessage];
236
+ function buildQqMessages(ctx: ExtensionContext, userMessage: UserMessage, mode: QqContextMode): Message[] {
237
+ return [...readBranchMessages(ctx, mode), userMessage];
108
238
  }
109
239
 
110
240
  export async function executeQq(
111
241
  question: string,
242
+ mode: QqContextMode,
112
243
  ctx: ExtensionContext,
113
244
  controller: AbortController,
114
245
  ): Promise<QqExecResult> {
@@ -135,7 +266,7 @@ export async function executeQq(
135
266
  try {
136
267
  const response = await completeSimple(
137
268
  model,
138
- { systemPrompt: QQ_SYSTEM_PROMPT, messages: buildQqMessages(ctx, userMessage), tools: [] },
269
+ { systemPrompt: QQ_SYSTEM_PROMPT, messages: buildQqMessages(ctx, userMessage, mode), tools: [] },
139
270
  {
140
271
  apiKey: auth.apiKey,
141
272
  headers: auth.headers,
@@ -220,8 +351,8 @@ async function handleQqCommand(args: string, ctx: ExtensionCommandContext): Prom
220
351
  ctx.ui.notify(MSG_REQUIRES_INTERACTIVE, "error");
221
352
  return;
222
353
  }
223
- const question = args.trim();
224
- if (!question) {
354
+ const parsedArgs = parseQqArgs(args);
355
+ if (!parsedArgs.question) {
225
356
  ctx.ui.notify(MSG_USAGE, "warning");
226
357
  return;
227
358
  }
@@ -233,12 +364,12 @@ async function handleQqCommand(args: string, ctx: ExtensionCommandContext): Prom
233
364
  const controller = new AbortController();
234
365
  const { overlayPromise, controllerReady } = showQqOverlay({
235
366
  ctx,
236
- question,
367
+ question: parsedArgs.question,
237
368
  controller,
238
369
  });
239
370
 
240
371
  const overlayCtl = await controllerReady;
241
- const result = await executeQq(question, ctx, controller);
372
+ const result = await executeQq(parsedArgs.question, parsedArgs.mode, ctx, controller);
242
373
 
243
374
  if (result.ok && result.answer) {
244
375
  overlayCtl.setAnswer(result.answer);
Binary file