@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/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>;
@@ -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
 
@@ -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
- /** Shows the box as large as fits in the view, up to the auto zoom. */
65
- export function frameBox(box: Box, width: number, height: number): Camera {
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 x = zoom * (width / 2 - (box.left + box.right) / 2);
69
- const y = zoom * (height / 2 - (box.top + box.bottom) / 2);
70
- return clampCamera({ zoom, x, y }, width, height);
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>): Box | null {
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
  }
@@ -6,31 +6,36 @@ export interface Point {
6
6
  y: number;
7
7
  }
8
8
 
9
- export interface Route {
10
- points: Point[];
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, share: number): Point {
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 Point;
24
- const to = path.points[index] as Point;
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
- return { x: from.x + (to.x - from.x) * part, y: from.y + (to.y - from.y) * part };
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 Point;
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 Point[]): Route | null {
33
- const kept: Point[] = [];
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
- // Places labels in request order at the first spot that overlaps no node or placed label,
88
- // moved inside the box when it would cross an edge. A label with no free spot is hidden.
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
- nodes: readonly { at: Point; radius: number }[] = [],
105
+ obstacles: readonly ({ at: Point; radius: number } | Rect)[] = [],
94
106
  gap = 3,
95
107
  ): Map<string, PlacedLabel> {
96
- const placed = nodes.map(({ at, radius }) => ({
97
- left: at.x - radius,
98
- right: at.x + radius,
99
- top: at.y - radius,
100
- bottom: at.y + radius,
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 pathsIn(child.title)) owners.set(path, child.identifier);
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 pathsIn(line)) {
224
+ for (const path of found(line)) {
209
225
  if (!owners.has(path)) owners.set(path, named ?? issue.identifier);
210
226
  }
211
227
  }