@dahrk/linear 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +24 -12
  2. package/dist/batch-source.d.ts +63 -0
  3. package/dist/batch-source.d.ts.map +1 -0
  4. package/dist/batch-source.js +149 -0
  5. package/dist/batch-source.js.map +1 -0
  6. package/dist/comments.d.ts +36 -0
  7. package/dist/comments.d.ts.map +1 -0
  8. package/dist/comments.js +104 -0
  9. package/dist/comments.js.map +1 -0
  10. package/dist/documents.d.ts +1 -15
  11. package/dist/documents.d.ts.map +1 -1
  12. package/dist/documents.js +37 -27
  13. package/dist/documents.js.map +1 -1
  14. package/dist/format-action.d.ts +25 -0
  15. package/dist/format-action.d.ts.map +1 -0
  16. package/dist/format-action.js +250 -0
  17. package/dist/format-action.js.map +1 -0
  18. package/dist/index.d.ts +119 -15
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +231 -59
  21. package/dist/index.js.map +1 -1
  22. package/dist/issue-graph.d.ts +37 -0
  23. package/dist/issue-graph.d.ts.map +1 -0
  24. package/dist/issue-graph.js +125 -0
  25. package/dist/issue-graph.js.map +1 -0
  26. package/dist/issues.d.ts +27 -2
  27. package/dist/issues.d.ts.map +1 -1
  28. package/dist/issues.js +33 -10
  29. package/dist/issues.js.map +1 -1
  30. package/dist/labels.d.ts +48 -1
  31. package/dist/labels.d.ts.map +1 -1
  32. package/dist/labels.js +72 -24
  33. package/dist/labels.js.map +1 -1
  34. package/dist/linear-client.d.ts +51 -0
  35. package/dist/linear-client.d.ts.map +1 -1
  36. package/dist/linear-client.js +233 -34
  37. package/dist/linear-client.js.map +1 -1
  38. package/dist/oauth.d.ts +52 -12
  39. package/dist/oauth.d.ts.map +1 -1
  40. package/dist/oauth.js +91 -34
  41. package/dist/oauth.js.map +1 -1
  42. package/dist/recording-client.d.ts +20 -2
  43. package/dist/recording-client.d.ts.map +1 -1
  44. package/dist/recording-client.js +39 -1
  45. package/dist/recording-client.js.map +1 -1
  46. package/dist/responding-client.d.ts +49 -0
  47. package/dist/responding-client.d.ts.map +1 -0
  48. package/dist/responding-client.js +47 -0
  49. package/dist/responding-client.js.map +1 -0
  50. package/dist/teams.d.ts +20 -0
  51. package/dist/teams.d.ts.map +1 -0
  52. package/dist/teams.js +32 -0
  53. package/dist/teams.js.map +1 -0
  54. package/package.json +8 -10
  55. package/src/batch-source.ts +208 -0
  56. package/src/comments.ts +126 -0
  57. package/src/documents.ts +162 -0
  58. package/src/format-action.ts +279 -0
  59. package/src/index.ts +617 -0
  60. package/src/issue-graph.ts +169 -0
  61. package/src/issues.ts +142 -0
  62. package/src/labels.ts +254 -0
  63. package/src/linear-client.ts +448 -0
  64. package/src/oauth.ts +255 -0
  65. package/src/recording-client.ts +141 -0
  66. package/src/responding-client.ts +106 -0
  67. package/src/teams.ts +44 -0
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Format a tool call into Linear's action-activity vocabulary (DHK-382): a human verb in
3
+ * `action` and a clean, humanised input in `parameter`. Linear renders these as
4
+ * "<action> · <parameter>" (e.g. "Ran · grep -rn ..."), so the agent session reads as a
5
+ * sequence of verbs instead of a wall of `ToolName {raw JSON args}`.
6
+ *
7
+ * `inputText` is the edge's bounded preview of the tool input: normally a JSON object string
8
+ * (`clip(JSON.stringify(input))`), but it may be TRUNCATED (over the edge's ~500-char cap) or
9
+ * otherwise malformed. Parsing is defensive: a preview that will not parse never throws and
10
+ * never falls through to raw JSON. Known single-field tools still salvage their primary field
11
+ * from a truncated preview; anything unrecoverable degrades to the bare verb.
12
+ */
13
+
14
+ /** A tool call rendered as Linear's `{ action, parameter }` pair. */
15
+ export interface ToolAction {
16
+ action: string;
17
+ parameter: string;
18
+ }
19
+
20
+ /** Keep a parameter readable on one line; the edge already caps the input at ~500 chars. */
21
+ const MAX_PARAM = 400;
22
+
23
+ /** Collapse whitespace to a single line and clip with an ellipsis so the parameter never wraps. */
24
+ function clip(s: string, max = MAX_PARAM): string {
25
+ const t = s.replace(/\s+/g, " ").trim();
26
+ return t.length <= max ? t : `${t.slice(0, max - 1).trimEnd()}…`;
27
+ }
28
+
29
+ /** The final path segment (e.g. "packages/hub/src/config-server.ts" -> "config-server.ts"). */
30
+ function basename(p: string): string {
31
+ const trimmed = p.replace(/\/+$/, "");
32
+ const i = trimmed.lastIndexOf("/");
33
+ return i >= 0 ? trimmed.slice(i + 1) : trimmed;
34
+ }
35
+
36
+ /** Humanise a tool name for display: split separators and camelCase, then Title Case
37
+ * (e.g. "WebFetch" -> "Web Fetch", "create_issue" -> "Create Issue"). */
38
+ function humaniseToolName(name: string): string {
39
+ return (
40
+ name
41
+ .replace(/[_-]+/g, " ")
42
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
43
+ .trim()
44
+ .replace(/\b\w/g, (c) => c.toUpperCase()) || "Tool"
45
+ );
46
+ }
47
+
48
+ /** A parsed tool-input object, or undefined when the preview is missing/truncated/not an object. */
49
+ function parseInput(text: string | undefined): Record<string, unknown> | undefined {
50
+ const t = text?.trim();
51
+ if (!t || !t.startsWith("{")) return undefined;
52
+ try {
53
+ const parsed: unknown = JSON.parse(t);
54
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
55
+ ? (parsed as Record<string, unknown>)
56
+ : undefined;
57
+ } catch {
58
+ return undefined;
59
+ }
60
+ }
61
+
62
+ /** A scalar field as a trimmed string, or undefined when absent/empty/non-scalar. */
63
+ function scalarField(input: Record<string, unknown> | undefined, key: string): string | undefined {
64
+ const v = input?.[key];
65
+ const s = typeof v === "string" ? v : typeof v === "number" || typeof v === "boolean" ? String(v) : undefined;
66
+ return s && s.trim() !== "" ? s : undefined;
67
+ }
68
+
69
+ /** Best-effort extraction of a string field straight from the raw preview, so a TRUNCATED JSON
70
+ * (a long command clipped mid-value) still yields its primary field rather than an empty verb. */
71
+ function looseField(text: string | undefined, key: string): string | undefined {
72
+ if (!text) return undefined;
73
+ const m = new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)`).exec(text);
74
+ if (!m?.[1]) return undefined;
75
+ const decoded = m[1]
76
+ .replace(/\\"/g, '"')
77
+ .replace(/\\[nt]/g, " ")
78
+ .replace(/\\\\/g, "\\")
79
+ .replace(/\\$/, "");
80
+ return decoded.trim() !== "" ? decoded : undefined;
81
+ }
82
+
83
+ /** A known string field, preferring the parsed value and salvaging a truncated one from the raw text. */
84
+ function stringField(
85
+ input: Record<string, unknown> | undefined,
86
+ text: string | undefined,
87
+ key: string,
88
+ ): string | undefined {
89
+ return scalarField(input, key) ?? looseField(text, key);
90
+ }
91
+
92
+ /** The first scalar argument of an unknown tool's input, for a best-guess parameter. */
93
+ function firstScalar(input: Record<string, unknown> | undefined): string {
94
+ for (const key of Object.keys(input ?? {})) {
95
+ const s = scalarField(input, key);
96
+ if (s !== undefined) return s;
97
+ }
98
+ return "";
99
+ }
100
+
101
+ /** A `Read`/inspection line-range suffix (":440-520" or ":440") from numeric offset/limit. */
102
+ function lineRange(input: Record<string, unknown> | undefined): string {
103
+ const offset = input?.offset;
104
+ const limit = input?.limit;
105
+ if (typeof offset !== "number") return "";
106
+ return typeof limit === "number" ? `:${offset}-${offset + limit}` : `:${offset}`;
107
+ }
108
+
109
+ /**
110
+ * Map a `(toolName, inputPreview)` pair to a verb plus a clean parameter. Total and defensive:
111
+ * every branch returns a `{ action, parameter }`, and the parameter is only ever a humanised
112
+ * field value (never the raw JSON), so a malformed or oversized preview degrades to the bare verb.
113
+ */
114
+ export function formatToolAction(tool: string | undefined, inputText: string | undefined): ToolAction {
115
+ const name = (tool ?? "").trim();
116
+ const input = parseInput(inputText);
117
+ const field = (key: string): string | undefined => stringField(input, inputText, key);
118
+
119
+ switch (name) {
120
+ case "Bash":
121
+ return { action: "Ran", parameter: clip(field("command") ?? "") };
122
+ case "Read": {
123
+ const path = field("file_path");
124
+ return { action: "Read", parameter: path ? clip(basename(path) + lineRange(input)) : "" };
125
+ }
126
+ case "Grep": {
127
+ const pattern = field("pattern");
128
+ const path = field("path");
129
+ const where = pattern ? `"${pattern}"${path ? ` in ${path}` : ""}` : "";
130
+ return { action: "Searched", parameter: clip(where) };
131
+ }
132
+ case "Glob": {
133
+ const pattern = field("pattern");
134
+ const path = field("path");
135
+ const where = pattern ? `${pattern}${path ? ` in ${path}` : ""}` : "";
136
+ return { action: "Searched", parameter: clip(where) };
137
+ }
138
+ case "Edit":
139
+ case "MultiEdit": {
140
+ const path = field("file_path");
141
+ return { action: "Edited", parameter: path ? clip(basename(path)) : "" };
142
+ }
143
+ case "Write": {
144
+ const path = field("file_path");
145
+ return { action: "Wrote", parameter: path ? clip(basename(path)) : "" };
146
+ }
147
+ case "ToolSearch":
148
+ return { action: "Loaded tools", parameter: clip(field("query") ?? "") };
149
+ default:
150
+ break;
151
+ }
152
+
153
+ // MCP tools arrive as `mcp__<server>__<tool>`; show the humanised tool segment.
154
+ if (name.startsWith("mcp__")) {
155
+ const segments = name.split("__").filter(Boolean);
156
+ const toolSegment = segments[segments.length - 1] ?? name;
157
+ return { action: humaniseToolName(toolSegment), parameter: clip(firstScalar(input)) };
158
+ }
159
+
160
+ // Unknown tool: a title-cased name plus its first scalar argument.
161
+ return { action: humaniseToolName(name), parameter: clip(firstScalar(input)) };
162
+ }
163
+
164
+ /**
165
+ * Render a completed tool's output as the markdown `result` of its action activity (DHK-386). This
166
+ * is the companion to formatToolAction: the verb + parameter describe the call, this shapes the
167
+ * outcome per tool - `Bash` as a fenced block, `Grep` as a match-count summary, `Edit`/`Write` as a
168
+ * diff stat, `Read` as how much was read - so the session reads richly instead of dumping raw text.
169
+ *
170
+ * `output` is the tool's full (untruncated) observation output. Total and defensive like its
171
+ * companion: it never throws, bounds every long output with a clear elision so a noisy tool can
172
+ * never flood the session, and degrades any unrecognised tool or shape to a bounded first line. An
173
+ * empty output yields an empty string, so no `result` is folded onto the action.
174
+ */
175
+
176
+ /** Caps for a rendered result: keep the outcome glanceable. Long output shows a head and tail with
177
+ * a "… (N more lines)" marker between, never the whole wall. */
178
+ const RESULT_MAX_LINES = 12;
179
+ const RESULT_HEAD_LINES = 8;
180
+ const RESULT_TAIL_LINES = 3;
181
+ const RESULT_MAX_CHARS = 1800;
182
+ const RESULT_TOP_HITS = 5;
183
+
184
+ /** Clip a multi-line block to a character budget, marking the cut with an ellipsis line. */
185
+ function clipBlock(s: string, max = RESULT_MAX_CHARS): string {
186
+ return s.length <= max ? s : `${s.slice(0, max).trimEnd()}\n…`;
187
+ }
188
+
189
+ /** Bound a block of lines: keep it whole when short, else show the head and tail with a
190
+ * "… (N more lines)" elision marker between them so long output never becomes a wall of text. */
191
+ function elideLines(lines: string[]): string {
192
+ if (lines.length <= RESULT_MAX_LINES) return lines.join("\n");
193
+ const head = lines.slice(0, RESULT_HEAD_LINES);
194
+ const tail = lines.slice(-RESULT_TAIL_LINES);
195
+ const omitted = lines.length - head.length - tail.length;
196
+ return [...head, `… (${omitted} more lines)`, ...tail].join("\n");
197
+ }
198
+
199
+ /** A fenced code block, the standard rendering for verbatim command/file output. */
200
+ function codeFence(body: string): string {
201
+ return "```\n" + body + "\n```";
202
+ }
203
+
204
+ /** The first non-empty line, collapsed and clipped - the safe fallback for MCP and unknown tools. */
205
+ function firstLine(output: string): string {
206
+ return clip(output.split("\n").find((l) => l.trim() !== "") ?? "");
207
+ }
208
+
209
+ /** English pluralisation for a count, e.g. `plural(2, "match", "matches")`. */
210
+ function plural(n: number, one: string, many: string): string {
211
+ return `${n} ${n === 1 ? one : many}`;
212
+ }
213
+
214
+ /** `Bash`: the stdout/stderr in a fenced code block, bounded head + tail. */
215
+ function renderBashResult(output: string): string {
216
+ return codeFence(clipBlock(elideLines(output.split("\n"))));
217
+ }
218
+
219
+ /** A `path:line` grep hit parsed from a `path:line:content` or `path:line` output row. */
220
+ function grepHit(row: string): { path: string; line: string } | undefined {
221
+ const m = /^(.+?):(\d+)(?::|$)/.exec(row);
222
+ return m ? { path: m[1]!, line: m[2]! } : undefined;
223
+ }
224
+
225
+ /** `Grep`: an "N matches in M files" summary plus the first few ``path:line`` hits. Falls back to a
226
+ * plain match count and the first rows when the output is not the `path:line` shape (e.g. a
227
+ * files-with-matches listing of bare paths). */
228
+ function renderGrepResult(output: string): string {
229
+ const rows = output.split("\n").map((r) => r.trim()).filter(Boolean);
230
+ const hits = rows.map(grepHit).filter((h): h is { path: string; line: string } => h !== undefined);
231
+ if (hits.length === 0) {
232
+ const listed = rows.slice(0, RESULT_TOP_HITS).map((r) => `- ${r}`);
233
+ const more = rows.length > RESULT_TOP_HITS ? [`… (${rows.length - RESULT_TOP_HITS} more)`] : [];
234
+ return [plural(rows.length, "match", "matches"), ...listed, ...more].join("\n");
235
+ }
236
+ const files = new Set(hits.map((h) => h.path)).size;
237
+ const summary = `${plural(hits.length, "match", "matches")} in ${plural(files, "file", "files")}`;
238
+ const top = hits.slice(0, RESULT_TOP_HITS).map((h) => `- \`${h.path}:${h.line}\``);
239
+ const more = hits.length > RESULT_TOP_HITS ? [`… (${hits.length - RESULT_TOP_HITS} more)`] : [];
240
+ return [summary, ...top, ...more].join("\n");
241
+ }
242
+
243
+ /** `Read`: how much was read; the file content itself is not worth echoing into the session. */
244
+ function renderReadResult(output: string): string {
245
+ return `Read ${plural(output.split("\n").length, "line", "lines")}.`;
246
+ }
247
+
248
+ /** `Edit`/`Write`: a `+added / −removed` diff stat when the output is a unified diff, else the tool's
249
+ * one-line confirmation. Ignores the `+++`/`---` file headers so they are not counted as changes. */
250
+ function renderEditResult(output: string): string {
251
+ let added = 0;
252
+ let removed = 0;
253
+ for (const line of output.split("\n")) {
254
+ if (line.startsWith("+") && !line.startsWith("+++")) added += 1;
255
+ else if (line.startsWith("-") && !line.startsWith("---")) removed += 1;
256
+ }
257
+ return added > 0 || removed > 0 ? `\`+${added} / −${removed}\`` : firstLine(output);
258
+ }
259
+
260
+ export function formatToolResult(tool: string | undefined, output: string | undefined): string {
261
+ const text = (output ?? "").trimEnd();
262
+ if (text.trim() === "") return "";
263
+
264
+ switch ((tool ?? "").trim()) {
265
+ case "Bash":
266
+ return renderBashResult(text);
267
+ case "Grep":
268
+ return renderGrepResult(text);
269
+ case "Read":
270
+ return renderReadResult(text);
271
+ case "Edit":
272
+ case "MultiEdit":
273
+ case "Write":
274
+ return renderEditResult(text);
275
+ default:
276
+ // MCP and unknown tools: a short, safe summary rather than a raw dump.
277
+ return firstLine(text);
278
+ }
279
+ }