@hienlh/ppm 0.12.11 → 0.13.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 +18 -0
- package/README.md +11 -0
- package/assets/skills/ppm/SKILL.md +74 -0
- package/assets/skills/ppm/references/cli-reference.md +728 -0
- package/assets/skills/ppm/references/common-tasks.md +139 -0
- package/assets/skills/ppm/references/http-api.md +204 -0
- package/bun.lock +2062 -0
- package/bunfig.toml +2 -0
- package/dist/web/assets/{audio-preview-DnQmf9fu.js → audio-preview-J5neETTY.js} +1 -1
- package/dist/web/assets/chat-tab-sVHRa1Fz.js +12 -0
- package/dist/web/assets/{code-editor-B-lU1fz3.js → code-editor-tMfcFaQ5.js} +2 -2
- package/dist/web/assets/{conflict-editor-BYzf3LuW.js → conflict-editor-FydCxWTC.js} +1 -1
- package/dist/web/assets/{database-viewer-DjvnIn8p.js → database-viewer-Celi1puH.js} +1 -1
- package/dist/web/assets/diff-viewer-NgDJLTk9.js +4 -0
- package/dist/web/assets/{extension-webview-4xMREn_x.js → extension-webview-xWAdCj3q.js} +1 -1
- package/dist/web/assets/{image-preview-CkS2PVdQ.js → image-preview-C6bFkdZD.js} +1 -1
- package/dist/web/assets/index-BMhiElt6.css +2 -0
- package/dist/web/assets/{index-FGlF8IWZ.js → index-DtbAoxyy.js} +2 -2
- package/dist/web/assets/{markdown-renderer-Bj2B05Km.js → markdown-renderer-BAnnk1pI.js} +1 -1
- package/dist/web/assets/{pdf-preview-CCyw5cuH.js → pdf-preview-BNuFTSOL.js} +1 -1
- package/dist/web/assets/{port-forwarding-tab-Cebb5Eix.js → port-forwarding-tab-BbDlGxAs.js} +1 -1
- package/dist/web/assets/{postgres-viewer-BrOiliEv.js → postgres-viewer-Cman1YRO.js} +1 -1
- package/dist/web/assets/{settings-tab-D0XjupJm.js → settings-tab-n5X_Dbu4.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-OEVq_-Po.js → sqlite-viewer-D6JT11uu.js} +1 -1
- package/dist/web/assets/{terminal-tab-MjmJaQyA.js → terminal-tab-B4kMthYo.js} +1 -1
- package/dist/web/assets/{video-preview-B819qvlp.js → video-preview-BftQOOzF.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/docs/project-changelog.md +15 -1
- package/package.json +3 -3
- package/scripts/generate-ppm-skill.ts +23 -0
- package/scripts/lib/generate-cli-reference.ts +81 -0
- package/scripts/lib/generate-common-tasks.ts +14 -0
- package/scripts/lib/generate-http-api.ts +145 -0
- package/scripts/lib/generate-skill-md.ts +28 -0
- package/scripts/lib/write-output.ts +17 -0
- package/src/cli/commands/export-cmd.ts +85 -0
- package/src/index.ts +167 -153
- package/src/providers/claude-agent-sdk.ts +1 -135
- package/src/server/index.ts +2 -1
- package/src/server/routes/chat.ts +18 -0
- package/src/server/routes/git.ts +16 -0
- package/src/services/git.service.ts +34 -0
- package/src/services/jsonl-transcript-parser.ts +216 -0
- package/src/services/skill-export/backup-existing.ts +33 -0
- package/src/services/skill-export/copy-bundled-skill.ts +36 -0
- package/src/services/skill-export/generate-db-schema.ts +66 -0
- package/src/services/skill-export/index.ts +6 -0
- package/src/services/skill-export/resolve-assets-dir.ts +31 -0
- package/src/services/skill-export/resolve-target-dir.ts +17 -0
- package/src/services/supervisor.ts +2 -1
- package/src/web/components/chat/chat-tab.tsx +6 -1
- package/src/web/components/chat/message-list.tsx +101 -9
- package/src/web/components/chat/pre-compact-button.tsx +50 -0
- package/src/web/components/editor/diff-viewer.tsx +21 -5
- package/src/web/hooks/use-chat.ts +37 -1
- package/src/web/lib/flatten-expansions.ts +36 -0
- package/templates/skill/SKILL.md.tmpl +74 -0
- package/templates/skill/common-tasks.md +139 -0
- package/assets/skills/ppm-guide/SKILL.md +0 -61
- package/dist/web/assets/chat-tab-Cf6T3mGO.js +0 -12
- package/dist/web/assets/diff-viewer-CP2jcR5J.js +0 -4
- package/dist/web/assets/index-BTjuH4fn.css +0 -2
- package/scripts/generate-ppm-guide.ts +0 -92
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses a Claude Code JSONL transcript file into ChatMessage[].
|
|
3
|
+
* Reusable across live SDK session history (claude-agent-sdk.ts) and
|
|
4
|
+
* pre-compact transcript loading (chat route /pre-compact-messages).
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
7
|
+
import { resolve } from "node:path";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import type { ChatEvent, ChatMessage } from "../types/chat.ts";
|
|
10
|
+
|
|
11
|
+
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
|
12
|
+
const TEAMMATE_MSG_RE = /<teammate-message[^>]*>[\s\S]*?<\/teammate-message>/g;
|
|
13
|
+
|
|
14
|
+
/** Strip SDK teammate-message XML tags from assistant text */
|
|
15
|
+
export function stripTeammateXml(text: string): string {
|
|
16
|
+
if (!text.includes("<teammate-message")) return text;
|
|
17
|
+
return text.replace(TEAMMATE_MSG_RE, "").replace(/\n{3,}/g, "\n\n").trim();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Extract plain text from message payload */
|
|
21
|
+
export function extractText(message: unknown): string {
|
|
22
|
+
if (!message || typeof message !== "object") return "";
|
|
23
|
+
const msg = message as Record<string, unknown>;
|
|
24
|
+
if (typeof msg.content === "string") return msg.content;
|
|
25
|
+
if (Array.isArray(msg.content)) {
|
|
26
|
+
return (msg.content as Array<Record<string, unknown>>)
|
|
27
|
+
.filter((b) => b.type === "text" && typeof b.text === "string")
|
|
28
|
+
.map((b) => b.text as string)
|
|
29
|
+
.join("");
|
|
30
|
+
}
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Parse SDK SessionMessage into ChatMessage with events for tool_use blocks */
|
|
35
|
+
export function parseSessionMessage(
|
|
36
|
+
msg: { uuid: string; type: string; message: unknown; parent_tool_use_id?: string | null },
|
|
37
|
+
): ChatMessage {
|
|
38
|
+
const message = msg.message as Record<string, unknown> | undefined;
|
|
39
|
+
const role = msg.type as "user" | "assistant";
|
|
40
|
+
const parentId = (msg as any).parent_tool_use_id as string | undefined;
|
|
41
|
+
|
|
42
|
+
// Filter synthetic SDK-generated error messages (auth failures, rate limits, etc.)
|
|
43
|
+
const isSdkErrorMessage =
|
|
44
|
+
(msg as any).isApiErrorMessage === true ||
|
|
45
|
+
typeof (msg as any).error === "string" ||
|
|
46
|
+
(message && (message as any).model === "<synthetic>" &&
|
|
47
|
+
Array.isArray(message.content) &&
|
|
48
|
+
(message.content as Array<Record<string, unknown>>).some(
|
|
49
|
+
(b) => b.type === "text" && typeof b.text === "string" &&
|
|
50
|
+
/Failed to authenticate|API Error: 40[13]|hit your limit|rate.?limit/i.test(b.text as string),
|
|
51
|
+
));
|
|
52
|
+
if (isSdkErrorMessage) {
|
|
53
|
+
return {
|
|
54
|
+
id: msg.uuid,
|
|
55
|
+
role,
|
|
56
|
+
content: "",
|
|
57
|
+
timestamp: new Date().toISOString(),
|
|
58
|
+
sdkUuid: msg.uuid,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const events: ChatEvent[] = [];
|
|
63
|
+
let textContent = "";
|
|
64
|
+
|
|
65
|
+
if (message && Array.isArray(message.content)) {
|
|
66
|
+
for (const block of message.content as Array<Record<string, unknown>>) {
|
|
67
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
68
|
+
const cleaned = role === "assistant" ? stripTeammateXml(block.text) : block.text;
|
|
69
|
+
textContent += cleaned;
|
|
70
|
+
if (role === "assistant" && cleaned) {
|
|
71
|
+
events.push({ type: "text", content: cleaned, ...(parentId && { parentToolUseId: parentId }) });
|
|
72
|
+
}
|
|
73
|
+
} else if (block.type === "tool_use") {
|
|
74
|
+
events.push({
|
|
75
|
+
type: "tool_use",
|
|
76
|
+
tool: (block.name as string) ?? "unknown",
|
|
77
|
+
input: block.input ?? {},
|
|
78
|
+
toolUseId: block.id as string | undefined,
|
|
79
|
+
...(parentId && { parentToolUseId: parentId }),
|
|
80
|
+
});
|
|
81
|
+
} else if (block.type === "tool_result") {
|
|
82
|
+
const output = block.content ?? block.output ?? "";
|
|
83
|
+
events.push({
|
|
84
|
+
type: "tool_result",
|
|
85
|
+
output: typeof output === "string" ? output : JSON.stringify(output),
|
|
86
|
+
isError: !!(block as Record<string, unknown>).is_error,
|
|
87
|
+
toolUseId: block.tool_use_id as string | undefined,
|
|
88
|
+
...(parentId && { parentToolUseId: parentId }),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
textContent = extractText(message);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// SDK-generated user messages carry system text (tool_result blocks, teammate XML) —
|
|
97
|
+
// clear so they don't render as user bubbles.
|
|
98
|
+
if (role === "user" && (events.some((e) => e.type === "tool_result") || textContent.includes("<teammate-message"))) {
|
|
99
|
+
textContent = "";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
id: msg.uuid,
|
|
104
|
+
role,
|
|
105
|
+
content: textContent,
|
|
106
|
+
events: events.length > 0 ? events : undefined,
|
|
107
|
+
timestamp: new Date().toISOString(),
|
|
108
|
+
sdkUuid: msg.uuid,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Move events with parentToolUseId into their parent Agent/Task tool_use's children array.
|
|
114
|
+
* Mutates the array in-place.
|
|
115
|
+
*/
|
|
116
|
+
export function nestChildEvents(events: ChatEvent[]): void {
|
|
117
|
+
const parentMap = new Map<string, ChatEvent & { type: "tool_use" }>();
|
|
118
|
+
for (const ev of events) {
|
|
119
|
+
if (ev.type === "tool_use" && (ev.tool === "Agent" || ev.tool === "Task") && ev.toolUseId) {
|
|
120
|
+
parentMap.set(ev.toolUseId, ev);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (parentMap.size === 0) return;
|
|
124
|
+
|
|
125
|
+
const childIndices: number[] = [];
|
|
126
|
+
for (let i = 0; i < events.length; i++) {
|
|
127
|
+
const ev = events[i]!;
|
|
128
|
+
const pid = (ev as any).parentToolUseId as string | undefined;
|
|
129
|
+
if (!pid) continue;
|
|
130
|
+
const parent = parentMap.get(pid);
|
|
131
|
+
if (parent) {
|
|
132
|
+
if (!parent.children) parent.children = [];
|
|
133
|
+
parent.children.push(ev);
|
|
134
|
+
childIndices.push(i);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
for (let i = childIndices.length - 1; i >= 0; i--) {
|
|
138
|
+
events.splice(childIndices[i]!, 1);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Validate JSONL path — must be under ~/.claude/ (prevents arbitrary file reads).
|
|
144
|
+
* Throws Error with descriptive message. Returns resolved realpath on success.
|
|
145
|
+
*/
|
|
146
|
+
export function validateJsonlPath(inputPath: string): string {
|
|
147
|
+
if (!inputPath) throw new Error("jsonlPath is required");
|
|
148
|
+
// Reject obvious traversal attempts before resolution
|
|
149
|
+
if (inputPath.includes("\0")) throw new Error("Invalid path: denied");
|
|
150
|
+
if (!inputPath.endsWith(".jsonl")) throw new Error("Invalid path: must be a .jsonl file");
|
|
151
|
+
|
|
152
|
+
const resolved = resolve(inputPath);
|
|
153
|
+
if (!existsSync(resolved)) throw new Error("File not found");
|
|
154
|
+
|
|
155
|
+
let real: string;
|
|
156
|
+
try {
|
|
157
|
+
real = realpathSync(resolved);
|
|
158
|
+
} catch {
|
|
159
|
+
throw new Error("File not found");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const claudeDir = resolve(homedir(), ".claude") + "/";
|
|
163
|
+
if (!(real + "/").startsWith(claudeDir)) {
|
|
164
|
+
throw new Error("Access denied: path traversal detected");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const stat = statSync(real);
|
|
168
|
+
if (!stat.isFile()) throw new Error("Not a regular file");
|
|
169
|
+
if (stat.size > MAX_FILE_SIZE) {
|
|
170
|
+
throw new Error(`File too large: ${Math.round(stat.size / 1024 / 1024)}MB exceeds 50MB limit`);
|
|
171
|
+
}
|
|
172
|
+
return real;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Read a JSONL transcript file, parse entries, apply merge/nest pipeline, return ChatMessage[].
|
|
177
|
+
* Applies the same logic as ClaudeAgentSdkProvider.getMessages() but reads from file directly.
|
|
178
|
+
*/
|
|
179
|
+
export async function parseJsonlTranscript(filePath: string): Promise<ChatMessage[]> {
|
|
180
|
+
const text = await Bun.file(filePath).text();
|
|
181
|
+
const parsed: ChatMessage[] = [];
|
|
182
|
+
for (const line of text.split("\n")) {
|
|
183
|
+
const trimmed = line.trim();
|
|
184
|
+
if (!trimmed) continue;
|
|
185
|
+
let entry: any;
|
|
186
|
+
try {
|
|
187
|
+
entry = JSON.parse(trimmed);
|
|
188
|
+
} catch {
|
|
189
|
+
continue; // skip malformed lines defensively
|
|
190
|
+
}
|
|
191
|
+
if (entry.type !== "user" && entry.type !== "assistant") continue;
|
|
192
|
+
if (!entry.uuid || !entry.message) continue;
|
|
193
|
+
parsed.push(parseSessionMessage(entry));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Merge tool_result-only user messages into preceding assistant
|
|
197
|
+
const merged: ChatMessage[] = [];
|
|
198
|
+
for (const msg of parsed) {
|
|
199
|
+
if (msg.events?.length && msg.events.every((e) => e.type === "tool_result")) {
|
|
200
|
+
const lastAssistant = [...merged].reverse().find((m) => m.role === "assistant");
|
|
201
|
+
if (lastAssistant?.events) {
|
|
202
|
+
lastAssistant.events.push(...msg.events);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
merged.push(msg);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
for (const msg of merged) {
|
|
210
|
+
if (msg.events) nestChildEvents(msg.events);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return merged.filter(
|
|
214
|
+
(msg) => msg.content.trim().length > 0 || (msg.events && msg.events.length > 0),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Rename existing skill files to `.bak-<timestamp>` before overwriting.
|
|
2
|
+
// Preserves earlier backups by embedding a UTC timestamp in the suffix.
|
|
3
|
+
import { existsSync, renameSync, readdirSync, statSync } from "node:fs";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
/** Produce a compact UTC timestamp like `202604211733` (YYYYMMDDHHmm). */
|
|
7
|
+
export function makeTimestamp(d: Date = new Date()): string {
|
|
8
|
+
return d.toISOString().replace(/[-:T]/g, "").slice(0, 12);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function backupExisting(targetDir: string, ts: string = makeTimestamp()): string[] {
|
|
12
|
+
if (!existsSync(targetDir)) return [];
|
|
13
|
+
const backedUp: string[] = [];
|
|
14
|
+
walkAndBackup(targetDir, ts, backedUp);
|
|
15
|
+
return backedUp;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function walkAndBackup(dir: string, ts: string, collected: string[]): void {
|
|
19
|
+
const entries = readdirSync(dir);
|
|
20
|
+
for (const name of entries) {
|
|
21
|
+
// Skip already-backed-up files to avoid `.md.bak-X.bak-Y` chains.
|
|
22
|
+
if (name.includes(".bak-")) continue;
|
|
23
|
+
const abs = resolve(dir, name);
|
|
24
|
+
const st = statSync(abs);
|
|
25
|
+
if (st.isDirectory()) {
|
|
26
|
+
walkAndBackup(abs, ts, collected);
|
|
27
|
+
} else if (st.isFile() && name.endsWith(".md")) {
|
|
28
|
+
const dest = `${abs}.bak-${ts}`;
|
|
29
|
+
renameSync(abs, dest);
|
|
30
|
+
collected.push(dest);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Recursive copy of the bundled skill package to a target directory.
|
|
2
|
+
// Skips any stale `.bak-*` files in source (defensive, should not occur in bundle).
|
|
3
|
+
import { cpSync, mkdirSync, existsSync, readdirSync, statSync, copyFileSync } from "node:fs";
|
|
4
|
+
import { resolve, relative } from "node:path";
|
|
5
|
+
|
|
6
|
+
export function copyBundledSkill(sourceDir: string, targetDir: string): string[] {
|
|
7
|
+
if (!existsSync(sourceDir)) {
|
|
8
|
+
throw new Error(`Source skill dir not found: ${sourceDir}`);
|
|
9
|
+
}
|
|
10
|
+
mkdirSync(targetDir, { recursive: true });
|
|
11
|
+
const copied: string[] = [];
|
|
12
|
+
walk(sourceDir, sourceDir, targetDir, copied);
|
|
13
|
+
return copied;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function walk(rootSrc: string, dir: string, targetRoot: string, collected: string[]): void {
|
|
17
|
+
for (const name of readdirSync(dir)) {
|
|
18
|
+
if (name.includes(".bak-")) continue;
|
|
19
|
+
const abs = resolve(dir, name);
|
|
20
|
+
const rel = relative(rootSrc, abs);
|
|
21
|
+
const dest = resolve(targetRoot, rel);
|
|
22
|
+
const st = statSync(abs);
|
|
23
|
+
if (st.isDirectory()) {
|
|
24
|
+
mkdirSync(dest, { recursive: true });
|
|
25
|
+
walk(rootSrc, abs, targetRoot, collected);
|
|
26
|
+
} else if (st.isFile()) {
|
|
27
|
+
copyFileSync(abs, dest);
|
|
28
|
+
collected.push(dest);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Fallback single-shot copy using Node 16.7+ cpSync (kept in case walk is bypassed).
|
|
34
|
+
export function copyTree(src: string, dest: string): void {
|
|
35
|
+
cpSync(src, dest, { recursive: true });
|
|
36
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Runtime: read the user's PPM SQLite config DB (readonly) and render a markdown schema doc.
|
|
2
|
+
// Never opens the DB read-write. Gracefully handles missing DB.
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { Database } from "bun:sqlite";
|
|
6
|
+
import { getPpmDir } from "../ppm-dir.ts";
|
|
7
|
+
|
|
8
|
+
interface ColumnInfo {
|
|
9
|
+
cid: number;
|
|
10
|
+
name: string;
|
|
11
|
+
type: string;
|
|
12
|
+
notnull: number;
|
|
13
|
+
dflt_value: string | null;
|
|
14
|
+
pk: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface TableRow {
|
|
18
|
+
name: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function generateDbSchemaMarkdown(dbPath?: string): string {
|
|
22
|
+
const path = dbPath ?? resolve(getPpmDir(), "ppm.db");
|
|
23
|
+
const header = "# PPM Database Schema\n\n_Auto-generated at install time from your local config DB._\n";
|
|
24
|
+
|
|
25
|
+
if (!existsSync(path)) {
|
|
26
|
+
return `${header}\n_Database not found at \`${path}\`. Run \`ppm init\` to create it._\n`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let db: Database | null = null;
|
|
30
|
+
try {
|
|
31
|
+
db = new Database(path, { readonly: true });
|
|
32
|
+
const tables = db
|
|
33
|
+
.query<TableRow, []>("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
|
34
|
+
.all();
|
|
35
|
+
|
|
36
|
+
if (tables.length === 0) {
|
|
37
|
+
return `${header}\n_No tables found in \`${path}\`._\n`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const parts: string[] = [header, ""];
|
|
41
|
+
parts.push(`Source: \`${path}\``);
|
|
42
|
+
parts.push("");
|
|
43
|
+
|
|
44
|
+
for (const t of tables) {
|
|
45
|
+
parts.push(`## ${t.name}`);
|
|
46
|
+
parts.push("");
|
|
47
|
+
const cols = db.query<ColumnInfo, []>(`PRAGMA table_info("${t.name}")`).all();
|
|
48
|
+
parts.push("| Column | Type | Nullable | PK | Default |");
|
|
49
|
+
parts.push("|---|---|---|---|---|");
|
|
50
|
+
for (const c of cols) {
|
|
51
|
+
const nullable = c.notnull === 0 ? "yes" : "no";
|
|
52
|
+
const pk = c.pk > 0 ? "yes" : "";
|
|
53
|
+
const def = c.dflt_value !== null ? `\`${c.dflt_value}\`` : "";
|
|
54
|
+
parts.push(`| \`${c.name}\` | \`${c.type || "—"}\` | ${nullable} | ${pk} | ${def} |`);
|
|
55
|
+
}
|
|
56
|
+
parts.push("");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return parts.join("\n") + "\n";
|
|
60
|
+
} catch (e) {
|
|
61
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
62
|
+
return `${header}\n_Failed to read database at \`${path}\`: ${msg}_\n`;
|
|
63
|
+
} finally {
|
|
64
|
+
db?.close();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Barrel re-exports for the skill-export service.
|
|
2
|
+
export { resolveTargetDir, type SkillScope, type ResolveTargetOpts } from "./resolve-target-dir.ts";
|
|
3
|
+
export { resolveAssetsDir } from "./resolve-assets-dir.ts";
|
|
4
|
+
export { backupExisting, makeTimestamp } from "./backup-existing.ts";
|
|
5
|
+
export { copyBundledSkill } from "./copy-bundled-skill.ts";
|
|
6
|
+
export { generateDbSchemaMarkdown } from "./generate-db-schema.ts";
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Resolve the bundled skill assets dir. Works both in dev (`bun src/index.ts`)
|
|
2
|
+
// and installed npm package. Throws a clear error if assets are missing.
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { resolve, dirname } from "node:path";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
|
|
7
|
+
const ASSETS_REL = "assets/skills/ppm";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Walks up from this file to find the repo/package root that contains
|
|
11
|
+
* `assets/skills/ppm/SKILL.md`. Covers both:
|
|
12
|
+
* - dev: src/services/skill-export/*.ts → repo root is ../../..
|
|
13
|
+
* - bundled: compiled binary walks up similarly when `bun build --compile` preserves structure
|
|
14
|
+
*/
|
|
15
|
+
export function resolveAssetsDir(): string {
|
|
16
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const candidates = [
|
|
18
|
+
resolve(here, "../../..", ASSETS_REL),
|
|
19
|
+
resolve(here, "../..", ASSETS_REL),
|
|
20
|
+
resolve(here, "..", ASSETS_REL),
|
|
21
|
+
resolve(process.cwd(), ASSETS_REL),
|
|
22
|
+
];
|
|
23
|
+
for (const c of candidates) {
|
|
24
|
+
if (existsSync(resolve(c, "SKILL.md"))) return c;
|
|
25
|
+
}
|
|
26
|
+
throw new Error(
|
|
27
|
+
`Bundled PPM skill assets not found. Searched:\n ${candidates.join(
|
|
28
|
+
"\n ",
|
|
29
|
+
)}\nRun \`bun run generate:skill\` (dev) or reinstall PPM.`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Resolve the install target directory based on scope/output flags.
|
|
2
|
+
// Precedence: --output > --scope project > --scope user (default).
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
|
|
6
|
+
export type SkillScope = "user" | "project";
|
|
7
|
+
|
|
8
|
+
export interface ResolveTargetOpts {
|
|
9
|
+
scope?: SkillScope;
|
|
10
|
+
output?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function resolveTargetDir(opts: ResolveTargetOpts): string {
|
|
14
|
+
if (opts.output) return resolve(opts.output);
|
|
15
|
+
if (opts.scope === "project") return resolve(process.cwd(), ".claude/skills/ppm");
|
|
16
|
+
return resolve(homedir(), ".claude/skills/ppm");
|
|
17
|
+
}
|
|
@@ -949,7 +949,8 @@ if (process.argv.includes("__supervise__")) {
|
|
|
949
949
|
const idx = process.argv.indexOf("__supervise__");
|
|
950
950
|
const port = parseInt(process.argv[idx + 1] ?? "8080", 10);
|
|
951
951
|
const host = process.argv[idx + 2] ?? "0.0.0.0";
|
|
952
|
-
const
|
|
952
|
+
const profileRaw = process.argv[idx + 3];
|
|
953
|
+
const profile = profileRaw && profileRaw !== "_" && !profileRaw.startsWith("--") ? profileRaw : undefined;
|
|
953
954
|
const share = process.argv.includes("--share");
|
|
954
955
|
|
|
955
956
|
// Set DB profile for supervisor (needed to read config)
|
|
@@ -88,6 +88,9 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
88
88
|
|
|
89
89
|
const {
|
|
90
90
|
messages,
|
|
91
|
+
renderedMessages,
|
|
92
|
+
expandCompact,
|
|
93
|
+
isCompactExpanded,
|
|
91
94
|
messagesLoading,
|
|
92
95
|
isStreaming,
|
|
93
96
|
phase,
|
|
@@ -384,7 +387,9 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
384
387
|
|
|
385
388
|
{/* Messages */}
|
|
386
389
|
<MessageList
|
|
387
|
-
messages={
|
|
390
|
+
messages={renderedMessages}
|
|
391
|
+
onExpandCompact={expandCompact}
|
|
392
|
+
isCompactExpanded={isCompactExpanded}
|
|
388
393
|
messagesLoading={messagesLoading}
|
|
389
394
|
pendingApproval={pendingApproval}
|
|
390
395
|
onApprovalResponse={respondToApproval}
|
|
@@ -5,6 +5,7 @@ import type { ChatMessage, ChatEvent } from "../../../types/chat";
|
|
|
5
5
|
import type { SessionPhase } from "../../../types/api";
|
|
6
6
|
import type { BashPartialEntry } from "../../hooks/use-chat";
|
|
7
7
|
import { ToolCard } from "./tool-cards";
|
|
8
|
+
import { extractJsonlPath, PreCompactButton, type PreCompactStatus } from "./pre-compact-button";
|
|
8
9
|
const MarkdownRenderer = lazy(() =>
|
|
9
10
|
import("@/components/shared/markdown-renderer").then((m) => ({ default: m.MarkdownRenderer }))
|
|
10
11
|
);
|
|
@@ -55,6 +56,10 @@ interface MessageListProps {
|
|
|
55
56
|
onSelectSession?: (session: import("../../../types/chat").SessionInfo) => void;
|
|
56
57
|
/** Partial bash output ref from useChat for real-time streaming */
|
|
57
58
|
bashPartialOutput?: React.RefObject<Map<string, BashPartialEntry>>;
|
|
59
|
+
/** Fetches pre-compact transcript and prepends messages. Returns loaded count. */
|
|
60
|
+
onExpandCompact?: (compactMessageId: string, jsonlPath: string) => Promise<number>;
|
|
61
|
+
/** Whether a given compact message has already been expanded. */
|
|
62
|
+
isCompactExpanded?: (compactMessageId: string) => boolean;
|
|
58
63
|
}
|
|
59
64
|
|
|
60
65
|
export function MessageList({
|
|
@@ -71,6 +76,8 @@ export function MessageList({
|
|
|
71
76
|
projectName,
|
|
72
77
|
onFork,
|
|
73
78
|
bashPartialOutput,
|
|
79
|
+
onExpandCompact,
|
|
80
|
+
isCompactExpanded,
|
|
74
81
|
}: MessageListProps) {
|
|
75
82
|
// Scroll handled by StickToBottom wrapper — no manual scroll logic needed
|
|
76
83
|
|
|
@@ -102,6 +109,15 @@ export function MessageList({
|
|
|
102
109
|
onFork?.(msgContent, msgId);
|
|
103
110
|
}, [onFork]);
|
|
104
111
|
|
|
112
|
+
// Wrap expandCompact: bump visibleCount by loaded count so expansion is immediately visible
|
|
113
|
+
// in the paginated view (pre-compact messages land at top of flattened array, above pagination window).
|
|
114
|
+
const handleExpandCompact = useCallback(async (compactId: string, jsonlPath: string): Promise<number> => {
|
|
115
|
+
if (!onExpandCompact) throw new Error("Expansion not wired");
|
|
116
|
+
const count = await onExpandCompact(compactId, jsonlPath);
|
|
117
|
+
setVisibleCount((c) => c + count);
|
|
118
|
+
return count;
|
|
119
|
+
}, [onExpandCompact]);
|
|
120
|
+
|
|
105
121
|
if (messagesLoading) {
|
|
106
122
|
return (
|
|
107
123
|
<div className="flex flex-col items-center justify-center h-full gap-3 text-text-secondary">
|
|
@@ -122,8 +138,8 @@ export function MessageList({
|
|
|
122
138
|
|
|
123
139
|
return (
|
|
124
140
|
<div className="relative flex-1 overflow-hidden flex flex-col min-h-0">
|
|
125
|
-
<StickToBottom className="flex-1 overflow-y-auto overflow-x-hidden [contain:strict]" resize="smooth" initial="instant">
|
|
126
|
-
<StickToBottom.Content className="p-4 space-y-4 select-none">
|
|
141
|
+
<StickToBottom className="flex-1 overflow-y-auto overflow-x-hidden [contain:strict] [overflow-anchor:auto]" resize="smooth" initial="instant">
|
|
142
|
+
<StickToBottom.Content className="p-4 space-y-4 select-none [&>*]:[overflow-anchor:auto]">
|
|
127
143
|
{hasMore && (
|
|
128
144
|
<button onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}
|
|
129
145
|
className="w-full py-2 text-xs text-text-secondary hover:text-text-primary bg-surface-elevated/50 hover:bg-surface-elevated rounded-md border border-border/50 transition-colors">
|
|
@@ -142,6 +158,8 @@ export function MessageList({
|
|
|
142
158
|
onFork={msg.role === "user" && onFork ? handleFork : undefined}
|
|
143
159
|
prevMsgId={prevMsg?.sdkUuid ?? prevMsg?.id}
|
|
144
160
|
bashPartialOutput={bashPartialOutput}
|
|
161
|
+
onExpandCompact={handleExpandCompact}
|
|
162
|
+
isCompactExpanded={isCompactExpanded}
|
|
145
163
|
/>
|
|
146
164
|
);
|
|
147
165
|
})}
|
|
@@ -176,16 +194,25 @@ function ScrollToBottomButton() {
|
|
|
176
194
|
);
|
|
177
195
|
}
|
|
178
196
|
|
|
179
|
-
const MessageBubble = memo(function MessageBubble({ message, isStreaming, projectName, onFork, prevMsgId, bashPartialOutput }: {
|
|
197
|
+
const MessageBubble = memo(function MessageBubble({ message, isStreaming, projectName, onFork, prevMsgId, bashPartialOutput, onExpandCompact, isCompactExpanded }: {
|
|
180
198
|
message: ChatMessage; isStreaming: boolean; projectName?: string;
|
|
181
199
|
onFork?: (content: string, messageId: string | undefined) => void;
|
|
182
200
|
prevMsgId?: string;
|
|
183
201
|
bashPartialOutput?: React.RefObject<Map<string, BashPartialEntry>>;
|
|
202
|
+
onExpandCompact?: (compactMessageId: string, jsonlPath: string) => Promise<number>;
|
|
203
|
+
isCompactExpanded?: (compactMessageId: string) => boolean;
|
|
184
204
|
}) {
|
|
185
205
|
if (message.role === "user") {
|
|
186
206
|
const handleFork = onFork ? () => onFork(message.content, prevMsgId) : undefined;
|
|
187
207
|
return (
|
|
188
|
-
<UserBubble
|
|
208
|
+
<UserBubble
|
|
209
|
+
content={message.content}
|
|
210
|
+
messageId={message.id}
|
|
211
|
+
projectName={projectName}
|
|
212
|
+
onFork={handleFork}
|
|
213
|
+
onExpandCompact={onExpandCompact}
|
|
214
|
+
isCompactExpanded={isCompactExpanded}
|
|
215
|
+
/>
|
|
189
216
|
);
|
|
190
217
|
}
|
|
191
218
|
|
|
@@ -202,7 +229,7 @@ const MessageBubble = memo(function MessageBubble({ message, isStreaming, projec
|
|
|
202
229
|
return (
|
|
203
230
|
<div className="flex flex-col gap-2">
|
|
204
231
|
{message.events && message.events.length > 0
|
|
205
|
-
? <InterleavedEvents events={message.events} isStreaming={isStreaming} projectName={projectName} bashPartialOutput={bashPartialOutput} />
|
|
232
|
+
? <InterleavedEvents events={message.events} isStreaming={isStreaming} projectName={projectName} bashPartialOutput={bashPartialOutput} messageId={message.id} onExpandCompact={onExpandCompact} isCompactExpanded={isCompactExpanded} />
|
|
206
233
|
: message.content && (
|
|
207
234
|
<div className="text-sm text-text-primary select-text">
|
|
208
235
|
<MarkdownContent content={message.content} projectName={projectName} />
|
|
@@ -309,14 +336,38 @@ function isPdfPath(path: string): boolean {
|
|
|
309
336
|
const SYSTEM_TAG_NAMES = new Set(["task-notification", "environment_details"]);
|
|
310
337
|
|
|
311
338
|
/** User message bubble — full width, collapsible, with system tag badges */
|
|
312
|
-
function UserBubble({ content,
|
|
313
|
-
|
|
339
|
+
function UserBubble({ content, messageId, projectName, onFork, onExpandCompact, isCompactExpanded }: {
|
|
340
|
+
content: string;
|
|
341
|
+
messageId?: string;
|
|
342
|
+
projectName?: string;
|
|
343
|
+
onFork?: () => void;
|
|
344
|
+
onExpandCompact?: (compactMessageId: string, jsonlPath: string) => Promise<number>;
|
|
345
|
+
isCompactExpanded?: (compactMessageId: string) => boolean;
|
|
346
|
+
}) {
|
|
347
|
+
const { files, text, tags, command, jsonlPath } = useMemo(() => {
|
|
314
348
|
const parsed = parseUserAttachments(content);
|
|
315
349
|
const { cleanText: noSysTags, tags } = extractSystemTags(parsed.text);
|
|
316
350
|
const { command, cleanText } = parseCommandTags(noSysTags);
|
|
317
|
-
return { files: parsed.files, text: cleanText, tags, command };
|
|
351
|
+
return { files: parsed.files, text: cleanText, tags, command, jsonlPath: extractJsonlPath(cleanText) };
|
|
318
352
|
}, [content]);
|
|
319
353
|
|
|
354
|
+
// Pre-compact expansion state — local per button instance
|
|
355
|
+
const [preCompactStatus, setPreCompactStatus] = useState<PreCompactStatus>(() =>
|
|
356
|
+
messageId && isCompactExpanded?.(messageId) ? "loaded" : "idle",
|
|
357
|
+
);
|
|
358
|
+
const [preCompactCount, setPreCompactCount] = useState<number | undefined>();
|
|
359
|
+
const handleExpand = useCallback(async () => {
|
|
360
|
+
if (!jsonlPath || !messageId || !onExpandCompact) return;
|
|
361
|
+
setPreCompactStatus("loading");
|
|
362
|
+
try {
|
|
363
|
+
const count = await onExpandCompact(messageId, jsonlPath);
|
|
364
|
+
setPreCompactCount(count);
|
|
365
|
+
setPreCompactStatus("loaded");
|
|
366
|
+
} catch {
|
|
367
|
+
setPreCompactStatus("error");
|
|
368
|
+
}
|
|
369
|
+
}, [jsonlPath, messageId, onExpandCompact]);
|
|
370
|
+
|
|
320
371
|
const isSystemContext = tags.some((t) => SYSTEM_TAG_NAMES.has(t.name));
|
|
321
372
|
|
|
322
373
|
const [expanded, setExpanded] = useState(false);
|
|
@@ -399,6 +450,15 @@ function UserBubble({ content, projectName, onFork }: { content: string; project
|
|
|
399
450
|
{expanded ? <><ChevronUp className="size-3" />Show less</> : <><ChevronDown className="size-3" />Show more</>}
|
|
400
451
|
</button>
|
|
401
452
|
)}
|
|
453
|
+
{/* Expand compacted conversation: detect JSONL path in compact summary user message.
|
|
454
|
+
Prepends pre-compact messages into main flattened list (see useChat.expandCompact). */}
|
|
455
|
+
{jsonlPath && messageId && onExpandCompact && (
|
|
456
|
+
<PreCompactButton
|
|
457
|
+
status={preCompactStatus}
|
|
458
|
+
onLoad={preCompactStatus === "idle" || preCompactStatus === "error" ? handleExpand : undefined}
|
|
459
|
+
count={preCompactCount}
|
|
460
|
+
/>
|
|
461
|
+
)}
|
|
402
462
|
{/* Fork/Rewind button — only for real user messages */}
|
|
403
463
|
{!isSystemContext && onFork && (
|
|
404
464
|
<button
|
|
@@ -673,7 +733,31 @@ type EventGroup =
|
|
|
673
733
|
| { kind: "thinking"; content: string }
|
|
674
734
|
| { kind: "tool"; tool: ChatEvent; result?: ChatEvent; completed?: boolean };
|
|
675
735
|
|
|
676
|
-
function InterleavedEvents({ events, isStreaming, projectName, bashPartialOutput
|
|
736
|
+
function InterleavedEvents({ events, isStreaming, projectName, bashPartialOutput, messageId, onExpandCompact, isCompactExpanded }: {
|
|
737
|
+
events: ChatEvent[];
|
|
738
|
+
isStreaming: boolean;
|
|
739
|
+
projectName?: string;
|
|
740
|
+
bashPartialOutput?: React.RefObject<Map<string, BashPartialEntry>>;
|
|
741
|
+
messageId?: string;
|
|
742
|
+
onExpandCompact?: (compactMessageId: string, jsonlPath: string) => Promise<number>;
|
|
743
|
+
isCompactExpanded?: (compactMessageId: string) => boolean;
|
|
744
|
+
}) {
|
|
745
|
+
// Local state for the /compact slash-command path (assistant-authored summary)
|
|
746
|
+
const [preCompactStatus, setPreCompactStatus] = useState<PreCompactStatus>(() =>
|
|
747
|
+
messageId && isCompactExpanded?.(messageId) ? "loaded" : "idle",
|
|
748
|
+
);
|
|
749
|
+
const [preCompactCount, setPreCompactCount] = useState<number | undefined>();
|
|
750
|
+
const handleExpand = useCallback(async (jsonlPath: string) => {
|
|
751
|
+
if (!messageId || !onExpandCompact) return;
|
|
752
|
+
setPreCompactStatus("loading");
|
|
753
|
+
try {
|
|
754
|
+
const count = await onExpandCompact(messageId, jsonlPath);
|
|
755
|
+
setPreCompactCount(count);
|
|
756
|
+
setPreCompactStatus("loaded");
|
|
757
|
+
} catch {
|
|
758
|
+
setPreCompactStatus("error");
|
|
759
|
+
}
|
|
760
|
+
}, [messageId, onExpandCompact]);
|
|
677
761
|
// Group: consecutive text → merged text block; tool_use + tool_result paired by toolUseId
|
|
678
762
|
const groups: EventGroup[] = [];
|
|
679
763
|
let textBuffer = "";
|
|
@@ -788,9 +872,17 @@ function InterleavedEvents({ events, isStreaming, projectName, bashPartialOutput
|
|
|
788
872
|
}
|
|
789
873
|
if (group.kind === "text") {
|
|
790
874
|
const isLast = isStreaming && i === groups.length - 1;
|
|
875
|
+
const jsonlPath = extractJsonlPath(group.content);
|
|
791
876
|
return (
|
|
792
877
|
<div key={`text-${i}`} className="text-sm text-text-primary select-text">
|
|
793
878
|
<StreamingText content={group.content} animate={isLast} projectName={projectName} />
|
|
879
|
+
{jsonlPath && messageId && onExpandCompact && (
|
|
880
|
+
<PreCompactButton
|
|
881
|
+
status={preCompactStatus}
|
|
882
|
+
onLoad={preCompactStatus === "idle" || preCompactStatus === "error" ? () => handleExpand(jsonlPath) : undefined}
|
|
883
|
+
count={preCompactCount}
|
|
884
|
+
/>
|
|
885
|
+
)}
|
|
794
886
|
</div>
|
|
795
887
|
);
|
|
796
888
|
}
|