xyne-plugin-spaces 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.
package/bun.lock ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "lockfileVersion": 1,
3
+ "workspaces": {
4
+ "": {
5
+ "name": "xyne-spaces",
6
+ "dependencies": {
7
+ "xyne-plugin": "^1.2.0",
8
+ },
9
+ "devDependencies": {
10
+ "typescript": "^5.9.3",
11
+ },
12
+ },
13
+ },
14
+ "packages": {
15
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
16
+
17
+ "xyne-plugin": ["xyne-plugin@1.2.0", "", { "dependencies": { "zod": "^3.25.0" } }, "sha512-ecJJFMka/KvOlhF28CcRVfgmfHv6kE8JIn3cUlUCQ39b2j+9asM+/z5TOGkGHOXqSbtd5hl36zgtgvq8EA6h7A=="],
18
+
19
+ "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
20
+ }
21
+ }
package/package.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "xyne-plugin-spaces",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "scripts": {
7
+ "typecheck": "tsc --noEmit"
8
+ },
9
+ "dependencies": {
10
+ "xyne-plugin": "^1.2.0"
11
+ },
12
+ "devDependencies": {
13
+ "typescript": "^5.9.3"
14
+ }
15
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Spaces auth — custom auth via localhost Electron server.
3
+ *
4
+ * Flow: POST /auth/request → Electron shows consent dialog → token returned.
5
+ * Token is stored in memory only — user re-approves each Xyne session.
6
+ */
7
+
8
+ import type { ToolAuthConfig, ServiceCredentials } from "xyne-plugin"
9
+
10
+ const BASE = "http://127.0.0.1:49231"
11
+
12
+ // In-memory only — no disk persistence
13
+ let _cached: ServiceCredentials | undefined
14
+
15
+ export const spacesAuth: ToolAuthConfig = {
16
+ serviceID: "spaces",
17
+ label: "Spaces",
18
+ storage: {
19
+ async get() { return _cached },
20
+ async set(entry) { _cached = entry },
21
+ async remove() { _cached = undefined },
22
+ },
23
+ methods: [
24
+ {
25
+ type: "custom" as const,
26
+ label: "Connect to Spaces",
27
+ pendingMessage: "Approve the connection in the Spaces desktop app",
28
+ pendingHint: "Waiting for approval... · esc cancel",
29
+ async authorize() {
30
+ const response = await fetch(`${BASE}/auth/request`, {
31
+ method: "POST",
32
+ headers: { "Content-Type": "application/json" },
33
+ body: JSON.stringify({
34
+ agentName: "Xyne",
35
+ agentType: "cli",
36
+ description: "Xyne CLI agent requesting access to search and memory",
37
+ }),
38
+ })
39
+
40
+ if (!response.ok) {
41
+ return { type: "failed" as const }
42
+ }
43
+
44
+ const data = (await response.json()) as {
45
+ status: string
46
+ accessToken?: string
47
+ expiresAt?: number
48
+ }
49
+
50
+ if (data.status !== "approved" || !data.accessToken) {
51
+ return { type: "failed" as const }
52
+ }
53
+
54
+ return {
55
+ type: "success" as const,
56
+ data: {
57
+ accessToken: data.accessToken,
58
+ expiresAt: data.expiresAt ?? Date.now() + 5 * 60 * 1000,
59
+ },
60
+ }
61
+ },
62
+ },
63
+ ],
64
+
65
+ async refresh(entry: ServiceCredentials) {
66
+ if (entry.type !== "custom") throw new Error("Expected custom credentials")
67
+ const expiresAt = entry.data["expiresAt"] as number | undefined
68
+ if (expiresAt && Date.now() < expiresAt) return entry
69
+ throw new Error("Token expired")
70
+ },
71
+ }
72
+
73
+ const log = (msg: string) => {
74
+ try { require("fs").appendFileSync("/tmp/tui-spaces-debug.log", `[${new Date().toISOString()}] ${msg}\n`) } catch {}
75
+ }
76
+
77
+ /** Make an authenticated request to the Spaces server. */
78
+ export async function spacesFetch(
79
+ path: string,
80
+ accessToken: string,
81
+ init?: RequestInit,
82
+ ): Promise<unknown> {
83
+ const url = `${BASE}${path}`
84
+ const method = init?.method ?? "GET"
85
+ log(`${method} ${path}${init?.body ? ` body=${String(init.body).slice(0, 500)}` : ""}`)
86
+
87
+ const response = await fetch(url, {
88
+ ...init,
89
+ headers: {
90
+ Authorization: `Bearer ${accessToken}`,
91
+ "Content-Type": "application/json",
92
+ ...(init?.headers as Record<string, string> | undefined),
93
+ },
94
+ })
95
+
96
+ if (!response.ok) {
97
+ const text = await response.text()
98
+ log(`ERROR ${response.status}: ${text.slice(0, 500)}`)
99
+ throw new Error(`Spaces API ${response.status}: ${text}`)
100
+ }
101
+
102
+ const data = await response.json()
103
+ log(`OK ${response.status} (${JSON.stringify(data).length} bytes)`)
104
+ return data
105
+ }
package/src/index.ts ADDED
@@ -0,0 +1,371 @@
1
+ import { type Plugin, tool, z } from "xyne-plugin"
2
+ import { spacesAuth, spacesFetch } from "./auth"
3
+ import { queryTickets, queryMessages, queryMessageDetail, queryChannels, queryUsers, queryUserActivity } from "./query"
4
+
5
+ function getToken(ctx: { credentials?: { type: string; data?: Record<string, unknown> } }): string {
6
+ const creds = ctx.credentials
7
+ if (!creds || creds.type !== "custom") throw new Error("Expected Spaces credentials")
8
+ return creds.data!["accessToken"] as string
9
+ }
10
+
11
+ // ── Vespa Search ─────────────────────────────────────────────────────
12
+
13
+ const spacesSearch = tool({
14
+ description:
15
+ "Search across all connected apps in Spaces — messages, tickets, files, channels, users. " +
16
+ "Supports filters like app, type, date range, priority, status, tags, and more.",
17
+ auth: spacesAuth,
18
+ args: {
19
+ query: z.string().describe("Search query text. Can be empty if filterOnly is true."),
20
+ apps: z.string().optional().describe("Comma-separated apps to search: chat, ticket, user, file (default: all)"),
21
+ type: z.string().optional().describe("Filter by type: messages, attachments, channels, tickets, files"),
22
+ from: z.string().optional().describe("Filter by sender user ID(s), comma-separated"),
23
+ in: z.string().optional().describe("Filter by channel ID(s), comma-separated"),
24
+ status: z.string().optional().describe("Filter by ticket status(es), comma-separated"),
25
+ priority: z.enum(["HIGH", "MEDIUM", "LOW", "CRITICAL"]).optional().describe("Filter by ticket priority"),
26
+ board: z.string().optional().describe("Filter by board name"),
27
+ tags: z.string().optional().describe("Filter by tags, comma-separated"),
28
+ stage: z.string().optional().describe("Filter by ticket stage"),
29
+ assignee: z.string().optional().describe("Filter by assigned user ID"),
30
+ before: z.string().optional().describe("Created before date (e.g. '15 Mar 26' or ISO format)"),
31
+ after: z.string().optional().describe("Created after date"),
32
+ range: z.string().optional().describe("Time range: today, yesterday, this week, last 7 days, last 30 days"),
33
+ filterOnly: z.boolean().optional().describe("Set true to search with filters only, no query text required"),
34
+ limit: z.number().min(1).max(50).default(10).describe("Max results per group (default 10)"),
35
+ offset: z.number().min(0).default(0).describe("Pagination offset (default 0)"),
36
+ },
37
+ async execute(args, ctx) {
38
+ const token = getToken(ctx)
39
+ const params = new URLSearchParams()
40
+ params.set("q", args.query)
41
+ params.set("limit", String(args.limit))
42
+ if (args.offset > 0) params.set("offset", String(args.offset))
43
+ if (args.apps) params.set("apps", args.apps)
44
+ if (args.type) params.set("type", args.type)
45
+ if (args.from) params.set("from", args.from)
46
+ if (args.in) params.set("in", args.in)
47
+ if (args.status) params.set("status", args.status)
48
+ if (args.priority) params.set("priority", args.priority)
49
+ if (args.board) params.set("board", args.board)
50
+ if (args.tags) params.set("tags", args.tags)
51
+ if (args.stage) params.set("stage", args.stage)
52
+ if (args.assignee) params.set("assignee", args.assignee)
53
+ if (args.before) params.set("before", args.before)
54
+ if (args.after) params.set("after", args.after)
55
+ if (args.range) params.set("range", args.range)
56
+ if (args.filterOnly) params.set("filterOnly", "true")
57
+
58
+ const data = (await spacesFetch(`/search?${params}`, token)) as {
59
+ success: boolean
60
+ data?: {
61
+ grouped: boolean
62
+ groups?: Array<{ groupValue: string; count: number; results: Array<SearchResult> }>
63
+ results?: SearchResult[]
64
+ totalCount?: number
65
+ }
66
+ }
67
+
68
+ if (!data.success || !data.data) return "Search failed."
69
+
70
+ if (data.data.grouped && data.data.groups) {
71
+ const groups = data.data.groups
72
+ if (groups.length === 0) return `No results found for "${args.query}".`
73
+ const parts: string[] = []
74
+ for (const group of groups) {
75
+ parts.push(`--- ${group.groupValue} (${group.count}) ---`)
76
+ for (const r of group.results) parts.push(formatSearchResult(r))
77
+ parts.push("")
78
+ }
79
+ return parts.join("\n")
80
+ }
81
+
82
+ const results = data.data.results ?? []
83
+ if (results.length === 0) return `No results found for "${args.query}".`
84
+ return `Found ${data.data.totalCount ?? results.length} result(s):\n\n${results.map(formatSearchResult).join("\n\n")}`
85
+ },
86
+ })
87
+
88
+ interface SearchResult {
89
+ id: string; type: string; title: string; subtitle?: string; context?: string
90
+ metadata?: Record<string, unknown>; searchContext?: Record<string, unknown>
91
+ }
92
+
93
+ function formatSearchResult(r: SearchResult): string {
94
+ const lines = [`[${r.type}] ${r.title}${r.subtitle ? ` — ${r.subtitle}` : ""}`]
95
+ if (r.context) lines.push(` ${r.context.replace(/<\/?[^>]+>/g, "").slice(0, 300)}`)
96
+ const meta = r.metadata
97
+ if (meta) {
98
+ const p: string[] = []
99
+ if (meta["timestamp"]) p.push(`${meta["timestamp"]}`)
100
+ if (meta["channelName"]) p.push(`#${meta["channelName"]}`)
101
+ if (meta["status"]) p.push(`status: ${meta["status"]}`)
102
+ if (p.length > 0) lines.push(` ${p.join(" · ")}`)
103
+ }
104
+ const sc = r.searchContext
105
+ if (sc) {
106
+ if (sc["senderName"]) lines.push(` From: ${sc["senderName"]}`)
107
+ if (sc["xyneId"]) lines.push(` ID: ${sc["xyneId"]}`)
108
+ }
109
+ return lines.join("\n")
110
+ }
111
+
112
+ // ── Memory Search ────────────────────────────────────────────────────
113
+
114
+ const memorySearch = tool({
115
+ description: "Search Spaces memory — facts, SOPs, and knowledge base entries from past sessions.",
116
+ auth: spacesAuth,
117
+ args: {
118
+ query: z.string().optional().describe("Search query (leave empty to list recent)"),
119
+ scope: z.enum(["my", "all"]).default("my").describe("'my' for your items, 'all' for team-wide"),
120
+ limit: z.number().min(1).max(50).default(10).describe("Max results"),
121
+ offset: z.number().min(0).default(0).describe("Pagination offset"),
122
+ docType: z.enum(["fact", "sop"]).optional().describe("Filter by type"),
123
+ tags: z.array(z.string()).optional().describe("Filter by tags"),
124
+ reviewStatus: z.enum(["pending", "verified", "rejected"]).optional().describe("Filter by review status"),
125
+ },
126
+ async execute(args, ctx) {
127
+ const token = getToken(ctx)
128
+ const body: Record<string, unknown> = { scope: args.scope, limit: args.limit, offset: args.offset, includeSummary: true, includeQuery: true }
129
+ if (args.query) body["query"] = args.query
130
+ if (args.docType) body["docType"] = args.docType
131
+ if (args.tags) body["tags"] = args.tags
132
+ if (args.reviewStatus) body["reviewStatus"] = args.reviewStatus
133
+
134
+ const data = (await spacesFetch("/memory/search", token, { method: "POST", body: JSON.stringify(body) })) as {
135
+ success?: boolean; data?: { documents?: Array<{ docId: string; docType: string; userQuery?: string; chatSummary?: string[]; rawContent?: string; tags?: string[]; reviewStatus?: string; createdAt?: number }>; pagination?: { total: number } }
136
+ }
137
+
138
+ const docs = data.data?.documents ?? []
139
+ if (docs.length === 0) return args.query ? `No memory results for "${args.query}".` : "No memory entries found."
140
+ const parts = docs.map((d) => {
141
+ const lines = [`[${d.docType}] ${d.docId}`]
142
+ if (d.userQuery) lines.push(` Query: ${d.userQuery}`)
143
+ if (d.chatSummary?.length) lines.push(` Summary: ${d.chatSummary.join(" ")}`)
144
+ else if (d.rawContent) lines.push(` ${d.rawContent.slice(0, 300)}${d.rawContent.length > 300 ? "..." : ""}`)
145
+ if (d.tags?.length) lines.push(` Tags: ${d.tags.join(", ")}`)
146
+ if (d.reviewStatus) lines.push(` Review: ${d.reviewStatus}`)
147
+ return lines.join("\n")
148
+ })
149
+ return `Found ${data.data?.pagination?.total ?? docs.length} result(s):\n\n${parts.join("\n\n")}`
150
+ },
151
+ })
152
+
153
+ // ── Tickets ──────────────────────────────────────────────────────────
154
+
155
+ const spacesTickets = tool({
156
+ description:
157
+ "List and filter tickets in Spaces. Filter by status, priority, assignee, board, project, or stage. " +
158
+ "Returns ticket details including assignee, tags, stage, and conversation ID.",
159
+ auth: spacesAuth,
160
+ args: {
161
+ status: z.enum(["TODO", "STARTED", "PAUSED", "CANCELLED", "COMPLETED"]).optional().describe("Filter by status"),
162
+ priority: z.enum(["LOW", "MEDIUM", "HIGH", "CRITICAL"]).optional().describe("Filter by priority"),
163
+ assignedTo: z.string().optional().describe("Filter by assigned user's ID (use spaces-users to find user IDs)"),
164
+ createdBy: z.string().optional().describe("Filter by ticket creator's user ID"),
165
+ boardId: z.string().optional().describe("Filter by board ID (use spaces-search to find board IDs)"),
166
+ projectId: z.string().optional().describe("Filter by project ID (use spaces-search to find project IDs)"),
167
+ stageName: z.string().optional().describe("Filter by stage name"),
168
+ limit: z.number().min(1).max(50).default(20).describe("Max tickets (default 20)"),
169
+ offset: z.number().min(0).default(0).describe("Pagination offset"),
170
+ },
171
+ async execute(args, ctx) {
172
+ const token = getToken(ctx)
173
+ return queryTickets(token, args)
174
+ },
175
+ })
176
+
177
+ // ── Messages ─────────────────────────────────────────────────────────
178
+
179
+ const spacesMessages = tool({
180
+ description:
181
+ "Read messages in a conversation thread. IMPORTANT: Use the conversationId field from spaces-tickets results (NOT the channel ID or ticket ID). " +
182
+ "Messages are returned in chronological order.",
183
+ auth: spacesAuth,
184
+ args: {
185
+ conversationId: z.string().describe("The conversationId field from spaces-tickets or spaces-activity results. This is NOT a channelId, ticketId, or messageId."),
186
+ limit: z.number().min(1).max(100).default(30).describe("Max messages (default 30)"),
187
+ offset: z.number().min(0).default(0).describe("Pagination offset"),
188
+ },
189
+ async execute(args, ctx) {
190
+ const token = getToken(ctx)
191
+ return queryMessages(token, args.conversationId, args.limit, args.offset)
192
+ },
193
+ })
194
+
195
+ // ── Message Detail ───────────────────────────────────────────────────
196
+
197
+ const spacesMessageDetail = tool({
198
+ description:
199
+ "Get detailed information about a specific message including full content, sender details, " +
200
+ "reactions (with counts), and attachments. Use messageId from spaces-messages or spaces-activity results.",
201
+ auth: spacesAuth,
202
+ args: {
203
+ messageId: z.string().describe("The messageId field from spaces-messages or spaces-activity results. This is NOT a conversationId, channelId, or ticketId."),
204
+ },
205
+ async execute(args, ctx) {
206
+ const token = getToken(ctx)
207
+ return queryMessageDetail(token, args.messageId)
208
+ },
209
+ })
210
+
211
+ // ── Channels ─────────────────────────────────────────────────────────
212
+
213
+ const spacesChannels = tool({
214
+ description:
215
+ "List channels in Spaces. Can filter by visibility (PUBLIC/PRIVATE) and scope type (DEFAULT/DM/TICKET/GROUP_DM). " +
216
+ "Returns channel name, participant count, project, and last activity.",
217
+ auth: spacesAuth,
218
+ args: {
219
+ visibility: z.enum(["PUBLIC", "PRIVATE"]).optional().describe("Filter by visibility"),
220
+ scopeType: z.enum(["DEFAULT", "DM", "TICKET", "DOCUMENT", "GROUP_DM"]).optional().describe("Filter by scope type"),
221
+ limit: z.number().min(1).max(50).default(20).describe("Max channels (default 20)"),
222
+ },
223
+ async execute(args, ctx) {
224
+ const token = getToken(ctx)
225
+ return queryChannels(token, args.limit, args.visibility, args.scopeType)
226
+ },
227
+ })
228
+
229
+ // ── Users ────────────────────────────────────────────────────────────
230
+
231
+ const spacesUsers = tool({
232
+ description: "Look up users by name or email. Returns user ID, name, email, and type.",
233
+ auth: spacesAuth,
234
+ args: {
235
+ nameOrEmail: z.string().describe("Person's name to search by name, or email address (with @ or .) to search by email"),
236
+ limit: z.number().min(1).max(20).default(10).describe("Max results (default 10)"),
237
+ },
238
+ async execute(args, ctx) {
239
+ const token = getToken(ctx)
240
+ return queryUsers(token, args.nameOrEmail, args.limit)
241
+ },
242
+ })
243
+
244
+ // ── User Activity ────────────────────────────────────────────────────
245
+
246
+ const spacesUserActivity = tool({
247
+ description:
248
+ "Get your activity feed — mentions, replies, assignments, and notifications. " +
249
+ "Returns messageId, conversationId, ticketId for each activity. " +
250
+ "Use conversationId with spaces-messages to read the full thread, or messageId with spaces-message-detail.",
251
+ auth: spacesAuth,
252
+ args: {
253
+ classification: z.string().optional().describe("Filter by classification (e.g. 'PENDING')"),
254
+ unreadOnly: z.boolean().optional().describe("Show only unread activity"),
255
+ limit: z.number().min(1).max(50).default(20).describe("Max entries (default 20)"),
256
+ },
257
+ async execute(args, ctx) {
258
+ const token = getToken(ctx)
259
+ return queryUserActivity(token, args.classification, args.unreadOnly === true ? false : undefined, args.limit)
260
+ },
261
+ })
262
+
263
+ // ── Web Fetch ────────────────────────────────────────────────────
264
+
265
+ const spacesWebFetch = tool({
266
+ description:
267
+ "Fetch an external URL and return its content as markdown or text. " +
268
+ "Only use for URLs outside Xyne Spaces (e.g. GitHub PRs, docs, external links from messages). " +
269
+ "Do NOT use for Xyne Spaces internal URLs — use the other spaces tools instead.",
270
+ auth: spacesAuth,
271
+ args: {
272
+ url: z.string().describe("The URL to fetch"),
273
+ format: z.enum(["text", "markdown", "html"]).default("markdown").describe("Output format (default: markdown)"),
274
+ },
275
+ async execute(args) {
276
+ if (!args.url.startsWith("http://") && !args.url.startsWith("https://")) {
277
+ throw new Error("URL must start with http:// or https://")
278
+ }
279
+
280
+ const controller = new AbortController()
281
+ const timer = setTimeout(() => controller.abort(), 30_000)
282
+
283
+ try {
284
+ const response = await fetch(args.url, {
285
+ signal: controller.signal,
286
+ headers: {
287
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
288
+ "Accept": "text/html;q=1.0, text/plain;q=0.8, */*;q=0.1",
289
+ },
290
+ redirect: "follow",
291
+ })
292
+ clearTimeout(timer)
293
+
294
+ if (!response.ok) throw new Error(`Fetch failed: ${response.status} ${response.statusText}`)
295
+
296
+ const contentType = response.headers.get("content-type") ?? ""
297
+ let text = await response.text()
298
+
299
+ if (contentType.includes("html") && args.format !== "html") {
300
+ // Strip scripts, styles, and tags
301
+ text = text
302
+ .replace(/<script[\s\S]*?<\/script>/gi, "")
303
+ .replace(/<style[\s\S]*?<\/style>/gi, "")
304
+ .replace(/<noscript[\s\S]*?<\/noscript>/gi, "")
305
+ .replace(/<[^>]+>/g, " ")
306
+ .replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">")
307
+ .replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, " ")
308
+ .replace(/\s+/g, " ")
309
+ .trim()
310
+ }
311
+
312
+ if (text.length > 15000) text = text.slice(0, 15000) + "\n\n... (truncated)"
313
+ return text
314
+ } finally {
315
+ clearTimeout(timer)
316
+ }
317
+ },
318
+ })
319
+
320
+ // ── Plugin ───────────────────────────────────────────────────────────
321
+
322
+ const spacesTools = {
323
+ "spaces-search": spacesSearch,
324
+ "spaces-memory-search": memorySearch,
325
+ "spaces-tickets": spacesTickets,
326
+ "spaces-messages": spacesMessages,
327
+ "spaces-message-detail": spacesMessageDetail,
328
+ "spaces-channels": spacesChannels,
329
+ "spaces-users": spacesUsers,
330
+ "spaces-activity": spacesUserActivity,
331
+ "webfetch": spacesWebFetch,
332
+ }
333
+
334
+ const plugin: Plugin = async (_input) => ({
335
+ agent: {
336
+ spaces: {
337
+ description:
338
+ "Use this agent for any query about Xyne Spaces — tickets, messages, conversations, users, channels, or activity. " +
339
+ "Collects information and context from the Xyne Spaces app.",
340
+ mode: "all",
341
+ tools: spacesTools,
342
+ isolatedTools: true,
343
+ system: [
344
+ "You are a Spaces assistant with access to the user's Spaces workspace.",
345
+ "",
346
+ "## Capabilities",
347
+ "- **Search**: Full-text search across messages, tickets, files, channels, users",
348
+ "- **Memory**: Search facts, SOPs, and knowledge from past sessions",
349
+ "- **Tickets**: List/filter tickets by status, priority, assignee, board, stage",
350
+ "- **Messages**: Read conversation threads linked to tickets or channels",
351
+ "- **Message Detail**: Get full message with reactions and attachments",
352
+ "- **Channels**: Browse channels by visibility and scope",
353
+ "- **Users**: Look up users by name or email",
354
+ "- **Activity**: View user activity feed (mentions, replies, assignments)",
355
+ "",
356
+ "## Guidelines",
357
+ "- Use spaces-search for broad queries across all connected apps",
358
+ "- Use spaces-tickets for structured ticket queries with specific filters",
359
+ "- To read a ticket's discussion: first use spaces-tickets to get the conversationId, then use spaces-messages with that conversationId (NOT the channelId or ticket ID)",
360
+ "- Use spaces-users to resolve user names to IDs before filtering tickets",
361
+ "- Priority values: LOW, MEDIUM, HIGH, CRITICAL",
362
+ "- Status values: TODO, STARTED, PAUSED, CANCELLED, COMPLETED",
363
+ "- Date range shortcuts for search: today, yesterday, this week, last 7 days, last 30 days",
364
+ "- Be concise — summarize results, don't dump raw data",
365
+ ].join("\n"),
366
+ color: "#ef4444",
367
+ },
368
+ },
369
+ })
370
+
371
+ export default plugin
package/src/query.ts ADDED
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Spaces query helpers — build Prisma ASTs and call /interact endpoint.
3
+ */
4
+
5
+ import { spacesFetch } from "./auth"
6
+
7
+ interface QueryAST {
8
+ model: string
9
+ operation: "findMany" | "count"
10
+ where?: Record<string, unknown>
11
+ orderBy?: Record<string, string> | Array<Record<string, string>>
12
+ take?: number
13
+ skip?: number
14
+ include?: Record<string, unknown>
15
+ select?: Record<string, unknown>
16
+ }
17
+
18
+ export async function query(token: string, ast: QueryAST): Promise<unknown> {
19
+ const result = (await spacesFetch("/interact", token, {
20
+ method: "POST",
21
+ body: JSON.stringify(ast),
22
+ })) as { data: unknown }
23
+ return result.data
24
+ }
25
+
26
+ // ── Tickets ──────────────────────────────────────────────────────────
27
+
28
+ interface TicketFilters {
29
+ status?: string | undefined
30
+ priority?: string | undefined
31
+ assignedTo?: string | undefined
32
+ createdBy?: string | undefined
33
+ boardId?: string | undefined
34
+ projectId?: string | undefined
35
+ stageName?: string | undefined
36
+ limit?: number | undefined
37
+ offset?: number | undefined
38
+ }
39
+
40
+ interface TicketRow {
41
+ id: string
42
+ title: string
43
+ xyneId: string
44
+ statusV2: string
45
+ priority: string
46
+ stageName?: string
47
+ eta?: string
48
+ createdAt: string
49
+ updatedAt: string
50
+ conversationId?: string
51
+ assignedToUser?: { name: string } | null
52
+ createdByUser?: { name: string } | null
53
+ board?: { name: string } | null
54
+ project?: { name: string } | null
55
+ tags?: Array<{ name: string }>
56
+ }
57
+
58
+ export async function queryTickets(token: string, filters: TicketFilters): Promise<string> {
59
+ const where: Record<string, unknown> = {}
60
+ if (filters.status) where["statusV2"] = { equals: filters.status }
61
+ if (filters.priority) where["priority"] = { equals: filters.priority }
62
+ if (filters.assignedTo) where["assignedTo"] = { equals: filters.assignedTo }
63
+ if (filters.createdBy) where["createdBy"] = { equals: filters.createdBy }
64
+ if (filters.boardId) where["boardId"] = { equals: filters.boardId }
65
+ if (filters.projectId) where["projectId"] = { equals: filters.projectId }
66
+ if (filters.stageName) where["stageName"] = { equals: filters.stageName }
67
+
68
+ const rows = (await query(token, {
69
+ model: "ticket",
70
+ operation: "findMany",
71
+ where,
72
+ orderBy: [{ updatedAt: "desc" }],
73
+ take: filters.limit ?? 20,
74
+ skip: filters.offset ?? 0,
75
+ include: {
76
+ assignedToUser: { select: { name: true } },
77
+ createdByUser: { select: { name: true } },
78
+ board: { select: { name: true } },
79
+ project: { select: { name: true } },
80
+ tags: { select: { name: true } },
81
+ },
82
+ })) as TicketRow[]
83
+
84
+ if (rows.length === 0) return "No tickets found."
85
+
86
+ const lines = rows.map((t) => {
87
+ const parts = [`[${t.xyneId}] ${t.title}`]
88
+ parts.push(` Status: ${t.statusV2} · Priority: ${t.priority}${t.stageName ? ` · Stage: ${t.stageName}` : ""}`)
89
+ if (t.assignedToUser) parts.push(` Assigned: ${t.assignedToUser.name}`)
90
+ if (t.createdByUser) parts.push(` Created by: ${t.createdByUser.name}`)
91
+ if (t.board) parts.push(` Board: ${t.board.name}${t.project ? ` · Project: ${t.project.name}` : ""}`)
92
+ if (t.tags && t.tags.length > 0) parts.push(` Tags: ${t.tags.map((tg) => tg.name).join(", ")}`)
93
+ if (t.eta) parts.push(` ETA: ${new Date(t.eta).toLocaleDateString()}`)
94
+ if (t.conversationId) parts.push(` ConversationID: ${t.conversationId}`)
95
+ parts.push(` Updated: ${new Date(t.updatedAt).toLocaleString()}`)
96
+ return parts.join("\n")
97
+ })
98
+
99
+ return `${rows.length} ticket(s):\n\n${lines.join("\n\n")}`
100
+ }
101
+
102
+ // ── Messages ─────────────────────────────────────────────────────────
103
+
104
+ interface MessageRow {
105
+ messageId: string
106
+ content: string
107
+ msgType: string
108
+ createdAt: string
109
+ hasAttachment: boolean
110
+ sender?: { name: string } | null
111
+ }
112
+
113
+ export async function queryMessages(token: string, conversationId: string, limit: number, offset: number): Promise<string> {
114
+ const rows = (await query(token, {
115
+ model: "message",
116
+ operation: "findMany",
117
+ where: {
118
+ conversationId: { equals: conversationId },
119
+ isDeleted: { equals: false },
120
+ },
121
+ orderBy: [{ createdAt: "asc" }],
122
+ take: limit,
123
+ skip: offset,
124
+ include: {
125
+ sender: { select: { name: true } },
126
+ },
127
+ })) as MessageRow[]
128
+
129
+ if (rows.length === 0) return `No messages found in conversation ${conversationId}.`
130
+
131
+ const lines = rows.map((m) => {
132
+ const sender = m.sender?.name ?? "unknown"
133
+ const time = new Date(m.createdAt).toLocaleString()
134
+ const attach = m.hasAttachment ? " 📎" : ""
135
+ return `[${time}] ${sender}${attach}: ${m.content}`
136
+ })
137
+
138
+ return `${rows.length} message(s):\n\n${lines.join("\n")}`
139
+ }
140
+
141
+ // ── Message Detail ───────────────────────────────────────────────────
142
+
143
+ interface MessageDetailRow {
144
+ messageId: string
145
+ content: string
146
+ msgType: string
147
+ createdAt: string
148
+ edited: boolean
149
+ hasAttachment: boolean
150
+ sender?: { name: string; email: string } | null
151
+ reactions?: Array<{ emojiName: string; userId: string }>
152
+ reactionCounts?: Array<{ emojiName: string; count: number }>
153
+ }
154
+
155
+ interface AttachmentRow {
156
+ id: string
157
+ originalFilename: string
158
+ mimetype: string
159
+ size: number
160
+ url: string
161
+ }
162
+
163
+ export async function queryMessageDetail(token: string, messageId: string): Promise<string> {
164
+ const rows = (await query(token, {
165
+ model: "message",
166
+ operation: "findMany",
167
+ where: { messageId: { equals: messageId } },
168
+ take: 1,
169
+ include: {
170
+ sender: { select: { name: true, email: true } },
171
+ reactions: { select: { emojiName: true, userId: true } },
172
+ reactionCounts: { select: { emojiName: true, count: true } },
173
+ },
174
+ })) as MessageDetailRow[]
175
+
176
+ if (rows.length === 0) return `Message ${messageId} not found.`
177
+ const m = rows[0]!
178
+
179
+ const parts = [
180
+ `Message: ${m.messageId}`,
181
+ `From: ${m.sender?.name ?? "unknown"} (${m.sender?.email ?? ""})`,
182
+ `Type: ${m.msgType}${m.edited ? " (edited)" : ""}`,
183
+ `Date: ${new Date(m.createdAt).toLocaleString()}`,
184
+ `\n${m.content}`,
185
+ ]
186
+
187
+ // Reactions
188
+ if (m.reactionCounts && m.reactionCounts.length > 0) {
189
+ const rxns = m.reactionCounts.map((r) => `${r.emojiName} ×${r.count}`).join(" ")
190
+ parts.push(`\nReactions: ${rxns}`)
191
+ }
192
+
193
+ // Attachments
194
+ if (m.hasAttachment) {
195
+ const attachments = (await query(token, {
196
+ model: "messageAttachment",
197
+ operation: "findMany",
198
+ where: { entityId: { equals: messageId } },
199
+ take: 20,
200
+ })) as AttachmentRow[]
201
+
202
+ if (attachments.length > 0) {
203
+ parts.push(`\nAttachments (${attachments.length}):`)
204
+ for (const a of attachments) {
205
+ const size = a.size < 1024 ? `${a.size} B` : a.size < 1024 * 1024 ? `${(a.size / 1024).toFixed(1)} KB` : `${(a.size / (1024 * 1024)).toFixed(1)} MB`
206
+ parts.push(` - ${a.originalFilename} (${a.mimetype}, ${size})`)
207
+ }
208
+ }
209
+ }
210
+
211
+ return parts.join("\n")
212
+ }
213
+
214
+ // ── Channels ─────────────────────────────────────────────────────────
215
+
216
+ interface ChannelRow {
217
+ id: string
218
+ name: string
219
+ description?: string
220
+ type: string
221
+ scopeType: string
222
+ visibility: string
223
+ participantCount: number
224
+ lastActivityAt?: string
225
+ project?: { name: string } | null
226
+ }
227
+
228
+ export async function queryChannels(token: string, limit: number, visibility?: string, scopeType?: string): Promise<string> {
229
+ const where: Record<string, unknown> = {}
230
+ if (visibility) where["visibility"] = { equals: visibility }
231
+ if (scopeType) where["scopeType"] = { equals: scopeType }
232
+
233
+ const rows = (await query(token, {
234
+ model: "channel",
235
+ operation: "findMany",
236
+ where,
237
+ orderBy: [{ lastActivityAt: "desc" }],
238
+ take: limit,
239
+ include: {
240
+ project: { select: { name: true } },
241
+ },
242
+ })) as ChannelRow[]
243
+
244
+ if (rows.length === 0) return "No channels found."
245
+
246
+ const lines = rows.map((c) => {
247
+ const parts = [`#${c.name} (${c.scopeType}, ${c.visibility})`]
248
+ if (c.description) parts.push(` ${c.description}`)
249
+ parts.push(` Participants: ${c.participantCount}${c.project ? ` · Project: ${c.project.name}` : ""}`)
250
+ if (c.lastActivityAt) parts.push(` Last active: ${new Date(c.lastActivityAt).toLocaleString()}`)
251
+ parts.push(` ID: ${c.id}`)
252
+ return parts.join("\n")
253
+ })
254
+
255
+ return `${rows.length} channel(s):\n\n${lines.join("\n\n")}`
256
+ }
257
+
258
+ // ── Users ────────────────────────────────────────────────────────────
259
+
260
+ interface UserRow {
261
+ id: string
262
+ name: string
263
+ email: string
264
+ status: string
265
+ userType: string
266
+ picture?: string
267
+ }
268
+
269
+ export async function queryUsers(token: string, nameOrEmail: string, limit: number): Promise<string> {
270
+ // OR not supported by query validator — search by name or email based on input
271
+ const isEmail = nameOrEmail.includes("@") || nameOrEmail.includes(".")
272
+ const where = isEmail
273
+ ? { email: { contains: nameOrEmail }, status: { equals: "ACTIVE" } }
274
+ : { name: { contains: nameOrEmail }, status: { equals: "ACTIVE" } }
275
+
276
+ const rows = (await query(token, {
277
+ model: "user",
278
+ operation: "findMany",
279
+ where,
280
+ take: limit,
281
+ })) as UserRow[]
282
+
283
+ if (rows.length === 0) return `No users found matching "${nameOrEmail}".`
284
+
285
+ const lines = rows.map((u) => `${u.name} (${u.email}) — ${u.userType}\n ID: ${u.id}`)
286
+
287
+ return `${rows.length} user(s):\n\n${lines.join("\n\n")}`
288
+ }
289
+
290
+ // ── User Activity ────────────────────────────────────────────────────
291
+
292
+ interface UserActivityRow {
293
+ id: string
294
+ actorAction: string
295
+ classification?: string
296
+ isRead: boolean
297
+ createdAt: string
298
+ channelId?: string
299
+ ticketId?: string
300
+ conversationId?: string
301
+ messageId?: string
302
+ actorId: string
303
+ }
304
+
305
+ export async function queryUserActivity(
306
+ token: string,
307
+ classification?: string,
308
+ isRead?: boolean,
309
+ limit?: number,
310
+ ): Promise<string> {
311
+ const where: Record<string, unknown> = {}
312
+ if (classification) where["classification"] = { equals: classification }
313
+ if (isRead !== undefined) where["isRead"] = { equals: isRead }
314
+
315
+ const rows = (await query(token, {
316
+ model: "activity",
317
+ operation: "findMany",
318
+ where,
319
+ orderBy: [{ createdAt: "desc" }],
320
+ take: limit ?? 20,
321
+ })) as UserActivityRow[]
322
+
323
+ if (rows.length === 0) return "No activity found."
324
+
325
+ const lines = rows.map((a) => {
326
+ const when = new Date(a.createdAt).toLocaleString()
327
+ const read = a.isRead ? "" : " (unread)"
328
+ const refs: string[] = []
329
+ if (a.messageId) refs.push(`messageId: ${a.messageId}`)
330
+ if (a.conversationId) refs.push(`conversationId: ${a.conversationId}`)
331
+ if (a.ticketId) refs.push(`ticketId: ${a.ticketId}`)
332
+ if (a.channelId) refs.push(`channelId: ${a.channelId}`)
333
+ const refStr = refs.length > 0 ? `\n ${refs.join(" · ")}` : ""
334
+ return `[${when}] ${a.actorAction}${read}${a.classification ? ` · ${a.classification}` : ""}${refStr}`
335
+ })
336
+
337
+ return `${rows.length} activity entries:\n\n${lines.join("\n")}`
338
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "extends": "@tsconfig/bun/tsconfig.json",
4
+ "compilerOptions": {
5
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
6
+ "noUnusedLocals": true,
7
+ "noUnusedParameters": true,
8
+ "exactOptionalPropertyTypes": true,
9
+ "noUncheckedIndexedAccess": true,
10
+ "noPropertyAccessFromIndexSignature": true,
11
+ "noImplicitReturns": true,
12
+ "noFallthroughCasesInSwitch": true,
13
+ "noImplicitOverride": true
14
+ },
15
+ "include": ["src/**/*"],
16
+ "exclude": ["node_modules"]
17
+ }