@huynle/hub 0.9.2 → 0.10.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 +320 -0
- package/Dockerfile +5 -1
- package/README.md +154 -77
- package/package.json +7 -4
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +534 -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 +81 -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 +547 -58
- 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 +227 -237
- 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-CsPjam8D.js} +1 -1
- package/web/dist/assets/{ArtifactPane-CKOiAUNH.js → ArtifactPane-B9weO8L6.js} +1 -1
- package/web/dist/assets/ArtifactWorkbench-DzKhMs3l.js +6 -0
- package/web/dist/assets/{Diagram-vdpmp3xu.css → Diagram-7y8kg3og.css} +1 -1
- package/web/dist/assets/Diagram-CuRIffka.js +1 -0
- package/web/dist/assets/{Library-CcjZXsfF.js → Library-rv5DGtrm.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-DUuQ9jmd.js +1 -0
- package/web/dist/assets/{TeamsPage-CDyjrK-I.js → TeamsPage-D_17MkvN.js} +1 -1
- package/web/dist/assets/{Watchtower-DYwoUWME.js → Watchtower-DlrNOJ7X.js} +2 -2
- package/web/dist/assets/{artifacts-DU9uRgs1.js → artifacts-But87Jfo.js} +1 -1
- package/web/dist/assets/index-bX8B6NLo.js +33 -0
- package/web/dist/assets/index-rW6LKQj1.css +1 -0
- package/web/dist/assets/{live-ycZ_aQZO.js → live-BGLA-peO.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-DyANQ1WS.js} +1 -1
- package/web/dist/assets/{renderer3d-C1a7eYGQ.js → renderer3d-vYYKFAJq.js} +1 -1
- package/web/dist/assets/{safe-area-DHhPy61Q.js → safe-area-B2Bk__FK.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,145 @@
|
|
|
1
|
+
// Context cards configuration (docs/context-cards.md "Configuration"):
|
|
2
|
+
//
|
|
3
|
+
// hub.json "contextCards": {
|
|
4
|
+
// "enabled": true, // false (or env HUB_CONTEXT_CARDS=off): the hub-wide kill switch
|
|
5
|
+
// "model": { // the light model the hub calls (all optional)
|
|
6
|
+
// "provider": "openrouter", // or "openai-compatible" (needs baseURL)
|
|
7
|
+
// "model": "anthropic/claude-haiku-4.5", // default on OpenRouter
|
|
8
|
+
// "baseURL": "https://…/v1", // https, or http on a private host
|
|
9
|
+
// "apiKey": "…" // else OpenRouter reuses the speech OpenRouter key
|
|
10
|
+
// },
|
|
11
|
+
// "budget": { "calls": 200, "tokens": 400000 } // per user per UTC day
|
|
12
|
+
// }
|
|
13
|
+
//
|
|
14
|
+
// The speech key is reused only for OpenRouter at OpenRouter's own base URL (a key never follows the settings to
|
|
15
|
+
// another endpoint, as in speech/settings.ts). A file other users can read has its apiKey ignored (as speech's).
|
|
16
|
+
import { OPENROUTER_BASE_URL } from "../speech/openrouter"
|
|
17
|
+
import { isPrivateHost, type StoredSpeech } from "../speech/settings"
|
|
18
|
+
|
|
19
|
+
export const CARD_MODEL_DEFAULT = "anthropic/claude-haiku-4.5"
|
|
20
|
+
export const CARD_PROVIDERS = ["openrouter", "openai-compatible"] as const
|
|
21
|
+
export type CardProviderName = (typeof CARD_PROVIDERS)[number]
|
|
22
|
+
|
|
23
|
+
export interface ContextCardsConfig {
|
|
24
|
+
readonly enabled?: boolean
|
|
25
|
+
readonly model?: {
|
|
26
|
+
readonly provider?: CardProviderName
|
|
27
|
+
readonly model?: string
|
|
28
|
+
readonly baseURL?: string
|
|
29
|
+
readonly apiKey?: string
|
|
30
|
+
}
|
|
31
|
+
readonly budget?: { readonly calls?: number; readonly tokens?: number }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The model the hub calls, with its key (never sent to clients). */
|
|
35
|
+
export interface ResolvedCardModel {
|
|
36
|
+
readonly provider: CardProviderName
|
|
37
|
+
readonly model: string
|
|
38
|
+
readonly baseURL: string
|
|
39
|
+
readonly apiKey: string
|
|
40
|
+
/** config: contextCards.model.apiKey; speech: the speech OpenRouter key (saved, hub.json or env). */
|
|
41
|
+
readonly keySource: "config" | "speech"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const KEYS = new Set(["enabled", "model", "budget"])
|
|
45
|
+
const MODEL_KEYS = new Set(["provider", "model", "baseURL", "apiKey"])
|
|
46
|
+
const BUDGET_KEYS = new Set(["calls", "tokens"])
|
|
47
|
+
const MAX_CHARS = 500
|
|
48
|
+
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
49
|
+
|
|
50
|
+
/** https, or http on a private host; no user info. */
|
|
51
|
+
function validBaseURL(raw: string): boolean {
|
|
52
|
+
try {
|
|
53
|
+
const url = new URL(raw)
|
|
54
|
+
if (url.username || url.password) return false
|
|
55
|
+
return url.protocol === "https:" || (url.protocol === "http:" && isPrivateHost(url.hostname))
|
|
56
|
+
} catch {
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const text = (value: unknown, name: string, fail: (message: string) => never): string => {
|
|
62
|
+
if (typeof value !== "string" || !value.trim() || value.length > MAX_CHARS) fail(`contextCards.${name} must be a non-empty string`)
|
|
63
|
+
return (value as string).trim()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** hub.json `contextCards`, validated (fail throws); `shared`: the file is readable by others (its key is dropped). */
|
|
67
|
+
export function readContextCards(raw: unknown, file: string, warnings: string[], fail: (message: string) => never, shared: boolean): ContextCardsConfig {
|
|
68
|
+
if (!isObj(raw)) fail("contextCards must be an object")
|
|
69
|
+
const input = raw as Record<string, unknown>
|
|
70
|
+
for (const key of Object.keys(input)) if (!KEYS.has(key)) warnings.push(`${file}: unknown key "contextCards.${key}" ignored`)
|
|
71
|
+
const out: { -readonly [K in keyof ContextCardsConfig]: ContextCardsConfig[K] } = {}
|
|
72
|
+
if (input.enabled !== undefined) {
|
|
73
|
+
if (typeof input.enabled !== "boolean") fail("contextCards.enabled must be true or false")
|
|
74
|
+
out.enabled = input.enabled as boolean
|
|
75
|
+
}
|
|
76
|
+
if (input.model !== undefined) {
|
|
77
|
+
if (!isObj(input.model)) fail("contextCards.model must be an object")
|
|
78
|
+
const m = input.model as Record<string, unknown>
|
|
79
|
+
for (const key of Object.keys(m)) if (!MODEL_KEYS.has(key)) warnings.push(`${file}: unknown key "contextCards.model.${key}" ignored`)
|
|
80
|
+
const model: { provider?: CardProviderName; model?: string; baseURL?: string; apiKey?: string } = {}
|
|
81
|
+
if (m.provider !== undefined) {
|
|
82
|
+
if (!CARD_PROVIDERS.includes(m.provider as CardProviderName)) fail(`contextCards.model.provider must be one of ${CARD_PROVIDERS.join(", ")}`)
|
|
83
|
+
model.provider = m.provider as CardProviderName
|
|
84
|
+
}
|
|
85
|
+
if (m.model !== undefined) model.model = text(m.model, "model.model", fail)
|
|
86
|
+
if (m.baseURL !== undefined) {
|
|
87
|
+
const url = text(m.baseURL, "model.baseURL", fail).replace(/\/+$/, "")
|
|
88
|
+
if (!validBaseURL(url)) fail("contextCards.model.baseURL must be https (or http on a private host)")
|
|
89
|
+
model.baseURL = url
|
|
90
|
+
}
|
|
91
|
+
if (m.apiKey !== undefined) {
|
|
92
|
+
const key = text(m.apiKey, "model.apiKey", fail)
|
|
93
|
+
if (shared) warnings.push(`${file} is readable by other users, so its contextCards.model.apiKey is ignored. Fix it with: chmod 600 ${file}`)
|
|
94
|
+
else model.apiKey = key
|
|
95
|
+
}
|
|
96
|
+
out.model = model
|
|
97
|
+
}
|
|
98
|
+
if (input.budget !== undefined) {
|
|
99
|
+
if (!isObj(input.budget)) fail("contextCards.budget must be an object")
|
|
100
|
+
const b = input.budget as Record<string, unknown>
|
|
101
|
+
const budget: { calls?: number; tokens?: number } = {}
|
|
102
|
+
for (const key of Object.keys(b)) {
|
|
103
|
+
if (!BUDGET_KEYS.has(key)) {
|
|
104
|
+
warnings.push(`${file}: unknown key "contextCards.budget.${key}" ignored`)
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
const n = b[key]
|
|
108
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n < 0) fail(`contextCards.budget.${key} must be a whole number ≥ 0`)
|
|
109
|
+
budget[key as "calls" | "tokens"] = n as number
|
|
110
|
+
}
|
|
111
|
+
out.budget = budget
|
|
112
|
+
}
|
|
113
|
+
return out
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface KeySources {
|
|
117
|
+
/** OPENROUTER_API_KEY. */
|
|
118
|
+
readonly envKey?: string
|
|
119
|
+
/** Speech settings layers, highest first (the saved server default, then hub.json's). */
|
|
120
|
+
readonly speech?: readonly StoredSpeech[]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The speech settings' OpenRouter key (highest layer first), else the env's. */
|
|
124
|
+
function speechKey(sources: KeySources): string | undefined {
|
|
125
|
+
for (const layer of sources.speech ?? []) {
|
|
126
|
+
for (const direction of [layer.stt, layer.tts]) {
|
|
127
|
+
if (direction?.apiKey && (direction.provider ?? "openrouter") === "openrouter" && (direction.baseURL === undefined || direction.baseURL.replace(/\/+$/, "") === OPENROUTER_BASE_URL)) return direction.apiKey
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return sources.envKey || undefined
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The configured model, or undefined (off, or no usable key / base URL). */
|
|
134
|
+
export function resolveCardModel(config: ContextCardsConfig, sources: KeySources): ResolvedCardModel | undefined {
|
|
135
|
+
if (config.enabled === false) return undefined
|
|
136
|
+
const provider = config.model?.provider ?? "openrouter"
|
|
137
|
+
const openrouter = provider === "openrouter"
|
|
138
|
+
const model = config.model?.model ?? (openrouter ? CARD_MODEL_DEFAULT : undefined)
|
|
139
|
+
const baseURL = config.model?.baseURL ?? (openrouter ? OPENROUTER_BASE_URL : undefined)
|
|
140
|
+
if (!model || !baseURL) return undefined
|
|
141
|
+
if (config.model?.apiKey) return { provider, model, baseURL, apiKey: config.model.apiKey, keySource: "config" }
|
|
142
|
+
if (!openrouter || baseURL !== OPENROUTER_BASE_URL) return undefined
|
|
143
|
+
const apiKey = speechKey(sources)
|
|
144
|
+
return apiKey ? { provider, model, baseURL, apiKey, keySource: "speech" } : undefined
|
|
145
|
+
}
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
// The context card engine (decision 9604b0a1; docs/context-cards.md). Triggers come in per user and session
|
|
2
|
+
// (turn-end, blocked, user-return from http.ts's wiring; sweep from the leader's timer; manual from a refresh) and
|
|
3
|
+
// run each provider that lists the trigger, through these gates, in order:
|
|
4
|
+
//
|
|
5
|
+
// unknown provider → skipped "unknown"
|
|
6
|
+
// the kill switch / the user's setting (enabled) → "disabled"
|
|
7
|
+
// automatic only: recent human activity in the session (humanActive, injectable) → "inactive"
|
|
8
|
+
// automatic only: at most one run per provider and session per minIntervalMs → "throttled" (the last trigger is
|
|
9
|
+
// kept and runs once the interval is over)
|
|
10
|
+
// model providers: a model configured → "no-model"; then the inputs are gathered (only the provider's needs)
|
|
11
|
+
// automatic only: inputs (and provider version) unchanged since the stored card → "unchanged"
|
|
12
|
+
// model providers: the user's failure backoff → "backoff"; the hub-wide circuit breaker → "circuit"
|
|
13
|
+
// not forced: strictly weaker inputs than the stored card's (e.g. an offline spoke: no transcript) → "weaker"
|
|
14
|
+
// model providers: each call RESERVES one call in the user's daily budget first (atomic) → "budget"; the tokens it
|
|
15
|
+
// reports are added after it, also when it failed (a failed or timed-out call still counts)
|
|
16
|
+
// generate() → bounded (registry.boundCard) → the session still exists (exists) → "gone" → stored
|
|
17
|
+
// (ContextCardStore) → onCard (SSE + journal)
|
|
18
|
+
//
|
|
19
|
+
// Manual refreshes (refresh()): one run per card at a time (concurrent requests share it), at most one per card per
|
|
20
|
+
// REFRESH_CARD_MS and REFRESH_USER_MAX per user per minute ("rate_limited" with retryAfterMs), and unchanged inputs
|
|
21
|
+
// are skipped unless forced. run(…, "manual") is the forced, unlimited form (tests, internal).
|
|
22
|
+
//
|
|
23
|
+
// Automatic triggers are debounced (debounceMs, the latest wins) and only run where isLeader() holds (one pod
|
|
24
|
+
// generates: http.ts elects "context-cards"). A manual refresh runs at once on the pod that took the request. A run
|
|
25
|
+
// never throws: failures are a `failed` outcome (logged without text). The primary provider's run on a session
|
|
26
|
+
// whose title is a placeholder (agent/names.ts) asks the existing autoName op, at most every AUTO_NAME_EVERY_MS
|
|
27
|
+
// per session; real titles are never touched.
|
|
28
|
+
import { createHash } from "node:crypto"
|
|
29
|
+
import type { ContextCard, ContextCardTrigger } from "../../protocol"
|
|
30
|
+
import { realTimers } from "../backend/memory-bus"
|
|
31
|
+
import type { Timers } from "../backend/types"
|
|
32
|
+
import { isPlaceholderTitle } from "../agent/names"
|
|
33
|
+
import { ChatError, type ChatClient } from "../llm/chat"
|
|
34
|
+
import type { CardBudget } from "./budget"
|
|
35
|
+
import { boundCard, type CardRegistry } from "./registry"
|
|
36
|
+
import type { ContextCardStore } from "./store"
|
|
37
|
+
import type { CardChat, CardInputs, ContextCardProvider, InputNeed } from "./types"
|
|
38
|
+
|
|
39
|
+
export const DEBOUNCE_MS = 15_000
|
|
40
|
+
/** Manual refreshes: at most one per card this often, and this many per user per minute. */
|
|
41
|
+
export const REFRESH_CARD_MS = 30_000
|
|
42
|
+
export const REFRESH_USER_MAX = 10
|
|
43
|
+
/** Consecutive model failures before a user backs off (BACKOFF_BASE_MS doubling, at most BACKOFF_MAX_MS). */
|
|
44
|
+
export const BACKOFF_AFTER = 3
|
|
45
|
+
export const BACKOFF_BASE_MS = 60_000
|
|
46
|
+
export const BACKOFF_MAX_MS = 3_600_000
|
|
47
|
+
/**
|
|
48
|
+
* The hub-wide breaker opens when PROVIDER-level errors (ChatError kind "provider": 401/403/408/429/5xx, network)
|
|
49
|
+
* came from at least CIRCUIT_USERS distinct users within CIRCUIT_WINDOW_MS, and pauses every model call for
|
|
50
|
+
* CIRCUIT_MS. One user can never open it alone, and content errors never count.
|
|
51
|
+
*/
|
|
52
|
+
export const CIRCUIT_USERS = 3
|
|
53
|
+
export const CIRCUIT_WINDOW_MS = 5 * 60_000
|
|
54
|
+
export const CIRCUIT_MS = 5 * 60_000
|
|
55
|
+
|
|
56
|
+
/** The budget refused a model call (the run is skipped "budget"). */
|
|
57
|
+
class BudgetRefused extends Error {}
|
|
58
|
+
/** A placeholder-titled session's auto name is asked at most this often. */
|
|
59
|
+
export const AUTO_NAME_EVERY_MS = 30 * 60_000
|
|
60
|
+
/** In-memory throttle entries kept (oldest dropped first). */
|
|
61
|
+
const TRACKED = 10_000
|
|
62
|
+
|
|
63
|
+
export interface CardModel {
|
|
64
|
+
readonly client: ChatClient
|
|
65
|
+
readonly model: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface EngineDeps {
|
|
69
|
+
readonly registry: CardRegistry
|
|
70
|
+
readonly store: ContextCardStore
|
|
71
|
+
readonly budget: CardBudget
|
|
72
|
+
/** Gathers the inputs a provider needs (./inputs.ts). */
|
|
73
|
+
readonly inputs: (userID: string, sessionID: string, needs: readonly InputNeed[]) => Promise<CardInputs>
|
|
74
|
+
/**
|
|
75
|
+
* Whether the user was recently active in the session (automatic triggers need it): the persisted per-user
|
|
76
|
+
* activity (src/central/human-activity.ts) in the hub, an in-memory map without it.
|
|
77
|
+
*/
|
|
78
|
+
readonly humanActive: (userID: string, sessionID: string) => boolean | Promise<boolean>
|
|
79
|
+
/** The hub's kill switch and the user's setting. */
|
|
80
|
+
readonly enabled: (userID: string) => Promise<boolean>
|
|
81
|
+
/** The configured model, else undefined (model providers are skipped). */
|
|
82
|
+
readonly model: () => CardModel | undefined
|
|
83
|
+
/** Whether this pod generates automatic cards (the "context-cards" leader). */
|
|
84
|
+
readonly isLeader: () => boolean
|
|
85
|
+
readonly onCard: (userID: string, card: ContextCard) => void
|
|
86
|
+
/** The autoName op for a placeholder-titled session (best effort). */
|
|
87
|
+
readonly autoName?: (userID: string, sessionID: string) => Promise<void>
|
|
88
|
+
/** Whether the session still exists right before a card is stored (a deleted session gets none). */
|
|
89
|
+
readonly exists?: (userID: string, sessionID: string) => Promise<boolean>
|
|
90
|
+
/** The session's live title, re-checked right before an auto name (a title the user just set wins). */
|
|
91
|
+
readonly title?: (userID: string, sessionID: string) => string | undefined
|
|
92
|
+
/** Sessions the sweep looks at (recently active ones). */
|
|
93
|
+
readonly recent?: () => Iterable<{ readonly userID: string; readonly sessionID: string }>
|
|
94
|
+
readonly now?: () => number
|
|
95
|
+
readonly timers?: Timers
|
|
96
|
+
readonly debounceMs?: number
|
|
97
|
+
readonly log?: (message: string) => void
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type SkipReason = "unknown" | "disabled" | "inactive" | "throttled" | "no-model" | "unchanged" | "budget" | "empty" | "gone" | "backoff" | "circuit" | "weaker" | "rate_limited"
|
|
101
|
+
|
|
102
|
+
export type RunOutcome =
|
|
103
|
+
| { readonly status: "generated"; readonly card: ContextCard }
|
|
104
|
+
| { readonly status: "skipped"; readonly reason: SkipReason; readonly retryAfterMs?: number }
|
|
105
|
+
| { readonly status: "failed"; readonly error: string }
|
|
106
|
+
|
|
107
|
+
const keyOf = (userID: string, sessionID: string, provider: string) => `${userID}\n${sessionID}\n${provider}`
|
|
108
|
+
|
|
109
|
+
/** Hash of a provider's inputs and version (unchanged → no regeneration). */
|
|
110
|
+
export function inputsHash(provider: Pick<ContextCardProvider, "id" | "version">, inputs: CardInputs): string {
|
|
111
|
+
return createHash("sha256").update(JSON.stringify([provider.id, provider.version, inputs])).digest("hex").slice(0, 24)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The needs a run actually had (an offline spoke leaves the transcript and changes out). */
|
|
115
|
+
function basisOf(needs: readonly InputNeed[], inputs: CardInputs): InputNeed[] {
|
|
116
|
+
return needs.filter((need) => {
|
|
117
|
+
switch (need) {
|
|
118
|
+
case "status":
|
|
119
|
+
return inputs.state !== undefined
|
|
120
|
+
case "intent":
|
|
121
|
+
return !!inputs.intent
|
|
122
|
+
case "transcript":
|
|
123
|
+
return !!inputs.transcript?.length
|
|
124
|
+
case "changes":
|
|
125
|
+
return inputs.changes !== undefined
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** `now` is a strict subset of `before`. */
|
|
131
|
+
const weaker = (now: readonly string[], before: readonly string[]) => now.length < before.length && now.every((n) => before.includes(n))
|
|
132
|
+
|
|
133
|
+
/** Sets `key` as the newest entry of a bounded, insertion-ordered map. */
|
|
134
|
+
function remember<K, V>(map: Map<K, V>, key: K, value: V): void {
|
|
135
|
+
map.delete(key)
|
|
136
|
+
map.set(key, value)
|
|
137
|
+
if (map.size > TRACKED) map.delete(map.keys().next().value!)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export class ContextCardEngine {
|
|
141
|
+
private readonly now: () => number
|
|
142
|
+
private readonly timers: Timers
|
|
143
|
+
private readonly debounceMs: number
|
|
144
|
+
private readonly log: (message: string) => void
|
|
145
|
+
/** Pending automatic runs: key → timer and the latest trigger. */
|
|
146
|
+
private readonly pending = new Map<string, { handle: unknown; trigger: ContextCardTrigger }>()
|
|
147
|
+
/** key → when its last automatic-eligible run started generating. */
|
|
148
|
+
private readonly lastRun = new Map<string, number>()
|
|
149
|
+
/** `user\nsession` → when its auto name was last asked. */
|
|
150
|
+
private readonly named = new Map<string, number>()
|
|
151
|
+
/** `user\nsession` → forget() count (a run that started before a forget does not store). */
|
|
152
|
+
private readonly epochs = new Map<string, number>()
|
|
153
|
+
private readonly running = new Set<Promise<unknown>>()
|
|
154
|
+
/** Manual refreshes in flight, per card. */
|
|
155
|
+
private readonly inflight = new Map<string, Promise<RunOutcome>>()
|
|
156
|
+
/** Manual refreshes: per card the last, per user the times within the last minute. */
|
|
157
|
+
private readonly lastRefresh = new Map<string, number>()
|
|
158
|
+
private readonly userRefreshes = new Map<string, number[]>()
|
|
159
|
+
/** Model failures per user: consecutive count and the backoff's end (only that user's success clears it). */
|
|
160
|
+
private readonly failures = new Map<string, { count: number; until: number }>()
|
|
161
|
+
/** Model attempts in flight per user (a user with a pending failure gets one at a time). */
|
|
162
|
+
private readonly attempts = new Map<string, number>()
|
|
163
|
+
/** Provider-level failures per user (last time), for the hub-wide breaker; and the breaker's end. */
|
|
164
|
+
private readonly providerFailures = new Map<string, number>()
|
|
165
|
+
private circuitUntil = 0
|
|
166
|
+
|
|
167
|
+
constructor(private readonly deps: EngineDeps) {
|
|
168
|
+
this.now = deps.now ?? Date.now
|
|
169
|
+
this.timers = deps.timers ?? realTimers
|
|
170
|
+
this.debounceMs = deps.debounceMs ?? DEBOUNCE_MS
|
|
171
|
+
this.log = deps.log ?? ((message) => console.error(message))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** An automatic trigger: each provider listing it runs after the debounce (on the leader only). */
|
|
175
|
+
trigger(userID: string, sessionID: string, trigger: Exclude<ContextCardTrigger, "manual">): void {
|
|
176
|
+
if (!this.deps.isLeader()) return
|
|
177
|
+
for (const provider of this.deps.registry.list()) {
|
|
178
|
+
if (provider.triggers.includes(trigger)) this.schedule(keyOf(userID, sessionID, provider.id), userID, sessionID, provider.id, trigger, this.debounceMs)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The sweep: a `sweep` trigger for each recently active session; answers how many. */
|
|
183
|
+
sweep(): number {
|
|
184
|
+
if (!this.deps.isLeader()) return 0
|
|
185
|
+
let n = 0
|
|
186
|
+
for (const { userID, sessionID } of this.deps.recent?.() ?? []) {
|
|
187
|
+
this.trigger(userID, sessionID, "sweep")
|
|
188
|
+
n++
|
|
189
|
+
}
|
|
190
|
+
return n
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Waits for every run in flight (tests, drain). */
|
|
194
|
+
async settled(): Promise<void> {
|
|
195
|
+
while (this.running.size) await Promise.allSettled([...this.running])
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Drops a session's cards and pending runs (session deleted). */
|
|
199
|
+
async forget(userID: string, sessionID: string): Promise<void> {
|
|
200
|
+
const prefix = `${userID}\n${sessionID}\n`
|
|
201
|
+
for (const [key, entry] of [...this.pending]) {
|
|
202
|
+
if (!key.startsWith(prefix)) continue
|
|
203
|
+
this.timers.clear(entry.handle)
|
|
204
|
+
this.pending.delete(key)
|
|
205
|
+
}
|
|
206
|
+
const session = `${userID}\n${sessionID}`
|
|
207
|
+
remember(this.epochs, session, (this.epochs.get(session) ?? 0) + 1)
|
|
208
|
+
await this.deps.store.forget(userID, sessionID)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** A user's manual refresh: deduped per card, rate limited, unchanged inputs skipped unless forced. Never throws. */
|
|
212
|
+
refresh(userID: string, sessionID: string, providerID: string, options: { readonly force?: boolean } = {}): Promise<RunOutcome> {
|
|
213
|
+
const key = keyOf(userID, sessionID, providerID)
|
|
214
|
+
const shared = this.inflight.get(key)
|
|
215
|
+
if (shared) return shared
|
|
216
|
+
const now = this.now()
|
|
217
|
+
const last = this.lastRefresh.get(key)
|
|
218
|
+
if (last !== undefined && now - last < REFRESH_CARD_MS) return Promise.resolve({ status: "skipped", reason: "rate_limited", retryAfterMs: last + REFRESH_CARD_MS - now })
|
|
219
|
+
const recent = (this.userRefreshes.get(userID) ?? []).filter((at) => now - at < 60_000)
|
|
220
|
+
if (recent.length >= REFRESH_USER_MAX) return Promise.resolve({ status: "skipped", reason: "rate_limited", retryAfterMs: recent[0]! + 60_000 - now })
|
|
221
|
+
remember(this.userRefreshes, userID, [...recent, now])
|
|
222
|
+
remember(this.lastRefresh, key, now)
|
|
223
|
+
const run = this.run(userID, sessionID, providerID, "manual", { force: options.force === true }).finally(() => this.inflight.delete(key))
|
|
224
|
+
this.inflight.set(key, run)
|
|
225
|
+
return run
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Runs one provider for a session now, through the gates; never throws. A manual run is forced unless told. */
|
|
229
|
+
run(userID: string, sessionID: string, providerID: string, trigger: ContextCardTrigger, options: { readonly force?: boolean } = {}): Promise<RunOutcome> {
|
|
230
|
+
const force = trigger === "manual" && options.force !== false
|
|
231
|
+
const run = this.attempt(userID, sessionID, providerID, trigger, force).catch((cause: unknown): RunOutcome => {
|
|
232
|
+
if (cause instanceof BudgetRefused) return { status: "skipped", reason: "budget" }
|
|
233
|
+
const error = cause instanceof Error ? cause.message : String(cause)
|
|
234
|
+
this.log(`[hub] context card ${providerID} failed: ${error.slice(0, 200)}`)
|
|
235
|
+
return { status: "failed", error }
|
|
236
|
+
})
|
|
237
|
+
this.running.add(run)
|
|
238
|
+
void run.finally(() => this.running.delete(run))
|
|
239
|
+
return run
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private schedule(key: string, userID: string, sessionID: string, providerID: string, trigger: ContextCardTrigger, delayMs: number): void {
|
|
243
|
+
const existing = this.pending.get(key)
|
|
244
|
+
if (existing) {
|
|
245
|
+
existing.trigger = trigger
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
const entry = {
|
|
249
|
+
trigger,
|
|
250
|
+
handle: this.timers.set(() => {
|
|
251
|
+
this.pending.delete(key)
|
|
252
|
+
if (this.deps.isLeader()) void this.run(userID, sessionID, providerID, entry.trigger)
|
|
253
|
+
}, delayMs),
|
|
254
|
+
}
|
|
255
|
+
this.pending.set(key, entry)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private async attempt(userID: string, sessionID: string, providerID: string, trigger: ContextCardTrigger, force: boolean): Promise<RunOutcome> {
|
|
259
|
+
const provider = this.deps.registry.get(providerID)
|
|
260
|
+
if (!provider) return { status: "skipped", reason: "unknown" }
|
|
261
|
+
if (!(await this.deps.enabled(userID))) return { status: "skipped", reason: "disabled" }
|
|
262
|
+
const manual = trigger === "manual"
|
|
263
|
+
const key = keyOf(userID, sessionID, provider.id)
|
|
264
|
+
if (!manual) {
|
|
265
|
+
if (!(await this.deps.humanActive(userID, sessionID))) return { status: "skipped", reason: "inactive" }
|
|
266
|
+
const last = this.lastRun.get(key)
|
|
267
|
+
const wait = last === undefined ? 0 : last + provider.minIntervalMs - this.now()
|
|
268
|
+
if (wait > 0) {
|
|
269
|
+
this.schedule(key, userID, sessionID, provider.id, trigger, wait)
|
|
270
|
+
return { status: "skipped", reason: "throttled" }
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const model = provider.usesModel ? this.deps.model() : undefined
|
|
274
|
+
if (provider.usesModel && !model) return { status: "skipped", reason: "no-model" }
|
|
275
|
+
if (model) {
|
|
276
|
+
if (this.now() < this.circuitUntil) return { status: "skipped", reason: "circuit" }
|
|
277
|
+
if (this.now() < (this.failures.get(userID)?.until ?? 0)) return { status: "skipped", reason: "backoff" }
|
|
278
|
+
}
|
|
279
|
+
const session = `${userID}\n${sessionID}`
|
|
280
|
+
const epoch = this.epochs.get(session) ?? 0
|
|
281
|
+
const inputs = await this.deps.inputs(userID, sessionID, provider.needs)
|
|
282
|
+
const hash = inputsHash(provider, inputs)
|
|
283
|
+
const basis = basisOf(provider.needs, inputs)
|
|
284
|
+
if (!force) {
|
|
285
|
+
const stored = await this.deps.store.get(userID, sessionID, provider.id)
|
|
286
|
+
if (stored?.inputsHash === hash && stored.version === provider.version) return { status: "skipped", reason: "unchanged" }
|
|
287
|
+
if (stored?.basis && weaker(basis, stored.basis)) return { status: "skipped", reason: "weaker" }
|
|
288
|
+
}
|
|
289
|
+
if (model) {
|
|
290
|
+
// Checked and counted with no await between: concurrent attempts see each other. After a failure that is not
|
|
291
|
+
// cleared yet, a user's attempts run one at a time (a burst cannot fail many calls at once).
|
|
292
|
+
if (this.now() < this.circuitUntil) return { status: "skipped", reason: "circuit" }
|
|
293
|
+
const failing = this.failures.get(userID)
|
|
294
|
+
if (failing && (this.now() < failing.until || (this.attempts.get(userID) ?? 0) > 0)) return { status: "skipped", reason: "backoff" }
|
|
295
|
+
this.attempts.set(userID, (this.attempts.get(userID) ?? 0) + 1)
|
|
296
|
+
}
|
|
297
|
+
remember(this.lastRun, key, this.now())
|
|
298
|
+
let draft
|
|
299
|
+
try {
|
|
300
|
+
draft = await provider.generate({ userID, sessionID, trigger, inputs, now: this.now(), ...(model ? { chat: this.chat(userID, model) } : {}) })
|
|
301
|
+
} catch (cause) {
|
|
302
|
+
if (model && !(cause instanceof BudgetRefused)) this.failed(userID, cause)
|
|
303
|
+
throw cause
|
|
304
|
+
} finally {
|
|
305
|
+
if (model) {
|
|
306
|
+
const left = (this.attempts.get(userID) ?? 1) - 1
|
|
307
|
+
if (left > 0) this.attempts.set(userID, left)
|
|
308
|
+
else this.attempts.delete(userID)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (model) this.succeeded(userID)
|
|
312
|
+
const card = boundCard(provider, sessionID, draft, { generatedAt: this.now(), inputsHash: hash, trigger, basis })
|
|
313
|
+
if (!card) return { status: "skipped", reason: "empty" }
|
|
314
|
+
if ((this.epochs.get(session) ?? 0) !== epoch) return { status: "skipped", reason: "gone" }
|
|
315
|
+
if (this.deps.exists && !(await this.deps.exists(userID, sessionID))) return { status: "skipped", reason: "gone" }
|
|
316
|
+
// The user may have turned cards off during the run (privacy): store and send nothing, before and after the write.
|
|
317
|
+
if (!(await this.deps.enabled(userID))) return { status: "skipped", reason: "disabled" }
|
|
318
|
+
await this.deps.store.put(userID, card)
|
|
319
|
+
if (!(await this.deps.enabled(userID))) {
|
|
320
|
+
await this.deps.store.forget(userID, sessionID)
|
|
321
|
+
return { status: "skipped", reason: "disabled" }
|
|
322
|
+
}
|
|
323
|
+
this.deps.onCard(userID, card)
|
|
324
|
+
if (provider.primary) this.maybeName(userID, sessionID, inputs)
|
|
325
|
+
return { status: "generated", card }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** The model for one run: each call reserves its place in the user's budget first; its tokens are added after. */
|
|
329
|
+
private chat(userID: string, model: CardModel): CardChat {
|
|
330
|
+
return {
|
|
331
|
+
model: model.model,
|
|
332
|
+
complete: async (request) => {
|
|
333
|
+
if (!(await this.deps.budget.reserve(userID)).ok) throw new BudgetRefused("budget")
|
|
334
|
+
try {
|
|
335
|
+
const result = await model.client.complete({ ...request, model: model.model })
|
|
336
|
+
await this.deps.budget.settle(userID, result.usage.inputTokens + result.usage.outputTokens)
|
|
337
|
+
return result
|
|
338
|
+
} catch (cause) {
|
|
339
|
+
const usage = cause instanceof ChatError ? cause.usage : undefined
|
|
340
|
+
if (usage) await this.deps.budget.settle(userID, usage.inputTokens + usage.outputTokens).catch(() => {})
|
|
341
|
+
throw cause
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private failed(userID: string, cause: unknown): void {
|
|
348
|
+
const now = this.now()
|
|
349
|
+
const count = (this.failures.get(userID)?.count ?? 0) + 1
|
|
350
|
+
const until = count >= BACKOFF_AFTER ? now + Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** (count - BACKOFF_AFTER)) : 0
|
|
351
|
+
remember(this.failures, userID, { count, until })
|
|
352
|
+
if (!(cause instanceof ChatError) || cause.kind !== "provider") return
|
|
353
|
+
remember(this.providerFailures, userID, now)
|
|
354
|
+
for (const [user, at] of this.providerFailures) if (now - at > CIRCUIT_WINDOW_MS) this.providerFailures.delete(user)
|
|
355
|
+
if (this.providerFailures.size >= CIRCUIT_USERS) {
|
|
356
|
+
this.providerFailures.clear()
|
|
357
|
+
this.circuitUntil = now + CIRCUIT_MS
|
|
358
|
+
this.log("[hub] context cards: the model keeps failing; paused for 5 minutes")
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Clears this user's failures only; the hub's record only when this user is not among its recent failures. */
|
|
363
|
+
private succeeded(userID: string): void {
|
|
364
|
+
this.failures.delete(userID)
|
|
365
|
+
if (!this.providerFailures.has(userID)) this.providerFailures.clear()
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** A placeholder title (known from the status input): ask the autoName op, throttled per session. */
|
|
369
|
+
private maybeName(userID: string, sessionID: string, inputs: CardInputs): void {
|
|
370
|
+
if (!this.deps.autoName || !("title" in inputs) || !isPlaceholderTitle(inputs.title)) return
|
|
371
|
+
// Re-checked now: the user may have named it while the card was being made.
|
|
372
|
+
if (this.deps.title && !isPlaceholderTitle(this.deps.title(userID, sessionID))) return
|
|
373
|
+
const session = `${userID}\n${sessionID}`
|
|
374
|
+
const last = this.named.get(session)
|
|
375
|
+
if (last !== undefined && this.now() - last < AUTO_NAME_EVERY_MS) return
|
|
376
|
+
remember(this.named, session, this.now())
|
|
377
|
+
void this.deps.autoName(userID, sessionID).catch((cause: unknown) => this.log(`[hub] context card auto name failed: ${(cause as Error).message}`))
|
|
378
|
+
}
|
|
379
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// The context card inputs gatherer: only what a provider needs, each piece best effort (an offline spoke or a
|
|
2
|
+
// failed read leaves it out; the provider decides what it can do without).
|
|
3
|
+
// status: the live session (registry): title, state (the session's own status entry), open blockers' summaries
|
|
4
|
+
// intent: the session's effective intent text (CON-02)
|
|
5
|
+
// transcript: the newest timeline rows from the spoke (op "timeline", order desc): user, assistant, tool and error
|
|
6
|
+
// rows, oldest first, sanitised and cut (reasoning, retries and hub messages are left out; tool rows
|
|
7
|
+
// keep only the tool's name and state, never their input or output)
|
|
8
|
+
// changes: the spoke summary's working-tree changes and branch
|
|
9
|
+
import { redactCredentials } from "../../../packages/protocol/src/credentials.ts"
|
|
10
|
+
import { sanitizeText } from "../agent/envelope"
|
|
11
|
+
import type { CardInputs, ChangesInput, InputNeed, TranscriptRow } from "./types"
|
|
12
|
+
|
|
13
|
+
/** Timeline rows asked for. */
|
|
14
|
+
export const TRANSCRIPT_ROWS = 40
|
|
15
|
+
/** A row's text is cut to this many characters. */
|
|
16
|
+
export const ROW_TEXT_MAX = 1500
|
|
17
|
+
const BLOCKERS_MAX = 5
|
|
18
|
+
const BLOCKER_MAX = 200
|
|
19
|
+
const NAME_MAX = 60
|
|
20
|
+
|
|
21
|
+
export interface InputSession {
|
|
22
|
+
readonly title?: string
|
|
23
|
+
readonly status?: ReadonlyArray<{ readonly id: string; readonly state: string; readonly blockers?: ReadonlyArray<{ readonly summary?: string }> }>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface InputDeps {
|
|
27
|
+
readonly session: (userID: string, sessionID: string) => InputSession | undefined
|
|
28
|
+
readonly intent: (userID: string, sessionID: string) => Promise<string | undefined>
|
|
29
|
+
/** The newest timeline rows (TRANSCRIPT_ROWS). */
|
|
30
|
+
readonly timeline: (userID: string, sessionID: string) => Promise<readonly unknown[]>
|
|
31
|
+
/** The spoke's summary (GET /api/sessions/:id/summary's body). */
|
|
32
|
+
readonly summary: (userID: string, sessionID: string) => Promise<unknown>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const rec = (v: unknown): Record<string, unknown> => (typeof v === "object" && v !== null ? (v as Record<string, unknown>) : {})
|
|
36
|
+
const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : 0)
|
|
37
|
+
/** Raw text redacted at most (bounds the work on a hostile row); far beyond any cut, so redaction always runs first. */
|
|
38
|
+
const REDACT_MAX = 1024 * 1024
|
|
39
|
+
/** Sanitised and credentials redacted first, then cut: a secret straddling the limit never leaks a part. */
|
|
40
|
+
const cut = (text: string, max: number) =>
|
|
41
|
+
redactCredentials(sanitizeText(text.slice(0, REDACT_MAX))).text.replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n").trim().slice(0, max)
|
|
42
|
+
|
|
43
|
+
const KINDS: ReadonlySet<string> = new Set(["user", "assistant", "tool", "error"])
|
|
44
|
+
|
|
45
|
+
/** Timeline items → transcript rows, oldest first (by `pos` when every row has one). */
|
|
46
|
+
export function transcriptRows(items: readonly unknown[]): TranscriptRow[] {
|
|
47
|
+
const rows = items.map(rec).filter((item) => typeof item.kind === "string" && KINDS.has(item.kind))
|
|
48
|
+
if (rows.every((item) => typeof item.pos === "number")) rows.sort((a, b) => num(a.pos) - num(b.pos))
|
|
49
|
+
const out: TranscriptRow[] = []
|
|
50
|
+
for (const item of rows) {
|
|
51
|
+
const kind = item.kind as TranscriptRow["kind"]
|
|
52
|
+
if (kind === "tool") {
|
|
53
|
+
const name = typeof item.name === "string" ? cut(item.name, NAME_MAX) : ""
|
|
54
|
+
if (name) out.push({ kind, name, ...(typeof item.state === "string" ? { state: cut(item.state, 16) } : {}) })
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
const text = typeof item.text === "string" ? cut(item.text, ROW_TEXT_MAX) : ""
|
|
58
|
+
if (text) out.push({ kind, text })
|
|
59
|
+
}
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The spoke summary's changes (undefined for anything but an object). */
|
|
64
|
+
export function changesInput(summary: unknown): ChangesInput | undefined {
|
|
65
|
+
if (typeof summary !== "object" || summary === null) return undefined
|
|
66
|
+
const s = rec(summary)
|
|
67
|
+
const c = rec(s.changes)
|
|
68
|
+
const files = (Array.isArray(c.files) ? c.files : [])
|
|
69
|
+
.map(rec)
|
|
70
|
+
.filter((f) => typeof f.file === "string" && f.file)
|
|
71
|
+
.map((f) => ({ file: String(f.file), additions: num(f.additions), deletions: num(f.deletions) }))
|
|
72
|
+
return {
|
|
73
|
+
...(typeof s.branch === "string" && s.branch ? { branch: s.branch } : {}),
|
|
74
|
+
files,
|
|
75
|
+
totalFiles: num(c.totalFiles) || files.length,
|
|
76
|
+
additions: num(c.additions),
|
|
77
|
+
deletions: num(c.deletions),
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const quietly = async <T>(read: () => Promise<T>): Promise<T | undefined> => {
|
|
82
|
+
try {
|
|
83
|
+
return await read()
|
|
84
|
+
} catch {
|
|
85
|
+
return undefined
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function gatherInputs(deps: InputDeps, userID: string, sessionID: string, needs: readonly InputNeed[]): Promise<CardInputs> {
|
|
90
|
+
const want = new Set(needs)
|
|
91
|
+
const live = want.has("status") ? deps.session(userID, sessionID) : undefined
|
|
92
|
+
const own = live?.status?.find((s) => s.id === sessionID) ?? live?.status?.[0]
|
|
93
|
+
const blockers = (own?.blockers ?? [])
|
|
94
|
+
.map((b) => (typeof b.summary === "string" ? cut(b.summary, BLOCKER_MAX).replace(/\s+/g, " ") : ""))
|
|
95
|
+
.filter(Boolean)
|
|
96
|
+
.slice(0, BLOCKERS_MAX)
|
|
97
|
+
const [intent, timeline, summary] = await Promise.all([
|
|
98
|
+
want.has("intent") ? quietly(() => deps.intent(userID, sessionID)) : undefined,
|
|
99
|
+
want.has("transcript") ? quietly(() => deps.timeline(userID, sessionID)) : undefined,
|
|
100
|
+
want.has("changes") ? quietly(() => deps.summary(userID, sessionID)) : undefined,
|
|
101
|
+
])
|
|
102
|
+
const changes = summary === undefined ? undefined : changesInput(summary)
|
|
103
|
+
return {
|
|
104
|
+
sessionID,
|
|
105
|
+
...(live?.title !== undefined ? { title: live.title } : {}),
|
|
106
|
+
...(own ? { state: own.state, blockers } : {}),
|
|
107
|
+
...(intent ? { intent } : {}),
|
|
108
|
+
...(timeline ? { transcript: transcriptRows(timeline) } : {}),
|
|
109
|
+
...(changes ? { changes } : {}),
|
|
110
|
+
}
|
|
111
|
+
}
|