@cjhyy/code-shell-core 0.8.6 → 0.8.8

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.
@@ -215,11 +215,13 @@ function freshSettings() {
215
215
  }
216
216
  const chatManager = new ChatSessionManager({
217
217
  runtime,
218
- // resolvedLlmConfig is the bootstrap-time snapshot. When the user
219
- // hot-switches models via configure() the modelPool.activeKey moves
220
- // ahead of it, so newly-created sessions must re-resolve from the pool
221
- // each time the factory fires; fall back to the snapshot only when the
222
- // pool can't resolve (no active key shouldn't happen in practice).
218
+ // resolvedLlmConfig is the bootstrap-time snapshot. Explicit global model
219
+ // changes can move modelPool.activeKey ahead of it, so newly-created
220
+ // sessions re-resolve from the pool each time the factory fires. A
221
+ // per-session configure intentionally does NOT move that shared key; this
222
+ // keeps one Session's selected model from leaking into the next Session.
223
+ // Fall back to the snapshot only when the pool can't resolve (no active key
224
+ // — shouldn't happen in practice).
223
225
  engineFactory: (slice) => {
224
226
  // Effective cwd for THIS session: explicit slice.cwd → that project; absent
225
227
  // → the no-repo sandbox (NOT the worker's stale boot cwd). See
@@ -547,8 +547,16 @@ export class ContextManager {
547
547
  const window = messages.slice(start, end);
548
548
  // Feed a prior anchored summary back so the LLM merge-updates rather than
549
549
  // re-summarizes from scratch, matching the rolling-summary behavior of
550
- // trySummaryCompact().
551
- const priorSummary = extractAnchoredSummary(messages) ?? this.lastSummary;
550
+ // trySummaryCompact() — but ONLY when that anchored summary sits inside
551
+ // the window being replaced. A summary inside [start, end) is about to be
552
+ // overwritten by this call, so it must be merged in or its content is
553
+ // lost. A summary OUTSIDE the window belongs to a different, untouched
554
+ // span and must NOT be merged in: doing so (via `?? this.lastSummary`,
555
+ // which is a single most-recent-summary carryover with no span
556
+ // awareness) made every subsequent range archive fold in the text of
557
+ // every prior one, so N archived spans produced N copies of the same
558
+ // growing summary instead of N independent summaries.
559
+ const priorSummary = extractAnchoredSummary(window);
552
560
  const prompt = buildSummarizationPrompt(window, priorSummary);
553
561
  const summary = await this.summarizeFn(prompt, opts.signal);
554
562
  if (!summary || summary.length <= 50)
@@ -457,6 +457,13 @@ export declare class Engine {
457
457
  * Switch the active model by pool key. Takes effect on the next run() call.
458
458
  * Returns the new model entry.
459
459
  *
460
+ * Per-session switches (`persist:false`) must not mutate the shared
461
+ * ModelPool active key. Every desktop ChatSession owns an Engine, but those
462
+ * Engines share one runtime pool; changing the pool selection here made the
463
+ * next newly-created Session inherit whichever model another Session had
464
+ * just selected (for example a Mimi manager model leaking into its delegated
465
+ * Work Session).
466
+ *
460
467
  * Persists settings.defaults.text (= the connection id / pool key) so the
461
468
  * next process startup defaults to the same model — without this, switches
462
469
  * only live in memory and every restart reverts to the previously persisted
@@ -623,22 +630,67 @@ export declare class Engine {
623
630
  */
624
631
  private prepareContextManagerForSession;
625
632
  /**
626
- * Archive a caller-chosen contiguous message-index window `[range.start,
627
- * range.end)` of a session into a single anchored summary, leaving everything
628
- * outside the window untouched, and cache the result so a later
629
- * forceCompact/resume reads the archived history. This is a generic
630
- * range-archival facade over ContextManager.summarizeRange the caller
631
- * decides which span to collapse (the range is a half-open message-index
632
- * window, matching summarizeRange). Returns token stats before/after; equal
633
- * before/after means the window was empty or the summary was rejected.
633
+ * Archive a contiguous message window of a session into a single anchored
634
+ * summary, leaving everything outside the window untouched, and cache the
635
+ * result so a later forceCompact/resume reads the archived history. This is
636
+ * a generic range-archival facade over ContextManager.summarizeRange.
637
+ * Returns token stats before/after; equal before/after means the window was
638
+ * empty/unresolvable or the summary was rejected.
639
+ *
640
+ * Window resolution depends on whether the caller supplied anchors:
641
+ * - With `anchors`, the caller's `range` is IGNORED and the window is
642
+ * resolved from the client-message-id anchors over a fresh, marker-aware
643
+ * transcript replay. Callers (the pet segment closure) compute index
644
+ * ranges over the RAW transcript message list, whose indices grow
645
+ * forever — but the live list is trimmed by every persisted marker
646
+ * (replay after restart) and by each in-process archival, so a raw index
647
+ * range clamps to an empty window or, worse, onto the WRONG tail
648
+ * messages, which would then be persistently mis-summarized against
649
+ * correct anchors. Anchors are the only stable coordinates.
650
+ * - Without `anchors`, legacy behavior: `range` is a half-open index window
651
+ * over the cached/in-process message list, and nothing is persisted.
634
652
  */
635
653
  archiveTurnRange(sessionId: string, range: {
636
654
  start: number;
637
655
  end: number;
656
+ }, anchors?: {
657
+ toClientMessageId: string;
658
+ fromClientMessageId?: string;
659
+ segmentId?: string;
638
660
  }): Promise<{
639
661
  before: number;
640
662
  after: number;
641
663
  }>;
664
+ /**
665
+ * Guard against writing a marker anchored to a client message id the
666
+ * transcript doesn't actually contain. toMessages()'s replay silently
667
+ * ignores a range_archive event whose anchor doesn't resolve (fail open),
668
+ * so a bad anchor wouldn't corrupt anything on its own — but if the marker
669
+ * carries a segmentId, appendRangeArchive's idempotency check treats that
670
+ * segmentId as "already recorded" forever, with no retry path (e.g. a
671
+ * one-time migration keyed "migration-v1" would be permanently burned on
672
+ * its first, failed attempt). Reject before writing so the caller can
673
+ * retry with a corrected anchor. fromClientMessageId is optional in the
674
+ * data model (absent means "from the beginning"), so only validate it when
675
+ * present; missing it is fail-open by design, same as the replay path.
676
+ */
677
+ private hasLiveArchiveAnchors;
678
+ /**
679
+ * Persist an archive boundary WITHOUT a summarization call — the caller
680
+ * already has the summary text (e.g. the one-time migration built from
681
+ * pet journal entries). Wraps the plain text in the anchored-summary
682
+ * envelope so replay and rolling-merge treat it like a real archive.
683
+ * Returns false when the segmentId was already recorded (idempotent) OR
684
+ * when the anchors don't resolve to real messages in this transcript —
685
+ * see hasLiveArchiveAnchors for why a dead anchor must be rejected before
686
+ * the (potentially one-shot) segmentId gets burned.
687
+ */
688
+ appendArchiveMarker(sessionId: string, marker: {
689
+ summary: string;
690
+ toClientMessageId: string;
691
+ fromClientMessageId?: string;
692
+ segmentId?: string;
693
+ }): Promise<boolean>;
642
694
  private recordCacheReadDiagnostics;
643
695
  private getSettingsManager;
644
696
  /**
@@ -12,6 +12,7 @@ import { enqueueSteerItem, consumeSteerItems, removeSteerItem, } from "./steer-q
12
12
  import { RunEnvironmentResolver } from "./run-environment.js";
13
13
  import { BUILTIN_TOOLS, } from "../tool-system/builtin/index.js";
14
14
  import { asyncAgentRegistry } from "../tool-system/builtin/agent-registry.js";
15
+ import { skillToolDef } from "../tool-system/builtin/skill.js";
15
16
  import { backgroundShellManager } from "../runtime/background-shell.js";
16
17
  import { notificationQueue } from "../tool-system/builtin/agent-notifications.js";
17
18
  import { HookRegistry } from "../hooks/registry.js";
@@ -20,7 +21,7 @@ import { loadPluginHooks } from "../plugins/loadPluginHooks.js";
20
21
  import { pluginAgentDirs } from "../plugins/installer/loadPluginAgents.js";
21
22
  import { runShellHook, shellHookMatches } from "../hooks/shell-runner.js";
22
23
  import { ContextManager } from "../context/manager.js";
23
- import { CONTEXT_PACKAGE_MAX_OUTPUT_TOKENS, buildContextPackagePromptFromSerialized, estimateTokens, groupMessagesByApiRound, serializeContextPackageMessages, clampContextRatios as clampContextRatiosImpl, } from "../context/compaction.js";
24
+ import { CONTEXT_PACKAGE_MAX_OUTPUT_TOKENS, buildAnchoredSummaryMessage, buildContextPackagePromptFromSerialized, estimateTokens, groupMessagesByApiRound, serializeContextPackageMessages, clampContextRatios as clampContextRatiosImpl, } from "../context/compaction.js";
24
25
  import { PromptComposer } from "../prompt/composer.js";
25
26
  import { SessionManager, assertSafeSessionId, isEphemeralSessionState, sessionsRoot, } from "../session/session-manager.js";
26
27
  import { createRunUsageAccounting, wireRunModelFacade } from "./run-accounting.js";
@@ -1935,6 +1936,10 @@ export class Engine {
1935
1936
  (visibilityStoredGoal !== undefined && visibilityStoredGoal.paused !== true) ||
1936
1937
  (visibilityDefaultGoal !== undefined && visibilityDefaultGoal.paused !== true));
1937
1938
  const { disabledSkills, disabledPlugins } = this.readDisabledLists(cwd, sessionProfileOverrides);
1939
+ // A profile whose tool allowlist excludes the Skill tool can never invoke
1940
+ // a skill, so the full skills listing would be dead context for every one
1941
+ // of its turns (e.g. the Pet manager) — inject none via an empty allowlist.
1942
+ const profileCanUseSkills = !profile?.allowedToolNames || profile.allowedToolNames.has(skillToolDef.name);
1938
1943
  const promptComposer = new PromptComposer(buildPromptComposerConfig({
1939
1944
  cwd,
1940
1945
  model: this.config.llm.model,
@@ -1954,8 +1959,9 @@ export class Engine {
1954
1959
  instructionBoundaryFinder: (scanCwd) => resolveInstructionBoundary(scanCwd, this.capabilities),
1955
1960
  disabledSkills,
1956
1961
  disabledPlugins,
1957
- skillAllowlist: this.config.skillAllowlist,
1962
+ skillAllowlist: profileCanUseSkills ? this.config.skillAllowlist : [],
1958
1963
  memoriesMaxAgeDays: this.readMemoriesConfig()?.maxAge,
1964
+ memoryCurrentProjectOnly: profile?.memoryCurrentProjectOnly,
1959
1965
  goalToolState: !profile?.allowedToolNames ||
1960
1966
  profile.allowedToolNames.has("complete_goal") ||
1961
1967
  profile.allowedToolNames.has("cancel_goal")
@@ -2228,13 +2234,28 @@ export class Engine {
2228
2234
  * Switch the active model by pool key. Takes effect on the next run() call.
2229
2235
  * Returns the new model entry.
2230
2236
  *
2237
+ * Per-session switches (`persist:false`) must not mutate the shared
2238
+ * ModelPool active key. Every desktop ChatSession owns an Engine, but those
2239
+ * Engines share one runtime pool; changing the pool selection here made the
2240
+ * next newly-created Session inherit whichever model another Session had
2241
+ * just selected (for example a Mimi manager model leaking into its delegated
2242
+ * Work Session).
2243
+ *
2231
2244
  * Persists settings.defaults.text (= the connection id / pool key) so the
2232
2245
  * next process startup defaults to the same model — without this, switches
2233
2246
  * only live in memory and every restart reverts to the previously persisted
2234
2247
  * defaults.text.
2235
2248
  */
2236
2249
  switchModel(key, opts) {
2237
- const entry = this.modelPool.switch(key);
2250
+ const perSession = opts?.persist === false;
2251
+ const entry = perSession ? this.modelPool.get(key) : this.modelPool.switch(key);
2252
+ if (!entry) {
2253
+ const available = this.modelPool
2254
+ .list()
2255
+ .map((candidate) => candidate.key)
2256
+ .join(", ");
2257
+ throw new Error(`Model "${key}" not found. Available: ${available}`);
2258
+ }
2238
2259
  // LLMConfig is pure model identity now — rotate it wholesale. Cross-model
2239
2260
  // runtime knobs (temperature/timeout/retryMaxAttempts/imageDetail) live on
2240
2261
  // this.config.clientDefaults and survive the switch untouched.
@@ -2243,7 +2264,7 @@ export class Engine {
2243
2264
  // persist: false is the per-session path (ChatSession) — switching one
2244
2265
  // session's model must not rewrite settings.defaults.text, the boot
2245
2266
  // default every future session inherits.
2246
- if (opts?.persist !== false)
2267
+ if (!perSession)
2247
2268
  this.persistActiveModel(entry);
2248
2269
  return entry;
2249
2270
  }
@@ -2839,33 +2860,149 @@ export class Engine {
2839
2860
  return contextManager;
2840
2861
  }
2841
2862
  /**
2842
- * Archive a caller-chosen contiguous message-index window `[range.start,
2843
- * range.end)` of a session into a single anchored summary, leaving everything
2844
- * outside the window untouched, and cache the result so a later
2845
- * forceCompact/resume reads the archived history. This is a generic
2846
- * range-archival facade over ContextManager.summarizeRange the caller
2847
- * decides which span to collapse (the range is a half-open message-index
2848
- * window, matching summarizeRange). Returns token stats before/after; equal
2849
- * before/after means the window was empty or the summary was rejected.
2850
- */
2851
- async archiveTurnRange(sessionId, range) {
2863
+ * Archive a contiguous message window of a session into a single anchored
2864
+ * summary, leaving everything outside the window untouched, and cache the
2865
+ * result so a later forceCompact/resume reads the archived history. This is
2866
+ * a generic range-archival facade over ContextManager.summarizeRange.
2867
+ * Returns token stats before/after; equal before/after means the window was
2868
+ * empty/unresolvable or the summary was rejected.
2869
+ *
2870
+ * Window resolution depends on whether the caller supplied anchors:
2871
+ * - With `anchors`, the caller's `range` is IGNORED and the window is
2872
+ * resolved from the client-message-id anchors over a fresh, marker-aware
2873
+ * transcript replay. Callers (the pet segment closure) compute index
2874
+ * ranges over the RAW transcript message list, whose indices grow
2875
+ * forever — but the live list is trimmed by every persisted marker
2876
+ * (replay after restart) and by each in-process archival, so a raw index
2877
+ * range clamps to an empty window or, worse, onto the WRONG tail
2878
+ * messages, which would then be persistently mis-summarized against
2879
+ * correct anchors. Anchors are the only stable coordinates.
2880
+ * - Without `anchors`, legacy behavior: `range` is a half-open index window
2881
+ * over the cached/in-process message list, and nothing is persisted.
2882
+ */
2883
+ async archiveTurnRange(sessionId, range, anchors) {
2852
2884
  const effectiveSessionId = sessionId || this.lastSessionId;
2853
2885
  if (!effectiveSessionId)
2854
2886
  return { before: 0, after: 0 };
2855
2887
  const session = this.sessionManager.resume(effectiveSessionId);
2856
- const sourceMessages = this.compactedMessagesBySession.get(effectiveSessionId) ?? session.transcript.toMessages();
2888
+ let sourceMessages;
2889
+ let window;
2890
+ if (anchors) {
2891
+ const { messages: liveMessages, liveIndexByClientMessageId } = session.transcript.toMessagesWithIndex();
2892
+ sourceMessages = liveMessages;
2893
+ // An absent from-anchor means "from the beginning" — over the live
2894
+ // list that is index 0, which includes any previously replayed
2895
+ // from-less summary at the head, so summarizeRange merge-feeds it
2896
+ // (extractAnchoredSummary) instead of losing it.
2897
+ const start = anchors.fromClientMessageId !== undefined
2898
+ ? liveIndexByClientMessageId.get(anchors.fromClientMessageId)
2899
+ : 0;
2900
+ const end = liveIndexByClientMessageId.get(anchors.toClientMessageId);
2901
+ if (start === undefined || end === undefined || end <= start) {
2902
+ // Fail open: no summarization, no persistence. Falling back to the
2903
+ // caller's raw range is exactly the bug this path exists to fix —
2904
+ // it would summarize the wrong messages and persist that against
2905
+ // the (correct) anchors, with segmentId dedupe blocking correction.
2906
+ logger.warn("engine.archive_range.anchor_window_unresolved", {
2907
+ sessionId: effectiveSessionId,
2908
+ segmentId: anchors.segmentId,
2909
+ fromClientMessageId: anchors.fromClientMessageId,
2910
+ toClientMessageId: anchors.toClientMessageId,
2911
+ });
2912
+ const before = estimateTokens(sourceMessages);
2913
+ return { before, after: before };
2914
+ }
2915
+ window = { start, end };
2916
+ }
2917
+ else {
2918
+ sourceMessages =
2919
+ this.compactedMessagesBySession.get(effectiveSessionId) ?? session.transcript.toMessages();
2920
+ window = range;
2921
+ }
2857
2922
  const before = estimateTokens(sourceMessages);
2858
2923
  const contextManager = await this.prepareContextManagerForSession(effectiveSessionId, session, sourceMessages, "archive_range");
2859
2924
  // Range archival is initiated deliberately, not by a pressure heuristic;
2860
2925
  // don't let a stale run callback retained on lastContextManager double-emit.
2861
2926
  contextManager.setOnCompact(() => { });
2862
- const archived = await contextManager.summarizeRange(sourceMessages, range);
2927
+ const archived = await contextManager.summarizeRange(sourceMessages, window);
2863
2928
  const after = estimateTokens(archived);
2929
+ // Persist the boundary so a restart replays the trimmed context. Only when
2930
+ // summarizeRange actually replaced the span (identity return means empty
2931
+ // window / rejected summary / no summarizer available) and the caller
2932
+ // supplied stable anchors to record it against.
2933
+ if (anchors && archived !== sourceMessages) {
2934
+ const clampedStart = Math.max(0, Math.min(window.start, archived.length - 1));
2935
+ const summaryMessage = archived[clampedStart];
2936
+ const summaryText = typeof summaryMessage?.content === "string" ? summaryMessage.content : undefined;
2937
+ if (summaryText && this.hasLiveArchiveAnchors(session, anchors)) {
2938
+ session.transcript.appendRangeArchive({ summary: summaryText, ...anchors });
2939
+ }
2940
+ }
2941
+ // In the anchored path this replaces any in-process-only cache state with
2942
+ // the fresh-replay-based result — safe, because the transcript is the
2943
+ // superset of the cache and pressure compaction re-runs if needed.
2864
2944
  this.compactedMessagesBySession.set(effectiveSessionId, archived);
2865
2945
  this.lastSessionId = effectiveSessionId;
2866
2946
  this.lastMessages = archived;
2867
2947
  return { before, after };
2868
2948
  }
2949
+ /**
2950
+ * Guard against writing a marker anchored to a client message id the
2951
+ * transcript doesn't actually contain. toMessages()'s replay silently
2952
+ * ignores a range_archive event whose anchor doesn't resolve (fail open),
2953
+ * so a bad anchor wouldn't corrupt anything on its own — but if the marker
2954
+ * carries a segmentId, appendRangeArchive's idempotency check treats that
2955
+ * segmentId as "already recorded" forever, with no retry path (e.g. a
2956
+ * one-time migration keyed "migration-v1" would be permanently burned on
2957
+ * its first, failed attempt). Reject before writing so the caller can
2958
+ * retry with a corrected anchor. fromClientMessageId is optional in the
2959
+ * data model (absent means "from the beginning"), so only validate it when
2960
+ * present; missing it is fail-open by design, same as the replay path.
2961
+ */
2962
+ hasLiveArchiveAnchors(session, anchors) {
2963
+ if (!session.transcript.hasClientMessageId(anchors.toClientMessageId))
2964
+ return false;
2965
+ if (anchors.fromClientMessageId !== undefined &&
2966
+ !session.transcript.hasClientMessageId(anchors.fromClientMessageId)) {
2967
+ return false;
2968
+ }
2969
+ return true;
2970
+ }
2971
+ /**
2972
+ * Persist an archive boundary WITHOUT a summarization call — the caller
2973
+ * already has the summary text (e.g. the one-time migration built from
2974
+ * pet journal entries). Wraps the plain text in the anchored-summary
2975
+ * envelope so replay and rolling-merge treat it like a real archive.
2976
+ * Returns false when the segmentId was already recorded (idempotent) OR
2977
+ * when the anchors don't resolve to real messages in this transcript —
2978
+ * see hasLiveArchiveAnchors for why a dead anchor must be rejected before
2979
+ * the (potentially one-shot) segmentId gets burned.
2980
+ */
2981
+ async appendArchiveMarker(sessionId, marker) {
2982
+ const session = this.sessionManager.resume(sessionId);
2983
+ if (!this.hasLiveArchiveAnchors(session, marker)) {
2984
+ logger.warn("engine.archive_marker.dead_anchor", {
2985
+ sessionId,
2986
+ segmentId: marker.segmentId,
2987
+ toClientMessageId: marker.toClientMessageId,
2988
+ fromClientMessageId: marker.fromClientMessageId,
2989
+ });
2990
+ return false;
2991
+ }
2992
+ const wrapped = buildAnchoredSummaryMessage(marker.summary, {
2993
+ ...(session.transcript.isPersistent()
2994
+ ? { transcriptPath: session.transcript.getFilePath() }
2995
+ : {}),
2996
+ });
2997
+ const content = typeof wrapped.content === "string" ? wrapped.content : marker.summary;
2998
+ const appended = session.transcript.appendRangeArchive({ ...marker, summary: content });
2999
+ if (!appended)
3000
+ return false;
3001
+ // The in-memory cache (if any) predates the marker; drop it so the next
3002
+ // run rebuilds from the trimmed transcript replay.
3003
+ this.compactedMessagesBySession.delete(sessionId);
3004
+ return true;
3005
+ }
2869
3006
  recordCacheReadDiagnostics(sessionId, sample) {
2870
3007
  const result = this.promptCacheDiagnostics.record(sessionId, sample);
2871
3008
  if (result.kind === "scope_changed") {
@@ -32,6 +32,7 @@ export interface RunPromptComposerConfigInput {
32
32
  disabledPlugins: ComposerOptions["disabledPlugins"];
33
33
  skillAllowlist: ComposerOptions["skillAllowlist"];
34
34
  memoriesMaxAgeDays: ComposerOptions["memoriesMaxAgeDays"];
35
+ memoryCurrentProjectOnly?: ComposerOptions["memoryCurrentProjectOnly"];
35
36
  goalToolState: ComposerOptions["goalToolState"];
36
37
  capabilityPromptSections: ComposerOptions["capabilityPromptSections"];
37
38
  dynamicContextProviders: ComposerOptions["dynamicContextProviders"];
@@ -23,7 +23,7 @@ export function resolveRunProfileState(args) {
23
23
  }
24
24
  /** Build the prompt-composer options for a run without capturing the Engine facade. */
25
25
  export function buildPromptComposerConfig(args) {
26
- const { cwd, model, preset, customSystemPrompt, appendSystemPrompt, responseLanguage, userProfile, workspaceProfile, sessionBrief, profileMemoryDir, instructionCompatFileNames, instructionBoundaryFinder, disabledSkills, disabledPlugins, skillAllowlist, memoriesMaxAgeDays, goalToolState, capabilityPromptSections, dynamicContextProviders, getSettingsManager, toolCatalog, } = args;
26
+ const { cwd, model, preset, customSystemPrompt, appendSystemPrompt, responseLanguage, userProfile, workspaceProfile, sessionBrief, profileMemoryDir, instructionCompatFileNames, instructionBoundaryFinder, disabledSkills, disabledPlugins, skillAllowlist, memoriesMaxAgeDays, memoryCurrentProjectOnly, goalToolState, capabilityPromptSections, dynamicContextProviders, getSettingsManager, toolCatalog, } = args;
27
27
  return {
28
28
  cwd,
29
29
  model,
@@ -49,6 +49,7 @@ export function buildPromptComposerConfig(args) {
49
49
  disabledPlugins,
50
50
  skillAllowlist,
51
51
  memoriesMaxAgeDays,
52
+ memoryCurrentProjectOnly,
52
53
  goalToolState,
53
54
  capabilityPromptSections,
54
55
  dynamicContextProviders,
@@ -32,6 +32,13 @@ export interface RunBehaviorProfile {
32
32
  disablePlanMode?: boolean;
33
33
  /** When true, MCP servers are neither connected nor exposed for the run. */
34
34
  disableMcp?: boolean;
35
+ /**
36
+ * When true, the injected persistent-memory index is trimmed to this run's
37
+ * project: global-layer project-type / dream-scope records tied to other
38
+ * projects are dropped. Meant for manager-style profiles whose runs never
39
+ * work inside other repos.
40
+ */
41
+ memoryCurrentProjectOnly?: boolean;
35
42
  /**
36
43
  * Wrapper tag for host-provided runtime context injected at the system
37
44
  * prompt tail (e.g. "pet-world"). Injection happens only when both this tag
@@ -202,9 +202,6 @@ export declare class TurnLoop {
202
202
  * loop forces a stop so a stuck goal can't loop forever.
203
203
  */
204
204
  private stopBlockCount;
205
- /** Consecutive identical tool-call/result batches, ignoring provider call ids. */
206
- private repeatedToolBatchFingerprint;
207
- private repeatedToolBatchCount;
208
205
  /**
209
206
  * Run-scoped goal budget tracker (Goal mode). Hoisted to an instance field
210
207
  * (not a run() local) so extend() can bump its budgets mid-run. Null when no
@@ -4,7 +4,7 @@
4
4
  * Following Claude Code's po_() pattern:
5
5
  * pre_check → model_call → post_check → tool_exec → context_mgmt → hook_notify → next turn
6
6
  */
7
- import { createHash, randomUUID } from "node:crypto";
7
+ import { randomUUID } from "node:crypto";
8
8
  import { buildAgentDirectionMessage } from "../tool-system/builtin/agent-notifications.js";
9
9
  import { newTurnId } from "./turn-state.js";
10
10
  import { formatFriendlyError } from "./friendly-error.js";
@@ -40,39 +40,6 @@ export function toolResultToBlock(result) {
40
40
  block.is_error = true;
41
41
  return block;
42
42
  }
43
- const REPEATED_TOOL_BATCH_LIMIT = 3;
44
- function canonicalToolValue(value) {
45
- if (value === null)
46
- return "null";
47
- if (value === undefined)
48
- return "undefined";
49
- if (Array.isArray(value))
50
- return `[${value.map(canonicalToolValue).join(",")}]`;
51
- if (typeof value === "object") {
52
- return `{${Object.entries(value)
53
- .sort(([left], [right]) => left.localeCompare(right))
54
- .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalToolValue(entry)}`)
55
- .join(",")}}`;
56
- }
57
- return JSON.stringify(value);
58
- }
59
- function repeatedToolBatchFingerprint(toolCalls, results) {
60
- // Hash immediately and never log the canonical source: tool results may
61
- // contain credentials or large media payloads. Call ids are deliberately
62
- // omitted because providers generate a fresh id for every identical retry.
63
- return createHash("sha256")
64
- .update(canonicalToolValue({
65
- calls: toolCalls.map((call) => ({ toolName: call.toolName, args: call.args })),
66
- results: results.map((result) => ({
67
- toolName: result.toolName,
68
- isError: result.isError === true || Boolean(result.error),
69
- error: result.error,
70
- result: result.result,
71
- contentBlocks: result.contentBlocks,
72
- })),
73
- }))
74
- .digest("hex");
75
- }
76
43
  export class TurnLoop {
77
44
  deps;
78
45
  config;
@@ -109,9 +76,6 @@ export class TurnLoop {
109
76
  * loop forces a stop so a stuck goal can't loop forever.
110
77
  */
111
78
  stopBlockCount = 0;
112
- /** Consecutive identical tool-call/result batches, ignoring provider call ids. */
113
- repeatedToolBatchFingerprint;
114
- repeatedToolBatchCount = 0;
115
79
  /**
116
80
  * Run-scoped goal budget tracker (Goal mode). Hoisted to an instance field
117
81
  * (not a run() local) so extend() can bump its budgets mid-run. Null when no
@@ -1445,49 +1409,6 @@ export class TurnLoop {
1445
1409
  tlog.info("guard.stale_task", { cat: "guard", turn: this.turnCount });
1446
1410
  }
1447
1411
  }
1448
- const toolBatchFingerprint = repeatedToolBatchFingerprint(toolCalls, results);
1449
- if (toolBatchFingerprint === this.repeatedToolBatchFingerprint) {
1450
- this.repeatedToolBatchCount++;
1451
- }
1452
- else {
1453
- this.repeatedToolBatchFingerprint = toolBatchFingerprint;
1454
- this.repeatedToolBatchCount = 1;
1455
- }
1456
- if (this.repeatedToolBatchCount >= REPEATED_TOOL_BATCH_LIMIT) {
1457
- tlog.warn("turn.repeated_tool_batch_stopped", {
1458
- cat: "turn",
1459
- repeatedCount: this.repeatedToolBatchCount,
1460
- tools: toolCalls.map((call) => call.toolName),
1461
- });
1462
- await this.emitHook("on_turn_end", {
1463
- turnNumber: this.turnCount,
1464
- hasToolUse: true,
1465
- toolCallCount: toolCalls.length,
1466
- });
1467
- finalText =
1468
- `检测到同一组工具调用及其结果连续重复 ${REPEATED_TOOL_BATCH_LIMIT} 次,` +
1469
- "已自动停止,避免继续空转。请调整请求或让 Session 获取新的上下文后再试。";
1470
- this.deps.transcript.appendMessage("assistant", finalText);
1471
- messages.push({ role: "assistant", content: finalText });
1472
- this.config.onStream?.({
1473
- type: "assistant_message",
1474
- messageId: assistantMessageId,
1475
- message: { role: "assistant", content: finalText },
1476
- });
1477
- this.finalizeModelTurn();
1478
- if (await this.consumeQueuedSteer(messages, "finalize_backfill")) {
1479
- this.repeatedToolBatchFingerprint = undefined;
1480
- this.repeatedToolBatchCount = 0;
1481
- continue;
1482
- }
1483
- messages = this.redactConsumedSensitiveToolResults(messages);
1484
- return {
1485
- text: finalText,
1486
- reason: "completed",
1487
- messages,
1488
- completionKind: "limit_stop",
1489
- };
1490
- }
1491
1412
  // Hook: turn end
1492
1413
  await this.emitHook("on_turn_end", {
1493
1414
  turnNumber: this.turnCount,
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Public API exports.
5
5
  */
6
- export declare const VERSION = "0.8.6";
6
+ export declare const VERSION = "0.8.8";
7
7
  export type { Message, ContentBlock, ToolDefinition, ToolCall, ToolResult, RegisteredTool, TranscriptEvent, TranscriptEventType, SessionState, SessionKind, SessionWorkspace, SessionForkLineage, ContextUsageAnchor, SessionStatus, TokenUsage, CompiledInput, PermissionDecision, PermissionMode, PermissionRule, TurnPhase, TurnResult, TerminalReason, TurnCompletionKind, StreamEvent, StreamCallback, LLMConfig, ClientDefaults, LLMResponse, Settings, MCPServerConfig, } from "./types.js";
8
8
  export type { GoalConfig, GoalLifecycleConfig, GoalLifecyclePhase, GoalLifecycleTerminalReason, GoalLifecycleV1, } from "./goal/lifecycle.js";
9
9
  export { FrameworkError, LLMError, LLMRateLimitError, ContextLimitError, ToolError, ToolNotFoundError, ToolExecutionError, ToolTimeoutError, PermissionDeniedError, SessionError, TranscriptError, ConfigError, SandboxUnavailableError, } from "./exceptions.js";
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Public API exports.
5
5
  */
6
- export const VERSION = "0.8.6";
6
+ export const VERSION = "0.8.8";
7
7
  // ─── Exceptions ──────────────────────────────────────────────────
8
8
  export { FrameworkError, LLMError, LLMRateLimitError, ContextLimitError, ToolError, ToolNotFoundError, ToolExecutionError, ToolTimeoutError, PermissionDeniedError, SessionError, TranscriptError, ConfigError, SandboxUnavailableError, } from "./exceptions.js";
9
9
  // ─── Engine (primary API) ────────────────────────────────────────