@yannelli/paseo-linear-plugin 1.0.1 → 1.1.0-alpha.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/OVERVIEW.md +32 -2
- package/README.md +134 -8
- package/client/access.tsx +67 -0
- package/client/browser.tsx +12 -9
- package/client/compat.ts +23 -5
- package/client/connect.tsx +3 -2
- package/client/launch-assignments.tsx +182 -0
- package/client/launch-choices.ts +21 -6
- package/client/launch-guidance.tsx +103 -0
- package/client/launch-plan.ts +114 -17
- package/client/launch.tsx +55 -9
- package/client/live-agents.tsx +446 -0
- package/client/live-feed.tsx +151 -0
- package/client/live-header.tsx +310 -0
- package/client/live-issues.tsx +345 -0
- package/client/live-map.tsx +351 -0
- package/client/live-panel.tsx +392 -0
- package/client/live-timeline.ts +379 -0
- package/client/pill.tsx +394 -0
- package/client/plugin-icon.tsx +75 -0
- package/client/project-init.tsx +192 -0
- package/client/project-knowledge.tsx +87 -0
- package/client/settings-access.tsx +56 -0
- package/client/settings-agents.tsx +113 -0
- package/client/settings-fields.tsx +11 -3
- package/client/settings-icon.tsx +277 -0
- package/client/settings-live.tsx +128 -0
- package/client/settings-overrides.tsx +148 -0
- package/client/settings-projects.tsx +26 -4
- package/client/settings-prompts.tsx +3 -3
- package/client/timeline-card.tsx +2 -1
- package/client/tool-buttons.tsx +68 -0
- package/client/ui.tsx +16 -7
- package/client/web.ts +81 -0
- package/index.client.tsx +160 -51
- package/index.server.ts +32 -5
- package/package.json +1 -1
- package/server/agent-hooks.ts +37 -0
- package/server/agent-runs.ts +312 -0
- package/server/agent-tools.ts +276 -0
- package/server/handlers.ts +12 -1
- package/server/init.ts +89 -0
- package/server/inspect.ts +392 -0
- package/server/knowledge.ts +157 -0
- package/server/live.ts +371 -0
- package/server/mcp-http.ts +156 -0
- package/server/stores.ts +126 -0
- package/server/subagent-logs.ts +146 -0
- package/server/sync.ts +133 -0
- package/shared/activity.ts +268 -0
- package/shared/agent-hooks.ts +68 -0
- package/shared/agent-tools.ts +157 -0
- package/shared/custom-icon.ts +188 -0
- package/shared/knowledge.ts +255 -0
- package/shared/live.ts +248 -0
- package/shared/map-model.ts +240 -0
- package/shared/project-setup.ts +56 -0
- package/shared/prompts.ts +109 -2
- package/shared/settings.ts +145 -3
- package/shared/shell-activity.ts +309 -0
- package/shared/shell-lexer.ts +180 -0
- package/shared/subagents.ts +106 -0
- package/shared/todo-sync.ts +158 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { type Area, MAX_KNOWLEDGE_FILES, type ProjectKnowledge } from "../shared/knowledge";
|
|
6
|
+
|
|
7
|
+
// Project inspection without an agent: list the files, read the manifests, and find the
|
|
8
|
+
// services, packages, and main folders. It reads only small manifest files inside the root.
|
|
9
|
+
|
|
10
|
+
const run = promisify(execFile);
|
|
11
|
+
const GIT_TIMEOUT_MS = 20_000;
|
|
12
|
+
const MAX_MANIFEST_BYTES = 256 * 1024;
|
|
13
|
+
const MAX_MANIFESTS = 300;
|
|
14
|
+
const MAX_AREAS = 60;
|
|
15
|
+
const MAX_GUIDES = 30;
|
|
16
|
+
/** A top folder with this share of the files also gets its subfolders as areas. */
|
|
17
|
+
const BIG_FOLDER_SHARE = 0.4;
|
|
18
|
+
|
|
19
|
+
const SKIPPED_DIRS = new Set([
|
|
20
|
+
".git", "node_modules", "dist", "build", "out", "target", "vendor", ".next", ".nuxt",
|
|
21
|
+
".turbo", ".cache", "coverage", "__pycache__", ".venv", "venv", ".gradle", "Pods",
|
|
22
|
+
]);
|
|
23
|
+
// Manifests in these folders describe test data or samples, not parts of the project.
|
|
24
|
+
const NOT_AREAS = /^(?:fixtures?|__fixtures__|testdata|test-data|examples?|samples?|templates?|node_modules|vendor|third_party)$/i;
|
|
25
|
+
const SERVICE_PARENTS = /^(?:apps|services|cmd|functions|lambdas|workers)$/i;
|
|
26
|
+
const SERVICE_MARKER =
|
|
27
|
+
/^(?:(?:Dockerfile|Containerfile)(?:\..+)?|Procfile|fly\.toml|serverless\.ya?ml|wrangler\.toml|vercel\.json|netlify\.toml|render\.ya?ml)$/;
|
|
28
|
+
const COMPOSE_FILE = /^(?:docker-)?compose(?:\.[\w-]+)?\.ya?ml$/;
|
|
29
|
+
const PROJECT_FILE = /\.(?:csproj|fsproj)$/;
|
|
30
|
+
|
|
31
|
+
export interface FileList {
|
|
32
|
+
files: string[];
|
|
33
|
+
total: number;
|
|
34
|
+
truncated: boolean;
|
|
35
|
+
source: "git" | "walk";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Shallow files first, so a cut keeps the outline of the project. */
|
|
39
|
+
function keepShallow(files: readonly string[], limit: number): string[] {
|
|
40
|
+
if (files.length <= limit) return [...files].sort();
|
|
41
|
+
const depth = (file: string) => file.split("/").length;
|
|
42
|
+
return [...files]
|
|
43
|
+
.sort((a, b) => depth(a) - depth(b) || a.localeCompare(b))
|
|
44
|
+
.slice(0, limit)
|
|
45
|
+
.sort();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function gitFiles(root: string): Promise<string[] | null> {
|
|
49
|
+
try {
|
|
50
|
+
const { stdout } = await run(
|
|
51
|
+
"git",
|
|
52
|
+
["-C", root, "ls-files", "-z", "--cached", "--others", "--exclude-standard"],
|
|
53
|
+
{ maxBuffer: 256 * 1024 * 1024, timeout: GIT_TIMEOUT_MS, encoding: "utf8" },
|
|
54
|
+
);
|
|
55
|
+
return [...new Set(stdout.split("\0").filter(Boolean))];
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function walkFiles(root: string, limit: number): Promise<{ files: string[]; complete: boolean }> {
|
|
62
|
+
const files: string[] = [];
|
|
63
|
+
const queue = [""];
|
|
64
|
+
for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
|
|
65
|
+
const entries = await readdir(path.join(root, next), { withFileTypes: true }).catch(() => []);
|
|
66
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
67
|
+
if (entry.isDirectory() && !SKIPPED_DIRS.has(entry.name)) queue.push(`${next}${entry.name}/`);
|
|
68
|
+
if (!entry.isFile()) continue;
|
|
69
|
+
files.push(`${next}${entry.name}`);
|
|
70
|
+
if (files.length >= limit) return { files, complete: false };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { files, complete: true };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Files git knows and does not ignore; a folder walk when the root is not in a repository. */
|
|
77
|
+
export async function listProjectFiles(root: string, limit = MAX_KNOWLEDGE_FILES): Promise<FileList> {
|
|
78
|
+
const tracked = await gitFiles(root);
|
|
79
|
+
if (tracked) {
|
|
80
|
+
return { files: keepShallow(tracked, limit), total: tracked.length, truncated: tracked.length > limit, source: "git" };
|
|
81
|
+
}
|
|
82
|
+
const walked = await walkFiles(root, limit);
|
|
83
|
+
return { files: walked.files.sort(), total: walked.files.length, truncated: !walked.complete, source: "walk" };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const baseName = (file: string) => file.slice(file.lastIndexOf("/") + 1);
|
|
87
|
+
const dirOf = (file: string) => file.slice(0, file.lastIndexOf("/") + 1);
|
|
88
|
+
|
|
89
|
+
function jsonName(text: string): string | null {
|
|
90
|
+
try {
|
|
91
|
+
const value = JSON.parse(text) as { name?: unknown };
|
|
92
|
+
return typeof value.name === "string" && value.name ? value.name : null;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function tomlName(text: string, section: RegExp): string | null {
|
|
99
|
+
const start = text.search(section);
|
|
100
|
+
if (start < 0) return null;
|
|
101
|
+
const body = text.slice(start).split(/\n(?=\[)/)[0] ?? "";
|
|
102
|
+
return /^name\s*=\s*["']([^"']+)["']/m.exec(body)?.[1] ?? null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const NAME_PARSERS: Partial<Record<string, (text: string) => string | null>> = {
|
|
106
|
+
"package.json": jsonName,
|
|
107
|
+
"composer.json": jsonName,
|
|
108
|
+
"deno.json": jsonName,
|
|
109
|
+
"go.mod": (text) => /^module\s+(\S+)/m.exec(text)?.[1]?.split("/").pop() ?? null,
|
|
110
|
+
"Cargo.toml": (text) => tomlName(text, /^\[package\]/m),
|
|
111
|
+
"pyproject.toml": (text) => tomlName(text, /^\[(?:project|tool\.poetry)\]/m),
|
|
112
|
+
"pubspec.yaml": (text) => /^name:\s*["']?([\w.-]+)/m.exec(text)?.[1] ?? null,
|
|
113
|
+
"pom.xml": (text) =>
|
|
114
|
+
/<artifactId>([^<]+)<\/artifactId>/.exec(text.replace(/<parent>[\s\S]*?<\/parent>/, ""))?.[1]?.trim() ?? null,
|
|
115
|
+
};
|
|
116
|
+
const NAMELESS = new Set(["setup.py", "setup.cfg", "Gemfile", "build.gradle", "build.gradle.kts", "mix.exs", "Package.swift"]);
|
|
117
|
+
|
|
118
|
+
export interface Manifest {
|
|
119
|
+
kind: "manifest" | "marker" | "compose";
|
|
120
|
+
name: string | null;
|
|
121
|
+
/** Services a compose file builds from folders, with those folders. */
|
|
122
|
+
services: { name: string; path: string }[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function insideRoot(dir: string, relative: string): string | null {
|
|
126
|
+
if (/^[\w+.-]+:\/\/|^git@/.test(relative)) return null;
|
|
127
|
+
const joined = path.posix.normalize(path.posix.join(dir || ".", relative));
|
|
128
|
+
if (joined.startsWith("..") || joined.startsWith("/")) return null;
|
|
129
|
+
return joined === "." ? "" : `${joined.replace(/\/$/, "")}/`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Services in a compose file that build from a folder, by indentation, without a YAML parser. */
|
|
133
|
+
export function composeServices(text: string, dir: string): { name: string; path: string }[] {
|
|
134
|
+
const found: { name: string; build: string | null }[] = [];
|
|
135
|
+
let inServices = false;
|
|
136
|
+
let indent = -1;
|
|
137
|
+
let current: { name: string; build: string | null } | null = null;
|
|
138
|
+
for (const raw of text.split("\n")) {
|
|
139
|
+
const line = raw.replace(/\s+#.*$/, "").replace(/\s+$/, "");
|
|
140
|
+
if (!line.trim() || line.trimStart().startsWith("#")) continue;
|
|
141
|
+
const depth = line.length - line.trimStart().length;
|
|
142
|
+
if (depth === 0) {
|
|
143
|
+
inServices = /^services:$/.test(line);
|
|
144
|
+
indent = -1;
|
|
145
|
+
current = null;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (!inServices) continue;
|
|
149
|
+
if (indent < 0) indent = depth;
|
|
150
|
+
if (depth === indent) {
|
|
151
|
+
const key = /^\s*["']?([\w.-]+)["']?:/.exec(line)?.[1];
|
|
152
|
+
current = key ? { name: key, build: null } : null;
|
|
153
|
+
if (current) found.push(current);
|
|
154
|
+
} else if (current && !current.build) {
|
|
155
|
+
current.build = /^\s*(?:build|context):\s*["']?([^"'\s]+)/.exec(line)?.[1] ?? null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return found.flatMap((service) => {
|
|
159
|
+
const folder = service.build ? insideRoot(dir, service.build) : null;
|
|
160
|
+
return folder === null ? [] : [{ name: service.name, path: folder }];
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function manifestKind(file: string): "manifest" | "marker" | "compose" | null {
|
|
165
|
+
const name = baseName(file);
|
|
166
|
+
if (name in NAME_PARSERS || NAMELESS.has(name) || PROJECT_FILE.test(name)) return "manifest";
|
|
167
|
+
if (SERVICE_MARKER.test(name)) return "marker";
|
|
168
|
+
return COMPOSE_FILE.test(name) ? "compose" : null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function describesProject(file: string): boolean {
|
|
172
|
+
return !file.split("/").slice(0, -1).some((segment) => NOT_AREAS.test(segment) || SKIPPED_DIRS.has(segment));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Reads a file under the real root, or null when it leaves the root or is too large. */
|
|
176
|
+
async function readText(root: string, file: string): Promise<string | null> {
|
|
177
|
+
const resolved = await realpath(path.join(root, file)).catch(() => null);
|
|
178
|
+
if (!resolved || !resolved.startsWith(`${root}${path.sep}`)) return null;
|
|
179
|
+
const info = await stat(resolved).catch(() => null);
|
|
180
|
+
if (!info?.isFile() || info.size > MAX_MANIFEST_BYTES) return null;
|
|
181
|
+
return readFile(resolved, "utf8").catch(() => null);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function readManifests(root: string, files: readonly string[]): Promise<Map<string, Manifest>> {
|
|
185
|
+
const chosen = files
|
|
186
|
+
.filter((file) => manifestKind(file) !== null && describesProject(file))
|
|
187
|
+
.slice(0, MAX_MANIFESTS);
|
|
188
|
+
const entries = await Promise.all(
|
|
189
|
+
chosen.map(async (file): Promise<[string, Manifest]> => {
|
|
190
|
+
const kind = manifestKind(file) ?? "manifest";
|
|
191
|
+
const parse = NAME_PARSERS[baseName(file)];
|
|
192
|
+
const text = kind === "compose" || parse ? await readText(root, file) : null;
|
|
193
|
+
let name = text && parse ? parse(text) : null;
|
|
194
|
+
if (PROJECT_FILE.test(file)) name = baseName(file).replace(PROJECT_FILE, "");
|
|
195
|
+
const services = kind === "compose" && text ? composeServices(text, dirOf(file)) : [];
|
|
196
|
+
return [file, { kind, name, services }];
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
return new Map(entries);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const LANGUAGES: Record<string, string> = {
|
|
203
|
+
ts: "TypeScript", tsx: "TypeScript", mts: "TypeScript", cts: "TypeScript",
|
|
204
|
+
js: "JavaScript", jsx: "JavaScript", mjs: "JavaScript", cjs: "JavaScript",
|
|
205
|
+
py: "Python", go: "Go", rs: "Rust", rb: "Ruby", java: "Java", kt: "Kotlin", swift: "Swift",
|
|
206
|
+
c: "C", h: "C", cc: "C++", cpp: "C++", hpp: "C++", cs: "C#", fs: "F#", php: "PHP",
|
|
207
|
+
vue: "Vue", svelte: "Svelte", css: "CSS", scss: "CSS", sass: "CSS", less: "CSS", html: "HTML",
|
|
208
|
+
md: "Markdown", mdx: "Markdown", sql: "SQL", sh: "Shell", bash: "Shell", ex: "Elixir",
|
|
209
|
+
exs: "Elixir", dart: "Dart", scala: "Scala", lua: "Lua", tf: "Terraform", proto: "Protobuf",
|
|
210
|
+
graphql: "GraphQL", gql: "GraphQL", prisma: "Prisma",
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
export function languageCounts(files: readonly string[]): { name: string; files: number }[] {
|
|
214
|
+
const counts = new Map<string, number>();
|
|
215
|
+
for (const file of files) {
|
|
216
|
+
const language = LANGUAGES[file.slice(file.lastIndexOf(".") + 1).toLowerCase()];
|
|
217
|
+
if (language && file.includes(".")) counts.set(language, (counts.get(language) ?? 0) + 1);
|
|
218
|
+
}
|
|
219
|
+
return [...counts]
|
|
220
|
+
.map(([name, count]) => ({ name, files: count }))
|
|
221
|
+
.sort((a, b) => b.files - a.files || a.name.localeCompare(b.name));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const ROLES: [RegExp, string][] = [
|
|
225
|
+
[/^(?:tests?|__tests__|specs?|e2e|integration|cypress|playwright)$/i, "tests"],
|
|
226
|
+
[/^(?:docs?|documentation|website)$/i, "docs"],
|
|
227
|
+
[/^(?:scripts?|bin|tools?|tooling|hack)$/i, "scripts"],
|
|
228
|
+
[/^\.(?:github|circleci|gitlab|buildkite)$/i, "CI"],
|
|
229
|
+
[/^(?:client|web|frontend|ui|www|site|pages|views|components)$/i, "frontend"],
|
|
230
|
+
[/^(?:server|api|backend|handlers|routes|controllers|daemon)$/i, "backend"],
|
|
231
|
+
[/^(?:shared|common|lib|libs|core|utils?|internal|pkg)$/i, "shared code"],
|
|
232
|
+
[/^(?:db|database|migrations?|prisma|schemas?|sql|models)$/i, "data"],
|
|
233
|
+
[/^(?:infra|infrastructure|deploy|deployment|terraform|k8s|kubernetes|helm|docker|ops|\.devcontainer)$/i, "infrastructure"],
|
|
234
|
+
[/^(?:config|configs|settings|\.vscode|\.claude|\.cursor)$/i, "config"],
|
|
235
|
+
[/^(?:assets|static|public|images|img|fonts|media)$/i, "assets"],
|
|
236
|
+
[/^(?:ios|android|mobile)$/i, "mobile"],
|
|
237
|
+
[/^(?:apps|services)$/i, "services"],
|
|
238
|
+
[/^(?:packages|modules|crates)$/i, "packages"],
|
|
239
|
+
];
|
|
240
|
+
|
|
241
|
+
export function roleOf(dir: string): string {
|
|
242
|
+
const folder = dir.replace(/\/$/, "").split("/").pop() ?? "";
|
|
243
|
+
return ROLES.find(([pattern]) => pattern.test(folder))?.[1] ?? "";
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
interface Seed {
|
|
247
|
+
path: string;
|
|
248
|
+
names: string[];
|
|
249
|
+
manifests: string[];
|
|
250
|
+
service: boolean;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function folderSeeds(files: readonly string[], taken: ReadonlyMap<string, Seed>): string[] {
|
|
254
|
+
const under = (dir: string) => files.filter((file) => file.startsWith(dir));
|
|
255
|
+
const childDirs = (dir: string) =>
|
|
256
|
+
[...new Set(under(dir).map((file) => file.slice(dir.length)).filter((rest) => rest.includes("/")).map((rest) => `${dir}${rest.split("/")[0]}/`))];
|
|
257
|
+
const folders: string[] = [];
|
|
258
|
+
for (const top of childDirs("")) {
|
|
259
|
+
if (!taken.has(top)) folders.push(top);
|
|
260
|
+
const children = childDirs(top);
|
|
261
|
+
if (under(top).length >= files.length * BIG_FOLDER_SHARE && children.length >= 2) {
|
|
262
|
+
folders.push(...children.filter((child) => !taken.has(child)));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return folders;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Services and packages from manifests, then top folders, and the subfolders of a big one. */
|
|
269
|
+
export function detectAreas(
|
|
270
|
+
files: readonly string[],
|
|
271
|
+
manifests: ReadonlyMap<string, Manifest>,
|
|
272
|
+
rootName: string,
|
|
273
|
+
): Area[] {
|
|
274
|
+
const seeds = new Map<string, Seed>();
|
|
275
|
+
const seed = (dir: string): Seed => {
|
|
276
|
+
const known = seeds.get(dir);
|
|
277
|
+
if (known) return known;
|
|
278
|
+
const created: Seed = { path: dir, names: [], manifests: [], service: false };
|
|
279
|
+
seeds.set(dir, created);
|
|
280
|
+
return created;
|
|
281
|
+
};
|
|
282
|
+
for (const [file, manifest] of manifests) {
|
|
283
|
+
if (manifest.kind !== "compose") {
|
|
284
|
+
const entry = seed(dirOf(file));
|
|
285
|
+
entry.manifests.push(baseName(file));
|
|
286
|
+
if (manifest.name) entry.names.push(manifest.name);
|
|
287
|
+
entry.service ||= manifest.kind === "marker";
|
|
288
|
+
}
|
|
289
|
+
for (const service of manifest.services) {
|
|
290
|
+
const target = seed(service.path);
|
|
291
|
+
target.names.push(service.name);
|
|
292
|
+
target.manifests.push(baseName(file));
|
|
293
|
+
target.service = true;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const named = [...seeds.values()].map((entry) => ({
|
|
297
|
+
path: entry.path,
|
|
298
|
+
name: entry.names[0] ?? (entry.path ? baseName(entry.path.slice(0, -1)) : rootName),
|
|
299
|
+
kind: entry.service || entry.path.split("/").slice(0, -2).some((part) => SERVICE_PARENTS.test(part))
|
|
300
|
+
? ("service" as const)
|
|
301
|
+
: ("package" as const),
|
|
302
|
+
manifests: [...new Set(entry.manifests)],
|
|
303
|
+
}));
|
|
304
|
+
const folders = folderSeeds(files, seeds).map((dir) => ({
|
|
305
|
+
path: dir,
|
|
306
|
+
name: baseName(dir.slice(0, -1)),
|
|
307
|
+
kind: "folder" as const,
|
|
308
|
+
manifests: [],
|
|
309
|
+
}));
|
|
310
|
+
return [...named.sort((a, b) => a.path.localeCompare(b.path)), ...folders]
|
|
311
|
+
.slice(0, MAX_AREAS)
|
|
312
|
+
.map((area): Area => {
|
|
313
|
+
const inside = files.filter((file) => file.startsWith(area.path));
|
|
314
|
+
return {
|
|
315
|
+
...area,
|
|
316
|
+
role: area.path ? roleOf(area.path) : "",
|
|
317
|
+
files: inside.length,
|
|
318
|
+
languages: languageCounts(inside).slice(0, 3).map((entry) => entry.name),
|
|
319
|
+
summary: "",
|
|
320
|
+
};
|
|
321
|
+
})
|
|
322
|
+
.sort((a, b) => a.path.localeCompare(b.path));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function guideFiles(files: readonly string[]): string[] {
|
|
326
|
+
const guides = files.filter((file) => {
|
|
327
|
+
const name = baseName(file);
|
|
328
|
+
if (/^(?:AGENTS|CLAUDE|GEMINI)\.md$/i.test(name)) return true;
|
|
329
|
+
if (file.startsWith(".github/workflows/") || file.startsWith(".cursor/rules/")) return true;
|
|
330
|
+
if (/^\.github\/(?:pull_request_template|copilot-instructions)\.md$/i.test(file)) return true;
|
|
331
|
+
return !file.includes("/") && /^(?:README|CONTRIBUTING|ARCHITECTURE)(?:\.\w+)?$|^\.cursorrules$/i.test(name);
|
|
332
|
+
});
|
|
333
|
+
const depth = (file: string) => file.split("/").length;
|
|
334
|
+
return guides.sort((a, b) => depth(a) - depth(b) || a.localeCompare(b)).slice(0, MAX_GUIDES);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const FIRST_SCRIPTS = ["check", "test", "lint", "typecheck", "build", "format", "dev", "start"];
|
|
338
|
+
|
|
339
|
+
/** Root package scripts and Makefile targets, as the commands to run them. */
|
|
340
|
+
export function commandsOf(files: readonly string[], packageJson: string | null, makefile: string | null): string[] {
|
|
341
|
+
const commands: string[] = [];
|
|
342
|
+
if (packageJson) {
|
|
343
|
+
let runner = "npm run";
|
|
344
|
+
if (files.includes("pnpm-lock.yaml")) runner = "pnpm run";
|
|
345
|
+
else if (files.includes("yarn.lock")) runner = "yarn run";
|
|
346
|
+
else if (files.includes("bun.lockb") || files.includes("bun.lock")) runner = "bun run";
|
|
347
|
+
let scripts: string[] = [];
|
|
348
|
+
try {
|
|
349
|
+
scripts = Object.keys((JSON.parse(packageJson) as { scripts?: object }).scripts ?? {});
|
|
350
|
+
} catch {
|
|
351
|
+
// Not JSON; no scripts.
|
|
352
|
+
}
|
|
353
|
+
const rank = (name: string) => (FIRST_SCRIPTS.includes(name) ? FIRST_SCRIPTS.indexOf(name) : FIRST_SCRIPTS.length);
|
|
354
|
+
const sorted = [...scripts].sort((a, b) => rank(a) - rank(b));
|
|
355
|
+
commands.push(...sorted.slice(0, 10).map((name) => `${runner} ${name}`));
|
|
356
|
+
}
|
|
357
|
+
if (makefile) {
|
|
358
|
+
const targets = [...makefile.matchAll(/^([A-Za-z][\w.-]*)\s*:(?!=)/gm)].map((match) => match[1] ?? "");
|
|
359
|
+
commands.push(...[...new Set(targets)].slice(0, 8).map((target) => `make ${target}`));
|
|
360
|
+
}
|
|
361
|
+
return commands;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Inspects a project folder. Summaries start empty; the store keeps earlier ones. */
|
|
365
|
+
export async function inspectProject(
|
|
366
|
+
rootPath: string,
|
|
367
|
+
projectId: string,
|
|
368
|
+
now = new Date(),
|
|
369
|
+
): Promise<ProjectKnowledge> {
|
|
370
|
+
const root = await realpath(rootPath).catch(() => {
|
|
371
|
+
throw new Error("The project folder was not found");
|
|
372
|
+
});
|
|
373
|
+
const listing = await listProjectFiles(root);
|
|
374
|
+
const manifests = await readManifests(root, listing.files);
|
|
375
|
+
const packageJson = listing.files.includes("package.json") ? await readText(root, "package.json") : null;
|
|
376
|
+
const makefile = listing.files.includes("Makefile") ? await readText(root, "Makefile") : null;
|
|
377
|
+
return {
|
|
378
|
+
projectId,
|
|
379
|
+
rootPath,
|
|
380
|
+
inspectedAt: now.toISOString(),
|
|
381
|
+
source: listing.source,
|
|
382
|
+
files: listing.files,
|
|
383
|
+
fileCount: listing.total,
|
|
384
|
+
truncated: listing.truncated,
|
|
385
|
+
areas: detectAreas(listing.files, manifests, path.basename(root)),
|
|
386
|
+
languages: languageCounts(listing.files).slice(0, 8),
|
|
387
|
+
guides: guideFiles(listing.files),
|
|
388
|
+
commands: commandsOf(listing.files, packageJson, makefile),
|
|
389
|
+
summary: "",
|
|
390
|
+
summarizedAt: null,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
4
|
+
import {
|
|
5
|
+
knowledgeGetRpc,
|
|
6
|
+
knowledgeInspectRpc,
|
|
7
|
+
type ProjectBrief,
|
|
8
|
+
type ProjectKnowledge,
|
|
9
|
+
ProjectKnowledgeSchema,
|
|
10
|
+
topFolders,
|
|
11
|
+
} from "../shared/knowledge";
|
|
12
|
+
import type { Paseo } from "./agent-runs";
|
|
13
|
+
import { inspectProject } from "./inspect";
|
|
14
|
+
import { createJsonFile, type JsonFile } from "./stores";
|
|
15
|
+
|
|
16
|
+
/** Knowledge older than this is inspected again before use; summaries carry over. */
|
|
17
|
+
export const KNOWLEDGE_MAX_AGE_MS = 6 * 60 * 60_000;
|
|
18
|
+
|
|
19
|
+
export interface KnowledgeProject {
|
|
20
|
+
projectId: string;
|
|
21
|
+
rootPath: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** What the setup agent learned: a line on the project and one per area. */
|
|
25
|
+
export interface KnowledgeNotes {
|
|
26
|
+
summary: string;
|
|
27
|
+
areas: readonly { path: string; summary: string }[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface KnowledgeService {
|
|
31
|
+
get(projectId: string): Promise<ProjectKnowledge | null>;
|
|
32
|
+
/** Saved knowledge, inspected again when missing, stale, or from another folder. */
|
|
33
|
+
ensure(project: KnowledgeProject): Promise<ProjectKnowledge>;
|
|
34
|
+
/** Inspects now. Runs for one project share one inspection. */
|
|
35
|
+
inspect(project: KnowledgeProject): Promise<ProjectKnowledge>;
|
|
36
|
+
annotate(projectId: string, notes: KnowledgeNotes): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type Inspect = (rootPath: string, projectId: string) => Promise<ProjectKnowledge>;
|
|
40
|
+
|
|
41
|
+
function areaPath(value: string): string {
|
|
42
|
+
const trimmed = value.trim().replace(/^(?:\.\/)+/, "").replace(/^\/+|\/+$/g, "");
|
|
43
|
+
return trimmed === "" || trimmed === "." ? "" : `${trimmed}/`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One file per project beside the key file, so each loads only when used. */
|
|
47
|
+
export function createKnowledge(
|
|
48
|
+
directory: string,
|
|
49
|
+
inspect: Inspect = inspectProject,
|
|
50
|
+
now = () => Date.now(),
|
|
51
|
+
): KnowledgeService {
|
|
52
|
+
const files = new Map<string, JsonFile<ProjectKnowledge | null>>();
|
|
53
|
+
const running = new Map<string, Promise<ProjectKnowledge>>();
|
|
54
|
+
const fileFor = (projectId: string) => {
|
|
55
|
+
let file = files.get(projectId);
|
|
56
|
+
if (!file) {
|
|
57
|
+
const name = createHash("sha256").update(projectId).digest("hex").slice(0, 32);
|
|
58
|
+
file = createJsonFile(path.join(directory, `${name}.json`), ProjectKnowledgeSchema.nullable(), () => null);
|
|
59
|
+
files.set(projectId, file);
|
|
60
|
+
}
|
|
61
|
+
return file;
|
|
62
|
+
};
|
|
63
|
+
const get = async (projectId: string) => {
|
|
64
|
+
const known = await fileFor(projectId).read();
|
|
65
|
+
return known?.projectId === projectId ? known : null;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function inspectNow(project: KnowledgeProject): Promise<ProjectKnowledge> {
|
|
69
|
+
const existing = running.get(project.projectId);
|
|
70
|
+
if (existing) return existing;
|
|
71
|
+
const work = (async () => {
|
|
72
|
+
const fresh = await inspect(project.rootPath, project.projectId);
|
|
73
|
+
return fileFor(project.projectId).update((previous) => {
|
|
74
|
+
const summaries = new Map((previous?.areas ?? []).map((area) => [area.path, area.summary]));
|
|
75
|
+
const next: ProjectKnowledge = {
|
|
76
|
+
...fresh,
|
|
77
|
+
areas: fresh.areas.map((area) => ({ ...area, summary: summaries.get(area.path) ?? area.summary })),
|
|
78
|
+
summary: previous?.summary ?? fresh.summary,
|
|
79
|
+
summarizedAt: previous?.summarizedAt ?? fresh.summarizedAt,
|
|
80
|
+
};
|
|
81
|
+
return { next, result: next };
|
|
82
|
+
});
|
|
83
|
+
})().finally(() => running.delete(project.projectId));
|
|
84
|
+
running.set(project.projectId, work);
|
|
85
|
+
return work;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
get,
|
|
90
|
+
inspect: inspectNow,
|
|
91
|
+
async ensure(project) {
|
|
92
|
+
const known = await get(project.projectId);
|
|
93
|
+
const age = known ? now() - Date.parse(known.inspectedAt) : Number.POSITIVE_INFINITY;
|
|
94
|
+
if (known && known.rootPath === project.rootPath && age < KNOWLEDGE_MAX_AGE_MS) return known;
|
|
95
|
+
// A failed inspection keeps the saved knowledge rather than losing it.
|
|
96
|
+
return inspectNow(project).catch((error: unknown) => {
|
|
97
|
+
if (known) return known;
|
|
98
|
+
throw error;
|
|
99
|
+
});
|
|
100
|
+
},
|
|
101
|
+
async annotate(projectId, notes) {
|
|
102
|
+
await fileFor(projectId).update((current) => {
|
|
103
|
+
if (!current) return { next: current, result: undefined };
|
|
104
|
+
const summaries = new Map(notes.areas.map((area) => [areaPath(area.path), area.summary.trim()]));
|
|
105
|
+
const next: ProjectKnowledge = {
|
|
106
|
+
...current,
|
|
107
|
+
summary: notes.summary.trim() || current.summary,
|
|
108
|
+
summarizedAt: new Date(now()).toISOString(),
|
|
109
|
+
areas: current.areas.map((area) => ({ ...area, summary: summaries.get(area.path) || area.summary })),
|
|
110
|
+
};
|
|
111
|
+
return { next, result: undefined };
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function toBrief(knowledge: ProjectKnowledge): ProjectBrief {
|
|
118
|
+
const { files, ...rest } = knowledge;
|
|
119
|
+
return { ...rest, folders: topFolders(files) };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A Paseo project on this host; the client names only an id, never a folder. */
|
|
123
|
+
export async function findProject(paseo: Paseo, projectId: string): Promise<KnowledgeProject | null> {
|
|
124
|
+
const { projects } = await paseo.projects.list();
|
|
125
|
+
const project = projects.find((entry) => entry.projectId === projectId);
|
|
126
|
+
return project ? { projectId, rootPath: project.projectRootPath } : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Knowledge for prompts and maps; null when the project is unknown or cannot be read. */
|
|
130
|
+
export async function knowledgeFor(
|
|
131
|
+
paseo: Paseo,
|
|
132
|
+
knowledge: KnowledgeService,
|
|
133
|
+
projectId: string | null,
|
|
134
|
+
): Promise<ProjectKnowledge | null> {
|
|
135
|
+
if (!projectId) return null;
|
|
136
|
+
try {
|
|
137
|
+
const project = await findProject(paseo, projectId);
|
|
138
|
+
return project ? await knowledge.ensure(project) : await knowledge.get(projectId);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
console.error(`Linear could not inspect project ${projectId}`, error);
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function registerKnowledge(server: PluginServerContext, knowledge: KnowledgeService) {
|
|
146
|
+
async function requireProject(paseo: Paseo, projectId: string) {
|
|
147
|
+
const project = await findProject(paseo, projectId);
|
|
148
|
+
if (!project) throw new Error("That project is not on this host");
|
|
149
|
+
return project;
|
|
150
|
+
}
|
|
151
|
+
server.handle(knowledgeGetRpc, async ({ projectId }, { paseo }) => ({
|
|
152
|
+
knowledge: toBrief(await knowledge.ensure(await requireProject(paseo, projectId))),
|
|
153
|
+
}));
|
|
154
|
+
server.handle(knowledgeInspectRpc, async ({ projectId }, { paseo }) => ({
|
|
155
|
+
knowledge: toBrief(await knowledge.inspect(await requireProject(paseo, projectId))),
|
|
156
|
+
}));
|
|
157
|
+
}
|