@inetafrica/open-claudia 3.5.16 → 3.5.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/README.md +1 -0
- package/bin/cli.js +1 -1
- package/bin/task.js +51 -0
- package/bot.js +19 -0
- package/channels/kazee/adapter.js +130 -21
- package/channels/telegram/adapter.js +4 -2
- package/core/actions.js +8 -0
- package/core/config.js +19 -1
- package/core/cost-report.js +3 -0
- package/core/cron-schedule.js +59 -0
- package/core/dream.js +141 -34
- package/core/guardrail.js +5 -0
- package/core/handlers.js +24 -12
- package/core/lessons.js +11 -5
- package/core/loopback.js +29 -0
- package/core/providers/claude-events.js +9 -0
- package/core/providers/codex.js +48 -0
- package/core/relationship.js +1 -1
- package/core/router.js +6 -1
- package/core/runner.js +259 -6
- package/core/safe-filename.js +47 -0
- package/core/scheduler.js +137 -36
- package/core/system-prompt.js +89 -16
- package/core/tasks.js +208 -6
- package/core/turn-journal.js +506 -0
- package/core/utility-agent.js +17 -1
- package/package.json +4 -4
- package/test-fixtures/fake-agent-cli.js +4 -1
- package/test-guardrail.js +19 -0
- package/test-kazee-context-media.js +1 -1
- package/test-provider-dream.js +3 -1
- package/test-provider-events.js +21 -0
- package/test-provider-language.js +8 -1
- package/test-provider-pack-review.js +3 -1
- package/test-provider-scheduler.js +3 -1
- package/test-thread-lanes.js +6 -1
- package/web.js +104 -51
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.5.17
|
|
4
|
+
|
|
5
|
+
One combined release (Sumeet, 7 Oct 09:21 EAT): Release 1 below, the 7 Oct security fixes, and the dashboard and provider fixes found on 6 Oct. What people will notice: Codex bots start answering in groups when a message concerns them (their checks failed before), a file over 200 MB sent to a bot is refused, and a /dashboard link opens a Continue page first.
|
|
6
|
+
|
|
7
|
+
Release 1, "reliable and lean" (Sumeet, 7 Oct: token diet approved 06:12 EAT, task-cleanup plan sections 2, 5 and 6 at 09:45, resuming interrupted work at 10:20). Fewer tokens re-read on every step, scheduled work that actually runs, a dream that reaches the owner, and work that survives a restart. The compaction changes are a canary: off until conversations are listed.
|
|
8
|
+
|
|
9
|
+
- **Each conversation sees its own tasks.** A new task records the conversation it was made in (the thread, or the main timeline; a plan's subtasks follow the plan). The first turn of a session no longer gets the whole channel's task tree (90-123k characters on the owner's Kazee chat, where every thread shares one list): it gets this conversation's open tasks in full (at most 12, 6,000 characters), one line about the rest of the chat (count and the 5 most recent titles) and the pointer to `open-claudia task list`, at most 7,500 characters in all. Older tasks with no conversation count as "elsewhere". `task list` is unchanged.
|
|
10
|
+
- **Team list and slash commands once per session.** They rode every turn (about 4.5k characters); now they come with the first turn of a session in each conversation (again after a restart, a new conversation or a compaction). Who is speaking and the formatting notes still come every turn.
|
|
11
|
+
- **Token diet canary for compaction.** `TOKEN_DIET_CONVERSATIONS` (conversation keys as the usage ledger writes them, or `*`; empty = off) makes listed conversations compact at `TOKEN_DIET_COMPACT_TOKENS` (200k) instead of 380k, and compact before a turn that resumes them after the prompt cache expired (idle over `TOKEN_DIET_COLD_MINUTES`, 60) when their context is over `TOKEN_DIET_COLD_MIN_TOKENS` (120k); the turn then runs on the fresh session. `/compactwindow` still wins. Every compaction now leaves a usage-ledger record (`kind: "compaction"`, `compactReason`: threshold, cold-resume or manual, the context it replaced and what it cost), so before and after can be measured per conversation; the cost report and the dashboard keep counting turns only.
|
|
12
|
+
- **The dream reports every task list.** The cold-task report read only the old Telegram list; it now covers every task list on the bot (task commands are owner-only, so they are all the owner's), at most 8 lines, each naming its list.
|
|
13
|
+
- **Task archive, with a reason.** `open-claudia task archive <id> --reason "done: <proof>" | "replaced: <by what>" | "dropped: <who decided>" | "handed: <to whom>"` moves a task and its subtasks out of the list without losing them; `task restore <id>` brings them back with the same ids; `task archived [--limit N] [--search <text>]` lists them. Nothing archives by age, and `task done` is unchanged.
|
|
14
|
+
- **Late crons still run.** node-cron skipped any run that started more than a second late, and the bot is often 2-3 seconds late at the top of a minute: the 04:00 dream ran on 7 of 14 nights and the 07:30 daily cron missed 6 and 7 Oct. Crons now tolerate `OC_CRON_LATE_TOLERANCE_MS` (10 min) and the dream `DREAM_LATE_TOLERANCE_MS` (3 h).
|
|
15
|
+
- **The dream catches up.** If it has not run for more than 26 hours, it runs once now (checked at boot and hourly), never beside a running dream and at most once per 26 hours.
|
|
16
|
+
- **The dream summary reaches the owner's main channel.** `DREAM_SUMMARY_TO` (`<adapter>:<channelId>`) if set, else the owner's primary channel from the people list, else Telegram as before. A Kazee-only owner never got it.
|
|
17
|
+
- **A failing recurring job keeps its schedule.** One occurrence that failed five times used to disable a cron for good. Now that occurrence is recorded and skipped and the job runs again at its next time; one-shot wake-ups still give up.
|
|
18
|
+
- **The owner hears when a job dies.** A wake-up or watch that gives up, or a cron occurrence that failed for good, posts one short message in its own conversation (or to the owner if that channel is gone): which job, the error code, and how to re-arm it.
|
|
19
|
+
- **"No response requested." is not an answer.** A wake-up, cron or watch whose whole reply is that placeholder (Claude Code writes it into a session cut short by a restart) is re-run once, in the same session, with "Act on the prompt above now. Your first output must be a tool call or a real reply."
|
|
20
|
+
- **New lessons reach running conversations.** A lesson learned after a conversation started is added to its next turn, once, under "New standing rules since this conversation started:".
|
|
21
|
+
- **Interrupted work picks up after a restart** (Sumeet, 7 Oct: "a way for interrupted threads and work to get resumed automatically"). When a chat turn starts, the bot notes it in a small journal file and removes the note when the turn ends, is stopped or fails. After a restart the bot reads what is left: a turn cut off less than 2 hours ago (`RESUME_MAX_AGE_MIN`) continues in the same conversation with "Picking up where I was cut off (restart at HH:MM)." and an instruction to check what was already done and never repeat a send, push, deploy or write (also when it was the conversation's first turn, or its session is lost: the work may be half done, so it is never replayed as the plain message); an older one is offered back with Continue / Drop buttons for a day; one that was already cut off twice is not run again and the owner is told. Messages that were waiting behind it run afterwards, in order. `RESUME_INTERRUPTED=off` turns this off; safe mode never resumes anything. Scheduled jobs keep their own retry and are never run twice.
|
|
22
|
+
- **/stop no longer loses waiting messages.** Each message it drops comes back with a "Run it" button for a day.
|
|
23
|
+
- **Planned restarts wait for running work.** On a restart, upgrade or rollout the bot stops starting new turns (a message sent then runs right after the restart) and gives running turns time to finish: up to `DRAIN_TIMEOUT_S` (60 s) on /restart, /upgrade and /downgrade, `DRAIN_SIGTERM_S` (6 s) on a rollout or launchd stop, whose sender kills the bot soon after (bot pods allow 15 s); the bot saves its state before it waits. Anything still running picks up after the restart.
|
|
24
|
+
- **A pack is not re-sent for a metadata change.** A memory pack already in the session is injected again only when what it shows changed, not when its "applied on" stamp or parent bookkeeping moves.
|
|
25
|
+
- **Security: a sender's file name stays inside the files folder.** Kazee and Telegram saved a document under the sender's own file name, so a name like `../auth.json` could overwrite the owner list. One helper (`core/safe-filename.js`) keeps the last path segment, drops control and odd characters, refuses hidden or empty names, caps the length and checks the result stays in the folder. A document the guard holds for the owner keeps its file for the approval.
|
|
26
|
+
- **Security: Kazee media downloads are fenced and capped at 200 MB.** The adapter fetched any link a message carried, with no size limit. Now `KAZEE_MEDIA_HOSTS`, when set, is the exact list of hosts allowed; when unset, any public host is allowed but private, loopback and link-local addresses are refused (checked on every lookup answer, so the address cannot change in between); Kazee's own host is always allowed. Files stream to a `.part` file and stop at 200 MB (Kazee Chat's own limit) or 60 s idle; a failed or oversized download is deleted.
|
|
27
|
+
- **Security: the dashboard escapes everything it shows.** Pending users' names, cron labels, session titles, logs, the soul text, config values and usage rows went into the page as HTML, so a Telegram name or cron label carrying markup ran as script in the owner's dashboard.
|
|
28
|
+
- **Security: a bad `/otp/` link answers 400.** A malformed link threw inside the request handler and messaged the owner each time, no sign-in needed. Bad links get a short 400 page; any handler failure answers 500 and is logged.
|
|
29
|
+
- **Fix: /dashboard links survive Kazee Chat's link previews.** Kazee Chat fetches every link in a message to build a preview, which used up the one-time sign-in link before anyone tapped it ("Link expired or already used", 6 Oct). The link now opens a Continue page; only pressing it (a POST) uses the link.
|
|
30
|
+
- **Fix: the Users tab works.** Approve, deny and remove on the bot's dashboard and Agent Space's Users tab answered 404, because the sign-in routes above them took every `/api/auth/*` path.
|
|
31
|
+
- **Fix: a conversation whose saved history is gone starts fresh.** Claude Code answers "No conversation found with session ID" when the session file is missing, and every turn of that conversation failed until someone sent /new (chat-spaces-bot, 6 Oct). The stale session is retired and the turn runs fresh.
|
|
32
|
+
- **Fix: helper agents' notes stay out of the chat.** Text streamed by a helper agent (the Agent/Task tool) was posted as the bot's reply (a security audit's working notes reached the owner, 6 Oct); it now only counts toward spend.
|
|
33
|
+
- **Fix: Codex bots' background checks pass OpenAI's strict schema rules.** Every utility schema broke strict mode (400 invalid_json_schema), so on Codex bots passive group replies, pack review, dedup, the dream, recall filtering and bot hand-off checks all failed. They work now, which is why Codex bots start answering in groups.
|
|
34
|
+
- **Fix: reassurances are never standing rules.** The owner-rule detector took "Never mind, I found it." as a never-directive and saved it, and offered "Don't worry so much about that." as a rule. Sentences opening with don't worry, do not worry or never mind no longer form a rule.
|
|
35
|
+
- **node-cron ^4.6.0.** 4.2.1 has no option to tolerate a late start, which the late-cron fix relies on (its fallback for old versions stays).
|
|
36
|
+
- **Tests:** `test-task-injection.js`, `test-speaker-extras.js`, `test-token-diet.js`, `test-dream-tasks.js`, `test-task-archive.js`, `test-cron-tolerance.js`, `test-dream-schedule.js`, `test-job-failures.js`, `test-scheduled-placeholder.js`, `test-lessons-since.js`, `test-pack-reinject.js`, `test-turn-journal.js` (new); `test-thread-lanes.js`, `test-loopback-owner-gate.js` and `test-provider-scheduler.js` updated. The fake agent CLI gains a `[[placeholder]]` prompt marker. Security and fix tests: `test-safe-filename.js`, `test-media-filename-paths.js`, `test-router-document-guard.js`, `test-web-dashboard-escape.js`, `test-web-bad-requests.js`, `test-kazee-download-limit.js`, `test-web-auth-routes.js`, `test-web-otp-link-preview.js`, `test-codex-output-schema.js` (new); `test-kazee-context-media.js`, `test-provider-refusal-recovery.js`, `test-provider-events.js`, `test-provider-dream.js`, `test-provider-pack-review.js`, `test-guardrail.js` extended.
|
|
37
|
+
|
|
3
38
|
## 3.5.16
|
|
4
39
|
|
|
5
40
|
Watch a bot's browser live (Sumeet, 6 Oct: "can that show the browser like a see my computer view?", then "yeh go ahead"). Also carries 3.5.14 and 3.5.15, which never reached the bots: the 3.5.15 build kept failing on a timing-sensitive test, fixed here. Canary on the sales and tickets bots, then the fleet.
|
package/README.md
CHANGED
|
@@ -439,6 +439,7 @@ All stored in `~/.open-claudia/`:
|
|
|
439
439
|
| `TELEGRAM_CHAT_ID` | Yes* | Comma-separated authorized chat IDs |
|
|
440
440
|
| `CHANNELS` | No | Channels to start: `telegram`, `kazee`, or `telegram,kazee` (default `telegram`) |
|
|
441
441
|
| `KAZEE_URL` / `KAZEE_BOT_TOKEN` / `KAZEE_BOT_USER_ID` / `KAZEE_OWNER_USER_ID` | Kazee only | Kazee Chat connection |
|
|
442
|
+
| `KAZEE_MEDIA_HOSTS` | No | Comma-separated hosts (or URLs) Kazee serves file links from. When set, files are fetched only from these and the `KAZEE_URL` host. When unset, any public host works but private, loopback and link-local addresses are refused. Files stop at 200 MB either way |
|
|
442
443
|
| `WORKSPACE` | Yes | Path to your projects directory |
|
|
443
444
|
| `CLAUDE_PATH` / `CODEX_PATH` | No | Optional paths to provider CLIs (auto-detected if in PATH) |
|
|
444
445
|
| `DEFAULT_PROVIDER` | No | Foreground default (`claude` or `codex`); blank deterministically chooses the first compatible provider |
|
package/bin/cli.js
CHANGED
|
@@ -648,7 +648,7 @@ Background work (only inside an active bot-spawned task):
|
|
|
648
648
|
open-claudia watch-list List watches on this channel + master switch state
|
|
649
649
|
open-claudia watch-remove <id> Remove a watch
|
|
650
650
|
open-claudia watch-on | watch-off Global master switch for all watches (owner)
|
|
651
|
-
open-claudia task add|list|start|done|remove
|
|
651
|
+
open-claudia task add|list|start|done|remove|archive|restore Per-channel todo (survives restarts)
|
|
652
652
|
open-claudia agent "<prompt>" [--role X] [--provider active|claude|codex] Spawn a provider-isolated sub-agent
|
|
653
653
|
|
|
654
654
|
Cluster control (self-management of this bot's own deployment via the broker):
|
package/bin/task.js
CHANGED
|
@@ -17,6 +17,10 @@ Per-channel todo list with optional plan/subtask hierarchy.
|
|
|
17
17
|
# a plan won't complete while any subtask is still open
|
|
18
18
|
open-claudia task remove <id> # removing a plan removes its subtasks
|
|
19
19
|
open-claudia task clear-completed # prune any leftover completed entries
|
|
20
|
+
open-claudia task archive <id> --reason "<how it closed>" # move a plan/task (and its subtasks) out of the list
|
|
21
|
+
reason (required): "done: <proof>" | "replaced: <by what>" | "dropped: <who decided>" | "handed: <to whom>"
|
|
22
|
+
open-claudia task restore <id> # bring an archived task back, same id
|
|
23
|
+
open-claudia task archived [--limit N] [--search <text>] # archived tasks, most recent first
|
|
20
24
|
|
|
21
25
|
Use plans for any work with 3+ distinct steps. Mark subtasks in_progress
|
|
22
26
|
as you start them and done as you finish, so a resumed turn or a new
|
|
@@ -166,6 +170,50 @@ async function runClearCompleted() {
|
|
|
166
170
|
} catch (e) { console.error(e.message); process.exit(1); }
|
|
167
171
|
}
|
|
168
172
|
|
|
173
|
+
async function runArchive(args) {
|
|
174
|
+
const reason = takeFlag(args, "reason");
|
|
175
|
+
const id = args[0];
|
|
176
|
+
if (!id || !reason) {
|
|
177
|
+
console.error('Usage: task archive <id> --reason "done: <proof>" | "replaced: <by what>" | "dropped: <who decided>" | "handed: <to whom>"');
|
|
178
|
+
process.exit(2);
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
const res = await postJson("task-archive", { id, reason });
|
|
182
|
+
if (!res.ok) { console.error(`Not found: ${id}`); process.exit(1); }
|
|
183
|
+
const extra = res.alsoArchived ? ` (and ${res.alsoArchived} subtask${res.alsoArchived === 1 ? "" : "s"})` : "";
|
|
184
|
+
console.log(`Archived ${id}${extra}. Restore with: open-claudia task restore ${id}`);
|
|
185
|
+
process.exit(0);
|
|
186
|
+
} catch (e) { console.error(e.message); process.exit(1); }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function runRestore(args) {
|
|
190
|
+
const id = args[0];
|
|
191
|
+
if (!id) { console.error("Usage: task restore <id>"); process.exit(2); }
|
|
192
|
+
try {
|
|
193
|
+
const res = await postJson("task-restore", { id });
|
|
194
|
+
if (!res.ok) { console.error(`Not in the archive: ${id}`); process.exit(1); }
|
|
195
|
+
const extra = res.alsoRestored ? ` (and ${res.alsoRestored} subtask${res.alsoRestored === 1 ? "" : "s"})` : "";
|
|
196
|
+
console.log(`Restored ${id}${extra}.`);
|
|
197
|
+
process.exit(0);
|
|
198
|
+
} catch (e) { console.error(e.message); process.exit(1); }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function runArchived(args) {
|
|
202
|
+
const limit = takeFlag(args, "limit");
|
|
203
|
+
const search = takeFlag(args, "search");
|
|
204
|
+
try {
|
|
205
|
+
const res = await postJson("task-archived", { ...(limit ? { limit: Number(limit) } : {}), ...(search ? { search } : {}) });
|
|
206
|
+
const list = res.tasks || [];
|
|
207
|
+
if (list.length === 0) { console.log("No archived tasks."); process.exit(0); }
|
|
208
|
+
for (const t of list) {
|
|
209
|
+
const when = t.archivedAt ? new Date(t.archivedAt).toISOString().slice(0, 10) : "?";
|
|
210
|
+
console.log(`${statusMark(t)} ${t.content} (${t.id}) · archived ${when} · ${t.closedReason || "?"}: ${t.reason || ""}`);
|
|
211
|
+
(t.children || []).forEach((c) => console.log(` ${statusMark(c)} ${c.content} (${c.id})`));
|
|
212
|
+
}
|
|
213
|
+
process.exit(0);
|
|
214
|
+
} catch (e) { console.error(e.message); process.exit(1); }
|
|
215
|
+
}
|
|
216
|
+
|
|
169
217
|
async function run(args) {
|
|
170
218
|
const sub = args[0];
|
|
171
219
|
const rest = args.slice(1);
|
|
@@ -180,6 +228,9 @@ async function run(args) {
|
|
|
180
228
|
case "pending": return runUpdate(rest, "pending");
|
|
181
229
|
case "remove": case "rm": return runRemove(rest);
|
|
182
230
|
case "clear-completed": case "clear": return runClearCompleted();
|
|
231
|
+
case "archive": return runArchive(rest);
|
|
232
|
+
case "restore": return runRestore(rest);
|
|
233
|
+
case "archived": return runArchived(rest);
|
|
183
234
|
default:
|
|
184
235
|
console.error(`Unknown task subcommand: ${sub}`);
|
|
185
236
|
console.log(HELP);
|
package/bot.js
CHANGED
|
@@ -123,6 +123,16 @@ function persist() {
|
|
|
123
123
|
async function gracefulShutdown(signal) {
|
|
124
124
|
console.log(`Received ${signal}, shutting down gracefully...`);
|
|
125
125
|
try { stopScheduler(); } catch (e) {}
|
|
126
|
+
// Save first: whoever sent SIGTERM kills the process soon after (bot pods
|
|
127
|
+
// allow 15 s, launchd 20 s), and the drain below must not cost the save.
|
|
128
|
+
persist();
|
|
129
|
+
// Planned restart (rollout, launchd stop): no new turns; running ones get
|
|
130
|
+
// DRAIN_SIGTERM_S (6) to finish, the rest stay journaled and resume after
|
|
131
|
+
// boot (core/turn-journal).
|
|
132
|
+
try {
|
|
133
|
+
const journal = require("./core/turn-journal");
|
|
134
|
+
await journal.drain({ timeoutMs: journal.sigtermDrainTimeoutMs() });
|
|
135
|
+
} catch (e) { require("./core/turn-journal").markShuttingDown(); }
|
|
126
136
|
try { loopback.stop(); } catch (e) {}
|
|
127
137
|
const sideCleanup = sideChatCoordinator.cancelAll().catch(() => 0);
|
|
128
138
|
const memoryCleanup = require("./core/pack-review")
|
|
@@ -362,6 +372,15 @@ async function safeModeBoot() {
|
|
|
362
372
|
}
|
|
363
373
|
}
|
|
364
374
|
|
|
375
|
+
// Turns cut short by the last restart resume (or are offered back), and
|
|
376
|
+
// messages that were queued behind them are restored in order.
|
|
377
|
+
try {
|
|
378
|
+
const resumed = await require("./core/turn-journal").resumeInterrupted(adapters);
|
|
379
|
+
if (resumed.resumed.length || resumed.asked.length || resumed.refused.length || resumed.restored) {
|
|
380
|
+
console.log(`Interrupted turns: ${resumed.resumed.length} resumed, ${resumed.asked.length} offered, ${resumed.refused.length} not resumed, ${resumed.restored} queued restored`);
|
|
381
|
+
}
|
|
382
|
+
} catch (e) { console.error("interrupted-turn resume failed:", e.message); }
|
|
383
|
+
|
|
365
384
|
// Kazee Cards: snoozed owner decisions are asked again when due.
|
|
366
385
|
try { require("./core/cards").startDecisionPump(registry); } catch (e) { console.error("Decision pump start failed:", e.message); }
|
|
367
386
|
|
|
@@ -8,10 +8,13 @@ const path = require("path");
|
|
|
8
8
|
const https = require("https");
|
|
9
9
|
const http = require("http");
|
|
10
10
|
const crypto = require("crypto");
|
|
11
|
+
const dns = require("dns");
|
|
12
|
+
const net = require("net");
|
|
11
13
|
const { pipeline } = require("stream/promises");
|
|
12
14
|
const { Transform } = require("stream");
|
|
13
15
|
const { URL } = require("url");
|
|
14
16
|
const { TEMP_DIR, FILES_DIR } = require("../../core/config");
|
|
17
|
+
const { safeJoin } = require("../../core/safe-filename");
|
|
15
18
|
const { canonicalForChannel } = require("../../core/identity");
|
|
16
19
|
const { inlineKeyboardToPortable } = require("../types");
|
|
17
20
|
const { createKazeeSocket } = require("./socket");
|
|
@@ -129,14 +132,72 @@ const MIME_EXT = {
|
|
|
129
132
|
"application/zip": ".zip",
|
|
130
133
|
};
|
|
131
134
|
|
|
135
|
+
// Inbound media never goes past 200 MB. With KAZEE_MEDIA_HOSTS set (comma-
|
|
136
|
+
// separated hosts or URLs), files are fetched only from those hosts and Kazee
|
|
137
|
+
// itself (KAZEE_URL). Without it, Kazee's presigned links on its storage host
|
|
138
|
+
// keep working: any public host is allowed, but never a private, loopback or
|
|
139
|
+
// link-local address (checked when connecting), so a crafted link cannot
|
|
140
|
+
// point the bot at internal services.
|
|
141
|
+
const MAX_DOWNLOAD_BYTES = 200 * 1024 * 1024;
|
|
142
|
+
const DOWNLOAD_IDLE_MS = 60 * 1000;
|
|
143
|
+
|
|
144
|
+
function mediaHostList(mediaHosts) {
|
|
145
|
+
return (Array.isArray(mediaHosts) ? mediaHosts : String(mediaHosts || "").split(","))
|
|
146
|
+
.map((raw) => String(raw || "").trim())
|
|
147
|
+
.filter(Boolean);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function mediaHostSet(url, mediaHosts) {
|
|
151
|
+
const hosts = new Set();
|
|
152
|
+
for (const entry of [url, ...mediaHostList(mediaHosts)]) {
|
|
153
|
+
try { hosts.add((entry.includes("://") ? new URL(entry).host : entry).toLowerCase()); } catch (_) {}
|
|
154
|
+
}
|
|
155
|
+
return hosts;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const NON_PUBLIC = new net.BlockList();
|
|
159
|
+
for (const [address, prefix] of [
|
|
160
|
+
["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16],
|
|
161
|
+
["172.16.0.0", 12], ["192.0.0.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], ["224.0.0.0", 4], ["240.0.0.0", 4],
|
|
162
|
+
]) NON_PUBLIC.addSubnet(address, prefix, "ipv4");
|
|
163
|
+
for (const [address, prefix] of [["::", 128], ["::1", 128], ["fc00::", 7], ["fe80::", 10], ["ff00::", 8]]) {
|
|
164
|
+
NON_PUBLIC.addSubnet(address, prefix, "ipv6");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function isPublicAddress(address) {
|
|
168
|
+
const family = net.isIP(String(address || ""));
|
|
169
|
+
if (!family) return false;
|
|
170
|
+
const mapped = family === 6 && /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address);
|
|
171
|
+
if (mapped) return !NON_PUBLIC.check(mapped[1], "ipv4");
|
|
172
|
+
return !NON_PUBLIC.check(address, family === 6 ? "ipv6" : "ipv4");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// dns.lookup for http(s).get that refuses names resolving to a non-public
|
|
176
|
+
// address. It runs when connecting, so the answer cannot change in between.
|
|
177
|
+
function publicOnlyLookup(hostname, options, callback) {
|
|
178
|
+
dns.lookup(hostname, options, (err, address, family) => {
|
|
179
|
+
if (err) return callback(err);
|
|
180
|
+
const answers = Array.isArray(address) ? address : [{ address, family }];
|
|
181
|
+
if (answers.some((a) => !isPublicAddress(a.address))) {
|
|
182
|
+
console.error(`Kazee download refused: ${hostname} resolves to a non-public address`);
|
|
183
|
+
return callback(Object.assign(new Error(`${hostname} resolves to a non-public address`), { code: "EMEDIAHOST" }));
|
|
184
|
+
}
|
|
185
|
+
callback(null, address, family);
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
132
189
|
class KazeeAdapter {
|
|
133
|
-
constructor({ id = "kazee", url, token, ownerUserId, botUserId, passiveGate = null, spawnUtilityAgent = null }) {
|
|
190
|
+
constructor({ id = "kazee", url, token, ownerUserId, botUserId, mediaHosts = "", passiveGate = null, spawnUtilityAgent = null }) {
|
|
134
191
|
this.id = id;
|
|
135
192
|
this.type = "kazee";
|
|
136
193
|
// KAZEE_URL is the root host (socket.io lives at /). REST routes are
|
|
137
194
|
// mounted under /api on chat-central; we prepend that internally.
|
|
138
195
|
this.url = url.replace(/\/$/, "");
|
|
139
196
|
this.apiBase = this.url + "/api";
|
|
197
|
+
this._mediaHosts = mediaHostSet(this.url, mediaHosts);
|
|
198
|
+
this._mediaHostsListed = mediaHostList(mediaHosts).length > 0;
|
|
199
|
+
try { this._kazeeHost = new URL(this.url).host.toLowerCase(); } catch (_) { this._kazeeHost = ""; }
|
|
200
|
+
this._maxDownloadBytes = MAX_DOWNLOAD_BYTES;
|
|
140
201
|
this.token = token;
|
|
141
202
|
this.ownerUserId = ownerUserId || "";
|
|
142
203
|
this.botUserId = botUserId || null;
|
|
@@ -1593,16 +1654,48 @@ class KazeeAdapter {
|
|
|
1593
1654
|
const baseDir = media.type === "document" ? FILES_DIR : TEMP_DIR;
|
|
1594
1655
|
const keepName = media.type === "document" && media.fileName;
|
|
1595
1656
|
const nameSuffix = keepName && !path.extname(media.fileName) && ext !== ".bin" ? ext : "";
|
|
1657
|
+
const generated = `kz-${Date.now()}-${crypto.randomBytes(4).toString("hex")}${ext}`;
|
|
1596
1658
|
const localPath = keepName
|
|
1597
|
-
?
|
|
1598
|
-
: path.join(baseDir,
|
|
1659
|
+
? safeJoin(baseDir, media.fileName + nameSuffix, generated)
|
|
1660
|
+
: path.join(baseDir, generated);
|
|
1599
1661
|
await this._downloadUrl(url, localPath);
|
|
1600
1662
|
return localPath;
|
|
1601
1663
|
}
|
|
1602
1664
|
|
|
1665
|
+
// The parsed URL when it may be fetched; otherwise logs why and throws.
|
|
1666
|
+
_checkedMediaUrl(raw) {
|
|
1667
|
+
let url = null;
|
|
1668
|
+
try { url = new URL(String(raw)); } catch (_) {}
|
|
1669
|
+
if (!url || !["https:", "http:"].includes(url.protocol) || url.username || url.password) {
|
|
1670
|
+
console.error("Kazee download refused: not an http(s) media URL");
|
|
1671
|
+
throw new Error("Invalid media URL");
|
|
1672
|
+
}
|
|
1673
|
+
const host = url.host.toLowerCase();
|
|
1674
|
+
if (this._mediaHostsListed) {
|
|
1675
|
+
if (!this._mediaHosts.has(host)) {
|
|
1676
|
+
console.error(`Kazee download refused: ${host} is not a Kazee media host (allowed: KAZEE_URL's host and KAZEE_MEDIA_HOSTS)`);
|
|
1677
|
+
throw new Error(`${host} is not a Kazee media host`);
|
|
1678
|
+
}
|
|
1679
|
+
} else if (host !== this._kazeeHost) {
|
|
1680
|
+
// Names are checked when connecting (publicOnlyLookup); IP literals never look up.
|
|
1681
|
+
const literal = url.hostname.replace(/^\[|\]$/g, "");
|
|
1682
|
+
if (net.isIP(literal) && !isPublicAddress(literal)) {
|
|
1683
|
+
console.error(`Kazee download refused: ${host} is a non-public address`);
|
|
1684
|
+
throw new Error(`${host} is a non-public address`);
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
return url;
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
// Request options for a checked media URL: without a KAZEE_MEDIA_HOSTS list,
|
|
1691
|
+
// anything but Kazee's own host must resolve to public addresses only.
|
|
1692
|
+
_mediaRequestOptions(url) {
|
|
1693
|
+
if (this._mediaHostsListed || url.host.toLowerCase() === this._kazeeHost) return {};
|
|
1694
|
+
return { lookup: publicOnlyLookup };
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1603
1697
|
async _downloadContextMedia(media) {
|
|
1604
|
-
const url =
|
|
1605
|
-
if (!["https:", "http:"].includes(url.protocol) || url.username || url.password) throw new Error("Invalid media URL");
|
|
1698
|
+
const url = this._checkedMediaUrl(media.fileId);
|
|
1606
1699
|
const candidateExt = path.extname(media.fileName || url.pathname).toLowerCase();
|
|
1607
1700
|
const ext = /^\.[a-z0-9]{1,8}$/.test(candidateExt) ? candidateExt : MIME_EXT[media.mimeType] || ".bin";
|
|
1608
1701
|
const key = crypto.createHash("sha256").update(media.contextKey).digest("hex");
|
|
@@ -1619,7 +1712,7 @@ class KazeeAdapter {
|
|
|
1619
1712
|
const timer = setTimeout(() => req?.destroy(new Error("Media download timed out")), Math.min(media.timeoutMs || 8000, 8000));
|
|
1620
1713
|
try {
|
|
1621
1714
|
const res = await new Promise((resolve, reject) => {
|
|
1622
|
-
req = (url.protocol === "https:" ? https : http).get(url, resolve);
|
|
1715
|
+
req = (url.protocol === "https:" ? https : http).get(url, this._mediaRequestOptions(url), resolve);
|
|
1623
1716
|
req.on("error", reject);
|
|
1624
1717
|
});
|
|
1625
1718
|
if (res.statusCode !== 200 || Number(res.headers["content-length"]) > maxBytes) {
|
|
@@ -1741,21 +1834,37 @@ class KazeeAdapter {
|
|
|
1741
1834
|
});
|
|
1742
1835
|
}
|
|
1743
1836
|
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1837
|
+
// Streams to a .part file next to localPath, counting bytes; over the cap
|
|
1838
|
+
// (declared or actual) or idle too long, it aborts and deletes the part.
|
|
1839
|
+
async _downloadUrl(rawUrl, localPath) {
|
|
1840
|
+
const url = this._checkedMediaUrl(rawUrl);
|
|
1841
|
+
const maxBytes = this._maxDownloadBytes;
|
|
1842
|
+
const tooBig = () => new Error(`Kazee media exceeds the ${Math.round(maxBytes / 1024 / 1024)} MB size limit`);
|
|
1843
|
+
const partial = `${localPath}.${crypto.randomUUID()}.part`;
|
|
1844
|
+
let req;
|
|
1845
|
+
try {
|
|
1846
|
+
const res = await new Promise((resolve, reject) => {
|
|
1847
|
+
req = (url.protocol === "https:" ? https : http).get(url, this._mediaRequestOptions(url), resolve);
|
|
1848
|
+
req.on("error", reject);
|
|
1849
|
+
req.setTimeout(DOWNLOAD_IDLE_MS, () => req.destroy(new Error("Kazee download stalled")));
|
|
1850
|
+
});
|
|
1851
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
1852
|
+
res.resume();
|
|
1853
|
+
throw new Error(`Download from ${url.host} → ${res.statusCode}`);
|
|
1854
|
+
}
|
|
1855
|
+
if (Number(res.headers["content-length"]) > maxBytes) { res.destroy(); throw tooBig(); }
|
|
1856
|
+
let bytes = 0;
|
|
1857
|
+
await pipeline(res, new Transform({ transform(chunk, enc, done) {
|
|
1858
|
+
bytes += chunk.length;
|
|
1859
|
+
done(bytes > maxBytes ? tooBig() : null, chunk);
|
|
1860
|
+
} }), fs.createWriteStream(partial, { flags: "wx" }));
|
|
1861
|
+
fs.renameSync(partial, localPath);
|
|
1862
|
+
} catch (e) {
|
|
1863
|
+
req?.destroy();
|
|
1864
|
+
try { fs.unlinkSync(partial); } catch (_) {}
|
|
1865
|
+
throw e;
|
|
1866
|
+
}
|
|
1758
1867
|
}
|
|
1759
1868
|
}
|
|
1760
1869
|
|
|
1761
|
-
module.exports = { KazeeAdapter };
|
|
1870
|
+
module.exports = { KazeeAdapter, isPublicAddress, publicOnlyLookup };
|
|
@@ -8,6 +8,7 @@ const path = require("path");
|
|
|
8
8
|
const https = require("https");
|
|
9
9
|
const TelegramBot = require("node-telegram-bot-api");
|
|
10
10
|
const { TEMP_DIR, FILES_DIR, CONFIG_DIR } = require("../../core/config");
|
|
11
|
+
const { safeJoin } = require("../../core/safe-filename");
|
|
11
12
|
const { canonicalForChannel } = require("../../core/identity");
|
|
12
13
|
const { portableToInlineKeyboard } = require("../types");
|
|
13
14
|
const { telegramHtml, htmlToPlain, splitMessage } = require("./format");
|
|
@@ -701,9 +702,10 @@ class TelegramAdapter {
|
|
|
701
702
|
: media.type === "video" ? ".mp4"
|
|
702
703
|
: ".bin");
|
|
703
704
|
const baseDir = media.type === "document" ? FILES_DIR : TEMP_DIR;
|
|
705
|
+
const generated = `tg-${Date.now()}${ext}`;
|
|
704
706
|
const localPath = media.type === "document" && media.fileName
|
|
705
|
-
?
|
|
706
|
-
: path.join(baseDir,
|
|
707
|
+
? safeJoin(baseDir, media.fileName, generated)
|
|
708
|
+
: path.join(baseDir, generated);
|
|
707
709
|
await new Promise((resolve, reject) => {
|
|
708
710
|
const out = fs.createWriteStream(localPath);
|
|
709
711
|
https.get(fileUrl, (res) => { res.pipe(out); out.on("finish", () => { out.close(); resolve(); }); }).on("error", reject);
|
package/core/actions.js
CHANGED
|
@@ -910,6 +910,14 @@ async function handleAction(envelope) {
|
|
|
910
910
|
`\nVerification settles identity only; permissions still come from mandates and rules.`
|
|
911
911
|
);
|
|
912
912
|
}
|
|
913
|
+
// Interrupted-turn offers (core/turn-journal): Continue / Drop a turn cut
|
|
914
|
+
// off by a restart, or run a message /stop dropped.
|
|
915
|
+
if (d.startsWith("rsm:")) {
|
|
916
|
+
return require("./turn-journal").handleResumeAction(d, envelope, { isOwner: isChatOwner(envelope.channelId) });
|
|
917
|
+
}
|
|
918
|
+
if (d.startsWith("rq:")) {
|
|
919
|
+
return require("./turn-journal").handleRunDropped(d, envelope, { isOwner: isChatOwner(envelope.channelId) });
|
|
920
|
+
}
|
|
913
921
|
if (d.startsWith("grd:")) {
|
|
914
922
|
// Guardrail capture buttons (Track C). Owner-only: standing rules layer
|
|
915
923
|
// above lessons and are never editable by an external speaker.
|
package/core/config.js
CHANGED
|
@@ -126,6 +126,22 @@ const CLAUDE_PATH = configuredProviderPath("claude");
|
|
|
126
126
|
const CODEX_PATH = config.CODEX_PATH || null;
|
|
127
127
|
const AUTO_COMPACT_TOKENS = parseInt(config.AUTO_COMPACT_TOKENS || process.env.AUTO_COMPACT_TOKENS || "380000", 10);
|
|
128
128
|
const MIN_COMPACT_INTERVAL_MS = parseInt(config.MIN_COMPACT_INTERVAL_MS || process.env.MIN_COMPACT_INTERVAL_MS || "1800000", 10);
|
|
129
|
+
// Token diet canary (off unless conversations are listed). TOKEN_DIET_CONVERSATIONS
|
|
130
|
+
// is a comma-separated list of conversation keys as the usage ledger's `project`
|
|
131
|
+
// field writes them (e.g. kazee:<room>#thread:<root>), or "*" for all. A listed
|
|
132
|
+
// conversation compacts at TOKEN_DIET_COMPACT_TOKENS instead of
|
|
133
|
+
// AUTO_COMPACT_TOKENS, and is compacted before a turn that resumes it after
|
|
134
|
+
// TOKEN_DIET_COLD_MINUTES idle (the prompt cache has expired, so the whole
|
|
135
|
+
// context would be re-written) when its context exceeds TOKEN_DIET_COLD_MIN_TOKENS.
|
|
136
|
+
const TOKEN_DIET_CONVERSATIONS = String(config.TOKEN_DIET_CONVERSATIONS || process.env.TOKEN_DIET_CONVERSATIONS || "")
|
|
137
|
+
.split(",").map((s) => s.trim()).filter(Boolean);
|
|
138
|
+
const TOKEN_DIET_COMPACT_TOKENS = parseInt(config.TOKEN_DIET_COMPACT_TOKENS || process.env.TOKEN_DIET_COMPACT_TOKENS || "200000", 10);
|
|
139
|
+
const TOKEN_DIET_COLD_MINUTES = parseInt(config.TOKEN_DIET_COLD_MINUTES || process.env.TOKEN_DIET_COLD_MINUTES || "60", 10);
|
|
140
|
+
const TOKEN_DIET_COLD_MIN_TOKENS = parseInt(config.TOKEN_DIET_COLD_MIN_TOKENS || process.env.TOKEN_DIET_COLD_MIN_TOKENS || "120000", 10);
|
|
141
|
+
// How late a cron occurrence may start and still run (node-cron otherwise drops
|
|
142
|
+
// a run more than 1 s late). Daily/user crons: 10 min; the nightly dream: 3 h.
|
|
143
|
+
const CRON_LATE_TOLERANCE_MS = parseInt(config.OC_CRON_LATE_TOLERANCE_MS || process.env.OC_CRON_LATE_TOLERANCE_MS || "600000", 10);
|
|
144
|
+
const DREAM_LATE_TOLERANCE_MS = parseInt(config.DREAM_LATE_TOLERANCE_MS || process.env.DREAM_LATE_TOLERANCE_MS || "10800000", 10);
|
|
129
145
|
const PROJECT_TRANSCRIPTS = configTruthy(config.PROJECT_TRANSCRIPTS || process.env.PROJECT_TRANSCRIPTS, true);
|
|
130
146
|
// Unified multi-channel identity. Default ON: the owner's Telegram, Kazee and
|
|
131
147
|
// voice channels resolve to one canonical person, sharing one state, session,
|
|
@@ -243,7 +259,7 @@ function loadChannels() {
|
|
|
243
259
|
channels.push({
|
|
244
260
|
id,
|
|
245
261
|
type: "kazee",
|
|
246
|
-
opts: { url, token, ownerUserId, botUserId },
|
|
262
|
+
opts: { url, token, ownerUserId, botUserId, mediaHosts: config.KAZEE_MEDIA_HOSTS || "" },
|
|
247
263
|
});
|
|
248
264
|
} else if (type === "spaces") {
|
|
249
265
|
// Conversational plane for Kazee Spaces. Bot authenticates with its own
|
|
@@ -374,6 +390,8 @@ module.exports = {
|
|
|
374
390
|
resolvedCodexPath,
|
|
375
391
|
AUTO_COMPACT_TOKENS,
|
|
376
392
|
MIN_COMPACT_INTERVAL_MS,
|
|
393
|
+
TOKEN_DIET_CONVERSATIONS, TOKEN_DIET_COMPACT_TOKENS, TOKEN_DIET_COLD_MINUTES, TOKEN_DIET_COLD_MIN_TOKENS,
|
|
394
|
+
CRON_LATE_TOLERANCE_MS, DREAM_LATE_TOLERANCE_MS,
|
|
377
395
|
PROJECT_TRANSCRIPTS,
|
|
378
396
|
UNIFIED_IDENTITY,
|
|
379
397
|
TRANSCRIPT_MAX_ENTRY_CHARS,
|
package/core/cost-report.js
CHANGED
|
@@ -38,6 +38,9 @@ function aggregate(records) {
|
|
|
38
38
|
byProject: new Map(),
|
|
39
39
|
};
|
|
40
40
|
for (const r of records) {
|
|
41
|
+
// Compaction records (kind "compaction") are not turns; their own cost
|
|
42
|
+
// sits in compactionUsage.
|
|
43
|
+
if (r.kind === "compaction") continue;
|
|
41
44
|
agg.turns += 1;
|
|
42
45
|
const cost = Number(r.costUsd) || 0;
|
|
43
46
|
agg.costUsd += cost;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Cron scheduling that tolerates a late start.
|
|
2
|
+
//
|
|
3
|
+
// node-cron drops an occurrence whose heartbeat fires late: 4.6.0 skips any run
|
|
4
|
+
// more than DEFAULT_MISSED_EXECUTION_TOLERANCE (1000 ms) late, and 4.2.1 (the
|
|
5
|
+
// version package-lock pins) misses it outright, because the late second no
|
|
6
|
+
// longer matches the expression's seconds field. The bot is often 2-3 s late
|
|
7
|
+
// at the top of a minute, so the 04:00 dream ran on 7 of 14 nights and daily
|
|
8
|
+
// crons were skipped. 4.6+ takes a `missedExecutionTolerance` option; for
|
|
9
|
+
// versions without it, a missed execution is run here, once, when the latest
|
|
10
|
+
// slot it belongs to is still within the tolerance (the task's own
|
|
11
|
+
// timeMatcher finds that slot; an occurrence never runs twice).
|
|
12
|
+
|
|
13
|
+
const cron = require("node-cron");
|
|
14
|
+
|
|
15
|
+
function scheduleTolerant(expression, callback, opts = {}) {
|
|
16
|
+
const cronLib = opts.cron || cron;
|
|
17
|
+
const now = opts.now || Date.now;
|
|
18
|
+
const toleranceMs = Number.isFinite(opts.toleranceMs) && opts.toleranceMs >= 0 ? opts.toleranceMs : 0;
|
|
19
|
+
const label = opts.label || expression;
|
|
20
|
+
const noOverlap = opts.noOverlap === true;
|
|
21
|
+
let lastSlot = -Infinity;
|
|
22
|
+
let running = 0;
|
|
23
|
+
|
|
24
|
+
const invoke = async (slot, context) => {
|
|
25
|
+
if (slot <= lastSlot) return undefined; // this occurrence already ran
|
|
26
|
+
if (noOverlap && running > 0) return undefined;
|
|
27
|
+
lastSlot = slot;
|
|
28
|
+
running += 1;
|
|
29
|
+
try { return await callback(context); }
|
|
30
|
+
finally { running -= 1; }
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const options = { missedExecutionTolerance: toleranceMs };
|
|
34
|
+
if (noOverlap) options.noOverlap = true;
|
|
35
|
+
const task = cronLib.schedule(expression, (context) => {
|
|
36
|
+
const date = context && context.date instanceof Date ? context.date : new Date(now());
|
|
37
|
+
// The slot is the matched time, whole seconds.
|
|
38
|
+
return invoke(Math.floor(date.getTime() / 1000) * 1000, context);
|
|
39
|
+
}, options);
|
|
40
|
+
|
|
41
|
+
const matcher = task && task.timeMatcher && typeof task.timeMatcher.match === "function" ? task.timeMatcher : null;
|
|
42
|
+
if (task && typeof task.on === "function" && matcher && toleranceMs > 0) {
|
|
43
|
+
const stepMs = String(expression).trim().split(/\s+/).length >= 6 ? 1000 : 60000;
|
|
44
|
+
task.on("execution:missed", () => {
|
|
45
|
+
const at = now();
|
|
46
|
+
let slot = Math.floor(at / stepMs) * stepMs;
|
|
47
|
+
for (; slot >= at - toleranceMs; slot -= stepMs) {
|
|
48
|
+
if (matcher.match(new Date(slot))) break;
|
|
49
|
+
}
|
|
50
|
+
if (slot < at - toleranceMs || slot <= lastSlot) return;
|
|
51
|
+
console.log(`[cron] ${label}: running the ${new Date(slot).toISOString()} occurrence ${Math.round((at - slot) / 1000)}s late (tolerance ${Math.round(toleranceMs / 1000)}s)`);
|
|
52
|
+
Promise.resolve(invoke(slot, { date: new Date(slot), late: true }))
|
|
53
|
+
.catch((error) => console.error(`[cron] ${label}: late run failed: ${error?.message || error}`));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return task;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { scheduleTolerant };
|