@addozhang/dsh-discord 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -0
  3. package/cordis.patch.yml +14 -0
  4. package/lib/client/DiscordSettingsCard.d.ts +6 -0
  5. package/lib/client/PluginCard.d.ts +43 -0
  6. package/lib/client/card-controller.d.ts +22 -0
  7. package/lib/client/card-form.d.ts +111 -0
  8. package/lib/client/fields.d.ts +50 -0
  9. package/lib/client/index.d.ts +12 -0
  10. package/lib/client/nav-icon.d.ts +21 -0
  11. package/lib/client/settings-model.d.ts +49 -0
  12. package/lib/client/slot-contract.d.ts +57 -0
  13. package/lib/client/snapshot-store.d.ts +9 -0
  14. package/lib/client.js +1622 -0
  15. package/lib/compose.d.ts +158 -0
  16. package/lib/compose.js +227 -0
  17. package/lib/credential.d.ts +20 -0
  18. package/lib/credential.js +24 -0
  19. package/lib/discord/commands.d.ts +45 -0
  20. package/lib/discord/commands.js +87 -0
  21. package/lib/discord/components.d.ts +29 -0
  22. package/lib/discord/components.js +57 -0
  23. package/lib/discord/delivery.d.ts +21 -0
  24. package/lib/discord/delivery.js +31 -0
  25. package/lib/discord/interaction-lifecycle.d.ts +42 -0
  26. package/lib/discord/interaction-lifecycle.js +78 -0
  27. package/lib/discord/rest.d.ts +74 -0
  28. package/lib/discord/rest.js +149 -0
  29. package/lib/discord/selector.d.ts +39 -0
  30. package/lib/discord/selector.js +70 -0
  31. package/lib/discord/thread-port.d.ts +37 -0
  32. package/lib/discord/thread-port.js +58 -0
  33. package/lib/dsh/api-proxy-face.d.ts +253 -0
  34. package/lib/dsh/api-proxy-face.js +370 -0
  35. package/lib/features/adapter-status.d.ts +111 -0
  36. package/lib/features/adapter-status.js +103 -0
  37. package/lib/features/approval-expiry.d.ts +27 -0
  38. package/lib/features/approval-expiry.js +36 -0
  39. package/lib/features/approval-routing.d.ts +50 -0
  40. package/lib/features/approval-routing.js +57 -0
  41. package/lib/features/approval-store.d.ts +86 -0
  42. package/lib/features/approval-store.js +119 -0
  43. package/lib/features/approval-view.d.ts +47 -0
  44. package/lib/features/approval-view.js +48 -0
  45. package/lib/features/ask-wiring.d.ts +74 -0
  46. package/lib/features/ask-wiring.js +151 -0
  47. package/lib/features/host-status.d.ts +41 -0
  48. package/lib/features/host-status.js +24 -0
  49. package/lib/features/image-collection.d.ts +56 -0
  50. package/lib/features/image-collection.js +79 -0
  51. package/lib/features/image-download.d.ts +48 -0
  52. package/lib/features/image-download.js +77 -0
  53. package/lib/features/image-submission.d.ts +67 -0
  54. package/lib/features/image-submission.js +57 -0
  55. package/lib/features/interaction-router.d.ts +84 -0
  56. package/lib/features/interaction-router.js +655 -0
  57. package/lib/features/model-control.d.ts +79 -0
  58. package/lib/features/model-control.js +51 -0
  59. package/lib/features/preset-control.d.ts +84 -0
  60. package/lib/features/preset-control.js +43 -0
  61. package/lib/features/project-bind.d.ts +69 -0
  62. package/lib/features/project-bind.js +57 -0
  63. package/lib/features/project-info.d.ts +25 -0
  64. package/lib/features/project-info.js +22 -0
  65. package/lib/features/project-list.d.ts +79 -0
  66. package/lib/features/project-list.js +93 -0
  67. package/lib/features/prompt-submission.d.ts +53 -0
  68. package/lib/features/prompt-submission.js +47 -0
  69. package/lib/features/question-expiry.d.ts +53 -0
  70. package/lib/features/question-expiry.js +56 -0
  71. package/lib/features/question-routing.d.ts +108 -0
  72. package/lib/features/question-routing.js +162 -0
  73. package/lib/features/question-store.d.ts +124 -0
  74. package/lib/features/question-store.js +186 -0
  75. package/lib/features/question-view.d.ts +58 -0
  76. package/lib/features/question-view.js +81 -0
  77. package/lib/features/queue-control.d.ts +63 -0
  78. package/lib/features/queue-control.js +31 -0
  79. package/lib/features/reconcile-bindings.d.ts +75 -0
  80. package/lib/features/reconcile-bindings.js +54 -0
  81. package/lib/features/reconcile-deliveries.d.ts +29 -0
  82. package/lib/features/reconcile-deliveries.js +28 -0
  83. package/lib/features/reconcile-events.d.ts +49 -0
  84. package/lib/features/reconcile-events.js +50 -0
  85. package/lib/features/reconcile-intents.d.ts +32 -0
  86. package/lib/features/reconcile-intents.js +32 -0
  87. package/lib/features/reconcile-interactions.d.ts +59 -0
  88. package/lib/features/reconcile-interactions.js +54 -0
  89. package/lib/features/session-adopt.d.ts +89 -0
  90. package/lib/features/session-adopt.js +77 -0
  91. package/lib/features/session-creation.d.ts +49 -0
  92. package/lib/features/session-creation.js +43 -0
  93. package/lib/features/session-mainline.d.ts +75 -0
  94. package/lib/features/session-mainline.js +96 -0
  95. package/lib/features/session-resume.d.ts +44 -0
  96. package/lib/features/session-resume.js +40 -0
  97. package/lib/features/skill-run.d.ts +48 -0
  98. package/lib/features/skill-run.js +25 -0
  99. package/lib/features/steer-control.d.ts +37 -0
  100. package/lib/features/steer-control.js +28 -0
  101. package/lib/features/stop-control.d.ts +38 -0
  102. package/lib/features/stop-control.js +31 -0
  103. package/lib/features/task-admission.d.ts +29 -0
  104. package/lib/features/task-admission.js +21 -0
  105. package/lib/features/thread-continuation.d.ts +36 -0
  106. package/lib/features/thread-continuation.js +18 -0
  107. package/lib/features/thread-creation.d.ts +81 -0
  108. package/lib/features/thread-creation.js +81 -0
  109. package/lib/features/thread-routing.d.ts +49 -0
  110. package/lib/features/thread-routing.js +34 -0
  111. package/lib/features/turn-ownership.d.ts +39 -0
  112. package/lib/features/turn-ownership.js +40 -0
  113. package/lib/features/unbound-mention.d.ts +49 -0
  114. package/lib/features/unbound-mention.js +31 -0
  115. package/lib/features/workspace-channel.d.ts +41 -0
  116. package/lib/features/workspace-channel.js +47 -0
  117. package/lib/gateway/gateway.d.ts +50 -0
  118. package/lib/gateway/gateway.js +229 -0
  119. package/lib/gateway/inbound.d.ts +85 -0
  120. package/lib/gateway/inbound.js +186 -0
  121. package/lib/gateway/ingress.d.ts +15 -0
  122. package/lib/gateway/ingress.js +22 -0
  123. package/lib/i18n.d.ts +92 -0
  124. package/lib/i18n.js +180 -0
  125. package/lib/index.d.ts +17 -0
  126. package/lib/index.js +869 -0
  127. package/lib/lifecycle.d.ts +44 -0
  128. package/lib/lifecycle.js +84 -0
  129. package/lib/policy/authorization.d.ts +41 -0
  130. package/lib/policy/authorization.js +64 -0
  131. package/lib/policy/disclosure.d.ts +93 -0
  132. package/lib/policy/disclosure.js +153 -0
  133. package/lib/policy/guard.d.ts +31 -0
  134. package/lib/policy/guard.js +46 -0
  135. package/lib/policy/suppress.d.ts +13 -0
  136. package/lib/policy/suppress.js +20 -0
  137. package/lib/settings-namespace.d.ts +7 -0
  138. package/lib/settings-namespace.js +6 -0
  139. package/lib/settings.d.ts +37 -0
  140. package/lib/settings.js +102 -0
  141. package/lib/startup.d.ts +16 -0
  142. package/lib/startup.js +60 -0
  143. package/lib/state/bindings.d.ts +46 -0
  144. package/lib/state/bindings.js +55 -0
  145. package/lib/state/channel-bindings.d.ts +37 -0
  146. package/lib/state/channel-bindings.js +43 -0
  147. package/lib/state/domain.d.ts +82 -0
  148. package/lib/state/domain.js +86 -0
  149. package/lib/state/effect-machine.d.ts +37 -0
  150. package/lib/state/effect-machine.js +52 -0
  151. package/lib/state/fail-closed.d.ts +44 -0
  152. package/lib/state/fail-closed.js +57 -0
  153. package/lib/state/intents.d.ts +51 -0
  154. package/lib/state/intents.js +73 -0
  155. package/lib/state/records.d.ts +37 -0
  156. package/lib/state/records.js +30 -0
  157. package/lib/state/retention.d.ts +74 -0
  158. package/lib/state/retention.js +68 -0
  159. package/lib/state/session-owners.d.ts +50 -0
  160. package/lib/state/session-owners.js +50 -0
  161. package/lib/stream/finalizer.d.ts +49 -0
  162. package/lib/stream/finalizer.js +67 -0
  163. package/lib/stream/icons.d.ts +15 -0
  164. package/lib/stream/icons.js +36 -0
  165. package/lib/stream/live.d.ts +140 -0
  166. package/lib/stream/live.js +515 -0
  167. package/lib/stream/markdown.d.ts +24 -0
  168. package/lib/stream/markdown.js +95 -0
  169. package/lib/stream/outbound.d.ts +27 -0
  170. package/lib/stream/outbound.js +25 -0
  171. package/lib/stream/render-fence.d.ts +27 -0
  172. package/lib/stream/render-fence.js +37 -0
  173. package/lib/stream/render-model.d.ts +48 -0
  174. package/lib/stream/render-model.js +68 -0
  175. package/lib/stream/splitter.d.ts +8 -0
  176. package/lib/stream/splitter.js +58 -0
  177. package/lib/stream/tool-view.d.ts +40 -0
  178. package/lib/stream/tool-view.js +39 -0
  179. package/lib/stream/typing.d.ts +22 -0
  180. package/lib/stream/typing.js +58 -0
  181. package/lib/stream/update-scheduler.d.ts +20 -0
  182. package/lib/stream/update-scheduler.js +65 -0
  183. package/package.json +86 -0
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Fail-closed integrity gate over a binding store (design.md ยง10). Storage
3
+ * records are validated against the strict zod schema on every access:
4
+ * malformed or NEWER-format records make the key read as corrupt, and every
5
+ * Discord-triggered write refuses โ€” the record is never replaced, repaired,
6
+ * or emptied by the adapter. Backend durability failures surface as plain
7
+ * values so callers can retry or alert without a crash crossing the dispatch
8
+ * loop.
9
+ */
10
+ export function createFailClosedBindingStore(inner, schema, raw) {
11
+ function diagnose(key) {
12
+ const record = raw.get(key);
13
+ if (record === undefined || record === null)
14
+ return { state: 'missing' };
15
+ const parsed = schema.safeParse(record);
16
+ if (parsed.success)
17
+ return { state: 'ok' };
18
+ return {
19
+ state: 'corrupt',
20
+ issues: parsed.error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`),
21
+ };
22
+ }
23
+ function guard(key) {
24
+ return diagnose(key).state === 'corrupt' ? 'state-corrupt' : undefined;
25
+ }
26
+ async function durable(operation) {
27
+ try {
28
+ return await operation();
29
+ }
30
+ catch (cause) {
31
+ return { ok: false, error: 'durable-write-failed', cause };
32
+ }
33
+ }
34
+ return {
35
+ get(key) {
36
+ // Read-path integrity: a corrupt or newer-format record never reaches
37
+ // the business layer typed as a validated V. Callers that need the
38
+ // reason ask `diagnose(key)` explicitly.
39
+ if (guard(key) === 'state-corrupt')
40
+ return undefined;
41
+ return inner.get(key);
42
+ },
43
+ diagnose,
44
+ async bind(key, record, options) {
45
+ if (guard(key) === 'state-corrupt')
46
+ return { ok: false, error: 'state-corrupt' };
47
+ const result = await durable(() => inner.bind(key, record, options));
48
+ return result;
49
+ },
50
+ async release(key, options) {
51
+ if (guard(key) === 'state-corrupt')
52
+ return { ok: false, error: 'state-corrupt' };
53
+ const result = await durable(() => inner.release(key, options));
54
+ return result;
55
+ },
56
+ };
57
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Inbound intent records (design.md ยง10). The stable Discord message identity
3
+ * plus the normalized payload hash are claimed atomically before any DSH
4
+ * call, so every later delivery of the same message either reuses the record
5
+ * (same-ID/same-hash) or fails closed (same-ID/different-hash โ€” a tampered or
6
+ * ambiguous replay). Unknown outcomes are recorded and retained, never
7
+ * silently rewritten.
8
+ */
9
+ /** SHA-256 over the canonical JSON of the normalized payload. */
10
+ export declare function hashPayload(payload: unknown): Promise<string>;
11
+ export type IntentState = 'claimed' | 'succeeded' | 'failed' | 'unknown';
12
+ export interface InboundIntentRecord {
13
+ contentHash: string;
14
+ state: IntentState;
15
+ claimedAtMs: number;
16
+ resolvedAtMs?: number | undefined;
17
+ /** Correlation: the Discord thread created for this source message. */
18
+ threadId?: string | undefined;
19
+ }
20
+ export interface ClaimRequest {
21
+ messageId: string;
22
+ contentHash: string;
23
+ claimedAtMs: number;
24
+ /** Test/diagnostic hook run inside the serialized claim slot. */
25
+ beforeClaim?: () => Promise<void> | void;
26
+ }
27
+ export type ClaimOutcome = {
28
+ outcome: 'claimed';
29
+ } | {
30
+ outcome: 'duplicate';
31
+ record: InboundIntentRecord;
32
+ } | {
33
+ outcome: 'conflict';
34
+ record: InboundIntentRecord;
35
+ };
36
+ export type ResolveOutcome = 'succeeded' | 'failed' | 'unknown';
37
+ /** Minimal durable face (the domain's KvTable provides it). */
38
+ export interface IntentTable {
39
+ get(messageId: string): InboundIntentRecord | undefined;
40
+ put(messageId: string, record: InboundIntentRecord): Promise<void>;
41
+ }
42
+ export interface IntentStore {
43
+ get(messageId: string): InboundIntentRecord | undefined;
44
+ claim(request: ClaimRequest): Promise<ClaimOutcome>;
45
+ resolve(messageId: string, outcome: ResolveOutcome, atMs: number): Promise<void>;
46
+ /** Record the correlation thread for this intent, serialized. */
47
+ annotate(messageId: string, correlation: {
48
+ threadId: string;
49
+ }): Promise<void>;
50
+ }
51
+ export declare function createIntentStore(table: IntentTable): IntentStore;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Inbound intent records (design.md ยง10). The stable Discord message identity
3
+ * plus the normalized payload hash are claimed atomically before any DSH
4
+ * call, so every later delivery of the same message either reuses the record
5
+ * (same-ID/same-hash) or fails closed (same-ID/different-hash โ€” a tampered or
6
+ * ambiguous replay). Unknown outcomes are recorded and retained, never
7
+ * silently rewritten.
8
+ */
9
+ import { createHash } from 'node:crypto';
10
+ /** Canonical JSON: recursively sorted keys, so hash equality is structural. */
11
+ function stableValue(value) {
12
+ if (Array.isArray(value))
13
+ return `[${value.map(item => stableValue(item)).join(',')}]`;
14
+ if (typeof value === 'object' && value !== null) {
15
+ const entries = Object.entries(value).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
16
+ return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${stableValue(item)}`).join(',')}}`;
17
+ }
18
+ return JSON.stringify(value);
19
+ }
20
+ /** SHA-256 over the canonical JSON of the normalized payload. */
21
+ export function hashPayload(payload) {
22
+ return Promise.resolve(createHash('sha256').update(stableValue(payload)).digest('hex'));
23
+ }
24
+ const noBeforeClaim = () => { };
25
+ export function createIntentStore(table) {
26
+ const chains = new Map();
27
+ function enqueue(messageId, op) {
28
+ const tail = chains.get(messageId) ?? Promise.resolve();
29
+ const run = tail.then(op, op);
30
+ chains.set(messageId, run.then(() => undefined, () => undefined));
31
+ return run;
32
+ }
33
+ return {
34
+ get: messageId => table.get(messageId),
35
+ claim(request) {
36
+ return enqueue(request.messageId, async () => {
37
+ const hook = request.beforeClaim ?? noBeforeClaim;
38
+ await Promise.resolve().then(hook);
39
+ const existing = table.get(request.messageId);
40
+ if (existing !== undefined) {
41
+ // Same identity, different content: refuse rather than guess.
42
+ if (existing.contentHash !== request.contentHash) {
43
+ return { outcome: 'conflict', record: existing };
44
+ }
45
+ return { outcome: 'duplicate', record: existing };
46
+ }
47
+ const record = {
48
+ contentHash: request.contentHash,
49
+ state: 'claimed',
50
+ claimedAtMs: request.claimedAtMs,
51
+ };
52
+ await table.put(request.messageId, record);
53
+ return { outcome: 'claimed' };
54
+ });
55
+ },
56
+ resolve(messageId, outcome, atMs) {
57
+ return enqueue(messageId, async () => {
58
+ const existing = table.get(messageId);
59
+ if (existing === undefined)
60
+ return;
61
+ await table.put(messageId, { ...existing, state: outcome, resolvedAtMs: atMs });
62
+ });
63
+ },
64
+ annotate(messageId, correlation) {
65
+ return enqueue(messageId, async () => {
66
+ const existing = table.get(messageId);
67
+ if (existing === undefined)
68
+ return;
69
+ await table.put(messageId, { ...existing, threadId: correlation.threadId });
70
+ });
71
+ },
72
+ };
73
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Binding record schemas, validated by zod at the durable boundary. The
3
+ * schemas are strict: unknown fields reject, so state written by a newer
4
+ * format fails closed instead of silently half-decoding.
5
+ */
6
+ import { z } from 'zod';
7
+ /** One channel-to-workspace binding; `revision` fences stale writes (6.2). */
8
+ export declare const ChannelBindingRecord: z.ZodObject<{
9
+ workspaceId: z.ZodString;
10
+ revision: z.ZodNumber;
11
+ boundBy: z.ZodString;
12
+ boundAtMs: z.ZodNumber;
13
+ }, z.core.$strict>;
14
+ export type ChannelBinding = z.infer<typeof ChannelBindingRecord>;
15
+ /** One inbound intent record; the durable at-most-once claim (design ยง10). */
16
+ export declare const InboundIntentRecordSchema: z.ZodObject<{
17
+ contentHash: z.ZodString;
18
+ state: z.ZodEnum<{
19
+ claimed: "claimed";
20
+ unknown: "unknown";
21
+ succeeded: "succeeded";
22
+ failed: "failed";
23
+ }>;
24
+ claimedAtMs: z.ZodNumber;
25
+ resolvedAtMs: z.ZodOptional<z.ZodNumber>;
26
+ threadId: z.ZodOptional<z.ZodString>;
27
+ }, z.core.$strict>;
28
+ export type InboundIntentRecordSchema = z.infer<typeof InboundIntentRecordSchema>;
29
+ /** One thread-to-session writable binding; `revision` fences stale writes. */
30
+ export declare const ThreadBindingRecord: z.ZodObject<{
31
+ sessionId: z.ZodString;
32
+ workspaceId: z.ZodString;
33
+ revision: z.ZodNumber;
34
+ createdBy: z.ZodString;
35
+ createdAtMs: z.ZodNumber;
36
+ }, z.core.$strict>;
37
+ export type ThreadBinding = z.infer<typeof ThreadBindingRecord>;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Binding record schemas, validated by zod at the durable boundary. The
3
+ * schemas are strict: unknown fields reject, so state written by a newer
4
+ * format fails closed instead of silently half-decoding.
5
+ */
6
+ import { z } from 'zod';
7
+ /** One channel-to-workspace binding; `revision` fences stale writes (6.2). */
8
+ export const ChannelBindingRecord = z.strictObject({
9
+ workspaceId: z.string().min(1),
10
+ revision: z.number().int().min(1),
11
+ /** Discord user id that performed the bind. */
12
+ boundBy: z.string().min(1),
13
+ boundAtMs: z.number().int().min(0),
14
+ });
15
+ /** One inbound intent record; the durable at-most-once claim (design ยง10). */
16
+ export const InboundIntentRecordSchema = z.strictObject({
17
+ contentHash: z.string().min(1),
18
+ state: z.enum(['claimed', 'succeeded', 'failed', 'unknown']),
19
+ claimedAtMs: z.number().int().min(0),
20
+ resolvedAtMs: z.number().int().min(0).optional(),
21
+ threadId: z.string().min(1).optional(),
22
+ });
23
+ /** One thread-to-session writable binding; `revision` fences stale writes. */
24
+ export const ThreadBindingRecord = z.strictObject({
25
+ sessionId: z.string().min(1),
26
+ workspaceId: z.string().min(1),
27
+ revision: z.number().int().min(1),
28
+ createdBy: z.string().min(1),
29
+ createdAtMs: z.number().int().min(0),
30
+ });
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Bounded retention and Guild forget (design.md ยง10). Completed intents and
3
+ * deliveries default to 30-day retention; resolved interactions to 7 days;
4
+ * no configured window may drop below the 7-day floor. Unresolved records โ€”
5
+ * claimed prompts and unknown outcomes โ€” never expire on a timer: they wait
6
+ * for explicit user resolution or a Guild forget. Active channel/thread
7
+ * bindings are ownership, not history, and no retention rule touches them.
8
+ * A Guild forget plans the removal of only that guild's adapter records;
9
+ * DSH workspaces and sessions are structurally out of reach.
10
+ */
11
+ import type { InboundIntentRecord } from './intents.js';
12
+ /** The retention floor: no configured window may go below 7 days. */
13
+ export declare const MINIMUM_RETENTION_MS: number;
14
+ /** Default retention for completed (succeeded/failed) intents and deliveries. */
15
+ export declare const DEFAULT_COMPLETED_RETENTION_MS: number;
16
+ /** Default retention for resolved interactions. */
17
+ export declare const DEFAULT_INTERACTION_RETENTION_MS: number;
18
+ export interface RetentionPolicy {
19
+ completedRetentionMs?: number | undefined;
20
+ interactionRetentionMs?: number | undefined;
21
+ }
22
+ export interface NormalizedRetentionPolicy {
23
+ completedRetentionMs: number;
24
+ interactionRetentionMs: number;
25
+ }
26
+ /** Clamp a policy into the legal range, applying defaults. */
27
+ export declare function normalizeRetentionPolicy(policy: RetentionPolicy): NormalizedRetentionPolicy;
28
+ /** Resolved-intent record shape (subset) the sweep reasons about. */
29
+ export interface SweepableIntent {
30
+ state: InboundIntentRecord['state'];
31
+ resolvedAtMs?: number | undefined;
32
+ }
33
+ export interface SweepableInteraction {
34
+ resolvedAtMs: number;
35
+ }
36
+ export interface SweepInput {
37
+ intents: ReadonlyArray<[string, SweepableIntent]>;
38
+ resolvedInteractions: ReadonlyArray<[string, SweepableInteraction]>;
39
+ }
40
+ export interface SweepOptions {
41
+ nowMs: number;
42
+ policy?: RetentionPolicy | undefined;
43
+ }
44
+ export interface SweepPlan {
45
+ intentKeys: string[];
46
+ interactionKeys: string[];
47
+ }
48
+ /**
49
+ * Plan one retention sweep. Only terminal, resolved records age out:
50
+ * `claimed` and `unknown` intents are retained indefinitely (unknown until
51
+ * explicit user resolution), regardless of age.
52
+ */
53
+ export declare function sweepExpired(input: SweepInput, options: SweepOptions): SweepPlan;
54
+ export interface ForgetPlanInput {
55
+ guildId: string;
56
+ channelBindingKeys: readonly string[];
57
+ threadBindingKeys: readonly string[];
58
+ }
59
+ export interface ForgetPlan {
60
+ channelKeys: string[];
61
+ threadKeys: string[];
62
+ /**
63
+ * Always empty: intent keys are message-scoped, so forget planning cannot
64
+ * sweep them by guild โ€” deliberate, to keep DSH-side data structurally
65
+ * unreachable from a Discord-initiated forget.
66
+ */
67
+ intentKeys: string[];
68
+ }
69
+ /**
70
+ * Plan one Guild forget: parse every binding key and keep only those whose
71
+ * guild matches. Removal execution (deletes) stays with the caller, which
72
+ * holds the open domain tables.
73
+ */
74
+ export declare function guildKeysToForget(input: ForgetPlanInput): ForgetPlan;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Bounded retention and Guild forget (design.md ยง10). Completed intents and
3
+ * deliveries default to 30-day retention; resolved interactions to 7 days;
4
+ * no configured window may drop below the 7-day floor. Unresolved records โ€”
5
+ * claimed prompts and unknown outcomes โ€” never expire on a timer: they wait
6
+ * for explicit user resolution or a Guild forget. Active channel/thread
7
+ * bindings are ownership, not history, and no retention rule touches them.
8
+ * A Guild forget plans the removal of only that guild's adapter records;
9
+ * DSH workspaces and sessions are structurally out of reach.
10
+ */
11
+ const DAY = 24 * 60 * 60_000;
12
+ /** The retention floor: no configured window may go below 7 days. */
13
+ export const MINIMUM_RETENTION_MS = 7 * DAY;
14
+ /** Default retention for completed (succeeded/failed) intents and deliveries. */
15
+ export const DEFAULT_COMPLETED_RETENTION_MS = 30 * DAY;
16
+ /** Default retention for resolved interactions. */
17
+ export const DEFAULT_INTERACTION_RETENTION_MS = 7 * DAY;
18
+ /** Clamp a policy into the legal range, applying defaults. */
19
+ export function normalizeRetentionPolicy(policy) {
20
+ const clamp = (value, fallback) => value === undefined || !Number.isFinite(value)
21
+ ? fallback
22
+ : Math.max(value, MINIMUM_RETENTION_MS);
23
+ return {
24
+ completedRetentionMs: clamp(policy.completedRetentionMs, DEFAULT_COMPLETED_RETENTION_MS),
25
+ interactionRetentionMs: clamp(policy.interactionRetentionMs, DEFAULT_INTERACTION_RETENTION_MS),
26
+ };
27
+ }
28
+ /**
29
+ * Plan one retention sweep. Only terminal, resolved records age out:
30
+ * `claimed` and `unknown` intents are retained indefinitely (unknown until
31
+ * explicit user resolution), regardless of age.
32
+ */
33
+ export function sweepExpired(input, options) {
34
+ const policy = normalizeRetentionPolicy(options.policy ?? {});
35
+ const intentKeys = input.intents
36
+ .filter(([, record]) => {
37
+ if (record.state !== 'succeeded' && record.state !== 'failed')
38
+ return false;
39
+ const resolvedAtMs = record.resolvedAtMs;
40
+ if (resolvedAtMs === undefined)
41
+ return false;
42
+ return options.nowMs - resolvedAtMs >= policy.completedRetentionMs;
43
+ })
44
+ .map(([key]) => key);
45
+ const interactionKeys = input.resolvedInteractions
46
+ .filter(([, record]) => options.nowMs - record.resolvedAtMs >= policy.interactionRetentionMs)
47
+ .map(([key]) => key);
48
+ return { intentKeys, interactionKeys };
49
+ }
50
+ /**
51
+ * Plan one Guild forget: parse every binding key and keep only those whose
52
+ * guild matches. Removal execution (deletes) stays with the caller, which
53
+ * holds the open domain tables.
54
+ */
55
+ export function guildKeysToForget(input) {
56
+ const parse = (key) => {
57
+ const segments = key.split(':');
58
+ if (segments.length !== 6)
59
+ return undefined;
60
+ const guildId = segments[3];
61
+ return guildId === input.guildId ? key : undefined;
62
+ };
63
+ return {
64
+ channelKeys: input.channelBindingKeys.filter(key => parse(key) !== undefined),
65
+ threadKeys: input.threadBindingKeys.filter(key => parse(key) !== undefined),
66
+ intentKeys: [],
67
+ };
68
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The unique logical owner record for writable Session bindings (design.md
3
+ * ยง6, ยง10). One record per session id โ€” the record itself IS the ownership,
4
+ * so uniqueness falls out of the key. Claims serialize per session; a second
5
+ * writable thread conflicts without any implicit takeover, the owning thread
6
+ * re-claiming is idempotent (crash/recovery), and only the owner can release.
7
+ */
8
+ export interface SessionOwnerRecord {
9
+ threadId: string;
10
+ guildId: string;
11
+ claimedAtMs: number;
12
+ }
13
+ export interface SessionOwnerTable {
14
+ get(sessionId: string): SessionOwnerRecord | undefined;
15
+ put(sessionId: string, record: SessionOwnerRecord): Promise<void>;
16
+ delete(sessionId: string): Promise<boolean>;
17
+ }
18
+ export interface ClaimOwnershipRequest {
19
+ sessionId: string;
20
+ threadId: string;
21
+ guildId: string;
22
+ claimedAtMs: number;
23
+ /** Test/diagnostic hook run inside the serialized claim slot. */
24
+ beforeClaim?: () => Promise<void> | void;
25
+ }
26
+ export type ClaimOwnershipOutcome = {
27
+ outcome: 'claimed';
28
+ record: SessionOwnerRecord;
29
+ } | {
30
+ outcome: 'duplicate';
31
+ record: SessionOwnerRecord;
32
+ } | {
33
+ outcome: 'conflict';
34
+ record: SessionOwnerRecord;
35
+ };
36
+ export type ReleaseOutcome = {
37
+ ok: true;
38
+ } | {
39
+ ok: false;
40
+ error: 'not-owner';
41
+ };
42
+ export interface SessionOwnerStore {
43
+ get(sessionId: string): SessionOwnerRecord | undefined;
44
+ claim(request: ClaimOwnershipRequest): Promise<ClaimOwnershipOutcome>;
45
+ release(request: {
46
+ sessionId: string;
47
+ threadId: string;
48
+ }): Promise<ReleaseOutcome>;
49
+ }
50
+ export declare function createSessionOwnerStore(table: SessionOwnerTable): SessionOwnerStore;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The unique logical owner record for writable Session bindings (design.md
3
+ * ยง6, ยง10). One record per session id โ€” the record itself IS the ownership,
4
+ * so uniqueness falls out of the key. Claims serialize per session; a second
5
+ * writable thread conflicts without any implicit takeover, the owning thread
6
+ * re-claiming is idempotent (crash/recovery), and only the owner can release.
7
+ */
8
+ const noBeforeClaim = () => { };
9
+ export function createSessionOwnerStore(table) {
10
+ const chains = new Map();
11
+ function enqueue(sessionId, op) {
12
+ const tail = chains.get(sessionId) ?? Promise.resolve();
13
+ const run = tail.then(op, op);
14
+ chains.set(sessionId, run.then(() => undefined, () => undefined));
15
+ return run;
16
+ }
17
+ return {
18
+ get: sessionId => table.get(sessionId),
19
+ claim(request) {
20
+ return enqueue(request.sessionId, async () => {
21
+ const hook = request.beforeClaim ?? noBeforeClaim;
22
+ await Promise.resolve().then(hook);
23
+ const existing = table.get(request.sessionId);
24
+ if (existing !== undefined) {
25
+ const record = existing;
26
+ return existing.threadId === request.threadId
27
+ ? { outcome: 'duplicate', record }
28
+ : { outcome: 'conflict', record };
29
+ }
30
+ const record = {
31
+ threadId: request.threadId,
32
+ guildId: request.guildId,
33
+ claimedAtMs: request.claimedAtMs,
34
+ };
35
+ await table.put(request.sessionId, record);
36
+ return { outcome: 'claimed', record };
37
+ });
38
+ },
39
+ release(request) {
40
+ return enqueue(request.sessionId, async () => {
41
+ const existing = table.get(request.sessionId);
42
+ if (existing === undefined || existing.threadId !== request.threadId) {
43
+ return { ok: false, error: 'not-owner' };
44
+ }
45
+ await table.delete(request.sessionId);
46
+ return { ok: true };
47
+ });
48
+ },
49
+ };
50
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The answer finalizer (design.md ยง8, task 11.10). The final answer lands as
3
+ * ONE edited head message; overflow continues as ordered follow-up messages.
4
+ * Every piece is sent exactly once: the finalizer records progress as it
5
+ * goes, duplicate finalize calls skip without touching Discord, a failed
6
+ * continuation halts at that index (recovery belongs to reconciliation, not
7
+ * blind resend), and a rate-limited head edit is retried in place โ€” bounded,
8
+ * so a port that keeps reporting rate-limit cannot spin forever.
9
+ */
10
+ export interface AnswerDeliveryPort {
11
+ editHead(request: {
12
+ messageId: string;
13
+ content: string;
14
+ }): Promise<{
15
+ outcome: 'completed';
16
+ } | {
17
+ outcome: 'rate-limited';
18
+ retryAfterMs: number;
19
+ } | {
20
+ outcome: 'failed';
21
+ }>;
22
+ sendContinuation(request: {
23
+ index: number;
24
+ content: string;
25
+ }): Promise<{
26
+ outcome: 'completed';
27
+ } | {
28
+ outcome: 'failed';
29
+ }>;
30
+ }
31
+ export type FinalizeResult = {
32
+ outcome: 'finalized';
33
+ editedHead: boolean;
34
+ continuations: number;
35
+ } | {
36
+ outcome: 'partial';
37
+ editedHead: boolean;
38
+ continuations: number;
39
+ } | {
40
+ outcome: 'skipped';
41
+ reason: 'already-finalized';
42
+ };
43
+ export interface AnswerFinalizer {
44
+ finalize(fullText: string): Promise<FinalizeResult>;
45
+ }
46
+ export declare function createAnswerFinalizer(deps: {
47
+ delivery: AnswerDeliveryPort;
48
+ headMessageId: string;
49
+ }): AnswerFinalizer;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The answer finalizer (design.md ยง8, task 11.10). The final answer lands as
3
+ * ONE edited head message; overflow continues as ordered follow-up messages.
4
+ * Every piece is sent exactly once: the finalizer records progress as it
5
+ * goes, duplicate finalize calls skip without touching Discord, a failed
6
+ * continuation halts at that index (recovery belongs to reconciliation, not
7
+ * blind resend), and a rate-limited head edit is retried in place โ€” bounded,
8
+ * so a port that keeps reporting rate-limit cannot spin forever.
9
+ */
10
+ import { splitMarkdownAware } from './markdown.js';
11
+ import { DISCORD_MESSAGE_LIMIT } from './splitter.js';
12
+ /** Head-edit retry budget when the port reports rate-limit backoff. */
13
+ const MAX_HEAD_EDIT_RETRIES = 10;
14
+ export function createAnswerFinalizer(deps) {
15
+ let finalized = false;
16
+ return {
17
+ async finalize(fullText) {
18
+ if (finalized)
19
+ return { outcome: 'skipped', reason: 'already-finalized' };
20
+ // Fence-aware: a long answer never splits a ``` code block across
21
+ // messages; each continuation renders as well-formed Markdown alone.
22
+ const chunks = splitMarkdownAware(fullText, DISCORD_MESSAGE_LIMIT);
23
+ if (chunks.length === 0) {
24
+ finalized = true;
25
+ return { outcome: 'finalized', editedHead: false, continuations: 0 };
26
+ }
27
+ // Head: edit until the port completes; a rate-limited port backs off
28
+ // here, within a bounded budget so the loop cannot spin forever.
29
+ let editedHead = false;
30
+ let rateLimitedAttempts = 0;
31
+ for (;;) {
32
+ const edited = await deps.delivery.editHead({ messageId: deps.headMessageId, content: chunks[0] ?? '' });
33
+ if (edited.outcome === 'completed') {
34
+ editedHead = true;
35
+ break;
36
+ }
37
+ if (edited.outcome === 'rate-limited') {
38
+ rateLimitedAttempts += 1;
39
+ if (rateLimitedAttempts > MAX_HEAD_EDIT_RETRIES) {
40
+ finalized = true;
41
+ return { outcome: 'partial', editedHead: false, continuations: 0 };
42
+ }
43
+ await new Promise(resolve => { setTimeout(resolve, edited.retryAfterMs); });
44
+ continue;
45
+ }
46
+ // Hard failure: nothing was mutated on Discord.
47
+ finalized = true;
48
+ return { outcome: 'partial', editedHead: false, continuations: 0 };
49
+ }
50
+ // Continuations: ordered, exactly once, halting at the first failure.
51
+ let sent = 0;
52
+ for (let index = 1; index < chunks.length; index += 1) {
53
+ const content = chunks[index];
54
+ if (content === undefined)
55
+ break;
56
+ const sentOutcome = await deps.delivery.sendContinuation({ index, content });
57
+ if (sentOutcome.outcome !== 'completed') {
58
+ finalized = true;
59
+ return { outcome: 'partial', editedHead, continuations: sent };
60
+ }
61
+ sent += 1;
62
+ }
63
+ finalized = true;
64
+ return { outcome: 'finalized', editedHead, continuations: sent };
65
+ },
66
+ };
67
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The adapter's fixed iconography table (stream-renderer "Fixed
3
+ * adapter-owned iconography"). This module is the ONLY source of icons:
4
+ * tool rows carry one category icon each (never a run-state mark โ€” state
5
+ * flips would cost one message edit per tool transition), and notices use
6
+ * kind-fixed prefixes. Icons are never derived from model output, tool
7
+ * output, or Host presentation views; sanitized text labels stay unchanged
8
+ * beside them, and assistant answer text carries no icon prefix.
9
+ */
10
+ /** The category icon for a sanitized tool label (generic fallback included). */
11
+ export declare function toolCategoryIcon(label: string): string;
12
+ /** The notice kinds the adapter renders, with their fixed prefixes. */
13
+ export type NoticeKind = 'failure' | 'guidance' | 'stop' | 'steer' | 'queued';
14
+ /** The fixed prefix for a notice kind. */
15
+ export declare function noticeIcon(kind: NoticeKind): string;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The adapter's fixed iconography table (stream-renderer "Fixed
3
+ * adapter-owned iconography"). This module is the ONLY source of icons:
4
+ * tool rows carry one category icon each (never a run-state mark โ€” state
5
+ * flips would cost one message edit per tool transition), and notices use
6
+ * kind-fixed prefixes. Icons are never derived from model output, tool
7
+ * output, or Host presentation views; sanitized text labels stay unchanged
8
+ * beside them, and assistant answer text carries no icon prefix.
9
+ */
10
+ /** Category icon by the sanitized label's first word (the label allowlist). */
11
+ const TOOL_CATEGORY_ICONS = {
12
+ Shell: '๐Ÿ’ป',
13
+ Read: '๐Ÿ“–',
14
+ Write: 'โœ๏ธ',
15
+ Edit: 'โœ๏ธ',
16
+ Search: '๐Ÿ”',
17
+ Find: '๐Ÿ—‚๏ธ',
18
+ Web: '๐ŸŒ',
19
+ };
20
+ const GENERIC_CATEGORY_ICON = '๐Ÿงฉ';
21
+ /** The category icon for a sanitized tool label (generic fallback included). */
22
+ export function toolCategoryIcon(label) {
23
+ const firstWord = label.split(' ')[0] ?? '';
24
+ return TOOL_CATEGORY_ICONS[firstWord] ?? GENERIC_CATEGORY_ICON;
25
+ }
26
+ const NOTICE_ICONS = {
27
+ failure: 'โš ๏ธ',
28
+ guidance: '๐Ÿ’ก',
29
+ stop: '๐Ÿ›‘',
30
+ steer: 'โ†ช๏ธ',
31
+ queued: 'โณ',
32
+ };
33
+ /** The fixed prefix for a notice kind. */
34
+ export function noticeIcon(kind) {
35
+ return NOTICE_ICONS[kind];
36
+ }