@phren/cli 0.0.4 → 0.0.6
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 +25 -1
- package/mcp/dist/cli-search.js +2 -2
- package/mcp/dist/content-citation.js +65 -2
- package/mcp/dist/content-dedup.js +3 -3
- package/mcp/dist/content-learning.js +32 -9
- package/mcp/dist/data-access.js +60 -50
- package/mcp/dist/entrypoint.js +1 -1
- package/mcp/dist/finding-impact.js +23 -0
- 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/mcp-session.js +61 -2
- package/mcp/dist/memory-ui-data.js +8 -8
- 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 +2 -2
- package/mcp/dist/shared-index.js +31 -18
- package/mcp/dist/shared-retrieval.js +61 -3
- package/mcp/dist/shell-entry.js +46 -3
- package/mcp/dist/status.js +1 -1
- package/mcp/dist/test-global-setup.js +3 -4
- package/mcp/dist/tool-registry.js +1 -1
- package/mcp/dist/utils.js +1 -1
- package/package.json +3 -3
package/mcp/dist/mcp-session.js
CHANGED
|
@@ -169,7 +169,7 @@ function findMostRecentSummaryWithProject(phrenPath) {
|
|
|
169
169
|
if (fs.existsSync(fastPath)) {
|
|
170
170
|
const data = JSON.parse(fs.readFileSync(fastPath, "utf-8"));
|
|
171
171
|
if (data.summary)
|
|
172
|
-
return { summary: data.summary, project: data.project };
|
|
172
|
+
return { summary: data.summary, project: data.project, endedAt: data.endedAt };
|
|
173
173
|
}
|
|
174
174
|
}
|
|
175
175
|
catch (err) {
|
|
@@ -181,7 +181,7 @@ function findMostRecentSummaryWithProject(phrenPath) {
|
|
|
181
181
|
if (results.length === 0)
|
|
182
182
|
return { summary: null };
|
|
183
183
|
const best = results[0]; // already sorted newest-mtime-first
|
|
184
|
-
return { summary: best.data.summary, project: best.data.project };
|
|
184
|
+
return { summary: best.data.summary, project: best.data.project, endedAt: best.data.endedAt };
|
|
185
185
|
}
|
|
186
186
|
/** Resolve session file from an explicit sessionId or a previously-bound connectionId. */
|
|
187
187
|
function resolveSessionFile(phrenPath, sessionId, connectionId) {
|
|
@@ -338,6 +338,45 @@ function hasCompletedTasksInSession(phrenPath, sessionId, project) {
|
|
|
338
338
|
const artifacts = getSessionArtifacts(phrenPath, sessionId, project);
|
|
339
339
|
return artifacts.tasks.some((task) => task.section === "Done" && task.checked);
|
|
340
340
|
}
|
|
341
|
+
/** Compute what changed since the last session ended. */
|
|
342
|
+
export function computeSessionDiff(phrenPath, project, lastSessionEnd) {
|
|
343
|
+
const projectDir = path.join(phrenPath, project);
|
|
344
|
+
const findingsPath = path.join(projectDir, "FINDINGS.md");
|
|
345
|
+
if (!fs.existsSync(findingsPath))
|
|
346
|
+
return { newFindings: 0, superseded: 0, tasksCompleted: 0 };
|
|
347
|
+
const content = fs.readFileSync(findingsPath, "utf8");
|
|
348
|
+
const lines = content.split("\n");
|
|
349
|
+
let currentDate = null;
|
|
350
|
+
let newFindings = 0;
|
|
351
|
+
let superseded = 0;
|
|
352
|
+
const cutoff = lastSessionEnd.slice(0, 10);
|
|
353
|
+
for (const line of lines) {
|
|
354
|
+
const dateMatch = line.match(/^## (\d{4}-\d{2}-\d{2})$/);
|
|
355
|
+
if (dateMatch) {
|
|
356
|
+
currentDate = dateMatch[1];
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (!line.startsWith("- ") || !currentDate)
|
|
360
|
+
continue;
|
|
361
|
+
if (currentDate >= cutoff) {
|
|
362
|
+
newFindings++;
|
|
363
|
+
if (line.includes('status "superseded"'))
|
|
364
|
+
superseded++;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// Count completed tasks since last session
|
|
368
|
+
const tasksPath = path.join(projectDir, "TASKS.md");
|
|
369
|
+
let tasksCompleted = 0;
|
|
370
|
+
if (fs.existsSync(tasksPath)) {
|
|
371
|
+
const taskContent = fs.readFileSync(tasksPath, "utf8");
|
|
372
|
+
const doneMatch = taskContent.match(/## Done[\s\S]*/);
|
|
373
|
+
if (doneMatch) {
|
|
374
|
+
const doneLines = doneMatch[0].split("\n").filter(l => l.startsWith("- "));
|
|
375
|
+
tasksCompleted = doneLines.length;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return { newFindings, superseded, tasksCompleted };
|
|
379
|
+
}
|
|
341
380
|
export function register(server, ctx) {
|
|
342
381
|
const { phrenPath } = ctx;
|
|
343
382
|
server.registerTool("session_start", {
|
|
@@ -364,6 +403,7 @@ export function register(server, ctx) {
|
|
|
364
403
|
const priorEnded = prior ? null : findMostRecentSummaryWithProject(phrenPath);
|
|
365
404
|
const priorSummary = prior?.summary ?? priorEnded?.summary ?? null;
|
|
366
405
|
const priorProject = prior?.project ?? priorEnded?.project;
|
|
406
|
+
const priorEndedAt = prior?.endedAt ?? priorEnded?.endedAt;
|
|
367
407
|
// Create new session with unique ID in its own file
|
|
368
408
|
const sessionId = crypto.randomUUID();
|
|
369
409
|
const next = {
|
|
@@ -447,6 +487,25 @@ export function register(server, ctx) {
|
|
|
447
487
|
debugError("session_start checkpointsRead", err);
|
|
448
488
|
}
|
|
449
489
|
}
|
|
490
|
+
// Compute context diff since last session
|
|
491
|
+
if (activeProject && isValidProjectName(activeProject) && priorEndedAt) {
|
|
492
|
+
try {
|
|
493
|
+
const diff = computeSessionDiff(phrenPath, activeProject, priorEndedAt);
|
|
494
|
+
if (diff.newFindings > 0 || diff.superseded > 0 || diff.tasksCompleted > 0) {
|
|
495
|
+
const diffParts = [];
|
|
496
|
+
if (diff.newFindings > 0)
|
|
497
|
+
diffParts.push(`${diff.newFindings} new finding${diff.newFindings === 1 ? "" : "s"}`);
|
|
498
|
+
if (diff.superseded > 0)
|
|
499
|
+
diffParts.push(`${diff.superseded} superseded`);
|
|
500
|
+
if (diff.tasksCompleted > 0)
|
|
501
|
+
diffParts.push(`${diff.tasksCompleted} task${diff.tasksCompleted === 1 ? "" : "s"} in done`);
|
|
502
|
+
parts.push(`## Since last session\n${diffParts.join(", ")}.`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
catch (err) {
|
|
506
|
+
debugError("session_start contextDiff", err);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
450
509
|
const message = parts.length > 0
|
|
451
510
|
? `Session started (${sessionId.slice(0, 8)}).\n\n${parts.join("\n\n")}`
|
|
452
511
|
: `Session started (${sessionId.slice(0, 8)}). No prior context found.`;
|
|
@@ -8,7 +8,7 @@ import { readCustomHooks } from "./hooks.js";
|
|
|
8
8
|
import { hookConfigPaths, hookConfigRoots } from "./provider-adapters.js";
|
|
9
9
|
import { getAllSkills } from "./skill-registry.js";
|
|
10
10
|
import { resolveTaskFilePath, readTasks, TASKS_FILENAME } from "./data-tasks.js";
|
|
11
|
-
import { buildIndex, queryRows } from "./shared-index.js";
|
|
11
|
+
import { buildIndex, queryDocBySourceKey, queryRows } from "./shared-index.js";
|
|
12
12
|
import { readProjectTopics, classifyTopicForText } from "./project-topics.js";
|
|
13
13
|
import { entryScoreKey } from "./governance-scores.js";
|
|
14
14
|
function extractGithubUrl(content) {
|
|
@@ -264,17 +264,16 @@ 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);
|
|
271
271
|
const rows = queryRows(db, `SELECT e.id, e.name, e.type, COUNT(DISTINCT el.source_doc) as ref_count
|
|
272
272
|
FROM entities e JOIN entity_links el ON el.target_id = e.id WHERE e.type != 'document'
|
|
273
273
|
GROUP BY e.id, e.name, e.type ORDER BY ref_count DESC LIMIT 500`, []);
|
|
274
|
-
const refRows = queryRows(db, `SELECT e.id, el.source_doc
|
|
274
|
+
const refRows = queryRows(db, `SELECT e.id, el.source_doc
|
|
275
275
|
FROM entities e
|
|
276
276
|
JOIN entity_links el ON el.target_id = e.id
|
|
277
|
-
LEFT JOIN docs d ON d.source_key = el.source_doc
|
|
278
277
|
WHERE e.type != 'document'`, []);
|
|
279
278
|
const refsByEntity = new Map();
|
|
280
279
|
const seenEntityDoc = new Set();
|
|
@@ -291,8 +290,9 @@ export async function buildGraph(phrenPath, profile, focusProject) {
|
|
|
291
290
|
continue;
|
|
292
291
|
seenEntityDoc.add(entityDocKey);
|
|
293
292
|
const project = projectFromSourceDoc(doc);
|
|
294
|
-
const
|
|
295
|
-
const
|
|
293
|
+
const docRow = queryDocBySourceKey(db, phrenPath, doc);
|
|
294
|
+
const content = docRow?.content ?? "";
|
|
295
|
+
const filename = docRow?.filename ?? "";
|
|
296
296
|
const scoreKey = project && filename && content ? entryScoreKey(project, filename, content) : undefined;
|
|
297
297
|
const refs = refsByEntity.get(entityId) ?? [];
|
|
298
298
|
refs.push({ doc, project, scoreKey });
|
|
@@ -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)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// query-correlation.ts — Lightweight query-to-finding correlation tracker.
|
|
2
|
+
// Tracks which queries led to which documents being selected (and later rated "helpful"),
|
|
3
|
+
// then uses that data to pre-warm results for recurring query patterns.
|
|
4
|
+
//
|
|
5
|
+
// Gated behind PHREN_FEATURE_QUERY_CORRELATION env var (disabled by default).
|
|
6
|
+
// Storage: JSONL append to .runtime/query-correlations.jsonl, last-500 window.
|
|
7
|
+
import * as fs from "fs";
|
|
8
|
+
import { runtimeFile, debugLog } from "./shared.js";
|
|
9
|
+
import { isFeatureEnabled, errorMessage } from "./utils.js";
|
|
10
|
+
const CORRELATION_FILENAME = "query-correlations.jsonl";
|
|
11
|
+
const RECENT_WINDOW = 500;
|
|
12
|
+
const MIN_TOKEN_OVERLAP = 2;
|
|
13
|
+
const MIN_TOKEN_LENGTH = 3;
|
|
14
|
+
/**
|
|
15
|
+
* Check if query correlation feature is enabled via env var.
|
|
16
|
+
*/
|
|
17
|
+
export function isQueryCorrelationEnabled() {
|
|
18
|
+
return isFeatureEnabled("PHREN_FEATURE_QUERY_CORRELATION", false);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Log query-to-finding correlations after snippet selection.
|
|
22
|
+
* Called from handleHookPrompt after selectSnippets.
|
|
23
|
+
*/
|
|
24
|
+
export function logCorrelations(phrenPath, keywords, selected, sessionId) {
|
|
25
|
+
if (!isQueryCorrelationEnabled())
|
|
26
|
+
return;
|
|
27
|
+
if (!selected.length || !keywords.trim())
|
|
28
|
+
return;
|
|
29
|
+
try {
|
|
30
|
+
const correlationFile = runtimeFile(phrenPath, CORRELATION_FILENAME);
|
|
31
|
+
const lines = [];
|
|
32
|
+
for (const sel of selected) {
|
|
33
|
+
const entry = {
|
|
34
|
+
timestamp: new Date().toISOString(),
|
|
35
|
+
keywords: keywords.slice(0, 200),
|
|
36
|
+
project: sel.doc.project,
|
|
37
|
+
filename: sel.doc.filename,
|
|
38
|
+
sessionId,
|
|
39
|
+
};
|
|
40
|
+
lines.push(JSON.stringify(entry));
|
|
41
|
+
}
|
|
42
|
+
fs.appendFileSync(correlationFile, lines.join("\n") + "\n");
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
debugLog(`query-correlation log failed: ${errorMessage(err)}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Mark correlations from a session as "helpful" when positive feedback is received.
|
|
50
|
+
* This retroactively stamps entries so that future correlation lookups weight them higher.
|
|
51
|
+
*/
|
|
52
|
+
export function markCorrelationsHelpful(phrenPath, sessionId, docKey) {
|
|
53
|
+
if (!isQueryCorrelationEnabled())
|
|
54
|
+
return;
|
|
55
|
+
try {
|
|
56
|
+
const correlationFile = runtimeFile(phrenPath, CORRELATION_FILENAME);
|
|
57
|
+
if (!fs.existsSync(correlationFile))
|
|
58
|
+
return;
|
|
59
|
+
const raw = fs.readFileSync(correlationFile, "utf8");
|
|
60
|
+
const lines = raw.split("\n").filter(Boolean);
|
|
61
|
+
let modified = false;
|
|
62
|
+
const updated = lines.map((line) => {
|
|
63
|
+
try {
|
|
64
|
+
const entry = JSON.parse(line);
|
|
65
|
+
if (entry.sessionId === sessionId &&
|
|
66
|
+
`${entry.project}/${entry.filename}` === docKey &&
|
|
67
|
+
!entry.helpful) {
|
|
68
|
+
entry.helpful = true;
|
|
69
|
+
modified = true;
|
|
70
|
+
return JSON.stringify(entry);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// keep original line
|
|
75
|
+
}
|
|
76
|
+
return line;
|
|
77
|
+
});
|
|
78
|
+
if (modified) {
|
|
79
|
+
fs.writeFileSync(correlationFile, updated.join("\n") + "\n");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
debugLog(`query-correlation mark-helpful failed: ${errorMessage(err)}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Tokenize a keyword string for overlap comparison.
|
|
88
|
+
*/
|
|
89
|
+
function tokenize(text) {
|
|
90
|
+
return new Set(text
|
|
91
|
+
.toLowerCase()
|
|
92
|
+
.split(/\s+/)
|
|
93
|
+
.filter((w) => w.length >= MIN_TOKEN_LENGTH));
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Find documents that historically correlate with the given query keywords.
|
|
97
|
+
* Returns doc keys (project/filename) sorted by correlation strength.
|
|
98
|
+
*
|
|
99
|
+
* Only looks at the last RECENT_WINDOW entries for performance.
|
|
100
|
+
* Entries marked "helpful" get a 2x weight boost.
|
|
101
|
+
*/
|
|
102
|
+
export function getCorrelatedDocs(phrenPath, keywords, limit = 3) {
|
|
103
|
+
if (!isQueryCorrelationEnabled())
|
|
104
|
+
return [];
|
|
105
|
+
try {
|
|
106
|
+
const correlationFile = runtimeFile(phrenPath, CORRELATION_FILENAME);
|
|
107
|
+
if (!fs.existsSync(correlationFile))
|
|
108
|
+
return [];
|
|
109
|
+
const raw = fs.readFileSync(correlationFile, "utf8");
|
|
110
|
+
const lines = raw.split("\n").filter(Boolean);
|
|
111
|
+
// Only look at last RECENT_WINDOW entries to keep it fast
|
|
112
|
+
const recent = lines.slice(-RECENT_WINDOW);
|
|
113
|
+
const queryTokens = tokenize(keywords);
|
|
114
|
+
if (queryTokens.size === 0)
|
|
115
|
+
return [];
|
|
116
|
+
const docScores = new Map();
|
|
117
|
+
for (const line of recent) {
|
|
118
|
+
try {
|
|
119
|
+
const entry = JSON.parse(line);
|
|
120
|
+
const entryTokens = tokenize(entry.keywords);
|
|
121
|
+
// Calculate overlap between current query and past query
|
|
122
|
+
let overlap = 0;
|
|
123
|
+
for (const t of queryTokens) {
|
|
124
|
+
if (entryTokens.has(t))
|
|
125
|
+
overlap++;
|
|
126
|
+
}
|
|
127
|
+
if (overlap >= MIN_TOKEN_OVERLAP) {
|
|
128
|
+
const key = `${entry.project}/${entry.filename}`;
|
|
129
|
+
// Helpful entries get a 2x weight boost
|
|
130
|
+
const weight = entry.helpful ? overlap * 2 : overlap;
|
|
131
|
+
docScores.set(key, (docScores.get(key) ?? 0) + weight);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// skip malformed lines
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return [...docScores.entries()]
|
|
139
|
+
.sort((a, b) => b[1] - a[1])
|
|
140
|
+
.slice(0, limit)
|
|
141
|
+
.map(([key]) => key);
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
debugLog(`query-correlation lookup failed: ${errorMessage(err)}`);
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -3,6 +3,6 @@ export { checkConsolidationNeeded, validateFindingsFormat, stripTaskDoneSection,
|
|
|
3
3
|
export { filterTrustedFindings, filterTrustedFindingsDetailed, } from "./content-citation.js";
|
|
4
4
|
export { scanForSecrets, resolveCoref, isDuplicateFinding, detectConflicts, extractDynamicEntities, checkSemanticDedup, checkSemanticConflicts, } from "./content-dedup.js";
|
|
5
5
|
export { countActiveFindings, autoArchiveToReference, } from "./content-archive.js";
|
|
6
|
-
export { upsertCanonical, addFindingToFile, addFindingsToFile, } from "./content-learning.js";
|
|
7
|
-
export { FINDING_LIFECYCLE_STATUSES, parseFindingLifecycle, buildLifecycleComments, isInactiveFindingLine, } from "./finding-lifecycle.js";
|
|
6
|
+
export { upsertCanonical, addFindingToFile, addFindingsToFile, autoDetectFindingType, } from "./content-learning.js";
|
|
7
|
+
export { FINDING_LIFECYCLE_STATUSES, FINDING_TYPE_DECAY, extractFindingType, parseFindingLifecycle, buildLifecycleComments, isInactiveFindingLine, } from "./finding-lifecycle.js";
|
|
8
8
|
export { METADATA_REGEX, parseStatus, parseStatusField, parseSupersession, parseSupersedesRef, parseContradiction, parseAllContradictions, parseFindingId, parseCreatedDate, isCitationLine, isArchiveStart, isArchiveEnd, stripLifecycleMetadata, stripRelationMetadata, stripAllMetadata, stripComments, addMetadata, } from "./content-metadata.js";
|