@yannelli/paseo-linear-plugin 1.1.0-beta.1 → 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.
@@ -0,0 +1,157 @@
1
+ import { createHash } from "node:crypto";
2
+ import path from "node:path";
3
+ import type { PluginServerContext } from "@getpaseo/plugin/server";
4
+ import {
5
+ knowledgeGetRpc,
6
+ knowledgeInspectRpc,
7
+ type ProjectBrief,
8
+ type ProjectKnowledge,
9
+ ProjectKnowledgeSchema,
10
+ topFolders,
11
+ } from "../shared/knowledge";
12
+ import type { Paseo } from "./agent-runs";
13
+ import { inspectProject } from "./inspect";
14
+ import { createJsonFile, type JsonFile } from "./stores";
15
+
16
+ /** Knowledge older than this is inspected again before use; summaries carry over. */
17
+ export const KNOWLEDGE_MAX_AGE_MS = 6 * 60 * 60_000;
18
+
19
+ export interface KnowledgeProject {
20
+ projectId: string;
21
+ rootPath: string;
22
+ }
23
+
24
+ /** What the setup agent learned: a line on the project and one per area. */
25
+ export interface KnowledgeNotes {
26
+ summary: string;
27
+ areas: readonly { path: string; summary: string }[];
28
+ }
29
+
30
+ export interface KnowledgeService {
31
+ get(projectId: string): Promise<ProjectKnowledge | null>;
32
+ /** Saved knowledge, inspected again when missing, stale, or from another folder. */
33
+ ensure(project: KnowledgeProject): Promise<ProjectKnowledge>;
34
+ /** Inspects now. Runs for one project share one inspection. */
35
+ inspect(project: KnowledgeProject): Promise<ProjectKnowledge>;
36
+ annotate(projectId: string, notes: KnowledgeNotes): Promise<void>;
37
+ }
38
+
39
+ type Inspect = (rootPath: string, projectId: string) => Promise<ProjectKnowledge>;
40
+
41
+ function areaPath(value: string): string {
42
+ const trimmed = value.trim().replace(/^(?:\.\/)+/, "").replace(/^\/+|\/+$/g, "");
43
+ return trimmed === "" || trimmed === "." ? "" : `${trimmed}/`;
44
+ }
45
+
46
+ /** One file per project beside the key file, so each loads only when used. */
47
+ export function createKnowledge(
48
+ directory: string,
49
+ inspect: Inspect = inspectProject,
50
+ now = () => Date.now(),
51
+ ): KnowledgeService {
52
+ const files = new Map<string, JsonFile<ProjectKnowledge | null>>();
53
+ const running = new Map<string, Promise<ProjectKnowledge>>();
54
+ const fileFor = (projectId: string) => {
55
+ let file = files.get(projectId);
56
+ if (!file) {
57
+ const name = createHash("sha256").update(projectId).digest("hex").slice(0, 32);
58
+ file = createJsonFile(path.join(directory, `${name}.json`), ProjectKnowledgeSchema.nullable(), () => null);
59
+ files.set(projectId, file);
60
+ }
61
+ return file;
62
+ };
63
+ const get = async (projectId: string) => {
64
+ const known = await fileFor(projectId).read();
65
+ return known?.projectId === projectId ? known : null;
66
+ };
67
+
68
+ function inspectNow(project: KnowledgeProject): Promise<ProjectKnowledge> {
69
+ const existing = running.get(project.projectId);
70
+ if (existing) return existing;
71
+ const work = (async () => {
72
+ const fresh = await inspect(project.rootPath, project.projectId);
73
+ return fileFor(project.projectId).update((previous) => {
74
+ const summaries = new Map((previous?.areas ?? []).map((area) => [area.path, area.summary]));
75
+ const next: ProjectKnowledge = {
76
+ ...fresh,
77
+ areas: fresh.areas.map((area) => ({ ...area, summary: summaries.get(area.path) ?? area.summary })),
78
+ summary: previous?.summary ?? fresh.summary,
79
+ summarizedAt: previous?.summarizedAt ?? fresh.summarizedAt,
80
+ };
81
+ return { next, result: next };
82
+ });
83
+ })().finally(() => running.delete(project.projectId));
84
+ running.set(project.projectId, work);
85
+ return work;
86
+ }
87
+
88
+ return {
89
+ get,
90
+ inspect: inspectNow,
91
+ async ensure(project) {
92
+ const known = await get(project.projectId);
93
+ const age = known ? now() - Date.parse(known.inspectedAt) : Number.POSITIVE_INFINITY;
94
+ if (known && known.rootPath === project.rootPath && age < KNOWLEDGE_MAX_AGE_MS) return known;
95
+ // A failed inspection keeps the saved knowledge rather than losing it.
96
+ return inspectNow(project).catch((error: unknown) => {
97
+ if (known) return known;
98
+ throw error;
99
+ });
100
+ },
101
+ async annotate(projectId, notes) {
102
+ await fileFor(projectId).update((current) => {
103
+ if (!current) return { next: current, result: undefined };
104
+ const summaries = new Map(notes.areas.map((area) => [areaPath(area.path), area.summary.trim()]));
105
+ const next: ProjectKnowledge = {
106
+ ...current,
107
+ summary: notes.summary.trim() || current.summary,
108
+ summarizedAt: new Date(now()).toISOString(),
109
+ areas: current.areas.map((area) => ({ ...area, summary: summaries.get(area.path) || area.summary })),
110
+ };
111
+ return { next, result: undefined };
112
+ });
113
+ },
114
+ };
115
+ }
116
+
117
+ export function toBrief(knowledge: ProjectKnowledge): ProjectBrief {
118
+ const { files, ...rest } = knowledge;
119
+ return { ...rest, folders: topFolders(files) };
120
+ }
121
+
122
+ /** A Paseo project on this host; the client names only an id, never a folder. */
123
+ export async function findProject(paseo: Paseo, projectId: string): Promise<KnowledgeProject | null> {
124
+ const { projects } = await paseo.projects.list();
125
+ const project = projects.find((entry) => entry.projectId === projectId);
126
+ return project ? { projectId, rootPath: project.projectRootPath } : null;
127
+ }
128
+
129
+ /** Knowledge for prompts and maps; null when the project is unknown or cannot be read. */
130
+ export async function knowledgeFor(
131
+ paseo: Paseo,
132
+ knowledge: KnowledgeService,
133
+ projectId: string | null,
134
+ ): Promise<ProjectKnowledge | null> {
135
+ if (!projectId) return null;
136
+ try {
137
+ const project = await findProject(paseo, projectId);
138
+ return project ? await knowledge.ensure(project) : await knowledge.get(projectId);
139
+ } catch (error) {
140
+ console.error(`Linear could not inspect project ${projectId}`, error);
141
+ return null;
142
+ }
143
+ }
144
+
145
+ export function registerKnowledge(server: PluginServerContext, knowledge: KnowledgeService) {
146
+ async function requireProject(paseo: Paseo, projectId: string) {
147
+ const project = await findProject(paseo, projectId);
148
+ if (!project) throw new Error("That project is not on this host");
149
+ return project;
150
+ }
151
+ server.handle(knowledgeGetRpc, async ({ projectId }, { paseo }) => ({
152
+ knowledge: toBrief(await knowledge.ensure(await requireProject(paseo, projectId))),
153
+ }));
154
+ server.handle(knowledgeInspectRpc, async ({ projectId }, { paseo }) => ({
155
+ knowledge: toBrief(await knowledge.inspect(await requireProject(paseo, projectId))),
156
+ }));
157
+ }
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,148 @@
1
+ import { z } from "zod";
2
+
3
+ // The user's own plugin icon. The web app draws it as an image, which never runs scripts or
4
+ // loads files; the checks below also refuse such content. Paint recolors every visible pixel,
5
+ // and Solid fills shapes that only have an outline.
6
+
7
+ export const ICON_PAINTS = ["original", "theme", "accent", "linear", "custom"] as const;
8
+ export type IconPaint = (typeof ICON_PAINTS)[number];
9
+
10
+ /** Linear's brand indigo, the plugin's own color. */
11
+ export const LINEAR_INDIGO = "#5E6AD2";
12
+ export const MAX_SVG_LENGTH = 64_000;
13
+ export const HEX_COLOR = /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
14
+
15
+ export const IconSettingsSchema = z.object({
16
+ /** Checked SVG markup. Empty uses the built-in icon. */
17
+ svg: z.string().max(MAX_SVG_LENGTH).default(""),
18
+ paint: z.enum(ICON_PAINTS).default("original"),
19
+ /** The color of the custom paint. */
20
+ color: z.string().regex(HEX_COLOR).default(LINEAR_INDIGO),
21
+ /** Fill shapes that only have an outline. */
22
+ solid: z.boolean().default(false),
23
+ });
24
+ export type IconSettings = z.infer<typeof IconSettingsSchema>;
25
+
26
+ const BLOCKED: readonly (readonly [RegExp, string])[] = [
27
+ [/<!DOCTYPE|<!ENTITY/i, "The SVG has a DOCTYPE or entities. Export it again without them."],
28
+ [/<script[\s>/]/i, "The SVG has a script. Use an SVG with shapes only."],
29
+ [/<foreignObject[\s>/]/i, "The SVG has HTML content. Use an SVG with shapes only."],
30
+ [/<[^>]*\son[a-z]+\s*=/i, "The SVG has event handlers. Use an SVG with shapes only."],
31
+ [/[\s:]href\s*=\s*["']\s*(?!#)/i, "The SVG links to other files or images. Use an SVG that has all its shapes inside."],
32
+ [/url\(\s*["']?\s*(?!#)/i, "The SVG uses files outside it. Use an SVG that has all its shapes inside."],
33
+ [/@import/i, "The SVG imports styles from another file."],
34
+ ];
35
+
36
+ const ROOT_TAG = /^<svg\b[^>]*>/i;
37
+ const NUMBER = String.raw`(-?[\d.]+(?:e[-+]?\d+)?)`;
38
+ const VIEW_BOX = new RegExp(String.raw`\sviewBox\s*=\s*["']\s*${NUMBER}[\s,]+${NUMBER}[\s,]+${NUMBER}[\s,]+${NUMBER}\s*["']`, "i");
39
+
40
+ const attribute = (tag: string, name: string) =>
41
+ new RegExp(String.raw`\s${name}\s*=\s*["']([^"']*)["']`, "i").exec(tag)?.[1]?.trim() ?? null;
42
+ const withoutAttribute = (tag: string, name: string) =>
43
+ tag.replace(new RegExp(String.raw`\s${name}\s*=\s*(?:"[^"]*"|'[^']*')`, "gi"), "");
44
+
45
+ /** The root's viewBox as x, y, width, and height, or null without a usable one. */
46
+ export function viewBoxOf(svg: string): [number, number, number, number] | null {
47
+ const root = ROOT_TAG.exec(svg)?.[0] ?? "";
48
+ const box = VIEW_BOX.exec(root)?.slice(1, 5).map(Number);
49
+ if (!box || box.some((value) => !Number.isFinite(value))) return null;
50
+ const [x = 0, y = 0, width = 0, height = 0] = box;
51
+ return width > 0 && height > 0 ? [x, y, width, height] : null;
52
+ }
53
+
54
+ /** Checks pasted or uploaded SVG and returns it ready to save, or why it cannot be used. */
55
+ export function parseIconSvg(text: string): { svg: string } | { error: string } {
56
+ const svg = text
57
+ .replace(/^\uFEFF/, "")
58
+ .replace(/<\?[\s\S]*?\?>/g, "")
59
+ .replace(/<!--[\s\S]*?-->/g, "")
60
+ .trim();
61
+ if (!svg) return { error: "Paste SVG markup or choose an SVG file." };
62
+ if (svg.length > MAX_SVG_LENGTH) {
63
+ return { error: `The SVG is too large. Use one under ${Math.round(MAX_SVG_LENGTH / 1000)} KB.` };
64
+ }
65
+ if (!/^<svg[\s>]/i.test(svg) || !/<\/svg>$/i.test(svg)) {
66
+ return { error: "This is not an SVG. It must start with <svg and end with </svg>." };
67
+ }
68
+ for (const [pattern, error] of BLOCKED) if (pattern.test(svg)) return { error };
69
+ const root = ROOT_TAG.exec(svg)?.[0] ?? "";
70
+ let next = root;
71
+ if (!viewBoxOf(svg)) {
72
+ const width = Number(/^([\d.]+)(?:px)?$/.exec(attribute(root, "width") ?? "")?.[1]);
73
+ const height = Number(/^([\d.]+)(?:px)?$/.exec(attribute(root, "height") ?? "")?.[1]);
74
+ if (!(width > 0 && height > 0)) return { error: "The SVG needs a viewBox, or a width and height in pixels." };
75
+ next = withoutAttribute(next, "viewBox").replace(/^<svg/i, `<svg viewBox="0 0 ${width} ${height}"`);
76
+ }
77
+ // The image scales to its box from the viewBox, so a fixed size would only crop it.
78
+ next = withoutAttribute(withoutAttribute(next, "width"), "height");
79
+ if (!/\sxmlns\s*=/.test(next)) next = next.replace(/^<svg/i, '<svg xmlns="http://www.w3.org/2000/svg"');
80
+ return { svg: next + svg.slice(root.length) };
81
+ }
82
+
83
+ /** Keeps a color inside an attribute value; anything else becomes gray. */
84
+ export function safeColor(color: string): string {
85
+ return /^[#a-z0-9(),.%\s-]+$/i.test(color) ? color.trim() : "#888888";
86
+ }
87
+
88
+ const FILL_NONE = /(?<![\w-])(fill\s*:\s*)none\b/gi;
89
+
90
+ /** Fills shapes that only have an outline, with their stroke color or the icon color. */
91
+ export function fillOutlines(svg: string): string {
92
+ const tags = svg.replace(/<([a-z][\w:.-]*)(\s[^>]*?)?(\/?)>/gi, (tag, name: string, attrs: string | undefined, close: string) => {
93
+ if (!attrs) return tag;
94
+ const stroke = attribute(attrs, "stroke") ?? /(?<![\w-])stroke\s*:\s*([^;"']+)/i.exec(attrs)?.[1]?.trim();
95
+ const fill = stroke && stroke !== "none" ? stroke : "currentColor";
96
+ const next = attrs.replace(/(\sfill\s*=\s*["'])\s*none\s*(["'])/gi, `$1${fill}$2`).replace(FILL_NONE, `$1${fill}`);
97
+ return `<${name}${next}${close}>`;
98
+ });
99
+ return tags.replace(/(<style\b[^>]*>)([\s\S]*?)(<\/style>)/gi, (_all, open: string, css: string, end: string) =>
100
+ `${open}${css.replace(FILL_NONE, "$1currentColor")}${end}`,
101
+ );
102
+ }
103
+
104
+ const TINT_ID = "paseo-linear-tint";
105
+
106
+ /** Paints every visible pixel one color and keeps its transparency. */
107
+ export function tint(svg: string, color: string): string {
108
+ const [x, y, width, height] = viewBoxOf(svg) ?? [0, 0, 24, 24];
109
+ const filter =
110
+ `<filter id="${TINT_ID}" filterUnits="userSpaceOnUse" x="${x - width}" y="${y - height}" width="${width * 3}" height="${height * 3}" color-interpolation-filters="sRGB">` +
111
+ `<feFlood flood-color="${safeColor(color)}"/><feComposite in2="SourceAlpha" operator="in"/></filter>`;
112
+ return svg
113
+ .replace(ROOT_TAG, (root) => `${root}<defs>${filter}</defs><g filter="url(#${TINT_ID})">`)
114
+ .replace(/<\/svg>$/i, "</g></svg>");
115
+ }
116
+
117
+ export interface IconLook {
118
+ /** The color `currentColor` takes: the color Paseo gives the icon. */
119
+ ink: string;
120
+ /** One color for the whole icon, or null to keep its colors. */
121
+ paint: string | null;
122
+ solid: boolean;
123
+ }
124
+
125
+ /** The SVG to draw for saved markup, or null when it no longer passes the checks. */
126
+ export function iconMarkup(saved: string, look: IconLook): string | null {
127
+ const parsed = parseIconSvg(saved);
128
+ if ("error" in parsed) return null;
129
+ const filled = look.solid ? fillOutlines(parsed.svg) : parsed.svg;
130
+ const inked = filled.replace(/currentColor/gi, safeColor(look.ink));
131
+ return look.paint ? tint(inked, look.paint) : inked;
132
+ }
133
+
134
+ /** The one color a paint gives, or null for the icon's own colors. */
135
+ export function paintColor(icon: IconSettings, colors: { ink: string; accent: string }): string | null {
136
+ switch (icon.paint) {
137
+ case "original":
138
+ return null;
139
+ case "theme":
140
+ return colors.ink;
141
+ case "accent":
142
+ return colors.accent;
143
+ case "linear":
144
+ return LINEAR_INDIGO;
145
+ case "custom":
146
+ return icon.color;
147
+ }
148
+ }
@@ -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
+ }