@phren/cli 0.0.5 → 0.0.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.
@@ -3,7 +3,7 @@ import { getQualityMultiplier, entryScoreKey, } from "./shared-governance.js";
3
3
  import { queryDocRows, queryRows, cosineFallback, extractSnippet, getDocSourceKey, getEntityBoostDocs, decodeFiniteNumber, rowToDocWithRowid, } from "./shared-index.js";
4
4
  import { filterTrustedFindingsDetailed, } from "./shared-content.js";
5
5
  import { parseCitationComment } from "./content-citation.js";
6
- import { getHighImpactFindings } from "./finding-impact.js";
6
+ import { getHighImpactFindings, getImpactSurfaceCounts } from "./finding-impact.js";
7
7
  import { buildFtsQueryVariants, buildRelaxedFtsQuery, isFeatureEnabled, STOP_WORDS } from "./utils.js";
8
8
  import * as fs from "fs";
9
9
  import * as path from "path";
@@ -12,7 +12,6 @@ import { vectorFallback } from "./shared-search-fallback.js";
12
12
  import { getOllamaUrl, getCloudEmbeddingUrl } from "./shared-ollama.js";
13
13
  import { keywordFallbackSearch } from "./core-search.js";
14
14
  import { debugLog } from "./shared.js";
15
- import { getCorrelatedDocs } from "./query-correlation.js";
16
15
  // ── Scoring constants ─────────────────────────────────────────────────────────
17
16
  /** Number of docs sampled for token-overlap semantic fallback search. */
18
17
  const SEMANTIC_FALLBACK_SAMPLE_LIMIT = 100;
@@ -32,13 +31,13 @@ const LOW_FOCUS_SNIPPET_CHAR_FRACTION = 0.55;
32
31
  const TASK_RESCUE_MIN_OVERLAP = 0.3;
33
32
  const TASK_RESCUE_OVERLAP_MARGIN = 0.12;
34
33
  const TASK_RESCUE_SCORE_MARGIN = 0.6;
35
- /** Boost applied to docs that correlate with recurring query patterns. */
36
- const CORRELATION_BOOST = 1.5;
37
34
  /** Fraction of bullets that must be low-value before applying the low-value penalty. */
38
35
  const LOW_VALUE_BULLET_FRACTION = 0.5;
39
36
  // ── Intent and scoring helpers ───────────────────────────────────────────────
40
37
  export function detectTaskIntent(prompt) {
41
38
  const p = prompt.toLowerCase();
39
+ if (/(^|\s)\/[a-z][a-z0-9_-]{1,63}(?=$|\s|[.,:;!?])/.test(p) || /\b(skill|swarm|lineup|slash command)\b/.test(p))
40
+ return "skill";
42
41
  if (/(bug|error|fix|broken|regression|fail|stack trace)/.test(p))
43
42
  return "debug";
44
43
  if (/(review|audit|pr|pull request|nit|refactor)/.test(p))
@@ -50,6 +49,8 @@ export function detectTaskIntent(prompt) {
50
49
  return "general";
51
50
  }
52
51
  function intentBoost(intent, docType) {
52
+ if (intent === "skill" && docType === "skill")
53
+ return 4;
53
54
  if (intent === "debug" && (docType === "findings" || docType === "reference"))
54
55
  return 3;
55
56
  if (intent === "review" && (docType === "canonical" || docType === "changelog"))
@@ -346,10 +347,23 @@ export function searchDocuments(db, safeQuery, prompt, keywords, detectedProject
346
347
  if (ftsDocs.length === 0 && relaxedQuery && relaxedQuery !== safeQuery) {
347
348
  runScopedFtsQuery(relaxedQuery);
348
349
  }
350
+ // Tier 1.5: Fragment graph expansion
351
+ const fragmentExpansionDocs = [];
352
+ const queryLower = (prompt + " " + keywords).toLowerCase();
353
+ const fragmentBoostDocKeys = getEntityBoostDocs(db, queryLower);
354
+ for (const docKey of fragmentBoostDocKeys) {
355
+ if (ftsSeenKeys.has(docKey))
356
+ continue;
357
+ const rows = queryDocRows(db, "SELECT project, filename, type, content, path FROM docs WHERE path = ? LIMIT 1", [docKey]);
358
+ if (rows?.length) {
359
+ ftsSeenKeys.add(docKey);
360
+ fragmentExpansionDocs.push(rows[0]);
361
+ }
362
+ }
349
363
  // Tier 2: Token-overlap semantic — always run, scored independently
350
364
  const semanticDocs = semanticFallbackDocs(db, `${prompt}\n${keywords}`, detectedProject);
351
365
  // Merge with Reciprocal Rank Fusion so documents found by both tiers rank highest
352
- const merged = rrfMerge([ftsDocs, semanticDocs]);
366
+ const merged = rrfMerge([ftsDocs, fragmentExpansionDocs, semanticDocs]);
353
367
  if (merged.length === 0)
354
368
  return null;
355
369
  return merged.slice(0, 12);
@@ -386,7 +400,7 @@ export async function searchDocumentsAsync(db, safeQuery, prompt, keywords, dete
386
400
  }
387
401
  catch (err) {
388
402
  // Vector search failure is non-fatal — return sync result
389
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
403
+ if (process.env.PHREN_DEBUG)
390
404
  process.stderr.write(`[phren] hybridSearch vectorFallback: ${err instanceof Error ? err.message : String(err)}\n`);
391
405
  return syncResult;
392
406
  }
@@ -486,7 +500,7 @@ export async function searchKnowledgeRows(db, options) {
486
500
  }
487
501
  }
488
502
  catch (err) {
489
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG)) {
503
+ if (process.env.PHREN_DEBUG) {
490
504
  process.stderr.write(`[phren] vectorFallback: ${err instanceof Error ? err.message : String(err)}\n`);
491
505
  }
492
506
  }
@@ -501,6 +515,7 @@ export function applyTrustFilter(rows, ttlDays, minConfidence, decay, phrenPath)
501
515
  const queueItems = [];
502
516
  const auditEntries = [];
503
517
  const highImpactFindingIds = phrenPath ? getHighImpactFindings(phrenPath, 3) : undefined;
518
+ const impactCounts = phrenPath ? getImpactSurfaceCounts(phrenPath, 1) : undefined;
504
519
  const filtered = rows
505
520
  .map((doc) => {
506
521
  if (!TRUST_FILTERED_TYPES.has(doc.type))
@@ -511,6 +526,7 @@ export function applyTrustFilter(rows, ttlDays, minConfidence, decay, phrenPath)
511
526
  decay,
512
527
  project: doc.project,
513
528
  highImpactFindingIds,
529
+ impactCounts,
514
530
  });
515
531
  if (trust.issues.length > 0) {
516
532
  const stale = trust.issues.filter((i) => i.reason === "stale").map((i) => i.bullet);
@@ -608,15 +624,13 @@ export function rankResults(rows, intent, gitCtx, detectedProject, phrenPathLoca
608
624
  }
609
625
  }
610
626
  const getRecentDate = (doc) => recentDateCache.get(doc.path || `${doc.project}/${doc.filename}`) ?? "0000-00-00";
611
- // Query correlation: pre-warm docs that historically correlated with similar queries
612
- const correlatedDocKeys = query ? new Set(getCorrelatedDocs(phrenPathLocal, query, 5)) : new Set();
613
627
  // Precompute per-doc ranking metadata once — avoids recomputing inside sort comparator.
614
628
  const changedFiles = gitCtx?.changedFiles || new Set();
615
629
  const FILE_MATCH_BOOST = 1.5;
616
630
  const scored = ranked.map((doc) => {
617
631
  const globBoost = getProjectGlobBoost(phrenPathLocal, doc.project, cwd, gitCtx?.changedFiles);
618
632
  const key = entryScoreKey(doc.project, doc.filename, doc.content);
619
- const entity = entityBoostPaths.has(doc.path) ? 1.3 : 1;
633
+ const entity = entityBoostPaths.has(doc.path) ? 1.5 : 1;
620
634
  const date = getRecentDate(doc);
621
635
  const fileRel = fileRelevanceBoost(doc.path, changedFiles);
622
636
  const branchMat = branchMatchBoost(doc.content, gitCtx?.branch);
@@ -631,17 +645,19 @@ export function rankResults(rows, intent, gitCtx, detectedProject, phrenPathLoca
631
645
  && queryOverlap < WEAK_CROSS_PROJECT_OVERLAP_MAX
632
646
  ? WEAK_CROSS_PROJECT_OVERLAP_PENALTY
633
647
  : 0;
634
- const correlationKey = `${doc.project}/${doc.filename}`;
635
- const correlationBoost = correlatedDocKeys.has(correlationKey) ? CORRELATION_BOOST : 0;
648
+ // Boost skills whose filename matches a query token (e.g. "swarm" matches swarm.md)
649
+ const skillNameBoost = doc.type === "skill" && queryTokens.length > 0
650
+ ? queryTokens.some((t) => doc.filename.replace(/\.md$/i, "").toLowerCase() === t) ? 4 : 0
651
+ : 0;
636
652
  const score = Math.round((intentBoost(intent, doc.type) +
653
+ skillNameBoost +
637
654
  fileRel +
638
655
  branchMat +
639
656
  globBoost +
640
657
  qualityMult +
641
658
  entity +
642
659
  queryOverlap * queryOverlapWeight +
643
- recencyBoost(doc.type, date) +
644
- correlationBoost -
660
+ recencyBoost(doc.type, date) -
645
661
  weakCrossProjectPenalty -
646
662
  lowValuePenalty(doc.content, doc.type)) * crossProjectAgeMultiplier(doc, detectedProject, date) * 10000) / 10000;
647
663
  const fileMatch = fileRel > 0 || branchMat > 0;
@@ -706,24 +722,6 @@ export function rankResults(rows, intent, gitCtx, detectedProject, phrenPathLoca
706
722
  }
707
723
  return ranked;
708
724
  }
709
- /** Annotate snippet lines that carry contradiction metadata with visible markers. */
710
- export function annotateContradictions(snippet) {
711
- return snippet.split('\n').map(line => {
712
- const conflictMatch = line.match(/<!-- conflicts_with: "(.*?)" -->/);
713
- const contradictMatch = line.match(/<!-- phren:contradicts "(.*?)" -->/);
714
- const statusMatch = line.match(/phren:status "contradicted"/);
715
- if (conflictMatch) {
716
- return line.replace(conflictMatch[0], '') + ` [CONTRADICTED — conflicts with: "${conflictMatch[1]}"]`;
717
- }
718
- if (contradictMatch) {
719
- return line.replace(contradictMatch[0], '') + ` [CONTRADICTED — see: "${contradictMatch[1]}"]`;
720
- }
721
- if (statusMatch) {
722
- return line + ' [CONTRADICTED]';
723
- }
724
- return line;
725
- }).join('\n');
726
- }
727
725
  /** Mark snippet lines with stale citations (cited file missing or line content changed). */
728
726
  export function markStaleCitations(snippet) {
729
727
  const lines = snippet.split("\n");
@@ -756,7 +754,7 @@ export function markStaleCitations(snippet) {
756
754
  }
757
755
  }
758
756
  catch (err) {
759
- if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
757
+ if (process.env.PHREN_DEBUG)
760
758
  process.stderr.write(`[phren] applyCitationAnnotations fileRead: ${err instanceof Error ? err.message : String(err)}\n`);
761
759
  stale = true;
762
760
  }
@@ -773,10 +771,40 @@ export function markStaleCitations(snippet) {
773
771
  }
774
772
  return result.join("\n");
775
773
  }
774
+ function annotateContradictions(snippet) {
775
+ return snippet.split('\n').map(line => {
776
+ const conflictMatch = line.match(/<!-- conflicts_with: "(.*?)" -->/);
777
+ const contradictMatch = line.match(/<!-- phren:contradicts "(.*?)" -->/);
778
+ const statusMatch = line.match(/phren:status "contradicted"/);
779
+ if (conflictMatch) {
780
+ return line.replace(conflictMatch[0], '') + ` [CONTRADICTED — conflicts with: "${conflictMatch[1]}"]`;
781
+ }
782
+ if (contradictMatch) {
783
+ return line.replace(contradictMatch[0], '') + ` [CONTRADICTED — see: "${contradictMatch[1]}"]`;
784
+ }
785
+ if (statusMatch) {
786
+ return line + ' [CONTRADICTED]';
787
+ }
788
+ return line;
789
+ }).join('\n');
790
+ }
776
791
  export function selectSnippets(rows, keywords, tokenBudget, lineBudget, charBudget) {
777
792
  const selected = [];
778
793
  let usedTokens = 36;
779
794
  const queryTokens = tokenizeForOverlap(keywords);
795
+ const seenBullets = new Set();
796
+ // For each snippet being added, hash its bullet lines and skip duplicates
797
+ function dedupSnippetBullets(snippet) {
798
+ return snippet.split('\n').filter(line => {
799
+ if (!line.startsWith('- '))
800
+ return true; // Keep non-bullet lines (headers, etc)
801
+ const normalized = line.replace(/<!--.*?-->/g, '').trim().toLowerCase();
802
+ if (seenBullets.has(normalized))
803
+ return false;
804
+ seenBullets.add(normalized);
805
+ return true;
806
+ }).join('\n');
807
+ }
780
808
  for (const doc of rows) {
781
809
  let snippet = compactSnippet(extractSnippet(doc.content, keywords, 8), lineBudget, charBudget);
782
810
  if (!snippet.trim())
@@ -785,8 +813,10 @@ export function selectSnippets(rows, keywords, tokenBudget, lineBudget, charBudg
785
813
  if (TRUST_FILTERED_TYPES.has(doc.type)) {
786
814
  snippet = markStaleCitations(snippet);
787
815
  }
788
- // Surface contradiction metadata as visible annotations
789
816
  snippet = annotateContradictions(snippet);
817
+ snippet = dedupSnippetBullets(snippet);
818
+ if (!snippet.trim())
819
+ continue;
790
820
  let focusScore = queryTokens.length > 0
791
821
  ? overlapScore(queryTokens, `${doc.filename}\n${snippet}`)
792
822
  : 1;
@@ -1,7 +1,7 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { debugLog, runtimeFile } from "./phren-paths.js";
4
- import { errorMessage, isValidProjectName } from "./utils.js";
4
+ import { errorMessage } from "./utils.js";
5
5
  export { HOOK_TOOL_NAMES, hookConfigPath } from "./provider-adapters.js";
6
6
  export { EXEC_TIMEOUT_MS, EXEC_TIMEOUT_QUICK_MS, PhrenError, phrenOk, phrenErr, forwardErr, parsePhrenErrorCode, isRecord, withDefaults, FINDING_TYPES, FINDING_TAGS, KNOWN_OBSERVATION_TAGS, DOC_TYPES, capCache, } from "./phren-core.js";
7
7
  export { ROOT_MANIFEST_FILENAME, homeDir, homePath, expandHomePath, defaultPhrenPath, rootManifestPath, readRootManifest, writeRootManifest, resolveInstallContext, findNearestPhrenPath, isProjectLocalMode, runtimeDir, tryUnlink, sessionsDir, runtimeFile, installPreferencesFile, runtimeHealthFile, shellStateFile, sessionMetricsFile, memoryScoresFile, memoryUsageLogFile, sessionMarker, debugLog, appendIndexEvent, resolveFindingsPath, findPhrenPath, ensurePhrenPath, findPhrenPathWithArg, normalizeProjectNameForCreate, findProjectNameCaseInsensitive, getProjectDirs, collectNativeMemoryFiles, computePhrenLiveStateToken, getPhrenPath, qualityMarkers, atomicWriteText, } from "./phren-paths.js";
@@ -28,15 +28,6 @@ export function isMemoryScopeVisible(itemScope, activeScope) {
28
28
  export function impactLogFile(phrenPath) {
29
29
  return runtimeFile(phrenPath, "impact.jsonl");
30
30
  }
31
- function isProjectDirEntry(entry) {
32
- return entry.isDirectory()
33
- && !entry.name.startsWith(".")
34
- && !entry.name.endsWith(".archived")
35
- && !RESERVED_PROJECT_DIR_NAMES.has(entry.name);
36
- }
37
- function isCanonicalProjectDirName(name) {
38
- return name === name.toLowerCase() && isValidProjectName(name);
39
- }
40
31
  export function appendAuditLog(phrenPath, event, details) {
41
32
  const logPath = runtimeFile(phrenPath, "audit.log");
42
33
  const line = `[${new Date().toISOString()}] ${event} ${details}\n`;
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * Vitest globalSetup — runs once in the main process before any test workers spawn.
3
3
  *
4
- * Builds mcp/dist if it is missing or stale so every fork sees a complete,
5
- * consistent dist artifact. Running the build here (rather than inside each
6
- * fork via ensureCliBuilt) eliminates the race condition where one fork runs
7
- * `rm -rf mcp/dist` while another fork is checking fs.existsSync(CLI_PATH).
4
+ * Builds mcp/dist if it is missing so every fork sees a complete, consistent
5
+ * dist artifact before tests begin. Individual subprocess helpers can still
6
+ * repair a missing artifact later under a lock if some test mutates dist.
8
7
  *
9
8
  * `pretest` in package.json already calls `npm run build`, so in normal `npm test`
10
9
  * runs this is a fast no-op check. It is the safety net for:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phren/cli",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "Knowledge layer for AI agents. Claude remembers you. Phren remembers your work.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,7 +34,7 @@
34
34
  "vitest": "^4.0.18"
35
35
  },
36
36
  "scripts": {
37
- "build": "rm -rf mcp/dist && tsc -p mcp/tsconfig.json && chmod +x mcp/dist/index.js && cp mcp/src/synonyms*.json mcp/dist/",
37
+ "build": "node scripts/build.mjs",
38
38
  "dev": "tsx mcp/src/index.ts",
39
39
  "lint": "eslint mcp/src/ --ignore-pattern '*.test.ts'",
40
40
  "validate-docs": "bash scripts/validate-docs.sh",