@ouro.bot/cli 0.1.0-alpha.504 → 0.1.0-alpha.506

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.506",
6
+ "changes": [
7
+ "Detect duplicate tool_call_id across assistant messages in `validateSessionMessages`. MiniMax-M2.7 emits canonical tool_call ids of the form `call_function_<hash>_<n>` and reuses the same id across turns when the same function gets called — which causes provider rejections on replay because tool_call_id is supposed to be unique per request. The session sanitize pass already had position-aware orphan detection (#613) and inline-reasoning strip (#612); this adds the third member of the family — collision detection.",
8
+ "New exported `detectDuplicateToolCallIds(messages)` returns `{ id, indices }[]` for each tool_call_id that appears in multiple assistant messages. Same-message duplicates (one assistant calling the same id twice) are not flagged — those are a legitimate parallel-call shape. `validateSessionMessages` now folds collisions into its violations list with a message that calls out MiniMax specifically so operators reading nerves know what they're looking at.",
9
+ "Detection only — no rewriting yet, since rewriting tool_call_ids and the matching tool_results requires careful pairing logic that risks regression. The collision is visible to operators via the `mind.session_invariant_violation` nerves event the sanitize pass already emits when violations are present, and the existing `nerves-review` CLI from #622 makes it filterable. 3 new tests cover collision detection, single-message parallel-call shape (no false positive), and the all-distinct happy path."
10
+ ]
11
+ },
12
+ {
13
+ "version": "0.1.0-alpha.505",
14
+ "changes": [
15
+ "New `ouro session-stats <session.json>` CLI for at-a-glance metrics on a saved session: total events, breakdown by role (system/user/assistant/tool), tool-call totals + top 5 by frequency, attachment count, time range with duration, projection breakdown (in/out, input tokens, max tokens, trimmed), and last usage. Read-only.",
16
+ "Pure `computeSessionStats(envelope, path)` core in `src/heart/session-stats.ts` — testable with synthesized envelopes, embeddable in future doctor checks. `runSessionStats(path)` adds the file-load layer; `formatStatsReport(report)` renders human-readable text; `--json` mode for jq piping. Composes with #619 (session-playback) and #622 (nerves-review): three pure-analyzer-plus-thin-CLI tools that together make a stuck session immediately diagnosable end-to-end.",
17
+ "8 tests cover role counts, tool-call name aggregation with frequency-sorted top-5, time range with and without authoredAt timestamps, attachment counting, projection-omission detection, the unrecognized-envelope stub, CLI no-args help, and CLI --json output. Wired as `npm run session:stats -- <path>` and `dist/heart/session-stats-cli-main.js`."
18
+ ]
19
+ },
4
20
  {
5
21
  "version": "0.1.0-alpha.504",
6
22
  "changes": [
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.normalizeContinuityState = normalizeContinuityState;
37
37
  exports.validateSessionMessages = validateSessionMessages;
38
+ exports.detectDuplicateToolCallIds = detectDuplicateToolCallIds;
38
39
  exports.repairSessionMessages = repairSessionMessages;
39
40
  exports.migrateToolNames = migrateToolNames;
40
41
  exports.sanitizeProviderMessages = sanitizeProviderMessages;
@@ -305,8 +306,49 @@ function validateSessionMessages(messages) {
305
306
  sawToolResultSincePrevAssistant = false;
306
307
  prevNonToolRole = msg.role;
307
308
  }
309
+ for (const collision of detectDuplicateToolCallIds(messages)) {
310
+ violations.push(`duplicate tool_call_id '${collision.id}' across assistant messages at indices ${collision.indices.join(", ")} — provider may reject (MiniMax canonicalizes call_function_<hash>_<n> across turns)`);
311
+ }
308
312
  return violations;
309
313
  }
314
+ /**
315
+ * Detect tool_call_ids that appear in more than one assistant message
316
+ * within the conversation. MiniMax-M2.7 in particular emits canonical
317
+ * ids of the form `call_function_<hash>_<n>` and reuses the same id
318
+ * across turns when the same function is called — which causes provider
319
+ * rejections on replay because tool_call_id is supposed to be unique
320
+ * per request. We don't (yet) rewrite these here; this function exists
321
+ * so the sanitize pipeline can surface the collision through nerves
322
+ * (`mind.session_invariant_violation`) and operators can decide.
323
+ *
324
+ * Same-message duplicates (one assistant calling the same id twice)
325
+ * are not collisions — they're a legitimate parallel call shape and
326
+ * would be handled by the assistant's own emit logic. We only flag
327
+ * cross-message reuse.
328
+ */
329
+ function detectDuplicateToolCallIds(messages) {
330
+ const idsByFirstIndex = new Map();
331
+ for (let i = 0; i < messages.length; i++) {
332
+ const msg = normalizeMessage(messages[i]);
333
+ if (msg.role !== "assistant")
334
+ continue;
335
+ const seenInThisMessage = new Set();
336
+ for (const call of msg.toolCalls) {
337
+ if (!call.id || seenInThisMessage.has(call.id))
338
+ continue;
339
+ seenInThisMessage.add(call.id);
340
+ const indices = idsByFirstIndex.get(call.id) ?? [];
341
+ indices.push(i);
342
+ idsByFirstIndex.set(call.id, indices);
343
+ }
344
+ }
345
+ const collisions = [];
346
+ for (const [id, indices] of idsByFirstIndex) {
347
+ if (indices.length > 1)
348
+ collisions.push({ id, indices });
349
+ }
350
+ return collisions;
351
+ }
310
352
  function repairSessionMessages(messages) {
311
353
  const normalized = messages.map(normalizeMessage);
312
354
  const violations = validateSessionMessages(messages);
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const session_stats_1 = require("./session-stats");
4
+ const code = (0, session_stats_1.runSessionStatsCli)(process.argv.slice(2));
5
+ process.exit(code);
@@ -0,0 +1,182 @@
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.computeSessionStats = computeSessionStats;
37
+ exports.runSessionStats = runSessionStats;
38
+ exports.formatStatsReport = formatStatsReport;
39
+ exports.runSessionStatsCli = runSessionStatsCli;
40
+ const fs = __importStar(require("node:fs"));
41
+ const session_events_1 = require("./session-events");
42
+ const ROLES = ["system", "user", "assistant", "tool"];
43
+ function emptyByRole() {
44
+ const counts = {};
45
+ for (const role of ROLES)
46
+ counts[role] = 0;
47
+ return counts;
48
+ }
49
+ function eventTimeMs(event) {
50
+ const value = event.time.authoredAt ?? event.time.observedAt ?? null;
51
+ if (!value)
52
+ return null;
53
+ const ms = Date.parse(value);
54
+ return Number.isFinite(ms) ? ms : null;
55
+ }
56
+ function computeSessionStats(envelope, sessionPath) {
57
+ const byRole = emptyByRole();
58
+ let toolCallTotal = 0;
59
+ let attachments = 0;
60
+ const toolNameCounts = new Map();
61
+ let earliestMs = null;
62
+ let latestMs = null;
63
+ for (const event of envelope.events) {
64
+ byRole[event.role] = (byRole[event.role] ?? 0) + 1;
65
+ attachments += event.attachments.length;
66
+ for (const call of event.toolCalls) {
67
+ toolCallTotal += 1;
68
+ const name = call.function.name;
69
+ toolNameCounts.set(name, (toolNameCounts.get(name) ?? 0) + 1);
70
+ }
71
+ const ms = eventTimeMs(event);
72
+ if (ms !== null) {
73
+ if (earliestMs === null || ms < earliestMs)
74
+ earliestMs = ms;
75
+ if (latestMs === null || ms > latestMs)
76
+ latestMs = ms;
77
+ }
78
+ }
79
+ const top = [...toolNameCounts.entries()]
80
+ .sort((left, right) => right[1] - left[1])
81
+ .slice(0, 5)
82
+ .map(([name, count]) => ({ name, count }));
83
+ return {
84
+ sessionPath,
85
+ envelopeVersion: envelope.version,
86
+ totalEvents: envelope.events.length,
87
+ byRole,
88
+ toolCalls: {
89
+ total: toolCallTotal,
90
+ distinctNames: toolNameCounts.size,
91
+ topByFrequency: top,
92
+ },
93
+ attachments,
94
+ timeRange: {
95
+ earliest: earliestMs !== null ? new Date(earliestMs).toISOString() : null,
96
+ latest: latestMs !== null ? new Date(latestMs).toISOString() : null,
97
+ durationMs: earliestMs !== null && latestMs !== null ? latestMs - earliestMs : null,
98
+ },
99
+ projection: {
100
+ eventCount: envelope.projection.eventIds.length,
101
+ omittedFromProjection: Math.max(0, envelope.events.length - envelope.projection.eventIds.length),
102
+ inputTokens: envelope.projection.inputTokens,
103
+ maxTokens: envelope.projection.maxTokens,
104
+ trimmed: envelope.projection.trimmed,
105
+ },
106
+ lastUsage: envelope.lastUsage,
107
+ };
108
+ }
109
+ function runSessionStats(sessionPath) {
110
+ const text = fs.readFileSync(sessionPath, "utf-8");
111
+ const raw = JSON.parse(text);
112
+ const envelope = (0, session_events_1.parseSessionEnvelope)(raw);
113
+ if (!envelope) {
114
+ return {
115
+ sessionPath,
116
+ envelopeVersion: null,
117
+ totalEvents: 0,
118
+ byRole: emptyByRole(),
119
+ toolCalls: { total: 0, distinctNames: 0, topByFrequency: [] },
120
+ attachments: 0,
121
+ timeRange: { earliest: null, latest: null, durationMs: null },
122
+ projection: { eventCount: 0, omittedFromProjection: 0, inputTokens: null, maxTokens: null, trimmed: false },
123
+ lastUsage: null,
124
+ };
125
+ }
126
+ return computeSessionStats(envelope, sessionPath);
127
+ }
128
+ function formatStatsReport(report) {
129
+ const lines = [];
130
+ lines.push(`Session stats: ${report.sessionPath}`);
131
+ if (report.envelopeVersion === null) {
132
+ lines.push(" envelope: unrecognized (could not parse)");
133
+ return lines.join("\n");
134
+ }
135
+ lines.push(` envelope version: ${report.envelopeVersion}`);
136
+ lines.push(` total events: ${report.totalEvents}`);
137
+ lines.push(` by role: system=${report.byRole.system} user=${report.byRole.user} assistant=${report.byRole.assistant} tool=${report.byRole.tool}`);
138
+ lines.push(` tool calls: ${report.toolCalls.total} (${report.toolCalls.distinctNames} distinct names)`);
139
+ if (report.toolCalls.topByFrequency.length > 0) {
140
+ lines.push(" top tools:");
141
+ for (const { name, count } of report.toolCalls.topByFrequency) {
142
+ lines.push(` ${name}: ${count}`);
143
+ }
144
+ }
145
+ lines.push(` attachments: ${report.attachments}`);
146
+ if (report.timeRange.earliest && report.timeRange.latest) {
147
+ const durationSec = report.timeRange.durationMs !== null ? Math.round(report.timeRange.durationMs / 1000) : null;
148
+ lines.push(` time range: ${report.timeRange.earliest} → ${report.timeRange.latest}${durationSec !== null ? ` (${durationSec}s)` : ""}`);
149
+ }
150
+ lines.push(" projection:");
151
+ lines.push(` in projection: ${report.projection.eventCount}`);
152
+ lines.push(` omitted: ${report.projection.omittedFromProjection}`);
153
+ if (report.projection.inputTokens !== null)
154
+ lines.push(` input tokens: ${report.projection.inputTokens}`);
155
+ if (report.projection.maxTokens !== null)
156
+ lines.push(` max tokens: ${report.projection.maxTokens}`);
157
+ if (report.projection.trimmed)
158
+ lines.push(" trimmed: true");
159
+ if (report.lastUsage) {
160
+ lines.push(` last usage: ${JSON.stringify(report.lastUsage)}`);
161
+ }
162
+ return lines.join("\n");
163
+ }
164
+ function runSessionStatsCli(argv) {
165
+ const positional = argv.filter((token) => !token.startsWith("--"));
166
+ const flags = new Set(argv.filter((token) => token.startsWith("--")));
167
+ if (flags.has("--help") || flags.has("-h") || positional.length === 0) {
168
+ // eslint-disable-next-line no-console -- meta-tooling
169
+ console.log("usage: ouro session-stats <session.json> [--json]");
170
+ return positional.length === 0 ? 2 : 0;
171
+ }
172
+ const report = runSessionStats(positional[0]);
173
+ if (flags.has("--json")) {
174
+ // eslint-disable-next-line no-console -- meta-tooling
175
+ console.log(JSON.stringify(report, null, 2));
176
+ }
177
+ else {
178
+ // eslint-disable-next-line no-console -- meta-tooling
179
+ console.log(formatStatsReport(report));
180
+ }
181
+ return 0;
182
+ }
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.validateSessionMessages = exports.repairSessionMessages = exports.migrateToolNames = void 0;
36
+ exports.validateSessionMessages = exports.repairSessionMessages = exports.migrateToolNames = exports.detectDuplicateToolCallIds = void 0;
37
37
  exports.trimMessages = trimMessages;
38
38
  exports.saveSession = saveSession;
39
39
  exports.appendSyntheticAssistantMessage = appendSyntheticAssistantMessage;
@@ -50,6 +50,7 @@ const fs = __importStar(require("fs"));
50
50
  const path = __importStar(require("path"));
51
51
  const token_estimate_1 = require("./token-estimate");
52
52
  var session_events_2 = require("../heart/session-events");
53
+ Object.defineProperty(exports, "detectDuplicateToolCallIds", { enumerable: true, get: function () { return session_events_2.detectDuplicateToolCallIds; } });
53
54
  Object.defineProperty(exports, "migrateToolNames", { enumerable: true, get: function () { return session_events_2.migrateToolNames; } });
54
55
  Object.defineProperty(exports, "repairSessionMessages", { enumerable: true, get: function () { return session_events_2.repairSessionMessages; } });
55
56
  Object.defineProperty(exports, "validateSessionMessages", { enumerable: true, get: function () { return session_events_2.validateSessionMessages; } });
@@ -129,6 +129,10 @@ const DISPATCH_EXEMPT_PATTERNS = [
129
129
  // lives at the caller (tools-mail.ts mail_body handler) which fires
130
130
  // repertoire.mail_body_cache_hit on cache reuse.
131
131
  "mailroom/body-cache",
132
+ // Session stats: read-only session.json analyzer CLI for debugging.
133
+ // Diagnostics-only utility; output is human-readable summary.
134
+ "heart/session-stats-cli-main",
135
+ "heart/session-stats",
132
136
  ];
133
137
  function isDispatchExempt(filePath) {
134
138
  return DISPATCH_EXEMPT_PATTERNS.some((pattern) => filePath.includes(pattern));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.504",
3
+ "version": "0.1.0-alpha.506",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",