@jini-ai/chat 0.5.4 → 0.5.6
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 +8 -0
- package/dist/core/ag-ui/entry.d.ts +33 -0
- package/dist/core/ag-ui/entry.js +80 -0
- package/dist/core/agentic/chat-capabilities.d.ts +2 -4
- package/dist/core/agentic/index.d.ts +4 -17
- package/dist/core/agentic/index.js +4 -17
- package/dist/core/assistant-client/a2ui-action-poster.d.ts +46 -0
- package/dist/core/assistant-client/a2ui-action-poster.js +113 -0
- package/dist/core/assistant-client/ag-ui-transport.d.ts +68 -0
- package/dist/core/assistant-client/ag-ui-transport.js +147 -0
- package/dist/core/assistant-client/attachment-liveness.d.ts +37 -0
- package/dist/core/assistant-client/attachment-liveness.js +93 -0
- package/dist/core/assistant-client/durable-run-subscription.d.ts +39 -0
- package/dist/core/assistant-client/durable-run-subscription.js +96 -0
- package/dist/core/assistant-client/entry.d.ts +13 -0
- package/dist/core/assistant-client/entry.js +7 -0
- package/dist/core/assistant-client/messages.d.ts +73 -0
- package/dist/core/assistant-client/messages.js +68 -0
- package/dist/core/assistant-client/transport.d.ts +164 -0
- package/dist/core/assistant-client/transport.js +734 -0
- package/dist/core/browser/page-actions.d.ts +121 -0
- package/dist/core/browser/page-actions.js +113 -0
- package/dist/core/durable-runs/continuation.d.ts +18 -0
- package/dist/core/durable-runs/continuation.js +61 -0
- package/dist/core/durable-runs/ledger-contracts.d.ts +53 -0
- package/dist/core/durable-runs/ledger-contracts.js +2 -0
- package/dist/core/durable-runs/policies.d.ts +6 -0
- package/dist/core/durable-runs/policies.js +6 -0
- package/dist/core/durable-runs/ports.d.ts +123 -0
- package/dist/core/durable-runs/ports.js +2 -0
- package/dist/core/durable-runs/run-contracts.d.ts +17 -0
- package/dist/core/durable-runs/run-contracts.js +2 -0
- package/dist/core/durable-runs/run-lock.d.ts +4 -0
- package/dist/core/durable-runs/run-lock.js +4 -0
- package/dist/core/index.d.ts +6 -19
- package/dist/core/index.js +6 -19
- package/dist/core/messages.d.ts +2 -0
- package/dist/core/question-form/format.d.ts +0 -9
- package/dist/core/question-form/format.js +16 -2
- package/dist/core/question-form/normalize.js +1 -0
- package/dist/core/question-form/secret-answers.d.ts +38 -0
- package/dist/core/question-form/secret-answers.js +45 -0
- package/dist/core/question-form/types.d.ts +2 -0
- package/dist/core/question-form.d.ts +1 -0
- package/dist/core/question-form.js +1 -0
- package/dist/core/run-events/entry.d.ts +208 -16
- package/dist/core/run-events/entry.js +273 -39
- package/dist/core/transcript.d.ts +3 -2
- package/dist/core/transcript.js +19 -3
- package/dist/core/user-text-redaction.d.ts +56 -0
- package/dist/core/user-text-redaction.js +89 -0
- package/dist/embed-widget/browser/page-actions.d.ts +1 -120
- package/dist/embed-widget/browser/page-actions.js +2 -112
- package/dist/embed-widget/react/embed.d.ts +1 -1
- package/dist/embed-widget/react/embed.js +1 -1
- package/dist/react/artifact-types.d.ts +9 -15
- package/dist/react/components/QuestionForm.d.ts +2 -1
- package/dist/react/components/QuestionForm.js +18 -12
- package/dist/react/components/QuestionsPanel.d.ts +3 -2
- package/dist/react/components/QuestionsPanel.js +2 -2
- package/dist/react/features/chat-pane/agent-tools.d.ts +1 -1
- package/dist/react/features/chat-pane/agent-tools.js +0 -1
- package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +5 -2
- package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
- package/dist/react/features/chat-pane/components/ChatPane.js +5 -2
- package/dist/react/features/chat-pane/create-typed-answer-poster.d.ts +2 -1
- package/dist/react/features/chat-pane/create-typed-answer-poster.js +33 -1
- package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.js +3 -2
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +4 -1
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +43 -22
- package/dist/react/features/chat-pane/model-options.d.ts +12 -0
- package/dist/react/features/chat-pane/model-options.js +55 -0
- package/dist/react/features/chat-pane/rules.d.ts +3 -2
- package/dist/react/features/chat-pane/rules.js +12 -4
- package/dist/react/features/chat-pane/types.d.ts +25 -1
- package/dist/react/hooks/assistant-chats-contracts.d.ts +164 -0
- package/dist/react/hooks/assistant-chats-contracts.js +2 -0
- package/dist/react/hooks/assistant-chats.d.ts +7 -0
- package/dist/react/hooks/assistant-chats.js +960 -0
- package/dist/react/hooks/attachment-preview-cache.d.ts +1 -1
- package/dist/react/hooks/attachment-preview-cache.js +1 -1
- package/dist/react/hooks/composer-draft-cache.d.ts +2 -2
- package/dist/react/hooks/composer-draft-cache.js +2 -2
- package/dist/react/hooks/fab-position.d.ts +90 -0
- package/dist/react/hooks/fab-position.js +271 -0
- package/dist/react/hooks/useConversation.d.ts +4 -1
- package/dist/react/hooks/useConversation.js +14 -12
- package/dist/react/hooks/useUserTextGuard.d.ts +11 -0
- package/dist/react/hooks/useUserTextGuard.js +19 -0
- package/dist/react/index.d.ts +12 -21
- package/dist/react/index.js +7 -0
- package/dist/server/durable-runs/accept.d.ts +25 -0
- package/dist/server/durable-runs/accept.js +21 -0
- package/dist/server/durable-runs/entry.d.ts +5 -0
- package/dist/server/durable-runs/entry.js +4 -0
- package/dist/server/durable-runs/recover.d.ts +5 -0
- package/dist/server/durable-runs/recover.js +147 -0
- package/dist/server/run-finalizer/ports.d.ts +2 -16
- package/dist/store/legacy/sqlite/messages.d.ts +24 -18
- package/dist/store/legacy/sqlite/messages.js +12 -2
- package/dist/store/pglite/index.d.ts +2 -1
- package/dist/store/postgres/index.d.ts +2 -1
- package/dist/store/runtime/chat-expiry-sweep.d.ts +40 -0
- package/dist/store/runtime/chat-expiry-sweep.js +70 -0
- package/dist/store/runtime/chat-history-store.d.ts +20 -0
- package/dist/store/runtime/chat-history-store.js +50 -0
- package/dist/store/runtime/durable-run-store.d.ts +21 -0
- package/dist/store/runtime/durable-run-store.js +342 -0
- package/dist/store/runtime/entry.d.ts +7 -0
- package/dist/store/runtime/entry.js +6 -0
- package/dist/store/runtime/kernel.d.ts +29 -0
- package/dist/store/runtime/kernel.js +2 -0
- package/dist/store/runtime/run-guarded-chat-store.d.ts +10 -0
- package/dist/store/runtime/run-guarded-chat-store.js +42 -0
- package/dist/store/runtime/run-ledger.d.ts +15 -0
- package/dist/store/runtime/run-ledger.js +206 -0
- package/dist/store/sql/boundary.d.ts +6 -0
- package/dist/store/sql/boundary.js +7 -1
- package/dist/store/sql/store.d.ts +3 -2
- package/dist/store/sql/store.js +9 -2
- package/dist/store/sqlite/index.d.ts +3 -2
- package/package.json +75 -10
|
@@ -3,17 +3,29 @@
|
|
|
3
3
|
* compaction and paragraph boundaries so live and persisted turns agree. Notice strings, custom
|
|
4
4
|
* event names and excluded run-ID prefixes are explicit host inputs, never product wire defaults.
|
|
5
5
|
* Authenticated routing, ownership, boot repair and storage transactions remain host responsibilities. */
|
|
6
|
+
import { decodeSseFrames, parseSseRecord } from "@jini-ai/agent-runtime/providers/sse-decode";
|
|
6
7
|
import { assistantContentFromEvents } from "../assistant-content.js";
|
|
7
8
|
import { mergeAdjacentTextEvents } from "../compact-events.js";
|
|
8
9
|
function wireString(v) {
|
|
9
10
|
return typeof v === "string" ? v : v == null ? "" : JSON.stringify(v);
|
|
10
11
|
}
|
|
11
12
|
/** Display a wire scalar or JSON value using the existing empty-null convention. */
|
|
12
|
-
export function asString({ value }) {
|
|
13
|
+
export function asString({ value }, _options = {}) {
|
|
13
14
|
return wireString(value);
|
|
14
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Parses a `"usage"` wire payload into its renderable `AgentEvent`.
|
|
18
|
+
*
|
|
19
|
+
* The one case in {@link translateRunAgentPayload}'s switch with real branching: four independent
|
|
20
|
+
* optional fields, each `typeof`-checked before use (a malformed/missing field must not throw or
|
|
21
|
+
* silently coerce to `0`/`NaN`). Pulled out (2026-08-06, complexity pass, sixth pass) so those four
|
|
22
|
+
* ternaries are scored in their own scope instead of the switch's — this is the fix for the earlier
|
|
23
|
+
* `@complexityExemption`'s cognitive attribution on `translateRunAgentPayload`, which credited the
|
|
24
|
+
* `mcp-ui`/`a2ui` cases (each a one-line unwrap, zero branching — see their own comments below) for
|
|
25
|
+
* a cost that actually came from here.
|
|
26
|
+
*/
|
|
15
27
|
/** Keep only numeric usage fields; omit absent fields without changing JSON output. */
|
|
16
|
-
export function parseUsageEvent({ payload }) {
|
|
28
|
+
export function parseUsageEvent({ payload }, _options = {}) {
|
|
17
29
|
const usage = (payload.usage ?? {});
|
|
18
30
|
return {
|
|
19
31
|
kind: "usage",
|
|
@@ -23,8 +35,66 @@ export function parseUsageEvent({ payload }) {
|
|
|
23
35
|
...(typeof payload.durationMs === "number" ? { durationMs: payload.durationMs } : {}),
|
|
24
36
|
};
|
|
25
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Reduces one wire-level `RunAgentPayload` into zero or one renderable `AgentEvent`s.
|
|
40
|
+
*
|
|
41
|
+
* Exported (a pure function, so directly testable with no `EventSource`/`fetch` stub needed — see
|
|
42
|
+
* `assistant-transport.transcript.test.ts`'s own module doc for the same reasoning applied to
|
|
43
|
+
* `runPrompt`) so `assistant-transport.a2ui.test.ts` can assert the `"a2ui"` branch below in
|
|
44
|
+
* isolation.
|
|
45
|
+
*
|
|
46
|
+
* @complexityExemption (2026-08-06, complexity pass, sixth pass; bar is ≤9/≤9) **Score: 12
|
|
47
|
+
* cyclomatic / 3 cognitive. Bar: ≤9 cyclomatic AND ≤9 cognitive. Cyclomatic-only exemption —
|
|
48
|
+
* cognitive already clears the bar since {@link parseUsageEvent} above took the switch's only real
|
|
49
|
+
* branching out of this function's own scope.** (An earlier version of this comment attributed the
|
|
50
|
+
* cognitive cost to the `mcp-ui`/`a2ui` cases below; that was wrong — an independent audit measured
|
|
51
|
+
* that extracting `parseUsageEvent` alone drops cognitive from 11 to 3, which only makes sense if
|
|
52
|
+
* `usage` was the real source. `mcp-ui`/`a2ui` are one-line unwraps with zero branching, exactly as
|
|
53
|
+
* their own comments already said.) Cyclomatic stays over by construction, not by accident: this is
|
|
54
|
+
* a flat `switch` over `RunProtocolEventWire`'s closed `payload.type` vocabulary, one `case` per
|
|
55
|
+
* wire type, and cyclomatic counts every `case` as a branch regardless of shape. Tried and rejected:
|
|
56
|
+
* a `Record<string, (payload) => AgentEvent | null>` lookup table scores lower but loses two things
|
|
57
|
+
* a switch over a TS discriminated union keeps — exhaustiveness checking (a lookup table compiles
|
|
58
|
+
* with a missing key; this switch does not, once `payload.type` is narrowed to the real union rather
|
|
59
|
+
* than the wire's untyped `string`), and the ability to attach a multi-paragraph comment to one case
|
|
60
|
+
* explaining a specific interop bug (the `mcp-ui`/`a2ui` cases' comments each document why THAT case
|
|
61
|
+
* cannot fall through to `default` — see below). A lookup table would have to carry those as a
|
|
62
|
+
* parallel structure, one step removed from the code they explain. Left as a `switch`, documented
|
|
63
|
+
* here rather than only in this session's report.
|
|
64
|
+
*/
|
|
65
|
+
// Typed media (currently just images) the daemon attached alongside the flattened `content`
|
|
66
|
+
// string — see `@jini-ai/protocol`'s `events.ts` doc on why the wire field is `unknown`
|
|
67
|
+
// rather than a checked type here: the real shape is validated where `ToolCard` renders it.
|
|
68
|
+
// Forwarded verbatim rather than re-validated a second time in this reducer — an
|
|
69
|
+
// `Array.isArray` guard rather than a deep shape check, since a malformed entry inside it
|
|
70
|
+
// is a rendering concern (`ToolCard`'s own `ToolResultMedia` already ignores anything that
|
|
71
|
+
// isn't a recognized block), not a transport one.
|
|
72
|
+
// An MCP content block the daemon withheld from the tool result because it is for the HUMAN,
|
|
73
|
+
// not the model (`@jini-ai/daemon`'s `delegated-tool-bridge.ts` → `tool-result-surfaces.ts`).
|
|
74
|
+
// Explicit rather than left to `default` below because the shapes do not line up: the default
|
|
75
|
+
// passes the WHOLE wire payload as `data`, but `@jini-ai/chat`'s `McpUiSurfaceCard` runs
|
|
76
|
+
// `parseUIResource` over each event's `data` and that requires the bare `EmbeddedResource`
|
|
77
|
+
// (`{type:'resource', resource:{uri,mimeType,text}}`). Handing it the envelope instead fails
|
|
78
|
+
// the `type !== 'resource'` check and renders an empty frame — a silent no-op, which is the
|
|
79
|
+
// worst possible failure for a confirmation dialog. Unwrapping here is what makes the two ends
|
|
80
|
+
// meet. `name` must stay `"mcp-ui"` to match `MCP_UI_EXT_EVENT_NAME`.
|
|
81
|
+
// A2UI's own agent->renderer envelope (`@jini-ai/core`'s `SurfaceEmission` with
|
|
82
|
+
// `channel: "a2ui"`, injected by `@jini-ai/daemon`'s `delegated-tool-bridge.ts` as
|
|
83
|
+
// `{type: "a2ui", message: <AgentToRendererMessage>}`). Unwrapped to the bare `.message` here,
|
|
84
|
+
// not left to the `default` branch below, for the same reason `mcp-ui` above is explicit:
|
|
85
|
+
// `@jini-ai/chat/react`'s `A2uiSurfaceCard` (registered against `'a2ui'` in
|
|
86
|
+
// `AssistantDock.tsx`) runs `extractSurfaceId`/`interpreter.applyAgentMessage` over each event's
|
|
87
|
+
// `data` directly, and both require a bare, spec-shaped envelope — not the `{type, message}`
|
|
88
|
+
// wire wrapper. Mirrors Jini's own reference host's identical `case "a2ui"` in
|
|
89
|
+
// `examples/reference-web/src/daemon-transport.ts`.
|
|
90
|
+
// thinking_start/stage_start/stage_end/surface_request/surface_response: no dedicated
|
|
91
|
+
// chat-core variant. Routed through the `ext` escape hatch rather than dropped, so a future
|
|
92
|
+
// renderer can opt in without a transport change.
|
|
93
|
+
// Streamed tool input (with `--include-partial-messages`): the finished `tool_use` carries the
|
|
94
|
+
// same input whole, and nothing renders the fragments. Kept, they became one no-op `ext` event
|
|
95
|
+
// per chunk in the saved row.
|
|
26
96
|
/** Reduce one daemon payload; preserve UI envelopes and drop duplicate input fragments. */
|
|
27
|
-
export function translateRunAgentPayload({ payload }) {
|
|
97
|
+
export function translateRunAgentPayload({ payload }, _options = {}) {
|
|
28
98
|
switch (payload.type) {
|
|
29
99
|
case "status":
|
|
30
100
|
return { kind: "status", label: wireString(payload.label), ...(payload.detail ? { detail: wireString(payload.detail) } : {}) };
|
|
@@ -58,15 +128,51 @@ export function translateRunAgentPayload({ payload }) {
|
|
|
58
128
|
return { kind: "ext", name: payload.type, data: payload };
|
|
59
129
|
}
|
|
60
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* Turns a terminal stream `reason` into the one renderable event a human needs to see, or `null`
|
|
133
|
+
* when the reason speaks for itself.
|
|
134
|
+
*
|
|
135
|
+
* Only `max_tool_turns` qualifies today, and it qualifies for a specific reason: it is the one
|
|
136
|
+
* terminal reason that is INDISTINGUISHABLE from success in the pane. `stop`/`end_turn` mean the
|
|
137
|
+
* assistant finished; an `error` reason already renders as an error. A turn that hit the tool-step
|
|
138
|
+
* ceiling just stops — mid-task, with whatever partial text it had, and nothing on screen saying
|
|
139
|
+
* the work was cut short rather than completed. That is the failure this exists to close: the
|
|
140
|
+
* server now reports the loop's real reason (`byok-provider-turn.ts`'s `normalizeTurnResult`
|
|
141
|
+
* captures it instead of echoing the provider's last raw stop code), and until this, the browser
|
|
142
|
+
* received that reason and dropped it.
|
|
143
|
+
*
|
|
144
|
+
* Rendered as a `status` event rather than an `error`, deliberately: nothing failed. The turn did
|
|
145
|
+
* real work and stopped at a budget, and the useful next action is "ask it to continue", which is
|
|
146
|
+
* what the detail says.
|
|
147
|
+
*/
|
|
61
148
|
/** A host notice for a run that used its tool-step budget. */
|
|
62
|
-
export function terminalReasonNotice({ reason, notice }) {
|
|
149
|
+
export function terminalReasonNotice({ reason, notice }, _options = {}) {
|
|
63
150
|
return reason === "max_tool_turns" ? { ...notice } : null;
|
|
64
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* Reads that classification off a raw `end` frame, or `null` when the run succeeded, sent nothing,
|
|
154
|
+
* or sent something unparseable.
|
|
155
|
+
*
|
|
156
|
+
* `code`/`signal`/`resumable` come back pre-rendered as display strings rather than as their wire
|
|
157
|
+
* types, because both callers ({@link terminalOutcomeNotice} and {@link terminalFailureError}) want
|
|
158
|
+
* the same human-facing rendering of an absent value (`"none"`/`"no"`) and neither does arithmetic
|
|
159
|
+
* on them. Rendering once, here, is what stops the operator-facing notice and the persisted error
|
|
160
|
+
* from describing the same dead run in two different ways.
|
|
161
|
+
*
|
|
162
|
+
* Every field is `typeof`-checked before use, and a malformed body returns `null` rather than
|
|
163
|
+
* throwing: this runs inside an `EventSource` listener whose other job is to END the run, and a
|
|
164
|
+
* throw there would strand the pane mid-turn over a cosmetic detail.
|
|
165
|
+
*/
|
|
166
|
+
/**
|
|
167
|
+
* Whether a failed run never started a process: no exit code, no signal, not resumable. That is the
|
|
168
|
+
* exact end the daemon's `failBeforeSpawn` and the host's own start refusals write, and a process
|
|
169
|
+
* that did run always exits with a code or a signal.
|
|
170
|
+
*/
|
|
65
171
|
function neverStarted(outcome) {
|
|
66
172
|
return outcome.status === "failed" && outcome.code === "none" && outcome.signal === "none" && outcome.resumable === "no";
|
|
67
173
|
}
|
|
68
174
|
/** Read non-successful daemon classification without throwing on malformed frames. */
|
|
69
|
-
export function readTerminalOutcome({ raw }) {
|
|
175
|
+
export function readTerminalOutcome({ raw }, _options = {}) {
|
|
70
176
|
if (!raw)
|
|
71
177
|
return null;
|
|
72
178
|
let payload;
|
|
@@ -89,8 +195,31 @@ export function readTerminalOutcome({ raw }) {
|
|
|
89
195
|
resumable: payload.resumable === true ? "yes" : "no",
|
|
90
196
|
};
|
|
91
197
|
}
|
|
198
|
+
/**
|
|
199
|
+
* Turns a daemon `end` frame's terminal outcome into a visible `status` event when — and only when —
|
|
200
|
+
* the run did NOT succeed.
|
|
201
|
+
*
|
|
202
|
+
* WHY THIS EXISTS (2026-09-06 chat-death investigation, `ADS-memory/reports/2026-09-06-chat-death-investigation.md`):
|
|
203
|
+
* `@jini-ai/protocol`'s `RunEndPayload` carries `status`/`code`/`signal`/`resumable`, and
|
|
204
|
+
* `@jini-ai/daemon`'s `finish()` is the ONLY event a terminal run emits — there is no separate
|
|
205
|
+
* `error` frame for a failed run. {@link subscribeToRun}'s `end` listener read only `reason` (a
|
|
206
|
+
* field `RunEndPayload` does not even have — it is BYOK-only), so a run the daemon had already
|
|
207
|
+
* classified `failed` arrived here indistinguishable from a completed one, was reported through
|
|
208
|
+
* `onDone`, and was persisted to `chat.db` as `run_status='succeeded'` with empty content. Two such
|
|
209
|
+
* rows existed in the investigated host's `chat.db` — one `codex`, one `claude`, 578 ms and 552 ms — and they
|
|
210
|
+
* are what "the chat just craps out with no error" actually looks like on disk.
|
|
211
|
+
*
|
|
212
|
+
* This is the SEEN half of that fix; {@link terminalFailureError} below is the WRITTEN-DOWN half
|
|
213
|
+
* (2026-09-07, owner-approved). An earlier version of this comment said the persistence change was
|
|
214
|
+
* deliberately NOT made and was out of scope until the owner signed off — that is no longer true,
|
|
215
|
+
* and the two halves stayed separate functions only because they disagree on exactly one input:
|
|
216
|
+
* `canceled` earns a notice but is not a failure.
|
|
217
|
+
*
|
|
218
|
+
* Returns `null` for a successful or absent status so a normal turn gains no extra event.
|
|
219
|
+
*/
|
|
220
|
+
// "exited without answering" would be false here: no process ever ran.
|
|
92
221
|
/** A visible terminal classification rendered with host-supplied copy. */
|
|
93
|
-
export function terminalOutcomeNotice({ raw, notices }) {
|
|
222
|
+
export function terminalOutcomeNotice({ raw, notices }, _options = {}) {
|
|
94
223
|
const outcome = readTerminalOutcome({ raw });
|
|
95
224
|
if (!outcome)
|
|
96
225
|
return null;
|
|
@@ -99,8 +228,46 @@ export function terminalOutcomeNotice({ raw, notices }) {
|
|
|
99
228
|
return { kind: "status", label: outcome.status === "canceled" ? notices.canceledLabel : notices.failedLabel,
|
|
100
229
|
detail: notices.terminalDetail({ outcome }) };
|
|
101
230
|
}
|
|
231
|
+
/**
|
|
232
|
+
* The reportable `Error` for a daemon `end` frame the daemon itself classified as `failed`, or
|
|
233
|
+
* `null` for every other terminal outcome.
|
|
234
|
+
*
|
|
235
|
+
* WHY THIS EXISTS (2026-09-07, owner-approved): it is what makes a dead run RECOVERABLE FROM THE
|
|
236
|
+
* DATABASE ALONE. Until now a failed run was reported only through `onDone`, so the durable record
|
|
237
|
+
* said `run_status='succeeded'` with empty content: the live transcript was the ONLY place the
|
|
238
|
+
* failure was visible, and once it was gone the row was indistinguishable from a successful turn
|
|
239
|
+
* that happened to answer with nothing. That lie is why diagnosing chat deaths burned multiple
|
|
240
|
+
* sessions and carried a wrong premise through two handoffs. `f682eff2` deliberately left it in
|
|
241
|
+
* place ("a behavior change to what the product writes down... out of scope until the owner signs
|
|
242
|
+
* off"); the owner has now signed off, and this is that change.
|
|
243
|
+
*
|
|
244
|
+
* NOTHING IN THIS FILE COMPUTES `runStatus`. Three pieces of `@jini-ai/chat` do, and the whole
|
|
245
|
+
* effect of this function rests on all three:
|
|
246
|
+
* 1. `useRunStream`'s `onError` sets the run's status to `'error'` — and its `onDone` is written
|
|
247
|
+
* as `prev.status === 'error' ? prev.status : 'done'`, so an `onDone` arriving AFTER this
|
|
248
|
+
* preserves the failure instead of overwriting it. That is what lets {@link subscribeToRun}
|
|
249
|
+
* report the failure and STILL settle the run through `finish()` with its collected events
|
|
250
|
+
* intact, rather than having to choose between the two. The call order in that listener is
|
|
251
|
+
* load-bearing, not incidental.
|
|
252
|
+
* 2. `useConversation` maps run status `'error'` to `ChatMessage.runStatus: 'failed'`.
|
|
253
|
+
* 3. `isTerminalRunStatus` already counts `'failed'` as terminal, so `assistant-chats.ts`'s
|
|
254
|
+
* `persistableMessages` KEEPS the message (it discards only still-streaming turns) and
|
|
255
|
+
* `AssistantDock/hooks/AssistantDock.hooks.tsx`'s `shouldPublishOnMessagesChange` still
|
|
256
|
+
* settles the dock. A failed run therefore cannot hang the pane waiting for a terminal state
|
|
257
|
+
* that never arrives — which is the failure this change would otherwise have traded the wrong
|
|
258
|
+
* record for.
|
|
259
|
+
*
|
|
260
|
+
* `canceled` is excluded on purpose: a run the operator stopped is not a failure, and
|
|
261
|
+
* `useRunStream.cancel()` already stamps its own `'canceled'` status. Marking it `failed` would
|
|
262
|
+
* swap one wrong record for another.
|
|
263
|
+
*
|
|
264
|
+
* Historical rows are NOT retrofitted. The investigated host's `chat.db` held 2 such rows on 2026-09-07 and
|
|
265
|
+
* the pre-split `content.db` copy held 6; nothing distinguishes a genuinely empty
|
|
266
|
+
* successful answer from a death after the fact, so a migration could only guess. Going-forward
|
|
267
|
+
* correctness is the goal.
|
|
268
|
+
*/
|
|
102
269
|
/** Report a failed run; operator cancellation is not a failure. */
|
|
103
|
-
export function terminalFailureError({ raw }) {
|
|
270
|
+
export function terminalFailureError({ raw }, _options = {}) {
|
|
104
271
|
const outcome = readTerminalOutcome({ raw });
|
|
105
272
|
if (!outcome || outcome.status !== "failed")
|
|
106
273
|
return null;
|
|
@@ -108,8 +275,12 @@ export function terminalFailureError({ raw }) {
|
|
|
108
275
|
return new Error("The run failed before the agent started.");
|
|
109
276
|
return new Error(`The agent process exited without answering (exit code ${outcome.code}, signal ${outcome.signal}, resumable ${outcome.resumable}).`);
|
|
110
277
|
}
|
|
278
|
+
/** Reads a terminal frame's `reason` from either stream shape without letting a malformed or absent
|
|
279
|
+
* body prevent the turn from ending: the daemon path wraps it in a `RunProtocolEventWire.payload`,
|
|
280
|
+
* the BYOK path sends a bare `{reason}`, and `subscribeToRun`'s `end` event may carry no data at
|
|
281
|
+
* all. A notice is a nicety; finishing the run is not. */
|
|
111
282
|
/** Read a wrapped daemon or bare provider terminal reason, failing soft. */
|
|
112
|
-
export function readTerminalReason({ raw, wrapped }) {
|
|
283
|
+
export function readTerminalReason({ raw, wrapped }, _options = {}) {
|
|
113
284
|
if (!raw)
|
|
114
285
|
return "";
|
|
115
286
|
try {
|
|
@@ -121,41 +292,50 @@ export function readTerminalReason({ raw, wrapped }) {
|
|
|
121
292
|
return "";
|
|
122
293
|
}
|
|
123
294
|
}
|
|
295
|
+
/** Preserve chat's per-line trimming and default event name; omit data-less keepalives. */
|
|
296
|
+
function chatFrame(frame) {
|
|
297
|
+
return frame.dataLines.length > 0
|
|
298
|
+
? { event: frame.event?.trim() ?? "message", data: frame.dataLines.map(line => line.trim()).join("\n") }
|
|
299
|
+
: null;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Parses one blank-line-delimited SSE frame's raw text into `{event, data}`, split out as its own
|
|
303
|
+
* pure function so it is directly testable with a plain string, no `ReadableStream`/reader involved.
|
|
304
|
+
*
|
|
305
|
+
* Returns `null` for a frame with no `data:` lines — {@link readSseFrames} skips yielding those
|
|
306
|
+
* (a bare `event: ping` keepalive, for example, or a frame carrying only an `id:` field).
|
|
307
|
+
*/
|
|
124
308
|
/** Parse the existing named SSE framing; bare keepalive frames return null. */
|
|
125
|
-
export function parseFrame({ rawFrame }) {
|
|
126
|
-
|
|
127
|
-
const dataLines = [];
|
|
128
|
-
for (const line of rawFrame.split("\n")) {
|
|
129
|
-
if (line.startsWith("event:"))
|
|
130
|
-
event = line.slice(6).trim();
|
|
131
|
-
else if (line.startsWith("data:"))
|
|
132
|
-
dataLines.push(line.slice(5).trim());
|
|
133
|
-
}
|
|
134
|
-
return dataLines.length > 0 ? { event, data: dataLines.join("\n") } : null;
|
|
309
|
+
export function parseFrame({ rawFrame }, _options = {}) {
|
|
310
|
+
return chatFrame(parseSseRecord({ rawFrame }));
|
|
135
311
|
}
|
|
136
|
-
/**
|
|
137
|
-
|
|
312
|
+
/**
|
|
313
|
+
* Splits a `text/event-stream` response body into `{event, data}` frames. Frames are
|
|
314
|
+
* blank-line-delimited per the SSE spec; per-frame field parsing lives in {@link parseFrame} above.
|
|
315
|
+
*
|
|
316
|
+
* @complexity O(n) in response body bytes; O(1) additional buffering per chunk beyond the
|
|
317
|
+
* not-yet-terminated tail of the current frame.
|
|
318
|
+
* @overallScore 100
|
|
319
|
+
*/
|
|
320
|
+
/** Decode complete named frames; cancellation always releases its reader. Inherits shared framing costs. */
|
|
321
|
+
export async function* readSseFrames({ body }, _options = {}) {
|
|
138
322
|
const reader = body.getReader();
|
|
139
|
-
const decoder = new TextDecoder();
|
|
140
|
-
let buffer = "";
|
|
141
323
|
let ended = false;
|
|
142
|
-
|
|
324
|
+
async function* chunks() {
|
|
143
325
|
for (;;) {
|
|
144
326
|
const { done, value } = await reader.read();
|
|
145
327
|
if (done) {
|
|
146
328
|
ended = true;
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
buffer += decoder.decode(value, { stream: true });
|
|
150
|
-
let boundary = buffer.indexOf("\n\n");
|
|
151
|
-
while (boundary !== -1) {
|
|
152
|
-
const rawFrame = buffer.slice(0, boundary);
|
|
153
|
-
buffer = buffer.slice(boundary + 2);
|
|
154
|
-
const frame = parseFrame({ rawFrame });
|
|
155
|
-
if (frame)
|
|
156
|
-
yield frame;
|
|
157
|
-
boundary = buffer.indexOf("\n\n");
|
|
329
|
+
return;
|
|
158
330
|
}
|
|
331
|
+
yield value;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
for await (const decoded of decodeSseFrames({ source: chunks() }, { flushFinalFrame: false })) {
|
|
336
|
+
const frame = chatFrame(decoded);
|
|
337
|
+
if (frame)
|
|
338
|
+
yield frame;
|
|
159
339
|
}
|
|
160
340
|
}
|
|
161
341
|
finally {
|
|
@@ -164,20 +344,37 @@ export async function* readSseFrames({ body }) {
|
|
|
164
344
|
reader.releaseLock();
|
|
165
345
|
}
|
|
166
346
|
}
|
|
347
|
+
/**
|
|
348
|
+
* Whether the agent daemon holds this run, so its event stream can be read at
|
|
349
|
+
* `/api/runs/:id/events`. A BYOK or AG-UI run id names a turn that lived on one browser request, and
|
|
350
|
+
* there is nothing server-side to watch.
|
|
351
|
+
*/
|
|
167
352
|
/** Identify runs held by the daemon using the host's excluded ID prefixes. */
|
|
168
|
-
export function isDaemonRunId({ runId, excludedPrefixes }) {
|
|
353
|
+
export function isDaemonRunId({ runId, excludedPrefixes }, _options = {}) {
|
|
169
354
|
return runId.length > 0 && !excludedPrefixes.some(prefix => runId.startsWith(prefix));
|
|
170
355
|
}
|
|
171
356
|
/** Copy the host's interruption notice into a saved turn. */
|
|
172
|
-
export function runInterruptedNotice({ notice }) {
|
|
357
|
+
export function runInterruptedNotice({ notice }, _options = {}) {
|
|
173
358
|
return { ...notice };
|
|
174
359
|
}
|
|
360
|
+
/**
|
|
361
|
+
* A chat message's `content` for a list of events: `@jini-ai/chat`'s own rule
|
|
362
|
+
* (`assistantContentFromEvents`, also what `useConversation` uses for a live turn), so a row the
|
|
363
|
+
* server finalizes reads the same as one the browser saved. That rule puts a paragraph break where a
|
|
364
|
+
* tool call or card sat between two text runs, so a working note is never glued onto the answer.
|
|
365
|
+
*/
|
|
175
366
|
/** Use the same paragraph-boundary rule as a live chat turn. */
|
|
176
|
-
export function runContentFromEvents({ events }) {
|
|
367
|
+
export function runContentFromEvents({ events }, _options = {}) {
|
|
177
368
|
return assistantContentFromEvents({ events });
|
|
178
369
|
}
|
|
370
|
+
/**
|
|
371
|
+
* The events to SAVE for a turn: streamed `text`/`thinking` deltas joined into one event per run
|
|
372
|
+
* (`@jini-ai/chat`'s `mergeAdjacentTextEvents`). Live, one event per few tokens is what streams words
|
|
373
|
+
* onto the screen; saved, it only repeats the event wrapper per token. Used by every saver (the server
|
|
374
|
+
* finalizer and the chat PUT route the browser saves through), so both write the same shape.
|
|
375
|
+
*/
|
|
179
376
|
/** Compact adjacent text/thinking deltas for durable storage. */
|
|
180
|
-
export function runEventsForSave({ events }) {
|
|
377
|
+
export function runEventsForSave({ events }, _options = {}) {
|
|
181
378
|
return mergeAdjacentTextEvents({ events });
|
|
182
379
|
}
|
|
183
380
|
function parseWire(raw) {
|
|
@@ -205,11 +402,17 @@ function parseJsonLine(line) {
|
|
|
205
402
|
function numberOf(value) {
|
|
206
403
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
207
404
|
}
|
|
405
|
+
/** `{attempt, maxAttempts}` from an `api_retry` line, keeping only the fields it actually carries. */
|
|
406
|
+
// `api_retry` is Claude Code's own line shape, so the service it names is Claude.
|
|
208
407
|
function retryData(line) {
|
|
209
408
|
const attempt = numberOf(line.attempt);
|
|
210
409
|
const maxAttempts = numberOf(line.max_retries);
|
|
211
410
|
return { ...(attempt !== undefined ? { attempt } : {}), ...(maxAttempts !== undefined ? { maxAttempts } : {}), service: "Claude" };
|
|
212
411
|
}
|
|
412
|
+
/**
|
|
413
|
+
* Claude Code's stdout heartbeats → typed `status` events for the live activity line
|
|
414
|
+
* (`@jini-ai/chat`'s `run-activity.ts` reads `code`/`data`). `null` for any other line.
|
|
415
|
+
*/
|
|
213
416
|
function heartbeatStatus(line) {
|
|
214
417
|
if (line.type === "tool_progress") {
|
|
215
418
|
const elapsedSeconds = numberOf(line.elapsed_time_seconds);
|
|
@@ -225,7 +428,20 @@ function heartbeatStatus(line) {
|
|
|
225
428
|
return { kind: "status", code: "thinking", label: "thinking" };
|
|
226
429
|
return null;
|
|
227
430
|
}
|
|
431
|
+
/**
|
|
432
|
+
* Lines the daemon has already parsed into typed events, so keeping them as `raw` only stores them
|
|
433
|
+
* twice: `stream_event` (the token-by-token echo `--include-partial-messages` adds → `text`), and the
|
|
434
|
+
* whole `assistant`/`user` messages (→ `text`/`thinking`/`tool_use`/`tool_result`). The `user` echo
|
|
435
|
+
* repeats every tool result: one "summarize my posts" turn saved 129 KB of them. Matched on the
|
|
436
|
+
* line's start, not a parse, because a long line arrives split over several stdout chunks and its
|
|
437
|
+
* first piece is not valid JSON on its own. Claude Code writes `type` first on every line.
|
|
438
|
+
*/
|
|
228
439
|
const ECHO_LINE = /^\{"type":"(?:stream_event|assistant|user)"/;
|
|
440
|
+
/**
|
|
441
|
+
* One CLI stdout/stderr chunk → events. Every JSON line that is a heartbeat also yields a typed
|
|
442
|
+
* `status` event; echo lines ({@link ECHO_LINE}) are dropped; everything else stays in ONE `raw`
|
|
443
|
+
* event, verbatim. A chunk with no JSON lines at all (a plain-format CLI) is one `raw` event, as before.
|
|
444
|
+
*/
|
|
229
445
|
function chunkEvent(raw) {
|
|
230
446
|
const frame = parseWire(raw);
|
|
231
447
|
if (!frame)
|
|
@@ -249,6 +465,8 @@ function chunkEvent(raw) {
|
|
|
249
465
|
kept = chunk;
|
|
250
466
|
return kept.length > 0 ? [{ kind: "raw", line: kept }, ...statuses] : statuses;
|
|
251
467
|
}
|
|
468
|
+
// Order matters and matches the browser's `end` listener: the `max_tool_turns` notice first, then
|
|
469
|
+
// the failed/canceled notice, then the failure itself.
|
|
252
470
|
function endOutcome(raw, notices) {
|
|
253
471
|
const events = [];
|
|
254
472
|
const reasonNotice = terminalReasonNotice({ reason: readTerminalReason({ raw, wrapped: true }), notice: notices.toolStepLimit });
|
|
@@ -262,8 +480,24 @@ function endOutcome(raw, notices) {
|
|
|
262
480
|
const terminal = outcome?.status ?? "succeeded";
|
|
263
481
|
return error ? { events, error, terminal } : { events, terminal };
|
|
264
482
|
}
|
|
483
|
+
/**
|
|
484
|
+
* Translates one named daemon SSE frame (`event:` name plus raw `data:` text) into its effect on the
|
|
485
|
+
* turn. The browser's `subscribeToRun` dispatches the result to `RunHandlers`; the server finalizer
|
|
486
|
+
* folds it into the row it saves. Never throws: a malformed frame yields no events rather than
|
|
487
|
+
* stranding either reader mid-turn.
|
|
488
|
+
*
|
|
489
|
+
* An `error` frame with no data is a connection error, not a run error — the browser's
|
|
490
|
+
* `EventSource` reports those on the same event name, so it must check for data before calling this.
|
|
491
|
+
*
|
|
492
|
+
* @complexity O(size of the frame).
|
|
493
|
+
*/
|
|
494
|
+
// The agent CLI's own output. `stderr` is where a dying CLI says WHY it is dying.
|
|
495
|
+
// Shown AND saved as a status line, not only reported: the error is live-only state, so without
|
|
496
|
+
// the event the saved turn (and a reload) showed a bare "failed" with the reason lost. This is
|
|
497
|
+
// where the daemon's plain pre-spawn reason ("The assistant could not start: ...") and the
|
|
498
|
+
// host's start refusals reach the user.
|
|
265
499
|
/** Fold one named daemon frame into events, a failure and an optional terminal status. */
|
|
266
|
-
export function translateRunFrame({ kind, raw, notices }) {
|
|
500
|
+
export function translateRunFrame({ kind, raw, notices }, _options = {}) {
|
|
267
501
|
switch (kind) {
|
|
268
502
|
case "agent": {
|
|
269
503
|
const frame = parseWire(raw);
|
|
@@ -13,12 +13,13 @@
|
|
|
13
13
|
* transcript assembly, so they are generalized into caller-supplied hooks
|
|
14
14
|
* (or dropped) below — see archived provenance ledger for the full accounting.
|
|
15
15
|
*/
|
|
16
|
+
import { type UserTextRedactionOptions } from './user-text-redaction.js';
|
|
16
17
|
import type { ChatMessage } from './messages.js';
|
|
17
18
|
import type { PersistedArtifactFileRef } from './util/strip.js';
|
|
18
19
|
/** The most recent `user`-authored message's content, or `''` if there isn't one. */
|
|
19
20
|
export declare function latestUserPromptFromHistory({ history }: {
|
|
20
21
|
history: ChatMessage[];
|
|
21
|
-
}): string;
|
|
22
|
+
}, options?: UserTextRedactionOptions): string;
|
|
22
23
|
/**
|
|
23
24
|
* Strip transcript-echo hazards from a prior assistant turn before it goes
|
|
24
25
|
* back to the agent as history: an already-answered `<question-form>` /
|
|
@@ -43,7 +44,7 @@ export declare function sanitizePriorAssistantTurn({ content }: {
|
|
|
43
44
|
}, { persistedArtifactFiles }?: {
|
|
44
45
|
persistedArtifactFiles?: (ReadonlyArray<PersistedArtifactFileRef>) | undefined;
|
|
45
46
|
}): string;
|
|
46
|
-
export interface BuildTranscriptOptions {
|
|
47
|
+
export interface BuildTranscriptOptions extends UserTextRedactionOptions {
|
|
47
48
|
/**
|
|
48
49
|
* Scope the transcript to messages from this agent's family onward,
|
|
49
50
|
* dropping any older history from a different agent. Omit to include the
|
package/dist/core/transcript.js
CHANGED
|
@@ -1,13 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module transcript
|
|
3
|
+
*
|
|
4
|
+
* Pure transcript-assembly helpers: turning a `ChatMessage[]` history into
|
|
5
|
+
* the flattened text a host sends to an agent as prior-turn context.
|
|
6
|
+
*
|
|
7
|
+
* Adapted from OD's `providers/daemon.ts` transcript section (see
|
|
8
|
+
* archived provenance ledger). The OD original also owned SSE/fetch transport
|
|
9
|
+
* (`streamViaDaemon`, `DaemonStreamHandlers`) — none of that is ported here;
|
|
10
|
+
* only the pure string-assembly helpers. It also hard-coded OD's BYOK/
|
|
11
|
+
* OpenCode agent-family policy and an `agent-browser`-tool-specific
|
|
12
|
+
* context-warning heuristic; both are OD product policy, not generic
|
|
13
|
+
* transcript assembly, so they are generalized into caller-supplied hooks
|
|
14
|
+
* (or dropped) below — see archived provenance ledger for the full accounting.
|
|
15
|
+
*/
|
|
16
|
+
import { redactUserMessage } from './user-text-redaction.js';
|
|
1
17
|
import { summarizeArtifactsForTranscript } from './util/strip.js';
|
|
2
18
|
const DEFAULT_MAX_TRANSCRIPT_MESSAGE_CHARS = 12_000;
|
|
3
19
|
const DEFAULT_LARGE_TOOL_RESULT_CHARS = 8_000;
|
|
4
20
|
const DEFAULT_HIGH_INPUT_TOKEN_WARNING_THRESHOLD = 200_000;
|
|
5
21
|
/** The most recent `user`-authored message's content, or `''` if there isn't one. */
|
|
6
|
-
export function latestUserPromptFromHistory({ history }) {
|
|
22
|
+
export function latestUserPromptFromHistory({ history }, options = {}) {
|
|
7
23
|
for (let i = history.length - 1; i >= 0; i -= 1) {
|
|
8
24
|
const message = history[i];
|
|
9
25
|
if (message?.role === 'user')
|
|
10
|
-
return message.content;
|
|
26
|
+
return redactUserMessage({ message }, options).message.content;
|
|
11
27
|
}
|
|
12
28
|
return '';
|
|
13
29
|
}
|
|
@@ -122,7 +138,7 @@ export function buildTranscript({ history }, options = {}) {
|
|
|
122
138
|
const scopedHistory = scopeHistoryToAgent(history, options.targetAgentId, isSameAgentFamily);
|
|
123
139
|
const transcript = scopedHistory
|
|
124
140
|
.map((m) => {
|
|
125
|
-
const trimmed = m.content.trim();
|
|
141
|
+
const trimmed = redactUserMessage({ message: m }, options).message.content.trim();
|
|
126
142
|
const sanitized = m.role === 'assistant' ? sanitizePriorAssistantTurn({ content: trimmed }, { persistedArtifactFiles: resolvePersistedArtifactFiles(m) }) : trimmed;
|
|
127
143
|
return `## ${m.role}\n${escapeTranscriptRoleDelimiters(truncateForTranscript(sanitized, maxMessageChars))}`;
|
|
128
144
|
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ChatMessage } from './messages.js';
|
|
2
|
+
export interface SecretRedactionSignal {
|
|
3
|
+
readonly secretRedacted: true;
|
|
4
|
+
readonly count: number;
|
|
5
|
+
}
|
|
6
|
+
export interface UserTextRedaction {
|
|
7
|
+
readonly text: string;
|
|
8
|
+
readonly secretRedacted: boolean;
|
|
9
|
+
readonly count: number;
|
|
10
|
+
}
|
|
11
|
+
export type RedactUserText = (required: {
|
|
12
|
+
readonly text: string;
|
|
13
|
+
}, optional?: {
|
|
14
|
+
readonly secretField?: boolean;
|
|
15
|
+
}) => UserTextRedaction;
|
|
16
|
+
export interface UserTextRedactionOptions {
|
|
17
|
+
/** Default on; hosts may supply a stricter credential policy without changing chat storage. */
|
|
18
|
+
readonly redactUserText?: RedactUserText;
|
|
19
|
+
/** Contains no user text or credential fragments. A host can offer its secure credential card. */
|
|
20
|
+
readonly onSecretRedacted?: (signal: SecretRedactionSignal) => void;
|
|
21
|
+
}
|
|
22
|
+
export declare const SECRET_REDACTED_NOTICE = "I removed what looked like a key from your message. If it was real, rotate it. Use the secure form to save it.";
|
|
23
|
+
export declare const CREDENTIAL_CARD_GUIDANCE = "Use the secure form to save keys, tokens, passwords, and secrets. Chat answers are sent to the assistant.";
|
|
24
|
+
/** Stricter than error redaction: also detects unlabelled long tokens, while retaining ordinary URLs,
|
|
25
|
+
* hexadecimal hashes, identifiers and prose. Strong credential syntax inside a URL is still caught. */
|
|
26
|
+
export declare function redactUserText({ text }: {
|
|
27
|
+
readonly text: string;
|
|
28
|
+
}, { secretField }?: {
|
|
29
|
+
readonly secretField?: boolean;
|
|
30
|
+
}): UserTextRedaction;
|
|
31
|
+
/** Prepare before storage OR model dispatch. Return only sanitized content and non-secret metadata. */
|
|
32
|
+
export declare function redactUserMessage<T extends {
|
|
33
|
+
readonly role: string;
|
|
34
|
+
readonly content: string;
|
|
35
|
+
}>({ message }: {
|
|
36
|
+
readonly message: T;
|
|
37
|
+
}, options?: UserTextRedactionOptions): {
|
|
38
|
+
readonly message: T;
|
|
39
|
+
readonly secretRedacted: boolean;
|
|
40
|
+
readonly count: number;
|
|
41
|
+
};
|
|
42
|
+
/** Also protects replay of historical text accepted before the guard was installed. */
|
|
43
|
+
export declare function redactUserHistory({ history }: {
|
|
44
|
+
readonly history: readonly ChatMessage[];
|
|
45
|
+
}, options?: UserTextRedactionOptions): ChatMessage[];
|
|
46
|
+
/** Redact model input and append the host's non-secret card guidance once, using the redaction
|
|
47
|
+
* signal rather than display text. `secretRedacted` carries an earlier ingress/composer signal;
|
|
48
|
+
* persistence must continue to use redactUserText/redactUserMessage without the model note. */
|
|
49
|
+
export declare function guardUserText({ text }: {
|
|
50
|
+
readonly text: string;
|
|
51
|
+
}, options?: {
|
|
52
|
+
readonly modelNote?: string;
|
|
53
|
+
readonly secretRedacted?: boolean;
|
|
54
|
+
readonly redactUserText?: RedactUserText;
|
|
55
|
+
}): string;
|
|
56
|
+
//# sourceMappingURL=user-text-redaction.d.ts.map
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** One credential policy for chat text; secure card values must bypass this text-only boundary. */
|
|
2
|
+
import { redactSecretShapes } from '@jini-ai/diagnostics/redaction/secrets-only';
|
|
3
|
+
export const SECRET_REDACTED_NOTICE = 'I removed what looked like a key from your message. If it was real, rotate it. Use the secure form to save it.';
|
|
4
|
+
export const CREDENTIAL_CARD_GUIDANCE = 'Use the secure form to save keys, tokens, passwords, and secrets. Chat answers are sent to the assistant.';
|
|
5
|
+
const REMOVED = '[token removed]';
|
|
6
|
+
const INTERNAL_REMOVED = '[REDACTED:chat_secret]';
|
|
7
|
+
const REDACTION_MARKER = /\[REDACTED:[^\]\r\n]+\]/g;
|
|
8
|
+
const CHAT_RULES = [
|
|
9
|
+
{ kind: 'chat_key', pattern: /(?<![A-Za-z0-9_-])sk-[A-Za-z0-9_-]{16,}/g, replacement: () => INTERNAL_REMOVED },
|
|
10
|
+
{ kind: 'chat_key', pattern: /(?<![A-Za-z0-9_])xox[baprs]-[A-Za-z0-9-]{16,}/g, replacement: () => INTERNAL_REMOVED },
|
|
11
|
+
// Explicit authorization syntax catches even short values. Exempt scheme descriptions in prose.
|
|
12
|
+
{ kind: 'chat_bearer', pattern: /\bBearer[ \t]+(?!\[REDACTED)(?!(?:token|tokens|authentication|authorization|auth|scheme|credentials?)\b)[A-Za-z0-9._~+\/=-]+/gi, replacement: () => 'Bearer ' + INTERNAL_REMOVED },
|
|
13
|
+
];
|
|
14
|
+
function entropy(text) {
|
|
15
|
+
const counts = new Map();
|
|
16
|
+
for (const char of text)
|
|
17
|
+
counts.set(char, (counts.get(char) ?? 0) + 1);
|
|
18
|
+
let bits = 0;
|
|
19
|
+
for (const count of counts.values()) {
|
|
20
|
+
const probability = count / text.length;
|
|
21
|
+
bits -= probability * Math.log2(probability);
|
|
22
|
+
}
|
|
23
|
+
return bits;
|
|
24
|
+
}
|
|
25
|
+
/** Stricter than error redaction: also detects unlabelled long tokens, while retaining ordinary URLs,
|
|
26
|
+
* hexadecimal hashes, identifiers and prose. Strong credential syntax inside a URL is still caught. */
|
|
27
|
+
export function redactUserText({ text }, { secretField = false } = {}) {
|
|
28
|
+
// Existing removal markers must survive a second pass through assignment rules (api_key=…).
|
|
29
|
+
const literalMarkers = new Map();
|
|
30
|
+
let literalPrefix = 'chat_literal_';
|
|
31
|
+
while (text.includes(literalPrefix))
|
|
32
|
+
literalPrefix += '_';
|
|
33
|
+
const protectedText = text.replace(REDACTION_MARKER, marker => {
|
|
34
|
+
const key = '[REDACTED:' + literalPrefix + literalMarkers.size + ']';
|
|
35
|
+
literalMarkers.set(key, marker);
|
|
36
|
+
return key;
|
|
37
|
+
}).replaceAll(REMOVED, INTERNAL_REMOVED);
|
|
38
|
+
const base = redactSecretShapes({ text: protectedText, policy: { additionalRules: CHAT_RULES } });
|
|
39
|
+
let count = base.redactions;
|
|
40
|
+
const urls = [...base.text.matchAll(/\b[a-z][a-z0-9+.-]*:\/\/[^\s<>"']+/gi)]
|
|
41
|
+
.map(match => ({ start: match.index, end: match.index + match[0].length }));
|
|
42
|
+
let cleaned = base.text.replace(/(?<![A-Za-z0-9_+\/=-])[A-Za-z0-9_+\/=-]{40,}(?![A-Za-z0-9_+\/=-])/g, (candidate, offset) => {
|
|
43
|
+
if (urls.some(url => offset >= url.start && offset < url.end))
|
|
44
|
+
return candidate;
|
|
45
|
+
if (/^[a-f0-9]+$/i.test(candidate) || !/[A-Za-z]/.test(candidate))
|
|
46
|
+
return candidate;
|
|
47
|
+
const bits = entropy(candidate);
|
|
48
|
+
const mixedAlphabet = /[A-Z]/.test(candidate) && /[a-z]/.test(candidate);
|
|
49
|
+
if (bits < 4 || (!/[0-9]/.test(candidate) && (!mixedAlphabet || bits < 4.5)))
|
|
50
|
+
return candidate;
|
|
51
|
+
count += 1;
|
|
52
|
+
return INTERNAL_REMOVED;
|
|
53
|
+
});
|
|
54
|
+
// A labelled credential answer can be a short password with no recognisable shape at all.
|
|
55
|
+
if (secretField && cleaned.trim() !== '' && cleaned !== INTERNAL_REMOVED) {
|
|
56
|
+
cleaned = INTERNAL_REMOVED;
|
|
57
|
+
count = Math.max(1, count);
|
|
58
|
+
}
|
|
59
|
+
return { text: cleaned.replace(REDACTION_MARKER, marker => literalMarkers.get(marker) ?? REMOVED), secretRedacted: count > 0, count };
|
|
60
|
+
}
|
|
61
|
+
/** Prepare before storage OR model dispatch. Return only sanitized content and non-secret metadata. */
|
|
62
|
+
export function redactUserMessage({ message }, options = {}) {
|
|
63
|
+
if (message.role !== 'user')
|
|
64
|
+
return { message, secretRedacted: false, count: 0 };
|
|
65
|
+
const result = (options.redactUserText ?? redactUserText)({ text: message.content });
|
|
66
|
+
const signal = result.secretRedacted ? { secretRedacted: true, count: result.count } : undefined;
|
|
67
|
+
if (signal)
|
|
68
|
+
options.onSecretRedacted?.(signal);
|
|
69
|
+
return {
|
|
70
|
+
message: { ...message, content: result.text, ...(signal ? { secretRedaction: signal } : {}) },
|
|
71
|
+
secretRedacted: result.secretRedacted, count: result.count,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/** Also protects replay of historical text accepted before the guard was installed. */
|
|
75
|
+
export function redactUserHistory({ history }, options = {}) {
|
|
76
|
+
return history.map(message => redactUserMessage({ message }, options).message);
|
|
77
|
+
}
|
|
78
|
+
/** Redact model input and append the host's non-secret card guidance once, using the redaction
|
|
79
|
+
* signal rather than display text. `secretRedacted` carries an earlier ingress/composer signal;
|
|
80
|
+
* persistence must continue to use redactUserText/redactUserMessage without the model note. */
|
|
81
|
+
export function guardUserText({ text }, options = {}) {
|
|
82
|
+
const safe = (options.redactUserText ?? redactUserText)({ text }, {});
|
|
83
|
+
if (!safe.secretRedacted && options.secretRedacted !== true)
|
|
84
|
+
return safe.text;
|
|
85
|
+
if (!options.modelNote || safe.text.includes(options.modelNote))
|
|
86
|
+
return safe.text;
|
|
87
|
+
return `${safe.text}\n\n${options.modelNote}`;
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=user-text-redaction.js.map
|