@ouro.bot/cli 0.1.0-alpha.357 → 0.1.0-alpha.359

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,18 @@
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.359",
6
+ "changes": [
7
+ "Fixed session storage explosion caused by `findCommonPrefixLength` returning 0 every turn (system prompt changes invalidated the prefix). Skips system messages in prefix comparison, prunes non-projected events from the envelope, archives evicted events to NDJSON, and adds `loadFullEventHistory()` for consumers that need full history. Eliminates unbounded duplicate event accumulation that caused 512MB session files and 15s freezes."
8
+ ]
9
+ },
10
+ {
11
+ "version": "0.1.0-alpha.358",
12
+ "changes": [
13
+ "Removed `/new` slash command from CLI (kept on Teams). Made `dispatch()` channel-aware so commands only fire on their registered channels."
14
+ ]
15
+ },
4
16
  {
5
17
  "version": "0.1.0-alpha.357",
6
18
  "changes": [
@@ -213,7 +213,8 @@ function readSessionTranscript(agentName, friendId, channel, key, options = {})
213
213
  const envelope = (0, shared_1.readSessionEnvelope)(sessionPath);
214
214
  if (!envelope)
215
215
  return null;
216
- const rawMessages = envelope.events;
216
+ // Use full event history (envelope + archive) for complete transcript
217
+ const rawMessages = (0, session_events_1.loadFullEventHistory)(sessionPath);
217
218
  const friendsDir = path.join(agentRoot, "friends");
218
219
  const friendName = (0, shared_1.resolveFriendName)(friendsDir, friendId);
219
220
  const messages = rawMessages;
@@ -51,6 +51,8 @@ exports.migrateLegacySessionEnvelope = migrateLegacySessionEnvelope;
51
51
  exports.parseSessionEnvelope = parseSessionEnvelope;
52
52
  exports.loadSessionEnvelopeFile = loadSessionEnvelopeFile;
53
53
  exports.buildCanonicalSessionEnvelope = buildCanonicalSessionEnvelope;
54
+ exports.loadFullEventHistory = loadFullEventHistory;
55
+ exports.appendEvictedToArchive = appendEvictedToArchive;
54
56
  exports.appendSyntheticAssistantEvent = appendSyntheticAssistantEvent;
55
57
  const fs = __importStar(require("fs"));
56
58
  const runtime_1 = require("../nerves/runtime");
@@ -652,10 +654,24 @@ function loadSessionEnvelopeFile(filePath) {
652
654
  return null;
653
655
  }
654
656
  }
657
+ function messageRole(msg) {
658
+ return normalizeRole(msg.role);
659
+ }
660
+ function filterNonSystem(messages) {
661
+ return messages.filter((msg) => messageRole(msg) !== "system");
662
+ }
663
+ /**
664
+ * Compare two message arrays by their non-system messages only.
665
+ * Returns the number of matching non-system messages from the start.
666
+ * System messages (whose content changes every turn due to live world-state)
667
+ * are excluded so that prefix matching is not defeated by system prompt updates.
668
+ */
655
669
  function findCommonPrefixLength(a, b) {
656
- const max = Math.min(a.length, b.length);
670
+ const aNonSys = filterNonSystem(a);
671
+ const bNonSys = filterNonSystem(b);
672
+ const max = Math.min(aNonSys.length, bNonSys.length);
657
673
  for (let i = 0; i < max; i++) {
658
- if (messageFingerprint(a[i]) !== messageFingerprint(b[i]))
674
+ if (messageFingerprint(aNonSys[i]) !== messageFingerprint(bNonSys[i]))
659
675
  return i;
660
676
  }
661
677
  return max;
@@ -684,33 +700,145 @@ function buildCanonicalSessionEnvelope(options) {
684
700
  const previousProjectionIds = existing?.projection.eventIds.length
685
701
  ? [...existing.projection.eventIds]
686
702
  : existing?.events.map((event) => event.id) ?? [];
687
- const commonPrefix = findCommonPrefixLength(previousMessages, currentMessages);
688
- const appendFrom = previousMessages.length === commonPrefix ? previousMessages.length : commonPrefix;
689
- const newMessages = currentMessages.slice(appendFrom);
690
- const newIngressTimes = currentIngressTimes.slice(appendFrom);
691
- const baseSequence = existing?.events.length ?? 0;
692
- const newEvents = newMessages.map((message, index) => buildEventFromMessage(message, baseSequence + index + 1, options.recordedAt, "live", null, null, newIngressTimes[index]));
693
- const events = [...(existing?.events ?? []), ...newEvents];
694
- const currentEventIds = [
695
- ...previousProjectionIds.slice(0, appendFrom),
696
- ...newEvents.map((event) => event.id),
697
- ];
703
+ // Compare only non-system messages to find the common prefix.
704
+ // System messages change every turn (live world-state in system prompt)
705
+ // and must not defeat prefix matching of the actual conversation.
706
+ const nonSystemPrefix = findCommonPrefixLength(previousMessages, currentMessages);
707
+ // Build a lookup of non-system previous projection IDs.
708
+ const prevNonSystemIds = [];
709
+ for (let i = 0; i < previousMessages.length; i++) {
710
+ if (messageRole(previousMessages[i]) !== "system") {
711
+ prevNonSystemIds.push(previousProjectionIds[i]);
712
+ }
713
+ }
714
+ // Walk currentMessages and build currentEventIds + new events.
715
+ // Non-system messages within the prefix reuse old event IDs.
716
+ // System messages and post-prefix messages get new events.
717
+ const events = [...(existing?.events ?? [])];
718
+ const currentEventIds = [];
719
+ let nonSystemSeen = 0;
720
+ for (let i = 0; i < currentMessages.length; i++) {
721
+ const role = messageRole(currentMessages[i]);
722
+ const isSystem = role === "system";
723
+ const inPrefix = !isSystem && nonSystemSeen < nonSystemPrefix;
724
+ if (inPrefix) {
725
+ // Reuse existing event ID for this matched non-system message
726
+ currentEventIds.push(prevNonSystemIds[nonSystemSeen]);
727
+ nonSystemSeen++;
728
+ }
729
+ else if (isSystem && i < previousMessages.length
730
+ && messageRole(previousMessages[i]) === "system"
731
+ && messageFingerprint(currentMessages[i]) === messageFingerprint(previousMessages[i])) {
732
+ // System message at same position with identical content -- reuse event ID
733
+ currentEventIds.push(previousProjectionIds[i]);
734
+ }
735
+ else {
736
+ if (!isSystem)
737
+ nonSystemSeen++;
738
+ // Create a new event
739
+ const event = buildEventFromMessage(currentMessages[i], events.length + 1, options.recordedAt, "live", null, null, currentIngressTimes[i]);
740
+ events.push(event);
741
+ currentEventIds.push(event.id);
742
+ }
743
+ }
698
744
  const projectionEventIds = selectProjectedEventIds(currentMessages, currentEventIds, trimmedMessages);
745
+ // Prune events: only keep events whose IDs are in the projection.
746
+ // Events not in projection are returned as evicted for archiving.
747
+ const projectionIdSet = new Set(projectionEventIds);
748
+ const prunedEvents = events.filter((event) => projectionIdSet.has(event.id));
749
+ const evictedEvents = events.filter((event) => !projectionIdSet.has(event.id));
699
750
  return {
700
- version: 2,
701
- events,
702
- projection: {
703
- eventIds: projectionEventIds,
704
- trimmed: projectionEventIds.length < currentEventIds.length,
705
- maxTokens: options.projectionBasis.maxTokens,
706
- contextMargin: options.projectionBasis.contextMargin,
707
- inputTokens: options.projectionBasis.inputTokens,
708
- projectedAt: options.recordedAt,
751
+ envelope: {
752
+ version: 2,
753
+ events: prunedEvents,
754
+ projection: {
755
+ eventIds: projectionEventIds,
756
+ trimmed: projectionEventIds.length < currentEventIds.length,
757
+ maxTokens: options.projectionBasis.maxTokens,
758
+ contextMargin: options.projectionBasis.contextMargin,
759
+ inputTokens: options.projectionBasis.inputTokens,
760
+ projectedAt: options.recordedAt,
761
+ },
762
+ lastUsage: normalizeUsage(options.lastUsage),
763
+ state: normalizeContinuityState(options.state),
709
764
  },
710
- lastUsage: normalizeUsage(options.lastUsage),
711
- state: normalizeContinuityState(options.state),
765
+ evictedEvents,
712
766
  };
713
767
  }
768
+ /**
769
+ * Load full event history from both the pruned envelope and the NDJSON archive.
770
+ * Returns all events deduplicated by id and sorted by sequence.
771
+ * Corrupted archive lines are silently skipped.
772
+ */
773
+ function loadFullEventHistory(sessPath) {
774
+ const envelope = loadSessionEnvelopeFile(sessPath);
775
+ if (!envelope)
776
+ return [];
777
+ const envelopeEvents = envelope.events;
778
+ const archivePath = sessPath.replace(/\.json$/, ".archive.ndjson");
779
+ let archiveEvents = [];
780
+ try {
781
+ const raw = fs.readFileSync(archivePath, "utf-8");
782
+ const lines = raw.split("\n");
783
+ for (const line of lines) {
784
+ const trimmed = line.trim();
785
+ if (trimmed.length === 0)
786
+ continue;
787
+ try {
788
+ const event = JSON.parse(trimmed);
789
+ if (event && typeof event.id === "string" && typeof event.sequence === "number") {
790
+ archiveEvents.push(event);
791
+ }
792
+ }
793
+ catch {
794
+ // Skip corrupted lines
795
+ }
796
+ }
797
+ }
798
+ catch {
799
+ // Archive file doesn't exist or can't be read -- that's fine
800
+ }
801
+ // Merge, deduplicate by id, sort by sequence
802
+ const seen = new Set();
803
+ const merged = [];
804
+ for (const event of [...archiveEvents, ...envelopeEvents]) {
805
+ if (seen.has(event.id))
806
+ continue;
807
+ seen.add(event.id);
808
+ merged.push(event);
809
+ }
810
+ merged.sort((a, b) => a.sequence - b.sequence);
811
+ return merged;
812
+ }
813
+ /**
814
+ * Append evicted events to an NDJSON archive file.
815
+ * The archive path is derived from the session path by replacing .json with .archive.ndjson.
816
+ * Each event is written as a single JSON line. The file is appended to, not overwritten.
817
+ * Failures are logged and swallowed -- archive write must never crash the persist path.
818
+ */
819
+ function appendEvictedToArchive(sessPath, evictedEvents) {
820
+ if (evictedEvents.length === 0)
821
+ return;
822
+ const archivePath = sessPath.replace(/\.json$/, ".archive.ndjson");
823
+ try {
824
+ const ndjson = evictedEvents.map((event) => JSON.stringify(event)).join("\n") + "\n";
825
+ fs.appendFileSync(archivePath, ndjson);
826
+ }
827
+ catch (err) {
828
+ (0, runtime_1.emitNervesEvent)({
829
+ level: "warn",
830
+ component: "heart",
831
+ event: "heart.archive_write_error",
832
+ message: "failed to write evicted events to archive",
833
+ meta: {
834
+ archivePath,
835
+ eventCount: evictedEvents.length,
836
+ /* v8 ignore next -- defensive: Node fs always throws Error instances @preserve */
837
+ error: err instanceof Error ? err.message : String(err),
838
+ },
839
+ });
840
+ }
841
+ }
714
842
  function appendSyntheticAssistantEvent(envelope, content, recordedAt) {
715
843
  const sequence = envelope.events.length + 1;
716
844
  const event = buildEventFromMessage({ role: "assistant", content }, sequence, recordedAt, "synthetic", null, null);
@@ -137,10 +137,15 @@ async function searchSessionTranscript(options) {
137
137
  maxMatches: options.maxMatches ?? 5,
138
138
  },
139
139
  });
140
- const envelope = (0, session_events_1.loadSessionEnvelopeFile)(options.sessionPath);
141
- if (!envelope)
142
- return { kind: "missing" };
143
- const messages = normalizeSessionMessages(envelope.events);
140
+ // Use full event history (envelope + archive) for search to find older messages
141
+ const allEvents = (0, session_events_1.loadFullEventHistory)(options.sessionPath);
142
+ if (allEvents.length === 0) {
143
+ const envelope = (0, session_events_1.loadSessionEnvelopeFile)(options.sessionPath);
144
+ if (!envelope)
145
+ return { kind: "missing" };
146
+ return { kind: "empty" };
147
+ }
148
+ const messages = normalizeSessionMessages(allEvents);
144
149
  if (messages.length === 0) {
145
150
  return { kind: "empty" };
146
151
  }
@@ -198,7 +198,7 @@ function saveSession(filePath, messages, lastUsage, state) {
198
198
  const previousMessages = existing ? (0, session_events_1.projectProviderMessages)(existing) : [];
199
199
  const currentIngressTimes = messages.map(session_events_1.getIngressTime);
200
200
  const sanitized = (0, session_events_1.sanitizeProviderMessages)(messages);
201
- const envelope = (0, session_events_1.buildCanonicalSessionEnvelope)({
201
+ const { envelope } = (0, session_events_1.buildCanonicalSessionEnvelope)({
202
202
  existing,
203
203
  previousMessages,
204
204
  currentMessages: sanitized,
@@ -295,7 +295,7 @@ function postTurnTrim(messages, usage, hooks) {
295
295
  function postTurnPersist(sessPath, prepared, usage, state) {
296
296
  const existing = (0, session_events_1.loadSessionEnvelopeFile)(sessPath);
297
297
  const previousMessages = existing ? (0, session_events_1.projectProviderMessages)(existing) : [];
298
- const envelope = (0, session_events_1.buildCanonicalSessionEnvelope)({
298
+ const { envelope, evictedEvents } = (0, session_events_1.buildCanonicalSessionEnvelope)({
299
299
  existing,
300
300
  previousMessages,
301
301
  currentMessages: prepared.currentMessages,
@@ -310,6 +310,7 @@ function postTurnPersist(sessPath, prepared, usage, state) {
310
310
  inputTokens: usage?.input_tokens ?? null,
311
311
  },
312
312
  });
313
+ (0, session_events_1.appendEvictedToArchive)(sessPath, evictedEvents);
313
314
  writeSessionEnvelope(sessPath, envelope);
314
315
  return envelope.events;
315
316
  }
@@ -799,14 +799,6 @@ async function runCliSession(options) {
799
799
  if (dispatchResult.result.action === "exit") {
800
800
  break;
801
801
  }
802
- else if (dispatchResult.result.action === "new") {
803
- messages.length = 0;
804
- messages.push({ role: "system", content: await (0, prompt_1.buildSystem)("cli") });
805
- await options.onNewSession?.();
806
- // eslint-disable-next-line no-console -- terminal UX: session cleared
807
- console.log("session cleared");
808
- continue;
809
- }
810
802
  else if (dispatchResult.result.action === "response") {
811
803
  display.text(dispatchResult.result.message || "");
812
804
  continue;
@@ -839,14 +831,6 @@ async function runCliSession(options) {
839
831
  if (result.commandAction === "exit") {
840
832
  break;
841
833
  }
842
- else if (result.commandAction === "new") {
843
- messages.length = 0;
844
- messages.push({ role: "system", content: await (0, prompt_1.buildSystem)("cli") });
845
- await options.onNewSession?.();
846
- // eslint-disable-next-line no-console -- terminal UX: session cleared
847
- console.log("session cleared");
848
- continue;
849
- }
850
834
  // For "response" commands: the pipeline already emitted the response via onTextChunk
851
835
  cliCallbacks.flushMarkdown();
852
836
  continue;
@@ -1109,9 +1093,6 @@ async function main(agentName, options) {
1109
1093
  }
1110
1094
  return { usage: result.usage, turnOutcome: result.turnOutcome, commandAction: result.commandAction };
1111
1095
  },
1112
- onNewSession: () => {
1113
- (0, context_1.deleteSession)(sessPath);
1114
- },
1115
1096
  });
1116
1097
  }
1117
1098
  finally {
@@ -25,7 +25,7 @@ function createCommandRegistry() {
25
25
  },
26
26
  dispatch(name, ctx) {
27
27
  const cmd = commands.get(name);
28
- if (!cmd)
28
+ if (!cmd || !cmd.channels.includes(ctx.channel))
29
29
  return { handled: false };
30
30
  return { handled: true, result: cmd.handler(ctx) };
31
31
  },
@@ -63,7 +63,7 @@ function registerDefaultCommands(registry) {
63
63
  registry.register({
64
64
  name: "new",
65
65
  description: "start a new conversation",
66
- channels: ["cli", "teams"],
66
+ channels: ["teams"],
67
67
  handler: () => ({ action: "new" }),
68
68
  });
69
69
  registry.register({
@@ -97,7 +97,6 @@ function registerDefaultCommands(registry) {
97
97
  "Commands:",
98
98
  " /help this help",
99
99
  " /commands list all commands",
100
- " /new start a new conversation",
101
100
  " /exit quit",
102
101
  ].join("\n"),
103
102
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.357",
3
+ "version": "0.1.0-alpha.359",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",