@huynle/hub 0.7.4 → 0.8.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 +430 -1
- package/Dockerfile +7 -2
- package/README.md +230 -32
- package/package.json +5 -3
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/daemon.ts +8 -1
- package/packages/claude-code/src/launcher.ts +29 -9
- package/packages/claude-code/src/paths.ts +3 -3
- package/packages/claude-code/src/profile.ts +173 -0
- package/packages/claude-code/src/sessions.ts +22 -4
- package/packages/cli/dist/hub-cli.js +6871 -3460
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
- package/packages/cli/src/artifacts-mcp.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
- package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
- package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +21 -9
- package/packages/cli/src/bootstrap/enrollment/policy.ts +138 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +96 -14
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +2 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/launches.ts +9 -3
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +385 -10
- package/packages/cli/src/messaging.ts +7 -3
- package/packages/cli/src/profiles-mcp.ts +301 -0
- package/packages/cli/src/setup/commands.ts +23 -4
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/spoke-services.ts +1 -1
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +460 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/paths.ts +3 -3
- package/packages/codex/src/profile.ts +163 -0
- package/packages/codex/src/sessions.ts +12 -0
- package/packages/codex/src/spoke.ts +19 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +104 -2
- package/packages/node/src/opencode-warm.ts +344 -0
- package/packages/node/src/run.ts +28 -1
- package/packages/preset/package.json +1 -1
- package/packages/preset/src/profile.ts +34 -0
- package/packages/preset/src/spoke.ts +4 -0
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/artifacts.ts +142 -0
- package/packages/protocol/src/concierge.ts +366 -0
- package/packages/protocol/src/contracts.ts +104 -0
- package/packages/protocol/src/graph.ts +70 -0
- package/packages/protocol/src/hub-workspace.ts +201 -0
- package/packages/protocol/src/index.ts +175 -6
- package/packages/protocol/src/launch.ts +36 -0
- package/packages/protocol/src/profiles.ts +301 -0
- package/packages/protocol/src/teams.ts +204 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/address.ts +50 -2
- package/packages/sdk/src/artifact-jobs.ts +194 -0
- package/packages/sdk/src/artifacts.ts +187 -0
- package/packages/sdk/src/client.ts +226 -5
- package/packages/sdk/src/concierge.ts +88 -0
- package/packages/sdk/src/errors.ts +4 -1
- package/packages/sdk/src/index.ts +6 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +568 -1
- package/packages/sdk/src/wake.ts +20 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/index.ts +1 -0
- package/packages/spoke-sdk/src/profile-apply.ts +245 -0
- package/src/central/actions.ts +7 -0
- package/src/central/agent/acting-routes.ts +2 -1
- package/src/central/agent/acting-switch.ts +2 -1
- package/src/central/agent/acting.ts +218 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +18 -1
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +125 -2
- package/src/central/agent/delivery.ts +194 -40
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +7 -0
- package/src/central/agent/escalation.ts +10 -2
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +18 -4
- package/src/central/agent/form-check.ts +85 -0
- package/src/central/agent/grant-requests.ts +2 -1
- package/src/central/agent/grant-routes.ts +4 -3
- package/src/central/agent/grants.ts +29 -2
- package/src/central/agent/idempotency.ts +133 -0
- package/src/central/agent/intent-routes.ts +116 -0
- package/src/central/agent/intents.ts +505 -0
- package/src/central/agent/interaction-graph-routes.ts +212 -0
- package/src/central/agent/interaction-graph.ts +865 -0
- package/src/central/agent/launch-engine.ts +189 -32
- package/src/central/agent/launch-routes.ts +26 -10
- package/src/central/agent/launch-spec.ts +40 -3
- package/src/central/agent/launch-store.ts +55 -2
- package/src/central/agent/message-limits.ts +89 -11
- package/src/central/agent/message-routes.ts +10 -4
- package/src/central/agent/messages.ts +28 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/placement.ts +93 -18
- package/src/central/agent/read-routes.ts +89 -19
- package/src/central/agent/routes.ts +178 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- package/src/central/agent/webhooks.ts +17 -2
- package/src/central/agent-data.ts +4 -0
- package/src/central/artifacts/access.ts +44 -0
- package/src/central/artifacts/blob-store.ts +113 -0
- package/src/central/artifacts/blob-support.ts +98 -0
- package/src/central/artifacts/events.ts +27 -0
- package/src/central/artifacts/host-caps.ts +162 -0
- package/src/central/artifacts/host.ts +315 -0
- package/src/central/artifacts/idempotency.ts +109 -0
- package/src/central/artifacts/jobs-routes.ts +776 -0
- package/src/central/artifacts/jobs.ts +344 -0
- package/src/central/artifacts/maintenance.ts +103 -0
- package/src/central/artifacts/routes.ts +360 -0
- package/src/central/artifacts/store.ts +776 -0
- package/src/central/artifacts/workspaces.ts +31 -0
- package/src/central/backend/file-blobs.ts +377 -0
- package/src/central/backend/index.ts +12 -3
- package/src/central/backend/memory-blobs.ts +116 -0
- package/src/central/backend/postgres-blobs.ts +513 -0
- package/src/central/backend/postgres-migrate.ts +33 -0
- package/src/central/backend/postgres-state.ts +19 -0
- package/src/central/backend/types.ts +8 -0
- package/src/central/concierge/activity.ts +142 -0
- package/src/central/concierge/arm.ts +63 -0
- package/src/central/concierge/board-render.ts +190 -0
- package/src/central/concierge/board-results.ts +169 -0
- package/src/central/concierge/board-routes.ts +350 -0
- package/src/central/concierge/board.ts +249 -0
- package/src/central/concierge/context.ts +221 -0
- package/src/central/concierge/entitlements.ts +13 -0
- package/src/central/concierge/guard.ts +120 -0
- package/src/central/concierge/inbound.ts +86 -0
- package/src/central/concierge/index.ts +84 -0
- package/src/central/concierge/notes.ts +41 -0
- package/src/central/concierge/notifier.ts +986 -0
- package/src/central/concierge/prompt.ts +131 -0
- package/src/central/concierge/proposals.ts +268 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +122 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +125 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +767 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +279 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +28 -6
- package/src/central/http.ts +660 -54
- package/src/central/main.ts +1 -1
- package/src/central/mcp-http.ts +10 -21
- package/src/central/profiles/apply.ts +333 -0
- package/src/central/profiles/content.ts +174 -0
- package/src/central/profiles/events.ts +40 -0
- package/src/central/profiles/idempotency.ts +14 -0
- package/src/central/profiles/import.ts +139 -0
- package/src/central/profiles/routes.ts +356 -0
- package/src/central/profiles/store.ts +597 -0
- package/src/central/profiles/usage-ports.ts +120 -0
- package/src/central/profiles/usage.ts +364 -0
- package/src/central/push.ts +7 -0
- package/src/central/registry.ts +69 -7
- package/src/central/store.ts +4 -0
- package/src/central/teams/collections.ts +34 -0
- package/src/central/teams/discovery.ts +346 -0
- package/src/central/teams/draft-edits.ts +46 -0
- package/src/central/teams/events.ts +46 -0
- package/src/central/teams/intake-routes.ts +100 -0
- package/src/central/teams/intake.ts +786 -0
- package/src/central/teams/policy.ts +231 -0
- package/src/central/teams/routes.ts +516 -0
- package/src/central/teams/seats.ts +272 -0
- package/src/central/teams/staffing.ts +910 -0
- package/src/central/teams/store.ts +667 -0
- package/src/central/teams/validation.ts +256 -0
- package/src/central/workspaces/board.ts +408 -0
- package/src/central/workspaces/checkpoints.ts +316 -0
- package/src/central/workspaces/coordinator.ts +841 -0
- package/src/central/workspaces/events.ts +85 -0
- package/src/central/workspaces/lifecycle.ts +93 -0
- package/src/central/workspaces/master.ts +490 -0
- package/src/central/workspaces/members.ts +632 -0
- package/src/central/workspaces/routes.ts +354 -0
- package/src/central/workspaces/sidechats-routes.ts +410 -0
- package/src/central/workspaces/sidechats.ts +482 -0
- package/src/central/workspaces/store.ts +571 -0
- package/src/central/workspaces/supervision.ts +355 -0
- package/src/index.ts +126 -23
- package/src/migrate.ts +109 -3
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/env-inject.ts +8 -1
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +404 -25
- package/src/spoke/launchers.ts +6 -4
- package/src/spoke/listing.ts +65 -10
- package/src/spoke/localhub.ts +14 -4
- package/src/spoke/locations.ts +40 -0
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +256 -0
- package/src/spoke/spoke.ts +330 -23
- package/src/spoke/timeline.ts +6 -1
- package/src/spoke/tracker.ts +23 -1
- package/src/spoke/who-teams.ts +81 -0
- package/src/tui-messaging.ts +32 -18
- package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
- package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
- package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
- package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
- package/web/dist/assets/index-BabrNDB3.css +1 -0
- package/web/dist/assets/index-lIOfSYck.js +34 -0
- package/web/dist/assets/live-BDq0AABg.js +5 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/pane-layout-DWXfDU-1.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-DAr4IHM8.js +1 -0
- package/web/dist/assets/renderer3d-DWZj595u.js +5393 -0
- package/web/dist/assets/safe-area-JUb6OeI3.js +1 -0
- package/web/dist/assets/source-CDU04kYw.js +1 -0
- package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
- package/web/dist/index.html +5 -2
- package/web/dist/sw.js +22 -2
- package/web/dist/assets/index-Dm8OmkaO.css +0 -1
- package/web/dist/assets/index-FUJbj_Du.js +0 -20
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
// Context-linked side chats (HWA-13; contract: Workspaces `POST /workspaces/:id/side-chats` in docs/plans/2026-10-30-
|
|
2
|
+
// hub-workspaces-artifacts-contracts.md, protocol SideChat and WORKSPACE_LIMITS). This module is the record store
|
|
3
|
+
// and the pure snapshot logic; sidechats-routes.ts is the HTTP side, which reads the source and launches the
|
|
4
|
+
// side-chat session through the existing /api/agent routes (so their grants, scopes and audits apply unchanged).
|
|
5
|
+
//
|
|
6
|
+
// A side chat is a NEW session launched through the launch API, started with a bounded copy of some of the source
|
|
7
|
+
// session's transcript. The link to the source is this record only: the side-chat session's native
|
|
8
|
+
// parentID/rootID and any supervision are untouched (a launched session is a root session).
|
|
9
|
+
//
|
|
10
|
+
// Snapshot rules (snapshotOf):
|
|
11
|
+
// - Sources are message IDs of the source session's transcript (timeline rows grouped by messageID), at most
|
|
12
|
+
// sideChatContextItems, read from the newest SNAPSHOT_SCAN_ROWS rows; no sources = the newest messages.
|
|
13
|
+
// `artifact_revision` sources are not supported in v1 (unsupported, nothing created).
|
|
14
|
+
// - What is stored is exactly what is delivered: the rendered context fits SIDECHAT_DELIVERY_MAX characters (the
|
|
15
|
+
// launch prompt and message text limits), and its quoted text stays within sideChatContextBytes. When it does
|
|
16
|
+
// not fit, the oldest items are omitted (counted in `omitted`) and a single oversized item is clipped (`clipped`).
|
|
17
|
+
// - Each item keeps its time and ordering references (`at`, `pos`); the snapshot keeps `takenAt`, its
|
|
18
|
+
// `generation` (1 at creation, +1 per explicit refresh) and the newest source row time it saw.
|
|
19
|
+
// - Refresh re-reads the same sources (or, for a "recent" snapshot, the newest messages again). An item whose
|
|
20
|
+
// source message can no longer be read keeps its previous text marked `stale: "missing"`; an item whose text
|
|
21
|
+
// changed is marked `changed`.
|
|
22
|
+
//
|
|
23
|
+
// Storage (StateStore, tenant = hub user): `workspace-sidechats` (one record per side chat, by ID) and
|
|
24
|
+
// `workspace-sidechat-index` (one aggregate per workspace: the IDs, to cap sideChatsPerWorkspace atomically; a
|
|
25
|
+
// reservation without a record is dropped after RESERVATION_MS). Records survive session and launch expiry.
|
|
26
|
+
// Events are compact and text-free (IDs, revisions, counts).
|
|
27
|
+
import { createHash, randomBytes } from "node:crypto"
|
|
28
|
+
import { ID_PREFIXES, LIST_LIMIT_DEFAULT, LIST_LIMIT_MAX, WORKSPACE_LIMITS, type Page, type SideChat } from "../../protocol"
|
|
29
|
+
import type { Clock, StateStore } from "../backend/types"
|
|
30
|
+
import type { EventInput } from "../agent/events"
|
|
31
|
+
import { AgentError } from "../agent/errors"
|
|
32
|
+
import { PROMPT_MAX } from "../agent/launch-spec"
|
|
33
|
+
import { TEXT_MAX } from "../agent/messages"
|
|
34
|
+
import { updateVersioned } from "../agent/versioned"
|
|
35
|
+
import { RESERVATION_MS } from "./store"
|
|
36
|
+
|
|
37
|
+
export const WORKSPACE_SIDECHATS = "workspace-sidechats"
|
|
38
|
+
export const WORKSPACE_SIDECHAT_INDEX = "workspace-sidechat-index"
|
|
39
|
+
|
|
40
|
+
/** The rendered context (launch prompt, refresh message) never exceeds the launch prompt or message text limit. */
|
|
41
|
+
export const SIDECHAT_DELIVERY_MAX = Math.min(PROMPT_MAX, TEXT_MAX)
|
|
42
|
+
/** Newest transcript rows a snapshot looks at (the log route's maximum page). */
|
|
43
|
+
export const SNAPSHOT_SCAN_ROWS = 200
|
|
44
|
+
/** Longest source message ID accepted. */
|
|
45
|
+
export const SOURCE_ID_MAX = 128
|
|
46
|
+
|
|
47
|
+
export interface SideChatLimits {
|
|
48
|
+
readonly sideChatsPerWorkspace: number
|
|
49
|
+
readonly sideChatContextItems: number
|
|
50
|
+
readonly sideChatContextBytes: number
|
|
51
|
+
readonly deliveryMax: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const SIDECHAT_LIMITS: SideChatLimits = {
|
|
55
|
+
sideChatsPerWorkspace: WORKSPACE_LIMITS.sideChatsPerWorkspace,
|
|
56
|
+
sideChatContextItems: WORKSPACE_LIMITS.sideChatContextItems,
|
|
57
|
+
sideChatContextBytes: WORKSPACE_LIMITS.sideChatContextBytes,
|
|
58
|
+
deliveryMax: SIDECHAT_DELIVERY_MAX,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type SnapshotRole = "user" | "assistant" | "message"
|
|
62
|
+
|
|
63
|
+
export interface SnapshotItem {
|
|
64
|
+
readonly kind: "message"
|
|
65
|
+
/** The source message ID. */
|
|
66
|
+
readonly id: string
|
|
67
|
+
readonly role: SnapshotRole
|
|
68
|
+
/** When the source message was written (ms), when the spoke reported it. */
|
|
69
|
+
readonly at?: number
|
|
70
|
+
/** The source's ordering position, when reported. */
|
|
71
|
+
readonly pos?: number
|
|
72
|
+
readonly text: string
|
|
73
|
+
/** UTF-8 bytes of `text`. */
|
|
74
|
+
readonly bytes: number
|
|
75
|
+
readonly clipped?: true
|
|
76
|
+
/** Refresh markers: the source message changed since the previous snapshot, or can't be read any more. */
|
|
77
|
+
readonly changed?: true
|
|
78
|
+
readonly stale?: "missing"
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface SideChatSnapshot {
|
|
82
|
+
readonly generation: number
|
|
83
|
+
readonly takenAt: number
|
|
84
|
+
/** selected: the caller named the messages; recent: the newest messages at the time. */
|
|
85
|
+
readonly mode: "selected" | "recent"
|
|
86
|
+
readonly items: readonly SnapshotItem[]
|
|
87
|
+
readonly bytes: number
|
|
88
|
+
/** Selected or newest messages left out to stay within the bounds. */
|
|
89
|
+
readonly omitted: number
|
|
90
|
+
/** The newest source row time seen when the snapshot was taken (the "as of" reference). */
|
|
91
|
+
readonly sourceNewestAt?: number
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The stored record: the frozen SideChat plus additive fields. `sessionID` is set once the launch reports the
|
|
96
|
+
* side-chat session (absent while it launches, or when the launch never produced one).
|
|
97
|
+
*/
|
|
98
|
+
export interface StoredSideChat extends Omit<SideChat, "sessionID"> {
|
|
99
|
+
readonly sessionID?: string
|
|
100
|
+
readonly launchID: string
|
|
101
|
+
/** Who created it: `user` or `app:<token id>` (as launches record it). */
|
|
102
|
+
readonly by: string
|
|
103
|
+
readonly bindingID: string
|
|
104
|
+
readonly machineID: string
|
|
105
|
+
readonly harness: string
|
|
106
|
+
readonly snapshot: SideChatSnapshot
|
|
107
|
+
readonly updatedAt: number
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface IndexEntry {
|
|
111
|
+
readonly id: string
|
|
112
|
+
readonly at: number
|
|
113
|
+
}
|
|
114
|
+
interface IndexRecord {
|
|
115
|
+
readonly v: 1
|
|
116
|
+
readonly entries: readonly IndexEntry[]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------- the snapshot (pure) ----------
|
|
120
|
+
|
|
121
|
+
/** A transcript row as the spoke's timeline reports it (src/spoke/timeline.ts TimelineItem); unknown fields ignored. */
|
|
122
|
+
interface Row {
|
|
123
|
+
readonly messageID: string
|
|
124
|
+
readonly kind: string
|
|
125
|
+
readonly text?: string
|
|
126
|
+
readonly name?: string
|
|
127
|
+
readonly state?: string
|
|
128
|
+
readonly at?: number
|
|
129
|
+
readonly pos?: number
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
type Message = Omit<SnapshotItem, "bytes">
|
|
133
|
+
|
|
134
|
+
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
135
|
+
const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : undefined)
|
|
136
|
+
|
|
137
|
+
function rowOf(raw: unknown): Row | undefined {
|
|
138
|
+
if (!isObj(raw) || typeof raw.messageID !== "string" || !raw.messageID || typeof raw.kind !== "string") return undefined
|
|
139
|
+
const at = num(raw.at)
|
|
140
|
+
const pos = num(raw.pos)
|
|
141
|
+
return {
|
|
142
|
+
messageID: raw.messageID,
|
|
143
|
+
kind: raw.kind,
|
|
144
|
+
...(typeof raw.text === "string" ? { text: raw.text } : {}),
|
|
145
|
+
...(typeof raw.name === "string" ? { name: raw.name } : {}),
|
|
146
|
+
...(typeof raw.state === "string" ? { state: raw.state } : {}),
|
|
147
|
+
...(at !== undefined ? { at } : {}),
|
|
148
|
+
...(pos !== undefined ? { pos } : {}),
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Control characters the launch prompt and message routes refuse (tab, newline and CR stay). */
|
|
153
|
+
const CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u2028\u2029]/g
|
|
154
|
+
const clean = (text: string) => text.replace(CONTROL, "")
|
|
155
|
+
const utf8 = (text: string) => Buffer.byteLength(text, "utf8")
|
|
156
|
+
|
|
157
|
+
/** The first `max` UTF-8 bytes of `text`, never splitting a character. */
|
|
158
|
+
export function clipBytes(text: string, max: number): string {
|
|
159
|
+
if (utf8(text) <= max) return text
|
|
160
|
+
return Buffer.from(text, "utf8").subarray(0, Math.max(0, max)).toString("utf8").replace(/\uFFFD+$/, "")
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Transcript order: by position, else time, else as reported. */
|
|
164
|
+
const byOrder = (a: { pos?: number; at?: number; i: number }, b: { pos?: number; at?: number; i: number }) =>
|
|
165
|
+
a.pos !== undefined && b.pos !== undefined ? a.pos - b.pos : a.at !== undefined && b.at !== undefined ? a.at - b.at : a.i - b.i
|
|
166
|
+
|
|
167
|
+
const defined = (values: readonly (number | undefined)[]) => values.filter((v): v is number => v !== undefined)
|
|
168
|
+
|
|
169
|
+
/** One message's rows as snapshot text: user, assistant and hub-message text; tools as one-line markers. */
|
|
170
|
+
function messageOf(rows: readonly Row[]): Message | undefined {
|
|
171
|
+
const parts: string[] = []
|
|
172
|
+
let role: SnapshotRole | undefined
|
|
173
|
+
for (const row of rows) {
|
|
174
|
+
if (row.kind === "user" || row.kind === "assistant" || row.kind === "message") {
|
|
175
|
+
role ??= row.kind
|
|
176
|
+
if (row.text) parts.push(clean(row.text))
|
|
177
|
+
} else if (row.kind === "tool") parts.push(`[tool ${clean(row.name ?? "call").slice(0, 80)}${row.state ? ` ${row.state}` : ""}]`)
|
|
178
|
+
// Reasoning rows stay out of the copy.
|
|
179
|
+
}
|
|
180
|
+
if (!role) return undefined
|
|
181
|
+
const at = defined(rows.map((r) => r.at))
|
|
182
|
+
const pos = defined(rows.map((r) => r.pos))
|
|
183
|
+
return {
|
|
184
|
+
kind: "message",
|
|
185
|
+
id: rows[0]!.messageID,
|
|
186
|
+
role,
|
|
187
|
+
...(at.length ? { at: Math.min(...at) } : {}),
|
|
188
|
+
...(pos.length ? { pos: Math.min(...pos) } : {}),
|
|
189
|
+
text: parts.join("\n").trim(),
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The source's messages in transcript order, and the newest row time. */
|
|
194
|
+
export function messagesOf(rawRows: readonly unknown[]): { messages: Message[]; newestAt?: number } {
|
|
195
|
+
const rows = rawRows.map(rowOf).filter((r): r is Row => r !== undefined)
|
|
196
|
+
const ordered = rows.map((row, i) => ({ ...row, i })).sort(byOrder)
|
|
197
|
+
const byMessage = new Map<string, Row[]>()
|
|
198
|
+
for (const row of ordered) byMessage.set(row.messageID, [...(byMessage.get(row.messageID) ?? []), row])
|
|
199
|
+
const messages = [...byMessage.values()].map(messageOf).filter((m): m is Message => m !== undefined)
|
|
200
|
+
const times = defined(rows.map((r) => r.at))
|
|
201
|
+
return { messages, ...(times.length ? { newestAt: Math.max(...times) } : {}) }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface SnapshotRequest {
|
|
205
|
+
/** Named source messages (in any order); empty = the newest messages. */
|
|
206
|
+
readonly ids: readonly string[]
|
|
207
|
+
readonly limits?: Partial<SideChatLimits>
|
|
208
|
+
/** For refresh: the previous snapshot (its mode, changed / missing markers). */
|
|
209
|
+
readonly previous?: SideChatSnapshot
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface RenderInfo {
|
|
213
|
+
readonly sideChatID: string
|
|
214
|
+
readonly workspaceID: string
|
|
215
|
+
readonly sourceSessionID: string
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const isoOf = (ms: number | undefined) => (ms === undefined ? "time unknown" : new Date(ms).toISOString())
|
|
219
|
+
|
|
220
|
+
/** The context a side-chat session gets (the launch prompt; a refresh's message): exactly the snapshot's items. */
|
|
221
|
+
export function renderContext(info: RenderInfo, snapshot: SideChatSnapshot): string {
|
|
222
|
+
const intro =
|
|
223
|
+
snapshot.generation === 1
|
|
224
|
+
? `[Hub side chat ${info.sideChatID}] You are a side conversation started from session ${info.sourceSessionID} (hub workspace ${info.workspaceID}). ` +
|
|
225
|
+
`Below is a read-only copy of ${snapshot.items.length} message(s) from that session, taken ${isoOf(snapshot.takenAt)}. ` +
|
|
226
|
+
"It does not update by itself and you cannot act in that session from here. Briefly acknowledge the context, then answer the user's questions in this conversation."
|
|
227
|
+
: `[Hub side chat ${info.sideChatID}] The user refreshed the context from session ${info.sourceSessionID} (${isoOf(snapshot.takenAt)}, refresh ${snapshot.generation - 1}). ` +
|
|
228
|
+
"It replaces the earlier copy; items marked changed or missing differ from it."
|
|
229
|
+
const lines = [intro]
|
|
230
|
+
if (snapshot.omitted > 0) lines.push(`(${snapshot.omitted} older message(s) were left out to stay within the side-chat context bounds.)`)
|
|
231
|
+
lines.push("--- context ---")
|
|
232
|
+
snapshot.items.forEach((item, i) => {
|
|
233
|
+
const marks = [item.clipped ? "clipped" : "", item.changed ? "changed" : "", item.stale === "missing" ? "missing at source (previous copy)" : ""].filter(Boolean)
|
|
234
|
+
lines.push(`[${i + 1}] ${item.role} · ${isoOf(item.at)} · ${item.id}${marks.length ? ` · ${marks.join(", ")}` : ""}`, item.text, "")
|
|
235
|
+
})
|
|
236
|
+
lines.push("--- end of context ---")
|
|
237
|
+
return lines.join("\n")
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The messages a snapshot quotes, before bounds: the newest ones, or the named ones (previous copies for missing). */
|
|
241
|
+
function pick(messages: readonly Message[], request: SnapshotRequest, mode: SideChatSnapshot["mode"], items: number): { picked: Message[]; omitted: number } {
|
|
242
|
+
if (mode === "recent") {
|
|
243
|
+
const withText = messages.filter((m) => m.text)
|
|
244
|
+
const picked = withText.slice(-items)
|
|
245
|
+
return { picked, omitted: withText.length - picked.length }
|
|
246
|
+
}
|
|
247
|
+
const byID = new Map(messages.map((m) => [m.id, m]))
|
|
248
|
+
const before = new Map((request.previous?.items ?? []).map((item) => [item.id, item]))
|
|
249
|
+
const missing = request.ids.filter((id) => !byID.has(id) && !before.has(id))
|
|
250
|
+
if (missing.length) {
|
|
251
|
+
throw new AgentError("invalid", `${missing.length} source message(s) are not among the source session's newest ${SNAPSHOT_SCAN_ROWS} transcript rows`, { extra: { missing: missing.slice(0, 20) } })
|
|
252
|
+
}
|
|
253
|
+
const picked: Message[] = request.ids.map((id) => {
|
|
254
|
+
const found = byID.get(id)
|
|
255
|
+
if (found) return found
|
|
256
|
+
const { bytes: _bytes, changed: _changed, ...old } = before.get(id)!
|
|
257
|
+
return { ...old, stale: "missing" as const }
|
|
258
|
+
})
|
|
259
|
+
return { picked: picked.map((m, i) => ({ ...m, i })).sort(byOrder).map(({ i: _i, ...m }) => m), omitted: 0 }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Marks items whose text differs from the previous snapshot's (refresh). */
|
|
263
|
+
function marked(picked: readonly Message[], previous: SideChatSnapshot | undefined): Message[] {
|
|
264
|
+
if (!previous) return [...picked]
|
|
265
|
+
const before = new Map(previous.items.map((item) => [item.id, item]))
|
|
266
|
+
return picked.map((item) => {
|
|
267
|
+
const old = before.get(item.id)
|
|
268
|
+
const { changed: _changed, ...rest } = item
|
|
269
|
+
return item.stale !== "missing" && old && old.stale !== "missing" && !old.clipped && old.text !== item.text ? { ...rest, changed: true as const } : rest
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Builds a bounded snapshot from the source rows. Throws invalid when named messages are neither among the rows nor
|
|
275
|
+
* in the previous snapshot (a refresh keeps the previous copy of a message it can no longer read, marked missing).
|
|
276
|
+
*/
|
|
277
|
+
export function snapshotOf(rawRows: readonly unknown[], request: SnapshotRequest, info: RenderInfo, takenAt: number): SideChatSnapshot {
|
|
278
|
+
const limits = { ...SIDECHAT_LIMITS, ...request.limits }
|
|
279
|
+
const { messages, newestAt } = messagesOf(rawRows)
|
|
280
|
+
const mode: SideChatSnapshot["mode"] = request.previous?.mode ?? (request.ids.length ? "selected" : "recent")
|
|
281
|
+
const chosen = pick(messages, request, mode, limits.sideChatContextItems)
|
|
282
|
+
let omitted = chosen.omitted
|
|
283
|
+
let items: SnapshotItem[] = marked(chosen.picked, request.previous).map((m) => {
|
|
284
|
+
const text = clipBytes(m.text, limits.sideChatContextBytes)
|
|
285
|
+
return { ...m, text, bytes: utf8(text), ...(text !== m.text ? { clipped: true as const } : {}) }
|
|
286
|
+
})
|
|
287
|
+
const snapshot = (): SideChatSnapshot => ({
|
|
288
|
+
generation: (request.previous?.generation ?? 0) + 1,
|
|
289
|
+
takenAt,
|
|
290
|
+
mode,
|
|
291
|
+
items,
|
|
292
|
+
bytes: items.reduce((n, item) => n + item.bytes, 0),
|
|
293
|
+
omitted,
|
|
294
|
+
...(newestAt !== undefined ? { sourceNewestAt: newestAt } : {}),
|
|
295
|
+
})
|
|
296
|
+
// The newest items win: drop the oldest until the quoted text and the whole render fit.
|
|
297
|
+
const tooBig = () => {
|
|
298
|
+
const s = snapshot()
|
|
299
|
+
return s.bytes > limits.sideChatContextBytes || renderContext(info, s).length > limits.deliveryMax
|
|
300
|
+
}
|
|
301
|
+
while (items.length > 1 && tooBig()) {
|
|
302
|
+
items = items.slice(1)
|
|
303
|
+
omitted++
|
|
304
|
+
}
|
|
305
|
+
const over = renderContext(info, snapshot()).length - limits.deliveryMax
|
|
306
|
+
if (items.length === 1 && over > 0) {
|
|
307
|
+
const only = items[0]!
|
|
308
|
+
// Characters over the limit, as bytes (≥ 1 byte per character), and a margin for the clipped marker.
|
|
309
|
+
const text = clipBytes(only.text, Math.max(0, only.bytes - over * 4 - 32))
|
|
310
|
+
items = [{ ...only, text, bytes: utf8(text), clipped: true }]
|
|
311
|
+
}
|
|
312
|
+
return snapshot()
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ---------- events (family manage, text-free) ----------
|
|
316
|
+
|
|
317
|
+
export const sideChatCreatedEvent = (chat: StoredSideChat): EventInput => ({
|
|
318
|
+
type: "workspace.sidechat.created",
|
|
319
|
+
family: "manage",
|
|
320
|
+
subject: { workspaceID: chat.workspaceID, sideChatID: chat.id, sourceSessionID: chat.sourceSessionID, launchID: chat.launchID, machineID: chat.machineID },
|
|
321
|
+
data: { revision: chat.revision, generation: chat.snapshot.generation, items: chat.snapshot.items.length, bytes: chat.snapshot.bytes, omitted: chat.snapshot.omitted },
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
/** The launch reported the side-chat session (linked) or the context was refreshed. */
|
|
325
|
+
export function sideChatChangedEvent(chat: StoredSideChat, change: "sidechat_linked" | "sidechat_refreshed"): EventInput {
|
|
326
|
+
const items = chat.snapshot.items
|
|
327
|
+
const counts: Record<string, number> = change === "sidechat_refreshed" ? { generation: chat.snapshot.generation, items: items.length, changed: items.filter((i) => i.changed).length, missing: items.filter((i) => i.stale).length } : {}
|
|
328
|
+
return {
|
|
329
|
+
type: "workspace.updated",
|
|
330
|
+
family: "manage",
|
|
331
|
+
subject: { workspaceID: chat.workspaceID, sideChatID: chat.id, ...(chat.sessionID ? { sessionID: chat.sessionID } : {}) },
|
|
332
|
+
data: { change, sideChatRevision: chat.revision, ...counts },
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ---------- the store ----------
|
|
337
|
+
|
|
338
|
+
export interface SideChatStoreDeps {
|
|
339
|
+
readonly state: StateStore
|
|
340
|
+
readonly now?: Clock
|
|
341
|
+
readonly limits?: Partial<SideChatLimits>
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export interface NewSideChat {
|
|
345
|
+
readonly id: string
|
|
346
|
+
readonly workspaceID: string
|
|
347
|
+
readonly sourceSessionID: string
|
|
348
|
+
readonly launchID: string
|
|
349
|
+
readonly sessionID?: string
|
|
350
|
+
readonly by: string
|
|
351
|
+
readonly bindingID: string
|
|
352
|
+
readonly machineID: string
|
|
353
|
+
readonly harness: string
|
|
354
|
+
readonly snapshot: SideChatSnapshot
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** A side-chat ID; derived from a seed (an Idempotency-Key) so a retried create reuses the same launch key. */
|
|
358
|
+
export function sideChatID(seed?: string): string {
|
|
359
|
+
const body = seed === undefined ? randomBytes(12).toString("hex") : createHash("sha256").update(seed).digest("hex").slice(0, 24)
|
|
360
|
+
return `${ID_PREFIXES.sideChat}_${body}`
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const sourcesOf = (snapshot: SideChatSnapshot) => snapshot.items.map((item) => ({ kind: "message" as const, id: item.id }))
|
|
364
|
+
|
|
365
|
+
export class SideChatStore {
|
|
366
|
+
readonly state: StateStore
|
|
367
|
+
readonly limits: SideChatLimits
|
|
368
|
+
private readonly now: Clock
|
|
369
|
+
|
|
370
|
+
constructor(deps: SideChatStoreDeps) {
|
|
371
|
+
this.state = deps.state
|
|
372
|
+
this.now = deps.now ?? Date.now
|
|
373
|
+
this.limits = { ...SIDECHAT_LIMITS, ...deps.limits }
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Reserves a slot for `id` in the workspace (rate_limited at the cap). Reserving an ID again is a no-op. */
|
|
377
|
+
async reserve(userID: string, workspaceID: string, id: string): Promise<void> {
|
|
378
|
+
const at = this.now()
|
|
379
|
+
const stored = await this.storedIDs(userID)
|
|
380
|
+
let full = false
|
|
381
|
+
await updateVersioned<IndexRecord>(this.state, userID, WORKSPACE_SIDECHAT_INDEX, workspaceID, (current) => {
|
|
382
|
+
full = false
|
|
383
|
+
const entries = (current?.entries ?? []).filter((e) => stored.has(e.id) || at - e.at < RESERVATION_MS)
|
|
384
|
+
if (entries.some((e) => e.id === id)) return undefined
|
|
385
|
+
if (entries.length >= this.limits.sideChatsPerWorkspace) {
|
|
386
|
+
full = true
|
|
387
|
+
return undefined
|
|
388
|
+
}
|
|
389
|
+
return { v: 1, entries: [...entries, { id, at }] }
|
|
390
|
+
})
|
|
391
|
+
if (full) throw new AgentError("rate_limited", `A workspace has at most ${this.limits.sideChatsPerWorkspace} side chats`, { extra: { limit: this.limits.sideChatsPerWorkspace, retryAfter: 3600 } })
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Frees a reservation whose create failed before its record was written (a written record keeps its slot). */
|
|
395
|
+
async release(userID: string, workspaceID: string, id: string): Promise<void> {
|
|
396
|
+
if (await this.get(userID, id)) return
|
|
397
|
+
await updateVersioned<IndexRecord>(this.state, userID, WORKSPACE_SIDECHAT_INDEX, workspaceID, (current) =>
|
|
398
|
+
current?.entries.some((e) => e.id === id) ? { v: 1, entries: current.entries.filter((e) => e.id !== id) } : undefined,
|
|
399
|
+
)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Writes the record once (a repeat with the same ID answers the existing one, created false). */
|
|
403
|
+
async insert(userID: string, input: NewSideChat): Promise<{ sideChat: StoredSideChat; created: boolean }> {
|
|
404
|
+
const at = this.now()
|
|
405
|
+
const chat: StoredSideChat = {
|
|
406
|
+
v: 1,
|
|
407
|
+
id: input.id,
|
|
408
|
+
workspaceID: input.workspaceID,
|
|
409
|
+
sourceSessionID: input.sourceSessionID,
|
|
410
|
+
...(input.sessionID ? { sessionID: input.sessionID } : {}),
|
|
411
|
+
sources: sourcesOf(input.snapshot),
|
|
412
|
+
launchID: input.launchID,
|
|
413
|
+
by: input.by,
|
|
414
|
+
bindingID: input.bindingID,
|
|
415
|
+
machineID: input.machineID,
|
|
416
|
+
harness: input.harness,
|
|
417
|
+
snapshot: input.snapshot,
|
|
418
|
+
revision: 1,
|
|
419
|
+
createdAt: at,
|
|
420
|
+
updatedAt: at,
|
|
421
|
+
}
|
|
422
|
+
const put = await this.state.put(userID, WORKSPACE_SIDECHATS, chat.id, chat, { ifVersion: 0 })
|
|
423
|
+
if (put.ok) return { sideChat: chat, created: true }
|
|
424
|
+
const existing = await this.get(userID, chat.id)
|
|
425
|
+
if (!existing) throw new Error(`side chat ${chat.id} could not be written`)
|
|
426
|
+
return { sideChat: existing, created: false }
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async get(userID: string, id: string): Promise<StoredSideChat | undefined> {
|
|
430
|
+
const stored = await this.state.get<StoredSideChat>(userID, WORKSPACE_SIDECHATS, id)
|
|
431
|
+
return stored?.value.v === 1 ? stored.value : undefined
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** The side chat (in that workspace, when named), or not_found (also another user's). */
|
|
435
|
+
async require(userID: string, workspaceID: string | undefined, id: string): Promise<StoredSideChat> {
|
|
436
|
+
const chat = await this.get(userID, id)
|
|
437
|
+
if (!chat || (workspaceID !== undefined && chat.workspaceID !== workspaceID)) throw new AgentError("not_found", `No side chat ${id}`)
|
|
438
|
+
return chat
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** The workspace's side chats, oldest first, paged. */
|
|
442
|
+
async list(userID: string, workspaceID: string, query: { readonly limit?: number; readonly cursor?: string } = {}): Promise<Page<StoredSideChat>> {
|
|
443
|
+
const limit = query.limit ?? LIST_LIMIT_DEFAULT
|
|
444
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > LIST_LIMIT_MAX) throw new AgentError("invalid", `limit must be 1–${LIST_LIMIT_MAX}`)
|
|
445
|
+
const all = (await this.state.list<StoredSideChat>(userID, WORKSPACE_SIDECHATS))
|
|
446
|
+
.map((e) => e.value)
|
|
447
|
+
.filter((c) => c.v === 1 && c.workspaceID === workspaceID)
|
|
448
|
+
.sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id))
|
|
449
|
+
let start = 0
|
|
450
|
+
if (query.cursor !== undefined) {
|
|
451
|
+
start = all.findIndex((c) => c.id === query.cursor) + 1
|
|
452
|
+
if (start === 0) throw new AgentError("invalid", "Unknown cursor")
|
|
453
|
+
}
|
|
454
|
+
const items = all.slice(start, start + limit)
|
|
455
|
+
return start + limit < all.length ? { items, cursor: items[items.length - 1]!.id } : { items }
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Records the side-chat session the launch reported (once; a later, different session is ignored). */
|
|
459
|
+
async link(userID: string, id: string, sessionID: string): Promise<StoredSideChat | undefined> {
|
|
460
|
+
return updateVersioned<StoredSideChat>(this.state, userID, WORKSPACE_SIDECHATS, id, (current) =>
|
|
461
|
+
current && current.sessionID === undefined ? { ...current, sessionID, revision: current.revision + 1, updatedAt: this.now() } : undefined,
|
|
462
|
+
)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Replaces the snapshot under the expected revision (409 conflict with currentRevision when stale). */
|
|
466
|
+
async refresh(userID: string, workspaceID: string, id: string, expectedRevision: number, snapshot: SideChatSnapshot): Promise<StoredSideChat> {
|
|
467
|
+
let current: StoredSideChat | undefined
|
|
468
|
+
const next = await updateVersioned<StoredSideChat>(this.state, userID, WORKSPACE_SIDECHATS, id, (value) => {
|
|
469
|
+
current = value
|
|
470
|
+
if (!value || value.workspaceID !== workspaceID || value.revision !== expectedRevision) return undefined
|
|
471
|
+
return { ...value, snapshot, sources: sourcesOf(snapshot), revision: value.revision + 1, updatedAt: this.now() }
|
|
472
|
+
})
|
|
473
|
+
if (next) return next
|
|
474
|
+
if (!current || current.workspaceID !== workspaceID) throw new AgentError("not_found", `No side chat ${id}`)
|
|
475
|
+
throw new AgentError("conflict", "The side chat changed since you read it", { extra: { currentRevision: current.revision } })
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** IDs of every stored side chat of the user (their reservations never expire). */
|
|
479
|
+
private async storedIDs(userID: string): Promise<Set<string>> {
|
|
480
|
+
return new Set((await this.state.list<StoredSideChat>(userID, WORKSPACE_SIDECHATS)).map((e) => e.key))
|
|
481
|
+
}
|
|
482
|
+
}
|