pi-qq 0.1.5 → 0.1.7

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
@@ -7,8 +7,8 @@ Ask quick side questions about your current [pi](https://pi.dev) session without
7
7
  ## Why try it?
8
8
 
9
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.
10
- - **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.
11
- - **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, and gives the side call no tools. Previous `/qq` answers are available only through `/qq-history`; they are not fed back into future `/qq` calls.
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.
12
12
 
13
13
  ## Demo
14
14
 
@@ -23,7 +23,7 @@ Another common flow:
23
23
  1. Press **alt+q** / **Option+Q**.
24
24
  2. Type `what's the risk with this plan?`.
25
25
  3. Hit Enter.
26
- 4. Read the concise overlay answer; press **Esc** to dismiss.
26
+ 4. Read the concise overlay answer; press **Esc** to dismiss. If you close it too soon, run `/qq-history` to reopen recent answers.
27
27
 
28
28
  ## Install
29
29
 
@@ -39,8 +39,20 @@ After installing, run `/reload` in pi or restart the session.
39
39
 
40
40
  ```text
41
41
  /qq <question>
42
+ /qq --recent <question>
43
+ /qq --full <question>
44
+ /qq-history
42
45
  ```
43
46
 
47
+ By default, `/qq` chooses a context mode automatically:
48
+
49
+ - **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?`.
50
+ - **Full mode** for retrospective questions like `summarize this session`, `what did we decide so far?`, or `what did we try earlier?`.
51
+
52
+ Use `--recent` when you want the fastest answer from the latest messages. Use `--full` when you explicitly want broader session context.
53
+
54
+ Use `/qq-history` to reopen recent `/qq` answers from the current session. History is view-only and is not included as context for future `/qq` model calls.
55
+
44
56
  ### Shortcut
45
57
 
46
58
  Press **alt+q** / **Option+Q** to toggle `/qq ` at the front of the editor:
@@ -58,9 +70,12 @@ Press **alt+q** / **Option+Q** to toggle `/qq ` at the front of the editor:
58
70
  ## Design constraints
59
71
 
60
72
  - The main transcript is never polluted by `/qq` questions or answers.
61
- - The side call receives the current session as read-only context.
73
+ - The side call receives read-only main-session context.
74
+ - Recent mode sends only the latest messages for speed; full mode sends broader bounded context.
75
+ - Large text parts are clipped, thinking blocks are removed, and images are omitted from the side-call context for speed.
62
76
  - The side call has no tools.
63
- - `/qq` stores no quick-question history; each call is independent except for the main-session context.
77
+ - Recent `/qq` answers are kept in memory only so `/qq-history` can reopen them after dismissal.
78
+ - `/qq-history` is view-only; it is not used as context for future `/qq` model calls.
64
79
  - The system prompt biases answers toward concise, direct responses.
65
80
 
66
81
  ## License
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-qq",
3
- "version": "0.1.5",
4
- "description": "Pi extension. Ask context-aware side questions with /qq or alt+q without polluting the main transcript.",
3
+ "version": "0.1.7",
4
+ "description": "Pi extension. Ask context-aware side questions with /qq or alt+q, then reopen answers with /qq-history.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "pi-extension",
@@ -9,7 +9,7 @@
9
9
  "side-question",
10
10
  "context-aware",
11
11
  "transcript-safe",
12
- "no-history",
12
+ "history",
13
13
  "question",
14
14
  "shortcut",
15
15
  "keybinding",
@@ -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-ui.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  * Esc → abort in-flight call + dismiss
13
13
  * ↑/↓ → scroll when content exceeds terminal
14
14
  *
15
- * Does not keep quick-question history.
15
+ * Used for both live /qq answers and view-only /qq-history.
16
16
  */
17
17
 
18
18
  import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
@@ -49,6 +49,7 @@ export interface ShowQqOverlayParams {
49
49
  ctx: ExtensionCommandContext;
50
50
  question: string;
51
51
  controller: AbortController;
52
+ commandLabel?: string;
52
53
  }
53
54
 
54
55
  export interface ShowQqOverlayResult {
@@ -68,6 +69,7 @@ export class QqOverlayController implements Component {
68
69
  private readonly tui: TUI,
69
70
  private readonly done: (result?: undefined) => void,
70
71
  private readonly controller: AbortController,
72
+ private readonly commandLabel: string = QQ_LITERAL,
71
73
  ) {}
72
74
 
73
75
  setAnswer(text: string): void {
@@ -128,7 +130,7 @@ export class QqOverlayController implements Component {
128
130
  }
129
131
 
130
132
  private renderBanner(width: number): string {
131
- const prefix = `${SIDE_PAD}${QQ_LITERAL} `;
133
+ const prefix = `${SIDE_PAD}${this.commandLabel} `;
132
134
  const prefixWidth = visibleWidth(prefix);
133
135
  const questionWidth = Math.max(0, width - prefixWidth);
134
136
  const truncatedQuestion = truncateToWidth(this.question, questionWidth, "…", false);
@@ -170,7 +172,14 @@ export function showQqOverlay(params: ShowQqOverlayParams): ShowQqOverlayResult
170
172
 
171
173
  const overlayPromise = params.ctx.ui.custom<void>(
172
174
  (tui, theme, _keybindings, done) => {
173
- const controller = new QqOverlayController(params.question, theme, tui, done, params.controller);
175
+ const controller = new QqOverlayController(
176
+ params.question,
177
+ theme,
178
+ tui,
179
+ done,
180
+ params.controller,
181
+ params.commandLabel,
182
+ );
174
183
  resolveReady(controller);
175
184
  return controller;
176
185
  },
package/qq.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * conversation as read-only context. The answer is rendered ephemerally in a
6
6
  * bottom-slot overlay and never enters the main session transcript.
7
7
  *
8
- * Does not keep quick-question history.
8
+ * Keeps in-memory, view-only answer history for /qq-history.
9
9
  */
10
10
 
11
11
  import { readFileSync } from "node:fs";
@@ -27,21 +27,42 @@ import {
27
27
  import { showQqOverlay } from "./qq-ui.js";
28
28
 
29
29
  export const QQ_COMMAND_NAME = "qq";
30
+ export const QQ_HISTORY_COMMAND_NAME = "qq-history";
30
31
  export const QQ_PREFIX = `/${QQ_COMMAND_NAME} `;
31
32
  export const QQ_STATE_KEY = Symbol.for("pi-qq:qq");
32
33
 
33
34
  const MSG_REQUIRES_INTERACTIVE = "/qq requires interactive mode";
34
- const MSG_USAGE = "Usage: /qq <question>";
35
+ const MSG_USAGE = "Usage: /qq [--recent|--full] <question>";
35
36
  const MSG_NO_MODEL = "/qq requires an active model";
36
37
  const ERR_EMPTY_RESPONSE = "/qq returned no text content.";
38
+ const MSG_NO_HISTORY = "No /qq history for this session yet";
39
+ const QQ_HISTORY_LIMIT = 20;
40
+ const RECENT_CONTEXT_MESSAGE_LIMIT = 12;
41
+ const FULL_CONTEXT_HEAD_MESSAGE_LIMIT = 4;
42
+ const FULL_CONTEXT_TAIL_MESSAGE_LIMIT = 80;
43
+ const MAX_TEXT_CHARS_PER_PART = 4_000;
37
44
 
38
45
  const errMisconfigured = (label: string, err: string) => `/qq model (${label}) is misconfigured: ${err}`;
39
46
  const errNoApiKey = (label: string) => `/qq model (${label}) has no API key available.`;
40
47
  const errCallFailed = (err: string | undefined) => `/qq call failed: ${err ?? "unknown error"}`;
41
48
  const errCallThrew = (msg: string) => `/qq call threw: ${msg}`;
42
49
 
50
+ type QqContextMode = "recent" | "full";
51
+
52
+ interface ParsedQqArgs {
53
+ question: string;
54
+ mode: QqContextMode;
55
+ }
56
+
57
+ interface QqHistoryEntry {
58
+ question: string;
59
+ answer: string;
60
+ timestamp: number;
61
+ }
62
+
43
63
  interface QqState {
44
64
  snapshots: Map<string, { messages: Message[] }>;
65
+ histories: Map<string, QqHistoryEntry[]>;
45
66
  }
46
67
 
47
68
  export const QQ_SYSTEM_PROMPT = readFileSync(
@@ -53,7 +74,7 @@ function getState(): QqState {
53
74
  const globalState = globalThis as unknown as { [k: symbol]: QqState | undefined };
54
75
  let state = globalState[QQ_STATE_KEY];
55
76
  if (!state) {
56
- state = { snapshots: new Map() };
77
+ state = { snapshots: new Map(), histories: new Map() };
57
78
  globalState[QQ_STATE_KEY] = state;
58
79
  }
59
80
  return state;
@@ -71,17 +92,169 @@ function setSnapshot(ctx: ExtensionContext, snapshot: { messages: Message[] }):
71
92
  getState().snapshots.set(getSessionFile(ctx), snapshot);
72
93
  }
73
94
 
95
+ function getSessionHistory(ctx: ExtensionContext): QqHistoryEntry[] {
96
+ const key = getSessionFile(ctx);
97
+ const state = getState();
98
+ let history = state.histories.get(key);
99
+ if (!history) {
100
+ history = [];
101
+ state.histories.set(key, history);
102
+ }
103
+ return history;
104
+ }
105
+
106
+ function pushSessionHistory(ctx: ExtensionContext, entry: QqHistoryEntry): void {
107
+ const history = getSessionHistory(ctx);
108
+ history.push(entry);
109
+ if (history.length > QQ_HISTORY_LIMIT) {
110
+ history.splice(0, history.length - QQ_HISTORY_LIMIT);
111
+ }
112
+ }
113
+
74
114
  export function invalidateSnapshot(ctx: ExtensionContext): void {
75
115
  getState().snapshots.delete(getSessionFile(ctx));
76
116
  }
77
117
 
118
+ function formatHistoryTimestamp(timestamp: number): string {
119
+ return new Date(timestamp).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
120
+ }
121
+
122
+ function formatQqHistory(entries: QqHistoryEntry[]): string {
123
+ return entries
124
+ .slice()
125
+ .reverse()
126
+ .map((entry, index) => {
127
+ const question = entry.question.replace(/\s+/g, " ").trim();
128
+ const answer = entry.answer.trim();
129
+ return `${index + 1}. ${formatHistoryTimestamp(entry.timestamp)} — /qq ${question}\n${answer}`;
130
+ })
131
+ .join("\n\n");
132
+ }
133
+
78
134
  export function assistantMessageText(msg: AssistantMessage): string {
79
135
  return msg.content
80
- .filter((c): c is { type: "text"; text: string } => c.type === "text")
81
- .map((c) => c.text)
136
+ .filter((content): content is { type: "text"; text: string } => content.type === "text")
137
+ .map((content) => content.text)
82
138
  .join("\n");
83
139
  }
84
140
 
141
+ function clipText(text: string): string {
142
+ if (text.length <= MAX_TEXT_CHARS_PER_PART) return text;
143
+ return `${text.slice(0, MAX_TEXT_CHARS_PER_PART)}\n[…truncated for /qq speed…]`;
144
+ }
145
+
146
+ function trimContentArray<T extends Array<{ type: string }>>(content: T): T {
147
+ return content
148
+ .filter((part) => part.type !== "thinking")
149
+ .map((part) => {
150
+ if (part.type === "text" && "text" in part && typeof part.text === "string") {
151
+ return { ...part, text: clipText(part.text) };
152
+ }
153
+ if (part.type === "image") {
154
+ return { type: "text", text: "[image omitted for /qq speed]" };
155
+ }
156
+ return part;
157
+ }) as T;
158
+ }
159
+
160
+ function trimUserContent(content: UserMessage["content"]): UserMessage["content"] {
161
+ if (typeof content === "string") return clipText(content);
162
+ return trimContentArray(content);
163
+ }
164
+
165
+ function trimMessageForContext(message: Message): Message {
166
+ if (message.role === "assistant") {
167
+ return { ...message, content: trimContentArray(message.content) };
168
+ }
169
+ if (message.role === "user") {
170
+ return { ...message, content: trimUserContent(message.content) };
171
+ }
172
+ return { ...message, content: trimContentArray(message.content) };
173
+ }
174
+
175
+ function selectRecentContextMessages(messages: Message[]): Message[] {
176
+ return messages.slice(-RECENT_CONTEXT_MESSAGE_LIMIT).map(trimMessageForContext);
177
+ }
178
+
179
+ function selectFullContextMessages(messages: Message[]): Message[] {
180
+ if (messages.length <= FULL_CONTEXT_HEAD_MESSAGE_LIMIT + FULL_CONTEXT_TAIL_MESSAGE_LIMIT) {
181
+ return messages.map(trimMessageForContext);
182
+ }
183
+ return [
184
+ ...messages.slice(0, FULL_CONTEXT_HEAD_MESSAGE_LIMIT),
185
+ ...messages.slice(-FULL_CONTEXT_TAIL_MESSAGE_LIMIT),
186
+ ].map(trimMessageForContext);
187
+ }
188
+
189
+ function selectContextMessages(messages: Message[], mode: QqContextMode): Message[] {
190
+ return mode === "full" ? selectFullContextMessages(messages) : selectRecentContextMessages(messages);
191
+ }
192
+
193
+ function parseQqArgs(args: string): ParsedQqArgs {
194
+ const trimmed = args.trim();
195
+ if (trimmed.startsWith("--recent ")) {
196
+ return { mode: "recent", question: trimmed.slice("--recent ".length).trim() };
197
+ }
198
+ if (trimmed === "--recent") {
199
+ return { mode: "recent", question: "" };
200
+ }
201
+ if (trimmed.startsWith("--full ")) {
202
+ return { mode: "full", question: trimmed.slice("--full ".length).trim() };
203
+ }
204
+ if (trimmed === "--full") {
205
+ return { mode: "full", question: "" };
206
+ }
207
+ return { mode: detectContextMode(trimmed), question: trimmed };
208
+ }
209
+
210
+ function includesAny(text: string, phrases: string[]): boolean {
211
+ return phrases.some((phrase) => text.includes(phrase));
212
+ }
213
+
214
+ function detectContextMode(question: string): QqContextMode {
215
+ const normalized = question.toLowerCase();
216
+
217
+ const recentPhrases = [
218
+ "last turn",
219
+ "previous turn",
220
+ "last message",
221
+ "latest",
222
+ "just now",
223
+ "what did we just",
224
+ "right now",
225
+ "current",
226
+ "currently",
227
+ "most recent",
228
+ "the last thing",
229
+ ];
230
+ if (includesAny(normalized, recentPhrases)) {
231
+ return "recent";
232
+ }
233
+
234
+ const fullPhrases = [
235
+ "entire session",
236
+ "whole session",
237
+ "this session",
238
+ "from the beginning",
239
+ "full context",
240
+ "so far",
241
+ "overall",
242
+ "earlier",
243
+ "previously",
244
+ "at the start",
245
+ "originally",
246
+ "what have we done",
247
+ "what did we decide",
248
+ "summarize",
249
+ "recap",
250
+ ];
251
+ if (includesAny(normalized, fullPhrases)) {
252
+ return "full";
253
+ }
254
+
255
+ return "recent";
256
+ }
257
+
85
258
  export interface QqExecResult {
86
259
  ok: boolean;
87
260
  answer?: string;
@@ -92,23 +265,26 @@ export interface QqExecResult {
92
265
  aborted?: boolean;
93
266
  }
94
267
 
95
- function readBranchMessages(ctx: ExtensionContext): Message[] {
268
+ function readBranchMessages(ctx: ExtensionContext, mode: QqContextMode): Message[] {
96
269
  const cached = getSnapshot(ctx);
97
- if (cached) return cached.messages;
270
+ if (cached) return selectContextMessages(cached.messages, mode);
98
271
 
99
272
  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);
273
+ const messageEntries = branch.filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message");
274
+ const selectedEntries = mode === "recent" ? messageEntries.slice(-RECENT_CONTEXT_MESSAGE_LIMIT) : messageEntries;
275
+ return selectContextMessages(
276
+ convertToLlm(selectedEntries.map((entry) => entry.message)),
277
+ mode,
278
+ );
104
279
  }
105
280
 
106
- function buildQqMessages(ctx: ExtensionContext, userMessage: UserMessage): Message[] {
107
- return [...readBranchMessages(ctx), userMessage];
281
+ function buildQqMessages(ctx: ExtensionContext, userMessage: UserMessage, mode: QqContextMode): Message[] {
282
+ return [...readBranchMessages(ctx, mode), userMessage];
108
283
  }
109
284
 
110
285
  export async function executeQq(
111
286
  question: string,
287
+ mode: QqContextMode,
112
288
  ctx: ExtensionContext,
113
289
  controller: AbortController,
114
290
  ): Promise<QqExecResult> {
@@ -135,7 +311,7 @@ export async function executeQq(
135
311
  try {
136
312
  const response = await completeSimple(
137
313
  model,
138
- { systemPrompt: QQ_SYSTEM_PROMPT, messages: buildQqMessages(ctx, userMessage), tools: [] },
314
+ { systemPrompt: QQ_SYSTEM_PROMPT, messages: buildQqMessages(ctx, userMessage, mode), tools: [] },
139
315
  {
140
316
  apiKey: auth.apiKey,
141
317
  headers: auth.headers,
@@ -213,6 +389,10 @@ export function registerQqCommand(pi: ExtensionAPI): void {
213
389
  description: "Ask a quick question without polluting the main conversation",
214
390
  handler: (args: string, ctx: ExtensionCommandContext) => handleQqCommand(args, ctx),
215
391
  });
392
+ pi.registerCommand(QQ_HISTORY_COMMAND_NAME, {
393
+ description: "Show recent /qq answers for this session",
394
+ handler: (_args: string, ctx: ExtensionCommandContext) => handleQqHistoryCommand(ctx),
395
+ });
216
396
  }
217
397
 
218
398
  async function handleQqCommand(args: string, ctx: ExtensionCommandContext): Promise<void> {
@@ -220,8 +400,8 @@ async function handleQqCommand(args: string, ctx: ExtensionCommandContext): Prom
220
400
  ctx.ui.notify(MSG_REQUIRES_INTERACTIVE, "error");
221
401
  return;
222
402
  }
223
- const question = args.trim();
224
- if (!question) {
403
+ const parsedArgs = parseQqArgs(args);
404
+ if (!parsedArgs.question) {
225
405
  ctx.ui.notify(MSG_USAGE, "warning");
226
406
  return;
227
407
  }
@@ -233,14 +413,19 @@ async function handleQqCommand(args: string, ctx: ExtensionCommandContext): Prom
233
413
  const controller = new AbortController();
234
414
  const { overlayPromise, controllerReady } = showQqOverlay({
235
415
  ctx,
236
- question,
416
+ question: parsedArgs.question,
237
417
  controller,
238
418
  });
239
419
 
240
420
  const overlayCtl = await controllerReady;
241
- const result = await executeQq(question, ctx, controller);
421
+ const result = await executeQq(parsedArgs.question, parsedArgs.mode, ctx, controller);
242
422
 
243
423
  if (result.ok && result.answer) {
424
+ pushSessionHistory(ctx, {
425
+ question: parsedArgs.question,
426
+ answer: result.answer,
427
+ timestamp: Date.now(),
428
+ });
244
429
  overlayCtl.setAnswer(result.answer);
245
430
  } else if (result.aborted) {
246
431
  // User Esc'd — overlay already dismissed via done(); no further action.
@@ -250,3 +435,27 @@ async function handleQqCommand(args: string, ctx: ExtensionCommandContext): Prom
250
435
 
251
436
  await overlayPromise;
252
437
  }
438
+
439
+ async function handleQqHistoryCommand(ctx: ExtensionCommandContext): Promise<void> {
440
+ if (!ctx.hasUI) {
441
+ ctx.ui.notify(MSG_REQUIRES_INTERACTIVE, "error");
442
+ return;
443
+ }
444
+ const history = getSessionHistory(ctx);
445
+ if (history.length === 0) {
446
+ ctx.ui.notify(MSG_NO_HISTORY, "info");
447
+ return;
448
+ }
449
+
450
+ const controller = new AbortController();
451
+ const { overlayPromise, controllerReady } = showQqOverlay({
452
+ ctx,
453
+ question: `${history.length} recent answer${history.length === 1 ? "" : "s"}`,
454
+ controller,
455
+ commandLabel: "/qq-history",
456
+ });
457
+
458
+ const overlayCtl = await controllerReady;
459
+ overlayCtl.setAnswer(formatQqHistory(history));
460
+ await overlayPromise;
461
+ }