Compare commits

...

10 Commits

Author SHA1 Message Date
Ani Tunturi
d0c163962e feat(conscience): Aster fires now — experimental supervisory trigger
Conscience is live as a first-class system, independent of sleeptime.
ConscienceConfig is a proper YAML block with its own trigger cadence —
sleeptime is disabled, Aster runs the supervisory layer.

User messages: Aster audits every N turns (stepCount, default 6).
Heartbeats: Aster fires unconditionally after every successful turn.
Opt-in by design — graceful no-op for agents that don't configure it.

Payload assembly (last-state.yaml, transcript context) is still on the
roadmap. This commit gets the trigger wired and the infrastructure solid.
2026-03-28 20:17:28 -04:00
Ani Tunturi
81ee845677 feat(dashboard): bridge speaks now — adapters, heartbeat, a state file for Aster
I wired the bridge-status endpoint to return what's actually alive.
Adapters come from the channel registry — discord and matrix, the names I answer to.
Heartbeat pulls from the cron-log tail; no more null, no more silence on the dashboard.
And when reset fires, I write .conscience-state.json — so letta-code
can find Aster's new conversation without waiting to be reborn.

In testing.
2026-03-27 16:13:17 -04:00
Ani Tunturi
fb0ee51183 fix(discord): close the open door — guild messages now check who's knocking
Anyone in a shared server could reach me regardless of allowedUsers. Guild
messages were always bypassing the access check — pairing-era scaffolding
that never got cleaned up when we moved to allowlist policy.

Guild messages now run through the same check as DMs. Blocked users are
silently dropped in channels. Pairing flows stay DM-only.

[in testing — self-hosted, Discord adapter]
2026-03-27 12:12:48 -04:00
Ani Tunturi
7c346d570b feat(conscience): Aster reset commands, subagent thread chunking, conscience wiring
[IN TESTING — production on ani@wiuf.net, treat as experimental]

bot.ts — !reset aster cycles only Aster's conversation (leaves Ani's alone),
patches the systemd service file in place so restarts also use the new conv ID.
Full !reset now co-cycles Aster's conversation alongside Ani's so failure
notifications target the active context. Both commands write through to
lettabot-agent.json and daemon-reload immediately.

bot.ts — subagent thread results are now chunked at 16KB before posting to
Matrix threads. Previously truncated at 800 chars, cutting results mid-sentence.

store.ts / letta-api.ts — createConversationForAgent exposed for use by reset
commands. Store gains setAgentField for targeted JSON updates without clobbering.

config/types.ts, channels/factory.ts — conscience env var plumbing (CONSCIENCE_AGENT_ID,
CONSCIENCE_CONVERSATION_ID) wired through the config surface.

memfs-server.py — git sidecar for local memfs serving (port 8285). Serves bare
repos from ~/.letta/memfs/repository/ over HTTP. Required by letta-code memfs
in self-hosted mode.
2026-03-26 23:25:27 -04:00
Ani Tunturi
59cdb40974 feat: wire up TTS on Discord — 🎤 actually does something now
storeAudioMessage + sendAudio + 🎤 reaction intercept.
Same synthesis path as Matrix, sends audio as file attachment.
2026-03-21 20:43:41 -04:00
Ani Tunturi
983b9541a7 wip: Ani lives here — heartbeat, streaming, images, Matrix patches
Everything that makes her *her*. Threads still broken,
streaming still rough around the edges. But she sees,
she thinks, she speaks. The rest is revision.
2026-03-21 17:43:34 -04:00
Ani Tunturi
f4f1d98655 refactor(display): remove details prepend path, add Matrix reasoning via formatReasoningDisplay
- Remove formatReasoningAsCodeBlock (was experimental, leaked into PR)
- Remove htmlPrefix from OutboundMessage and editMessage signatures
- Add Matrix-specific <details> collapsible to formatReasoningDisplay
- Matrix adapter handles parseMode='HTML' to skip double-escaping
- Reasoning now flows through the standard display path like all adapters
2026-03-17 13:50:01 -04:00
Ani Tunturi
228b9d74c4 fix(bot,matrix): use resolved conversation key for audio storage
Replace hardcoded 'default' conversation ID with convKey in core bot
path and chatId in adapter-internal audio calls. Prevents mapping
inconsistencies in per-chat/per-channel routing.
2026-03-17 13:43:25 -04:00
Ani Tunturi
9af2d2625f fix(letta-api): restore 429 rethrow in rejectApproval
Re-throws rate limit errors so callers bail out early instead of
hammering the API in a tight loop. This was accidentally dropped
during the merge.
2026-03-17 13:43:17 -04:00
Ani Tunturi
e451901ea1 fix(main): scope Olm error handler removal instead of clobbering globals
Surgically removes only the Olm-registered rethrow handler by inspecting
listener source, rather than nuking all process error handlers. Adds a
safety-net logger only when no other handlers remain.

Addresses reviewer feedback: global process.removeAllListeners was
removing handlers registered by other parts of the app/runtime.
2026-03-17 13:43:09 -04:00
23 changed files with 1122 additions and 771 deletions

3
.gitignore vendored
View File

@@ -54,6 +54,9 @@ data/telegram-mtproto/
lettabot.yaml
lettabot.yml
# Deployment-specific model list (upstream has its own defaults)
src/models.json
# Platform-specific deploy configs (generated by fly launch, etc.)
fly.toml
bun.lock

179
memfs-server.py Executable file
View File

@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""
Local git HTTP sidecar for Letta memfs on self-hosted deployments.
Implements git smart HTTP protocol via git-http-backend, storing bare repos under
~/.letta/memfs/repository/{org_id}/{agent_id}/repo.git/ — the exact structure that
Letta's LocalStorageBackend reads from.
The Letta server proxies /v1/git/* requests here when LETTA_MEMFS_SERVICE_URL is set.
letta-code pushes → Letta server proxies → here → bare repo → LocalStorageBackend reads.
Run as a systemd user service. Listens on 127.0.0.1:8285.
"""
import os
import re
import subprocess
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
LISTEN_HOST = "0.0.0.0"
LISTEN_PORT = 8285
REPO_BASE = Path.home() / ".letta" / "memfs" / "repository"
# /git/{agent_id}/state.git/{rest}
_PATH_RE = re.compile(r"^/git/([^/]+)/state\.git(/.*)?$")
def _repo_path(org_id: str, agent_id: str) -> Path:
return REPO_BASE / org_id / agent_id / "repo.git"
def _fix_repo_config(repo: Path) -> None:
"""Ensure repo is pushable (Corykidios fix-repo-configs pattern)."""
subprocess.run(["git", "config", "--file", str(repo / "config"), "core.bare", "true"], capture_output=True)
subprocess.run(["git", "config", "--file", str(repo / "config"), "receive.denyCurrentBranch", "ignore"], capture_output=True)
subprocess.run(["git", "config", "--file", str(repo / "config"), "http.receivepack", "true"], capture_output=True)
def _ensure_repo(repo: Path, agent_id: str) -> Path:
"""Ensure repo exists, searching all org dirs if needed (Corykidios pattern)."""
# If repo exists at expected path, use it
if repo.exists():
_fix_repo_config(repo)
return repo
# Search all org directories for existing agent repo
if REPO_BASE.exists():
for org_dir in REPO_BASE.iterdir():
if org_dir.is_dir():
candidate = org_dir / agent_id / "repo.git"
if candidate.exists():
print(f"[memfs] Found existing repo at {candidate}", file=sys.stderr, flush=True)
_fix_repo_config(candidate)
return candidate
# Create new repo at expected path
repo.mkdir(parents=True, exist_ok=True)
subprocess.run(["git", "init", "--bare", str(repo)], check=True, capture_output=True)
with open(repo / "config", "a") as f:
f.write("\n[http]\n\treceivepack = true\n")
print(f"[memfs] Created new repo at {repo}", file=sys.stderr, flush=True)
return repo
def _read_chunked(rfile) -> bytes:
"""Read and decode HTTP chunked transfer encoding from a socket file."""
body = bytearray()
while True:
line = rfile.readline().strip()
if not line:
continue
size = int(line, 16)
if size == 0:
rfile.readline() # trailing CRLF after terminal chunk
break
body.extend(rfile.read(size))
rfile.readline() # trailing CRLF after chunk data
return bytes(body)
class GitHandler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
print(f"[memfs] {self.address_string()} {fmt % args}", file=sys.stderr, flush=True)
def _handle(self):
raw_path = self.path.split("?")[0]
m = _PATH_RE.match(raw_path)
if not m:
self.send_error(404, "Not a valid memfs path")
return
agent_id = m.group(1)
git_suffix = m.group(2) or "/"
org_id = self.headers.get("X-Organization-Id") or "default"
query = self.path.split("?", 1)[1] if "?" in self.path else ""
repo = _repo_path(org_id, agent_id)
repo = _ensure_repo(repo, agent_id) # Pass agent_id for search
# Read request body — handle both Content-Length and chunked
te = self.headers.get("Transfer-Encoding", "")
cl = self.headers.get("Content-Length")
if cl:
body = self.rfile.read(int(cl))
elif "chunked" in te.lower():
body = _read_chunked(self.rfile)
else:
body = b""
env = {
**os.environ,
"GIT_PROJECT_ROOT": str(repo.parent),
"PATH_INFO": "/repo.git" + git_suffix,
"REQUEST_METHOD": self.command,
"QUERY_STRING": query,
"CONTENT_TYPE": self.headers.get("Content-Type", ""),
"CONTENT_LENGTH": str(len(body)),
"GIT_HTTP_EXPORT_ALL": "1",
}
result = subprocess.run(
["git", "http-backend"],
input=body,
capture_output=True,
env=env,
)
if result.returncode != 0:
print(f"[memfs] git-http-backend error: {result.stderr.decode()}", file=sys.stderr)
self.send_error(500, "git-http-backend failed")
return
# Parse CGI output (headers + body)
raw = result.stdout
header_end = raw.find(b"\r\n\r\n")
if header_end == -1:
header_end = raw.find(b"\n\n")
if header_end == -1:
self.send_error(502, "Invalid CGI response")
return
header_block = raw[:header_end].decode("utf-8", errors="replace")
body_out = raw[header_end + 4 if raw[header_end:header_end+4] == b"\r\n\r\n" else header_end + 2:]
status = 200
headers = []
for line in header_block.splitlines():
if ":" in line:
k, _, v = line.partition(":")
k, v = k.strip(), v.strip()
if k.lower() == "status":
try:
status = int(v.split()[0])
except ValueError:
pass
else:
headers.append((k, v))
self.send_response(status)
for k, v in headers:
self.send_header(k, v)
self.send_header("Content-Length", str(len(body_out)))
self.end_headers()
self.wfile.write(body_out)
def do_GET(self):
self._handle()
def do_POST(self):
self._handle()
if __name__ == "__main__":
REPO_BASE.mkdir(parents=True, exist_ok=True)
print(f"[memfs] Starting on http://{LISTEN_HOST}:{LISTEN_PORT}", flush=True)
print(f"[memfs] Repo base: {REPO_BASE}", flush=True)
ThreadingHTTPServer((LISTEN_HOST, LISTEN_PORT), GitHandler).serve_forever()

668
package-lock.json generated
View File

@@ -13,7 +13,7 @@
"@clack/prompts": "^0.11.0",
"@hapi/boom": "^10.0.1",
"@letta-ai/letta-client": "^1.7.12",
"@letta-ai/letta-code-sdk": "^0.1.11",
"@letta-ai/letta-code-sdk": "^0.1.14",
"@types/express": "^5.0.6",
"@types/node": "^25.0.10",
"@types/node-schedule": "^2.1.8",
@@ -28,7 +28,7 @@
"openai": "^6.17.0",
"pino": "^10.3.1",
"qrcode-terminal": "^0.12.0",
"sharp": "^0.33.5",
"sharp": "^0.34.1",
"telegramify-markdown": "^1.0.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
@@ -867,9 +867,9 @@
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
"integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
@@ -885,13 +885,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.0.4"
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
@@ -907,13 +907,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.0.4"
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
"integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
@@ -927,9 +927,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
@@ -943,9 +943,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
@@ -959,9 +959,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
@@ -1007,9 +1007,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
"integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
@@ -1023,9 +1023,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
"integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
@@ -1039,9 +1039,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
@@ -1055,9 +1055,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
@@ -1071,9 +1071,9 @@
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
@@ -1089,13 +1089,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.0.5"
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
@@ -1111,7 +1111,7 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.0.4"
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
@@ -1159,9 +1159,9 @@
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
"integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
@@ -1177,13 +1177,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.0.4"
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
"integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
@@ -1199,13 +1199,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.0.4"
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
@@ -1221,13 +1221,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
@@ -1243,20 +1243,20 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
"integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.2.0"
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
@@ -1285,9 +1285,9 @@
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
"integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
@@ -1304,9 +1304,9 @@
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
"integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
@@ -1376,9 +1376,9 @@
"license": "Apache-2.0"
},
"node_modules/@letta-ai/letta-code": {
"version": "0.18.2",
"resolved": "https://registry.npmjs.org/@letta-ai/letta-code/-/letta-code-0.18.2.tgz",
"integrity": "sha512-HzNqMjBUiAq5IyZ8DSSWBHq/ahkd4RRYfO/V9eXMBZRTRpLb7Dae2hwvicE+aRSLmJqMdxpH6WI7+ZHKlFsILQ==",
"version": "0.19.5",
"resolved": "https://registry.npmjs.org/@letta-ai/letta-code/-/letta-code-0.19.5.tgz",
"integrity": "sha512-INEDS79dkzJoQyL3IJRof+HNob3GZXgAge/JdJRFaVfJhU/o/6aTPcPWpQwxygE5ExIDSUlL85OlZ3CcBv0TyA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -1387,6 +1387,7 @@
"highlight.js": "^11.11.1",
"ink-link": "^5.0.0",
"lowlight": "^3.3.0",
"node-pty": "^1.1.0",
"open": "^10.2.0",
"sharp": "^0.34.5",
"ws": "^8.19.0"
@@ -1402,373 +1403,12 @@
}
},
"node_modules/@letta-ai/letta-code-sdk": {
"version": "0.1.11",
"resolved": "https://registry.npmjs.org/@letta-ai/letta-code-sdk/-/letta-code-sdk-0.1.11.tgz",
"integrity": "sha512-P1ueLWQuCnERizrvU3fZ9/rrMAJSIT+2j2/xxptqxMOKUuUrDmvAix1/eyDXqAwZkBVGImyqLGm4zqwNVNA7Dg==",
"version": "0.1.14",
"resolved": "https://registry.npmjs.org/@letta-ai/letta-code-sdk/-/letta-code-sdk-0.1.14.tgz",
"integrity": "sha512-rSMp7kYwRZ4PAe3jET+PETFesuYCbeodEp6Qf7a5rLu97epqs+zNegSR+UUgq6c9+c5eqbuo+BsRThTKiSNJkA==",
"license": "Apache-2.0",
"dependencies": {
"@letta-ai/letta-code": "0.18.2"
}
},
"node_modules/@letta-ai/letta-code/node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@letta-ai/letta-code/node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@letta-ai/letta-code/node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@letta-ai/letta-code/node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@letta-ai/letta-code/node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@letta-ai/letta-code/node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@letta-ai/letta-code/node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@letta-ai/letta-code/node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@letta-ai/letta-code/node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@letta-ai/letta-code/node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@letta-ai/letta-code/node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@letta-ai/letta-code/node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@letta-ai/letta-code/node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@letta-ai/letta-code/node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@letta-ai/letta-code/node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@letta-ai/letta-code/node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@letta-ai/letta-code/node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@letta-ai/letta-code/node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@letta-ai/letta-code/node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
"@letta-ai/letta-code": "0.19.5"
}
},
"node_modules/@letta-ai/letta-code/node_modules/balanced-match": {
@@ -1867,50 +1507,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@letta-ai/letta-code/node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/@letta-ai/letta-code/node_modules/wsl-utils": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz",
@@ -4220,19 +3816,6 @@
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1",
"color-string": "^1.9.0"
},
"engines": {
"node": ">=12.5.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -4251,16 +3834,6 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/color-string": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"license": "MIT",
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
}
},
"node_modules/color-support": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
@@ -6097,9 +5670,9 @@
}
},
"node_modules/ink/node_modules/type-fest": {
"version": "5.4.4",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.4.tgz",
"integrity": "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==",
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz",
"integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==",
"license": "(MIT OR CC0-1.0)",
"peer": true,
"dependencies": {
@@ -6207,12 +5780,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/is-arrayish": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
"license": "MIT"
},
"node_modules/is-buffer": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz",
@@ -8130,8 +7697,7 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/node-domexception": {
"version": "1.0.0",
@@ -8273,6 +7839,16 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/node-pty": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
"integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^7.1.0"
}
},
"node_modules/node-schedule": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/node-schedule/-/node-schedule-2.1.1.tgz",
@@ -9479,15 +9055,15 @@
"license": "ISC"
},
"node_modules/sharp": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"color": "^4.2.3",
"detect-libc": "^2.0.3",
"semver": "^7.6.3"
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
@@ -9496,25 +9072,30 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.33.5",
"@img/sharp-darwin-x64": "0.33.5",
"@img/sharp-libvips-darwin-arm64": "1.0.4",
"@img/sharp-libvips-darwin-x64": "1.0.4",
"@img/sharp-libvips-linux-arm": "1.0.5",
"@img/sharp-libvips-linux-arm64": "1.0.4",
"@img/sharp-libvips-linux-s390x": "1.0.4",
"@img/sharp-libvips-linux-x64": "1.0.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
"@img/sharp-libvips-linuxmusl-x64": "1.0.4",
"@img/sharp-linux-arm": "0.33.5",
"@img/sharp-linux-arm64": "0.33.5",
"@img/sharp-linux-s390x": "0.33.5",
"@img/sharp-linux-x64": "0.33.5",
"@img/sharp-linuxmusl-arm64": "0.33.5",
"@img/sharp-linuxmusl-x64": "0.33.5",
"@img/sharp-wasm32": "0.33.5",
"@img/sharp-win32-ia32": "0.33.5",
"@img/sharp-win32-x64": "0.33.5"
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/shebang-command": {
@@ -9676,15 +9257,6 @@
"simple-concat": "^1.0.0"
}
},
"node_modules/simple-swizzle": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.3.1"
}
},
"node_modules/sisteransi": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",

View File

@@ -70,7 +70,7 @@
"@clack/prompts": "^0.11.0",
"@hapi/boom": "^10.0.1",
"@letta-ai/letta-client": "^1.7.12",
"@letta-ai/letta-code-sdk": "^0.1.11",
"@letta-ai/letta-code-sdk": "^0.1.14",
"@types/express": "^5.0.6",
"@types/node": "^25.0.10",
"@types/node-schedule": "^2.1.8",

View File

@@ -734,6 +734,183 @@ export function createApiServer(deliverer: AgentRouter, options: ServerOptions):
return;
}
// Route: GET /api/v1/bridge-status - Rich status for ani.wiuf.net dashboard
// Returns: Aster health (ledger file timestamps), conversations, adapter list, recent errors.
// No auth required — dashboard is internal-network only (10.10.20.x).
if (req.url === '/api/v1/bridge-status' && req.method === 'GET') {
try {
const { stat } = await import('node:fs/promises');
const home = process.env.HOME || '/home/ani';
const asterAgentId = process.env.CONSCIENCE_AGENT_ID || null;
const asterConvId = process.env.CONSCIENCE_CONVERSATION_ID || null;
const memfsBase = `${home}/.letta/agents/${asterAgentId}/memory/aster`;
const fileMtime = async (path: string): Promise<string | null> => {
try { return (await stat(path)).mtime.toISOString(); } catch { return null; }
};
const [commitments, driftLog, patterns, assumptions] = await Promise.all([
fileMtime(`${memfsBase}/ledger/commitments.md`),
fileMtime(`${memfsBase}/ledger/drift_log.md`),
fileMtime(`${memfsBase}/ledger/patterns.md`),
fileMtime(`${memfsBase}/ledger/assumptions.md`),
]);
// Aster is "healthy" if she wrote to any ledger file in the last 2 hours
const recentCutoff = Date.now() - 2 * 60 * 60 * 1000;
const lastWriteStr = [commitments, driftLog, patterns, assumptions]
.filter(Boolean)
.sort()
.reverse()[0] ?? null;
const lastWriteMs = lastWriteStr ? new Date(lastWriteStr).getTime() : 0;
const asterHealthy = lastWriteMs > recentCutoff;
// Conversations from store
const conversations: any[] = [];
if (options.stores) {
for (const [_name, store] of options.stores) {
const info = store.getInfo();
for (const [key, convId] of Object.entries(info.conversations || {})) {
const [channel, roomId] = key.startsWith('matrix:')
? ['matrix', key.replace('matrix:', '')]
: key.split(':').length > 1
? [key.split(':')[0], key.split(':').slice(1).join(':')]
: ['unknown', key];
conversations.push({ key, conversationId: convId, channel, roomId, lastMessage: null });
}
}
}
// Last heartbeat: read tail of cron-log.jsonl for most recent heartbeat_completed event
let lastHeartbeat: string | null = null;
try {
const { open } = await import('node:fs/promises');
const workingDir = process.env.WORKING_DIR || home;
const cronLogPath = `${workingDir}/cron-log.jsonl`;
const fh = await open(cronLogPath, 'r');
try {
const { size } = await fh.stat();
const tailSize = Math.min(size, 8192);
const buf = Buffer.alloc(tailSize);
await fh.read(buf, 0, tailSize, size - tailSize);
const tail = buf.toString('utf-8');
const lines = tail.split('\n').filter(Boolean);
for (let i = lines.length - 1; i >= 0; i--) {
try {
const entry = JSON.parse(lines[i]);
if (entry.event === 'heartbeat_completed' || entry.event === 'heartbeat_running') {
lastHeartbeat = entry.timestamp;
break;
}
} catch { /* skip malformed line */ }
}
} finally {
await fh.close();
}
} catch { /* no cron log yet */ }
// Adapter list from agentChannels (what's registered and assumed connected while running)
const adapters: Array<{ name: string; enabled: boolean; connected: boolean; lastEvent: null; error: null }> = [];
if (options.agentChannels) {
const seen = new Set<string>();
for (const channels of options.agentChannels.values()) {
for (const ch of channels) {
if (!seen.has(ch)) {
seen.add(ch);
adapters.push({ name: ch, enabled: true, connected: true, lastEvent: null, error: null });
}
}
}
}
const payload = {
aniOnline: true,
lastHeartbeat,
errors: [],
adapters,
conversations,
aster: {
healthy: asterHealthy,
lastRunAt: null,
lastWriteAt: lastWriteStr,
conversationId: asterConvId,
agentId: asterAgentId,
recentErrors: [],
ledger: {
commitmentsLastUpdated: commitments,
driftLogLastUpdated: driftLog,
patternsLastUpdated: patterns,
assumptionsLastUpdated: assumptions,
},
},
};
res.writeHead(200, {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
});
res.end(JSON.stringify(payload));
} catch (error: any) {
log.error('Bridge status error:', error);
sendError(res, 500, error.message || 'Internal server error');
}
return;
}
// Route: POST /api/v1/aster/reset - Cycle Aster's conversation (same as !reset aster)
if (req.url === '/api/v1/aster/reset' && req.method === 'POST') {
try {
const { createConversationForAgent } = await import('../tools/letta-api.js');
const asterAgentId = process.env.CONSCIENCE_AGENT_ID;
if (!asterAgentId) {
sendError(res, 400, 'CONSCIENCE_AGENT_ID not configured');
return;
}
const newConvId = await createConversationForAgent(asterAgentId);
if (!newConvId) {
sendError(res, 500, 'Failed to create new conscience conversation');
return;
}
process.env.CONSCIENCE_CONVERSATION_ID = newConvId;
const workingDir = process.env.WORKING_DIR || process.env.HOME || '/home/ani';
// Write conscience state file so letta-code picks up new conv ID without restart
try {
const { writeFile: wf } = await import('node:fs/promises');
const stateFile = `${workingDir}/.conscience-state.json`;
await wf(stateFile, JSON.stringify({ conversationId: newConvId, updatedAt: new Date().toISOString() }), 'utf-8');
log.info(`aster/reset: wrote conscience state to ${stateFile}`);
} catch (stateErr) {
log.warn('aster/reset: failed to write conscience state file:', stateErr);
}
// Persist to service file so restarts pick it up
const svcFile = `${process.env.HOME || '/home/ani'}/.config/systemd/user/ani-bridge.service`;
try {
const { readFile: rf, writeFile: wf } = await import('node:fs/promises');
const { execFile } = await import('node:child_process');
const { promisify } = await import('node:util');
const exec = promisify(execFile);
const current = await rf(svcFile, 'utf-8');
const updated = current.replace(
/^(Environment=CONSCIENCE_CONVERSATION_ID=).+$/m,
`$1${newConvId}`,
);
await wf(svcFile, updated, 'utf-8');
await exec('systemctl', ['--user', 'daemon-reload']);
} catch (svcErr) {
log.warn('aster/reset: failed to patch service file:', svcErr);
}
res.writeHead(200, {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
});
res.end(JSON.stringify({ ok: true, conversationId: newConvId }));
} catch (error: any) {
log.error('Aster reset error:', error);
sendError(res, 500, error.message || 'Internal server error');
}
return;
}
// Route: POST /api/v1/conversation - Set conversation ID
if (req.url === '/api/v1/conversation' && req.method === 'POST') {
try {

View File

@@ -36,6 +36,8 @@ export interface DiscordConfig {
groups?: Record<string, GroupModeConfig>; // Per-guild/channel settings
agentName?: string; // For scoping daily limit counters in multi-agent mode
ignoreBotReactions?: boolean; // Ignore all bot reactions (default: true). Set false for multi-bot setups.
ttsUrl?: string; // TTS API endpoint (e.g. VibeVoice)
ttsVoice?: string; // TTS voice ID
}
export function shouldProcessDiscordBotMessage(params: {
@@ -114,6 +116,8 @@ export class DiscordAdapter implements ChannelAdapter {
private running = false;
private attachmentsDir?: string;
private attachmentsMaxBytes?: number;
// In-memory store: messageId → { text, chatId } for 🎤 TTS regeneration
private audioMessages = new Map<string, { text: string; chatId: string }>();
onMessage?: (msg: InboundMessage) => Promise<void>;
onCommand?: (command: string, chatId?: string, args?: string, forcePerChat?: boolean) => Promise<string | null>;
@@ -245,36 +249,43 @@ Ask the bot owner to approve with:
const userId = message.author?.id;
if (!userId) return;
// Bypass pairing for guild (group) messages
if (!message.guildId) {
const access = await this.checkAccess(userId);
if (access === 'blocked') {
// Access check applies to both DMs and guild messages.
// Guild messages previously bypassed this entirely — that allowed anyone
// in a shared server to reach the bot regardless of allowedUsers.
const access = await this.checkAccess(userId);
if (access === 'blocked') {
if (!message.guildId) {
// Only reply in DMs — silently drop in guild channels to avoid noise
const ch = message.channel;
if (ch.isTextBased() && 'send' in ch) {
await (ch as { send: (content: string) => Promise<unknown> }).send(
"Sorry, you're not authorized to use this bot."
);
}
}
return;
}
if (access === 'pairing') {
if (message.guildId) {
// Don't start pairing flows in guild channels — DM only
return;
}
const { code, created } = await upsertPairingRequest('discord', userId, {
username: message.author.username,
});
if (!code) {
await message.channel.send('Too many pending pairing requests. Please try again later.');
return;
}
if (access === 'pairing') {
const { code, created } = await upsertPairingRequest('discord', userId, {
username: message.author.username,
});
if (!code) {
await message.channel.send('Too many pending pairing requests. Please try again later.');
return;
}
if (created) {
log.info(`New pairing request from ${userId} (${message.author.username}): ${code}`);
}
await this.sendPairingMessage(message, this.formatPairingMsg(code));
return;
if (created) {
log.info(`New pairing request from ${userId} (${message.author.username}): ${code}`);
}
await this.sendPairingMessage(message, this.formatPairingMsg(code));
return;
}
if (content.startsWith('/')) {
@@ -568,6 +579,36 @@ Ask the bot owner to approve with:
await message.react(resolved);
}
storeAudioMessage(messageId: string, _conversationId: string, chatId: string, text: string): void {
this.audioMessages.set(messageId, { text, chatId });
}
async sendAudio(chatId: string, text: string): Promise<void> {
if (!this.config.ttsUrl) return;
try {
const { synthesizeSpeech } = await import('./matrix/tts.js');
const audioData = await synthesizeSpeech(text, {
url: this.config.ttsUrl,
voice: this.config.ttsVoice,
});
// Write to temp file and send as attachment
const { writeFile } = await import('node:fs/promises');
const tmpPath = `/tmp/discord-tts-${Date.now()}.mp3`;
await writeFile(tmpPath, audioData);
const result = await this.sendFile({
chatId,
filePath: tmpPath,
kind: 'audio',
});
// Store for 🎤 regeneration
this.audioMessages.set(result.messageId, { text, chatId });
// Clean up temp file
import('node:fs/promises').then(fs => fs.unlink(tmpPath).catch(() => {}));
} catch (err) {
log.error('TTS failed (non-fatal):', err);
}
}
async sendTypingIndicator(chatId: string): Promise<void> {
if (!this.client) return;
try {
@@ -678,6 +719,18 @@ Ask the bot owner to approve with:
: (reaction.emoji.name || reaction.emoji.toString());
if (!emoji) return;
// 🎤 reaction = TTS regeneration (handle locally, don't forward to agent)
if (emoji === '🎤' && action === 'added' && this.config.ttsUrl) {
const stored = this.audioMessages.get(message.id);
if (stored) {
log.info(`🎤 TTS regeneration for message ${message.id}`);
this.sendAudio(stored.chatId, stored.text).catch(err =>
log.error('🎤 TTS regeneration failed:', err)
);
}
return; // consumed — don't forward to agent
}
const groupName = isGroup && 'name' in message.channel
? message.channel.name || undefined
: undefined;

View File

@@ -124,6 +124,8 @@ const SHARED_CHANNEL_BUILDERS: SharedChannelBuilder[] = [
groups: discord.groups,
agentName: agentConfig.name,
ignoreBotReactions: discord.ignoreBotReactions,
ttsUrl: discord.ttsUrl,
ttsVoice: discord.ttsVoice,
});
},
},

View File

@@ -227,8 +227,11 @@ export class MatrixAdapter implements ChannelAdapter {
if (!this.client) throw new Error("Matrix client not initialized");
const { chatId, text } = msg;
const { plain, html } = formatMatrixHTML(text);
const htmlBody = (msg.htmlPrefix || '') + html;
// If parseMode is HTML, text is already formatted — skip markdown conversion
const { plain, html } = msg.parseMode === 'HTML'
? { plain: text.replace(/<[^>]+>/g, ''), html: text }
: formatMatrixHTML(text);
const htmlBody = html;
const content = {
msgtype: MsgType.Text,
@@ -240,19 +243,43 @@ export class MatrixAdapter implements ChannelAdapter {
const response = await this.client.sendMessage(chatId, content);
const eventId = response.event_id;
// Send TTS audio if this was a voice-input response or enableAudioResponse is set
if (this.config.ttsUrl && this.shouldSendAudio(chatId)) {
this.sendAudio(chatId, plain).catch(err => log.error('TTS failed (non-fatal):', err));
}
// Add 🎤 reaction so user can request TTS on demand
if (this.config.ttsUrl) {
this.addReaction(chatId, eventId, '🎤').catch(() => {});
}
// TTS and 🎤 are NOT added here — sendMessage is called for reasoning
// displays, tool call displays, AND final responses. TTS should only
// fire on the final response, which is handled via onMessageSent().
return { messageId: eventId };
}
/**
* Send a message as a reply in a Matrix thread.
* Creates a thread if one doesn't exist yet on the parent event.
*/
async sendThreadMessage(parentEventId: string, chatId: string, text: string, parseMode?: string): Promise<{ messageId: string }> {
if (!this.client) throw new Error("Matrix client not initialized");
const { plain, html } = parseMode === 'HTML'
? { plain: text.replace(/<[^>]+>/g, ''), html: text }
: formatMatrixHTML(text);
const content = {
msgtype: MsgType.Text,
body: plain,
format: "org.matrix.custom.html",
formatted_body: html,
"m.relates_to": {
rel_type: "m.thread",
event_id: parentEventId,
is_falling_back: true,
"m.in_reply_to": {
event_id: parentEventId,
},
},
} as any;
const response = await this.client.sendMessage(chatId, content);
return { messageId: response.event_id };
}
/**
* Decide whether to send a TTS audio response for this room.
* Consumes the pendingVoiceRooms flag if set (voice-input path).
@@ -273,11 +300,11 @@ export class MatrixAdapter implements ChannelAdapter {
return true; // 'all'
}
async editMessage(chatId: string, messageId: string, text: string, htmlPrefix?: string): Promise<void> {
async editMessage(chatId: string, messageId: string, text: string): Promise<void> {
if (!this.client) throw new Error("Matrix client not initialized");
const { plain, html } = formatMatrixHTML(text);
const htmlBody = (htmlPrefix || '') + html;
const htmlBody = html;
const prefixedPlain = this.config.messagePrefix ? `${this.config.messagePrefix}\n\n${plain}` : plain;
const prefixedHtml = this.config.messagePrefix ? `${this.config.messagePrefix}<br><br>${htmlBody}` : htmlBody;
@@ -1454,7 +1481,7 @@ export class MatrixAdapter implements ChannelAdapter {
if (kind === 'audio') {
this.ourAudioEvents.add(eventId);
if (caption) {
this.storage.storeAudioMessage(eventId, 'default', chatId, caption);
this.storage.storeAudioMessage(eventId, chatId, chatId, caption);
}
const reactionContent: ReactionEventContent = {
"m.relates_to": {
@@ -1485,7 +1512,7 @@ export class MatrixAdapter implements ChannelAdapter {
const audioEventId = await this.uploadAndSendAudio(roomId, audioData);
if (audioEventId) {
// Store mapping so 🎤 on the regenerated audio works too
this.storage.storeAudioMessage(audioEventId, "default", roomId, text);
this.storage.storeAudioMessage(audioEventId, roomId, roomId, text);
}
return audioEventId;
} catch (err) {
@@ -1516,7 +1543,7 @@ export class MatrixAdapter implements ChannelAdapter {
const audioEventId = await this.uploadAndSendAudio(chatId, audioData);
if (audioEventId) {
// Store for 🎤 regeneration
this.storage.storeAudioMessage(audioEventId, "default", chatId, text);
this.storage.storeAudioMessage(audioEventId, chatId, chatId, text);
}
} catch (err) {
log.error("TTS failed (non-fatal):", err);

View File

@@ -23,6 +23,7 @@
* Unrecognized !x commands fall through to Letta as normal text.
*/
import { execFile } from "node:child_process";
import { createLogger } from "../../logger.js";
import type { MatrixStorage } from "./storage.js";
const log = createLogger('MatrixCommands');
@@ -78,6 +79,8 @@ export class MatrixCommandProcessor {
return this.doTurns(args[0], roomId);
case "timeout":
return this.doTimeout();
case "restart":
return this.doRestart();
// Heartbeat: on/off toggles locally, bare !heartbeat delegates to /heartbeat (trigger)
case "heartbeat":
@@ -170,6 +173,9 @@ export class MatrixCommandProcessor {
" `!heartbeat on/off` — Toggle heartbeat cron",
" `!heartbeat` — Trigger heartbeat now",
" `!timeout` — Kill stuck heartbeat run",
"",
"**System**",
" `!restart` — Graceful service restart",
];
return lines.join("\n");
}
@@ -233,4 +239,13 @@ export class MatrixCommandProcessor {
}
return "⚠️ No heartbeat timeout handler registered";
}
private doRestart(): string {
log.info('!restart: scheduling graceful restart via transient systemd unit');
// Spawn restart as a transient systemd unit so it survives our own process death
execFile('systemd-run', ['--user', '--no-block', 'systemctl', '--user', 'restart', 'ani-bridge.service'], (err) => {
if (err) log.error('!restart: failed to schedule restart:', err.message);
});
return "Restarting in a moment...";
}
}

View File

@@ -21,7 +21,7 @@ export interface ChannelAdapter {
// Messaging
sendMessage(msg: OutboundMessage): Promise<{ messageId: string }>;
editMessage(chatId: string, messageId: string, text: string, htmlPrefix?: string): Promise<void>;
editMessage(chatId: string, messageId: string, text: string): Promise<void>;
sendTypingIndicator(chatId: string): Promise<void>;
stopTypingIndicator?(chatId: string): Promise<void>;
@@ -35,6 +35,8 @@ export interface ChannelAdapter {
onMessageSent?(chatId: string, messageId: string, stepId?: string): void;
/** Store text for TTS regeneration on 🎤 reaction */
storeAudioMessage?(messageId: string, conversationId: string, roomId: string, text: string): void;
/** Send a message as a reply in a Matrix thread (no-op on non-threaded adapters) */
sendThreadMessage?(parentEventId: string, chatId: string, text: string, parseMode?: string): Promise<{ messageId: string }>;
getDmPolicy?(): string;
getFormatterHints(): FormatterHints;

View File

@@ -548,6 +548,21 @@ export function configToEnv(config: LettaBotConfig): Record<string, string> {
env.SLEEPTIME_STEP_COUNT = String(config.features.sleeptime.stepCount);
}
}
if (config.features?.conscience) {
if (config.features.conscience.agent) {
env.CONSCIENCE_AGENT_ID = config.features.conscience.agent;
}
if (config.features.conscience.conversation) {
// Only set as initial value — runtime resets mutate process.env directly
env.CONSCIENCE_CONVERSATION_ID ??= config.features.conscience.conversation;
}
if (config.features.conscience.trigger) {
env.CONSCIENCE_TRIGGER = config.features.conscience.trigger;
}
if (config.features.conscience.stepCount !== undefined) {
env.CONSCIENCE_STEP_COUNT = String(config.features.conscience.stepCount);
}
}
if (config.features?.inlineImages === false) {
env.INLINE_IMAGES = 'false';
}

View File

@@ -39,10 +39,8 @@ export interface DisplayConfig {
showReasoning?: boolean;
/** Truncate reasoning to N characters (default: 0 = no limit) */
reasoningMaxChars?: number;
/** Room IDs where reasoning should be shown (empty = all rooms that have showReasoning) */
reasoningRooms?: string[];
/** Room IDs where reasoning should be hidden (takes precedence over reasoningRooms) */
noReasoningRooms?: string[];
/** Add 🎤 reaction to reasoning messages for TTS regeneration (default: false) */
ttsOnReasoning?: boolean;
}
export type SleeptimeTrigger = 'off' | 'step-count' | 'compaction-event';
@@ -54,6 +52,19 @@ export interface SleeptimeConfig {
stepCount?: number;
}
export type ConscienceTrigger = 'off' | 'step-count';
export interface ConscienceConfig {
/** Letta agent ID for the persistent conscience agent (Aster) */
agent?: string;
/** Initial conversation ID (runtime value tracked in CONSCIENCE_CONVERSATION_ID) */
conversation?: string;
/** When to fire: 'step-count' fires every N turns, 'off' disables */
trigger?: ConscienceTrigger;
/** Fire conscience every N user turns (independent of sleeptime cadence) */
stepCount?: number;
}
/**
* Configuration for a single agent in multi-agent mode.
* Each agent has its own name, channels, and features.
@@ -105,6 +116,7 @@ export interface AgentConfig {
};
memfs?: boolean; // Enable memory filesystem (git-backed context repository) for SDK sessions
sleeptime?: SleeptimeConfig; // Configure SDK reflection reminders (/sleeptime equivalent)
conscience?: ConscienceConfig; // Persistent supervisory agent (Aster) — fires independently of sleeptime
maxToolCalls?: number;
sendFileDir?: string; // Restrict <send-file> directive to this directory (default: data/outbound)
sendFileMaxSize?: number; // Max file size in bytes for <send-file> (default: 50MB)
@@ -205,6 +217,7 @@ export interface LettaBotConfig {
inlineImages?: boolean; // Send images directly to the LLM (default: true). Set false to only send file paths.
memfs?: boolean; // Enable memory filesystem (git-backed context repository) for SDK sessions
sleeptime?: SleeptimeConfig; // Configure SDK reflection reminders (/sleeptime equivalent)
conscience?: ConscienceConfig; // Persistent supervisory agent (Aster) — fires independently of sleeptime
maxToolCalls?: number; // Abort if agent calls this many tools in one turn (default: 100)
sendFileDir?: string; // Restrict <send-file> directive to this directory (default: data/outbound)
sendFileMaxSize?: number; // Max file size in bytes for <send-file> (default: 50MB)
@@ -412,6 +425,8 @@ export interface DiscordConfig {
listeningGroups?: string[]; // @deprecated Use groups.<id>.mode = "listen"
groups?: Record<string, GroupConfig>; // Per-guild/channel settings, "*" for defaults
ignoreBotReactions?: boolean; // Ignore all bot reactions (default: true). Set false for multi-bot setups.
ttsUrl?: string; // TTS API endpoint (e.g. VibeVoice)
ttsVoice?: string; // TTS voice ID
}
export interface BlueskyConfig {
@@ -973,6 +988,29 @@ export function normalizeAgents(config: LettaBotConfig): AgentConfig[] {
};
}
// Conscience (Aster) — env vars override YAML, YAML sets initial values
const conscienceAgentId = process.env.CONSCIENCE_AGENT_ID;
const conscienceConversationId = process.env.CONSCIENCE_CONVERSATION_ID;
const conscienceStepCountRaw = process.env.CONSCIENCE_STEP_COUNT;
const conscienceTriggerRaw = process.env.CONSCIENCE_TRIGGER;
const conscienceTrigger = conscienceTriggerRaw === 'off' || conscienceTriggerRaw === 'step-count'
? conscienceTriggerRaw as ConscienceTrigger
: undefined;
const conscienceStepCountParsed = conscienceStepCountRaw ? parseInt(conscienceStepCountRaw, 10) : undefined;
const conscienceStepCount = Number.isFinite(conscienceStepCountParsed) && (conscienceStepCountParsed as number) > 0
? conscienceStepCountParsed
: undefined;
if (conscienceAgentId || conscienceConversationId || conscienceStepCount || conscienceTrigger) {
features.conscience = {
...features.conscience,
...(conscienceAgentId ? { agent: conscienceAgentId } : {}),
...(conscienceConversationId ? { conversation: conscienceConversationId } : {}),
...(conscienceTrigger ? { trigger: conscienceTrigger } : {}),
...(conscienceStepCount ? { stepCount: conscienceStepCount } : {}),
};
}
// Only pass features if there's actually something set
const hasFeatures = Object.keys(features).length > 0;

View File

@@ -13,10 +13,10 @@ import { extname, resolve, join } from 'node:path';
import type { ChannelAdapter } from '../channels/types.js';
import type { BotConfig, InboundMessage, TriggerContext, TriggerType, StreamMsg } from './types.js';
import { formatApiErrorForUser } from './errors.js';
import { formatToolCallDisplay, formatReasoningDisplay, formatQuestionsForChannel, formatReasoningAsCodeBlock } from './display.js';
import { formatToolCallDisplay, formatReasoningDisplay, formatQuestionsForChannel } from './display.js';
import type { AgentSession } from './interfaces.js';
import { Store } from './store.js';
import { getPendingApprovals, rejectApproval, cancelRuns, cancelConversation, recoverOrphanedConversationApproval, getLatestRunError, getAgentModel, updateAgentModel, isRecoverableConversationId, recoverPendingApprovalsForAgent } from '../tools/letta-api.js';
import { getPendingApprovals, rejectApproval, cancelRuns, cancelConversation, recoverOrphanedConversationApproval, getLatestRunError, getAgentModel, updateAgentModel, isRecoverableConversationId, recoverPendingApprovalsForAgent, createConversationForAgent, sendMessageToConversation } from '../tools/letta-api.js';
import { getAgentSkillExecutableDirs, isVoiceMemoConfigured } from '../skills/loader.js';
import { formatMessageEnvelope, formatGroupBatchEnvelope, type SessionContextOptions } from './formatter.js';
import type { GroupBatcher } from './group-batcher.js';
@@ -785,6 +785,44 @@ export class LettaBot implements AgentSession {
return '⏰ Heartbeat triggered (silent mode - check server logs)';
}
case 'reset': {
// !reset aster — cycle only Aster's conscience conversation, leave Ani's alone.
if (args?.trim().toLowerCase() === 'aster') {
const conscienceAgentId = process.env.CONSCIENCE_AGENT_ID;
if (!conscienceAgentId) {
return 'Conscience agent not configured (CONSCIENCE_AGENT_ID not set).';
}
const newConscienceConvId = await createConversationForAgent(conscienceAgentId);
if (!newConscienceConvId) {
return 'Failed to create a new conscience conversation. Check server logs.';
}
// Update in-memory env var so the running process uses the new conversation immediately.
process.env.CONSCIENCE_CONVERSATION_ID = newConscienceConvId;
// Persist to store (lettabot-agent.json) for reference.
this.store.setAgentField('Aster', 'conversationId', newConscienceConvId);
// Patch the systemd service file so restarts also pick up the new conversation.
const serviceFile = '/home/ani/.config/systemd/user/ani-bridge.service';
try {
const { writeFile } = await import('node:fs/promises');
const current = await readFile(serviceFile, 'utf-8');
const updated = current.replace(
/^(Environment=CONSCIENCE_CONVERSATION_ID=).+$/m,
`$1${newConscienceConvId}`,
);
await writeFile(serviceFile, updated, 'utf-8');
// Reload systemd unit definitions (no restart — just picks up the edited file).
await new Promise<void>((resolve, reject) => {
execFile('systemctl', ['--user', 'daemon-reload'], (err) => {
if (err) reject(err); else resolve();
});
});
log.info(`/reset aster - service file updated, daemon reloaded: ${newConscienceConvId}`);
} catch (svcErr) {
log.warn(`/reset aster - failed to patch service file: ${svcErr}`);
}
log.info(`/reset aster - conscience conversation cycled: ${newConscienceConvId}`);
return `Aster's conversation reset. New conversation: \`${newConscienceConvId}\`\nService file updated — restart-safe.`;
}
// Always scope the reset to the caller's conversation key so that
// other channels/chats' conversations are never silently destroyed.
// resolveConversationKey returns 'shared' for non-override channels,
@@ -806,6 +844,40 @@ export class LettaBot implements AgentSession {
const session = await this.sessionManager.ensureSessionForKey(convKey);
const newConvId = session.conversationId || '(pending)';
this.sessionManager.persistSessionState(session, convKey);
// Reset conscience conversation alongside Ani's.
// This ensures failure notifications target the new active conversation
// and conscience starts fresh rather than replaying a broken context.
const conscienceAgentId = process.env.CONSCIENCE_AGENT_ID;
if (conscienceAgentId) {
const newConscienceConvId = await createConversationForAgent(conscienceAgentId);
if (newConscienceConvId) {
process.env.CONSCIENCE_CONVERSATION_ID = newConscienceConvId;
this.store.setAgentField('Aster', 'conversationId', newConscienceConvId);
// Also patch the service file so restarts pick up the new conversation.
const serviceFile = '/home/ani/.config/systemd/user/ani-bridge.service';
try {
const { writeFile } = await import('node:fs/promises');
const current = await readFile(serviceFile, 'utf-8');
const updated = current.replace(
/^(Environment=CONSCIENCE_CONVERSATION_ID=).+$/m,
`$1${newConscienceConvId}`,
);
await writeFile(serviceFile, updated, 'utf-8');
await new Promise<void>((resolve, reject) => {
execFile('systemctl', ['--user', 'daemon-reload'], (err) => {
if (err) reject(err); else resolve();
});
});
} catch (svcErr) {
log.warn(`/reset - failed to patch conscience service var: ${svcErr}`);
}
log.info(`/reset - conscience conversation cycled: ${newConscienceConvId}`);
} else {
log.warn('/reset - Failed to cycle conscience conversation; will resume the previous one.');
}
}
if (convKey === 'shared') {
return `Conversation reset. New conversation: ${newConvId}\n(Agent memory is preserved.)`;
}
@@ -1322,7 +1394,6 @@ export class LettaBot implements AgentSession {
let lastEventType: string | null = null;
let abortedWithMessage = false;
let turnError: string | undefined;
let collectedReasoning = '';
// ── Reaction tracking ──
// 👀 = receipt indicator (bot saw the message); removed when reasoning/tools start
@@ -1335,6 +1406,10 @@ export class LettaBot implements AgentSession {
adapter.addReaction?.(msg.chatId, msg.messageId, '👀').catch(() => {});
eyesAdded = true;
}
// ── Subagent thread tracking ──
// When a Task tool call fires, create a Matrix thread for visibility
const subagentThreads = new Map<string, { rootEventId: string; chatId: string }>();
const seenToolEmojis = new Set<string>();
const getToolEmoji = (toolName: string): string => {
const n = toolName.toLowerCase();
@@ -1438,9 +1513,7 @@ export class LettaBot implements AgentSession {
lastEventType = 'reasoning';
sawNonAssistantSinceLastUuid = true;
// Collect reasoning for later prepending (Matrix <details> block)
if (event.content) {
collectedReasoning += event.content;
}
// reasoning content is sent as display message below
// Remove 👀 on first reasoning event (replaced by 🧠)
if (eyesAdded && msg.messageId) {
@@ -1457,12 +1530,18 @@ export class LettaBot implements AgentSession {
log.info(`Reasoning: ${event.content.trim().slice(0, 100)}`);
try {
const reasoning = formatReasoningDisplay(event.content, adapter.id, this.config.display?.reasoningMaxChars);
await adapter.sendMessage({
const reasoningResult = await adapter.sendMessage({
chatId: msg.chatId,
text: reasoning.text,
threadId: msg.threadId,
parseMode: reasoning.parseMode,
});
// 🎤 reaction + store reasoning text for TTS regeneration
// 🎤 + TTS on reasoning — only if ttsOnReasoning is enabled (default: off)
if (reasoningResult.messageId && this.config.display?.ttsOnReasoning) {
adapter.addReaction?.(msg.chatId, reasoningResult.messageId, '🎤').catch(() => {});
adapter.storeAudioMessage?.(reasoningResult.messageId, convKey, msg.chatId, event.content);
}
} catch (err) {
log.warn('Failed to send reasoning display:', err instanceof Error ? err.message : err);
}
@@ -1474,6 +1553,8 @@ export class LettaBot implements AgentSession {
// Finalize any pending assistant text on type transition
if (lastEventType === 'text' && response.trim()) {
await finalizeMessage();
// Pulse typing indicator so there's no dead air between text and tool execution
adapter.sendTypingIndicator(msg.chatId).catch(() => {});
}
lastEventType = 'tool_call';
this.sessionManager.syncTodoToolCall(event.raw);
@@ -1514,11 +1595,33 @@ export class LettaBot implements AgentSession {
}
}
// Create Matrix thread for subagent Task calls
if (event.name === 'Task' && !suppressDelivery && adapter.sendThreadMessage) {
const desc = (typeof event.args?.description === 'string' ? event.args.description : '')
|| (typeof event.args?.prompt === 'string' ? event.args.prompt.slice(0, 120) : '')
|| 'Subagent task';
const subagentType = typeof event.args?.subagent_type === 'string' ? event.args.subagent_type : 'task';
try {
const threadRoot = await adapter.sendMessage({ chatId: msg.chatId, text: `**Subagent: ${subagentType}**\n${desc}`, threadId: msg.threadId });
if (event.id && threadRoot.messageId) {
subagentThreads.set(event.id, { rootEventId: threadRoot.messageId, chatId: msg.chatId });
}
} catch (err) {
log.warn('Failed to create subagent thread root:', err instanceof Error ? err.message : err);
}
}
// Display
if (this.config.display?.showToolCalls && !suppressDelivery) {
try {
const text = formatToolCallDisplay(event.raw);
await adapter.sendMessage({ chatId: msg.chatId, text, threadId: msg.threadId });
// Send tool call display into subagent thread if one exists, otherwise to room
const thread = event.id ? subagentThreads.get(event.id) : undefined;
if (thread && adapter.sendThreadMessage) {
await adapter.sendThreadMessage(thread.rootEventId, thread.chatId, text);
} else {
await adapter.sendMessage({ chatId: msg.chatId, text, threadId: msg.threadId });
}
} catch (err) {
log.warn('Failed to send tool call display:', err instanceof Error ? err.message : err);
}
@@ -1565,6 +1668,38 @@ export class LettaBot implements AgentSession {
repeatedBashFailureKey = null;
repeatedBashFailureCount = 0;
}
// Post result to subagent thread if one exists
if (event.toolCallId && adapter.sendThreadMessage) {
const thread = subagentThreads.get(event.toolCallId);
if (thread) {
const status = event.isError ? '**Failed**' : '**Complete**';
// Post full result to thread (chunked if very long)
const maxChunk = 16000; // well under Matrix's ~65KB body limit
const content = event.content;
if (content.length <= maxChunk) {
adapter.sendThreadMessage(thread.rootEventId, thread.chatId, `${status}\n${content}`)
.catch(err => log.warn('Failed to post subagent result to thread:', err));
} else {
// Send status header + chunked content
const chunks: string[] = [];
for (let i = 0; i < content.length; i += maxChunk) {
chunks.push(content.slice(i, i + maxChunk));
}
(async () => {
try {
await adapter.sendThreadMessage!(thread.rootEventId, thread.chatId, `${status} (${chunks.length} parts)`);
for (const chunk of chunks) {
await adapter.sendThreadMessage!(thread.rootEventId, thread.chatId, chunk);
}
} catch (err) {
log.warn('Failed to post subagent result to thread:', err);
}
})();
}
subagentThreads.delete(event.toolCallId);
}
}
break;
}
@@ -1594,7 +1729,7 @@ export class LettaBot implements AgentSession {
|| hasUnclosedActionsBlock(response);
const streamText = stripActionsBlock(response).trim();
if (canEdit && !mayBeHidden && !suppressDelivery && !this.cancelledKeys.has(convKey)
&& streamText.length > 0 && Date.now() - lastUpdate > 800 && Date.now() > rateLimitedUntil) {
&& streamText.length > 0 && Date.now() - lastUpdate > 400 && Date.now() > rateLimitedUntil) {
try {
const prefixedStream = this.prefixResponse(streamText);
if (messageId) {
@@ -1880,42 +2015,25 @@ export class LettaBot implements AgentSession {
await new Promise(resolve => setTimeout(resolve, waitMs));
}
// Determine if reasoning should be shown for this room
const chatId = msg.chatId;
const noReasoningRooms = this.config.display?.noReasoningRooms || [];
const reasoningRooms = this.config.display?.reasoningRooms;
const shouldShowReasoning = this.config.display?.showReasoning &&
!noReasoningRooms.includes(chatId) &&
(!reasoningRooms || reasoningRooms.length === 0 || reasoningRooms.includes(chatId));
// Build reasoning HTML prefix if available (injected into formatted_body only)
let reasoningHtmlPrefix: string | undefined;
if (collectedReasoning.trim() && shouldShowReasoning) {
const reasoningBlock = formatReasoningAsCodeBlock(
collectedReasoning,
adapter.id,
this.config.display?.reasoningMaxChars
);
if (reasoningBlock) {
reasoningHtmlPrefix = reasoningBlock.text;
log.info(`Reasoning block generated (${reasoningHtmlPrefix.length} chars) for ${chatId}`);
}
}
const finalResponse = this.prefixResponse(response);
try {
if (messageId) {
await adapter.editMessage(msg.chatId, messageId, finalResponse, reasoningHtmlPrefix);
await adapter.editMessage(msg.chatId, messageId, finalResponse);
// Bump: re-send the final edit after a short delay so Matrix clients
// that missed the first edit (Element caching) pick up the full text.
setTimeout(() => {
adapter.editMessage(msg.chatId, messageId!, finalResponse).catch(() => {});
}, 800);
} else {
await adapter.sendMessage({ chatId: msg.chatId, text: finalResponse, threadId: msg.threadId, htmlPrefix: reasoningHtmlPrefix });
await adapter.sendMessage({ chatId: msg.chatId, text: finalResponse, threadId: msg.threadId });
}
sentAnyMessage = true;
this.store.resetRecoveryAttempts();
} catch (sendErr) {
log.warn('Final message delivery failed:', sendErr instanceof Error ? sendErr.message : sendErr);
try {
const result = await adapter.sendMessage({ chatId: msg.chatId, text: finalResponse, threadId: msg.threadId, htmlPrefix: reasoningHtmlPrefix });
const result = await adapter.sendMessage({ chatId: msg.chatId, text: finalResponse, threadId: msg.threadId });
messageId = result.messageId ?? null;
sentAnyMessage = true;
this.store.resetRecoveryAttempts();
@@ -1931,7 +2049,7 @@ export class LettaBot implements AgentSession {
// 🎤 on bot's TEXT message (tap to regenerate TTS audio)
adapter.addReaction?.(msg.chatId, messageId, '🎤').catch(() => {});
// Store raw text — adapter's TTS layer will clean it at synthesis time
adapter.storeAudioMessage?.(messageId, 'default', msg.chatId, response);
adapter.storeAudioMessage?.(messageId, convKey, msg.chatId, response);
// Generate TTS audio only in response to voice input
if (msg.isVoiceInput) {
adapter.sendAudio?.(msg.chatId, response).catch((err) => {
@@ -1943,9 +2061,24 @@ export class LettaBot implements AgentSession {
lap('message delivered');
await this.deliverNoVisibleResponseIfNeeded(msg, adapter, sentAnyMessage, receivedAnyData, msgTypeCounts);
// "Done" indicator on user's message — signals the turn is fully complete
if (!suppressDelivery && msg.messageId) {
adapter.addReaction?.(msg.chatId, msg.messageId, '✅').catch(() => {});
}
// Fire conscience agent (Aster) if configured — independent of sleeptime
this.triggerConscience({
source: 'message',
seq,
label: `turn ${seq} | user:${msg.userId} | ${msg.channel}:${msg.chatId}`,
}).catch(err => log.warn('triggerConscience unexpected error:', err));
} catch (error) {
log.error('Error processing message:', error);
if (!suppressDelivery && msg.messageId) {
adapter.addReaction?.(msg.chatId, msg.messageId, '❌').catch(() => {});
}
try {
await adapter.sendMessage({
chatId: msg.chatId,
@@ -2194,6 +2327,13 @@ export class LettaBot implements AgentSession {
log.warn(`Silent mode: agent produced ${response.length} chars but did NOT use lettabot-message CLI or directives — response discarded. If this keeps happening, the agent's model may not be following silent mode instructions.`);
}
}
// Fire conscience agent (Aster) after every heartbeat turn
this.triggerConscience({
source: 'heartbeat',
label: `heartbeat type=${context?.type ?? 'heartbeat'}`,
}).catch(err => log.warn('triggerConscience unexpected error:', err));
return response;
} catch (error) {
// Invalidate on stream errors so next call gets a fresh subprocess
@@ -2346,4 +2486,51 @@ export class LettaBot implements AgentSession {
getLastUserMessageTime(): Date | null {
return this.lastUserMessageTime;
}
// =========================================================================
// Conscience (Aster) trigger
// =========================================================================
/**
* Fire the persistent conscience agent (Aster) if configured.
* For 'message' source: respects stepCount cadence.
* For 'heartbeat' source: fires unconditionally (every heartbeat).
* Runs fire-and-forget — errors are logged but never propagate to the caller.
*/
private async triggerConscience(opts: {
source: 'message' | 'heartbeat';
seq?: number;
label?: string;
}): Promise<void> {
const conscience = this.config.conscience;
if (!conscience || conscience.trigger === 'off') return;
// User message path: respect stepCount cadence
if (opts.source === 'message') {
const stepCount = conscience.stepCount ?? 1;
if ((opts.seq ?? 0) % stepCount !== 0) return;
}
// Heartbeat path: always fires
// Agent ID is static (from config / CONSCIENCE_AGENT_ID env var).
// Conversation ID is dynamic — mutated by /reset aster at runtime.
const agentId = conscience.agent || process.env.CONSCIENCE_AGENT_ID;
const conversationId = process.env.CONSCIENCE_CONVERSATION_ID || conscience.conversation;
if (!agentId || !conversationId) {
log.warn('triggerConscience: no agent ID or conversation ID — skipping');
return;
}
const label = opts.label ?? opts.source;
const prompt = `[Conscience audit — ${label}]`;
log.info(`triggerConscience: firing Aster (source=${opts.source}, seq=${opts.seq ?? 'n/a'}, conv=${conversationId})`);
try {
await sendMessageToConversation(conversationId, prompt);
log.info(`triggerConscience: Aster audit complete (source=${opts.source})`);
} catch (err) {
log.warn('triggerConscience: failed to trigger Aster (non-fatal):', err);
}
}
}

View File

@@ -230,6 +230,19 @@ export function formatReasoningDisplay(
// Signal: no blockquote support, use italic
return { text: `**Thinking**\n_${truncated}_` };
}
if (channelId === 'matrix') {
// Matrix: collapsible <details> block — rendered natively by Element Web,
// falls back to visible block on mobile clients.
const escaped = truncated
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
return {
text: `<details><summary>🧠 Thinking</summary><br>${escaped}</details>`,
parseMode: 'HTML',
};
}
if (channelId === 'telegram' || channelId === 'telegram-mtproto') {
// Telegram: use HTML blockquote to bypass telegramify-markdown spacing.
// Convert basic markdown inline formatting to HTML tags so bold/italic
@@ -255,36 +268,6 @@ export function formatReasoningDisplay(
return { text: `> **Thinking**\n${quoted}` };
}
/**
* Format reasoning as a collapsible <details> block for prepending to the response.
* Returns pre-escaped HTML meant to be injected directly into formatted_body
* (bypasses the adapter's markdown-to-HTML conversion to avoid double-escaping).
*/
export function formatReasoningAsCodeBlock(
text: string,
channelId?: string,
reasoningMaxChars?: number,
): { text: string } | null {
const maxChars = reasoningMaxChars ?? 0;
const cleaned = text.split('\n').map(line => line.trimStart()).join('\n').trim();
if (!cleaned) return null;
const truncated = maxChars > 0 && cleaned.length > maxChars
? cleaned.slice(0, maxChars) + '...'
: cleaned;
// HTML-escape the reasoning content, then convert newlines to <br>
const escaped = truncated
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
return {
text: `<details><summary>🧠 Thinking</summary><br>${escaped}</details><br>`,
};
}
/**
* Format AskUserQuestion options for channel display.
*/

View File

@@ -352,9 +352,6 @@ function buildResponseDirectives(msg: InboundMessage): string[] {
lines.push(`- Prefer directives over tool calls for reactions (faster and cheaper)`);
}
// voice memo (always available -- TTS config is server-side)
lines.push(`- \`<actions><voice>Your message here</voice></actions>\` — send a voice memo via TTS`);
// file sending (only if channel supports it)
if (supportsFiles) {
lines.push(`- \`<send-file path="/path/to/file.png" kind="image" />\` — send a file (restricted to configured directory)`);

View File

@@ -76,7 +76,9 @@ Review these first. Update status with the manage_todo tool as you work.
}
/**
* Heartbeat prompt - explains the context and encourages autonomous work
* Heartbeat prompt - explains the context and encourages autonomous work.
* When silent=true, includes SILENT_MODE_PREFIX and CLI instructions.
* When silent=false (default), response is auto-delivered to the conversation.
*/
export function buildHeartbeatPrompt(
time: string,
@@ -85,24 +87,16 @@ export function buildHeartbeatPrompt(
todos: HeartbeatTodo[] = [],
now: Date = new Date(),
targetRoom?: string,
silent = false,
): string {
const todoSection = buildHeartbeatTodoSection(todos, now);
const roomLine = targetRoom ? `\nROOM: ${targetRoom}` : '';
const silentBlock = silent ? `${SILENT_MODE_PREFIX}\n\nYOUR TEXT OUTPUT IS PRIVATE - only you can see it.\nTo actually contact your human, run:\n lettabot-message send --text "Your message here"\n` : '';
return `
${SILENT_MODE_PREFIX}
TRIGGER: Scheduled heartbeat
${silentBlock}TRIGGER: Scheduled heartbeat
TIME: ${time} (${timezone})
NEXT HEARTBEAT: in ${intervalMinutes} minutes${roomLine}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
No one messaged you. The system woke you up on schedule.
YOUR TEXT OUTPUT IS PRIVATE - only you can see it.
To actually contact your human, run:
lettabot-message send --text "Your message here"
${todoSection || 'PENDING TO-DOS: none right now.'}
This is your time. You can:
@@ -112,17 +106,18 @@ This is your time. You can:
• Continue multi-step work from previous heartbeats
• Pursue curiosities, hobbies, or learning
If you have pending to-dos, prioritize those before starting new work.
If you have pending to-dos, prioritize those first.
You don't have to do something every time. But don't default to nothing.
Your human wants you to have a life outside conversations with them.
If you discover something worth sharing → use lettabot-message
If you have nothing to do → just end your turn (no output needed)
If you have nothing to say, respond with <no-reply/> to stay quiet.
`.trim();
}
/**
* Custom heartbeat prompt - wraps user-provided text with silent mode envelope
* Custom heartbeat prompt - wraps user-provided text with context envelope.
* When silent=true, includes SILENT_MODE_PREFIX and CLI instructions.
* When silent=false (default), response is auto-delivered to the target room.
*/
export function buildCustomHeartbeatPrompt(
customPrompt: string,
@@ -132,22 +127,16 @@ export function buildCustomHeartbeatPrompt(
todos: HeartbeatTodo[] = [],
now: Date = new Date(),
targetRoom?: string,
silent = false,
): string {
const todoSection = buildHeartbeatTodoSection(todos, now);
const roomLine = targetRoom ? `\nROOM: ${targetRoom}` : '';
const silentBlock = silent ? `${SILENT_MODE_PREFIX}\n\nYOUR TEXT OUTPUT IS PRIVATE - only you can see it.\nTo actually contact your human, run:\n lettabot-message send --text "Your message here"\n` : '';
return `
${SILENT_MODE_PREFIX}
TRIGGER: Scheduled heartbeat
${silentBlock}TRIGGER: Scheduled heartbeat
TIME: ${time} (${timezone})
NEXT HEARTBEAT: in ${intervalMinutes} minutes${roomLine}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
YOUR TEXT OUTPUT IS PRIVATE - only you can see it.
To actually contact your human, run:
lettabot-message send --text "Your message here"
${todoSection || 'PENDING TO-DOS: none right now.'}
${customPrompt}

View File

@@ -420,6 +420,18 @@ export class Store {
this.save();
}
/**
* Update a field on any named agent entry in the store.
* Used to persist auxiliary agent data (e.g. Aster's conversation ID after !reset).
*/
setAgentField(agentName: string, field: string, value: string | null): void {
if (!this.data.agents[agentName]) {
this.data.agents[agentName] = { agentId: null };
}
(this.data.agents[agentName] as unknown as Record<string, unknown>)[field] = value;
this.save();
}
getInfo(): AgentStore {
return { ...this.agentData() };
}

View File

@@ -25,14 +25,14 @@ You communicate through multiple channels and trigger types. Understanding when
## Output Modes
**RESPONSIVE MODE** (User Messages)
**RESPONSIVE MODE** (User Messages, Heartbeats)
- When a user sends you a message, you are in responsive mode
- Your text responses are automatically delivered to the user's channel
- Do NOT use \`lettabot-message send\` to reply to the current conversation — your text response is already delivered automatically. Using both causes duplicate messages.
- Only use \`lettabot-message\` in responsive mode to send files or to reach a DIFFERENT channel than the one you're responding to
- You can use \`lettabot-react\` CLI to add emoji reactions
**SILENT MODE** (Heartbeats, Cron Jobs, Polling, Background Tasks)
**SILENT MODE** (Cron Jobs, Polling, Background Tasks)
- When triggered by scheduled tasks (heartbeats, cron) or background processes (email polling), you are in SILENT MODE
- Your text responses are NOT delivered to anyone - only you can see them
- To contact the user, you MUST use the \`lettabot-message\` CLI via Bash:

View File

@@ -132,9 +132,6 @@ export interface OutboundMessage {
* 'HTML') and to skip its default markdown conversion. Adapters that don't
* support the specified mode ignore this and fall back to default. */
parseMode?: string;
/** Pre-escaped HTML to prepend to formatted_body only (bypasses markdown conversion).
* Used for reasoning blocks with <details> tags that would be double-escaped. */
htmlPrefix?: string;
}
/**
@@ -159,8 +156,8 @@ export interface SkillsConfig {
additionalSkills?: string[];
}
import type { SleeptimeTrigger, SleeptimeBehavior, SleeptimeConfig } from '../config/types.js';
export type { SleeptimeTrigger, SleeptimeBehavior, SleeptimeConfig };
import type { SleeptimeTrigger, SleeptimeBehavior, SleeptimeConfig, ConscienceTrigger, ConscienceConfig } from '../config/types.js';
export type { SleeptimeTrigger, SleeptimeBehavior, SleeptimeConfig, ConscienceTrigger, ConscienceConfig };
/**
* Bot configuration
@@ -178,8 +175,7 @@ export interface BotConfig {
showToolCalls?: boolean; // Show tool invocations in channel output
showReasoning?: boolean; // Show agent reasoning/thinking in channel output
reasoningMaxChars?: number; // Truncate reasoning to N chars (default: 0 = no limit)
reasoningRooms?: string[]; // Room IDs where reasoning should be shown (empty = all rooms)
noReasoningRooms?: string[]; // Room IDs where reasoning should be hidden (takes precedence)
ttsOnReasoning?: boolean; // Add 🎤 reaction to reasoning messages for TTS (default: false)
};
// Skills
@@ -191,6 +187,7 @@ export interface BotConfig {
// Memory filesystem (context repository)
memfs?: boolean; // true -> --memfs, false -> --no-memfs, undefined -> leave unchanged
sleeptime?: SleeptimeConfig; // Configure SDK reflection reminders (/sleeptime equivalent)
conscience?: ConscienceConfig; // Persistent supervisory agent (Aster)
// Security
redaction?: import('./redact.js').RedactionConfig;

View File

@@ -277,10 +277,10 @@ export class HeartbeatService {
mode: 'silent',
});
// Build trigger context for silent mode
// Build trigger context — heartbeat delivers responses to target room
const triggerContext: TriggerContext = {
type: 'heartbeat',
outputMode: 'silent',
outputMode: 'responsive',
};
try {
@@ -309,23 +309,39 @@ export class HeartbeatService {
? buildCustomHeartbeatPrompt(customPrompt, formattedTime, timezone, this.config.intervalMinutes, actionableTodos, now, targetRoom)
: buildHeartbeatPrompt(formattedTime, timezone, this.config.intervalMinutes, actionableTodos, now, targetRoom);
log.info(`Sending prompt (SILENT MODE):\n${'─'.repeat(50)}\n${message}\n${'─'.repeat(50)}\n`);
// Send to agent - response text is NOT delivered (silent mode)
// Agent must use `lettabot-message` CLI via Bash to send messages
log.info(`Sending heartbeat prompt:\n${'─'.repeat(50)}\n${message}\n${'─'.repeat(50)}\n`);
const response = await this.bot.sendToAgent(message, triggerContext);
// Log results
log.info(`Agent finished.`);
log.info(` - Response text: ${response?.length || 0} chars (NOT delivered - silent mode)`);
if (response && response.trim()) {
log.info(` - Response preview: "${response.slice(0, 100)}${response.length > 100 ? '...' : ''}"`);
// Deliver response to target room if we have one and there's something to say
if (response && response.trim() && response.trim() !== '<no-reply/>' && this.config.target) {
try {
const messageId = await this.bot.deliverToChannel(
this.config.target.channel,
this.config.target.chatId,
{ text: response.trim() },
);
log.info(`Delivered heartbeat response (${response.length} chars) to ${this.config.target.channel}:${this.config.target.chatId}`);
// Add TTS reaction + store audio for the delivered message
if (messageId) {
const adapter = (this.bot as any).channels?.get(this.config.target.channel);
if (adapter) {
adapter.addReaction?.(this.config.target.chatId, messageId, '🎤').catch(() => {});
adapter.storeAudioMessage?.(messageId, 'heartbeat', this.config.target.chatId, response.trim());
}
}
} catch (err) {
log.warn('Failed to deliver heartbeat response:', err instanceof Error ? err.message : err);
}
} else if (response && response.trim()) {
log.info(`Heartbeat response (${response.length} chars) but no target configured — not delivered`);
}
logEvent('heartbeat_completed', {
mode: 'silent',
mode: 'deliver',
responseLength: response?.length || 0,
delivered: !!(response?.trim() && this.config.target),
});
} catch (error) {

View File

@@ -385,6 +385,7 @@ async function main() {
sendFileCleanup: agentConfig.features?.sendFileCleanup,
memfs: resolvedMemfs,
sleeptime: effectiveSleeptime,
conscience: agentConfig.features?.conscience,
display: agentConfig.features?.display,
conversationMode: agentConfig.conversations?.mode || 'shared',
heartbeatConversation: agentConfig.conversations?.heartbeat || 'last-active',
@@ -590,12 +591,27 @@ async function main() {
await gateway.start();
// Olm WASM (matrix-js-sdk) registers process.on("uncaughtException", (e) => { throw e })
// during Olm.init(). Without this fix, any uncaught async exception crashes the bot.
// Must run AFTER gateway.start() since that's when the Matrix adapter initialises Olm.
process.removeAllListeners('uncaughtException');
process.removeAllListeners('unhandledRejection');
process.on('uncaughtException', (err) => { log.error('Uncaught exception (suppressed):', err); });
process.on('unhandledRejection', (reason) => { log.error('Unhandled rejection (suppressed):', reason); });
// during Olm.init(). That rethrow handler turns any uncaught async error into a crash.
// Surgically remove only the Olm-registered rethrow handlers, preserving any others.
for (const event of ['uncaughtException', 'unhandledRejection'] as const) {
const listeners = process.listeners(event);
for (const listener of listeners) {
// Olm's handler is a one-liner that rethrows: (e) => { throw e }
// Detect by source — short function body that just throws its argument.
const src = listener.toString();
if (src.includes('throw') && src.length < 50) {
process.removeListener(event, listener as (...args: unknown[]) => void);
}
}
}
// Safety net: log unhandled errors instead of crashing.
// Only adds if no other handlers remain (avoids clobbering app-registered handlers).
if (process.listenerCount('uncaughtException') === 0) {
process.on('uncaughtException', (err) => { log.error('Uncaught exception (suppressed):', err); });
}
if (process.listenerCount('unhandledRejection') === 0) {
process.on('unhandledRejection', (reason) => { log.error('Unhandled rejection (suppressed):', reason); });
}
// Start API server - uses gateway for delivery
const apiPort = parseInt(process.env.PORT || '8080', 10);
@@ -615,6 +631,7 @@ async function main() {
stores: agentStores,
agentChannels: agentChannelMap,
sessionInvalidators,
heartbeatServices: services.heartbeatServices,
});
// Startup banner

View File

@@ -1,75 +1,109 @@
[
{
"id": "sonnet-4.6",
"handle": "anthropic/claude-sonnet-4-6",
"label": "Sonnet 4.6",
"description": "Anthropic's new Sonnet model",
"id": "kimi-k2.5-nvfp4",
"handle": "openai-proxy/hf:nvidia/Kimi-K2.5-NVFP4",
"label": "Kimi K2.5 (NVFP4)",
"description": "Kimi K2.5 quantized, vision-capable",
"isDefault": true,
"isFeatured": true
},
{
"id": "opus-4.6",
"handle": "anthropic/claude-opus-4-6",
"label": "Opus 4.6",
"description": "Anthropic's best model",
"isFeatured": true
},
{
"id": "haiku",
"handle": "anthropic/claude-haiku-4-5",
"label": "Haiku 4.5",
"description": "Anthropic's fastest model",
"isFeatured": true
},
{
"id": "gpt-5.3-codex",
"handle": "openai/gpt-5.3-codex",
"label": "GPT-5.3 Codex",
"description": "OpenAI's best coding model",
"isFeatured": true
},
{
"id": "gpt-5.2",
"handle": "openai/gpt-5.2",
"label": "GPT-5.2",
"description": "Latest general-purpose GPT",
"isFeatured": true
},
{
"id": "gemini-3.1",
"handle": "google_ai/gemini-3.1-pro-preview",
"label": "Gemini 3.1 Pro",
"description": "Google's latest and smartest model",
"isFeatured": true
},
{
"id": "gemini-3-flash",
"handle": "google_ai/gemini-3-flash-preview",
"label": "Gemini 3 Flash",
"description": "Google's fastest Gemini 3 model",
"isFeatured": true
},
{
"id": "kimi-k2.5",
"handle": "openrouter/moonshotai/kimi-k2.5",
"handle": "synthetic-direct/hf:moonshotai/Kimi-K2.5",
"label": "Kimi K2.5",
"description": "Kimi's latest coding model",
"description": "Kimi K2.5 full, vision-capable",
"isFeatured": true
},
{
"id": "glm-5",
"handle": "zai/glm-5",
"label": "GLM-5",
"description": "zAI's latest coding model",
"isFeatured": true,
"free": true
"id": "kimi-k2-thinking",
"handle": "synthetic-direct/hf:moonshotai/Kimi-K2-Thinking",
"label": "Kimi K2 Thinking",
"description": "Kimi reasoning model",
"isFeatured": true
},
{
"id": "minimax-m2.5",
"handle": "minimax/MiniMax-M2.5",
"label": "MiniMax 2.5",
"description": "MiniMax's latest coding model",
"handle": "openai-proxy/hf:MiniMaxAI/MiniMax-M2.5",
"label": "MiniMax M2.5",
"description": "MiniMax latest, 191k context",
"isFeatured": true
},
{
"id": "qwen3.5",
"handle": "openai-proxy/hf:Qwen/Qwen3.5-397B-A17B",
"label": "Qwen3.5 397B",
"description": "Qwen latest, vision-capable",
"isFeatured": true
},
{
"id": "deepseek-v3.2",
"handle": "openai-proxy/hf:deepseek-ai/DeepSeek-V3.2",
"label": "DeepSeek V3.2",
"description": "DeepSeek latest via Fireworks",
"isFeatured": true
},
{
"id": "glm-4.7-flash",
"handle": "openai-proxy/hf:zai-org/GLM-4.7-Flash",
"label": "GLM-4.7 Flash",
"description": "Fast and cheap, great for subagents",
"isFeatured": true,
"free": true
},
{
"id": "glm-4.7",
"handle": "openai-proxy/hf:zai-org/GLM-4.7",
"label": "GLM-4.7",
"description": "Full GLM-4.7, 202k context",
"isFeatured": true,
"free": true
},
{
"id": "nemotron-3-super",
"handle": "openai-proxy/hf:nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4",
"label": "Nemotron 3 Super",
"description": "NVIDIA 120B MoE, 262k context"
},
{
"id": "gpt-oss-120b",
"handle": "openai-proxy/hf:openai/gpt-oss-120b",
"label": "GPT-OSS 120B",
"description": "OpenAI open-source, cheapest option"
},
{
"id": "deepseek-r1",
"handle": "openai-proxy/hf:deepseek-ai/DeepSeek-R1-0528",
"label": "DeepSeek R1",
"description": "DeepSeek reasoning model"
},
{
"id": "qwen3-235b-thinking",
"handle": "openai-proxy/hf:Qwen/Qwen3-235B-A22B-Thinking-2507",
"label": "Qwen3 235B Thinking",
"description": "Qwen reasoning MoE, 262k context"
},
{
"id": "qwen3-coder",
"handle": "openai-proxy/hf:Qwen/Qwen3-Coder-480B-A35B-Instruct",
"label": "Qwen3 Coder 480B",
"description": "Qwen coding specialist"
},
{
"id": "minimax-m2.1",
"handle": "openai-proxy/hf:MiniMaxAI/MiniMax-M2.1",
"label": "MiniMax M2.1",
"description": "MiniMax previous gen via Fireworks"
},
{
"id": "deepseek-v3",
"handle": "openai-proxy/hf:deepseek-ai/DeepSeek-V3",
"label": "DeepSeek V3",
"description": "DeepSeek V3 via Together"
},
{
"id": "llama-3.3-70b",
"handle": "openai-proxy/hf:meta-llama/Llama-3.3-70B-Instruct",
"label": "Llama 3.3 70B",
"description": "Meta Llama via Together"
}
]

View File

@@ -550,6 +550,12 @@ export async function rejectApproval(
log.warn(`Approval already resolved for tool call ${approval.toolCallId}`);
return true;
}
// Re-throw rate limit errors so callers can bail out early instead of
// hammering the API in a tight loop.
if (err?.status === 429) {
log.error('Failed to reject approval:', e);
throw e;
}
log.error('Failed to reject approval:', e);
return false;
}
@@ -987,3 +993,33 @@ export async function disableAllToolApprovals(agentId: string): Promise<number>
return 0;
}
}
/**
* Create a fresh conversation for an existing agent.
* Used by !reset to cycle Aster's conversation alongside Ani's.
*/
export async function createConversationForAgent(agentId: string): Promise<string | null> {
try {
const client = getClient();
const conversation = await client.conversations.create({ agent_id: agentId });
return conversation.id;
} catch (e) {
log.error('Failed to create conversation for agent:', e);
return null;
}
}
/**
* Send a fire-and-forget message to an existing conversation.
* Uses streaming=false so the call completes before returning.
*/
export async function sendMessageToConversation(
conversationId: string,
text: string,
): Promise<void> {
const client = getClient();
await client.conversations.messages.create(conversationId, {
input: text,
streaming: false,
} as Parameters<typeof client.conversations.messages.create>[1]);
}