From a1db1b2d8edd7cd0a897d7f1a80ea818a443f315 Mon Sep 17 00:00:00 2001 From: thecodacus Date: Fri, 23 Oct 2026 21:43:03 +0530 Subject: [PATCH 01/11] chore(release): 0.2.0 The portal, @pithagoras/extension-api and the docs site move from 0.1.0 to 0.2.0, in package.json and in the lock's entries for them, as tests/extension-api-package.test.mts requires for the tag v0.2.0. Co-Authored-By: Claude Opus 5.5 --- docs/package.json | 2 +- package-lock.json | 8 ++++---- package.json | 2 +- packages/extension-api/package.json | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/package.json b/docs/package.json index 07a25b95f..b980404c3 100644 --- a/docs/package.json +++ b/docs/package.json @@ -2,7 +2,7 @@ "name": "pithagoras-docs", "private": true, "license": "Apache-2.0", - "version": "0.1.0", + "version": "0.2.0", "scripts": { "dev": "vitepress dev .", "build": "vitepress build .", diff --git a/package-lock.json b/package-lock.json index 2f815917c..9addf6ce0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pithagoras", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pithagoras", - "version": "0.1.0", + "version": "0.2.0", "license": "Apache-2.0", "workspaces": [ "core/server", @@ -74,7 +74,7 @@ }, "docs": { "name": "pithagoras-docs", - "version": "0.1.0", + "version": "0.2.0", "license": "Apache-2.0", "devDependencies": { "vitepress": "^1.6.3" @@ -10314,7 +10314,7 @@ }, "packages/extension-api": { "name": "@pithagoras/extension-api", - "version": "0.1.0", + "version": "0.2.0", "license": "Apache-2.0", "peerDependencies": { "@types/express": "^5", diff --git a/package.json b/package.json index e84328650..671058569 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "pithagoras", "private": true, "license": "Apache-2.0", - "version": "0.1.0", + "version": "0.2.0", "workspaces": [ "core/server", "core/web", diff --git a/packages/extension-api/package.json b/packages/extension-api/package.json index 55d333565..d955f45f3 100644 --- a/packages/extension-api/package.json +++ b/packages/extension-api/package.json @@ -1,6 +1,6 @@ { "name": "@pithagoras/extension-api", - "version": "0.1.0", + "version": "0.2.0", "description": "What a Pithagoras extension is given, as types, and the build that bundles one written in TypeScript and JSX", "keywords": ["pithagoras", "pi", "extension", "types", "esbuild", "tailwindcss"], "homepage": "https://thecodacus.github.io/pithagoras/guide/extension-ui", From 5a94685fbd94a9d1f74f6da80f08858c8e419ab6 Mon Sep 17 00:00:00 2001 From: thecodacus Date: Fri, 23 Oct 2026 21:46:31 +0530 Subject: [PATCH 02/11] fix(upgrade): keep the name v0.1.0 gave its pictures' tools, and run a whole v0.1.0 data folder in one start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v0.1.0 filed its picture tools as the set "". #164 renamed the set "" without carrying a name given to it, and the Media hand-off (schema 7) moved only "" to pithagoras-media, so a name a person gave the set in v0.1.0 was lost. The hand-off now moves both, the newer one first. The new test starts from fixtures/portal-v3.db with something of every part an extension holds now, kept as v0.1.0 kept it: a canvas, a Media page picture and its file, the image endpoint, voice mode connected to the voice service with the person's own addresses remembered, a designed voice and a clone, the GPU, Understory's settings and MCP entry, the browser's settings and a local profile, the allowlist, a heartbeat rule for routine_update, the tools seen and the sets' names. One start takes it to schema 11 with one backup, portal-v3-….db, and leaves media, browser, voice-service, voice-addresses and understory for their extensions. Co-Authored-By: Claude Opus 5.5 --- core/server/src/db.ts | 4 +- core/server/test/schema-fingerprint.test.mjs | 110 +++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/core/server/src/db.ts b/core/server/src/db.ts index 23edcd36f..e06564081 100644 --- a/core/server/src/db.ts +++ b/core/server/src/db.ts @@ -559,8 +559,10 @@ function handOffMedia(d: Database.Database): void { d.prepare("DELETE FROM settings WHERE key IN ('image_generation', 'video_generation')").run(); d.exec("DROP TABLE images"); d.exec("DROP TABLE IF EXISTS videos"); - // Core filed its picture and video tools as one set of their own. + // Core filed its picture and video tools as one set of their own: "", and in v0.1.0, before there were + // videos, "". A name given to the newer one wins. setMoved(d, "", "pithagoras-media"); + setMoved(d, "", "pithagoras-media"); mediaToolsSeenMoved(d); mediaToldOfRemovals(d); } diff --git a/core/server/test/schema-fingerprint.test.mjs b/core/server/test/schema-fingerprint.test.mjs index 2b1ab4b95..9e89c94af 100644 --- a/core/server/test/schema-fingerprint.test.mjs +++ b/core/server/test/schema-fingerprint.test.mjs @@ -1322,3 +1322,113 @@ test("a database of version 10 is backed up and given chat modes: every chat is now.close(); assert.deepEqual(modesIn(dir), { chats: [{ id: "c1", mode: null }, { id: "c2", mode: null }], made: [] }); }); + +// A data folder of v0.1.0 that used every part an extension holds now, each kept as v0.1.0 kept it, on fixtures/portal-v3.db: +// a chat with a canvas, a picture of the Media page with its file, the image endpoint, voice mode on at the voice service's +// addresses with the person's own remembered as connecting it kept them, a designed voice and a clone, the GPU chosen, +// Understory's four settings and its MCP entry, the browser's six settings and the local Chrome's profile, the allowlist, +// a heartbeat's rule for a tool that runs as the primary user, the tools as a chat reported them, and names given to the +// pictures' and the browser's sets. One start takes it to this version, with one backup, and leaves each part for its +// extension; what the extensions do with it is in their own tests. +test("a data folder of v0.1.0 that used every part is brought to this version in one start, with one backup, and each part left for its extension", async () => { + const dir = path.join(home, "everything-from-v3"); + const site = path.join(dir, "ws", "site"); + mkdirSync(site, { recursive: true }); + const file = path.join(dir, "portal.db"); + copyFileSync(new URL("./fixtures/portal-v3.db", import.meta.url), file); + const before = new Database(file); + before.prepare("INSERT INTO sessions (id, title, workspace, kind) VALUES ('c1', 'Kites', ?, 'task')").run(site); + before.prepare("INSERT INTO canvases (id, session_id, title, content) VALUES ('k1', 'c1', 'Plan', '# Plan\n\nFly it.')").run(); + before.prepare(`INSERT INTO images (${IMAGE_COLUMNS}) VALUES (${IMAGE_COLUMNS.map(() => "?")})`).run(["aaaaaaaaaaa1", "page", null, null, "aaaaaaaaaaa1.png", "generated", "a red kite", "{}", null, 14, 1000]); + const calm = { id: "voice-5b1e0c9a-0d7e-4f3c-8a2b-6c4d2e1f0a9b", name: "Calm", kind: "design", instruction: "Slow and low.", transcript: "", audio: null }; + const voice = before.prepare("INSERT INTO voice_presets (id, name, kind, instruction, transcript, audio) VALUES (?, ?, ?, ?, ?, ?)"); + for (const v of [calm, ARIA]) voice.run(v.id, v.name, v.kind, v.instruction, v.transcript, v.audio); + const rule = before.prepare("INSERT INTO tool_rules (id, role, tool, pattern, note, person_key) VALUES (?, ?, ?, ?, '', NULL)"); + rule.run("r3", "heartbeat", "routine_update", "{*"); + rule.run("r4", "colleague", "bash", "echo up-*"); + // Voice mode as v0.1.0 saved it once the voice service was connected (api/voice.ts there). + const voiceMode = { + vad: VAD, lazyLoad: true, runtime: "audio-cpp", voice: ARIA.id, language: "en", cfgScale: 1, sttModel: "", exaggeration: 0.5, kokoroVoice: "af_heart", speed: 1, + responseInstructions: "", skipThinkingProviders: ["llama-swap"], enabled: true, whisperUrl: SERVICE.whisper, breezeUrl: SERVICE.speech, instruction: DEFAULT_INSTRUCTION, + }; + const remembered = { listening: { whisperUrl: OWN.whisper, sttModel: "", enabled: true }, speaking: { breezeUrl: OWN.speech, runtime: "kokoro", enabled: true }, noSpeech: false }; + const browserKept = { browser_user: "agent", browser_password: BROWSER_PASSWORD, browser_port: "3010", browser_https_port: "3011", browser_cursor: "0", browser_tools: "1" }; + const seen = [ + { name: "browser_navigate", source: "browser", package: null, inline: true }, + { name: "show_image", source: "pictures", package: null, inline: true }, + { name: "web_fetch", source: "pi-web-access", package: "npm:pi-web-access", inline: false }, + ]; + const setting = before.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value"); + for (const [key, value] of Object.entries({ + voice: JSON.stringify(voiceMode), + voice_gpu: "GPU-648bf6c5-346e-81f5-f155-dc8061172da3", + voice_before_managed: JSON.stringify(remembered), + image_generation: IMAGE_SETTINGS, + ...UNDERSTORY_KEPT, + ...browserKept, + browser_allowlist: "*.example.com", + tools_seen: JSON.stringify(seen), + // v0.1.0 filed its picture tools as "" (web/src/tool-groups.ts there). + tool_group_names: JSON.stringify({ "": "Pictures", browser: "Web browser", "pi-web-access": "Web" }), + })) setting.run(key, value); + before.close(); + mkdirSync(path.join(dir, "images"), { recursive: true }); + writeFileSync(path.join(dir, "images", "aaaaaaaaaaa1.png"), "the page's kite"); + mkdirSync(path.join(dir, "browser-profile", "Default"), { recursive: true }); + writeFileSync(path.join(dir, "browser-profile", "Default", "Cookies"), "the agent's logins"); + assert.deepEqual(upgradeCheck(file), { needed: true, from: 3 }); + + assert.match(await upgradedWith(dir, UNDERSTORY_ENTRY), new RegExp(`upgrading the database from version 3 to ${SCHEMA_VERSION}`)); + const backups = backupsIn(path.join(dir, "backups")); + assert.equal(backups.length, 1, "one backup, of version 3"); + assert.match(path.basename(backups[0]), /^portal-v3-\d{8}-\d{6}\.db$/); + const copy = new Database(backups[0], { readonly: true }); + assert.deepEqual( + ["canvases", "images", "voice_presets", "settings"].map((table) => copy.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n), + [1, 1, 2, 2 + 3 + 1 + 4 + 6 + 3], + "the copy from before holds all of it", + ); + copy.close(); + + const d = new Database(file, { readonly: true }); + assert.deepEqual([d.pragma("user_version", { simple: true }), schemaFingerprint(d)], [SCHEMA_VERSION, PINNED.fingerprint]); + for (const table of ["canvases", "images", "voice_presets"]) assert.ok(!d.prepare("SELECT 1 FROM sqlite_master WHERE name = ?").get(table), `${table} is gone`); + const settings = Object.fromEntries(d.prepare("SELECT key, value FROM settings").all().map((row) => [row.key, row.value])); + for (const key of ["voice_gpu", "voice_before_managed", "image_generation", ...Object.keys(UNDERSTORY_KEPT), ...Object.keys(browserKept)]) assert.equal(settings[key], undefined, key); + assert.deepEqual(JSON.parse(settings.voice), { provider: "pithagoras-voice", vad: VAD, responseInstructions: "", skipThinkingProviders: ["llama-swap"] }); + assert.equal(settings.browser_allowlist, "*.example.com", "the allowlist stays the guard's"); + assert.equal(settings.guard_trusted_mcp_servers, JSON.stringify(["understory"])); + assert.equal(settings.tools_off_default, "codemode\nweb_search"); + assert.deepEqual(JSON.parse(settings.tool_group_names), { "pithagoras-media": "Pictures", "pithagoras-browser": "Web browser", "pi-web-access": "Web" }); + assert.deepEqual(JSON.parse(settings.tools_seen), [ + { ...seen[0], source: "pithagoras-browser" }, + { ...seen[1], source: "pithagoras-media" }, + seen[2], + ]); + assert.deepEqual(d.prepare("SELECT id FROM tool_rules ORDER BY id").all(), [{ id: "r4" }], "a heartbeat's rule for a tool that runs as the primary user goes too"); + d.close(); + assert.deepEqual(modesIn(dir), { chats: [{ id: "c1", mode: null }], made: [] }); + assert.equal(readFileSync(path.join(site, "canvases", "Plan.md"), "utf8"), "# Plan\n\nFly it."); + + // Each part left for its extension, at the schema that left it. + assert.deepEqual(leftAt(dir), ["browser", "media", "understory", "voice-addresses", "voice-service"], "nothing half done beside them"); + for (const [name, schema] of [["media", 7], ["browser", 8], ["voice-service", 9], ["voice-addresses", 9], ["understory", 10]]) { + const { made: _made, ...about } = JSON.parse(readFileSync(path.join(dir, "handoff", name, "handoff.json"), "utf8")); + assert.deepEqual(about, { format: 1, name, schema }, name); + } + const left = (name, rel) => readFileSync(path.join(dir, "handoff", name, rel), "utf8"); + assert.deepEqual(JSON.parse(left("media", "settings.json")), { image_generation: IMAGE_SETTINGS, video_generation: null }); + assert.deepEqual(JSON.parse(left("media", "images.json")).rows.map((row) => row.id), ["aaaaaaaaaaa1"]); + assert.equal(left("media", "files/images/aaaaaaaaaaa1.png"), "the page's kite"); + assert.deepEqual(JSON.parse(left("browser", "settings.json")), browserKept); + assert.equal(left("browser", "profile/Default/Cookies"), "the agent's logins"); + assert.equal(existsSync(path.join(dir, "browser-profile")), false); + assert.deepEqual(voiceLeft(dir, "voice-service", "settings.json"), serviceOf(voiceMode, "GPU-648bf6c5-346e-81f5-f155-dc8061172da3")); + const { audio: _audio, ...aria } = ARIA; + assert.deepEqual(voiceLeft(dir, "voice-service", "voices.json"), [calm, { ...aria, audio: "voices/1.wav" }]); + assert.equal(sha256(readFileSync(path.join(dir, "handoff", "voice-service", "voices", "1.wav"))), sha256(ARIA.audio)); + assert.deepEqual(voiceLeft(dir, "voice-addresses", "settings.json"), { + whisperUrl: OWN.whisper, sttModel: "", breezeUrl: OWN.speech, runtime: "kokoro", dictationUrl: null, dictationModel: null, language: "en", kokoroVoice: "af_heart", + }); + assert.deepEqual(understoryLeft(dir, "settings.json"), UNDERSTORY_KEPT); +}); From 25e663c0eafc4b2615dfeba0c1418c51761aa0b7 Mon Sep 17 00:00:00 2001 From: thecodacus Date: Fri, 23 Oct 2026 21:47:48 +0530 Subject: [PATCH 03/11] test(upgrade): the old start files and the Git drafts that a v0.1.0 install has Two items of the v0.1.0 checklist (#221) were ticked without a test that would fail: - server/dist/index.js, reset-password.js and sso-only-off.js: scripts/old-entries.mjs is run into a folder of its own, and each file it writes runs the file of the same name in core/server/dist with the arguments it was given. The build runs the script, and the Dockerfile copies server/dist. - Git drafts: a commit message or a pull request's text that v0.1.0's own Git panel kept in the tab's session storage, under pithagoras.git-draft., is read and written by the extension's gitDrafts under the same name. tests/drafts.test.mts only used the prefix as a string. Co-Authored-By: Claude Opus 5.5 --- extensions/git/test/drafts.test.mts | 26 ++++++++++++++++++++++++++ tests/deploy-files.test.mts | 22 ++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 extensions/git/test/drafts.test.mts diff --git a/extensions/git/test/drafts.test.mts b/extensions/git/test/drafts.test.mts new file mode 100644 index 000000000..a96c23483 --- /dev/null +++ b/extensions/git/test/drafts.test.mts @@ -0,0 +1,26 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import type { Portal } from "@pithagoras/extension-api"; +import { setPortal } from "../src/portal.tsx"; +import { gitDrafts } from "../src/helpers.ts"; + +// The tab's session storage, as the portal hands it to an extension. +const stored = new Map(); +setPortal({ + ui: { storage: { session: { get: (key: string) => stored.get(key) ?? null, set: (key: string, value: string) => void stored.set(key, value), remove: (key: string) => void stored.delete(key) } } }, +} as unknown as Portal); + +// Before Git was an extension, the portal's own Git panel kept what was typed in it under these names +// (web/src/drafts.ts and web/src/components/git in v0.1.0): a tab left open across the upgrade keeps them. +test("a commit message or a pull request's text that the portal's own Git panel kept in the tab is there in the extension's", () => { + stored.set("pithagoras.git-draft.s1:commit", "Fix the login"); + stored.set("pithagoras.git-draft.s1:pull-request:main", "What this changes"); + assert.equal(gitDrafts.get("s1:commit"), "Fix the login"); + assert.equal(gitDrafts.get("s1:pull-request:main"), "What this changes"); + + gitDrafts.set("s1:commit", "Fix the login, and the logout"); + assert.equal(stored.get("pithagoras.git-draft.s1:commit"), "Fix the login, and the logout", "kept under the same name"); + gitDrafts.set("s1:commit", ""); + assert.equal(stored.has("pithagoras.git-draft.s1:commit"), false, "emptied, it is forgotten"); + assert.equal(gitDrafts.get("s1:commit"), ""); +}); diff --git a/tests/deploy-files.test.mts b/tests/deploy-files.test.mts index be6d78d53..7a1881e1b 100644 --- a/tests/deploy-files.test.mts +++ b/tests/deploy-files.test.mts @@ -271,6 +271,28 @@ test('the build context leaves out local data and what the image builds itself, for (const source of copied) for (const pattern of ignored) assert.ok(!matches(pattern, source), `${source} is copied but ${pattern} leaves it out`); }); +// The server moved from server/ to core/server (#198). A service, the restart after an update, or a container's +// `node server/dist/reset-password.js` that still names the old place starts the same file from the new one. +test('a start file in the old server/dist runs the same file in core/server/dist, with its arguments, and the image has them', () => { + const dir = mkdtempSync(path.join(tmpdir(), 'pithagoras-old-entries-')); + try { + mkdirSync(path.join(dir, 'scripts')); + writeFileSync(path.join(dir, 'scripts', 'old-entries.mjs'), read('scripts/old-entries.mjs')); + mkdirSync(path.join(dir, 'core', 'server', 'dist'), { recursive: true }); + writeFileSync(path.join(dir, 'core', 'server', 'package.json'), read('core/server/package.json')); + const entries = ['index.js', 'reset-password.js', 'sso-only-off.js']; + for (const entry of entries) writeFileSync(path.join(dir, 'core', 'server', 'dist', entry), `console.log("core/server/dist/${entry}", process.argv.slice(2).join(" "));\n`); + execFileSync(process.execPath, [path.join(dir, 'scripts', 'old-entries.mjs')], { stdio: 'ignore' }); + for (const entry of entries) { + assert.equal(execFileSync(process.execPath, [path.join(dir, 'server', 'dist', entry), '--yes'], { encoding: 'utf8' }), `core/server/dist/${entry} --yes\n`, entry); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + assert.match(read('package.json'), /"build": "[^"]*&& node scripts\/old-entries\.mjs"/, 'the build writes them'); + assert.match(dockerfile(), /^COPY --from=build \/app\/server\/dist server\/dist$/m, 'the image has them'); +}); + test('the README of the channels folder says what the folder is and points at the guide, with no second copy of the contract to go stale', () => { const readme = read('channels/README.md'); assert.ok(readme.includes('/channels/writing-a-channel)')); From c542ea694b748cf949438d04dbb9a5ed7d3ed274 Mon Sep 17 00:00:00 2001 From: thecodacus Date: Fri, 23 Oct 2026 21:57:38 +0530 Subject: [PATCH 04/11] test(mcp): a tool left from before the first look goes once its server is added and removed again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #295 judges nothing the first time the portal looks at mcp.json, so a tool of a server taken out by hand before then stays listed. The MCP guide sent the person to tools_seen in the database. Adding the server again under its name in Settings → MCP and removing it there forgets its tools as any removal does; the new test checks that, and the guide says it. Co-Authored-By: Claude Opus 5.5 --- core/server/test/mcp-edited-outside.test.mjs | 14 ++++++++++++++ docs/guide/mcp.md | 3 ++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/core/server/test/mcp-edited-outside.test.mjs b/core/server/test/mcp-edited-outside.test.mjs index b5c593766..962abdfdf 100644 --- a/core/server/test/mcp-edited-outside.test.mjs +++ b/core/server/test/mcp-edited-outside.test.mjs @@ -246,3 +246,17 @@ test("an edit made while the portal was not running is noticed when it starts", assert.ok(!tools.includes("wiki_page"), tools.join(", ")); assert.ok(tools.includes("jira_search")); }); + +test("a tool left behind by a server taken out by hand before the first look goes once the server is added again under its name and removed in Settings → MCP", () => { + // As the first look left `old_lookup` above: remembered, listed, and claimed by no server of the file. + writeMcpFile({ mcpServers: { jira: JIRA } }); + db.rememberTools([adapter("gone_lookup")]); + assert.ok(listed().includes("gone_lookup")); + assert.ok(!configured().includes("gone")); + // Added again in the panel, with any entry, and removed there: its tools go as any removed server's do. + writeMcpFile({ mcpServers: { jira: JIRA, gone: { command: "true" } } }); + writeMcpFile({ mcpServers: { jira: JIRA } }); + assert.ok(!listed().includes("gone_lookup")); + assert.ok(!known().includes("gone_lookup"), "forgotten, not only hidden"); + assert.ok(removed().includes("gone")); +}); diff --git a/docs/guide/mcp.md b/docs/guide/mcp.md index 45840aa5c..8ddf3b17f 100644 --- a/docs/guide/mcp.md +++ b/docs/guide/mcp.md @@ -139,7 +139,8 @@ from anywhere else is not judged, though where its name is that of a server taken out of this file, or starts with it and an underscore, its tools are taken for that server's. The first time the portal looks at the file it has nothing to compare it with, so a tool left behind by a server taken out by hand before then -stays listed, until it is removed from `tools_seen` in the portal's database. +stays listed. To be rid of it, add the server again under its name here, with +any entry, and remove it. ::: tip The adapter has its own commands `/mcp` opens its status panel, `/mcp tools` lists what is available, and From 460aee3999a1f7760377c64a14824c0c9277908a Mon Sep 17 00:00:00 2001 From: thecodacus Date: Fri, 23 Oct 2026 21:57:38 +0530 Subject: [PATCH 05/11] docs: Sandbox and Guard in the settings overview, and the variables the configuration reference lacked - settings.md: the Agent group lists Sandbox and Guard, as the rail has them. - deploying.md: PORTAL_DEFAULT_EXTENSIONS names the six extensions a portal starts with, not two. - configuration.md: rows for PORTAL_DEFAULT_EXTENSIONS and BROWSER_BIND, which .env.example and both Compose files have. Co-Authored-By: Claude Opus 5.5 --- docs/guide/deploying.md | 2 +- docs/guide/settings.md | 2 +- docs/reference/configuration.md | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/guide/deploying.md b/docs/guide/deploying.md index 05dc315f3..f23220f8e 100644 --- a/docs/guide/deploying.md +++ b/docs/guide/deploying.md @@ -501,7 +501,7 @@ Everything here is optional except, with the Compose files, `WORKSPACES_DIR`. | `PORTAL_UPDATE_RESTART` | — | From source: how the **Update** button in Settings → About restarts the portal after it has built the new version, and whether the **Restart** button may start a copy of it itself (`self`) or only stop it (`exit`, see [Restarting](#restarting)). `exit` only stops it, with status 75, for a portal that something else starts again (pm2, a restart policy); `self` starts the new one itself. Unset, a portal that is a systemd service's own process exits; one that only is in a service's control group (`npm start` under dash, a wrapper script, a cron job) starts its own copy and exits with status 75 as well; any other starts its own. A systemd unit needs `Restart=on-failure` or `Restart=always`. See [Settings](/guide/settings#update). | | `TZ` | UTC | The time zone of the portal's clock, such as `Europe/Berlin`. A container keeps UTC unless it is told one. The quiet hours of an [agent's heartbeat](/guide/agents#its-heartbeat) and the schedule of a repeating [routine](/guide/routines) are read on this clock. Both Compose files pass it from `.env` or the shell. | | `PORTAL_UPGRADE_BACKUP` | — | `skip` upgrades the database without the backup it is otherwise made first, for a volume with no room for the copy. Both Compose files pass it; see [Upgrading](/guide/upgrading#no-room-for-the-backup). | -| `PORTAL_DEFAULT_EXTENSIONS` | — | `off` keeps the portal from installing the extensions it starts with (the terminal, the theme pack) on its first start: you choose every extension yourself, in Settings → Extensions. See [What a portal starts with](/guide/extensions#what-a-portal-starts-with). | +| `PORTAL_DEFAULT_EXTENSIONS` | — | `off` keeps the portal from installing the extensions it starts with (the terminal, Git, Files, Canvases, Media and the theme pack) on its first start: you choose every extension yourself, in Settings → Extensions. See [What a portal starts with](/guide/extensions#what-a-portal-starts-with). | | `PORTAL_UPDATE_CHECK` | — | From source: `off` stops the portal from fetching `origin/main` by itself at the start and every six hours to see whether there is something new. The **Update** button in Settings → About still asks when it is pressed. See [Settings](/guide/settings#update). | | `EXECUTOR` | `host` | `host` or `container` — see [Architecture](/reference/architecture#executors). | | `WORKSPACE_ROOT` | `/workspaces` | Where workspaces live inside the container. | diff --git a/docs/guide/settings.md b/docs/guide/settings.md index 7ab252beb..cb21cb3fa 100644 --- a/docs/guide/settings.md +++ b/docs/guide/settings.md @@ -7,7 +7,7 @@ German. Navigation runs down the left edge, in four groups: | Group | Pages | | --- | --- | | **Models** | **Providers** — where models come from, see [Models and providers](/guide/models); **Defaults** — model, effort and context for new chats | -| **Agent** | **Tools**, **Modes**, **Skills**, **MCP**, **Extensions** | +| **Agent** | **Tools**, **Modes**, **Sandbox**, **Guard**, **Skills**, **MCP**, **Extensions** | | **Reach** | **Channels**, **People** | | **Portal** | **This browser**, **Authentication**, **Shortcuts**, **Logs**, **About** (with **Update** and **Restart**), **Advanced** | diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 4e2445c67..3a5d84ea3 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -68,6 +68,8 @@ an empty field inherits, and clearing one hands the setting back. | `UNDERSTORY_LLM_BODY_TIMEOUT_MS` | `1800000` | How long, in ms, a streaming answer from that model may go quiet between bytes before it is cut off. `0` turns the wait off. | | `PORTAL_FETCH_HEADERS_TIMEOUT_MS` | `1800000` | A ceiling of quiet, not of work: how long, in ms, any fetch the portal's process makes — pi's MCP tool calls among them — may wait for an answer's first byte before it is cut off; Node's own fetch cuts one off after five quiet minutes. The default outlasts Understory's own model wait, so a run allowed to think half an hour is not cut off mid-thought by the client waiting on it. `0` sets no wait. | | `PORTAL_FETCH_BODY_TIMEOUT_MS` | `1800000` | How long, in ms, a streaming answer to such a fetch may go quiet between its bytes before it is cut off; an answer that keeps moving is never cut by these waits. `0` turns the wait off. | +| `PORTAL_DEFAULT_EXTENSIONS` | — | `off` keeps the portal from installing the extensions it starts with (the terminal, Git, Files, Canvases, Media and the theme pack) on its first start; you choose every extension yourself in Settings → Extensions. See [What a portal starts with](/guide/extensions#what-a-portal-starts-with). | +| `BROWSER_BIND` | `127.0.0.1` | Read by the [Browser](/guide/browser) extension: the IPv4 address the browser container's own ports (`3010` and `3011`) listen on. The portal reaches them on `127.0.0.1` whatever it is; `0.0.0.0` opens them to every machine that can reach this one. Applies when the browser is installed, so remove it and install it again after a change. Both Compose files pass it on. | | `NPM_REGISTRY_URL` | `https://registry.npmjs.org` | Registry the package catalogue in Settings → Extensions searches. | | `DOCKER_SOCKET` | `/var/run/docker.sock` | The Docker socket the [Docker](/guide/docker) extension uses; Docker runs the containers of the [Browser](/guide/browser), [Voice](/guide/voice-service) and [Understory](/guide/features#memory-understory) extensions. The portal reads its own container's restart policy through it too, for Settings → About → Restart. | | `PORTAL_CONTAINER_NAME` | — (Compose: `pithagoras`) | The portal's own container name; a container an extension makes with Docker's `sharedNetwork()` (the voice service's and Understory's among them) joins its network, the container executor finds its mounts through it, and the database repair steps shown by the upgrade page use it. | From cdb3df2b13967f399b5ba9f4459f63eab06f5699 Mon Sep 17 00:00:00 2001 From: thecodacus Date: Fri, 23 Oct 2026 21:57:38 +0530 Subject: [PATCH 06/11] docs(upgrading): what a portal at 0.1.0 meets when it goes to 0.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guide had a section for each move made on main, written for a portal that followed main, and a section called "Upgrading from the first release" that was mostly 0.1.0's own notes for builds before it. It now reads true for a portal at 0.1.0: - From 0.1.0 to 0.2.0: schemas 3 to 11 in one start with one backup (portal-v3-….db), in order, each linked to its section; what the person does; the pages, settings and routes that moved, as 0.1.0 named them (Settings → Agent → Images, Settings → Add-ons, the allowlist on the Browser page); the new environment variables; what does not carry over. - New sections for steps that had none: agents' and routines' tools (schema 4), code mode off by default, the six extensions a portal starts with and the ones it does not install, the terminal and Git as extensions (routes, T, panel places), a tool's files on old picture calls (schema 6), the guard (taint only on a suspected injection, MCP output, heartbeat rules, a routine's exemptions), MCP servers taken out by hand (#295's first look, dotted names from #296), and pi 1.1. - Canvases: the routes, D and the panel's place. Media: 0.1.0's /settings/images and its Images group. Browser: the allowlist's old place and route. Voice: the container is given unless-stopped in place (#298), which needs Docker for extensions 1.2.0, and nothing remakes it before Voice is installed. - The section for builds before 0.1.0 is renamed "Upgrading to 0.1.0 from an earlier build" and keeps only 0.1.0's own items; the password, pi 1.1 and the voice container moved to 0.2.0's sections, and the heartbeat line says 0.2.0 removes a heartbeat's rules too. - Going back to 0.1.0: one procedure, from removing the extensions 0.1.0 does not have to remaking Understory's container. tests/deploy-files.test.mts follows the renamed section. tests/extension-packages.test.mts pins extensions/defaults.json to the six packages the guide lists. Co-Authored-By: Claude Opus 5.5 --- docs/guide/upgrading.md | 399 ++++++++++++++++++++++++++++-- tests/deploy-files.test.mts | 6 +- tests/extension-packages.test.mts | 17 ++ 3 files changed, 401 insertions(+), 21 deletions(-) diff --git a/docs/guide/upgrading.md b/docs/guide/upgrading.md index 83fa812d9..e76c7235d 100644 --- a/docs/guide/upgrading.md +++ b/docs/guide/upgrading.md @@ -63,6 +63,125 @@ mv portal.db portal-damaged.db && rm -f portal.db-wal portal.db-shm && mv portal The portal checks the recovered database before upgrading it. Rows on damaged pages can't be recovered, which usually means part of the event history of some conversations. Once you're happy with the result, delete `portal-damaged.db`. +## From 0.1.0 to 0.2.0 + +A portal at 0.1.0 takes every step of this release in its first start. The +database goes from schema 3 to 11 in one transaction, after one check and one +backup, `backups/portal-v3--