@phren/cli 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +590 -0
  3. package/mcp/dist/capabilities/cli.js +61 -0
  4. package/mcp/dist/capabilities/index.js +15 -0
  5. package/mcp/dist/capabilities/mcp.js +61 -0
  6. package/mcp/dist/capabilities/types.js +57 -0
  7. package/mcp/dist/capabilities/vscode.js +61 -0
  8. package/mcp/dist/capabilities/web-ui.js +61 -0
  9. package/mcp/dist/cli-actions.js +302 -0
  10. package/mcp/dist/cli-config.js +580 -0
  11. package/mcp/dist/cli-extract.js +305 -0
  12. package/mcp/dist/cli-govern.js +371 -0
  13. package/mcp/dist/cli-graph.js +169 -0
  14. package/mcp/dist/cli-hooks-citations.js +44 -0
  15. package/mcp/dist/cli-hooks-context.js +56 -0
  16. package/mcp/dist/cli-hooks-globs.js +83 -0
  17. package/mcp/dist/cli-hooks-output.js +130 -0
  18. package/mcp/dist/cli-hooks-retrieval.js +2 -0
  19. package/mcp/dist/cli-hooks-session.js +1402 -0
  20. package/mcp/dist/cli-hooks.js +350 -0
  21. package/mcp/dist/cli-namespaces.js +989 -0
  22. package/mcp/dist/cli-ops.js +253 -0
  23. package/mcp/dist/cli-search.js +407 -0
  24. package/mcp/dist/cli.js +108 -0
  25. package/mcp/dist/content-archive.js +278 -0
  26. package/mcp/dist/content-citation.js +391 -0
  27. package/mcp/dist/content-dedup.js +622 -0
  28. package/mcp/dist/content-learning.js +472 -0
  29. package/mcp/dist/content-metadata.js +186 -0
  30. package/mcp/dist/content-validate.js +462 -0
  31. package/mcp/dist/core-finding.js +54 -0
  32. package/mcp/dist/core-project.js +36 -0
  33. package/mcp/dist/core-search.js +50 -0
  34. package/mcp/dist/data-access.js +400 -0
  35. package/mcp/dist/data-tasks.js +821 -0
  36. package/mcp/dist/embedding.js +344 -0
  37. package/mcp/dist/entrypoint.js +387 -0
  38. package/mcp/dist/finding-context.js +172 -0
  39. package/mcp/dist/finding-impact.js +181 -0
  40. package/mcp/dist/finding-journal.js +122 -0
  41. package/mcp/dist/finding-lifecycle.js +259 -0
  42. package/mcp/dist/governance-audit.js +22 -0
  43. package/mcp/dist/governance-locks.js +96 -0
  44. package/mcp/dist/governance-policy.js +648 -0
  45. package/mcp/dist/governance-scores.js +355 -0
  46. package/mcp/dist/hooks.js +449 -0
  47. package/mcp/dist/impact-scoring.js +22 -0
  48. package/mcp/dist/index-query.js +168 -0
  49. package/mcp/dist/index.js +205 -0
  50. package/mcp/dist/init-config.js +336 -0
  51. package/mcp/dist/init-preferences.js +62 -0
  52. package/mcp/dist/init-setup.js +1305 -0
  53. package/mcp/dist/init-shared.js +29 -0
  54. package/mcp/dist/init.js +1730 -0
  55. package/mcp/dist/link-checksums.js +62 -0
  56. package/mcp/dist/link-context.js +257 -0
  57. package/mcp/dist/link-doctor.js +591 -0
  58. package/mcp/dist/link-skills.js +212 -0
  59. package/mcp/dist/link.js +596 -0
  60. package/mcp/dist/logger.js +15 -0
  61. package/mcp/dist/machine-identity.js +38 -0
  62. package/mcp/dist/mcp-config.js +254 -0
  63. package/mcp/dist/mcp-data.js +315 -0
  64. package/mcp/dist/mcp-extract-facts.js +78 -0
  65. package/mcp/dist/mcp-extract.js +133 -0
  66. package/mcp/dist/mcp-finding.js +557 -0
  67. package/mcp/dist/mcp-graph.js +339 -0
  68. package/mcp/dist/mcp-hooks.js +256 -0
  69. package/mcp/dist/mcp-memory.js +58 -0
  70. package/mcp/dist/mcp-ops.js +328 -0
  71. package/mcp/dist/mcp-search.js +628 -0
  72. package/mcp/dist/mcp-session.js +651 -0
  73. package/mcp/dist/mcp-skills.js +189 -0
  74. package/mcp/dist/mcp-tasks.js +551 -0
  75. package/mcp/dist/mcp-types.js +7 -0
  76. package/mcp/dist/memory-ui-assets.js +6 -0
  77. package/mcp/dist/memory-ui-data.js +513 -0
  78. package/mcp/dist/memory-ui-graph.js +1910 -0
  79. package/mcp/dist/memory-ui-page.js +353 -0
  80. package/mcp/dist/memory-ui-scripts.js +1387 -0
  81. package/mcp/dist/memory-ui-server.js +1218 -0
  82. package/mcp/dist/memory-ui-styles.js +555 -0
  83. package/mcp/dist/memory-ui.js +9 -0
  84. package/mcp/dist/package-metadata.js +13 -0
  85. package/mcp/dist/phren-art.js +52 -0
  86. package/mcp/dist/phren-core.js +108 -0
  87. package/mcp/dist/phren-dotenv.js +67 -0
  88. package/mcp/dist/phren-paths.js +476 -0
  89. package/mcp/dist/proactivity.js +172 -0
  90. package/mcp/dist/profile-store.js +228 -0
  91. package/mcp/dist/project-config.js +85 -0
  92. package/mcp/dist/project-locator.js +25 -0
  93. package/mcp/dist/project-topics.js +1134 -0
  94. package/mcp/dist/provider-adapters.js +176 -0
  95. package/mcp/dist/runtime-profile.js +18 -0
  96. package/mcp/dist/session-checkpoints.js +131 -0
  97. package/mcp/dist/session-utils.js +68 -0
  98. package/mcp/dist/shared-content.js +8 -0
  99. package/mcp/dist/shared-embedding-cache.js +143 -0
  100. package/mcp/dist/shared-fragment-graph.js +456 -0
  101. package/mcp/dist/shared-governance.js +4 -0
  102. package/mcp/dist/shared-index.js +1334 -0
  103. package/mcp/dist/shared-ollama.js +192 -0
  104. package/mcp/dist/shared-paths.js +1 -0
  105. package/mcp/dist/shared-retrieval.js +796 -0
  106. package/mcp/dist/shared-search-fallback.js +375 -0
  107. package/mcp/dist/shared-sqljs.js +42 -0
  108. package/mcp/dist/shared-stemmer.js +171 -0
  109. package/mcp/dist/shared-vector-index.js +199 -0
  110. package/mcp/dist/shared.js +114 -0
  111. package/mcp/dist/shell-entry.js +209 -0
  112. package/mcp/dist/shell-input.js +943 -0
  113. package/mcp/dist/shell-palette.js +119 -0
  114. package/mcp/dist/shell-render.js +252 -0
  115. package/mcp/dist/shell-state-store.js +81 -0
  116. package/mcp/dist/shell-types.js +13 -0
  117. package/mcp/dist/shell-view-list.js +14 -0
  118. package/mcp/dist/shell-view.js +707 -0
  119. package/mcp/dist/shell.js +352 -0
  120. package/mcp/dist/skill-files.js +117 -0
  121. package/mcp/dist/skill-registry.js +279 -0
  122. package/mcp/dist/skill-state.js +28 -0
  123. package/mcp/dist/startup-embedding.js +57 -0
  124. package/mcp/dist/status.js +323 -0
  125. package/mcp/dist/synonyms.json +670 -0
  126. package/mcp/dist/task-hygiene.js +251 -0
  127. package/mcp/dist/task-lifecycle.js +347 -0
  128. package/mcp/dist/tasks-github.js +76 -0
  129. package/mcp/dist/telemetry.js +165 -0
  130. package/mcp/dist/test-global-setup.js +37 -0
  131. package/mcp/dist/tool-registry.js +104 -0
  132. package/mcp/dist/update.js +97 -0
  133. package/mcp/dist/utils.js +543 -0
  134. package/package.json +67 -0
  135. package/skills/README.md +7 -0
  136. package/skills/consolidate/SKILL.md +152 -0
  137. package/skills/discover/SKILL.md +175 -0
  138. package/skills/init/SKILL.md +216 -0
  139. package/skills/profiles/SKILL.md +121 -0
  140. package/skills/sync/SKILL.md +261 -0
  141. package/starter/README.md +74 -0
  142. package/starter/global/CLAUDE.md +89 -0
  143. package/starter/global/skills/humanize.md +30 -0
  144. package/starter/global/skills/pipeline.md +35 -0
  145. package/starter/global/skills/release.md +35 -0
  146. package/starter/machines.yaml +8 -0
  147. package/starter/my-api/.claude/skills/README.md +7 -0
  148. package/starter/my-api/CLAUDE.md +33 -0
  149. package/starter/my-api/FINDINGS.md +9 -0
  150. package/starter/my-api/summary.md +7 -0
  151. package/starter/my-api/tasks.md +7 -0
  152. package/starter/my-first-project/.claude/skills/README.md +7 -0
  153. package/starter/my-first-project/CLAUDE.md +49 -0
  154. package/starter/my-first-project/FINDINGS.md +24 -0
  155. package/starter/my-first-project/summary.md +11 -0
  156. package/starter/my-first-project/tasks.md +25 -0
  157. package/starter/my-frontend/.claude/skills/README.md +7 -0
  158. package/starter/my-frontend/CLAUDE.md +33 -0
  159. package/starter/my-frontend/FINDINGS.md +9 -0
  160. package/starter/my-frontend/summary.md +7 -0
  161. package/starter/my-frontend/tasks.md +7 -0
  162. package/starter/profiles/default.yaml +4 -0
  163. package/starter/profiles/personal.yaml +4 -0
  164. package/starter/profiles/work.yaml +4 -0
  165. package/starter/templates/README.md +7 -0
  166. package/starter/templates/frontend/CLAUDE.md +23 -0
  167. package/starter/templates/frontend/FINDINGS.md +7 -0
  168. package/starter/templates/frontend/reference/README.md +4 -0
  169. package/starter/templates/frontend/summary.md +7 -0
  170. package/starter/templates/frontend/tasks.md +11 -0
  171. package/starter/templates/library/CLAUDE.md +22 -0
  172. package/starter/templates/library/FINDINGS.md +7 -0
  173. package/starter/templates/library/reference/README.md +4 -0
  174. package/starter/templates/library/summary.md +7 -0
  175. package/starter/templates/library/tasks.md +11 -0
  176. package/starter/templates/monorepo/CLAUDE.md +21 -0
  177. package/starter/templates/monorepo/FINDINGS.md +7 -0
  178. package/starter/templates/monorepo/reference/README.md +4 -0
  179. package/starter/templates/monorepo/summary.md +7 -0
  180. package/starter/templates/monorepo/tasks.md +11 -0
  181. package/starter/templates/python-project/CLAUDE.md +21 -0
  182. package/starter/templates/python-project/FINDINGS.md +7 -0
  183. package/starter/templates/python-project/reference/README.md +4 -0
  184. package/starter/templates/python-project/summary.md +7 -0
  185. package/starter/templates/python-project/tasks.md +10 -0
@@ -0,0 +1,254 @@
1
+ import { mcpResponse } from "./mcp-types.js";
2
+ import { z } from "zod";
3
+ import { getRetentionPolicy, updateRetentionPolicy, getWorkflowPolicy, updateWorkflowPolicy, getIndexPolicy, updateIndexPolicy, } from "./shared-governance.js";
4
+ import { PROACTIVITY_LEVELS, getProactivityLevel, getProactivityLevelForFindings, getProactivityLevelForTask, } from "./proactivity.js";
5
+ import { readGovernanceInstallPreferences, writeGovernanceInstallPreferences, } from "./init-preferences.js";
6
+ import { FINDING_SENSITIVITY_CONFIG } from "./cli-config.js";
7
+ // ── Helpers ─────────────────────────────────────────────────────────────────
8
+ function normalizeProactivityLevel(raw) {
9
+ if (!raw)
10
+ return undefined;
11
+ const normalized = raw.trim().toLowerCase();
12
+ return PROACTIVITY_LEVELS.includes(normalized)
13
+ ? normalized
14
+ : undefined;
15
+ }
16
+ function proactivitySnapshot(phrenPath) {
17
+ const prefs = readGovernanceInstallPreferences(phrenPath);
18
+ return {
19
+ configured: {
20
+ proactivity: prefs.proactivity ?? null,
21
+ proactivityFindings: prefs.proactivityFindings ?? null,
22
+ proactivityTask: prefs.proactivityTask ?? null,
23
+ },
24
+ effective: {
25
+ proactivity: getProactivityLevel(phrenPath),
26
+ proactivityFindings: getProactivityLevelForFindings(phrenPath),
27
+ proactivityTask: getProactivityLevelForTask(phrenPath),
28
+ },
29
+ };
30
+ }
31
+ const TASK_MODES = ["off", "manual", "suggest", "auto"];
32
+ const FINDING_SENSITIVITY_LEVELS = ["minimal", "conservative", "balanced", "aggressive"];
33
+ // ── Registration ────────────────────────────────────────────────────────────
34
+ export function register(server, ctx) {
35
+ const { phrenPath } = ctx;
36
+ // ── get_config ────────────────────────────────────────────────────────────
37
+ server.registerTool("get_config", {
38
+ title: "◆ phren · get config",
39
+ description: "Read current configuration for one or all config domains: proactivity, taskMode, " +
40
+ "findingSensitivity, retention (policy), workflow, access, index. " +
41
+ "Returns both configured and effective values.",
42
+ inputSchema: z.object({
43
+ domain: z
44
+ .enum(["proactivity", "taskMode", "findingSensitivity", "retention", "workflow", "access", "index", "all"])
45
+ .optional()
46
+ .describe("Config domain to read. Defaults to 'all'."),
47
+ }),
48
+ }, async ({ domain }) => {
49
+ const d = domain ?? "all";
50
+ const result = {};
51
+ if (d === "all" || d === "proactivity") {
52
+ result.proactivity = proactivitySnapshot(phrenPath);
53
+ }
54
+ if (d === "all" || d === "taskMode") {
55
+ const wf = getWorkflowPolicy(phrenPath);
56
+ result.taskMode = { taskMode: wf.taskMode };
57
+ }
58
+ if (d === "all" || d === "findingSensitivity") {
59
+ const wf = getWorkflowPolicy(phrenPath);
60
+ const level = wf.findingSensitivity;
61
+ const config = FINDING_SENSITIVITY_CONFIG[level];
62
+ result.findingSensitivity = { level, ...config };
63
+ }
64
+ if (d === "all" || d === "retention") {
65
+ result.retention = getRetentionPolicy(phrenPath);
66
+ }
67
+ if (d === "all" || d === "workflow") {
68
+ result.workflow = getWorkflowPolicy(phrenPath);
69
+ }
70
+ if (d === "all" || d === "index") {
71
+ result.index = getIndexPolicy(phrenPath);
72
+ }
73
+ return mcpResponse({
74
+ ok: true,
75
+ message: `Config for ${d === "all" ? "all domains" : d}.`,
76
+ data: result,
77
+ });
78
+ });
79
+ // ── set_proactivity ───────────────────────────────────────────────────────
80
+ server.registerTool("set_proactivity", {
81
+ title: "◆ phren · set proactivity",
82
+ description: "Set the proactivity level for auto-capture. Controls how aggressively phren " +
83
+ "captures findings and tasks. Supports base level, findings-specific, and task-specific overrides.",
84
+ inputSchema: z.object({
85
+ level: z.enum(PROACTIVITY_LEVELS).describe("Proactivity level: high, medium, or low."),
86
+ scope: z
87
+ .enum(["base", "findings", "tasks"])
88
+ .optional()
89
+ .describe("Which proactivity to set. Defaults to 'base'."),
90
+ }),
91
+ }, async ({ level, scope }) => {
92
+ const s = scope ?? "base";
93
+ const patch = {};
94
+ if (s === "base")
95
+ patch.proactivity = level;
96
+ else if (s === "findings")
97
+ patch.proactivityFindings = level;
98
+ else if (s === "tasks")
99
+ patch.proactivityTask = level;
100
+ writeGovernanceInstallPreferences(phrenPath, patch);
101
+ return mcpResponse({
102
+ ok: true,
103
+ message: `Proactivity ${s} set to ${level}.`,
104
+ data: proactivitySnapshot(phrenPath),
105
+ });
106
+ });
107
+ // ── set_task_mode ─────────────────────────────────────────────────────────
108
+ server.registerTool("set_task_mode", {
109
+ title: "◆ phren · set task mode",
110
+ description: "Set the task automation mode: off (no auto-tasks), manual (user creates), " +
111
+ "suggest (phren suggests, user approves), auto (phren creates automatically).",
112
+ inputSchema: z.object({
113
+ mode: z.enum(TASK_MODES).describe("Task mode: off, manual, suggest, or auto."),
114
+ }),
115
+ }, async ({ mode }) => {
116
+ const result = updateWorkflowPolicy(phrenPath, { taskMode: mode });
117
+ if (!result.ok) {
118
+ return mcpResponse({ ok: false, error: result.error, errorCode: result.code });
119
+ }
120
+ return mcpResponse({
121
+ ok: true,
122
+ message: `Task mode set to ${mode}.`,
123
+ data: { taskMode: mode },
124
+ });
125
+ });
126
+ // ── set_finding_sensitivity ───────────────────────────────────────────────
127
+ server.registerTool("set_finding_sensitivity", {
128
+ title: "◆ phren · set finding sensitivity",
129
+ description: "Set the finding capture sensitivity level. Controls how many findings phren captures per session. " +
130
+ "minimal: only explicit asks. conservative: decisions/pitfalls only. " +
131
+ "balanced: non-obvious patterns. aggressive: capture everything.",
132
+ inputSchema: z.object({
133
+ level: z.enum(FINDING_SENSITIVITY_LEVELS).describe("Sensitivity level."),
134
+ }),
135
+ }, async ({ level }) => {
136
+ const result = updateWorkflowPolicy(phrenPath, { findingSensitivity: level });
137
+ if (!result.ok) {
138
+ return mcpResponse({ ok: false, error: result.error, errorCode: result.code });
139
+ }
140
+ const config = FINDING_SENSITIVITY_CONFIG[level];
141
+ return mcpResponse({
142
+ ok: true,
143
+ message: `Finding sensitivity set to ${level}.`,
144
+ data: { level, ...config },
145
+ });
146
+ });
147
+ // ── set_retention_policy ──────────────────────────────────────────────────
148
+ server.registerTool("set_retention_policy", {
149
+ title: "◆ phren · set retention policy",
150
+ description: "Update memory retention policy: TTL, retention days, auto-accept threshold, " +
151
+ "minimum injection confidence, and decay curve.",
152
+ inputSchema: z.object({
153
+ ttlDays: z.number().int().min(1).optional().describe("Days before a finding is considered for expiry."),
154
+ retentionDays: z.number().int().min(1).optional().describe("Hard retention limit in days."),
155
+ autoAcceptThreshold: z.number().min(0).max(1).optional().describe("Score threshold (0-1) for auto-accepting extracted memories."),
156
+ minInjectConfidence: z.number().min(0).max(1).optional().describe("Minimum confidence (0-1) to inject a finding into context."),
157
+ decay: z
158
+ .object({
159
+ d30: z.number().min(0).max(1).optional(),
160
+ d60: z.number().min(0).max(1).optional(),
161
+ d90: z.number().min(0).max(1).optional(),
162
+ d120: z.number().min(0).max(1).optional(),
163
+ })
164
+ .optional()
165
+ .describe("Decay multipliers at 30/60/90/120 day marks."),
166
+ }),
167
+ }, async ({ ttlDays, retentionDays, autoAcceptThreshold, minInjectConfidence, decay }) => {
168
+ const patch = {};
169
+ if (ttlDays !== undefined)
170
+ patch.ttlDays = ttlDays;
171
+ if (retentionDays !== undefined)
172
+ patch.retentionDays = retentionDays;
173
+ if (autoAcceptThreshold !== undefined)
174
+ patch.autoAcceptThreshold = autoAcceptThreshold;
175
+ if (minInjectConfidence !== undefined)
176
+ patch.minInjectConfidence = minInjectConfidence;
177
+ if (decay !== undefined)
178
+ patch.decay = decay;
179
+ const result = updateRetentionPolicy(phrenPath, patch);
180
+ if (!result.ok) {
181
+ return mcpResponse({ ok: false, error: result.error, errorCode: result.code });
182
+ }
183
+ return mcpResponse({
184
+ ok: true,
185
+ message: "Retention policy updated.",
186
+ data: result.data,
187
+ });
188
+ });
189
+ // ── set_workflow_policy ───────────────────────────────────────────────────
190
+ server.registerTool("set_workflow_policy", {
191
+ title: "◆ phren · set workflow policy",
192
+ description: "Update workflow policy: low-confidence threshold, " +
193
+ "risky sections list, task mode, and finding sensitivity.",
194
+ inputSchema: z.object({
195
+ lowConfidenceThreshold: z.number().min(0).max(1).optional()
196
+ .describe("Confidence below which items are flagged as low-confidence."),
197
+ riskySections: z.array(z.enum(["Review", "Stale", "Conflicts"])).optional()
198
+ .describe("Which queue sections are considered risky."),
199
+ taskMode: z.enum(TASK_MODES).optional()
200
+ .describe("Task automation mode."),
201
+ findingSensitivity: z.enum(FINDING_SENSITIVITY_LEVELS).optional()
202
+ .describe("Finding capture sensitivity."),
203
+ }),
204
+ }, async ({ lowConfidenceThreshold, riskySections, taskMode, findingSensitivity }) => {
205
+ const patch = {};
206
+ if (lowConfidenceThreshold !== undefined)
207
+ patch.lowConfidenceThreshold = lowConfidenceThreshold;
208
+ if (riskySections !== undefined)
209
+ patch.riskySections = riskySections;
210
+ if (taskMode !== undefined)
211
+ patch.taskMode = taskMode;
212
+ if (findingSensitivity !== undefined)
213
+ patch.findingSensitivity = findingSensitivity;
214
+ const result = updateWorkflowPolicy(phrenPath, patch);
215
+ if (!result.ok) {
216
+ return mcpResponse({ ok: false, error: result.error, errorCode: result.code });
217
+ }
218
+ return mcpResponse({
219
+ ok: true,
220
+ message: "Workflow policy updated.",
221
+ data: result.data,
222
+ });
223
+ });
224
+ // ── set_index_policy ──────────────────────────────────────────────────────
225
+ server.registerTool("set_index_policy", {
226
+ title: "◆ phren · set index policy",
227
+ description: "Update the FTS indexer policy: include/exclude glob patterns and hidden file inclusion.",
228
+ inputSchema: z.object({
229
+ includeGlobs: z.array(z.string()).optional()
230
+ .describe("Glob patterns for files to include in the index."),
231
+ excludeGlobs: z.array(z.string()).optional()
232
+ .describe("Glob patterns for files to exclude from the index."),
233
+ includeHidden: z.boolean().optional()
234
+ .describe("Whether to index hidden (dot-prefixed) files."),
235
+ }),
236
+ }, async ({ includeGlobs, excludeGlobs, includeHidden }) => {
237
+ const patch = {};
238
+ if (includeGlobs !== undefined)
239
+ patch.includeGlobs = includeGlobs;
240
+ if (excludeGlobs !== undefined)
241
+ patch.excludeGlobs = excludeGlobs;
242
+ if (includeHidden !== undefined)
243
+ patch.includeHidden = includeHidden;
244
+ const result = updateIndexPolicy(phrenPath, patch);
245
+ if (!result.ok) {
246
+ return mcpResponse({ ok: false, error: result.error, errorCode: result.code });
247
+ }
248
+ return mcpResponse({
249
+ ok: true,
250
+ message: "Index policy updated.",
251
+ data: result.data,
252
+ });
253
+ });
254
+ }
@@ -0,0 +1,315 @@
1
+ import { mcpResponse } from "./mcp-types.js";
2
+ import { z } from "zod";
3
+ import * as fs from "fs";
4
+ import * as path from "path";
5
+ import { isValidProjectName } from "./utils.js";
6
+ import { readFindings, readTasks, resolveTaskFilePath, TASKS_FILENAME } from "./data-access.js";
7
+ import { debugLog, findProjectNameCaseInsensitive, normalizeProjectNameForCreate } from "./shared.js";
8
+ const importPayloadSchema = z.object({
9
+ project: z.string(),
10
+ overwrite: z.boolean().optional(),
11
+ summary: z.string().optional(),
12
+ claudeMd: z.string().optional(),
13
+ taskRaw: z.string().optional(),
14
+ learnings: z
15
+ .array(z.object({
16
+ text: z.string(),
17
+ }).passthrough())
18
+ .optional(),
19
+ task: z
20
+ .object({
21
+ Active: z.array(z.object({ line: z.string(), checked: z.boolean().optional(), context: z.string().optional(), priority: z.string().optional(), pinned: z.boolean().optional(), id: z.string().optional(), githubIssue: z.number().optional(), githubUrl: z.string().optional() }).passthrough()).optional(),
22
+ Queue: z.array(z.object({ line: z.string(), checked: z.boolean().optional(), context: z.string().optional(), priority: z.string().optional(), pinned: z.boolean().optional(), id: z.string().optional(), githubIssue: z.number().optional(), githubUrl: z.string().optional() }).passthrough()).optional(),
23
+ Done: z.array(z.object({ line: z.string(), checked: z.boolean().optional(), context: z.string().optional(), priority: z.string().optional(), pinned: z.boolean().optional(), id: z.string().optional(), githubIssue: z.number().optional(), githubUrl: z.string().optional() }).passthrough()).optional(),
24
+ })
25
+ .partial()
26
+ .optional(),
27
+ }).passthrough();
28
+ export function register(server, ctx) {
29
+ const { phrenPath, withWriteQueue, rebuildIndex } = ctx;
30
+ server.registerTool("export_project", {
31
+ title: "◆ phren · export",
32
+ description: "Export a project's data (findings, task, summary) as portable JSON for sharing or backup.",
33
+ inputSchema: z.object({
34
+ project: z.string().describe("Project name to export."),
35
+ }),
36
+ }, async ({ project }) => {
37
+ if (!isValidProjectName(project))
38
+ return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
39
+ const projectDir = path.join(phrenPath, project);
40
+ if (!fs.existsSync(projectDir))
41
+ return mcpResponse({ ok: false, error: `Project "${project}" not found.` });
42
+ const exported = { project, exportedAt: new Date().toISOString(), version: 1 };
43
+ const summaryPath = path.join(projectDir, "summary.md");
44
+ if (fs.existsSync(summaryPath))
45
+ exported.summary = fs.readFileSync(summaryPath, "utf8");
46
+ const learningsResult = readFindings(phrenPath, project);
47
+ if (learningsResult.ok)
48
+ exported.learnings = learningsResult.data;
49
+ const findingsPath = path.join(projectDir, "FINDINGS.md");
50
+ if (fs.existsSync(findingsPath))
51
+ exported.findingsRaw = fs.readFileSync(findingsPath, "utf8");
52
+ const taskResult = readTasks(phrenPath, project);
53
+ if (taskResult.ok) {
54
+ exported.task = taskResult.data.items;
55
+ // Also export the raw task file string for lossless round-trip (preserves priority/pinned/stable IDs)
56
+ const taskRawPath = resolveTaskFilePath(phrenPath, project);
57
+ if (taskRawPath && fs.existsSync(taskRawPath))
58
+ exported.taskRaw = fs.readFileSync(taskRawPath, "utf8");
59
+ }
60
+ const claudePath = path.join(projectDir, "CLAUDE.md");
61
+ if (fs.existsSync(claudePath))
62
+ exported.claudeMd = fs.readFileSync(claudePath, "utf8");
63
+ return mcpResponse({ ok: true, message: `Exported project "${project}".`, data: exported });
64
+ });
65
+ server.registerTool("import_project", {
66
+ title: "◆ phren · import",
67
+ description: "Import project data from a previously exported JSON payload. Creates the project directory if needed.",
68
+ inputSchema: z.object({
69
+ data: z.string().describe("JSON string from a previous export_project call."),
70
+ }),
71
+ }, async ({ data: rawData }) => {
72
+ return withWriteQueue(async () => {
73
+ let decoded;
74
+ try {
75
+ decoded = JSON.parse(rawData);
76
+ }
77
+ catch (err) {
78
+ if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
79
+ process.stderr.write(`[phren] import_project jsonParse: ${err instanceof Error ? err.message : String(err)}\n`);
80
+ return mcpResponse({ ok: false, error: "Invalid JSON input." });
81
+ }
82
+ const parsedResult = importPayloadSchema.safeParse(decoded);
83
+ if (!parsedResult.success) {
84
+ return mcpResponse({ ok: false, error: `Invalid import payload: ${parsedResult.error.issues[0]?.message ?? "schema validation failed"}` });
85
+ }
86
+ const parsed = parsedResult.data;
87
+ // Warn about unknown fields silently discarded by .passthrough()
88
+ const knownTopLevel = new Set(["project", "overwrite", "summary", "claudeMd", "learnings", "task", "exportedAt", "version", "findingsRaw"]);
89
+ const unknownFields = Object.keys(decoded).filter(k => !knownTopLevel.has(k));
90
+ if (unknownFields.length > 0) {
91
+ debugLog(`import_project: unknown fields will be ignored: ${unknownFields.join(", ")}`);
92
+ }
93
+ const projectName = normalizeProjectNameForCreate(parsed.project);
94
+ if (!isValidProjectName(projectName)) {
95
+ return mcpResponse({ ok: false, error: `Invalid project name: "${parsed.project}"` });
96
+ }
97
+ const existingProject = findProjectNameCaseInsensitive(phrenPath, projectName);
98
+ if (existingProject && existingProject !== projectName) {
99
+ return mcpResponse({
100
+ ok: false,
101
+ error: `Project "${existingProject}" already exists with different casing. Refusing to import "${projectName}" because it would split the same project on case-sensitive filesystems.`,
102
+ });
103
+ }
104
+ const projectDir = path.join(phrenPath, projectName);
105
+ const overwrite = parsed.overwrite === true;
106
+ if (fs.existsSync(projectDir) && !overwrite) {
107
+ return mcpResponse({
108
+ ok: false,
109
+ error: `Project "${projectName}" already exists. Re-run with "overwrite": true to replace it.`,
110
+ });
111
+ }
112
+ const stagingRoot = fs.mkdtempSync(path.join(phrenPath, `.phren-import-${projectName}-`));
113
+ const stagedProjectDir = path.join(stagingRoot, projectName);
114
+ const imported = [];
115
+ const cleanupDir = (dir) => {
116
+ if (fs.existsSync(dir))
117
+ fs.rmSync(dir, { recursive: true, force: true });
118
+ };
119
+ const buildFindingsContent = () => {
120
+ if (!parsed.learnings || parsed.learnings.length === 0)
121
+ return null;
122
+ const date = new Date().toISOString().slice(0, 10);
123
+ const lines = [`# ${projectName} Findings`, "", `## ${date}`, ""];
124
+ for (const item of parsed.learnings) {
125
+ lines.push(`- ${item.text}`);
126
+ }
127
+ lines.push("");
128
+ return lines.join("\n");
129
+ };
130
+ const buildTaskContent = () => {
131
+ // Prefer the raw task string (lossless: preserves priority/pinned/stable IDs)
132
+ const taskRaw = parsed.taskRaw;
133
+ if (typeof taskRaw === "string")
134
+ return taskRaw;
135
+ if (!parsed.task)
136
+ return null;
137
+ const sections = ["Active", "Queue", "Done"];
138
+ const lines = [`# ${projectName} tasks`, ""];
139
+ for (const section of sections) {
140
+ lines.push(`## ${section}`, "");
141
+ const items = parsed.task[section];
142
+ if (items) {
143
+ for (const item of items) {
144
+ const prefix = item.checked || section === "Done" ? "- [x] " : "- [ ] ";
145
+ const priorityTag = item.priority ? ` [${item.priority}]` : "";
146
+ lines.push(`${prefix}${item.line}${priorityTag}`);
147
+ if (item.context)
148
+ lines.push(` Context: ${item.context}`);
149
+ if (item.githubIssue || item.githubUrl) {
150
+ const githubRef = item.githubIssue && item.githubUrl
151
+ ? `#${item.githubIssue} ${item.githubUrl}`
152
+ : item.githubIssue
153
+ ? `#${item.githubIssue}`
154
+ : item.githubUrl;
155
+ lines.push(` GitHub: ${githubRef}`);
156
+ }
157
+ }
158
+ }
159
+ lines.push("");
160
+ }
161
+ return lines.join("\n");
162
+ };
163
+ try {
164
+ fs.mkdirSync(stagedProjectDir, { recursive: true });
165
+ if (parsed.summary) {
166
+ fs.writeFileSync(path.join(stagedProjectDir, "summary.md"), parsed.summary);
167
+ imported.push("summary.md");
168
+ }
169
+ if (parsed.claudeMd) {
170
+ fs.writeFileSync(path.join(stagedProjectDir, "CLAUDE.md"), parsed.claudeMd);
171
+ imported.push("CLAUDE.md");
172
+ }
173
+ const findingsRaw = parsed.findingsRaw;
174
+ const findingsContent = typeof findingsRaw === "string" ? findingsRaw : buildFindingsContent();
175
+ if (findingsContent) {
176
+ fs.writeFileSync(path.join(stagedProjectDir, "FINDINGS.md"), findingsContent);
177
+ imported.push("FINDINGS.md");
178
+ }
179
+ const taskContent = buildTaskContent();
180
+ if (taskContent) {
181
+ fs.writeFileSync(path.join(stagedProjectDir, TASKS_FILENAME), taskContent);
182
+ imported.push(TASKS_FILENAME);
183
+ }
184
+ const backupDir = overwrite ? path.join(phrenPath, `${projectName}.import-backup-${Date.now()}`) : null;
185
+ try {
186
+ if (overwrite && fs.existsSync(projectDir)) {
187
+ fs.renameSync(projectDir, backupDir);
188
+ }
189
+ fs.renameSync(stagedProjectDir, projectDir);
190
+ cleanupDir(stagingRoot);
191
+ }
192
+ catch (error) {
193
+ if (backupDir && fs.existsSync(backupDir) && !fs.existsSync(projectDir)) {
194
+ fs.renameSync(backupDir, projectDir);
195
+ }
196
+ cleanupDir(stagingRoot);
197
+ return mcpResponse({
198
+ ok: false,
199
+ error: error instanceof Error ? `Failed to finalize import: ${error.message}` : "Failed to finalize import.",
200
+ errorCode: "INTERNAL_ERROR",
201
+ });
202
+ }
203
+ }
204
+ catch (error) {
205
+ cleanupDir(stagingRoot);
206
+ return mcpResponse({
207
+ ok: false,
208
+ error: error instanceof Error ? `Failed to stage import: ${error.message}` : "Failed to stage import.",
209
+ errorCode: "INTERNAL_ERROR",
210
+ });
211
+ }
212
+ // Wrap rebuildIndex in a try/catch so that indexing failures trigger backup restore.
213
+ try {
214
+ await rebuildIndex();
215
+ }
216
+ catch (indexError) {
217
+ // Index rebuild failed — restore backup if we replaced the project dir
218
+ if (overwrite) {
219
+ // Find the backup dir that was created earlier
220
+ try {
221
+ for (const entry of fs.readdirSync(phrenPath)) {
222
+ if (entry.startsWith(`${projectName}.import-backup-`)) {
223
+ const backupPath = path.join(phrenPath, entry);
224
+ if (fs.existsSync(backupPath) && !fs.existsSync(projectDir)) {
225
+ fs.renameSync(backupPath, projectDir);
226
+ }
227
+ else if (fs.existsSync(backupPath)) {
228
+ // Active dir exists — remove imported dir then restore backup
229
+ fs.rmSync(projectDir, { recursive: true, force: true });
230
+ fs.renameSync(backupPath, projectDir);
231
+ }
232
+ break;
233
+ }
234
+ }
235
+ }
236
+ catch (err) {
237
+ if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
238
+ process.stderr.write(`[phren] import_project backupRestore: ${err instanceof Error ? err.message : String(err)}\n`);
239
+ }
240
+ }
241
+ return mcpResponse({
242
+ ok: false,
243
+ error: indexError instanceof Error ? `Index rebuild failed after import: ${indexError.message}` : "Index rebuild failed after import.",
244
+ errorCode: "INTERNAL_ERROR",
245
+ });
246
+ }
247
+ // Backup is only deleted after successful rebuild so we can restore on failure
248
+ if (overwrite) {
249
+ try {
250
+ for (const entry of fs.readdirSync(phrenPath)) {
251
+ if (entry.startsWith(`${projectName}.import-backup-`)) {
252
+ fs.rmSync(path.join(phrenPath, entry), { recursive: true, force: true });
253
+ }
254
+ }
255
+ }
256
+ catch (err) {
257
+ if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
258
+ process.stderr.write(`[phren] import_project backupCleanup: ${err instanceof Error ? err.message : String(err)}\n`);
259
+ }
260
+ }
261
+ return mcpResponse({
262
+ ok: true,
263
+ message: `Imported project "${projectName}": ${imported.join(", ")}`,
264
+ data: { project: projectName, files: imported, overwrite },
265
+ });
266
+ });
267
+ });
268
+ server.registerTool("manage_project", {
269
+ title: "◆ phren · manage project",
270
+ description: "Archive or unarchive a project. Archive moves it out of the active index without deleting data (renamed with .archived suffix). Unarchive restores it.",
271
+ inputSchema: z.object({
272
+ project: z.string().describe("Project name."),
273
+ action: z.enum(["archive", "unarchive"]).describe("Action to perform."),
274
+ }),
275
+ }, async ({ project, action }) => {
276
+ if (!isValidProjectName(project))
277
+ return mcpResponse({ ok: false, error: `Invalid project name: "${project}"` });
278
+ return withWriteQueue(async () => {
279
+ const activeProject = findProjectNameCaseInsensitive(phrenPath, project);
280
+ const projectDir = path.join(phrenPath, project);
281
+ const archiveDir = path.join(phrenPath, `${project}.archived`);
282
+ if (action === "archive") {
283
+ if (!fs.existsSync(projectDir)) {
284
+ return mcpResponse({ ok: false, error: `Project "${project}" not found.` });
285
+ }
286
+ if (fs.existsSync(archiveDir)) {
287
+ return mcpResponse({ ok: false, error: `Archive "${project}.archived" already exists. Unarchive or remove it first.` });
288
+ }
289
+ fs.renameSync(projectDir, archiveDir);
290
+ await rebuildIndex();
291
+ return mcpResponse({
292
+ ok: true,
293
+ message: `Archived project "${project}". Data preserved at ${archiveDir}.`,
294
+ data: { project, archivePath: archiveDir },
295
+ });
296
+ }
297
+ // unarchive
298
+ if (activeProject) {
299
+ return mcpResponse({ ok: false, error: `Project "${activeProject}" already exists as an active project.` });
300
+ }
301
+ if (!fs.existsSync(archiveDir)) {
302
+ const entries = fs.readdirSync(phrenPath).filter((e) => e.endsWith(".archived"));
303
+ const available = entries.map((e) => e.replace(/\.archived$/, ""));
304
+ return mcpResponse({ ok: false, error: `No archive found for "${project}".`, data: { availableArchives: available } });
305
+ }
306
+ fs.renameSync(archiveDir, projectDir);
307
+ await rebuildIndex();
308
+ return mcpResponse({
309
+ ok: true,
310
+ message: `Unarchived project "${project}". It is now active again.`,
311
+ data: { project, path: projectDir },
312
+ });
313
+ }); // end withWriteQueue
314
+ });
315
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Structured fact extraction from findings (PHREN_FEATURE_FACT_EXTRACT=1).
3
+ * Each new finding is passed to an LLM that extracts a preference or fact
4
+ * ("prefers X", "uses Y", "avoids Z"). Stored in project/preferences.json
5
+ * and surfaced in session_start.
6
+ */
7
+ import * as fs from "fs";
8
+ import * as path from "path";
9
+ import { debugLog } from "./shared.js";
10
+ import { safeProjectPath, isFeatureEnabled, errorMessage } from "./utils.js";
11
+ import { callLlm } from "./content-dedup.js";
12
+ const FACT_EXTRACT_FLAG = "PHREN_FEATURE_FACT_EXTRACT";
13
+ const MAX_FACTS = 50;
14
+ function preferencesPath(phrenPath, project) {
15
+ const dir = safeProjectPath(phrenPath, project);
16
+ return dir ? path.join(dir, "preferences.json") : null;
17
+ }
18
+ export function readExtractedFacts(phrenPath, project) {
19
+ const p = preferencesPath(phrenPath, project);
20
+ if (!p)
21
+ return [];
22
+ try {
23
+ const data = JSON.parse(fs.readFileSync(p, "utf8"));
24
+ return Array.isArray(data) ? data : [];
25
+ }
26
+ catch (err) {
27
+ if ((process.env.PHREN_DEBUG || process.env.PHREN_DEBUG))
28
+ process.stderr.write(`[phren] readExtractedFacts: ${errorMessage(err)}\n`);
29
+ return [];
30
+ }
31
+ }
32
+ function writeExtractedFacts(phrenPath, project, facts) {
33
+ const p = preferencesPath(phrenPath, project);
34
+ if (!p)
35
+ return;
36
+ try {
37
+ const trimmed = facts.slice(-MAX_FACTS);
38
+ const tmp = `${p}.tmp-${process.pid}-${Date.now()}`;
39
+ fs.writeFileSync(tmp, JSON.stringify(trimmed, null, 2));
40
+ fs.renameSync(tmp, p);
41
+ }
42
+ catch (err) {
43
+ debugLog(`writeExtractedFacts: ${errorMessage(err)}`);
44
+ }
45
+ }
46
+ /**
47
+ * Fire-and-forget: extract a structured fact from a new finding using an LLM.
48
+ * Skips silently if the feature flag is off or no LLM is configured.
49
+ */
50
+ export function extractFactFromFinding(phrenPath, project, finding) {
51
+ if (!isFeatureEnabled(FACT_EXTRACT_FLAG, false))
52
+ return;
53
+ // no LLM configured, skip
54
+ if (!(process.env.PHREN_LLM_ENDPOINT) && !process.env.ANTHROPIC_API_KEY && !process.env.OPENAI_API_KEY)
55
+ return;
56
+ const prompt = `Extract a single user preference, technology choice, or architectural fact from this finding. ` +
57
+ `Return ONLY a short statement in the format "prefers X", "uses Y", "avoids Z", ` +
58
+ `or "decided to X because Y". If no clear preference or fact exists, return "none".\n\nFinding: ${finding.slice(0, 500)}`;
59
+ callLlm(prompt, undefined, 60)
60
+ .then(raw => {
61
+ if (!raw || raw.toLowerCase() === "none")
62
+ return;
63
+ // cap and strip newlines before storing
64
+ const fact = raw.replace(/[\r\n]+/g, " ").trim().slice(0, 200);
65
+ if (!fact)
66
+ return;
67
+ // Re-read inside the callback to minimize race window (best-effort; not locked)
68
+ const existing = readExtractedFacts(phrenPath, project);
69
+ const normalized = fact.toLowerCase();
70
+ if (existing.some(f => f.fact.toLowerCase() === normalized))
71
+ return;
72
+ existing.push({ fact, source: finding.slice(0, 120), at: new Date().toISOString() });
73
+ writeExtractedFacts(phrenPath, project, existing);
74
+ })
75
+ .catch((err) => {
76
+ debugLog(`extractFactFromFinding: ${errorMessage(err)}`);
77
+ });
78
+ }