Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,12 @@ and versions follow [Semantic Versioning](https://semver.org/).
- A task that repeats the same failing step three times now pauses as stuck and asks what to do, instead of failing.

### Fixed
- A second Sentient data folder (a test or demo profile set with `SENTIENT_HOME`) no longer shares the keychain with
your own: it gets its own place for API keys, tokens and sign-ins, so it can't use, change or remove yours, and a
seeded demo can't message anyone through your Telegram bot. Your own data folder (`~/.sentient`) keeps every key
and sign-in as it is, with nothing to redo. `SENTIENT_KEYCHAIN_NAMESPACE=default` shares yours on purpose. The demo
seed scripts and `scripts/smoke.mjs` now pause the seeded tasks and turn off messaging deliveries unless given
`--keep-live`.
- Chats using Claude through your own Claude Code now show the context meter and the "getting long" warning at
85%: the context length is read from the matching Anthropic model, which LiteLLM knows, instead of nothing.
- Messages and memories saved at the same moment now always keep their order, and the daily upkeep of MEMORY.md and
Expand Down
4 changes: 3 additions & 1 deletion desktop/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,7 @@ node scripts/smoke.mjs chat shot.png 1440x900 # launch the built app, capture

Point `SENTIENT_HOME` at an empty folder and run the seed scripts with the engine's Python in this order, then
capture with the same `SENTIENT_HOME`: `seed-memory-skills.py --reset`, `seed-chats-devices-channels.py --add`,
`seed-tasks.py`, `seed-integrations-notifications.py --keep-db`, `seed-automations-usermodel.py`. They all use one fictional persona (Maya Rao). In Git Bash write routes without a leading slash. When several people or agents build at once,
`seed-tasks.py`, `seed-integrations-notifications.py --keep-db`, `seed-automations-usermodel.py`. They all use one fictional persona (Maya Rao).
The seeders and `smoke.mjs` pause the home's tasks and turn off its messaging deliveries unless given `--keep-live`
(`scripts/seed_safety.py`), and a test home has its own keychain namespace, so a demo never reaches a real chat. In Git Bash write routes without a leading slash. When several people or agents build at once,
take `desktop/.build.lock` before `npm run build` and remove it after, because `out/` is shared.
4 changes: 4 additions & 0 deletions desktop/scripts/seed-automations-usermodel.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@

REPO_ROOT = Path(__file__).resolve().parents[2]
OFFLINE_API_BASE = "http://10.255.255.1:11434"
import seed_safety # noqa: E402 (next to this script)


def _args() -> argparse.Namespace:
Expand All @@ -38,6 +39,7 @@ def _args() -> argparse.Namespace:
help="folder to seed (created if missing); defaults to SENTIENT_HOME")
p.add_argument("--reset", action="store_true", help="delete sentient.db in that home first")
p.add_argument("--theme", choices=["dark", "light", "system"], default=None)
p.add_argument(seed_safety.KEEP_LIVE_FLAG, action="store_true", help=seed_safety.KEEP_LIVE_HELP)
args = p.parse_args()
if not args.home:
p.error("pass a folder or set SENTIENT_HOME")
Expand Down Expand Up @@ -402,6 +404,8 @@ async def main() -> None:
finally:
await app.stop()
print(json.dumps({"home": str(HOME), **REPORT}, indent=2, ensure_ascii=False))
if not ARGS.keep_live:
seed_safety.announce(seed_safety.quiet(HOME))


if __name__ == "__main__":
Expand Down
7 changes: 7 additions & 0 deletions desktop/scripts/seed-chats-devices-channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
from pathlib import Path
from typing import Any

import seed_safety # next to this script

REPO = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO))

Expand Down Expand Up @@ -778,6 +780,7 @@ def main() -> None:
ap.add_argument("--theme", choices=["dark", "light", "system"], default="dark")
ap.add_argument("--add", action="store_true",
help="add this demo to a home other seed scripts already filled (keeps its data and config)")
ap.add_argument(seed_safety.KEEP_LIVE_FLAG, action="store_true", help=seed_safety.KEEP_LIVE_HELP)
args = ap.parse_args()
if not args.home:
ap.error("--home or SENTIENT_HOME is required")
Expand All @@ -786,6 +789,8 @@ def main() -> None:
home.mkdir(parents=True, exist_ok=True)
os.environ["SENTIENT_HOME"] = str(home)
asyncio.run(seed(home, args.theme, add=True))
if not args.keep_live:
seed_safety.announce(seed_safety.quiet(home))
return
if home.exists() and any(home.iterdir()):
if not args.reset:
Expand All @@ -797,6 +802,8 @@ def main() -> None:
(home / MARKER).write_text("created by desktop/scripts/seed-chats-devices-channels.py\n", encoding="utf-8")
os.environ["SENTIENT_HOME"] = str(home)
asyncio.run(seed(home, args.theme))
if not args.keep_live:
seed_safety.announce(seed_safety.quiet(home))


if __name__ == "__main__":
Expand Down
7 changes: 7 additions & 0 deletions desktop/scripts/seed-integrations-notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,21 @@
import asyncio
import json
import os
import sys
from collections.abc import AsyncIterator
from datetime import UTC, datetime, timedelta
from pathlib import Path

import seed_safety # next to this script


def _parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("home", nargs="?", default=os.environ.get("SENTIENT_HOME") or None,
help="SENTIENT_HOME folder to seed (created if missing); defaults to the SENTIENT_HOME variable")
p.add_argument("--keep-db", action="store_true",
help="keep the existing sentient.db and add to it instead of starting from an empty database")
p.add_argument(seed_safety.KEEP_LIVE_FLAG, action="store_true", help=seed_safety.KEEP_LIVE_HELP)
args = p.parse_args()
if not args.home:
p.error("pass a folder or set SENTIENT_HOME")
Expand All @@ -52,6 +56,7 @@ def _parse_args() -> argparse.Namespace:
HOME = Path(ARGS.home).expanduser()
KEEP_DB: bool = ARGS.keep_db
os.environ["SENTIENT_HOME"] = str(HOME)
sys.path.insert(0, str(Path(__file__).resolve().parents[2])) # this checkout's engine, like the other seeders

# Import after SENTIENT_HOME is set.
from sentient.app import SentientApp # noqa: E402
Expand Down Expand Up @@ -193,6 +198,8 @@ async def seed() -> None:
finally:
await app.stop()
print(f"seeded {HOME}" + (" (kept existing database)" if KEEP_DB else ""))
if not ARGS.keep_live:
seed_safety.announce(seed_safety.quiet(HOME))


async def seed_integrations(app: SentientApp) -> None:
Expand Down
5 changes: 5 additions & 0 deletions desktop/scripts/seed-memory-skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
from datetime import UTC, datetime, timedelta
from pathlib import Path

import seed_safety # next to this script

REPO = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO))

Expand Down Expand Up @@ -622,6 +624,7 @@ def main() -> None:
ap.add_argument("--reset", action="store_true", help=f"wipe the home first (only if it contains {MARKER})")
ap.add_argument("--theme", choices=["dark", "light", "system"], default="dark")
ap.add_argument("--theme-only", action="store_true", help="only change ui.theme in an already seeded home")
ap.add_argument(seed_safety.KEEP_LIVE_FLAG, action="store_true", help=seed_safety.KEEP_LIVE_HELP)
args = ap.parse_args()
if not args.home:
ap.error("--home or SENTIENT_HOME is required")
Expand All @@ -639,6 +642,8 @@ def main() -> None:
(home / MARKER).write_text("created by desktop/scripts/seed-memory-skills.py\n", encoding="utf-8")
os.environ["SENTIENT_HOME"] = str(home)
asyncio.run(seed(home, args.theme))
if not args.keep_live:
seed_safety.announce(seed_safety.quiet(home))


if __name__ == "__main__":
Expand Down
4 changes: 4 additions & 0 deletions desktop/scripts/seed-tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

REPO_ROOT = Path(__file__).resolve().parents[2]
OFFLINE_API_BASE = "http://10.255.255.1:11434"
import seed_safety # noqa: E402 (next to this script)


def _parse_args() -> argparse.Namespace:
Expand All @@ -39,6 +40,7 @@ def _parse_args() -> argparse.Namespace:
p.add_argument("--timezone", default="Asia/Kolkata", help="assistant timezone written to config")
p.add_argument("--theme", choices=["dark", "light", "system"], default=None, help="set ui.theme (for screenshots)")
p.add_argument("--config-only", action="store_true", help="only update config.yaml (theme, models); keep tasks")
p.add_argument(seed_safety.KEEP_LIVE_FLAG, action="store_true", help=seed_safety.KEEP_LIVE_HELP)
args = p.parse_args()
if not args.home:
p.error("pass a folder or set SENTIENT_HOME")
Expand Down Expand Up @@ -759,6 +761,8 @@ async def main() -> None:
print(f"Seeded {len(ids)} tasks into {paths.home()}")
for key, task_id in ids.items():
print(f" {key:<13} {task_id}")
if not ARGS.keep_live:
seed_safety.announce(seed_safety.quiet(paths.home()))


if __name__ == "__main__":
Expand Down
70 changes: 70 additions & 0 deletions desktop/scripts/seed_safety.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""Keep a seeded demo home quiet, so running the app on it can't reach anyone (issue #302).

.venv/Scripts/python.exe desktop/scripts/seed_safety.py <SENTIENT_HOME>

Pauses every task (``enabled = 0``: the scheduler and triggers skip it), turns off every paired chat's delivery
switch and turns messaging apps off in config.yaml (``channels.enabled: false``), so no seeded task runs and nothing
is sent through Telegram, WhatsApp or Discord. The seed scripts call ``quiet()`` when they finish and
``scripts/smoke.mjs`` runs this file before it starts the app; both skip it with ``--keep-live``.

It refuses the default data folder (``~/.sentient``): that is the real user's, never a demo.
"""

from __future__ import annotations

import os
import sqlite3
import sys
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parents[2]
KEEP_LIVE_FLAG = "--keep-live"
KEEP_LIVE_HELP = "keep seeded tasks active and messaging deliveries on (by default they are paused, see seed_safety.py)"


def _table_has(db: sqlite3.Connection, table: str, column: str) -> bool:
return any(row[1] == column for row in db.execute(f"PRAGMA table_info({table})"))


def quiet(home: str | Path) -> dict:
"""Pause the tasks and turn off channel deliveries in ``home``. Returns what changed."""
home = Path(home).expanduser().resolve()
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from sentient import paths, secrets
from sentient.config.loader import load_config, save_config

if secrets.is_default_home(home):
raise SystemExit(f"seed_safety: {home} is the default data folder (the real one); refusing to change it.")
os.environ["SENTIENT_HOME"] = str(home) # paths.* read it on every call
report = {"tasks_paused": 0, "chats_muted": 0, "channels_off": False}
if paths.config_file().exists() or paths.db_file().exists():
cfg = load_config()
if cfg.channels.enabled:
cfg.channels.enabled = False
save_config(cfg)
report["channels_off"] = True
if paths.db_file().exists():
db = sqlite3.connect(paths.db_file())
try:
with db:
if _table_has(db, "tasks", "enabled"):
report["tasks_paused"] = db.execute("UPDATE tasks SET enabled = 0 WHERE enabled != 0").rowcount
if _table_has(db, "channel_chats", "deliver"):
report["chats_muted"] = db.execute(
"UPDATE channel_chats SET deliver = 0 WHERE deliver != 0").rowcount
finally:
db.close()
return report


def announce(report: dict) -> None:
print(f"quiet demo home: {report['tasks_paused']} tasks paused, {report['chats_muted']} chat deliveries off, "
f"messaging apps {'turned off' if report['channels_off'] else 'already off'} "
f"(pass {KEEP_LIVE_FLAG} to keep them live)")


if __name__ == "__main__":
if len(sys.argv) != 2:
raise SystemExit("usage: seed_safety.py <SENTIENT_HOME>")
announce(quiet(sys.argv[1]))
31 changes: 27 additions & 4 deletions desktop/scripts/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,28 @@
* Visual smoke test: launch the built app on a route, capture a PNG, exit.
*
* npm run build
* node scripts/smoke.mjs <route> <out.png> [WIDTHxHEIGHT]
* node scripts/smoke.mjs <route> <out.png> [WIDTHxHEIGHT] [--keep-live]
*
* Honors SENTIENT_HOME (use a throwaway profile). See electron/main/smoke.ts.
* Before it starts the app on a SENTIENT_HOME it pauses that home's tasks and turns off its messaging deliveries
* (scripts/seed_safety.py, issue #302), so a seeded demo never runs a task or messages anyone. --keep-live skips that.
*/
import { spawn } from 'node:child_process'
import { spawn, spawnSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'

const here = dirname(fileURLToPath(import.meta.url))
const root = resolve(here, '..')
const [rawRoute = '/', out, size = '1280x820'] = process.argv.slice(2)
const argv = process.argv.slice(2)
const keepLive = argv.includes('--keep-live')
const [rawRoute = '/', out, size = '1280x820'] = argv.filter((a) => a !== '--keep-live')
// Accept "settings/models" or "#/settings/models". (Git Bash rewrites a leading "/" into a
// Windows path unless MSYS_NO_PATHCONV=1, so prefer the slash-less form there.)
const route = `/${rawRoute.replace(/^#?\/*/, '')}`
if (!out) {
console.error('usage: node scripts/smoke.mjs <route> <out.png> [WIDTHxHEIGHT]')
console.error('usage: node scripts/smoke.mjs <route> <out.png> [WIDTHxHEIGHT] [--keep-live]')
process.exit(64)
}
const main = join(root, 'out', 'main', 'index.js')
Expand All @@ -29,6 +33,25 @@ if (!existsSync(main)) {
process.exit(1)
}

// The same Python the app would start the engine with (electron/main/paths.ts): SENTIENT_PYTHON, else the repo's .venv.
function enginePython() {
if (process.env.SENTIENT_PYTHON?.trim()) return process.env.SENTIENT_PYTHON.trim()
const venv = join(root, '..', '.venv', process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python')
if (existsSync(venv)) return venv
return process.platform === 'win32' ? 'python' : 'python3'
}

const home = process.env.SENTIENT_HOME?.trim()
if (home && !keepLive) {
const quiet = spawnSync(enginePython(), [join(here, 'seed_safety.py'), home], { stdio: 'inherit' })
if (quiet.status !== 0) {
console.error('[smoke] could not pause the tasks and messaging deliveries in SENTIENT_HOME (pass --keep-live to skip)')
process.exit(1)
}
} else if (!home) {
console.warn('[smoke] SENTIENT_HOME is not set: the app uses your real data folder')
}

const require = createRequire(import.meta.url)
const electron = require('electron')
const env = { ...process.env, SENTIENT_SMOKE_SCREENSHOT: resolve(out), SENTIENT_SMOKE_ROUTE: route, SENTIENT_SMOKE_SIZE: size }
Expand Down
13 changes: 11 additions & 2 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,15 @@ accept or loosen a rule.
this way has it as its `error` (instead of "Sorry, the AI model is unavailable right now...") and a task route
answers `503` with it as `detail`. The check-up reports a `claude-code/` model without calling it (`fail` for roles other than primary, voice
and vision). Stop everything kills every running Claude Code process tree.
- **Keychain namespaces (#302).** Every secret (API keys, integration tokens, channel bot tokens, MCP headers and
sign-ins, the ChatGPT sign-in) is an OS keychain entry under one service per data folder. The default data folder
(`~/.sentient`, used when `SENTIENT_HOME` is unset or points at it) uses the service `sentient`, the entries every
earlier version wrote, so nothing moves. Any other `SENTIENT_HOME` uses `sentient-<first 12 hex of the SHA-256 of
its resolved, case-folded path>` and can't see the default folder's entries. `SENTIENT_KEYCHAIN_NAMESPACE`
overrides it: exactly `default` (any case) uses `sentient`; any other value gives `sentient-<word>` (lower case,
other characters become `-`; a value left empty by that, or spelled like `default!`, gets `sentient-x<hash>`), so
only the word `default` ever shares the default folder's entries. Entry names, and the `<name>:1`, `<name>:2`... parts of long values, are the same in every namespace. The
engine logs the service it uses at start (never a value). Every route below reads and writes this namespace only.
- `GET /api/secrets` → `[{name, set: bool, source: "keychain"|"env"|null, kind: "provider"|"integration"}]` for every provider + integration secret name
- `PUT /api/secrets/{name}` `{value}` → `{ok}` (stored in OS keychain; never echoed back). `chatgpt` → 400: it is a
sign-in, not a key.
Expand Down Expand Up @@ -851,7 +860,7 @@ A run counts as missed when it is more than `max(300, 3 × tasks.tick_seconds)`
- `status: "needs_sign_in"`: the server answered 401, or `auth` is `oauth` with no stored sign-in, or the stored sign-in expired and could not be refreshed. `error` says what to do: `"This server asks you to sign in."` (none), `"The server didn't accept the saved headers. Change their values with the key button on the server."` (headers), `"Sign in to use this server."` (oauth). The engine retries a server in this state every 5 minutes, and at once after a sign-in or a test.
- `POST /api/integrations/mcp` `{name, transport, command?, args?, url?, env?, headers?, auth?, enabled?, access?}` → server object (waits up to 15 s for the first connection; replaces a server with the same name; 400 on invalid input; `access` is saved before the server starts, and left out keeps the current choice)
- `headers`: `{name: value}`; values go to the keychain. `auth` defaults to `headers` when headers are given, else `none`. 400 when `auth` is `headers` without headers, a header name or value is invalid, or a stdio server has headers or `auth` other than `none`.
- Headers not given are deleted. The keychain is shared by every Sentient setup on the computer, so a stored sign-in
- Headers not given are deleted. The keychain is shared by every Sentient setup in the same keychain namespace (§3), so a stored sign-in
(tokens and client registration) records the server URL it was made for (`server_url`) and is kept when a
server with the same name and URL is added (in this setup or another), then used. It is dropped when the URL
differs, and an older record without `server_url` is dropped only when this setup had the server at another
Expand Down Expand Up @@ -2171,7 +2180,7 @@ backup API while the engine runs, never a raw copy of the live WAL file), `confi
`files/` and `tls/` (the devices certificate, so paired devices keep trusting it). Never inside: keychain secrets (API
keys, tokens, sign-ins), `gateway.token`, `logs/`, the browser profile, WhatsApp's link, downloaded voice models,
scratch folders, the backups folder itself and open incognito chats (removed from the copy with secure delete). A backup is complete or not made: if a file can't be read (open in another program), the backup fails with a plain
`detail` naming it, since a restore replaces whole folders. The manifest lists the sign-ins that were in the keychain (`sign_ins`)
`detail` naming it, since a restore replaces whole folders. The manifest lists the sign-ins that were in this data folder's keychain namespace (§3, `sign_ins`)
so the window can say which to redo. Names: `sentient-backup-YYYYMMDD-HHMMSS.zip` (`-auto` for scheduled ones) and
`sentient-snapshot-YYYYMMDD-HHMMSS.zip`.

Expand Down
Loading
Loading