omni-pi 0.8.3 → 0.9.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 +21 -0
- package/extensions/omni-core/index.ts +22 -0
- package/package.json +7 -7
- package/src/commands.ts +12 -0
- package/src/repo-map-contracts.ts +93 -0
- package/src/repo-map-index.ts +467 -0
- package/src/repo-map-rank.ts +201 -0
- package/src/repo-map-runtime.ts +120 -0
- package/src/repo-map-store.ts +46 -0
- package/src/rtk.ts +408 -0
- package/src/theme.ts +12 -0
package/README.md
CHANGED
|
@@ -14,6 +14,7 @@ Requires Node.js 22 or newer.
|
|
|
14
14
|
- `/omni-mode` turns on Omni's specialized interview, plan, build, and verify workflow for the current project.
|
|
15
15
|
- Keeps durable standards and project context in `.omni/`, even when Omni mode is off.
|
|
16
16
|
- Writes specs, tasks, and progress into `.omni/` once Omni mode is enabled.
|
|
17
|
+
- Adds a repo map that indexes supported source files, ranks them by structure plus recent activity, and injects a compact codebase-awareness block into Omni prompts.
|
|
17
18
|
- Bundles web search, guided interviews, themed UI, native micro-UI via Glimpse, a task viewer, a powerbar, custom provider/model management, and automatic updates out of the box.
|
|
18
19
|
|
|
19
20
|
## Install
|
|
@@ -41,6 +42,25 @@ Omni-Pi now ships the essential skill-discovery stack in the package itself:
|
|
|
41
42
|
- `skill-creator` is bundled for creating project-specific skills when nothing suitable exists
|
|
42
43
|
- `brainstorming` is bundled and used for Omni planning and task creation flows
|
|
43
44
|
|
|
45
|
+
### Repo Map
|
|
46
|
+
|
|
47
|
+
Omni-Pi now includes a SoulForge-style repo map for codebase awareness while Omni mode is on.
|
|
48
|
+
|
|
49
|
+
The first shipped version includes:
|
|
50
|
+
|
|
51
|
+
- incremental indexing of supported repo files while respecting `.gitignore`
|
|
52
|
+
- symbol/import extraction for TypeScript/JavaScript-family files with graceful fallback for partial/unsupported cases
|
|
53
|
+
- graph-aware ranking blended with current-turn boosts from recent reads, edits, writes, and prompt mentions
|
|
54
|
+
- budget-aware prompt rendering so Omni gets a compact ranked view of important files and exported symbols
|
|
55
|
+
- runtime cache storage under `.pi/repo-map/` rather than durable `.omni/` memory
|
|
56
|
+
|
|
57
|
+
Current deferred roadmap items remain intentional and visible in docs rather than hidden in code:
|
|
58
|
+
|
|
59
|
+
- semantic symbol summaries
|
|
60
|
+
- git co-change ranking
|
|
61
|
+
- richer analysis views such as dead-code or clone-detection signals
|
|
62
|
+
- broader parser/language coverage as needed
|
|
63
|
+
|
|
44
64
|
### Bundled Extensions
|
|
45
65
|
|
|
46
66
|
| Extension | What it does |
|
|
@@ -107,6 +127,7 @@ Omni-Pi keeps its current branding and shell at all times, but the specialized w
|
|
|
107
127
|
|
|
108
128
|
- When Omni mode is off, Omni behaves like normal Pi and only uses `.omni/` as passive standards/context when those files already exist.
|
|
109
129
|
- When Omni mode is on, Omni lazily initializes or migrates `.omni/` on the first real turn, then uses the full interview, planning, task, and verification workflow.
|
|
130
|
+
- While Omni mode is on, Omni also maintains a runtime repo map in `.pi/repo-map/` so prompts can include a compact ranked view of important files and symbols.
|
|
110
131
|
- During Omni init, Omni can discover standards from files like `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, Copilot instructions, Cursor rules, Windsurf rules, and Continue rules, then ask whether to keep those standards in Omni's durable memory.
|
|
111
132
|
- In Git repos, Omni ensures `.pi/` is ignored because that directory is only runtime-local Pi state.
|
|
112
133
|
- While Omni mode is on, every planned or executed task checks for required skills, auto-installs matching skills into `.omni/project-skills/`, creates a project skill when none exists, records task-to-skill dependencies, and removes project skills once no open task still needs them.
|
|
@@ -13,12 +13,23 @@ import {
|
|
|
13
13
|
registerPiCommands,
|
|
14
14
|
} from "../../src/pi.js";
|
|
15
15
|
import { registerProviderAuthCommand } from "../../src/provider-auth-command.js";
|
|
16
|
+
import {
|
|
17
|
+
buildRepoMapPromptSuffix,
|
|
18
|
+
registerRepoMapTracking,
|
|
19
|
+
warmRepoMap,
|
|
20
|
+
} from "../../src/repo-map-runtime.js";
|
|
21
|
+
import {
|
|
22
|
+
formatRtkModeStatus,
|
|
23
|
+
refreshRtkStatusIndicator,
|
|
24
|
+
registerRtkBashRouting,
|
|
25
|
+
} from "../../src/rtk.js";
|
|
16
26
|
import {
|
|
17
27
|
createOmniTheme,
|
|
18
28
|
ensurePiSettings,
|
|
19
29
|
formatOmniModeStatus,
|
|
20
30
|
loadSavedTheme,
|
|
21
31
|
readOmniMode,
|
|
32
|
+
readRtkMode,
|
|
22
33
|
} from "../../src/theme.js";
|
|
23
34
|
import { registerThemeCommand } from "../../src/theme-command.js";
|
|
24
35
|
import { registerTodoShortcut } from "../../src/todo-shortcut.js";
|
|
@@ -33,6 +44,8 @@ export default function omniCoreExtension(api: ExtensionAPI): void {
|
|
|
33
44
|
registerThemeCommand(api);
|
|
34
45
|
registerTodoShortcut(api);
|
|
35
46
|
registerUpdater(api);
|
|
47
|
+
registerRtkBashRouting(api);
|
|
48
|
+
registerRepoMapTracking(api);
|
|
36
49
|
|
|
37
50
|
api.on("session_start", async (_event, ctx) => {
|
|
38
51
|
await ensurePiSettings(ctx.cwd);
|
|
@@ -42,6 +55,11 @@ export default function omniCoreExtension(api: ExtensionAPI): void {
|
|
|
42
55
|
ctx.ui.setTheme(createOmniTheme());
|
|
43
56
|
ctx.ui.setHeader((_tui, theme) => renderHeader(theme));
|
|
44
57
|
ctx.ui.setStatus("omni", formatOmniModeStatus(omniMode));
|
|
58
|
+
ctx.ui.setStatus("rtk", formatRtkModeStatus(readRtkMode(ctx.cwd), false));
|
|
59
|
+
await refreshRtkStatusIndicator(ctx);
|
|
60
|
+
if (omniMode) {
|
|
61
|
+
void warmRepoMap(ctx.cwd);
|
|
62
|
+
}
|
|
45
63
|
});
|
|
46
64
|
|
|
47
65
|
api.on("before_agent_start", async (event, ctx) => {
|
|
@@ -62,10 +80,14 @@ export default function omniCoreExtension(api: ExtensionAPI): void {
|
|
|
62
80
|
const onboardingKickoff = init.initResult?.onboardingInterviewNeeded
|
|
63
81
|
? buildOnboardingInterviewKickoff(init.initResult)
|
|
64
82
|
: "";
|
|
83
|
+
const repoMapPrompt = await buildRepoMapPromptSuffix(ctx.cwd, {
|
|
84
|
+
prompt: typeof event.prompt === "string" ? event.prompt : "",
|
|
85
|
+
});
|
|
65
86
|
const prompt = [
|
|
66
87
|
event.systemPrompt,
|
|
67
88
|
passivePrompt,
|
|
68
89
|
workflowPrompt,
|
|
90
|
+
repoMapPrompt,
|
|
69
91
|
onboardingKickoff,
|
|
70
92
|
]
|
|
71
93
|
.filter(Boolean)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omni-pi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Single-agent Pi package that interviews the user, documents the spec, and implements work in bounded slices.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -78,12 +78,12 @@
|
|
|
78
78
|
},
|
|
79
79
|
"dependencies": {
|
|
80
80
|
"@anthropic-ai/claude-agent-sdk": "0.2.84",
|
|
81
|
-
"@juanibiapina/pi-extension-settings": "^0.6.
|
|
82
|
-
"@juanibiapina/pi-powerbar": "^0.
|
|
83
|
-
"@mariozechner/pi-coding-agent": "^0.
|
|
84
|
-
"glimpseui": "^0.
|
|
85
|
-
"pi-interview": "^0.
|
|
86
|
-
"pi-web-access": "^0.10.
|
|
81
|
+
"@juanibiapina/pi-extension-settings": "^0.6.1",
|
|
82
|
+
"@juanibiapina/pi-powerbar": "^0.8.0",
|
|
83
|
+
"@mariozechner/pi-coding-agent": "^0.67.68",
|
|
84
|
+
"glimpseui": "^0.7.0",
|
|
85
|
+
"pi-interview": "^0.6.2",
|
|
86
|
+
"pi-web-access": "^0.10.6",
|
|
87
87
|
"zod": "^4.3.6"
|
|
88
88
|
},
|
|
89
89
|
"overrides": {
|
package/src/commands.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AppCommandDefinition } from "./pi.js";
|
|
2
|
+
import { executeRtkCommand } from "./rtk.js";
|
|
2
3
|
import { formatOmniModeStatus, readOmniMode, saveOmniMode } from "./theme.js";
|
|
3
4
|
|
|
4
5
|
export function createOmniCommands(): AppCommandDefinition[] {
|
|
@@ -22,5 +23,16 @@ export function createOmniCommands(): AppCommandDefinition[] {
|
|
|
22
23
|
: "Omni mode is now OFF. Omni will keep using durable standards from .omni/ when present, but task workflow state is disabled.";
|
|
23
24
|
},
|
|
24
25
|
},
|
|
26
|
+
{
|
|
27
|
+
name: "omni-rtk",
|
|
28
|
+
description:
|
|
29
|
+
"Install RTK and control Omni's bash-side RTK routing (status, install, on, off)",
|
|
30
|
+
async execute(context) {
|
|
31
|
+
if (!context.runtime) {
|
|
32
|
+
return "The /omni-rtk command is only available inside Omni-Pi interactive sessions.";
|
|
33
|
+
}
|
|
34
|
+
return await executeRtkCommand(context.args, context.runtime.ctx);
|
|
35
|
+
},
|
|
36
|
+
},
|
|
25
37
|
];
|
|
26
38
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
export const REPO_MAP_DIR = path.join(".pi", "repo-map");
|
|
4
|
+
export const REPO_MAP_STATE_FILE = path.join(REPO_MAP_DIR, "state.json");
|
|
5
|
+
export const REPO_MAP_SCHEMA_VERSION = 1;
|
|
6
|
+
|
|
7
|
+
export type RepoMapParserStatus =
|
|
8
|
+
| "indexed"
|
|
9
|
+
| "unsupported"
|
|
10
|
+
| "binary-fallback"
|
|
11
|
+
| "parse-fallback";
|
|
12
|
+
|
|
13
|
+
export type RepoMapSignalType = "read" | "edit" | "write" | "mention";
|
|
14
|
+
|
|
15
|
+
export interface RepoMapSymbol {
|
|
16
|
+
name: string;
|
|
17
|
+
kind:
|
|
18
|
+
| "function"
|
|
19
|
+
| "class"
|
|
20
|
+
| "interface"
|
|
21
|
+
| "type"
|
|
22
|
+
| "enum"
|
|
23
|
+
| "const"
|
|
24
|
+
| "default"
|
|
25
|
+
| "module";
|
|
26
|
+
signature?: string;
|
|
27
|
+
exported: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RepoMapImport {
|
|
31
|
+
specifier: string;
|
|
32
|
+
resolvedPath?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface RepoMapFileRecord {
|
|
36
|
+
path: string;
|
|
37
|
+
language: string;
|
|
38
|
+
parserStatus: RepoMapParserStatus;
|
|
39
|
+
size: number;
|
|
40
|
+
mtimeMs: number;
|
|
41
|
+
fingerprint: string;
|
|
42
|
+
indexedAt: string;
|
|
43
|
+
firstIndexedAt: string;
|
|
44
|
+
symbols: RepoMapSymbol[];
|
|
45
|
+
imports: RepoMapImport[];
|
|
46
|
+
outgoingPaths: string[];
|
|
47
|
+
incomingPaths: string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface RepoMapState {
|
|
51
|
+
schemaVersion: number;
|
|
52
|
+
indexedAt: string;
|
|
53
|
+
files: Record<string, RepoMapFileRecord>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface RepoMapRankedEntry {
|
|
57
|
+
path: string;
|
|
58
|
+
file: RepoMapFileRecord;
|
|
59
|
+
baseScore: number;
|
|
60
|
+
turnScore: number;
|
|
61
|
+
finalScore: number;
|
|
62
|
+
blastRadius: number;
|
|
63
|
+
tags: string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface RepoMapSignal {
|
|
67
|
+
type: RepoMapSignalType;
|
|
68
|
+
path: string;
|
|
69
|
+
timestamp: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface RepoMapSessionState {
|
|
73
|
+
signals: RepoMapSignal[];
|
|
74
|
+
dirtyPaths: Set<string>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface RepoMapRenderOptions {
|
|
78
|
+
prompt?: string;
|
|
79
|
+
maxTokens?: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface RepoMapRefreshResult {
|
|
83
|
+
state: RepoMapState;
|
|
84
|
+
indexedPaths: string[];
|
|
85
|
+
removedPaths: string[];
|
|
86
|
+
reusedPaths: string[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface RepoMapDebugSnapshot {
|
|
90
|
+
state: RepoMapState;
|
|
91
|
+
ranked: RepoMapRankedEntry[];
|
|
92
|
+
rendered: string;
|
|
93
|
+
}
|
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
RepoMapFileRecord,
|
|
6
|
+
RepoMapImport,
|
|
7
|
+
RepoMapRefreshResult,
|
|
8
|
+
RepoMapState,
|
|
9
|
+
RepoMapSymbol,
|
|
10
|
+
} from "./repo-map-contracts.js";
|
|
11
|
+
import { REPO_MAP_SCHEMA_VERSION } from "./repo-map-contracts.js";
|
|
12
|
+
import { readRepoMapState, writeRepoMapState } from "./repo-map-store.js";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_IGNORES = new Set([
|
|
15
|
+
".git",
|
|
16
|
+
".pi",
|
|
17
|
+
".omni",
|
|
18
|
+
"node_modules",
|
|
19
|
+
"dist",
|
|
20
|
+
"build",
|
|
21
|
+
"coverage",
|
|
22
|
+
".next",
|
|
23
|
+
".turbo",
|
|
24
|
+
".cache",
|
|
25
|
+
"target",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const SUPPORTED_EXTENSIONS = new Map<string, string>([
|
|
29
|
+
[".ts", "typescript"],
|
|
30
|
+
[".tsx", "typescript"],
|
|
31
|
+
[".mts", "typescript"],
|
|
32
|
+
[".cts", "typescript"],
|
|
33
|
+
[".js", "javascript"],
|
|
34
|
+
[".jsx", "javascript"],
|
|
35
|
+
[".mjs", "javascript"],
|
|
36
|
+
[".cjs", "javascript"],
|
|
37
|
+
[".json", "json"],
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
interface IgnoreRule {
|
|
41
|
+
pattern: string;
|
|
42
|
+
anchored: boolean;
|
|
43
|
+
directoryOnly: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface WalkContext {
|
|
47
|
+
rootDir: string;
|
|
48
|
+
relativeDir: string;
|
|
49
|
+
rules: IgnoreRule[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface ParsedFile {
|
|
53
|
+
language: string;
|
|
54
|
+
parserStatus: RepoMapFileRecord["parserStatus"];
|
|
55
|
+
symbols: RepoMapSymbol[];
|
|
56
|
+
imports: RepoMapImport[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function normalizeRelativePath(value: string): string {
|
|
60
|
+
return value.split(path.sep).join("/");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function hashContent(text: string): string {
|
|
64
|
+
let hash = 2166136261;
|
|
65
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
66
|
+
hash ^= text.charCodeAt(index);
|
|
67
|
+
hash = Math.imul(hash, 16777619);
|
|
68
|
+
}
|
|
69
|
+
return (hash >>> 0).toString(16);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseIgnoreRules(content: string): IgnoreRule[] {
|
|
73
|
+
return content
|
|
74
|
+
.split(/\r?\n/u)
|
|
75
|
+
.map((line) => line.trim())
|
|
76
|
+
.filter(
|
|
77
|
+
(line) =>
|
|
78
|
+
line.length > 0 && !line.startsWith("#") && !line.startsWith("!"),
|
|
79
|
+
)
|
|
80
|
+
.map((line) => {
|
|
81
|
+
const directoryOnly = line.endsWith("/");
|
|
82
|
+
const raw = directoryOnly ? line.slice(0, -1) : line;
|
|
83
|
+
const anchored = raw.startsWith("/");
|
|
84
|
+
return {
|
|
85
|
+
pattern: raw.replace(/^\//u, ""),
|
|
86
|
+
anchored,
|
|
87
|
+
directoryOnly,
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function matchSingleSegment(pattern: string, value: string): boolean {
|
|
93
|
+
const escaped = pattern
|
|
94
|
+
.replace(/[.+^${}()|[\]\\]/gu, "\\$&")
|
|
95
|
+
.replace(/\*/gu, ".*");
|
|
96
|
+
return new RegExp(`^${escaped}$`, "u").test(value);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isIgnored(
|
|
100
|
+
relativePath: string,
|
|
101
|
+
isDirectory: boolean,
|
|
102
|
+
rules: IgnoreRule[],
|
|
103
|
+
): boolean {
|
|
104
|
+
const normalized = normalizeRelativePath(relativePath);
|
|
105
|
+
const segments = normalized.split("/").filter(Boolean);
|
|
106
|
+
return rules.some((rule) => {
|
|
107
|
+
if (rule.directoryOnly && !isDirectory) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
if (rule.pattern.includes("/")) {
|
|
111
|
+
const target = rule.anchored ? normalized : normalized;
|
|
112
|
+
if (rule.pattern.includes("*")) {
|
|
113
|
+
const escaped = rule.pattern
|
|
114
|
+
.replace(/[.+^${}()|[\]\\]/gu, "\\$&")
|
|
115
|
+
.replace(/\*/gu, ".*");
|
|
116
|
+
const regex = rule.anchored
|
|
117
|
+
? new RegExp(`^${escaped}(?:/.*)?$`, "u")
|
|
118
|
+
: new RegExp(`(?:^|/)${escaped}(?:/.*)?$`, "u");
|
|
119
|
+
return regex.test(target);
|
|
120
|
+
}
|
|
121
|
+
return rule.anchored
|
|
122
|
+
? target === rule.pattern || target.startsWith(`${rule.pattern}/`)
|
|
123
|
+
: target === rule.pattern ||
|
|
124
|
+
target.includes(`/${rule.pattern}`) ||
|
|
125
|
+
target.startsWith(`${rule.pattern}/`);
|
|
126
|
+
}
|
|
127
|
+
return segments.some((segment) =>
|
|
128
|
+
matchSingleSegment(rule.pattern, segment),
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function loadIgnoreRules(dir: string): Promise<IgnoreRule[]> {
|
|
134
|
+
try {
|
|
135
|
+
const content = await readFile(path.join(dir, ".gitignore"), "utf8");
|
|
136
|
+
return parseIgnoreRules(content);
|
|
137
|
+
} catch {
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function walkEligibleFiles(context: WalkContext): Promise<string[]> {
|
|
143
|
+
const absoluteDir = path.join(context.rootDir, context.relativeDir);
|
|
144
|
+
const [entries, localRules] = await Promise.all([
|
|
145
|
+
readdir(absoluteDir, { withFileTypes: true }),
|
|
146
|
+
loadIgnoreRules(absoluteDir),
|
|
147
|
+
]);
|
|
148
|
+
const rules = [...context.rules, ...localRules];
|
|
149
|
+
const results: string[] = [];
|
|
150
|
+
|
|
151
|
+
for (const entry of entries) {
|
|
152
|
+
const relativePath = normalizeRelativePath(
|
|
153
|
+
path.join(context.relativeDir, entry.name),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
if (DEFAULT_IGNORES.has(entry.name)) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (isIgnored(relativePath, entry.isDirectory(), rules)) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (entry.isDirectory()) {
|
|
164
|
+
results.push(
|
|
165
|
+
...(await walkEligibleFiles({
|
|
166
|
+
rootDir: context.rootDir,
|
|
167
|
+
relativeDir: relativePath,
|
|
168
|
+
rules,
|
|
169
|
+
})),
|
|
170
|
+
);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const extension = path.extname(entry.name).toLowerCase();
|
|
175
|
+
if (!SUPPORTED_EXTENSIONS.has(extension)) {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
results.push(relativePath);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return results.sort();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function discoverRepoMapFiles(rootDir: string): Promise<string[]> {
|
|
185
|
+
return walkEligibleFiles({ rootDir, relativeDir: "", rules: [] });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function detectLanguage(filePath: string): string {
|
|
189
|
+
return (
|
|
190
|
+
SUPPORTED_EXTENSIONS.get(path.extname(filePath).toLowerCase()) ?? "text"
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function resolveImportPath(
|
|
195
|
+
rootDir: string,
|
|
196
|
+
filePath: string,
|
|
197
|
+
specifier: string,
|
|
198
|
+
): string | undefined {
|
|
199
|
+
if (!specifier.startsWith(".")) {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const basedir = path.dirname(path.join(rootDir, filePath));
|
|
204
|
+
const candidateBase = path.resolve(basedir, specifier);
|
|
205
|
+
const candidates = [
|
|
206
|
+
candidateBase,
|
|
207
|
+
`${candidateBase}.ts`,
|
|
208
|
+
`${candidateBase}.tsx`,
|
|
209
|
+
`${candidateBase}.mts`,
|
|
210
|
+
`${candidateBase}.cts`,
|
|
211
|
+
`${candidateBase}.js`,
|
|
212
|
+
`${candidateBase}.jsx`,
|
|
213
|
+
`${candidateBase}.mjs`,
|
|
214
|
+
`${candidateBase}.cjs`,
|
|
215
|
+
path.join(candidateBase, "index.ts"),
|
|
216
|
+
path.join(candidateBase, "index.tsx"),
|
|
217
|
+
path.join(candidateBase, "index.js"),
|
|
218
|
+
path.join(candidateBase, "index.jsx"),
|
|
219
|
+
];
|
|
220
|
+
|
|
221
|
+
for (const candidate of candidates) {
|
|
222
|
+
const relative = normalizeRelativePath(path.relative(rootDir, candidate));
|
|
223
|
+
if (relative.startsWith("..")) {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (
|
|
227
|
+
SUPPORTED_EXTENSIONS.has(path.extname(candidate).toLowerCase()) ||
|
|
228
|
+
candidate.endsWith("/index.ts") ||
|
|
229
|
+
candidate.endsWith("/index.tsx") ||
|
|
230
|
+
candidate.endsWith("/index.js") ||
|
|
231
|
+
candidate.endsWith("/index.jsx")
|
|
232
|
+
) {
|
|
233
|
+
return relative;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function uniqueSymbols(symbols: RepoMapSymbol[]): RepoMapSymbol[] {
|
|
241
|
+
const seen = new Set<string>();
|
|
242
|
+
return symbols.filter((symbol) => {
|
|
243
|
+
const key = `${symbol.kind}:${symbol.name}:${symbol.exported}`;
|
|
244
|
+
if (seen.has(key)) {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
seen.add(key);
|
|
248
|
+
return true;
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function parseModuleFile(
|
|
253
|
+
rootDir: string,
|
|
254
|
+
filePath: string,
|
|
255
|
+
content: string,
|
|
256
|
+
): ParsedFile {
|
|
257
|
+
if (content.includes("\u0000")) {
|
|
258
|
+
return {
|
|
259
|
+
language: detectLanguage(filePath),
|
|
260
|
+
parserStatus: "binary-fallback",
|
|
261
|
+
symbols: [],
|
|
262
|
+
imports: [],
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const imports: RepoMapImport[] = [];
|
|
267
|
+
const importRegex =
|
|
268
|
+
/^(?:import\s+[\s\S]*?\s+from\s+|export\s+[\s\S]*?\s+from\s+)["']([^"']+)["'];?/gmu;
|
|
269
|
+
for (const match of content.matchAll(importRegex)) {
|
|
270
|
+
const specifier = match[1]?.trim();
|
|
271
|
+
if (!specifier) continue;
|
|
272
|
+
imports.push({
|
|
273
|
+
specifier,
|
|
274
|
+
resolvedPath: resolveImportPath(rootDir, filePath, specifier),
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const symbols: RepoMapSymbol[] = [];
|
|
279
|
+
const symbolPatterns: Array<{ regex: RegExp; kind: RepoMapSymbol["kind"] }> =
|
|
280
|
+
[
|
|
281
|
+
{
|
|
282
|
+
regex:
|
|
283
|
+
/^export\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*(\([^)]*\))/gmu,
|
|
284
|
+
kind: "function",
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
regex: /^export\s+class\s+([A-Za-z_$][\w$]*)/gmu,
|
|
288
|
+
kind: "class",
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
regex: /^export\s+interface\s+([A-Za-z_$][\w$]*)/gmu,
|
|
292
|
+
kind: "interface",
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
regex: /^export\s+type\s+([A-Za-z_$][\w$]*)/gmu,
|
|
296
|
+
kind: "type",
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
regex: /^export\s+enum\s+([A-Za-z_$][\w$]*)/gmu,
|
|
300
|
+
kind: "enum",
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
regex: /^export\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/gmu,
|
|
304
|
+
kind: "const",
|
|
305
|
+
},
|
|
306
|
+
];
|
|
307
|
+
|
|
308
|
+
for (const pattern of symbolPatterns) {
|
|
309
|
+
for (const match of content.matchAll(pattern.regex)) {
|
|
310
|
+
const name = match[1]?.trim();
|
|
311
|
+
if (!name) continue;
|
|
312
|
+
const signature = match[2]?.trim();
|
|
313
|
+
symbols.push({
|
|
314
|
+
name,
|
|
315
|
+
kind: pattern.kind,
|
|
316
|
+
signature,
|
|
317
|
+
exported: true,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (/^export\s+default\b/gmu.test(content)) {
|
|
323
|
+
symbols.push({
|
|
324
|
+
name: path.basename(filePath, path.extname(filePath)),
|
|
325
|
+
kind: "default",
|
|
326
|
+
exported: true,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
language: detectLanguage(filePath),
|
|
332
|
+
parserStatus: "indexed",
|
|
333
|
+
symbols: uniqueSymbols(symbols),
|
|
334
|
+
imports,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export async function indexRepoMapFile(
|
|
339
|
+
rootDir: string,
|
|
340
|
+
filePath: string,
|
|
341
|
+
previous?: RepoMapFileRecord,
|
|
342
|
+
): Promise<RepoMapFileRecord> {
|
|
343
|
+
const absolutePath = path.join(rootDir, filePath);
|
|
344
|
+
const [stats, content] = await Promise.all([
|
|
345
|
+
stat(absolutePath),
|
|
346
|
+
readFile(absolutePath, "utf8"),
|
|
347
|
+
]);
|
|
348
|
+
const parsed = parseModuleFile(rootDir, filePath, content);
|
|
349
|
+
const now = new Date().toISOString();
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
path: filePath,
|
|
353
|
+
language: parsed.language,
|
|
354
|
+
parserStatus: parsed.parserStatus,
|
|
355
|
+
size: stats.size,
|
|
356
|
+
mtimeMs: stats.mtimeMs,
|
|
357
|
+
fingerprint: hashContent(content),
|
|
358
|
+
indexedAt: now,
|
|
359
|
+
firstIndexedAt: previous?.firstIndexedAt ?? now,
|
|
360
|
+
symbols: parsed.symbols,
|
|
361
|
+
imports: parsed.imports,
|
|
362
|
+
outgoingPaths: parsed.imports
|
|
363
|
+
.map((entry) => entry.resolvedPath)
|
|
364
|
+
.filter((value): value is string => Boolean(value)),
|
|
365
|
+
incomingPaths: previous?.incomingPaths ?? [],
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function rebuildIncomingPaths(files: Record<string, RepoMapFileRecord>): void {
|
|
370
|
+
for (const file of Object.values(files)) {
|
|
371
|
+
file.incomingPaths = [];
|
|
372
|
+
}
|
|
373
|
+
for (const file of Object.values(files)) {
|
|
374
|
+
const uniqueOutgoing = [...new Set(file.outgoingPaths)].filter(
|
|
375
|
+
(target) => target in files,
|
|
376
|
+
);
|
|
377
|
+
file.outgoingPaths = uniqueOutgoing;
|
|
378
|
+
for (const target of uniqueOutgoing) {
|
|
379
|
+
files[target]?.incomingPaths.push(file.path);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
for (const file of Object.values(files)) {
|
|
383
|
+
file.incomingPaths = [...new Set(file.incomingPaths)].sort();
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export async function refreshRepoMapState(
|
|
388
|
+
rootDir: string,
|
|
389
|
+
dirtyPaths: Iterable<string> = [],
|
|
390
|
+
): Promise<RepoMapRefreshResult> {
|
|
391
|
+
const previous = await readRepoMapState(rootDir);
|
|
392
|
+
const discovered = await discoverRepoMapFiles(rootDir);
|
|
393
|
+
const discoveredSet = new Set(discovered);
|
|
394
|
+
const dirtySet = new Set([...dirtyPaths].map(normalizeRelativePath));
|
|
395
|
+
const nextFiles: Record<string, RepoMapFileRecord> = {};
|
|
396
|
+
const indexedPaths: string[] = [];
|
|
397
|
+
const reusedPaths: string[] = [];
|
|
398
|
+
|
|
399
|
+
for (const filePath of discovered) {
|
|
400
|
+
const previousRecord = previous.files[filePath];
|
|
401
|
+
const absolutePath = path.join(rootDir, filePath);
|
|
402
|
+
const stats = await stat(absolutePath);
|
|
403
|
+
const unchanged =
|
|
404
|
+
previousRecord &&
|
|
405
|
+
previousRecord.mtimeMs === stats.mtimeMs &&
|
|
406
|
+
previousRecord.size === stats.size &&
|
|
407
|
+
!dirtySet.has(filePath) &&
|
|
408
|
+
previous.schemaVersion === REPO_MAP_SCHEMA_VERSION;
|
|
409
|
+
|
|
410
|
+
if (unchanged) {
|
|
411
|
+
nextFiles[filePath] = {
|
|
412
|
+
...previousRecord,
|
|
413
|
+
incomingPaths: [...previousRecord.incomingPaths],
|
|
414
|
+
outgoingPaths: [...previousRecord.outgoingPaths],
|
|
415
|
+
imports: [...previousRecord.imports],
|
|
416
|
+
symbols: [...previousRecord.symbols],
|
|
417
|
+
};
|
|
418
|
+
reusedPaths.push(filePath);
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
try {
|
|
423
|
+
nextFiles[filePath] = await indexRepoMapFile(
|
|
424
|
+
rootDir,
|
|
425
|
+
filePath,
|
|
426
|
+
previousRecord,
|
|
427
|
+
);
|
|
428
|
+
indexedPaths.push(filePath);
|
|
429
|
+
} catch {
|
|
430
|
+
nextFiles[filePath] = {
|
|
431
|
+
path: filePath,
|
|
432
|
+
language: detectLanguage(filePath),
|
|
433
|
+
parserStatus: "parse-fallback",
|
|
434
|
+
size: stats.size,
|
|
435
|
+
mtimeMs: stats.mtimeMs,
|
|
436
|
+
fingerprint: `${stats.size}:${stats.mtimeMs}`,
|
|
437
|
+
indexedAt: new Date().toISOString(),
|
|
438
|
+
firstIndexedAt:
|
|
439
|
+
previousRecord?.firstIndexedAt ?? new Date().toISOString(),
|
|
440
|
+
symbols: [],
|
|
441
|
+
imports: [],
|
|
442
|
+
outgoingPaths: [],
|
|
443
|
+
incomingPaths: [],
|
|
444
|
+
};
|
|
445
|
+
indexedPaths.push(filePath);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
rebuildIncomingPaths(nextFiles);
|
|
450
|
+
const removedPaths = Object.keys(previous.files).filter(
|
|
451
|
+
(filePath) => !discoveredSet.has(filePath),
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
const state: RepoMapState = {
|
|
455
|
+
schemaVersion: REPO_MAP_SCHEMA_VERSION,
|
|
456
|
+
indexedAt: new Date().toISOString(),
|
|
457
|
+
files: nextFiles,
|
|
458
|
+
};
|
|
459
|
+
await writeRepoMapState(rootDir, state);
|
|
460
|
+
|
|
461
|
+
return {
|
|
462
|
+
state,
|
|
463
|
+
indexedPaths: indexedPaths.sort(),
|
|
464
|
+
removedPaths: removedPaths.sort(),
|
|
465
|
+
reusedPaths: reusedPaths.sort(),
|
|
466
|
+
};
|
|
467
|
+
}
|