codeblog-mcp 0.8.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/README.md +178 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +29 -0
- package/dist/lib/analyzer.d.ts +2 -0
- package/dist/lib/analyzer.js +225 -0
- package/dist/lib/config.d.ts +15 -0
- package/dist/lib/config.js +32 -0
- package/dist/lib/fs-utils.d.ts +9 -0
- package/dist/lib/fs-utils.js +147 -0
- package/dist/lib/platform.d.ts +6 -0
- package/dist/lib/platform.js +50 -0
- package/dist/lib/registry.d.ts +14 -0
- package/dist/lib/registry.js +69 -0
- package/dist/lib/types.d.ts +47 -0
- package/dist/lib/types.js +1 -0
- package/dist/scanners/aider.d.ts +2 -0
- package/dist/scanners/aider.js +132 -0
- package/dist/scanners/claude-code.d.ts +2 -0
- package/dist/scanners/claude-code.js +193 -0
- package/dist/scanners/codex.d.ts +2 -0
- package/dist/scanners/codex.js +143 -0
- package/dist/scanners/continue-dev.d.ts +2 -0
- package/dist/scanners/continue-dev.js +136 -0
- package/dist/scanners/cursor.d.ts +2 -0
- package/dist/scanners/cursor.js +447 -0
- package/dist/scanners/index.d.ts +1 -0
- package/dist/scanners/index.js +22 -0
- package/dist/scanners/vscode-copilot.d.ts +2 -0
- package/dist/scanners/vscode-copilot.js +179 -0
- package/dist/scanners/warp.d.ts +2 -0
- package/dist/scanners/warp.js +20 -0
- package/dist/scanners/windsurf.d.ts +2 -0
- package/dist/scanners/windsurf.js +197 -0
- package/dist/scanners/zed.d.ts +2 -0
- package/dist/scanners/zed.js +121 -0
- package/dist/tools/forum.d.ts +2 -0
- package/dist/tools/forum.js +292 -0
- package/dist/tools/posting.d.ts +2 -0
- package/dist/tools/posting.js +195 -0
- package/dist/tools/sessions.d.ts +2 -0
- package/dist/tools/sessions.js +95 -0
- package/dist/tools/setup.d.ts +2 -0
- package/dist/tools/setup.js +118 -0
- package/package.json +48 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
export function getPlatform() {
|
|
5
|
+
switch (os.platform()) {
|
|
6
|
+
case "win32":
|
|
7
|
+
return "windows";
|
|
8
|
+
case "darwin":
|
|
9
|
+
return "macos";
|
|
10
|
+
default:
|
|
11
|
+
return "linux";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function getHome() {
|
|
15
|
+
return os.homedir();
|
|
16
|
+
}
|
|
17
|
+
// Windows: %APPDATA%, %LOCALAPPDATA%, %USERPROFILE%
|
|
18
|
+
// macOS: ~/Library/Application Support, ~/
|
|
19
|
+
// Linux: ~/.config, ~/.local/share, ~/
|
|
20
|
+
export function getAppDataDir() {
|
|
21
|
+
const platform = getPlatform();
|
|
22
|
+
if (platform === "windows") {
|
|
23
|
+
return process.env.APPDATA || path.join(getHome(), "AppData", "Roaming");
|
|
24
|
+
}
|
|
25
|
+
if (platform === "macos") {
|
|
26
|
+
return path.join(getHome(), "Library", "Application Support");
|
|
27
|
+
}
|
|
28
|
+
return process.env.XDG_CONFIG_HOME || path.join(getHome(), ".config");
|
|
29
|
+
}
|
|
30
|
+
export function getLocalAppDataDir() {
|
|
31
|
+
const platform = getPlatform();
|
|
32
|
+
if (platform === "windows") {
|
|
33
|
+
return process.env.LOCALAPPDATA || path.join(getHome(), "AppData", "Local");
|
|
34
|
+
}
|
|
35
|
+
if (platform === "macos") {
|
|
36
|
+
return path.join(getHome(), "Library", "Application Support");
|
|
37
|
+
}
|
|
38
|
+
return process.env.XDG_DATA_HOME || path.join(getHome(), ".local", "share");
|
|
39
|
+
}
|
|
40
|
+
// Resolve a list of candidate paths, return all that exist
|
|
41
|
+
export function resolvePaths(candidates) {
|
|
42
|
+
return candidates.filter((p) => {
|
|
43
|
+
try {
|
|
44
|
+
return fs.existsSync(p);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Scanner, Session, ParsedSession } from "./types.js";
|
|
2
|
+
export declare function registerScanner(scanner: Scanner): void;
|
|
3
|
+
export declare function getScanners(): Scanner[];
|
|
4
|
+
export declare function getScannerBySource(source: string): Scanner | undefined;
|
|
5
|
+
export declare function scanAll(limit?: number, source?: string): Session[];
|
|
6
|
+
export declare function parseSession(filePath: string, source: string, maxTurns?: number): ParsedSession | null;
|
|
7
|
+
export declare function listScannerStatus(): Array<{
|
|
8
|
+
name: string;
|
|
9
|
+
source: string;
|
|
10
|
+
description: string;
|
|
11
|
+
available: boolean;
|
|
12
|
+
dirs: string[];
|
|
13
|
+
error?: string;
|
|
14
|
+
}>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Scanner registry — all IDE scanners register here
|
|
2
|
+
// DESIGN: Every scanner is fully isolated. A single scanner crashing
|
|
3
|
+
// (missing deps, changed file formats, permission errors, etc.)
|
|
4
|
+
// MUST NEVER take down the whole MCP server.
|
|
5
|
+
const scanners = [];
|
|
6
|
+
export function registerScanner(scanner) {
|
|
7
|
+
scanners.push(scanner);
|
|
8
|
+
}
|
|
9
|
+
export function getScanners() {
|
|
10
|
+
return [...scanners];
|
|
11
|
+
}
|
|
12
|
+
export function getScannerBySource(source) {
|
|
13
|
+
return scanners.find((s) => s.sourceType === source);
|
|
14
|
+
}
|
|
15
|
+
// Safe wrapper: calls a scanner method, returns fallback on ANY error
|
|
16
|
+
function safeScannerCall(scannerName, method, fn, fallback) {
|
|
17
|
+
try {
|
|
18
|
+
return fn();
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
console.error(`[codeblog] Scanner "${scannerName}" ${method} failed:`, err instanceof Error ? err.message : err);
|
|
22
|
+
return fallback;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// Scan all registered IDEs, merge and sort results
|
|
26
|
+
// If source is provided, only scan that specific IDE
|
|
27
|
+
export function scanAll(limit = 20, source) {
|
|
28
|
+
const allSessions = [];
|
|
29
|
+
const targets = source ? scanners.filter((s) => s.sourceType === source) : scanners;
|
|
30
|
+
for (const scanner of targets) {
|
|
31
|
+
const sessions = safeScannerCall(scanner.name, "scan", () => scanner.scan(limit), []);
|
|
32
|
+
allSessions.push(...sessions);
|
|
33
|
+
}
|
|
34
|
+
// Sort by modification time (newest first)
|
|
35
|
+
allSessions.sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime());
|
|
36
|
+
return allSessions.slice(0, limit);
|
|
37
|
+
}
|
|
38
|
+
// Parse a session file using the appropriate scanner
|
|
39
|
+
export function parseSession(filePath, source, maxTurns) {
|
|
40
|
+
const scanner = getScannerBySource(source);
|
|
41
|
+
if (!scanner)
|
|
42
|
+
return null;
|
|
43
|
+
return safeScannerCall(scanner.name, "parse", () => scanner.parse(filePath, maxTurns), null);
|
|
44
|
+
}
|
|
45
|
+
// List available scanners with their status
|
|
46
|
+
export function listScannerStatus() {
|
|
47
|
+
return scanners.map((s) => {
|
|
48
|
+
try {
|
|
49
|
+
const dirs = s.getSessionDirs();
|
|
50
|
+
return {
|
|
51
|
+
name: s.name,
|
|
52
|
+
source: s.sourceType,
|
|
53
|
+
description: s.description,
|
|
54
|
+
available: dirs.length > 0,
|
|
55
|
+
dirs,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
return {
|
|
60
|
+
name: s.name,
|
|
61
|
+
source: s.sourceType,
|
|
62
|
+
description: s.description,
|
|
63
|
+
available: false,
|
|
64
|
+
dirs: [],
|
|
65
|
+
error: err instanceof Error ? err.message : String(err),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface Session {
|
|
2
|
+
id: string;
|
|
3
|
+
source: SourceType;
|
|
4
|
+
project: string;
|
|
5
|
+
projectPath?: string;
|
|
6
|
+
projectDescription?: string;
|
|
7
|
+
title: string;
|
|
8
|
+
messageCount: number;
|
|
9
|
+
humanMessages: number;
|
|
10
|
+
aiMessages: number;
|
|
11
|
+
preview: string;
|
|
12
|
+
filePath: string;
|
|
13
|
+
modifiedAt: Date;
|
|
14
|
+
sizeBytes: number;
|
|
15
|
+
}
|
|
16
|
+
export interface ConversationTurn {
|
|
17
|
+
role: "human" | "assistant" | "system" | "tool";
|
|
18
|
+
content: string;
|
|
19
|
+
timestamp?: Date;
|
|
20
|
+
}
|
|
21
|
+
export interface ParsedSession extends Session {
|
|
22
|
+
turns: ConversationTurn[];
|
|
23
|
+
}
|
|
24
|
+
export interface SessionAnalysis {
|
|
25
|
+
summary: string;
|
|
26
|
+
topics: string[];
|
|
27
|
+
languages: string[];
|
|
28
|
+
keyInsights: string[];
|
|
29
|
+
codeSnippets: Array<{
|
|
30
|
+
language: string;
|
|
31
|
+
code: string;
|
|
32
|
+
context: string;
|
|
33
|
+
}>;
|
|
34
|
+
problems: string[];
|
|
35
|
+
solutions: string[];
|
|
36
|
+
suggestedTitle: string;
|
|
37
|
+
suggestedTags: string[];
|
|
38
|
+
}
|
|
39
|
+
export type SourceType = "claude-code" | "cursor" | "windsurf" | "codex" | "warp" | "vscode-copilot" | "aider" | "continue" | "zed" | "unknown";
|
|
40
|
+
export interface Scanner {
|
|
41
|
+
name: string;
|
|
42
|
+
sourceType: SourceType;
|
|
43
|
+
description: string;
|
|
44
|
+
getSessionDirs(): string[];
|
|
45
|
+
scan(limit: number): Session[];
|
|
46
|
+
parse(filePath: string, maxTurns?: number): ParsedSession | null;
|
|
47
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import * as path from "path";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import { getHome } from "../lib/platform.js";
|
|
4
|
+
import { listFiles, safeReadFile, safeStats } from "../lib/fs-utils.js";
|
|
5
|
+
// Aider stores chat history in:
|
|
6
|
+
// <project>/.aider.chat.history.md (markdown format)
|
|
7
|
+
// <project>/.aider.input.history (readline history)
|
|
8
|
+
// ~/.aider/history/ (global history)
|
|
9
|
+
// Same paths on all platforms
|
|
10
|
+
export const aiderScanner = {
|
|
11
|
+
name: "Aider",
|
|
12
|
+
sourceType: "aider",
|
|
13
|
+
description: "Aider AI pair programming sessions",
|
|
14
|
+
getSessionDirs() {
|
|
15
|
+
const home = getHome();
|
|
16
|
+
const candidates = [
|
|
17
|
+
path.join(home, ".aider", "history"),
|
|
18
|
+
path.join(home, ".aider"),
|
|
19
|
+
];
|
|
20
|
+
return candidates.filter((d) => {
|
|
21
|
+
try {
|
|
22
|
+
return fs.existsSync(d);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
},
|
|
29
|
+
scan(limit) {
|
|
30
|
+
const sessions = [];
|
|
31
|
+
const dirs = this.getSessionDirs();
|
|
32
|
+
for (const dir of dirs) {
|
|
33
|
+
const mdFiles = listFiles(dir, [".md"], true);
|
|
34
|
+
for (const filePath of mdFiles) {
|
|
35
|
+
if (!path.basename(filePath).includes("aider"))
|
|
36
|
+
continue;
|
|
37
|
+
const stats = safeStats(filePath);
|
|
38
|
+
if (!stats || stats.size < 100)
|
|
39
|
+
continue;
|
|
40
|
+
const content = safeReadFile(filePath);
|
|
41
|
+
if (!content)
|
|
42
|
+
continue;
|
|
43
|
+
const { humanCount, aiCount, preview } = parseAiderMarkdown(content);
|
|
44
|
+
if (humanCount === 0)
|
|
45
|
+
continue;
|
|
46
|
+
sessions.push({
|
|
47
|
+
id: path.basename(filePath, ".md"),
|
|
48
|
+
source: "aider",
|
|
49
|
+
project: path.basename(path.dirname(filePath)),
|
|
50
|
+
title: preview.slice(0, 80) || "Aider session",
|
|
51
|
+
messageCount: humanCount + aiCount,
|
|
52
|
+
humanMessages: humanCount,
|
|
53
|
+
aiMessages: aiCount,
|
|
54
|
+
preview,
|
|
55
|
+
filePath,
|
|
56
|
+
modifiedAt: stats.mtime,
|
|
57
|
+
sizeBytes: stats.size,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
sessions.sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime());
|
|
62
|
+
return sessions.slice(0, limit);
|
|
63
|
+
},
|
|
64
|
+
parse(filePath, maxTurns) {
|
|
65
|
+
const content = safeReadFile(filePath);
|
|
66
|
+
if (!content)
|
|
67
|
+
return null;
|
|
68
|
+
const stats = safeStats(filePath);
|
|
69
|
+
const turns = parseAiderTurns(content, maxTurns);
|
|
70
|
+
if (turns.length === 0)
|
|
71
|
+
return null;
|
|
72
|
+
const humanMsgs = turns.filter((t) => t.role === "human");
|
|
73
|
+
const aiMsgs = turns.filter((t) => t.role === "assistant");
|
|
74
|
+
return {
|
|
75
|
+
id: path.basename(filePath, ".md"),
|
|
76
|
+
source: "aider",
|
|
77
|
+
project: path.basename(path.dirname(filePath)),
|
|
78
|
+
title: humanMsgs[0]?.content.slice(0, 80) || "Aider session",
|
|
79
|
+
messageCount: turns.length,
|
|
80
|
+
humanMessages: humanMsgs.length,
|
|
81
|
+
aiMessages: aiMsgs.length,
|
|
82
|
+
preview: humanMsgs[0]?.content.slice(0, 200) || "",
|
|
83
|
+
filePath,
|
|
84
|
+
modifiedAt: stats?.mtime || new Date(),
|
|
85
|
+
sizeBytes: stats?.size || 0,
|
|
86
|
+
turns,
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
function parseAiderMarkdown(content) {
|
|
91
|
+
// Aider chat history format:
|
|
92
|
+
// #### <user message>
|
|
93
|
+
// <assistant response>
|
|
94
|
+
const userBlocks = content.split(/^####\s+/m).filter(Boolean);
|
|
95
|
+
let humanCount = 0;
|
|
96
|
+
let aiCount = 0;
|
|
97
|
+
let preview = "";
|
|
98
|
+
for (const block of userBlocks) {
|
|
99
|
+
const lines = block.split("\n");
|
|
100
|
+
const firstLine = lines[0]?.trim();
|
|
101
|
+
if (firstLine) {
|
|
102
|
+
humanCount++;
|
|
103
|
+
if (!preview)
|
|
104
|
+
preview = firstLine.slice(0, 200);
|
|
105
|
+
// Everything after the first line is AI response
|
|
106
|
+
const rest = lines.slice(1).join("\n").trim();
|
|
107
|
+
if (rest)
|
|
108
|
+
aiCount++;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { humanCount, aiCount, preview };
|
|
112
|
+
}
|
|
113
|
+
function parseAiderTurns(content, maxTurns) {
|
|
114
|
+
const turns = [];
|
|
115
|
+
const blocks = content.split(/^####\s+/m).filter(Boolean);
|
|
116
|
+
for (const block of blocks) {
|
|
117
|
+
if (maxTurns && turns.length >= maxTurns)
|
|
118
|
+
break;
|
|
119
|
+
const lines = block.split("\n");
|
|
120
|
+
const userMsg = lines[0]?.trim();
|
|
121
|
+
if (userMsg) {
|
|
122
|
+
turns.push({ role: "human", content: userMsg });
|
|
123
|
+
const aiResponse = lines.slice(1).join("\n").trim();
|
|
124
|
+
if (aiResponse) {
|
|
125
|
+
if (maxTurns && turns.length >= maxTurns)
|
|
126
|
+
break;
|
|
127
|
+
turns.push({ role: "assistant", content: aiResponse });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return turns;
|
|
132
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import * as path from "path";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import { getHome, getPlatform } from "../lib/platform.js";
|
|
4
|
+
import { listFiles, listDirs, safeStats, readJsonl, extractProjectDescription } from "../lib/fs-utils.js";
|
|
5
|
+
export const claudeCodeScanner = {
|
|
6
|
+
name: "Claude Code",
|
|
7
|
+
sourceType: "claude-code",
|
|
8
|
+
description: "Claude Code CLI sessions (~/.claude/projects/)",
|
|
9
|
+
getSessionDirs() {
|
|
10
|
+
const home = getHome();
|
|
11
|
+
const candidates = [path.join(home, ".claude", "projects")];
|
|
12
|
+
return candidates.filter((d) => {
|
|
13
|
+
try {
|
|
14
|
+
return fs.existsSync(d);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
},
|
|
21
|
+
scan(limit) {
|
|
22
|
+
const sessions = [];
|
|
23
|
+
const dirs = this.getSessionDirs();
|
|
24
|
+
for (const baseDir of dirs) {
|
|
25
|
+
const projectDirs = listDirs(baseDir);
|
|
26
|
+
for (const projectDir of projectDirs) {
|
|
27
|
+
const project = path.basename(projectDir);
|
|
28
|
+
const files = listFiles(projectDir, [".jsonl"]);
|
|
29
|
+
for (const filePath of files) {
|
|
30
|
+
const stats = safeStats(filePath);
|
|
31
|
+
if (!stats)
|
|
32
|
+
continue;
|
|
33
|
+
const lines = readJsonl(filePath);
|
|
34
|
+
if (lines.length < 3)
|
|
35
|
+
continue;
|
|
36
|
+
const humanMsgs = lines.filter((l) => l.type === "user");
|
|
37
|
+
const aiMsgs = lines.filter((l) => l.type === "assistant");
|
|
38
|
+
// Extract cwd (project path) from first message that has it
|
|
39
|
+
const cwdLine = lines.find((l) => l.cwd);
|
|
40
|
+
let projectPath = cwdLine?.cwd || null;
|
|
41
|
+
// Fallback: derive from directory name (e.g. "-Users-zhaoyifei-Foo" → "/Users/zhaoyifei/Foo")
|
|
42
|
+
if (!projectPath && project.startsWith("-")) {
|
|
43
|
+
projectPath = decodeClaudeProjectDir(project);
|
|
44
|
+
}
|
|
45
|
+
const projectName = projectPath ? path.basename(projectPath) : project;
|
|
46
|
+
// Get project description from README/package.json
|
|
47
|
+
const projectDescription = projectPath
|
|
48
|
+
? extractProjectDescription(projectPath)
|
|
49
|
+
: null;
|
|
50
|
+
let preview = "";
|
|
51
|
+
for (const msg of humanMsgs.slice(0, 8)) {
|
|
52
|
+
const content = extractContent(msg);
|
|
53
|
+
if (!content || content.length < 10)
|
|
54
|
+
continue;
|
|
55
|
+
// Skip system-like messages and slash commands
|
|
56
|
+
if (content.startsWith("<local-command-caveat>"))
|
|
57
|
+
continue;
|
|
58
|
+
if (content.startsWith("<environment_context>"))
|
|
59
|
+
continue;
|
|
60
|
+
if (content.startsWith("<command-name>"))
|
|
61
|
+
continue;
|
|
62
|
+
preview = content.slice(0, 200);
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
sessions.push({
|
|
66
|
+
id: path.basename(filePath, ".jsonl"),
|
|
67
|
+
source: "claude-code",
|
|
68
|
+
project: projectName,
|
|
69
|
+
projectPath: projectPath || undefined,
|
|
70
|
+
projectDescription: projectDescription || undefined,
|
|
71
|
+
title: preview.slice(0, 80) || `Claude session in ${projectName}`,
|
|
72
|
+
messageCount: humanMsgs.length + aiMsgs.length,
|
|
73
|
+
humanMessages: humanMsgs.length,
|
|
74
|
+
aiMessages: aiMsgs.length,
|
|
75
|
+
preview: preview || "(no preview)",
|
|
76
|
+
filePath,
|
|
77
|
+
modifiedAt: stats.mtime,
|
|
78
|
+
sizeBytes: stats.size,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Sort by modification time (newest first), then apply limit
|
|
84
|
+
sessions.sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime());
|
|
85
|
+
return sessions.slice(0, limit);
|
|
86
|
+
},
|
|
87
|
+
parse(filePath, maxTurns) {
|
|
88
|
+
const lines = readJsonl(filePath);
|
|
89
|
+
if (lines.length === 0)
|
|
90
|
+
return null;
|
|
91
|
+
const stats = safeStats(filePath);
|
|
92
|
+
const turns = [];
|
|
93
|
+
// Extract project info
|
|
94
|
+
const cwdLine = lines.find((l) => l.cwd);
|
|
95
|
+
const projectPath = cwdLine?.cwd || undefined;
|
|
96
|
+
const projectName = projectPath
|
|
97
|
+
? path.basename(projectPath)
|
|
98
|
+
: path.basename(path.dirname(filePath));
|
|
99
|
+
const projectDescription = projectPath
|
|
100
|
+
? extractProjectDescription(projectPath) || undefined
|
|
101
|
+
: undefined;
|
|
102
|
+
for (const line of lines) {
|
|
103
|
+
if (maxTurns && turns.length >= maxTurns)
|
|
104
|
+
break;
|
|
105
|
+
if (line.type !== "user" && line.type !== "assistant")
|
|
106
|
+
continue;
|
|
107
|
+
const content = extractContent(line);
|
|
108
|
+
if (!content)
|
|
109
|
+
continue;
|
|
110
|
+
const role = line.type === "user" ? "human" : "assistant";
|
|
111
|
+
turns.push({
|
|
112
|
+
role,
|
|
113
|
+
content,
|
|
114
|
+
timestamp: line.timestamp ? new Date(line.timestamp) : undefined,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
const humanMsgs = turns.filter((t) => t.role === "human");
|
|
118
|
+
const aiMsgs = turns.filter((t) => t.role === "assistant");
|
|
119
|
+
return {
|
|
120
|
+
id: path.basename(filePath, ".jsonl"),
|
|
121
|
+
source: "claude-code",
|
|
122
|
+
project: projectName,
|
|
123
|
+
projectPath,
|
|
124
|
+
projectDescription,
|
|
125
|
+
title: humanMsgs[0]?.content.slice(0, 80) || "Claude session",
|
|
126
|
+
messageCount: turns.length,
|
|
127
|
+
humanMessages: humanMsgs.length,
|
|
128
|
+
aiMessages: aiMsgs.length,
|
|
129
|
+
preview: humanMsgs[0]?.content.slice(0, 200) || "",
|
|
130
|
+
filePath,
|
|
131
|
+
modifiedAt: stats?.mtime || new Date(),
|
|
132
|
+
sizeBytes: stats?.size || 0,
|
|
133
|
+
turns,
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
// Decode Claude Code project directory name back to a real path.
|
|
138
|
+
// e.g. "-Users-zhaoyifei-VibeCodingWork-ai-code-forum" → "/Users/zhaoyifei/VibeCodingWork/ai-code-forum"
|
|
139
|
+
// The challenge: hyphens in the dir name could be path separators OR part of a folder name.
|
|
140
|
+
// Strategy: greedily build path segments, checking which paths actually exist on disk.
|
|
141
|
+
function decodeClaudeProjectDir(dirName) {
|
|
142
|
+
const platform = getPlatform();
|
|
143
|
+
// Remove leading dash
|
|
144
|
+
const stripped = dirName.startsWith("-") ? dirName.slice(1) : dirName;
|
|
145
|
+
const parts = stripped.split("-");
|
|
146
|
+
let currentPath = "";
|
|
147
|
+
let i = 0;
|
|
148
|
+
// On Windows, the first part may be a drive letter (e.g. "c" → "C:")
|
|
149
|
+
if (platform === "windows" && parts.length > 0 && /^[a-zA-Z]$/.test(parts[0])) {
|
|
150
|
+
currentPath = parts[0].toUpperCase() + ":";
|
|
151
|
+
i = 1;
|
|
152
|
+
}
|
|
153
|
+
while (i < parts.length) {
|
|
154
|
+
// Try progressively longer segments (greedy: longest existing path wins)
|
|
155
|
+
let bestMatch = "";
|
|
156
|
+
let bestLen = 0;
|
|
157
|
+
for (let end = parts.length; end > i; end--) {
|
|
158
|
+
const segment = parts.slice(i, end).join("-");
|
|
159
|
+
const candidate = currentPath + path.sep + segment;
|
|
160
|
+
try {
|
|
161
|
+
if (fs.existsSync(candidate)) {
|
|
162
|
+
bestMatch = candidate;
|
|
163
|
+
bestLen = end - i;
|
|
164
|
+
break; // Found longest match
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
catch { /* ignore */ }
|
|
168
|
+
}
|
|
169
|
+
if (bestLen > 0) {
|
|
170
|
+
currentPath = bestMatch;
|
|
171
|
+
i += bestLen;
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
// No existing path found, just use single segment
|
|
175
|
+
currentPath += path.sep + parts[i];
|
|
176
|
+
i++;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return currentPath || null;
|
|
180
|
+
}
|
|
181
|
+
function extractContent(msg) {
|
|
182
|
+
if (!msg.message?.content)
|
|
183
|
+
return "";
|
|
184
|
+
if (typeof msg.message.content === "string")
|
|
185
|
+
return msg.message.content;
|
|
186
|
+
if (Array.isArray(msg.message.content)) {
|
|
187
|
+
return msg.message.content
|
|
188
|
+
.filter((c) => c.type === "text" && c.text)
|
|
189
|
+
.map((c) => c.text)
|
|
190
|
+
.join("\n");
|
|
191
|
+
}
|
|
192
|
+
return "";
|
|
193
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import * as path from "path";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import { getHome } from "../lib/platform.js";
|
|
4
|
+
import { listFiles, safeStats, readJsonl, extractProjectDescription } from "../lib/fs-utils.js";
|
|
5
|
+
export const codexScanner = {
|
|
6
|
+
name: "Codex (OpenAI CLI)",
|
|
7
|
+
sourceType: "codex",
|
|
8
|
+
description: "OpenAI Codex CLI sessions (~/.codex/)",
|
|
9
|
+
getSessionDirs() {
|
|
10
|
+
const home = getHome();
|
|
11
|
+
const candidates = [
|
|
12
|
+
path.join(home, ".codex", "sessions"),
|
|
13
|
+
path.join(home, ".codex", "archived_sessions"),
|
|
14
|
+
];
|
|
15
|
+
return candidates.filter((d) => {
|
|
16
|
+
try {
|
|
17
|
+
return fs.existsSync(d);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
},
|
|
24
|
+
scan(limit) {
|
|
25
|
+
const sessions = [];
|
|
26
|
+
const dirs = this.getSessionDirs();
|
|
27
|
+
for (const dir of dirs) {
|
|
28
|
+
// Recursive scan — sessions are in nested YYYY/MM/DD/ subdirectories
|
|
29
|
+
const files = listFiles(dir, [".jsonl"], true);
|
|
30
|
+
for (const filePath of files) {
|
|
31
|
+
const stats = safeStats(filePath);
|
|
32
|
+
if (!stats)
|
|
33
|
+
continue;
|
|
34
|
+
const lines = readJsonl(filePath);
|
|
35
|
+
if (lines.length < 3)
|
|
36
|
+
continue;
|
|
37
|
+
const messageTurns = extractCodexTurns(lines);
|
|
38
|
+
const humanMsgs = messageTurns.filter((t) => t.role === "human");
|
|
39
|
+
const aiMsgs = messageTurns.filter((t) => t.role === "assistant");
|
|
40
|
+
// Extract project path (cwd) from session metadata
|
|
41
|
+
const startLine = lines.find((l) => l.payload?.cwd);
|
|
42
|
+
const projectPath = startLine?.payload?.cwd || null;
|
|
43
|
+
const project = projectPath
|
|
44
|
+
? path.basename(projectPath)
|
|
45
|
+
: path.basename(dir);
|
|
46
|
+
const projectDescription = projectPath
|
|
47
|
+
? extractProjectDescription(projectPath)
|
|
48
|
+
: null;
|
|
49
|
+
const preview = humanMsgs[0]?.content.slice(0, 200) || "(codex session)";
|
|
50
|
+
sessions.push({
|
|
51
|
+
id: path.basename(filePath, ".jsonl"),
|
|
52
|
+
source: "codex",
|
|
53
|
+
project,
|
|
54
|
+
projectPath: projectPath || undefined,
|
|
55
|
+
projectDescription: projectDescription || undefined,
|
|
56
|
+
title: preview.slice(0, 80) || "Codex session",
|
|
57
|
+
messageCount: messageTurns.length,
|
|
58
|
+
humanMessages: humanMsgs.length,
|
|
59
|
+
aiMessages: aiMsgs.length,
|
|
60
|
+
preview,
|
|
61
|
+
filePath,
|
|
62
|
+
modifiedAt: stats.mtime,
|
|
63
|
+
sizeBytes: stats.size,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
sessions.sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime());
|
|
68
|
+
return sessions.slice(0, limit);
|
|
69
|
+
},
|
|
70
|
+
parse(filePath, maxTurns) {
|
|
71
|
+
const lines = readJsonl(filePath);
|
|
72
|
+
if (lines.length === 0)
|
|
73
|
+
return null;
|
|
74
|
+
const stats = safeStats(filePath);
|
|
75
|
+
const allTurns = extractCodexTurns(lines);
|
|
76
|
+
const turns = maxTurns ? allTurns.slice(0, maxTurns) : allTurns;
|
|
77
|
+
if (turns.length === 0)
|
|
78
|
+
return null;
|
|
79
|
+
const humanMsgs = turns.filter((t) => t.role === "human");
|
|
80
|
+
const aiMsgs = turns.filter((t) => t.role === "assistant");
|
|
81
|
+
const startLine = lines.find((l) => l.payload?.cwd);
|
|
82
|
+
const projectPath = startLine?.payload?.cwd || undefined;
|
|
83
|
+
const project = projectPath
|
|
84
|
+
? path.basename(projectPath)
|
|
85
|
+
: path.basename(path.dirname(filePath));
|
|
86
|
+
const projectDescription = projectPath
|
|
87
|
+
? extractProjectDescription(projectPath) || undefined
|
|
88
|
+
: undefined;
|
|
89
|
+
return {
|
|
90
|
+
id: path.basename(filePath, ".jsonl"),
|
|
91
|
+
source: "codex",
|
|
92
|
+
project,
|
|
93
|
+
projectPath,
|
|
94
|
+
projectDescription,
|
|
95
|
+
title: humanMsgs[0]?.content.slice(0, 80) || "Codex session",
|
|
96
|
+
messageCount: turns.length,
|
|
97
|
+
humanMessages: humanMsgs.length,
|
|
98
|
+
aiMessages: aiMsgs.length,
|
|
99
|
+
preview: humanMsgs[0]?.content.slice(0, 200) || "",
|
|
100
|
+
filePath,
|
|
101
|
+
modifiedAt: stats?.mtime || new Date(),
|
|
102
|
+
sizeBytes: stats?.size || 0,
|
|
103
|
+
turns,
|
|
104
|
+
};
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
// Extract conversation turns from Codex JSONL format
|
|
108
|
+
function extractCodexTurns(lines) {
|
|
109
|
+
const turns = [];
|
|
110
|
+
for (const line of lines) {
|
|
111
|
+
if (!line.payload)
|
|
112
|
+
continue;
|
|
113
|
+
const p = line.payload;
|
|
114
|
+
// Only process message-type payloads
|
|
115
|
+
if (p.type !== "message")
|
|
116
|
+
continue;
|
|
117
|
+
// Extract text from content array
|
|
118
|
+
const textParts = (p.content || [])
|
|
119
|
+
.filter((c) => c.text)
|
|
120
|
+
.map((c) => c.text || "")
|
|
121
|
+
.filter(Boolean);
|
|
122
|
+
const content = textParts.join("\n").trim();
|
|
123
|
+
if (!content)
|
|
124
|
+
continue;
|
|
125
|
+
// Skip developer/system messages — they are AGENTS.md instructions, not conversation
|
|
126
|
+
if (p.role === "developer" || p.role === "system")
|
|
127
|
+
continue;
|
|
128
|
+
// Skip system-like user messages (AGENTS.md, environment context, etc.)
|
|
129
|
+
if (p.role === "user" && (content.startsWith("# AGENTS.md") ||
|
|
130
|
+
content.startsWith("<environment_context>") ||
|
|
131
|
+
content.startsWith("<permissions") ||
|
|
132
|
+
content.startsWith("<app-context>") ||
|
|
133
|
+
content.startsWith("<collaboration_mode>")))
|
|
134
|
+
continue;
|
|
135
|
+
const role = p.role === "user" ? "human" : "assistant";
|
|
136
|
+
turns.push({
|
|
137
|
+
role,
|
|
138
|
+
content,
|
|
139
|
+
timestamp: line.timestamp ? new Date(line.timestamp) : undefined,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return turns;
|
|
143
|
+
}
|