@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
735 lines (734 loc) • 37.6 kB
JavaScript
import { nonInteractiveGitEnv } from "../utils-helpers.js";
import { moduleEnabled } from "../modules/runtime.js";
import { mcpResponse, resolveStoreForProject } from "./types.js";
import { z } from "zod";
import * as fs from "fs";
import * as path from "path";
import * as crypto from "crypto";
import { execFileSync } from "child_process";
import { debugLog, getProjectDirs, isMemoryScopeVisible, normalizeMemoryScope } from "../shared.js";
import { withFileLock } from "../shared/governance.js";
import { isValidProjectName, errorMessage } from "../utils.js";
import { runCustomHooks } from "../hooks.js";
import { readExtractedFacts } from "./extract-facts.js";
import { resolveFindingSessionId } from "../finding/context.js";
import { readTasks } from "../data/tasks.js";
import { readFindings, FINDINGS_FILENAME } from "../data/access.js";
import { getActiveTaskForSession } from "../task/lifecycle.js";
import { listTaskCheckpoints, writeTaskCheckpoint } from "../session/checkpoints.js";
import { findMostRecentSummaryWithProject as findMostRecentSummaryRecord, writeLastSummary, } from "../session/artifacts.js";
import { markImpactEntriesCompletedForSession } from "../finding/impact.js";
import { debugError, scanSessionFiles, runtimeSessionsDir, sessionFileForId, readSessionStateFile, writeSessionStateFile, } from "../session/utils.js";
import { getRuntimeHealth } from "../governance/policy.js";
import { getProjectSourcePath, readProjectConfig } from "../project-config.js";
const STALE_SESSION_MS = 24 * 60 * 60 * 1000; // 24 hours
function collectGitStatusSnapshot(cwd) {
try {
const output = execFileSync("git", ["status", "--short"], { env: nonInteractiveGitEnv(), cwd, encoding: "utf8" }).trim();
if (!output)
return { gitStatus: "", editedFiles: [] };
const lines = output.split("\n").map((line) => line.trim()).filter(Boolean);
const editedFiles = lines.map((line) => line.replace(/^[ MADRCU?!]{1,2}\s+/, "").trim()).filter(Boolean);
return { gitStatus: output, editedFiles };
}
catch (err) {
debugLog(`session checkpoint git status failed: ${errorMessage(err)}`);
return { gitStatus: "", editedFiles: [] };
}
}
function extractFailingTests(summary, fallbackContext) {
const text = [summary || "", fallbackContext || ""].join("\n").trim();
if (!text)
return [];
const tests = new Set();
for (const line of text.split("\n")) {
const trimmed = line.trim();
const failLine = trimmed.match(/^FAIL(?:ED)?\s+(.+)$/i);
if (failLine?.[1])
tests.add(failLine[1].trim());
const vitestLine = trimmed.match(/^(?:\u00d7|\u2716)\s+(.+)$/);
if (vitestLine?.[1])
tests.add(vitestLine[1].trim());
const named = trimmed.match(/(?:failing tests?|failed tests?)\s*:\s*(.+)$/i);
if (named?.[1]) {
for (const part of named[1].split(/[;,]/)) {
const candidate = part.trim();
if (candidate)
tests.add(candidate);
}
}
const junitStyle = trimmed.match(/test(?:\s+case)?\s+["'`](.+?)["'`]\s+failed/i);
if (junitStyle?.[1])
tests.add(junitStyle[1].trim());
}
return [...tests];
}
function extractResumptionHint(summary, fallbackNextStep, fallbackLastAttempt) {
const normalizedSummary = (summary || "").trim();
if (!normalizedSummary) {
return {
lastAttempt: fallbackLastAttempt.trim() || "No prior attempt captured",
nextStep: fallbackNextStep.trim() || "Resume implementation",
};
}
const lines = normalizedSummary
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
const nextPattern = /^(?:next(?:\s+step)?|next up|todo|to do|follow-up|follow up|remaining)\s*[:\-]\s*(.+)$/i;
let nextStep = null;
const lastAttemptLines = [];
for (const line of lines) {
const nextMatch = line.match(nextPattern);
if (nextMatch?.[1]) {
if (!nextStep)
nextStep = nextMatch[1].trim();
continue;
}
lastAttemptLines.push(line);
}
const lastAttempt = (lastAttemptLines.join(" ").trim() || normalizedSummary).trim();
return {
lastAttempt: lastAttempt || fallbackLastAttempt.trim() || "No prior attempt captured",
nextStep: nextStep || fallbackNextStep.trim() || "Resume implementation",
};
}
/** Per-connection session map keyed by arbitrary connection ID (if provided). */
const MAX_SESSION_MAP_ENTRIES = 200;
const _sessionMap = new Map();
/** Find the most recent *active* (not ended) session file by mtime. */
function findMostRecentSession(phrenPath) {
const dir = runtimeSessionsDir(phrenPath);
const results = scanSessionFiles(dir, readSessionStateFile, (state) => !state.endedAt, { errorScope: "findMostRecentSession" });
if (results.length === 0)
return null;
const best = results[0]; // already sorted newest-mtime-first
return { file: best.fullPath, state: best.data };
}
export function resolveActiveSessionScope(phrenPath, project) {
// PHREN_SCOPE env var takes priority over session-derived scope
const envScope = normalizeMemoryScope(process.env.PHREN_SCOPE);
if (envScope)
return envScope;
const dir = runtimeSessionsDir(phrenPath);
const results = scanSessionFiles(dir, readSessionStateFile, (state) => {
if (state.endedAt)
return false;
if (project && state.project && state.project !== project)
return false;
return true;
}, { includeMtime: false, errorScope: "resolveActiveSessionScope" });
let bestState = null;
let bestStartedAt = 0;
for (const { data: state } of results) {
const startedAt = Date.parse(state.startedAt || "");
const candidate = Number.isNaN(startedAt) ? 0 : startedAt;
if (!bestState || candidate >= bestStartedAt) {
bestState = state;
bestStartedAt = candidate;
}
}
return normalizeMemoryScope(bestState?.agentScope);
}
/** Find the most recent session with a summary (including ended sessions).
* @internal Exported for tests. */
export function findMostRecentSummary(phrenPath) {
return findMostRecentSummaryWithProject(phrenPath).summary;
}
/** Find the most recent session with a summary and project context. */
function findMostRecentSummaryWithProject(phrenPath) {
const result = findMostRecentSummaryRecord(phrenPath);
return {
summary: result.summary,
project: result.project,
endedAt: result.endedAt,
};
}
/** Resolve session file from an explicit sessionId or a previously-bound connectionId. */
function resolveSessionFile(phrenPath, sessionId, connectionId) {
const effectiveId = sessionId ?? (connectionId ? _sessionMap.get(connectionId) : undefined);
if (effectiveId) {
const file = sessionFileForId(phrenPath, effectiveId);
const state = readSessionStateFile(file);
if (!state)
return null;
// Always reject ended sessions — prevents double session_end and stale session_context.
if (state.endedAt)
return null;
return { file, state };
}
return null;
}
/** Remove session files older than 24 hours. */
function cleanupStaleSessions(phrenPath) {
const dir = runtimeSessionsDir(phrenPath);
// Scan all session files (keep all, we'll filter and unlink manually)
const results = scanSessionFiles(dir, (filePath) => readSessionStateFile(filePath) ?? null, () => true, // accept everything — we decide inside the loop
{ includeMtime: false, errorScope: "cleanupStaleSessions" });
let cleaned = 0;
for (const { fullPath, data: state } of results) {
try {
// Only clean up sessions that have ended (have endedAt). Active sessions
// (no endedAt) should never be removed regardless of age.
if (state && !state.endedAt)
continue;
// For ended sessions, age out by end time rather than start time so
// long-running sessions do not disappear immediately after they finish.
const expirationAnchor = state?.endedAt || state?.startedAt;
const ageMs = expirationAnchor
? Date.now() - new Date(expirationAnchor).getTime()
: Date.now() - fs.statSync(fullPath).mtimeMs;
if (ageMs > STALE_SESSION_MS) {
fs.unlinkSync(fullPath);
cleaned++;
}
}
catch (err) {
debugError("cleanupStaleSessions entry", err);
}
}
return cleaned;
}
/** Increment the findingsAdded counter for a session. Falls back to the most relevant active session for the project. */
export function incrementSessionFindings(phrenPath, count = 1, sessionId, project) {
incrementSessionCounter(phrenPath, "findingsAdded", count, sessionId, project);
}
export function incrementSessionTasksCompleted(phrenPath, count = 1, sessionId, project) {
incrementSessionCounter(phrenPath, "tasksCompleted", count, sessionId, project);
}
function incrementSessionCounter(phrenPath, field, count = 1, sessionId, project) {
try {
const effectiveSessionId = project
? resolveFindingSessionId(phrenPath, project, sessionId)
: sessionId;
if (!effectiveSessionId) {
debugLog(`${field} increment called without a resolvable sessionId — skipping`);
return;
}
const resolved = resolveSessionFile(phrenPath, effectiveSessionId);
if (!resolved)
return;
const { file } = resolved;
withFileLock(file, () => {
const current = readSessionStateFile(file);
if (!current)
return;
const nextValue = Number.isFinite(current[field]) ? current[field] + count : count;
writeSessionStateFile(file, {
...current,
findingsAdded: Number.isFinite(current.findingsAdded) ? current.findingsAdded : 0,
tasksCompleted: Number.isFinite(current.tasksCompleted) ? current.tasksCompleted : 0,
[field]: nextValue,
});
});
}
catch (err) {
debugError(`incrementSessionCounter(${field})`, err);
}
}
/** List all sessions (both active and ended) from the sessions directory, sorted newest first. */
export function listAllSessions(phrenPath, limit = 50) {
const dir = runtimeSessionsDir(phrenPath);
// scanSessionFiles returns results sorted by mtime (newest first)
const results = scanSessionFiles(dir, readSessionStateFile, () => true, { errorScope: "listAllSessions" });
const entries = [];
for (const { data: state } of results) {
if (entries.length >= limit)
break;
const durationMs = state.endedAt
? new Date(state.endedAt).getTime() - new Date(state.startedAt).getTime()
: Date.now() - new Date(state.startedAt).getTime();
entries.push({
sessionId: state.sessionId,
project: state.project,
agentScope: state.agentScope,
startedAt: state.startedAt,
endedAt: state.endedAt,
durationMins: Math.round(durationMs / 60000),
summary: state.summary,
findingsAdded: Number.isFinite(state.findingsAdded) ? state.findingsAdded : 0,
tasksCompleted: Number.isFinite(state.tasksCompleted) ? state.tasksCompleted : 0,
status: state.endedAt ? "ended" : "active",
});
}
// Already sorted by mtime, but re-sort by startedAt for accuracy
entries.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
return entries;
}
export async function getSessionArtifacts(phrenPath, sessionId, project) {
const findings = [];
const tasks = [];
const shortId = sessionId.slice(0, 8);
try {
// Primary store projects
const projectDirs = getProjectDirs(phrenPath);
const targetProjects = project ? [project] : projectDirs.map((d) => path.basename(d));
const seen = new Set();
const readProjectArtifacts = (storePath, proj) => {
if (seen.has(proj))
return;
seen.add(proj);
// Tasks with matching sessionId
const tasksResult = readTasks(storePath, proj);
if (tasksResult.ok) {
for (const section of ["Active", "Queue", "Done"]) {
for (const t of tasksResult.data.items[section]) {
if (t.sessionId && (t.sessionId === sessionId || t.sessionId.startsWith(shortId))) {
tasks.push({
project: proj,
id: t.id,
text: t.line,
section,
checked: t.checked,
});
}
}
}
}
};
for (const proj of targetProjects) {
readProjectArtifacts(phrenPath, proj);
}
// Team store projects
try {
const { getNonPrimaryStores, getStoreProjectDirs } = await import("../store-registry.js");
for (const store of getNonPrimaryStores(phrenPath)) {
if (!fs.existsSync(store.path))
continue;
const storeDirs = getStoreProjectDirs(store).map((d) => path.basename(d)).filter((p) => p !== "global");
const storeTargetProjects = project ? (storeDirs.includes(project) ? [project] : []) : storeDirs;
for (const proj of storeTargetProjects) {
readProjectArtifacts(store.path, proj);
}
}
}
catch { /* store-registry not available */ }
}
catch (err) {
debugLog(`getSessionArtifacts error: ${errorMessage(err)}`);
}
return { findings, tasks };
}
async function hasCompletedTasksInSession(phrenPath, sessionId, project) {
const artifacts = await getSessionArtifacts(phrenPath, sessionId, project);
return artifacts.tasks.some((task) => task.section === "Done" && task.checked);
}
/** Compute what changed since the last session ended. */
function computeSessionDiff(phrenPath, project, lastSessionEnd) {
const projectDir = path.join(phrenPath, project);
const findingsPath = path.join(projectDir, FINDINGS_FILENAME);
if (!fs.existsSync(findingsPath))
return { newFindings: 0, superseded: 0, tasksCompleted: 0 };
const content = fs.readFileSync(findingsPath, "utf8");
const lines = content.split("\n");
let currentDate = null;
let newFindings = 0;
let superseded = 0;
const cutoff = lastSessionEnd.slice(0, 10);
for (const line of lines) {
const dateMatch = line.match(/^## (\d{4}-\d{2}-\d{2})$/);
if (dateMatch) {
currentDate = dateMatch[1];
continue;
}
if (!line.startsWith("- ") || !currentDate)
continue;
if (currentDate >= cutoff) {
newFindings++;
if (line.includes('status "superseded"'))
superseded++;
}
}
// Count tasks completed since last session by checking Done items with recent activity dates
let tasksCompleted = 0;
const tasksResult = readTasks(phrenPath, project);
if (tasksResult.ok) {
for (const item of tasksResult.data.items.Done) {
// lastActivity is updated on completion; fall back to createdAt
const itemDate = item.lastActivity?.slice(0, 10) ?? item.createdAt?.slice(0, 10);
if (itemDate && itemDate >= cutoff) {
tasksCompleted++;
}
}
}
return { newFindings, superseded, tasksCompleted };
}
export function register(server, ctx) {
const { phrenPath } = ctx;
const tasksEnabled = moduleEnabled(phrenPath, "tasks", ctx.profile);
server.registerTool("session_start", {
title: "◆ phren · session start",
description: "Mark the start of a new session and retrieve context from prior sessions. Call this at the start of a conversation when not using hooks. Returns prior session summary and recent project findings. The returned sessionId should be passed to session_end and session_context to avoid cross-client collisions.",
inputSchema: z.object({
project: z.string().optional().describe("Project to load context for."),
agentScope: z.string().optional().describe("Optional memory scope for this agent session (for example 'researcher' or 'builder')."),
connectionId: z.string().optional().describe("Optional stable identifier for this client connection. When provided, session_end/session_context can resolve the session without an explicit sessionId."),
}),
}, async ({ project, agentScope, connectionId }) => {
// Clean up stale sessions (>24h)
cleanupStaleSessions(phrenPath);
const normalizedAgentScope = agentScope === undefined ? undefined : normalizeMemoryScope(agentScope);
if (agentScope !== undefined && !normalizedAgentScope) {
return mcpResponse({ ok: false, error: `Invalid agentScope: "${agentScope}". Use lowercase letters/numbers with '-' or '_' (max 64 chars).` });
}
// Find most recent prior session for context.
// When no explicit project is provided, prefer the last ENDED session's
// project (completed context) over an active session from a different client.
const priorEnded = findMostRecentSummaryWithProject(phrenPath);
const priorResult = findMostRecentSession(phrenPath);
const prior = priorResult?.state ?? null;
const priorSummary = priorEnded?.summary ?? prior?.summary ?? null;
const priorProject = priorEnded?.project ?? prior?.project;
const priorEndedAt = priorEnded?.endedAt ?? prior?.endedAt;
// Create new session with unique ID in its own file
const sessionId = crypto.randomUUID();
const next = {
sessionId,
project: project ?? priorProject,
agentScope: normalizedAgentScope,
startedAt: new Date().toISOString(),
findingsAdded: 0,
tasksCompleted: 0,
};
const newFile = sessionFileForId(phrenPath, sessionId);
writeSessionStateFile(newFile, next);
if (connectionId) {
_sessionMap.set(connectionId, sessionId);
if (_sessionMap.size > MAX_SESSION_MAP_ENTRIES) {
const oldest = _sessionMap.keys().next().value;
if (oldest !== undefined)
_sessionMap.delete(oldest);
}
}
const parts = [];
if (priorSummary) {
parts.push(`## Last session\n${priorSummary}`);
}
const activeProject = project ?? priorProject;
const activeScope = normalizedAgentScope;
if (activeProject && isValidProjectName(activeProject)) {
const projectStorePath = (() => {
try {
return resolveStoreForProject(ctx, activeProject, "read").phrenPath;
}
catch {
return phrenPath;
}
})();
try {
const findings = readFindings(projectStorePath, activeProject);
if (findings.ok) {
const bullets = findings.data
.filter((entry) => isMemoryScopeVisible(normalizeMemoryScope(entry.scope), activeScope))
.slice(-5)
.map((entry) => `- ${entry.text}`);
if (bullets.length > 0) {
parts.push(`## Recent findings (${activeProject})\n${bullets.join("\n")}`);
}
}
}
catch (err) {
debugError("session_start findingsRead", err);
}
try {
const tasks = readTasks(projectStorePath, activeProject);
if (tasks.ok) {
const activeItems = tasks.data.items.Active
.filter((entry) => isMemoryScopeVisible(normalizeMemoryScope(entry.scope), activeScope))
.slice(0, 5)
.map((entry) => `- [ ] ${entry.line}`);
if (activeItems.length > 0) {
parts.push(`## Active task (${activeProject})\n${activeItems.join("\n")}`);
}
}
}
catch (err) {
debugError("session_start taskRead", err);
}
// Surface extracted preferences/facts for this project
try {
const facts = readExtractedFacts(projectStorePath, activeProject).slice(-10);
if (facts.length > 0) {
parts.push(`## Preferences (${activeProject})\n${facts.map(f => `- ${f.fact}`).join("\n")}`);
}
}
catch (err) {
debugError("session_start factsRead", err);
}
try {
const checkpoints = listTaskCheckpoints(projectStorePath, activeProject).slice(0, 3);
if (checkpoints.length > 0) {
const lines = [];
for (const checkpoint of checkpoints) {
lines.push(`- ${(checkpoint.taskText || checkpoint.taskLine).trim()} (task: ${checkpoint.taskId})`);
lines.push(` Last attempt: ${checkpoint.resumptionHint.lastAttempt}`);
lines.push(` Next step: ${checkpoint.resumptionHint.nextStep}`);
if (checkpoint.editedFiles.length > 0) {
lines.push(` Edited files: ${checkpoint.editedFiles.slice(0, 5).join(", ")}${checkpoint.editedFiles.length > 5 ? ", ..." : ""}`);
}
if (checkpoint.failingTests.length > 0) {
lines.push(` Failing tests: ${checkpoint.failingTests.slice(0, 3).join(", ")}${checkpoint.failingTests.length > 3 ? ", ..." : ""}`);
}
}
parts.push(`## Continue where you left off? (${activeProject})\n${lines.join("\n")}`);
}
}
catch (err) {
debugError("session_start checkpointsRead", err);
}
}
// Compute context diff since last session
if (activeProject && isValidProjectName(activeProject) && priorEndedAt) {
try {
const diff = computeSessionDiff(phrenPath, activeProject, priorEndedAt);
if (diff.newFindings > 0 || diff.superseded > 0 || diff.tasksCompleted > 0) {
const diffParts = [];
if (diff.newFindings > 0)
diffParts.push(`${diff.newFindings} new finding${diff.newFindings === 1 ? "" : "s"}`);
if (diff.superseded > 0)
diffParts.push(`${diff.superseded} superseded`);
if (diff.tasksCompleted > 0)
diffParts.push(`${diff.tasksCompleted} task${diff.tasksCompleted === 1 ? "" : "s"} in done`);
parts.push(`## Since last session\n${diffParts.join(", ")}.`);
}
}
catch (err) {
debugError("session_start contextDiff", err);
}
}
// ── Surface sync/health warnings ────────────────────────────────────
const sessionWarnings = [];
try {
const health = getRuntimeHealth(phrenPath);
if (health.lastAutoSave?.status === "error") {
sessionWarnings.push(`Last auto-save failed: ${health.lastAutoSave.detail ?? "unknown"}`);
}
if (health.lastSync?.lastPushStatus === "error") {
sessionWarnings.push(`Last push failed: ${health.lastSync.lastPushDetail ?? "unknown"}`);
}
const unsynced = health.lastSync?.unsyncedCommits;
if (typeof unsynced === "number" && unsynced > 0) {
sessionWarnings.push(`${unsynced} unsynced commit${unsynced === 1 ? "" : "s"} — run 'phren doctor' or check git remote`);
}
}
catch (err) {
debugError("session_start runtimeHealth", err);
}
if (sessionWarnings.length > 0) {
parts.push(`## Sync warnings\n${sessionWarnings.map(w => `- ${w}`).join("\n")}`);
}
const message = parts.length > 0
? `Session started (${sessionId.slice(0, 8)}).\n\n${parts.join("\n\n")}`
: `Session started (${sessionId.slice(0, 8)}). No prior context found.`;
return mcpResponse({ ok: true, message, data: { sessionId, project: activeProject, agentScope: activeScope, warnings: sessionWarnings.length > 0 ? sessionWarnings : undefined } });
});
server.registerTool("session_end", {
title: "◆ phren · session end",
description: "Mark the end of a session and save a summary for the next session to pick up. Call this before ending a conversation to preserve context. Pass the sessionId returned by session_start, or a stable connectionId bound at session_start.",
inputSchema: z.object({
summary: z.string().optional().describe("What was accomplished this session. Shown at the start of the next session."),
sessionId: z.string().optional().describe("Session ID to end (returned by session_start)."),
connectionId: z.string().optional().describe("Connection ID passed to session_start. Used to resolve the session when sessionId is not provided."),
}),
}, async ({ summary, sessionId, connectionId }) => {
if (!sessionId && !connectionId) {
return mcpResponse({ ok: false, error: "Pass sessionId or connectionId. Implicit process-global session fallback has been removed." });
}
const resolved = resolveSessionFile(phrenPath, sessionId, connectionId);
if (!resolved)
return mcpResponse({ ok: false, error: "No active session. Call session_start first." });
const { file, state } = resolved;
const endedState = withFileLock(file, () => {
const current = readSessionStateFile(file);
if (!current)
return null;
const next = {
...current,
endedAt: new Date().toISOString(),
summary: summary ?? current.summary,
};
writeSessionStateFile(file, next);
return next;
});
if (!endedState)
return mcpResponse({ ok: false, error: "No active session. Call session_start first." });
if (connectionId) {
_sessionMap.delete(connectionId);
}
// Also remove from _sessionMap by value (in case connectionId wasn't provided but was used at start)
for (const [key, val] of _sessionMap) {
if (val === state.sessionId)
_sessionMap.delete(key);
}
// Write fast-path summary file for next session_start — also persist project so
// session_start can restore project context even after a normal session_end.
const effectiveSummary = endedState.summary;
if (effectiveSummary) {
writeLastSummary(phrenPath, { summary: effectiveSummary, sessionId: state.sessionId, project: endedState.project });
}
if (endedState.project && isValidProjectName(endedState.project)) {
const projectStorePath = (() => {
if (!endedState.project)
return phrenPath;
try {
return resolveStoreForProject(ctx, endedState.project, "read").phrenPath;
}
catch {
return phrenPath;
}
})();
try {
const trackedActiveTask = moduleEnabled(projectStorePath, "tasks", ctx.profile) ? getActiveTaskForSession(projectStorePath, state.sessionId, endedState.project) : null;
const activeTask = trackedActiveTask ?? (() => {
const tasks = readTasks(projectStorePath, endedState.project);
if (!tasks.ok)
return null;
return tasks.data.items.Active[0] ?? null;
})();
if (activeTask) {
const taskId = activeTask.stableId || activeTask.id;
const projectConfig = readProjectConfig(projectStorePath, endedState.project);
const snapshotRoot = getProjectSourcePath(projectStorePath, endedState.project, projectConfig) ||
path.join(projectStorePath, endedState.project);
const { gitStatus, editedFiles } = moduleEnabled(projectStorePath, "git", ctx.profile) ? collectGitStatusSnapshot(snapshotRoot) : { gitStatus: "", editedFiles: [] };
const resumptionHint = extractResumptionHint(effectiveSummary, activeTask.line, activeTask.context || "No prior attempt captured");
writeTaskCheckpoint(phrenPath, {
project: endedState.project,
taskId,
taskText: activeTask.line,
taskLine: activeTask.line,
sessionId: state.sessionId,
createdAt: new Date().toISOString(),
resumptionHint,
gitStatus,
editedFiles,
failingTests: extractFailingTests(effectiveSummary, activeTask.context),
});
}
}
catch (err) {
debugLog(`session checkpoint write failed: ${errorMessage(err)}`);
}
}
try {
const tasksCompleted = Number.isFinite(endedState.tasksCompleted) ? endedState.tasksCompleted : 0;
if (tasksCompleted > 0 || await hasCompletedTasksInSession(phrenPath, state.sessionId, endedState.project)) {
markImpactEntriesCompletedForSession(phrenPath, state.sessionId, endedState.project);
}
}
catch (err) {
debugLog(`impact scoring update failed: ${errorMessage(err)}`);
}
const durationMs = new Date(endedState.endedAt).getTime() - new Date(state.startedAt).getTime();
const durationMins = Math.round(durationMs / 60000);
runCustomHooks(phrenPath, "post-session-end", {
PHREN_SESSION_ID: state.sessionId,
PHREN_DURATION_MINS: String(durationMins),
PHREN_FINDINGS_ADDED: String(endedState.findingsAdded),
...(tasksEnabled ? { PHREN_TASKS_COMPLETED: String(Number.isFinite(endedState.tasksCompleted) ? endedState.tasksCompleted : 0) } : {}),
...(endedState.project ? { PHREN_PROJECT: endedState.project } : {}),
});
return mcpResponse({
ok: true,
message: `Session ended. Duration: ~${durationMins} min. ${endedState.findingsAdded} finding(s) added${tasksEnabled ? `, ${Number.isFinite(endedState.tasksCompleted) ? endedState.tasksCompleted : 0} task(s) completed` : ""}.${summary ? " Summary saved for next session." : ""}`,
data: {
sessionId: state.sessionId,
durationMins,
findingsAdded: endedState.findingsAdded,
...(tasksEnabled ? { tasksCompleted: Number.isFinite(endedState.tasksCompleted) ? endedState.tasksCompleted : 0 } : {}),
},
});
});
server.registerTool("session_context", {
title: "◆ phren · session context",
description: "Get the current session context -- active project, session duration, findings added, and prior session summary. Pass the sessionId returned by session_start, or a stable connectionId bound at session_start.",
inputSchema: z.object({
sessionId: z.string().optional().describe("Session ID to query (returned by session_start)."),
connectionId: z.string().optional().describe("Connection ID passed to session_start. Used to resolve the session when sessionId is not provided."),
}),
}, async ({ sessionId, connectionId }) => {
if (!sessionId && !connectionId) {
return mcpResponse({ ok: false, error: "Pass sessionId or connectionId. Implicit process-global session fallback has been removed." });
}
const resolved = resolveSessionFile(phrenPath, sessionId, connectionId);
if (!resolved)
return mcpResponse({ ok: false, error: "No active session. Call session_start first.", data: null });
const { state } = resolved;
const durationMs = Date.now() - new Date(state.startedAt).getTime();
const durationMins = Math.round(durationMs / 60000);
const parts = [
`Session: ${state.sessionId.slice(0, 8)}`,
`Project: ${state.project ?? "none"}`,
`Agent scope: ${state.agentScope ?? "none"}`,
`Started: ${state.startedAt}`,
`Duration: ~${durationMins} min`,
`Findings added: ${state.findingsAdded}`,
...(tasksEnabled ? [`Tasks completed: ${Number.isFinite(state.tasksCompleted) ? state.tasksCompleted : 0}`] : []),
];
if (state.summary)
parts.push(`Prior summary: ${state.summary}`);
return mcpResponse({ ok: true, message: parts.join("\n"), data: state });
});
server.registerTool("session_history", {
title: "◆ phren · session history",
description: "List past sessions with their duration, findings count, and summary. Optionally drill into a specific session to see all artifacts created during it.",
inputSchema: z.object({
limit: z.number().optional().describe("Max sessions to return (default 20)."),
sessionId: z.string().optional().describe("If provided, return full session artifacts for this session instead of listing all sessions."),
project: z.string().optional().describe("Filter sessions and artifacts by project."),
}),
}, async ({ limit, sessionId: targetSessionId, project }) => {
if (targetSessionId) {
// Drill into a specific session
const sessions = listAllSessions(phrenPath, 200);
const session = sessions.find(s => s.sessionId === targetSessionId || s.sessionId.startsWith(targetSessionId));
if (!session)
return mcpResponse({ ok: false, error: `Session ${targetSessionId} not found.` });
const artifacts = await getSessionArtifacts(phrenPath, session.sessionId, project);
const parts = [
`Session: ${session.sessionId.slice(0, 8)}`,
`Project: ${session.project ?? "none"}`,
`Started: ${session.startedAt}`,
`Status: ${session.status}`,
`Duration: ~${session.durationMins ?? 0} min`,
`Findings: ${artifacts.findings.length}`,
...(tasksEnabled ? [`Tasks: ${artifacts.tasks.length}`] : []),
];
if (session.summary)
parts.push(`\nSummary: ${session.summary}`);
if (artifacts.findings.length > 0) {
parts.push("\n## Findings");
for (const f of artifacts.findings) {
parts.push(`- [${f.project}] ${f.text}`);
}
}
if (artifacts.tasks.length > 0) {
parts.push("\n## Tasks");
for (const t of artifacts.tasks) {
parts.push(`- [${t.project}/${t.section}] ${t.text}`);
}
}
return mcpResponse({ ok: true, message: parts.join("\n"), data: { session, ...artifacts } });
}
// List sessions
const sessions = listAllSessions(phrenPath, limit ?? 20);
const filtered = project ? sessions.filter(s => s.project === project) : sessions;
if (filtered.length === 0)
return mcpResponse({ ok: true, message: "No sessions found.", data: [] });
const lines = filtered.map(s => {
const id = s.sessionId.slice(0, 8);
const proj = s.project ?? "—";
const dur = s.durationMins != null ? `${s.durationMins}m` : "?";
const status = s.status === "active" ? " ●" : "";
const findings = s.findingsAdded > 0 ? ` ${s.findingsAdded}f` : "";
const tasks = tasksEnabled && s.tasksCompleted > 0 ? ` ${s.tasksCompleted}t` : "";
const date = s.startedAt.slice(0, 16).replace("T", " ");
return `${id}${status} ${date} ${dur}${findings}${tasks} ${proj}${s.summary ? " " + s.summary.slice(0, 60) : ""}`;
});
return mcpResponse({
ok: true,
message: `${filtered.length} session(s):\n\n${lines.join("\n")}`,
data: filtered,
});
});
}