Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e21b725
fix: support service-funded Soul generation through Surplus
mariyadavydova Oct 21, 2026
95ac829
Support qualified Surplus embeddings with bound Memory disclosure
mariyadavydova Oct 21, 2026
6c9bb9a
Show qualified Surplus embeddings in server key coverage
mariyadavydova Oct 21, 2026
37f9592
Support automatic Surplus-only embedding selection
mariyadavydova Oct 21, 2026
6367d8e
Recover Soul generation within Surplus when model supply is unavailable
mariyadavydova Oct 21, 2026
787a1a8
Let Surplus choose available sellers for Soul generation
mariyadavydova Oct 21, 2026
2f643fb
fix: harden Surplus-only chat compatibility
mariyadavydova Oct 22, 2026
7476a40
Fix default shell avatar in web onboarding
mariyadavydova Oct 23, 2026
b3ae5b2
Limit invite room lists to public internal rooms
mariyadavydova Oct 23, 2026
55474ca
Fix onboarding Soul persistence and Surplus-only automatic chat
mariyadavydova Oct 23, 2026
1b5d66f
Use Surplus discovery for chat model capabilities and limits
mariyadavydova Oct 23, 2026
cd7a88b
Preserve terminal metadata for Surplus Venice streams
mariyadavydova Oct 23, 2026
410c647
Accept completed Surplus output-ceiling adaptations within budget
mariyadavydova Oct 23, 2026
5fd72b4
Preserve admitted provider routes and Memory embedding disclosure
mariyadavydova Oct 23, 2026
b0aa470
Preserve native Task server admission at the agent boundary
mariyadavydova Oct 23, 2026
2c8e878
Snapshot the resolved model for native Task report-back wakes
mariyadavydova Oct 23, 2026
e0af911
Keep native serving transport separate from foreground resume snapshots
mariyadavydova Oct 23, 2026
58958e2
Merge main and preserve Surplus serving integrations
mariyadavydova Oct 23, 2026
30389e4
Refresh crypto governance and generated runtime dependencies
mariyadavydova Oct 23, 2026
63b8e0f
Merge main and reconcile changelog entries
mariyadavydova Oct 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,39 @@ See [`RELEASE.md`](RELEASE.md) for publication and verification procedures.

## [Unreleased]

- Personal chat keeps its admitted provider and credential when marketplace
availability changes. Already-admitted direct calls skip Surplus discovery,
and protected Memory retains its approved marketplace embedding disclosure.

- Surplus model selection and requests now use its live catalogue for confirmed
model names, capabilities, reasoning parameters, and token limits. Auto skips
unconfirmed routes, and chat and Soul share the same marketplace budget.
Responses with an adapted output ceiling are accepted when they finish
naturally and reported output stays within the original request budget.

- Onboarding waits for an in-progress Soul before saving the final profile,
and automatic foreground chat selection supports Surplus-only servers.

- Invite room pickers now list only public rooms and exclude external rooms.

- Web Genie customization now loads the default shell avatar correctly instead
of showing a missing-image error and requiring a replacement.

- Surplus-only chat can use available sellers for eligible ordinary models,
preserves streamed tool calls, and respects qualified marketplace output
limits. Missing recovery keys retain the original marketplace failure;
pre-routing error traces no longer become pending cost receipts.

- Embeddings can use Surplus with an explicit Venice text-embedding-3-large
selection and Prefer Surplus enabled. Protected Memory shows and binds the
processing route before saving or searching, and usage receipts track
marketplace costs without replaying provider requests.

- Soul generation supports a server Surplus key without a direct provider key,
including streamed customization previews and service cost tracking. Eligible
Surplus-only requests let the marketplace choose an available seller and can
recover from confirmed supply refusals through other eligible models.

- Operators can connect a selected Genie to mAgent through a private Unix socket,
with a fresh external-context Room and a fixed sponsor and sender identity.

Expand Down
25 changes: 19 additions & 6 deletions apps/desktop/electron/foreground-shadow-controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { NautiloApiClient, ProtectedMemoryProjectionV1 } from "@nautilo/api-client";
import { type NautiloApiClient, type ProtectedMemoryProjectionV1 } from "@nautilo/api-client";
import {
sameMemoryEmbeddingDisclosureV2,
type MemoryEmbeddingDisclosureV2,
} from "@nautilo/lattice-crypto/wire";
import type {
TaskOperationRunResultReadV1,
TaskOperationRunTranscriptReadV1,
Expand Down Expand Up @@ -743,18 +747,27 @@ export class ElectronForegroundShadowController {
type: string;
content: string;
importance: number;
requestedEmbedding?: MemoryEmbeddingDisclosureV2;
}>) {
return this.#runMemory(async () => {
const recipient = await this.#api.getMemoryProcessorRecipient();
if (recipient.embedding === undefined) {
throw new TypeError("Memory embedding is unavailable");
const embedding = input.requestedEmbedding === undefined
? (await this.#api.getMemoryProcessorRecipient()).embedding
: (await this.#api.getMemoryProcessorRecipientV2()).embedding;
if (embedding === undefined) throw new TypeError("Memory embedding is unavailable");
if (input.requestedEmbedding !== undefined
&& (!("canonicalSpace" in embedding)
|| !sameMemoryEmbeddingDisclosureV2(input.requestedEmbedding, embedding))) {
throw new TypeError(
"The Memory embedding disclosure changed. Reload it before trying again.",
);
}
let item: unknown;
const receipt = await this.#humanMemory.update(input.memoryId, {
payload: { formatVersion: 1, type: input.type, content: input.content },
importance: input.importance,
requestedProvider: recipient.embedding.provider,
requestedModel: recipient.embedding.model,
...("canonicalSpace" in embedding
? { requestedEmbedding: input.requestedEmbedding! }
: { requestedProvider: embedding.provider, requestedModel: embedding.model }),
}, (opened) => {
item = protectedOpenedMemory(opened);
});
Expand Down
53 changes: 30 additions & 23 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { createChatGPTDesktopListener } from "./chatgpt-plan-loopback";
import {
assertMemoryEmbeddingDisclosureV2,
decodeMemoryEmbeddingDisclosureV2,
encodeMemoryEmbeddingDisclosureV2,
} from "@nautilo/lattice-crypto/wire";
import "./instance-argv-bootstrap.ts";
import { LOCAL_EXECUTION_MAX_IDENTITIES } from "@nautilo/relay";
import { developmentProfileScope, projectAgentAccess, selectionForAgentAccess, selectionForReadyComponents } from "./agent-access";
Expand Down Expand Up @@ -8601,10 +8606,21 @@ ipcMain.handle("foregroundShadow:memory:list", async (e, raw: unknown) => {
});

ipcMain.handle("foregroundShadow:memory:search", async (e, raw: unknown) => {
const options = boundedForegroundShadowValue(raw, "Protected Memory search") as
Parameters<ElectronForegroundShadowController["memorySearch"]>[0];
const value = boundedForegroundShadowValue(raw, "Protected Memory search") as
Parameters<ElectronForegroundShadowController["memorySearch"]>[0] & {
embeddingDisclosure?: unknown;
};
let embeddingDisclosure;
if (value.embeddingDisclosure !== undefined) {
assertMemoryEmbeddingDisclosureV2(value.embeddingDisclosure);
embeddingDisclosure = decodeMemoryEmbeddingDisclosureV2(
encodeMemoryEmbeddingDisclosureV2(value.embeddingDisclosure),
);
}
const { embeddingDisclosure: _untrustedEmbedding, ...options } = value;
const { controller } = await foregroundShadowControllerForSender(e);
return controller.memorySearch(options);
return controller.memorySearch({ ...options,
...(embeddingDisclosure === undefined ? {} : { embeddingDisclosure }) });
});

ipcMain.handle("foregroundShadow:memory:detail", async (e, raw: unknown) => {
Expand All @@ -8618,12 +8634,21 @@ ipcMain.handle("foregroundShadow:memory:detail", async (e, raw: unknown) => {
ipcMain.handle("foregroundShadow:memory:update", async (e, raw: unknown) => {
const value = boundedForegroundShadowValue(raw, "Protected Memory update") as {
memoryId?: unknown; type?: unknown; content?: unknown; importance?: unknown;
requestedEmbedding?: unknown;
};
let requestedEmbedding;
if (value.requestedEmbedding !== undefined) {
assertMemoryEmbeddingDisclosureV2(value.requestedEmbedding);
requestedEmbedding = decodeMemoryEmbeddingDisclosureV2(
encodeMemoryEmbeddingDisclosureV2(value.requestedEmbedding),
);
}
const input = Object.freeze({
memoryId: foregroundShadowString(value.memoryId, "Memory id", 200),
type: foregroundShadowString(value.type, "Memory type", 4_096),
content: foregroundShadowString(value.content, "Memory content", 65_536),
importance: value.importance,
...(requestedEmbedding === undefined ? {} : { requestedEmbedding }),
});
if (typeof input.importance !== "number" || !Number.isFinite(input.importance)
|| input.importance < 0 || input.importance > 1) {
Expand Down Expand Up @@ -15003,26 +15028,8 @@ function showOnboardingWizard(
"POST /api/profile/generate-soul/stream returned no final soul file",
);
}
// First-run onboarding must never hold its final save screen open for
// model latency. The renderer is allowed to close as soon as the
// ordinary profile write finishes, so Desktop durably commits the soul
// from this detached main-process request when generation completes.
// A final profile write that omits `soulFile` preserves this result no
// matter which request wins the insert/update race.
const persistResp = await fetch(`${serverUrl}/api/profile`, {
method: "PUT",
headers,
body: JSON.stringify({ soulFile: finalSoulFile }),
});
if (!persistResp.ok) {
console.warn(
`[onboarding:soul] generated in ${Date.now() - generationStartedAt}ms but background persistence failed: ${persistResp.status}`,
);
} else {
console.log(
`[onboarding:soul] generated and persisted in ${Date.now() - generationStartedAt}ms`,
);
}
// Generation is a preview. The shared wizard waits for this result and
// commits it in the final profile write, just as the web adapter does.
return ipcOk({ soulFile: finalSoulFile });
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type {
} from "@nautilo/api-client";
import type { AtomicDocumentMutationEventBatch } from "@nautilo/document-mutations";
import type { MessagePayloadV2 } from "@nautilo/lattice-bridge";
import type { MemoryEmbeddingDisclosureV2 } from "@nautilo/lattice-crypto/wire";
import type {
BrowserRoomHistoryShadowAcknowledgementInput,
HumanPeerLiveShadowReceiveResult,
Expand Down Expand Up @@ -2082,12 +2083,14 @@ const foregroundShadowAPI = {
search: (options: Readonly<{
q: string; mode: "text" | "semantic"; limit?: number;
includeArchive?: boolean;
embeddingDisclosure?: MemoryEmbeddingDisclosureV2;
}>) => ipcRenderer.invoke("foregroundShadow:memory:search", options),
detail: (memoryId: string) => ipcRenderer.invoke(
"foregroundShadow:memory:detail", { memoryId },
),
update: (input: Readonly<{
memoryId: string; type: string; content: string; importance: number;
requestedEmbedding?: MemoryEmbeddingDisclosureV2;
}>) => ipcRenderer.invoke("foregroundShadow:memory:update", input),
retryPendingMutations: () => ipcRenderer.invoke(
"foregroundShadow:memory:retryPending",
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"@nautilo/config": "workspace:*",
"@nautilo/instance-discovery": "workspace:*",
"@nautilo/lattice-bridge": "workspace:*",
"@nautilo/lattice-crypto": "workspace:*",
"@nautilo/logger": "workspace:*",
"@nautilo/mcp-client": "workspace:*",
"@nautilo/relay": "workspace:*",
Expand Down
83 changes: 83 additions & 0 deletions apps/desktop/tests/unit/foreground-shadow-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -843,6 +843,89 @@ describe("ElectronForegroundShadowController", () => {
await controller.dispose();
});

test("signs Desktop Memory edits with the advertised marketplace disclosure", async () => {
let intent: unknown;
const embedding = {
contractVersion: 2 as const,
canonicalSpace: { provider: "venice" as const,
model: "text-embedding-3-large", dimensions: 1536 as const },
processingRoute: { transport: "surplus_marketplace" as const,
marketplacePolicyVersion: "surplus-embedding-marketplace-v1" as const,
dataRecipientClasses: [
"surplus", "marketplace_seller", "seller_upstream_processor",
] as const },
};
const controller = createElectronForegroundShadowController(controllerInput({
api: {
setToken: () => undefined,
admin: { encryptionTransition: { getPolicy: async () => ({
policy: { mode: "shadow_encryption", shadowBehavior: "fallback",
revision: 1, updatedAt: "2026-09-01T00:00:00.000Z" },
}) } },
getMemoryProcessorRecipientV2: async () => ({ formatVersion: 2,
purpose: "memory.foreground_embedding", recipientId: "A".repeat(43),
publicKeyBase64url: "B".repeat(87), embedding }),
} as unknown as ElectronForegroundShadowApi,
factories: factories({ humanMemory: {
update: async (_memoryId: string, value: unknown) => {
intent = value;
return { status: "ordinary_fallback", memoryId: "memory-1",
reason: "target_encryption_not_ready" };
},
} }),
}));
expect(await controller.memoryUpdate({ memoryId: "memory-1", type: "fact",
content: "private", importance: 0.8,
requestedEmbedding: embedding })).toMatchObject({
status: "ordinary_fallback", memoryId: "memory-1",
});
expect(intent).toEqual({
payload: { formatVersion: 1, type: "fact", content: "private" },
importance: 0.8,
requestedEmbedding: embedding,
});
await controller.dispose();
});

test("rejects a Desktop Memory edit when the displayed route no longer matches", async () => {
const direct = {
contractVersion: 2 as const,
canonicalSpace: { provider: "venice" as const,
model: "text-embedding-3-large", dimensions: 1536 as const },
processingRoute: { transport: "direct" as const },
};
const marketplace = { ...direct, processingRoute: {
transport: "surplus_marketplace" as const,
marketplacePolicyVersion: "surplus-embedding-marketplace-v1" as const,
dataRecipientClasses: [
"surplus", "marketplace_seller", "seller_upstream_processor",
] as const,
} };
let updates = 0;
const controller = createElectronForegroundShadowController(controllerInput({
api: {
setToken: () => undefined,
admin: { encryptionTransition: { getPolicy: async () => ({
policy: { mode: "shadow_encryption", shadowBehavior: "fallback",
revision: 1, updatedAt: "2026-09-01T00:00:00.000Z" },
}) } },
getMemoryProcessorRecipientV2: async () => ({ formatVersion: 2,
purpose: "memory.foreground_embedding", recipientId: "A".repeat(43),
publicKeyBase64url: "B".repeat(87), embedding: marketplace }),
} as unknown as ElectronForegroundShadowApi,
factories: factories({ humanMemory: {
update: async () => { updates += 1; throw new Error("unexpected update"); },
} }),
}));
const failure = await rejected(controller.memoryUpdate({ memoryId: "memory-1",
type: "fact", content: "private", importance: 0.8,
requestedEmbedding: direct }));
expect(failure).toBeInstanceOf(TypeError);
expect((failure as TypeError).message).toContain("embedding disclosure changed");
expect(updates).toBe(0);
await controller.dispose();
});

test("withholds a Memory result when policy changes during the operation", async () => {
const started = deferred();
const release = deferred();
Expand Down
15 changes: 15 additions & 0 deletions apps/desktop/tests/unit/foreground-shadow-main-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,21 @@ describe("foreground Shadow main-process contract", () => {
}
});

test("strictly decodes the renderer-approved Memory disclosure before custody", () => {
const search = handler("foregroundShadow:memory:search", "foregroundShadow:memory:detail");
const update = handler("foregroundShadow:memory:update", "foregroundShadow:memory:retryPending");
for (const operation of [search, update]) {
expect(operation).toContain("assertMemoryEmbeddingDisclosureV2(");
expect(operation).toContain("decodeMemoryEmbeddingDisclosureV2(");
expect(operation).toContain("encodeMemoryEmbeddingDisclosureV2(");
expect(operation.indexOf("assertMemoryEmbeddingDisclosureV2(")).toBeLessThan(
operation.indexOf("foregroundShadowControllerForSender(e)"),
);
}
expect(search).toContain("embeddingDisclosure");
expect(update).toContain("requestedEmbedding");
});

test("binds one controller to the active sender/session and disposes on lifecycle exits", () => {
const factory = main.slice(
main.indexOf("async function foregroundShadowControllerForSender("),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,11 @@ describe("M300 foreground Shadow preload contract", () => {
"DesktopForegroundShadowStoredMessage",
);
});

test("carries the displayed Memory embedding disclosure across the IPC surface", () => {
expect(preload).toContain("embeddingDisclosure?: MemoryEmbeddingDisclosureV2");
expect(preload).toContain("requestedEmbedding?: MemoryEmbeddingDisclosureV2");
expect(workbenchDesktop).toContain("embeddingDisclosure?: MemoryEmbeddingDisclosureV2");
expect(workbenchDesktop).toContain("requestedEmbedding?: MemoryEmbeddingDisclosureV2");
});
});
32 changes: 32 additions & 0 deletions apps/desktop/tests/unit/onboarding-soul-preview-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import ts from "typescript";

test("Desktop Soul generation remains a preview until the wizard saves the profile", () => {
const source = readFileSync(join(import.meta.dir, "../../electron/main.ts"), "utf8");
const file = ts.createSourceFile("main.ts", source, ts.ScriptTarget.Latest, true);
let handler: ts.Node | undefined;
function find(node: ts.Node): void {
if (ts.isCallExpression(node)
&& ts.isPropertyAccessExpression(node.expression)
&& node.expression.getText(file) === "ipcMain.handle"
&& node.arguments[0] && ts.isStringLiteral(node.arguments[0])
&& node.arguments[0].text === "onboarding:generate-soul") {
handler = node.arguments[1];
}
ts.forEachChild(node, find);
}
find(file);
if (!handler) throw new Error("Missing Desktop Soul generation handler");
const requests: string[] = [];
function inspect(node: ts.Node): void {
if (ts.isCallExpression(node) && node.expression.getText(file) === "fetch") {
requests.push(node.arguments[0]?.getText(file) ?? "");
}
ts.forEachChild(node, inspect);
}
inspect(handler);
expect(requests).toEqual(["`${serverUrl}/api/profile/generate-soul/stream`"]);
expect(handler.getText(file)).toContain("return ipcOk({ soulFile: finalSoulFile })");
});
1 change: 1 addition & 0 deletions apps/workbench/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
"@nautilo/agent": "workspace:*",
"@nautilo/computer-use-contracts": "workspace:*",
"@nautilo/lattice-bridge": "workspace:*",
"@nautilo/lattice-crypto": "workspace:*",
"@tailwindcss/typography": "0.5.19",
"@tailwindcss/vite": "4.2.2",
"@testing-library/react": "16.3.2",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ function toSnapshot(profile: AgentProfileFull, avatarUrl: string | null): Profil
}

function onboardingAvatarUrl(avatar: AvatarRef): string | null {
return avatar.kind === "preset"
// The shell is rendered by the authenticated avatar route, not a preset file.
return avatar.kind === "preset" && avatar.id !== "shell"
? `/api/onboarding/images/avatars/${encodeURIComponent(avatar.id)}.webp`
: null;
}
Expand Down
3 changes: 3 additions & 0 deletions apps/workbench/src/lib/desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import type {
TaskContentBackfillProgressResponse,
} from "@nautilo/api-client/browser";
import type { MessagePayloadV2 } from "@nautilo/lattice-bridge";
import type { MemoryEmbeddingDisclosureV2 } from "@nautilo/lattice-crypto/wire";
import type {
AuthorizedHumanMemoryClient,
BrowserRoomHistoryShadowAcknowledgementInput,
Expand Down Expand Up @@ -374,6 +375,7 @@ export interface DesktopForegroundShadowAPI {
search(options: Readonly<{
q: string; mode: "text" | "semantic"; limit?: number;
includeArchive?: boolean;
embeddingDisclosure?: MemoryEmbeddingDisclosureV2;
}>): Promise<Readonly<{
items: readonly WorkbenchProtectedMemorySearchResult[];
memoryMode: "namespace" | "scope";
Expand All @@ -388,6 +390,7 @@ export interface DesktopForegroundShadowAPI {
}>>;
update(input: Readonly<{
memoryId: string; type: string; content: string; importance: number;
requestedEmbedding?: MemoryEmbeddingDisclosureV2;
}>): Promise<Readonly<{
status: "published" | "replayed";
memoryId: string;
Expand Down
Loading
Loading