@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 +12 -0
- package/dist/heart/outlook/readers/sessions.js +2 -1
- package/dist/heart/session-events.js +152 -24
- package/dist/heart/session-transcript.js +9 -4
- package/dist/mind/context.js +3 -2
- package/dist/senses/cli.js +0 -19
- package/dist/senses/commands.js +2 -3
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
const
|
|
691
|
-
|
|
692
|
-
const
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
}
|
package/dist/mind/context.js
CHANGED
|
@@ -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
|
}
|
package/dist/senses/cli.js
CHANGED
|
@@ -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 {
|
package/dist/senses/commands.js
CHANGED
|
@@ -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: ["
|
|
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
|
}),
|