pi-compass 0.2.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 +74 -0
- package/dist/analyzers/build-script-detector.d.ts +3 -0
- package/dist/analyzers/build-script-detector.d.ts.map +1 -0
- package/dist/analyzers/build-script-detector.js +75 -0
- package/dist/analyzers/build-script-detector.js.map +1 -0
- package/dist/analyzers/convention-detector.d.ts +3 -0
- package/dist/analyzers/convention-detector.d.ts.map +1 -0
- package/dist/analyzers/convention-detector.js +47 -0
- package/dist/analyzers/convention-detector.js.map +1 -0
- package/dist/analyzers/directory-tree.d.ts +4 -0
- package/dist/analyzers/directory-tree.d.ts.map +1 -0
- package/dist/analyzers/directory-tree.js +60 -0
- package/dist/analyzers/directory-tree.js.map +1 -0
- package/dist/analyzers/entry-point-detector.d.ts +3 -0
- package/dist/analyzers/entry-point-detector.d.ts.map +1 -0
- package/dist/analyzers/entry-point-detector.js +87 -0
- package/dist/analyzers/entry-point-detector.js.map +1 -0
- package/dist/analyzers/framework-detector.d.ts +3 -0
- package/dist/analyzers/framework-detector.d.ts.map +1 -0
- package/dist/analyzers/framework-detector.js +63 -0
- package/dist/analyzers/framework-detector.js.map +1 -0
- package/dist/analyzers/index.d.ts +8 -0
- package/dist/analyzers/index.d.ts.map +1 -0
- package/dist/analyzers/index.js +8 -0
- package/dist/analyzers/index.js.map +1 -0
- package/dist/analyzers/key-file-detector.d.ts +3 -0
- package/dist/analyzers/key-file-detector.d.ts.map +1 -0
- package/dist/analyzers/key-file-detector.js +32 -0
- package/dist/analyzers/key-file-detector.js.map +1 -0
- package/dist/analyzers/package-detector.d.ts +3 -0
- package/dist/analyzers/package-detector.d.ts.map +1 -0
- package/dist/analyzers/package-detector.js +78 -0
- package/dist/analyzers/package-detector.js.map +1 -0
- package/dist/codemap-formatter.d.ts +4 -0
- package/dist/codemap-formatter.d.ts.map +1 -0
- package/dist/codemap-formatter.js +72 -0
- package/dist/codemap-formatter.js.map +1 -0
- package/dist/codemap-generator.d.ts +10 -0
- package/dist/codemap-generator.d.ts.map +1 -0
- package/dist/codemap-generator.js +80 -0
- package/dist/codemap-generator.js.map +1 -0
- package/dist/codemap-injector.d.ts +12 -0
- package/dist/codemap-injector.d.ts.map +1 -0
- package/dist/codemap-injector.js +20 -0
- package/dist/codemap-injector.js.map +1 -0
- package/dist/fs-utils.d.ts +3 -0
- package/dist/fs-utils.d.ts.map +1 -0
- package/dist/fs-utils.js +21 -0
- package/dist/fs-utils.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +69 -0
- package/dist/index.js.map +1 -0
- package/dist/onboard-command.d.ts +5 -0
- package/dist/onboard-command.d.ts.map +1 -0
- package/dist/onboard-command.js +33 -0
- package/dist/onboard-command.js.map +1 -0
- package/dist/onboard-tools.d.ts +4 -0
- package/dist/onboard-tools.d.ts.map +1 -0
- package/dist/onboard-tools.js +77 -0
- package/dist/onboard-tools.js.map +1 -0
- package/dist/project.d.ts +4 -0
- package/dist/project.d.ts.map +1 -0
- package/dist/project.js +20 -0
- package/dist/project.js.map +1 -0
- package/dist/storage.d.ts +12 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +46 -0
- package/dist/storage.js.map +1 -0
- package/dist/tour-command.d.ts +5 -0
- package/dist/tour-command.d.ts.map +1 -0
- package/dist/tour-command.js +26 -0
- package/dist/tour-command.js.map +1 -0
- package/dist/tour-generator.d.ts +6 -0
- package/dist/tour-generator.d.ts.map +1 -0
- package/dist/tour-generator.js +204 -0
- package/dist/tour-generator.js.map +1 -0
- package/dist/types.d.ts +89 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +71 -0
- package/src/analyzers/build-script-detector.ts +85 -0
- package/src/analyzers/convention-detector.ts +52 -0
- package/src/analyzers/directory-tree.ts +65 -0
- package/src/analyzers/entry-point-detector.ts +98 -0
- package/src/analyzers/framework-detector.ts +76 -0
- package/src/analyzers/index.ts +7 -0
- package/src/analyzers/key-file-detector.ts +36 -0
- package/src/analyzers/package-detector.ts +87 -0
- package/src/codemap-formatter.ts +90 -0
- package/src/codemap-generator.ts +110 -0
- package/src/codemap-injector.ts +44 -0
- package/src/fs-utils.ts +19 -0
- package/src/index.ts +90 -0
- package/src/onboard-command.ts +60 -0
- package/src/onboard-tools.ts +116 -0
- package/src/project.ts +29 -0
- package/src/storage.ts +82 -0
- package/src/tour-command.ts +50 -0
- package/src/tour-generator.ts +237 -0
- package/src/types.ts +104 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionCommandContext,
|
|
4
|
+
} from "@mariozechner/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
import { detectProject } from "./project.js";
|
|
7
|
+
import { ensureStorageLayout, loadCachedCodemap } from "./storage.js";
|
|
8
|
+
import { computeContentHash } from "./codemap-generator.js";
|
|
9
|
+
import {
|
|
10
|
+
handleBeforeAgentStart as buildInjection,
|
|
11
|
+
type BeforeAgentStartEvent,
|
|
12
|
+
} from "./codemap-injector.js";
|
|
13
|
+
import { handleOnboardCommand, COMMAND_NAME as ONBOARD_CMD } from "./onboard-command.js";
|
|
14
|
+
import { handleTourCommand, COMMAND_NAME as TOUR_CMD } from "./tour-command.js";
|
|
15
|
+
import { registerOnboardTools } from "./onboard-tools.js";
|
|
16
|
+
import type { CompassState } from "./types.js";
|
|
17
|
+
|
|
18
|
+
const DEFAULT_MAX_INJECTION_CHARS = 6000;
|
|
19
|
+
|
|
20
|
+
export default function (pi: ExtensionAPI): void {
|
|
21
|
+
let state: CompassState = {
|
|
22
|
+
project: null,
|
|
23
|
+
turnCount: 0,
|
|
24
|
+
codemapInjected: false,
|
|
25
|
+
cachedCodemap: null,
|
|
26
|
+
stale: false,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const stateRef = {
|
|
30
|
+
get: () => state,
|
|
31
|
+
set: (s: CompassState) => { state = s; },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
35
|
+
try {
|
|
36
|
+
const project = await detectProject(pi, ctx.cwd);
|
|
37
|
+
ensureStorageLayout(project.id);
|
|
38
|
+
|
|
39
|
+
const cached = loadCachedCodemap(project.id);
|
|
40
|
+
let stale = false;
|
|
41
|
+
if (cached) {
|
|
42
|
+
const currentHash = computeContentHash(project.root);
|
|
43
|
+
stale = cached.contentHash !== currentHash;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
state = { ...state, project, cachedCodemap: cached, stale };
|
|
47
|
+
|
|
48
|
+
registerOnboardTools(pi, stateRef);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
console.error("[pi-compass] session_start error:", err);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
pi.on("before_agent_start", (event, _ctx) => {
|
|
55
|
+
try {
|
|
56
|
+
if (!state.project) return;
|
|
57
|
+
const result = buildInjection(
|
|
58
|
+
event as BeforeAgentStartEvent,
|
|
59
|
+
state,
|
|
60
|
+
DEFAULT_MAX_INJECTION_CHARS,
|
|
61
|
+
);
|
|
62
|
+
if (result) {
|
|
63
|
+
state = { ...state, codemapInjected: true };
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
} catch (err) {
|
|
67
|
+
console.error("[pi-compass] before_agent_start error:", err);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
pi.on("turn_end", (_event, _ctx) => {
|
|
72
|
+
try {
|
|
73
|
+
state = { ...state, turnCount: state.turnCount + 1 };
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.error("[pi-compass] turn_end error:", err);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
pi.registerCommand(ONBOARD_CMD, {
|
|
80
|
+
description: "Generate a structured codebase map for the current project",
|
|
81
|
+
handler: (args: string, ctx: ExtensionCommandContext) =>
|
|
82
|
+
handleOnboardCommand(args, ctx, pi, stateRef),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
pi.registerCommand(TOUR_CMD, {
|
|
86
|
+
description: "Take a guided tour of a specific area of the codebase",
|
|
87
|
+
handler: (args: string, ctx: ExtensionCommandContext) =>
|
|
88
|
+
handleTourCommand(args, ctx, pi, stateRef),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionCommandContext,
|
|
4
|
+
} from "@mariozechner/pi-coding-agent";
|
|
5
|
+
import { generateCodemap } from "./codemap-generator.js";
|
|
6
|
+
import { formatCodemapMarkdown } from "./codemap-formatter.js";
|
|
7
|
+
import { saveCachedCodemap } from "./storage.js";
|
|
8
|
+
import type { StateRef, CacheEntry, CodeMap } from "./types.js";
|
|
9
|
+
|
|
10
|
+
export const COMMAND_NAME = "onboard";
|
|
11
|
+
|
|
12
|
+
export async function handleOnboardCommand(
|
|
13
|
+
args: string,
|
|
14
|
+
ctx: ExtensionCommandContext,
|
|
15
|
+
pi: ExtensionAPI,
|
|
16
|
+
stateRef: StateRef,
|
|
17
|
+
): Promise<void> {
|
|
18
|
+
const state = stateRef.get();
|
|
19
|
+
if (!state.project) {
|
|
20
|
+
ctx.ui.notify("No project detected. Run from within a git repository.", "error");
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const forceRefresh = args.trim() === "--refresh";
|
|
25
|
+
|
|
26
|
+
if (!forceRefresh && state.cachedCodemap && !state.stale) {
|
|
27
|
+
const markdown = formatCodemapMarkdown(state.cachedCodemap.data);
|
|
28
|
+
pi.sendUserMessage(
|
|
29
|
+
`Here is the cached codebase map for ${state.project.name}:\n\n${markdown}`,
|
|
30
|
+
{ deliverAs: "followUp" },
|
|
31
|
+
);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const codemap = generateCodemap(
|
|
36
|
+
state.project.root,
|
|
37
|
+
state.project.id,
|
|
38
|
+
state.project.name,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const entry: CacheEntry<CodeMap> = {
|
|
42
|
+
data: codemap,
|
|
43
|
+
contentHash: codemap.contentHash,
|
|
44
|
+
createdAt: codemap.generatedAt,
|
|
45
|
+
};
|
|
46
|
+
saveCachedCodemap(state.project.id, entry);
|
|
47
|
+
|
|
48
|
+
stateRef.set({
|
|
49
|
+
...state,
|
|
50
|
+
cachedCodemap: entry,
|
|
51
|
+
stale: false,
|
|
52
|
+
codemapInjected: false,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const markdown = formatCodemapMarkdown(codemap);
|
|
56
|
+
pi.sendUserMessage(
|
|
57
|
+
`Here is the codebase map for ${state.project.name}:\n\n${markdown}`,
|
|
58
|
+
{ deliverAs: "followUp" },
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import type { StateRef } from "./types.js";
|
|
4
|
+
import { getOrGenerateCodemap } from "./codemap-generator.js";
|
|
5
|
+
import { formatCodemapMarkdown } from "./codemap-formatter.js";
|
|
6
|
+
import { detectAvailableTopics, getOrGenerateTour, formatTourMarkdown } from "./tour-generator.js";
|
|
7
|
+
|
|
8
|
+
const CodemapParams = Type.Object({});
|
|
9
|
+
|
|
10
|
+
const TourParams = Type.Object({
|
|
11
|
+
topic: Type.Optional(
|
|
12
|
+
Type.String({ description: "Tour topic (e.g., 'auth', 'api', 'testing'). Omit to list available topics." }),
|
|
13
|
+
),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function createCodemapTool(stateRef: StateRef) {
|
|
17
|
+
return {
|
|
18
|
+
name: "codebase_map" as const,
|
|
19
|
+
label: "Codebase Map",
|
|
20
|
+
description: "Returns a structured map of the current codebase including directory tree, packages, frameworks, entry points, build scripts, and conventions",
|
|
21
|
+
promptSnippet: "Get or generate a structured map of the current codebase",
|
|
22
|
+
parameters: CodemapParams,
|
|
23
|
+
async execute(
|
|
24
|
+
_toolCallId: string,
|
|
25
|
+
_params: Record<string, never>,
|
|
26
|
+
_signal: AbortSignal | undefined,
|
|
27
|
+
_onUpdate: unknown,
|
|
28
|
+
_ctx: unknown,
|
|
29
|
+
) {
|
|
30
|
+
const state = stateRef.get();
|
|
31
|
+
if (!state.project) {
|
|
32
|
+
throw new Error("No project detected.");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const { codemap } = getOrGenerateCodemap(
|
|
36
|
+
state.project.root,
|
|
37
|
+
state.project.id,
|
|
38
|
+
state.project.name,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const markdown = formatCodemapMarkdown(codemap);
|
|
42
|
+
return {
|
|
43
|
+
content: [{ type: "text" as const, text: markdown }],
|
|
44
|
+
details: { projectId: codemap.projectId, contentHash: codemap.contentHash },
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function createTourTool(stateRef: StateRef) {
|
|
51
|
+
return {
|
|
52
|
+
name: "code_tour" as const,
|
|
53
|
+
label: "Code Tour",
|
|
54
|
+
description: "Returns a guided walkthrough of a specific area of the codebase, or lists available topics",
|
|
55
|
+
promptSnippet: "Get a guided code tour for a specific topic or area",
|
|
56
|
+
parameters: TourParams,
|
|
57
|
+
async execute(
|
|
58
|
+
_toolCallId: string,
|
|
59
|
+
params: { topic?: string },
|
|
60
|
+
_signal: AbortSignal | undefined,
|
|
61
|
+
_onUpdate: unknown,
|
|
62
|
+
_ctx: unknown,
|
|
63
|
+
) {
|
|
64
|
+
const state = stateRef.get();
|
|
65
|
+
if (!state.project) {
|
|
66
|
+
throw new Error("No project detected.");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const codemap = state.cachedCodemap?.data
|
|
70
|
+
?? getOrGenerateCodemap(state.project.root, state.project.id, state.project.name).codemap;
|
|
71
|
+
|
|
72
|
+
if (!params.topic) {
|
|
73
|
+
const topics = detectAvailableTopics(state.project.root, codemap);
|
|
74
|
+
return {
|
|
75
|
+
content: [{
|
|
76
|
+
type: "text" as const,
|
|
77
|
+
text: topics.length > 0
|
|
78
|
+
? `Available tour topics: ${topics.join(", ")}`
|
|
79
|
+
: "No tour topics detected.",
|
|
80
|
+
}],
|
|
81
|
+
details: { topics } as Record<string, unknown>,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const tour = getOrGenerateTour(
|
|
86
|
+
state.project.root,
|
|
87
|
+
params.topic,
|
|
88
|
+
codemap,
|
|
89
|
+
state.project.id,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
content: [{ type: "text" as const, text: formatTourMarkdown(tour) }],
|
|
94
|
+
details: { topic: tour.topic, steps: tour.steps.length } as Record<string, unknown>,
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function registerOnboardTools(
|
|
101
|
+
pi: ExtensionAPI,
|
|
102
|
+
stateRef: StateRef,
|
|
103
|
+
): void {
|
|
104
|
+
const guidelines = [
|
|
105
|
+
"Use codebase_map to understand the overall project structure. Use code_tour to walk through specific areas in detail.",
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
pi.registerTool({
|
|
109
|
+
...createCodemapTool(stateRef),
|
|
110
|
+
promptGuidelines: guidelines,
|
|
111
|
+
});
|
|
112
|
+
pi.registerTool({
|
|
113
|
+
...createTourTool(stateRef),
|
|
114
|
+
promptGuidelines: guidelines,
|
|
115
|
+
});
|
|
116
|
+
}
|
package/src/project.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
4
|
+
import type { ProjectInfo } from "./types.js";
|
|
5
|
+
|
|
6
|
+
function hashString(input: string): string {
|
|
7
|
+
return createHash("sha256").update(input).digest("hex").substring(0, 12);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function detectProject(
|
|
11
|
+
pi: ExtensionAPI,
|
|
12
|
+
cwd: string,
|
|
13
|
+
): Promise<ProjectInfo> {
|
|
14
|
+
const name = basename(cwd);
|
|
15
|
+
|
|
16
|
+
const remoteResult = await pi.exec("git", ["remote", "get-url", "origin"], { cwd });
|
|
17
|
+
if (remoteResult.code === 0) {
|
|
18
|
+
const remote = remoteResult.stdout.trim();
|
|
19
|
+
return { id: hashString(remote), name, root: cwd, remote };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const rootResult = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd });
|
|
23
|
+
if (rootResult.code === 0) {
|
|
24
|
+
const root = rootResult.stdout.trim();
|
|
25
|
+
return { id: hashString(root), name, root, remote: "" };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return { id: "global", name, root: cwd, remote: "" };
|
|
29
|
+
}
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync,
|
|
3
|
+
readFileSync,
|
|
4
|
+
writeFileSync,
|
|
5
|
+
} from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import type { CodeMap, CodeTour, CacheEntry } from "./types.js";
|
|
9
|
+
|
|
10
|
+
export function getBaseDir(): string {
|
|
11
|
+
return join(homedir(), ".pi", "compass");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getProjectDir(projectId: string, baseDir = getBaseDir()): string {
|
|
15
|
+
return join(baseDir, "projects", projectId);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getCodemapPath(projectId: string, baseDir = getBaseDir()): string {
|
|
19
|
+
return join(getProjectDir(projectId, baseDir), "codemap.json");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getToursDir(projectId: string, baseDir = getBaseDir()): string {
|
|
23
|
+
return join(getProjectDir(projectId, baseDir), "tours");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getTourPath(projectId: string, topic: string, baseDir = getBaseDir()): string {
|
|
27
|
+
return join(getToursDir(projectId, baseDir), `${topic}.json`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function ensureStorageLayout(projectId: string, baseDir = getBaseDir()): void {
|
|
31
|
+
mkdirSync(getToursDir(projectId, baseDir), { recursive: true });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function loadCachedCodemap(
|
|
35
|
+
projectId: string,
|
|
36
|
+
baseDir = getBaseDir(),
|
|
37
|
+
): CacheEntry<CodeMap> | null {
|
|
38
|
+
try {
|
|
39
|
+
const raw = readFileSync(getCodemapPath(projectId, baseDir), "utf-8");
|
|
40
|
+
return JSON.parse(raw) as CacheEntry<CodeMap>;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function saveCachedCodemap(
|
|
47
|
+
projectId: string,
|
|
48
|
+
entry: CacheEntry<CodeMap>,
|
|
49
|
+
baseDir = getBaseDir(),
|
|
50
|
+
): void {
|
|
51
|
+
writeFileSync(
|
|
52
|
+
getCodemapPath(projectId, baseDir),
|
|
53
|
+
JSON.stringify(entry, null, 2),
|
|
54
|
+
"utf-8",
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function loadCachedTour(
|
|
59
|
+
projectId: string,
|
|
60
|
+
topic: string,
|
|
61
|
+
baseDir = getBaseDir(),
|
|
62
|
+
): CacheEntry<CodeTour> | null {
|
|
63
|
+
try {
|
|
64
|
+
const raw = readFileSync(getTourPath(projectId, topic, baseDir), "utf-8");
|
|
65
|
+
return JSON.parse(raw) as CacheEntry<CodeTour>;
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function saveCachedTour(
|
|
72
|
+
projectId: string,
|
|
73
|
+
topic: string,
|
|
74
|
+
entry: CacheEntry<CodeTour>,
|
|
75
|
+
baseDir = getBaseDir(),
|
|
76
|
+
): void {
|
|
77
|
+
writeFileSync(
|
|
78
|
+
getTourPath(projectId, topic, baseDir),
|
|
79
|
+
JSON.stringify(entry, null, 2),
|
|
80
|
+
"utf-8",
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionCommandContext,
|
|
4
|
+
} from "@mariozechner/pi-coding-agent";
|
|
5
|
+
import { detectAvailableTopics, getOrGenerateTour, formatTourMarkdown } from "./tour-generator.js";
|
|
6
|
+
import type { StateRef } from "./types.js";
|
|
7
|
+
import { getOrGenerateCodemap } from "./codemap-generator.js";
|
|
8
|
+
|
|
9
|
+
export const COMMAND_NAME = "tour";
|
|
10
|
+
|
|
11
|
+
export async function handleTourCommand(
|
|
12
|
+
args: string,
|
|
13
|
+
ctx: ExtensionCommandContext,
|
|
14
|
+
pi: ExtensionAPI,
|
|
15
|
+
stateRef: StateRef,
|
|
16
|
+
): Promise<void> {
|
|
17
|
+
const state = stateRef.get();
|
|
18
|
+
if (!state.project) {
|
|
19
|
+
ctx.ui.notify("No project detected. Run from within a git repository.", "error");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const codemap = state.cachedCodemap?.data
|
|
24
|
+
?? getOrGenerateCodemap(state.project.root, state.project.id, state.project.name).codemap;
|
|
25
|
+
|
|
26
|
+
const topic = args.trim();
|
|
27
|
+
|
|
28
|
+
if (!topic) {
|
|
29
|
+
const topics = detectAvailableTopics(state.project.root, codemap);
|
|
30
|
+
if (topics.length === 0) {
|
|
31
|
+
ctx.ui.notify("No tour topics detected in this project.", "info");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
ctx.ui.notify(`Available tour topics:\n${topics.map((t) => ` - ${t}`).join("\n")}\n\nUsage: /tour <topic>`, "info");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const tour = getOrGenerateTour(
|
|
39
|
+
state.project.root,
|
|
40
|
+
topic,
|
|
41
|
+
codemap,
|
|
42
|
+
state.project.id,
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const markdown = formatTourMarkdown(tour);
|
|
46
|
+
pi.sendUserMessage(
|
|
47
|
+
`${markdown}\n\nAsk me about any of these files for more detail.`,
|
|
48
|
+
{ deliverAs: "followUp" },
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join, extname } from "node:path";
|
|
3
|
+
import type { CodeMap, CodeTour, TourStep, CacheEntry } from "./types.js";
|
|
4
|
+
import { loadCachedTour, saveCachedTour } from "./storage.js";
|
|
5
|
+
|
|
6
|
+
const TEST_INDICATORS = ["test", "tests", "spec", "__tests__", "e2e"];
|
|
7
|
+
const CI_INDICATORS = [".github", ".gitlab-ci.yml", "Jenkinsfile"];
|
|
8
|
+
const DB_INDICATORS = ["migrations", "prisma", "drizzle", "alembic", "db", "database"];
|
|
9
|
+
|
|
10
|
+
export function detectAvailableTopics(
|
|
11
|
+
_cwd: string,
|
|
12
|
+
codemap: CodeMap,
|
|
13
|
+
): readonly string[] {
|
|
14
|
+
const topics: string[] = [];
|
|
15
|
+
|
|
16
|
+
for (const entry of codemap.directoryTree) {
|
|
17
|
+
if (entry.type === "dir" && entry.name !== "node_modules" && entry.name !== "dist") {
|
|
18
|
+
topics.push(entry.name);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const allNames = codemap.directoryTree.map((e) => e.name.toLowerCase());
|
|
23
|
+
const keyFilePaths = codemap.keyFiles.map((k) => k.path.toLowerCase());
|
|
24
|
+
|
|
25
|
+
if (TEST_INDICATORS.some((t) => allNames.includes(t))) {
|
|
26
|
+
topics.push("testing");
|
|
27
|
+
}
|
|
28
|
+
if (CI_INDICATORS.some((c) => allNames.includes(c) || keyFilePaths.some((k) => k.includes(c)))) {
|
|
29
|
+
topics.push("ci");
|
|
30
|
+
}
|
|
31
|
+
if (DB_INDICATORS.some((d) => allNames.includes(d))) {
|
|
32
|
+
topics.push("database");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return [...new Set(topics)];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function generateTour(
|
|
39
|
+
cwd: string,
|
|
40
|
+
topic: string,
|
|
41
|
+
codemap: CodeMap,
|
|
42
|
+
): CodeTour {
|
|
43
|
+
const steps = buildTourSteps(cwd, topic);
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
projectId: codemap.projectId,
|
|
47
|
+
topic,
|
|
48
|
+
generatedAt: new Date().toISOString(),
|
|
49
|
+
steps,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function buildTourSteps(
|
|
54
|
+
cwd: string,
|
|
55
|
+
topic: string,
|
|
56
|
+
): TourStep[] {
|
|
57
|
+
const steps: TourStep[] = [];
|
|
58
|
+
|
|
59
|
+
const topicDir = join(cwd, topic);
|
|
60
|
+
const srcTopicDir = join(cwd, "src", topic);
|
|
61
|
+
|
|
62
|
+
const dir = safeIsDir(topicDir) ? topicDir : safeIsDir(srcTopicDir) ? srcTopicDir : null;
|
|
63
|
+
|
|
64
|
+
if (dir) {
|
|
65
|
+
const files = collectFiles(dir, cwd, 3);
|
|
66
|
+
for (const file of files.slice(0, 15)) {
|
|
67
|
+
steps.push({
|
|
68
|
+
file,
|
|
69
|
+
description: describeFile(file),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (topic === "testing") {
|
|
75
|
+
steps.push(...findTestFiles(cwd));
|
|
76
|
+
} else if (topic === "ci") {
|
|
77
|
+
steps.push(...findCiFiles(cwd));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return steps;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function collectFiles(dir: string, root: string, depth: number): string[] {
|
|
84
|
+
if (depth <= 0) return [];
|
|
85
|
+
const results: string[] = [];
|
|
86
|
+
|
|
87
|
+
let entries: string[];
|
|
88
|
+
try {
|
|
89
|
+
entries = readdirSync(dir);
|
|
90
|
+
} catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const name of entries.sort()) {
|
|
95
|
+
if (name.startsWith(".")) continue;
|
|
96
|
+
const full = join(dir, name);
|
|
97
|
+
const rel = full.slice(root.length + 1);
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const stat = statSync(full);
|
|
101
|
+
if (stat.isFile() && isSourceFile(name)) {
|
|
102
|
+
results.push(rel);
|
|
103
|
+
} else if (stat.isDirectory()) {
|
|
104
|
+
results.push(...collectFiles(full, root, depth - 1));
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return results;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function findTestFiles(cwd: string): TourStep[] {
|
|
115
|
+
const steps: TourStep[] = [];
|
|
116
|
+
for (const dir of TEST_INDICATORS) {
|
|
117
|
+
const full = join(cwd, dir);
|
|
118
|
+
if (safeIsDir(full)) {
|
|
119
|
+
const files = collectFiles(full, cwd, 2);
|
|
120
|
+
for (const file of files.slice(0, 5)) {
|
|
121
|
+
steps.push({ file, description: describeFile(file) });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return steps;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function findCiFiles(cwd: string): TourStep[] {
|
|
129
|
+
const steps: TourStep[] = [];
|
|
130
|
+
const workflowDir = join(cwd, ".github", "workflows");
|
|
131
|
+
if (safeIsDir(workflowDir)) {
|
|
132
|
+
try {
|
|
133
|
+
for (const name of readdirSync(workflowDir)) {
|
|
134
|
+
steps.push({
|
|
135
|
+
file: `.github/workflows/${name}`,
|
|
136
|
+
description: `CI workflow: ${name}`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
} catch {
|
|
140
|
+
// ignore
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for (const file of [".gitlab-ci.yml", "Jenkinsfile"]) {
|
|
145
|
+
if (safeExists(join(cwd, file))) {
|
|
146
|
+
steps.push({ file, description: `CI configuration: ${file}` });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return steps;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function describeFile(filePath: string): string {
|
|
154
|
+
const parts = filePath.split("/");
|
|
155
|
+
const fileName = parts[parts.length - 1] ?? filePath;
|
|
156
|
+
const ext = extname(fileName);
|
|
157
|
+
const baseName = fileName.replace(ext, "");
|
|
158
|
+
|
|
159
|
+
if (fileName.includes("test") || fileName.includes("spec")) {
|
|
160
|
+
return `Test file for ${baseName.replace(/[._-]?(test|spec)/, "")}`;
|
|
161
|
+
}
|
|
162
|
+
if (fileName === "index.ts" || fileName === "index.js") {
|
|
163
|
+
const parent = parts[parts.length - 2];
|
|
164
|
+
return parent ? `Entry point for ${parent} module` : "Package entry point";
|
|
165
|
+
}
|
|
166
|
+
if (fileName.includes("config")) return `Configuration: ${baseName}`;
|
|
167
|
+
if (fileName.includes("route") || fileName.includes("router")) return `Routing: ${baseName}`;
|
|
168
|
+
if (fileName.includes("middleware")) return `Middleware: ${baseName}`;
|
|
169
|
+
if (fileName.includes("model") || fileName.includes("schema")) return `Data model: ${baseName}`;
|
|
170
|
+
if (fileName.includes("service")) return `Service: ${baseName}`;
|
|
171
|
+
if (fileName.includes("controller") || fileName.includes("handler")) return `Handler: ${baseName}`;
|
|
172
|
+
if (fileName.includes("util") || fileName.includes("helper")) return `Utility: ${baseName}`;
|
|
173
|
+
|
|
174
|
+
return `${baseName} module`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
178
|
+
".ts", ".tsx", ".js", ".jsx", ".py", ".go", ".rs", ".java",
|
|
179
|
+
".rb", ".php", ".ex", ".exs", ".kt", ".swift", ".c", ".cpp",
|
|
180
|
+
".h", ".yml", ".yaml", ".toml", ".json",
|
|
181
|
+
]);
|
|
182
|
+
|
|
183
|
+
function isSourceFile(name: string): boolean {
|
|
184
|
+
return SOURCE_EXTENSIONS.has(extname(name));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function safeIsDir(path: string): boolean {
|
|
188
|
+
try {
|
|
189
|
+
return statSync(path).isDirectory();
|
|
190
|
+
} catch {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function safeExists(path: string): boolean {
|
|
196
|
+
try {
|
|
197
|
+
statSync(path);
|
|
198
|
+
return true;
|
|
199
|
+
} catch {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function formatTourMarkdown(tour: CodeTour): string {
|
|
205
|
+
if (tour.steps.length === 0) {
|
|
206
|
+
return `No files found for topic "${tour.topic}".`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const lines = [`## Code Tour: ${tour.topic}`, ""];
|
|
210
|
+
for (let i = 0; i < tour.steps.length; i++) {
|
|
211
|
+
const step = tour.steps[i]!;
|
|
212
|
+
lines.push(`**${i + 1}.** \`${step.file}\``);
|
|
213
|
+
lines.push(` ${step.description}`);
|
|
214
|
+
lines.push("");
|
|
215
|
+
}
|
|
216
|
+
return lines.join("\n");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function getOrGenerateTour(
|
|
220
|
+
cwd: string,
|
|
221
|
+
topic: string,
|
|
222
|
+
codemap: CodeMap,
|
|
223
|
+
projectId: string,
|
|
224
|
+
baseDir?: string,
|
|
225
|
+
): CodeTour {
|
|
226
|
+
const cached = loadCachedTour(projectId, topic, baseDir);
|
|
227
|
+
if (cached) return cached.data;
|
|
228
|
+
|
|
229
|
+
const tour = generateTour(cwd, topic, codemap);
|
|
230
|
+
const entry: CacheEntry<CodeTour> = {
|
|
231
|
+
data: tour,
|
|
232
|
+
contentHash: codemap.contentHash,
|
|
233
|
+
createdAt: tour.generatedAt,
|
|
234
|
+
};
|
|
235
|
+
saveCachedTour(projectId, topic, entry, baseDir);
|
|
236
|
+
return tour;
|
|
237
|
+
}
|