typeclaw 0.22.0 → 0.24.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 (53) hide show
  1. package/package.json +1 -1
  2. package/src/agent/index.ts +91 -22
  3. package/src/agent/plugin-tools.ts +38 -2
  4. package/src/agent/restart/index.ts +15 -3
  5. package/src/agent/restart-handoff/index.ts +110 -12
  6. package/src/agent/session-origin.ts +41 -2
  7. package/src/agent/subagent-completion-reminder.ts +3 -1
  8. package/src/agent/subagents.ts +44 -1
  9. package/src/agent/system-prompt.ts +4 -0
  10. package/src/agent/todo/continuation-policy.ts +242 -0
  11. package/src/agent/todo/continuation-state.ts +87 -0
  12. package/src/agent/todo/continuation-wiring.ts +113 -0
  13. package/src/agent/todo/continuation.ts +71 -0
  14. package/src/agent/todo/scope.ts +77 -0
  15. package/src/agent/todo/store.ts +98 -0
  16. package/src/agent/tool-not-found-nudge.ts +119 -0
  17. package/src/agent/tools/channel-reply.ts +51 -0
  18. package/src/agent/tools/restart.ts +11 -4
  19. package/src/agent/tools/todo/index.ts +119 -0
  20. package/src/bundled-plugins/backup/runner.ts +1 -1
  21. package/src/bundled-plugins/memory/memory-logger.ts +28 -10
  22. package/src/bundled-plugins/reviewer/reviewer.ts +14 -0
  23. package/src/channels/adapters/discord-bot-reference.ts +78 -0
  24. package/src/channels/adapters/discord-bot.ts +31 -3
  25. package/src/channels/adapters/github/inbound.ts +161 -10
  26. package/src/channels/adapters/github/index.ts +18 -0
  27. package/src/channels/adapters/github/review-thread-resolver.ts +246 -0
  28. package/src/channels/adapters/kakaotalk-classify.ts +67 -6
  29. package/src/channels/adapters/slack-bot-classify.ts +9 -1
  30. package/src/channels/adapters/slack-bot-reference.ts +129 -0
  31. package/src/channels/adapters/slack-bot.ts +75 -8
  32. package/src/channels/adapters/telegram-bot.ts +11 -0
  33. package/src/channels/manager.ts +8 -2
  34. package/src/channels/router.ts +477 -22
  35. package/src/channels/schema.ts +20 -4
  36. package/src/channels/types.ts +95 -0
  37. package/src/cli/inspect-controller.ts +99 -0
  38. package/src/cli/inspect.ts +21 -123
  39. package/src/commands/index.ts +9 -0
  40. package/src/init/gitignore.ts +5 -2
  41. package/src/inspect/index.ts +30 -26
  42. package/src/inspect/live.ts +17 -3
  43. package/src/inspect/loop.ts +23 -17
  44. package/src/run/index.ts +60 -5
  45. package/src/sandbox/build.ts +10 -0
  46. package/src/sandbox/index.ts +2 -0
  47. package/src/sandbox/policy.ts +10 -0
  48. package/src/sandbox/writable-zones.ts +78 -0
  49. package/src/server/index.ts +118 -4
  50. package/src/skills/typeclaw-channel-github/SKILL.md +34 -7
  51. package/src/skills/typeclaw-config/SKILL.md +1 -1
  52. package/src/skills/typeclaw-git/SKILL.md +1 -1
  53. package/typeclaw.schema.json +10 -0
@@ -0,0 +1,246 @@
1
+ import type { ReviewThreadResolveRequest, ReviewThreadResolveResult, ReviewThreadResolver } from '@/channels/types'
2
+
3
+ import type { GithubAuthContext } from './auth'
4
+ import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
5
+
6
+ const GRAPHQL_ENDPOINT = `${GITHUB_API_BASE}/graphql`
7
+
8
+ // One page of review threads. `first: 100` is the GraphQL max; a busy PR can
9
+ // carry more, so the resolver paginates until it matches the root comment id
10
+ // or exhausts the pages — stopping early on a 404-equivalent (thread absent)
11
+ // rather than fabricating a node id.
12
+ const THREADS_QUERY = `query($owner:String!,$name:String!,$number:Int!,$after:String){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100,after:$after){pageInfo{hasNextPage endCursor}nodes{id isResolved comments(first:1){nodes{databaseId author{login}}}}}}}}`
13
+
14
+ const RESOLVE_MUTATION = `mutation($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{id isResolved}}}`
15
+
16
+ type ReviewThreadNode = {
17
+ id: string
18
+ isResolved: boolean
19
+ rootCommentId: number | null
20
+ rootAuthorLogin: string | null
21
+ }
22
+
23
+ type ThreadLookup =
24
+ | { kind: 'found'; thread: ReviewThreadNode }
25
+ | { kind: 'absent' }
26
+ | { kind: 'error'; result: ReviewThreadResolveResult & { ok: false } }
27
+
28
+ export function createGithubReviewThreadResolver(deps: {
29
+ token: (context?: GithubAuthContext) => Promise<string>
30
+ selfLogin: () => string | null
31
+ fetchImpl?: typeof fetch
32
+ }): ReviewThreadResolver {
33
+ const fetchImpl = deps.fetchImpl ?? fetch
34
+ return async (req): Promise<ReviewThreadResolveResult> => {
35
+ if (req.adapter !== 'github') {
36
+ return { ok: false, error: `unknown adapter: ${req.adapter}`, code: 'unsupported' }
37
+ }
38
+ const target = parseTarget(req)
39
+ if (target === null) {
40
+ return {
41
+ ok: false,
42
+ error: `unparseable github review-thread target (chat=${req.chat}, root=${req.rootCommentId})`,
43
+ code: 'transient',
44
+ }
45
+ }
46
+ const selfLogin = deps.selfLogin()
47
+ if (selfLogin === null) {
48
+ return {
49
+ ok: false,
50
+ error: 'github self-identity not resolved; cannot verify thread authorship',
51
+ code: 'transient',
52
+ }
53
+ }
54
+
55
+ const token = await deps.token({ repoSlug: `${target.owner}/${target.repo}` })
56
+ const lookup = await findThread(fetchImpl, token, target)
57
+ if (lookup.kind === 'error') return lookup.result
58
+ if (lookup.kind === 'absent') {
59
+ return {
60
+ ok: false,
61
+ error: `no review thread rooted at comment ${target.rootCommentId} on ${req.chat}`,
62
+ code: 'no-match',
63
+ }
64
+ }
65
+
66
+ const thread = lookup.thread
67
+ // The load-bearing guard: only the bot may resolve the bot's own thread.
68
+ // Resolving a human reviewer's thread would erase their open question.
69
+ if (thread.rootAuthorLogin !== selfLogin) {
70
+ return {
71
+ ok: false,
72
+ error: `refusing to resolve thread authored by @${thread.rootAuthorLogin ?? 'unknown'} (not @${selfLogin})`,
73
+ code: 'not-author',
74
+ }
75
+ }
76
+ if (thread.isResolved) return { ok: true, alreadyResolved: true }
77
+
78
+ return await runResolveMutation(fetchImpl, token, thread.id)
79
+ }
80
+ }
81
+
82
+ type ResolveTarget = { owner: string; repo: string; prNumber: number; rootCommentId: number }
83
+
84
+ function parseTarget(req: ReviewThreadResolveRequest): ResolveTarget | null {
85
+ const [owner, repo, ...rest] = req.workspace.split('/')
86
+ if (owner === undefined || owner === '' || repo === undefined || repo === '' || rest.length > 0) return null
87
+ const prMatch = /^pr:(\d+)$/.exec(req.chat)
88
+ if (prMatch === null) return null
89
+ const prNumber = parseDecimalId(prMatch[1])
90
+ const rootCommentId = parseDecimalId(req.rootCommentId)
91
+ if (prNumber === null || rootCommentId === null) return null
92
+ return { owner, repo, prNumber, rootCommentId }
93
+ }
94
+
95
+ // Strict decimal-id parse: `Number()` would coerce '' -> 0, '1e2' -> 100, and
96
+ // silently round ids past 2^53 (GitHub comment ids are large), any of which
97
+ // could match the WRONG thread. Demand a plain run of digits and a safe
98
+ // integer, so a malformed or oversized id fails closed (no resolution) rather
99
+ // than resolving a collided thread.
100
+ function parseDecimalId(value: string | undefined): number | null {
101
+ if (value === undefined || !/^\d+$/.test(value)) return null
102
+ const n = Number(value)
103
+ return Number.isSafeInteger(n) ? n : null
104
+ }
105
+
106
+ async function findThread(fetchImpl: typeof fetch, token: string, target: ResolveTarget): Promise<ThreadLookup> {
107
+ let after: string | null = null
108
+ for (;;) {
109
+ let response: Response
110
+ try {
111
+ response = await fetchImpl(GRAPHQL_ENDPOINT, {
112
+ method: 'POST',
113
+ headers: githubJsonHeaders(token),
114
+ body: JSON.stringify({
115
+ query: THREADS_QUERY,
116
+ variables: { owner: target.owner, name: target.repo, number: target.prNumber, after },
117
+ }),
118
+ })
119
+ } catch (err) {
120
+ return {
121
+ kind: 'error',
122
+ result: { ok: false, error: err instanceof Error ? err.message : String(err), code: 'transient' },
123
+ }
124
+ }
125
+ const parsed = await parseThreadsPage(response)
126
+ if (parsed.kind === 'error') return { kind: 'error', result: parsed.result }
127
+
128
+ for (const node of parsed.nodes) {
129
+ if (node.rootCommentId === target.rootCommentId) return { kind: 'found', thread: node }
130
+ }
131
+ if (!parsed.hasNextPage || parsed.endCursor === null) return { kind: 'absent' }
132
+ after = parsed.endCursor
133
+ }
134
+ }
135
+
136
+ type ThreadsPage =
137
+ | { kind: 'ok'; nodes: ReviewThreadNode[]; hasNextPage: boolean; endCursor: string | null }
138
+ | { kind: 'error'; result: ReviewThreadResolveResult & { ok: false } }
139
+
140
+ async function parseThreadsPage(response: Response): Promise<ThreadsPage> {
141
+ if (!response.ok) {
142
+ const text = await response.text().catch(() => '')
143
+ return {
144
+ kind: 'error',
145
+ result: {
146
+ ok: false,
147
+ error: `GitHub GraphQL ${response.status}${text !== '' ? `: ${text}` : ''}`,
148
+ code: classifyStatus(response.status),
149
+ },
150
+ }
151
+ }
152
+ const body = (await response.json().catch(() => null)) as GraphqlThreadsResponse | null
153
+ if (body === null)
154
+ return { kind: 'error', result: { ok: false, error: 'GitHub GraphQL returned non-JSON', code: 'transient' } }
155
+ if (body.errors !== undefined && body.errors.length > 0) {
156
+ return {
157
+ kind: 'error',
158
+ result: {
159
+ ok: false,
160
+ error: `GitHub GraphQL error: ${body.errors.map((e) => e.message).join('; ')}`,
161
+ code: 'transient',
162
+ },
163
+ }
164
+ }
165
+ const connection = body.data?.repository?.pullRequest?.reviewThreads
166
+ if (connection === undefined)
167
+ return {
168
+ kind: 'error',
169
+ result: { ok: false, error: 'GitHub GraphQL response missing reviewThreads', code: 'transient' },
170
+ }
171
+ const nodes = connection.nodes.map((n) => {
172
+ const root = n.comments.nodes[0]
173
+ return {
174
+ id: n.id,
175
+ isResolved: n.isResolved,
176
+ rootCommentId: root?.databaseId ?? null,
177
+ rootAuthorLogin: root?.author?.login ?? null,
178
+ }
179
+ })
180
+ return { kind: 'ok', nodes, hasNextPage: connection.pageInfo.hasNextPage, endCursor: connection.pageInfo.endCursor }
181
+ }
182
+
183
+ async function runResolveMutation(
184
+ fetchImpl: typeof fetch,
185
+ token: string,
186
+ threadId: string,
187
+ ): Promise<ReviewThreadResolveResult> {
188
+ let response: Response
189
+ try {
190
+ response = await fetchImpl(GRAPHQL_ENDPOINT, {
191
+ method: 'POST',
192
+ headers: githubJsonHeaders(token),
193
+ body: JSON.stringify({ query: RESOLVE_MUTATION, variables: { threadId } }),
194
+ })
195
+ } catch (err) {
196
+ return { ok: false, error: err instanceof Error ? err.message : String(err), code: 'transient' }
197
+ }
198
+ if (!response.ok) {
199
+ const text = await response.text().catch(() => '')
200
+ return {
201
+ ok: false,
202
+ error: `GitHub GraphQL ${response.status}${text !== '' ? `: ${text}` : ''}`,
203
+ code: classifyStatus(response.status),
204
+ }
205
+ }
206
+ const body = (await response.json().catch(() => null)) as GraphqlResolveResponse | null
207
+ if (body === null) return { ok: false, error: 'GitHub GraphQL returned non-JSON', code: 'transient' }
208
+ if (body.errors !== undefined && body.errors.length > 0) {
209
+ return {
210
+ ok: false,
211
+ error: `GitHub GraphQL error: ${body.errors.map((e) => e.message).join('; ')}`,
212
+ code: 'transient',
213
+ }
214
+ }
215
+ if (body.data?.resolveReviewThread?.thread?.isResolved === true) return { ok: true }
216
+ return { ok: false, error: 'resolveReviewThread mutation did not report isResolved', code: 'transient' }
217
+ }
218
+
219
+ function classifyStatus(status: number): 'permission-denied' | 'not-found' | 'transient' {
220
+ if (status === 401 || status === 403) return 'permission-denied'
221
+ if (status === 404) return 'not-found'
222
+ return 'transient'
223
+ }
224
+
225
+ type GraphqlThreadsResponse = {
226
+ data?: {
227
+ repository?: {
228
+ pullRequest?: {
229
+ reviewThreads?: {
230
+ pageInfo: { hasNextPage: boolean; endCursor: string | null }
231
+ nodes: Array<{
232
+ id: string
233
+ isResolved: boolean
234
+ comments: { nodes: Array<{ databaseId?: number; author?: { login?: string } }> }
235
+ }>
236
+ }
237
+ }
238
+ }
239
+ }
240
+ errors?: Array<{ message: string }>
241
+ }
242
+
243
+ type GraphqlResolveResponse = {
244
+ data?: { resolveReviewThread?: { thread?: { id: string; isResolved: boolean } } }
245
+ errors?: Array<{ message: string }>
246
+ }
@@ -1,8 +1,8 @@
1
- import type { KakaoTalkPushMessageEvent } from 'agent-messenger/kakaotalk'
1
+ import { KAKAO_MESSAGE_TYPE, type KakaoTalkPushMessageEvent } from 'agent-messenger/kakaotalk'
2
2
 
3
3
  import { matchesAnyAlias } from '@/channels/engagement'
4
4
  import type { ChannelAdapterConfig } from '@/channels/schema'
5
- import type { InboundAttachment, InboundMessage } from '@/channels/types'
5
+ import type { InboundAttachment, InboundMessage, InboundReferenceContext } from '@/channels/types'
6
6
 
7
7
  export type InboundDropReason = 'self_author' | 'empty_text' | 'unknown_chat' | 'pre_connect' | 'bot_message'
8
8
 
@@ -49,7 +49,9 @@ export function classifyInbound(
49
49
  return { kind: 'drop', reason: 'bot_message' }
50
50
  }
51
51
 
52
- const text = event.message ?? ''
52
+ const rawText = event.message ?? ''
53
+ const replyContext = parseReplyContext(event, context.selfUserId)
54
+ const text = rawText
53
55
  if (text === '') return { kind: 'drop', reason: 'empty_text' }
54
56
 
55
57
  const chatInfo = context.lookupChat(event.chat_id)
@@ -64,7 +66,7 @@ export function classifyInbound(
64
66
  // mention (see engagement.ts: alias is unconditional and ranks alongside
65
67
  // explicit triggers). Without aliases configured, only `reply` and `dm`
66
68
  // triggers can fire on KakaoTalk.
67
- const aliasMatched = matchesAnyAlias(text, context.selfAliases ?? [])
69
+ const aliasMatched = matchesAnyAlias(rawText, context.selfAliases ?? [])
68
70
 
69
71
  return {
70
72
  kind: 'route',
@@ -74,6 +76,7 @@ export function classifyInbound(
74
76
  chat: event.chat_id,
75
77
  thread: null,
76
78
  text,
79
+ ...referenceContextPayload(replyContext),
77
80
  ...(context.attachments !== undefined && context.attachments.length > 0
78
81
  ? { attachments: context.attachments }
79
82
  : {}),
@@ -82,9 +85,9 @@ export function classifyInbound(
82
85
  authorName: event.author_name ?? String(event.author_id),
83
86
  authorIsBot: false,
84
87
  isBotMention: aliasMatched,
85
- replyToBotMessageId: null,
88
+ replyToBotMessageId: replyContext?.target === 'bot' ? replyContext.logId : null,
86
89
  mentionsOthers: false,
87
- replyToOtherMessageId: null,
90
+ replyToOtherMessageId: replyContext?.target === 'other' ? replyContext.logId : null,
88
91
  isDm: chatInfo.isDm,
89
92
  // SDK delivers `sent_at` in Unix seconds (LOCO `sendAt`); contract
90
93
  // wants ms (see `src/channels/types.ts`). Without `* 1000`, ms-based
@@ -93,3 +96,61 @@ export function classifyInbound(
93
96
  },
94
97
  }
95
98
  }
99
+
100
+ type ParsedReplyContext = {
101
+ logId: string
102
+ authorId: string
103
+ authorName: string
104
+ quotedText: string | null
105
+ target: 'bot' | 'other'
106
+ }
107
+
108
+ function parseReplyContext(event: KakaoTalkPushMessageEvent, selfUserId: string): ParsedReplyContext | null {
109
+ if (event.message_type !== KAKAO_MESSAGE_TYPE.REPLY) return null
110
+ if (event.attachment === null) return null
111
+
112
+ const logId = stringField(event.attachment, 'src_logId')
113
+ const sourceUserId = numericField(event.attachment, 'src_userId')
114
+ if (logId === null || sourceUserId === null) return null
115
+
116
+ const sourceAuthorId = String(sourceUserId)
117
+ const quotedText = stringField(event.attachment, 'src_message')
118
+ return {
119
+ logId,
120
+ authorId: sourceAuthorId,
121
+ // classifyInbound is synchronous and has no cheap author resolver access;
122
+ // fall back to Kakao's stable source user id rather than fetching.
123
+ authorName: sourceAuthorId,
124
+ quotedText,
125
+ target: sourceAuthorId === selfUserId ? 'bot' : 'other',
126
+ }
127
+ }
128
+
129
+ function referenceContextPayload(
130
+ replyContext: ParsedReplyContext | null,
131
+ ): { referenceContext: InboundReferenceContext } | Record<string, never> {
132
+ if (replyContext === null || replyContext.quotedText === null || replyContext.quotedText.trim() === '') return {}
133
+ return {
134
+ referenceContext: {
135
+ kind: 'reply',
136
+ sources: [
137
+ {
138
+ adapter: 'kakaotalk',
139
+ authorId: replyContext.authorId,
140
+ authorName: replyContext.authorName,
141
+ text: replyContext.quotedText,
142
+ },
143
+ ],
144
+ },
145
+ }
146
+ }
147
+
148
+ function stringField(record: Record<string, unknown>, key: string): string | null {
149
+ const value = record[key]
150
+ return typeof value === 'string' && value.length > 0 ? value : null
151
+ }
152
+
153
+ function numericField(record: Record<string, unknown>, key: string): number | null {
154
+ const value = record[key]
155
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
156
+ }
@@ -4,6 +4,7 @@ import { matchesAnyAlias } from '@/channels/engagement'
4
4
  import type { ChannelAdapterConfig } from '@/channels/schema'
5
5
  import type { InboundAttachment, InboundMessage } from '@/channels/types'
6
6
 
7
+ import { hasSlackMessageShareAttachments } from './slack-bot-reference'
7
8
  import { slackTsToMillis } from './slack-bot-time'
8
9
 
9
10
  export type SlackInboundMessageEvent = SlackSocketModeMessageEvent
@@ -62,7 +63,8 @@ export function classifyInbound(
62
63
 
63
64
  const rawText = event.text ?? ''
64
65
  const { text, attachments } = splitInbound(event)
65
- if (text === '') return { kind: 'drop', reason: 'empty_text' }
66
+ const slackAttachments = Array.isArray(event.attachments) ? event.attachments : undefined
67
+ if (text === '' && !hasSlackMessageShareAttachments(slackAttachments)) return { kind: 'drop', reason: 'empty_text' }
66
68
 
67
69
  const isDm = event.channel_type === 'im'
68
70
  const workspace = isDm ? '@dm' : context.teamId
@@ -92,6 +94,11 @@ export function classifyInbound(
92
94
  // found, keeping the cost negligible in mention-heavy channels.
93
95
  const aliasMatched = !isBotMention && matchesAnyAlias(rawText, context.selfAliases ?? [])
94
96
  const thread = event.thread_ts ?? (!isDm && (isBotMention || aliasMatched) ? event.ts : null)
97
+ // DMs stay flat (`thread` null), but Slack's typing status still needs a real
98
+ // message ts; anchor it on the inbound so the indicator renders without
99
+ // threading the reply. `assistant.threads.setStatus` accepts any live channel
100
+ // message ts, confirmed against the API.
101
+ const typingThread = isDm && thread === null ? event.ts : undefined
95
102
 
96
103
  // A reply is "to the bot" only when the thread parent was authored by the
97
104
  // bot. Slack surfaces the parent author via `parent_user_id` on every
@@ -130,6 +137,7 @@ export function classifyInbound(
130
137
  workspace,
131
138
  chat: event.channel,
132
139
  thread,
140
+ ...(typingThread !== undefined ? { typingThread } : {}),
133
141
  text,
134
142
  ...(attachments.length > 0 ? { attachments } : {}),
135
143
  externalMessageId: event.ts,
@@ -0,0 +1,129 @@
1
+ import type { InboundReferenceContext, QuoteAnchorSource } from '@/channels/types'
2
+
3
+ export type SlackResolvedReference = {
4
+ authorId: string
5
+ authorName: string
6
+ text: string
7
+ }
8
+
9
+ export type SlackReferenceFetch = (channelId: string, messageTs: string) => Promise<SlackResolvedReference | null>
10
+
11
+ export type SlackMessagePointer = {
12
+ channelId: string
13
+ messageTs: string
14
+ }
15
+
16
+ export async function enrichSlackReferenceContext(args: {
17
+ text: string
18
+ channelId: string
19
+ threadTs?: string
20
+ messageTs: string
21
+ attachments?: readonly unknown[]
22
+ fetchMessage: SlackReferenceFetch
23
+ linkLimit?: number
24
+ }): Promise<{ text: string; referenceContext?: InboundReferenceContext }> {
25
+ const sources: QuoteAnchorSource[] = []
26
+ let kind: InboundReferenceContext['kind'] = 'link'
27
+
28
+ if (args.threadTs !== undefined && args.threadTs !== args.messageTs) {
29
+ const parent = await fetchSafely(args.fetchMessage, { channelId: args.channelId, messageTs: args.threadTs })
30
+ if (parent !== null) {
31
+ sources.push(toSource(parent))
32
+ kind = 'reply'
33
+ }
34
+ }
35
+
36
+ for (const source of extractSlackShareSources(args.attachments ?? [])) {
37
+ sources.push(source)
38
+ if (kind !== 'reply') kind = 'quote'
39
+ }
40
+
41
+ const links = extractSlackMessageLinks(args.text).slice(0, args.linkLimit ?? 3)
42
+ const seen = new Set(sources.map((source) => `${source.authorId}\x00${source.text}`))
43
+ for (const link of links) {
44
+ const message = await fetchSafely(args.fetchMessage, link)
45
+ if (message === null) continue
46
+ const source = toSource(message)
47
+ const key = `${source.authorId}\x00${source.text}`
48
+ if (seen.has(key)) continue
49
+ seen.add(key)
50
+ sources.push(source)
51
+ }
52
+
53
+ if (sources.length === 0) return { text: args.text }
54
+ return { text: args.text, referenceContext: { kind, sources } }
55
+ }
56
+
57
+ export function hasSlackMessageShareAttachments(attachments: readonly unknown[] | undefined): boolean {
58
+ return extractSlackShareSources(attachments ?? []).length > 0
59
+ }
60
+
61
+ const SLACK_ARCHIVE_LINK = /https?:\/\/[^\s/]+\.slack\.com\/archives\/([^/\s]+)\/p(\d{10})(\d{6})/g
62
+
63
+ function extractSlackMessageLinks(text: string): SlackMessagePointer[] {
64
+ const seen = new Set<string>()
65
+ const links: SlackMessagePointer[] = []
66
+ for (const match of text.matchAll(SLACK_ARCHIVE_LINK)) {
67
+ const channelId = match[1]
68
+ const seconds = match[2]
69
+ const micros = match[3]
70
+ if (channelId === undefined || seconds === undefined || micros === undefined) continue
71
+ const messageTs = `${seconds}.${micros}`
72
+ const key = `${channelId}:${messageTs}`
73
+ if (seen.has(key)) continue
74
+ seen.add(key)
75
+ links.push({ channelId, messageTs })
76
+ }
77
+ return links
78
+ }
79
+
80
+ // A stable author id is required because the quote renders as `<@id>`
81
+ // downstream; a name-only source would emit `<@unknown>`, worse than omitting
82
+ // the context. Forwarded message-shares put it under `author_id`, classic
83
+ // shares under `user`/`user_id`. `author_subname` is Slack's display-name
84
+ // fallback on forwarded messages — used for the name only, never the id.
85
+ function extractSlackShareSources(attachments: readonly unknown[]): QuoteAnchorSource[] {
86
+ const sources: QuoteAnchorSource[] = []
87
+ for (const attachment of attachments) {
88
+ const record = recordValue(attachment)
89
+ if (record === null) continue
90
+ const text = stringField(record, 'text') ?? stringField(record, 'fallback')
91
+ if (text === null || text.trim() === '') continue
92
+ const authorId = stringField(record, 'author_id') ?? stringField(record, 'user') ?? stringField(record, 'user_id')
93
+ if (authorId === null) continue
94
+ const authorName = stringField(record, 'author_name') ?? stringField(record, 'author_subname') ?? authorId
95
+ sources.push({
96
+ adapter: 'slack-bot',
97
+ authorId,
98
+ authorName,
99
+ text,
100
+ })
101
+ }
102
+ return sources
103
+ }
104
+
105
+ async function fetchSafely(
106
+ fetchMessage: SlackReferenceFetch,
107
+ pointer: SlackMessagePointer,
108
+ ): Promise<SlackResolvedReference | null> {
109
+ try {
110
+ return await fetchMessage(pointer.channelId, pointer.messageTs)
111
+ } catch {
112
+ return null
113
+ }
114
+ }
115
+
116
+ function toSource(message: SlackResolvedReference): QuoteAnchorSource {
117
+ return { adapter: 'slack-bot', authorId: message.authorId, authorName: message.authorName, text: message.text }
118
+ }
119
+
120
+ function recordValue(value: unknown): Record<string, unknown> | null {
121
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
122
+ ? (value as Record<string, unknown>)
123
+ : null
124
+ }
125
+
126
+ function stringField(record: Record<string, unknown>, key: string): string | null {
127
+ const value = record[key]
128
+ return typeof value === 'string' && value.length > 0 ? value : null
129
+ }