@huynle/hub 0.9.2 → 0.10.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 +296 -0
- package/Dockerfile +5 -1
- package/README.md +152 -77
- package/package.json +7 -4
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +532 -261
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/cli.ts +36 -1
- package/packages/cli/src/subscriptions.ts +49 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/sessions.ts +10 -6
- package/packages/codex/src/spoke.ts +2 -1
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/schema/app-delivery/v1.schema.json +7 -5
- package/packages/protocol/schema/app-front/v1.schema.json +410 -0
- package/packages/protocol/src/app-front.ts +293 -0
- package/packages/protocol/src/concierge.ts +10 -1
- package/packages/protocol/src/context-cards.ts +59 -0
- package/packages/protocol/src/credentials.ts +39 -2
- package/packages/protocol/src/index.ts +69 -11
- 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/app-front.ts +524 -0
- package/packages/sdk/src/client.ts +37 -4
- package/packages/sdk/src/envelope.ts +9 -2
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/index.ts +1 -0
- package/packages/sdk/src/types.ts +39 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/delivery.ts +17 -1
- package/packages/spoke-sdk/src/harness-spoke.ts +4 -3
- package/packages/voice-client/README.md +146 -0
- package/packages/voice-client/src/vad/LICENSE +21 -0
- package/src/central/agent/acting-routes.ts +17 -3
- package/src/central/agent/acting.ts +12 -3
- package/src/central/agent/actor.ts +6 -1
- package/src/central/agent/app-decisions.ts +146 -0
- package/src/central/agent/app-tokens.ts +9 -3
- package/src/central/agent/audit.ts +3 -0
- package/src/central/agent/connect-routes.ts +64 -0
- package/src/central/agent/connect.ts +868 -0
- package/src/central/agent/conversation.ts +625 -0
- package/src/central/agent/decisions.ts +71 -6
- package/src/central/agent/delivery.ts +3 -0
- package/src/central/agent/directory.ts +5 -0
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/grant-requests.ts +46 -9
- package/src/central/agent/grants.ts +13 -2
- package/src/central/agent/message-limits.ts +2 -2
- package/src/central/agent/message-routes.ts +13 -4
- package/src/central/agent/read-routes.ts +16 -2
- package/src/central/agent/routes.ts +108 -6
- package/src/central/agent/speech-routes.ts +316 -0
- package/src/central/agent/subscription-routes.ts +174 -0
- package/src/central/agent/subscriptions.ts +980 -0
- package/src/central/agent-data.ts +43 -1
- package/src/central/backend/memory-counters.ts +2 -2
- package/src/central/backend/types.ts +5 -2
- package/src/central/backend/valkey-counters.ts +5 -4
- package/src/central/client-address.ts +16 -0
- package/src/central/concierge/board-render.ts +52 -1
- package/src/central/concierge/board-routes.ts +25 -2
- package/src/central/concierge/board.ts +11 -1
- package/src/central/concierge/context.ts +29 -8
- package/src/central/concierge/grant-request.ts +216 -0
- package/src/central/concierge/prompt.ts +49 -2
- package/src/central/concierge/session-routes.ts +3 -0
- package/src/central/config.ts +64 -3
- package/src/central/context-cards/budget.ts +121 -0
- package/src/central/context-cards/changes.ts +29 -0
- package/src/central/context-cards/config.ts +145 -0
- package/src/central/context-cards/engine.ts +379 -0
- package/src/central/context-cards/inputs.ts +111 -0
- package/src/central/context-cards/pickup.ts +134 -0
- package/src/central/context-cards/registry.ts +89 -0
- package/src/central/context-cards/routes.ts +37 -0
- package/src/central/context-cards/service.ts +367 -0
- package/src/central/context-cards/store.ts +67 -0
- package/src/central/context-cards/types.ts +88 -0
- package/src/central/fetcher.ts +10 -2
- package/src/central/front-relays.ts +423 -0
- package/src/central/http.ts +516 -56
- package/src/central/human-activity.ts +453 -0
- package/src/central/llm/chat.ts +133 -0
- package/src/central/registry.ts +162 -9
- package/src/central/speech/service.ts +9 -0
- package/src/central/spoke-ws.ts +13 -0
- package/src/central/store.ts +9 -12
- package/src/config.ts +30 -13
- package/src/index.ts +45 -43
- package/src/rpc.ts +33 -4
- package/src/spoke/concierge-agent.ts +1 -1
- package/src/spoke/conversation.ts +6 -75
- package/src/spoke/deliver.ts +18 -8
- package/src/spoke/hub-rpc.ts +7 -2
- package/src/spoke/hub-tool-subscriptions.ts +82 -0
- package/src/spoke/hub-tool-title.ts +89 -0
- package/src/spoke/hub-tool.ts +56 -18
- package/src/spoke/session-context.ts +10 -2
- package/src/spoke/spoke.ts +209 -238
- package/src/spoke/summary.ts +36 -1
- package/src/spoke/taskboard.ts +5 -6
- package/src/spoke/voice-front.ts +323 -30
- package/src/spoke/who-teams.ts +51 -2
- package/src/tui-messaging.ts +24 -10
- package/src/tui.ts +6 -6
- package/web/dist/assets/{ArtifactPage-BKISN-Qj.js → ArtifactPage-Bz992ZX6.js} +1 -1
- package/web/dist/assets/{ArtifactPane-CKOiAUNH.js → ArtifactPane-BJZNixJC.js} +1 -1
- package/web/dist/assets/ArtifactWorkbench-Dti3VP07.js +6 -0
- package/web/dist/assets/{Diagram-vdpmp3xu.css → Diagram-7y8kg3og.css} +1 -1
- package/web/dist/assets/Diagram-B4HdUxw0.js +1 -0
- package/web/dist/assets/{Library-CcjZXsfF.js → Library-CpzD2HDW.js} +2 -2
- package/web/dist/assets/Lightbox-BlbivCkd.js +1 -0
- package/web/dist/assets/MediaImage-Ch3ryIdR.js +4 -0
- package/web/dist/assets/ProfilesDestination-DlQeogea.js +1 -0
- package/web/dist/assets/{TeamsPage-CDyjrK-I.js → TeamsPage-4xdWCaI-.js} +1 -1
- package/web/dist/assets/{Watchtower-DYwoUWME.js → Watchtower-CpQdIRns.js} +2 -2
- package/web/dist/assets/{artifacts-DU9uRgs1.js → artifacts-But87Jfo.js} +1 -1
- package/web/dist/assets/index-BC0O1gfR.css +1 -0
- package/web/dist/assets/index-BS6lA6fN.js +33 -0
- package/web/dist/assets/{live-ycZ_aQZO.js → live-CVUFIdUX.js} +2 -2
- package/web/dist/assets/{pane-layout-0RrLHGDr.js → pane-layout-BW0RdX5a.js} +1 -1
- package/web/dist/assets/{renderer2d-BIwbgRoX.js → renderer2d-IaJiloPi.js} +1 -1
- package/web/dist/assets/{renderer3d-C1a7eYGQ.js → renderer3d-ZiD1uKGW.js} +1 -1
- package/web/dist/assets/{safe-area-DHhPy61Q.js → safe-area-C7IAlkgY.js} +1 -1
- package/web/dist/assets/{source-BWlvaODa.js → source-C-De8L3s.js} +1 -1
- package/web/dist/assets/toast-CXTwTvDw.js +9 -0
- package/web/dist/assets/vad-worklet-BfxJhrXy.js +27 -0
- package/web/dist/index.html +6 -6
- package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +0 -6
- package/web/dist/assets/Diagram-azw2B_QH.js +0 -1
- package/web/dist/assets/Lightbox-Du56MXGX.js +0 -1
- package/web/dist/assets/MediaImage-Ds_JUx3a.js +0 -4
- package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +0 -1
- package/web/dist/assets/index-C3ci5UtY.css +0 -1
- package/web/dist/assets/index-LNMZCqgE.js +0 -36
- package/web/dist/assets/toast-Cuu2K0hx.js +0 -1
- /package/{web/src/voice → packages/voice-client/src}/turn/LICENSE +0 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// The `pickup` context card (primary): "Pick up where you left off", a short summary the user (and the Concierge)
|
|
2
|
+
// reads to get back into a session: what you were doing / last asked, what the agent did since, where it stands,
|
|
3
|
+
// and the next step or what it waits on. Written by the context card model from a bounded, sanitised, redacted
|
|
4
|
+
// view of the session (title unless a placeholder, intent, state, blockers, the newest transcript rows: tool rows
|
|
5
|
+
// as names only). Its one-line headline is the session list's second line.
|
|
6
|
+
import { redactCredentials } from "../../../packages/protocol/src/credentials.ts"
|
|
7
|
+
import { isPlaceholderTitle } from "../agent/names"
|
|
8
|
+
import type { CardDraft, CardInputs, ContextCardProvider, TranscriptRow } from "./types"
|
|
9
|
+
|
|
10
|
+
/** The prompt's user message stays within this many characters. */
|
|
11
|
+
export const PICKUP_PROMPT_MAX = 8000
|
|
12
|
+
/** A transcript row in the prompt is cut to this many characters. */
|
|
13
|
+
const PROMPT_ROW_MAX = 1200
|
|
14
|
+
/** Room for reasoning models, which spend output tokens before they answer (the answer itself is ~150 tokens). */
|
|
15
|
+
export const PICKUP_MAX_TOKENS = 800
|
|
16
|
+
|
|
17
|
+
export const PICKUP_SYSTEM = [
|
|
18
|
+
"You write a short pick-up note for one AI coding session so its user (or their assistant) can get back into it fast.",
|
|
19
|
+
"Read the session facts and transcript excerpt. Be terse and factual; never invent work that is not in the excerpt.",
|
|
20
|
+
"Answer ONE JSON object only:",
|
|
21
|
+
'{"headline": "<= 90 characters: where the work stands now, e.g. \\"Login loop fixed; waiting for your review\\"",',
|
|
22
|
+
' "summary": "<= 300 characters: what the user was doing or last asked, what the agent did since, and the current state",',
|
|
23
|
+
' "next": "<= 120 characters: the next step, or what the session is waiting on (omit if nothing)"}',
|
|
24
|
+
"Write to the user as \"you\". No greetings, no markdown headings, no secrets.",
|
|
25
|
+
].join("\n")
|
|
26
|
+
|
|
27
|
+
const LABEL: Record<Exclude<TranscriptRow["kind"], "tool">, string> = { user: "User", assistant: "Agent", error: "Error", message: "Message" }
|
|
28
|
+
|
|
29
|
+
/** Transcript rows as prompt lines, oldest first; consecutive tool rows fold into one "Tools: a, b" line. */
|
|
30
|
+
function lines(rows: readonly TranscriptRow[]): string[] {
|
|
31
|
+
const out: string[] = []
|
|
32
|
+
let tools: string[] = []
|
|
33
|
+
const flush = () => {
|
|
34
|
+
if (tools.length) out.push(`Tools: ${tools.join(", ")}`)
|
|
35
|
+
tools = []
|
|
36
|
+
}
|
|
37
|
+
for (const row of rows) {
|
|
38
|
+
if (row.kind === "tool") {
|
|
39
|
+
if (row.name) tools.push(row.state === "failed" ? `${row.name} (failed)` : row.name)
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
flush()
|
|
43
|
+
if (row.text) out.push(`${LABEL[row.kind]}: ${clip(row.text, PROMPT_ROW_MAX)}`)
|
|
44
|
+
}
|
|
45
|
+
flush()
|
|
46
|
+
return out
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Credentials redacted, THEN cut: a secret straddling the limit never leaks a part. */
|
|
50
|
+
const clip = (text: string, max: number) => redactCredentials(text).text.slice(0, max)
|
|
51
|
+
|
|
52
|
+
export function buildPickupPrompt(inputs: CardInputs): { system: string; user: string } {
|
|
53
|
+
const head: string[] = []
|
|
54
|
+
if (!isPlaceholderTitle(inputs.title)) head.push(`Title: ${clip(inputs.title!, 200)}`)
|
|
55
|
+
if (inputs.intent) head.push(`Intent: ${clip(inputs.intent, 280)}`)
|
|
56
|
+
if (inputs.state) head.push(`State: ${clip(inputs.state, 20)}`)
|
|
57
|
+
if (inputs.blockers?.length) head.push(`Waiting on: ${clip(inputs.blockers.join("; "), 1000)}`)
|
|
58
|
+
const header = `${head.join("\n")}\n\nTranscript (oldest first):\n`
|
|
59
|
+
// Newest lines first until the budget is used, then back in order.
|
|
60
|
+
const kept: string[] = []
|
|
61
|
+
let used = header.length
|
|
62
|
+
for (const line of lines(inputs.transcript ?? []).reverse()) {
|
|
63
|
+
const clean = line
|
|
64
|
+
if (used + clean.length + 1 > PICKUP_PROMPT_MAX) break
|
|
65
|
+
kept.push(clean)
|
|
66
|
+
used += clean.length + 1
|
|
67
|
+
}
|
|
68
|
+
return { system: PICKUP_SYSTEM, user: `${header}${kept.reverse().join("\n")}`.slice(0, PICKUP_PROMPT_MAX) }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The model's answer as a draft: JSON {headline, summary, next?} (fenced or not); plain text: its first line. */
|
|
72
|
+
export function parsePickup(text: string): CardDraft | undefined {
|
|
73
|
+
const raw = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "").trim()
|
|
74
|
+
if (!raw) return undefined
|
|
75
|
+
const start = raw.indexOf("{")
|
|
76
|
+
const end = raw.lastIndexOf("}")
|
|
77
|
+
if (start !== -1 && end > start) {
|
|
78
|
+
let parsed: unknown
|
|
79
|
+
try {
|
|
80
|
+
parsed = JSON.parse(raw.slice(start, end + 1))
|
|
81
|
+
} catch {
|
|
82
|
+
parsed = undefined
|
|
83
|
+
}
|
|
84
|
+
if (parsed !== undefined) {
|
|
85
|
+
const answer = (typeof parsed === "object" && parsed !== null ? parsed : {}) as Record<string, unknown>
|
|
86
|
+
const str = (v: unknown) => (typeof v === "string" ? v.trim() : "")
|
|
87
|
+
const headline = str(answer.headline)
|
|
88
|
+
if (!headline) return undefined
|
|
89
|
+
const summary = str(answer.summary)
|
|
90
|
+
const next = str(answer.next)
|
|
91
|
+
return { headline, body: [summary, next ? `Next: ${next}` : ""].filter(Boolean).join("\n\n") }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// JSON the model cut or broke: its headline field when it can be read, else nothing (never the raw JSON).
|
|
95
|
+
if (/^[[{]/.test(raw)) {
|
|
96
|
+
const headline = /"headline"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(raw)?.[1]
|
|
97
|
+
if (headline === undefined) return undefined
|
|
98
|
+
let text: string
|
|
99
|
+
try {
|
|
100
|
+
text = JSON.parse(`"${headline}"`) as string
|
|
101
|
+
} catch {
|
|
102
|
+
return undefined
|
|
103
|
+
}
|
|
104
|
+
return text.trim() ? { headline: text.trim(), body: "" } : undefined
|
|
105
|
+
}
|
|
106
|
+
const first = raw.split("\n").map((l) => l.trim()).find(Boolean)
|
|
107
|
+
return first && !/[{}"]/.test(first.slice(0, 2)) ? { headline: first, body: "" } : undefined
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function pickupProvider(options: { readonly minIntervalMs?: number } = {}): ContextCardProvider {
|
|
111
|
+
return {
|
|
112
|
+
id: "pickup",
|
|
113
|
+
label: "Pick up where you left off",
|
|
114
|
+
version: 1,
|
|
115
|
+
primary: true,
|
|
116
|
+
usesModel: true,
|
|
117
|
+
triggers: ["turn-end", "blocked", "user-return", "sweep"],
|
|
118
|
+
minIntervalMs: options.minIntervalMs ?? 5 * 60_000,
|
|
119
|
+
needs: ["status", "intent", "transcript"],
|
|
120
|
+
async generate({ inputs, chat }) {
|
|
121
|
+
if (!chat) return undefined
|
|
122
|
+
if (!inputs.transcript?.length && !inputs.intent) return undefined
|
|
123
|
+
const { system, user } = buildPickupPrompt(inputs)
|
|
124
|
+
const answer = await chat.complete({ system, user, maxTokens: PICKUP_MAX_TOKENS, json: true })
|
|
125
|
+
const draft = parsePickup(answer.text)
|
|
126
|
+
if (!draft) return undefined
|
|
127
|
+
const fields = [
|
|
128
|
+
...(inputs.state ? [{ label: "State", value: inputs.state }] : []),
|
|
129
|
+
...(inputs.blockers?.length ? [{ label: "Waiting on", value: inputs.blockers.join("; ") }] : []),
|
|
130
|
+
]
|
|
131
|
+
return { ...draft, ...(fields.length ? { fields } : {}), model: answer.model }
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// The context card provider registry and the bounding of a provider's draft into a stored card. Card text is
|
|
2
|
+
// untrusted (a model or a session wrote it): sanitised (control, ANSI, bidi, zero-width characters go), literal
|
|
3
|
+
// credentials redacted, every field cut to CONTEXT_CARD_LIMITS. A draft without a headline is no card.
|
|
4
|
+
import { redactCredentials } from "../../../packages/protocol/src/credentials.ts"
|
|
5
|
+
import { CONTEXT_CARD_LIMITS, type ContextCard, type ContextCardField, type ContextCardProviderInfo, type ContextCardTrigger } from "../../protocol"
|
|
6
|
+
import { sanitizeText } from "../agent/envelope"
|
|
7
|
+
import type { CardDraft, ContextCardProvider } from "./types"
|
|
8
|
+
|
|
9
|
+
const PROVIDER_ID = /^[a-z][a-z0-9-]{1,31}$/
|
|
10
|
+
|
|
11
|
+
export class CardRegistry {
|
|
12
|
+
private readonly providers = new Map<string, ContextCardProvider>()
|
|
13
|
+
|
|
14
|
+
/** Adds a provider; throws on a bad or taken id, or a second primary. */
|
|
15
|
+
register(provider: ContextCardProvider): void {
|
|
16
|
+
if (!PROVIDER_ID.test(provider.id)) throw new Error(`Context card provider id ${JSON.stringify(provider.id)} must be 2–32 lowercase letters, digits or -`)
|
|
17
|
+
if (this.providers.has(provider.id)) throw new Error(`Context card provider ${provider.id} is registered already`)
|
|
18
|
+
if (provider.primary && this.primary()) throw new Error(`Context card provider ${provider.id}: ${this.primary()!.id} is the primary already`)
|
|
19
|
+
this.providers.set(provider.id, provider)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
list(): ContextCardProvider[] {
|
|
23
|
+
return [...this.providers.values()]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
get(id: string): ContextCardProvider | undefined {
|
|
27
|
+
return this.providers.get(id)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
primary(): ContextCardProvider | undefined {
|
|
31
|
+
return this.list().find((p) => p.primary)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What clients see of the providers. */
|
|
35
|
+
info(): ContextCardProviderInfo[] {
|
|
36
|
+
return this.list().map((p) => ({ id: p.id, label: p.label, primary: p.primary === true, usesModel: p.usesModel === true }))
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Sanitised, credentials redacted, cut to `max` code points. `lines`: newlines kept (runs of blank lines folded). */
|
|
41
|
+
export function cardText(input: unknown, max: number, lines = false): string {
|
|
42
|
+
if (typeof input !== "string") return ""
|
|
43
|
+
// Redacted on the whole (bounded) text BEFORE the cut, so a secret straddling the limit never leaks a part.
|
|
44
|
+
let text = redactCredentials(sanitizeText(input.slice(0, 1024 * 1024))).text
|
|
45
|
+
text = lines ? text.replace(/[ \t]+/g, " ").replace(/ *\n */g, "\n").replace(/\n{3,}/g, "\n\n").trim() : text.replace(/\s+/g, " ").trim()
|
|
46
|
+
return Array.from(text).slice(0, max).join("").trimEnd()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function boundFields(fields: unknown): ContextCardField[] | undefined {
|
|
50
|
+
if (!Array.isArray(fields)) return undefined
|
|
51
|
+
const out: ContextCardField[] = []
|
|
52
|
+
for (const field of fields) {
|
|
53
|
+
if (out.length >= CONTEXT_CARD_LIMITS.fields) break
|
|
54
|
+
if (!field || typeof field !== "object") continue
|
|
55
|
+
const label = cardText((field as { label?: unknown }).label, CONTEXT_CARD_LIMITS.fieldLabel)
|
|
56
|
+
const value = cardText((field as { value?: unknown }).value, CONTEXT_CARD_LIMITS.fieldValue)
|
|
57
|
+
if (label && value) out.push({ label, value })
|
|
58
|
+
}
|
|
59
|
+
return out.length ? out : undefined
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A provider's draft as a stored card (undefined when it has no headline). */
|
|
63
|
+
export function boundCard(
|
|
64
|
+
provider: Pick<ContextCardProvider, "id" | "label" | "version" | "primary">,
|
|
65
|
+
sessionID: string,
|
|
66
|
+
draft: CardDraft | null | undefined,
|
|
67
|
+
meta: { readonly generatedAt: number; readonly inputsHash: string; readonly trigger: ContextCardTrigger; readonly basis?: readonly string[] },
|
|
68
|
+
): ContextCard | undefined {
|
|
69
|
+
if (!draft || typeof draft !== "object") return undefined
|
|
70
|
+
const headline = cardText(draft.headline, CONTEXT_CARD_LIMITS.headline)
|
|
71
|
+
if (!headline) return undefined
|
|
72
|
+
const fields = boundFields(draft.fields)
|
|
73
|
+
const model = cardText(draft.model, CONTEXT_CARD_LIMITS.model)
|
|
74
|
+
return {
|
|
75
|
+
provider: provider.id,
|
|
76
|
+
sessionID,
|
|
77
|
+
title: cardText(draft.title, CONTEXT_CARD_LIMITS.title) || cardText(provider.label, CONTEXT_CARD_LIMITS.title),
|
|
78
|
+
headline,
|
|
79
|
+
body: cardText(draft.body, CONTEXT_CARD_LIMITS.body, true),
|
|
80
|
+
...(fields ? { fields } : {}),
|
|
81
|
+
generatedAt: meta.generatedAt,
|
|
82
|
+
...(model ? { model } : {}),
|
|
83
|
+
inputsHash: meta.inputsHash,
|
|
84
|
+
version: provider.version,
|
|
85
|
+
trigger: meta.trigger,
|
|
86
|
+
...(meta.basis ? { basis: [...meta.basis] } : {}),
|
|
87
|
+
...(provider.primary ? { primary: true as const } : {}),
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Context card agent route (docs/context-cards.md "API"):
|
|
2
|
+
//
|
|
3
|
+
// GET /api/agent/sessions/:addr/context-cards → {sessionID, cards: ContextCard[], providers}
|
|
4
|
+
// users; app tokens holding sessions:read; session tokens (the session read rules, team-scoped when the hub
|
|
5
|
+
// has team discovery), exactly like the intent read. Another user's session is 404 not_found.
|
|
6
|
+
//
|
|
7
|
+
// None while cards are off for the user. The Concierge reads a session's cards here (and the primary headline rides its board). Card text is untrusted.
|
|
8
|
+
import type { Directory, DirectoryEntry } from "../agent/directory"
|
|
9
|
+
import type { AgentActor } from "../agent/actor"
|
|
10
|
+
import { AGENT_PREFIX, type AgentRoute } from "../agent/routes"
|
|
11
|
+
import type { TeamDiscovery } from "../teams/discovery"
|
|
12
|
+
import type { ContextCardService } from "./service"
|
|
13
|
+
|
|
14
|
+
export interface ContextCardRoutesDeps {
|
|
15
|
+
readonly cards: Pick<ContextCardService, "list" | "registry">
|
|
16
|
+
readonly directory: Directory
|
|
17
|
+
readonly discovery?: TeamDiscovery
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function contextCardRoutes(deps: ContextCardRoutesDeps): AgentRoute[] {
|
|
21
|
+
async function resolve(userID: string, actor: AgentActor, addr: string): Promise<DirectoryEntry> {
|
|
22
|
+
if (deps.discovery) return deps.discovery.resolveOne(await deps.discovery.census(userID, actor), addr)
|
|
23
|
+
return deps.directory.resolveOne(userID, addr)
|
|
24
|
+
}
|
|
25
|
+
return [
|
|
26
|
+
{
|
|
27
|
+
method: "GET",
|
|
28
|
+
path: `${AGENT_PREFIX}/sessions/:addr/context-cards`,
|
|
29
|
+
allow: { session: true, scope: "sessions:read" },
|
|
30
|
+
handle: async ({ user, actor, params }) => {
|
|
31
|
+
const entry = await resolve(user.id, actor, params.addr!)
|
|
32
|
+
const cards = await deps.cards.list(user.id, entry.sessionID)
|
|
33
|
+
return Response.json({ sessionID: entry.sessionID, cards, providers: deps.cards.registry.info() }, { headers: { "cache-control": "no-store" } })
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
]
|
|
37
|
+
}
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// The context card service (docs/context-cards.md): the registry with the built-in providers (pickup, changes),
|
|
2
|
+
// the store, the daily budget, the engine, the human activity gate, the per-user setting, the model, and the
|
|
3
|
+
// triggers http.ts feeds it:
|
|
4
|
+
// - journal appends (cardTriggerOf): a root session's turn ended (working → idle | error) → "turn-end";
|
|
5
|
+
// the tree went blocked (state blocked, or a blocker opened) → "blocked"
|
|
6
|
+
// - human turns and read marks (the user opened the session): human activity; after RETURN_GAP_MS without any
|
|
7
|
+
// (or the first time this process sees one) → "user-return"
|
|
8
|
+
// - the leader's sweep every SWEEP_MS: "sweep" for every session with recent human activity; and every
|
|
9
|
+
// PRUNE_EVERY_MS cards older than CARD_RETENTION_MS are dropped (a deleted session's cards go at once: forget)
|
|
10
|
+
// Automatic triggers run on the "context-cards" leader only: other pods forward them (forward, a leader kick)
|
|
11
|
+
// with the human activity time, so the leader's gate knows it.
|
|
12
|
+
//
|
|
13
|
+
// Opt-in (user decision 2026-11-08): cards are OFF for every user until that user turns them on in Settings; nothing
|
|
14
|
+
// is gathered or sent to a model for a user who has not, even with a model configured. HUB_CONTEXT_CARDS=off (or
|
|
15
|
+
// contextCards.enabled false) is the hub-wide kill switch over every user's choice.
|
|
16
|
+
//
|
|
17
|
+
// Privacy: while cards are off for a user (their setting or the kill switch) every read (console, primary headlines,
|
|
18
|
+
// agent API, Concierge board) answers none and no card is stored or sent; turning them off deletes their stored cards.
|
|
19
|
+
//
|
|
20
|
+
// Human activity: the engine takes an injectable predicate (EngineDeps.humanActive). The hub gives the persisted
|
|
21
|
+
// per-user activity (deps.lastHumanAt, src/central/human-activity.ts `last`, root sessions): active when the user
|
|
22
|
+
// touched the session within HUMAN_ACTIVE_MS. Without it, the in-memory HumanActivity below (bounded) decides. The
|
|
23
|
+
// in-memory map still tells a return after RETURN_GAP_MS ("user-return") and lists the sweep's recent sessions; in the
|
|
24
|
+
// hub it is fed from the same human touches (prompts typed or sent, Voice, answers, explicit opens).
|
|
25
|
+
import type { ContextCard, ContextCardProviderInfo, ContextCardTrigger } from "../../protocol"
|
|
26
|
+
import { realTimers } from "../backend/memory-bus"
|
|
27
|
+
import type { StateStore, Timers } from "../backend/types"
|
|
28
|
+
import type { AgentEvent, EventInput } from "../agent/events"
|
|
29
|
+
import { OpenAICompatibleChat, type ChatClient } from "../llm/chat"
|
|
30
|
+
import { DailyBudget, DEFAULT_BUDGET, type BudgetUsage } from "./budget"
|
|
31
|
+
import { changesProvider } from "./changes"
|
|
32
|
+
import { resolveCardModel, type ContextCardsConfig, type KeySources } from "./config"
|
|
33
|
+
import { ContextCardEngine, type CardModel, type RunOutcome } from "./engine"
|
|
34
|
+
import { gatherInputs, type InputDeps } from "./inputs"
|
|
35
|
+
import { pickupProvider } from "./pickup"
|
|
36
|
+
import { CardRegistry } from "./registry"
|
|
37
|
+
import { ContextCardStore } from "./store"
|
|
38
|
+
|
|
39
|
+
/** A session the user touched within this long is "recently active" (automatic cards, the sweep). */
|
|
40
|
+
export const HUMAN_ACTIVE_MS = 24 * 3_600_000
|
|
41
|
+
/** A human touch after this long without one is a return ("user-return"). */
|
|
42
|
+
export const RETURN_GAP_MS = 30 * 60_000
|
|
43
|
+
export const SWEEP_MS = 30 * 60_000
|
|
44
|
+
export const PRUNE_EVERY_MS = 6 * 3_600_000
|
|
45
|
+
export const CARD_RETENTION_MS = 30 * 86_400_000
|
|
46
|
+
const ACTIVITY_MAX = 10_000
|
|
47
|
+
|
|
48
|
+
export type AutoTrigger = Exclude<ContextCardTrigger, "manual">
|
|
49
|
+
|
|
50
|
+
/** The card trigger a journal event is, on which (root) session; undefined for most events. */
|
|
51
|
+
export function cardTriggerOf(event: Pick<AgentEvent, "type" | "subject" | "data"> | null | undefined): { sessionID: string; trigger: "turn-end" | "blocked" } | undefined {
|
|
52
|
+
if (typeof event !== "object" || event === null) return undefined
|
|
53
|
+
const subject = event.subject ?? {}
|
|
54
|
+
const data = event.data ?? {}
|
|
55
|
+
const root = subject.rootID ?? subject.sessionID
|
|
56
|
+
if (typeof root !== "string" || !root) return undefined
|
|
57
|
+
if (event.type === "blocker.opened") return { sessionID: root, trigger: "blocked" }
|
|
58
|
+
if (event.type !== "session.changed" || subject.sessionID !== root) return undefined
|
|
59
|
+
if (data.state === "blocked") return { sessionID: root, trigger: "blocked" }
|
|
60
|
+
if ((data.state === "idle" || data.state === "error") && data.prev === "working") return { sessionID: root, trigger: "turn-end" }
|
|
61
|
+
return undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** When the user last acted in each session (human turns, read marks), in memory, bounded. */
|
|
65
|
+
export class HumanActivity {
|
|
66
|
+
private readonly now: () => number
|
|
67
|
+
private readonly max: number
|
|
68
|
+
/** `user\nsession` → at (insertion order = age). */
|
|
69
|
+
private readonly last = new Map<string, number>()
|
|
70
|
+
|
|
71
|
+
constructor(options: { readonly now?: () => number; readonly max?: number } = {}) {
|
|
72
|
+
this.now = options.now ?? Date.now
|
|
73
|
+
this.max = options.max ?? ACTIVITY_MAX
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The user acted now; `returned` after RETURN_GAP_MS without activity (or none known). */
|
|
77
|
+
touch(userID: string, sessionID: string): { returned: boolean } {
|
|
78
|
+
const at = this.now()
|
|
79
|
+
const before = this.last.get(`${userID}\n${sessionID}`)
|
|
80
|
+
this.note(userID, sessionID, at)
|
|
81
|
+
return { returned: before === undefined || at - before > RETURN_GAP_MS }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Records activity seen elsewhere (another pod's forward). */
|
|
85
|
+
note(userID: string, sessionID: string, at: number): void {
|
|
86
|
+
const key = `${userID}\n${sessionID}`
|
|
87
|
+
if ((this.last.get(key) ?? 0) > at) return
|
|
88
|
+
this.last.delete(key)
|
|
89
|
+
this.last.set(key, at)
|
|
90
|
+
if (this.last.size > this.max) this.last.delete(this.last.keys().next().value!)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
lastAt(userID: string, sessionID: string): number | undefined {
|
|
94
|
+
return this.last.get(`${userID}\n${sessionID}`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
active(userID: string, sessionID: string): boolean {
|
|
98
|
+
const at = this.lastAt(userID, sessionID)
|
|
99
|
+
return at !== undefined && this.now() - at <= HUMAN_ACTIVE_MS
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
*recent(): Iterable<{ userID: string; sessionID: string }> {
|
|
103
|
+
const now = this.now()
|
|
104
|
+
for (const [key, at] of this.last) {
|
|
105
|
+
if (now - at > HUMAN_ACTIVE_MS) continue
|
|
106
|
+
const [userID, sessionID] = key.split("\n") as [string, string]
|
|
107
|
+
yield { userID, sessionID }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
forget(userID: string, sessionID: string): void {
|
|
112
|
+
this.last.delete(`${userID}\n${sessionID}`)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const CARD_SETTINGS = "context-card-settings"
|
|
117
|
+
|
|
118
|
+
/** The per-user setting (StateStore `context-card-settings`, key "settings"): {enabled}; unset is off (opt-in). */
|
|
119
|
+
export class CardSettings {
|
|
120
|
+
constructor(private readonly deps: { readonly state: StateStore }) {}
|
|
121
|
+
|
|
122
|
+
async stored(userID: string): Promise<boolean | undefined> {
|
|
123
|
+
const value = (await this.deps.state.get<{ enabled?: unknown }>(userID, CARD_SETTINGS, "settings"))?.value
|
|
124
|
+
return typeof value?.enabled === "boolean" ? value.enabled : undefined
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async enabled(userID: string, fallback: boolean): Promise<boolean> {
|
|
128
|
+
return (await this.stored(userID)) ?? fallback
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The user turned context cards on (opt-in: never by default). */
|
|
132
|
+
async optedIn(userID: string): Promise<boolean> {
|
|
133
|
+
return (await this.stored(userID)) === true
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async set(userID: string, enabled: boolean): Promise<void> {
|
|
137
|
+
await this.deps.state.put(userID, CARD_SETTINGS, "settings", { enabled })
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The journal event of a new card: its provider, version and primary flag; never the text. */
|
|
142
|
+
export const contextCardEvent = (card: ContextCard): EventInput => ({
|
|
143
|
+
type: "session.contextCard",
|
|
144
|
+
family: "session",
|
|
145
|
+
subject: { sessionID: card.sessionID },
|
|
146
|
+
data: { provider: card.provider, version: card.version, primary: card.primary === true },
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
export interface ContextCardsView {
|
|
150
|
+
readonly cards: ContextCard[]
|
|
151
|
+
readonly providers: ContextCardProviderInfo[]
|
|
152
|
+
/** Cards are on for this user (hub kill switch and their setting). */
|
|
153
|
+
readonly enabled: boolean
|
|
154
|
+
/** A model is configured (model providers can run). */
|
|
155
|
+
readonly configured: boolean
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface ContextCardSettingsView {
|
|
159
|
+
/** The user's effective setting. */
|
|
160
|
+
readonly enabled: boolean
|
|
161
|
+
/** The hub allows cards (HUB_CONTEXT_CARDS / contextCards.enabled). */
|
|
162
|
+
readonly available: boolean
|
|
163
|
+
readonly configured: boolean
|
|
164
|
+
readonly model?: { readonly provider: string; readonly model: string; readonly keySource: string }
|
|
165
|
+
readonly usage: BudgetUsage
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface ContextCardServiceDeps {
|
|
169
|
+
readonly state: StateStore
|
|
170
|
+
readonly config: ContextCardsConfig
|
|
171
|
+
/** The speech keys (read per call: settings changes apply at once). */
|
|
172
|
+
readonly keySources: () => KeySources
|
|
173
|
+
/** Overrides the model client (tests). */
|
|
174
|
+
readonly chat?: ChatClient
|
|
175
|
+
readonly fetch?: typeof globalThis.fetch
|
|
176
|
+
readonly inputs: InputDeps
|
|
177
|
+
readonly isLeader: () => boolean
|
|
178
|
+
/** Another pod's automatic trigger goes to the leader (a leader kick). */
|
|
179
|
+
readonly forward?: (userID: string, sessionID: string, trigger: AutoTrigger, humanAt?: number) => void
|
|
180
|
+
readonly onCard: (userID: string, card: ContextCard) => void
|
|
181
|
+
readonly autoName?: (userID: string, sessionID: string) => Promise<void>
|
|
182
|
+
/** The session still exists (not deleted) right before a card is stored. */
|
|
183
|
+
readonly exists?: (userID: string, sessionID: string) => Promise<boolean>
|
|
184
|
+
/** The live title, re-checked right before an auto name. */
|
|
185
|
+
readonly title?: (userID: string, sessionID: string) => string | undefined
|
|
186
|
+
/** When the user last touched the (root) session, persisted (the automatic cards' gate); in memory without it. */
|
|
187
|
+
readonly lastHumanAt?: (userID: string, sessionID: string) => Promise<number | undefined> | number | undefined
|
|
188
|
+
readonly users?: () => Promise<Iterable<string>>
|
|
189
|
+
readonly now?: () => number
|
|
190
|
+
readonly timers?: Timers
|
|
191
|
+
readonly debounceMs?: number
|
|
192
|
+
readonly log?: (message: string) => void
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export class ContextCardService {
|
|
196
|
+
readonly registry = new CardRegistry()
|
|
197
|
+
readonly store: ContextCardStore
|
|
198
|
+
readonly budget: DailyBudget
|
|
199
|
+
readonly engine: ContextCardEngine
|
|
200
|
+
readonly activity: HumanActivity
|
|
201
|
+
readonly settings: CardSettings
|
|
202
|
+
private readonly now: () => number
|
|
203
|
+
private readonly timers: Timers
|
|
204
|
+
private timer: unknown
|
|
205
|
+
private lastPrune = 0
|
|
206
|
+
private client: { key: string; baseURL: string; chat: ChatClient } | undefined
|
|
207
|
+
|
|
208
|
+
constructor(private readonly deps: ContextCardServiceDeps) {
|
|
209
|
+
this.now = deps.now ?? Date.now
|
|
210
|
+
this.timers = deps.timers ?? realTimers
|
|
211
|
+
this.registry.register(pickupProvider())
|
|
212
|
+
this.registry.register(changesProvider())
|
|
213
|
+
this.store = new ContextCardStore({ state: deps.state })
|
|
214
|
+
this.budget = new DailyBudget({ state: deps.state, now: this.now, limits: { ...DEFAULT_BUDGET, ...deps.config.budget } })
|
|
215
|
+
this.activity = new HumanActivity({ now: this.now })
|
|
216
|
+
this.settings = new CardSettings({ state: deps.state })
|
|
217
|
+
const lastHumanAt = deps.lastHumanAt
|
|
218
|
+
this.engine = new ContextCardEngine({
|
|
219
|
+
registry: this.registry,
|
|
220
|
+
store: this.store,
|
|
221
|
+
budget: this.budget,
|
|
222
|
+
inputs: (userID, sessionID, needs) => gatherInputs(deps.inputs, userID, sessionID, needs),
|
|
223
|
+
humanActive: lastHumanAt
|
|
224
|
+
? async (userID, sessionID) => this.now() - ((await lastHumanAt(userID, sessionID)) ?? Number.NEGATIVE_INFINITY) <= HUMAN_ACTIVE_MS
|
|
225
|
+
: (userID, sessionID) => this.activity.active(userID, sessionID),
|
|
226
|
+
enabled: (userID) => this.enabled(userID),
|
|
227
|
+
model: () => this.model(),
|
|
228
|
+
isLeader: deps.isLeader,
|
|
229
|
+
onCard: deps.onCard,
|
|
230
|
+
...(deps.autoName ? { autoName: deps.autoName } : {}),
|
|
231
|
+
...(deps.exists ? { exists: deps.exists } : {}),
|
|
232
|
+
...(deps.title ? { title: deps.title } : {}),
|
|
233
|
+
recent: () => this.activity.recent(),
|
|
234
|
+
now: this.now,
|
|
235
|
+
timers: this.timers,
|
|
236
|
+
...(deps.debounceMs !== undefined ? { debounceMs: deps.debounceMs } : {}),
|
|
237
|
+
...(deps.log ? { log: deps.log } : {}),
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The hub-wide kill switch (HUB_CONTEXT_CARDS=off, contextCards.enabled false). */
|
|
242
|
+
available(): boolean {
|
|
243
|
+
return this.deps.config.enabled !== false
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private resolved() {
|
|
247
|
+
return resolveCardModel(this.deps.config, this.deps.keySources())
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The model, when one is configured (an injected client counts). */
|
|
251
|
+
model(): CardModel | undefined {
|
|
252
|
+
if (!this.available()) return undefined
|
|
253
|
+
const resolved = this.resolved()
|
|
254
|
+
if (this.deps.chat) return { client: this.deps.chat, model: resolved?.model ?? "test" }
|
|
255
|
+
if (!resolved) return undefined
|
|
256
|
+
if (this.client?.key !== resolved.apiKey || this.client.baseURL !== resolved.baseURL) {
|
|
257
|
+
const apiKey = resolved.apiKey
|
|
258
|
+
this.client = { key: apiKey, baseURL: resolved.baseURL, chat: new OpenAICompatibleChat({ baseURL: resolved.baseURL, apiKey: async () => apiKey, ...(this.deps.fetch ? { fetch: this.deps.fetch } : {}) }) }
|
|
259
|
+
}
|
|
260
|
+
return { client: this.client.chat, model: resolved.model }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
configured(): boolean {
|
|
264
|
+
return this.model() !== undefined
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Cards on for a user: the hub allows them and the user opted in (off by default, model or not). */
|
|
268
|
+
async enabled(userID: string): Promise<boolean> {
|
|
269
|
+
return this.available() && (await this.settings.optedIn(userID))
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** An automatic trigger: run here on the leader, else forwarded to it. */
|
|
273
|
+
trigger(userID: string, sessionID: string, trigger: AutoTrigger): void {
|
|
274
|
+
if (this.deps.isLeader()) this.engine.trigger(userID, sessionID, trigger)
|
|
275
|
+
else this.deps.forward?.(userID, sessionID, trigger, this.activity.lastAt(userID, sessionID))
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** A forwarded trigger, on the leader. */
|
|
279
|
+
forwarded(userID: string, sessionID: string, trigger: AutoTrigger, humanAt?: number): void {
|
|
280
|
+
if (humanAt !== undefined) this.activity.note(userID, sessionID, humanAt)
|
|
281
|
+
this.engine.trigger(userID, sessionID, trigger)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** A journal append (the AgentEvents onAppend path). Never throws. */
|
|
285
|
+
onEvent(userID: string, event: Pick<AgentEvent, "type" | "subject" | "data">): void {
|
|
286
|
+
const found = cardTriggerOf(event)
|
|
287
|
+
if (found) this.trigger(userID, found.sessionID, found.trigger)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** The user acted in a session (a human turn, a read mark): after a gap, its user-return cards run. */
|
|
291
|
+
human(userID: string, sessionID: string): void {
|
|
292
|
+
if (this.activity.touch(userID, sessionID).returned) this.trigger(userID, sessionID, "user-return")
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async forget(userID: string, sessionID: string): Promise<void> {
|
|
296
|
+
this.activity.forget(userID, sessionID)
|
|
297
|
+
await this.engine.forget(userID, sessionID)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async view(userID: string, sessionID: string): Promise<ContextCardsView> {
|
|
301
|
+
const enabled = await this.enabled(userID)
|
|
302
|
+
return { cards: enabled ? await this.store.list(userID, sessionID) : [], providers: this.registry.info(), enabled, configured: this.configured() }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** A session's cards, none while cards are off for the user (kill switch or their setting): every read goes here. */
|
|
306
|
+
async list(userID: string, sessionID: string): Promise<ContextCard[]> {
|
|
307
|
+
return (await this.enabled(userID)) ? this.store.list(userID, sessionID) : []
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** A manual refresh: deduped, rate limited, unchanged inputs skipped unless forced. */
|
|
311
|
+
refresh(userID: string, sessionID: string, provider: string, options: { readonly force?: boolean } = {}): Promise<RunOutcome> {
|
|
312
|
+
return this.engine.refresh(userID, sessionID, provider, options)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** The primary card of each session (the list's second line, the Concierge board); none while cards are off. */
|
|
316
|
+
async primaries(userID: string, sessionIDs: Iterable<string>): Promise<Map<string, ContextCard>> {
|
|
317
|
+
return (await this.enabled(userID)) ? this.store.primaries(userID, sessionIDs) : new Map()
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async settingsView(userID: string): Promise<ContextCardSettingsView> {
|
|
321
|
+
const resolved = this.available() ? this.resolved() : undefined
|
|
322
|
+
return {
|
|
323
|
+
enabled: await this.enabled(userID),
|
|
324
|
+
available: this.available(),
|
|
325
|
+
configured: this.configured(),
|
|
326
|
+
...(resolved ? { model: { provider: resolved.provider, model: resolved.model, keySource: resolved.keySource } } : {}),
|
|
327
|
+
usage: await this.budget.usage(userID),
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Turning cards off deletes the user's stored cards (privacy): turning them on again starts fresh. */
|
|
332
|
+
async setEnabled(userID: string, enabled: boolean): Promise<ContextCardSettingsView> {
|
|
333
|
+
await this.settings.set(userID, enabled)
|
|
334
|
+
if (!enabled) await this.store.clear(userID)
|
|
335
|
+
return this.settingsView(userID)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** The leader's loop: the sweep every SWEEP_MS, retention every PRUNE_EVERY_MS. */
|
|
339
|
+
start(): void {
|
|
340
|
+
if (this.timer !== undefined) return
|
|
341
|
+
this.timer = this.timers.set(() => {
|
|
342
|
+
this.timer = undefined
|
|
343
|
+
void this.tick().finally(() => this.start())
|
|
344
|
+
}, SWEEP_MS)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
stop(): void {
|
|
348
|
+
if (this.timer !== undefined) this.timers.clear(this.timer)
|
|
349
|
+
this.timer = undefined
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** One sweep (and retention when due). */
|
|
353
|
+
async tick(): Promise<void> {
|
|
354
|
+
this.engine.sweep()
|
|
355
|
+
if (this.now() - this.lastPrune < PRUNE_EVERY_MS || !this.deps.users) return
|
|
356
|
+
this.lastPrune = this.now()
|
|
357
|
+
try {
|
|
358
|
+
for (const userID of await this.deps.users()) await this.store.prune(userID, this.now() - CARD_RETENTION_MS)
|
|
359
|
+
} catch (cause) {
|
|
360
|
+
;(this.deps.log ?? console.error)(`[hub] context card retention failed: ${(cause as Error).message}`)
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
settled(): Promise<void> {
|
|
365
|
+
return this.engine.settled()
|
|
366
|
+
}
|
|
367
|
+
}
|