@huynle/hub 0.7.4 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +217 -0
- package/Dockerfile +7 -2
- package/README.md +148 -19
- 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/profile.ts +173 -0
- package/packages/claude-code/src/sessions.ts +22 -4
- package/packages/cli/dist/hub-cli.js +6474 -3470
- 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/enrollment/enroll.ts +20 -8
- package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +95 -13
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +380 -9
- package/packages/cli/src/messaging.ts +3 -1
- package/packages/cli/src/setup/commands.ts +3 -2
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +403 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- 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 +96 -2
- package/packages/node/src/opencode-warm.ts +325 -0
- package/packages/node/src/run.ts +27 -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 +169 -6
- package/packages/protocol/src/launch.ts +22 -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 +3 -1
- package/packages/sdk/src/index.ts +5 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +548 -1
- 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 +215 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +17 -0
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +69 -2
- package/src/central/agent/delivery.ts +95 -15
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +5 -0
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +11 -3
- 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 +16 -2
- package/src/central/agent/launch-routes.ts +6 -1
- package/src/central/agent/launch-spec.ts +14 -1
- package/src/central/agent/message-routes.ts +2 -1
- package/src/central/agent/messages.ts +27 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/read-routes.ts +86 -19
- package/src/central/agent/routes.ts +176 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- 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 +773 -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 +339 -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 +89 -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 +130 -0
- package/src/central/concierge/proposals.ts +262 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +62 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +69 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +740 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +277 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +7 -1
- package/src/central/http.ts +632 -49
- 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 +43 -6
- 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 +772 -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 +888 -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 +97 -15
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +383 -18
- package/src/spoke/listing.ts +47 -4
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +176 -0
- package/src/spoke/spoke.ts +319 -20
- 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-BS0gVkfu.js +1 -0
- package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
- package/web/dist/assets/Library-CtxRqTjo.js +2 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
- package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
- package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/index-DaH5GUdV.js +31 -0
- package/web/dist/assets/index-WWuKfWxL.css +1 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/live-YyTjE47g.js +5 -0
- package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
- package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
- package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
- package/web/dist/assets/source-CvUHTc3O.js +1 -0
- package/web/dist/assets/ui-C9Igxzjm.js +1 -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,505 @@
|
|
|
1
|
+
// Session intents (CON-02; contract: "Session intent (CON-02)" in docs/plans/2026-11-01-concierge-contracts.md):
|
|
2
|
+
// what a session is for, in one untrusted line the hub keeps beside the session (never on AgentSession, so 2000
|
|
3
|
+
// sessions × 280 characters do not bloat the user record).
|
|
4
|
+
//
|
|
5
|
+
// Storage: StateStore collection `session-intents` (tenant = hub user, key = session ID), one record per session
|
|
6
|
+
// holding a LAYER per source. Precedence user > launch > self > prompt: the highest layer present is the intent, so
|
|
7
|
+
// a lower source never replaces a higher one, and clearing a layer (text null) falls back to the next one. `title`
|
|
8
|
+
// is never stored: reads fall back to the session's non-placeholder title (titleIntent).
|
|
9
|
+
// - user: PUT by the user, or an app token holding sessions:manage (`by` tells them apart)
|
|
10
|
+
// - launch: the launch spec's `intent`, stored when the launched session is identified
|
|
11
|
+
// - self: PUT by a session for itself
|
|
12
|
+
// - prompt: derived ONCE, from the first prompt the hub carries for the session (console turn, launch prompt,
|
|
13
|
+
// agent prompt through acting): first line, then first sentence, cut to 280 characters, no model call.
|
|
14
|
+
//
|
|
15
|
+
// `revision` counts changes of the EFFECTIVE intent (text or source), 1 for the first; a write that only changes a
|
|
16
|
+
// layer beneath it bumps nothing. Writers send `expectedRevision` (0 before anything was stored); a stale one is
|
|
17
|
+
// 409 conflict {currentRevision}. Records are version-checked (updateVersioned), so concurrent writers retry.
|
|
18
|
+
// Every change of the effective intent appends `session.intent {source, revision}` (family manage) to the journal:
|
|
19
|
+
// never the text. `source` is "title" once nothing is stored any more.
|
|
20
|
+
//
|
|
21
|
+
// Reads on request paths use many(ids): one get per listed session, never the whole collection (the StateStore has
|
|
22
|
+
// no batch get; the gets run concurrently, pipelined on Postgres). Retention (IntentRetention, one leader
|
|
23
|
+
// "session-intents", first run a minute after election unless this process swept within the interval, then every
|
|
24
|
+
// 6 h) keeps every record of an online session (the registry decides, not the directory, which keeps at most 2000
|
|
25
|
+
// records per user and may have evicted an offline one) and drops records of deleted sessions (deletedAt;
|
|
26
|
+
// Directory.markDeleted is not called today) and of sessions neither seen (directory lastSeen, or a sweep while
|
|
27
|
+
// online) nor written for 30 days. A dropped record's revision starts over at 0, as the contract has it before
|
|
28
|
+
// anything is stored (coordinator decision, round-3 review): a client that held a revision across a 30-day-offline
|
|
29
|
+
// deletion could write over a newer intent at the same revision (ABA). That window is accepted.
|
|
30
|
+
//
|
|
31
|
+
// The text is untrusted data: sanitised (control, ANSI, bidi and zero-width characters go), folded onto one line,
|
|
32
|
+
// at most INTENT_MAX characters. The hub never interprets it.
|
|
33
|
+
import { INTENT_MAX, type IntentSource, type SessionIntent } from "../../protocol"
|
|
34
|
+
import type { Actions } from "../actions"
|
|
35
|
+
import { realTimers } from "../backend/memory-bus"
|
|
36
|
+
import type { StateStore, Timers } from "../backend/types"
|
|
37
|
+
import { sanitizeText } from "./envelope"
|
|
38
|
+
import { AgentError } from "./errors"
|
|
39
|
+
import type { AgentEvents, EventInput } from "./events"
|
|
40
|
+
import { isPlaceholderTitle } from "./names"
|
|
41
|
+
import { updateVersioned } from "./versioned"
|
|
42
|
+
|
|
43
|
+
export const INTENTS = "session-intents"
|
|
44
|
+
|
|
45
|
+
/** The sources the hub stores (title is a read fallback only). */
|
|
46
|
+
export type StoredSource = Exclude<IntentSource, "title">
|
|
47
|
+
|
|
48
|
+
/** Highest precedence first. */
|
|
49
|
+
export const INTENT_ORDER: readonly StoredSource[] = ["user", "launch", "self", "prompt"]
|
|
50
|
+
|
|
51
|
+
/** Raw input longer than this is refused before it is cleaned (bounds the work on hostile input). */
|
|
52
|
+
const RAW_MAX = 4 * 1024
|
|
53
|
+
|
|
54
|
+
interface Layer {
|
|
55
|
+
readonly text: string
|
|
56
|
+
readonly at: number
|
|
57
|
+
readonly by?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A session's stored record. */
|
|
61
|
+
interface StoredIntent {
|
|
62
|
+
readonly sessionID: string
|
|
63
|
+
readonly layers: Partial<Record<StoredSource, Layer>>
|
|
64
|
+
readonly revision: number
|
|
65
|
+
/** The first prompt was seen (it is derived once, even when a higher layer hides it). */
|
|
66
|
+
readonly prompted?: true
|
|
67
|
+
/** The last write (any layer), for retention; older records fall back to their newest layer. */
|
|
68
|
+
readonly touched?: number
|
|
69
|
+
/** The last time a retention sweep saw the session online. */
|
|
70
|
+
readonly seen?: number
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
/** When a record was last written. */
|
|
76
|
+
const touchedAt = (record: StoredIntent) => record.touched ?? Math.max(0, ...Object.values(record.layers).map((l) => l?.at ?? 0))
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* What retention knows of a session: online from the registry (whatever the directory holds), lastSeen and deletedAt
|
|
80
|
+
* from the directory when it has a record (it keeps at most 2000 per user, so an offline session may have none).
|
|
81
|
+
*/
|
|
82
|
+
export interface IntentSessionFacts {
|
|
83
|
+
readonly online: boolean
|
|
84
|
+
readonly lastSeen?: number
|
|
85
|
+
readonly deletedAt?: number
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Records of sessions neither seen (directory, or a sweep while online) nor written this long are dropped. */
|
|
89
|
+
export const INTENT_RETENTION_MS = 30 * 86_400_000
|
|
90
|
+
/** A sweep re-stamps `seen` on an online session's record at most this often. */
|
|
91
|
+
const SEEN_STEP_MS = 86_400_000
|
|
92
|
+
|
|
93
|
+
/** Sanitised, one line, trimmed, cut to `max` characters (code points). */
|
|
94
|
+
function clean(text: string, max = Infinity): string {
|
|
95
|
+
return Array.from(sanitizeText(text).replace(/\s+/g, " ").trim()).slice(0, max).join("").trimEnd()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A user, app, session or launch intent: sanitised, one line, 1–280 characters; anything else is 400 invalid. */
|
|
99
|
+
export function intentText(input: unknown): string {
|
|
100
|
+
const problem = `intent is text of 1–${INTENT_MAX} characters on one line`
|
|
101
|
+
if (typeof input !== "string" || input.length > RAW_MAX) throw new AgentError("invalid", problem)
|
|
102
|
+
const text = clean(input)
|
|
103
|
+
const length = Array.from(text).length
|
|
104
|
+
if (length === 0 || length > INTENT_MAX) throw new AgentError("invalid", problem)
|
|
105
|
+
return text
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The intent of a first prompt: its first non-blank line, then that line's first sentence, ≤ 280 characters. */
|
|
109
|
+
export function deriveIntent(prompt: string): string | undefined {
|
|
110
|
+
const line = sanitizeText(prompt.slice(0, RAW_MAX))
|
|
111
|
+
.split("\n")
|
|
112
|
+
.map((l) => l.trim())
|
|
113
|
+
.find((l) => l.length > 0)
|
|
114
|
+
if (line === undefined) return undefined
|
|
115
|
+
return clean(firstSentence(line), INTENT_MAX) || undefined
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Common abbreviations a sentence does not end at ("e.g. the docs", "Bun vs. Node"). */
|
|
119
|
+
const ABBREVIATIONS = new Set(["e.g", "i.e", "etc", "vs", "cf", "approx", "incl", "mr", "mrs", "ms", "dr", "st", "u.s", "u.k", "a.m", "p.m"])
|
|
120
|
+
|
|
121
|
+
/** The line up to its first sentence end ([.!?] then a space or the end) that does not close an abbreviation. */
|
|
122
|
+
function firstSentence(line: string): string {
|
|
123
|
+
const end = /[.!?](?=\s|$)/g
|
|
124
|
+
for (let match = end.exec(line); match; match = end.exec(line)) {
|
|
125
|
+
const word = /(\S+)$/.exec(line.slice(0, match.index))?.[1]?.toLowerCase().replace(/^[("'[]+/, "")
|
|
126
|
+
if (match[0] === "." && word !== undefined && ABBREVIATIONS.has(word)) continue
|
|
127
|
+
return line.slice(0, match.index + 1)
|
|
128
|
+
}
|
|
129
|
+
return line
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The read fallback: the session's title when it is not a placeholder, as source "title". */
|
|
133
|
+
export function titleIntent(sessionID: string, title: string | undefined, revision: number, updatedAt: number): SessionIntent | undefined {
|
|
134
|
+
if (isPlaceholderTitle(title)) return undefined
|
|
135
|
+
const text = clean(title!, INTENT_MAX)
|
|
136
|
+
return text ? { sessionID, text, source: "title", revision, updatedAt } : undefined
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The journal event of a changed intent: its source and revision, never the text. */
|
|
140
|
+
export const intentEvent = (sessionID: string, source: IntentSource, revision: number): EventInput => ({
|
|
141
|
+
type: "session.intent",
|
|
142
|
+
family: "manage",
|
|
143
|
+
subject: { sessionID },
|
|
144
|
+
data: { source, revision },
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
/** The effective intent of a record (its highest layer). */
|
|
148
|
+
function effective(record: StoredIntent | undefined): SessionIntent | undefined {
|
|
149
|
+
if (!record) return undefined
|
|
150
|
+
for (const source of INTENT_ORDER) {
|
|
151
|
+
const layer = record.layers[source]
|
|
152
|
+
if (layer) return { sessionID: record.sessionID, text: layer.text, source, revision: record.revision, updatedAt: layer.at, ...(layer.by !== undefined ? { by: layer.by } : {}) }
|
|
153
|
+
}
|
|
154
|
+
return undefined
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const sameIntent = (a: SessionIntent | undefined, b: SessionIntent | undefined) => a?.text === b?.text && a?.source === b?.source
|
|
158
|
+
|
|
159
|
+
/** (CON-10) A Concierge proposal's prompt the user ran (Do it): its session, text and when it was answered. */
|
|
160
|
+
export interface ProposedPrompt {
|
|
161
|
+
readonly sessionID: string
|
|
162
|
+
readonly text: string
|
|
163
|
+
readonly at: number
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The old build derived a run proposal's prompt in the background, right after the answer: within this window. */
|
|
167
|
+
export const PROPOSAL_DERIVE_WINDOW_MS = 60_000
|
|
168
|
+
|
|
169
|
+
export interface SessionIntentsDeps {
|
|
170
|
+
readonly state: StateStore
|
|
171
|
+
readonly now?: () => number
|
|
172
|
+
/** The journal: `session.intent` on every change of an effective intent. */
|
|
173
|
+
readonly events?: Pick<AgentEvents, "append">
|
|
174
|
+
/** Every change of an effective intent (the console stream: IDs, source and revision only, never the text). */
|
|
175
|
+
readonly onChange?: (userID: string, sessionID: string, source: IntentSource, revision: number) => void
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A write through the API: user, app (as source user) or the session itself (source self). */
|
|
179
|
+
export interface IntentWrite {
|
|
180
|
+
readonly source: StoredSource
|
|
181
|
+
/** Validated by intentText here; null clears this source's layer. */
|
|
182
|
+
readonly text: string | null
|
|
183
|
+
readonly expectedRevision: number
|
|
184
|
+
/** Party key of the writer. */
|
|
185
|
+
readonly by?: string
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export class SessionIntents {
|
|
189
|
+
private readonly now: () => number
|
|
190
|
+
|
|
191
|
+
constructor(private readonly deps: SessionIntentsDeps) {
|
|
192
|
+
this.now = deps.now ?? Date.now
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The stored effective intent (undefined when nothing is stored: callers fall back to the title). */
|
|
196
|
+
async read(userID: string, sessionID: string): Promise<SessionIntent | undefined> {
|
|
197
|
+
return effective(await this.record(userID, sessionID))
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The record's revision (0 before anything was stored, or after retention dropped it). */
|
|
201
|
+
async revision(userID: string, sessionID: string): Promise<number> {
|
|
202
|
+
return (await this.record(userID, sessionID))?.revision ?? 0
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Every stored effective intent of a user, by session ID (lists the whole collection: not for request paths; use many). */
|
|
206
|
+
async all(userID: string): Promise<Map<string, SessionIntent>> {
|
|
207
|
+
const out = new Map<string, SessionIntent>()
|
|
208
|
+
for (const { key, value } of await this.deps.state.list<StoredIntent>(userID, INTENTS)) {
|
|
209
|
+
const intent = effective({ ...value, sessionID: key })
|
|
210
|
+
if (intent) out.set(key, intent)
|
|
211
|
+
}
|
|
212
|
+
return out
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The stored effective intents of these sessions (one read each, concurrently; never the whole collection). */
|
|
216
|
+
async many(userID: string, sessionIDs: Iterable<string>): Promise<Map<string, SessionIntent>> {
|
|
217
|
+
const ids = [...new Set(sessionIDs)]
|
|
218
|
+
const records = await Promise.all(ids.map((id) => this.record(userID, id)))
|
|
219
|
+
const out = new Map<string, SessionIntent>()
|
|
220
|
+
records.forEach((record, i) => {
|
|
221
|
+
const intent = effective(record)
|
|
222
|
+
if (intent) out.set(ids[i]!, intent)
|
|
223
|
+
})
|
|
224
|
+
return out
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Retention. A record is kept while its session is online (the sweep stamps `seen` then); it is dropped when its
|
|
229
|
+
* session is deleted, or when it was neither seen (directory lastSeen, or a sweep while online) nor written for
|
|
230
|
+
* INTENT_RETENTION_MS. A session the directory has no record of is judged the same way (offline, lastSeen unknown).
|
|
231
|
+
* Deletes and stamps are version-checked (a concurrent write wins); a dropped record's revision starts over at 0.
|
|
232
|
+
* Answers how many records were dropped.
|
|
233
|
+
*/
|
|
234
|
+
async sweep(userID: string, facts: (sessionID: string) => IntentSessionFacts, options: { readonly retentionMs?: number } = {}): Promise<number> {
|
|
235
|
+
const retention = options.retentionMs ?? INTENT_RETENTION_MS
|
|
236
|
+
const t = this.now()
|
|
237
|
+
let dropped = 0
|
|
238
|
+
for (const { key, value, version } of await this.deps.state.list<StoredIntent>(userID, INTENTS)) {
|
|
239
|
+
const session = facts(key)
|
|
240
|
+
if (session.online) {
|
|
241
|
+
if (t - (value.seen ?? 0) >= SEEN_STEP_MS) await this.deps.state.put(userID, INTENTS, key, { ...value, seen: t }, { ifVersion: version })
|
|
242
|
+
continue
|
|
243
|
+
}
|
|
244
|
+
const last = Math.max(session.lastSeen ?? 0, touchedAt(value), value.seen ?? 0)
|
|
245
|
+
if (session.deletedAt === undefined && t - last <= retention) continue
|
|
246
|
+
if ((await this.deps.state.delete(userID, INTENTS, key, { ifVersion: version })).ok) dropped++
|
|
247
|
+
}
|
|
248
|
+
return dropped
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Sets (or with null clears) one source's layer under expectedRevision; answers the effective intent. */
|
|
252
|
+
async set(userID: string, sessionID: string, write: IntentWrite): Promise<SessionIntent | undefined> {
|
|
253
|
+
const text = write.text === null ? null : intentText(write.text)
|
|
254
|
+
return this.change(userID, sessionID, (current) => {
|
|
255
|
+
const revision = current?.revision ?? 0
|
|
256
|
+
if (write.expectedRevision !== revision) {
|
|
257
|
+
throw new AgentError("conflict", `The intent changed since you read it (revision ${revision} now)`, { extra: { currentRevision: revision } })
|
|
258
|
+
}
|
|
259
|
+
// (CON-10) Clearing the first-prompt layer is a change the user made even when a higher layer hides it: the
|
|
260
|
+
// revision moves (a stale client must re-read) and it is journaled and audited like any change.
|
|
261
|
+
const bump = write.source === "prompt" && text === null ? ({ bump: true } as const) : {}
|
|
262
|
+
return { layer: write.source, value: text === null ? null : { text, at: this.now(), ...(write.by !== undefined ? { by: write.by } : {}) }, ...bump }
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** The launch spec's intent, when its session is identified (validated by the launch spec). */
|
|
267
|
+
async storeLaunch(userID: string, sessionID: string, text: string, by?: string): Promise<void> {
|
|
268
|
+
const clean = intentText(text)
|
|
269
|
+
await this.change(userID, sessionID, () => ({ layer: "launch", value: { text: clean, at: this.now(), ...(by !== undefined ? { by } : {}) } }))
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Derives the prompt layer from the session's first prompt (once); true when it was recorded now. */
|
|
273
|
+
async derive(userID: string, sessionID: string, prompt: string): Promise<boolean> {
|
|
274
|
+
const text = deriveIntent(prompt)
|
|
275
|
+
if (text === undefined) return false
|
|
276
|
+
let recorded = false
|
|
277
|
+
await this.change(userID, sessionID, (current) => {
|
|
278
|
+
if (current?.prompted) return undefined
|
|
279
|
+
recorded = true
|
|
280
|
+
return { layer: "prompt", value: { text, at: this.now() }, prompted: true }
|
|
281
|
+
})
|
|
282
|
+
return recorded
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* (CON-10) One-time correction of records the build before CON-09a wrote: it derived a Concierge-proposed prompt
|
|
287
|
+
* (run as the user when they tapped Do it) as the session's first-prompt intent. A prompt layer whose text is the
|
|
288
|
+
* derivation of such a proposal's prompt, written within PROPOSAL_DERIVE_WINDOW_MS after the proposal was answered,
|
|
289
|
+
* is cleared (the session falls back to its title); `prompted` stays, so a later prompt never fills it. Idempotent.
|
|
290
|
+
* Answers how many layers were cleared.
|
|
291
|
+
*/
|
|
292
|
+
async correctProposed(userID: string, proposals: readonly ProposedPrompt[]): Promise<number> {
|
|
293
|
+
let cleared = 0
|
|
294
|
+
const bySession = new Map<string, ProposedPrompt[]>()
|
|
295
|
+
for (const p of proposals) bySession.set(p.sessionID, [...(bySession.get(p.sessionID) ?? []), p])
|
|
296
|
+
for (const [sessionID, list] of bySession) {
|
|
297
|
+
const layer = (await this.record(userID, sessionID))?.layers.prompt
|
|
298
|
+
if (!layer) continue
|
|
299
|
+
const derived = list.some((p) => deriveIntent(p.text) === layer.text && layer.at >= p.at && layer.at - p.at <= PROPOSAL_DERIVE_WINDOW_MS)
|
|
300
|
+
if (!derived) continue
|
|
301
|
+
let done = false
|
|
302
|
+
await this.change(userID, sessionID, (current) => {
|
|
303
|
+
if (current?.layers.prompt?.text !== layer.text || current.layers.prompt.at !== layer.at) return undefined
|
|
304
|
+
done = true
|
|
305
|
+
return { layer: "prompt", value: null }
|
|
306
|
+
})
|
|
307
|
+
if (done) cleared++
|
|
308
|
+
}
|
|
309
|
+
return cleared
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private async record(userID: string, sessionID: string): Promise<StoredIntent | undefined> {
|
|
313
|
+
const value = (await this.deps.state.get<StoredIntent>(userID, INTENTS, sessionID))?.value
|
|
314
|
+
return value && { ...value, sessionID }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Applies one layer change (value null clears it; `plan` undefined leaves the record alone). The revision grows
|
|
319
|
+
* only when the effective intent changes, and only then is the event appended.
|
|
320
|
+
*/
|
|
321
|
+
private async change(
|
|
322
|
+
userID: string,
|
|
323
|
+
sessionID: string,
|
|
324
|
+
plan: (current: StoredIntent | undefined) => { layer: StoredSource; value: Layer | null; prompted?: true; bump?: true } | undefined,
|
|
325
|
+
): Promise<SessionIntent | undefined> {
|
|
326
|
+
let before: SessionIntent | undefined
|
|
327
|
+
let bumped = false
|
|
328
|
+
const written = await updateVersioned<StoredIntent>(this.deps.state, userID, INTENTS, sessionID, (stored) => {
|
|
329
|
+
const current = stored && { ...stored, sessionID }
|
|
330
|
+
before = effective(current)
|
|
331
|
+
bumped = false
|
|
332
|
+
const step = plan(current)
|
|
333
|
+
if (!step) return undefined
|
|
334
|
+
const layers = { ...current?.layers }
|
|
335
|
+
const old = layers[step.layer]
|
|
336
|
+
if (step.value === null) {
|
|
337
|
+
if (!old) return undefined
|
|
338
|
+
delete layers[step.layer]
|
|
339
|
+
} else {
|
|
340
|
+
if (old?.text === step.value.text && !step.prompted) return undefined
|
|
341
|
+
layers[step.layer] = old?.text === step.value.text ? old : step.value
|
|
342
|
+
}
|
|
343
|
+
const prompted = step.prompted || current?.prompted ? { prompted: true as const } : {}
|
|
344
|
+
const draft: StoredIntent = { sessionID, layers, revision: current?.revision ?? 0, ...prompted, touched: this.now(), ...(current?.seen !== undefined ? { seen: current.seen } : {}) }
|
|
345
|
+
bumped = !sameIntent(before, effective(draft)) || step.bump === true
|
|
346
|
+
return bumped ? { ...draft, revision: draft.revision + 1 } : draft
|
|
347
|
+
})
|
|
348
|
+
const after = written ? effective(written) : before
|
|
349
|
+
if (bumped && written) {
|
|
350
|
+
this.deps.onChange?.(userID, sessionID, after?.source ?? "title", written.revision)
|
|
351
|
+
await this.deps.events
|
|
352
|
+
?.append(userID, intentEvent(sessionID, after?.source ?? "title", written.revision))
|
|
353
|
+
.catch((cause: unknown) => console.error(`[hub] session.intent event failed: ${(cause as Error).message}`))
|
|
354
|
+
}
|
|
355
|
+
return after
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** What reads embed (GET /sessions, /sessions/:addr, /who): the stored intent without writer details. */
|
|
360
|
+
export const intentField = (intent: SessionIntent | undefined): { intent?: { text: string; source: IntentSource; revision: number } } =>
|
|
361
|
+
intent ? { intent: { text: intent.text, source: intent.source, revision: intent.revision } } : {}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* The shared action executor with the first-prompt derivation: the prompts the hub carries as human turns (the
|
|
365
|
+
* user's console turn or prompt, a launch prompt) go through Actions.prompt; once one was carried, the session
|
|
366
|
+
* owner's prompt layer is derived in the background (never delaying or failing the prompt). (CON-09a) Agents'
|
|
367
|
+
* prompts are deliveries (never through here), and a prompt the user only approved (a Concierge proposal, intent:
|
|
368
|
+
* false) is never a source.
|
|
369
|
+
*/
|
|
370
|
+
export function withPromptIntents<A extends Actions>(
|
|
371
|
+
actions: A,
|
|
372
|
+
intents: Pick<SessionIntents, "derive">,
|
|
373
|
+
tenant: (sessionID: string) => string | undefined,
|
|
374
|
+
title?: (sessionID: string) => string | undefined,
|
|
375
|
+
): A {
|
|
376
|
+
return {
|
|
377
|
+
...actions,
|
|
378
|
+
async prompt(input) {
|
|
379
|
+
// (CON-10) Only the session's own first prompt: a session that already has a real title had its first prompt
|
|
380
|
+
// elsewhere (typed in its terminal, before the hub carried any), so a later hub-carried prompt is not it. Read
|
|
381
|
+
// before the turn runs (the turn itself may generate the title).
|
|
382
|
+
// A launch's prompt (intent "first") is its session's first prompt even though the launch titled it already.
|
|
383
|
+
const first = input.intent === "first" || title === undefined || isPlaceholderTitle(title(input.sessionID))
|
|
384
|
+
const result = await actions.prompt(input)
|
|
385
|
+
const userID = result.outcome === "done" ? tenant(input.sessionID) : undefined
|
|
386
|
+
if (userID !== undefined && first && input.text && input.intent !== false) {
|
|
387
|
+
void intents.derive(userID, input.sessionID, input.text).catch((cause: unknown) => console.error(`[hub] intent derivation failed: ${(cause as Error).message}`))
|
|
388
|
+
}
|
|
389
|
+
return result
|
|
390
|
+
},
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export interface IntentRetentionOptions {
|
|
395
|
+
readonly intents: Pick<SessionIntents, "sweep"> & Partial<Pick<SessionIntents, "correctProposed">>
|
|
396
|
+
/**
|
|
397
|
+
* (CON-10) The user's run Concierge proposal prompts (answered Do it): the first pass of this process corrects
|
|
398
|
+
* the prompt intents the build before CON-09a derived from them (SessionIntents.correctProposed), once per user.
|
|
399
|
+
*/
|
|
400
|
+
readonly proposed?: (userID: string) => Promise<readonly ProposedPrompt[]>
|
|
401
|
+
/** The hub's user IDs (read at every run). */
|
|
402
|
+
readonly users: () => Iterable<string> | Promise<Iterable<string>>
|
|
403
|
+
/** The directory's facts about a user's sessions (read once per user per run). */
|
|
404
|
+
readonly facts: (userID: string) => (sessionID: string) => IntentSessionFacts
|
|
405
|
+
readonly intervalMs?: number
|
|
406
|
+
/** The first run after start (default a minute), or the rest of the interval when this process swept more recently. */
|
|
407
|
+
readonly firstDelayMs?: number
|
|
408
|
+
readonly now?: () => number
|
|
409
|
+
readonly timers?: Timers
|
|
410
|
+
readonly log?: (message: string) => void
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Time between retention runs. */
|
|
414
|
+
export const INTENT_RETENTION_INTERVAL_MS = 6 * 3_600_000
|
|
415
|
+
|
|
416
|
+
/** The retention sweep of every user's session intents, every intervalMs while started (under a leader). */
|
|
417
|
+
export class IntentRetention {
|
|
418
|
+
private readonly intervalMs: number
|
|
419
|
+
private readonly timers: Timers
|
|
420
|
+
private readonly log: (message: string) => void
|
|
421
|
+
private running: Promise<{ users: number; failures: number; dropped: number }> | undefined
|
|
422
|
+
private timer: unknown
|
|
423
|
+
private started = false
|
|
424
|
+
/** When this process last finished a pass (a re-election here does not redo it within the interval). */
|
|
425
|
+
private lastPassAt: number | undefined
|
|
426
|
+
/** (CON-10) Users whose proposal correction ran in this process. */
|
|
427
|
+
private readonly corrected = new Set<string>()
|
|
428
|
+
|
|
429
|
+
constructor(private readonly options: IntentRetentionOptions) {
|
|
430
|
+
this.intervalMs = options.intervalMs ?? INTENT_RETENTION_INTERVAL_MS
|
|
431
|
+
this.timers = options.timers ?? realTimers
|
|
432
|
+
this.log = options.log ?? ((message) => console.error(message))
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** One pass over every user (a failure is logged without IDs and the pass goes on); concurrent callers share it. */
|
|
436
|
+
runOnce(): Promise<{ users: number; failures: number; dropped: number }> {
|
|
437
|
+
this.running ??= this.pass().finally(() => {
|
|
438
|
+
this.running = undefined
|
|
439
|
+
})
|
|
440
|
+
return this.running
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
start(): void {
|
|
444
|
+
if (this.started) return
|
|
445
|
+
this.started = true
|
|
446
|
+
const first = this.options.firstDelayMs ?? 60_000
|
|
447
|
+
const sinceLast = this.lastPassAt === undefined ? Infinity : (this.options.now ?? Date.now)() - this.lastPassAt
|
|
448
|
+
this.schedule(Math.max(first, this.intervalMs - sinceLast))
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
stop(): void {
|
|
452
|
+
this.started = false
|
|
453
|
+
if (this.timer !== undefined) this.timers.clear(this.timer)
|
|
454
|
+
this.timer = undefined
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private schedule(delayMs = this.intervalMs): void {
|
|
458
|
+
this.timer = this.timers.set(() => {
|
|
459
|
+
this.timer = undefined
|
|
460
|
+
void this.runOnce().finally(() => {
|
|
461
|
+
if (this.started) this.schedule()
|
|
462
|
+
})
|
|
463
|
+
}, delayMs)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** (CON-10) The one-time proposal correction of a user (a failure is logged without IDs and retried next pass). */
|
|
467
|
+
private async correct(userID: string): Promise<void> {
|
|
468
|
+
const { proposed } = this.options
|
|
469
|
+
const correctProposed = this.options.intents.correctProposed?.bind(this.options.intents)
|
|
470
|
+
if (!proposed || !correctProposed || this.corrected.has(userID)) return
|
|
471
|
+
try {
|
|
472
|
+
const cleared = await correctProposed(userID, await proposed(userID))
|
|
473
|
+
this.corrected.add(userID)
|
|
474
|
+
if (cleared > 0) this.log(`[hub] session intent correction: cleared ${cleared} intent(s) derived from Concierge proposals`)
|
|
475
|
+
} catch {
|
|
476
|
+
// No error text: it could name sessions or Decisions (retried on the next pass).
|
|
477
|
+
this.log("[hub] session intent correction failed for a user; it is retried on the next pass")
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
private async pass(): Promise<{ users: number; failures: number; dropped: number }> {
|
|
482
|
+
let users = 0
|
|
483
|
+
let failures = 0
|
|
484
|
+
let dropped = 0
|
|
485
|
+
let list: Iterable<string>
|
|
486
|
+
try {
|
|
487
|
+
list = await this.options.users()
|
|
488
|
+
} catch (cause) {
|
|
489
|
+
this.log(`[hub] session intent retention could not list users: ${(cause as Error).message}`)
|
|
490
|
+
return { users, failures: 1, dropped }
|
|
491
|
+
}
|
|
492
|
+
for (const userID of list) {
|
|
493
|
+
users++
|
|
494
|
+
await this.correct(userID)
|
|
495
|
+
try {
|
|
496
|
+
dropped += await this.options.intents.sweep(userID, this.options.facts(userID))
|
|
497
|
+
} catch (cause) {
|
|
498
|
+
failures++
|
|
499
|
+
this.log(`[hub] session intent retention failed for a user: ${(cause as Error).message}`)
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
this.lastPassAt = (this.options.now ?? Date.now)()
|
|
503
|
+
return { users, failures, dropped }
|
|
504
|
+
}
|
|
505
|
+
}
|