@phren/cli 0.0.3 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -565
- package/mcp/dist/cli-actions.js +1 -1
- package/mcp/dist/cli-govern.js +2 -2
- package/mcp/dist/cli-hooks.js +6 -0
- package/mcp/dist/cli-search.js +2 -2
- package/mcp/dist/content-citation.js +42 -0
- package/mcp/dist/content-dedup.js +3 -3
- package/mcp/dist/content-learning.js +7 -7
- package/mcp/dist/entrypoint.js +1 -1
- package/mcp/dist/finding-lifecycle.js +18 -0
- package/mcp/dist/governance-policy.js +1 -1
- package/mcp/dist/governance-scores.js +9 -1
- package/mcp/dist/link-checksums.js +1 -1
- package/mcp/dist/link-doctor.js +1 -1
- package/mcp/dist/mcp-memory.js +4 -4
- package/mcp/dist/mcp-ops.js +1 -1
- package/mcp/dist/memory-ui-data.js +3 -3
- package/mcp/dist/phren-art.js +268 -1
- package/mcp/dist/phren-core.js +4 -4
- package/mcp/dist/phren-paths.js +1 -1
- package/mcp/dist/profile-store.js +1 -1
- package/mcp/dist/query-correlation.js +147 -0
- package/mcp/dist/shared-content.js +1 -1
- package/mcp/dist/shared-index.js +15 -15
- package/mcp/dist/shared-retrieval.js +29 -1
- package/mcp/dist/shell-entry.js +46 -3
- package/mcp/dist/shell-render.js +26 -7
- package/mcp/dist/status.js +1 -1
- package/mcp/dist/tool-registry.js +1 -1
- package/mcp/dist/utils.js +1 -1
- package/package.json +2 -2
package/mcp/dist/cli-search.js
CHANGED
|
@@ -16,7 +16,7 @@ const SEARCH_TYPES = new Set([
|
|
|
16
16
|
"task",
|
|
17
17
|
"changelog",
|
|
18
18
|
"canonical",
|
|
19
|
-
"
|
|
19
|
+
"review-queue",
|
|
20
20
|
"skill",
|
|
21
21
|
"other",
|
|
22
22
|
]);
|
|
@@ -45,7 +45,7 @@ function printSearchUsage() {
|
|
|
45
45
|
console.error(" phren search --project <name> [--type <type>] [--limit <n>] [--all]");
|
|
46
46
|
console.error(" phren search --history Show recent searches");
|
|
47
47
|
console.error(" phren search --from-history <n> Re-run search #n from history");
|
|
48
|
-
console.error(" type: claude|summary|findings|reference|task|changelog|canonical|
|
|
48
|
+
console.error(" type: claude|summary|findings|reference|task|changelog|canonical|review-queue|skill|other");
|
|
49
49
|
}
|
|
50
50
|
function validateAndNormalizeSearchOptions(phrenPath, queryParts, project, type, limit, showHistory, fromHistory, searchAll) {
|
|
51
51
|
if (showHistory) {
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
|
+
import { statSync } from "fs";
|
|
2
3
|
import * as path from "path";
|
|
3
4
|
import { debugLog, EXEC_TIMEOUT_MS, EXEC_TIMEOUT_QUICK_MS } from "./shared.js";
|
|
4
5
|
import { errorMessage, runGitOrThrow } from "./utils.js";
|
|
5
6
|
import { findingIdFromLine } from "./finding-impact.js";
|
|
6
7
|
import { METADATA_REGEX, isArchiveStart, isArchiveEnd } from "./content-metadata.js";
|
|
8
|
+
import { FINDING_TYPE_DECAY, extractFindingType } from "./finding-lifecycle.js";
|
|
7
9
|
export const FINDING_PROVENANCE_SOURCES = [
|
|
8
10
|
"human",
|
|
9
11
|
"agent",
|
|
@@ -270,6 +272,16 @@ function confidenceForAge(ageDays, decay) {
|
|
|
270
272
|
return d90 - ((d90 - d120) * ((ageDays - 90) / 30));
|
|
271
273
|
return d120; // don't decay further past d120; TTL handles final expiry
|
|
272
274
|
}
|
|
275
|
+
function wasFileModifiedAfter(filePath, findingDate) {
|
|
276
|
+
try {
|
|
277
|
+
const stat = statSync(filePath);
|
|
278
|
+
const fileModified = stat.mtime.toISOString().slice(0, 10);
|
|
279
|
+
return fileModified > findingDate;
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return false; // File doesn't exist or can't stat — handled by citation validation
|
|
283
|
+
}
|
|
284
|
+
}
|
|
273
285
|
export function filterTrustedFindings(content, ttlDays) {
|
|
274
286
|
return filterTrustedFindingsDetailed(content, { ttlDays }).content;
|
|
275
287
|
}
|
|
@@ -356,11 +368,41 @@ export function filterTrustedFindingsDetailed(content, opts) {
|
|
|
356
368
|
else {
|
|
357
369
|
confidence = DEFAULT_UNDATED_CONFIDENCE;
|
|
358
370
|
}
|
|
371
|
+
// Type-specific decay adjustment
|
|
372
|
+
const findingType = extractFindingType(line);
|
|
373
|
+
if (findingType) {
|
|
374
|
+
const typeConfig = FINDING_TYPE_DECAY[findingType];
|
|
375
|
+
if (typeConfig) {
|
|
376
|
+
// Override max age for this type
|
|
377
|
+
if (effectiveDate && typeConfig.maxAgeDays !== Infinity) {
|
|
378
|
+
const age = ageDaysForDate(effectiveDate);
|
|
379
|
+
if (age !== null && age > typeConfig.maxAgeDays) {
|
|
380
|
+
issues.push({ date: effectiveDate || "unknown", bullet: line, reason: "stale" });
|
|
381
|
+
if (citation)
|
|
382
|
+
i++;
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
// Apply type-specific decay multiplier
|
|
387
|
+
confidence *= typeConfig.decayMultiplier;
|
|
388
|
+
// Decisions and anti-patterns get a floor boost (never drop below 0.6)
|
|
389
|
+
if (typeConfig.maxAgeDays === Infinity) {
|
|
390
|
+
confidence = Math.max(confidence, 0.6);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
359
394
|
if (citation && !validateFindingCitation(citation)) {
|
|
360
395
|
issues.push({ date: effectiveDate || "unknown", bullet: line, reason: "invalid_citation" });
|
|
361
396
|
i++;
|
|
362
397
|
continue;
|
|
363
398
|
}
|
|
399
|
+
// If cited file was modified after finding was created, lower confidence
|
|
400
|
+
if (citation && effectiveDate && citation.file) {
|
|
401
|
+
const fileModifiedAfterFinding = wasFileModifiedAfter(citation.file, effectiveDate);
|
|
402
|
+
if (fileModifiedAfterFinding) {
|
|
403
|
+
confidence *= 0.7; // File changed since finding was written — may be stale
|
|
404
|
+
}
|
|
405
|
+
}
|
|
364
406
|
if (!citation)
|
|
365
407
|
confidence *= 0.8;
|
|
366
408
|
const provenance = parseSourceComment(line)?.source ?? "unknown";
|
|
@@ -187,7 +187,7 @@ export function jaccardSimilarity(a, b) {
|
|
|
187
187
|
const PROSE_ENTITY_RE = UNIVERSAL_TECH_TERMS_RE;
|
|
188
188
|
const POSITIVE_RE = /\b(always|prefer|should|must|works|recommend|enable)\b/i;
|
|
189
189
|
const NEGATIVE_RE = /\b(never|avoid|don't|do not|shouldn't|must not|broken|deprecated|disable)\b/i;
|
|
190
|
-
// ── Dynamic
|
|
190
|
+
// ── Dynamic fragment extraction ────────────────────────────────────────────────
|
|
191
191
|
const ENTITY_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
192
192
|
// Patterns that suggest a token is a proper noun / tool name:
|
|
193
193
|
// - CamelCase word (at least one interior uppercase): PhotonMappingEngine, GameKit
|
|
@@ -257,7 +257,7 @@ function extractProseEntities(text, dynamicEntities) {
|
|
|
257
257
|
let m;
|
|
258
258
|
while ((m = re.exec(text)) !== null)
|
|
259
259
|
found.add(m[0].toLowerCase());
|
|
260
|
-
// Match additional
|
|
260
|
+
// Match additional fragment patterns (versions, env keys, file paths, error codes, dates)
|
|
261
261
|
for (const { re: pattern } of EXTRA_ENTITY_PATTERNS) {
|
|
262
262
|
const pRe = new RegExp(pattern.source, pattern.flags);
|
|
263
263
|
let pm;
|
|
@@ -265,7 +265,7 @@ function extractProseEntities(text, dynamicEntities) {
|
|
|
265
265
|
found.add(pm[0].toLowerCase());
|
|
266
266
|
}
|
|
267
267
|
if (dynamicEntities) {
|
|
268
|
-
// Also check whether any dynamic
|
|
268
|
+
// Also check whether any dynamic fragment appears (case-insensitive word match)
|
|
269
269
|
for (const entity of dynamicEntities) {
|
|
270
270
|
const escaped = entity.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
271
271
|
if (new RegExp(`\\b${escaped}\\b`, "i").test(text)) {
|
|
@@ -204,20 +204,20 @@ export function upsertCanonical(phrenPath, project, memory) {
|
|
|
204
204
|
const resolvedDir = safeProjectPath(phrenPath, project);
|
|
205
205
|
if (!resolvedDir || !fs.existsSync(resolvedDir))
|
|
206
206
|
return phrenErr(`Project "${project}" not found in phren.`, PhrenError.PROJECT_NOT_FOUND);
|
|
207
|
-
const canonicalPath = path.join(resolvedDir, "
|
|
207
|
+
const canonicalPath = path.join(resolvedDir, "truths.md");
|
|
208
208
|
const today = new Date().toISOString().slice(0, 10);
|
|
209
209
|
const bullet = memory.startsWith("- ") ? memory : `- ${memory}`;
|
|
210
210
|
withFileLock(canonicalPath, () => {
|
|
211
211
|
if (!fs.existsSync(canonicalPath)) {
|
|
212
|
-
fs.writeFileSync(canonicalPath, `# ${project}
|
|
212
|
+
fs.writeFileSync(canonicalPath, `# ${project} Truths\n\n## Truths\n\n${bullet} _(added ${today})_\n`);
|
|
213
213
|
}
|
|
214
214
|
else {
|
|
215
215
|
const existing = fs.readFileSync(canonicalPath, "utf8");
|
|
216
|
-
const line = `${bullet} _(
|
|
216
|
+
const line = `${bullet} _(added ${today})_`;
|
|
217
217
|
if (!existing.includes(bullet)) {
|
|
218
|
-
const updated = existing.includes("##
|
|
219
|
-
? existing.replace("##
|
|
220
|
-
: `${existing.trimEnd()}\n\n##
|
|
218
|
+
const updated = existing.includes("## Truths")
|
|
219
|
+
? existing.replace("## Truths", `## Truths\n\n${line}`)
|
|
220
|
+
: `${existing.trimEnd()}\n\n## Truths\n\n${line}\n`;
|
|
221
221
|
const content = updated.endsWith("\n") ? updated : updated + "\n";
|
|
222
222
|
const tmpPath = canonicalPath + `.tmp-${crypto.randomUUID()}`;
|
|
223
223
|
fs.writeFileSync(tmpPath, content);
|
|
@@ -226,7 +226,7 @@ export function upsertCanonical(phrenPath, project, memory) {
|
|
|
226
226
|
}
|
|
227
227
|
});
|
|
228
228
|
appendAuditLog(phrenPath, "pin_memory", `project=${project} memory=${JSON.stringify(memory)}`);
|
|
229
|
-
return phrenOk(`
|
|
229
|
+
return phrenOk(`Truth saved in ${project}.`);
|
|
230
230
|
}
|
|
231
231
|
export function addFindingToFile(phrenPath, project, learning, citationInput, opts) {
|
|
232
232
|
const findingError = validateFinding(learning);
|
package/mcp/dist/entrypoint.js
CHANGED
|
@@ -31,7 +31,7 @@ Usage:
|
|
|
31
31
|
phren search <query> [--project <n>] [--type <t>] [--limit <n>]
|
|
32
32
|
Search what phren remembers
|
|
33
33
|
phren add-finding <project> "..." Tell phren what you learned
|
|
34
|
-
phren pin <project> "..."
|
|
34
|
+
phren pin <project> "..." Save a truth
|
|
35
35
|
phren tasks Cross-project task view
|
|
36
36
|
phren skill-list List installed skills
|
|
37
37
|
phren doctor [--fix] [--check-data] [--agents]
|
|
@@ -6,6 +6,24 @@ const LIFECYCLE_PREFIX = "phren";
|
|
|
6
6
|
import { withFileLock } from "./governance-locks.js";
|
|
7
7
|
import { isValidProjectName, safeProjectPath } from "./utils.js";
|
|
8
8
|
import { parseCreatedDate as parseCreatedDateMeta, parseStatusField, parseStatus, parseSupersession, parseContradiction, parseFindingId as parseFindingIdMeta, stripLifecycleMetadata, stripRelationMetadata, } from "./content-metadata.js";
|
|
9
|
+
export const FINDING_TYPE_DECAY = {
|
|
10
|
+
'pattern': { maxAgeDays: 365, decayMultiplier: 1.0 }, // Slow decay, long-lived
|
|
11
|
+
'decision': { maxAgeDays: Infinity, decayMultiplier: 1.0 }, // Never decays
|
|
12
|
+
'pitfall': { maxAgeDays: 365, decayMultiplier: 1.0 }, // Slow decay
|
|
13
|
+
'anti-pattern': { maxAgeDays: Infinity, decayMultiplier: 1.0 }, // Never decays
|
|
14
|
+
'observation': { maxAgeDays: 14, decayMultiplier: 0.7 }, // Fast decay, short-lived
|
|
15
|
+
'workaround': { maxAgeDays: 60, decayMultiplier: 0.85 }, // Medium decay
|
|
16
|
+
'bug': { maxAgeDays: 30, decayMultiplier: 0.8 }, // Medium-fast decay
|
|
17
|
+
'tooling': { maxAgeDays: 180, decayMultiplier: 0.95 }, // Medium-slow decay
|
|
18
|
+
'context': { maxAgeDays: 30, decayMultiplier: 0.75 }, // Fast decay (contextual facts)
|
|
19
|
+
};
|
|
20
|
+
export function extractFindingType(line) {
|
|
21
|
+
const match = line.match(/\[(\w[\w-]*)\]/);
|
|
22
|
+
if (!match)
|
|
23
|
+
return null;
|
|
24
|
+
const tag = match[1].toLowerCase();
|
|
25
|
+
return tag in FINDING_TYPE_DECAY ? tag : null;
|
|
26
|
+
}
|
|
9
27
|
export const FINDING_LIFECYCLE_STATUSES = [
|
|
10
28
|
"active",
|
|
11
29
|
"superseded",
|
|
@@ -387,7 +387,7 @@ export function appendReviewQueue(phrenPath, project, section, entries) {
|
|
|
387
387
|
const resolvedDir = safeProjectPath(phrenPath, project);
|
|
388
388
|
if (!resolvedDir || !fs.existsSync(resolvedDir))
|
|
389
389
|
return phrenErr(`Project "${project}" not found in phren.`, PhrenError.PROJECT_NOT_FOUND);
|
|
390
|
-
const queuePath = path.join(resolvedDir, "
|
|
390
|
+
const queuePath = path.join(resolvedDir, "review.md");
|
|
391
391
|
const today = new Date().toISOString().slice(0, 10);
|
|
392
392
|
const normalized = [];
|
|
393
393
|
for (const entry of entries) {
|
|
@@ -270,7 +270,7 @@ export function recordInjection(phrenPath, key, sessionId) {
|
|
|
270
270
|
debugLog(`Usage log rotation failed: ${errorMessage(err)}`);
|
|
271
271
|
}
|
|
272
272
|
}
|
|
273
|
-
export function recordFeedback(phrenPath, key, feedback) {
|
|
273
|
+
export function recordFeedback(phrenPath, key, feedback, sessionId) {
|
|
274
274
|
const delta = {};
|
|
275
275
|
if (feedback === "helpful")
|
|
276
276
|
delta.helpful = 1;
|
|
@@ -280,6 +280,14 @@ export function recordFeedback(phrenPath, key, feedback) {
|
|
|
280
280
|
delta.regressionPenalty = 1;
|
|
281
281
|
appendScoreJournal(phrenPath, key, delta);
|
|
282
282
|
appendAuditLog(phrenPath, "memory_feedback", `key=${key} feedback=${feedback}`);
|
|
283
|
+
// When feedback is "helpful", mark correlated query entries for future boost
|
|
284
|
+
if (feedback === "helpful" && sessionId) {
|
|
285
|
+
import("./query-correlation.js").then(({ markCorrelationsHelpful: markHelpful }) => {
|
|
286
|
+
const colonIdx = key.indexOf(":");
|
|
287
|
+
const docKey = colonIdx >= 0 ? key.slice(0, colonIdx) : key;
|
|
288
|
+
markHelpful(phrenPath, sessionId, docKey);
|
|
289
|
+
}).catch(() => { });
|
|
290
|
+
}
|
|
283
291
|
}
|
|
284
292
|
// Module-level cache for the journal aggregation used by getQualityMultiplier.
|
|
285
293
|
// Invalidated whenever flushEntryScores runs (at which point the journal is cleared).
|
|
@@ -34,7 +34,7 @@ export function updateFileChecksums(phrenPath, profileName) {
|
|
|
34
34
|
const tracked = [];
|
|
35
35
|
const dirs = getProjectDirs(phrenPath, profileName);
|
|
36
36
|
for (const dir of dirs) {
|
|
37
|
-
for (const name of ["FINDINGS.md", ...TASK_FILE_ALIASES, "
|
|
37
|
+
for (const name of ["FINDINGS.md", ...TASK_FILE_ALIASES, "truths.md"]) {
|
|
38
38
|
const full = path.join(dir, name);
|
|
39
39
|
if (!fs.existsSync(full))
|
|
40
40
|
continue;
|
package/mcp/dist/link-doctor.js
CHANGED
|
@@ -533,7 +533,7 @@ export async function runDoctor(phrenPath, fix = false, checkData = false) {
|
|
|
533
533
|
const projectName = path.basename(projectDir);
|
|
534
534
|
if (projectName === "global")
|
|
535
535
|
continue;
|
|
536
|
-
for (const mdFile of ["FINDINGS.md", ...TASK_FILE_ALIASES, "
|
|
536
|
+
for (const mdFile of ["FINDINGS.md", ...TASK_FILE_ALIASES, "review.md", "CLAUDE.md", "REFERENCE.md"]) {
|
|
537
537
|
const filePath = path.join(projectDir, mdFile);
|
|
538
538
|
if (!fs.existsSync(filePath))
|
|
539
539
|
continue;
|
package/mcp/dist/mcp-memory.js
CHANGED
|
@@ -10,10 +10,10 @@ export function register(server, ctx) {
|
|
|
10
10
|
const { phrenPath, withWriteQueue, updateFileInIndex } = ctx;
|
|
11
11
|
server.registerTool("pin_memory", {
|
|
12
12
|
title: "◆ phren · pin memory",
|
|
13
|
-
description: "
|
|
13
|
+
description: "Write a truth — a high-confidence, always-inject entry in truths.md that never decays.",
|
|
14
14
|
inputSchema: z.object({
|
|
15
15
|
project: z.string().describe("Project name."),
|
|
16
|
-
memory: z.string().describe("
|
|
16
|
+
memory: z.string().describe("Truth text."),
|
|
17
17
|
}),
|
|
18
18
|
}, async ({ project, memory }) => {
|
|
19
19
|
if (!isValidProjectName(project))
|
|
@@ -22,8 +22,8 @@ export function register(server, ctx) {
|
|
|
22
22
|
const result = upsertCanonical(phrenPath, project, memory);
|
|
23
23
|
if (!result.ok)
|
|
24
24
|
return mcpResponse({ ok: false, error: result.error });
|
|
25
|
-
// Update FTS index so newly
|
|
26
|
-
const canonicalPath = path.join(phrenPath, project, "
|
|
25
|
+
// Update FTS index so newly added truth is immediately searchable
|
|
26
|
+
const canonicalPath = path.join(phrenPath, project, "truths.md");
|
|
27
27
|
updateFileInIndex(canonicalPath);
|
|
28
28
|
return mcpResponse({ ok: true, message: result.data, data: { project, memory } });
|
|
29
29
|
});
|
package/mcp/dist/mcp-ops.js
CHANGED
|
@@ -294,7 +294,7 @@ export function register(server, ctx) {
|
|
|
294
294
|
// ── get_review_queue ─────────────────────────────────────────────────────
|
|
295
295
|
server.registerTool("get_review_queue", {
|
|
296
296
|
title: "◆ phren · get review queue",
|
|
297
|
-
description: "List all items in a project's
|
|
297
|
+
description: "List all items in a project's review queue (review.md), or across all projects when omitted. " +
|
|
298
298
|
"Returns items with their id, section (Review/Stale/Conflicts), date, text, confidence, and risky flag.",
|
|
299
299
|
inputSchema: z.object({
|
|
300
300
|
project: z.string().optional().describe("Project name. Omit to read the review queue across all projects in the active profile."),
|
|
@@ -264,7 +264,7 @@ export async function buildGraph(phrenPath, profile, focusProject) {
|
|
|
264
264
|
catch {
|
|
265
265
|
// task loading failed — continue with other data sources
|
|
266
266
|
}
|
|
267
|
-
// ── Fragments (
|
|
267
|
+
// ── Fragments (fragment graph) ──────────────────────────────────────
|
|
268
268
|
let db = null;
|
|
269
269
|
try {
|
|
270
270
|
db = await buildIndex(phrenPath, profile);
|
|
@@ -326,7 +326,7 @@ export async function buildGraph(phrenPath, profile, focusProject) {
|
|
|
326
326
|
entityType: type,
|
|
327
327
|
refDocs: refs,
|
|
328
328
|
});
|
|
329
|
-
// Link
|
|
329
|
+
// Link fragment to each project it appears in
|
|
330
330
|
const linkedProjects = new Set();
|
|
331
331
|
for (const ref of refs) {
|
|
332
332
|
if (ref.project && projectSet.has(ref.project))
|
|
@@ -339,7 +339,7 @@ export async function buildGraph(phrenPath, profile, focusProject) {
|
|
|
339
339
|
}
|
|
340
340
|
}
|
|
341
341
|
catch {
|
|
342
|
-
//
|
|
342
|
+
// fragment loading failed — continue with other data sources
|
|
343
343
|
}
|
|
344
344
|
finally {
|
|
345
345
|
if (db) {
|
package/mcp/dist/phren-art.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Phren character ASCII/Unicode art and spinner for CLI presence.
|
|
2
|
+
* Phren character ASCII/Unicode art, animation engine, and spinner for CLI presence.
|
|
3
3
|
*
|
|
4
4
|
* Based on the pixel art: purple 8-bit brain with diamond eyes,
|
|
5
5
|
* smile, little legs, and cyan sparkle.
|
|
6
|
+
*
|
|
7
|
+
* Animation system provides lifelike movement through composable effects:
|
|
8
|
+
* bob, blink, sparkle, and lean — all driven by setTimeout with randomized
|
|
9
|
+
* intervals for organic timing.
|
|
6
10
|
*/
|
|
7
11
|
const ESC = "\x1b[";
|
|
8
12
|
const RESET = `${ESC}0m`;
|
|
@@ -14,6 +18,7 @@ const DARK_PURPLE = `${ESC}38;5;57m`; // deep purple — shadow/outline
|
|
|
14
18
|
const LIGHT_PURPLE = `${ESC}38;5;141m`; // lavender — brain highlights
|
|
15
19
|
const MID_PURPLE = `${ESC}38;5;98m`; // mid tone
|
|
16
20
|
const NAVY = `${ESC}38;5;18m`; // darkest outline
|
|
21
|
+
// ── Art constants (24px wide, truecolor half-blocks) ─────────────────────────
|
|
17
22
|
/**
|
|
18
23
|
* Phren truecolor art (24px wide, generated from phren-transparent.png).
|
|
19
24
|
* Uses half-block ▀ with RGB foreground+background for pixel-faithful rendering.
|
|
@@ -33,6 +38,268 @@ export const PHREN_ART = [
|
|
|
33
38
|
" ",
|
|
34
39
|
" ",
|
|
35
40
|
];
|
|
41
|
+
/** The art width in visible columns */
|
|
42
|
+
const ART_WIDTH = 24;
|
|
43
|
+
// ── Sparkle row: the cyan pixels at row 2 ────────────────────────────────────
|
|
44
|
+
// Sparkle uses ▄ half-blocks with cyan truecolor. For animation we cycle through
|
|
45
|
+
// decorative unicode characters at different brightness levels.
|
|
46
|
+
const SPARKLE_ROW = 2;
|
|
47
|
+
const SPARKLE_CHARS = ["\u2726", "\u2727", "\u2736", " "]; // ✦ ✧ ✶ (dim/blank)
|
|
48
|
+
// ── Eye detection: dark navy pixels in row 6 (fg R<30, G<45, B<120) ──────────
|
|
49
|
+
// Row 6 has two eye pixels at segments 1 and 5 (visual positions 7 and 11).
|
|
50
|
+
// When blinking, we replace their dark fg color with the surrounding body purple.
|
|
51
|
+
const EYE_ROW = 6;
|
|
52
|
+
// Dark-pixel fg threshold
|
|
53
|
+
const EYE_R_MAX = 30;
|
|
54
|
+
const EYE_G_MAX = 45;
|
|
55
|
+
const EYE_B_MAX = 120;
|
|
56
|
+
// Body-purple color to use when "closing" eyes (average of surrounding pixels)
|
|
57
|
+
const BLINK_COLOR = "146;130;250";
|
|
58
|
+
/**
|
|
59
|
+
* Flip a single art line horizontally. Reverses the order of colored pixel
|
|
60
|
+
* segments and swaps leading/trailing whitespace so the character faces right.
|
|
61
|
+
* Half-block characters (▀ ▄) are horizontally symmetric so no char swap needed.
|
|
62
|
+
*/
|
|
63
|
+
function flipLine(line) {
|
|
64
|
+
const stripped = line.replace(/\x1b\[[^m]*m/g, "");
|
|
65
|
+
const leadSpaces = stripped.match(/^( *)/)[1].length;
|
|
66
|
+
const trailSpaces = stripped.match(/( *)$/)[1].length;
|
|
67
|
+
// Parse pixel segments: each is one or two ANSI color codes followed by a block char.
|
|
68
|
+
// We strip any leading reset (\x1b[0m) from captured codes — it's an artifact from
|
|
69
|
+
// the original per-pixel reset pattern and will be re-added during reassembly.
|
|
70
|
+
const pixels = [];
|
|
71
|
+
const pixelRegex = /((?:\x1b\[[^m]*m)+)([\u2580\u2584])/g;
|
|
72
|
+
let match;
|
|
73
|
+
while ((match = pixelRegex.exec(line)) !== null) {
|
|
74
|
+
const codes = match[1].replace(/\x1b\[0m/g, "");
|
|
75
|
+
if (codes)
|
|
76
|
+
pixels.push({ codes, char: match[2] });
|
|
77
|
+
}
|
|
78
|
+
if (pixels.length === 0)
|
|
79
|
+
return line; // blank or space-only line
|
|
80
|
+
const reversed = [...pixels].reverse();
|
|
81
|
+
const newLead = " ".repeat(trailSpaces);
|
|
82
|
+
const newTrail = " ".repeat(leadSpaces);
|
|
83
|
+
let result = newLead;
|
|
84
|
+
for (const px of reversed) {
|
|
85
|
+
result += px.codes + px.char + "\x1b[0m";
|
|
86
|
+
}
|
|
87
|
+
result += newTrail;
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Generate horizontally flipped art (facing right).
|
|
92
|
+
*/
|
|
93
|
+
function generateFlippedArt(art) {
|
|
94
|
+
return art.map(flipLine);
|
|
95
|
+
}
|
|
96
|
+
/** Pre-computed right-facing art */
|
|
97
|
+
export const PHREN_ART_RIGHT = generateFlippedArt(PHREN_ART);
|
|
98
|
+
/** Random integer in [min, max] inclusive */
|
|
99
|
+
function randInt(min, max) {
|
|
100
|
+
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Replace eye pixels on a single line with the blink color.
|
|
104
|
+
* Eye pixels are identified by their dark navy fg color (R<30, G<45, B<120).
|
|
105
|
+
* We replace the fg color code while preserving the bg code and character.
|
|
106
|
+
*/
|
|
107
|
+
function applyBlinkToLine(line) {
|
|
108
|
+
// Match ANSI color sequences: each pixel is \e[38;2;R;G;Bm (optionally with \e[48;2;...m) then a block char
|
|
109
|
+
// We scan for fg codes that match the eye threshold and replace them with the blink color
|
|
110
|
+
return line.replace(/\x1b\[38;2;(\d+);(\d+);(\d+)m/g, (full, rStr, gStr, bStr) => {
|
|
111
|
+
const r = Number(rStr);
|
|
112
|
+
const g = Number(gStr);
|
|
113
|
+
const b = Number(bStr);
|
|
114
|
+
if (r < EYE_R_MAX && g < EYE_G_MAX && b < EYE_B_MAX) {
|
|
115
|
+
return `\x1b[38;2;${BLINK_COLOR}m`;
|
|
116
|
+
}
|
|
117
|
+
return full;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Replace the sparkle pixels on the sparkle row with the current sparkle character.
|
|
122
|
+
* The sparkle row has two cyan ▄ half-blocks. During sparkle animation we replace
|
|
123
|
+
* them with decorative Unicode characters from SPARKLE_CHARS.
|
|
124
|
+
*/
|
|
125
|
+
function applySparkleToLine(line, frame, active) {
|
|
126
|
+
if (!active)
|
|
127
|
+
return line;
|
|
128
|
+
const sparkleChar = SPARKLE_CHARS[frame % SPARKLE_CHARS.length];
|
|
129
|
+
if (sparkleChar === " ") {
|
|
130
|
+
// Dim: replace the cyan-colored segments with spaces (they disappear)
|
|
131
|
+
return line.replace(/\x1b\[38;2;\d+;2\d\d;2\d\dm[\u2580\u2584]\x1b\[0m/g, " ");
|
|
132
|
+
}
|
|
133
|
+
// Replace the half-block characters with the sparkle unicode char, keeping the cyan color
|
|
134
|
+
return line.replace(/(\x1b\[38;2;\d+;2\d\d;2\d\dm)[\u2580\u2584](\x1b\[0m)/g, `$1${sparkleChar}$2`);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Apply lean (horizontal shift) to a line.
|
|
138
|
+
* Positive offset = shift right (prepend spaces, trim from end).
|
|
139
|
+
* Negative offset = shift left (trim from start, append spaces).
|
|
140
|
+
*/
|
|
141
|
+
function applyLean(line, offset) {
|
|
142
|
+
if (offset === 0)
|
|
143
|
+
return line;
|
|
144
|
+
if (offset > 0) {
|
|
145
|
+
// Shift right: prepend spaces
|
|
146
|
+
return " ".repeat(offset) + line;
|
|
147
|
+
}
|
|
148
|
+
// Shift left: remove leading spaces (up to |offset|)
|
|
149
|
+
const trimCount = Math.min(-offset, line.match(/^( *)/)[1].length);
|
|
150
|
+
return line.slice(trimCount);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Create an animated phren character controller.
|
|
154
|
+
*
|
|
155
|
+
* @param options.facing - 'left' (default, original) or 'right' (flipped)
|
|
156
|
+
* @param options.size - art width; unused but reserved for future scaling (default 24)
|
|
157
|
+
*/
|
|
158
|
+
export function createPhrenAnimator(options) {
|
|
159
|
+
const facing = options?.facing ?? "left";
|
|
160
|
+
const baseArt = facing === "right" ? PHREN_ART_RIGHT : PHREN_ART;
|
|
161
|
+
const state = {
|
|
162
|
+
bobUp: false,
|
|
163
|
+
isBlinking: false,
|
|
164
|
+
sparkleFrame: 0,
|
|
165
|
+
sparkleActive: false,
|
|
166
|
+
leanOffset: 0,
|
|
167
|
+
};
|
|
168
|
+
const timers = [];
|
|
169
|
+
function scheduleTimer(fn, ms) {
|
|
170
|
+
const t = setTimeout(fn, ms);
|
|
171
|
+
timers.push(t);
|
|
172
|
+
}
|
|
173
|
+
// ── Bob animation: toggles bobUp every ~500ms ──────────────────────────
|
|
174
|
+
function scheduleBob() {
|
|
175
|
+
scheduleTimer(() => {
|
|
176
|
+
state.bobUp = !state.bobUp;
|
|
177
|
+
scheduleBob();
|
|
178
|
+
}, 500);
|
|
179
|
+
}
|
|
180
|
+
// ── Blink animation: eyes close for 150ms, random 2-8s intervals ──────
|
|
181
|
+
function scheduleBlink() {
|
|
182
|
+
const interval = randInt(2000, 8000);
|
|
183
|
+
scheduleTimer(() => {
|
|
184
|
+
// Perform blink
|
|
185
|
+
state.isBlinking = true;
|
|
186
|
+
scheduleTimer(() => {
|
|
187
|
+
state.isBlinking = false;
|
|
188
|
+
// 30% chance of double-blink
|
|
189
|
+
if (Math.random() < 0.3) {
|
|
190
|
+
scheduleTimer(() => {
|
|
191
|
+
state.isBlinking = true;
|
|
192
|
+
scheduleTimer(() => {
|
|
193
|
+
state.isBlinking = false;
|
|
194
|
+
scheduleBlink();
|
|
195
|
+
}, 150);
|
|
196
|
+
}, 200);
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
scheduleBlink();
|
|
200
|
+
}
|
|
201
|
+
}, 150);
|
|
202
|
+
}, interval);
|
|
203
|
+
}
|
|
204
|
+
// ── Sparkle animation: fast cycle during bursts, long pauses between ───
|
|
205
|
+
function scheduleSparkle() {
|
|
206
|
+
// Wait 1-5 seconds before next sparkle burst
|
|
207
|
+
const pause = randInt(1000, 5000);
|
|
208
|
+
scheduleTimer(() => {
|
|
209
|
+
state.sparkleActive = true;
|
|
210
|
+
state.sparkleFrame = 0;
|
|
211
|
+
sparkleStep(0);
|
|
212
|
+
}, pause);
|
|
213
|
+
}
|
|
214
|
+
function sparkleStep(step) {
|
|
215
|
+
if (step >= SPARKLE_CHARS.length) {
|
|
216
|
+
// Burst complete
|
|
217
|
+
state.sparkleActive = false;
|
|
218
|
+
scheduleSparkle();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
state.sparkleFrame = step;
|
|
222
|
+
scheduleTimer(() => {
|
|
223
|
+
sparkleStep(step + 1);
|
|
224
|
+
}, 200);
|
|
225
|
+
}
|
|
226
|
+
// ── Lean animation: shift 1 col left or right every 4-10s, hold 1-2s ──
|
|
227
|
+
function scheduleLean() {
|
|
228
|
+
const interval = randInt(4000, 10000);
|
|
229
|
+
scheduleTimer(() => {
|
|
230
|
+
const direction = Math.random() < 0.5 ? -1 : 1;
|
|
231
|
+
state.leanOffset = direction;
|
|
232
|
+
const holdTime = randInt(1000, 2000);
|
|
233
|
+
scheduleTimer(() => {
|
|
234
|
+
state.leanOffset = 0;
|
|
235
|
+
scheduleLean();
|
|
236
|
+
}, holdTime);
|
|
237
|
+
}, interval);
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
getFrame() {
|
|
241
|
+
let lines = baseArt.map((line, i) => {
|
|
242
|
+
let result = line;
|
|
243
|
+
// Apply blink to the eye row
|
|
244
|
+
if (state.isBlinking && i === EYE_ROW) {
|
|
245
|
+
result = applyBlinkToLine(result);
|
|
246
|
+
}
|
|
247
|
+
// Apply sparkle to sparkle row
|
|
248
|
+
if (i === SPARKLE_ROW) {
|
|
249
|
+
result = applySparkleToLine(result, state.sparkleFrame, state.sparkleActive);
|
|
250
|
+
}
|
|
251
|
+
// Apply lean
|
|
252
|
+
result = applyLean(result, state.leanOffset);
|
|
253
|
+
return result;
|
|
254
|
+
});
|
|
255
|
+
// Apply bob: when bobUp, prepend a blank line (shift everything down visually)
|
|
256
|
+
if (state.bobUp) {
|
|
257
|
+
lines = ["", ...lines.slice(0, -1)];
|
|
258
|
+
}
|
|
259
|
+
return lines;
|
|
260
|
+
},
|
|
261
|
+
start() {
|
|
262
|
+
scheduleBob();
|
|
263
|
+
scheduleBlink();
|
|
264
|
+
scheduleSparkle();
|
|
265
|
+
scheduleLean();
|
|
266
|
+
},
|
|
267
|
+
stop() {
|
|
268
|
+
for (const t of timers) {
|
|
269
|
+
clearTimeout(t);
|
|
270
|
+
}
|
|
271
|
+
timers.length = 0;
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
// ── Startup frames (pre-baked, no timers) ────────────────────────────────────
|
|
276
|
+
/**
|
|
277
|
+
* Returns 4 pre-baked animation frames for shell startup display.
|
|
278
|
+
* No timers needed — the caller cycles through them manually.
|
|
279
|
+
*
|
|
280
|
+
* Frames: [neutral, bob-up, neutral, bob-down(sparkle)]
|
|
281
|
+
*
|
|
282
|
+
* @param facing - 'left' (default) or 'right'
|
|
283
|
+
*/
|
|
284
|
+
export function getPhrenStartupFrames(facing) {
|
|
285
|
+
const art = facing === "right" ? PHREN_ART_RIGHT : PHREN_ART;
|
|
286
|
+
// Frame 0: neutral
|
|
287
|
+
const frame0 = [...art];
|
|
288
|
+
// Frame 1: bob up (prepend blank line, drop last line)
|
|
289
|
+
const frame1 = ["", ...art.slice(0, -1)];
|
|
290
|
+
// Frame 2: neutral (same as frame 0)
|
|
291
|
+
const frame2 = [...art];
|
|
292
|
+
// Frame 3: bob down with sparkle burst — shift down by removing first line, append blank
|
|
293
|
+
const frame3WithSparkle = art.map((line, i) => {
|
|
294
|
+
if (i === SPARKLE_ROW) {
|
|
295
|
+
return applySparkleToLine(line, 0, true); // ✦ sparkle
|
|
296
|
+
}
|
|
297
|
+
return line;
|
|
298
|
+
});
|
|
299
|
+
const frame3 = [...frame3WithSparkle.slice(1), ""];
|
|
300
|
+
return [frame0, frame1, frame2, frame3];
|
|
301
|
+
}
|
|
302
|
+
// ── Legacy exports (unchanged) ───────────────────────────────────────────────
|
|
36
303
|
/** Single-line compact phren for inline use */
|
|
37
304
|
export const PHREN_INLINE = `${PURPLE}◆${RESET}`;
|
|
38
305
|
/** Phren spinner frames for search/sync operations — cycles through in purple */
|
package/mcp/dist/phren-core.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Shared Phren result types, validation tags, and low-level helpers.
|
|
2
2
|
/**
|
|
3
|
-
* Minimal cross-domain starter set for
|
|
3
|
+
* Minimal cross-domain starter set for fragment/conflict detection.
|
|
4
4
|
*
|
|
5
5
|
* Kept intentionally small: only terms that are genuinely universal across
|
|
6
6
|
* disciplines (languages, infra primitives, version control). Framework-specific
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
*/
|
|
10
10
|
export const UNIVERSAL_TECH_TERMS_RE = /\b(Python|Rust|Go|Java|TypeScript|JavaScript|Docker|Kubernetes|AWS|GCP|Azure|SQL|Git)\b/gi;
|
|
11
11
|
/**
|
|
12
|
-
* Additional
|
|
13
|
-
* Each pattern has a named group so callers can identify the
|
|
12
|
+
* Additional fragment patterns beyond CamelCase and acronyms.
|
|
13
|
+
* Each pattern has a named group so callers can identify the fragment type.
|
|
14
14
|
*/
|
|
15
15
|
export const EXTRA_ENTITY_PATTERNS = [
|
|
16
16
|
// Semantic version numbers: v1.2.3, 2.0.0-beta.1
|
|
@@ -91,7 +91,7 @@ export const FINDING_TAGS = FINDING_TYPES;
|
|
|
91
91
|
/** Canonical set of known observation tags — derived from FINDING_TYPES */
|
|
92
92
|
export const KNOWN_OBSERVATION_TAGS = new Set(FINDING_TYPES);
|
|
93
93
|
/** Document types in the FTS index */
|
|
94
|
-
export const DOC_TYPES = ["claude", "findings", "reference", "skills", "summary", "task", "changelog", "canonical", "
|
|
94
|
+
export const DOC_TYPES = ["claude", "findings", "reference", "skills", "summary", "task", "changelog", "canonical", "review-queue", "skill", "other"];
|
|
95
95
|
// ── Cache eviction helper ────────────────────────────────────────────────────
|
|
96
96
|
const CACHE_MAX = 1000;
|
|
97
97
|
const CACHE_EVICT = 100;
|
package/mcp/dist/phren-paths.js
CHANGED
|
@@ -433,7 +433,7 @@ export function computePhrenLiveStateToken(phrenPath) {
|
|
|
433
433
|
for (const projectDir of projectDirs) {
|
|
434
434
|
const project = path.basename(projectDir);
|
|
435
435
|
parts.push(`project:${project}`);
|
|
436
|
-
for (const file of ["CLAUDE.md", "summary.md", "FINDINGS.md", "tasks.md", "
|
|
436
|
+
for (const file of ["CLAUDE.md", "summary.md", "FINDINGS.md", "tasks.md", "review.md", "truths.md", "topic-config.json", "phren.project.yaml"]) {
|
|
437
437
|
pushFileToken(parts, path.join(projectDir, file));
|
|
438
438
|
}
|
|
439
439
|
pushDirTokens(parts, path.join(projectDir, "reference"));
|
|
@@ -209,7 +209,7 @@ function buildProjectCard(dir) {
|
|
|
209
209
|
.split("\n")
|
|
210
210
|
.map((line) => line.trim())
|
|
211
211
|
.find((line) => line && !line.startsWith("#")) || "";
|
|
212
|
-
const docs = ["CLAUDE.md", "FINDINGS.md", "summary.md", "
|
|
212
|
+
const docs = ["CLAUDE.md", "FINDINGS.md", "summary.md", "review.md"]
|
|
213
213
|
.filter((file) => fs.existsSync(path.join(dir, file)));
|
|
214
214
|
const taskFile = TASK_FILE_ALIASES.find((file) => fs.existsSync(path.join(dir, file)));
|
|
215
215
|
if (taskFile)
|