@yannelli/paseo-linear-plugin 1.1.0-beta.2 → 1.1.0-beta.4
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 +8 -4
- package/README.md +24 -9
- package/client/graph-camera.tsx +28 -8
- package/client/graph-labels.ts +7 -4
- package/client/live-cursor.tsx +76 -49
- package/client/live-graph.tsx +59 -29
- 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/plugin-icon.tsx +41 -9
- package/client/project-init.tsx +28 -4
- package/client/project-knowledge.tsx +87 -0
- package/client/settings-icon.tsx +57 -8
- package/client/settings-projects.tsx +16 -5
- package/client/web.ts +32 -0
- package/index.client.tsx +134 -79
- 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/custom-icon.ts +40 -0
- package/shared/graph-camera.ts +28 -8
- package/shared/graph-geometry.ts +30 -19
- 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>;
|
package/shared/custom-icon.ts
CHANGED
|
@@ -12,6 +12,44 @@ export const LINEAR_INDIGO = "#5E6AD2";
|
|
|
12
12
|
export const MAX_SVG_LENGTH = 64_000;
|
|
13
13
|
export const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
|
14
14
|
|
|
15
|
+
/** Paseo draws panel tabs, the new tab menu, and Command Center with Lucide names only. */
|
|
16
|
+
export const BUILT_IN_ICON = "SquareKanban";
|
|
17
|
+
export const MENU_ICONS: readonly { name: string; label: string }[] = [
|
|
18
|
+
{ name: BUILT_IN_ICON, label: "Board (default)" },
|
|
19
|
+
...[
|
|
20
|
+
["Kanban", "Kanban"],
|
|
21
|
+
["LayoutGrid", "Grid"],
|
|
22
|
+
["ListTodo", "To-do list"],
|
|
23
|
+
["ClipboardList", "Clipboard"],
|
|
24
|
+
["CircleDot", "Circle dot"],
|
|
25
|
+
["Target", "Target"],
|
|
26
|
+
["Zap", "Lightning"],
|
|
27
|
+
["Rocket", "Rocket"],
|
|
28
|
+
["Star", "Star"],
|
|
29
|
+
["Hexagon", "Hexagon"],
|
|
30
|
+
["Triangle", "Triangle"],
|
|
31
|
+
["Diamond", "Diamond"],
|
|
32
|
+
["Box", "Box"],
|
|
33
|
+
["Layers", "Layers"],
|
|
34
|
+
["Compass", "Compass"],
|
|
35
|
+
["Flag", "Flag"],
|
|
36
|
+
["Bookmark", "Bookmark"],
|
|
37
|
+
["Ticket", "Ticket"],
|
|
38
|
+
["Bug", "Bug"],
|
|
39
|
+
["Sparkles", "Sparkles"],
|
|
40
|
+
["Bot", "Bot"],
|
|
41
|
+
["Gem", "Gem"],
|
|
42
|
+
["Flame", "Flame"],
|
|
43
|
+
["Shapes", "Shapes"],
|
|
44
|
+
["Orbit", "Orbit"],
|
|
45
|
+
["Workflow", "Workflow"],
|
|
46
|
+
["Puzzle", "Puzzle"],
|
|
47
|
+
["Atom", "Atom"],
|
|
48
|
+
["Command", "Command"],
|
|
49
|
+
].map(([name = "", label = ""]) => ({ name, label })),
|
|
50
|
+
];
|
|
51
|
+
const LUCIDE_NAME = /^[A-Z][A-Za-z0-9]*$/;
|
|
52
|
+
|
|
15
53
|
export const IconSettingsSchema = z.object({
|
|
16
54
|
/** Checked SVG markup. Empty uses the built-in icon. */
|
|
17
55
|
svg: z.string().max(MAX_SVG_LENGTH).default(""),
|
|
@@ -20,6 +58,8 @@ export const IconSettingsSchema = z.object({
|
|
|
20
58
|
color: z.string().regex(HEX_COLOR).default(LINEAR_INDIGO),
|
|
21
59
|
/** Fill shapes that only have an outline. */
|
|
22
60
|
solid: z.boolean().default(false),
|
|
61
|
+
/** The Lucide icon for panel tabs, the new tab menu, and Command Center. */
|
|
62
|
+
menu: z.string().regex(LUCIDE_NAME).default(BUILT_IN_ICON),
|
|
23
63
|
});
|
|
24
64
|
export type IconSettings = z.infer<typeof IconSettingsSchema>;
|
|
25
65
|
|
package/shared/graph-camera.ts
CHANGED
|
@@ -17,6 +17,11 @@ export interface Box {
|
|
|
17
17
|
bottom: number;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/** A box with the point the view centers on when the box still fits around it. */
|
|
21
|
+
export interface Focus extends Box {
|
|
22
|
+
center: Point;
|
|
23
|
+
}
|
|
24
|
+
|
|
20
25
|
export const FIT: Camera = { x: 0, y: 0, zoom: 1 };
|
|
21
26
|
export const MIN_ZOOM = 1;
|
|
22
27
|
export const MAX_ZOOM = 4;
|
|
@@ -27,6 +32,11 @@ const PAD_Y = 56;
|
|
|
27
32
|
|
|
28
33
|
const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));
|
|
29
34
|
|
|
35
|
+
/** How much nodes, labels, and cursors grow on screen: less than the zoom, so they stay readable. */
|
|
36
|
+
export function growth(zoom: number): number {
|
|
37
|
+
return Math.sqrt(zoom);
|
|
38
|
+
}
|
|
39
|
+
|
|
30
40
|
/** Where a canvas point draws in the view. */
|
|
31
41
|
export function project(camera: Camera, at: Point, width: number, height: number): Point {
|
|
32
42
|
return {
|
|
@@ -61,21 +71,31 @@ export function zoomAt(camera: Camera, at: Point, factor: number, width: number,
|
|
|
61
71
|
return clampCamera({ zoom, x: at.x - cx - zoom * (px - cx), y: at.y - cy - zoom * (py - cy) }, width, height);
|
|
62
72
|
}
|
|
63
73
|
|
|
64
|
-
/**
|
|
65
|
-
|
|
74
|
+
/** The center nearest `wanted` that keeps the span from low to high in a view of this size. */
|
|
75
|
+
function centerFor(wanted: number, low: number, high: number, half: number): number {
|
|
76
|
+
return high - low >= half * 2 ? (low + high) / 2 : clamp(wanted, high - half, low + half);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Shows the box as large as fits in the view, up to the auto zoom, centered on its focus. */
|
|
80
|
+
export function frameBox(box: Box | Focus, width: number, height: number): Camera {
|
|
66
81
|
const fit = Math.min(width / Math.max(1, box.right - box.left), height / Math.max(1, box.bottom - box.top));
|
|
67
82
|
const zoom = clamp(fit, MIN_ZOOM, AUTO_ZOOM);
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
|
|
83
|
+
const wanted = "center" in box ? box.center : { x: (box.left + box.right) / 2, y: (box.top + box.bottom) / 2 };
|
|
84
|
+
const middleX = centerFor(wanted.x, box.left, box.right, width / 2 / zoom);
|
|
85
|
+
const middleY = centerFor(wanted.y, box.top, box.bottom, height / 2 / zoom);
|
|
86
|
+
return clampCamera({ zoom, x: zoom * (width / 2 - middleX), y: zoom * (height / 2 - middleY) }, width, height);
|
|
71
87
|
}
|
|
72
88
|
|
|
73
89
|
// The area auto mode shows: the nodes the running agents work on and their neighbors, with
|
|
74
90
|
// room for labels and cursors. Null when no agent works on a node other than the issue.
|
|
75
|
-
export function focusBox(graph: Graph, positions: ReadonlyMap<string, Point>, targets: Iterable<string>):
|
|
91
|
+
export function focusBox(graph: Graph, positions: ReadonlyMap<string, Point>, targets: Iterable<string>): Focus | null {
|
|
76
92
|
const ids = new Set<string>();
|
|
77
93
|
for (const target of targets) if (target !== graph.root) ids.add(target);
|
|
78
94
|
if (ids.size === 0) return null;
|
|
95
|
+
const spots = [...ids].flatMap((id) => positions.get(id) ?? []);
|
|
96
|
+
const center = spots.length
|
|
97
|
+
? { x: spots.reduce((sum, at) => sum + at.x, 0) / spots.length, y: spots.reduce((sum, at) => sum + at.y, 0) / spots.length }
|
|
98
|
+
: null;
|
|
79
99
|
for (const edge of graph.edges) {
|
|
80
100
|
if (ids.has(edge.from) && edge.to !== graph.root) ids.add(edge.to);
|
|
81
101
|
else if (ids.has(edge.to) && edge.from !== graph.root) ids.add(edge.from);
|
|
@@ -88,6 +108,6 @@ export function focusBox(graph: Graph, positions: ReadonlyMap<string, Point>, ta
|
|
|
88
108
|
? { left: Math.min(box.left, at.x), top: Math.min(box.top, at.y), right: Math.max(box.right, at.x), bottom: Math.max(box.bottom, at.y) }
|
|
89
109
|
: { left: at.x, top: at.y, right: at.x, bottom: at.y };
|
|
90
110
|
}
|
|
91
|
-
if (!box) return null;
|
|
92
|
-
return { left: box.left - PAD_X, top: box.top - PAD_Y, right: box.right + PAD_X, bottom: box.bottom + PAD_Y };
|
|
111
|
+
if (!box || !center) return null;
|
|
112
|
+
return { left: box.left - PAD_X, top: box.top - PAD_Y, right: box.right + PAD_X, bottom: box.bottom + PAD_Y, center };
|
|
93
113
|
}
|
package/shared/graph-geometry.ts
CHANGED
|
@@ -6,31 +6,36 @@ export interface Point {
|
|
|
6
6
|
y: number;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
export interface Route {
|
|
10
|
-
points:
|
|
9
|
+
export interface Route<P extends Point = Point> {
|
|
10
|
+
points: P[];
|
|
11
11
|
/** Each point's share of the whole distance, from 0 to 1. */
|
|
12
12
|
stops: number[];
|
|
13
13
|
length: number;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
/** Where a route is at a share of its distance, from 0 to 1. */
|
|
17
|
-
export function pointAt(path: Route
|
|
16
|
+
/** Where a route is at a share of its distance, from 0 to 1. Mixes every number of the points. */
|
|
17
|
+
export function pointAt<P extends Point>(path: Route<P>, share: number): P {
|
|
18
18
|
const at = Math.min(1, Math.max(0, share));
|
|
19
19
|
for (let index = 1; index < path.stops.length; index += 1) {
|
|
20
20
|
const stop = path.stops[index] as number;
|
|
21
21
|
if (at > stop) continue;
|
|
22
22
|
const before = path.stops[index - 1] as number;
|
|
23
|
-
const from = path.points[index - 1] as
|
|
24
|
-
const to = path.points[index] as
|
|
23
|
+
const from = path.points[index - 1] as P;
|
|
24
|
+
const to = path.points[index] as P;
|
|
25
25
|
const part = stop > before ? (at - before) / (stop - before) : 1;
|
|
26
|
-
|
|
26
|
+
const mixed: Record<string, unknown> = { ...(to as object) };
|
|
27
|
+
for (const [key, value] of Object.entries(to as object)) {
|
|
28
|
+
const start = (from as Record<string, unknown>)[key];
|
|
29
|
+
if (typeof value === "number" && typeof start === "number") mixed[key] = start + (value - start) * part;
|
|
30
|
+
}
|
|
31
|
+
return mixed as P;
|
|
27
32
|
}
|
|
28
|
-
return path.points[path.points.length - 1] as
|
|
33
|
+
return path.points[path.points.length - 1] as P;
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
/** A route through the points without zero-length steps, or null when there is nowhere to go. */
|
|
32
|
-
export function route(points: readonly
|
|
33
|
-
const kept:
|
|
37
|
+
export function route<P extends Point>(points: readonly P[]): Route<P> | null {
|
|
38
|
+
const kept: P[] = [];
|
|
34
39
|
const steps = [0];
|
|
35
40
|
for (const point of points) {
|
|
36
41
|
const last = kept[kept.length - 1];
|
|
@@ -84,21 +89,27 @@ export interface PlacedLabel {
|
|
|
84
89
|
short: boolean;
|
|
85
90
|
}
|
|
86
91
|
|
|
87
|
-
|
|
88
|
-
|
|
92
|
+
export interface Rect {
|
|
93
|
+
left: number;
|
|
94
|
+
top: number;
|
|
95
|
+
right: number;
|
|
96
|
+
bottom: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Places labels in request order at the first spot that overlaps no node, other obstacle, or
|
|
100
|
+
// placed label, moved inside the box when it would cross an edge. Others are hidden.
|
|
89
101
|
export function placeLabels(
|
|
90
102
|
requests: readonly LabelRequest[],
|
|
91
103
|
width: number,
|
|
92
104
|
height: number,
|
|
93
|
-
|
|
105
|
+
obstacles: readonly ({ at: Point; radius: number } | Rect)[] = [],
|
|
94
106
|
gap = 3,
|
|
95
107
|
): Map<string, PlacedLabel> {
|
|
96
|
-
const placed =
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}));
|
|
108
|
+
const placed: Rect[] = obstacles.map((shape) =>
|
|
109
|
+
"at" in shape
|
|
110
|
+
? { left: shape.at.x - shape.radius, right: shape.at.x + shape.radius, top: shape.at.y - shape.radius, bottom: shape.at.y + shape.radius }
|
|
111
|
+
: { ...shape },
|
|
112
|
+
);
|
|
102
113
|
const shown = new Map<string, PlacedLabel>();
|
|
103
114
|
for (const request of requests) {
|
|
104
115
|
const tries = [{ ...request, short: false }];
|
|
@@ -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
|
}
|