@phren/cli 0.0.57 → 0.0.58

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.
Files changed (166) hide show
  1. package/dist/capabilities/cli.d.ts +2 -0
  2. package/dist/capabilities/index.d.ts +7 -0
  3. package/dist/capabilities/mcp.d.ts +2 -0
  4. package/dist/capabilities/types.d.ts +12 -0
  5. package/dist/capabilities/vscode.d.ts +2 -0
  6. package/dist/capabilities/web-ui.d.ts +2 -0
  7. package/dist/cli/actions.d.ts +16 -0
  8. package/dist/cli/cli.d.ts +3 -0
  9. package/dist/cli/config.d.ts +24 -0
  10. package/dist/cli/extract.d.ts +22 -0
  11. package/dist/cli/govern.d.ts +11 -0
  12. package/dist/cli/graph.d.ts +15 -0
  13. package/dist/cli/hooks-citations.d.ts +8 -0
  14. package/dist/cli/hooks-context.d.ts +34 -0
  15. package/dist/cli/hooks-globs.d.ts +2 -0
  16. package/dist/cli/hooks-output.d.ts +2 -0
  17. package/dist/cli/hooks-session.d.ts +38 -0
  18. package/dist/cli/hooks.d.ts +12 -0
  19. package/dist/cli/namespaces.d.ts +11 -0
  20. package/dist/cli/ops.d.ts +5 -0
  21. package/dist/cli/search.d.ts +38 -0
  22. package/dist/cli/team.d.ts +1 -0
  23. package/dist/cli-hooks-git.d.ts +35 -0
  24. package/dist/cli-hooks-prompt.d.ts +18 -0
  25. package/dist/cli-hooks-session-handlers.d.ts +3 -0
  26. package/dist/cli-hooks-stop.d.ts +11 -0
  27. package/dist/content/archive.d.ts +13 -0
  28. package/dist/content/citation.d.ts +50 -0
  29. package/dist/content/dedup.d.ts +55 -0
  30. package/dist/content/learning.d.ts +29 -0
  31. package/dist/content/metadata.d.ts +107 -0
  32. package/dist/content/validate.d.ts +70 -0
  33. package/dist/core/finding.d.ts +25 -0
  34. package/dist/core/project.d.ts +16 -0
  35. package/dist/core/search.d.ts +13 -0
  36. package/dist/data/access.d.ts +83 -0
  37. package/dist/data/tasks.d.ts +89 -0
  38. package/dist/embedding.d.ts +54 -0
  39. package/dist/entrypoint.d.ts +1 -0
  40. package/dist/finding/context.d.ts +8 -0
  41. package/dist/finding/impact.d.ts +11 -0
  42. package/dist/finding/journal.d.ts +40 -0
  43. package/dist/finding/lifecycle.d.ts +40 -0
  44. package/dist/governance/audit.d.ts +1 -0
  45. package/dist/governance/locks.d.ts +3 -0
  46. package/dist/governance/policy.d.ts +109 -0
  47. package/dist/governance/rbac.d.ts +25 -0
  48. package/dist/governance/scores.d.ts +12 -0
  49. package/dist/hooks.d.ts +59 -0
  50. package/dist/index-query.d.ts +33 -0
  51. package/dist/index.d.ts +2 -0
  52. package/dist/init/config.d.ts +43 -0
  53. package/dist/init/init-configure.d.ts +21 -0
  54. package/dist/init/init-hooks-mode.d.ts +1 -0
  55. package/dist/init/init-mcp-mode.d.ts +1 -0
  56. package/dist/init/init-uninstall.d.ts +3 -0
  57. package/dist/init/init-walkthrough.d.ts +61 -0
  58. package/dist/init/init.d.ts +84 -0
  59. package/dist/init/preferences.d.ts +28 -0
  60. package/dist/init/setup.d.ts +86 -0
  61. package/dist/init/shared.d.ts +13 -0
  62. package/dist/init-bootstrap.d.ts +5 -0
  63. package/dist/init-detect.d.ts +8 -0
  64. package/dist/init-env.d.ts +6 -0
  65. package/dist/init-fresh.d.ts +10 -0
  66. package/dist/init-hooks.d.ts +5 -0
  67. package/dist/init-mcp.d.ts +8 -0
  68. package/dist/init-modes.d.ts +4 -0
  69. package/dist/init-npm.d.ts +10 -0
  70. package/dist/init-project-local.d.ts +2 -0
  71. package/dist/init-semantic.d.ts +4 -0
  72. package/dist/init-types.d.ts +59 -0
  73. package/dist/init-uninstall.d.ts +3 -0
  74. package/dist/init-update.d.ts +10 -0
  75. package/dist/init-walkthrough.d.ts +55 -0
  76. package/dist/link/checksums.d.ts +8 -0
  77. package/dist/link/context.d.ts +7 -0
  78. package/dist/link/doctor.d.ts +2 -0
  79. package/dist/link/link.d.ts +29 -0
  80. package/dist/link/skills.d.ts +47 -0
  81. package/dist/logger.d.ts +9 -0
  82. package/dist/machine-identity.d.ts +4 -0
  83. package/dist/package-metadata.d.ts +4 -0
  84. package/dist/phren-art.d.ts +26 -0
  85. package/dist/phren-core.d.ts +64 -0
  86. package/dist/phren-dotenv.d.ts +2 -0
  87. package/dist/phren-paths.d.ts +60 -0
  88. package/dist/proactivity.d.ts +13 -0
  89. package/dist/profile-store.d.ts +34 -0
  90. package/dist/project-config.d.ts +60 -0
  91. package/dist/project-locator.d.ts +1 -0
  92. package/dist/project-topics.d.ts +122 -0
  93. package/dist/provider-adapters.d.ts +34 -0
  94. package/dist/query-correlation.d.ts +31 -0
  95. package/dist/runtime-profile.d.ts +6 -0
  96. package/dist/session/checkpoints.d.ts +25 -0
  97. package/dist/session/utils.d.ts +43 -0
  98. package/dist/shared/content.d.ts +7 -0
  99. package/dist/shared/data-utils.d.ts +8 -0
  100. package/dist/shared/embedding-cache.d.ts +30 -0
  101. package/dist/shared/fragment-graph.d.ts +60 -0
  102. package/dist/shared/governance.d.ts +4 -0
  103. package/dist/shared/index.d.ts +29 -0
  104. package/dist/shared/ollama.d.ts +28 -0
  105. package/dist/shared/process.d.ts +17 -0
  106. package/dist/shared/retrieval.d.ts +84 -0
  107. package/dist/shared/search-fallback.d.ts +23 -0
  108. package/dist/shared/sqljs.d.ts +5 -0
  109. package/dist/shared/stemmer.d.ts +5 -0
  110. package/dist/shared/vector-index.d.ts +18 -0
  111. package/dist/shared.d.ts +9 -0
  112. package/dist/shell/entry.d.ts +26 -0
  113. package/dist/shell/input.d.ts +57 -0
  114. package/dist/shell/palette.d.ts +13 -0
  115. package/dist/shell/render-api.d.ts +29 -0
  116. package/dist/shell/render.d.ts +50 -0
  117. package/dist/shell/shell.d.ts +61 -0
  118. package/dist/shell/state-store.d.ts +14 -0
  119. package/dist/shell/types.d.ts +29 -0
  120. package/dist/shell/view-list.d.ts +5 -0
  121. package/dist/shell/view.d.ts +34 -0
  122. package/dist/skill/files.d.ts +5 -0
  123. package/dist/skill/registry.d.ts +55 -0
  124. package/dist/skill/state.d.ts +3 -0
  125. package/dist/startup-embedding.d.ts +15 -0
  126. package/dist/status.d.ts +1 -0
  127. package/dist/store-registry.d.ts +60 -0
  128. package/dist/store-routing.d.ts +37 -0
  129. package/dist/task/github.d.ts +22 -0
  130. package/dist/task/hygiene.d.ts +13 -0
  131. package/dist/task/lifecycle.d.ts +26 -0
  132. package/dist/telemetry.d.ts +10 -0
  133. package/dist/test-global-setup.d.ts +14 -0
  134. package/dist/tool-registry.d.ts +14 -0
  135. package/dist/tools/config.d.ts +3 -0
  136. package/dist/tools/data.d.ts +3 -0
  137. package/dist/tools/extract-facts.d.ts +17 -0
  138. package/dist/tools/extract.d.ts +3 -0
  139. package/dist/tools/finding.d.ts +3 -0
  140. package/dist/tools/graph.d.ts +3 -0
  141. package/dist/tools/hooks.d.ts +3 -0
  142. package/dist/tools/memory.d.ts +3 -0
  143. package/dist/tools/ops.d.ts +3 -0
  144. package/dist/tools/search.d.ts +8 -0
  145. package/dist/tools/session.d.ts +44 -0
  146. package/dist/tools/skills.d.ts +3 -0
  147. package/dist/tools/tasks.d.ts +3 -0
  148. package/dist/tools/types.d.ts +50 -0
  149. package/dist/ui/assets.d.ts +2 -0
  150. package/dist/ui/data.d.ts +113 -0
  151. package/dist/ui/graph.d.ts +1 -0
  152. package/dist/ui/memory-ui.d.ts +4 -0
  153. package/dist/ui/page.d.ts +2 -0
  154. package/dist/ui/scripts.d.ts +17 -0
  155. package/dist/ui/server.d.ts +17 -0
  156. package/dist/ui/styles.d.ts +4 -0
  157. package/dist/update.d.ts +9 -0
  158. package/dist/utils-fts.d.ts +12 -0
  159. package/dist/utils-fts.js +450 -0
  160. package/dist/utils-helpers.d.ts +12 -0
  161. package/dist/utils-helpers.js +80 -0
  162. package/dist/utils-paths.d.ts +3 -0
  163. package/dist/utils-paths.js +61 -0
  164. package/dist/utils.d.ts +3 -0
  165. package/dist/utils.js +8 -587
  166. package/package.json +45 -11
@@ -0,0 +1,450 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import * as yaml from "js-yaml";
4
+ import { fileURLToPath } from "url";
5
+ import { findPhrenPath } from "./phren-paths.js";
6
+ import { isValidProjectName, safeProjectPath } from "./utils-paths.js";
7
+ // Lazy import of logDebug to break circular dependency:
8
+ // utils.ts -> phren-paths.ts -> logger.ts -> phren-paths.ts -> utils.ts
9
+ let _logDebug;
10
+ async function ensureLogDebug() {
11
+ if (!_logDebug) {
12
+ try {
13
+ const mod = await import("./logger.js");
14
+ _logDebug = mod.logger.debug;
15
+ }
16
+ catch {
17
+ _logDebug = () => { };
18
+ }
19
+ }
20
+ }
21
+ function getLogDebug() {
22
+ if (!_logDebug) {
23
+ // Kick off the async import for future calls; fall back to no-op for this call
24
+ void ensureLogDebug();
25
+ return () => { };
26
+ }
27
+ return _logDebug;
28
+ }
29
+ const _moduleDir = path.dirname(fileURLToPath(import.meta.url));
30
+ function loadSynonymsJson(fileName) {
31
+ const filePath = path.join(_moduleDir, fileName);
32
+ try {
33
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
34
+ }
35
+ catch (err) {
36
+ getLogDebug()("loadSynonymsJson", `${fileName} load failed: ${err instanceof Error ? err.message : String(err)}`);
37
+ return {};
38
+ }
39
+ }
40
+ const _baseSynonymsJson = loadSynonymsJson("synonyms.json");
41
+ // Common English stop words to strip from prompts before searching
42
+ export const STOP_WORDS = new Set([
43
+ "the", "is", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for",
44
+ "of", "with", "by", "from", "it", "this", "that", "are", "was", "were",
45
+ "be", "been", "being", "have", "has", "had", "do", "does", "did", "will",
46
+ "would", "could", "should", "may", "might", "can", "shall", "not", "no",
47
+ "so", "if", "then", "than", "too", "very", "just", "about", "up", "out",
48
+ "my", "me", "i", "you", "your", "we", "our", "they", "them", "their",
49
+ "he", "she", "his", "her", "its", "what", "which", "who", "when", "where",
50
+ "how", "why", "all", "each", "every", "some", "any", "few", "more", "most",
51
+ "other", "into", "over", "such", "only", "own", "same", "also", "back",
52
+ "get", "got", "make", "made", "take", "like", "well", "here", "there",
53
+ "use", "using", "used", "need", "want", "look", "help", "please",
54
+ ]);
55
+ // Extract meaningful keywords from a prompt, including bigrams (2-word noun phrases).
56
+ // Bigrams capture intent better than isolated words (e.g., "rate limit" vs "rate" + "limit").
57
+ export function extractKeywordEntries(text) {
58
+ const words = text
59
+ .toLowerCase()
60
+ .replace(/[^\p{L}\p{N}\s_-]/gu, " ")
61
+ .split(/\s+/)
62
+ .filter(w => w.length > 1 && !STOP_WORDS.has(w));
63
+ // Build bigrams from adjacent non-stop-words
64
+ const bigrams = [];
65
+ for (let i = 0; i < words.length - 1; i++) {
66
+ // Filter out bigrams where both tokens are stop words (words is already filtered,
67
+ // so this is defensive — the real stop-word bigram filter is in buildRobustFtsQuery)
68
+ bigrams.push(`${words[i]} ${words[i + 1]}`);
69
+ }
70
+ // Deduplicate and limit: prefer individual words first, then bigrams add extra signal
71
+ const seen = new Set();
72
+ const result = [];
73
+ for (const w of [...words, ...bigrams]) {
74
+ if (!seen.has(w)) {
75
+ seen.add(w);
76
+ result.push(w);
77
+ }
78
+ if (result.length >= 10)
79
+ break;
80
+ }
81
+ return result;
82
+ }
83
+ export function extractKeywords(text) {
84
+ return extractKeywordEntries(text).join(" ");
85
+ }
86
+ // Base synonym map for fuzzy search expansion — source of truth is mcp/src/synonyms.json
87
+ const BASE_SYNONYMS = _baseSynonymsJson;
88
+ const LEARNED_SYNONYMS_FILE = "learned-synonyms.json";
89
+ function normalizeSynonymTerm(term) {
90
+ return term.toLowerCase().replace(/"/g, "").trim();
91
+ }
92
+ function normalizeSynonymValues(items, baseTerm) {
93
+ const normalizedBase = baseTerm ? normalizeSynonymTerm(baseTerm) : "";
94
+ const seen = new Set();
95
+ const normalized = [];
96
+ for (const raw of items) {
97
+ const term = normalizeSynonymTerm(raw);
98
+ if (!term || term.length <= 1 || term === normalizedBase || seen.has(term))
99
+ continue;
100
+ seen.add(term);
101
+ normalized.push(term);
102
+ }
103
+ return normalized;
104
+ }
105
+ function mergeSynonymMaps(...maps) {
106
+ const merged = {};
107
+ for (const map of maps) {
108
+ for (const [rawKey, rawValues] of Object.entries(map)) {
109
+ const key = normalizeSynonymTerm(rawKey);
110
+ if (!key)
111
+ continue;
112
+ const existing = merged[key] ?? [];
113
+ const values = normalizeSynonymValues([...(existing || []), ...(Array.isArray(rawValues) ? rawValues : [])], key);
114
+ if (values.length > 0)
115
+ merged[key] = values;
116
+ }
117
+ }
118
+ return merged;
119
+ }
120
+ function parseSynonymsYaml(filePath) {
121
+ if (!fs.existsSync(filePath))
122
+ return {};
123
+ try {
124
+ const parsed = yaml.load(fs.readFileSync(filePath, "utf8"), { schema: yaml.CORE_SCHEMA });
125
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
126
+ return {};
127
+ const loaded = {};
128
+ for (const [rawKey, value] of Object.entries(parsed)) {
129
+ const key = String(rawKey).trim().toLowerCase();
130
+ if (!key || !Array.isArray(value))
131
+ continue;
132
+ const synonyms = value
133
+ .filter((item) => typeof item === "string")
134
+ .map((item) => item.replace(/"/g, "").trim())
135
+ .filter((item) => item.length > 1);
136
+ if (synonyms.length > 0)
137
+ loaded[key] = synonyms;
138
+ }
139
+ return loaded;
140
+ }
141
+ catch (err) {
142
+ getLogDebug()("parseSynonymsYaml", `synonyms.yaml parse failed (${filePath}): ${err instanceof Error ? err.message : String(err)}`);
143
+ return {};
144
+ }
145
+ }
146
+ function loadUserSynonyms(project, phrenPath) {
147
+ const resolved = phrenPath ?? findPhrenPath();
148
+ if (!resolved)
149
+ return {};
150
+ const globalSynonyms = parseSynonymsYaml(path.join(resolved, "global", "synonyms.yaml"));
151
+ if (!project || !isValidProjectName(project))
152
+ return globalSynonyms;
153
+ const projectSynonyms = parseSynonymsYaml(path.join(resolved, project, "synonyms.yaml"));
154
+ return {
155
+ ...globalSynonyms,
156
+ ...projectSynonyms,
157
+ };
158
+ }
159
+ function parseLearnedSynonymsJson(filePath) {
160
+ if (!fs.existsSync(filePath))
161
+ return {};
162
+ try {
163
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
164
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
165
+ return {};
166
+ const loaded = {};
167
+ for (const [rawKey, rawValue] of Object.entries(parsed)) {
168
+ if (!Array.isArray(rawValue))
169
+ continue;
170
+ const key = normalizeSynonymTerm(rawKey);
171
+ if (!key)
172
+ continue;
173
+ const synonyms = normalizeSynonymValues(rawValue.filter((v) => typeof v === "string"), key);
174
+ if (synonyms.length > 0)
175
+ loaded[key] = synonyms;
176
+ }
177
+ return loaded;
178
+ }
179
+ catch (err) {
180
+ getLogDebug()("parseLearnedSynonymsJson", `learned-synonyms parse failed (${filePath}): ${err instanceof Error ? err.message : String(err)}`);
181
+ return {};
182
+ }
183
+ }
184
+ export function learnedSynonymsPath(phrenPath, project) {
185
+ if (!isValidProjectName(project))
186
+ return null;
187
+ return safeProjectPath(phrenPath, project, LEARNED_SYNONYMS_FILE);
188
+ }
189
+ export function loadLearnedSynonyms(project, phrenPath) {
190
+ if (!project || !isValidProjectName(project))
191
+ return {};
192
+ const resolved = phrenPath ?? findPhrenPath();
193
+ if (!resolved)
194
+ return {};
195
+ const targetPath = learnedSynonymsPath(resolved, project);
196
+ if (!targetPath)
197
+ return {};
198
+ return parseLearnedSynonymsJson(targetPath);
199
+ }
200
+ export function loadSynonymMap(project, phrenPath) {
201
+ return mergeSynonymMaps(BASE_SYNONYMS, loadUserSynonyms(project, phrenPath), loadLearnedSynonyms(project, phrenPath));
202
+ }
203
+ export function learnSynonym(phrenPath, project, term, synonyms) {
204
+ if (!isValidProjectName(project))
205
+ throw new Error(`Invalid project name: ${project}`);
206
+ const targetPath = learnedSynonymsPath(phrenPath, project);
207
+ if (!targetPath)
208
+ throw new Error(`Path traversal detected for project: ${project}`);
209
+ const normalizedTerm = normalizeSynonymTerm(term);
210
+ if (!normalizedTerm || normalizedTerm.length <= 1) {
211
+ throw new Error("Invalid synonym term");
212
+ }
213
+ const normalizedSynonyms = normalizeSynonymValues(synonyms, normalizedTerm);
214
+ if (normalizedSynonyms.length === 0) {
215
+ return loadLearnedSynonyms(project, phrenPath);
216
+ }
217
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
218
+ const existing = parseLearnedSynonymsJson(targetPath);
219
+ const next = mergeSynonymMaps(existing, { [normalizedTerm]: normalizedSynonyms });
220
+ const tmpPath = `${targetPath}.tmp-${Date.now()}`;
221
+ fs.writeFileSync(tmpPath, JSON.stringify(next, null, 2) + "\n", "utf8");
222
+ fs.renameSync(tmpPath, targetPath);
223
+ return next;
224
+ }
225
+ export function removeLearnedSynonym(phrenPath, project, term, synonyms) {
226
+ if (!isValidProjectName(project))
227
+ throw new Error(`Invalid project name: ${project}`);
228
+ const targetPath = learnedSynonymsPath(phrenPath, project);
229
+ if (!targetPath)
230
+ throw new Error(`Path traversal detected for project: ${project}`);
231
+ const normalizedTerm = normalizeSynonymTerm(term);
232
+ if (!normalizedTerm || normalizedTerm.length <= 1) {
233
+ throw new Error("Invalid synonym term");
234
+ }
235
+ const existing = parseLearnedSynonymsJson(targetPath);
236
+ if (!existing[normalizedTerm])
237
+ return existing;
238
+ if (!synonyms || synonyms.length === 0) {
239
+ delete existing[normalizedTerm];
240
+ }
241
+ else {
242
+ const drop = new Set(normalizeSynonymValues(synonyms));
243
+ existing[normalizedTerm] = (existing[normalizedTerm] || []).filter((item) => !drop.has(item));
244
+ if (existing[normalizedTerm].length === 0)
245
+ delete existing[normalizedTerm];
246
+ }
247
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
248
+ if (Object.keys(existing).length === 0) {
249
+ try {
250
+ fs.unlinkSync(targetPath);
251
+ }
252
+ catch { }
253
+ return {};
254
+ }
255
+ const tmpPath = `${targetPath}.tmp-${Date.now()}`;
256
+ fs.writeFileSync(tmpPath, JSON.stringify(existing, null, 2) + "\n", "utf8");
257
+ fs.renameSync(tmpPath, targetPath);
258
+ return existing;
259
+ }
260
+ const MAX_FTS_QUERY_LENGTH = 500;
261
+ // Sanitize user input before passing it to an FTS5 MATCH expression.
262
+ // Strips FTS5-specific syntax that could cause injection or parse errors.
263
+ export function sanitizeFts5Query(raw) {
264
+ if (!raw)
265
+ return "";
266
+ if (raw.length > MAX_FTS_QUERY_LENGTH)
267
+ raw = raw.slice(0, MAX_FTS_QUERY_LENGTH);
268
+ // Whitelist approach: only allow alphanumeric, spaces, hyphens, apostrophes, asterisks
269
+ let q = raw.replace(/[^\p{L}\p{N} \-"*]/gu, " ");
270
+ // Strip all double quotes — buildFtsClauses wraps terms in quotes itself,
271
+ // so user-supplied quotes only risk producing unbalanced FTS5 syntax.
272
+ q = q.replace(/"/g, "");
273
+ // Q83: see docs/decisions/Q83-fts5-asterisk-validation.md
274
+ q = q.replace(/(?<!\w)\*/g, "");
275
+ // Also strip a trailing asterisk that is preceded only by whitespace at word
276
+ // end of the whole query (handles "foo *" → "foo").
277
+ q = q.replace(/\s+\*$/g, "");
278
+ // Normalize spaces after all stripping to avoid double spaces from removed characters
279
+ q = q.replace(/\s+/g, " ").trim();
280
+ return q;
281
+ }
282
+ function buildFtsClauses(raw, project, phrenPath) {
283
+ const MAX_TOTAL_TERMS = 10;
284
+ const MAX_SYNONYM_GROUPS = 3;
285
+ // Step 1: Sanitize — strip FTS5 special chars, enforce length limits
286
+ const safe = sanitizeFts5Query(raw);
287
+ if (!safe)
288
+ return [];
289
+ // Step 2: Merge built-in and per-project synonym maps
290
+ const synonymsMap = loadSynonymMap(project, phrenPath);
291
+ // Step 3: Tokenize — split sanitized input into individual words (min length 2)
292
+ const baseWords = safe.split(/\s+/).filter((t) => t.length > 1);
293
+ if (baseWords.length === 0)
294
+ return [];
295
+ // Step 4: Filter stop words — remove common English words that add no search signal
296
+ const filteredTerms = baseWords.filter((t) => !STOP_WORDS.has(t.toLowerCase()));
297
+ // Step 5: Build bigrams — sliding window over adjacent filtered terms for phrase matching
298
+ const bigrams = [];
299
+ for (let i = 0; i < filteredTerms.length - 1; i++) {
300
+ bigrams.push(`${filteredTerms[i]} ${filteredTerms[i + 1]}`);
301
+ }
302
+ // Step 6: Match bigrams against synonym keys — bigram matches are promoted to quoted
303
+ // phrases and their constituent words are marked consumed (not repeated as singletons)
304
+ const consumedIndices = new Set();
305
+ const matchedBigrams = [];
306
+ for (let i = 0; i < bigrams.length; i++) {
307
+ const bg = bigrams[i].toLowerCase();
308
+ if (synonymsMap[bg]) {
309
+ consumedIndices.add(i);
310
+ consumedIndices.add(i + 1);
311
+ matchedBigrams.push(bigrams[i]);
312
+ }
313
+ }
314
+ // Step 7: Assemble and deduplicate core terms — matched bigrams (as quoted phrases)
315
+ // first, then unconsumed individual words; duplicates removed via seenTerms
316
+ const dedupedTerms = [];
317
+ const seenTerms = new Set();
318
+ for (const bg of matchedBigrams) {
319
+ const clean = bg.replace(/"/g, "").trim().toLowerCase();
320
+ if (!seenTerms.has(clean)) {
321
+ seenTerms.add(clean);
322
+ dedupedTerms.push(`"${bg.replace(/"/g, "").trim()}"`);
323
+ }
324
+ }
325
+ for (let i = 0; i < filteredTerms.length; i++) {
326
+ if (!consumedIndices.has(i)) {
327
+ const w = filteredTerms[i].replace(/"/g, "").trim();
328
+ const wLow = w.toLowerCase();
329
+ if (w.length > 1 && !seenTerms.has(wLow)) {
330
+ seenTerms.add(wLow);
331
+ dedupedTerms.push(`"${w}"`);
332
+ }
333
+ }
334
+ }
335
+ if (dedupedTerms.length === 0)
336
+ return [];
337
+ // Step 8: Expand synonyms — for up to MAX_SYNONYM_GROUPS core terms, add OR alternatives
338
+ // from the synonym map; total term count is capped at MAX_TOTAL_TERMS to keep queries sane
339
+ let totalTermCount = dedupedTerms.length;
340
+ let groupsExpanded = 0;
341
+ const expandedClauses = [];
342
+ for (const coreTerm of dedupedTerms) {
343
+ const termText = coreTerm.slice(1, -1).toLowerCase(); // strip surrounding quotes
344
+ const synonyms = [];
345
+ if (groupsExpanded < MAX_SYNONYM_GROUPS && synonymsMap[termText]) {
346
+ for (const syn of synonymsMap[termText]) {
347
+ if (totalTermCount >= MAX_TOTAL_TERMS)
348
+ break;
349
+ const cleanSyn = syn.replace(/"/g, "").trim();
350
+ if (cleanSyn.length > 1) {
351
+ synonyms.push(`"${cleanSyn}"`);
352
+ totalTermCount++;
353
+ }
354
+ }
355
+ groupsExpanded++;
356
+ }
357
+ if (synonyms.length > 0) {
358
+ expandedClauses.push(`(${coreTerm} OR ${synonyms.join(" OR ")})`);
359
+ }
360
+ else {
361
+ expandedClauses.push(coreTerm);
362
+ }
363
+ }
364
+ // Step 9: Join all clauses with AND — every core term (with its OR synonyms) must match
365
+ return expandedClauses;
366
+ }
367
+ function clauseSignalScore(clause) {
368
+ const normalized = clause
369
+ .replace(/[()"]/g, " ")
370
+ .replace(/\bOR\b/gi, " ")
371
+ .replace(/\s+/g, " ")
372
+ .trim()
373
+ .toLowerCase();
374
+ if (!normalized)
375
+ return 0;
376
+ const tokens = normalized.split(" ").filter(Boolean);
377
+ const longestToken = tokens.reduce((max, token) => Math.max(max, token.length), 0);
378
+ const phraseBonus = tokens.length > 1 ? 1.5 : 0;
379
+ const synonymBonus = /\bOR\b/i.test(clause) ? 0.5 : 0;
380
+ return longestToken + phraseBonus + synonymBonus;
381
+ }
382
+ // Build a defensive FTS5 MATCH query:
383
+ // - sanitizes user input
384
+ // - extracts bigrams and treats them as quoted phrases
385
+ // - expands known synonyms (capped at 10 total terms)
386
+ // - applies AND between core terms, with synonyms as OR alternatives
387
+ export function buildRobustFtsQuery(raw, project, phrenPath) {
388
+ const clauses = buildFtsClauses(raw, project, phrenPath);
389
+ if (clauses.length === 0)
390
+ return "";
391
+ return clauses.join(" AND ");
392
+ }
393
+ // Build a relaxed lexical rescue query that matches any 2 of the most informative
394
+ // clauses. This is only intended as a fallback when the stricter AND query returns
395
+ // nothing; it trades precision for recall while staying in the FTS index.
396
+ export function buildRelaxedFtsQuery(raw, project, phrenPath) {
397
+ const clauses = buildFtsClauses(raw, project, phrenPath);
398
+ if (clauses.length === 0)
399
+ return "";
400
+ // Short queries (1-2 terms): OR the clauses together with prefix expansion
401
+ if (clauses.length === 1) {
402
+ const term = clauses[0];
403
+ // Add prefix wildcard for unquoted-style terms to broaden recall
404
+ const inner = term.replace(/^"(.*)"$/, "$1");
405
+ if (inner.length >= 3) {
406
+ return `(${term} OR "${inner}"*)`;
407
+ }
408
+ return term;
409
+ }
410
+ if (clauses.length === 2) {
411
+ return `(${clauses[0]} OR ${clauses[1]})`;
412
+ }
413
+ const salientClauses = clauses
414
+ .map((clause, index) => ({ clause, index, score: clauseSignalScore(clause) }))
415
+ .sort((a, b) => {
416
+ const scoreDelta = b.score - a.score;
417
+ if (Math.abs(scoreDelta) > 0.01)
418
+ return scoreDelta;
419
+ return a.index - b.index;
420
+ })
421
+ .slice(0, Math.min(4, clauses.length))
422
+ .sort((a, b) => a.index - b.index);
423
+ if (salientClauses.length < 2)
424
+ return "";
425
+ const combos = [];
426
+ for (let i = 0; i < salientClauses.length - 1; i++) {
427
+ for (let j = i + 1; j < salientClauses.length; j++) {
428
+ combos.push(`(${salientClauses[i].clause} AND ${salientClauses[j].clause})`);
429
+ }
430
+ }
431
+ return combos.join(" OR ");
432
+ }
433
+ export function buildFtsQueryVariants(raw, project, phrenPath) {
434
+ const variants = [
435
+ buildRobustFtsQuery(raw, project, phrenPath),
436
+ buildRelaxedFtsQuery(raw, project, phrenPath),
437
+ ].filter(Boolean);
438
+ // For short queries, add a prefix-expanded variant to catch partial matches
439
+ const clauses = buildFtsClauses(raw, project, phrenPath);
440
+ if (clauses.length <= 2) {
441
+ const prefixParts = clauses
442
+ .map(c => c.replace(/^"(.*)"$/, "$1"))
443
+ .filter(t => t.length >= 3)
444
+ .map(t => `"${t}"*`);
445
+ if (prefixParts.length > 0) {
446
+ variants.push(prefixParts.join(" OR "));
447
+ }
448
+ }
449
+ return [...new Set(variants)];
450
+ }
@@ -0,0 +1,12 @@
1
+ export declare function runGitOrThrow(cwd: string, args: string[], timeoutMs: number): string;
2
+ export declare function runGit(cwd: string, args: string[], timeoutMs: number, debugLogFn?: (msg: string) => void): string | null;
3
+ interface ResolvedExecCommand {
4
+ command: string;
5
+ shell: boolean;
6
+ }
7
+ export declare function normalizeExecCommand(cmd: string, platform?: NodeJS.Platform, whereOutput?: string | null): ResolvedExecCommand;
8
+ export declare function resolveExecCommand(cmd: string): ResolvedExecCommand;
9
+ export declare function errorMessage(err: unknown): string;
10
+ export declare function isFeatureEnabled(envName: string, defaultValue?: boolean): boolean;
11
+ export declare function clampInt(raw: string | undefined, fallback: number, min: number, max: number): number;
12
+ export {};
@@ -0,0 +1,80 @@
1
+ import * as path from "path";
2
+ import { execFileSync, spawnSync } from "child_process";
3
+ import { bootstrapPhrenDotEnv } from "./phren-dotenv.js";
4
+ // ── Shared Git helper ────────────────────────────────────────────────────────
5
+ export function runGitOrThrow(cwd, args, timeoutMs) {
6
+ const result = spawnSync("git", args, {
7
+ cwd,
8
+ encoding: "utf8",
9
+ stdio: ["ignore", "pipe", "pipe"],
10
+ timeout: timeoutMs,
11
+ });
12
+ if (result.error)
13
+ throw result.error;
14
+ if (result.status !== 0) {
15
+ const stderr = (result.stderr ?? "").trim();
16
+ const suffix = stderr ? `: ${stderr}` : result.signal ? ` (signal: ${result.signal})` : "";
17
+ throw new Error(`git ${args.join(" ")} exited with status ${result.status ?? "unknown"}${suffix}`);
18
+ }
19
+ return result.stdout ?? "";
20
+ }
21
+ export function runGit(cwd, args, timeoutMs, debugLogFn) {
22
+ try {
23
+ return runGitOrThrow(cwd, args, timeoutMs).trim();
24
+ }
25
+ catch (err) {
26
+ const msg = errorMessage(err);
27
+ if (debugLogFn)
28
+ debugLogFn(`runGit: git ${args[0]} failed in ${cwd}: ${msg}`);
29
+ return null;
30
+ }
31
+ }
32
+ function needsCommandShell(cmd) {
33
+ return /\.(cmd|bat)$/i.test(path.basename(cmd));
34
+ }
35
+ export function normalizeExecCommand(cmd, platform = process.platform, whereOutput) {
36
+ if (platform !== "win32")
37
+ return { command: cmd, shell: false };
38
+ if (cmd.includes("\\") || cmd.includes("/") || /\.[A-Za-z0-9]+$/i.test(path.basename(cmd))) {
39
+ return { command: cmd, shell: needsCommandShell(cmd) };
40
+ }
41
+ const candidate = (whereOutput || "")
42
+ .split(/\r?\n/)
43
+ .map((line) => line.trim())
44
+ .find(Boolean);
45
+ const resolved = candidate || cmd;
46
+ return { command: resolved, shell: needsCommandShell(resolved) };
47
+ }
48
+ export function resolveExecCommand(cmd) {
49
+ if (process.platform !== "win32")
50
+ return { command: cmd, shell: false };
51
+ try {
52
+ const whereOutput = execFileSync("where.exe", [cmd], {
53
+ encoding: "utf8",
54
+ stdio: ["ignore", "pipe", "ignore"],
55
+ timeout: 5000,
56
+ });
57
+ return normalizeExecCommand(cmd, process.platform, whereOutput);
58
+ }
59
+ catch {
60
+ return normalizeExecCommand(cmd, process.platform, null);
61
+ }
62
+ }
63
+ // ── Error message extractor ─────────────────────────────────────────────────
64
+ export function errorMessage(err) {
65
+ return err instanceof Error ? err.message : String(err);
66
+ }
67
+ // ── Feature flag and clamping helpers ────────────────────────────────────────
68
+ export function isFeatureEnabled(envName, defaultValue = true) {
69
+ bootstrapPhrenDotEnv();
70
+ const raw = process.env[envName];
71
+ if (!raw)
72
+ return defaultValue;
73
+ return !["0", "false", "off", "no"].includes(raw.trim().toLowerCase());
74
+ }
75
+ export function clampInt(raw, fallback, min, max) {
76
+ const parsed = Number.parseInt(raw || "", 10);
77
+ if (Number.isNaN(parsed))
78
+ return fallback;
79
+ return Math.min(max, Math.max(min, parsed));
80
+ }
@@ -0,0 +1,3 @@
1
+ export declare function isValidProjectName(name: string): boolean;
2
+ export declare function safeProjectPath(base: string, ...segments: string[]): string | null;
3
+ export declare function queueFilePath(phrenPath: string, project: string): string;
@@ -0,0 +1,61 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ // Validate a project name: lowercase letters/numbers with optional hyphen/underscore separators.
4
+ // Must not start with a hyphen (breaks CLI flags) or dot (hidden dirs). Max 100 chars.
5
+ // Internal keys like "native:-home" bypass this — they never go through user-facing validation.
6
+ // Explicitly rejects traversal sequences, null bytes, and path separators as defense-in-depth.
7
+ export function isValidProjectName(name) {
8
+ if (!name || name.length === 0)
9
+ return false;
10
+ if (name.length > 100)
11
+ return false;
12
+ // Reject null bytes, path separators, and traversal patterns before the regex check
13
+ if (name.includes("\0") || name.includes("/") || name.includes("\\") || name.includes(".."))
14
+ return false;
15
+ return /^[a-z0-9][a-z0-9_-]*$/.test(name);
16
+ }
17
+ // Resolve a path inside the phren directory and reject anything that escapes it.
18
+ // Checks both lexical resolution and (when the path exists) real path after symlink
19
+ // resolution to prevent symlink-based traversal.
20
+ export function safeProjectPath(base, ...segments) {
21
+ // Reject segments containing null bytes
22
+ for (const seg of segments) {
23
+ if (seg.includes("\0"))
24
+ return null;
25
+ }
26
+ const resolvedBase = path.resolve(base);
27
+ const resolved = path.resolve(base, ...segments);
28
+ if (resolved !== resolvedBase && !resolved.startsWith(resolvedBase + path.sep))
29
+ return null;
30
+ // Walk up from resolved path to find the deepest existing ancestor and verify
31
+ // it resolves inside base after symlink resolution. This catches symlink escapes
32
+ // even when the final leaf doesn't exist yet.
33
+ try {
34
+ let check = resolved;
35
+ while (!fs.existsSync(check) && check !== resolvedBase) {
36
+ check = path.dirname(check);
37
+ }
38
+ if (fs.existsSync(check)) {
39
+ const realBase = fs.realpathSync.native(resolvedBase);
40
+ const realCheck = fs.realpathSync.native(check);
41
+ if (realCheck !== realBase && !realCheck.startsWith(realBase + path.sep))
42
+ return null;
43
+ }
44
+ }
45
+ catch {
46
+ // If realpath fails (e.g. broken symlink), reject to be safe
47
+ return null;
48
+ }
49
+ return resolved;
50
+ }
51
+ const QUEUE_FILENAME = "review.md";
52
+ export function queueFilePath(phrenPath, project) {
53
+ if (!isValidProjectName(project)) {
54
+ throw new Error(`Invalid project name: ${project}`);
55
+ }
56
+ const result = safeProjectPath(phrenPath, project, QUEUE_FILENAME);
57
+ if (!result) {
58
+ throw new Error(`Path traversal detected for project: ${project}`);
59
+ }
60
+ return result;
61
+ }
@@ -0,0 +1,3 @@
1
+ export { runGitOrThrow, runGit, normalizeExecCommand, resolveExecCommand, errorMessage, isFeatureEnabled, clampInt, } from "./utils-helpers.js";
2
+ export { isValidProjectName, safeProjectPath, queueFilePath, } from "./utils-paths.js";
3
+ export { STOP_WORDS, extractKeywordEntries, extractKeywords, learnedSynonymsPath, loadLearnedSynonyms, loadSynonymMap, learnSynonym, removeLearnedSynonym, sanitizeFts5Query, buildRobustFtsQuery, buildRelaxedFtsQuery, buildFtsQueryVariants, } from "./utils-fts.js";