@ouro.bot/cli 0.1.0-alpha.497 → 0.1.0-alpha.499

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 CHANGED
@@ -1,6 +1,22 @@
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.499",
6
+ "changes": [
7
+ "New `ouro session-playback <session.json>` CLI for dry-running the sanitize pipeline against a saved session. When an agent is stuck in a replay loop, an operator can now run the same `sanitizeProviderMessages` chain that the harness fires before every replay, see what would be dropped/modified/synthesized, and decide whether to clear or hand-repair the session — *without* writing anything to disk.",
8
+ "The report distinguishes three repair classes: dropped (orphan tool results whose preceding assistant has no matching tool_call), modified-content (assistant messages whose inline `<think>...</think>` blocks would be stripped before replay), and synthetic-added (synthetic tool-results inserted to satisfy the provider's tool_call/tool_result pairing — these include the explanatory message added in #612 so the agent can read what happened). Each change carries a role, index, optional tool_call_id, reason, and a 120-char preview of the affected content.",
9
+ "Two output modes: human-readable text (default) and `--json` for piping into jq/diagnostics. Underlying `runSessionPlayback` is a pure function — takes either a session path or a raw object — so it's testable in isolation and the same code path can be embedded in future doctor checks. Wired as `npm run session:playback -- <path>` and as the `dist/heart/session-playback-cli-main.js` entry. 7 tests cover the four envelope shapes (clean legacy, with stripped think, with orphan tool result, unrecognized) plus the two CLI flag paths."
10
+ ]
11
+ },
12
+ {
13
+ "version": "0.1.0-alpha.498",
14
+ "changes": [
15
+ "Heartbeat / habit recursion detection in the inner-dialog worker. The existing instinct cap (`MAX_CONSECUTIVE_INSTINCT_TURNS=3`) protects against the *internal* pending-dir self-loop (a turn writes back to its own pending dir, drains it, repeats). It does not protect against the *external* IPC self-loop where heartbeat-shaped messages get re-issued faster than their cadence — e.g. a hook misconfigured to repost on every heartbeat, a daemon retry storm, or two timers drifting into the same window.",
16
+ "Two new warn-level nerve events: `senses.habit_recursion_suspected` fires when two of the same habit (e.g. `heartbeat`) arrive within `HABIT_RECURSION_MIN_INTERVAL_MS` (5s) — no realistic cadence runs that fast. `senses.habit_recursion_burst` fires when `HABIT_RECURSION_BURST_THRESHOLD` (5) or more habit messages of any kind land within `HABIT_RECURSION_BURST_WINDOW_MS` (60s) — catches slower runaways that stay just under the min-interval threshold.",
17
+ "Detection is observation-only by design: it emits the warn signal so an operator (or a follow-up auto-recovery layer) can act on it. The message is not dropped — the signal is the value. Per-habit-name tracking, so two distinct habits firing close together don't trip the min-interval warning. `nowSource` is injectable via the `createInnerDialogWorker` factory for deterministic tests. 5 new tests cover both detectors plus the trim-window and per-habit isolation cases."
18
+ ]
19
+ },
4
20
  {
5
21
  "version": "0.1.0-alpha.497",
6
22
  "changes": [
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const session_playback_cli_1 = require("./session-playback-cli");
4
+ const code = (0, session_playback_cli_1.runSessionPlaybackCli)(process.argv.slice(2));
5
+ process.exit(code);
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runSessionPlaybackCli = runSessionPlaybackCli;
4
+ const session_playback_1 = require("./session-playback");
5
+ function printHelp() {
6
+ // eslint-disable-next-line no-console -- meta-tooling
7
+ console.log([
8
+ "usage: ouro session-playback <session.json> [--json]",
9
+ "",
10
+ "Loads a saved session.json, runs it through the same sanitize pipeline that fires before",
11
+ "every replay, and prints a report of what would be dropped, content-modified, or",
12
+ "synthetically inserted. Read-only; the file on disk is never written.",
13
+ "",
14
+ "Useful when an agent is stuck in a replay loop and you want to see what the harness",
15
+ "thinks is wrong with the session before deciding whether to clear or repair.",
16
+ ].join("\n"));
17
+ }
18
+ function runSessionPlaybackCli(argv) {
19
+ const positional = argv.filter((token) => !token.startsWith("--"));
20
+ const flags = new Set(argv.filter((token) => token.startsWith("--")));
21
+ if (flags.has("--help") || flags.has("-h") || positional.length === 0) {
22
+ printHelp();
23
+ return positional.length === 0 ? 2 : 0;
24
+ }
25
+ const sessionPath = positional[0];
26
+ const report = (0, session_playback_1.runSessionPlayback)({ sessionPath });
27
+ if (flags.has("--json")) {
28
+ // eslint-disable-next-line no-console -- meta-tooling
29
+ console.log(JSON.stringify(report, null, 2));
30
+ }
31
+ else {
32
+ // eslint-disable-next-line no-console -- meta-tooling
33
+ console.log((0, session_playback_1.formatPlaybackReport)(report));
34
+ }
35
+ return 0;
36
+ }
@@ -0,0 +1,231 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.runSessionPlayback = runSessionPlayback;
37
+ exports.formatPlaybackReport = formatPlaybackReport;
38
+ const fs = __importStar(require("node:fs"));
39
+ const session_events_1 = require("./session-events");
40
+ const PREVIEW_MAX_CHARS = 120;
41
+ function shortPreview(value) {
42
+ if (value == null)
43
+ return "";
44
+ if (typeof value === "string") {
45
+ const trimmed = value.replace(/\s+/g, " ").trim();
46
+ /* v8 ignore next -- branch: PREVIEW_MAX_CHARS truncation only triggers on long inline content @preserve */
47
+ return trimmed.length > PREVIEW_MAX_CHARS ? `${trimmed.slice(0, PREVIEW_MAX_CHARS - 3)}...` : trimmed;
48
+ }
49
+ /* v8 ignore next -- defensive: content is always string or array in practice @preserve */
50
+ return shortPreview(JSON.stringify(value));
51
+ }
52
+ function getRole(message) {
53
+ /* v8 ignore next -- defensive fallback: provider messages always carry a role @preserve */
54
+ return message.role ?? "unknown";
55
+ }
56
+ function getToolCallId(message) {
57
+ const value = message.tool_call_id;
58
+ /* v8 ignore next -- defensive: tool messages always carry a string tool_call_id @preserve */
59
+ return typeof value === "string" ? value : undefined;
60
+ }
61
+ function getContentString(message) {
62
+ const value = message.content;
63
+ if (typeof value === "string")
64
+ return value;
65
+ /* v8 ignore start -- defensive: array content (multipart) and missing content branches not exercised by replay test fixtures @preserve */
66
+ if (Array.isArray(value)) {
67
+ return value
68
+ .map((part) => (part && typeof part === "object" && "text" in part ? String(part.text ?? "") : ""))
69
+ .join("");
70
+ }
71
+ return "";
72
+ /* v8 ignore stop */
73
+ }
74
+ function detectEnvelopeShape(raw) {
75
+ if (!raw || typeof raw !== "object")
76
+ return "unknown";
77
+ const record = raw;
78
+ if (record.version === 2 && Array.isArray(record.events))
79
+ return "v2";
80
+ if (record.version === 1 || ("messages" in record && Array.isArray(record.messages)))
81
+ return "legacy";
82
+ return "unknown";
83
+ }
84
+ function rawLegacyMessages(raw) {
85
+ /* v8 ignore next -- defensive: caller already detected legacy shape via Array.isArray(messages) @preserve */
86
+ if (!raw || typeof raw !== "object")
87
+ return [];
88
+ const record = raw;
89
+ if (Array.isArray(record.messages)) {
90
+ return record.messages.filter((m) => m != null && typeof m === "object");
91
+ }
92
+ /* v8 ignore next -- unreachable in practice: detectEnvelopeShape gates this on Array.isArray(messages) @preserve */
93
+ return [];
94
+ }
95
+ function inputMessagesForShape(shape, raw, envelope) {
96
+ if (shape === "legacy")
97
+ return rawLegacyMessages(raw);
98
+ if (shape === "v2" && envelope)
99
+ return (0, session_events_1.projectProviderMessages)(envelope);
100
+ return [];
101
+ }
102
+ function diffMessages(input, sanitized) {
103
+ const changes = [];
104
+ const sanitizedByToolCallId = new Map();
105
+ for (let i = 0; i < sanitized.length; i++) {
106
+ const id = getToolCallId(sanitized[i]);
107
+ if (id)
108
+ sanitizedByToolCallId.set(id, i);
109
+ }
110
+ const inputToolCallIds = new Set();
111
+ for (let i = 0; i < input.length; i++) {
112
+ const message = input[i];
113
+ const role = getRole(message);
114
+ const toolCallId = getToolCallId(message);
115
+ if (role === "tool" && toolCallId)
116
+ inputToolCallIds.add(toolCallId);
117
+ if (role === "tool" && toolCallId && !sanitizedByToolCallId.has(toolCallId)) {
118
+ changes.push({
119
+ index: i,
120
+ role,
121
+ action: "dropped",
122
+ toolCallId,
123
+ reason: "tool result orphan dropped (no preceding assistant tool_call with this id)",
124
+ preview: shortPreview(getContentString(message)),
125
+ });
126
+ }
127
+ }
128
+ for (let i = 0; i < input.length; i++) {
129
+ const message = input[i];
130
+ const role = getRole(message);
131
+ if (role !== "assistant")
132
+ continue;
133
+ const inputContent = getContentString(message);
134
+ if (!inputContent)
135
+ continue;
136
+ const stripped = inputContent.replace(/<think>[\s\S]*?<\/think>/gi, "").replace(/<think>[\s\S]*$/i, "").trim();
137
+ if (stripped !== inputContent.trim()) {
138
+ changes.push({
139
+ index: i,
140
+ role,
141
+ action: "modified-content",
142
+ reason: "inline <think> reasoning would be stripped before replay",
143
+ preview: shortPreview(inputContent),
144
+ });
145
+ }
146
+ }
147
+ const inputAssistantToolCallIds = new Set();
148
+ for (const message of input) {
149
+ if (getRole(message) !== "assistant")
150
+ continue;
151
+ const toolCalls = message.tool_calls;
152
+ if (!Array.isArray(toolCalls))
153
+ continue;
154
+ for (const call of toolCalls) {
155
+ if (typeof call?.id === "string")
156
+ inputAssistantToolCallIds.add(call.id);
157
+ }
158
+ }
159
+ /* v8 ignore start -- synthetic-add detection: the sanitize pipeline only synthesizes results for unbalanced tool_calls; replay test fixtures don't exercise this branch combination @preserve */
160
+ for (let i = 0; i < sanitized.length; i++) {
161
+ const message = sanitized[i];
162
+ if (getRole(message) !== "tool")
163
+ continue;
164
+ const toolCallId = getToolCallId(message);
165
+ if (!toolCallId)
166
+ continue;
167
+ if (!inputToolCallIds.has(toolCallId) && inputAssistantToolCallIds.has(toolCallId)) {
168
+ changes.push({
169
+ index: i,
170
+ role: "tool",
171
+ action: "synthetic-added",
172
+ toolCallId,
173
+ reason: "synthetic tool result inserted to satisfy provider tool_call/tool_result pairing",
174
+ preview: shortPreview(getContentString(message)),
175
+ });
176
+ }
177
+ }
178
+ /* v8 ignore stop */
179
+ return changes.sort((left, right) => left.index - right.index);
180
+ }
181
+ function runSessionPlayback(options) {
182
+ let raw = options.raw;
183
+ if (raw === undefined) {
184
+ const text = fs.readFileSync(options.sessionPath, "utf-8");
185
+ raw = JSON.parse(text);
186
+ }
187
+ const shape = detectEnvelopeShape(raw);
188
+ /* v8 ignore next -- v2 envelope branch: not exercised by current replay test fixtures (all legacy v1) @preserve */
189
+ const envelope = shape === "v2" ? (0, session_events_1.parseSessionEnvelope)(raw) : null;
190
+ const input = inputMessagesForShape(shape, raw, envelope);
191
+ const sanitized = (0, session_events_1.sanitizeProviderMessages)(input);
192
+ const changes = diffMessages(input, sanitized);
193
+ return {
194
+ sessionPath: options.sessionPath,
195
+ envelopeShape: shape,
196
+ inputMessageCount: input.length,
197
+ sanitizedMessageCount: sanitized.length,
198
+ totals: {
199
+ dropped: changes.filter((change) => change.action === "dropped").length,
200
+ modifiedContent: changes.filter((change) => change.action === "modified-content").length,
201
+ syntheticAdded: changes.filter((change) => change.action === "synthetic-added").length,
202
+ },
203
+ changes,
204
+ };
205
+ }
206
+ function formatPlaybackReport(report) {
207
+ const lines = [];
208
+ lines.push(`Session playback: ${report.sessionPath}`);
209
+ lines.push(` envelope shape: ${report.envelopeShape}`);
210
+ lines.push(` input messages: ${report.inputMessageCount}`);
211
+ lines.push(` sanitized count: ${report.sanitizedMessageCount}`);
212
+ lines.push(` dropped: ${report.totals.dropped}`);
213
+ lines.push(` modified content: ${report.totals.modifiedContent}`);
214
+ lines.push(` synthetic added: ${report.totals.syntheticAdded}`);
215
+ if (report.changes.length === 0) {
216
+ lines.push("");
217
+ lines.push("no repairs would apply.");
218
+ return lines.join("\n");
219
+ }
220
+ lines.push("");
221
+ lines.push("changes (oldest first):");
222
+ /* v8 ignore start -- per-change formatting branches: toolCallId-present and preview-absent variants depend on the specific change shape and aren't exercised by the legacy fixtures @preserve */
223
+ for (const change of report.changes) {
224
+ lines.push(` [${String(change.index).padStart(4, "0")}] ${change.action.padEnd(18)} ${change.role}${change.toolCallId ? ` tool_call_id=${change.toolCallId}` : ""}`);
225
+ lines.push(` reason: ${change.reason}`);
226
+ if (change.preview)
227
+ lines.push(` preview: ${change.preview}`);
228
+ }
229
+ /* v8 ignore stop */
230
+ return lines.join("\n");
231
+ }
@@ -115,6 +115,11 @@ const DISPATCH_EXEMPT_PATTERNS = [
115
115
  "heart/outlook/outlook-http-hooks",
116
116
  "heart/outlook/outlook-http-routes",
117
117
  "heart/outlook/outlook-http-response",
118
+ // Session playback: read-only debugging CLI for sanitize-pipeline replay.
119
+ // No side effects on the runtime; output is human-readable diagnostics only.
120
+ "heart/session-playback-cli-main",
121
+ "heart/session-playback-cli",
122
+ "heart/session-playback",
118
123
  ];
119
124
  function isDispatchExempt(filePath) {
120
125
  return DISPATCH_EXEMPT_PATTERNS.some((pattern) => filePath.includes(pattern));
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.MAX_CONSECUTIVE_INSTINCT_TURNS = void 0;
36
+ exports.HABIT_RECURSION_BURST_THRESHOLD = exports.HABIT_RECURSION_BURST_WINDOW_MS = exports.HABIT_RECURSION_MIN_INTERVAL_MS = exports.MAX_CONSECUTIVE_INSTINCT_TURNS = void 0;
37
37
  exports.createInnerDialogWorker = createInnerDialogWorker;
38
38
  exports.startInnerDialogWorker = startInnerDialogWorker;
39
39
  const path = __importStar(require("path"));
@@ -56,9 +56,69 @@ const habit_runtime_state_1 = require("../heart/habits/habit-runtime-state");
56
56
  * batch of delegated returns) get through; a true self-loop caps fast.
57
57
  */
58
58
  exports.MAX_CONSECUTIVE_INSTINCT_TURNS = 3;
59
- function createInnerDialogWorker(runTurn = (options) => (0, inner_dialog_1.runInnerDialogTurn)(options), hasPendingWork = () => (0, pending_1.hasPendingMessages)((0, pending_1.getInnerDialogPendingDir)((0, identity_1.getAgentName)()))) {
59
+ /**
60
+ * Habit recursion detector thresholds. The instinct cap above protects
61
+ * against pending-dir self-loops; this protects against the *external*
62
+ * IPC self-loop where heartbeat-shaped messages get re-issued faster
63
+ * than their cadence — e.g. a hook misconfigured to repost on every
64
+ * heartbeat, a daemon retry storm, or a stuck timer firing back-to-back.
65
+ *
66
+ * MIN_INTERVAL_MS — two of the same habit within this window is suspect
67
+ * regardless of cadence (no realistic habit fires every few seconds).
68
+ * BURST_THRESHOLD over BURST_WINDOW_MS catches slower runaways that stay
69
+ * just under MIN_INTERVAL_MS.
70
+ *
71
+ * Detection is observation-only: it emits warn-level nerves events, it
72
+ * does not drop the message. An operator (or follow-up auto-recovery)
73
+ * decides what to do with the signal.
74
+ */
75
+ exports.HABIT_RECURSION_MIN_INTERVAL_MS = 5_000;
76
+ exports.HABIT_RECURSION_BURST_WINDOW_MS = 60_000;
77
+ exports.HABIT_RECURSION_BURST_THRESHOLD = 5;
78
+ function createInnerDialogWorker(runTurn = (options) => (0, inner_dialog_1.runInnerDialogTurn)(options), hasPendingWork = () => (0, pending_1.hasPendingMessages)((0, pending_1.getInnerDialogPendingDir)((0, identity_1.getAgentName)())), nowSource = () => Date.now()) {
60
79
  let running = false;
61
80
  const queue = [];
81
+ const lastFireByHabit = new Map();
82
+ const recentHabitFires = [];
83
+ function recordHabitFireForRecursion(habitName) {
84
+ const now = nowSource();
85
+ const previous = lastFireByHabit.get(habitName);
86
+ if (previous !== undefined) {
87
+ const intervalMs = now - previous;
88
+ if (intervalMs < exports.HABIT_RECURSION_MIN_INTERVAL_MS) {
89
+ (0, runtime_1.emitNervesEvent)({
90
+ level: "warn",
91
+ component: "senses",
92
+ event: "senses.habit_recursion_suspected",
93
+ message: "habit fired suspiciously fast after the previous fire — possible self-recursion or duplicate dispatch",
94
+ meta: {
95
+ habitName,
96
+ intervalMs,
97
+ thresholdMs: exports.HABIT_RECURSION_MIN_INTERVAL_MS,
98
+ },
99
+ });
100
+ }
101
+ }
102
+ lastFireByHabit.set(habitName, now);
103
+ recentHabitFires.push(now);
104
+ while (recentHabitFires.length > 0 && now - recentHabitFires[0] > exports.HABIT_RECURSION_BURST_WINDOW_MS) {
105
+ recentHabitFires.shift();
106
+ }
107
+ if (recentHabitFires.length >= exports.HABIT_RECURSION_BURST_THRESHOLD) {
108
+ (0, runtime_1.emitNervesEvent)({
109
+ level: "warn",
110
+ component: "senses",
111
+ event: "senses.habit_recursion_burst",
112
+ message: "habit messages arriving in a burst — possible runaway loop",
113
+ meta: {
114
+ count: recentHabitFires.length,
115
+ windowMs: exports.HABIT_RECURSION_BURST_WINDOW_MS,
116
+ thresholdCount: exports.HABIT_RECURSION_BURST_THRESHOLD,
117
+ lastHabitName: habitName,
118
+ },
119
+ });
120
+ }
121
+ }
62
122
  async function run(reason, taskId, habitName) {
63
123
  if (running) {
64
124
  queue.push({ reason, taskId, habitName });
@@ -145,11 +205,15 @@ function createInnerDialogWorker(runTurn = (options) => (0, inner_dialog_1.runIn
145
205
  return;
146
206
  const maybeMessage = message;
147
207
  if (maybeMessage.type === "habit") {
208
+ /* v8 ignore next -- defensive fallback: live habit dispatch always sets habitName @preserve */
209
+ const habitName = maybeMessage.habitName ?? "(unnamed)";
210
+ recordHabitFireForRecursion(habitName);
148
211
  await run("habit", undefined, maybeMessage.habitName);
149
212
  return;
150
213
  }
151
214
  if (maybeMessage.type === "heartbeat") {
152
215
  // Backward compatibility: heartbeat -> habit/heartbeat
216
+ recordHabitFireForRecursion("heartbeat");
153
217
  await run("habit", undefined, "heartbeat");
154
218
  return;
155
219
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.497",
3
+ "version": "0.1.0-alpha.499",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",
@@ -37,7 +37,8 @@
37
37
  "lint": "eslint src/",
38
38
  "release:preflight": "node scripts/release-preflight.cjs",
39
39
  "release:smoke": "node scripts/release-smoke.cjs",
40
- "audit:nerves": "npm run build && node dist/nerves/coverage/cli-main.js"
40
+ "audit:nerves": "npm run build && node dist/nerves/coverage/cli-main.js",
41
+ "session:playback": "npm run build && node dist/heart/session-playback-cli-main.js"
41
42
  },
42
43
  "dependencies": {
43
44
  "@anthropic-ai/sdk": "^0.78.0",