@huynle/hub 0.6.0 → 0.7.1
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/CHANGELOG.md +800 -0
- package/Dockerfile +30 -0
- package/README.md +870 -66
- package/package.json +45 -2
- package/packages/claude-code/package.json +12 -0
- package/packages/claude-code/src/bridge.ts +79 -0
- package/packages/claude-code/src/daemon.ts +339 -0
- package/packages/claude-code/src/hook-input.ts +44 -0
- package/packages/claude-code/src/ids.ts +27 -0
- package/packages/claude-code/src/index.ts +18 -0
- package/packages/claude-code/src/launcher.ts +265 -0
- package/packages/claude-code/src/main.ts +127 -0
- package/packages/claude-code/src/mcp.ts +109 -0
- package/packages/claude-code/src/paths.ts +34 -0
- package/packages/claude-code/src/plugin.ts +99 -0
- package/packages/claude-code/src/references.ts +51 -0
- package/packages/claude-code/src/sessions.ts +600 -0
- package/packages/claude-code/src/tools.ts +66 -0
- package/packages/claude-code/src/wire.ts +71 -0
- package/packages/claude-code/src/wrapper.ts +155 -0
- package/packages/cli/dist/hub-cli.js +10162 -0
- package/packages/codex/package.json +12 -0
- package/packages/codex/src/app-server.ts +44 -0
- package/packages/codex/src/ids.ts +28 -0
- package/packages/codex/src/index.ts +11 -0
- package/packages/codex/src/jsonrpc.ts +168 -0
- package/packages/codex/src/main.ts +122 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +249 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +100 -0
- package/packages/node/package.json +12 -0
- package/packages/node/src/config.ts +184 -0
- package/packages/node/src/connection.ts +283 -0
- package/packages/node/src/daemon.ts +281 -0
- package/packages/node/src/git.ts +107 -0
- package/packages/node/src/harnesses.ts +60 -0
- package/packages/node/src/host.ts +319 -0
- package/packages/node/src/index.ts +17 -0
- package/packages/node/src/main.ts +11 -0
- package/packages/node/src/openshell.ts +73 -0
- package/packages/node/src/run.ts +106 -0
- package/packages/preset/package.json +12 -0
- package/packages/preset/presets/aider.json +8 -0
- package/packages/preset/presets/goose.json +8 -0
- package/packages/preset/src/index.ts +2 -0
- package/packages/preset/src/main.ts +195 -0
- package/packages/preset/src/presets.ts +87 -0
- package/packages/preset/src/spoke.ts +251 -0
- package/packages/preset/src/tmux.ts +160 -0
- package/packages/protocol/package.json +12 -0
- package/packages/protocol/src/harness-events.ts +207 -0
- package/packages/protocol/src/index.ts +1215 -0
- package/packages/protocol/src/launch.ts +172 -0
- package/packages/protocol/src/node.ts +409 -0
- package/packages/protocol/src/preset.ts +144 -0
- package/packages/protocol/src/webhook.ts +62 -0
- package/packages/protocol/src/wire.ts +54 -0
- package/packages/runtime-openshell/image/Dockerfile +36 -0
- package/packages/runtime-openshell/image/README.md +78 -0
- package/packages/runtime-openshell/image/build.ts +203 -0
- package/packages/runtime-openshell/image/entry.sh +163 -0
- package/packages/runtime-openshell/image/image.ts +119 -0
- package/packages/runtime-openshell/image/start.sh +24 -0
- package/packages/runtime-openshell/package.json +12 -0
- package/packages/runtime-openshell/src/cli.ts +584 -0
- package/packages/runtime-openshell/src/index.ts +6 -0
- package/packages/runtime-openshell/src/policy.ts +155 -0
- package/packages/runtime-openshell/src/profiles.ts +181 -0
- package/packages/runtime-openshell/src/runtime.ts +475 -0
- package/packages/runtime-sdk/package.json +12 -0
- package/packages/runtime-sdk/src/index.ts +87 -0
- package/packages/sdk/package.json +12 -0
- package/packages/sdk/src/address.ts +25 -0
- package/packages/sdk/src/client.ts +502 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +106 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +67 -0
- package/packages/sdk/src/types.ts +458 -0
- package/packages/sdk/src/webhook.ts +102 -0
- package/packages/spoke-sdk/package.json +12 -0
- package/packages/spoke-sdk/src/activity.ts +3 -0
- package/packages/spoke-sdk/src/connection.ts +295 -0
- package/packages/spoke-sdk/src/delivery.ts +119 -0
- package/packages/spoke-sdk/src/enroll.ts +79 -0
- package/packages/spoke-sdk/src/harness-hello.ts +79 -0
- package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
- package/packages/spoke-sdk/src/index.ts +12 -0
- package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
- package/packages/spoke-sdk/src/prompt-source.ts +8 -0
- package/packages/spoke-sdk/src/session-ids.ts +49 -0
- package/packages/spoke-sdk/src/session-tokens.ts +78 -0
- package/scripts/hub-claude-code.service +34 -0
- package/scripts/hub-codex-spoke.service +34 -0
- package/scripts/hub-node.service +35 -0
- package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
- package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
- package/scripts/io.github.huynle.hub-node.plist +52 -0
- package/src/central/actions.ts +233 -0
- package/src/central/agent/acting-routes.ts +133 -0
- package/src/central/agent/acting-switch.ts +93 -0
- package/src/central/agent/acting.ts +599 -0
- package/src/central/agent/actor.ts +193 -0
- package/src/central/agent/app-tokens.ts +142 -0
- package/src/central/agent/audit.ts +185 -0
- package/src/central/agent/decisions.ts +530 -0
- package/src/central/agent/delivery.ts +678 -0
- package/src/central/agent/directory-routes.ts +185 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +72 -0
- package/src/central/agent/escalation.ts +219 -0
- package/src/central/agent/grant-requests.ts +221 -0
- package/src/central/agent/grant-routes.ts +70 -0
- package/src/central/agent/grants.ts +235 -0
- package/src/central/agent/launch-engine.ts +1025 -0
- package/src/central/agent/launch-routes.ts +252 -0
- package/src/central/agent/launch-spec.ts +289 -0
- package/src/central/agent/launch-store.ts +264 -0
- package/src/central/agent/launch-tokens.ts +206 -0
- package/src/central/agent/message-limits.ts +123 -0
- package/src/central/agent/message-routes.ts +385 -0
- package/src/central/agent/messages.ts +442 -0
- package/src/central/agent/names.ts +86 -0
- package/src/central/agent/placement.ts +419 -0
- package/src/central/agent/policy.ts +378 -0
- package/src/central/agent/read-routes.ts +389 -0
- package/src/central/agent/routes.ts +278 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +221 -0
- package/src/central/agent/webhook-routes.ts +78 -0
- package/src/central/agent/webhooks.ts +449 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +224 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +113 -0
- package/src/central/backend/memory-bus.ts +93 -0
- package/src/central/backend/memory-catalog.ts +90 -0
- package/src/central/backend/memory-counters.ts +102 -0
- package/src/central/backend/memory-events.ts +45 -0
- package/src/central/backend/memory-kv.ts +38 -0
- package/src/central/backend/memory-state.ts +77 -0
- package/src/central/backend/postgres-migrate.ts +100 -0
- package/src/central/backend/postgres-state.ts +197 -0
- package/src/central/backend/types.ts +244 -0
- package/src/central/backend/valkey-bus.ts +425 -0
- package/src/central/backend/valkey-catalog.ts +190 -0
- package/src/central/backend/valkey-client.ts +36 -0
- package/src/central/backend/valkey-counters.ts +38 -0
- package/src/central/backend/valkey-events.ts +168 -0
- package/src/central/backend/valkey-kv.ts +54 -0
- package/src/central/backend/valkey-locks.ts +92 -0
- package/src/central/cluster/directory.ts +141 -0
- package/src/central/cluster/feed.ts +84 -0
- package/src/central/cluster/leader.ts +232 -0
- package/src/central/cluster/live.ts +151 -0
- package/src/central/cluster/recommend.ts +59 -0
- package/src/central/cluster/rpc.ts +150 -0
- package/src/central/cluster/view.ts +232 -0
- package/src/central/config.ts +197 -3
- package/src/central/done.ts +96 -0
- package/src/central/events-log.ts +100 -0
- package/src/central/http.ts +1067 -171
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- package/src/central/nodes.ts +314 -0
- package/src/central/oidc.ts +34 -14
- package/src/central/push-target.ts +118 -18
- package/src/central/push.ts +256 -82
- package/src/central/registry.ts +902 -92
- package/src/central/say-stream.ts +128 -0
- package/src/central/session-token-push.ts +233 -0
- package/src/central/speech/openai.ts +21 -14
- package/src/central/speech/openrouter.ts +27 -10
- package/src/central/speech/provider.ts +54 -0
- package/src/central/speech/service.ts +22 -7
- package/src/central/spoke-ws.ts +522 -44
- package/src/central/store-shared.ts +518 -0
- package/src/central/store.ts +572 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +49 -1
- package/src/index.ts +165 -12
- package/src/protocol.ts +3 -650
- package/src/rpc.ts +27 -2
- package/src/spoke/client.ts +3 -272
- package/src/spoke/conversation.ts +16 -12
- package/src/spoke/deliver.ts +75 -0
- package/src/spoke/enroll.ts +2 -79
- package/src/spoke/env-inject.ts +160 -0
- package/src/spoke/hub-rpc.ts +115 -0
- package/src/spoke/hub-tool.ts +409 -0
- package/src/spoke/launch-source.ts +22 -0
- package/src/spoke/launchers.ts +68 -0
- package/src/spoke/locations.ts +3 -2
- package/src/spoke/node-roots.ts +61 -0
- package/src/spoke/prompt-refs.ts +116 -0
- package/src/spoke/session-tokens.ts +2 -0
- package/src/spoke/spoke.ts +465 -28
- package/src/spoke/taskboard.ts +243 -0
- package/src/spoke/timeline.ts +184 -6
- package/src/spoke/tracker.ts +79 -4
- package/src/status.ts +203 -10
- package/src/tui-commands.ts +13 -6
- package/src/tui-messaging.ts +93 -0
- package/src/tui.ts +15 -0
- package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-D9zMbxSg.css +1 -0
- package/web/dist/assets/index-db1z4duA.js +20 -0
- package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
- package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
- package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
- package/web/dist/index.html +9 -2
- package/web/dist/sw.js +79 -15
- package/web/dist/assets/index-DClBvfLL.css +0 -1
- package/web/dist/assets/index-Djo_yF0a.js +0 -12
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
// The agent message store (design §3.1, §3.2), over the StateStore interface with an injected clock. No routes:
|
|
2
|
+
// the message routes (P2-T6) and the delivery engine (P2-T5) call it.
|
|
3
|
+
//
|
|
4
|
+
// - Stored form: {id, kind, from, to, recipient, generation?, text, replyTo?, state, at, hop, expiresAt?,
|
|
5
|
+
// delivery?, deliveredAt?, answer?}. `from` is the authenticated sender (partyFromActor); `to` keeps the
|
|
6
|
+
// address as written (`@@reviewer`), `recipient` the party it reached; text and `to` are sanitised.
|
|
7
|
+
// - Caps: each party (session, the user, an app) keeps its latest SESSION_MESSAGES; a message no party keeps
|
|
8
|
+
// is deleted. Per user, at most USER_MESSAGES messages / USER_BYTES; over either, the oldest go (down to
|
|
9
|
+
// 90%). Eviction keeps open asks while anything else can go.
|
|
10
|
+
// - Asks: open → answered (a reply) | declined | expired (at expiresAt, seen on read; expire() persists it).
|
|
11
|
+
// The first answer wins (version-checked write); later ones get `gone`.
|
|
12
|
+
// - Replies and declines come only from the recipient (anyone else: not_found, like a foreign id) and go to
|
|
13
|
+
// the real sender, never via a role; a reply from another role-holder generation is `gone`.
|
|
14
|
+
// - Mutes: a party refuses msg/ask from a session; answers to the muter's own asks still go through.
|
|
15
|
+
import { randomBytes } from "node:crypto"
|
|
16
|
+
import type { Clock, StateStore } from "../backend/types"
|
|
17
|
+
import { type Party, partyKey, sanitizeLabel, sanitizeText } from "./envelope"
|
|
18
|
+
import { AgentError } from "./errors"
|
|
19
|
+
import { updateVersioned } from "./versioned"
|
|
20
|
+
|
|
21
|
+
/** Messages each party keeps. */
|
|
22
|
+
export const SESSION_MESSAGES = 200
|
|
23
|
+
/** Messages per user. */
|
|
24
|
+
export const USER_MESSAGES = 10_000
|
|
25
|
+
/** Stored bytes per user. */
|
|
26
|
+
export const USER_BYTES = 20 * 1024 * 1024
|
|
27
|
+
/** Characters of message text. */
|
|
28
|
+
/** Body limit: the rendered envelope (escaped lines, header, boundaries, hint) must fit DELIVER_TEXT_MAX (16000). */
|
|
29
|
+
export const TEXT_MAX = 10_000
|
|
30
|
+
/** Characters of a decline reason. */
|
|
31
|
+
export const WHY_MAX = 1_000
|
|
32
|
+
/** Default and bounds of an ask's lifetime. */
|
|
33
|
+
export const ASK_EXPIRES_MS = 24 * 60 * 60_000
|
|
34
|
+
export const ASK_EXPIRES_MIN_MS = 60_000
|
|
35
|
+
export const ASK_EXPIRES_MAX_MS = 7 * 24 * 60 * 60_000
|
|
36
|
+
/** Received messages an inbox shows. */
|
|
37
|
+
export const INBOX_RECENT = 50
|
|
38
|
+
|
|
39
|
+
export type MessageKind = "msg" | "ask" | "reply" | "decline"
|
|
40
|
+
/** `sent` for msg/reply/decline; asks are open until answered, declined or expired. */
|
|
41
|
+
export type MessageState = "sent" | "open" | "answered" | "declined" | "expired"
|
|
42
|
+
/** How a message reached its recipient (design §3.3), recorded by the delivery engine. */
|
|
43
|
+
export type Delivery = "steer" | "turn" | "queued" | "retained" | "pending"
|
|
44
|
+
|
|
45
|
+
export interface Message {
|
|
46
|
+
readonly id: string
|
|
47
|
+
readonly kind: MessageKind
|
|
48
|
+
readonly from: Party
|
|
49
|
+
/** The address as the sender wrote it. */
|
|
50
|
+
readonly to: string
|
|
51
|
+
/** The party it reached. */
|
|
52
|
+
readonly recipient: Party
|
|
53
|
+
/** The role holder generation it reached (role addresses). */
|
|
54
|
+
readonly generation?: number
|
|
55
|
+
readonly text: string
|
|
56
|
+
/** What a reply or decline answers. */
|
|
57
|
+
readonly replyTo?: string
|
|
58
|
+
readonly state: MessageState
|
|
59
|
+
readonly at: number
|
|
60
|
+
/** Loop protection: hops since a human turn (see HopTracker). */
|
|
61
|
+
readonly hop: number
|
|
62
|
+
/** Asks only. */
|
|
63
|
+
readonly expiresAt?: number
|
|
64
|
+
readonly delivery?: Delivery
|
|
65
|
+
/** When it was first delivered (steer, turn or queued). */
|
|
66
|
+
readonly deliveredAt?: number
|
|
67
|
+
/** The reply or decline that closed an ask. */
|
|
68
|
+
readonly answer?: { readonly id: string; readonly at: number }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface MessageLimitsConfig {
|
|
72
|
+
readonly session: number
|
|
73
|
+
readonly userMessages: number
|
|
74
|
+
readonly userBytes: number
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface SendInput {
|
|
78
|
+
readonly from: Party
|
|
79
|
+
readonly to: string
|
|
80
|
+
readonly recipient: Party
|
|
81
|
+
readonly kind: "msg" | "ask"
|
|
82
|
+
readonly text: string
|
|
83
|
+
readonly generation?: number
|
|
84
|
+
/** From HopTracker.next (default 0). */
|
|
85
|
+
readonly hop?: number
|
|
86
|
+
/** Asks only; default ASK_EXPIRES_MS. */
|
|
87
|
+
readonly expiresInMs?: number
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface AnswerInput {
|
|
91
|
+
/** The authenticated replier. */
|
|
92
|
+
readonly from: Party
|
|
93
|
+
readonly replyTo: string
|
|
94
|
+
/** The replier's current role holder generation, when it holds the role the original reached. */
|
|
95
|
+
readonly generation?: number
|
|
96
|
+
readonly hop?: number
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface Answered {
|
|
100
|
+
/** The reply or decline. */
|
|
101
|
+
readonly message: Message
|
|
102
|
+
/** What it answered (an ask now closed). */
|
|
103
|
+
readonly original: Message
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface Inbox {
|
|
107
|
+
/** Open asks to the party, newest first. */
|
|
108
|
+
readonly asks: Message[]
|
|
109
|
+
/** The party's own open asks, newest first. */
|
|
110
|
+
readonly waiting: Message[]
|
|
111
|
+
/** Recent messages to the party, newest first. */
|
|
112
|
+
readonly messages: Message[]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface Usage {
|
|
116
|
+
readonly count: number
|
|
117
|
+
readonly bytes: number
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const MESSAGES = "messages"
|
|
121
|
+
const INDEX = "message-index"
|
|
122
|
+
const USAGE = "message-usage"
|
|
123
|
+
const MUTES = "message-mutes"
|
|
124
|
+
const USAGE_KEY = "total"
|
|
125
|
+
/** Version-checked attempts to detach a contended message. */
|
|
126
|
+
const DETACH_ATTEMPTS = 16
|
|
127
|
+
|
|
128
|
+
interface StoredMessage {
|
|
129
|
+
readonly message: Message
|
|
130
|
+
/** Party keys whose index lists it. */
|
|
131
|
+
readonly indexed: string[]
|
|
132
|
+
readonly bytes: number
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
interface Index {
|
|
136
|
+
/** Oldest first. */
|
|
137
|
+
readonly ids: string[]
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
interface MuteList {
|
|
141
|
+
/** Muted session id → when. */
|
|
142
|
+
readonly sessions: Record<string, number>
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
146
|
+
|
|
147
|
+
/** `msg_` + 22 base62 characters from the CSPRNG (~131 bits, unbiased). */
|
|
148
|
+
export function newMessageID(): string {
|
|
149
|
+
let id = ""
|
|
150
|
+
while (id.length < 22) for (const byte of randomBytes(32)) if (byte < 248 && id.length < 22) id += BASE62[byte % 62]
|
|
151
|
+
return `msg_${id}`
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** How a reply addresses a party: `@name`, `ses_…`, `@me`, `app:<tokenID>`. */
|
|
155
|
+
export function addressOf(party: Party): string {
|
|
156
|
+
switch (party.kind) {
|
|
157
|
+
case "session":
|
|
158
|
+
return party.name ? `@${party.name}` : party.sessionID
|
|
159
|
+
case "user":
|
|
160
|
+
return "@me"
|
|
161
|
+
case "app":
|
|
162
|
+
return `app:${party.tokenID}`
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const isOpenAsk = (message: Message) => message.kind === "ask" && message.state === "open"
|
|
167
|
+
|
|
168
|
+
const notFound = () => new AgentError("not_found", "No such message")
|
|
169
|
+
const gone = (message = "The ask is already answered, declined or expired") => new AgentError("gone", message)
|
|
170
|
+
|
|
171
|
+
/** Sanitised text; invalid when too long or empty. */
|
|
172
|
+
function cleanText(text: string, max: number, what: string): string {
|
|
173
|
+
if (typeof text !== "string" || text.length > max) throw new AgentError("invalid", `${what} is longer than ${max} characters`)
|
|
174
|
+
const clean = sanitizeText(text)
|
|
175
|
+
if (!clean.trim()) throw new AgentError("invalid", `${what} is empty`)
|
|
176
|
+
return clean
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export class Messages {
|
|
180
|
+
private readonly state: StateStore
|
|
181
|
+
private readonly now: Clock
|
|
182
|
+
private readonly newID: () => string
|
|
183
|
+
private readonly limits: MessageLimitsConfig
|
|
184
|
+
|
|
185
|
+
constructor(deps: { readonly state: StateStore; readonly now?: Clock; readonly newID?: () => string; readonly limits?: Partial<MessageLimitsConfig> }) {
|
|
186
|
+
this.state = deps.state
|
|
187
|
+
this.now = deps.now ?? Date.now
|
|
188
|
+
this.newID = deps.newID ?? newMessageID
|
|
189
|
+
this.limits = { session: SESSION_MESSAGES, userMessages: USER_MESSAGES, userBytes: USER_BYTES, ...deps.limits }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Stores a new msg or ask. Throws invalid (text, expiry) or forbidden (the recipient muted the sender). */
|
|
193
|
+
async send(userID: string, input: SendInput): Promise<Message> {
|
|
194
|
+
const text = cleanText(input.text, TEXT_MAX, "Message text")
|
|
195
|
+
if (input.kind !== "ask" && input.expiresInMs !== undefined) throw new AgentError("invalid", "Only asks expire")
|
|
196
|
+
const expiresIn = input.expiresInMs ?? ASK_EXPIRES_MS
|
|
197
|
+
if (input.kind === "ask" && !(expiresIn >= ASK_EXPIRES_MIN_MS && expiresIn <= ASK_EXPIRES_MAX_MS)) {
|
|
198
|
+
throw new AgentError("invalid", `An ask expires after ${ASK_EXPIRES_MIN_MS / 60_000} minute to ${ASK_EXPIRES_MAX_MS / 86_400_000} days`)
|
|
199
|
+
}
|
|
200
|
+
if (input.from.kind === "session" && (await this.muted(userID, input.recipient)).includes(input.from.sessionID)) {
|
|
201
|
+
throw new AgentError("forbidden", "The recipient muted this session")
|
|
202
|
+
}
|
|
203
|
+
const at = this.now()
|
|
204
|
+
const message: Message = {
|
|
205
|
+
id: this.newID(),
|
|
206
|
+
kind: input.kind,
|
|
207
|
+
from: input.from,
|
|
208
|
+
to: sanitizeLabel(input.to),
|
|
209
|
+
recipient: input.recipient,
|
|
210
|
+
...(input.generation === undefined ? {} : { generation: input.generation }),
|
|
211
|
+
text,
|
|
212
|
+
state: input.kind === "ask" ? "open" : "sent",
|
|
213
|
+
at,
|
|
214
|
+
hop: input.hop ?? 0,
|
|
215
|
+
...(input.kind === "ask" ? { expiresAt: at + expiresIn } : {}),
|
|
216
|
+
}
|
|
217
|
+
await this.store(userID, message)
|
|
218
|
+
return message
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Replies to a message (answering an ask), from its recipient to its real sender. */
|
|
222
|
+
async reply(userID: string, input: AnswerInput & { readonly text: string }): Promise<Answered> {
|
|
223
|
+
const original = await this.answerable(userID, input)
|
|
224
|
+
const text = cleanText(input.text, TEXT_MAX, "Reply text")
|
|
225
|
+
return this.answer(userID, original, input, "reply", text)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Declines an ask, with a reason. */
|
|
229
|
+
async decline(userID: string, input: AnswerInput & { readonly why: string }): Promise<Answered> {
|
|
230
|
+
const original = await this.answerable(userID, input)
|
|
231
|
+
if (original.kind !== "ask") throw new AgentError("invalid", "Only asks can be declined")
|
|
232
|
+
const why = cleanText(input.why, WHY_MAX, "The reason")
|
|
233
|
+
return this.answer(userID, original, input, "decline", why)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async get(userID: string, id: string): Promise<Message | undefined> {
|
|
237
|
+
const stored = await this.state.get<StoredMessage>(userID, MESSAGES, id)
|
|
238
|
+
return stored && this.view(stored.value.message)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Messages the party sent or received, newest first. */
|
|
242
|
+
async list(userID: string, party: Party, options: { readonly limit?: number } = {}): Promise<Message[]> {
|
|
243
|
+
const ids = (await this.state.get<Index>(userID, INDEX, partyKey(party)))?.value.ids ?? []
|
|
244
|
+
const wanted = ids.slice().reverse().slice(0, options.limit ?? this.limits.session)
|
|
245
|
+
const messages = await Promise.all(wanted.map((id) => this.get(userID, id)))
|
|
246
|
+
return messages.filter((message): message is Message => message !== undefined)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async inbox(userID: string, party: Party): Promise<Inbox> {
|
|
250
|
+
const key = partyKey(party)
|
|
251
|
+
const all = await this.list(userID, party)
|
|
252
|
+
const toMe = all.filter((message) => partyKey(message.recipient) === key)
|
|
253
|
+
return {
|
|
254
|
+
asks: toMe.filter(isOpenAsk),
|
|
255
|
+
waiting: all.filter((message) => isOpenAsk(message) && partyKey(message.from) === key),
|
|
256
|
+
messages: toMe.slice(0, INBOX_RECENT),
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async usage(userID: string): Promise<Usage> {
|
|
261
|
+
return (await this.state.get<Usage>(userID, USAGE, USAGE_KEY))?.value ?? { count: 0, bytes: 0 }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Records how the message was delivered; undefined when there is no such message. */
|
|
265
|
+
async setDelivery(userID: string, id: string, delivery: Delivery): Promise<Message | undefined> {
|
|
266
|
+
const at = this.now()
|
|
267
|
+
const delivered = delivery === "steer" || delivery === "turn" || delivery === "queued"
|
|
268
|
+
const stored = await updateVersioned<StoredMessage>(this.state, userID, MESSAGES, id, (current) =>
|
|
269
|
+
current && {
|
|
270
|
+
...current,
|
|
271
|
+
message: { ...current.message, delivery, ...(current.message.deliveredAt === undefined && delivered ? { deliveredAt: at } : {}) },
|
|
272
|
+
},
|
|
273
|
+
)
|
|
274
|
+
return stored && this.view(stored.message)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Closes the user's asks that are past expiresAt and returns them (to tell their senders). */
|
|
278
|
+
async expire(userID: string): Promise<Message[]> {
|
|
279
|
+
const expired: Message[] = []
|
|
280
|
+
for (const entry of await this.state.list<StoredMessage>(userID, MESSAGES)) {
|
|
281
|
+
if (!this.isDue(entry.value.message)) continue
|
|
282
|
+
const stored = await updateVersioned<StoredMessage>(this.state, userID, MESSAGES, entry.key, (current) =>
|
|
283
|
+
current && this.isDue(current.message) ? { ...current, message: { ...current.message, state: "expired" } } : undefined,
|
|
284
|
+
)
|
|
285
|
+
if (stored) expired.push(stored.message)
|
|
286
|
+
}
|
|
287
|
+
return expired
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** `by` refuses msg/ask from the session. */
|
|
291
|
+
async mute(userID: string, by: Party, sessionID: string): Promise<void> {
|
|
292
|
+
const at = this.now()
|
|
293
|
+
await updateVersioned<MuteList>(this.state, userID, MUTES, partyKey(by), (current) =>
|
|
294
|
+
current?.sessions[sessionID] !== undefined ? undefined : { sessions: { ...current?.sessions, [sessionID]: at } },
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async unmute(userID: string, by: Party, sessionID: string): Promise<void> {
|
|
299
|
+
await updateVersioned<MuteList>(this.state, userID, MUTES, partyKey(by), (current) => {
|
|
300
|
+
if (current?.sessions[sessionID] === undefined) return undefined
|
|
301
|
+
const { [sessionID]: _, ...sessions } = current.sessions
|
|
302
|
+
return { sessions }
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** The sessions `by` muted, sorted. */
|
|
307
|
+
async muted(userID: string, by: Party): Promise<string[]> {
|
|
308
|
+
const list = (await this.state.get<MuteList>(userID, MUTES, partyKey(by)))?.value
|
|
309
|
+
return Object.keys(list?.sessions ?? {}).sort()
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ---- internals ----------------------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
/** The message as of now (an open ask past expiresAt reads expired). */
|
|
315
|
+
private view(message: Message): Message {
|
|
316
|
+
return this.isDue(message) ? { ...message, state: "expired" } : message
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private isDue(message: Message): boolean {
|
|
320
|
+
return isOpenAsk(message) && message.expiresAt !== undefined && this.now() >= message.expiresAt
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The message `input` answers, when `input.from` is its recipient; otherwise not_found (or gone for another generation). */
|
|
324
|
+
private async answerable(userID: string, input: AnswerInput): Promise<Message> {
|
|
325
|
+
const original = await this.get(userID, input.replyTo)
|
|
326
|
+
if (!original || partyKey(original.recipient) !== partyKey(input.from)) throw notFound()
|
|
327
|
+
if (input.generation !== undefined && original.generation !== undefined && input.generation !== original.generation) {
|
|
328
|
+
throw gone("The role changed hands since this message arrived")
|
|
329
|
+
}
|
|
330
|
+
return original
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private async answer(userID: string, original: Message, input: AnswerInput, kind: "reply" | "decline", text: string): Promise<Answered> {
|
|
334
|
+
const at = this.now()
|
|
335
|
+
const message: Message = {
|
|
336
|
+
id: this.newID(),
|
|
337
|
+
kind,
|
|
338
|
+
from: input.from,
|
|
339
|
+
to: addressOf(original.from),
|
|
340
|
+
recipient: original.from,
|
|
341
|
+
text,
|
|
342
|
+
replyTo: original.id,
|
|
343
|
+
state: "sent",
|
|
344
|
+
at,
|
|
345
|
+
hop: input.hop ?? 0,
|
|
346
|
+
}
|
|
347
|
+
let closed = original
|
|
348
|
+
if (original.kind === "ask") {
|
|
349
|
+
if (original.state !== "open") throw gone()
|
|
350
|
+
const state = kind === "reply" ? "answered" : "declined"
|
|
351
|
+
const stored = await updateVersioned<StoredMessage>(this.state, userID, MESSAGES, original.id, (current) =>
|
|
352
|
+
current && !this.isDue(current.message) && isOpenAsk(current.message) ? { ...current, message: { ...current.message, state, answer: { id: message.id, at } } } : undefined,
|
|
353
|
+
)
|
|
354
|
+
if (!stored) throw gone()
|
|
355
|
+
closed = stored.message
|
|
356
|
+
}
|
|
357
|
+
await this.store(userID, message)
|
|
358
|
+
return { message, original: closed }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Writes a new message, indexes it for its parties and enforces the caps. */
|
|
362
|
+
private async store(userID: string, message: Message): Promise<void> {
|
|
363
|
+
const indexed = [...new Set([partyKey(message.from), partyKey(message.recipient)])]
|
|
364
|
+
const bytes = Buffer.byteLength(JSON.stringify(message))
|
|
365
|
+
const written = await this.state.put<StoredMessage>(userID, MESSAGES, message.id, { message, indexed, bytes }, { ifVersion: 0 })
|
|
366
|
+
if (!written.ok) throw new Error("message id collision")
|
|
367
|
+
await this.addUsage(userID, 1, bytes)
|
|
368
|
+
for (const key of indexed) {
|
|
369
|
+
await updateVersioned<Index>(this.state, userID, INDEX, key, (current) => ({ ids: [...(current?.ids ?? []), message.id] }))
|
|
370
|
+
await this.trimIndex(userID, key)
|
|
371
|
+
}
|
|
372
|
+
await this.trimUser(userID)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private async addUsage(userID: string, count: number, bytes: number): Promise<void> {
|
|
376
|
+
await updateVersioned<Usage>(this.state, userID, USAGE, USAGE_KEY, (current) => ({
|
|
377
|
+
count: Math.max(0, (current?.count ?? 0) + count),
|
|
378
|
+
bytes: Math.max(0, (current?.bytes ?? 0) + bytes),
|
|
379
|
+
}))
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Drops a party's oldest messages over the cap (open asks last). */
|
|
383
|
+
private async trimIndex(userID: string, key: string): Promise<void> {
|
|
384
|
+
const ids = (await this.state.get<Index>(userID, INDEX, key))?.value.ids ?? []
|
|
385
|
+
const excess = ids.length - this.limits.session
|
|
386
|
+
if (excess <= 0) return
|
|
387
|
+
const victims = await this.oldest(userID, ids, excess)
|
|
388
|
+
const drop = new Set(victims)
|
|
389
|
+
await updateVersioned<Index>(this.state, userID, INDEX, key, (current) => current && { ids: current.ids.filter((id) => !drop.has(id)) })
|
|
390
|
+
for (const id of victims) await this.detach(userID, id, [key])
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Drops the user's oldest messages (open asks last) to 90% of a cap they exceed. */
|
|
394
|
+
private async trimUser(userID: string): Promise<void> {
|
|
395
|
+
const usage = await this.usage(userID)
|
|
396
|
+
if (usage.count <= this.limits.userMessages && usage.bytes <= this.limits.userBytes) return
|
|
397
|
+
const entries = (await this.state.list<StoredMessage>(userID, MESSAGES)).sort((a, b) => rank(a.value.message) - rank(b.value.message) || a.value.message.at - b.value.message.at)
|
|
398
|
+
let { count, bytes } = usage
|
|
399
|
+
const countTarget = Math.floor(this.limits.userMessages * 0.9)
|
|
400
|
+
const bytesTarget = Math.floor(this.limits.userBytes * 0.9)
|
|
401
|
+
for (const entry of entries) {
|
|
402
|
+
if (count <= countTarget && bytes <= bytesTarget) break
|
|
403
|
+
count -= 1
|
|
404
|
+
bytes -= entry.value.bytes
|
|
405
|
+
for (const key of entry.value.indexed) {
|
|
406
|
+
await updateVersioned<Index>(this.state, userID, INDEX, key, (current) => current && { ids: current.ids.filter((id) => id !== entry.key) })
|
|
407
|
+
}
|
|
408
|
+
await this.detach(userID, entry.key, entry.value.indexed)
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** The `count` oldest of `ids` (oldest first), passing over open asks while others remain. */
|
|
413
|
+
private async oldest(userID: string, ids: string[], count: number): Promise<string[]> {
|
|
414
|
+
const closed: string[] = []
|
|
415
|
+
const open: string[] = []
|
|
416
|
+
for (const id of ids) {
|
|
417
|
+
if (closed.length >= count) break
|
|
418
|
+
const message = await this.get(userID, id)
|
|
419
|
+
;(message && isOpenAsk(message) ? open : closed).push(id)
|
|
420
|
+
}
|
|
421
|
+
return [...closed, ...open].slice(0, count)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Removes party keys from a message; deletes it (and its usage) when no party keeps it. */
|
|
425
|
+
private async detach(userID: string, id: string, keys: string[]): Promise<void> {
|
|
426
|
+
for (let attempt = 0; attempt < DETACH_ATTEMPTS; attempt++) {
|
|
427
|
+
const current = await this.state.get<StoredMessage>(userID, MESSAGES, id)
|
|
428
|
+
if (!current) return
|
|
429
|
+
const indexed = current.value.indexed.filter((key) => !keys.includes(key))
|
|
430
|
+
if (indexed.length) {
|
|
431
|
+
if ((await this.state.put<StoredMessage>(userID, MESSAGES, id, { ...current.value, indexed }, { ifVersion: current.version })).ok) return
|
|
432
|
+
} else if ((await this.state.delete(userID, MESSAGES, id, { ifVersion: current.version })).ok) {
|
|
433
|
+
await this.addUsage(userID, -1, -current.value.bytes)
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
throw new Error(`detaching message ${id} kept conflicting`)
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Eviction order: closed messages before open asks. */
|
|
442
|
+
const rank = (message: Message) => (isOpenAsk(message) ? 1 : 0)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Pure helpers for @name / @@role addressing (design §2.1): OpenCode's placeholder titles, slugs, derived names,
|
|
2
|
+
// conflict suffixes, the reserved name and reserved roles. Name syntax and address parsing live in the SDK (packages/sdk/src/address.ts)
|
|
3
|
+
// and are re-exported here.
|
|
4
|
+
|
|
5
|
+
import { NAME_MAX, validName } from "../../../packages/sdk/src/address.ts"
|
|
6
|
+
|
|
7
|
+
export { NAME_MAX, parseAddress, validName, type Address } from "../../../packages/sdk/src/address.ts"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* OpenCode's fallback titles: "New session" / "Child session" (display labels) and the historical
|
|
11
|
+
* timestamped "New session - <ISO>" (see @opencode/util session-title-fallback). Spokes list an untitled
|
|
12
|
+
* session with an empty title.
|
|
13
|
+
*/
|
|
14
|
+
const FALLBACK = /^(New session|Child session)( - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)?$/
|
|
15
|
+
|
|
16
|
+
/** A title the hub must not derive a name from (missing, blank or an OpenCode fallback). */
|
|
17
|
+
export const isPlaceholderTitle = (title: string | undefined): boolean => title === undefined || title.trim() === "" || FALLBACK.test(title.trim())
|
|
18
|
+
|
|
19
|
+
/** The slug of a title for a derived name: undefined when fewer than 2 usable characters remain. */
|
|
20
|
+
export function slugify(title: string): string | undefined {
|
|
21
|
+
const slug = title
|
|
22
|
+
.normalize("NFKD")
|
|
23
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
24
|
+
.toLowerCase()
|
|
25
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
26
|
+
.replace(/^-+|-+$/g, "")
|
|
27
|
+
.slice(0, NAME_MAX)
|
|
28
|
+
.replace(/-+$/, "")
|
|
29
|
+
return validName(slug) ? slug : undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `base` when free, else `base-2`, `base-3`, … (the base cut so the result stays a valid name). */
|
|
33
|
+
export function withSuffix(base: string, taken: (name: string) => boolean): string {
|
|
34
|
+
if (!taken(base)) return base
|
|
35
|
+
for (let n = 2; ; n++) {
|
|
36
|
+
const suffix = `-${n}`
|
|
37
|
+
const name = `${base.slice(0, NAME_MAX - suffix.length).replace(/-+$/, "")}${suffix}`
|
|
38
|
+
if (!taken(name)) return name
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `me` always addresses the user (@me), so no session may hold it, explicitly or derived. Only the exact name. */
|
|
43
|
+
export const isReservedName = (name: string): boolean => name === "me"
|
|
44
|
+
|
|
45
|
+
/** Roles only the user may claim in phase 1 (grants including `roles` come in phase 3). */
|
|
46
|
+
const RESERVED_ROLES = ["orchestrator", "concierge"]
|
|
47
|
+
|
|
48
|
+
export const isReservedRole = (role: string): boolean => RESERVED_ROLES.some((prefix) => role.startsWith(prefix))
|
|
49
|
+
|
|
50
|
+
/** The longest derived name: a few words, short enough to type and read in an address (@send-hello-to-session). */
|
|
51
|
+
export const DERIVED_NAME_MAX = 24
|
|
52
|
+
/** At most this many words of the title make a derived name. */
|
|
53
|
+
const DERIVED_WORDS_MAX = 4
|
|
54
|
+
|
|
55
|
+
/** Tokens that identify rather than describe (dropped from derived names): ses_…/msg_… IDs, UUIDs, hashes, long letter+digit runs. */
|
|
56
|
+
const ID_LIKE = [
|
|
57
|
+
/^[a-z]{2,5}_[a-z0-9]{6,}$/i,
|
|
58
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
|
59
|
+
/^(?=[a-f]*\d)[0-9a-f]{8,}$/i,
|
|
60
|
+
/^(?=.*\d)(?=.*[a-z])[a-z0-9]{10,}$/i,
|
|
61
|
+
]
|
|
62
|
+
const isIDLike = (token: string): boolean => ID_LIKE.some((re) => re.test(token))
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The name derived from a session title: its first few meaningful words (ID-like tokens dropped), at most
|
|
66
|
+
* DERIVED_WORDS_MAX words and DERIVED_NAME_MAX characters, cut at a word boundary (a single longer word is cut).
|
|
67
|
+
* Undefined when fewer than 2 usable characters remain. Derived once per session (see directory.ts), so later
|
|
68
|
+
* title changes never rename it.
|
|
69
|
+
*/
|
|
70
|
+
export function deriveName(title: string): string | undefined {
|
|
71
|
+
const words = title
|
|
72
|
+
.split(/\s+/)
|
|
73
|
+
.map((token) => token.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, ""))
|
|
74
|
+
.filter((token) => token && !isIDLike(token))
|
|
75
|
+
.flatMap((token) => slugify(token)?.split("-") ?? (/^[a-z0-9]$/i.test(token) ? [token.toLowerCase()] : []))
|
|
76
|
+
let name = ""
|
|
77
|
+
for (const word of words.slice(0, DERIVED_WORDS_MAX)) {
|
|
78
|
+
const next = name ? `${name}-${word}` : word
|
|
79
|
+
if (next.length > DERIVED_NAME_MAX) {
|
|
80
|
+
if (!name) name = word.slice(0, DERIVED_NAME_MAX)
|
|
81
|
+
break
|
|
82
|
+
}
|
|
83
|
+
name = next
|
|
84
|
+
}
|
|
85
|
+
return validName(name) ? name : undefined
|
|
86
|
+
}
|