@inetafrica/open-claudia 3.5.16 → 3.5.18
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/.env.example +10 -0
- package/CHANGELOG.md +53 -0
- package/README.md +4 -0
- package/bin/cli.js +1 -1
- package/bin/task.js +51 -0
- package/bot.js +26 -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 +22 -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 +78 -17
- package/core/lessons.js +11 -5
- package/core/limit-pickup.js +313 -0
- package/core/loopback.js +29 -0
- package/core/providers/claude-events.js +79 -0
- package/core/providers/codex.js +48 -0
- package/core/providers/events.js +4 -1
- package/core/relationship.js +1 -1
- package/core/router.js +16 -1
- package/core/run-context.js +3 -0
- package/core/runner.js +440 -14
- package/core/safe-filename.js +47 -0
- package/core/scheduler.js +170 -38
- package/core/state.js +91 -0
- package/core/system-prompt.js +89 -16
- package/core/tasks.js +208 -6
- package/core/turn-journal.js +508 -0
- package/core/usage-limits.js +321 -0
- package/core/utility-agent.js +17 -1
- package/package.json +4 -4
- package/test-fixtures/fake-agent-cli.js +26 -2
- package/test-guardrail.js +19 -0
- package/test-kazee-context-media.js +1 -1
- package/test-provider-compaction.js +66 -1
- package/test-provider-dream.js +3 -1
- package/test-provider-events.js +106 -0
- package/test-provider-language.js +16 -1
- package/test-provider-pack-review.js +3 -1
- package/test-provider-scheduler.js +72 -1
- package/test-thread-lanes.js +6 -1
- package/test-usage-accounting.js +4 -0
- package/web.js +104 -51
package/core/dream.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
const fs = require("fs");
|
|
13
13
|
const path = require("path");
|
|
14
14
|
const cron = require("node-cron");
|
|
15
|
+
const { scheduleTolerant } = require("./cron-schedule");
|
|
15
16
|
const { atomicWriteFileSync } = require("./fsutil");
|
|
16
17
|
const { memoryMutationQueue } = require("./memory-mutation-queue");
|
|
17
18
|
|
|
@@ -937,20 +938,40 @@ function writeDreamReport(data) {
|
|
|
937
938
|
|
|
938
939
|
// Task-hygiene pass: surface tasks gone cold for the owner to decide on.
|
|
939
940
|
// Flag-and-ask only — tasks are user intent, so dream NEVER edits or deletes
|
|
940
|
-
// them; it just lists the stalest few in the morning report.
|
|
941
|
-
//
|
|
941
|
+
// them; it just lists the stalest few in the morning report. It covers every
|
|
942
|
+
// task list on this bot (tasks.taskLists): task kinds are owner-only, so each
|
|
943
|
+
// list is the owner's, whether it lives in Telegram, a Kazee chat or another
|
|
944
|
+
// channel. (It used to read only the Telegram CHAT_ID list, so an owner who
|
|
945
|
+
// works in Kazee never got a report.) At most STALE_REPORT_MAX task lines in
|
|
946
|
+
// all, shared out across the lists, each naming its list.
|
|
942
947
|
const STALE_REPORT_MAX = Number(process.env.DREAM_STALE_TASK_MAX) || 8;
|
|
943
|
-
function staleTaskReport() {
|
|
948
|
+
function staleTaskReport(opts = {}) {
|
|
944
949
|
try {
|
|
945
950
|
const tasks = require("./tasks");
|
|
946
|
-
const
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
const
|
|
952
|
-
const
|
|
953
|
-
|
|
951
|
+
const lists = tasks.taskLists(opts.dir)
|
|
952
|
+
.map((list) => ({ ...list, stale: tasks.staleRootsOf(tasks.loadFile(list.file), { now: opts.now }) }))
|
|
953
|
+
.filter((list) => list.stale.length > 0)
|
|
954
|
+
.sort((a, b) => b.stale.length - a.stale.length);
|
|
955
|
+
if (!lists.length) return "";
|
|
956
|
+
const total = lists.reduce((n, list) => n + list.stale.length, 0);
|
|
957
|
+
const max = Number.isFinite(opts.max) ? opts.max : STALE_REPORT_MAX;
|
|
958
|
+
// Round-robin, oldest first within each list, so every list gets a line.
|
|
959
|
+
const shown = [];
|
|
960
|
+
for (let i = 0; shown.length < max; i += 1) {
|
|
961
|
+
const before = shown.length;
|
|
962
|
+
for (const list of lists) {
|
|
963
|
+
if (shown.length >= max) break;
|
|
964
|
+
if (list.stale[i]) shown.push({ list, task: list.stale[i] });
|
|
965
|
+
}
|
|
966
|
+
if (shown.length === before) break;
|
|
967
|
+
}
|
|
968
|
+
const clip = (text) => (String(text).length > 80 ? `${String(text).slice(0, 79)}…` : String(text));
|
|
969
|
+
const lines = shown.map(({ list, task }) => ` - [${list.label}] ${clip(task.content)} (${task.id}) · ${task.idleDays}d idle`);
|
|
970
|
+
const where = lists.length === 1 ? `in ${lists[0].label}` : `across ${lists.length} task lists`;
|
|
971
|
+
const more = total > shown.length
|
|
972
|
+
? `\n …and ${total - shown.length} more (${lists.map((list) => `${list.label}: ${list.stale.length}`).join(" · ")})`
|
|
973
|
+
: "";
|
|
974
|
+
return `🧹 ${total} task${total === 1 ? "" : "s"} have gone cold (untouched >${tasks.STALE_DAYS}d) ${where}. Worth closing or reviving?\n${lines.join("\n")}${more}\nClose with \`open-claudia task done <id>\` in that chat, or tell me to.`;
|
|
954
975
|
} catch (e) { return ""; }
|
|
955
976
|
}
|
|
956
977
|
|
|
@@ -1566,37 +1587,123 @@ const defaultDreamService = createDreamService();
|
|
|
1566
1587
|
const runDream = defaultDreamService.runDream;
|
|
1567
1588
|
const previewDream = defaultDreamService.previewDream;
|
|
1568
1589
|
|
|
1569
|
-
//
|
|
1570
|
-
//
|
|
1571
|
-
//
|
|
1572
|
-
//
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1590
|
+
// Where the dream summary goes: DREAM_SUMMARY_TO ("<adapter>:<channelId>") if
|
|
1591
|
+
// set; else the owner's primary handle from the people roster when its
|
|
1592
|
+
// adapter is loaded (a Kazee user-id handle resolves to the owner's DM); else
|
|
1593
|
+
// the Telegram owner chat (CHAT_ID). It used to go only to Telegram, so a
|
|
1594
|
+
// Kazee-only owner never saw it. null = nowhere to send.
|
|
1595
|
+
async function dreamSummaryTarget(adapters, deps = {}) {
|
|
1596
|
+
const list = adapters || [];
|
|
1597
|
+
const find = (name) => list.find((a) => a.id === name) || list.find((a) => a.type === name) || null;
|
|
1598
|
+
const to = String(deps.summaryTo ?? process.env.DREAM_SUMMARY_TO ?? config.DREAM_SUMMARY_TO ?? "").trim();
|
|
1599
|
+
if (to) {
|
|
1600
|
+
const cut = to.indexOf(":");
|
|
1601
|
+
const adapter = cut > 0 ? find(to.slice(0, cut)) : null;
|
|
1602
|
+
if (adapter && to.slice(cut + 1)) return { adapter, channelId: to.slice(cut + 1), via: "DREAM_SUMMARY_TO" };
|
|
1603
|
+
console.warn(`dream: DREAM_SUMMARY_TO "${to}" names no loaded adapter; using the owner's channel`);
|
|
1578
1604
|
}
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1605
|
+
try {
|
|
1606
|
+
const peopleLib = deps.people || require("./people");
|
|
1607
|
+
const owner = (peopleLib.owners() || [])[0];
|
|
1608
|
+
const handles = owner?.handles || [];
|
|
1609
|
+
const handle = owner?.primaryChannel
|
|
1610
|
+
? handles.find((h) => h.adapter === owner.primaryChannel.adapter && String(h.channelId) === String(owner.primaryChannel.channelId))
|
|
1611
|
+
: null;
|
|
1612
|
+
const adapter = handle ? find(handle.adapter) : null;
|
|
1613
|
+
if (adapter) {
|
|
1614
|
+
const resolve = deps.resolveHandle || require("./relationship").resolveHandleSendTarget;
|
|
1615
|
+
const target = await resolve(handle);
|
|
1616
|
+
if (target?.channelId) return { adapter, channelId: String(target.channelId), via: "owner primary handle" };
|
|
1591
1617
|
}
|
|
1618
|
+
} catch (e) { /* fall back to Telegram */ }
|
|
1619
|
+
const chatId = deps.chatId !== undefined ? deps.chatId : require("./config").CHAT_ID;
|
|
1620
|
+
const tg = find("telegram");
|
|
1621
|
+
if (tg && chatId) return { adapter: tg, channelId: String(chatId), via: "TELEGRAM_CHAT_ID" };
|
|
1622
|
+
return null;
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
// One scheduled dream (cron or catch-up) with its summary.
|
|
1626
|
+
async function runScheduledDream(trigger, adapters, deps = {}) {
|
|
1627
|
+
try {
|
|
1628
|
+
const res = await (deps.runDream || runDream)({ trigger });
|
|
1629
|
+
if (res.skipped) { console.log(`dream: skipped — ${res.skipped}`); return res; }
|
|
1630
|
+
console.log(`dream: applied ${(res.applied || []).length} change(s)`);
|
|
1631
|
+
if (!summaryEnabled()) { console.log("dream: summary muted (DREAM_SUMMARY=off)"); return res; }
|
|
1632
|
+
if (!res.message) return res;
|
|
1633
|
+
const target = await dreamSummaryTarget(adapters, deps);
|
|
1634
|
+
if (!target) { console.warn("dream: no channel to send the summary to"); return res; }
|
|
1635
|
+
await target.adapter.send(target.channelId, res.message);
|
|
1636
|
+
return res;
|
|
1637
|
+
} catch (e) {
|
|
1638
|
+
console.error("dream: run failed:", e.message);
|
|
1639
|
+
return { failed: e.message };
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
// Catch-up: a dream that has not run for more than DREAM_CATCHUP_HOURS (26 h)
|
|
1644
|
+
// runs once now, checked at boot and hourly — a night the bot was down or the
|
|
1645
|
+
// cron slot was missed is not lost. At most one catch-up attempt per window
|
|
1646
|
+
// (stamped in the dream state, so a failing dream is not retried every hour),
|
|
1647
|
+
// and never beside a running dream.
|
|
1648
|
+
const DREAM_CATCHUP_MS = (Number(process.env.DREAM_CATCHUP_HOURS) || 26) * 3600000;
|
|
1649
|
+
function dreamOverdue(state, nowMs, windowMs = DREAM_CATCHUP_MS) {
|
|
1650
|
+
const last = Date.parse(state?.lastRun || "");
|
|
1651
|
+
if (Number.isFinite(last) && nowMs - last <= windowMs) return false;
|
|
1652
|
+
const tried = Date.parse(state?.lastCatchUpAt || "");
|
|
1653
|
+
return !(Number.isFinite(tried) && nowMs - tried <= windowMs);
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
function createDreamCatchUp(adapters, deps = {}) {
|
|
1657
|
+
const now = deps.now || Date.now;
|
|
1658
|
+
const readState = deps.readState || readDreamState;
|
|
1659
|
+
const writeState = deps.writeState || writeDreamState;
|
|
1660
|
+
const isDreaming = deps.isDreaming || defaultDreamService.isDreaming;
|
|
1661
|
+
let inFlight = null;
|
|
1662
|
+
return async function catchUp(reason = "check") {
|
|
1663
|
+
if (inFlight || isDreaming()) return { skipped: "a dream is already running" };
|
|
1664
|
+
const state = readState() || {};
|
|
1665
|
+
if (!dreamOverdue(state, now())) return { skipped: "not overdue" };
|
|
1666
|
+
writeState({ ...state, lastCatchUpAt: new Date(now()).toISOString() });
|
|
1667
|
+
console.log(`dream: catch-up (${reason}) — last run ${state.lastRun || "never"}`);
|
|
1668
|
+
inFlight = runScheduledDream("catch-up", adapters, deps);
|
|
1669
|
+
try { return await inFlight; } finally { inFlight = null; }
|
|
1670
|
+
};
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
// In-bot scheduler: one quiet-hours cron (tolerating a late start up to
|
|
1674
|
+
// DREAM_LATE_TOLERANCE_MS, see core/cron-schedule), a boot + hourly catch-up
|
|
1675
|
+
// check, and the summary to the owner's channel (dreamSummaryTarget). When the
|
|
1676
|
+
// dream summary is on (default) it posts a short note after every run —
|
|
1677
|
+
// including the charming "all tidy" line when nothing changed.
|
|
1678
|
+
// /dreamsummary off mutes it to console only.
|
|
1679
|
+
function initDream(adapters, deps = {}) {
|
|
1680
|
+
if (!enabled()) return null;
|
|
1681
|
+
const cronLib = deps.cron || cron;
|
|
1682
|
+
if (!cronLib.validate(DREAM_CRON)) {
|
|
1683
|
+
console.error(`dream: invalid DREAM_CRON "${DREAM_CRON}" — dream disabled`);
|
|
1684
|
+
return null;
|
|
1685
|
+
}
|
|
1686
|
+
const { DREAM_LATE_TOLERANCE_MS } = require("./config");
|
|
1687
|
+
const toleranceMs = Number.isFinite(deps.toleranceMs) ? deps.toleranceMs : DREAM_LATE_TOLERANCE_MS;
|
|
1688
|
+
const task = scheduleTolerant(DREAM_CRON, () => runScheduledDream("cron", adapters, deps), {
|
|
1689
|
+
cron: cronLib, toleranceMs, noOverlap: true, label: "dream",
|
|
1592
1690
|
});
|
|
1593
|
-
|
|
1691
|
+
const catchUp = createDreamCatchUp(adapters, deps);
|
|
1692
|
+
const setTimer = deps.setTimer || setTimeout;
|
|
1693
|
+
const setRepeat = deps.setInterval || setInterval;
|
|
1694
|
+
const boot = setTimer(() => { catchUp("boot").catch(() => {}); }, Number.isFinite(deps.bootDelayMs) ? deps.bootDelayMs : 60000);
|
|
1695
|
+
const hourly = setRepeat(() => { catchUp("hourly").catch(() => {}); }, 3600000);
|
|
1696
|
+
boot?.unref?.();
|
|
1697
|
+
hourly?.unref?.();
|
|
1698
|
+
console.log(`dream: scheduled (${DREAM_CRON}, late tolerance ${Math.round(toleranceMs / 60000)} min, provider policy tier ${dreamTier()}, effort ${DREAM_EFFORT}, introspect ${introspectEnabled() ? "on" : "off"})`);
|
|
1699
|
+
return { task, catchUp };
|
|
1594
1700
|
}
|
|
1595
1701
|
|
|
1596
1702
|
module.exports = {
|
|
1597
1703
|
createDreamService, previewDream, runDream, initDream, buildDreamPrompt, parseDream, applyDream, manageAbilityTiers, nestPrefixFamilies, dreamTimeoutMs,
|
|
1598
1704
|
buildIntrospectionPrompt, parseIntrospection, applyIntrospection, writeDreamReport,
|
|
1599
|
-
summaryDelta, healthOf, mergeClusters, selectDumpDirs, journalBackfillDedupe,
|
|
1705
|
+
summaryDelta, healthOf, mergeClusters, selectDumpDirs, journalBackfillDedupe, staleTaskReport,
|
|
1706
|
+
dreamSummaryTarget, runScheduledDream, dreamOverdue, createDreamCatchUp, DREAM_CATCHUP_MS,
|
|
1600
1707
|
coRescueEdgesPhase, negativeHebbianPhase, readDreamState, writeDreamState,
|
|
1601
1708
|
enabled, summaryEnabled, introspectEnabled, selfApplyEnabled, dreamTier,
|
|
1602
1709
|
DREAM_CRON, DREAM_EFFORT, DREAM_OUTPUT_SCHEMA, INTROSPECTION_OUTPUT_SCHEMA,
|
package/core/guardrail.js
CHANGED
|
@@ -59,6 +59,10 @@ const MED_OPENER = /^(?:always|don'?t|do not|make sure|be sure|remember to|prefe
|
|
|
59
59
|
|
|
60
60
|
// Junk that pattern-matches a directive but is never a rule.
|
|
61
61
|
const STOP_RULES = new Set(["never mind", "nevermind", "stop it", "never ever", "don't worry", "do not worry"]);
|
|
62
|
+
// Reassurances open like directives but never carry a rule, however they go
|
|
63
|
+
// on: "Don't worry so much about that.", "Never mind, I found it." (the
|
|
64
|
+
// latter would otherwise read as a high-tier never-directive).
|
|
65
|
+
const REASSURANCE = /^(?:don'?t worry|do not worry|never ?mind)\b/i;
|
|
62
66
|
|
|
63
67
|
function splitSentences(text) {
|
|
64
68
|
return String(text || "")
|
|
@@ -92,6 +96,7 @@ function ruleOk(rule) {
|
|
|
92
96
|
if (!rule || rule.length < MIN_RULE_CHARS) return false;
|
|
93
97
|
const low = rule.toLowerCase();
|
|
94
98
|
if (STOP_RULES.has(low)) return false;
|
|
99
|
+
if (REASSURANCE.test(rule)) return false;
|
|
95
100
|
if (low.split(/\s+/).length < MIN_RULE_WORDS) return false;
|
|
96
101
|
return true;
|
|
97
102
|
}
|
package/core/handlers.js
CHANGED
|
@@ -7,7 +7,7 @@ const path = require("path");
|
|
|
7
7
|
const cronLib = require("node-cron");
|
|
8
8
|
const { execSync } = require("child_process");
|
|
9
9
|
const {
|
|
10
|
-
WORKSPACE, FULL_PATH,
|
|
10
|
+
WORKSPACE, FULL_PATH, CONFIG_DIR,
|
|
11
11
|
config, saveEnvKey,
|
|
12
12
|
CHAT_ID, discoverProviderExecutable, SOUL_FILE,
|
|
13
13
|
} = require("./config");
|
|
@@ -38,7 +38,7 @@ const scheduler = require("./scheduler");
|
|
|
38
38
|
const skillsLib = require("./skills");
|
|
39
39
|
const packsLib = require("./packs");
|
|
40
40
|
const {
|
|
41
|
-
admitRunContext, runAgent, compactActiveSession, getActiveSessionId, effectiveCompactThreshold,
|
|
41
|
+
admitRunContext, runAgent, compactActiveSession, getActiveSessionId, effectiveCompactThreshold, defaultCompactTokens,
|
|
42
42
|
} = require("./runner");
|
|
43
43
|
const {
|
|
44
44
|
getClaudeOAuthToken, saveClaudeOAuthToken,
|
|
@@ -87,12 +87,25 @@ function cancelledQueueResult(item, reason) {
|
|
|
87
87
|
};
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
// A pick-up waiting for a usage limit to reset (core/limit-pickup.js) in
|
|
91
|
+
// the current conversation, dropped by /stop, /new and /end.
|
|
92
|
+
function dropLimitPickup(reason, state = currentState(), laneKey = currentLaneKey(state)) {
|
|
93
|
+
try { return require("./limit-pickup").cancel(state.userId, laneKey, reason); } catch (_) { return null; }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function pickupDroppedNote(count) {
|
|
97
|
+
return count === 1
|
|
98
|
+
? "I won't pick up your earlier message when the usage limit resets."
|
|
99
|
+
: `I won't pick up your ${count} earlier messages when the usage limits reset.`;
|
|
100
|
+
}
|
|
101
|
+
|
|
90
102
|
// Drops every message queued in one lane: each one's caller gets a cancelled
|
|
91
103
|
// result (no hanging promise), and an idle lane loses its typing heartbeat (the
|
|
92
104
|
// chip would stay on, and the lane could never be pruned). Returns the items.
|
|
93
105
|
function dropLaneQueue(lane, reason) {
|
|
94
106
|
if (!lane) return [];
|
|
95
107
|
const queued = lane.messageQueue.splice(0);
|
|
108
|
+
try { require("./turn-journal").dropQueued(lane.key, queued.map((item) => item?.runContext?.runId).filter(Boolean)); } catch (_) {}
|
|
96
109
|
lane.waitingForSlot = false;
|
|
97
110
|
for (const item of queued) {
|
|
98
111
|
try { item?.disposeCallListener?.(); } catch (_) {}
|
|
@@ -592,7 +605,8 @@ register({
|
|
|
592
605
|
if (!ownerEnv(env)) return;
|
|
593
606
|
await send("Going offline for a quick restart — back in a moment.");
|
|
594
607
|
try { require("./restart-reason").recordExitReason("User /restart command", { code: 0 }); } catch (e) {}
|
|
595
|
-
|
|
608
|
+
// Running turns get DRAIN_TIMEOUT_S to finish; the rest resume after boot.
|
|
609
|
+
setTimeout(() => { require("./turn-journal").exitAfterDrain(0); }, 1000);
|
|
596
610
|
},
|
|
597
611
|
});
|
|
598
612
|
|
|
@@ -854,7 +868,7 @@ register({
|
|
|
854
868
|
try { require("./boot-health").recordPendingDoctor({ reason: "source overlay upgrade", from: CURRENT_VERSION, to: newVersion }); } catch (e) {}
|
|
855
869
|
try { require("./safe-mode").clearCrashLoop(); } catch (e) {}
|
|
856
870
|
try { require("./restart-reason").recordExitReason(`User /upgrade → v${newVersion} (source refresh)`, { code: 0 }); } catch (e) {}
|
|
857
|
-
setTimeout(() =>
|
|
871
|
+
setTimeout(() => { require("./turn-journal").exitAfterDrain(0); }, 2000);
|
|
858
872
|
return;
|
|
859
873
|
}
|
|
860
874
|
try { process.chdir(process.env.HOME || require("os").homedir()); } catch (e) {}
|
|
@@ -918,7 +932,7 @@ register({
|
|
|
918
932
|
await send(`Upgrade failed:\n${errOutput}`);
|
|
919
933
|
return;
|
|
920
934
|
}
|
|
921
|
-
setTimeout(() =>
|
|
935
|
+
setTimeout(() => { require("./turn-journal").exitAfterDrain(0); }, 2000);
|
|
922
936
|
},
|
|
923
937
|
});
|
|
924
938
|
|
|
@@ -982,7 +996,7 @@ register({
|
|
|
982
996
|
try { require("./boot-health").recordPendingDoctor({ reason: "downgrade overlay", from: CURRENT_VERSION, to: resolved }); } catch (e) {}
|
|
983
997
|
try { safeModeLib.clearCrashLoop(); } catch (e) {}
|
|
984
998
|
try { require("./restart-reason").recordExitReason(`User /downgrade → v${resolved} (source refresh)`, { code: 0 }); } catch (e) {}
|
|
985
|
-
setTimeout(() =>
|
|
999
|
+
setTimeout(() => { require("./turn-journal").exitAfterDrain(0); }, 2000);
|
|
986
1000
|
return;
|
|
987
1001
|
}
|
|
988
1002
|
try { process.chdir(process.env.HOME || require("os").homedir()); } catch (e) {}
|
|
@@ -1002,7 +1016,7 @@ register({
|
|
|
1002
1016
|
await send(`Downgrade failed:\n${errOutput}`);
|
|
1003
1017
|
return;
|
|
1004
1018
|
}
|
|
1005
|
-
setTimeout(() =>
|
|
1019
|
+
setTimeout(() => { require("./turn-journal").exitAfterDrain(0); }, 2000);
|
|
1006
1020
|
},
|
|
1007
1021
|
});
|
|
1008
1022
|
|
|
@@ -1020,7 +1034,7 @@ register({
|
|
|
1020
1034
|
safeModeLib.requestContinue();
|
|
1021
1035
|
await send("Override armed: the next boot runs normally even if the substrate is missing (keyword-only memory) and the crash-loop counter resets. Restarting now...");
|
|
1022
1036
|
try { require("./restart-reason").recordExitReason("User /safemode continue override", { code: 0 }); } catch (e) {}
|
|
1023
|
-
setTimeout(() =>
|
|
1037
|
+
setTimeout(() => { require("./turn-journal").exitAfterDrain(0); }, 1500);
|
|
1024
1038
|
return;
|
|
1025
1039
|
}
|
|
1026
1040
|
if (safeModeLib.isSafeMode()) {
|
|
@@ -1537,6 +1551,7 @@ register({
|
|
|
1537
1551
|
if (!requireSession()) return;
|
|
1538
1552
|
const result = startNewConversation();
|
|
1539
1553
|
if (!result.ok) return send(`Could not start a new conversation: ${result.error.message}`);
|
|
1554
|
+
dropLimitPickup("/new");
|
|
1540
1555
|
return send(`Session: ${result.project}\nNew ${result.provider} conversation\n\nSend text, voice, or images.`);
|
|
1541
1556
|
},
|
|
1542
1557
|
});
|
|
@@ -1575,7 +1590,7 @@ function formatCompactWindow(state) {
|
|
|
1575
1590
|
const fmt = (n) => n >= 1000 ? (n / 1000).toFixed(0) + "k" : String(n);
|
|
1576
1591
|
const cw = state.settings?.compactWindow;
|
|
1577
1592
|
if (cw === 0) return "off";
|
|
1578
|
-
if (cw == null) return `default (${fmt(
|
|
1593
|
+
if (cw == null) return `default (${fmt(defaultCompactTokens(getProjectKey(state)))})`;
|
|
1579
1594
|
return fmt(cw);
|
|
1580
1595
|
}
|
|
1581
1596
|
|
|
@@ -1726,6 +1741,16 @@ function stopLane(state, lane) {
|
|
|
1726
1741
|
|
|
1727
1742
|
const STOP_DROP_REASON = "Dropped by /stop before it started";
|
|
1728
1743
|
|
|
1744
|
+
// /stop never silently loses a queued message: each dropped one comes back
|
|
1745
|
+
// with a "Run it" button (kept 24 h, core/turn-journal).
|
|
1746
|
+
function dropOffer(items) {
|
|
1747
|
+
if (!items || !items.length) return {};
|
|
1748
|
+
try {
|
|
1749
|
+
const keyboard = require("./turn-journal").droppedKeyboard(items);
|
|
1750
|
+
return keyboard ? { keyboard } : {};
|
|
1751
|
+
} catch (_) { return {}; }
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1729
1754
|
// "Dropped 2 queued: "reboot pe2", "and check the…"": what /stop dropped, a
|
|
1730
1755
|
// few words of each message (the first few in full, then a count).
|
|
1731
1756
|
function droppedNote(items, kind) {
|
|
@@ -1780,11 +1805,17 @@ register({
|
|
|
1780
1805
|
const dropped = lanes.flatMap((lane) => dropLaneQueue(lane, STOP_DROP_REASON));
|
|
1781
1806
|
const stopped = lanes.filter((lane) => stopLane(state, lane)).length;
|
|
1782
1807
|
const sideStopped = await sideCancellation;
|
|
1783
|
-
|
|
1808
|
+
let pickups = [];
|
|
1809
|
+
try { pickups = require("./limit-pickup").cancelAllFor(state.userId, "/stop all"); } catch (_) {}
|
|
1810
|
+
if (!stopped && !dropped.length) {
|
|
1811
|
+
if (sideStopped) return send("Cancelled side response.");
|
|
1812
|
+
return send(pickups.length ? pickupDroppedNote(pickups.length) : "Nothing running.");
|
|
1813
|
+
}
|
|
1784
1814
|
const parts = [];
|
|
1785
1815
|
if (stopped) parts.push(`Cancelled ${stopped} running conversation${stopped === 1 ? "" : "s"}.`);
|
|
1786
1816
|
if (dropped.length) parts.push(droppedNote(dropped, stopped ? "queued" : "waiting"));
|
|
1787
|
-
|
|
1817
|
+
if (pickups.length) parts.push(pickupDroppedNote(pickups.length));
|
|
1818
|
+
return send(parts.join(" "), dropOffer(dropped));
|
|
1788
1819
|
}
|
|
1789
1820
|
// /stop cancels the CURRENT conversation's turn only (other threads keep
|
|
1790
1821
|
// running) and drops what was queued in it before the /stop: the person
|
|
@@ -1793,16 +1824,19 @@ register({
|
|
|
1793
1824
|
const lane = laneFor(state);
|
|
1794
1825
|
const sideCancellation = sideChatCoordinator.cancel(state.userId, lane.key);
|
|
1795
1826
|
const dropped = dropLaneQueue(lane, STOP_DROP_REASON);
|
|
1827
|
+
// A pick-up waiting for a usage limit to reset is dropped too.
|
|
1828
|
+
const pickup = dropLimitPickup("/stop", state, lane.key);
|
|
1829
|
+
const pickupNote = pickup ? ` ${pickupDroppedNote(1)}` : "";
|
|
1796
1830
|
if (stopLane(state, lane)) {
|
|
1797
1831
|
await sideCancellation;
|
|
1798
|
-
await send(dropped.length ? `Cancelled. ${droppedNote(dropped, "queued")}` : "Cancelled.");
|
|
1832
|
+
await send(`${dropped.length ? `Cancelled. ${droppedNote(dropped, "queued")}` : "Cancelled."}${pickupNote}`, dropOffer(dropped));
|
|
1799
1833
|
} else if (dropped.length) {
|
|
1800
1834
|
// Nothing to cancel: the dropped messages were waiting for a slot (or
|
|
1801
1835
|
// queued behind a turn that is only finishing its tail).
|
|
1802
1836
|
await sideCancellation;
|
|
1803
|
-
await send(droppedNote(dropped, laneBusy(lane) ? "queued" : "waiting"));
|
|
1804
|
-
} else if (await sideCancellation) await send(
|
|
1805
|
-
else await send("Nothing running.");
|
|
1837
|
+
await send(`${droppedNote(dropped, laneBusy(lane) ? "queued" : "waiting")}${pickupNote}`, dropOffer(dropped));
|
|
1838
|
+
} else if (await sideCancellation) await send(`Cancelled side response.${pickupNote}`);
|
|
1839
|
+
else await send(pickup ? pickupDroppedNote(1) : "Nothing running.");
|
|
1806
1840
|
},
|
|
1807
1841
|
});
|
|
1808
1842
|
|
|
@@ -1855,16 +1889,42 @@ register({
|
|
|
1855
1889
|
const state = currentState();
|
|
1856
1890
|
const result = endCurrentSession({ state });
|
|
1857
1891
|
if (!result.ok) return send(`Could not end conversation: ${result.error.message}`);
|
|
1892
|
+
dropLimitPickup("/end", state);
|
|
1858
1893
|
send("Ended. Send anything to start a fresh conversation.");
|
|
1859
1894
|
},
|
|
1860
1895
|
});
|
|
1861
1896
|
|
|
1897
|
+
// Plan limits as last reported (core/usage-limits.js), and this person's
|
|
1898
|
+
// pick-up in this conversation if a usage limit stopped a message.
|
|
1899
|
+
function planLimitLines(state = currentState()) {
|
|
1900
|
+
const lines = [];
|
|
1901
|
+
try {
|
|
1902
|
+
const limits = require("./usage-limits");
|
|
1903
|
+
const byProvider = new Map();
|
|
1904
|
+
for (const w of limits.knownWindows()) {
|
|
1905
|
+
if (!byProvider.has(w.provider)) byProvider.set(w.provider, []);
|
|
1906
|
+
byProvider.get(w.provider).push(w);
|
|
1907
|
+
}
|
|
1908
|
+
const order = { five_hour: 0, seven_day: 1 };
|
|
1909
|
+
for (const [provider, windows] of byProvider) {
|
|
1910
|
+
const parts = windows
|
|
1911
|
+
.sort((a, b) => (order[a.id] ?? 9) - (order[b.id] ?? 9))
|
|
1912
|
+
.map((w) => `${limits.windowLabel(w.id).replace(/ limit$/, "")} ${Math.round(w.usedPercent)}%${w.resetsAt ? ` (resets ${limits.formatResetTime(w.resetsAt)})` : ""}`);
|
|
1913
|
+
lines.push(`${limits.providerLabel(provider)} plan limits: ${parts.join(" · ")}`);
|
|
1914
|
+
}
|
|
1915
|
+
const pickup = require("./limit-pickup").get(state.userId, currentLaneKey(state));
|
|
1916
|
+
if (pickup) lines.push(`Picking up your stopped message ${limits.whenPhrase(pickup.runAt)} (/stop drops it).`);
|
|
1917
|
+
} catch (_) {}
|
|
1918
|
+
return lines;
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1862
1921
|
register({
|
|
1863
1922
|
name: "usage", description: "Show token usage & cost for this session",
|
|
1864
1923
|
handler: async (env) => {
|
|
1865
1924
|
if (!authorized(env)) return;
|
|
1866
1925
|
const u = currentState().sessionUsage;
|
|
1867
|
-
|
|
1926
|
+
const limitLines = planLimitLines();
|
|
1927
|
+
if (u.turns === 0) return send(["No usage yet in this session.", ...limitLines].join("\n"));
|
|
1868
1928
|
const fmt = (n) => n >= 1000 ? (n / 1000).toFixed(1) + "k" : String(n);
|
|
1869
1929
|
const lines = [
|
|
1870
1930
|
`*Session usage* (${u.turns} turn${u.turns === 1 ? "" : "s"})`,
|
|
@@ -1880,7 +1940,8 @@ register({
|
|
|
1880
1940
|
if (u.subagentThreads > 0) {
|
|
1881
1941
|
lines.push(`Provider sub-agents: ${u.subagentThreads} thread${u.subagentThreads === 1 ? "" : "s"} · ${fmt(u.subagentInputTokens || 0)} in / ${fmt(u.subagentOutputTokens || 0)} out (spawned by the model; not in the totals above)`);
|
|
1882
1942
|
}
|
|
1883
|
-
if (
|
|
1943
|
+
if (limitLines.length) lines.push(...limitLines);
|
|
1944
|
+
else if (Number.isFinite(u.quotaUsedPercent)) {
|
|
1884
1945
|
lines.push(`Plan quota used: ${u.quotaUsedPercent}%${u.quotaResetsAt ? ` (resets ${String(u.quotaResetsAt).slice(0, 16).replace("T", " ")}Z)` : ""}`);
|
|
1885
1946
|
}
|
|
1886
1947
|
if (u.lastInputTokens > 200000) {
|
package/core/lessons.js
CHANGED
|
@@ -148,8 +148,15 @@ function listLessons() {
|
|
|
148
148
|
// then most recently reinforced) survive the char budget. Returns "" when
|
|
149
149
|
// there are none, so the system prompt stays clean on a fresh install.
|
|
150
150
|
function loadLessonsBlock() {
|
|
151
|
+
const out = injectedLessons().map(lessonLine);
|
|
152
|
+
if (out.length === 0) return "";
|
|
153
|
+
return out.join("\n");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// The lessons the always-injected block carries, in its order, with meta.
|
|
157
|
+
function injectedLessons() {
|
|
151
158
|
const lessons = readLessons();
|
|
152
|
-
if (lessons.length === 0) return
|
|
159
|
+
if (lessons.length === 0) return [];
|
|
153
160
|
const meta = reconcileMeta(lessons);
|
|
154
161
|
const ranked = lessons.slice().sort((a, b) => {
|
|
155
162
|
const ma = meta[a.id] || {}, mb = meta[b.id] || {};
|
|
@@ -162,11 +169,10 @@ function loadLessonsBlock() {
|
|
|
162
169
|
for (const l of ranked) {
|
|
163
170
|
const line = lessonLine(l);
|
|
164
171
|
if (used + line.length + 1 > MAX_LESSONS_CHARS) break;
|
|
165
|
-
out.push(
|
|
172
|
+
out.push({ ...l, ...(meta[l.id] || {}) });
|
|
166
173
|
used += line.length + 1;
|
|
167
174
|
}
|
|
168
|
-
|
|
169
|
-
return out.join("\n");
|
|
175
|
+
return out;
|
|
170
176
|
}
|
|
171
177
|
|
|
172
178
|
// Add or reinforce a lesson. Dedupe is by normalized text (id), so the same
|
|
@@ -271,7 +277,7 @@ module.exports = {
|
|
|
271
277
|
LESSONS_FILE, LESSONS_META_FILE,
|
|
272
278
|
MAX_LESSONS, MAX_LESSON_CHARS, MAX_LESSONS_CHARS,
|
|
273
279
|
normalize, lessonId, parseLessons,
|
|
274
|
-
readLessons, listLessons, loadLessonsBlock,
|
|
280
|
+
readLessons, listLessons, loadLessonsBlock, injectedLessons, lessonLine,
|
|
275
281
|
readMeta, writeMeta, reconcileMeta,
|
|
276
282
|
addLesson, editLesson, removeLesson, saveLessonsRaw, lessonsExist,
|
|
277
283
|
};
|