pi-voice-input 0.2.13 → 0.2.15

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.
@@ -6,6 +6,7 @@ import { randomUUID } from "node:crypto";
6
6
  import {
7
7
  chmodSync,
8
8
  existsSync,
9
+ lstatSync,
9
10
  mkdirSync,
10
11
  mkdtempSync,
11
12
  readFileSync,
@@ -24,6 +25,25 @@ const CONFIG_PATH = path.join(homedir(), ".pi", "agent", "voice-input.config.jso
24
25
  const VOLC_API_KEY_URL = "https://console.volcengine.com/speech/new/setting/apikeys?projectName=default";
25
26
  const DEFAULT_SHORTCUT = Key.ctrlShift("r");
26
27
  const DEFAULT_POSTPROCESS_MODEL = "";
28
+ const DEFAULT_POSTPROCESS_CONTEXT_TOKENS = 20000;
29
+ const POSTPROCESS_GIT_LOG_LIMIT = 10;
30
+ const POSTPROCESS_DIRECTORY_DEPTH = 2;
31
+ const POSTPROCESS_DIRECTORY_TOKENS = 20000;
32
+ const SKIPPED_DIRECTORY_ENTRIES = new Set([
33
+ ".git",
34
+ ".hg",
35
+ ".svn",
36
+ "node_modules",
37
+ "dist",
38
+ "build",
39
+ "coverage",
40
+ ".next",
41
+ ".nuxt",
42
+ ".turbo",
43
+ ".cache",
44
+ "logs",
45
+ "recordings",
46
+ ]);
27
47
  const POSTPROCESS_SYSTEM_PROMPT = `You are the speech-recognition postprocessor for the pi voice input extension. Your only job is to polish the raw ASR text into text that the plugin can paste verbatim at the current cursor position in the pi editor.
28
48
 
29
49
  Interaction contract:
@@ -88,7 +108,7 @@ type VoiceConfig = {
88
108
  postprocessModel: string;
89
109
  postprocessTimeoutMs: number;
90
110
  postprocessMaxTokens: number;
91
- postprocessContextChars: number;
111
+ postprocessContextTokens: number;
92
112
  duckSystemVolume: boolean;
93
113
  duckSystemVolumeFactor: number;
94
114
  duckSystemVolumeFadeMs: number;
@@ -222,7 +242,7 @@ function getConfig(): VoiceConfig {
222
242
  postprocessModel: polishModel,
223
243
  postprocessTimeoutMs: 30000,
224
244
  postprocessMaxTokens: 2048,
225
- postprocessContextChars: 6000,
245
+ postprocessContextTokens: DEFAULT_POSTPROCESS_CONTEXT_TOKENS,
226
246
  duckSystemVolume: fileConfig.duckSystemVolume,
227
247
  duckSystemVolumeFactor: fileConfig.duckSystemVolumeFactor,
228
248
  duckSystemVolumeFadeMs: fileConfig.duckSystemVolumeFadeMs,
@@ -956,16 +976,77 @@ async function transcribePcm(pcm: Buffer, durationMs: number, config: VoiceConfi
956
976
  };
957
977
  }
958
978
 
959
- function tailText(text: string, maxChars: number): string {
960
- if (maxChars <= 0) return "";
961
- if (text.length <= maxChars) return text;
962
- return `…${text.slice(-maxChars)}`;
979
+ function estimateTokens(text: string): number {
980
+ let tokens = 0;
981
+ let asciiRun = 0;
982
+ const flushAscii = () => {
983
+ if (asciiRun > 0) {
984
+ tokens += Math.ceil(asciiRun / 4);
985
+ asciiRun = 0;
986
+ }
987
+ };
988
+
989
+ for (const char of text) {
990
+ if (/\s/u.test(char)) {
991
+ flushAscii();
992
+ } else if (/[^\x00-\x7F]/u.test(char)) {
993
+ flushAscii();
994
+ tokens += 1;
995
+ } else {
996
+ asciiRun += 1;
997
+ }
998
+ }
999
+ flushAscii();
1000
+ return tokens;
1001
+ }
1002
+
1003
+ function takeTokensFromStart(text: string, maxTokens: number): string {
1004
+ if (maxTokens <= 0 || !text) return "";
1005
+ if (estimateTokens(text) <= maxTokens) return text;
1006
+
1007
+ let low = 0;
1008
+ let high = text.length;
1009
+ while (low < high) {
1010
+ const mid = Math.ceil((low + high) / 2);
1011
+ if (estimateTokens(text.slice(0, mid)) <= maxTokens) low = mid;
1012
+ else high = mid - 1;
1013
+ }
1014
+ return text.slice(0, low);
1015
+ }
1016
+
1017
+ function takeTokensFromEnd(text: string, maxTokens: number): string {
1018
+ if (maxTokens <= 0 || !text) return "";
1019
+ if (estimateTokens(text) <= maxTokens) return text;
1020
+
1021
+ let low = 0;
1022
+ let high = text.length;
1023
+ while (low < high) {
1024
+ const mid = Math.ceil((low + high) / 2);
1025
+ if (estimateTokens(text.slice(text.length - mid)) <= maxTokens) low = mid;
1026
+ else high = mid - 1;
1027
+ }
1028
+ return text.slice(text.length - low);
1029
+ }
1030
+
1031
+ function limitTokensFromStart(text: string, maxTokens: number, marker = "\n…(truncated to fit token budget)"): string {
1032
+ if (maxTokens <= 0) return "";
1033
+ if (estimateTokens(text) <= maxTokens) return text;
1034
+ const markerTokens = estimateTokens(marker);
1035
+ return `${takeTokensFromStart(text, Math.max(0, maxTokens - markerTokens)).trimEnd()}${marker}`;
1036
+ }
1037
+
1038
+ function limitTokensFromEnd(text: string, maxTokens: number, marker = "…(older context omitted to fit token budget)\n"): string {
1039
+ if (maxTokens <= 0) return "";
1040
+ if (estimateTokens(text) <= maxTokens) return text;
1041
+ const markerTokens = estimateTokens(marker);
1042
+ return `${marker}${takeTokensFromEnd(text, Math.max(0, maxTokens - markerTokens)).trimStart()}`;
963
1043
  }
964
1044
 
965
- function truncateText(text: string, maxChars: number): string {
966
- if (maxChars <= 0) return "";
967
- if (text.length <= maxChars) return text;
968
- return `${text.slice(0, maxChars)}…`;
1045
+ function redactSensitiveText(text: string): string {
1046
+ return text
1047
+ .replace(/(\bVOLC_API_KEY\s*=\s*)[^\s"']+/giu, "$1[redacted]")
1048
+ .replace(/((?:\b|["'])(?:volcApiKey|api[_-]?key|apikey|access[_-]?token|secret|password)(?:\b|["'])\s*[:=]\s*["']?)[^"'\s,}]+/giu, "$1[redacted]")
1049
+ .replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/giu, "[redacted-uuid]");
969
1050
  }
970
1051
 
971
1052
  function textFromContent(content: unknown): string {
@@ -982,27 +1063,163 @@ function textFromContent(content: unknown): string {
982
1063
  .join("\n");
983
1064
  }
984
1065
 
985
- function getEditorContext(ctx: ExtensionContext, maxChars: number): string {
986
- if (maxChars <= 0) return "";
1066
+ function getEditorContext(ctx: ExtensionContext): string {
987
1067
  try {
988
- return tailText(ctx.ui.getEditorText(), maxChars);
1068
+ return redactSensitiveText(ctx.ui.getEditorText()).trim();
989
1069
  } catch {
990
1070
  return "";
991
1071
  }
992
1072
  }
993
1073
 
994
- function getRecentSessionContext(ctx: ExtensionContext, maxChars: number): string {
995
- if (maxChars <= 0) return "";
1074
+ function getRecentSessionContext(ctx: ExtensionContext): string {
996
1075
  const lines: string[] = [];
997
1076
  for (const entry of ctx.sessionManager.getBranch()) {
998
1077
  if (entry.type !== "message") continue;
999
1078
  const message = entry.message as { role?: unknown; content?: unknown };
1000
1079
  if (message.role !== "user" && message.role !== "assistant") continue;
1001
- const text = textFromContent(message.content).replace(/\s+/g, " ").trim();
1080
+ const text = redactSensitiveText(textFromContent(message.content)).trim();
1002
1081
  if (!text) continue;
1003
- lines.push(`${message.role}: ${truncateText(text, 1200)}`);
1082
+ lines.push(`${message.role}: ${text}`);
1083
+ }
1084
+ return lines.join("\n\n");
1085
+ }
1086
+
1087
+ function buildConversationContext(ctx: ExtensionContext, maxTokens: number): { editorContext: string; sessionContext: string } {
1088
+ if (maxTokens <= 0) return { editorContext: "", sessionContext: "" };
1089
+
1090
+ const editorContext = getEditorContext(ctx);
1091
+ const sessionContext = getRecentSessionContext(ctx);
1092
+ const editorSection = [
1093
+ "--- Context: current unsent editor draft (context only; do not output wholesale) ---",
1094
+ editorContext || "(empty)",
1095
+ ].join("\n");
1096
+ const sessionHeader = "--- Context: recent conversation ---\n";
1097
+ const totalTokens = estimateTokens([editorSection, sessionHeader, sessionContext || "(empty)"].join("\n\n"));
1098
+ if (totalTokens <= maxTokens) return { editorContext, sessionContext };
1099
+
1100
+ const editorTokenBudget = Math.min(5000, Math.floor(maxTokens / 4));
1101
+ const limitedEditor = editorContext ? limitTokensFromEnd(editorContext, editorTokenBudget) : "";
1102
+ const fixedTokens = estimateTokens(
1103
+ [
1104
+ "--- Context: current unsent editor draft (context only; do not output wholesale) ---",
1105
+ limitedEditor || "(empty)",
1106
+ sessionHeader,
1107
+ ].join("\n\n"),
1108
+ );
1109
+ const sessionBudget = Math.max(0, maxTokens - fixedTokens);
1110
+ return {
1111
+ editorContext: limitedEditor,
1112
+ sessionContext: sessionContext ? limitTokensFromEnd(sessionContext, sessionBudget) : "",
1113
+ };
1114
+ }
1115
+
1116
+ function commandOutputInDir(cwd: string, command: string, args: string[], timeoutMs = 2500): string {
1117
+ const result = spawnSync(command, args, { cwd, encoding: "utf8", timeout: timeoutMs, maxBuffer: 1024 * 1024 * 8 });
1118
+ if (result.status !== 0) return "";
1119
+ return (result.stdout || "").trim();
1120
+ }
1121
+
1122
+ function findGitRoot(startDir: string): string | null {
1123
+ const root = commandOutputInDir(startDir, "git", ["rev-parse", "--show-toplevel"], 1500);
1124
+ return root || null;
1125
+ }
1126
+
1127
+ function buildGitContext(cwd: string): string {
1128
+ const gitRoot = findGitRoot(cwd);
1129
+ if (!gitRoot) return "(not a git repository)";
1130
+
1131
+ const recentLog = commandOutputInDir(
1132
+ gitRoot,
1133
+ "git",
1134
+ ["log", `-${POSTPROCESS_GIT_LOG_LIMIT}`, "--date=short", "--pretty=format:%h %ad %an %s"],
1135
+ 2500,
1136
+ );
1137
+
1138
+ return [
1139
+ `Repository root: ${gitRoot}`,
1140
+ `Recent commits (up to ${POSTPROCESS_GIT_LOG_LIMIT}):`,
1141
+ recentLog ? redactSensitiveText(recentLog) : "(no commits yet)",
1142
+ ].join("\n");
1143
+ }
1144
+
1145
+ function shouldSkipDirectoryEntry(name: string): boolean {
1146
+ return SKIPPED_DIRECTORY_ENTRIES.has(name);
1147
+ }
1148
+
1149
+ function safeReadDir(dir: string): string[] {
1150
+ try {
1151
+ return readdirSync(dir).sort((a, b) => a.localeCompare(b));
1152
+ } catch {
1153
+ return [];
1154
+ }
1155
+ }
1156
+
1157
+ function formatDirectoryEntry(fullPath: string, name: string, isCwd = false): string {
1158
+ try {
1159
+ const stat = lstatSync(fullPath);
1160
+ const suffix = stat.isDirectory() ? "/" : stat.isSymbolicLink() ? "@" : "";
1161
+ return `${name}${suffix}${isCwd ? " <-- current" : ""}`;
1162
+ } catch {
1163
+ return `${name}${isCwd ? " <-- current" : ""}`;
1004
1164
  }
1005
- return tailText(lines.slice(-8).join("\n"), maxChars);
1165
+ }
1166
+
1167
+ function buildTreeLines(dir: string, depth: number, prefix = "", state = { entries: 0, omitted: 0 }): string[] {
1168
+ if (depth < 0) return [];
1169
+ const names = safeReadDir(dir).filter((name) => !shouldSkipDirectoryEntry(name));
1170
+ const visibleNames = names.slice(0, 120);
1171
+ state.omitted += Math.max(0, names.length - visibleNames.length);
1172
+ const lines: string[] = [];
1173
+
1174
+ visibleNames.forEach((name, index) => {
1175
+ const fullPath = path.join(dir, name);
1176
+ const isLast = index === visibleNames.length - 1;
1177
+ const connector = isLast ? "└── " : "├── ";
1178
+ const childPrefix = `${prefix}${isLast ? " " : "│ "}`;
1179
+ const label = formatDirectoryEntry(fullPath, name);
1180
+ lines.push(`${prefix}${connector}${label}`);
1181
+ state.entries += 1;
1182
+
1183
+ try {
1184
+ const stat = lstatSync(fullPath);
1185
+ if (stat.isDirectory() && !stat.isSymbolicLink() && depth > 0) {
1186
+ lines.push(...buildTreeLines(fullPath, depth - 1, childPrefix, state));
1187
+ }
1188
+ } catch {
1189
+ // ignore unreadable entries
1190
+ }
1191
+ });
1192
+
1193
+ return lines;
1194
+ }
1195
+
1196
+ function buildDirectoryContext(cwd: string, maxTokens: number): string {
1197
+ if (maxTokens <= 0) return "";
1198
+ const parent = path.dirname(cwd);
1199
+ const isRootDirectory = path.resolve(parent) === path.resolve(cwd);
1200
+ const cwdName = path.basename(cwd) || cwd;
1201
+ const parentEntries = isRootDirectory
1202
+ ? []
1203
+ : safeReadDir(parent)
1204
+ .filter((name) => !shouldSkipDirectoryEntry(name))
1205
+ .slice(0, 160)
1206
+ .map((name) => formatDirectoryEntry(path.join(parent, name), name, path.resolve(parent, name) === path.resolve(cwd)));
1207
+ const state = { entries: 0, omitted: 0 };
1208
+ const cwdTree = buildTreeLines(cwd, POSTPROCESS_DIRECTORY_DEPTH - 1, "", state);
1209
+ const text = [
1210
+ `Current directory: ${cwd}`,
1211
+ isRootDirectory ? "Parent directory: (current directory is filesystem root; no parent directory)" : `Parent directory: ${parent}`,
1212
+ "Parent entries (one level up):",
1213
+ isRootDirectory ? "- (none; current directory is filesystem root)" : parentEntries.length ? parentEntries.map((entry) => `- ${entry}`).join("\n") : "- (empty or unreadable)",
1214
+ "",
1215
+ `Current directory tree (${cwdName}/, ${POSTPROCESS_DIRECTORY_DEPTH} levels deep):`,
1216
+ `${cwdName}/`,
1217
+ cwdTree.length ? cwdTree.join("\n") : "└── (empty or no readable child entries)",
1218
+ state.omitted > 0 ? `…(${state.omitted} entries omitted)` : "",
1219
+ ]
1220
+ .filter(Boolean)
1221
+ .join("\n");
1222
+ return limitTokensFromStart(text, maxTokens);
1006
1223
  }
1007
1224
 
1008
1225
  function simplifyModelReference(value: string): string {
@@ -1169,9 +1386,10 @@ function getFullEditorText(ctx: ExtensionContext): string {
1169
1386
  }
1170
1387
 
1171
1388
  function buildPostprocessPrompt(ctx: ExtensionContext, rawText: string, config: VoiceConfig): string {
1172
- const contextBudget = config.postprocessContextChars;
1173
- const editorContext = getEditorContext(ctx, Math.floor(contextBudget / 2));
1174
- const sessionContext = getRecentSessionContext(ctx, Math.ceil(contextBudget / 2));
1389
+ const { editorContext, sessionContext } = buildConversationContext(ctx, config.postprocessContextTokens);
1390
+ const cwd = process.cwd();
1391
+ const gitContext = buildGitContext(cwd);
1392
+ const directoryContext = buildDirectoryContext(cwd, POSTPROCESS_DIRECTORY_TOKENS);
1175
1393
 
1176
1394
  return [
1177
1395
  "Polish only the raw ASR text below, using context only when it helps disambiguate the user's intent.",
@@ -1184,15 +1402,22 @@ function buildPostprocessPrompt(ctx: ExtensionContext, rawText: string, config:
1184
1402
  "The true cursor position is not marked in the draft shown here; the pi editor owns the actual insertion point. Do not guess the cursor and synthesize a full surrounding sentence.",
1185
1403
  "Preserve layout: if the raw ASR text is one line, output one line unless the user explicitly dictated line breaks or another multiline layout.",
1186
1404
  "If the raw speech is an inline insertion, continuation, a few words, or a phrase, output only the newly spoken words or phrase.",
1405
+ "Use the git history and directory structure only as reference context for project names, files, APIs, and intent; never summarize them in the output.",
1187
1406
  "Example: draft is `Please make this function async and [cursor].`, raw speech is `add error handling`, correct output is `add error handling`, not `Please make this function async and add error handling.`.",
1188
1407
  "Example: draft is `This variable name is [cursor]unclear`, raw speech is `still`, correct output is `still`, not `This variable name is still unclear`.",
1189
1408
  "",
1190
1409
  "--- Context: current unsent editor draft (context only; do not output wholesale) ---",
1191
- editorContext.trim() || "(empty)",
1410
+ editorContext || "(empty)",
1192
1411
  "",
1193
- "--- Context: recent conversation ---",
1412
+ "--- Context: recent conversation (recent tail, capped at 20k estimated tokens with the editor draft) ---",
1194
1413
  sessionContext || "(empty)",
1195
1414
  "",
1415
+ "--- Context: git history (latest commit summaries only) ---",
1416
+ gitContext || "(empty)",
1417
+ "",
1418
+ "--- Context: directory structure (parent one level; current directory two levels deep) ---",
1419
+ directoryContext || "(empty)",
1420
+ "",
1196
1421
  "--- Raw ASR text ---",
1197
1422
  rawText.trim(),
1198
1423
  ].join("\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-voice-input",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "description": "Press Ctrl+Shift+R to dictate prompts into Pi using VolcEngine ASR",
5
5
  "type": "module",
6
6
  "keywords": [