@gi4nks/ph 1.4.0 → 1.5.0

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/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [1.5.0](https://github.com/gi4nks/ph/compare/v1.4.0...v1.5.0) (2026-05-16)
2
+
3
+
4
+ ### Features
5
+
6
+ * add timeline CLI command and two new MCP tools (get_project_timeline, check_project_knowledge) ([12d667c](https://github.com/gi4nks/ph/commit/12d667c69e35f4aff31a2e13018a00949587fb55))
7
+
1
8
  # [1.4.0](https://github.com/gi4nks/ph/compare/v1.3.2...v1.4.0) (2026-05-16)
2
9
 
3
10
 
package/README.md CHANGED
@@ -14,8 +14,15 @@
14
14
 
15
15
  ## Installation
16
16
 
17
+ ### From npm
18
+
19
+ ```bash
20
+ npm install -g @gi4nks/ph
21
+ ```
22
+
23
+ ### From source
24
+
17
25
  ```bash
18
- # Clone and local build
19
26
  git clone git@github.com:gi4nks/ph.git
20
27
  cd ph
21
28
  npm install
@@ -23,11 +30,6 @@ make build
23
30
  make install
24
31
  ```
25
32
 
26
- Or install from npm:
27
- ```bash
28
- npm install -g @gi4nks/ph
29
- ```
30
-
31
33
  ## Quick Start
32
34
 
33
35
  ```bash
@@ -63,6 +65,127 @@ ph config set remote-url http://server:3001 # remote sync target
63
65
 
64
66
  Environment variable `PH_REMOTE_URL` takes precedence over config.
65
67
 
68
+ | Option | Default | Description |
69
+ |--------|---------|-------------|
70
+ | `analyze-provider` | `ollama` | LLM provider for analysis (`ollama` or `gemini`) |
71
+ | `gemini-api-key` | — | API key for Gemini provider |
72
+ | `ollama-url` | `http://localhost:11434` | Ollama server URL |
73
+ | `ollama-model` | `llama3.1:latest` | Ollama model for analysis |
74
+ | `ollama-embed-model` | `nomic-embed-text-v2-moe` | Ollama model for embeddings |
75
+ | `background-analysis` | `false` | Auto-analyze after each `ph log` |
76
+ | `remote-url` | — | HTTP URL of remote ph server |
77
+ | `remote-api-key` | — | Optional API key for remote server |
78
+ | `db-path` | `~/.prompt_history.db` | Custom database path |
79
+ | `filter-min-length` | `15` | Ignore prompts shorter than N chars |
80
+ | `filter-min-relevance` | `3` | Minimum relevance score (0–10) |
81
+
82
+ ## Remote Server Setup
83
+
84
+ ### Ubuntu (systemd)
85
+
86
+ Install ph on your remote server:
87
+
88
+ ```bash
89
+ sudo npm install -g @gi4nks/ph
90
+ ```
91
+
92
+ Create `/etc/systemd/system/ph.service`:
93
+
94
+ ```ini
95
+ [Unit]
96
+ Description=ph remote sync server
97
+ After=network.target
98
+
99
+ [Service]
100
+ Type=simple
101
+ ExecStart=<path-to-ph> server --port 3001 --host 0.0.0.0
102
+ Restart=always
103
+ RestartSec=5
104
+ User=<your-user>
105
+ Environment=NODE_ENV=production
106
+ StandardOutput=append:/var/log/ph-server.log
107
+ StandardError=append:/var/log/ph-server.log
108
+
109
+ [Install]
110
+ WantedBy=multi-user.target
111
+ ```
112
+
113
+ Find `<path-to-ph>` with `which ph` on the server. Start the service:
114
+
115
+ ```bash
116
+ sudo systemctl daemon-reload
117
+ sudo systemctl enable --now ph.service
118
+ sudo ufw allow 3001/tcp # if firewall is active
119
+ ```
120
+
121
+ ### macOS (launchd)
122
+
123
+ Create `~/Library/LaunchAgents/com.gi4nks.ph.plist`:
124
+
125
+ ```xml
126
+ <?xml version="1.0" encoding="UTF-8"?>
127
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
128
+ <plist version="1.0">
129
+ <dict>
130
+ <key>Label</key>
131
+ <string>com.gi4nks.ph</string>
132
+ <key>ProgramArguments</key>
133
+ <array>
134
+ <string>/usr/local/bin/ph</string>
135
+ <string>server</string>
136
+ <string>--port</string>
137
+ <string>3001</string>
138
+ <string>--host</string>
139
+ <string>0.0.0.0</string>
140
+ </array>
141
+ <key>RunAtLoad</key>
142
+ <true/>
143
+ <key>KeepAlive</key>
144
+ <true/>
145
+ <key>StandardOutPath</key>
146
+ <string>/tmp/ph-server.log</string>
147
+ <key>StandardErrorPath</key>
148
+ <string>/tmp/ph-server.err</string>
149
+ </dict>
150
+ </plist>
151
+ ```
152
+
153
+ ```bash
154
+ launchctl load ~/Library/LaunchAgents/com.gi4nks.ph.plist
155
+ ```
156
+
157
+ ### Client configuration
158
+
159
+ ```bash
160
+ # Set remote URL (config or env)
161
+ ph config set remote-url http://your-server:3001
162
+ # export PH_REMOTE_URL=http://your-server:3001
163
+
164
+ # Sync
165
+ ph remote push # send local prompts to server
166
+ ph remote pull # fetch server prompts locally
167
+ ph remote status # check sync state
168
+ ```
169
+
170
+ ## How Releases Work
171
+
172
+ Releases are automated via **semantic-release** on push to `main`:
173
+
174
+ 1. Push conventional commits (`feat:`, `fix:`, `chore:`, etc.) to `main`
175
+ 2. GitHub Actions runs `npx semantic-release`
176
+ 3. Semantic-release analyzes commits since last release
177
+ 4. Bumps version automatically (major/minor/patch)
178
+ 5. Generates `CHANGELOG.md`
179
+ 6. Creates a git tag
180
+ 7. Publishes to npm with **OIDC trusted publishing** (provenance attestation)
181
+ 8. Creates a GitHub Release
182
+
183
+ The published package is signed with provenance — you can verify it with:
184
+
185
+ ```bash
186
+ npm audit signatures
187
+ ```
188
+
66
189
  ## Storage
67
190
 
68
191
  | Item | Default Path | Description |
package/dist/cli.js CHANGED
@@ -485,6 +485,17 @@ var PhDB = class {
485
485
  last_accessed: row.last_accessed
486
486
  };
487
487
  }
488
+ getAllPromptsByProject(project) {
489
+ return this.db.prepare(`
490
+ SELECT * FROM prompts
491
+ WHERE json_extract(metadata, '$.project') = ?
492
+ ORDER BY timestamp ASC
493
+ `).all(project);
494
+ }
495
+ getAllMemoriesByProject(project) {
496
+ const rows = this.db.prepare("SELECT * FROM memories WHERE project = ? ORDER BY created_at ASC").all(project);
497
+ return rows.map((r) => this.hydrateMemory(r));
498
+ }
488
499
  close() {
489
500
  this.db.close();
490
501
  }
@@ -886,6 +897,30 @@ async function runMCPServer() {
886
897
  },
887
898
  required: ["query"]
888
899
  }
900
+ },
901
+ {
902
+ name: "get_project_timeline",
903
+ description: "Get full chronological timeline of a project: all prompts, analysis summaries, key insights, and technical decisions in sequence.",
904
+ inputSchema: {
905
+ type: "object",
906
+ properties: {
907
+ project: { type: "string", description: "The project name" }
908
+ },
909
+ required: ["project"]
910
+ }
911
+ },
912
+ {
913
+ name: "check_project_knowledge",
914
+ description: "Before implementing a feature, check if it already exists in project history. Searches memories and prompts for previous implementations, discussions, and technical decisions related to a task.",
915
+ inputSchema: {
916
+ type: "object",
917
+ properties: {
918
+ project: { type: "string", description: "The project name" },
919
+ task: { type: "string", description: "Description of the feature or task to check" },
920
+ limit: { type: "number", description: "Max results per search (default 5)", default: 5 }
921
+ },
922
+ required: ["project", "task"]
923
+ }
889
924
  }
890
925
  ]
891
926
  };
@@ -1045,6 +1080,144 @@ async function runMCPServer() {
1045
1080
  content: [{ type: "text", text: formatPromptList(filtered, query) }]
1046
1081
  };
1047
1082
  }
1083
+ if (name === "get_project_timeline") {
1084
+ const { project } = z.object({
1085
+ project: z.string()
1086
+ }).parse(args);
1087
+ const prompts = db.getAllPromptsByProject(project);
1088
+ const memories = db.getAllMemoriesByProject(project);
1089
+ if (prompts.length === 0 && memories.length === 0) {
1090
+ return {
1091
+ content: [{ type: "text", text: `No history found for project "${project}".` }]
1092
+ };
1093
+ }
1094
+ const events = [];
1095
+ for (const p of prompts) {
1096
+ let meta = {};
1097
+ try {
1098
+ meta = JSON.parse(p.metadata);
1099
+ } catch {
1100
+ }
1101
+ events.push({
1102
+ timestamp: p.timestamp,
1103
+ text: [
1104
+ `### #${p.id} \u2014 ${p.tool}`,
1105
+ meta.role ? `Role: ${meta.role}` : "",
1106
+ meta.title ? `Title: ${meta.title}` : "",
1107
+ meta.tags?.length ? `Tags: ${meta.tags.join(", ")}` : "",
1108
+ meta.summary ? `Summary: ${meta.summary}` : "",
1109
+ `Prompt: ${p.prompt.slice(0, 500)}${p.prompt.length > 500 ? "..." : ""}`,
1110
+ p.response ? `Response: ${p.response.slice(0, 300)}${p.response.length > 300 ? "..." : ""}` : "",
1111
+ meta.key_insights?.length ? `Insights:
1112
+ ${meta.key_insights.map((i) => `- ${i}`).join("\n")}` : ""
1113
+ ].filter(Boolean).join("\n")
1114
+ });
1115
+ }
1116
+ for (const m of memories) {
1117
+ events.push({
1118
+ timestamp: m.created_at,
1119
+ text: [
1120
+ `\u{1F4CC} Memory #${m.id}`,
1121
+ m.prompt_ids.length > 0 ? `From prompts: #${m.prompt_ids.join(", #")}` : "",
1122
+ m.summary ? `Summary: ${m.summary}` : "",
1123
+ m.key_insights.length > 0 ? `Key Insights:
1124
+ ${m.key_insights.map((i) => `- ${i}`).join("\n")}` : "",
1125
+ m.technical_decisions.length > 0 ? `Technical Decisions:
1126
+ ${m.technical_decisions.map((d) => `- ${d}`).join("\n")}` : ""
1127
+ ].filter(Boolean).join("\n")
1128
+ });
1129
+ }
1130
+ events.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
1131
+ const header = `# Timeline: ${project}
1132
+
1133
+ ${prompts.length} prompts \xB7 ${memories.length} memory entries
1134
+
1135
+ `;
1136
+ const body = events.map((e) => {
1137
+ const date = new Date(e.timestamp).toLocaleDateString("en-CA");
1138
+ const time = new Date(e.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
1139
+ return `## ${date} ${time}
1140
+
1141
+ ${e.text}
1142
+ `;
1143
+ }).join("---\n");
1144
+ return {
1145
+ content: [{ type: "text", text: header + body }]
1146
+ };
1147
+ }
1148
+ if (name === "check_project_knowledge") {
1149
+ const { project, task, limit = 5 } = z.object({
1150
+ project: z.string(),
1151
+ task: z.string(),
1152
+ limit: z.number().optional()
1153
+ }).parse(args);
1154
+ const parts = [];
1155
+ parts.push(`# Knowledge Check: "${task}" in ${project}
1156
+ `);
1157
+ const memories = db.searchMemories(project, limit);
1158
+ const relevantMemories = memories.filter(
1159
+ (m) => m.summary.toLowerCase().includes(task.toLowerCase()) || m.key_insights.some((k) => k.toLowerCase().includes(task.toLowerCase())) || m.technical_decisions.some((d) => d.toLowerCase().includes(task.toLowerCase()))
1160
+ );
1161
+ if (relevantMemories.length > 0) {
1162
+ parts.push(`## Found in Project Memories
1163
+ `);
1164
+ for (const m of relevantMemories) {
1165
+ parts.push(`### Memory #${m.id}`);
1166
+ if (m.summary) parts.push(`Summary: ${m.summary}`);
1167
+ if (m.key_insights.length > 0) {
1168
+ parts.push(`Key Insights:
1169
+ ${m.key_insights.map((i) => `- ${i}`).join("\n")}`);
1170
+ }
1171
+ if (m.technical_decisions.length > 0) {
1172
+ parts.push(`Technical Decisions:
1173
+ ${m.technical_decisions.map((d) => `- ${d}`).join("\n")}`);
1174
+ }
1175
+ parts.push("");
1176
+ }
1177
+ }
1178
+ const ollamaUrl = cfg.ollamaUrl ?? "http://localhost:11434";
1179
+ const embedModel = cfg.ollamaEmbedModel ?? "nomic-embed-text-v2-moe";
1180
+ try {
1181
+ const [queryVec] = await getEmbeddings([`${project}: ${task}`], ollamaUrl, embedModel, 1);
1182
+ if (queryVec) {
1183
+ const results = db.searchSemantic(queryVec, limit * 3);
1184
+ const projectPrompts = results.filter((e) => {
1185
+ try {
1186
+ const meta = JSON.parse(e.metadata);
1187
+ return meta.project === project;
1188
+ } catch {
1189
+ return false;
1190
+ }
1191
+ }).slice(0, limit);
1192
+ if (projectPrompts.length > 0) {
1193
+ parts.push(`## Related Prompts
1194
+ `);
1195
+ for (const p of projectPrompts) {
1196
+ let meta = {};
1197
+ try {
1198
+ meta = JSON.parse(p.metadata);
1199
+ } catch {
1200
+ }
1201
+ parts.push(`### #${p.id} \u2014 ${p.tool} (${new Date(p.timestamp).toLocaleDateString()})`);
1202
+ if (meta.role) parts.push(`Role: ${meta.role}`);
1203
+ if (meta.summary) parts.push(`Summary: ${meta.summary}`);
1204
+ parts.push(`Prompt: ${p.prompt.slice(0, 400)}${p.prompt.length > 400 ? "..." : ""}`);
1205
+ parts.push("");
1206
+ }
1207
+ }
1208
+ }
1209
+ } catch {
1210
+ parts.push("(Semantic search unavailable \u2014 Ollama may not be running)\n");
1211
+ }
1212
+ if (relevantMemories.length === 0 && !parts.some((p) => p.startsWith("## Related Prompts"))) {
1213
+ parts.push("No existing knowledge found for this task. This appears to be new work.\n");
1214
+ }
1215
+ parts.push("---\n");
1216
+ parts.push("**Recommendation**: Review the above before implementing. If a previous implementation exists, consider reusing or adapting it.\n");
1217
+ return {
1218
+ content: [{ type: "text", text: parts.join("\n") }]
1219
+ };
1220
+ }
1048
1221
  throw new Error(`Unknown tool: ${name}`);
1049
1222
  } catch (error) {
1050
1223
  return {
@@ -1479,7 +1652,7 @@ import { AppHeader, THEMES } from "@gi4nks/ink";
1479
1652
  // package.json
1480
1653
  var package_default = {
1481
1654
  name: "@gi4nks/ph",
1482
- version: "2.0.1",
1655
+ version: "1.4.0",
1483
1656
  description: "Transparent CLI wrapper that captures and indexes AI prompts",
1484
1657
  type: "module",
1485
1658
  license: "MIT",
@@ -5022,6 +5195,103 @@ Deleted ${deleted} prompts, freed ~${(totalToFree / 1024 / 1024).toFixed(1)} MB`
5022
5195
  console.log("Database vacuumed.");
5023
5196
  }
5024
5197
 
5198
+ // src/commands/timeline.ts
5199
+ async function cmdTimeline(db, cfg, args) {
5200
+ const { flags, positional } = parseFlags(args);
5201
+ const project = flags["project"] || positional.join(" ") || detectProject(process.cwd()).projectName;
5202
+ if (!project) {
5203
+ process.stderr.write("ph: could not detect project. Use --project <name> or pass a project name.\n");
5204
+ process.exit(1);
5205
+ }
5206
+ const prompts = db.getAllPromptsByProject(project);
5207
+ const memories = db.getAllMemoriesByProject(project);
5208
+ if (prompts.length === 0 && memories.length === 0) {
5209
+ process.stdout.write(`No history found for project "${project}".
5210
+ `);
5211
+ return;
5212
+ }
5213
+ const output = [];
5214
+ output.push(`# Timeline: ${project}
5215
+ `);
5216
+ output.push(`**${prompts.length} prompts** \xB7 **${memories.length} memory entries**
5217
+ `);
5218
+ const events = [
5219
+ ...prompts.map((p) => ({ type: "prompt", timestamp: p.timestamp, prompt: p })),
5220
+ ...memories.map((m) => ({ type: "memory", timestamp: m.created_at, memory: m }))
5221
+ ];
5222
+ events.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
5223
+ let currentDate = "";
5224
+ for (const event of events) {
5225
+ const date = new Date(event.timestamp).toLocaleDateString("en-CA");
5226
+ if (date !== currentDate) {
5227
+ currentDate = date;
5228
+ output.push(`
5229
+ ## ${date}
5230
+ `);
5231
+ }
5232
+ if (event.type === "prompt" && event.prompt) {
5233
+ output.push(formatPromptEntry(event.prompt));
5234
+ }
5235
+ if (event.type === "memory" && event.memory) {
5236
+ output.push(formatMemoryEntry(event.memory));
5237
+ }
5238
+ }
5239
+ process.stdout.write(output.join("\n"));
5240
+ }
5241
+ function formatPromptEntry(e) {
5242
+ let meta = {};
5243
+ try {
5244
+ meta = JSON.parse(e.metadata);
5245
+ } catch {
5246
+ }
5247
+ const time = new Date(e.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
5248
+ const lines = [];
5249
+ lines.push(`### #${e.id} \u2014 ${e.tool} \u2014 ${time}`);
5250
+ if (meta.role) lines.push(`**Role**: ${meta.role}`);
5251
+ if (meta.title) lines.push(`**Title**: ${meta.title}`);
5252
+ if (meta.tags?.length) lines.push(`**Tags**: ${meta.tags.join(", ")}`);
5253
+ if (meta.summary) lines.push(`**Summary**: ${meta.summary}`);
5254
+ if (meta.relevance !== void 0) lines.push(`**Relevance**: ${meta.relevance}/10`);
5255
+ lines.push("");
5256
+ lines.push("**Prompt**:");
5257
+ lines.push("```");
5258
+ lines.push(e.prompt.slice(0, 500) + (e.prompt.length > 500 ? "\n..." : ""));
5259
+ lines.push("```");
5260
+ if (e.response) {
5261
+ lines.push("");
5262
+ lines.push("**Response**:");
5263
+ lines.push("```");
5264
+ lines.push(e.response.slice(0, 300) + (e.response.length > 300 ? "\n..." : ""));
5265
+ lines.push("```");
5266
+ }
5267
+ if (meta.key_insights?.length) {
5268
+ lines.push("");
5269
+ lines.push("**Insights**:");
5270
+ for (const i of meta.key_insights) lines.push(`- ${i}`);
5271
+ }
5272
+ lines.push("---\n");
5273
+ return lines.join("\n");
5274
+ }
5275
+ function formatMemoryEntry(m) {
5276
+ const time = new Date(m.created_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
5277
+ const lines = [];
5278
+ lines.push(`> \u{1F4CC} **Memory #${m.id} \u2014 ${time}**`);
5279
+ if (m.prompt_ids.length > 0) {
5280
+ lines.push(`> _Derived from prompts: #${m.prompt_ids.join(", #")}_`);
5281
+ }
5282
+ if (m.summary) lines.push(`> **Summary**: ${m.summary}`);
5283
+ if (m.key_insights.length > 0) {
5284
+ lines.push(`> **Insights**:`);
5285
+ for (const i of m.key_insights) lines.push(`> - ${i}`);
5286
+ }
5287
+ if (m.technical_decisions.length > 0) {
5288
+ lines.push(`> **Technical Decisions**:`);
5289
+ for (const d of m.technical_decisions) lines.push(`> - ${d}`);
5290
+ }
5291
+ lines.push("---\n");
5292
+ return lines.join("\n");
5293
+ }
5294
+
5025
5295
  // src/commands/wrap.ts
5026
5296
  import path8 from "path";
5027
5297
  import os9 from "os";
@@ -5190,6 +5460,7 @@ USAGE:
5190
5460
  ph analyze [--limit n] [--force] [--prune] [--dry-run] Analyze prompts with LLM
5191
5461
  ph mcp Start MCP server (Stdio)
5192
5462
  ph server [--port 3001] Start HTTP REST server for remote sync
5463
+ ph timeline [project] Show full project history with prompts and memories
5193
5464
  ph remote push|pull|status Sync prompts with remote ph server
5194
5465
  ph cleanup [--dry-run] [--min-length N] [--min-score N] Remove useless prompts
5195
5466
  ph cleanup-reusability [--dry-run] [--threshold 0.7] [--force] Cleanup based on reusability
@@ -5508,6 +5779,12 @@ async function main() {
5508
5779
  await cmdChat(dbPath, cfg, cmdArgs);
5509
5780
  break;
5510
5781
  }
5782
+ case "timeline": {
5783
+ const db = new PhDB(dbPath);
5784
+ await cmdTimeline(db, cfg, cmdArgs);
5785
+ db.close();
5786
+ break;
5787
+ }
5511
5788
  case "ollama-models": {
5512
5789
  const ollamaUrl = cfg.ollamaUrl ?? "http://localhost:11434";
5513
5790
  try {