@supaku/agentfactory-linear 0.1.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.
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Linear API Utilities
3
+ *
4
+ * Helper functions for working with Linear API
5
+ */
6
+ /**
7
+ * Truncate a string to a maximum length, adding truncation marker if needed
8
+ */
9
+ export declare function truncateText(text: string, maxLength?: number): string;
10
+ /**
11
+ * Build a completion comment with smart truncation.
12
+ * Prioritizes: summary > plan status > session ID
13
+ *
14
+ * If the full comment exceeds maxLength:
15
+ * 1. First, truncate plan items to show only states
16
+ * 2. If still too long, truncate the summary
17
+ */
18
+ export declare function buildCompletionComment(summary: string, planItems: Array<{
19
+ state: string;
20
+ title: string;
21
+ }>, sessionId: string | null, maxLength?: number): string;
22
+ /**
23
+ * Represents a chunk of content split for multiple comments
24
+ */
25
+ export interface CommentChunk {
26
+ body: string;
27
+ partNumber: number;
28
+ totalParts: number;
29
+ }
30
+ /**
31
+ * Split content into multiple comment chunks
32
+ *
33
+ * Splitting strategy:
34
+ * 1. Reserve space for part markers
35
+ * 2. Split at paragraph boundaries first
36
+ * 3. If paragraph too long, split at sentence boundaries
37
+ * 4. If sentence too long, split at word boundaries
38
+ * 5. Never split inside code blocks
39
+ */
40
+ export declare function splitContentIntoComments(content: string, maxLength?: number, maxComments?: number): CommentChunk[];
41
+ /**
42
+ * Build completion comments with smart splitting.
43
+ * Returns multiple comment chunks if content exceeds max length.
44
+ *
45
+ * For backward compatibility, maintains the same header/footer structure
46
+ * as buildCompletionComment, but splits long content across multiple comments.
47
+ */
48
+ export declare function buildCompletionComments(summary: string, planItems: Array<{
49
+ state: string;
50
+ title: string;
51
+ }>, sessionId: string | null, maxLength?: number): CommentChunk[];
52
+ //# sourceMappingURL=utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../src/utils.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAUH;;GAEG;AACH,wBAAgB,YAAY,CAC1B,IAAI,EAAE,MAAM,EACZ,SAAS,GAAE,MAAkC,GAC5C,MAAM,CAOR;AAED;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,KAAK,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,EAClD,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,SAAS,GAAE,MAAkC,GAC5C,MAAM,CA6DR;AAED;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAA;IACZ,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,EAAE,MAAM,CAAA;CACnB;AAkED;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,MAAM,EACf,SAAS,GAAE,MAAkC,EAC7C,WAAW,GAAE,MAAgC,GAC5C,YAAY,EAAE,CAqDhB;AAED;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,KAAK,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,EAClD,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,SAAS,GAAE,MAAkC,GAC5C,YAAY,EAAE,CAiGhB"}
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Linear API Utilities
3
+ *
4
+ * Helper functions for working with Linear API
5
+ */
6
+ import { LINEAR_COMMENT_MAX_LENGTH, TRUNCATION_MARKER, MAX_COMPLETION_COMMENTS, COMMENT_OVERHEAD, CONTINUATION_MARKER, } from './constants';
7
+ /**
8
+ * Truncate a string to a maximum length, adding truncation marker if needed
9
+ */
10
+ export function truncateText(text, maxLength = LINEAR_COMMENT_MAX_LENGTH) {
11
+ if (text.length <= maxLength) {
12
+ return text;
13
+ }
14
+ const truncateAt = maxLength - TRUNCATION_MARKER.length;
15
+ return text.substring(0, truncateAt) + TRUNCATION_MARKER;
16
+ }
17
+ /**
18
+ * Build a completion comment with smart truncation.
19
+ * Prioritizes: summary > plan status > session ID
20
+ *
21
+ * If the full comment exceeds maxLength:
22
+ * 1. First, truncate plan items to show only states
23
+ * 2. If still too long, truncate the summary
24
+ */
25
+ export function buildCompletionComment(summary, planItems, sessionId, maxLength = LINEAR_COMMENT_MAX_LENGTH) {
26
+ const stateEmoji = {
27
+ pending: '\u{2B1C}',
28
+ inProgress: '\u{1F504}',
29
+ completed: '\u{2705}',
30
+ canceled: '\u{274C}',
31
+ };
32
+ // Build static parts
33
+ const header = '## Agent Work Complete\n\n';
34
+ const planHeader = '\n\n### Final Plan Status\n\n';
35
+ const footer = `\n\n---\n*Session ID: ${sessionId ?? 'unknown'}*`;
36
+ // Full plan status
37
+ const fullPlanStatus = planItems
38
+ .map((item) => `${stateEmoji[item.state] ?? '\u{2B1C}'} ${item.title}`)
39
+ .join('\n');
40
+ // Abbreviated plan status (just emoji counts)
41
+ const completedCount = planItems.filter((i) => i.state === 'completed').length;
42
+ const pendingCount = planItems.filter((i) => i.state === 'pending').length;
43
+ const canceledCount = planItems.filter((i) => i.state === 'canceled').length;
44
+ const abbreviatedPlanStatus = [
45
+ `\u{2705} ${completedCount} completed`,
46
+ pendingCount > 0 ? `\u{2B1C} ${pendingCount} pending` : null,
47
+ canceledCount > 0 ? `\u{274C} ${canceledCount} canceled` : null,
48
+ ]
49
+ .filter(Boolean)
50
+ .join(' | ');
51
+ // Try full comment first
52
+ const fullComment = header + summary + planHeader + fullPlanStatus + footer;
53
+ if (fullComment.length <= maxLength) {
54
+ return fullComment;
55
+ }
56
+ // Try with abbreviated plan
57
+ const abbreviatedComment = header + summary + planHeader + abbreviatedPlanStatus + footer;
58
+ if (abbreviatedComment.length <= maxLength) {
59
+ return abbreviatedComment;
60
+ }
61
+ // Need to truncate summary
62
+ const fixedLength = header.length +
63
+ planHeader.length +
64
+ abbreviatedPlanStatus.length +
65
+ footer.length +
66
+ TRUNCATION_MARKER.length;
67
+ const availableForSummary = maxLength - fixedLength;
68
+ if (availableForSummary > 100) {
69
+ // Only truncate if we have reasonable space
70
+ const truncatedSummary = summary.substring(0, availableForSummary) + TRUNCATION_MARKER;
71
+ return header + truncatedSummary + planHeader + abbreviatedPlanStatus + footer;
72
+ }
73
+ // Extreme case: even the fixed parts are too long, just truncate everything
74
+ return truncateText(fullComment, maxLength);
75
+ }
76
+ /**
77
+ * Check if a position is inside a code block
78
+ */
79
+ function isInsideCodeBlock(text, position) {
80
+ let insideCodeBlock = false;
81
+ let i = 0;
82
+ while (i < position && i < text.length) {
83
+ if (text.slice(i, i + 3) === '```') {
84
+ insideCodeBlock = !insideCodeBlock;
85
+ i += 3;
86
+ }
87
+ else {
88
+ i++;
89
+ }
90
+ }
91
+ return insideCodeBlock;
92
+ }
93
+ /**
94
+ * Find a safe split point in text that doesn't break code blocks
95
+ */
96
+ function findSafeSplitPoint(text, targetLength) {
97
+ if (text.length <= targetLength) {
98
+ return text.length;
99
+ }
100
+ // Try to split at paragraph boundary first
101
+ const paragraphBoundary = text.lastIndexOf('\n\n', targetLength);
102
+ if (paragraphBoundary > targetLength * 0.5 && !isInsideCodeBlock(text, paragraphBoundary)) {
103
+ return paragraphBoundary;
104
+ }
105
+ // Try to split at sentence boundary
106
+ const sentenceEnd = text.lastIndexOf('. ', targetLength);
107
+ if (sentenceEnd > targetLength * 0.5 && !isInsideCodeBlock(text, sentenceEnd)) {
108
+ return sentenceEnd + 1; // Include the period
109
+ }
110
+ // Try to split at newline
111
+ const newline = text.lastIndexOf('\n', targetLength);
112
+ if (newline > targetLength * 0.5 && !isInsideCodeBlock(text, newline)) {
113
+ return newline;
114
+ }
115
+ // Try to split at word boundary
116
+ const wordBoundary = text.lastIndexOf(' ', targetLength);
117
+ if (wordBoundary > targetLength * 0.3 && !isInsideCodeBlock(text, wordBoundary)) {
118
+ return wordBoundary;
119
+ }
120
+ // If we're inside a code block, find the end of it
121
+ if (isInsideCodeBlock(text, targetLength)) {
122
+ // Look for code block end after targetLength
123
+ const codeBlockEnd = text.indexOf('```', targetLength);
124
+ if (codeBlockEnd !== -1 && codeBlockEnd < targetLength * 1.5) {
125
+ // Include the closing fence and newline
126
+ const afterFence = text.indexOf('\n', codeBlockEnd + 3);
127
+ return afterFence !== -1 ? afterFence : codeBlockEnd + 3;
128
+ }
129
+ }
130
+ // Last resort: split at target length
131
+ return targetLength;
132
+ }
133
+ /**
134
+ * Split content into multiple comment chunks
135
+ *
136
+ * Splitting strategy:
137
+ * 1. Reserve space for part markers
138
+ * 2. Split at paragraph boundaries first
139
+ * 3. If paragraph too long, split at sentence boundaries
140
+ * 4. If sentence too long, split at word boundaries
141
+ * 5. Never split inside code blocks
142
+ */
143
+ export function splitContentIntoComments(content, maxLength = LINEAR_COMMENT_MAX_LENGTH, maxComments = MAX_COMPLETION_COMMENTS) {
144
+ // Account for overhead (part markers, continuation markers)
145
+ const effectiveMaxLength = maxLength - COMMENT_OVERHEAD;
146
+ if (content.length <= effectiveMaxLength) {
147
+ return [{ body: content, partNumber: 1, totalParts: 1 }];
148
+ }
149
+ const chunks = [];
150
+ let remaining = content;
151
+ while (remaining.length > 0 && chunks.length < maxComments) {
152
+ // Reserve space for continuation marker if not the last chunk
153
+ const reserveForContinuation = remaining.length > effectiveMaxLength
154
+ ? CONTINUATION_MARKER.length
155
+ : 0;
156
+ const chunkMaxLength = effectiveMaxLength - reserveForContinuation;
157
+ if (remaining.length <= chunkMaxLength) {
158
+ chunks.push(remaining);
159
+ remaining = '';
160
+ }
161
+ else {
162
+ const splitPoint = findSafeSplitPoint(remaining, chunkMaxLength);
163
+ const chunk = remaining.slice(0, splitPoint).trimEnd();
164
+ chunks.push(chunk);
165
+ remaining = remaining.slice(splitPoint).trimStart();
166
+ }
167
+ }
168
+ // If we hit max comments and still have content, append truncation to last chunk
169
+ if (remaining.length > 0 && chunks.length > 0) {
170
+ chunks[chunks.length - 1] += TRUNCATION_MARKER;
171
+ }
172
+ const totalParts = chunks.length;
173
+ return chunks.map((chunk, index) => {
174
+ const partNumber = index + 1;
175
+ const isLastPart = partNumber === totalParts;
176
+ // Add part marker for multi-part comments
177
+ let body = chunk;
178
+ if (totalParts > 1) {
179
+ const partMarker = `\n\n---\n*Part ${partNumber}/${totalParts}*`;
180
+ if (!isLastPart) {
181
+ body = chunk + CONTINUATION_MARKER + partMarker;
182
+ }
183
+ else {
184
+ body = chunk + partMarker;
185
+ }
186
+ }
187
+ return { body, partNumber, totalParts };
188
+ });
189
+ }
190
+ /**
191
+ * Build completion comments with smart splitting.
192
+ * Returns multiple comment chunks if content exceeds max length.
193
+ *
194
+ * For backward compatibility, maintains the same header/footer structure
195
+ * as buildCompletionComment, but splits long content across multiple comments.
196
+ */
197
+ export function buildCompletionComments(summary, planItems, sessionId, maxLength = LINEAR_COMMENT_MAX_LENGTH) {
198
+ const stateEmoji = {
199
+ pending: '\u{2B1C}',
200
+ inProgress: '\u{1F504}',
201
+ completed: '\u{2705}',
202
+ canceled: '\u{274C}',
203
+ };
204
+ // Build static parts
205
+ const header = '## Agent Work Complete\n\n';
206
+ const planHeader = '\n\n### Final Plan Status\n\n';
207
+ const footer = `\n\n---\n*Session ID: ${sessionId ?? 'unknown'}*`;
208
+ // Full plan status
209
+ const fullPlanStatus = planItems
210
+ .map((item) => `${stateEmoji[item.state] ?? '\u{2B1C}'} ${item.title}`)
211
+ .join('\n');
212
+ // Abbreviated plan status (just emoji counts)
213
+ const completedCount = planItems.filter((i) => i.state === 'completed').length;
214
+ const pendingCount = planItems.filter((i) => i.state === 'pending').length;
215
+ const canceledCount = planItems.filter((i) => i.state === 'canceled').length;
216
+ const abbreviatedPlanStatus = [
217
+ `\u{2705} ${completedCount} completed`,
218
+ pendingCount > 0 ? `\u{2B1C} ${pendingCount} pending` : null,
219
+ canceledCount > 0 ? `\u{274C} ${canceledCount} canceled` : null,
220
+ ]
221
+ .filter(Boolean)
222
+ .join(' | ');
223
+ // Try full comment first (single comment)
224
+ const fullComment = header + summary + planHeader + fullPlanStatus + footer;
225
+ if (fullComment.length <= maxLength) {
226
+ return [{ body: fullComment, partNumber: 1, totalParts: 1 }];
227
+ }
228
+ // Try with abbreviated plan (still single comment)
229
+ const abbreviatedComment = header + summary + planHeader + abbreviatedPlanStatus + footer;
230
+ if (abbreviatedComment.length <= maxLength) {
231
+ return [{ body: abbreviatedComment, partNumber: 1, totalParts: 1 }];
232
+ }
233
+ // Need to split into multiple comments
234
+ // First comment gets header + beginning of summary
235
+ // Middle comments get summary continuation
236
+ // Last comment gets end of summary + plan status + footer
237
+ const fixedSuffixLength = planHeader.length + abbreviatedPlanStatus.length + footer.length;
238
+ const headerLength = header.length;
239
+ // Split the summary into chunks
240
+ const summaryChunks = splitContentIntoComments(summary, maxLength - COMMENT_OVERHEAD - Math.max(headerLength, fixedSuffixLength), MAX_COMPLETION_COMMENTS);
241
+ // Build final comments
242
+ const result = [];
243
+ const totalParts = summaryChunks.length;
244
+ for (let i = 0; i < summaryChunks.length; i++) {
245
+ const isFirst = i === 0;
246
+ const isLast = i === summaryChunks.length - 1;
247
+ const partNumber = i + 1;
248
+ let body = '';
249
+ if (isFirst) {
250
+ body += header;
251
+ }
252
+ body += summaryChunks[i].body;
253
+ // Remove the part marker from the chunk (we'll add our own)
254
+ if (totalParts > 1) {
255
+ body = body.replace(/\n\n---\n\*Part \d+\/\d+\*$/, '');
256
+ body = body.replace(new RegExp(escapeRegExp(CONTINUATION_MARKER), 'g'), '');
257
+ }
258
+ if (isLast) {
259
+ body += planHeader + abbreviatedPlanStatus + footer;
260
+ }
261
+ // Add part marker for multi-part comments
262
+ if (totalParts > 1) {
263
+ if (!isLast) {
264
+ body += CONTINUATION_MARKER;
265
+ }
266
+ body += `\n\n---\n*Part ${partNumber}/${totalParts}*`;
267
+ }
268
+ result.push({ body, partNumber, totalParts });
269
+ }
270
+ return result;
271
+ }
272
+ /**
273
+ * Escape special regex characters in a string
274
+ */
275
+ function escapeRegExp(string) {
276
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
277
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@supaku/agentfactory-linear",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Linear issue tracker integration for AgentFactory — status transitions, agent sessions, work routing",
6
+ "author": "Supaku (https://supaku.com)",
7
+ "license": "MIT",
8
+ "engines": {
9
+ "node": ">=22.0.0"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://github.com/supaku/agentfactory",
14
+ "directory": "packages/linear"
15
+ },
16
+ "homepage": "https://github.com/supaku/agentfactory/tree/main/packages/linear",
17
+ "bugs": {
18
+ "url": "https://github.com/supaku/agentfactory/issues"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "keywords": [
24
+ "linear",
25
+ "issue-tracker",
26
+ "agent",
27
+ "workflow",
28
+ "automation"
29
+ ],
30
+ "main": "./dist/src/index.js",
31
+ "module": "./dist/src/index.js",
32
+ "types": "./dist/src/index.d.ts",
33
+ "exports": {
34
+ ".": {
35
+ "types": "./dist/src/index.d.ts",
36
+ "import": "./dist/src/index.js"
37
+ }
38
+ },
39
+ "files": [
40
+ "dist",
41
+ "README.md",
42
+ "LICENSE"
43
+ ],
44
+ "dependencies": {
45
+ "@linear/sdk": "^70.0.0"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^22.5.4",
49
+ "typescript": "^5.7.3",
50
+ "vitest": "^3.2.3"
51
+ },
52
+ "scripts": {
53
+ "build": "tsc",
54
+ "typecheck": "tsc --noEmit",
55
+ "test": "vitest run",
56
+ "test:watch": "vitest",
57
+ "clean": "rm -rf dist"
58
+ }
59
+ }