@addozhang/dsh-discord 0.2.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/README.zh.md +4 -3
- package/lib/compose.d.ts +20 -0
- package/lib/compose.js +8 -3
- package/lib/discord/commands.js +6 -0
- package/lib/dsh/api-proxy-face.d.ts +59 -9
- package/lib/dsh/api-proxy-face.js +67 -1
- package/lib/features/image-collection.d.ts +11 -0
- package/lib/features/image-collection.js +22 -8
- package/lib/features/image-download.d.ts +6 -0
- package/lib/features/image-download.js +21 -0
- package/lib/features/interaction-router.d.ts +42 -0
- package/lib/features/interaction-router.js +79 -6
- package/lib/features/project-list.d.ts +1 -0
- package/lib/features/prompt-submission.d.ts +9 -0
- package/lib/features/prompt-submission.js +7 -1
- package/lib/features/session-mainline.d.ts +42 -1
- package/lib/features/session-mainline.js +29 -3
- package/lib/features/session-resume.d.ts +64 -30
- package/lib/features/session-resume.js +108 -35
- package/lib/gateway/inbound.d.ts +14 -0
- package/lib/gateway/inbound.js +41 -0
- package/lib/i18n.d.ts +14 -0
- package/lib/i18n.js +32 -0
- package/lib/index.js +128 -5
- package/package.json +13 -2
- package/lib/features/image-submission.d.ts +0 -67
- package/lib/features/image-submission.js +0 -57
|
@@ -52,19 +52,41 @@ export function createInteractionRouter(deps) {
|
|
|
52
52
|
});
|
|
53
53
|
}
|
|
54
54
|
async function routeAutocomplete(event, interactionToken) {
|
|
55
|
-
if (event.commandName !== 'project')
|
|
55
|
+
if (event.commandName !== 'project' && event.commandName !== 'session')
|
|
56
56
|
return;
|
|
57
57
|
// Autocomplete has no deferred-ack form: whatever happens below, Discord
|
|
58
58
|
// must receive exactly one type-8 answer, even if only empty choices.
|
|
59
59
|
try {
|
|
60
60
|
const decision = authorize(event);
|
|
61
61
|
const choices = [];
|
|
62
|
-
|
|
62
|
+
const wireOptions = event.data['options'];
|
|
63
|
+
const sub = Array.isArray(wireOptions) ? wireOptions[0] : undefined;
|
|
64
|
+
const focused = Array.isArray(sub?.options) ? sub.options.find(option => option.focused === true) : undefined;
|
|
65
|
+
const query = typeof focused?.value === 'string' ? focused.value : '';
|
|
66
|
+
if (decision.allowed && event.commandName === 'session') {
|
|
67
|
+
// /session resume: live candidates scoped to the bound workspace.
|
|
68
|
+
try {
|
|
69
|
+
const binding = deps.channelBinding(event.guildId, event.channelId);
|
|
70
|
+
const catalog = await deps.catalogPort.listWorkspaces();
|
|
71
|
+
const workspacePath = binding !== undefined && catalog.outcome === 'completed'
|
|
72
|
+
? catalog.workspaces.find(workspace => workspace.id === binding.workspaceId)?.path
|
|
73
|
+
: undefined;
|
|
74
|
+
const archivedSessionIds = catalog.outcome === 'completed'
|
|
75
|
+
? new Set(catalog.archivedSessionIds)
|
|
76
|
+
: undefined;
|
|
77
|
+
const outcome = await deps.resumeCandidates(query, workspacePath, archivedSessionIds);
|
|
78
|
+
if (outcome.outcome === 'ok') {
|
|
79
|
+
choices.push(...outcome.options.slice(0, 25).map(option => (option.description === undefined
|
|
80
|
+
? { name: option.label, value: option.value }
|
|
81
|
+
: { name: option.label, value: option.value, description: option.description })));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch (cause) {
|
|
85
|
+
deps.warn('discord_autocomplete_sessions_failed', String(cause));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
else if (decision.allowed && event.commandName === 'project') {
|
|
63
89
|
try {
|
|
64
|
-
const wireOptions = event.data['options'];
|
|
65
|
-
const sub = Array.isArray(wireOptions) ? wireOptions[0] : undefined;
|
|
66
|
-
const focused = Array.isArray(sub?.options) ? sub.options.find(option => option.focused === true) : undefined;
|
|
67
|
-
const query = typeof focused?.value === 'string' ? focused.value : '';
|
|
68
90
|
const catalog = await deps.catalogPort.listWorkspaces();
|
|
69
91
|
if (catalog.outcome === 'completed') {
|
|
70
92
|
choices.push(...workspaceAutocompleteChoices(catalog.workspaces, query).slice(0, 25));
|
|
@@ -483,6 +505,57 @@ export function createInteractionRouter(deps) {
|
|
|
483
505
|
await followUp(content);
|
|
484
506
|
return;
|
|
485
507
|
}
|
|
508
|
+
if (event.commandName === 'session') {
|
|
509
|
+
// /session resume: cold-adopt an existing DSH session into a new
|
|
510
|
+
// thread of the bound project channel (16.44). /session new is
|
|
511
|
+
// deliberately absent — the @mention is the new-session path.
|
|
512
|
+
const options = event.data['options'];
|
|
513
|
+
const subcommand = Array.isArray(options) ? options[0] : undefined;
|
|
514
|
+
if (subcommand?.name !== 'resume') {
|
|
515
|
+
await followUp(deps.copy.unknownSubcommand);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
const binding = deps.channelBinding(event.guildId, event.channelId);
|
|
519
|
+
if (binding === undefined) {
|
|
520
|
+
await followUp(deps.copy.sessionResumeNeedsBoundChannel);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
const wireOptions = Array.isArray(subcommand.options) ? subcommand.options : [];
|
|
524
|
+
const sessionId = wireOptions.find(option => option.name === 'session')?.value;
|
|
525
|
+
if (typeof sessionId !== 'string' || sessionId === '') {
|
|
526
|
+
await followUp(deps.copy.sessionResumeFailed);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
const outcome = await deps.resumeSession({
|
|
530
|
+
sessionId,
|
|
531
|
+
workspaceId: binding.workspaceId,
|
|
532
|
+
guildId: event.guildId,
|
|
533
|
+
parentChannelId: event.channelId,
|
|
534
|
+
actorId: event.actorId,
|
|
535
|
+
});
|
|
536
|
+
if (outcome.outcome === 'refused-control-channel') {
|
|
537
|
+
await followUp(deps.copy.sessionResumeControlChannel);
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
if (outcome.outcome === 'refused-subagent') {
|
|
541
|
+
await followUp(deps.copy.sessionResumeSubagent);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
if (outcome.outcome === 'refused-archived') {
|
|
545
|
+
await followUp(deps.copy.sessionResumeArchived);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (outcome.outcome === 'already-bound') {
|
|
549
|
+
await followUp(deps.copy.sessionResumeAlreadyBound(outcome.threadId));
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
if (outcome.outcome === 'started') {
|
|
553
|
+
await followUp(deps.copy.sessionResumeStarted(outcome.threadId));
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
await followUp(deps.copy.sessionResumeFailed);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
486
559
|
// Terminal guard: a recognized interaction type with an unhandled
|
|
487
560
|
// command name must still answer, never strand on the deferred ack.
|
|
488
561
|
await followUp(deps.copy.unknownSubcommand);
|
|
@@ -15,6 +15,8 @@ export interface DshPromptPort {
|
|
|
15
15
|
sessionId: string;
|
|
16
16
|
prompt: string;
|
|
17
17
|
mode: 'queue';
|
|
18
|
+
/** Collected images (16.50); omitted for text-only prompts so legacy shapes are untouched. */
|
|
19
|
+
images?: ReadonlyArray<PromptRequestImage>;
|
|
18
20
|
}): Promise<{
|
|
19
21
|
outcome: 'accepted';
|
|
20
22
|
} | {
|
|
@@ -24,6 +26,11 @@ export interface DshPromptPort {
|
|
|
24
26
|
outcome: 'unknown';
|
|
25
27
|
}>;
|
|
26
28
|
}
|
|
29
|
+
/** One collected image, ready for the prompt parts encoding. */
|
|
30
|
+
export interface PromptRequestImage {
|
|
31
|
+
mediaType: string;
|
|
32
|
+
base64: string;
|
|
33
|
+
}
|
|
27
34
|
export interface PromptSubmissionDeps {
|
|
28
35
|
prompts: DshPromptPort;
|
|
29
36
|
intents: IntentStore;
|
|
@@ -44,6 +51,8 @@ export interface PromptRequest {
|
|
|
44
51
|
requestId: string;
|
|
45
52
|
sessionId: string;
|
|
46
53
|
prompt: string;
|
|
54
|
+
/** Collected images (16.50); omitted for text-only prompts. */
|
|
55
|
+
images?: ReadonlyArray<PromptRequestImage>;
|
|
47
56
|
}
|
|
48
57
|
export declare function createPromptSubmissionFlow(deps: PromptSubmissionDeps): {
|
|
49
58
|
submitOnce(request: PromptRequest): Promise<PromptSubmissionResult>;
|
|
@@ -10,7 +10,12 @@
|
|
|
10
10
|
import { hashPayload } from '../state/intents.js';
|
|
11
11
|
export function createPromptSubmissionFlow(deps) {
|
|
12
12
|
async function submit(requestId, request) {
|
|
13
|
-
const
|
|
13
|
+
const images = request.images ?? [];
|
|
14
|
+
const contentHash = await hashPayload({
|
|
15
|
+
sessionId: request.sessionId,
|
|
16
|
+
prompt: request.prompt,
|
|
17
|
+
...(images.length > 0 ? { images } : {}),
|
|
18
|
+
});
|
|
14
19
|
const claim = await deps.intents.claim({
|
|
15
20
|
messageId: requestId,
|
|
16
21
|
contentHash,
|
|
@@ -26,6 +31,7 @@ export function createPromptSubmissionFlow(deps) {
|
|
|
26
31
|
sessionId: request.sessionId,
|
|
27
32
|
prompt: request.prompt,
|
|
28
33
|
mode: 'queue',
|
|
34
|
+
...(images.length > 0 ? { images } : {}),
|
|
29
35
|
});
|
|
30
36
|
if (submitted.outcome === 'accepted') {
|
|
31
37
|
await deps.intents.resolve(requestId, 'succeeded', deps.nowMs());
|
|
@@ -9,13 +9,44 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import type { createThreadCreationFlow } from './thread-creation.js';
|
|
11
11
|
import type { createSessionCreationFlow } from './session-creation.js';
|
|
12
|
-
import type { createPromptSubmissionFlow } from './prompt-submission.js';
|
|
12
|
+
import type { createPromptSubmissionFlow, PromptRequestImage } from './prompt-submission.js';
|
|
13
13
|
import type { TurnTracker } from './turn-ownership.js';
|
|
14
|
+
/** Declared image metadata as carried on the normalized wire message. */
|
|
15
|
+
export interface MainlineWireImage {
|
|
16
|
+
url: string;
|
|
17
|
+
filename: string;
|
|
18
|
+
declaredSize: number;
|
|
19
|
+
contentType: string;
|
|
20
|
+
}
|
|
21
|
+
/** One collected image, ready for submission. */
|
|
22
|
+
export type MainlineImage = PromptRequestImage;
|
|
23
|
+
/**
|
|
24
|
+
* The bounded collection boundary (design.md §12, 16.50): declared wire
|
|
25
|
+
* metadata goes in, base64-encoded images come out. The composition root
|
|
26
|
+
* implements it over the safe-download boundary — this layer never touches
|
|
27
|
+
* the network itself.
|
|
28
|
+
*/
|
|
29
|
+
export interface MainlineImageCollector {
|
|
30
|
+
collect(request: {
|
|
31
|
+
attachments: ReadonlyArray<{
|
|
32
|
+
url: string;
|
|
33
|
+
declaredSize: number;
|
|
34
|
+
contentType: string;
|
|
35
|
+
}>;
|
|
36
|
+
}): Promise<{
|
|
37
|
+
outcome: 'collected';
|
|
38
|
+
images: ReadonlyArray<MainlineImage>;
|
|
39
|
+
} | {
|
|
40
|
+
outcome: 'failed';
|
|
41
|
+
reason: string;
|
|
42
|
+
}>;
|
|
43
|
+
}
|
|
14
44
|
export interface SessionMainlineDeps {
|
|
15
45
|
threads: ReturnType<typeof createThreadCreationFlow>;
|
|
16
46
|
sessions: ReturnType<typeof createSessionCreationFlow>;
|
|
17
47
|
prompts: ReturnType<typeof createPromptSubmissionFlow>;
|
|
18
48
|
turns: TurnTracker;
|
|
49
|
+
images: MainlineImageCollector;
|
|
19
50
|
}
|
|
20
51
|
export type MentionMainlineResult = {
|
|
21
52
|
outcome: 'admitted';
|
|
@@ -33,6 +64,9 @@ export type MentionMainlineResult = {
|
|
|
33
64
|
outcome: 'prompt-rejected';
|
|
34
65
|
} | {
|
|
35
66
|
outcome: 'prompt-unknown';
|
|
67
|
+
} | {
|
|
68
|
+
outcome: 'image-failed';
|
|
69
|
+
reason: string;
|
|
36
70
|
};
|
|
37
71
|
export interface MentionMainlineRequest {
|
|
38
72
|
applicationId: string;
|
|
@@ -43,6 +77,8 @@ export interface MentionMainlineRequest {
|
|
|
43
77
|
authorId: string;
|
|
44
78
|
workspaceId: string;
|
|
45
79
|
prompt: string;
|
|
80
|
+
/** Declared image attachments (16.50); omitted for text-only mentions. */
|
|
81
|
+
images?: ReadonlyArray<MainlineWireImage>;
|
|
46
82
|
}
|
|
47
83
|
export type ContinuationMainlineResult = {
|
|
48
84
|
outcome: 'queued';
|
|
@@ -54,6 +90,9 @@ export type ContinuationMainlineResult = {
|
|
|
54
90
|
outcome: 'rejected';
|
|
55
91
|
} | {
|
|
56
92
|
outcome: 'unknown';
|
|
93
|
+
} | {
|
|
94
|
+
outcome: 'image-failed';
|
|
95
|
+
reason: string;
|
|
57
96
|
};
|
|
58
97
|
export interface ContinuationMainlineRequest {
|
|
59
98
|
applicationId: string;
|
|
@@ -62,6 +101,8 @@ export interface ContinuationMainlineRequest {
|
|
|
62
101
|
sessionId: string;
|
|
63
102
|
messageId: string;
|
|
64
103
|
prompt: string;
|
|
104
|
+
/** Declared image attachments (16.50); omitted for text-only messages. */
|
|
105
|
+
images?: ReadonlyArray<MainlineWireImage>;
|
|
65
106
|
}
|
|
66
107
|
/**
|
|
67
108
|
* The stable, adapter-owned request id for a submitted prompt: derived from
|
|
@@ -30,17 +30,38 @@ function mapSubmission(result) {
|
|
|
30
30
|
return { outcome: 'conflict' };
|
|
31
31
|
return result.outcome === 'rejected' ? { outcome: 'rejected' } : { outcome: 'unknown' };
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Collect the request's declared images through the bounded boundary. A
|
|
35
|
+
* text-only request never touches the collector; a failed collection is a
|
|
36
|
+
* first-class outcome so no degraded text-only submission is sent silently.
|
|
37
|
+
*/
|
|
38
|
+
async function collectRequestImages(deps, images) {
|
|
39
|
+
if (images.length === 0)
|
|
40
|
+
return { outcome: 'collected', images: [] };
|
|
41
|
+
const collected = await deps.images.collect({
|
|
42
|
+
attachments: images.map(({ url, declaredSize, contentType }) => ({ url, declaredSize, contentType })),
|
|
43
|
+
});
|
|
44
|
+
if (collected.outcome !== 'collected')
|
|
45
|
+
return { outcome: 'image-failed', reason: collected.reason };
|
|
46
|
+
return collected;
|
|
47
|
+
}
|
|
33
48
|
export function createSessionMainline(deps) {
|
|
34
49
|
return {
|
|
35
50
|
async admitMention(request) {
|
|
51
|
+
const declaredImages = request.images ?? [];
|
|
52
|
+
const images = await collectRequestImages(deps, declaredImages);
|
|
53
|
+
if (images.outcome !== 'collected')
|
|
54
|
+
return images;
|
|
36
55
|
// The source message id is the durable intent: one thread per message,
|
|
37
|
-
// deterministic recovery on redelivery (design.md §4, §10).
|
|
56
|
+
// deterministic recovery on redelivery (design.md §4, §10). An
|
|
57
|
+
// image-only mention names the thread after its first image's filename.
|
|
58
|
+
const title = request.prompt !== '' ? request.prompt : (declaredImages[0]?.filename ?? '');
|
|
38
59
|
const thread = await deps.threads.ensureThread({
|
|
39
60
|
sourceMessageId: request.messageId,
|
|
40
|
-
contentHash: await hashPayload({ prompt: request.prompt }),
|
|
61
|
+
contentHash: await hashPayload({ prompt: request.prompt, images: declaredImages }),
|
|
41
62
|
guildId: request.guildId,
|
|
42
63
|
parentChannelId: request.channelId,
|
|
43
|
-
threadName: safeTitle(
|
|
64
|
+
threadName: safeTitle(title),
|
|
44
65
|
creatorUserId: request.authorId,
|
|
45
66
|
});
|
|
46
67
|
if (thread.outcome === 'conflict')
|
|
@@ -66,6 +87,7 @@ export function createSessionMainline(deps) {
|
|
|
66
87
|
requestId,
|
|
67
88
|
sessionId,
|
|
68
89
|
prompt: request.prompt,
|
|
90
|
+
...(images.images.length > 0 ? { images: images.images } : {}),
|
|
69
91
|
});
|
|
70
92
|
if (submitted.outcome === 'accepted') {
|
|
71
93
|
ownTurn(deps, { sessionId, requestId, threadId });
|
|
@@ -81,11 +103,15 @@ export function createSessionMainline(deps) {
|
|
|
81
103
|
return submitted.outcome === 'rejected' ? { outcome: 'prompt-rejected' } : { outcome: 'prompt-unknown' };
|
|
82
104
|
},
|
|
83
105
|
async continueInThread(request) {
|
|
106
|
+
const images = await collectRequestImages(deps, request.images ?? []);
|
|
107
|
+
if (images.outcome !== 'collected')
|
|
108
|
+
return images;
|
|
84
109
|
const requestId = requestIdFor(request.messageId);
|
|
85
110
|
const submitted = await deps.prompts.submitOnce({
|
|
86
111
|
requestId,
|
|
87
112
|
sessionId: request.sessionId,
|
|
88
113
|
prompt: request.prompt,
|
|
114
|
+
...(images.images.length > 0 ? { images: images.images } : {}),
|
|
89
115
|
});
|
|
90
116
|
if (submitted.outcome === 'accepted') {
|
|
91
117
|
ownTurn(deps, { sessionId: request.sessionId, requestId, threadId: request.threadId });
|
|
@@ -1,44 +1,78 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `/session resume`
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* `/session resume` candidate building (design.md §13, tasks 9.1 + 16.44).
|
|
3
|
+
* The Host's `sessions.list` returns rich rows (updatedAt desc, running,
|
|
4
|
+
* blank, cwd, title via projection values). Candidates exclude blank
|
|
5
|
+
* sessions and sessions this adapter already owns a thread for, filter by
|
|
6
|
+
* title/id substring, and sort newest-first — Discord autocomplete caps at
|
|
7
|
+
* 25 choices, the caller slices.
|
|
7
8
|
*/
|
|
9
|
+
import type { SessionResumeRow } from '../dsh/api-proxy-face.js';
|
|
10
|
+
export type { SessionResumeRow };
|
|
11
|
+
import type { CopyTable } from '../i18n.js';
|
|
8
12
|
import { type SelectorOption } from '../discord/selector.js';
|
|
9
13
|
export interface SessionCatalogPort {
|
|
10
14
|
listSessions(): Promise<{
|
|
11
15
|
outcome: 'completed';
|
|
12
|
-
sessions: ReadonlyArray<
|
|
13
|
-
sessionId: string;
|
|
14
|
-
title: string | null;
|
|
15
|
-
archived: boolean;
|
|
16
|
-
}>;
|
|
16
|
+
sessions: ReadonlyArray<SessionResumeRow>;
|
|
17
17
|
} | {
|
|
18
18
|
outcome: 'failed';
|
|
19
19
|
} | {
|
|
20
20
|
outcome: 'unknown';
|
|
21
21
|
}>;
|
|
22
22
|
}
|
|
23
|
-
export
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
23
|
+
export type ResumeCandidatesPort = (query: string,
|
|
24
|
+
/**
|
|
25
|
+
* The bound Workspace's REGISTERED PATH, resolved by the router from the
|
|
26
|
+
* channel binding through the workspace catalog. Not a workspace id: the
|
|
27
|
+
* port must never re-resolve it through the catalog (an id/path swap here
|
|
28
|
+
* once answered `unavailable` in every channel — 16.45). `undefined`
|
|
29
|
+
* (unbound channel / catalog miss) is fail-closed.
|
|
30
|
+
*/
|
|
31
|
+
workspacePath?: string,
|
|
32
|
+
/** The registry's archived set (16.49): archived sessions never resume. */
|
|
33
|
+
archivedSessionIds?: ReadonlySet<string>) => Promise<{
|
|
30
34
|
outcome: 'ok';
|
|
31
|
-
|
|
32
|
-
pageIndex: number;
|
|
33
|
-
pageCount: number;
|
|
34
|
-
hasPrev: boolean;
|
|
35
|
-
hasNext: boolean;
|
|
36
|
-
navValues: {
|
|
37
|
-
prev?: string;
|
|
38
|
-
next?: string;
|
|
39
|
-
};
|
|
35
|
+
options: SelectorOption[];
|
|
40
36
|
} | {
|
|
41
|
-
outcome: '
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
37
|
+
outcome: 'unavailable';
|
|
38
|
+
}>;
|
|
39
|
+
/**
|
|
40
|
+
* Relative-time rendering for a candidate's label. Discord autocomplete
|
|
41
|
+
* choices carry no description field on the wire (name/value only), so the
|
|
42
|
+
* age rides the label — bilingual via the copy table (16.47).
|
|
43
|
+
*/
|
|
44
|
+
export declare function relativeTime(copy: CopyTable, fromMs: number, nowMs: number): string;
|
|
45
|
+
/**
|
|
46
|
+
* Build the /session resume autocomplete candidates from one Host listing:
|
|
47
|
+
* blank sessions are hidden (nothing to resume), already-bound sessions are
|
|
48
|
+
* excluded (their thread is the live surface), the query filters over
|
|
49
|
+
* title/id case-insensitively, and rows sort newest-first. Uncapped here —
|
|
50
|
+
* the caller applies Discord's 25-choice ceiling. Same-titled candidates
|
|
51
|
+
* get a short-id suffix: choices have no description on the wire, so
|
|
52
|
+
* identical labels would be indistinguishable (16.47).
|
|
53
|
+
*/
|
|
54
|
+
export declare function buildResumeCandidates(sessions: ReadonlyArray<SessionResumeRow>, options: {
|
|
55
|
+
/** Registered path of the bound Workspace; only its sessions are offered. */
|
|
56
|
+
workspacePath: string | undefined;
|
|
57
|
+
boundSessionIds?: ReadonlySet<string>;
|
|
58
|
+
/** Registry's archived set: archived sessions never resume (16.49). */
|
|
59
|
+
archivedSessionIds?: ReadonlySet<string>;
|
|
60
|
+
query?: string | undefined;
|
|
61
|
+
nowMs?: number;
|
|
62
|
+
copy: CopyTable;
|
|
63
|
+
}): SelectorOption[];
|
|
64
|
+
/** Discard candidates: prefix matches first, then substring, capped. */
|
|
65
|
+
export declare function filterResumeCandidates(candidates: ReadonlyArray<SelectorOption>, query: string, limit?: number): SelectorOption[];
|
|
66
|
+
/**
|
|
67
|
+
* The composition-root wiring for the router's `resumeCandidates` dep
|
|
68
|
+
* (16.44): one Host listing per call, filtered by the handed-in workspace
|
|
69
|
+
* path against the live thread-ownership set. Listing failure is logged at
|
|
70
|
+
* the face (`…failed`/`…timeout`) and degrades to `unavailable` — Discord
|
|
71
|
+
* autocomplete then answers empty choices, never an unanswered interaction.
|
|
72
|
+
*/
|
|
73
|
+
export declare function createResumeCandidatesPort(deps: {
|
|
74
|
+
listSessions: SessionCatalogPort['listSessions'];
|
|
75
|
+
/** Sessions this adapter already owns a thread for, re-read per call. */
|
|
76
|
+
boundSessionIds: () => ReadonlySet<string>;
|
|
77
|
+
copy: CopyTable;
|
|
78
|
+
}): ResumeCandidatesPort;
|
|
@@ -1,40 +1,113 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `/session resume`
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* `/session resume` candidate building (design.md §13, tasks 9.1 + 16.44).
|
|
3
|
+
* The Host's `sessions.list` returns rich rows (updatedAt desc, running,
|
|
4
|
+
* blank, cwd, title via projection values). Candidates exclude blank
|
|
5
|
+
* sessions and sessions this adapter already owns a thread for, filter by
|
|
6
|
+
* title/id substring, and sort newest-first — Discord autocomplete caps at
|
|
7
|
+
* 25 choices, the caller slices.
|
|
7
8
|
*/
|
|
8
|
-
import { filterAutocomplete
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
9
|
+
import { filterAutocomplete } from '../discord/selector.js';
|
|
10
|
+
/**
|
|
11
|
+
* Relative-time rendering for a candidate's label. Discord autocomplete
|
|
12
|
+
* choices carry no description field on the wire (name/value only), so the
|
|
13
|
+
* age rides the label — bilingual via the copy table (16.47).
|
|
14
|
+
*/
|
|
15
|
+
export function relativeTime(copy, fromMs, nowMs) {
|
|
16
|
+
const delta = Math.max(0, nowMs - fromMs);
|
|
17
|
+
const minutes = Math.floor(delta / 60_000);
|
|
18
|
+
if (minutes < 1)
|
|
19
|
+
return copy.sessionCandidateJustNow;
|
|
20
|
+
if (minutes < 60)
|
|
21
|
+
return copy.sessionCandidateMinutesAgo(minutes);
|
|
22
|
+
const hours = Math.floor(minutes / 60);
|
|
23
|
+
if (hours < 24)
|
|
24
|
+
return copy.sessionCandidateHoursAgo(hours);
|
|
25
|
+
const days = Math.floor(hours / 24);
|
|
26
|
+
return copy.sessionCandidateDaysAgo(days);
|
|
27
|
+
}
|
|
28
|
+
/** Short display form of a session id (first 8 chars). */
|
|
29
|
+
function shortId(sessionId) {
|
|
30
|
+
return sessionId.slice(0, 8);
|
|
31
|
+
}
|
|
32
|
+
/** Discord rejects the whole autocomplete answer when a choice name exceeds 100. */
|
|
33
|
+
const DISCORD_CHOICE_NAME_MAX = 100;
|
|
34
|
+
function candidateLabel(copy, session, nowMs) {
|
|
35
|
+
const parts = [session.title ?? shortId(session.sessionId), relativeTime(copy, session.updatedAt, nowMs)];
|
|
36
|
+
if (session.running)
|
|
37
|
+
parts.push(copy.sessionCandidateRunning);
|
|
38
|
+
const label = parts.join(' · ');
|
|
39
|
+
return label.length <= DISCORD_CHOICE_NAME_MAX ? label : label.slice(0, DISCORD_CHOICE_NAME_MAX);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Build the /session resume autocomplete candidates from one Host listing:
|
|
43
|
+
* blank sessions are hidden (nothing to resume), already-bound sessions are
|
|
44
|
+
* excluded (their thread is the live surface), the query filters over
|
|
45
|
+
* title/id case-insensitively, and rows sort newest-first. Uncapped here —
|
|
46
|
+
* the caller applies Discord's 25-choice ceiling. Same-titled candidates
|
|
47
|
+
* get a short-id suffix: choices have no description on the wire, so
|
|
48
|
+
* identical labels would be indistinguishable (16.47).
|
|
49
|
+
*/
|
|
50
|
+
export function buildResumeCandidates(sessions, options) {
|
|
51
|
+
const query = (options.query ?? '').trim().toLowerCase();
|
|
52
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
53
|
+
const bound = options.boundSessionIds ?? new Set();
|
|
54
|
+
const archived = options.archivedSessionIds ?? new Set();
|
|
55
|
+
const copy = options.copy;
|
|
56
|
+
const rows = sessions
|
|
57
|
+
.filter(session => !session.blank && !bound.has(session.sessionId))
|
|
58
|
+
// Subagent sessions are never resumable as top-level threads (the spec's
|
|
59
|
+
// "Subagent Session selected" refusal) — offering them only produces
|
|
60
|
+
// selections that must fail (16.48).
|
|
61
|
+
.filter(session => session.origin !== 'subagent')
|
|
62
|
+
// Archived sessions adopt fine but never run a turn — the user waits in
|
|
63
|
+
// a dead thread. `workspace.list` carries the registry's archived set;
|
|
64
|
+
// sessions.list rows have no archived marker (16.49).
|
|
65
|
+
.filter(session => !archived.has(session.sessionId))
|
|
66
|
+
// Workspace scoping (16.44): a session belongs to the channel's Workspace
|
|
67
|
+
// only when its recorded cwd is that Workspace's registered path.
|
|
68
|
+
// Sessions with no recorded cwd cannot be attributed and are never
|
|
69
|
+
// offered.
|
|
70
|
+
.filter(session => options.workspacePath === undefined || session.cwd === options.workspacePath)
|
|
71
|
+
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
72
|
+
const options_ = rows.map(session => ({
|
|
73
|
+
label: candidateLabel(copy, session, nowMs),
|
|
74
|
+
value: session.sessionId,
|
|
25
75
|
}));
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
76
|
+
const counts = new Map();
|
|
77
|
+
for (const option of options_)
|
|
78
|
+
counts.set(option.label, (counts.get(option.label) ?? 0) + 1);
|
|
79
|
+
const disambiguated = options_.map(option => (counts.get(option.label) ?? 0) > 1
|
|
80
|
+
? { label: `${option.label} · ${shortId(option.value)}`.slice(0, DISCORD_CHOICE_NAME_MAX), value: option.value }
|
|
81
|
+
: option);
|
|
82
|
+
if (query === '')
|
|
83
|
+
return disambiguated;
|
|
84
|
+
return disambiguated.filter(option => option.label.toLowerCase().includes(query) || option.value.toLowerCase().includes(query));
|
|
85
|
+
}
|
|
86
|
+
/** Discard candidates: prefix matches first, then substring, capped. */
|
|
87
|
+
export function filterResumeCandidates(candidates, query, limit = 25) {
|
|
88
|
+
return filterAutocomplete(candidates, query, limit);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The composition-root wiring for the router's `resumeCandidates` dep
|
|
92
|
+
* (16.44): one Host listing per call, filtered by the handed-in workspace
|
|
93
|
+
* path against the live thread-ownership set. Listing failure is logged at
|
|
94
|
+
* the face (`…failed`/`…timeout`) and degrades to `unavailable` — Discord
|
|
95
|
+
* autocomplete then answers empty choices, never an unanswered interaction.
|
|
96
|
+
*/
|
|
97
|
+
export function createResumeCandidatesPort(deps) {
|
|
98
|
+
return async (query, workspacePath, archivedSessionIds) => {
|
|
99
|
+
if (workspacePath === undefined)
|
|
100
|
+
return { outcome: 'unavailable' };
|
|
101
|
+
const summaries = await deps.listSessions();
|
|
102
|
+
if (summaries.outcome !== 'completed')
|
|
103
|
+
return { outcome: 'unavailable' };
|
|
104
|
+
const candidates = buildResumeCandidates(summaries.sessions, {
|
|
105
|
+
workspacePath,
|
|
106
|
+
boundSessionIds: deps.boundSessionIds(),
|
|
107
|
+
...(archivedSessionIds === undefined ? {} : { archivedSessionIds }),
|
|
108
|
+
query,
|
|
109
|
+
copy: deps.copy,
|
|
110
|
+
});
|
|
111
|
+
return { outcome: 'ok', options: filterResumeCandidates(candidates, query, 25) };
|
|
39
112
|
};
|
|
40
113
|
}
|
package/lib/gateway/inbound.d.ts
CHANGED
|
@@ -40,6 +40,20 @@ export interface NormalizedMessage {
|
|
|
40
40
|
mentionedBot: boolean;
|
|
41
41
|
/** The snowflake of the message this one replies to, when present and valid. */
|
|
42
42
|
repliedToId: DiscordSnowflake | undefined;
|
|
43
|
+
/**
|
|
44
|
+
* DECLARED image-attachment metadata (16.50): supported types only,
|
|
45
|
+
* malformed entries skipped, capped at Discord's per-message limit. No
|
|
46
|
+
* bytes cross this boundary — download happens later, behind the
|
|
47
|
+
* allowlisted fetch boundary.
|
|
48
|
+
*/
|
|
49
|
+
images: DiscordImageAttachment[];
|
|
50
|
+
}
|
|
51
|
+
/** One supported image attachment, as declared by the wire. */
|
|
52
|
+
export interface DiscordImageAttachment {
|
|
53
|
+
url: string;
|
|
54
|
+
filename: string;
|
|
55
|
+
declaredSize: number;
|
|
56
|
+
contentType: string;
|
|
43
57
|
}
|
|
44
58
|
/** A validated guild interaction with its actor identity. */
|
|
45
59
|
export interface NormalizedInteraction {
|
package/lib/gateway/inbound.js
CHANGED
|
@@ -40,6 +40,46 @@ function extractRoleIds(payload) {
|
|
|
40
40
|
function asSnowflake(value) {
|
|
41
41
|
return isDiscordSnowflake(value) ? value : undefined;
|
|
42
42
|
}
|
|
43
|
+
/** Image content types the adapter carries (design.md §12). */
|
|
44
|
+
const SUPPORTED_IMAGE_TYPES = new Set([
|
|
45
|
+
'image/jpeg',
|
|
46
|
+
'image/png',
|
|
47
|
+
'image/webp',
|
|
48
|
+
'image/gif',
|
|
49
|
+
]);
|
|
50
|
+
/** Discord's per-message attachment cap; a defensive bound on untrusted input. */
|
|
51
|
+
const MAX_MESSAGE_IMAGES = 10;
|
|
52
|
+
/**
|
|
53
|
+
* Narrow untrusted `attachments` onto declared image metadata. Entries that
|
|
54
|
+
* are not records, lack any required field, or declare an unsupported media
|
|
55
|
+
* type are skipped — a malformed attachment never rejects the message.
|
|
56
|
+
*/
|
|
57
|
+
function extractImages(payload) {
|
|
58
|
+
const raw = payload['attachments'];
|
|
59
|
+
if (!Array.isArray(raw))
|
|
60
|
+
return [];
|
|
61
|
+
const images = [];
|
|
62
|
+
for (const entry of raw) {
|
|
63
|
+
if (images.length >= MAX_MESSAGE_IMAGES)
|
|
64
|
+
break;
|
|
65
|
+
if (!isRecord(entry))
|
|
66
|
+
continue;
|
|
67
|
+
const url = entry['url'];
|
|
68
|
+
const filename = entry['filename'];
|
|
69
|
+
const size = entry['size'];
|
|
70
|
+
const contentType = entry['content_type'];
|
|
71
|
+
if (typeof url !== 'string' || url === '')
|
|
72
|
+
continue;
|
|
73
|
+
if (typeof filename !== 'string' || filename === '')
|
|
74
|
+
continue;
|
|
75
|
+
if (typeof size !== 'number' || !Number.isFinite(size) || size < 0)
|
|
76
|
+
continue;
|
|
77
|
+
if (typeof contentType !== 'string' || !SUPPORTED_IMAGE_TYPES.has(contentType))
|
|
78
|
+
continue;
|
|
79
|
+
images.push({ url, filename, declaredSize: size, contentType });
|
|
80
|
+
}
|
|
81
|
+
return images;
|
|
82
|
+
}
|
|
43
83
|
function parseMessage(payload, selfUserId) {
|
|
44
84
|
// Webhook-authored messages are app-driven surfaces, not member input:
|
|
45
85
|
// their author object need not carry `bot: true`, and anyone with
|
|
@@ -93,6 +133,7 @@ function parseMessage(payload, selfUserId) {
|
|
|
93
133
|
content: stripped.text,
|
|
94
134
|
mentionedBot: stripped.mentioned,
|
|
95
135
|
repliedToId,
|
|
136
|
+
images: extractImages(payload),
|
|
96
137
|
},
|
|
97
138
|
};
|
|
98
139
|
}
|