opencode-lore 0.2.3 → 0.2.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-lore",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Three-tier memory architecture for OpenCode — distillation, not summarization",
package/src/gradient.ts CHANGED
@@ -926,6 +926,23 @@ export function estimateMessages(messages: MessageWithParts[]): number {
926
926
  return messages.reduce((sum, m) => sum + estimateMessage(m), 0);
927
927
  }
928
928
 
929
+ // Identify the current agentic turn: the last user message plus all subsequent
930
+ // assistant messages that share its ID as parentID. These messages form an atomic
931
+ // unit — the model must see all of them or it will lose track of its own prior
932
+ // tool calls and re-issue them in an infinite loop.
933
+ function currentTurnStart(messages: MessageWithParts[]): number {
934
+ // Find the last user message
935
+ let lastUserIdx = -1;
936
+ for (let i = messages.length - 1; i >= 0; i--) {
937
+ if (messages[i].info.role === "user") {
938
+ lastUserIdx = i;
939
+ break;
940
+ }
941
+ }
942
+ if (lastUserIdx === -1) return 0; // no user message — treat all as current turn
943
+ return lastUserIdx;
944
+ }
945
+
929
946
  function tryFit(input: {
930
947
  messages: MessageWithParts[];
931
948
  prefix: MessageWithParts[];
@@ -939,32 +956,49 @@ function tryFit(input: {
939
956
  if (input.prefixTokens > input.distilledBudget && input.prefix.length > 0)
940
957
  return null;
941
958
 
942
- // Walk backwards through messages, accumulating tokens within raw budget
943
- let rawTokens = 0;
944
- let cutoff = input.messages.length;
959
+ // Identify the current turn (last user message + all following assistant messages).
960
+ // These are always included — they must never be evicted. If they alone exceed the
961
+ // raw budget, escalate to the next layer (which strips tool outputs to reduce size).
962
+ const turnStart = currentTurnStart(input.messages);
963
+ const currentTurn = input.messages.slice(turnStart);
964
+ const currentTurnTokens = currentTurn.reduce((s, m) => s + estimateMessage(m), 0);
965
+
966
+ if (currentTurnTokens > input.rawBudget) {
967
+ // Current turn alone exceeds budget — can't fit even with everything else dropped.
968
+ // Signal failure so the caller escalates to the next layer (tool-output stripping).
969
+ return null;
970
+ }
971
+
972
+ // Walk backwards through older messages (before the current turn),
973
+ // filling the remaining budget after reserving space for the current turn.
974
+ const olderMessages = input.messages.slice(0, turnStart);
975
+ const remainingBudget = input.rawBudget - currentTurnTokens;
976
+ let olderTokens = 0;
977
+ let cutoff = olderMessages.length; // default: include none of the older messages
945
978
  const protectedTurns = input.protectedTurns ?? 0;
946
- let turns = 0;
947
979
 
948
- for (let i = input.messages.length - 1; i >= 0; i--) {
949
- const msg = input.messages[i];
950
- if (msg.info.role === "user") turns++;
980
+ for (let i = olderMessages.length - 1; i >= 0; i--) {
981
+ const msg = olderMessages[i];
951
982
  const tokens = estimateMessage(msg);
952
- if (rawTokens + tokens > input.rawBudget) {
983
+ if (olderTokens + tokens > remainingBudget) {
953
984
  cutoff = i + 1;
954
985
  break;
955
986
  }
956
- rawTokens += tokens;
987
+ olderTokens += tokens;
957
988
  if (i === 0) cutoff = 0;
958
989
  }
959
990
 
960
- const raw = input.messages.slice(cutoff);
961
- // Must keep at least 1 raw message — otherwise this layer fails
962
- if (!raw.length) return null;
991
+ const rawMessages = [...olderMessages.slice(cutoff), ...currentTurn];
992
+ const rawTokens = olderTokens + currentTurnTokens;
963
993
 
964
- // Apply system-reminder stripping + optional tool output stripping
965
- const processed = raw.map((msg, idx) => {
966
- const fromEnd = raw.length - idx;
994
+ // Apply system-reminder stripping + optional tool output stripping.
995
+ // The current turn (end of rawMessages) is always "protected" — never stripped.
996
+ const currentTurnSet = new Set(currentTurn.map((m) => m.info.id));
997
+ const processed = rawMessages.map((msg, idx) => {
998
+ const fromEnd = rawMessages.length - idx;
999
+ const isCurrentTurn = currentTurnSet.has(msg.info.id);
967
1000
  const isProtected =
1001
+ isCurrentTurn ||
968
1002
  input.strip === "none" ||
969
1003
  (input.strip === "old-tools" && fromEnd <= protectedTurns * 2);
970
1004
  const parts = isProtected
package/src/index.ts CHANGED
@@ -395,12 +395,25 @@ export const LorePlugin: Plugin = async (ctx) => {
395
395
  // so the append-only sequence stays intact for prompt caching.
396
396
  if (result.layer > 0) {
397
397
  // The API requires the conversation to end with a user message.
398
- // Always drop trailing non-user messages even assistant messages with
399
- // tool parts. A hard API error is worse than the model re-invoking a tool.
398
+ // Drop trailing non-user messages, but stop if we hit an assistant message
399
+ // with an in-progress (non-completed) tool call dropping it would cause
400
+ // the model to lose its pending tool invocation and re-issue it in an
401
+ // infinite loop. A completed tool part is safe to drop; a pending one is not.
400
402
  while (
401
403
  result.messages.length > 0 &&
402
404
  result.messages.at(-1)!.info.role !== "user"
403
405
  ) {
406
+ const last = result.messages.at(-1)!;
407
+ const hasPendingTool = last.parts.some(
408
+ (p) => p.type === "tool" && p.state.status !== "completed",
409
+ );
410
+ if (hasPendingTool) {
411
+ console.error(
412
+ "[lore] WARN: cannot drop trailing assistant message with pending tool call — may cause prefill error. id:",
413
+ last.info.id,
414
+ );
415
+ break;
416
+ }
404
417
  const dropped = result.messages.pop()!;
405
418
  console.error(
406
419
  "[lore] WARN: dropping trailing",