@pinet/slack-bridge 0.2.11 → 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.
- package/dist/code-anchor.d.ts +57 -0
- package/dist/code-anchor.js +144 -0
- package/dist/contextual-thread-context.d.ts +19 -0
- package/dist/contextual-thread-context.js +39 -0
- package/dist/index.js +94 -3
- package/dist/nvim-pinet-adapter.d.ts +83 -0
- package/dist/nvim-pinet-adapter.js +663 -0
- package/dist/pinet-tools.d.ts +6 -0
- package/dist/pinet-tools.js +53 -1
- package/dist/slack-pinet-runtime-adapter.js +35 -1
- package/package.json +5 -5
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export type ContextJsonValue = string | number | boolean | null | ContextJsonObject | ContextJsonValue[];
|
|
2
|
+
export interface ContextJsonObject {
|
|
3
|
+
[key: string]: ContextJsonValue | undefined;
|
|
4
|
+
}
|
|
5
|
+
export type CodeAnchorSide = "old" | "new";
|
|
6
|
+
export type CodeAnchorKind = "diff" | "normal";
|
|
7
|
+
export interface CodeRevisionIdentity extends ContextJsonObject {
|
|
8
|
+
repository: string;
|
|
9
|
+
worktree: string;
|
|
10
|
+
path: string;
|
|
11
|
+
baseOid: string | null;
|
|
12
|
+
headOid: string;
|
|
13
|
+
blobOid: string;
|
|
14
|
+
anchorKind: CodeAnchorKind;
|
|
15
|
+
side?: CodeAnchorSide;
|
|
16
|
+
headBlobOid?: string | null;
|
|
17
|
+
dirty?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface CodeAnchor extends CodeRevisionIdentity {
|
|
20
|
+
startLine: number;
|
|
21
|
+
endLine: number;
|
|
22
|
+
selectedTextSha256: string | null;
|
|
23
|
+
contextSha256: string | null;
|
|
24
|
+
}
|
|
25
|
+
export interface ContextualThreadState extends ContextJsonObject {
|
|
26
|
+
resolved: boolean;
|
|
27
|
+
resolvedAt?: string;
|
|
28
|
+
resolvedBy?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface ContextualThreadMetadata extends ContextJsonObject {
|
|
31
|
+
pinetKind: "contextual_thread";
|
|
32
|
+
schemaVersion: 1 | 2;
|
|
33
|
+
codeAnchor: CodeAnchor;
|
|
34
|
+
state: ContextualThreadState;
|
|
35
|
+
}
|
|
36
|
+
export declare function sha256Text(text: string): string;
|
|
37
|
+
export declare function buildNvimThreadId(repoSocketHash: string): string;
|
|
38
|
+
export declare function buildContextualThreadMetadata(input: {
|
|
39
|
+
repository: string;
|
|
40
|
+
worktree: string;
|
|
41
|
+
path: string;
|
|
42
|
+
baseOid?: string | null;
|
|
43
|
+
headOid: string;
|
|
44
|
+
blobOid: string;
|
|
45
|
+
anchorKind?: CodeAnchorKind;
|
|
46
|
+
side?: CodeAnchorSide;
|
|
47
|
+
headBlobOid?: string | null;
|
|
48
|
+
dirty?: boolean;
|
|
49
|
+
startLine: number;
|
|
50
|
+
endLine?: number;
|
|
51
|
+
selectedText?: string | null;
|
|
52
|
+
contextText?: string | null;
|
|
53
|
+
}): ContextualThreadMetadata;
|
|
54
|
+
export declare function parseContextualThreadMetadata(value: ContextJsonValue | undefined): ContextualThreadMetadata | null;
|
|
55
|
+
export declare function updateContextualThreadResolvedState(metadata: ContextualThreadMetadata, resolved: boolean, actor: string, now?: string): ContextualThreadMetadata;
|
|
56
|
+
export declare function hasSameCodeRevision(anchor: CodeAnchor, candidate: CodeRevisionIdentity): boolean;
|
|
57
|
+
export declare function formatAnchorForMessage(metadata: ContextualThreadMetadata): string;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
export function sha256Text(text) {
|
|
3
|
+
return createHash("sha256").update(text).digest("hex");
|
|
4
|
+
}
|
|
5
|
+
export function buildNvimThreadId(repoSocketHash) {
|
|
6
|
+
return `nvim:${repoSocketHash}:${randomUUID()}`;
|
|
7
|
+
}
|
|
8
|
+
export function buildContextualThreadMetadata(input) {
|
|
9
|
+
const startLine = Math.max(1, Math.floor(input.startLine));
|
|
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");
|
|
14
|
+
return {
|
|
15
|
+
pinetKind: "contextual_thread",
|
|
16
|
+
schemaVersion: anchorKind === "normal" ? 2 : 1,
|
|
17
|
+
codeAnchor: {
|
|
18
|
+
repository: input.repository,
|
|
19
|
+
worktree: input.worktree,
|
|
20
|
+
path: input.path,
|
|
21
|
+
baseOid: input.baseOid ?? null,
|
|
22
|
+
headOid: input.headOid,
|
|
23
|
+
blobOid: input.blobOid,
|
|
24
|
+
anchorKind,
|
|
25
|
+
...(input.side ? { side: input.side } : {}),
|
|
26
|
+
...(anchorKind === "normal"
|
|
27
|
+
? { headBlobOid: input.headBlobOid ?? null, dirty: input.dirty === true }
|
|
28
|
+
: {}),
|
|
29
|
+
startLine,
|
|
30
|
+
endLine,
|
|
31
|
+
selectedTextSha256: input.selectedText ? sha256Text(input.selectedText) : null,
|
|
32
|
+
contextSha256: input.contextText ? sha256Text(input.contextText) : null,
|
|
33
|
+
},
|
|
34
|
+
state: { resolved: false },
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function readRecord(value) {
|
|
38
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
39
|
+
}
|
|
40
|
+
function readString(record, key) {
|
|
41
|
+
const value = record[key];
|
|
42
|
+
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
43
|
+
}
|
|
44
|
+
function readPositiveInteger(record, key) {
|
|
45
|
+
const value = record[key];
|
|
46
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
47
|
+
return null;
|
|
48
|
+
const integer = Math.floor(value);
|
|
49
|
+
return integer > 0 ? integer : null;
|
|
50
|
+
}
|
|
51
|
+
export function parseContextualThreadMetadata(value) {
|
|
52
|
+
const metadata = readRecord(value);
|
|
53
|
+
if (!metadata ||
|
|
54
|
+
metadata.pinetKind !== "contextual_thread" ||
|
|
55
|
+
(metadata.schemaVersion !== 1 && metadata.schemaVersion !== 2)) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const anchorRecord = readRecord(metadata.codeAnchor);
|
|
59
|
+
const stateRecord = readRecord(metadata.state);
|
|
60
|
+
if (!anchorRecord || !stateRecord || typeof stateRecord.resolved !== "boolean")
|
|
61
|
+
return null;
|
|
62
|
+
const repository = readString(anchorRecord, "repository");
|
|
63
|
+
const worktree = readString(anchorRecord, "worktree");
|
|
64
|
+
const path = readString(anchorRecord, "path");
|
|
65
|
+
const headOid = readString(anchorRecord, "headOid");
|
|
66
|
+
const blobOid = readString(anchorRecord, "blobOid");
|
|
67
|
+
const anchorKind = metadata.schemaVersion === 2 ? anchorRecord.anchorKind : "diff";
|
|
68
|
+
if (anchorKind !== "diff" && anchorKind !== "normal")
|
|
69
|
+
return null;
|
|
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;
|
|
75
|
+
const startLine = readPositiveInteger(anchorRecord, "startLine");
|
|
76
|
+
const endLine = readPositiveInteger(anchorRecord, "endLine");
|
|
77
|
+
if (!repository ||
|
|
78
|
+
!worktree ||
|
|
79
|
+
!path ||
|
|
80
|
+
!headOid ||
|
|
81
|
+
!blobOid ||
|
|
82
|
+
startLine == null ||
|
|
83
|
+
endLine == null) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const resolvedAt = readString(stateRecord, "resolvedAt");
|
|
87
|
+
const resolvedBy = readString(stateRecord, "resolvedBy");
|
|
88
|
+
return {
|
|
89
|
+
pinetKind: "contextual_thread",
|
|
90
|
+
schemaVersion: metadata.schemaVersion,
|
|
91
|
+
codeAnchor: {
|
|
92
|
+
repository,
|
|
93
|
+
worktree,
|
|
94
|
+
path,
|
|
95
|
+
baseOid: readString(anchorRecord, "baseOid"),
|
|
96
|
+
headOid,
|
|
97
|
+
blobOid,
|
|
98
|
+
anchorKind,
|
|
99
|
+
...(side ? { side } : {}),
|
|
100
|
+
...(anchorKind === "normal"
|
|
101
|
+
? {
|
|
102
|
+
headBlobOid: readString(anchorRecord, "headBlobOid"),
|
|
103
|
+
dirty: anchorRecord.dirty === true,
|
|
104
|
+
}
|
|
105
|
+
: {}),
|
|
106
|
+
startLine,
|
|
107
|
+
endLine,
|
|
108
|
+
selectedTextSha256: readString(anchorRecord, "selectedTextSha256"),
|
|
109
|
+
contextSha256: readString(anchorRecord, "contextSha256"),
|
|
110
|
+
},
|
|
111
|
+
state: {
|
|
112
|
+
resolved: stateRecord.resolved,
|
|
113
|
+
...(resolvedAt ? { resolvedAt } : {}),
|
|
114
|
+
...(resolvedBy ? { resolvedBy } : {}),
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
export function updateContextualThreadResolvedState(metadata, resolved, actor, now = new Date().toISOString()) {
|
|
119
|
+
return {
|
|
120
|
+
...metadata,
|
|
121
|
+
state: resolved ? { resolved: true, resolvedAt: now, resolvedBy: actor } : { resolved: false },
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
export function hasSameCodeRevision(anchor, candidate) {
|
|
125
|
+
return (anchor.repository === candidate.repository &&
|
|
126
|
+
anchor.worktree === candidate.worktree &&
|
|
127
|
+
anchor.path === candidate.path &&
|
|
128
|
+
anchor.baseOid === candidate.baseOid &&
|
|
129
|
+
anchor.headOid === candidate.headOid &&
|
|
130
|
+
anchor.blobOid === candidate.blobOid &&
|
|
131
|
+
anchor.anchorKind === candidate.anchorKind &&
|
|
132
|
+
anchor.side === candidate.side &&
|
|
133
|
+
anchor.headBlobOid === candidate.headBlobOid &&
|
|
134
|
+
anchor.dirty === candidate.dirty);
|
|
135
|
+
}
|
|
136
|
+
export function formatAnchorForMessage(metadata) {
|
|
137
|
+
const anchor = metadata.codeAnchor;
|
|
138
|
+
const range = anchor.startLine === anchor.endLine
|
|
139
|
+
? `${anchor.path}:${anchor.startLine}`
|
|
140
|
+
: `${anchor.path}:${anchor.startLine}-${anchor.endLine}`;
|
|
141
|
+
const base = anchor.baseOid ? ` base=${anchor.baseOid}` : "";
|
|
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}]`;
|
|
144
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type ContextualThreadMetadata } from "./code-anchor.js";
|
|
2
|
+
import type { ThreadInfo } from "./broker/types.js";
|
|
3
|
+
export interface ContextualThreadContextMessage {
|
|
4
|
+
sender: string;
|
|
5
|
+
body: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ContextualThreadContextRepository {
|
|
8
|
+
repository: string;
|
|
9
|
+
worktree: string;
|
|
10
|
+
headOid: string;
|
|
11
|
+
baseOid: string | null;
|
|
12
|
+
}
|
|
13
|
+
interface OpenContextualThread {
|
|
14
|
+
thread: ThreadInfo;
|
|
15
|
+
metadata: ContextualThreadMetadata;
|
|
16
|
+
}
|
|
17
|
+
export declare function selectOpenContextualThreads(threads: readonly ThreadInfo[], repository: ContextualThreadContextRepository, limit?: number): OpenContextualThread[];
|
|
18
|
+
export declare function formatContextualThreadContext(items: readonly OpenContextualThread[], messagesByThread: ReadonlyMap<string, readonly ContextualThreadContextMessage[]>, maxCharacters?: number): string;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { parseContextualThreadMetadata, } from "./code-anchor.js";
|
|
2
|
+
export function selectOpenContextualThreads(threads, repository, limit = 5) {
|
|
3
|
+
return threads
|
|
4
|
+
.map((thread) => ({
|
|
5
|
+
thread,
|
|
6
|
+
metadata: parseContextualThreadMetadata(thread.metadata),
|
|
7
|
+
}))
|
|
8
|
+
.filter((item) => item.metadata !== null &&
|
|
9
|
+
!item.metadata.state.resolved &&
|
|
10
|
+
item.metadata.codeAnchor.repository === repository.repository &&
|
|
11
|
+
item.metadata.codeAnchor.worktree === repository.worktree &&
|
|
12
|
+
item.metadata.codeAnchor.headOid === repository.headOid &&
|
|
13
|
+
item.metadata.codeAnchor.baseOid === repository.baseOid)
|
|
14
|
+
.sort((left, right) => right.thread.updatedAt.localeCompare(left.thread.updatedAt))
|
|
15
|
+
.slice(0, Math.max(0, limit));
|
|
16
|
+
}
|
|
17
|
+
export function formatContextualThreadContext(items, messagesByThread, maxCharacters = 5000) {
|
|
18
|
+
if (items.length === 0)
|
|
19
|
+
return "";
|
|
20
|
+
const lines = [
|
|
21
|
+
"Open persisted Pinet code threads relevant to this worktree and revision:",
|
|
22
|
+
"Reply with `pinet action=reply args.thread_id=<id> args.message=<text>`; resolved threads are omitted.",
|
|
23
|
+
];
|
|
24
|
+
for (const { thread, metadata } of items) {
|
|
25
|
+
const anchor = metadata.codeAnchor;
|
|
26
|
+
const range = anchor.startLine === anchor.endLine
|
|
27
|
+
? `${anchor.path}:${anchor.startLine}`
|
|
28
|
+
: `${anchor.path}:${anchor.startLine}-${anchor.endLine}`;
|
|
29
|
+
lines.push(`- ${thread.threadId} — ${range} (${anchor.side})`);
|
|
30
|
+
for (const message of (messagesByThread.get(thread.threadId) ?? []).slice(-3)) {
|
|
31
|
+
const compactBody = message.body.replace(/\s+/g, " ").trim().slice(0, 600);
|
|
32
|
+
if (compactBody)
|
|
33
|
+
lines.push(` ${message.sender}: ${compactBody}`);
|
|
34
|
+
}
|
|
35
|
+
if (lines.join("\n").length >= maxCharacters)
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
return lines.join("\n").slice(0, maxCharacters);
|
|
39
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -22,6 +22,8 @@ import { createFollowerRuntime, resolveBrokerSocketPath, } from "./follower-runt
|
|
|
22
22
|
import { createSinglePlayerRuntime, } from "./single-player-runtime.js";
|
|
23
23
|
import { createBrokerRuntime } from "./broker-runtime.js";
|
|
24
24
|
import { createSlackPinetRuntimeAdapterFactory } from "./slack-pinet-runtime-adapter.js";
|
|
25
|
+
import { createNvimPinetRuntimeAdapterFactory, resolveNvimRepositoryContext, } from "./nvim-pinet-adapter.js";
|
|
26
|
+
import { formatContextualThreadContext, selectOpenContextualThreads, } from "./contextual-thread-context.js";
|
|
25
27
|
import { SlackActivityLogger } from "./activity-log.js";
|
|
26
28
|
import { createBrokerDeliveryState, queueBrokerInboxIds } from "./broker-delivery.js";
|
|
27
29
|
import { buildBrokerControlPlaneDashboardSnapshot } from "./broker/control-plane-dashboard.js";
|
|
@@ -629,8 +631,22 @@ export default function (pi) {
|
|
|
629
631
|
broker.db.queueMessage(decision.agentId, routedMessage);
|
|
630
632
|
return;
|
|
631
633
|
}
|
|
632
|
-
if (decision.action === "
|
|
633
|
-
const
|
|
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") {
|
|
649
|
+
const persisted = routedMessage.threadId
|
|
634
650
|
? persistDeliveredInboundMessage(broker.db, selfId, routedMessage)
|
|
635
651
|
: null;
|
|
636
652
|
if (persisted && !persisted.result.freshDelivery) {
|
|
@@ -688,7 +704,7 @@ export default function (pi) {
|
|
|
688
704
|
},
|
|
689
705
|
buildControlPlaneDashboardSnapshot: (input) => buildBrokerControlPlaneDashboardSnapshot(input),
|
|
690
706
|
buildCurrentDashboardSnapshot: async (openedAt) => buildCurrentBrokerControlPlaneDashboardSnapshot(openedAt),
|
|
691
|
-
createAdapterBindings: [slackPinetAdapterFactory],
|
|
707
|
+
createAdapterBindings: [slackPinetAdapterFactory, createNvimPinetRuntimeAdapterFactory()],
|
|
692
708
|
onAdminShutdownRequested: async (ctx) => {
|
|
693
709
|
// `/pinet start replace` from another local session: stop being the
|
|
694
710
|
// broker but keep this session alive (issue #951).
|
|
@@ -1223,6 +1239,30 @@ export default function (pi) {
|
|
|
1223
1239
|
requireToolPolicy,
|
|
1224
1240
|
sendPinetAgentMessage,
|
|
1225
1241
|
sendPinetBroadcastMessage,
|
|
1242
|
+
replyToPinetThread: async (threadId, body) => {
|
|
1243
|
+
if (brokerRole !== "broker") {
|
|
1244
|
+
if (!brokerClient?.client)
|
|
1245
|
+
throw new Error("Pinet is in an unexpected state.");
|
|
1246
|
+
const result = await brokerClient.client.sendMessage({ threadId, body });
|
|
1247
|
+
return {
|
|
1248
|
+
messageId: result.messageId,
|
|
1249
|
+
threadId: result.threadId,
|
|
1250
|
+
source: result.source,
|
|
1251
|
+
channel: result.channel,
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
const broker = getActiveBroker();
|
|
1255
|
+
const selfId = getActiveBrokerSelfId();
|
|
1256
|
+
if (!broker || !selfId)
|
|
1257
|
+
throw new Error("Broker agent identity is unavailable.");
|
|
1258
|
+
const result = await sendBrokerMessage({ db: broker.db, adapters: broker.adapters }, { threadId, body, senderAgentId: selfId });
|
|
1259
|
+
return {
|
|
1260
|
+
messageId: result.message.id,
|
|
1261
|
+
threadId: result.thread.threadId,
|
|
1262
|
+
source: result.thread.source,
|
|
1263
|
+
channel: result.thread.channel,
|
|
1264
|
+
};
|
|
1265
|
+
},
|
|
1226
1266
|
signalAgentFree,
|
|
1227
1267
|
scheduleBrokerWakeup,
|
|
1228
1268
|
scheduleFollowerWakeup,
|
|
@@ -1603,6 +1643,57 @@ export default function (pi) {
|
|
|
1603
1643
|
});
|
|
1604
1644
|
// ─── Agent event wiring ──────────────────────────────
|
|
1605
1645
|
agentEventRuntime.register(pi);
|
|
1646
|
+
pi.on("before_agent_start", async (_event, ctx) => {
|
|
1647
|
+
if (!pinetEnabled || !brokerRole)
|
|
1648
|
+
return;
|
|
1649
|
+
const repository = resolveNvimRepositoryContext(ctx.cwd);
|
|
1650
|
+
if (!repository)
|
|
1651
|
+
return;
|
|
1652
|
+
try {
|
|
1653
|
+
const brokerDb = brokerRole === "broker" ? getActiveBrokerDb() : null;
|
|
1654
|
+
const selfId = brokerRole === "broker" ? getActiveBrokerSelfId() : null;
|
|
1655
|
+
const threads = brokerDb && selfId
|
|
1656
|
+
? brokerDb.getThreads(selfId)
|
|
1657
|
+
: brokerRole === "follower" && brokerClient?.client
|
|
1658
|
+
? await brokerClient.client.listThreads()
|
|
1659
|
+
: [];
|
|
1660
|
+
const selected = selectOpenContextualThreads(threads, repository, 5);
|
|
1661
|
+
if (selected.length === 0)
|
|
1662
|
+
return;
|
|
1663
|
+
const messagesByThread = new Map();
|
|
1664
|
+
for (const item of selected) {
|
|
1665
|
+
if (brokerDb) {
|
|
1666
|
+
messagesByThread.set(item.thread.threadId, brokerDb
|
|
1667
|
+
.getMessagesForThread(item.thread.threadId, 6)
|
|
1668
|
+
.map((message) => ({ sender: message.sender, body: message.body })));
|
|
1669
|
+
continue;
|
|
1670
|
+
}
|
|
1671
|
+
if (brokerClient?.client) {
|
|
1672
|
+
const result = await brokerClient.client.readInbox({
|
|
1673
|
+
threadId: item.thread.threadId,
|
|
1674
|
+
unreadOnly: false,
|
|
1675
|
+
markRead: false,
|
|
1676
|
+
limit: 6,
|
|
1677
|
+
});
|
|
1678
|
+
messagesByThread.set(item.thread.threadId, result.messages.map(({ message }) => ({ sender: message.sender, body: message.body })));
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
const content = formatContextualThreadContext(selected, messagesByThread);
|
|
1682
|
+
if (!content)
|
|
1683
|
+
return;
|
|
1684
|
+
return {
|
|
1685
|
+
message: {
|
|
1686
|
+
customType: "pinet-contextual-threads",
|
|
1687
|
+
content,
|
|
1688
|
+
display: true,
|
|
1689
|
+
},
|
|
1690
|
+
};
|
|
1691
|
+
}
|
|
1692
|
+
catch (error) {
|
|
1693
|
+
console.error(`[slack-bridge] contextual thread hydration failed: ${msg(error)}`);
|
|
1694
|
+
return;
|
|
1695
|
+
}
|
|
1696
|
+
});
|
|
1606
1697
|
pi.on("session_before_compact", (event) => {
|
|
1607
1698
|
compactionGate.begin(event.signal);
|
|
1608
1699
|
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { type CodeRevisionIdentity, type ContextJsonValue } from "./code-anchor.js";
|
|
2
|
+
import type { BrokerMessage, InboundMessage, MessageAdapter, OutboundMessage, ThreadInfo, DocumentInfo } from "./broker/types.js";
|
|
3
|
+
export interface NvimAdapterDbPort {
|
|
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[];
|
|
14
|
+
createThread(thread: ThreadInfo): ThreadInfo;
|
|
15
|
+
updateThread(threadId: string, updates: Partial<ThreadInfo>): void;
|
|
16
|
+
getThreads(ownerAgent?: string): ThreadInfo[];
|
|
17
|
+
getMessagesForThread(threadId: string, limit?: number): BrokerMessage[];
|
|
18
|
+
insertMessage(threadId: string, source: string, direction: "inbound" | "outbound", sender: string, body: string, targetAgentIds: string[], metadata?: Record<string, ContextJsonValue>): BrokerMessage;
|
|
19
|
+
getAgent?(agentId: string): {
|
|
20
|
+
id: string;
|
|
21
|
+
name: string;
|
|
22
|
+
} | null;
|
|
23
|
+
}
|
|
24
|
+
export interface NvimRepositoryContext {
|
|
25
|
+
repository: string;
|
|
26
|
+
worktree: string;
|
|
27
|
+
branch: string;
|
|
28
|
+
headOid: string;
|
|
29
|
+
baseOid: string | null;
|
|
30
|
+
}
|
|
31
|
+
export interface NvimPinetAdapterOptions extends NvimRepositoryContext {
|
|
32
|
+
db: NvimAdapterDbPort;
|
|
33
|
+
getAgentById: (agentId: string) => {
|
|
34
|
+
id: string;
|
|
35
|
+
name: string;
|
|
36
|
+
} | null;
|
|
37
|
+
}
|
|
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;
|
|
41
|
+
export declare function computeNvimSocketPath(worktree: string, branch: string): string;
|
|
42
|
+
export declare class NvimPinetAdapter implements MessageAdapter {
|
|
43
|
+
private readonly options;
|
|
44
|
+
readonly name = "nvim";
|
|
45
|
+
private readonly repoSocketHash;
|
|
46
|
+
private readonly socketPath;
|
|
47
|
+
private server;
|
|
48
|
+
private inboundHandler;
|
|
49
|
+
private readonly clients;
|
|
50
|
+
private readonly editorState;
|
|
51
|
+
constructor(options: NvimPinetAdapterOptions);
|
|
52
|
+
connect(): Promise<void>;
|
|
53
|
+
disconnect(): Promise<void>;
|
|
54
|
+
onInbound(handler: (msg: InboundMessage) => void): void;
|
|
55
|
+
send(message: OutboundMessage): Promise<void>;
|
|
56
|
+
private accept;
|
|
57
|
+
private handleLine;
|
|
58
|
+
private handleRequest;
|
|
59
|
+
private resolveGitDocument;
|
|
60
|
+
private createThread;
|
|
61
|
+
private replyToThread;
|
|
62
|
+
private resolveThread;
|
|
63
|
+
private listThreads;
|
|
64
|
+
private getThread;
|
|
65
|
+
private getDocument;
|
|
66
|
+
private setDocumentOwner;
|
|
67
|
+
private subscribeDocument;
|
|
68
|
+
private bindDocumentThread;
|
|
69
|
+
private emitDocumentEvent;
|
|
70
|
+
private serializeThread;
|
|
71
|
+
private emitInbound;
|
|
72
|
+
private broadcast;
|
|
73
|
+
}
|
|
74
|
+
export declare function createNvimPinetRuntimeAdapterFactory(): (context: {
|
|
75
|
+
broker: {
|
|
76
|
+
db: NvimAdapterDbPort;
|
|
77
|
+
};
|
|
78
|
+
ctx: {
|
|
79
|
+
cwd: string;
|
|
80
|
+
};
|
|
81
|
+
}) => {
|
|
82
|
+
adapter: MessageAdapter;
|
|
83
|
+
} | [];
|
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as net from "node:net";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { buildContextualThreadMetadata, buildNvimThreadId, formatAnchorForMessage, hasSameCodeRevision, parseContextualThreadMetadata, updateContextualThreadResolvedState, } from "./code-anchor.js";
|
|
7
|
+
function git(cwd, ...args) {
|
|
8
|
+
return execFileSync("git", args, {
|
|
9
|
+
cwd,
|
|
10
|
+
encoding: "utf-8",
|
|
11
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
12
|
+
}).trim();
|
|
13
|
+
}
|
|
14
|
+
export function resolveNvimRepositoryContext(cwd) {
|
|
15
|
+
try {
|
|
16
|
+
const worktree = fs.realpathSync(git(cwd, "rev-parse", "--show-toplevel"));
|
|
17
|
+
const commonDir = fs.realpathSync(git(cwd, "rev-parse", "--path-format=absolute", "--git-common-dir"));
|
|
18
|
+
const repository = path.basename(commonDir) === ".git" ? path.dirname(commonDir) : commonDir;
|
|
19
|
+
const branch = git(cwd, "branch", "--show-current");
|
|
20
|
+
const headOid = git(cwd, "rev-parse", "HEAD");
|
|
21
|
+
let baseOid = null;
|
|
22
|
+
try {
|
|
23
|
+
baseOid = git(cwd, "merge-base", "HEAD", "@{upstream}");
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
baseOid = null;
|
|
27
|
+
}
|
|
28
|
+
return { repository, worktree, branch, headOid, baseOid };
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
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
|
+
}
|
|
40
|
+
function computeRepoSocketHash(worktree, branch) {
|
|
41
|
+
return createHash("sha256").update(`${worktree}:${branch}`).digest("hex");
|
|
42
|
+
}
|
|
43
|
+
export function computeNvimSocketPath(worktree, branch) {
|
|
44
|
+
const dir = "/tmp/pi-nvim";
|
|
45
|
+
if (!fs.existsSync(dir))
|
|
46
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
47
|
+
fs.chmodSync(dir, 0o700);
|
|
48
|
+
return path.join(dir, `${computeRepoSocketHash(worktree, branch)}.sock`);
|
|
49
|
+
}
|
|
50
|
+
function getRecord(value) {
|
|
51
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
52
|
+
}
|
|
53
|
+
function getString(record, key) {
|
|
54
|
+
const value = record[key];
|
|
55
|
+
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
56
|
+
}
|
|
57
|
+
function getPositiveInteger(record, key, fallback) {
|
|
58
|
+
const value = record[key];
|
|
59
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
60
|
+
return fallback;
|
|
61
|
+
const integer = Math.floor(value);
|
|
62
|
+
return integer > 0 ? integer : fallback;
|
|
63
|
+
}
|
|
64
|
+
// agent-standards-ignore prefer-inline-single-use-helper: versioned socket envelope parser is a protocol boundary.
|
|
65
|
+
function parseRequest(line) {
|
|
66
|
+
const parsed = JSON.parse(line);
|
|
67
|
+
const record = getRecord(parsed);
|
|
68
|
+
const id = record ? getString(record, "id") : null;
|
|
69
|
+
const type = record ? getString(record, "type") : null;
|
|
70
|
+
if (!record || !type)
|
|
71
|
+
return { id, request: null };
|
|
72
|
+
return {
|
|
73
|
+
id,
|
|
74
|
+
request: {
|
|
75
|
+
id,
|
|
76
|
+
type,
|
|
77
|
+
payload: getRecord(record.payload) ?? record,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function parseAnchor(payload) {
|
|
82
|
+
const anchor = getRecord(payload.anchor);
|
|
83
|
+
if (!anchor)
|
|
84
|
+
return null;
|
|
85
|
+
const repository = getString(anchor, "repository");
|
|
86
|
+
const worktree = getString(anchor, "worktree");
|
|
87
|
+
const anchorPath = getString(anchor, "path");
|
|
88
|
+
const headOid = getString(anchor, "headOid");
|
|
89
|
+
const blobOid = getString(anchor, "blobOid");
|
|
90
|
+
const side = anchor.side === "old" || anchor.side === "new" ? anchor.side : null;
|
|
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")
|
|
101
|
+
return null;
|
|
102
|
+
return {
|
|
103
|
+
repository,
|
|
104
|
+
worktree,
|
|
105
|
+
path: anchorPath,
|
|
106
|
+
baseOid: getString(anchor, "baseOid"),
|
|
107
|
+
headOid,
|
|
108
|
+
blobOid,
|
|
109
|
+
anchorKind,
|
|
110
|
+
...(side ? { side } : {}),
|
|
111
|
+
...(anchorKind === "normal"
|
|
112
|
+
? { headBlobOid: getString(anchor, "headBlobOid"), dirty: anchor.dirty === true }
|
|
113
|
+
: {}),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
// agent-standards-ignore prefer-inline-single-use-helper: create payload parser is a named protocol DTO boundary.
|
|
117
|
+
function parseCreateThreadRequest(payload) {
|
|
118
|
+
const targetAgentId = getString(payload, "targetAgentId");
|
|
119
|
+
const body = getString(payload, "body");
|
|
120
|
+
const anchor = parseAnchor(payload);
|
|
121
|
+
const startLine = getPositiveInteger(payload, "startLine", null);
|
|
122
|
+
const endLine = getPositiveInteger(payload, "endLine", startLine);
|
|
123
|
+
if (!body || !anchor || startLine == null || endLine == null)
|
|
124
|
+
return null;
|
|
125
|
+
return {
|
|
126
|
+
targetAgentId,
|
|
127
|
+
body,
|
|
128
|
+
anchor,
|
|
129
|
+
startLine,
|
|
130
|
+
endLine,
|
|
131
|
+
selectedText: getString(payload, "selectedText"),
|
|
132
|
+
contextText: getString(payload, "contextText"),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
// agent-standards-ignore prefer-inline-single-use-helper: reply payload parser is a named protocol DTO boundary.
|
|
136
|
+
function parseReplyRequest(payload) {
|
|
137
|
+
const threadId = getString(payload, "threadId");
|
|
138
|
+
const body = getString(payload, "body");
|
|
139
|
+
return threadId && body ? { threadId, body } : null;
|
|
140
|
+
}
|
|
141
|
+
// agent-standards-ignore prefer-inline-single-use-helper: resolution payload parser is a named protocol DTO boundary.
|
|
142
|
+
function parseResolveRequest(payload) {
|
|
143
|
+
const threadId = getString(payload, "threadId");
|
|
144
|
+
if (!threadId || typeof payload.resolved !== "boolean")
|
|
145
|
+
return null;
|
|
146
|
+
return { threadId, resolved: payload.resolved };
|
|
147
|
+
}
|
|
148
|
+
// agent-standards-ignore prefer-inline-single-use-helper: list payload parser is a named protocol DTO boundary.
|
|
149
|
+
function parseListRequest(payload) {
|
|
150
|
+
const anchor = parseAnchor(payload);
|
|
151
|
+
if (!anchor)
|
|
152
|
+
return null;
|
|
153
|
+
return {
|
|
154
|
+
anchor,
|
|
155
|
+
includeResolved: typeof payload.includeResolved === "boolean" ? payload.includeResolved : false,
|
|
156
|
+
limit: getPositiveInteger(payload, "limit", 100) ?? 100,
|
|
157
|
+
};
|
|
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
|
+
}
|
|
174
|
+
function serializeMetadata(metadata) {
|
|
175
|
+
return JSON.parse(JSON.stringify(metadata));
|
|
176
|
+
}
|
|
177
|
+
function sendJson(socket, payload) {
|
|
178
|
+
socket.write(`${JSON.stringify(payload)}\n`);
|
|
179
|
+
}
|
|
180
|
+
export class NvimPinetAdapter {
|
|
181
|
+
options;
|
|
182
|
+
name = "nvim";
|
|
183
|
+
repoSocketHash;
|
|
184
|
+
socketPath;
|
|
185
|
+
server = null;
|
|
186
|
+
inboundHandler = null;
|
|
187
|
+
clients = new Set();
|
|
188
|
+
editorState = {
|
|
189
|
+
file: null,
|
|
190
|
+
line: null,
|
|
191
|
+
visibleStart: null,
|
|
192
|
+
visibleEnd: null,
|
|
193
|
+
selectionStart: null,
|
|
194
|
+
selectionEnd: null,
|
|
195
|
+
};
|
|
196
|
+
constructor(options) {
|
|
197
|
+
this.options = options;
|
|
198
|
+
this.repoSocketHash = computeRepoSocketHash(options.worktree, options.branch);
|
|
199
|
+
this.socketPath = computeNvimSocketPath(options.worktree, options.branch);
|
|
200
|
+
}
|
|
201
|
+
async connect() {
|
|
202
|
+
try {
|
|
203
|
+
fs.unlinkSync(this.socketPath);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
// Missing stale socket is fine.
|
|
207
|
+
}
|
|
208
|
+
await new Promise((resolve, reject) => {
|
|
209
|
+
this.server = net.createServer((socket) => this.accept(socket));
|
|
210
|
+
this.server.once("error", reject);
|
|
211
|
+
this.server.listen(this.socketPath, () => {
|
|
212
|
+
this.server?.off("error", reject);
|
|
213
|
+
fs.chmodSync(this.socketPath, 0o600);
|
|
214
|
+
resolve();
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
async disconnect() {
|
|
219
|
+
for (const client of this.clients)
|
|
220
|
+
client.destroy();
|
|
221
|
+
this.clients.clear();
|
|
222
|
+
await new Promise((resolve) => {
|
|
223
|
+
if (!this.server)
|
|
224
|
+
return resolve();
|
|
225
|
+
this.server.close(() => resolve());
|
|
226
|
+
this.server = null;
|
|
227
|
+
});
|
|
228
|
+
try {
|
|
229
|
+
fs.unlinkSync(this.socketPath);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
// Ignore stale socket cleanup failures.
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
onInbound(handler) {
|
|
236
|
+
this.inboundHandler = handler;
|
|
237
|
+
}
|
|
238
|
+
async send(message) {
|
|
239
|
+
this.broadcast({
|
|
240
|
+
type: "thread.updated",
|
|
241
|
+
payload: {
|
|
242
|
+
threadId: message.threadId,
|
|
243
|
+
channel: message.channel,
|
|
244
|
+
body: message.text,
|
|
245
|
+
sender: message.agentName ?? "agent",
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
accept(socket) {
|
|
250
|
+
this.clients.add(socket);
|
|
251
|
+
let buffer = "";
|
|
252
|
+
socket.on("data", (data) => {
|
|
253
|
+
buffer += data.toString();
|
|
254
|
+
const lines = buffer.split("\n");
|
|
255
|
+
buffer = lines.pop() ?? "";
|
|
256
|
+
for (const line of lines) {
|
|
257
|
+
if (line.trim())
|
|
258
|
+
this.handleLine(socket, line);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
socket.on("close", () => this.clients.delete(socket));
|
|
262
|
+
socket.on("error", () => this.clients.delete(socket));
|
|
263
|
+
}
|
|
264
|
+
handleLine(socket, line) {
|
|
265
|
+
let request = null;
|
|
266
|
+
let requestId = null;
|
|
267
|
+
try {
|
|
268
|
+
const parsed = parseRequest(line);
|
|
269
|
+
request = parsed.request;
|
|
270
|
+
requestId = parsed.id;
|
|
271
|
+
if (!request) {
|
|
272
|
+
sendJson(socket, {
|
|
273
|
+
type: "error",
|
|
274
|
+
id: requestId ?? "request-error",
|
|
275
|
+
error: { code: "invalid_request", message: "type is required" },
|
|
276
|
+
});
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const result = this.handleRequest(request);
|
|
280
|
+
if (request.id)
|
|
281
|
+
sendJson(socket, { type: "ok", id: request.id, result });
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
sendJson(socket, {
|
|
285
|
+
type: "error",
|
|
286
|
+
id: requestId ?? request?.id ?? "request-error",
|
|
287
|
+
error: {
|
|
288
|
+
code: "request_error",
|
|
289
|
+
message: error instanceof Error ? error.message : "nvim request failed",
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
handleRequest(request) {
|
|
295
|
+
switch (request.type) {
|
|
296
|
+
case "buffer_focus": {
|
|
297
|
+
const file = getString(request.payload, "file");
|
|
298
|
+
const line = getPositiveInteger(request.payload, "line", null);
|
|
299
|
+
if (file)
|
|
300
|
+
this.editorState.file = file;
|
|
301
|
+
if (line != null)
|
|
302
|
+
this.editorState.line = line;
|
|
303
|
+
return { status: "ok" };
|
|
304
|
+
}
|
|
305
|
+
case "visible_range": {
|
|
306
|
+
const file = getString(request.payload, "file");
|
|
307
|
+
if (file)
|
|
308
|
+
this.editorState.file = file;
|
|
309
|
+
this.editorState.visibleStart = getPositiveInteger(request.payload, "start", null);
|
|
310
|
+
this.editorState.visibleEnd = getPositiveInteger(request.payload, "end", null);
|
|
311
|
+
return { status: "ok" };
|
|
312
|
+
}
|
|
313
|
+
case "selection": {
|
|
314
|
+
const file = getString(request.payload, "file");
|
|
315
|
+
if (file)
|
|
316
|
+
this.editorState.file = file;
|
|
317
|
+
this.editorState.selectionStart = getPositiveInteger(request.payload, "start", null);
|
|
318
|
+
this.editorState.selectionEnd = getPositiveInteger(request.payload, "end", null);
|
|
319
|
+
return { status: "ok" };
|
|
320
|
+
}
|
|
321
|
+
case "editor.context":
|
|
322
|
+
return { ...this.editorState };
|
|
323
|
+
case "editor.open": {
|
|
324
|
+
const file = getString(request.payload, "file");
|
|
325
|
+
if (!file)
|
|
326
|
+
throw new Error("file is required");
|
|
327
|
+
const line = getPositiveInteger(request.payload, "line", null);
|
|
328
|
+
this.broadcast({ type: "open_file", file, ...(line ? { line } : {}) });
|
|
329
|
+
return { delivered: this.clients.size > 1 };
|
|
330
|
+
}
|
|
331
|
+
case "pinet.thread.create":
|
|
332
|
+
return this.createThread(request.payload);
|
|
333
|
+
case "pinet.thread.reply":
|
|
334
|
+
return this.replyToThread(request.payload);
|
|
335
|
+
case "pinet.thread.resolve":
|
|
336
|
+
return this.resolveThread(request.payload);
|
|
337
|
+
case "pinet.thread.list":
|
|
338
|
+
return this.listThreads(request.payload);
|
|
339
|
+
case "pinet.thread.get":
|
|
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);
|
|
351
|
+
default:
|
|
352
|
+
throw new Error(`Unknown nvim request: ${request.type}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
resolveGitDocument(anchor) {
|
|
356
|
+
return (this.options.db.getDocumentByAlias("nvim", buildGitFileAliasExternalId(anchor)) ??
|
|
357
|
+
this.options.db.getDocument(buildGitFileDocumentId(anchor)));
|
|
358
|
+
}
|
|
359
|
+
createThread(payload) {
|
|
360
|
+
const parsed = parseCreateThreadRequest(payload);
|
|
361
|
+
if (!parsed)
|
|
362
|
+
throw new Error("body, anchor, startLine, and endLine are required");
|
|
363
|
+
if (parsed.anchor.repository !== this.options.repository ||
|
|
364
|
+
parsed.anchor.worktree !== this.options.worktree) {
|
|
365
|
+
throw new Error("code anchor does not belong to this Pinet worktree");
|
|
366
|
+
}
|
|
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);
|
|
389
|
+
}
|
|
390
|
+
const metadata = buildContextualThreadMetadata({
|
|
391
|
+
...parsed.anchor,
|
|
392
|
+
startLine: parsed.startLine,
|
|
393
|
+
endLine: parsed.endLine,
|
|
394
|
+
selectedText: parsed.selectedText,
|
|
395
|
+
contextText: parsed.contextText,
|
|
396
|
+
});
|
|
397
|
+
const threadId = buildNvimThreadId(this.repoSocketHash);
|
|
398
|
+
const now = new Date().toISOString();
|
|
399
|
+
this.options.db.createThread({
|
|
400
|
+
threadId,
|
|
401
|
+
source: "nvim",
|
|
402
|
+
channel: this.repoSocketHash,
|
|
403
|
+
ownerAgent: targetAgentId,
|
|
404
|
+
ownerBinding: "explicit",
|
|
405
|
+
metadata: { ...serializeMetadata(metadata), documentId },
|
|
406
|
+
createdAt: now,
|
|
407
|
+
updatedAt: now,
|
|
408
|
+
});
|
|
409
|
+
this.emitInbound(threadId, targetAgentId, `${formatAnchorForMessage(metadata)}\n\n${parsed.body}`, {
|
|
410
|
+
pinetKind: "contextual_thread_message",
|
|
411
|
+
schemaVersion: 1,
|
|
412
|
+
event: "thread.created",
|
|
413
|
+
documentId,
|
|
414
|
+
});
|
|
415
|
+
return { threadId, metadata: serializeMetadata(metadata) };
|
|
416
|
+
}
|
|
417
|
+
replyToThread(payload) {
|
|
418
|
+
const parsed = parseReplyRequest(payload);
|
|
419
|
+
if (!parsed)
|
|
420
|
+
throw new Error("threadId and body are required");
|
|
421
|
+
const thread = this.options.db.getThread(parsed.threadId);
|
|
422
|
+
if (!thread)
|
|
423
|
+
throw new Error(`Unknown thread: ${parsed.threadId}`);
|
|
424
|
+
const documentId = typeof thread.metadata?.documentId === "string" ? thread.metadata.documentId : null;
|
|
425
|
+
this.emitInbound(parsed.threadId, thread.ownerAgent ?? "", parsed.body, {
|
|
426
|
+
pinetKind: "contextual_thread_message",
|
|
427
|
+
schemaVersion: 1,
|
|
428
|
+
event: "thread.reply",
|
|
429
|
+
...(documentId ? { documentId } : {}),
|
|
430
|
+
});
|
|
431
|
+
return { threadId: parsed.threadId };
|
|
432
|
+
}
|
|
433
|
+
resolveThread(payload) {
|
|
434
|
+
const parsed = parseResolveRequest(payload);
|
|
435
|
+
if (!parsed)
|
|
436
|
+
throw new Error("threadId and resolved are required");
|
|
437
|
+
const thread = this.options.db.getThread(parsed.threadId);
|
|
438
|
+
const metadata = parseContextualThreadMetadata(thread?.metadata);
|
|
439
|
+
if (!thread || !metadata)
|
|
440
|
+
throw new Error(`Unknown contextual thread: ${parsed.threadId}`);
|
|
441
|
+
const updated = updateContextualThreadResolvedState(metadata, parsed.resolved, "nvim");
|
|
442
|
+
this.options.db.updateThread(parsed.threadId, { metadata: serializeMetadata(updated) });
|
|
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.", {
|
|
445
|
+
pinetKind: "contextual_thread_message",
|
|
446
|
+
schemaVersion: 1,
|
|
447
|
+
event: parsed.resolved ? "thread.resolved" : "thread.reopened",
|
|
448
|
+
...(documentId ? { documentId } : {}),
|
|
449
|
+
});
|
|
450
|
+
this.broadcast({ type: "thread.updated", payload: { threadId: parsed.threadId } });
|
|
451
|
+
return { threadId: parsed.threadId, metadata: serializeMetadata(updated) };
|
|
452
|
+
}
|
|
453
|
+
listThreads(payload) {
|
|
454
|
+
const parsed = parseListRequest(payload);
|
|
455
|
+
if (!parsed)
|
|
456
|
+
throw new Error("revision-aware anchor is required");
|
|
457
|
+
const threads = this.options.db
|
|
458
|
+
.getThreads()
|
|
459
|
+
.map((thread) => ({
|
|
460
|
+
thread,
|
|
461
|
+
metadata: parseContextualThreadMetadata(thread.metadata),
|
|
462
|
+
}))
|
|
463
|
+
.filter((item) => item.metadata !== null && hasSameCodeRevision(item.metadata.codeAnchor, parsed.anchor))
|
|
464
|
+
.filter((item) => parsed.includeResolved || !item.metadata.state.resolved)
|
|
465
|
+
.slice(0, parsed.limit)
|
|
466
|
+
.map((item) => this.serializeThread(item.thread, item.metadata, 20));
|
|
467
|
+
return { threads };
|
|
468
|
+
}
|
|
469
|
+
getThread(payload) {
|
|
470
|
+
const threadId = getString(payload, "threadId");
|
|
471
|
+
if (!threadId)
|
|
472
|
+
throw new Error("threadId is required");
|
|
473
|
+
const thread = this.options.db.getThread(threadId);
|
|
474
|
+
const metadata = parseContextualThreadMetadata(thread?.metadata);
|
|
475
|
+
if (!thread || !metadata)
|
|
476
|
+
throw new Error(`Unknown contextual thread: ${threadId}`);
|
|
477
|
+
return this.serializeThread(thread, metadata, 50);
|
|
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
|
+
}
|
|
615
|
+
serializeThread(thread, metadata, messageLimit) {
|
|
616
|
+
const messages = this.options.db.getMessagesForThread(thread.threadId, messageLimit);
|
|
617
|
+
return {
|
|
618
|
+
threadId: thread.threadId,
|
|
619
|
+
updatedAt: thread.updatedAt,
|
|
620
|
+
metadata: serializeMetadata(metadata),
|
|
621
|
+
messages: messages.map((message) => ({
|
|
622
|
+
id: message.id,
|
|
623
|
+
sender: message.sender,
|
|
624
|
+
direction: message.direction,
|
|
625
|
+
body: message.body,
|
|
626
|
+
createdAt: message.createdAt,
|
|
627
|
+
})),
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
emitInbound(threadId, targetAgentId, text, metadata) {
|
|
631
|
+
this.inboundHandler?.({
|
|
632
|
+
source: "nvim",
|
|
633
|
+
threadId,
|
|
634
|
+
channel: this.repoSocketHash,
|
|
635
|
+
userId: "nvim",
|
|
636
|
+
userName: "Neovim",
|
|
637
|
+
text,
|
|
638
|
+
timestamp: new Date().toISOString(),
|
|
639
|
+
metadata: {
|
|
640
|
+
...metadata,
|
|
641
|
+
...(targetAgentId ? { threadAffinityOwnerAgentId: targetAgentId } : {}),
|
|
642
|
+
},
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
broadcast(payload) {
|
|
646
|
+
for (const client of this.clients)
|
|
647
|
+
sendJson(client, payload);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
export function createNvimPinetRuntimeAdapterFactory() {
|
|
651
|
+
return ({ broker, ctx }) => {
|
|
652
|
+
const repositoryContext = resolveNvimRepositoryContext(ctx.cwd);
|
|
653
|
+
if (!repositoryContext)
|
|
654
|
+
return [];
|
|
655
|
+
return {
|
|
656
|
+
adapter: new NvimPinetAdapter({
|
|
657
|
+
...repositoryContext,
|
|
658
|
+
db: broker.db,
|
|
659
|
+
getAgentById: (agentId) => broker.db.getAgent?.(agentId) ?? null,
|
|
660
|
+
}),
|
|
661
|
+
};
|
|
662
|
+
};
|
|
663
|
+
}
|
package/dist/pinet-tools.d.ts
CHANGED
|
@@ -66,6 +66,12 @@ export interface RegisterPinetToolsDeps {
|
|
|
66
66
|
messageIds: number[];
|
|
67
67
|
recipients: string[];
|
|
68
68
|
};
|
|
69
|
+
replyToPinetThread?: (threadId: string, body: string) => Promise<{
|
|
70
|
+
messageId: number;
|
|
71
|
+
threadId: string;
|
|
72
|
+
source: string;
|
|
73
|
+
channel: string;
|
|
74
|
+
}>;
|
|
69
75
|
signalAgentFree: (ctx: ExtensionContext | undefined, options: {
|
|
70
76
|
requirePinet?: boolean;
|
|
71
77
|
}) => Promise<{
|
package/dist/pinet-tools.js
CHANGED
|
@@ -19,6 +19,9 @@ const PINET_DISPATCHER_EXAMPLES = {
|
|
|
19
19
|
{ action: "send", args: { to: "@worker", message: "Please review PR #123" } },
|
|
20
20
|
{ action: "send", args: { to: "@worker", message: "/steer stop polling" } },
|
|
21
21
|
],
|
|
22
|
+
reply: [
|
|
23
|
+
{ action: "reply", args: { thread_id: "nvim:<repo>:<id>", message: "I updated this block." } },
|
|
24
|
+
],
|
|
22
25
|
read: [
|
|
23
26
|
// Routine inbox drain: defaults are `unread_only: true` + `mark_read: true`,
|
|
24
27
|
// so this returns and consumes only this agent's unread rows. The compact
|
|
@@ -100,6 +103,7 @@ function normalizeDispatcherAction(value) {
|
|
|
100
103
|
"help",
|
|
101
104
|
"send",
|
|
102
105
|
"read",
|
|
106
|
+
"reply",
|
|
103
107
|
"free",
|
|
104
108
|
"snooze",
|
|
105
109
|
"schedule",
|
|
@@ -681,6 +685,44 @@ function runPinetSendAction(params, deps, toolName, output) {
|
|
|
681
685
|
};
|
|
682
686
|
})();
|
|
683
687
|
}
|
|
688
|
+
// agent-standards-ignore prefer-inline-single-use-helper: dispatcher actions are kept as named seams for progressive discovery.
|
|
689
|
+
function runPinetReplyAction(params, deps, toolName, output) {
|
|
690
|
+
return (async () => {
|
|
691
|
+
const threadId = typeof params.thread_id === "string" ? params.thread_id.trim() : "";
|
|
692
|
+
const message = typeof params.message === "string" ? params.message : "";
|
|
693
|
+
if (!threadId)
|
|
694
|
+
throw new Error("thread_id is required");
|
|
695
|
+
if (!message)
|
|
696
|
+
throw new Error("message is required");
|
|
697
|
+
if (!deps.replyToPinetThread) {
|
|
698
|
+
throw new Error("pinet:reply is only available when the broker thread sender is active.");
|
|
699
|
+
}
|
|
700
|
+
deps.requireToolPolicy(toolName, undefined, `thread_id=${threadId} | message=${message}`);
|
|
701
|
+
const result = await deps.replyToPinetThread(threadId, message);
|
|
702
|
+
return {
|
|
703
|
+
content: [
|
|
704
|
+
{
|
|
705
|
+
type: "text",
|
|
706
|
+
text: output.full
|
|
707
|
+
? `Reply sent to ${result.source} thread ${result.threadId} (${result.channel}); message id ${result.messageId}.`
|
|
708
|
+
: `Pinet thread reply sent to ${result.threadId}.`,
|
|
709
|
+
},
|
|
710
|
+
],
|
|
711
|
+
details: result,
|
|
712
|
+
compactDetails: {
|
|
713
|
+
messageId: result.messageId,
|
|
714
|
+
threadId: result.threadId,
|
|
715
|
+
source: result.source,
|
|
716
|
+
},
|
|
717
|
+
expandedText: buildSentMessageExpandedText([
|
|
718
|
+
["thread id", result.threadId],
|
|
719
|
+
["source", result.source],
|
|
720
|
+
["channel", result.channel],
|
|
721
|
+
["message id", String(result.messageId)],
|
|
722
|
+
], message),
|
|
723
|
+
};
|
|
724
|
+
})();
|
|
725
|
+
}
|
|
684
726
|
function runPinetReadAction(params, deps, toolName, output) {
|
|
685
727
|
return (async () => {
|
|
686
728
|
deps.requireToolPolicy(toolName, undefined, `thread_id=${params.thread_id ?? ""} | limit=${params.limit ?? ""} | unread_only=${params.unread_only ?? ""} | mark_read=${params.mark_read ?? ""} | format=${output.format} | full=${output.full}`);
|
|
@@ -1765,6 +1807,16 @@ export function registerPinetTools(pi, deps) {
|
|
|
1765
1807
|
}),
|
|
1766
1808
|
execute: (_id, params, output) => runPinetSendAction(params, deps, "pinet:send", output),
|
|
1767
1809
|
});
|
|
1810
|
+
registerAction({
|
|
1811
|
+
name: "reply",
|
|
1812
|
+
description: "Reply to an existing Pinet transport thread using the thread's stored source/channel. Use for ordinary anchored contextual threads such as Neovim code comments.",
|
|
1813
|
+
parameters: Type.Object({
|
|
1814
|
+
thread_id: Type.String({ description: "Existing Pinet transport thread id." }),
|
|
1815
|
+
message: Type.String({ description: "Reply body." }),
|
|
1816
|
+
...PINET_OUTPUT_OPTION_PARAMETERS,
|
|
1817
|
+
}),
|
|
1818
|
+
execute: (_id, params, output) => runPinetReplyAction(params, deps, "pinet:reply", output),
|
|
1819
|
+
});
|
|
1768
1820
|
registerAction({
|
|
1769
1821
|
name: "read",
|
|
1770
1822
|
description: "Read this agent's durable SQLite-backed Pinet inbox context with unread/read semantics. Defaults to draining unread rows (`unread_only: true`, `mark_read: true`) with compact per-message previews — no `full=true` required for routine reads. Override `unread_only`/`mark_read` only for a non-destructive latest-history peek. Ordinary workers only see rows addressed to their own agent identity; broker coordination visibility is limited to broker-addressed inbox rows.",
|
|
@@ -1998,7 +2050,7 @@ export function registerPinetTools(pi, deps) {
|
|
|
1998
2050
|
promptGuidelines: deps.promptGuidelines,
|
|
1999
2051
|
parameters: Type.Object({
|
|
2000
2052
|
action: Type.String({
|
|
2001
|
-
description: "Action name: help, send, read, free, snooze, schedule, agents, sessions, lanes, ports, reload, exit, hibernate (broker-managed checkpoint+hibernate), or wake (broker-managed fenced wake). Also supports spawn for launching worker-owned subtree children.",
|
|
2053
|
+
description: "Action name: help, send, read, reply, free, snooze, schedule, agents, sessions, lanes, ports, reload, exit, hibernate (broker-managed checkpoint+hibernate), or wake (broker-managed fenced wake). Also supports spawn for launching worker-owned subtree children.",
|
|
2002
2054
|
}),
|
|
2003
2055
|
args: Type.Optional(Type.Record(Type.String(), Type.Unknown(), {
|
|
2004
2056
|
description: 'Action arguments. Add format="cli"|"json" (or f/"-f") for presentation, and full=true (or "--full": true) only for verbose/debug details. Default cli and non-full json keep data.details compact.',
|
|
@@ -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
|
|
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.
|
|
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.
|
|
53
|
-
"@pinet/imessage-bridge": "0.2.
|
|
54
|
-
"@pinet/pinet-core": "0.2.
|
|
55
|
-
"@pinet/transport-core": "0.2.
|
|
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",
|