@phren/cli 0.0.1
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/LICENSE +21 -0
- package/README.md +590 -0
- package/mcp/dist/capabilities/cli.js +61 -0
- package/mcp/dist/capabilities/index.js +15 -0
- package/mcp/dist/capabilities/mcp.js +61 -0
- package/mcp/dist/capabilities/types.js +57 -0
- package/mcp/dist/capabilities/vscode.js +61 -0
- package/mcp/dist/capabilities/web-ui.js +61 -0
- package/mcp/dist/cli-actions.js +302 -0
- package/mcp/dist/cli-config.js +580 -0
- package/mcp/dist/cli-extract.js +305 -0
- package/mcp/dist/cli-govern.js +371 -0
- package/mcp/dist/cli-graph.js +169 -0
- package/mcp/dist/cli-hooks-citations.js +44 -0
- package/mcp/dist/cli-hooks-context.js +56 -0
- package/mcp/dist/cli-hooks-globs.js +83 -0
- package/mcp/dist/cli-hooks-output.js +130 -0
- package/mcp/dist/cli-hooks-retrieval.js +2 -0
- package/mcp/dist/cli-hooks-session.js +1402 -0
- package/mcp/dist/cli-hooks.js +350 -0
- package/mcp/dist/cli-namespaces.js +989 -0
- package/mcp/dist/cli-ops.js +253 -0
- package/mcp/dist/cli-search.js +407 -0
- package/mcp/dist/cli.js +108 -0
- package/mcp/dist/content-archive.js +278 -0
- package/mcp/dist/content-citation.js +391 -0
- package/mcp/dist/content-dedup.js +622 -0
- package/mcp/dist/content-learning.js +472 -0
- package/mcp/dist/content-metadata.js +186 -0
- package/mcp/dist/content-validate.js +462 -0
- package/mcp/dist/core-finding.js +54 -0
- package/mcp/dist/core-project.js +36 -0
- package/mcp/dist/core-search.js +50 -0
- package/mcp/dist/data-access.js +400 -0
- package/mcp/dist/data-tasks.js +821 -0
- package/mcp/dist/embedding.js +344 -0
- package/mcp/dist/entrypoint.js +387 -0
- package/mcp/dist/finding-context.js +172 -0
- package/mcp/dist/finding-impact.js +181 -0
- package/mcp/dist/finding-journal.js +122 -0
- package/mcp/dist/finding-lifecycle.js +259 -0
- package/mcp/dist/governance-audit.js +22 -0
- package/mcp/dist/governance-locks.js +96 -0
- package/mcp/dist/governance-policy.js +648 -0
- package/mcp/dist/governance-scores.js +355 -0
- package/mcp/dist/hooks.js +449 -0
- package/mcp/dist/impact-scoring.js +22 -0
- package/mcp/dist/index-query.js +168 -0
- package/mcp/dist/index.js +205 -0
- package/mcp/dist/init-config.js +336 -0
- package/mcp/dist/init-preferences.js +62 -0
- package/mcp/dist/init-setup.js +1305 -0
- package/mcp/dist/init-shared.js +29 -0
- package/mcp/dist/init.js +1730 -0
- package/mcp/dist/link-checksums.js +62 -0
- package/mcp/dist/link-context.js +257 -0
- package/mcp/dist/link-doctor.js +591 -0
- package/mcp/dist/link-skills.js +212 -0
- package/mcp/dist/link.js +596 -0
- package/mcp/dist/logger.js +15 -0
- package/mcp/dist/machine-identity.js +38 -0
- package/mcp/dist/mcp-config.js +254 -0
- package/mcp/dist/mcp-data.js +315 -0
- package/mcp/dist/mcp-extract-facts.js +78 -0
- package/mcp/dist/mcp-extract.js +133 -0
- package/mcp/dist/mcp-finding.js +557 -0
- package/mcp/dist/mcp-graph.js +339 -0
- package/mcp/dist/mcp-hooks.js +256 -0
- package/mcp/dist/mcp-memory.js +58 -0
- package/mcp/dist/mcp-ops.js +328 -0
- package/mcp/dist/mcp-search.js +628 -0
- package/mcp/dist/mcp-session.js +651 -0
- package/mcp/dist/mcp-skills.js +189 -0
- package/mcp/dist/mcp-tasks.js +551 -0
- package/mcp/dist/mcp-types.js +7 -0
- package/mcp/dist/memory-ui-assets.js +6 -0
- package/mcp/dist/memory-ui-data.js +513 -0
- package/mcp/dist/memory-ui-graph.js +1910 -0
- package/mcp/dist/memory-ui-page.js +353 -0
- package/mcp/dist/memory-ui-scripts.js +1387 -0
- package/mcp/dist/memory-ui-server.js +1218 -0
- package/mcp/dist/memory-ui-styles.js +555 -0
- package/mcp/dist/memory-ui.js +9 -0
- package/mcp/dist/package-metadata.js +13 -0
- package/mcp/dist/phren-art.js +52 -0
- package/mcp/dist/phren-core.js +108 -0
- package/mcp/dist/phren-dotenv.js +67 -0
- package/mcp/dist/phren-paths.js +476 -0
- package/mcp/dist/proactivity.js +172 -0
- package/mcp/dist/profile-store.js +228 -0
- package/mcp/dist/project-config.js +85 -0
- package/mcp/dist/project-locator.js +25 -0
- package/mcp/dist/project-topics.js +1134 -0
- package/mcp/dist/provider-adapters.js +176 -0
- package/mcp/dist/runtime-profile.js +18 -0
- package/mcp/dist/session-checkpoints.js +131 -0
- package/mcp/dist/session-utils.js +68 -0
- package/mcp/dist/shared-content.js +8 -0
- package/mcp/dist/shared-embedding-cache.js +143 -0
- package/mcp/dist/shared-fragment-graph.js +456 -0
- package/mcp/dist/shared-governance.js +4 -0
- package/mcp/dist/shared-index.js +1334 -0
- package/mcp/dist/shared-ollama.js +192 -0
- package/mcp/dist/shared-paths.js +1 -0
- package/mcp/dist/shared-retrieval.js +796 -0
- package/mcp/dist/shared-search-fallback.js +375 -0
- package/mcp/dist/shared-sqljs.js +42 -0
- package/mcp/dist/shared-stemmer.js +171 -0
- package/mcp/dist/shared-vector-index.js +199 -0
- package/mcp/dist/shared.js +114 -0
- package/mcp/dist/shell-entry.js +209 -0
- package/mcp/dist/shell-input.js +943 -0
- package/mcp/dist/shell-palette.js +119 -0
- package/mcp/dist/shell-render.js +252 -0
- package/mcp/dist/shell-state-store.js +81 -0
- package/mcp/dist/shell-types.js +13 -0
- package/mcp/dist/shell-view-list.js +14 -0
- package/mcp/dist/shell-view.js +707 -0
- package/mcp/dist/shell.js +352 -0
- package/mcp/dist/skill-files.js +117 -0
- package/mcp/dist/skill-registry.js +279 -0
- package/mcp/dist/skill-state.js +28 -0
- package/mcp/dist/startup-embedding.js +57 -0
- package/mcp/dist/status.js +323 -0
- package/mcp/dist/synonyms.json +670 -0
- package/mcp/dist/task-hygiene.js +251 -0
- package/mcp/dist/task-lifecycle.js +347 -0
- package/mcp/dist/tasks-github.js +76 -0
- package/mcp/dist/telemetry.js +165 -0
- package/mcp/dist/test-global-setup.js +37 -0
- package/mcp/dist/tool-registry.js +104 -0
- package/mcp/dist/update.js +97 -0
- package/mcp/dist/utils.js +543 -0
- package/package.json +67 -0
- package/skills/README.md +7 -0
- package/skills/consolidate/SKILL.md +152 -0
- package/skills/discover/SKILL.md +175 -0
- package/skills/init/SKILL.md +216 -0
- package/skills/profiles/SKILL.md +121 -0
- package/skills/sync/SKILL.md +261 -0
- package/starter/README.md +74 -0
- package/starter/global/CLAUDE.md +89 -0
- package/starter/global/skills/humanize.md +30 -0
- package/starter/global/skills/pipeline.md +35 -0
- package/starter/global/skills/release.md +35 -0
- package/starter/machines.yaml +8 -0
- package/starter/my-api/.claude/skills/README.md +7 -0
- package/starter/my-api/CLAUDE.md +33 -0
- package/starter/my-api/FINDINGS.md +9 -0
- package/starter/my-api/summary.md +7 -0
- package/starter/my-api/tasks.md +7 -0
- package/starter/my-first-project/.claude/skills/README.md +7 -0
- package/starter/my-first-project/CLAUDE.md +49 -0
- package/starter/my-first-project/FINDINGS.md +24 -0
- package/starter/my-first-project/summary.md +11 -0
- package/starter/my-first-project/tasks.md +25 -0
- package/starter/my-frontend/.claude/skills/README.md +7 -0
- package/starter/my-frontend/CLAUDE.md +33 -0
- package/starter/my-frontend/FINDINGS.md +9 -0
- package/starter/my-frontend/summary.md +7 -0
- package/starter/my-frontend/tasks.md +7 -0
- package/starter/profiles/default.yaml +4 -0
- package/starter/profiles/personal.yaml +4 -0
- package/starter/profiles/work.yaml +4 -0
- package/starter/templates/README.md +7 -0
- package/starter/templates/frontend/CLAUDE.md +23 -0
- package/starter/templates/frontend/FINDINGS.md +7 -0
- package/starter/templates/frontend/reference/README.md +4 -0
- package/starter/templates/frontend/summary.md +7 -0
- package/starter/templates/frontend/tasks.md +11 -0
- package/starter/templates/library/CLAUDE.md +22 -0
- package/starter/templates/library/FINDINGS.md +7 -0
- package/starter/templates/library/reference/README.md +4 -0
- package/starter/templates/library/summary.md +7 -0
- package/starter/templates/library/tasks.md +11 -0
- package/starter/templates/monorepo/CLAUDE.md +21 -0
- package/starter/templates/monorepo/FINDINGS.md +7 -0
- package/starter/templates/monorepo/reference/README.md +4 -0
- package/starter/templates/monorepo/summary.md +7 -0
- package/starter/templates/monorepo/tasks.md +11 -0
- package/starter/templates/python-project/CLAUDE.md +21 -0
- package/starter/templates/python-project/FINDINGS.md +7 -0
- package/starter/templates/python-project/reference/README.md +4 -0
- package/starter/templates/python-project/summary.md +7 -0
- package/starter/templates/python-project/tasks.md +10 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import { decodeStringRow } from "./shared-index.js";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import { runtimeFile } from "./shared.js";
|
|
4
|
+
import { UNIVERSAL_TECH_TERMS_RE } from "./phren-core.js";
|
|
5
|
+
export function escapeRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
|
|
6
|
+
/** Escape SQL LIKE wildcard characters so user input is treated literally. */
|
|
7
|
+
export function escapeLike(s) { return s.replace(/[%_\\]/g, '\\$&'); }
|
|
8
|
+
/**
|
|
9
|
+
* Log fragment resolution misses to .runtime/fragment-misses.jsonl.
|
|
10
|
+
*
|
|
11
|
+
* Judgment criteria — what's worth capturing vs noise:
|
|
12
|
+
* - Worth capturing: repeated lookups for the same fragment name (indicates a gap
|
|
13
|
+
* in the fragment graph that the user keeps hitting), fragment names that look like
|
|
14
|
+
* real library/tool names (not random query fragments).
|
|
15
|
+
* - Noise: single one-off lookups for short generic terms, lookups that fail
|
|
16
|
+
* because the query was malformed. We filter these by requiring name.length > 2.
|
|
17
|
+
*
|
|
18
|
+
* Gated by PHREN_DEBUG (or PHREN_DEBUG for compat) to avoid disk writes for
|
|
19
|
+
* regular users. The miss log is append-only JSONL so downstream tooling can
|
|
20
|
+
* detect repeated patterns (e.g. "fragment X was looked up 5 times but never
|
|
21
|
+
* found" -> suggest adding it).
|
|
22
|
+
*/
|
|
23
|
+
export function logFragmentMiss(phrenPath, name, context, project) {
|
|
24
|
+
if (!process.env.PHREN_DEBUG && !(process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
|
|
25
|
+
return;
|
|
26
|
+
if (!name || name.length <= 2)
|
|
27
|
+
return;
|
|
28
|
+
try {
|
|
29
|
+
const entry = JSON.stringify({
|
|
30
|
+
fragment: name,
|
|
31
|
+
context,
|
|
32
|
+
ts: Date.now(),
|
|
33
|
+
project: project ?? null,
|
|
34
|
+
});
|
|
35
|
+
const missFile = runtimeFile(phrenPath, "fragment-misses.jsonl");
|
|
36
|
+
fs.appendFileSync(missFile, entry + "\n");
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Best-effort logging; don't let miss tracking break the caller.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** @deprecated Use logFragmentMiss instead */
|
|
43
|
+
export const logEntityMiss = logFragmentMiss;
|
|
44
|
+
// Use the shared universal starter set. Framework/tool specifics are learned
|
|
45
|
+
// dynamically per project via extractDynamicFragments() in content-dedup.ts.
|
|
46
|
+
const PROSE_FRAGMENT_PATTERN = UNIVERSAL_TECH_TERMS_RE;
|
|
47
|
+
const FRAGMENT_PATTERNS = [
|
|
48
|
+
// import/require patterns: import X from 'pkg' or require('pkg')
|
|
49
|
+
/(?:import\s+.*?\s+from\s+['"])(@?[\w\-/]+)(?:['"])/g,
|
|
50
|
+
/(?:require\s*\(\s*['"])(@?[\w\-/]+)(?:['"]\s*\))/g,
|
|
51
|
+
// @scope/package patterns in text
|
|
52
|
+
/@[\w-]+\/[\w-]+/g,
|
|
53
|
+
// Known library/tool names mentioned in prose (case-insensitive word boundaries)
|
|
54
|
+
PROSE_FRAGMENT_PATTERN,
|
|
55
|
+
// Backtick-quoted identifiers: `word` or `word-with-dashes`
|
|
56
|
+
/`([\w][\w\-\.\/]{1,48}[\w])`/g,
|
|
57
|
+
// Double-quoted short identifiers (tool/package names, not full sentences)
|
|
58
|
+
/"([\w][\w\-]{1,30}[\w])"/g,
|
|
59
|
+
];
|
|
60
|
+
function isAllowedFragmentName(name) {
|
|
61
|
+
const trimmed = name.trim();
|
|
62
|
+
if (!trimmed || trimmed.length <= 1 || trimmed.length >= 100)
|
|
63
|
+
return false;
|
|
64
|
+
// Skip version strings (e.g. "1.2.3", "v2.0.0-beta")
|
|
65
|
+
if (/^v?\d+\.\d+/.test(trimmed))
|
|
66
|
+
return false;
|
|
67
|
+
// Skip file paths and file-like names (e.g. "src/utils.ts", "./config.json")
|
|
68
|
+
if (/^\.?\//.test(trimmed) ||
|
|
69
|
+
/\.(ts|js|json|md|yaml|yml|py|go|rs|java|tsx|jsx|css|html|txt|sh|toml|cfg|ini|env|lock)$/i.test(trimmed)) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
if (!/\s/.test(trimmed)) {
|
|
73
|
+
const normalized = trimmed.replace(/^[@#]/, "").toLowerCase();
|
|
74
|
+
if (COMMON_SINGLE_WORD_FRAGMENTS.has(normalized))
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Lightweight synchronous fragment extraction from text — regex only, no DB writes.
|
|
81
|
+
* Used by add_finding to surface detected fragments in the MCP response without
|
|
82
|
+
* requiring a DB reference in the write path. Full DB linking happens on the next
|
|
83
|
+
* index rebuild, which is triggered automatically after every add_finding call via
|
|
84
|
+
* updateFileInIndex -> extractAndLinkFragments.
|
|
85
|
+
*/
|
|
86
|
+
export function extractFragmentNames(content) {
|
|
87
|
+
const found = new Set();
|
|
88
|
+
for (const pattern of FRAGMENT_PATTERNS) {
|
|
89
|
+
const regex = new RegExp(pattern.source, pattern.flags);
|
|
90
|
+
let match;
|
|
91
|
+
while ((match = regex.exec(content)) !== null) {
|
|
92
|
+
const name = match[1] || match[0];
|
|
93
|
+
if (!isAllowedFragmentName(name))
|
|
94
|
+
continue;
|
|
95
|
+
found.add(name.toLowerCase());
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Extract explicit fragment annotations: <!-- fragment: Foo,Bar --> (also supports legacy <!-- entity: ... -->)
|
|
99
|
+
const annotationRe = /<!--\s*(?:fragment|entity):\s*([^-]+)-->/gi;
|
|
100
|
+
let m;
|
|
101
|
+
while ((m = annotationRe.exec(content)) !== null) {
|
|
102
|
+
for (const part of m[1].split(",")) {
|
|
103
|
+
const name = part.trim();
|
|
104
|
+
if (isAllowedFragmentName(name)) {
|
|
105
|
+
found.add(name.toLowerCase());
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return [...found];
|
|
110
|
+
}
|
|
111
|
+
/** @deprecated Use extractFragmentNames instead */
|
|
112
|
+
export const extractEntityNames = extractFragmentNames;
|
|
113
|
+
function getOrCreateFragment(db, name, type) {
|
|
114
|
+
try {
|
|
115
|
+
db.run("INSERT OR IGNORE INTO entities (name, type, first_seen_at) VALUES (?, ?, ?)", [name, type, new Date().toISOString().slice(0, 10)]);
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
if (process.env.PHREN_DEBUG || (process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
|
|
119
|
+
process.stderr.write(`[phren] fragmentInsert: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
120
|
+
}
|
|
121
|
+
const result = db.exec("SELECT id FROM entities WHERE name = ? AND type = ?", [name, type]);
|
|
122
|
+
if (result?.length && result[0]?.values?.length) {
|
|
123
|
+
return Number(result[0].values[0][0]);
|
|
124
|
+
}
|
|
125
|
+
return -1;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Ensure the global_entities cross-project index table exists.
|
|
129
|
+
* Called during buildIndex to enable cross-project fragment queries.
|
|
130
|
+
*/
|
|
131
|
+
export function ensureGlobalEntitiesTable(db) {
|
|
132
|
+
try {
|
|
133
|
+
db.run(`CREATE TABLE IF NOT EXISTS global_entities (
|
|
134
|
+
entity TEXT NOT NULL,
|
|
135
|
+
project TEXT NOT NULL,
|
|
136
|
+
doc_key TEXT NOT NULL,
|
|
137
|
+
PRIMARY KEY (entity, project, doc_key)
|
|
138
|
+
)`);
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
if (process.env.PHREN_DEBUG || (process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
|
|
142
|
+
process.stderr.write(`[phren] ensureGlobalEntitiesTable: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Parse user-defined fragment names from CLAUDE.md frontmatter.
|
|
147
|
+
* Looks for: <!-- phren:fragments: Redis,MyService,InternalAPI -->
|
|
148
|
+
* Also supports legacy: <!-- phren:entities: ... -->
|
|
149
|
+
*
|
|
150
|
+
* Results are cached per project+mtime to avoid repeated sync readFileSync calls
|
|
151
|
+
* during a single index build that processes many docs for the same project.
|
|
152
|
+
*/
|
|
153
|
+
const _userFragmentCache = new Map();
|
|
154
|
+
const _buildUserFragmentCache = new Map();
|
|
155
|
+
let _activeBuildCacheKeyPrefix = null;
|
|
156
|
+
function readUserDefinedFragmentsFromDisk(claudeMdPath) {
|
|
157
|
+
if (!fs.existsSync(claudeMdPath))
|
|
158
|
+
return null;
|
|
159
|
+
const stat = fs.statSync(claudeMdPath);
|
|
160
|
+
const content = fs.readFileSync(claudeMdPath, "utf-8");
|
|
161
|
+
// Support both new phren:fragments and legacy phren:entities annotations
|
|
162
|
+
const match = content.match(/<!--\s*(?:phren:fragments|phren:entities):\s*(.+?)\s*-->/);
|
|
163
|
+
const fragments = match
|
|
164
|
+
? match[1].split(",").map(s => s.trim()).filter(s => s.length > 0)
|
|
165
|
+
: [];
|
|
166
|
+
return { mtime: stat.mtimeMs, fragments };
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Prime CLAUDE.md fragments per project for a single build pass.
|
|
170
|
+
* During an active build, extractAndLinkFragments resolves user fragments from this
|
|
171
|
+
* in-memory map and avoids per-file sync stat/read calls.
|
|
172
|
+
*/
|
|
173
|
+
export function beginUserFragmentBuildCache(phrenPath, projects) {
|
|
174
|
+
_activeBuildCacheKeyPrefix = `${phrenPath}/`;
|
|
175
|
+
for (const project of projects) {
|
|
176
|
+
const cacheKey = `${phrenPath}/${project}`;
|
|
177
|
+
const claudeMdPath = `${phrenPath}/${project}/CLAUDE.md`;
|
|
178
|
+
try {
|
|
179
|
+
const loaded = readUserDefinedFragmentsFromDisk(claudeMdPath);
|
|
180
|
+
if (!loaded) {
|
|
181
|
+
_buildUserFragmentCache.set(cacheKey, []);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
_userFragmentCache.set(cacheKey, loaded);
|
|
185
|
+
_buildUserFragmentCache.set(cacheKey, loaded.fragments);
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
if (process.env.PHREN_DEBUG || (process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
|
|
189
|
+
process.stderr.write(`[phren] beginUserFragmentBuildCache: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
190
|
+
_buildUserFragmentCache.set(cacheKey, []);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/** @deprecated Use beginUserFragmentBuildCache instead */
|
|
195
|
+
export const beginUserEntityBuildCache = beginUserFragmentBuildCache;
|
|
196
|
+
/** End a build-scoped cache created by beginUserFragmentBuildCache(). */
|
|
197
|
+
export function endUserFragmentBuildCache(phrenPath) {
|
|
198
|
+
const prefix = `${phrenPath}/`;
|
|
199
|
+
for (const key of [..._buildUserFragmentCache.keys()]) {
|
|
200
|
+
if (key.startsWith(prefix))
|
|
201
|
+
_buildUserFragmentCache.delete(key);
|
|
202
|
+
}
|
|
203
|
+
if (_activeBuildCacheKeyPrefix === prefix)
|
|
204
|
+
_activeBuildCacheKeyPrefix = null;
|
|
205
|
+
}
|
|
206
|
+
/** @deprecated Use endUserFragmentBuildCache instead */
|
|
207
|
+
export const endUserEntityBuildCache = endUserFragmentBuildCache;
|
|
208
|
+
function parseUserDefinedFragments(phrenPath, project) {
|
|
209
|
+
const claudeMdPath = `${phrenPath}/${project}/CLAUDE.md`;
|
|
210
|
+
const cacheKey = `${phrenPath}/${project}`;
|
|
211
|
+
try {
|
|
212
|
+
// Active build path: no sync I/O in per-file extraction.
|
|
213
|
+
if (_activeBuildCacheKeyPrefix === `${phrenPath}/`) {
|
|
214
|
+
if (_buildUserFragmentCache.has(cacheKey))
|
|
215
|
+
return _buildUserFragmentCache.get(cacheKey) ?? [];
|
|
216
|
+
_buildUserFragmentCache.set(cacheKey, []);
|
|
217
|
+
return [];
|
|
218
|
+
}
|
|
219
|
+
const cached = _userFragmentCache.get(cacheKey);
|
|
220
|
+
if (cached) {
|
|
221
|
+
try {
|
|
222
|
+
if (fs.existsSync(claudeMdPath) && fs.statSync(claudeMdPath).mtimeMs === cached.mtime) {
|
|
223
|
+
return cached.fragments;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
if (process.env.PHREN_DEBUG || (process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
|
|
228
|
+
process.stderr.write(`[phren] parseUserDefinedFragments statCheck: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const loaded = readUserDefinedFragmentsFromDisk(claudeMdPath);
|
|
232
|
+
if (!loaded)
|
|
233
|
+
return [];
|
|
234
|
+
_userFragmentCache.set(cacheKey, loaded);
|
|
235
|
+
return loaded.fragments;
|
|
236
|
+
}
|
|
237
|
+
catch (err) {
|
|
238
|
+
if (process.env.PHREN_DEBUG || (process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
|
|
239
|
+
process.stderr.write(`[phren] parseUserDefinedFragments: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
240
|
+
return [];
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/** Clear the user fragment cache (call between index builds). */
|
|
244
|
+
export function clearUserFragmentCache() {
|
|
245
|
+
_userFragmentCache.clear();
|
|
246
|
+
_buildUserFragmentCache.clear();
|
|
247
|
+
_activeBuildCacheKeyPrefix = null;
|
|
248
|
+
}
|
|
249
|
+
/** @deprecated Use clearUserFragmentCache instead */
|
|
250
|
+
export const clearUserEntityCache = clearUserFragmentCache;
|
|
251
|
+
// Words that commonly start sentences or appear in titles — not fragment names
|
|
252
|
+
const SENTENCE_START_WORDS = new Set([
|
|
253
|
+
"the", "this", "that", "these", "those", "when", "where", "which", "while",
|
|
254
|
+
"what", "with", "will", "would", "should", "could", "have", "has", "had",
|
|
255
|
+
"been", "being", "before", "after", "about", "above", "below", "between",
|
|
256
|
+
"only", "also", "even", "just", "like", "make", "made", "many", "more",
|
|
257
|
+
"most", "much", "must", "need", "never", "note", "once", "other", "over",
|
|
258
|
+
"same", "some", "such", "sure", "take", "than", "them", "then", "they",
|
|
259
|
+
"each", "every", "both", "either", "neither", "here", "there", "first",
|
|
260
|
+
"second", "third", "next", "last", "new", "old", "good", "bad", "best",
|
|
261
|
+
"however", "therefore", "because", "although", "since", "unless", "until",
|
|
262
|
+
"instead", "rather", "already", "always", "never", "sometimes", "often",
|
|
263
|
+
]);
|
|
264
|
+
const COMMON_SINGLE_WORD_FRAGMENTS = new Set([
|
|
265
|
+
...SENTENCE_START_WORDS,
|
|
266
|
+
"agent", "analysis", "app", "approach", "artifact", "branch", "build", "cache",
|
|
267
|
+
"change", "changes", "check", "cli", "code", "command", "config", "context",
|
|
268
|
+
"data", "debug", "detail", "doc", "docs", "document", "entity", "error",
|
|
269
|
+
"example", "extract", "feature", "file", "files", "fix", "flow", "hook", "idea",
|
|
270
|
+
"index", "info", "issue", "item", "key", "log", "memory", "message", "model",
|
|
271
|
+
"note", "output", "path", "pattern", "policy", "process", "profile", "project",
|
|
272
|
+
"query", "repo", "result", "rule", "search", "session", "setting", "state",
|
|
273
|
+
"step", "summary", "system", "task", "tasks", "test", "tool", "tools", "type",
|
|
274
|
+
"update", "user", "value", "version", "workflow", "write",
|
|
275
|
+
]);
|
|
276
|
+
// Patterns that look like version strings, file paths, or dates — not fragments
|
|
277
|
+
const FALSE_POSITIVE_PATTERNS = [
|
|
278
|
+
/^v?\d+\.\d+/, // version strings: 1.2.3, v2.0
|
|
279
|
+
/^[A-Z]:\\/, // Windows paths: C:\
|
|
280
|
+
/^\//, // Unix paths: /usr/bin
|
|
281
|
+
/^\d{4}-\d{2}/, // ISO dates: 2026-03
|
|
282
|
+
/^[A-Z]{2,6}$/, // All-caps abbreviations shorter than 7 chars (OK, API, etc.)
|
|
283
|
+
/^(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\b/i, // Month names
|
|
284
|
+
];
|
|
285
|
+
/**
|
|
286
|
+
* Extract capitalized noun phrases (2+ words starting with uppercase) as candidate fragments.
|
|
287
|
+
* e.g. "Auth Service", "Data Pipeline", "Internal API"
|
|
288
|
+
*
|
|
289
|
+
* Filters out common false positives: sentence-start capitalization, version strings,
|
|
290
|
+
* file paths, and single-word abbreviations.
|
|
291
|
+
*/
|
|
292
|
+
function extractCapitalizedPhrases(content) {
|
|
293
|
+
const found = new Set();
|
|
294
|
+
const pattern = /\b([A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z]+)+)\b/g;
|
|
295
|
+
let match;
|
|
296
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
297
|
+
const phrase = match[1];
|
|
298
|
+
if (phrase.length < 4 || phrase.length >= 60)
|
|
299
|
+
continue;
|
|
300
|
+
// Check if first word is a common sentence-start word
|
|
301
|
+
const firstWord = phrase.split(/\s+/)[0].toLowerCase();
|
|
302
|
+
if (SENTENCE_START_WORDS.has(firstWord))
|
|
303
|
+
continue;
|
|
304
|
+
// Check if it looks like a false positive pattern
|
|
305
|
+
if (FALSE_POSITIVE_PATTERNS.some(p => p.test(phrase)))
|
|
306
|
+
continue;
|
|
307
|
+
found.add(phrase.toLowerCase());
|
|
308
|
+
}
|
|
309
|
+
return [...found];
|
|
310
|
+
}
|
|
311
|
+
export function extractAndLinkFragments(db, content, sourceDoc, phrenPath) {
|
|
312
|
+
const fragmentNames = extractFragmentNames(content);
|
|
313
|
+
// Extract capitalized noun phrases as candidate fragments
|
|
314
|
+
const capitalizedPhrases = extractCapitalizedPhrases(content);
|
|
315
|
+
for (const phrase of capitalizedPhrases) {
|
|
316
|
+
fragmentNames.push(phrase);
|
|
317
|
+
}
|
|
318
|
+
// Add user-defined fragments from CLAUDE.md frontmatter
|
|
319
|
+
if (phrenPath) {
|
|
320
|
+
const projectMatch = sourceDoc.match(/^([^/]+)\//);
|
|
321
|
+
if (projectMatch) {
|
|
322
|
+
const project = projectMatch[1];
|
|
323
|
+
const userFragments = parseUserDefinedFragments(phrenPath, project);
|
|
324
|
+
for (const uf of userFragments) {
|
|
325
|
+
const lower = uf.toLowerCase();
|
|
326
|
+
// Check if user-defined fragment appears in content (use escaped regex for safe matching)
|
|
327
|
+
const safePattern = new RegExp(`\\b${escapeRegex(lower)}\\b`, "i");
|
|
328
|
+
if (safePattern.test(content)) {
|
|
329
|
+
fragmentNames.push(lower);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// Deduplicate
|
|
335
|
+
const uniqueNames = [...new Set(fragmentNames)];
|
|
336
|
+
if (uniqueNames.length === 0)
|
|
337
|
+
return;
|
|
338
|
+
const docFragmentId = getOrCreateFragment(db, sourceDoc, "document");
|
|
339
|
+
if (docFragmentId === -1)
|
|
340
|
+
return;
|
|
341
|
+
// Ensure global_entities table exists
|
|
342
|
+
ensureGlobalEntitiesTable(db);
|
|
343
|
+
// Extract project from sourceDoc for global_entities
|
|
344
|
+
const projectMatch = sourceDoc.match(/^([^/]+)\//);
|
|
345
|
+
const project = projectMatch ? projectMatch[1] : null;
|
|
346
|
+
for (const name of uniqueNames) {
|
|
347
|
+
const fragmentType = name.includes(" ") ? "concept" : "library";
|
|
348
|
+
const fragmentId = getOrCreateFragment(db, name, fragmentType);
|
|
349
|
+
if (fragmentId === -1)
|
|
350
|
+
continue;
|
|
351
|
+
try {
|
|
352
|
+
db.run("INSERT OR IGNORE INTO entity_links (source_id, target_id, rel_type, source_doc) VALUES (?, ?, ?, ?)", [docFragmentId, fragmentId, "mentions", sourceDoc]);
|
|
353
|
+
}
|
|
354
|
+
catch (err) {
|
|
355
|
+
if (process.env.PHREN_DEBUG || (process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
|
|
356
|
+
process.stderr.write(`[phren] fragmentLinksInsert: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
357
|
+
}
|
|
358
|
+
// Write to global_entities for cross-project queries
|
|
359
|
+
if (project) {
|
|
360
|
+
try {
|
|
361
|
+
db.run("INSERT OR IGNORE INTO global_entities (entity, project, doc_key) VALUES (?, ?, ?)", [name, project, sourceDoc]);
|
|
362
|
+
}
|
|
363
|
+
catch (err) {
|
|
364
|
+
if (process.env.PHREN_DEBUG || (process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
|
|
365
|
+
process.stderr.write(`[phren] globalFragmentsInsert: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
/** @deprecated Use extractAndLinkFragments instead */
|
|
371
|
+
export const extractAndLinkEntities = extractAndLinkFragments;
|
|
372
|
+
/**
|
|
373
|
+
* Query related fragments for a given name.
|
|
374
|
+
*/
|
|
375
|
+
export function queryFragmentLinks(db, name) {
|
|
376
|
+
const related = [];
|
|
377
|
+
try {
|
|
378
|
+
// Find the fragment
|
|
379
|
+
const fragmentResult = db.exec("SELECT id FROM entities WHERE name = ?", [name.toLowerCase()]);
|
|
380
|
+
if (!fragmentResult?.length || !fragmentResult[0]?.values?.length)
|
|
381
|
+
return { related };
|
|
382
|
+
const fragmentId = Number(fragmentResult[0].values[0][0]);
|
|
383
|
+
// Find related fragments through links (both directions)
|
|
384
|
+
const links = db.exec(`SELECT DISTINCT e.name FROM entity_links el JOIN entities e ON (el.target_id = e.id OR el.source_id = e.id)
|
|
385
|
+
WHERE (el.source_id = ? OR el.target_id = ?) AND e.id != ?`, [fragmentId, fragmentId, fragmentId]);
|
|
386
|
+
if (links?.length && links[0]?.values?.length) {
|
|
387
|
+
for (const row of links[0].values) {
|
|
388
|
+
related.push(decodeStringRow(row, 1, "queryFragmentLinks")[0]);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
catch (err) {
|
|
393
|
+
if (process.env.PHREN_DEBUG || (process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
|
|
394
|
+
process.stderr.write(`[phren] queryFragmentLinks: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
395
|
+
}
|
|
396
|
+
return { related };
|
|
397
|
+
}
|
|
398
|
+
/** @deprecated Use queryFragmentLinks instead */
|
|
399
|
+
export const queryEntityLinks = queryFragmentLinks;
|
|
400
|
+
/**
|
|
401
|
+
* Query cross-project fragment relationships.
|
|
402
|
+
* Returns projects and docs that share fragments with the given query.
|
|
403
|
+
*/
|
|
404
|
+
export function queryCrossProjectFragments(db, fragmentName, excludeProject) {
|
|
405
|
+
const results = [];
|
|
406
|
+
try {
|
|
407
|
+
ensureGlobalEntitiesTable(db);
|
|
408
|
+
const pattern = `%${escapeLike(fragmentName.toLowerCase())}%`;
|
|
409
|
+
let sql = "SELECT entity, project, doc_key FROM global_entities WHERE entity LIKE ? ESCAPE '\\'";
|
|
410
|
+
const params = [pattern];
|
|
411
|
+
if (excludeProject) {
|
|
412
|
+
sql += " AND project != ?";
|
|
413
|
+
params.push(excludeProject);
|
|
414
|
+
}
|
|
415
|
+
sql += " ORDER BY entity LIMIT 50";
|
|
416
|
+
const rows = db.exec(sql, params);
|
|
417
|
+
if (rows?.length && rows[0]?.values?.length) {
|
|
418
|
+
for (const row of rows[0].values) {
|
|
419
|
+
const [fragment, project, docKey] = decodeStringRow(row, 3, "queryCrossProjectFragments");
|
|
420
|
+
results.push({
|
|
421
|
+
fragment,
|
|
422
|
+
project,
|
|
423
|
+
docKey,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
catch (err) {
|
|
429
|
+
if (process.env.PHREN_DEBUG || (process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
|
|
430
|
+
process.stderr.write(`[phren] queryCrossProjectFragments: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
431
|
+
}
|
|
432
|
+
return results;
|
|
433
|
+
}
|
|
434
|
+
export function getFragmentBoostDocs(db, query) {
|
|
435
|
+
const normalizedQuery = query.toLowerCase();
|
|
436
|
+
try {
|
|
437
|
+
const rows = db.exec(`SELECT DISTINCT el.source_doc
|
|
438
|
+
FROM entity_links el
|
|
439
|
+
JOIN entities e ON el.target_id = e.id
|
|
440
|
+
WHERE length(e.name) > 2
|
|
441
|
+
AND ? LIKE '%' || lower(e.name) || '%'`, [normalizedQuery])[0]?.values ?? [];
|
|
442
|
+
const boostDocs = new Set();
|
|
443
|
+
for (const [doc] of rows) {
|
|
444
|
+
if (typeof doc === "string")
|
|
445
|
+
boostDocs.add(doc);
|
|
446
|
+
}
|
|
447
|
+
return boostDocs;
|
|
448
|
+
}
|
|
449
|
+
catch (err) {
|
|
450
|
+
if (process.env.PHREN_DEBUG || (process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
|
|
451
|
+
process.stderr.write(`[phren] getFragmentBoostDocs: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
452
|
+
return new Set();
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
/** @deprecated Use getFragmentBoostDocs instead */
|
|
456
|
+
export const getEntityBoostDocs = getFragmentBoostDocs;
|