@yannelli/paseo-linear-plugin 1.1.0-beta.2 → 1.1.0-beta.3
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 +6 -2
- package/README.md +20 -6
- package/client/live-graph.tsx +6 -3
- package/client/live-header.tsx +26 -2
- package/client/live-map.tsx +7 -1
- package/client/live-panel.tsx +9 -2
- package/client/live-timeline.ts +36 -5
- package/client/project-init.tsx +28 -4
- package/client/project-knowledge.tsx +87 -0
- package/client/settings-projects.tsx +16 -5
- package/index.server.ts +5 -2
- package/package.json +1 -1
- package/server/init.ts +31 -7
- package/server/inspect.ts +392 -0
- package/server/knowledge.ts +157 -0
- package/server/live.ts +21 -3
- package/server/stores.ts +1 -1
- package/shared/knowledge.ts +255 -0
- package/shared/live.ts +19 -3
- package/shared/map-model.ts +50 -2
- package/shared/project-setup.ts +3 -2
package/server/live.ts
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { relativePath } from "../shared/activity";
|
|
6
|
+
import { knowledgeLines, liveProjectRpc, type ProjectKnowledge, resolvePaths, toAreaRef } from "../shared/knowledge";
|
|
6
7
|
import { type IssueDetail, ISSUE_CARD_KIND } from "../shared/linear";
|
|
7
8
|
import {
|
|
8
9
|
ExploreResultSchema,
|
|
@@ -29,6 +30,7 @@ import {
|
|
|
29
30
|
startInternalRun,
|
|
30
31
|
} from "./agent-runs";
|
|
31
32
|
import type { LinearAccess } from "./handlers";
|
|
33
|
+
import { type KnowledgeService, knowledgeFor } from "./knowledge";
|
|
32
34
|
import { fileLinks } from "./links";
|
|
33
35
|
import { subagentLogs } from "./subagent-logs";
|
|
34
36
|
import type { LaunchQueue, MapStore } from "./stores";
|
|
@@ -97,8 +99,9 @@ export async function listDirectories(cwd: string, dirs: readonly string[]) {
|
|
|
97
99
|
return results;
|
|
98
100
|
}
|
|
99
101
|
|
|
100
|
-
export function explorePrompt(issue: IssueDetail): string {
|
|
102
|
+
export function explorePrompt(issue: IssueDetail, knowledge: ProjectKnowledge | null = null): string {
|
|
101
103
|
const keys = [issue.identifier, ...issue.children.map((child) => child.identifier)];
|
|
104
|
+
const map = knowledgeLines(knowledge);
|
|
102
105
|
return [
|
|
103
106
|
`Map this repository for Linear issue ${issue.identifier}: ${issue.title}`,
|
|
104
107
|
"",
|
|
@@ -106,6 +109,7 @@ export function explorePrompt(issue: IssueDetail): string {
|
|
|
106
109
|
"",
|
|
107
110
|
issueSnapshot(issue, { includeComments: false }),
|
|
108
111
|
"",
|
|
112
|
+
...(map.length > 0 ? [...map, ""] : []),
|
|
109
113
|
"List the files an engineer will most likely read or change for this issue and each sub-issue.",
|
|
110
114
|
`Include files that will probably be created, at their expected path. List at most ${MAX_EXPLORE_FILES} files.`,
|
|
111
115
|
`Set "issue" to the sub-issue key the file belongs to, or ${issue.identifier} for the whole issue.`,
|
|
@@ -138,6 +142,7 @@ export interface LiveDependencies {
|
|
|
138
142
|
access: LinearAccess;
|
|
139
143
|
maps: MapStore;
|
|
140
144
|
launches: LaunchQueue;
|
|
145
|
+
knowledge: KnowledgeService;
|
|
141
146
|
readSettings(): Promise<LinearSettings | null>;
|
|
142
147
|
}
|
|
143
148
|
|
|
@@ -159,7 +164,7 @@ export async function startLaunch(workspace: WorkspaceHandle, launch: LaunchInpu
|
|
|
159
164
|
}
|
|
160
165
|
|
|
161
166
|
export function registerLive(server: PluginServerContext, dependencies: LiveDependencies) {
|
|
162
|
-
const { access, maps, launches, readSettings } = dependencies;
|
|
167
|
+
const { access, maps, launches, knowledge, readSettings } = dependencies;
|
|
163
168
|
const jobs = createJobStore<IssueMap>();
|
|
164
169
|
const recovering = new Map<string, Promise<Job | null>>();
|
|
165
170
|
const recoveredAt = new Map<string, number>();
|
|
@@ -230,6 +235,14 @@ export function registerLive(server: PluginServerContext, dependencies: LiveDepe
|
|
|
230
235
|
return { links: await fileLinks(agent.cwd, files) };
|
|
231
236
|
});
|
|
232
237
|
|
|
238
|
+
server.handle(liveProjectRpc, async ({ agentId, paths }, { paseo }) => {
|
|
239
|
+
const agent = await agentInfo(paseo, agentId);
|
|
240
|
+
const known = await knowledgeFor(paseo, knowledge, agent.projectId);
|
|
241
|
+
if (!known) return { areas: [], resolved: [] };
|
|
242
|
+
const resolved = [...resolvePaths(paths, known.files)].map(([from, to]) => ({ from, to }));
|
|
243
|
+
return { areas: known.areas.map(toAreaRef), resolved };
|
|
244
|
+
});
|
|
245
|
+
|
|
233
246
|
server.handle(clearMapRpc, async ({ agentId }, { paseo }) => {
|
|
234
247
|
const agent = await agentInfo(paseo, agentId);
|
|
235
248
|
const identifier = agent.labels[AGENT_LABELS.issue];
|
|
@@ -267,6 +280,8 @@ export function registerLive(server: PluginServerContext, dependencies: LiveDepe
|
|
|
267
280
|
interface ExploreTarget {
|
|
268
281
|
identifier: string;
|
|
269
282
|
keyScope: string | null;
|
|
283
|
+
/** Paseo project whose saved knowledge goes into the prompt. */
|
|
284
|
+
projectId: string | null;
|
|
270
285
|
cwd: string;
|
|
271
286
|
/** Opened only when a new run starts. */
|
|
272
287
|
workspace: () => Promise<WorkspaceHandle>;
|
|
@@ -293,6 +308,7 @@ export function registerLive(server: PluginServerContext, dependencies: LiveDepe
|
|
|
293
308
|
const issue = await linear.getIssue(identifier);
|
|
294
309
|
const labels: Record<string, string> = { [AGENT_LABELS.explore]: identifier };
|
|
295
310
|
if (target.keyScope) labels[AGENT_LABELS.project] = target.keyScope;
|
|
311
|
+
const known = await knowledgeFor(paseo, knowledge, target.projectId);
|
|
296
312
|
const run = await startInternalRun({
|
|
297
313
|
paseo,
|
|
298
314
|
workspace: await target.workspace(),
|
|
@@ -300,7 +316,7 @@ export function registerLive(server: PluginServerContext, dependencies: LiveDepe
|
|
|
300
316
|
provider: target.provider,
|
|
301
317
|
effort: target.effort,
|
|
302
318
|
title: `Explore ${identifier}`,
|
|
303
|
-
prompt: explorePrompt(issue),
|
|
319
|
+
prompt: explorePrompt(issue, known),
|
|
304
320
|
labels,
|
|
305
321
|
schema: ExploreResultSchema,
|
|
306
322
|
timeoutMs: EXPLORE_TIMEOUT_MS,
|
|
@@ -326,6 +342,7 @@ export function registerLive(server: PluginServerContext, dependencies: LiveDepe
|
|
|
326
342
|
const job = await ensureExplore(paseo, {
|
|
327
343
|
identifier,
|
|
328
344
|
keyScope: agent.labels[AGENT_LABELS.project] ?? null,
|
|
345
|
+
projectId: agent.projectId,
|
|
329
346
|
cwd,
|
|
330
347
|
workspace: async () =>
|
|
331
348
|
workspaceId ? paseo.workspaces.ref(workspaceId) : paseo.workspaces.open(cwd),
|
|
@@ -344,6 +361,7 @@ export function registerLive(server: PluginServerContext, dependencies: LiveDepe
|
|
|
344
361
|
const job = await ensureExplore(paseo, {
|
|
345
362
|
identifier: input.identifier,
|
|
346
363
|
keyScope: input.keyScope,
|
|
364
|
+
projectId: info.projectId,
|
|
347
365
|
cwd: info.workspaceDirectory ?? info.projectRootPath,
|
|
348
366
|
workspace: async () => workspace,
|
|
349
367
|
provider: settings?.live.exploreProvider || input.agent.config.provider,
|
package/server/stores.ts
CHANGED
|
@@ -7,7 +7,7 @@ const MAX_MAPS = 200;
|
|
|
7
7
|
/** A launch still waiting after this long is dropped, so a stale one never starts by surprise. */
|
|
8
8
|
export const PENDING_LAUNCH_MAX_AGE_MS = 60 * 60_000;
|
|
9
9
|
|
|
10
|
-
interface JsonFile<T> {
|
|
10
|
+
export interface JsonFile<T> {
|
|
11
11
|
read(): Promise<T>;
|
|
12
12
|
/** Changes run one at a time, so a read, change, and write never interleave. */
|
|
13
13
|
update<R>(change: (current: T) => { next: T; result: R }): Promise<R>;
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { defineRpc } from "@getpaseo/plugin";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
// Project knowledge: what the daemon learns about a project's files without an agent. It is
|
|
5
|
+
// kept on disk, so explore and setup prompts, ticket-text maps, and Linear Live can use it.
|
|
6
|
+
|
|
7
|
+
export const AREA_KINDS = ["service", "package", "folder"] as const;
|
|
8
|
+
export type AreaKind = (typeof AREA_KINDS)[number];
|
|
9
|
+
|
|
10
|
+
export const AreaSchema = z.object({
|
|
11
|
+
/** Folder with a trailing slash; "" is the repository root. */
|
|
12
|
+
path: z.string(),
|
|
13
|
+
name: z.string(),
|
|
14
|
+
kind: z.enum(AREA_KINDS),
|
|
15
|
+
/** What the folder holds, from its name, such as "backend" or "tests"; "" when unknown. */
|
|
16
|
+
role: z.string(),
|
|
17
|
+
/** Files that define it, such as package.json or Dockerfile. */
|
|
18
|
+
manifests: z.array(z.string()),
|
|
19
|
+
/** Files under the folder, at any depth. */
|
|
20
|
+
files: z.number(),
|
|
21
|
+
languages: z.array(z.string()),
|
|
22
|
+
/** One line from the setup agent; "" until it writes one. */
|
|
23
|
+
summary: z.string(),
|
|
24
|
+
});
|
|
25
|
+
export type Area = z.infer<typeof AreaSchema>;
|
|
26
|
+
|
|
27
|
+
/** The part of an area the app needs to label files. */
|
|
28
|
+
export const AreaRefSchema = AreaSchema.pick({
|
|
29
|
+
path: true,
|
|
30
|
+
name: true,
|
|
31
|
+
kind: true,
|
|
32
|
+
role: true,
|
|
33
|
+
summary: true,
|
|
34
|
+
});
|
|
35
|
+
export type AreaRef = z.infer<typeof AreaRefSchema>;
|
|
36
|
+
|
|
37
|
+
const CountSchema = z.object({ name: z.string(), files: z.number() });
|
|
38
|
+
|
|
39
|
+
export const ProjectKnowledgeSchema = z.object({
|
|
40
|
+
projectId: z.string(),
|
|
41
|
+
rootPath: z.string(),
|
|
42
|
+
inspectedAt: z.string(),
|
|
43
|
+
source: z.enum(["git", "walk"]),
|
|
44
|
+
/** Repo-relative file paths, sorted, at most MAX_KNOWLEDGE_FILES. */
|
|
45
|
+
files: z.array(z.string()),
|
|
46
|
+
fileCount: z.number(),
|
|
47
|
+
truncated: z.boolean(),
|
|
48
|
+
areas: z.array(AreaSchema),
|
|
49
|
+
languages: z.array(CountSchema),
|
|
50
|
+
/** Files that guide agents and contributors, such as AGENTS.md and CI workflows. */
|
|
51
|
+
guides: z.array(z.string()),
|
|
52
|
+
/** Commands the repository defines, such as "npm run check". */
|
|
53
|
+
commands: z.array(z.string()),
|
|
54
|
+
/** One line from the setup agent about the project; "" until it writes one. */
|
|
55
|
+
summary: z.string(),
|
|
56
|
+
summarizedAt: z.string().nullable(),
|
|
57
|
+
});
|
|
58
|
+
export type ProjectKnowledge = z.infer<typeof ProjectKnowledgeSchema>;
|
|
59
|
+
|
|
60
|
+
/** What the app gets: everything but the file list, plus file counts per top folder. */
|
|
61
|
+
export const ProjectBriefSchema = ProjectKnowledgeSchema.omit({ files: true }).extend({
|
|
62
|
+
folders: z.array(CountSchema),
|
|
63
|
+
});
|
|
64
|
+
export type ProjectBrief = z.infer<typeof ProjectBriefSchema>;
|
|
65
|
+
|
|
66
|
+
export const MAX_KNOWLEDGE_FILES = 20_000;
|
|
67
|
+
export const MAX_RESOLVE_PATHS = 200;
|
|
68
|
+
|
|
69
|
+
/** Saved knowledge of a project; the daemon inspects the project first when it has none. */
|
|
70
|
+
export const knowledgeGetRpc = defineRpc({
|
|
71
|
+
name: "linear.knowledge.get",
|
|
72
|
+
input: z.object({ projectId: z.string().min(1) }),
|
|
73
|
+
output: z.object({ knowledge: ProjectBriefSchema }),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
export const knowledgeInspectRpc = defineRpc({
|
|
77
|
+
name: "linear.knowledge.inspect",
|
|
78
|
+
input: z.object({ projectId: z.string().min(1) }),
|
|
79
|
+
output: z.object({ knowledge: ProjectBriefSchema }),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/** The areas of an agent's project, and ticket paths matched to the project's real files. */
|
|
83
|
+
export const liveProjectRpc = defineRpc({
|
|
84
|
+
name: "linear.live.project",
|
|
85
|
+
input: z.object({
|
|
86
|
+
agentId: z.string().min(1),
|
|
87
|
+
paths: z.array(z.string()).max(MAX_RESOLVE_PATHS),
|
|
88
|
+
}),
|
|
89
|
+
output: z.object({
|
|
90
|
+
areas: z.array(AreaRefSchema),
|
|
91
|
+
resolved: z.array(z.object({ from: z.string(), to: z.string() })),
|
|
92
|
+
}),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
export function toAreaRef(area: Area): AreaRef {
|
|
96
|
+
return { path: area.path, name: area.name, kind: area.kind, role: area.role, summary: area.summary };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The deepest area whose folder holds the path. */
|
|
100
|
+
export function areaOf<A extends { path: string }>(path: string, areas: readonly A[]): A | null {
|
|
101
|
+
let best: A | null = null;
|
|
102
|
+
for (const area of areas) {
|
|
103
|
+
if (path.startsWith(area.path) && (!best || area.path.length > best.path.length)) best = area;
|
|
104
|
+
}
|
|
105
|
+
return best;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Such as "billing-api · service", "server · backend", or "docs". */
|
|
109
|
+
export function areaLabel(area: AreaRef): string {
|
|
110
|
+
const what = area.role || (area.kind === "folder" ? "" : area.kind);
|
|
111
|
+
return what && what.toLowerCase() !== area.name.toLowerCase() ? `${area.name} · ${what}` : area.name;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function topFolder(path: string): string {
|
|
115
|
+
const index = path.indexOf("/");
|
|
116
|
+
return index < 0 ? "./" : path.slice(0, index + 1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** File counts per top folder; "./" counts files at the root. */
|
|
120
|
+
export function topFolders(files: readonly string[]): { name: string; files: number }[] {
|
|
121
|
+
const counts = new Map<string, number>();
|
|
122
|
+
for (const file of files) counts.set(topFolder(file), (counts.get(topFolder(file)) ?? 0) + 1);
|
|
123
|
+
return [...counts]
|
|
124
|
+
.map(([name, count]) => ({ name, files: count }))
|
|
125
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Where an off-map file is: its area, or its top folder when the project has no knowledge. */
|
|
129
|
+
export function whereLabel(path: string, areas: readonly AreaRef[]): string {
|
|
130
|
+
const area = areaOf(path, areas);
|
|
131
|
+
return area && area.path !== "" ? areaLabel(area) : topFolder(path);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Folders to the given depth with their file counts, indented, for prompts. */
|
|
135
|
+
export function folderLines(files: readonly string[], depth = 2, limit = 60): string[] {
|
|
136
|
+
const counts = new Map<string, number>();
|
|
137
|
+
for (const file of files) {
|
|
138
|
+
const parts = file.split("/").slice(0, -1);
|
|
139
|
+
for (let level = 1; level <= Math.min(depth, parts.length); level += 1) {
|
|
140
|
+
const dir = `${parts.slice(0, level).join("/")}/`;
|
|
141
|
+
counts.set(dir, (counts.get(dir) ?? 0) + 1);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const dirs = [...counts.keys()].sort();
|
|
145
|
+
if (dirs.length > limit && depth > 1) return folderLines(files, depth - 1, limit);
|
|
146
|
+
const lines = dirs.slice(0, limit).map((dir) => {
|
|
147
|
+
const indent = " ".repeat(dir.split("/").length - 2);
|
|
148
|
+
return `${indent}${dir} (${counts.get(dir)})`;
|
|
149
|
+
});
|
|
150
|
+
if (dirs.length > limit) lines.push(`... ${dirs.length - limit} more folders`);
|
|
151
|
+
return lines;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function countLine(entries: readonly { name: string; files: number }[]): string {
|
|
155
|
+
return entries.map((entry) => `${entry.name} ${entry.files}`).join(", ");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The project map an agent prompt starts from. Empty without knowledge. */
|
|
159
|
+
export function knowledgeLines(knowledge: ProjectKnowledge | null): string[] {
|
|
160
|
+
if (!knowledge) return [];
|
|
161
|
+
const source = knowledge.source === "git" ? "git ls-files" : "a folder walk";
|
|
162
|
+
const lines = [
|
|
163
|
+
`Project map from the plugin's inspection (${knowledge.inspectedAt}, by ${source}). Start from`,
|
|
164
|
+
"it instead of listing the whole repository; files may have changed since.",
|
|
165
|
+
];
|
|
166
|
+
if (knowledge.summary) lines.push(`- Project: ${knowledge.summary}`);
|
|
167
|
+
const truncated = knowledge.truncated ? ", list truncated" : "";
|
|
168
|
+
lines.push(`- ${knowledge.fileCount} files${truncated}. Languages: ${countLine(knowledge.languages) || "unknown"}.`);
|
|
169
|
+
if (knowledge.guides.length > 0) lines.push(`- Guides: ${knowledge.guides.join(", ")}`);
|
|
170
|
+
if (knowledge.commands.length > 0) lines.push(`- Commands: ${knowledge.commands.join(", ")}`);
|
|
171
|
+
if (knowledge.areas.length > 0) {
|
|
172
|
+
lines.push("Areas (services, packages, and main folders):");
|
|
173
|
+
for (const area of knowledge.areas) {
|
|
174
|
+
const manifests = area.manifests.length > 0 ? ` [${area.manifests.join(", ")}]` : "";
|
|
175
|
+
const languages = area.languages.length > 0 ? `, ${area.languages.join("/")}` : "";
|
|
176
|
+
const summary = area.summary ? `: ${area.summary}` : "";
|
|
177
|
+
const files = `${area.files} ${area.files === 1 ? "file" : "files"}`;
|
|
178
|
+
lines.push(`- ${area.path || "./"} ${areaLabel(area)}${manifests}, ${files}${languages}${summary}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
lines.push("Folders (file count):", ...folderLines(knowledge.files));
|
|
182
|
+
return lines;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function byName(files: readonly string[]): Map<string, string[]> {
|
|
186
|
+
const index = new Map<string, string[]>();
|
|
187
|
+
for (const file of files) {
|
|
188
|
+
const name = file.slice(file.lastIndexOf("/") + 1);
|
|
189
|
+
const known = index.get(name);
|
|
190
|
+
if (known) known.push(file);
|
|
191
|
+
else index.set(name, [file]);
|
|
192
|
+
}
|
|
193
|
+
return index;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function dirsOf(files: readonly string[]): Set<string> {
|
|
197
|
+
const dirs = new Set<string>();
|
|
198
|
+
for (const file of files) {
|
|
199
|
+
let index = file.indexOf("/");
|
|
200
|
+
while (index >= 0) {
|
|
201
|
+
dirs.add(file.slice(0, index + 1));
|
|
202
|
+
index = file.indexOf("/", index + 1);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return dirs;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Ticket paths matched to real files: "linear.ts" to "server/linear.ts" when only one fits. */
|
|
209
|
+
export function resolvePaths(paths: readonly string[], files: readonly string[]): Map<string, string> {
|
|
210
|
+
const known = new Set(files);
|
|
211
|
+
const names = byName(files);
|
|
212
|
+
let dirs: Set<string> | null = null;
|
|
213
|
+
const resolved = new Map<string, string>();
|
|
214
|
+
for (const path of paths) {
|
|
215
|
+
let matches: string[];
|
|
216
|
+
if (path.endsWith("/")) {
|
|
217
|
+
dirs ??= dirsOf(files);
|
|
218
|
+
if (dirs.has(path)) continue;
|
|
219
|
+
matches = [...dirs].filter((dir) => dir.endsWith(`/${path}`));
|
|
220
|
+
} else {
|
|
221
|
+
if (known.has(path)) continue;
|
|
222
|
+
const name = path.slice(path.lastIndexOf("/") + 1);
|
|
223
|
+
matches = (names.get(name) ?? []).filter((file) => file === name || file.endsWith(`/${path}`));
|
|
224
|
+
}
|
|
225
|
+
if (matches.length === 1 && matches[0]) resolved.set(path, matches[0]);
|
|
226
|
+
}
|
|
227
|
+
return resolved;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const KINDS_SAID = "(?:service|package|app|module|library|lib|worker|folder|directory|dir)";
|
|
231
|
+
|
|
232
|
+
function escapeRegex(text: string): string {
|
|
233
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function aliases(area: AreaRef): string[] {
|
|
237
|
+
const folder = area.path.replace(/\/$/, "").split("/").pop() ?? "";
|
|
238
|
+
return [...new Set([area.name, folder])].filter((alias) => alias.length >= 3);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// A service or package counts when its name appears; a plain folder only when the text says
|
|
242
|
+
// "the docs folder" or similar, since folder names like "server" are common words.
|
|
243
|
+
function mentions(text: string, alias: string, plain: boolean): boolean {
|
|
244
|
+
const word = `(?:^|[^\\w@/.-])${escapeRegex(alias)}(?![\\w@/-])`;
|
|
245
|
+
if (plain && alias.length >= 4 && new RegExp(word, "i").test(text)) return true;
|
|
246
|
+
return new RegExp(`${word}\\s+${KINDS_SAID}\\b`, "i").test(text);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Areas the text names, other than the whole repository. */
|
|
250
|
+
export function areasIn(text: string, areas: readonly AreaRef[]): AreaRef[] {
|
|
251
|
+
return areas.filter(
|
|
252
|
+
(area) =>
|
|
253
|
+
area.path !== "" && aliases(area).some((alias) => mentions(text, alias, area.kind !== "folder")),
|
|
254
|
+
);
|
|
255
|
+
}
|
package/shared/live.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineRpc } from "@getpaseo/plugin";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { McpHttpServerSchema, ToolPolicySchema } from "./agent-tools";
|
|
4
|
+
import { type AreaRef, areasIn } from "./knowledge";
|
|
4
5
|
import { IssueCardSchema, WorkflowStateSchema } from "./linear";
|
|
5
6
|
|
|
6
7
|
// Contracts for Linear Live, todo sync, and project setup. Agents the plugin starts for itself
|
|
@@ -40,6 +41,9 @@ export const InitProposalSchema = z.object({
|
|
|
40
41
|
steps: z.array(z.string()).default([]),
|
|
41
42
|
implement: z.string().default(""),
|
|
42
43
|
review: z.string().default(""),
|
|
44
|
+
/** Saved to the project knowledge at once; not part of the prompt proposal. */
|
|
45
|
+
summary: z.string().default(""),
|
|
46
|
+
areas: z.array(z.object({ path: z.string(), summary: z.string() })).max(80).default([]),
|
|
43
47
|
});
|
|
44
48
|
export type InitProposal = z.infer<typeof InitProposalSchema>;
|
|
45
49
|
|
|
@@ -194,18 +198,30 @@ export interface SemanticIssue {
|
|
|
194
198
|
children: readonly { identifier: string; title: string }[];
|
|
195
199
|
}
|
|
196
200
|
|
|
201
|
+
/** What project knowledge adds to a ticket-text map. */
|
|
202
|
+
export interface MapHints {
|
|
203
|
+
/** Ticket paths matched to real files, such as "linear.ts" to "server/linear.ts". */
|
|
204
|
+
resolved?: ReadonlyMap<string, string>;
|
|
205
|
+
/** A service or package the text names puts its folder on the map. */
|
|
206
|
+
areas?: readonly AreaRef[];
|
|
207
|
+
}
|
|
208
|
+
|
|
197
209
|
// Linear sends sub-issue titles but not their descriptions, so a path in the parent's text
|
|
198
210
|
// belongs to the sub-issue its line names and otherwise to the parent.
|
|
199
|
-
export function semanticMap(issue: SemanticIssue, now = new Date()): IssueMap {
|
|
211
|
+
export function semanticMap(issue: SemanticIssue, now = new Date(), hints: MapHints = {}): IssueMap {
|
|
200
212
|
const owners = new Map<string, string>();
|
|
201
213
|
const childKeys = new Set(issue.children.map((child) => child.identifier.toUpperCase()));
|
|
214
|
+
const found = (text: string) => [
|
|
215
|
+
...pathsIn(text).map((path) => hints.resolved?.get(path) ?? path),
|
|
216
|
+
...areasIn(text, hints.areas ?? []).map((area) => area.path),
|
|
217
|
+
];
|
|
202
218
|
for (const child of issue.children) {
|
|
203
|
-
for (const path of
|
|
219
|
+
for (const path of found(child.title)) owners.set(path, child.identifier);
|
|
204
220
|
}
|
|
205
221
|
const lines = `${issue.title}\n${issue.description ?? ""}`.split("\n");
|
|
206
222
|
for (const line of lines) {
|
|
207
223
|
const named = (line.match(ISSUE_KEY) ?? []).find((key) => childKeys.has(key));
|
|
208
|
-
for (const path of
|
|
224
|
+
for (const path of found(line)) {
|
|
209
225
|
if (!owners.has(path)) owners.set(path, named ?? issue.identifier);
|
|
210
226
|
}
|
|
211
227
|
}
|
package/shared/map-model.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DirTouch, FileTouch } from "./activity";
|
|
2
|
+
import { type AreaRef, areaLabel, areaOf, whereLabel } from "./knowledge";
|
|
2
3
|
import { dirOf, type MapFile } from "./live";
|
|
3
4
|
|
|
4
5
|
// The Linear Live map: predicted files and touched files grouped into folder zones, and the
|
|
@@ -31,12 +32,21 @@ export interface MapZone {
|
|
|
31
32
|
/** Touched files that are on the map. */
|
|
32
33
|
hits: number;
|
|
33
34
|
explored: ZoneExplored;
|
|
35
|
+
/** The service, package, or main folder the zone is in, from the project knowledge. */
|
|
36
|
+
area: AreaRef | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Touched files the map did not predict, grouped by where they are. */
|
|
40
|
+
export interface OffMapGroup {
|
|
41
|
+
where: string;
|
|
42
|
+
files: number;
|
|
34
43
|
}
|
|
35
44
|
|
|
36
45
|
export interface MapModel {
|
|
37
46
|
zones: MapZone[];
|
|
38
47
|
touched: number;
|
|
39
48
|
hits: number;
|
|
49
|
+
offMap: OffMapGroup[];
|
|
40
50
|
}
|
|
41
51
|
|
|
42
52
|
// Search hits alone do not count: one broad search would flood the map with off-map tiles.
|
|
@@ -98,6 +108,7 @@ export function buildMapModel(
|
|
|
98
108
|
touches: readonly FileTouch[],
|
|
99
109
|
listing: ReadonlyMap<string, readonly string[]>,
|
|
100
110
|
dirs: readonly DirTouch[] = [],
|
|
111
|
+
areas: readonly AreaRef[] = [],
|
|
101
112
|
): MapModel {
|
|
102
113
|
const predicted = predictions(files);
|
|
103
114
|
const touched = new Map(touches.filter(isTouched).map((file) => [file.path, file]));
|
|
@@ -134,11 +145,19 @@ export function buildMapModel(
|
|
|
134
145
|
touched: tiles.filter((tile) => tile.touch !== "none").length,
|
|
135
146
|
hits: tiles.filter((tile) => tile.touch !== "none" && tile.onMap).length,
|
|
136
147
|
explored: exploredState(dir, dirs),
|
|
148
|
+
area: areaOf(dir, areas),
|
|
137
149
|
};
|
|
138
150
|
});
|
|
139
151
|
let hits = 0;
|
|
140
|
-
|
|
141
|
-
|
|
152
|
+
const off = new Map<string, number>();
|
|
153
|
+
for (const path of touched.keys()) {
|
|
154
|
+
if (ownerOf(path, predicted)) hits += 1;
|
|
155
|
+
else off.set(whereLabel(path, areas), (off.get(whereLabel(path, areas)) ?? 0) + 1);
|
|
156
|
+
}
|
|
157
|
+
const offMap = [...off]
|
|
158
|
+
.map(([where, count]) => ({ where, files: count }))
|
|
159
|
+
.sort((a, b) => b.files - a.files || a.where.localeCompare(b.where));
|
|
160
|
+
return { zones, touched: touched.size, hits, offMap };
|
|
142
161
|
}
|
|
143
162
|
|
|
144
163
|
export function hitRateLine(model: MapModel): string {
|
|
@@ -147,6 +166,35 @@ export function hitRateLine(model: MapModel): string {
|
|
|
147
166
|
return `${model.touched} ${noun} touched, ${model.hits} on the map`;
|
|
148
167
|
}
|
|
149
168
|
|
|
169
|
+
const OFF_MAP_SHOWN = 4;
|
|
170
|
+
|
|
171
|
+
/** Such as "Off the map: billing-api · service (3), docs (1)"; null when all are on it. */
|
|
172
|
+
export function offMapLine(model: MapModel): string | null {
|
|
173
|
+
if (model.offMap.length === 0) return null;
|
|
174
|
+
const shown = model.offMap.slice(0, OFF_MAP_SHOWN).map((group) => `${group.where} (${group.files})`);
|
|
175
|
+
const more = model.offMap.length - OFF_MAP_SHOWN;
|
|
176
|
+
return `Off the map: ${shown.join(", ")}${more > 0 ? `, ${more} more` : ""}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Where the file is when the agent touched it off the map; null on the map or untouched. */
|
|
180
|
+
export function offMapWhere(model: MapModel, path: string | null, areas: readonly AreaRef[]): string | null {
|
|
181
|
+
if (!path) return null;
|
|
182
|
+
for (const zone of model.zones) {
|
|
183
|
+
const tile = zone.tiles.find((entry) => entry.path === path && !entry.ghost);
|
|
184
|
+
if (tile) return tile.touch !== "none" && !tile.onMap ? whereLabel(path, areas) : null;
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The area a zone header names; only the part its folder name does not already say. */
|
|
190
|
+
export function zoneAreaLabel(zone: MapZone): string | null {
|
|
191
|
+
const area = zone.area;
|
|
192
|
+
if (!area || area.path === "") return null;
|
|
193
|
+
const folder = area.path.replace(/\/$/, "").split("/").pop();
|
|
194
|
+
if (zone.dir !== area.path || area.name !== folder) return areaLabel(area);
|
|
195
|
+
return area.role || (area.kind === "folder" ? null : area.kind);
|
|
196
|
+
}
|
|
197
|
+
|
|
150
198
|
export const ZONE_GAP = 10;
|
|
151
199
|
|
|
152
200
|
// Rough zone height from its tiles, so zones can go to the shortest column. Tiles wrap, so
|
package/shared/project-setup.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { ProjectConfig } from "./settings";
|
|
|
3
3
|
|
|
4
4
|
// A setup proposal shows only fields that change; accepted fields append to the prompts.
|
|
5
5
|
export type ProposalField = "instructions" | "steps" | "implement" | "review";
|
|
6
|
+
type PromptProposal = Pick<InitProposal, ProposalField>;
|
|
6
7
|
|
|
7
8
|
export interface FieldDiff {
|
|
8
9
|
field: ProposalField;
|
|
@@ -11,7 +12,7 @@ export interface FieldDiff {
|
|
|
11
12
|
proposed: string;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
export function proposalDiffs(config: ProjectConfig, proposal:
|
|
15
|
+
export function proposalDiffs(config: ProjectConfig, proposal: PromptProposal): FieldDiff[] {
|
|
15
16
|
const steps = proposal.steps.map((step) => step.trim()).filter(Boolean);
|
|
16
17
|
const rows: FieldDiff[] = [
|
|
17
18
|
{
|
|
@@ -43,7 +44,7 @@ export function proposalDiffs(config: ProjectConfig, proposal: InitProposal): Fi
|
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
export function applyProposal(
|
|
46
|
-
proposal:
|
|
47
|
+
proposal: PromptProposal,
|
|
47
48
|
fields: ReadonlySet<ProposalField>,
|
|
48
49
|
): Partial<ProjectConfig> {
|
|
49
50
|
const patch: Partial<ProjectConfig> = {};
|