@ouro.bot/cli 0.1.0-alpha.155 → 0.1.0-alpha.157
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.json +21 -1
- package/dist/heart/daemon/habit-scheduler.js +18 -15
- package/dist/heart/tool-description.js +8 -2
- package/dist/mind/prompt.js +36 -0
- package/dist/senses/bluebubbles.js +47 -3
- package/dist/senses/commands.js +1 -1
- package/dist/senses/habit-turn-message.js +108 -0
- package/dist/senses/inner-dialog.js +37 -78
- package/package.json +1 -1
- package/dist/senses/contextual-heartbeat.js +0 -106
package/changelog.json
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
|
|
3
3
|
"versions": [
|
|
4
|
+
{
|
|
5
|
+
"version": "0.1.0-alpha.157",
|
|
6
|
+
"changes": [
|
|
7
|
+
"Habit turns as awareness: unified buildHabitTurnMessage replaces contextual-heartbeat. Continuity-first format (checkpoint leads, not elapsed time). Same format for all habits \u2014 no heartbeat special-casing.",
|
|
8
|
+
"First beat experience: new habits get \"your [Title] is alive. this is its first breath\" on first fire.",
|
|
9
|
+
"Fix: reconcile() now fires new/overdue habits immediately (was start()-only). New habits created via write_file fire within seconds.",
|
|
10
|
+
"Rhythm awareness across all channels: rhythmStatusSection() in system prompt shows heartbeat health in every conversation.",
|
|
11
|
+
"Removed contextual-heartbeat.ts entirely. Dead lastSurfaceAt parameter removed."
|
|
12
|
+
]
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.1.0-alpha.156",
|
|
16
|
+
"changes": [
|
|
17
|
+
"polish: observe tool now hidden (like settle/rest/descend)",
|
|
18
|
+
"polish(bb): batch rapid tool status messages within 500ms debounce window",
|
|
19
|
+
"polish(tools): word-boundary truncation instead of mid-word cut",
|
|
20
|
+
"polish(commands): /commands output uses em dash separators",
|
|
21
|
+
"polish(bb): re-enable typing indicator after each status message"
|
|
22
|
+
]
|
|
23
|
+
},
|
|
4
24
|
{
|
|
5
25
|
"version": "0.1.0-alpha.155",
|
|
6
26
|
"changes": [
|
|
@@ -1023,4 +1043,4 @@
|
|
|
1023
1043
|
]
|
|
1024
1044
|
}
|
|
1025
1045
|
]
|
|
1026
|
-
}
|
|
1046
|
+
}
|
|
@@ -73,7 +73,24 @@ class HabitScheduler {
|
|
|
73
73
|
const jobs = this.buildJobs(habits);
|
|
74
74
|
this.osCronManager.sync(jobs);
|
|
75
75
|
this.verifyCronAndCreateFallbacks(jobs);
|
|
76
|
-
|
|
76
|
+
this.fireOverdueHabits(habits);
|
|
77
|
+
}
|
|
78
|
+
reconcile() {
|
|
79
|
+
(0, runtime_1.emitNervesEvent)({
|
|
80
|
+
component: "daemon",
|
|
81
|
+
event: "daemon.habit_scheduler_reconcile",
|
|
82
|
+
message: "habit scheduler reconciling",
|
|
83
|
+
meta: { agent: this.agent },
|
|
84
|
+
});
|
|
85
|
+
// Clear ALL existing timers FIRST to prevent overlap window
|
|
86
|
+
this.clearAllTimerFallbacks();
|
|
87
|
+
const habits = this.scanHabits();
|
|
88
|
+
const jobs = this.buildJobs(habits);
|
|
89
|
+
this.osCronManager.sync(jobs);
|
|
90
|
+
this.verifyCronAndCreateFallbacks(jobs);
|
|
91
|
+
this.fireOverdueHabits(habits);
|
|
92
|
+
}
|
|
93
|
+
fireOverdueHabits(habits) {
|
|
77
94
|
for (const habit of habits) {
|
|
78
95
|
if (habit.status !== "active")
|
|
79
96
|
continue;
|
|
@@ -106,20 +123,6 @@ class HabitScheduler {
|
|
|
106
123
|
}
|
|
107
124
|
}
|
|
108
125
|
}
|
|
109
|
-
reconcile() {
|
|
110
|
-
(0, runtime_1.emitNervesEvent)({
|
|
111
|
-
component: "daemon",
|
|
112
|
-
event: "daemon.habit_scheduler_reconcile",
|
|
113
|
-
message: "habit scheduler reconciling",
|
|
114
|
-
meta: { agent: this.agent },
|
|
115
|
-
});
|
|
116
|
-
// Clear ALL existing timers FIRST to prevent overlap window
|
|
117
|
-
this.clearAllTimerFallbacks();
|
|
118
|
-
const habits = this.scanHabits();
|
|
119
|
-
const jobs = this.buildJobs(habits);
|
|
120
|
-
this.osCronManager.sync(jobs);
|
|
121
|
-
this.verifyCronAndCreateFallbacks(jobs);
|
|
122
|
-
}
|
|
123
126
|
stop() {
|
|
124
127
|
(0, runtime_1.emitNervesEvent)({
|
|
125
128
|
component: "daemon",
|
|
@@ -9,7 +9,13 @@ function basename(filePath) {
|
|
|
9
9
|
function truncate(text, max) {
|
|
10
10
|
if (text.length <= max)
|
|
11
11
|
return text;
|
|
12
|
-
|
|
12
|
+
// Cut at last space before maxLen to avoid mid-word truncation
|
|
13
|
+
const slice = text.slice(0, max - 1);
|
|
14
|
+
const lastSpace = slice.lastIndexOf(" ");
|
|
15
|
+
if (lastSpace > 0)
|
|
16
|
+
return text.slice(0, lastSpace) + "\u2026";
|
|
17
|
+
// No space found (single long word) — hard truncate
|
|
18
|
+
return slice + "\u2026";
|
|
13
19
|
}
|
|
14
20
|
const TOOL_DESCRIPTIONS = {
|
|
15
21
|
// File operations
|
|
@@ -98,7 +104,7 @@ const TOOL_DESCRIPTIONS = {
|
|
|
98
104
|
const thought = args.thought;
|
|
99
105
|
return thought ? `thinking about ${truncate(thought, 40)}...` : "thinking deeper...";
|
|
100
106
|
},
|
|
101
|
-
observe: () =>
|
|
107
|
+
observe: () => null,
|
|
102
108
|
claude: () => "reasoning...",
|
|
103
109
|
set_reasoning_effort: (args) => {
|
|
104
110
|
const level = args.level;
|
package/dist/mind/prompt.js
CHANGED
|
@@ -51,6 +51,7 @@ exports.channelNatureSection = channelNatureSection;
|
|
|
51
51
|
exports.groupChatParticipationSection = groupChatParticipationSection;
|
|
52
52
|
exports.feedbackSignalSection = feedbackSignalSection;
|
|
53
53
|
exports.mixedTrustGroupSection = mixedTrustGroupSection;
|
|
54
|
+
exports.rhythmStatusSection = rhythmStatusSection;
|
|
54
55
|
exports.buildSystem = buildSystem;
|
|
55
56
|
const fs = __importStar(require("fs"));
|
|
56
57
|
const path = __importStar(require("path"));
|
|
@@ -71,6 +72,7 @@ const session_activity_1 = require("../heart/session-activity");
|
|
|
71
72
|
const active_work_1 = require("../heart/active-work");
|
|
72
73
|
const commitments_1 = require("../heart/commitments");
|
|
73
74
|
const obligation_steering_1 = require("./obligation-steering");
|
|
75
|
+
const daemon_health_1 = require("../heart/daemon/daemon-health");
|
|
74
76
|
// Lazy-loaded psyche text cache
|
|
75
77
|
let _psycheCache = null;
|
|
76
78
|
let _senseStatusLinesCache = null;
|
|
@@ -886,6 +888,39 @@ function mixedTrustGroupSection(context) {
|
|
|
886
888
|
return "";
|
|
887
889
|
return "## mixed trust group\nin this group chat, my capabilities depend on who's talking. some people here have full trust, others don't — i adjust what i can do based on who's asking.";
|
|
888
890
|
}
|
|
891
|
+
function formatElapsedBrief(ms) {
|
|
892
|
+
const minutes = Math.floor(ms / 60000);
|
|
893
|
+
if (minutes < 60)
|
|
894
|
+
return `${minutes}m ago`;
|
|
895
|
+
const hours = Math.floor(minutes / 60);
|
|
896
|
+
return `${hours}h ago`;
|
|
897
|
+
}
|
|
898
|
+
function rhythmStatusSection() {
|
|
899
|
+
try {
|
|
900
|
+
const health = (0, daemon_health_1.readHealth)((0, daemon_health_1.getDefaultHealthPath)());
|
|
901
|
+
if (!health)
|
|
902
|
+
return "";
|
|
903
|
+
const habitNames = Object.keys(health.habits);
|
|
904
|
+
if (habitNames.length === 0)
|
|
905
|
+
return "";
|
|
906
|
+
const nowMs = Date.now();
|
|
907
|
+
const parts = [];
|
|
908
|
+
for (const name of habitNames) {
|
|
909
|
+
const h = health.habits[name];
|
|
910
|
+
const lastFired = h.lastFired ? formatElapsedBrief(nowMs - new Date(h.lastFired).getTime()) : "never";
|
|
911
|
+
parts.push(`${name} last fired ${lastFired}`);
|
|
912
|
+
}
|
|
913
|
+
const degradedNote = health.degraded.length > 0
|
|
914
|
+
? health.degraded.map((d) => `${d.component}: ${d.reason}`).join("; ") + "."
|
|
915
|
+
: "healthy.";
|
|
916
|
+
return `my rhythms: ${parts.join(". ")}. ${degradedNote}`;
|
|
917
|
+
/* v8 ignore start -- defensive: readHealth handles its own errors; this catch is a safety net for truly unexpected failures @preserve */
|
|
918
|
+
}
|
|
919
|
+
catch {
|
|
920
|
+
return "";
|
|
921
|
+
}
|
|
922
|
+
/* v8 ignore stop */
|
|
923
|
+
}
|
|
889
924
|
async function buildSystem(channel = "cli", options, context) {
|
|
890
925
|
(0, runtime_1.emitNervesEvent)({
|
|
891
926
|
event: "mind.step_start",
|
|
@@ -906,6 +941,7 @@ async function buildSystem(channel = "cli", options, context) {
|
|
|
906
941
|
channel === "inner" ? journalSection((0, identity_1.getAgentRoot)()) : "",
|
|
907
942
|
loopOrientationSection(channel),
|
|
908
943
|
runtimeInfoSection(channel),
|
|
944
|
+
rhythmStatusSection(),
|
|
909
945
|
channelNatureSection((0, channel_1.getChannelCapabilities)(channel)),
|
|
910
946
|
providerSection(),
|
|
911
947
|
dateSection(),
|
|
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.enrichReactionText = enrichReactionText;
|
|
37
|
+
exports.createStatusBatcher = createStatusBatcher;
|
|
37
38
|
exports.handleBlueBubblesEvent = handleBlueBubblesEvent;
|
|
38
39
|
exports.recoverMissedBlueBubblesMessages = recoverMissedBlueBubblesMessages;
|
|
39
40
|
exports.createBlueBubblesWebhookHandler = createBlueBubblesWebhookHandler;
|
|
@@ -81,6 +82,44 @@ function enrichReactionText(baseText, originalText, maxLen) {
|
|
|
81
82
|
: originalText;
|
|
82
83
|
return `${baseText} to: "${truncated}"`;
|
|
83
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Accumulates status descriptions and debounces them.
|
|
87
|
+
* If multiple descriptions arrive within `delayMs`, they are joined with ` · `
|
|
88
|
+
* and sent as a single message. Flush sends immediately and clears the timer.
|
|
89
|
+
*/
|
|
90
|
+
function createStatusBatcher(send, delayMs) {
|
|
91
|
+
(0, runtime_1.emitNervesEvent)({
|
|
92
|
+
component: "senses",
|
|
93
|
+
event: "senses.bluebubbles_status_batcher_created",
|
|
94
|
+
message: "status batcher initialized",
|
|
95
|
+
meta: { delayMs },
|
|
96
|
+
});
|
|
97
|
+
let pending = [];
|
|
98
|
+
let timer = null;
|
|
99
|
+
function fire() {
|
|
100
|
+
if (pending.length === 0)
|
|
101
|
+
return;
|
|
102
|
+
const combined = pending.join(" \u00b7 ");
|
|
103
|
+
pending = [];
|
|
104
|
+
timer = null;
|
|
105
|
+
send(combined);
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
add(text) {
|
|
109
|
+
pending.push(text);
|
|
110
|
+
if (timer !== null)
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
timer = setTimeout(fire, delayMs);
|
|
113
|
+
},
|
|
114
|
+
flush() {
|
|
115
|
+
if (timer !== null) {
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
timer = null;
|
|
118
|
+
}
|
|
119
|
+
fire();
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
84
123
|
const defaultDeps = {
|
|
85
124
|
getAgentName: identity_1.getAgentName,
|
|
86
125
|
buildSystem: prompt_1.buildSystem,
|
|
@@ -401,14 +440,17 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat) {
|
|
|
401
440
|
text,
|
|
402
441
|
replyToMessageGuid: replyTarget.getReplyToMessageGuid(),
|
|
403
442
|
});
|
|
443
|
+
// Re-enable typing indicator — sending a message clears the typing bubble
|
|
444
|
+
await client.setTyping(chat, true);
|
|
404
445
|
});
|
|
405
446
|
}
|
|
447
|
+
const statusBatcher = createStatusBatcher((text) => sendStatus(text), 500);
|
|
406
448
|
const toolCallbacks = (0, tool_activity_callbacks_1.createToolActivityCallbacks)({
|
|
407
|
-
onDescription: (text) =>
|
|
449
|
+
onDescription: (text) => statusBatcher.add(text),
|
|
408
450
|
/* v8 ignore next -- onResult only called in debug mode; tested via tool-activity-callbacks.test.ts @preserve */
|
|
409
|
-
onResult: (text) => sendStatus(text),
|
|
451
|
+
onResult: (text) => { statusBatcher.flush(); sendStatus(text); },
|
|
410
452
|
/* v8 ignore next -- onFailure only called on tool failure; tested via tool-activity-callbacks.test.ts @preserve */
|
|
411
|
-
onFailure: (text) => sendStatus(text),
|
|
453
|
+
onFailure: (text) => { statusBatcher.flush(); sendStatus(text); },
|
|
412
454
|
isDebug: commands_1.getDebugMode,
|
|
413
455
|
});
|
|
414
456
|
return {
|
|
@@ -471,6 +513,7 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat) {
|
|
|
471
513
|
textBuffer = "";
|
|
472
514
|
},
|
|
473
515
|
async flush() {
|
|
516
|
+
statusBatcher.flush();
|
|
474
517
|
await queue;
|
|
475
518
|
const trimmed = textBuffer.trim();
|
|
476
519
|
if (!trimmed) {
|
|
@@ -495,6 +538,7 @@ function createBlueBubblesCallbacks(client, chat, replyTarget, isGroupChat) {
|
|
|
495
538
|
});
|
|
496
539
|
},
|
|
497
540
|
async finish() {
|
|
541
|
+
statusBatcher.flush();
|
|
498
542
|
if (!typingActive) {
|
|
499
543
|
await queue;
|
|
500
544
|
return;
|
package/dist/senses/commands.js
CHANGED
|
@@ -72,7 +72,7 @@ function registerDefaultCommands(registry) {
|
|
|
72
72
|
channels: ["cli", "teams"],
|
|
73
73
|
handler: (ctx) => {
|
|
74
74
|
const cmds = registry.list(ctx.channel);
|
|
75
|
-
const lines = cmds.map((c) => `/${c.name}
|
|
75
|
+
const lines = cmds.map((c) => `/${c.name} \u2014 ${c.description}`);
|
|
76
76
|
return { action: "response", message: lines.join("\n") };
|
|
77
77
|
},
|
|
78
78
|
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildHabitTurnMessage = buildHabitTurnMessage;
|
|
4
|
+
const runtime_1 = require("../nerves/runtime");
|
|
5
|
+
function formatElapsed(ms) {
|
|
6
|
+
const minutes = Math.floor(ms / 60000);
|
|
7
|
+
if (minutes < 60) {
|
|
8
|
+
return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
|
|
9
|
+
}
|
|
10
|
+
const hours = Math.floor(minutes / 60);
|
|
11
|
+
return `${hours} ${hours === 1 ? "hour" : "hours"}`;
|
|
12
|
+
}
|
|
13
|
+
function buildHabitTurnMessage(options) {
|
|
14
|
+
const { habitName, habitTitle, habitBody, lastRun, checkpoint, alsoDue, staleObligations, parseErrors, degradedComponents, now, } = options;
|
|
15
|
+
const hasBody = habitBody !== undefined && habitBody !== "";
|
|
16
|
+
// First beat: lastRun is null
|
|
17
|
+
if (lastRun === null) {
|
|
18
|
+
// Cold start: no checkpoint, no body — bare awareness
|
|
19
|
+
if (!checkpoint && !hasBody) {
|
|
20
|
+
(0, runtime_1.emitNervesEvent)({
|
|
21
|
+
component: "senses",
|
|
22
|
+
event: "senses.habit_turn_message_built",
|
|
23
|
+
message: "habit turn message built (cold start)",
|
|
24
|
+
meta: { habitName, coldStart: true },
|
|
25
|
+
});
|
|
26
|
+
return "...time passing. anything stirring?";
|
|
27
|
+
}
|
|
28
|
+
if (!hasBody) {
|
|
29
|
+
// First beat with no body: nudge
|
|
30
|
+
const sections = [
|
|
31
|
+
`your ${habitTitle} fired but has no instructions \u2014 add a body to \`habits/${habitName}.md\``,
|
|
32
|
+
];
|
|
33
|
+
appendTrailingExtras(sections, alsoDue, staleObligations, parseErrors, degradedComponents);
|
|
34
|
+
(0, runtime_1.emitNervesEvent)({
|
|
35
|
+
component: "senses",
|
|
36
|
+
event: "senses.habit_turn_message_built",
|
|
37
|
+
message: "habit turn message built (first beat, no body)",
|
|
38
|
+
meta: { habitName, firstBeat: true, hasBody: false },
|
|
39
|
+
});
|
|
40
|
+
return sections.join("\n\n");
|
|
41
|
+
}
|
|
42
|
+
const sections = [
|
|
43
|
+
`your ${habitTitle} is alive. this is its first breath.`,
|
|
44
|
+
habitBody,
|
|
45
|
+
];
|
|
46
|
+
appendTrailingExtras(sections, alsoDue, staleObligations, parseErrors, degradedComponents);
|
|
47
|
+
(0, runtime_1.emitNervesEvent)({
|
|
48
|
+
component: "senses",
|
|
49
|
+
event: "senses.habit_turn_message_built",
|
|
50
|
+
message: "habit turn message built (first beat)",
|
|
51
|
+
meta: { habitName, firstBeat: true },
|
|
52
|
+
});
|
|
53
|
+
return sections.join("\n\n");
|
|
54
|
+
}
|
|
55
|
+
// Normal turn
|
|
56
|
+
const sections = [];
|
|
57
|
+
// 1. Checkpoint
|
|
58
|
+
if (checkpoint) {
|
|
59
|
+
sections.push(`you were thinking about ${checkpoint}.`);
|
|
60
|
+
}
|
|
61
|
+
// 2. Elapsed time
|
|
62
|
+
const nowMs = now().getTime();
|
|
63
|
+
const lastRunMs = new Date(lastRun).getTime();
|
|
64
|
+
const elapsed = nowMs - lastRunMs;
|
|
65
|
+
sections.push(`${formatElapsed(elapsed)} have passed.`);
|
|
66
|
+
// 3. Body or no-body nudge
|
|
67
|
+
if (hasBody) {
|
|
68
|
+
sections.push(habitBody);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
sections.push(`your ${habitTitle} fired but has no instructions \u2014 add a body to \`habits/${habitName}.md\``);
|
|
72
|
+
}
|
|
73
|
+
// 4-7. Trailing extras
|
|
74
|
+
appendTrailingExtras(sections, alsoDue, staleObligations, parseErrors, degradedComponents);
|
|
75
|
+
(0, runtime_1.emitNervesEvent)({
|
|
76
|
+
component: "senses",
|
|
77
|
+
event: "senses.habit_turn_message_built",
|
|
78
|
+
message: "habit turn message built",
|
|
79
|
+
meta: {
|
|
80
|
+
habitName,
|
|
81
|
+
hasCheckpoint: !!checkpoint,
|
|
82
|
+
hasBody: hasBody,
|
|
83
|
+
staleObligationCount: staleObligations.length,
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
return sections.join("\n\n");
|
|
87
|
+
}
|
|
88
|
+
function appendTrailingExtras(sections, alsoDue, staleObligations, parseErrors, degradedComponents) {
|
|
89
|
+
// 4. Also-due
|
|
90
|
+
if (alsoDue) {
|
|
91
|
+
sections.push(alsoDue);
|
|
92
|
+
}
|
|
93
|
+
// 5. Stale obligations
|
|
94
|
+
if (staleObligations.length > 0) {
|
|
95
|
+
const lines = staleObligations.map((o) => `something for ${o.friendName} has been sitting for ${formatElapsed(o.stalenessMs)}`);
|
|
96
|
+
sections.push(lines.join("\n"));
|
|
97
|
+
}
|
|
98
|
+
// 6. Parse errors
|
|
99
|
+
if (parseErrors.length > 0) {
|
|
100
|
+
const lines = parseErrors.map((e) => `I noticed my habit file \`${e.file}\` has invalid frontmatter \u2014 I should fix it. (${e.error})`);
|
|
101
|
+
sections.push(lines.join("\n"));
|
|
102
|
+
}
|
|
103
|
+
// 7. Degraded state
|
|
104
|
+
if (degradedComponents.length > 0) {
|
|
105
|
+
const reasons = degradedComponents.map((d) => `${d.component}: ${d.reason}`).join("; ");
|
|
106
|
+
sections.push(`[note: my scheduling is degraded: ${reasons}]`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -67,7 +67,7 @@ const manager_1 = require("../heart/bridges/manager");
|
|
|
67
67
|
const session_activity_1 = require("../heart/session-activity");
|
|
68
68
|
const bluebubbles_1 = require("./bluebubbles");
|
|
69
69
|
const obligations_2 = require("../heart/obligations");
|
|
70
|
-
const
|
|
70
|
+
const habit_turn_message_1 = require("./habit-turn-message");
|
|
71
71
|
const journal_index_1 = require("../mind/journal-index");
|
|
72
72
|
const habit_parser_1 = require("../heart/daemon/habit-parser");
|
|
73
73
|
const cadence_1 = require("../heart/daemon/cadence");
|
|
@@ -559,106 +559,65 @@ async function runInnerDialogTurn(options) {
|
|
|
559
559
|
const habitFilePath = path.join(agentRoot, "habits", `${habitName}.md`);
|
|
560
560
|
// Read and parse the habit file
|
|
561
561
|
let habitBody;
|
|
562
|
+
let habitTitle = habitName;
|
|
562
563
|
let habitLastRun = null;
|
|
563
564
|
try {
|
|
564
565
|
const habitContent = fs.readFileSync(habitFilePath, "utf-8");
|
|
565
566
|
const parsed = (0, habit_parser_1.parseHabitFile)(habitContent, habitFilePath);
|
|
566
|
-
habitBody = parsed.body;
|
|
567
|
+
habitBody = parsed.body || undefined;
|
|
568
|
+
habitTitle = parsed.title || habitName;
|
|
567
569
|
habitLastRun = parsed.lastRun;
|
|
568
570
|
}
|
|
569
571
|
catch {
|
|
570
572
|
// Habit file missing or unreadable
|
|
571
573
|
}
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
const raw = fs.readFileSync(runtimeStatePath, "utf-8");
|
|
579
|
-
const runtimeState = JSON.parse(raw);
|
|
580
|
-
lastCompletedAt = typeof runtimeState.lastCompletedAt === "string" ? runtimeState.lastCompletedAt : undefined;
|
|
581
|
-
}
|
|
582
|
-
catch {
|
|
583
|
-
// no runtime state — will get cold start fallback
|
|
584
|
-
}
|
|
574
|
+
// If the habit file couldn't be read at all (no body, no title parsed), error message
|
|
575
|
+
if (habitBody === undefined && habitTitle === habitName) {
|
|
576
|
+
userContent = `habit "${habitName}" could not be read (file not found or unreadable). check habits/${habitName}.md exists.`;
|
|
577
|
+
}
|
|
578
|
+
else {
|
|
579
|
+
// Unified path: gather context for ALL habits (heartbeat included)
|
|
585
580
|
const obligations = (0, obligations_1.listActiveObligations)(agentName);
|
|
586
581
|
const nowMs = now().getTime();
|
|
587
|
-
const
|
|
588
|
-
id: o.id,
|
|
589
|
-
content: o.delegatedContent,
|
|
582
|
+
const staleObligations = obligations.map((o) => ({
|
|
590
583
|
friendName: o.origin.friendId,
|
|
591
|
-
|
|
592
|
-
|
|
584
|
+
content: o.delegatedContent,
|
|
585
|
+
stalenessMs: nowMs - o.createdAt,
|
|
593
586
|
}));
|
|
594
|
-
const
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
587
|
+
const alsoDue = buildAlsoDueLine(agentRoot, habitName, now);
|
|
588
|
+
// Degraded state (best-effort: never crash)
|
|
589
|
+
let degradedComponents = [];
|
|
590
|
+
try {
|
|
591
|
+
const health = (0, daemon_health_1.readHealth)((0, daemon_health_1.getDefaultHealthPath)());
|
|
592
|
+
if (health && health.degraded.length > 0) {
|
|
593
|
+
degradedComponents = health.degraded.map((d) => ({ component: d.component, reason: d.reason }));
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
catch {
|
|
597
|
+
// Best-effort: missing file or parse error -> empty array, no crash
|
|
598
|
+
}
|
|
599
|
+
userContent = (0, habit_turn_message_1.buildHabitTurnMessage)({
|
|
600
|
+
habitName,
|
|
601
|
+
habitTitle,
|
|
602
602
|
habitBody,
|
|
603
|
+
lastRun: habitLastRun,
|
|
604
|
+
checkpoint: displayCheckpoint(state.checkpoint),
|
|
605
|
+
alsoDue: alsoDue || undefined,
|
|
606
|
+
staleObligations,
|
|
607
|
+
parseErrors: options?.parseErrors ?? [],
|
|
608
|
+
degradedComponents,
|
|
609
|
+
now,
|
|
603
610
|
});
|
|
604
|
-
// Build also-due line
|
|
605
|
-
const alsoDue = buildAlsoDueLine(agentRoot, habitName, now);
|
|
606
|
-
userContent = alsoDue ? `${heartbeatContent}\n\n${alsoDue}` : heartbeatContent;
|
|
607
|
-
// Parse error nudge
|
|
608
|
-
const parseErrorNudge = buildParseErrorNudge(options?.parseErrors ?? []);
|
|
609
|
-
if (parseErrorNudge)
|
|
610
|
-
userContent = `${userContent}\n\n${parseErrorNudge}`;
|
|
611
611
|
// Piggyback journal embedding indexing (best-effort, fire-and-forget)
|
|
612
|
+
const journalDir = path.join(agentRoot, "journal");
|
|
612
613
|
/* v8 ignore start -- journal indexing piggyback: embedding provider may not be available; tested via journal-index unit tests @preserve */
|
|
613
614
|
void (0, journal_index_1.indexJournalFiles)(journalDir, path.join(journalDir, ".index.json"), {
|
|
614
615
|
embed: async () => [],
|
|
615
616
|
}).catch(() => {
|
|
616
|
-
// swallowed: indexing failure must never block
|
|
617
|
+
// swallowed: indexing failure must never block habit turn
|
|
617
618
|
});
|
|
618
619
|
/* v8 ignore stop */
|
|
619
620
|
}
|
|
620
|
-
else if (habitBody !== undefined) {
|
|
621
|
-
// Non-heartbeat habit: basic context + body
|
|
622
|
-
const sections = [];
|
|
623
|
-
// Elapsed time since last run
|
|
624
|
-
if (habitLastRun) {
|
|
625
|
-
const lastRunMs = new Date(habitLastRun).getTime();
|
|
626
|
-
const elapsed = now().getTime() - lastRunMs;
|
|
627
|
-
const minutes = Math.floor(elapsed / 60000);
|
|
628
|
-
if (minutes < 60) {
|
|
629
|
-
sections.push(`it's been ${minutes} ${minutes === 1 ? "minute" : "minutes"} since ${habitName} last ran.`);
|
|
630
|
-
}
|
|
631
|
-
else {
|
|
632
|
-
const hours = Math.floor(minutes / 60);
|
|
633
|
-
sections.push(`it's been ${hours} ${hours === 1 ? "hour" : "hours"} since ${habitName} last ran.`);
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
sections.push(`## habit: ${habitName}\n${habitBody}`);
|
|
637
|
-
// Also due line
|
|
638
|
-
const alsoDue = buildAlsoDueLine(agentRoot, habitName, now);
|
|
639
|
-
if (alsoDue)
|
|
640
|
-
sections.push(alsoDue);
|
|
641
|
-
// Parse error nudge
|
|
642
|
-
const parseErrorNudge = buildParseErrorNudge(options?.parseErrors ?? []);
|
|
643
|
-
if (parseErrorNudge)
|
|
644
|
-
sections.push(parseErrorNudge);
|
|
645
|
-
userContent = sections.join("\n\n");
|
|
646
|
-
}
|
|
647
|
-
else {
|
|
648
|
-
// Habit file not found
|
|
649
|
-
userContent = `habit "${habitName}" could not be read (file not found or unreadable). check habits/${habitName}.md exists.`;
|
|
650
|
-
}
|
|
651
|
-
// Degraded state nudge (best-effort: never crash on health file read failure)
|
|
652
|
-
try {
|
|
653
|
-
const health = (0, daemon_health_1.readHealth)((0, daemon_health_1.getDefaultHealthPath)());
|
|
654
|
-
if (health && health.degraded.length > 0) {
|
|
655
|
-
const reasons = health.degraded.map((d) => `${d.component}: ${d.reason}`).join("; ");
|
|
656
|
-
userContent = `${userContent}\n\n[note: my scheduling is degraded: ${reasons}]`;
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
catch {
|
|
660
|
-
// Best-effort: missing file or parse error -> no nudge, no crash
|
|
661
|
-
}
|
|
662
621
|
}
|
|
663
622
|
else {
|
|
664
623
|
userContent = buildInstinctUserMessage(instincts, reason, state);
|
package/package.json
CHANGED
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.buildContextualHeartbeat = buildContextualHeartbeat;
|
|
4
|
-
const runtime_1 = require("../nerves/runtime");
|
|
5
|
-
const STALE_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes
|
|
6
|
-
const MAX_JOURNAL_FILES = 10;
|
|
7
|
-
function formatElapsed(ms) {
|
|
8
|
-
const minutes = Math.floor(ms / 60000);
|
|
9
|
-
if (minutes < 60) {
|
|
10
|
-
return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
|
|
11
|
-
}
|
|
12
|
-
const hours = Math.floor(minutes / 60);
|
|
13
|
-
return `${hours} ${hours === 1 ? "hour" : "hours"}`;
|
|
14
|
-
}
|
|
15
|
-
function formatRelativeTime(nowMs, mtimeMs) {
|
|
16
|
-
const diffMs = nowMs - mtimeMs;
|
|
17
|
-
const minutes = Math.floor(diffMs / 60000);
|
|
18
|
-
if (minutes < 1)
|
|
19
|
-
return "just now";
|
|
20
|
-
if (minutes < 60)
|
|
21
|
-
return `${minutes} ${minutes === 1 ? "minute" : "minutes"} ago`;
|
|
22
|
-
const hours = Math.floor(minutes / 60);
|
|
23
|
-
if (hours < 24)
|
|
24
|
-
return `${hours} ${hours === 1 ? "hour" : "hours"} ago`;
|
|
25
|
-
const days = Math.floor(hours / 24);
|
|
26
|
-
return `${days} ${days === 1 ? "day" : "days"} ago`;
|
|
27
|
-
}
|
|
28
|
-
function buildContextualHeartbeat(options) {
|
|
29
|
-
const { lastCompletedAt, pendingObligations, lastSurfaceAt, checkpoint, now, readJournalDir, habitBody, } = options;
|
|
30
|
-
const nowDate = now();
|
|
31
|
-
const nowMs = nowDate.getTime();
|
|
32
|
-
const journalFiles = readJournalDir();
|
|
33
|
-
// Cold start: no journal files and no runtime state
|
|
34
|
-
if (journalFiles.length === 0 && !lastCompletedAt) {
|
|
35
|
-
const lines = ["...time passing. anything stirring?"];
|
|
36
|
-
if (checkpoint) {
|
|
37
|
-
lines.push(`\nlast i remember: ${checkpoint}`);
|
|
38
|
-
}
|
|
39
|
-
(0, runtime_1.emitNervesEvent)({
|
|
40
|
-
component: "senses",
|
|
41
|
-
event: "senses.contextual_heartbeat_built",
|
|
42
|
-
message: "contextual heartbeat built (cold start)",
|
|
43
|
-
meta: { coldStart: true },
|
|
44
|
-
});
|
|
45
|
-
return lines.join("\n");
|
|
46
|
-
}
|
|
47
|
-
const sections = [];
|
|
48
|
-
// Elapsed time since last turn
|
|
49
|
-
if (lastCompletedAt) {
|
|
50
|
-
const lastMs = new Date(lastCompletedAt).getTime();
|
|
51
|
-
const elapsed = nowMs - lastMs;
|
|
52
|
-
sections.push(`it's been ${formatElapsed(elapsed)} since your last turn.`);
|
|
53
|
-
}
|
|
54
|
-
// Habit body (instructions from habit file)
|
|
55
|
-
if (habitBody) {
|
|
56
|
-
sections.push(habitBody);
|
|
57
|
-
}
|
|
58
|
-
// Pending attention count
|
|
59
|
-
if (pendingObligations.length > 0) {
|
|
60
|
-
const count = pendingObligations.length;
|
|
61
|
-
sections.push(`you're holding ${count} ${count === 1 ? "thought" : "thoughts"}.`);
|
|
62
|
-
}
|
|
63
|
-
// Journal index
|
|
64
|
-
if (journalFiles.length > 0) {
|
|
65
|
-
const sorted = [...journalFiles].sort((a, b) => b.mtime - a.mtime).slice(0, MAX_JOURNAL_FILES);
|
|
66
|
-
const indexLines = ["## journal"];
|
|
67
|
-
for (const file of sorted) {
|
|
68
|
-
const ago = formatRelativeTime(nowMs, file.mtime);
|
|
69
|
-
const previewClause = file.preview ? ` — ${file.preview}` : "";
|
|
70
|
-
indexLines.push(`- ${file.name} (${ago})${previewClause}`);
|
|
71
|
-
}
|
|
72
|
-
sections.push(indexLines.join("\n"));
|
|
73
|
-
}
|
|
74
|
-
// Journal entries since last surface
|
|
75
|
-
if (lastSurfaceAt && journalFiles.length > 0) {
|
|
76
|
-
const surfaceMs = new Date(lastSurfaceAt).getTime();
|
|
77
|
-
const entriesSinceSurface = journalFiles.filter((f) => f.mtime > surfaceMs).length;
|
|
78
|
-
if (entriesSinceSurface > 0) {
|
|
79
|
-
const surfaceElapsed = formatElapsed(nowMs - surfaceMs);
|
|
80
|
-
sections.push(`${entriesSinceSurface} journal ${entriesSinceSurface === 1 ? "entry" : "entries"} since you last surfaced, ${surfaceElapsed} ago.`);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
// Stale obligation alerts
|
|
84
|
-
const staleObligations = pendingObligations.filter((o) => o.staleness >= STALE_THRESHOLD_MS);
|
|
85
|
-
if (staleObligations.length > 0) {
|
|
86
|
-
for (const obligation of staleObligations) {
|
|
87
|
-
sections.push(`this has been sitting for ${formatElapsed(obligation.staleness)}: ${obligation.content}`);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
// Checkpoint
|
|
91
|
-
if (checkpoint) {
|
|
92
|
-
sections.push(`last i remember: ${checkpoint}`);
|
|
93
|
-
}
|
|
94
|
-
(0, runtime_1.emitNervesEvent)({
|
|
95
|
-
component: "senses",
|
|
96
|
-
event: "senses.contextual_heartbeat_built",
|
|
97
|
-
message: "contextual heartbeat built",
|
|
98
|
-
meta: {
|
|
99
|
-
hasJournal: journalFiles.length > 0,
|
|
100
|
-
hasLastCompleted: !!lastCompletedAt,
|
|
101
|
-
obligationCount: pendingObligations.length,
|
|
102
|
-
staleCount: staleObligations.length,
|
|
103
|
-
},
|
|
104
|
-
});
|
|
105
|
-
return sections.join("\n\n");
|
|
106
|
-
}
|