@ai-matrx/chat 0.4.9 → 0.4.10
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 +17 -0
- package/dist/agents/redux/execution-system/thunks/resolve-base-url.js +17 -0
- package/dist/agents/redux/execution-system/thunks/run-headless-agent-json.js +14 -2
- package/dist/agents/routing/confidential-routing.d.ts +52 -0
- package/dist/agents/routing/confidential-routing.js +38 -0
- package/dist/host/configure.js +4 -1
- package/dist/host/contract.d.ts +14 -1
- package/dist/host/index.d.ts +1 -0
- package/dist/host/index.js +1 -0
- package/package.json +8 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# @ai-matrx/chat
|
|
2
2
|
|
|
3
|
+
## 0.4.10 — a confidential conversation never reaches a cloud target
|
|
4
|
+
|
|
5
|
+
- THE CONFIDENTIAL-ROUTING RULE moves from the frozen kernel (`src/compat`) into the real chat:
|
|
6
|
+
`agents/routing/confidential-routing.ts` (pure: `decideRouting`, `localityOfChannel`,
|
|
7
|
+
`ConfidentialRoutingRefusedError`), also exported from `@ai-matrx/chat/host`.
|
|
8
|
+
- New host port `routing: { policy(conversationId) => "any" | "local-only" }` (default `"any"`).
|
|
9
|
+
- `resolveBackendForConversation` — the one place every send path resolves its target — refuses a
|
|
10
|
+
`local-only` conversation on every channel but `local-runtime` (the Matrx Local engine on this
|
|
11
|
+
computer), including the cloud fallback when a local-pc conversation finds no engine. It throws
|
|
12
|
+
`ConfidentialRoutingRefusedError` (message = the sentence the person reads, `remedy`, `code`
|
|
13
|
+
`confidential_thread_cloud_target_refused`) before any request is made.
|
|
14
|
+
- Forcing test `agents/routing/__tests__/confidential-conversation-never-reaches-the-cloud.test.ts`
|
|
15
|
+
(red on the unfixed resolver; three plant.py mutations red).
|
|
16
|
+
|
|
17
|
+
Consumer action: none for hosts without confidential conversations (the default policy is `"any"`).
|
|
18
|
+
A host with confidential conversations (matrx-local) passes `routing` to its chat host.
|
|
19
|
+
|
|
3
20
|
## 0.4.9 — the shell chat remembers each page's own chat, and its title menu lists your conversations
|
|
4
21
|
|
|
5
22
|
- `shellChatHome`: outside boards and Education, the conversation is remembered PER PAGE
|
|
@@ -14,6 +14,12 @@ import {
|
|
|
14
14
|
supportsLocalAgentExecution
|
|
15
15
|
} from "../../../../host/server/local-engine.js";
|
|
16
16
|
import { selectOrganizationId } from "../../../../host/org.js";
|
|
17
|
+
import { getChatHost, isChatHostConfigured } from "../../../../host/configure.js";
|
|
18
|
+
import {
|
|
19
|
+
ConfidentialRoutingRefusedError,
|
|
20
|
+
decideRouting,
|
|
21
|
+
localityOfChannel
|
|
22
|
+
} from "../../../routing/confidential-routing.js";
|
|
17
23
|
const EC2_SANDBOX_SERVER_URL = typeof process !== "undefined" && process.env.NEXT_PUBLIC_EC2_SANDBOX_SERVER_URL || "";
|
|
18
24
|
function dedicatedEc2ServerForConversation(state, conversationId) {
|
|
19
25
|
if (selectActiveServer(state) !== "production") return null;
|
|
@@ -65,6 +71,17 @@ function resolveBaseUrlForConversation(state, conversationId) {
|
|
|
65
71
|
return resolved.endsWith("/") ? resolved.slice(0, -1) : resolved;
|
|
66
72
|
}
|
|
67
73
|
function resolveBackendForConversation(state, conversationId) {
|
|
74
|
+
const resolved = resolveBackendUnchecked(state, conversationId);
|
|
75
|
+
if (resolved && isChatHostConfigured()) {
|
|
76
|
+
const decision = decideRouting(
|
|
77
|
+
getChatHost().routing.policy(conversationId),
|
|
78
|
+
localityOfChannel(resolved.channel)
|
|
79
|
+
);
|
|
80
|
+
if (!decision.allowed) throw new ConfidentialRoutingRefusedError(conversationId, decision);
|
|
81
|
+
}
|
|
82
|
+
return resolved;
|
|
83
|
+
}
|
|
84
|
+
function resolveBackendUnchecked(state, conversationId) {
|
|
68
85
|
const entry = state.instanceUIState?.byConversationId?.[conversationId] ?? null;
|
|
69
86
|
const overrideUrl = entry?.serverOverrideUrl ?? null;
|
|
70
87
|
const headers = {
|
|
@@ -151,7 +151,8 @@ async function failWarnedOutputMissingKeys(getState, opts, result) {
|
|
|
151
151
|
}
|
|
152
152
|
const who = holderName ?? (warning.holderType === "workflow" ? "The chosen workflow" : "The chosen agent");
|
|
153
153
|
const keys = missing.join(", ");
|
|
154
|
-
const
|
|
154
|
+
const brokenJson = !(result.success && isRecord) && looksLikeBrokenJson(result.fullResponse);
|
|
155
|
+
const sentence = brokenJson ? `${who} ran, but its answer came back as broken JSON, so it couldn't be read and nothing was saved. Run it again.` : `${who} ran, but its answer is missing ${keys} this job needs, so nothing was saved. It was chosen although it does not declare those keys \u2014 pick one that does.`;
|
|
155
156
|
captureError({
|
|
156
157
|
source: "agent-json-result",
|
|
157
158
|
code: "mandate_output_unusable",
|
|
@@ -162,7 +163,7 @@ async function failWarnedOutputMissingKeys(getState, opts, result) {
|
|
|
162
163
|
...result.requestId ? { requestId: result.requestId } : {},
|
|
163
164
|
...result.conversationId ? { conversationId: result.conversationId } : {},
|
|
164
165
|
raw: {
|
|
165
|
-
defect: "warned-holder-answer-missing-required-keys",
|
|
166
|
+
defect: brokenJson ? "answer-is-broken-json" : "warned-holder-answer-missing-required-keys",
|
|
166
167
|
mandateKey: opts.mandateKey,
|
|
167
168
|
surfaceKey: opts.surfaceKey,
|
|
168
169
|
holderId,
|
|
@@ -179,6 +180,17 @@ async function failWarnedOutputMissingKeys(getState, opts, result) {
|
|
|
179
180
|
errorDetail: `mandate_output_unusable: missing ${keys}`
|
|
180
181
|
};
|
|
181
182
|
}
|
|
183
|
+
function looksLikeBrokenJson(text) {
|
|
184
|
+
if (!text) return false;
|
|
185
|
+
const t = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
|
|
186
|
+
if (!t.startsWith("{") && !t.startsWith("[")) return false;
|
|
187
|
+
try {
|
|
188
|
+
JSON.parse(t);
|
|
189
|
+
return false;
|
|
190
|
+
} catch {
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
182
194
|
function missingRequiredKeys(record, required) {
|
|
183
195
|
const answers = typeof record.answers === "object" && record.answers !== null && !Array.isArray(record.answers) ? record.answers : null;
|
|
184
196
|
return required.filter((key) => !(key in record) && !(answers && key in answers));
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE CONFIDENTIAL-ROUTING RULE (CHAT-UI-PACKAGE-DESIGN §12 risk 4 — the highest-severity item).
|
|
3
|
+
*
|
|
4
|
+
* A conversation pinned `local-only` (matrx-local's "Confidential") REFUSES every target that is
|
|
5
|
+
* not the person's own machine. It does not downgrade, it does not warn-and-send, and it never
|
|
6
|
+
* quietly picks a different target: the send fails with a named reason and a remedy, before a byte
|
|
7
|
+
* leaves the machine. Re-deriving this per host is how private text reaches a cloud provider.
|
|
8
|
+
*
|
|
9
|
+
* Ported from the frozen kernel (`src/compat/config.ts` `decideRouting`) into the real chat
|
|
10
|
+
* (lanes/agents-chat-proposal.md W0(a)). Enforced at the one place every send path resolves its
|
|
11
|
+
* target: `resolveBackendForConversation` (`agents/redux/execution-system/thunks/resolve-base-url.ts`).
|
|
12
|
+
* The policy comes from the host's `routing` port (`host/contract.ts`; default: `"any"`).
|
|
13
|
+
*
|
|
14
|
+
* PURE on purpose: no imports, no React, no Redux, no `window`. The decision is headless so it can
|
|
15
|
+
* move (e.g. into `@ai-matrx/agents`) without a rewrite.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Where a conversation's text is allowed to go.
|
|
19
|
+
* - `"any"` — the platform default: any target the host offers.
|
|
20
|
+
* - `"local-only"` — CONFIDENTIAL: only the person's own machine (the Matrx Local engine).
|
|
21
|
+
*/
|
|
22
|
+
export type ChatRoutingPolicy = "any" | "local-only";
|
|
23
|
+
/** Where a resolved target sends text. */
|
|
24
|
+
export type ChatTargetLocality = "local" | "cloud";
|
|
25
|
+
/** The refusal a confidential conversation produces instead of a silent fallback. */
|
|
26
|
+
export interface ChatRoutingRefusal {
|
|
27
|
+
readonly allowed: false;
|
|
28
|
+
readonly code: "confidential_thread_cloud_target_refused";
|
|
29
|
+
readonly userMessage: string;
|
|
30
|
+
readonly remedy: string;
|
|
31
|
+
}
|
|
32
|
+
export type ChatRoutingDecision = {
|
|
33
|
+
readonly allowed: true;
|
|
34
|
+
} | ChatRoutingRefusal;
|
|
35
|
+
export declare const CONFIDENTIAL_REFUSAL_MESSAGE = "This conversation is marked confidential, so it cannot be sent to a cloud AI provider.";
|
|
36
|
+
export declare const CONFIDENTIAL_REFUSAL_REMEDY = "Run it on Matrx Local on this computer, or start a new conversation that is not marked confidential.";
|
|
37
|
+
/** The one decision. Anything that is not `"local"` is treated as cloud by its callers. */
|
|
38
|
+
export declare function decideRouting(policy: ChatRoutingPolicy, target: ChatTargetLocality): ChatRoutingDecision;
|
|
39
|
+
/**
|
|
40
|
+
* The locality of a resolved backend channel. Only `"local-runtime"` — the Matrx Local engine on
|
|
41
|
+
* this computer — is local. Every other channel (the global server, a sandbox override, a dedicated
|
|
42
|
+
* EC2 server, and any channel added later) is cloud: an unknown channel can never pass the rule.
|
|
43
|
+
*/
|
|
44
|
+
export declare function localityOfChannel(channel: string): ChatTargetLocality;
|
|
45
|
+
/** Thrown by the target resolver when a confidential conversation would reach the cloud. */
|
|
46
|
+
export declare class ConfidentialRoutingRefusedError extends Error {
|
|
47
|
+
readonly code: "confidential_thread_cloud_target_refused";
|
|
48
|
+
readonly remedy: string;
|
|
49
|
+
readonly conversationId: string;
|
|
50
|
+
constructor(conversationId: string, refusal: ChatRoutingRefusal);
|
|
51
|
+
}
|
|
52
|
+
export declare function isConfidentialRoutingRefusedError(error: unknown): error is ConfidentialRoutingRefusedError;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const CONFIDENTIAL_REFUSAL_MESSAGE = "This conversation is marked confidential, so it cannot be sent to a cloud AI provider.";
|
|
2
|
+
const CONFIDENTIAL_REFUSAL_REMEDY = "Run it on Matrx Local on this computer, or start a new conversation that is not marked confidential.";
|
|
3
|
+
function decideRouting(policy, target) {
|
|
4
|
+
if (policy === "local-only" && target !== "local") {
|
|
5
|
+
return {
|
|
6
|
+
allowed: false,
|
|
7
|
+
code: "confidential_thread_cloud_target_refused",
|
|
8
|
+
userMessage: CONFIDENTIAL_REFUSAL_MESSAGE,
|
|
9
|
+
remedy: CONFIDENTIAL_REFUSAL_REMEDY
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
return { allowed: true };
|
|
13
|
+
}
|
|
14
|
+
function localityOfChannel(channel) {
|
|
15
|
+
return channel === "local-runtime" ? "local" : "cloud";
|
|
16
|
+
}
|
|
17
|
+
class ConfidentialRoutingRefusedError extends Error {
|
|
18
|
+
code = "confidential_thread_cloud_target_refused";
|
|
19
|
+
remedy;
|
|
20
|
+
conversationId;
|
|
21
|
+
constructor(conversationId, refusal) {
|
|
22
|
+
super(refusal.userMessage);
|
|
23
|
+
this.name = "ConfidentialRoutingRefusedError";
|
|
24
|
+
this.remedy = refusal.remedy;
|
|
25
|
+
this.conversationId = conversationId;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function isConfidentialRoutingRefusedError(error) {
|
|
29
|
+
return error instanceof ConfidentialRoutingRefusedError;
|
|
30
|
+
}
|
|
31
|
+
export {
|
|
32
|
+
CONFIDENTIAL_REFUSAL_MESSAGE,
|
|
33
|
+
CONFIDENTIAL_REFUSAL_REMEDY,
|
|
34
|
+
ConfidentialRoutingRefusedError,
|
|
35
|
+
decideRouting,
|
|
36
|
+
isConfidentialRoutingRefusedError,
|
|
37
|
+
localityOfChannel
|
|
38
|
+
};
|
package/dist/host/configure.js
CHANGED
|
@@ -44,8 +44,10 @@ const PORTS = [
|
|
|
44
44
|
"chrome",
|
|
45
45
|
"feedback",
|
|
46
46
|
"routes",
|
|
47
|
-
"canvas"
|
|
47
|
+
"canvas",
|
|
48
|
+
"routing"
|
|
48
49
|
];
|
|
50
|
+
const ANY_ROUTING = Object.freeze({ policy: () => "any" });
|
|
49
51
|
const EMPTY_REGISTRATIONS = Object.freeze({});
|
|
50
52
|
function assertDb(host) {
|
|
51
53
|
const db = host?.db;
|
|
@@ -127,6 +129,7 @@ function resolveChatHost(host) {
|
|
|
127
129
|
feedback: host.feedback ?? createDbFeedback(db, () => ref.identity),
|
|
128
130
|
routes: { ...DEFAULT_CHAT_ROUTES, ...host.routes },
|
|
129
131
|
canvas: host.canvas ?? createUnhostedCanvas(() => ref.diagnostics),
|
|
132
|
+
routing: host.routing ?? ANY_ROUTING,
|
|
130
133
|
overridden
|
|
131
134
|
});
|
|
132
135
|
return ref;
|
package/dist/host/contract.d.ts
CHANGED
|
@@ -25,6 +25,7 @@ import type { ChatCanvasTabKind } from "./canvas-tabs.js";
|
|
|
25
25
|
import type { ChatWindowOpeners } from "./window-openers.js";
|
|
26
26
|
import type { DefaultChatServerApi, DefaultChatServerTypes } from "./defaults/server-api.js";
|
|
27
27
|
import type { ChatMessageSource } from "./source.js";
|
|
28
|
+
import type { ChatRoutingPolicy } from "../agents/routing/confidential-routing.js";
|
|
28
29
|
/**
|
|
29
30
|
* The connection contract (R10). Authenticated, RLS applies. Typed with the
|
|
30
31
|
* package's own `ChatDatabase` (`./db-types`, generated: the schemas the
|
|
@@ -731,6 +732,15 @@ export interface ChatAppIdentity {
|
|
|
731
732
|
/** `source_feature` for every conversation this app starts; absent keeps each screen's own. */
|
|
732
733
|
sourceFeature?: string;
|
|
733
734
|
}
|
|
735
|
+
/**
|
|
736
|
+
* Where each conversation's text may go (lanes/agents-chat-proposal.md W0(a)). The package enforces
|
|
737
|
+
* the answer at the one place every send resolves its target (`resolveBackendForConversation`): a
|
|
738
|
+
* `"local-only"` (confidential) conversation refuses every target but the Matrx Local engine on this
|
|
739
|
+
* computer, loudly, before a byte leaves the machine. Default: `"any"` for every conversation.
|
|
740
|
+
*/
|
|
741
|
+
export interface ChatRoutingPort {
|
|
742
|
+
policy(conversationId: string): ChatRoutingPolicy;
|
|
743
|
+
}
|
|
734
744
|
export interface ChatHost {
|
|
735
745
|
/**
|
|
736
746
|
* R10 connection contract. Authenticated, RLS applies. Required unless `source` is given: a
|
|
@@ -791,8 +801,10 @@ export interface ChatHost {
|
|
|
791
801
|
routes?: Partial<ChatRoutes>;
|
|
792
802
|
/** Default: no canvas here — every open refuses and says so. */
|
|
793
803
|
canvas?: ChatCanvasPort;
|
|
804
|
+
/** Default: every conversation may go to any target (`"any"`). */
|
|
805
|
+
routing?: ChatRoutingPort;
|
|
794
806
|
}
|
|
795
|
-
export type ChatPortName = "identity" | "org" | "server" | "notify" | "diagnostics" | "prefs" | "navigation" | "windows" | "catalog" | "registry" | "chrome" | "feedback" | "routes" | "canvas";
|
|
807
|
+
export type ChatPortName = "identity" | "org" | "server" | "notify" | "diagnostics" | "prefs" | "navigation" | "windows" | "catalog" | "registry" | "chrome" | "feedback" | "routes" | "canvas" | "routing";
|
|
796
808
|
/** Every port present. `overridden` names the ports the host supplied itself. */
|
|
797
809
|
export interface ResolvedChatHost {
|
|
798
810
|
/** The host's client, or the no-database stand-in when it gave only a `source`. */
|
|
@@ -818,5 +830,6 @@ export interface ResolvedChatHost {
|
|
|
818
830
|
feedback: ChatFeedbackPort;
|
|
819
831
|
routes: ChatRoutes;
|
|
820
832
|
canvas: ChatCanvasPort;
|
|
833
|
+
routing: ChatRoutingPort;
|
|
821
834
|
overridden: ReadonlySet<ChatPortName>;
|
|
822
835
|
}
|
package/dist/host/index.d.ts
CHANGED
|
@@ -24,3 +24,4 @@ export { createClient, getClaimsUser, schedulerDb, supabase, type ChatClaimsUser
|
|
|
24
24
|
export type { ChatDatabase } from "./db-types.js";
|
|
25
25
|
export { ChatNoDatabaseError, NO_DATABASE_CODE_PREFIX, createNoDatabaseStandIn, isChatNoDatabaseError, isNoDatabase, } from "./no-database.js";
|
|
26
26
|
export * from "./source.js";
|
|
27
|
+
export * from "../agents/routing/confidential-routing.js";
|
package/dist/host/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-matrx/chat",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.10",
|
|
4
4
|
"description": "THE one AI Matrx person-to-agent chat system: the full chat (running agents, conversations, tool calls, streaming, context, surfaces, voice) as per-module subpaths @ai-matrx/chat/<domain>/<module> over a host contract (/host, /host/react) whose only required value is a db; plus the kernel compatibility entries (., /external-store, /matrx, /react, /tools, styles.css, tokens.css) until P28.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -227,25 +227,25 @@
|
|
|
227
227
|
"uuid": "^14.0.2",
|
|
228
228
|
"zod": "^4.6.5",
|
|
229
229
|
"@ai-matrx/agents": "latest",
|
|
230
|
-
"@ai-matrx/associations": "latest",
|
|
231
|
-
"@ai-matrx/browser-audio": "latest",
|
|
232
230
|
"@ai-matrx/alchemy": "latest",
|
|
231
|
+
"@ai-matrx/associations": "latest",
|
|
233
232
|
"@ai-matrx/canvas": "latest",
|
|
233
|
+
"@ai-matrx/browser-audio": "latest",
|
|
234
234
|
"@ai-matrx/content-ir": "latest",
|
|
235
|
-
"@ai-matrx/diff": "latest",
|
|
236
235
|
"@ai-matrx/content-ir-react": "latest",
|
|
237
|
-
"@ai-matrx/design-system": "latest",
|
|
238
236
|
"@ai-matrx/data": "latest",
|
|
237
|
+
"@ai-matrx/design-system": "latest",
|
|
238
|
+
"@ai-matrx/diff": "latest",
|
|
239
239
|
"@ai-matrx/icons": "latest",
|
|
240
|
+
"@ai-matrx/kit": "latest",
|
|
240
241
|
"@ai-matrx/media": "latest",
|
|
241
242
|
"@ai-matrx/print": "latest",
|
|
242
|
-
"@ai-matrx/kit": "latest",
|
|
243
243
|
"@ai-matrx/realtime": "latest",
|
|
244
244
|
"@ai-matrx/records": "latest",
|
|
245
|
+
"@ai-matrx/records-ui": "latest",
|
|
245
246
|
"@ai-matrx/rich-content": "latest",
|
|
246
|
-
"@ai-matrx/rich-editor": "latest",
|
|
247
247
|
"@ai-matrx/tap-target": "latest",
|
|
248
|
-
"@ai-matrx/
|
|
248
|
+
"@ai-matrx/rich-editor": "latest"
|
|
249
249
|
},
|
|
250
250
|
"devDependencies": {
|
|
251
251
|
"@arethetypeswrong/cli": "^0.18.5",
|