@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/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,
|
|
@@ -93,6 +93,7 @@ function cancelledQueueResult(item, reason) {
|
|
|
93
93
|
function dropLaneQueue(lane, reason) {
|
|
94
94
|
if (!lane) return [];
|
|
95
95
|
const queued = lane.messageQueue.splice(0);
|
|
96
|
+
try { require("./turn-journal").dropQueued(lane.key, queued.map((item) => item?.runContext?.runId).filter(Boolean)); } catch (_) {}
|
|
96
97
|
lane.waitingForSlot = false;
|
|
97
98
|
for (const item of queued) {
|
|
98
99
|
try { item?.disposeCallListener?.(); } catch (_) {}
|
|
@@ -592,7 +593,8 @@ register({
|
|
|
592
593
|
if (!ownerEnv(env)) return;
|
|
593
594
|
await send("Going offline for a quick restart — back in a moment.");
|
|
594
595
|
try { require("./restart-reason").recordExitReason("User /restart command", { code: 0 }); } catch (e) {}
|
|
595
|
-
|
|
596
|
+
// Running turns get DRAIN_TIMEOUT_S to finish; the rest resume after boot.
|
|
597
|
+
setTimeout(() => { require("./turn-journal").exitAfterDrain(0); }, 1000);
|
|
596
598
|
},
|
|
597
599
|
});
|
|
598
600
|
|
|
@@ -854,7 +856,7 @@ register({
|
|
|
854
856
|
try { require("./boot-health").recordPendingDoctor({ reason: "source overlay upgrade", from: CURRENT_VERSION, to: newVersion }); } catch (e) {}
|
|
855
857
|
try { require("./safe-mode").clearCrashLoop(); } catch (e) {}
|
|
856
858
|
try { require("./restart-reason").recordExitReason(`User /upgrade → v${newVersion} (source refresh)`, { code: 0 }); } catch (e) {}
|
|
857
|
-
setTimeout(() =>
|
|
859
|
+
setTimeout(() => { require("./turn-journal").exitAfterDrain(0); }, 2000);
|
|
858
860
|
return;
|
|
859
861
|
}
|
|
860
862
|
try { process.chdir(process.env.HOME || require("os").homedir()); } catch (e) {}
|
|
@@ -918,7 +920,7 @@ register({
|
|
|
918
920
|
await send(`Upgrade failed:\n${errOutput}`);
|
|
919
921
|
return;
|
|
920
922
|
}
|
|
921
|
-
setTimeout(() =>
|
|
923
|
+
setTimeout(() => { require("./turn-journal").exitAfterDrain(0); }, 2000);
|
|
922
924
|
},
|
|
923
925
|
});
|
|
924
926
|
|
|
@@ -982,7 +984,7 @@ register({
|
|
|
982
984
|
try { require("./boot-health").recordPendingDoctor({ reason: "downgrade overlay", from: CURRENT_VERSION, to: resolved }); } catch (e) {}
|
|
983
985
|
try { safeModeLib.clearCrashLoop(); } catch (e) {}
|
|
984
986
|
try { require("./restart-reason").recordExitReason(`User /downgrade → v${resolved} (source refresh)`, { code: 0 }); } catch (e) {}
|
|
985
|
-
setTimeout(() =>
|
|
987
|
+
setTimeout(() => { require("./turn-journal").exitAfterDrain(0); }, 2000);
|
|
986
988
|
return;
|
|
987
989
|
}
|
|
988
990
|
try { process.chdir(process.env.HOME || require("os").homedir()); } catch (e) {}
|
|
@@ -1002,7 +1004,7 @@ register({
|
|
|
1002
1004
|
await send(`Downgrade failed:\n${errOutput}`);
|
|
1003
1005
|
return;
|
|
1004
1006
|
}
|
|
1005
|
-
setTimeout(() =>
|
|
1007
|
+
setTimeout(() => { require("./turn-journal").exitAfterDrain(0); }, 2000);
|
|
1006
1008
|
},
|
|
1007
1009
|
});
|
|
1008
1010
|
|
|
@@ -1020,7 +1022,7 @@ register({
|
|
|
1020
1022
|
safeModeLib.requestContinue();
|
|
1021
1023
|
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
1024
|
try { require("./restart-reason").recordExitReason("User /safemode continue override", { code: 0 }); } catch (e) {}
|
|
1023
|
-
setTimeout(() =>
|
|
1025
|
+
setTimeout(() => { require("./turn-journal").exitAfterDrain(0); }, 1500);
|
|
1024
1026
|
return;
|
|
1025
1027
|
}
|
|
1026
1028
|
if (safeModeLib.isSafeMode()) {
|
|
@@ -1575,7 +1577,7 @@ function formatCompactWindow(state) {
|
|
|
1575
1577
|
const fmt = (n) => n >= 1000 ? (n / 1000).toFixed(0) + "k" : String(n);
|
|
1576
1578
|
const cw = state.settings?.compactWindow;
|
|
1577
1579
|
if (cw === 0) return "off";
|
|
1578
|
-
if (cw == null) return `default (${fmt(
|
|
1580
|
+
if (cw == null) return `default (${fmt(defaultCompactTokens(getProjectKey(state)))})`;
|
|
1579
1581
|
return fmt(cw);
|
|
1580
1582
|
}
|
|
1581
1583
|
|
|
@@ -1726,6 +1728,16 @@ function stopLane(state, lane) {
|
|
|
1726
1728
|
|
|
1727
1729
|
const STOP_DROP_REASON = "Dropped by /stop before it started";
|
|
1728
1730
|
|
|
1731
|
+
// /stop never silently loses a queued message: each dropped one comes back
|
|
1732
|
+
// with a "Run it" button (kept 24 h, core/turn-journal).
|
|
1733
|
+
function dropOffer(items) {
|
|
1734
|
+
if (!items || !items.length) return {};
|
|
1735
|
+
try {
|
|
1736
|
+
const keyboard = require("./turn-journal").droppedKeyboard(items);
|
|
1737
|
+
return keyboard ? { keyboard } : {};
|
|
1738
|
+
} catch (_) { return {}; }
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1729
1741
|
// "Dropped 2 queued: "reboot pe2", "and check the…"": what /stop dropped, a
|
|
1730
1742
|
// few words of each message (the first few in full, then a count).
|
|
1731
1743
|
function droppedNote(items, kind) {
|
|
@@ -1784,7 +1796,7 @@ register({
|
|
|
1784
1796
|
const parts = [];
|
|
1785
1797
|
if (stopped) parts.push(`Cancelled ${stopped} running conversation${stopped === 1 ? "" : "s"}.`);
|
|
1786
1798
|
if (dropped.length) parts.push(droppedNote(dropped, stopped ? "queued" : "waiting"));
|
|
1787
|
-
return send(parts.join(" "));
|
|
1799
|
+
return send(parts.join(" "), dropOffer(dropped));
|
|
1788
1800
|
}
|
|
1789
1801
|
// /stop cancels the CURRENT conversation's turn only (other threads keep
|
|
1790
1802
|
// running) and drops what was queued in it before the /stop: the person
|
|
@@ -1795,12 +1807,12 @@ register({
|
|
|
1795
1807
|
const dropped = dropLaneQueue(lane, STOP_DROP_REASON);
|
|
1796
1808
|
if (stopLane(state, lane)) {
|
|
1797
1809
|
await sideCancellation;
|
|
1798
|
-
await send(dropped.length ? `Cancelled. ${droppedNote(dropped, "queued")}` : "Cancelled.");
|
|
1810
|
+
await send(dropped.length ? `Cancelled. ${droppedNote(dropped, "queued")}` : "Cancelled.", dropOffer(dropped));
|
|
1799
1811
|
} else if (dropped.length) {
|
|
1800
1812
|
// Nothing to cancel: the dropped messages were waiting for a slot (or
|
|
1801
1813
|
// queued behind a turn that is only finishing its tail).
|
|
1802
1814
|
await sideCancellation;
|
|
1803
|
-
await send(droppedNote(dropped, laneBusy(lane) ? "queued" : "waiting"));
|
|
1815
|
+
await send(droppedNote(dropped, laneBusy(lane) ? "queued" : "waiting"), dropOffer(dropped));
|
|
1804
1816
|
} else if (await sideCancellation) await send("Cancelled side response.");
|
|
1805
1817
|
else await send("Nothing running.");
|
|
1806
1818
|
},
|
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
|
};
|
package/core/loopback.js
CHANGED
|
@@ -91,12 +91,14 @@ const OWNER_ONLY_KINDS = new Set([
|
|
|
91
91
|
"people-list", "people-show", "people-note",
|
|
92
92
|
"task-add", "task-plan", "task-update", "task-remove",
|
|
93
93
|
"task-list", "task-get", "task-tree", "task-clear-completed",
|
|
94
|
+
"task-archive", "task-restore", "task-archived",
|
|
94
95
|
]);
|
|
95
96
|
const JSON_KINDS = new Set([
|
|
96
97
|
"whoami",
|
|
97
98
|
"schedule-wakeup", "cron-add", "cron-remove", "job-list",
|
|
98
99
|
"watch-add", "watch-list", "watch-remove", "watch-toggle",
|
|
99
100
|
"task-add", "task-plan", "task-update", "task-remove", "task-list", "task-get", "task-tree", "task-clear-completed",
|
|
101
|
+
"task-archive", "task-restore", "task-archived",
|
|
100
102
|
"people-list", "people-show", "people-add", "people-note", "people-link", "people-unlink",
|
|
101
103
|
"people-remove", "people-set-primary",
|
|
102
104
|
"intros-list", "intros-approve", "intros-reject",
|
|
@@ -668,9 +670,12 @@ async function handleJson(req, res, url, kind) {
|
|
|
668
670
|
if (kind === "task-add") {
|
|
669
671
|
if (!payload.content) return reply(res, 400, { error: "missing content" });
|
|
670
672
|
try {
|
|
673
|
+
// A task belongs to the conversation it was made in (the run's thread,
|
|
674
|
+
// null on the main timeline); a subtask follows its plan.
|
|
671
675
|
const t = tasksStore.add(adapterId, channelId, payload.content, {
|
|
672
676
|
parentId: payload.parentId || null,
|
|
673
677
|
description: payload.description || null,
|
|
678
|
+
threadId: threadForCaller(callerOf(payload), channelId, payload.threadId),
|
|
674
679
|
});
|
|
675
680
|
return reply(res, 200, { ok: true, task: t });
|
|
676
681
|
} catch (e) { return reply(res, 400, { error: e.message }); }
|
|
@@ -682,6 +687,7 @@ async function handleJson(req, res, url, kind) {
|
|
|
682
687
|
if (subs.length === 0) return reply(res, 400, { error: "subtasks must be a non-empty array" });
|
|
683
688
|
const result = tasksStore.plan(adapterId, channelId, payload.title, subs, {
|
|
684
689
|
description: payload.description || null,
|
|
690
|
+
threadId: threadForCaller(callerOf(payload), channelId, payload.threadId),
|
|
685
691
|
});
|
|
686
692
|
return reply(res, 200, { ok: true, plan: result });
|
|
687
693
|
}
|
|
@@ -723,6 +729,29 @@ async function handleJson(req, res, url, kind) {
|
|
|
723
729
|
return reply(res, got ? 200 : 404, { ok: !!got, ...(got || {}) });
|
|
724
730
|
}
|
|
725
731
|
|
|
732
|
+
if (kind === "task-archive") {
|
|
733
|
+
if (!payload.id) return reply(res, 400, { error: "missing id" });
|
|
734
|
+
try {
|
|
735
|
+
const result = tasksStore.archive(adapterId, channelId, payload.id, payload.reason);
|
|
736
|
+
return reply(res, result ? 200 : 404, { ok: !!result, ...(result || {}) });
|
|
737
|
+
} catch (e) { return reply(res, 400, { error: e.message }); }
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (kind === "task-restore") {
|
|
741
|
+
if (!payload.id) return reply(res, 400, { error: "missing id" });
|
|
742
|
+
try {
|
|
743
|
+
const result = tasksStore.restore(adapterId, channelId, payload.id);
|
|
744
|
+
return reply(res, result ? 200 : 404, { ok: !!result, ...(result || {}) });
|
|
745
|
+
} catch (e) { return reply(res, 400, { error: e.message }); }
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (kind === "task-archived") {
|
|
749
|
+
const limit = Number(payload.limit);
|
|
750
|
+
const opts = { search: payload.search || "" };
|
|
751
|
+
if (Number.isFinite(limit) && limit > 0) opts.limit = limit;
|
|
752
|
+
return reply(res, 200, { ok: true, tasks: tasksStore.archived(adapterId, channelId, opts) });
|
|
753
|
+
}
|
|
754
|
+
|
|
726
755
|
if (kind === "task-clear-completed") {
|
|
727
756
|
const remaining = tasksStore.clearCompleted(adapterId, channelId);
|
|
728
757
|
return reply(res, 200, { ok: true, tasks: remaining });
|
|
@@ -58,6 +58,15 @@ function normalizeClaudeEvent(rawEvent, state = createClaudeParserState()) {
|
|
|
58
58
|
const decoderFailure = normalizeDecoderError(rawEvent);
|
|
59
59
|
if (decoderFailure) return [decoderFailure];
|
|
60
60
|
|
|
61
|
+
// A helper agent's (Agent/Task tool) own messages carry parent_tool_use_id:
|
|
62
|
+
// its working notes, tool calls and refusals are not this turn's reply.
|
|
63
|
+
// Only its spend still counts.
|
|
64
|
+
if (rawEvent.parent_tool_use_id && ["assistant", "stream_event", "user"].includes(rawEvent.type)) {
|
|
65
|
+
if (rawEvent.type !== "assistant") return [];
|
|
66
|
+
const usage = normalizedUsage(rawEvent.message?.usage, "provider_call", state.seenUsage);
|
|
67
|
+
return usage ? [usage] : [];
|
|
68
|
+
}
|
|
69
|
+
|
|
61
70
|
if (rawEvent.type === "system" && rawEvent.subtype === "init") {
|
|
62
71
|
const event = sessionEvent(rawEvent.session_id, state);
|
|
63
72
|
return event ? [event] : [];
|
package/core/providers/codex.js
CHANGED
|
@@ -75,6 +75,52 @@ function imagePathsOf(context) {
|
|
|
75
75
|
return paths;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
// Codex hands --output-schema to OpenAI in strict mode, which refuses the call
|
|
79
|
+
// (400 invalid_json_schema) when the root is not an object, or an object has no
|
|
80
|
+
// properties, allows extra keys or leaves a property out of `required`
|
|
81
|
+
// (6 Oct 2026: every passive-reply check, pack review, dedup and dream on Codex
|
|
82
|
+
// bots failed that way). An object whose properties are all required is closed
|
|
83
|
+
// with additionalProperties: false, which costs a reply nothing. A schema that
|
|
84
|
+
// needs optional or free-form fields gives null: no schema is sent, and the
|
|
85
|
+
// utility agent still checks the reply against the caller's full schema.
|
|
86
|
+
function strictOutputSchema(schema) {
|
|
87
|
+
function close(node, depth) {
|
|
88
|
+
if (!node || typeof node !== "object" || Array.isArray(node)) return depth > 0 ? node : null;
|
|
89
|
+
if (depth > 20 || node.$ref !== undefined) return null;
|
|
90
|
+
const copy = { ...node };
|
|
91
|
+
if ([].concat(node.type || []).includes("object")) {
|
|
92
|
+
const properties = node.properties && typeof node.properties === "object" ? node.properties : {};
|
|
93
|
+
const keys = Object.keys(properties);
|
|
94
|
+
if (!keys.length) return null;
|
|
95
|
+
if (node.additionalProperties && typeof node.additionalProperties === "object") return null;
|
|
96
|
+
const required = new Set(Array.isArray(node.required) ? node.required : []);
|
|
97
|
+
if (keys.some((key) => !required.has(key))) return null;
|
|
98
|
+
copy.properties = {};
|
|
99
|
+
for (const key of keys) {
|
|
100
|
+
const child = close(properties[key], depth + 1);
|
|
101
|
+
if (child === null) return null;
|
|
102
|
+
copy.properties[key] = child;
|
|
103
|
+
}
|
|
104
|
+
copy.additionalProperties = false;
|
|
105
|
+
}
|
|
106
|
+
if (node.items !== undefined) {
|
|
107
|
+
const items = Array.isArray(node.items) ? null : close(node.items, depth + 1);
|
|
108
|
+
if (items === null) return null;
|
|
109
|
+
copy.items = items;
|
|
110
|
+
}
|
|
111
|
+
for (const key of ["anyOf", "oneOf", "allOf"]) {
|
|
112
|
+
if (node[key] === undefined) continue;
|
|
113
|
+
if (!Array.isArray(node[key])) return null;
|
|
114
|
+
const variants = node[key].map((variant) => close(variant, depth + 1));
|
|
115
|
+
if (variants.includes(null)) return null;
|
|
116
|
+
copy[key] = variants;
|
|
117
|
+
}
|
|
118
|
+
return copy;
|
|
119
|
+
}
|
|
120
|
+
if (!schema || typeof schema !== "object" || schema.type !== "object") return null;
|
|
121
|
+
return close(schema, 0);
|
|
122
|
+
}
|
|
123
|
+
|
|
78
124
|
function createCodexProvider(options = {}) {
|
|
79
125
|
const resolveExecutable = options.resolveExecutable || (() => config.discoverProviderExecutable("codex"));
|
|
80
126
|
const defaultModel = options.defaultModel ?? config.config.CODEX_MODEL ?? null;
|
|
@@ -266,6 +312,7 @@ function createCodexProvider(options = {}) {
|
|
|
266
312
|
return buildInvocation({ ...context, permissionMode: "plan" }, false);
|
|
267
313
|
},
|
|
268
314
|
buildUtilityInvocation: (context) => buildInvocation({ ...context, fresh: true }, true),
|
|
315
|
+
outputSchemaFor: (schema) => strictOutputSchema(schema),
|
|
269
316
|
createEventDecoder: (decoderOptions) => createJsonlDecoder(decoderOptions),
|
|
270
317
|
normalizeEvent: normalizeCodexEvent,
|
|
271
318
|
authStatus: () => getAuthStatus(executable()),
|
|
@@ -300,4 +347,5 @@ module.exports = {
|
|
|
300
347
|
capabilities,
|
|
301
348
|
codexProvider,
|
|
302
349
|
createCodexProvider,
|
|
350
|
+
strictOutputSchema,
|
|
303
351
|
};
|
package/core/relationship.js
CHANGED
|
@@ -323,4 +323,4 @@ function ensureContact(envelope) {
|
|
|
323
323
|
}
|
|
324
324
|
}
|
|
325
325
|
|
|
326
|
-
module.exports = { speakerFor, currentSpeaker, guardActive, isCurrentSpeakerGuarded, ownerTarget, ownerTargets, configuredOwnerTarget, ensureContact };
|
|
326
|
+
module.exports = { speakerFor, currentSpeaker, guardActive, isCurrentSpeakerGuarded, ownerTarget, ownerTargets, configuredOwnerTarget, ensureContact, resolveHandleSendTarget };
|
package/core/router.js
CHANGED
|
@@ -431,8 +431,13 @@ async function handleDocument(envelope) {
|
|
|
431
431
|
attachments: [{ type: "document", mimeType: media.mimeType || null, fileName: media.fileName || null }],
|
|
432
432
|
});
|
|
433
433
|
stopPreTurnTyping = startPreTurnTyping(state);
|
|
434
|
+
// Saved before the guard runs: a held request resumes after the owner's
|
|
435
|
+
// approval from the guard's text alone (core/actions.js), so that text
|
|
436
|
+
// must say where the file is. The adapters keep the file inside FILES_DIR
|
|
437
|
+
// under a cleaned name (core/safe-filename.js), and that name is the one
|
|
438
|
+
// shown, never the sender's raw path.
|
|
434
439
|
const savePath = await envelope.adapter.downloadMedia(media);
|
|
435
|
-
const fileName =
|
|
440
|
+
const fileName = path.basename(savePath);
|
|
436
441
|
const mime = media.mimeType || "";
|
|
437
442
|
const caption = envelope.caption || "";
|
|
438
443
|
const isImage = mime.startsWith("image/");
|