@cjhyy/code-shell-core 0.8.7 → 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.7";
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.7";
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) ────────────────────────────────────────
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  export declare const PANEL_APP_MANIFEST_FILE = ".codeshell-panel/panel.json";
3
- export declare const PANEL_APP_PERMISSIONS: readonly ["context.session", "context.workspace", "storage", "external.open", "agent.submitPrompt", "workspace.info", "workspace.read", "workspace.write", "notifications.send", "credentials.cookies", "automations.manage"];
3
+ export declare const PANEL_APP_PERMISSIONS: readonly ["context.session", "context.workspace", "storage", "external.open", "agent.submitPrompt", "workspace.info", "workspace.read", "workspace.write", "notifications.send", "audio.transcribe", "credentials.cookies", "automations.manage"];
4
4
  export declare const PANEL_APP_ICONS: readonly ["panel", "activity", "bar-chart-3", "chart", "file-text", "globe", "image", "layout-dashboard", "line-chart", "palette", "pie-chart", "table", "terminal"];
5
5
  export declare const PanelAppAgentTool: z.ZodEffects<z.ZodObject<{
6
6
  name: z.ZodString;
@@ -116,12 +116,12 @@ export declare const PanelAppManifest: z.ZodEffects<z.ZodDiscriminatedUnion<"sch
116
116
  icon: z.ZodDefault<z.ZodEnum<["panel", "activity", "bar-chart-3", "chart", "file-text", "globe", "image", "layout-dashboard", "line-chart", "palette", "pie-chart", "table", "terminal"]>>;
117
117
  placement: z.ZodDefault<z.ZodLiteral<"right-dock">>;
118
118
  singleton: z.ZodDefault<z.ZodBoolean>;
119
- permissions: z.ZodDefault<z.ZodArray<z.ZodEnum<["context.session", "context.workspace", "storage", "external.open", "agent.submitPrompt", "workspace.info", "workspace.read", "workspace.write", "notifications.send", "credentials.cookies", "automations.manage"]>, "many">>;
119
+ permissions: z.ZodDefault<z.ZodArray<z.ZodEnum<["context.session", "context.workspace", "storage", "external.open", "agent.submitPrompt", "workspace.info", "workspace.read", "workspace.write", "notifications.send", "audio.transcribe", "credentials.cookies", "automations.manage"]>, "many">>;
120
120
  schemaVersion: z.ZodLiteral<1>;
121
121
  }, "strict", z.ZodTypeAny, {
122
122
  id: string;
123
123
  version: string;
124
- permissions: ("context.session" | "context.workspace" | "storage" | "external.open" | "agent.submitPrompt" | "workspace.info" | "workspace.read" | "workspace.write" | "notifications.send" | "credentials.cookies" | "automations.manage")[];
124
+ permissions: ("context.session" | "context.workspace" | "storage" | "external.open" | "agent.submitPrompt" | "workspace.info" | "workspace.read" | "workspace.write" | "notifications.send" | "audio.transcribe" | "credentials.cookies" | "automations.manage")[];
125
125
  entry: string;
126
126
  title: {
127
127
  default: string;
@@ -144,7 +144,7 @@ export declare const PanelAppManifest: z.ZodEffects<z.ZodDiscriminatedUnion<"sch
144
144
  };
145
145
  schemaVersion: 1;
146
146
  description?: string | undefined;
147
- permissions?: ("context.session" | "context.workspace" | "storage" | "external.open" | "agent.submitPrompt" | "workspace.info" | "workspace.read" | "workspace.write" | "notifications.send" | "credentials.cookies" | "automations.manage")[] | undefined;
147
+ permissions?: ("context.session" | "context.workspace" | "storage" | "external.open" | "agent.submitPrompt" | "workspace.info" | "workspace.read" | "workspace.write" | "notifications.send" | "audio.transcribe" | "credentials.cookies" | "automations.manage")[] | undefined;
148
148
  icon?: "terminal" | "image" | "panel" | "activity" | "bar-chart-3" | "chart" | "file-text" | "globe" | "layout-dashboard" | "line-chart" | "palette" | "pie-chart" | "table" | undefined;
149
149
  placement?: "right-dock" | undefined;
150
150
  singleton?: boolean | undefined;
@@ -230,12 +230,12 @@ export declare const PanelAppManifest: z.ZodEffects<z.ZodDiscriminatedUnion<"sch
230
230
  icon: z.ZodDefault<z.ZodEnum<["panel", "activity", "bar-chart-3", "chart", "file-text", "globe", "image", "layout-dashboard", "line-chart", "palette", "pie-chart", "table", "terminal"]>>;
231
231
  placement: z.ZodDefault<z.ZodLiteral<"right-dock">>;
232
232
  singleton: z.ZodDefault<z.ZodBoolean>;
233
- permissions: z.ZodDefault<z.ZodArray<z.ZodEnum<["context.session", "context.workspace", "storage", "external.open", "agent.submitPrompt", "workspace.info", "workspace.read", "workspace.write", "notifications.send", "credentials.cookies", "automations.manage"]>, "many">>;
233
+ permissions: z.ZodDefault<z.ZodArray<z.ZodEnum<["context.session", "context.workspace", "storage", "external.open", "agent.submitPrompt", "workspace.info", "workspace.read", "workspace.write", "notifications.send", "audio.transcribe", "credentials.cookies", "automations.manage"]>, "many">>;
234
234
  schemaVersion: z.ZodLiteral<2>;
235
235
  }, "strict", z.ZodTypeAny, {
236
236
  id: string;
237
237
  version: string;
238
- permissions: ("context.session" | "context.workspace" | "storage" | "external.open" | "agent.submitPrompt" | "workspace.info" | "workspace.read" | "workspace.write" | "notifications.send" | "credentials.cookies" | "automations.manage")[];
238
+ permissions: ("context.session" | "context.workspace" | "storage" | "external.open" | "agent.submitPrompt" | "workspace.info" | "workspace.read" | "workspace.write" | "notifications.send" | "audio.transcribe" | "credentials.cookies" | "automations.manage")[];
239
239
  entry: string;
240
240
  title: {
241
241
  default: string;
@@ -276,14 +276,14 @@ export declare const PanelAppManifest: z.ZodEffects<z.ZodDiscriminatedUnion<"sch
276
276
  }[] | undefined;
277
277
  } | undefined;
278
278
  description?: string | undefined;
279
- permissions?: ("context.session" | "context.workspace" | "storage" | "external.open" | "agent.submitPrompt" | "workspace.info" | "workspace.read" | "workspace.write" | "notifications.send" | "credentials.cookies" | "automations.manage")[] | undefined;
279
+ permissions?: ("context.session" | "context.workspace" | "storage" | "external.open" | "agent.submitPrompt" | "workspace.info" | "workspace.read" | "workspace.write" | "notifications.send" | "audio.transcribe" | "credentials.cookies" | "automations.manage")[] | undefined;
280
280
  icon?: "terminal" | "image" | "panel" | "activity" | "bar-chart-3" | "chart" | "file-text" | "globe" | "layout-dashboard" | "line-chart" | "palette" | "pie-chart" | "table" | undefined;
281
281
  placement?: "right-dock" | undefined;
282
282
  singleton?: boolean | undefined;
283
283
  }>]>, {
284
284
  id: string;
285
285
  version: string;
286
- permissions: ("context.session" | "context.workspace" | "storage" | "external.open" | "agent.submitPrompt" | "workspace.info" | "workspace.read" | "workspace.write" | "notifications.send" | "credentials.cookies" | "automations.manage")[];
286
+ permissions: ("context.session" | "context.workspace" | "storage" | "external.open" | "agent.submitPrompt" | "workspace.info" | "workspace.read" | "workspace.write" | "notifications.send" | "audio.transcribe" | "credentials.cookies" | "automations.manage")[];
287
287
  entry: string;
288
288
  title: {
289
289
  default: string;
@@ -298,7 +298,7 @@ export declare const PanelAppManifest: z.ZodEffects<z.ZodDiscriminatedUnion<"sch
298
298
  } | {
299
299
  id: string;
300
300
  version: string;
301
- permissions: ("context.session" | "context.workspace" | "storage" | "external.open" | "agent.submitPrompt" | "workspace.info" | "workspace.read" | "workspace.write" | "notifications.send" | "credentials.cookies" | "automations.manage")[];
301
+ permissions: ("context.session" | "context.workspace" | "storage" | "external.open" | "agent.submitPrompt" | "workspace.info" | "workspace.read" | "workspace.write" | "notifications.send" | "audio.transcribe" | "credentials.cookies" | "automations.manage")[];
302
302
  entry: string;
303
303
  title: {
304
304
  default: string;
@@ -330,7 +330,7 @@ export declare const PanelAppManifest: z.ZodEffects<z.ZodDiscriminatedUnion<"sch
330
330
  };
331
331
  schemaVersion: 1;
332
332
  description?: string | undefined;
333
- permissions?: ("context.session" | "context.workspace" | "storage" | "external.open" | "agent.submitPrompt" | "workspace.info" | "workspace.read" | "workspace.write" | "notifications.send" | "credentials.cookies" | "automations.manage")[] | undefined;
333
+ permissions?: ("context.session" | "context.workspace" | "storage" | "external.open" | "agent.submitPrompt" | "workspace.info" | "workspace.read" | "workspace.write" | "notifications.send" | "audio.transcribe" | "credentials.cookies" | "automations.manage")[] | undefined;
334
334
  icon?: "terminal" | "image" | "panel" | "activity" | "bar-chart-3" | "chart" | "file-text" | "globe" | "layout-dashboard" | "line-chart" | "palette" | "pie-chart" | "table" | undefined;
335
335
  placement?: "right-dock" | undefined;
336
336
  singleton?: boolean | undefined;
@@ -354,7 +354,7 @@ export declare const PanelAppManifest: z.ZodEffects<z.ZodDiscriminatedUnion<"sch
354
354
  }[] | undefined;
355
355
  } | undefined;
356
356
  description?: string | undefined;
357
- permissions?: ("context.session" | "context.workspace" | "storage" | "external.open" | "agent.submitPrompt" | "workspace.info" | "workspace.read" | "workspace.write" | "notifications.send" | "credentials.cookies" | "automations.manage")[] | undefined;
357
+ permissions?: ("context.session" | "context.workspace" | "storage" | "external.open" | "agent.submitPrompt" | "workspace.info" | "workspace.read" | "workspace.write" | "notifications.send" | "audio.transcribe" | "credentials.cookies" | "automations.manage")[] | undefined;
358
358
  icon?: "terminal" | "image" | "panel" | "activity" | "bar-chart-3" | "chart" | "file-text" | "globe" | "layout-dashboard" | "line-chart" | "palette" | "pie-chart" | "table" | undefined;
359
359
  placement?: "right-dock" | undefined;
360
360
  singleton?: boolean | undefined;
@@ -11,6 +11,7 @@ export const PANEL_APP_PERMISSIONS = [
11
11
  "workspace.read",
12
12
  "workspace.write",
13
13
  "notifications.send",
14
+ "audio.transcribe",
14
15
  "credentials.cookies",
15
16
  "automations.manage",
16
17
  ];
@@ -180,12 +181,13 @@ export const PanelAppManifest = z
180
181
  });
181
182
  }
182
183
  if ((value.permissions.includes("workspace.read") ||
183
- value.permissions.includes("workspace.write")) &&
184
+ value.permissions.includes("workspace.write") ||
185
+ value.permissions.includes("audio.transcribe")) &&
184
186
  !value.permissions.includes("context.workspace")) {
185
187
  ctx.addIssue({
186
188
  code: z.ZodIssueCode.custom,
187
189
  path: ["permissions"],
188
- message: "workspace.read and workspace.write require context.workspace",
190
+ message: "workspace.read, workspace.write, and audio.transcribe require context.workspace",
189
191
  });
190
192
  }
191
193
  if (value.permissions.includes("automations.manage") &&
@@ -89,6 +89,13 @@ export interface ComposerOptions {
89
89
  * Undefined/0 → inject all.
90
90
  */
91
91
  memoriesMaxAgeDays?: number;
92
+ /**
93
+ * When true (set from the active behavior profile, e.g. the Pet manager),
94
+ * the injected memory index keeps only memories relevant to this cwd —
95
+ * global-layer project/dream records tied to other projects are dropped.
96
+ * See MemoryManager.buildInjectionIndex(currentProjectOnly).
97
+ */
98
+ memoryCurrentProjectOnly?: boolean;
92
99
  }
93
100
  export declare class PromptComposer {
94
101
  private readonly options;
@@ -102,18 +102,24 @@ export class PromptComposer {
102
102
  });
103
103
  const skillsListing = buildSkillListing(skills);
104
104
  const declaredSkillGap = this.buildDeclaredSkillGap(skills);
105
- const capabilityContext = await this.buildSystemContext();
105
+ // Capability and sources context are independent I/O — resolve them
106
+ // concurrently instead of serially.
107
+ const [capabilityContext, sourcesContext] = await Promise.all([
108
+ this.buildSystemContext(),
109
+ (async () => {
110
+ try {
111
+ return (await this.options.sourcesContextProvider?.()) ?? "";
112
+ }
113
+ catch {
114
+ // Optional metadata context must not make a turn fail.
115
+ return "";
116
+ }
117
+ })(),
118
+ ]);
106
119
  // Memory rides here (tail, past the cache breakpoint) — not the system
107
120
  // prefix — so a memory change (extraction / recall usage++ / approve) never
108
121
  // re-bills the cached prefix. See buildUserContextMessage for the rationale.
109
122
  const memoryContext = this.getMemoryContext();
110
- let sourcesContext = "";
111
- try {
112
- sourcesContext = (await this.options.sourcesContextProvider?.()) ?? "";
113
- }
114
- catch {
115
- // Optional metadata context must not make a turn fail.
116
- }
117
123
  const goalToolContext = this.buildGoalToolContext();
118
124
  const parts = [
119
125
  skillsListing,
@@ -268,6 +274,7 @@ export class PromptComposer {
268
274
  projectDir: this.options.cwd,
269
275
  profileDir: this.options.profileMemoryDir,
270
276
  maxAgeDays: this.options.memoriesMaxAgeDays,
277
+ currentProjectOnly: this.options.memoryCurrentProjectOnly,
271
278
  });
272
279
  }
273
280
  catch {
@@ -2240,7 +2240,17 @@ export class AgentServer {
2240
2240
  if (!archiveEngine)
2241
2241
  return;
2242
2242
  try {
2243
- const result = await archiveEngine.archiveTurnRange(archiveSessionId, { start, end });
2243
+ const toClientMessageId = typeof params.toClientMessageId === "string" ? params.toClientMessageId : undefined;
2244
+ const anchors = toClientMessageId
2245
+ ? {
2246
+ toClientMessageId,
2247
+ ...(typeof params.fromClientMessageId === "string"
2248
+ ? { fromClientMessageId: params.fromClientMessageId }
2249
+ : {}),
2250
+ ...(typeof params.segmentId === "string" ? { segmentId: params.segmentId } : {}),
2251
+ }
2252
+ : undefined;
2253
+ const result = await archiveEngine.archiveTurnRange(archiveSessionId, { start, end }, anchors);
2244
2254
  if (result.before > result.after) {
2245
2255
  const event = {
2246
2256
  type: "context_compact",
@@ -2265,6 +2275,35 @@ export class AgentServer {
2265
2275
  }
2266
2276
  break;
2267
2277
  }
2278
+ case "archive_marker": {
2279
+ const markerSessionId = typeof params.sessionId === "string" && params.sessionId.length > 0
2280
+ ? params.sessionId
2281
+ : undefined;
2282
+ const summary = typeof params.summary === "string" ? params.summary : undefined;
2283
+ const toClientMessageId = typeof params.toClientMessageId === "string" ? params.toClientMessageId : undefined;
2284
+ if (!markerSessionId || !summary || !toClientMessageId) {
2285
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InvalidParams, "archive_marker requires sessionId, summary and toClientMessageId"));
2286
+ return;
2287
+ }
2288
+ const markerEngine = await this.resolveEngineForSessionQuery(req, markerSessionId, engine, "archive_marker");
2289
+ if (!markerEngine)
2290
+ return;
2291
+ try {
2292
+ const appended = await markerEngine.appendArchiveMarker(markerSessionId, {
2293
+ summary,
2294
+ toClientMessageId,
2295
+ ...(typeof params.fromClientMessageId === "string"
2296
+ ? { fromClientMessageId: params.fromClientMessageId }
2297
+ : {}),
2298
+ ...(typeof params.segmentId === "string" ? { segmentId: params.segmentId } : {}),
2299
+ });
2300
+ this.transport.send(createResponse(req.id, { type: "archive_marker", data: { appended } }));
2301
+ }
2302
+ catch (err) {
2303
+ this.transport.send(createErrorResponse(req.id, ErrorCodes.InternalError, err.message));
2304
+ }
2305
+ break;
2306
+ }
2268
2307
  case "models": {
2269
2308
  if (!engine) {
2270
2309
  this.transport.send(createErrorResponse(req.id, ErrorCodes.InternalError, "No engine available for models query"));
@@ -342,6 +342,20 @@ export interface QueryParams {
342
342
  start?: unknown;
343
343
  /** Used by archive_range: half-open message-index window end (exclusive). */
344
344
  end?: unknown;
345
+ /**
346
+ * Used by archive_range (optional) and archive_marker (required): the
347
+ * clientMessageId the archived/marked span ends at. archive_range only
348
+ * persists a boundary when this anchor is supplied and resolves to a live
349
+ * message; without it, archive_range behaves exactly as before (in-memory
350
+ * compaction only, no persisted range_archive event).
351
+ */
352
+ toClientMessageId?: string;
353
+ /** Used by archive_range / archive_marker: optional start-of-span anchor. */
354
+ fromClientMessageId?: string;
355
+ /** Used by archive_range / archive_marker: optional idempotency key. */
356
+ segmentId?: string;
357
+ /** Used by archive_marker: the summary text to persist without a model call. */
358
+ summary?: string;
345
359
  }
346
360
  export interface QueryResult {
347
361
  type: string;
@@ -233,6 +233,16 @@ export declare class MemoryManager {
233
233
  baseDir?: string;
234
234
  maxAgeDays?: number;
235
235
  now?: number;
236
+ /**
237
+ * Trim the global layer to what is relevant to this run's project:
238
+ * project-type and dream-scope records are per-project experience, so
239
+ * they are kept only when their origin metadata ties them to
240
+ * `projectDir`. General knowledge (user/feedback/reference in user
241
+ * scope) always stays; the project layer is untouched because it is
242
+ * already this project's own store. Set by behavior profiles whose runs
243
+ * never work inside other repos (e.g. a manager/dispatcher session).
244
+ */
245
+ currentProjectOnly?: boolean;
236
246
  }): string;
237
247
  /**
238
248
  * Load every entry belonging to the given scope, without changing the
@@ -651,7 +651,13 @@ export class MemoryManager {
651
651
  const profile = opts.profileDir ? new MemoryManager({ baseDir: opts.profileDir }) : null;
652
652
  const pinnedFirst = (a, b) => Number(b.pinned ?? false) - Number(a.pinned ?? false);
653
653
  const collect = (mm) => filterByAge([...mm.loadScope("user"), ...mm.loadScope("dream")], opts.maxAgeDays, opts.now).sort(pinnedFirst);
654
- const globalEntries = collect(global);
654
+ let globalEntries = collect(global);
655
+ if (opts.currentProjectOnly) {
656
+ const tiedToProject = (e) => opts.projectDir !== undefined &&
657
+ (e.originProject === opts.projectDir ||
658
+ (e.originProjects?.includes(opts.projectDir) ?? false));
659
+ globalEntries = globalEntries.filter((e) => (e.type !== "project" && e.scope !== "dream") || tiedToProject(e));
660
+ }
655
661
  const profileEntries = profile ? collect(profile) : [];
656
662
  const projectEntries = project ? collect(project) : [];
657
663
  if (globalEntries.length === 0 && profileEntries.length === 0 && projectEntries.length === 0) {
@@ -154,6 +154,7 @@ const FORK_COPY_EVENT_TYPES = new Set([
154
154
  "tool_result",
155
155
  "summary",
156
156
  "context_transfer",
157
+ "range_archive",
157
158
  "content_replace",
158
159
  "subagent",
159
160
  "external_file_changes",
@@ -106,12 +106,48 @@ export declare class Transcript {
106
106
  */
107
107
  appendTurnStopped(): TranscriptEvent | undefined;
108
108
  appendSummary(summary: string, metadata: SummaryAppendMetadata): TranscriptEvent;
109
+ /**
110
+ * Persist a range-archival boundary. Span is [fromClientMessageId,
111
+ * toClientMessageId) over message events; an absent from means "from the
112
+ * beginning". Idempotent on segmentId so a crash-replayed closure cannot
113
+ * double-archive.
114
+ */
115
+ appendRangeArchive(data: {
116
+ summary: string;
117
+ toClientMessageId: string;
118
+ fromClientMessageId?: string;
119
+ segmentId?: string;
120
+ }): TranscriptEvent | undefined;
109
121
  appendError(error: string, details?: Record<string, unknown>): TranscriptEvent;
110
122
  /**
111
123
  * Derive Message[] from transcript events for sending to the LLM.
112
124
  * This is the critical boundary: the LLM never sees the event log directly.
113
125
  */
114
126
  toMessages(): Message[];
127
+ /**
128
+ * Marker-aware replay that ALSO reports, for every emitted message event
129
+ * carrying a clientMessageId, the LIVE index (its position in the returned
130
+ * messages array) of its FIRST emission. This is how the engine resolves an
131
+ * anchored archival window: raw transcript indices grow forever and go stale
132
+ * the moment a range_archive marker trims the replay, so any caller-held
133
+ * index range is meaningless — only client-message-id anchors resolved over
134
+ * THIS replay identify the right span.
135
+ *
136
+ * Contract details:
137
+ * - Messages dropped inside an archived span get NO index entry — they are
138
+ * not in the live list, so a window anchored on them cannot be built
139
+ * (the engine fails open in that case).
140
+ * - Only the FIRST emission of a duplicated clientMessageId is recorded
141
+ * (duplicates replay as plain messages per the one-shot span rule; the
142
+ * first index is the meaningful one).
143
+ * - The clientMessageId is deliberately NOT attached to the Message objects
144
+ * themselves: Message[] is exactly what gets serialized into LLM request
145
+ * payloads, and transport metadata must not leak into them.
146
+ */
147
+ toMessagesWithIndex(): {
148
+ messages: Message[];
149
+ liveIndexByClientMessageId: Map<string, number>;
150
+ };
115
151
  getEvents(type?: TranscriptEventType): TranscriptEvent[];
116
152
  get turnNumber(): number;
117
153
  get eventCount(): number;
@@ -12,6 +12,7 @@ const CONTEXT_EVENT_TYPES = new Set([
12
12
  "tool_result",
13
13
  "summary",
14
14
  "context_transfer",
15
+ "range_archive",
15
16
  ]);
16
17
  const INTERRUPTED_TOOL_RESULT_ERROR = "[Tool result missing due to interrupted session]";
17
18
  function isSyntheticInterruptedToolResult(event) {
@@ -212,6 +213,19 @@ export class Transcript {
212
213
  },
213
214
  });
214
215
  }
216
+ /**
217
+ * Persist a range-archival boundary. Span is [fromClientMessageId,
218
+ * toClientMessageId) over message events; an absent from means "from the
219
+ * beginning". Idempotent on segmentId so a crash-replayed closure cannot
220
+ * double-archive.
221
+ */
222
+ appendRangeArchive(data) {
223
+ if (data.segmentId &&
224
+ this.events.some((e) => e.type === "range_archive" && e.data.segmentId === data.segmentId)) {
225
+ return undefined;
226
+ }
227
+ return this.append("range_archive", { ...data });
228
+ }
215
229
  appendError(error, details) {
216
230
  return this.append("error", { error, ...details });
217
231
  }
@@ -220,12 +234,128 @@ export class Transcript {
220
234
  * This is the critical boundary: the LLM never sees the event log directly.
221
235
  */
222
236
  toMessages() {
237
+ return this.toMessagesWithIndex().messages;
238
+ }
239
+ /**
240
+ * Marker-aware replay that ALSO reports, for every emitted message event
241
+ * carrying a clientMessageId, the LIVE index (its position in the returned
242
+ * messages array) of its FIRST emission. This is how the engine resolves an
243
+ * anchored archival window: raw transcript indices grow forever and go stale
244
+ * the moment a range_archive marker trims the replay, so any caller-held
245
+ * index range is meaningless — only client-message-id anchors resolved over
246
+ * THIS replay identify the right span.
247
+ *
248
+ * Contract details:
249
+ * - Messages dropped inside an archived span get NO index entry — they are
250
+ * not in the live list, so a window anchored on them cannot be built
251
+ * (the engine fails open in that case).
252
+ * - Only the FIRST emission of a duplicated clientMessageId is recorded
253
+ * (duplicates replay as plain messages per the one-shot span rule; the
254
+ * first index is the meaningful one).
255
+ * - The clientMessageId is deliberately NOT attached to the Message objects
256
+ * themselves: Message[] is exactly what gets serialized into LLM request
257
+ * payloads, and transport metadata must not leak into them.
258
+ */
259
+ toMessagesWithIndex() {
223
260
  const messages = [];
261
+ const liveIndexByClientMessageId = new Map();
224
262
  const selectedToolResults = preferredToolResults(this.events);
263
+ const hasRangeArchive = this.events.some((e) => e.type === "range_archive");
264
+ const spansByFromId = new Map();
265
+ let openingSpan;
266
+ if (hasRangeArchive) {
267
+ // First-occurrence event index per client message id, so a marker whose
268
+ // `to` does not come strictly after its `from` (out of order, or a
269
+ // degenerate from === to) can be rejected. Without this check the span
270
+ // opens at `from` but its close condition (`to`) was already passed
271
+ // while scanning forward, so it would never close — silently swallowing
272
+ // the rest of the conversation. Fail open instead: ignore the marker.
273
+ const firstIndexByClientId = new Map();
274
+ for (const [index, event] of this.events.entries()) {
275
+ if (event.type === "message" && typeof event.data.clientMessageId === "string") {
276
+ if (!firstIndexByClientId.has(event.data.clientMessageId)) {
277
+ firstIndexByClientId.set(event.data.clientMessageId, index);
278
+ }
279
+ }
280
+ }
281
+ const presentClientIds = new Set(firstIndexByClientId.keys());
282
+ for (const event of this.events) {
283
+ if (event.type !== "range_archive")
284
+ continue;
285
+ const { summary, toClientMessageId, fromClientMessageId } = event.data;
286
+ if (typeof summary !== "string" || !presentClientIds.has(toClientMessageId))
287
+ continue;
288
+ if (fromClientMessageId === undefined) {
289
+ // Multiple from-less markers compete for this single opening-span
290
+ // slot; the LAST one wins (matching spansByFromId's Map.set
291
+ // semantics below). Last-wins is CORRECT by construction, not
292
+ // merely a tiebreak: the engine resolves a from-less archival
293
+ // window as [0, to) over the LIVE replay, so the window that
294
+ // produced a LATER from-less marker began with the EARLIER
295
+ // marker's replayed summary message, and summarizeRange merge-fed
296
+ // that prior summary (extractAnchoredSummary) into the new one.
297
+ // The later summary therefore already contains the earlier one's
298
+ // content — dropping the earlier marker here loses nothing. (And
299
+ // in production from-less windows only advance: each new marker
300
+ // ends at a later boundary, so the surviving span is the widest.)
301
+ openingSpan = { summary, toClientMessageId };
302
+ }
303
+ else if (presentClientIds.has(fromClientMessageId)) {
304
+ const fromIndex = firstIndexByClientId.get(fromClientMessageId);
305
+ const toIndex = firstIndexByClientId.get(toClientMessageId);
306
+ if (toIndex <= fromIndex)
307
+ continue; // out of order or degenerate: fail open
308
+ spansByFromId.set(fromClientMessageId, { summary, toClientMessageId });
309
+ }
310
+ }
311
+ }
312
+ let activeSpan = null;
313
+ if (openingSpan) {
314
+ activeSpan = openingSpan;
315
+ messages.push({ role: "user", content: openingSpan.summary });
316
+ }
317
+ // tool_use ids actually emitted into assistant message content blocks so
318
+ // far. A tool_result whose tool_use_id isn't in this set — e.g. because
319
+ // its opening tool_use fell inside an archived span while the (later,
320
+ // preferred) real result landed outside it — would be an orphaned block
321
+ // that breaks provider validation; skip it instead of emitting it.
322
+ const emittedToolUseIds = new Set();
225
323
  for (const event of this.events) {
324
+ // Span bookkeeping runs on message events only: exit before entry so
325
+ // adjacent spans (A.to === B.from) hand over on the boundary message.
326
+ if (event.type === "message") {
327
+ const clientMessageId = typeof event.data.clientMessageId === "string" ? event.data.clientMessageId : undefined;
328
+ if (activeSpan && clientMessageId === activeSpan.toClientMessageId) {
329
+ activeSpan = null;
330
+ }
331
+ if (!activeSpan && clientMessageId && spansByFromId.has(clientMessageId)) {
332
+ activeSpan = spansByFromId.get(clientMessageId);
333
+ // One-shot: a duplicate `from` message (e.g. from a torn JSONL
334
+ // reload) must not reopen this span a second time — it would have
335
+ // no more `to` ahead of it and swallow the rest of the transcript.
336
+ spansByFromId.delete(clientMessageId);
337
+ messages.push({ role: "user", content: activeSpan.summary });
338
+ }
339
+ }
340
+ if (activeSpan)
341
+ continue; // archived span: drop every context event inside
226
342
  switch (event.type) {
227
343
  case "message": {
228
- const { role, content } = event.data;
344
+ const { role, content, clientMessageId } = event.data;
345
+ // Record the live index of this message's FIRST emission before
346
+ // pushing it (the index it is about to occupy). Dropped-in-span
347
+ // messages never reach this point, so they get no entry.
348
+ if (typeof clientMessageId === "string" &&
349
+ !liveIndexByClientMessageId.has(clientMessageId)) {
350
+ liveIndexByClientMessageId.set(clientMessageId, messages.length);
351
+ }
352
+ if (role === "assistant" && Array.isArray(content)) {
353
+ for (const block of content) {
354
+ if (block.type === "tool_use" && typeof block.id === "string") {
355
+ emittedToolUseIds.add(block.id);
356
+ }
357
+ }
358
+ }
229
359
  messages.push({ role: role, content });
230
360
  break;
231
361
  }
@@ -237,7 +367,8 @@ export class Transcript {
237
367
  case "tool_result": {
238
368
  const eventToolCallId = event.data.toolCallId;
239
369
  if (typeof eventToolCallId !== "string" ||
240
- selectedToolResults.get(eventToolCallId) !== event) {
370
+ selectedToolResults.get(eventToolCallId) !== event ||
371
+ !emittedToolUseIds.has(eventToolCallId)) {
241
372
  break;
242
373
  }
243
374
  const { toolCallId, result, error, contentBlocks } = event.data;
@@ -280,10 +411,12 @@ export class Transcript {
280
411
  break;
281
412
  }
282
413
  // turn_boundary, run_result, session_meta, file_history, plan_operation, error
283
- // are not included in LLM messages
414
+ // are not included in LLM messages. range_archive is handled entirely
415
+ // by the pre-pass above (it emits the summary at span entry and drops
416
+ // events inside the span); it never falls through to this switch.
284
417
  }
285
418
  }
286
- return messages;
419
+ return { messages, liveIndexByClientMessageId };
287
420
  }
288
421
  getEvents(type) {
289
422
  if (!type)
@@ -448,6 +581,18 @@ export class Transcript {
448
581
  });
449
582
  break;
450
583
  }
584
+ case "range_archive": {
585
+ // A hand-picked context range is an explicit user selection: inject
586
+ // the archive summary as context but do NOT replace/drop the
587
+ // messages inside its span the way toMessages() does — the caller
588
+ // asked for exactly this range and expects to see it in full.
589
+ const { summary } = event.data;
590
+ messages.push({
591
+ role: "user",
592
+ content: `<system-reminder>Archived summary for part of this range:\n${summary}</system-reminder>`,
593
+ });
594
+ break;
595
+ }
451
596
  }
452
597
  }
453
598
  return messages;
package/dist/types.d.ts CHANGED
@@ -145,7 +145,7 @@ export interface RegisteredTool {
145
145
  */
146
146
  timeoutMs?: number;
147
147
  }
148
- export type TranscriptEventType = "message" | "tool_use" | "tool_result" | "summary" | "context_transfer" | "content_replace" | "file_history" | "plan_operation" | "session_meta" | "subagent" | "external_file_changes" | "turn_boundary" | "run_result" | "goal_progress" | "turn_stopped" | "error";
148
+ export type TranscriptEventType = "message" | "tool_use" | "tool_result" | "summary" | "context_transfer" | "range_archive" | "content_replace" | "file_history" | "plan_operation" | "session_meta" | "subagent" | "external_file_changes" | "turn_boundary" | "run_result" | "goal_progress" | "turn_stopped" | "error";
149
149
  export interface TranscriptEvent {
150
150
  id: string;
151
151
  type: TranscriptEventType;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cjhyy/code-shell-core",
3
- "version": "0.8.7",
3
+ "version": "0.8.8",
4
4
  "description": "Core engine for code-shell — agent orchestration, tool execution, hooks, protocol. UI-agnostic.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",