@pinet/slack-bridge 0.2.12 → 0.2.13

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.
@@ -3,6 +3,7 @@ export interface ContextJsonObject {
3
3
  [key: string]: ContextJsonValue | undefined;
4
4
  }
5
5
  export type CodeAnchorSide = "old" | "new";
6
+ export type CodeAnchorKind = "diff" | "normal";
6
7
  export interface CodeRevisionIdentity extends ContextJsonObject {
7
8
  repository: string;
8
9
  worktree: string;
@@ -10,7 +11,10 @@ export interface CodeRevisionIdentity extends ContextJsonObject {
10
11
  baseOid: string | null;
11
12
  headOid: string;
12
13
  blobOid: string;
13
- side: CodeAnchorSide;
14
+ anchorKind: CodeAnchorKind;
15
+ side?: CodeAnchorSide;
16
+ headBlobOid?: string | null;
17
+ dirty?: boolean;
14
18
  }
15
19
  export interface CodeAnchor extends CodeRevisionIdentity {
16
20
  startLine: number;
@@ -25,7 +29,7 @@ export interface ContextualThreadState extends ContextJsonObject {
25
29
  }
26
30
  export interface ContextualThreadMetadata extends ContextJsonObject {
27
31
  pinetKind: "contextual_thread";
28
- schemaVersion: 1;
32
+ schemaVersion: 1 | 2;
29
33
  codeAnchor: CodeAnchor;
30
34
  state: ContextualThreadState;
31
35
  }
@@ -38,7 +42,10 @@ export declare function buildContextualThreadMetadata(input: {
38
42
  baseOid?: string | null;
39
43
  headOid: string;
40
44
  blobOid: string;
41
- side: CodeAnchorSide;
45
+ anchorKind?: CodeAnchorKind;
46
+ side?: CodeAnchorSide;
47
+ headBlobOid?: string | null;
48
+ dirty?: boolean;
42
49
  startLine: number;
43
50
  endLine?: number;
44
51
  selectedText?: string | null;
@@ -8,9 +8,12 @@ export function buildNvimThreadId(repoSocketHash) {
8
8
  export function buildContextualThreadMetadata(input) {
9
9
  const startLine = Math.max(1, Math.floor(input.startLine));
10
10
  const endLine = Math.max(startLine, Math.floor(input.endLine ?? startLine));
11
+ const anchorKind = input.anchorKind ?? "diff";
12
+ if (anchorKind === "diff" && !input.side)
13
+ throw new Error("diff code anchor side is required");
11
14
  return {
12
15
  pinetKind: "contextual_thread",
13
- schemaVersion: 1,
16
+ schemaVersion: anchorKind === "normal" ? 2 : 1,
14
17
  codeAnchor: {
15
18
  repository: input.repository,
16
19
  worktree: input.worktree,
@@ -18,7 +21,11 @@ export function buildContextualThreadMetadata(input) {
18
21
  baseOid: input.baseOid ?? null,
19
22
  headOid: input.headOid,
20
23
  blobOid: input.blobOid,
21
- side: input.side,
24
+ anchorKind,
25
+ ...(input.side ? { side: input.side } : {}),
26
+ ...(anchorKind === "normal"
27
+ ? { headBlobOid: input.headBlobOid ?? null, dirty: input.dirty === true }
28
+ : {}),
22
29
  startLine,
23
30
  endLine,
24
31
  selectedTextSha256: input.selectedText ? sha256Text(input.selectedText) : null,
@@ -43,7 +50,9 @@ function readPositiveInteger(record, key) {
43
50
  }
44
51
  export function parseContextualThreadMetadata(value) {
45
52
  const metadata = readRecord(value);
46
- if (!metadata || metadata.pinetKind !== "contextual_thread" || metadata.schemaVersion !== 1) {
53
+ if (!metadata ||
54
+ metadata.pinetKind !== "contextual_thread" ||
55
+ (metadata.schemaVersion !== 1 && metadata.schemaVersion !== 2)) {
47
56
  return null;
48
57
  }
49
58
  const anchorRecord = readRecord(metadata.codeAnchor);
@@ -55,7 +64,14 @@ export function parseContextualThreadMetadata(value) {
55
64
  const path = readString(anchorRecord, "path");
56
65
  const headOid = readString(anchorRecord, "headOid");
57
66
  const blobOid = readString(anchorRecord, "blobOid");
67
+ const anchorKind = metadata.schemaVersion === 2 ? anchorRecord.anchorKind : "diff";
68
+ if (anchorKind !== "diff" && anchorKind !== "normal")
69
+ return null;
58
70
  const side = anchorRecord.side === "old" || anchorRecord.side === "new" ? anchorRecord.side : null;
71
+ if (anchorKind === "diff" && !side)
72
+ return null;
73
+ if (anchorKind === "normal" && typeof anchorRecord.dirty !== "boolean")
74
+ return null;
59
75
  const startLine = readPositiveInteger(anchorRecord, "startLine");
60
76
  const endLine = readPositiveInteger(anchorRecord, "endLine");
61
77
  if (!repository ||
@@ -63,7 +79,6 @@ export function parseContextualThreadMetadata(value) {
63
79
  !path ||
64
80
  !headOid ||
65
81
  !blobOid ||
66
- !side ||
67
82
  startLine == null ||
68
83
  endLine == null) {
69
84
  return null;
@@ -72,7 +87,7 @@ export function parseContextualThreadMetadata(value) {
72
87
  const resolvedBy = readString(stateRecord, "resolvedBy");
73
88
  return {
74
89
  pinetKind: "contextual_thread",
75
- schemaVersion: 1,
90
+ schemaVersion: metadata.schemaVersion,
76
91
  codeAnchor: {
77
92
  repository,
78
93
  worktree,
@@ -80,7 +95,14 @@ export function parseContextualThreadMetadata(value) {
80
95
  baseOid: readString(anchorRecord, "baseOid"),
81
96
  headOid,
82
97
  blobOid,
83
- side,
98
+ anchorKind,
99
+ ...(side ? { side } : {}),
100
+ ...(anchorKind === "normal"
101
+ ? {
102
+ headBlobOid: readString(anchorRecord, "headBlobOid"),
103
+ dirty: anchorRecord.dirty === true,
104
+ }
105
+ : {}),
84
106
  startLine,
85
107
  endLine,
86
108
  selectedTextSha256: readString(anchorRecord, "selectedTextSha256"),
@@ -106,7 +128,10 @@ export function hasSameCodeRevision(anchor, candidate) {
106
128
  anchor.baseOid === candidate.baseOid &&
107
129
  anchor.headOid === candidate.headOid &&
108
130
  anchor.blobOid === candidate.blobOid &&
109
- anchor.side === candidate.side);
131
+ anchor.anchorKind === candidate.anchorKind &&
132
+ anchor.side === candidate.side &&
133
+ anchor.headBlobOid === candidate.headBlobOid &&
134
+ anchor.dirty === candidate.dirty);
110
135
  }
111
136
  export function formatAnchorForMessage(metadata) {
112
137
  const anchor = metadata.codeAnchor;
@@ -114,5 +139,6 @@ export function formatAnchorForMessage(metadata) {
114
139
  ? `${anchor.path}:${anchor.startLine}`
115
140
  : `${anchor.path}:${anchor.startLine}-${anchor.endLine}`;
116
141
  const base = anchor.baseOid ? ` base=${anchor.baseOid}` : "";
117
- return `[code-anchor ${range} side=${anchor.side} head=${anchor.headOid} blob=${anchor.blobOid}${base}]`;
142
+ const mode = anchor.anchorKind === "diff" ? `side=${anchor.side}` : `mode=normal dirty=${anchor.dirty}`;
143
+ return `[code-anchor ${range} ${mode} head=${anchor.headOid} blob=${anchor.blobOid}${base}]`;
118
144
  }
package/dist/index.js CHANGED
@@ -631,7 +631,21 @@ export default function (pi) {
631
631
  broker.db.queueMessage(decision.agentId, routedMessage);
632
632
  return;
633
633
  }
634
- if (decision.action === "deliver" || decision.action === "unrouted") {
634
+ if (decision.action === "broadcast") {
635
+ const remoteAgentIds = decision.agentIds.filter((agentId) => agentId !== selfId);
636
+ if (broker.db.queueMessageToAgents) {
637
+ broker.db.queueMessageToAgents(remoteAgentIds, routedMessage);
638
+ }
639
+ else {
640
+ for (const agentId of remoteAgentIds)
641
+ broker.db.queueMessage(agentId, routedMessage);
642
+ }
643
+ if (!decision.agentIds.includes(selfId))
644
+ return;
645
+ }
646
+ if (decision.action === "deliver" ||
647
+ decision.action === "broadcast" ||
648
+ decision.action === "unrouted") {
635
649
  const persisted = routedMessage.threadId
636
650
  ? persistDeliveredInboundMessage(broker.db, selfId, routedMessage)
637
651
  : null;
@@ -1,7 +1,16 @@
1
- import { type ContextJsonValue } from "./code-anchor.js";
2
- import type { BrokerMessage, InboundMessage, MessageAdapter, OutboundMessage, ThreadInfo } from "./broker/types.js";
1
+ import { type CodeRevisionIdentity, type ContextJsonValue } from "./code-anchor.js";
2
+ import type { BrokerMessage, InboundMessage, MessageAdapter, OutboundMessage, ThreadInfo, DocumentInfo } from "./broker/types.js";
3
3
  export interface NvimAdapterDbPort {
4
4
  getThread(threadId: string): ThreadInfo | null;
5
+ getDocument(documentId: string): DocumentInfo | null;
6
+ getDocumentByAlias(source: string, externalId: string): DocumentInfo | null;
7
+ upsertDocument(document: Omit<DocumentInfo, "createdAt" | "updatedAt">): DocumentInfo;
8
+ bindDocumentAlias(source: string, externalId: string, documentId: string, metadata?: Record<string, ContextJsonValue> | null): void;
9
+ setDocumentOwner(documentId: string, ownerAgent: string | null): DocumentInfo;
10
+ subscribeDocument(documentId: string, agentId: string): void;
11
+ unsubscribeDocument(documentId: string, agentId: string): void;
12
+ listDocumentSubscribers(documentId: string): string[];
13
+ getDocumentRecipients(documentId: string): string[];
5
14
  createThread(thread: ThreadInfo): ThreadInfo;
6
15
  updateThread(threadId: string, updates: Partial<ThreadInfo>): void;
7
16
  getThreads(ownerAgent?: string): ThreadInfo[];
@@ -27,6 +36,8 @@ export interface NvimPinetAdapterOptions extends NvimRepositoryContext {
27
36
  } | null;
28
37
  }
29
38
  export declare function resolveNvimRepositoryContext(cwd: string): NvimRepositoryContext | null;
39
+ export declare function buildGitFileAliasExternalId(anchor: Pick<CodeRevisionIdentity, "repository" | "worktree" | "path">): string;
40
+ export declare function buildGitFileDocumentId(anchor: Pick<CodeRevisionIdentity, "repository" | "worktree" | "path">): string;
30
41
  export declare function computeNvimSocketPath(worktree: string, branch: string): string;
31
42
  export declare class NvimPinetAdapter implements MessageAdapter {
32
43
  private readonly options;
@@ -45,11 +56,17 @@ export declare class NvimPinetAdapter implements MessageAdapter {
45
56
  private accept;
46
57
  private handleLine;
47
58
  private handleRequest;
59
+ private resolveGitDocument;
48
60
  private createThread;
49
61
  private replyToThread;
50
62
  private resolveThread;
51
63
  private listThreads;
52
64
  private getThread;
65
+ private getDocument;
66
+ private setDocumentOwner;
67
+ private subscribeDocument;
68
+ private bindDocumentThread;
69
+ private emitDocumentEvent;
53
70
  private serializeThread;
54
71
  private emitInbound;
55
72
  private broadcast;
@@ -31,6 +31,12 @@ export function resolveNvimRepositoryContext(cwd) {
31
31
  return null;
32
32
  }
33
33
  }
34
+ export function buildGitFileAliasExternalId(anchor) {
35
+ return `${anchor.repository}\0${anchor.worktree}\0${anchor.path}`;
36
+ }
37
+ export function buildGitFileDocumentId(anchor) {
38
+ return `doc:git-file:${createHash("sha256").update(buildGitFileAliasExternalId(anchor)).digest("hex")}`;
39
+ }
34
40
  function computeRepoSocketHash(worktree, branch) {
35
41
  return createHash("sha256").update(`${worktree}:${branch}`).digest("hex");
36
42
  }
@@ -82,7 +88,16 @@ function parseAnchor(payload) {
82
88
  const headOid = getString(anchor, "headOid");
83
89
  const blobOid = getString(anchor, "blobOid");
84
90
  const side = anchor.side === "old" || anchor.side === "new" ? anchor.side : null;
85
- if (!repository || !worktree || !anchorPath || !headOid || !blobOid || !side)
91
+ const anchorKind = anchor.anchorKind === "normal" || anchor.anchorKind === "diff"
92
+ ? anchor.anchorKind
93
+ : side
94
+ ? "diff"
95
+ : null;
96
+ if (!repository || !worktree || !anchorPath || !headOid || !blobOid || !anchorKind)
97
+ return null;
98
+ if (anchorKind === "diff" && !side)
99
+ return null;
100
+ if (anchorKind === "normal" && typeof anchor.dirty !== "boolean")
86
101
  return null;
87
102
  return {
88
103
  repository,
@@ -91,7 +106,11 @@ function parseAnchor(payload) {
91
106
  baseOid: getString(anchor, "baseOid"),
92
107
  headOid,
93
108
  blobOid,
94
- side,
109
+ anchorKind,
110
+ ...(side ? { side } : {}),
111
+ ...(anchorKind === "normal"
112
+ ? { headBlobOid: getString(anchor, "headBlobOid"), dirty: anchor.dirty === true }
113
+ : {}),
95
114
  };
96
115
  }
97
116
  // agent-standards-ignore prefer-inline-single-use-helper: create payload parser is a named protocol DTO boundary.
@@ -101,7 +120,7 @@ function parseCreateThreadRequest(payload) {
101
120
  const anchor = parseAnchor(payload);
102
121
  const startLine = getPositiveInteger(payload, "startLine", null);
103
122
  const endLine = getPositiveInteger(payload, "endLine", startLine);
104
- if (!targetAgentId || !body || !anchor || startLine == null || endLine == null)
123
+ if (!body || !anchor || startLine == null || endLine == null)
105
124
  return null;
106
125
  return {
107
126
  targetAgentId,
@@ -137,6 +156,21 @@ function parseListRequest(payload) {
137
156
  limit: getPositiveInteger(payload, "limit", 100) ?? 100,
138
157
  };
139
158
  }
159
+ // agent-standards-ignore prefer-inline-single-use-helper: document payload parser is a named protocol DTO boundary.
160
+ function parseDocumentRequest(payload) {
161
+ const anchor = parseAnchor(payload);
162
+ return anchor ? { anchor } : null;
163
+ }
164
+ function parseBindThreadRequest(payload) {
165
+ const anchor = parseAnchor(payload);
166
+ const threadId = getString(payload, "threadId");
167
+ return anchor && threadId ? { anchor, threadId } : null;
168
+ }
169
+ function parseDocumentAgentRequest(payload) {
170
+ const anchor = parseAnchor(payload);
171
+ const agentId = getString(payload, "agentId");
172
+ return anchor && agentId ? { anchor, agentId } : null;
173
+ }
140
174
  function serializeMetadata(metadata) {
141
175
  return JSON.parse(JSON.stringify(metadata));
142
176
  }
@@ -304,20 +338,54 @@ export class NvimPinetAdapter {
304
338
  return this.listThreads(request.payload);
305
339
  case "pinet.thread.get":
306
340
  return this.getThread(request.payload);
341
+ case "pinet.document.get":
342
+ return this.getDocument(request.payload);
343
+ case "pinet.document.owner":
344
+ return this.setDocumentOwner(request.payload);
345
+ case "pinet.document.subscribe":
346
+ return this.subscribeDocument(request.payload, true);
347
+ case "pinet.document.unsubscribe":
348
+ return this.subscribeDocument(request.payload, false);
349
+ case "pinet.document.bind_thread":
350
+ return this.bindDocumentThread(request.payload);
307
351
  default:
308
352
  throw new Error(`Unknown nvim request: ${request.type}`);
309
353
  }
310
354
  }
355
+ resolveGitDocument(anchor) {
356
+ return (this.options.db.getDocumentByAlias("nvim", buildGitFileAliasExternalId(anchor)) ??
357
+ this.options.db.getDocument(buildGitFileDocumentId(anchor)));
358
+ }
311
359
  createThread(payload) {
312
360
  const parsed = parseCreateThreadRequest(payload);
313
361
  if (!parsed)
314
- throw new Error("targetAgentId, body, anchor, startLine, and endLine are required");
362
+ throw new Error("body, anchor, startLine, and endLine are required");
315
363
  if (parsed.anchor.repository !== this.options.repository ||
316
364
  parsed.anchor.worktree !== this.options.worktree) {
317
365
  throw new Error("code anchor does not belong to this Pinet worktree");
318
366
  }
319
- if (!this.options.getAgentById(parsed.targetAgentId)) {
320
- throw new Error(`Unknown target agent: ${parsed.targetAgentId}`);
367
+ let document = this.resolveGitDocument(parsed.anchor);
368
+ const documentId = document?.documentId ?? buildGitFileDocumentId(parsed.anchor);
369
+ const targetAgentId = parsed.targetAgentId ?? document?.ownerAgent ?? null;
370
+ if (!targetAgentId)
371
+ throw new Error("targetAgentId is required until this document has an owner");
372
+ if (!this.options.getAgentById(targetAgentId)) {
373
+ throw new Error(`Unknown target agent: ${targetAgentId}`);
374
+ }
375
+ if (!document) {
376
+ document = this.options.db.upsertDocument({
377
+ documentId,
378
+ kind: "git_file",
379
+ title: parsed.anchor.path,
380
+ ownerAgent: targetAgentId,
381
+ ownerBinding: "explicit",
382
+ metadata: {
383
+ repository: parsed.anchor.repository,
384
+ worktree: parsed.anchor.worktree,
385
+ path: parsed.anchor.path,
386
+ },
387
+ });
388
+ this.options.db.bindDocumentAlias("nvim", buildGitFileAliasExternalId(parsed.anchor), documentId);
321
389
  }
322
390
  const metadata = buildContextualThreadMetadata({
323
391
  ...parsed.anchor,
@@ -332,16 +400,17 @@ export class NvimPinetAdapter {
332
400
  threadId,
333
401
  source: "nvim",
334
402
  channel: this.repoSocketHash,
335
- ownerAgent: parsed.targetAgentId,
403
+ ownerAgent: targetAgentId,
336
404
  ownerBinding: "explicit",
337
- metadata: serializeMetadata(metadata),
405
+ metadata: { ...serializeMetadata(metadata), documentId },
338
406
  createdAt: now,
339
407
  updatedAt: now,
340
408
  });
341
- this.emitInbound(threadId, parsed.targetAgentId, `${formatAnchorForMessage(metadata)}\n\n${parsed.body}`, {
409
+ this.emitInbound(threadId, targetAgentId, `${formatAnchorForMessage(metadata)}\n\n${parsed.body}`, {
342
410
  pinetKind: "contextual_thread_message",
343
411
  schemaVersion: 1,
344
412
  event: "thread.created",
413
+ documentId,
345
414
  });
346
415
  return { threadId, metadata: serializeMetadata(metadata) };
347
416
  }
@@ -352,10 +421,12 @@ export class NvimPinetAdapter {
352
421
  const thread = this.options.db.getThread(parsed.threadId);
353
422
  if (!thread)
354
423
  throw new Error(`Unknown thread: ${parsed.threadId}`);
424
+ const documentId = typeof thread.metadata?.documentId === "string" ? thread.metadata.documentId : null;
355
425
  this.emitInbound(parsed.threadId, thread.ownerAgent ?? "", parsed.body, {
356
426
  pinetKind: "contextual_thread_message",
357
427
  schemaVersion: 1,
358
428
  event: "thread.reply",
429
+ ...(documentId ? { documentId } : {}),
359
430
  });
360
431
  return { threadId: parsed.threadId };
361
432
  }
@@ -369,10 +440,12 @@ export class NvimPinetAdapter {
369
440
  throw new Error(`Unknown contextual thread: ${parsed.threadId}`);
370
441
  const updated = updateContextualThreadResolvedState(metadata, parsed.resolved, "nvim");
371
442
  this.options.db.updateThread(parsed.threadId, { metadata: serializeMetadata(updated) });
372
- this.options.db.insertMessage(parsed.threadId, "nvim", "inbound", "nvim", parsed.resolved ? "Resolved this thread." : "Reopened this thread.", thread.ownerAgent ? [thread.ownerAgent] : [], {
443
+ const documentId = typeof thread.metadata?.documentId === "string" ? thread.metadata.documentId : null;
444
+ this.emitInbound(parsed.threadId, thread.ownerAgent ?? "", parsed.resolved ? "Resolved this thread." : "Reopened this thread.", {
373
445
  pinetKind: "contextual_thread_message",
374
446
  schemaVersion: 1,
375
447
  event: parsed.resolved ? "thread.resolved" : "thread.reopened",
448
+ ...(documentId ? { documentId } : {}),
376
449
  });
377
450
  this.broadcast({ type: "thread.updated", payload: { threadId: parsed.threadId } });
378
451
  return { threadId: parsed.threadId, metadata: serializeMetadata(updated) };
@@ -403,6 +476,142 @@ export class NvimPinetAdapter {
403
476
  throw new Error(`Unknown contextual thread: ${threadId}`);
404
477
  return this.serializeThread(thread, metadata, 50);
405
478
  }
479
+ getDocument(payload) {
480
+ const parsed = parseDocumentRequest(payload);
481
+ if (!parsed)
482
+ throw new Error("revision-aware anchor is required");
483
+ const document = this.resolveGitDocument(parsed.anchor);
484
+ const documentId = document?.documentId ?? buildGitFileDocumentId(parsed.anchor);
485
+ return {
486
+ documentId,
487
+ ownerAgentId: document?.ownerAgent ?? null,
488
+ subscribers: document ? this.options.db.listDocumentSubscribers(documentId) : [],
489
+ };
490
+ }
491
+ setDocumentOwner(payload) {
492
+ const parsed = parseDocumentAgentRequest(payload);
493
+ if (!parsed)
494
+ throw new Error("anchor and agentId are required");
495
+ if (!this.options.getAgentById(parsed.agentId))
496
+ throw new Error(`Unknown agent: ${parsed.agentId}`);
497
+ const existingDocument = this.resolveGitDocument(parsed.anchor);
498
+ const documentId = existingDocument?.documentId ?? buildGitFileDocumentId(parsed.anchor);
499
+ if (!existingDocument) {
500
+ this.options.db.upsertDocument({
501
+ documentId,
502
+ kind: "git_file",
503
+ title: parsed.anchor.path,
504
+ ownerAgent: parsed.agentId,
505
+ ownerBinding: "explicit",
506
+ metadata: {
507
+ repository: parsed.anchor.repository,
508
+ worktree: parsed.anchor.worktree,
509
+ path: parsed.anchor.path,
510
+ },
511
+ });
512
+ }
513
+ else {
514
+ this.options.db.setDocumentOwner(documentId, parsed.agentId);
515
+ }
516
+ this.options.db.bindDocumentAlias("nvim", buildGitFileAliasExternalId(parsed.anchor), documentId);
517
+ for (const thread of this.options.db.getThreads()) {
518
+ if (thread.metadata?.documentId === documentId && thread.ownerAgent !== parsed.agentId) {
519
+ this.options.db.updateThread(thread.threadId, {
520
+ ownerAgent: parsed.agentId,
521
+ ownerBinding: "explicit",
522
+ });
523
+ }
524
+ }
525
+ this.emitDocumentEvent(documentId, parsed.agentId, `Document owner changed to ${parsed.agentId}.`, "document.owner_changed");
526
+ return this.getDocument({ anchor: parsed.anchor });
527
+ }
528
+ subscribeDocument(payload, subscribe) {
529
+ const parsed = parseDocumentAgentRequest(payload);
530
+ if (!parsed)
531
+ throw new Error("anchor and agentId are required");
532
+ if (!this.options.getAgentById(parsed.agentId))
533
+ throw new Error(`Unknown agent: ${parsed.agentId}`);
534
+ const document = this.resolveGitDocument(parsed.anchor);
535
+ const documentId = document?.documentId ?? buildGitFileDocumentId(parsed.anchor);
536
+ if (!document)
537
+ throw new Error(`Unknown document: ${documentId}`);
538
+ if (subscribe)
539
+ this.options.db.subscribeDocument(documentId, parsed.agentId);
540
+ else
541
+ this.options.db.unsubscribeDocument(documentId, parsed.agentId);
542
+ const updatedDocument = this.options.db.getDocument(documentId);
543
+ this.emitDocumentEvent(documentId, updatedDocument.ownerAgent ?? parsed.agentId, subscribe
544
+ ? `${parsed.agentId} subscribed to this document.`
545
+ : `${parsed.agentId} unsubscribed from this document.`, subscribe ? "document.subscribed" : "document.unsubscribed");
546
+ return this.getDocument({ anchor: parsed.anchor });
547
+ }
548
+ bindDocumentThread(payload) {
549
+ const parsed = parseBindThreadRequest(payload);
550
+ if (!parsed)
551
+ throw new Error("anchor and threadId are required");
552
+ const document = this.resolveGitDocument(parsed.anchor);
553
+ if (!document?.ownerAgent)
554
+ throw new Error("Set a document owner before binding a Slack thread");
555
+ const thread = this.options.db.getThread(parsed.threadId);
556
+ if (!thread || thread.source !== "slack") {
557
+ throw new Error(`Unknown Slack thread: ${parsed.threadId}`);
558
+ }
559
+ const previousDocumentId = typeof thread.metadata?.documentId === "string" ? thread.metadata.documentId : null;
560
+ if (previousDocumentId && previousDocumentId !== document.documentId) {
561
+ for (const subscriber of this.options.db.listDocumentSubscribers(previousDocumentId)) {
562
+ this.options.db.subscribeDocument(document.documentId, subscriber);
563
+ }
564
+ }
565
+ const referenceExternalId = `${thread.channel}\0${thread.threadId}`;
566
+ this.options.db.bindDocumentAlias("slack-thread-ref", referenceExternalId, document.documentId, { channelId: thread.channel, threadTs: thread.threadId });
567
+ const scopedExternalId = typeof thread.metadata?.documentAliasExternalId === "string"
568
+ ? thread.metadata.documentAliasExternalId
569
+ : null;
570
+ if (scopedExternalId) {
571
+ this.options.db.bindDocumentAlias("slack", scopedExternalId, document.documentId, {
572
+ channelId: thread.channel,
573
+ threadTs: thread.threadId,
574
+ });
575
+ }
576
+ this.options.db.updateThread(thread.threadId, {
577
+ ownerAgent: document.ownerAgent,
578
+ ownerBinding: "explicit",
579
+ metadata: {
580
+ ...(thread.metadata ?? {}),
581
+ documentId: document.documentId,
582
+ documentReferenceExternalId: referenceExternalId,
583
+ },
584
+ });
585
+ this.emitDocumentEvent(document.documentId, document.ownerAgent, `Bound Slack thread ${thread.channel}/${thread.threadId} to this document.`, "document.thread_bound");
586
+ return this.getDocument({ anchor: parsed.anchor });
587
+ }
588
+ emitDocumentEvent(documentId, ownerAgentId, body, event) {
589
+ const threadId = `document:${documentId}`;
590
+ const now = new Date().toISOString();
591
+ const existing = this.options.db.getThread(threadId);
592
+ if (!existing) {
593
+ this.options.db.createThread({
594
+ threadId,
595
+ source: "nvim",
596
+ channel: this.repoSocketHash,
597
+ ownerAgent: ownerAgentId,
598
+ ownerBinding: "explicit",
599
+ metadata: { pinetKind: "document_thread", documentId },
600
+ createdAt: now,
601
+ updatedAt: now,
602
+ });
603
+ }
604
+ else if (existing.ownerAgent !== ownerAgentId) {
605
+ this.options.db.updateThread(threadId, { ownerAgent: ownerAgentId });
606
+ }
607
+ this.emitInbound(threadId, ownerAgentId, body, {
608
+ pinetKind: "document_message",
609
+ schemaVersion: 1,
610
+ event,
611
+ documentId,
612
+ });
613
+ this.broadcast({ type: "document.updated", payload: { documentId } });
614
+ }
406
615
  serializeThread(thread, metadata, messageLimit) {
407
616
  const messages = this.options.db.getMessagesForThread(thread.threadId, messageLimit);
408
617
  return {
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { SlackAdapter } from "./broker/adapters/slack.js";
2
3
  export function readStoredSlackThreadContext(metadata) {
3
4
  const value = metadata?.slackThreadContext;
@@ -34,12 +35,45 @@ function getKnownSlackThread(broker, threadTs) {
34
35
  };
35
36
  }
36
37
  function rememberKnownSlackThread(broker, threadTs, channelId, context) {
37
- const existingMetadata = broker.db.getThread(threadTs)?.metadata ?? {};
38
+ const existing = broker.db.getThread(threadTs);
39
+ const existingMetadata = existing?.metadata ?? {};
40
+ const scopeKey = context ? JSON.stringify(context.scope) : "workspace";
41
+ const externalId = `${scopeKey}\0${channelId}\0${threadTs}`;
42
+ const referenceExternalId = `${channelId}\0${threadTs}`;
43
+ const aliasedDocument = broker.db.getDocumentByAlias("slack-thread-ref", referenceExternalId) ??
44
+ broker.db.getDocumentByAlias("slack", externalId);
45
+ const documentId = aliasedDocument?.documentId ??
46
+ `doc:slack-thread:${createHash("sha256").update(externalId).digest("hex")}`;
47
+ if (!aliasedDocument) {
48
+ broker.db.upsertDocument({
49
+ documentId,
50
+ kind: "slack_thread",
51
+ title: `Slack ${channelId}/${threadTs}`,
52
+ ownerAgent: existing?.ownerAgent ?? null,
53
+ ownerBinding: existing?.ownerBinding ?? null,
54
+ metadata: {
55
+ channelId,
56
+ threadTs,
57
+ ...(context ? { slackThreadContext: context } : {}),
58
+ },
59
+ });
60
+ }
61
+ broker.db.bindDocumentAlias("slack", externalId, documentId, {
62
+ channelId,
63
+ threadTs,
64
+ });
65
+ broker.db.bindDocumentAlias("slack-thread-ref", referenceExternalId, documentId, {
66
+ channelId,
67
+ threadTs,
68
+ });
38
69
  broker.db.updateThread(threadTs, {
39
70
  source: "slack",
40
71
  channel: channelId,
41
72
  metadata: {
42
73
  ...existingMetadata,
74
+ documentId,
75
+ documentAliasExternalId: externalId,
76
+ documentReferenceExternalId: referenceExternalId,
43
77
  ...(context ? { slackThreadContext: context } : {}),
44
78
  },
45
79
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pinet/slack-bridge",
3
- "version": "0.2.12",
3
+ "version": "0.2.13",
4
4
  "type": "module",
5
5
  "description": "Pi package for Pinet Slack assistant integration — multi-agent broker, thread routing, and inbox tools",
6
6
  "author": "Will Porcellini <5994936+gugu91@users.noreply.github.com>",
@@ -49,10 +49,10 @@
49
49
  "test": "vitest run --config ../vitest.config.ts"
50
50
  },
51
51
  "dependencies": {
52
- "@pinet/broker-core": "0.2.12",
53
- "@pinet/imessage-bridge": "0.2.12",
54
- "@pinet/pinet-core": "0.2.12",
55
- "@pinet/transport-core": "0.2.12",
52
+ "@pinet/broker-core": "0.2.13",
53
+ "@pinet/imessage-bridge": "0.2.13",
54
+ "@pinet/pinet-core": "0.2.13",
55
+ "@pinet/transport-core": "0.2.13",
56
56
  "@sinclair/typebox": "^0.34.49"
57
57
  },
58
58
  "types": "./dist/index.d.ts",