switchroom 0.21.16 → 0.21.17

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.
@@ -11144,6 +11144,10 @@ var init_schema = __esm(() => {
11144
11144
  }).optional().describe("Operator-declared, per-specialist Hindsight mental models (RFC " + "Phase 5). Named, opt-in curated reflections this agent's bank should " + "carry — e.g. a coach's 'training-plan-state' or a lawyer's " + "'open-matters'. Ensured idempotently at scaffold/reconcile: NOTHING " + "is created unless declared here (zero declarations = zero models, " + "matching post-#2447 behaviour), and no fixed identity model is " + "reintroduced — 'who the user is' stays owned by dedicated profile " + "banks (users.*.profile_bank), never a per-agent model. Per-agent " + "ONLY: intentionally not accepted at the defaults/profile tier, so a " + "model can never be fleet-seeded — each specialist opts in on its own " + "(the invariant-clean inverse of the retired blind auto-seeding)."),
11145
11145
  observations_mission: exports_external.string().optional().describe("Steers what the observation-consolidation LLM synthesises from raw " + "facts (the higher-order 'what patterns matter' lens). Cascade: override."),
11146
11146
  rules_block: exports_external.boolean().default(false).describe("Memory v2 M3 go-live flag (M1 only DEFINES this, default false; " + "M3 flips it per agent). When true, the sanctioned rules/index " + "blocks render live in this agent's CLAUDE.md (marker-delimited, " + "below the `# --- Yours ---` line) AND the permission deny for " + "direct Edit/Write of the agent's own CLAUDE.md is seeded — the " + "flag couples deny + tools together so a live deny never orphans " + "the invited free-text edit path (the `memory_edit_yours` verb is " + "the only sanctioned writer once flipped). Unset/false ⇒ byte-" + "identical to pre-M1 behaviour: no blocks, no deny, dark build. " + "Cascade: override (per-agent wins over default; never fleet-" + "seeded — each agent's flip is a deliberate M3 rollout step)."),
11147
+ orientation: exports_external.boolean().optional().describe("Memory v2 M5 go-live flag (Surface B: orientation-at-boot), " + "effective default false — dark build (unset ⇒ OFF, applied by the " + "scaffold resolver, NOT a Zod .default() — a hard default here would " + "shadow the per-agent value in the cascade the way it does every other " + "memory knob). When true, the orientation SessionStart " + "hook injects this agent's cron-refreshed `orientation` mental model " + "into context at boot AND re-seats it after every compaction (E-88), " + "deterministically and with zero tool call. Unset/false ⇒ byte-" + "identical to pre-M5 behaviour: the hook no-ops before any bank " + "resolve or network call. Per-agent ONLY (never fleet-seeded — a " + "flip is a deliberate canary/rollout step gated on the agent's " + "m2-residue measurement + operator-created model, carve-M5 §7). " + "Cascade: override (per-agent wins over default)."),
11148
+ orientation_reinject_turns: exports_external.number().int().min(0).optional().describe("Memory v2 M5 optional per-turn re-inject cadence, effective default " + "0 = off (unset ⇒ 0, applied by the scaffold resolver, not a Zod " + ".default() — same cascade-shadowing reason as `orientation`). At 0 the " + "briefing is injected only at SessionStart + compaction (the cheap " + "path). N>0 " + "would additionally re-inject the orientation briefing every N turns; " + "priced at ~55M tok/30d at every-turn, which is WHY it defaults off " + "(carve-M5 §2/§4). Only late-session context loss should ever set it, " + "per-agent, measured. A stripped/absent value fails to 0 (fail-safe " + "AND fail-cheap). Per-agent ONLY. Cascade: override."),
11149
+ orientation_cadence_hours: exports_external.number().int().min(1).optional().describe("Memory v2 M5 per-agent refresh cadence tier (hours), effective " + "default 48 (unset ⇒ 48, applied by the scaffold resolver, not a Zod " + ".default() — a hard default would shadow a `defaults.memory." + "orientation_cadence_hours` fleet value in the cascade). " + "The fast-churning agents (klanker, overlord) set 24; everyone else " + "inherits 48 (carve-M5 §6, Ken's locked tiered cadence). Drives BOTH " + "the out-of-session refresh scheduler AND the staleness guard's " + "per-tier thresholds (stale prefix at 1.5× cadence, cold degrade at " + "3×) — so a fixed 36h can never mislabel a 48h-tier agent as stale a " + "day early. Modelled as an explicit int (not a hard-coded name list) " + "so the tiering is data-driven and auditable. A cost knob, not an " + "enablement knob: a stripped/absent value failing to 48 is safe " + "(the cheaper tier). Cascade: override (per-agent wins over default); " + "unlike `orientation`/`_reinject_turns` it IS accepted at the " + "defaults/profile tier."),
11150
+ inject_directives: exports_external.boolean().optional().describe("Memory v2 M3 Surface-A directive-injection switch (default TRUE — " + "on-by-default via the plugin settings.json stamp + config.py " + "DEFAULTS). When left unset the agent keeps injecting the " + "`<active_directives>` block on every turn, byte-identical to pre-M3. " + "Set FALSE to SUPPRESS that injection for this agent — its standing " + "rules are then served from the live rules block (memory.rules_block) " + "instead of re-injected directives, which is the change that collapses " + "the always-on directive-token spend (E-41). The suppression is " + "fail-safe and re-checked every turn in recall.py: it fires ONLY when " + "a non-empty rules block is physically present in the agent's " + "CLAUDE.md — a false flag with an empty/absent block keeps injecting " + "AND emits a degraded-canary notice, so a zero-standing-rules turn is " + "unreachable. Requires memory.rules_block=true first (the block must " + "be live before injection is turned off — enforce the ordering with " + "`switchroom memory flip`, never a bare flag flip). Coordinates with " + "M4's memory.recall prefetch (memoryPrefetchEnabled): the same guard " + "gates all three recall.py emit sites so a flipped agent leaks no " + "directive on a cache/prefetch hit. Cascade: override, per-agent ONLY " + "(never fleet-seeded from the defaults/profile tier — each flip is a " + "deliberate staggered M3 rollout step, same darkness safeguard as " + "rules_block). Default-true is the fail-safe direction under the " + "Zod-strip footgun: a stripped/misread key keeps the agent injecting."),
11147
11151
  disposition: exports_external.object({
11148
11152
  skepticism: exports_external.number().int().min(1).max(5).optional().describe("How much the bank doubts unverified claims (1-5; engine default 3)."),
11149
11153
  literalism: exports_external.number().int().min(1).max(5).optional().describe("How literally the bank reads statements vs inferring intent (1-5; engine default 3)."),
@@ -11589,6 +11593,7 @@ var init_schema = __esm(() => {
11589
11593
  profile: exports_external.string().optional(),
11590
11594
  directive_capture_nudge: exports_external.boolean().optional(),
11591
11595
  profile_capture_nudge: exports_external.boolean().optional(),
11596
+ orientation_cadence_hours: exports_external.number().int().min(1).optional(),
11592
11597
  anti_confabulation_directive: AntiConfabulationDirectiveSchema,
11593
11598
  observation_scopes: ObservationScopesSchema,
11594
11599
  observation_scope_strategy: ObservationScopeStrategySchema,
@@ -21593,7 +21598,7 @@ function allocateAgentUid(name) {
21593
21598
  }
21594
21599
 
21595
21600
  // src/build-info.ts
21596
- var VERSION = "0.21.16";
21601
+ var VERSION = "0.21.17";
21597
21602
 
21598
21603
  // src/setup/hindsight-recall-passthrough.ts
21599
21604
  var HINDSIGHT_RECALL_TAG_WEIGHT_SEED = Object.freeze({ sidechain: 0.8 });
@@ -4584,6 +4584,10 @@ var init_schema = __esm(() => {
4584
4584
  }).optional().describe("Operator-declared, per-specialist Hindsight mental models (RFC " + "Phase 5). Named, opt-in curated reflections this agent's bank should " + "carry — e.g. a coach's 'training-plan-state' or a lawyer's " + "'open-matters'. Ensured idempotently at scaffold/reconcile: NOTHING " + "is created unless declared here (zero declarations = zero models, " + "matching post-#2447 behaviour), and no fixed identity model is " + "reintroduced — 'who the user is' stays owned by dedicated profile " + "banks (users.*.profile_bank), never a per-agent model. Per-agent " + "ONLY: intentionally not accepted at the defaults/profile tier, so a " + "model can never be fleet-seeded — each specialist opts in on its own " + "(the invariant-clean inverse of the retired blind auto-seeding)."),
4585
4585
  observations_mission: exports_external.string().optional().describe("Steers what the observation-consolidation LLM synthesises from raw " + "facts (the higher-order 'what patterns matter' lens). Cascade: override."),
4586
4586
  rules_block: exports_external.boolean().default(false).describe("Memory v2 M3 go-live flag (M1 only DEFINES this, default false; " + "M3 flips it per agent). When true, the sanctioned rules/index " + "blocks render live in this agent's CLAUDE.md (marker-delimited, " + "below the `# --- Yours ---` line) AND the permission deny for " + "direct Edit/Write of the agent's own CLAUDE.md is seeded — the " + "flag couples deny + tools together so a live deny never orphans " + "the invited free-text edit path (the `memory_edit_yours` verb is " + "the only sanctioned writer once flipped). Unset/false ⇒ byte-" + "identical to pre-M1 behaviour: no blocks, no deny, dark build. " + "Cascade: override (per-agent wins over default; never fleet-" + "seeded — each agent's flip is a deliberate M3 rollout step)."),
4587
+ orientation: exports_external.boolean().optional().describe("Memory v2 M5 go-live flag (Surface B: orientation-at-boot), " + "effective default false — dark build (unset ⇒ OFF, applied by the " + "scaffold resolver, NOT a Zod .default() — a hard default here would " + "shadow the per-agent value in the cascade the way it does every other " + "memory knob). When true, the orientation SessionStart " + "hook injects this agent's cron-refreshed `orientation` mental model " + "into context at boot AND re-seats it after every compaction (E-88), " + "deterministically and with zero tool call. Unset/false ⇒ byte-" + "identical to pre-M5 behaviour: the hook no-ops before any bank " + "resolve or network call. Per-agent ONLY (never fleet-seeded — a " + "flip is a deliberate canary/rollout step gated on the agent's " + "m2-residue measurement + operator-created model, carve-M5 §7). " + "Cascade: override (per-agent wins over default)."),
4588
+ orientation_reinject_turns: exports_external.number().int().min(0).optional().describe("Memory v2 M5 optional per-turn re-inject cadence, effective default " + "0 = off (unset ⇒ 0, applied by the scaffold resolver, not a Zod " + ".default() — same cascade-shadowing reason as `orientation`). At 0 the " + "briefing is injected only at SessionStart + compaction (the cheap " + "path). N>0 " + "would additionally re-inject the orientation briefing every N turns; " + "priced at ~55M tok/30d at every-turn, which is WHY it defaults off " + "(carve-M5 §2/§4). Only late-session context loss should ever set it, " + "per-agent, measured. A stripped/absent value fails to 0 (fail-safe " + "AND fail-cheap). Per-agent ONLY. Cascade: override."),
4589
+ orientation_cadence_hours: exports_external.number().int().min(1).optional().describe("Memory v2 M5 per-agent refresh cadence tier (hours), effective " + "default 48 (unset ⇒ 48, applied by the scaffold resolver, not a Zod " + ".default() — a hard default would shadow a `defaults.memory." + "orientation_cadence_hours` fleet value in the cascade). " + "The fast-churning agents (klanker, overlord) set 24; everyone else " + "inherits 48 (carve-M5 §6, Ken's locked tiered cadence). Drives BOTH " + "the out-of-session refresh scheduler AND the staleness guard's " + "per-tier thresholds (stale prefix at 1.5× cadence, cold degrade at " + "3×) — so a fixed 36h can never mislabel a 48h-tier agent as stale a " + "day early. Modelled as an explicit int (not a hard-coded name list) " + "so the tiering is data-driven and auditable. A cost knob, not an " + "enablement knob: a stripped/absent value failing to 48 is safe " + "(the cheaper tier). Cascade: override (per-agent wins over default); " + "unlike `orientation`/`_reinject_turns` it IS accepted at the " + "defaults/profile tier."),
4590
+ inject_directives: exports_external.boolean().optional().describe("Memory v2 M3 Surface-A directive-injection switch (default TRUE — " + "on-by-default via the plugin settings.json stamp + config.py " + "DEFAULTS). When left unset the agent keeps injecting the " + "`<active_directives>` block on every turn, byte-identical to pre-M3. " + "Set FALSE to SUPPRESS that injection for this agent — its standing " + "rules are then served from the live rules block (memory.rules_block) " + "instead of re-injected directives, which is the change that collapses " + "the always-on directive-token spend (E-41). The suppression is " + "fail-safe and re-checked every turn in recall.py: it fires ONLY when " + "a non-empty rules block is physically present in the agent's " + "CLAUDE.md — a false flag with an empty/absent block keeps injecting " + "AND emits a degraded-canary notice, so a zero-standing-rules turn is " + "unreachable. Requires memory.rules_block=true first (the block must " + "be live before injection is turned off — enforce the ordering with " + "`switchroom memory flip`, never a bare flag flip). Coordinates with " + "M4's memory.recall prefetch (memoryPrefetchEnabled): the same guard " + "gates all three recall.py emit sites so a flipped agent leaks no " + "directive on a cache/prefetch hit. Cascade: override, per-agent ONLY " + "(never fleet-seeded from the defaults/profile tier — each flip is a " + "deliberate staggered M3 rollout step, same darkness safeguard as " + "rules_block). Default-true is the fail-safe direction under the " + "Zod-strip footgun: a stripped/misread key keeps the agent injecting."),
4587
4591
  disposition: exports_external.object({
4588
4592
  skepticism: exports_external.number().int().min(1).max(5).optional().describe("How much the bank doubts unverified claims (1-5; engine default 3)."),
4589
4593
  literalism: exports_external.number().int().min(1).max(5).optional().describe("How literally the bank reads statements vs inferring intent (1-5; engine default 3)."),
@@ -5029,6 +5033,7 @@ var init_schema = __esm(() => {
5029
5033
  profile: exports_external.string().optional(),
5030
5034
  directive_capture_nudge: exports_external.boolean().optional(),
5031
5035
  profile_capture_nudge: exports_external.boolean().optional(),
5036
+ orientation_cadence_hours: exports_external.number().int().min(1).optional(),
5032
5037
  anti_confabulation_directive: AntiConfabulationDirectiveSchema,
5033
5038
  observation_scopes: ObservationScopesSchema,
5034
5039
  observation_scope_strategy: ObservationScopeStrategySchema,
@@ -4180,6 +4180,10 @@ var init_schema = __esm(() => {
4180
4180
  }).optional().describe("Operator-declared, per-specialist Hindsight mental models (RFC " + "Phase 5). Named, opt-in curated reflections this agent's bank should " + "carry — e.g. a coach's 'training-plan-state' or a lawyer's " + "'open-matters'. Ensured idempotently at scaffold/reconcile: NOTHING " + "is created unless declared here (zero declarations = zero models, " + "matching post-#2447 behaviour), and no fixed identity model is " + "reintroduced — 'who the user is' stays owned by dedicated profile " + "banks (users.*.profile_bank), never a per-agent model. Per-agent " + "ONLY: intentionally not accepted at the defaults/profile tier, so a " + "model can never be fleet-seeded — each specialist opts in on its own " + "(the invariant-clean inverse of the retired blind auto-seeding)."),
4181
4181
  observations_mission: exports_external.string().optional().describe("Steers what the observation-consolidation LLM synthesises from raw " + "facts (the higher-order 'what patterns matter' lens). Cascade: override."),
4182
4182
  rules_block: exports_external.boolean().default(false).describe("Memory v2 M3 go-live flag (M1 only DEFINES this, default false; " + "M3 flips it per agent). When true, the sanctioned rules/index " + "blocks render live in this agent's CLAUDE.md (marker-delimited, " + "below the `# --- Yours ---` line) AND the permission deny for " + "direct Edit/Write of the agent's own CLAUDE.md is seeded — the " + "flag couples deny + tools together so a live deny never orphans " + "the invited free-text edit path (the `memory_edit_yours` verb is " + "the only sanctioned writer once flipped). Unset/false ⇒ byte-" + "identical to pre-M1 behaviour: no blocks, no deny, dark build. " + "Cascade: override (per-agent wins over default; never fleet-" + "seeded — each agent's flip is a deliberate M3 rollout step)."),
4183
+ orientation: exports_external.boolean().optional().describe("Memory v2 M5 go-live flag (Surface B: orientation-at-boot), " + "effective default false — dark build (unset ⇒ OFF, applied by the " + "scaffold resolver, NOT a Zod .default() — a hard default here would " + "shadow the per-agent value in the cascade the way it does every other " + "memory knob). When true, the orientation SessionStart " + "hook injects this agent's cron-refreshed `orientation` mental model " + "into context at boot AND re-seats it after every compaction (E-88), " + "deterministically and with zero tool call. Unset/false ⇒ byte-" + "identical to pre-M5 behaviour: the hook no-ops before any bank " + "resolve or network call. Per-agent ONLY (never fleet-seeded — a " + "flip is a deliberate canary/rollout step gated on the agent's " + "m2-residue measurement + operator-created model, carve-M5 §7). " + "Cascade: override (per-agent wins over default)."),
4184
+ orientation_reinject_turns: exports_external.number().int().min(0).optional().describe("Memory v2 M5 optional per-turn re-inject cadence, effective default " + "0 = off (unset ⇒ 0, applied by the scaffold resolver, not a Zod " + ".default() — same cascade-shadowing reason as `orientation`). At 0 the " + "briefing is injected only at SessionStart + compaction (the cheap " + "path). N>0 " + "would additionally re-inject the orientation briefing every N turns; " + "priced at ~55M tok/30d at every-turn, which is WHY it defaults off " + "(carve-M5 §2/§4). Only late-session context loss should ever set it, " + "per-agent, measured. A stripped/absent value fails to 0 (fail-safe " + "AND fail-cheap). Per-agent ONLY. Cascade: override."),
4185
+ orientation_cadence_hours: exports_external.number().int().min(1).optional().describe("Memory v2 M5 per-agent refresh cadence tier (hours), effective " + "default 48 (unset ⇒ 48, applied by the scaffold resolver, not a Zod " + ".default() — a hard default would shadow a `defaults.memory." + "orientation_cadence_hours` fleet value in the cascade). " + "The fast-churning agents (klanker, overlord) set 24; everyone else " + "inherits 48 (carve-M5 §6, Ken's locked tiered cadence). Drives BOTH " + "the out-of-session refresh scheduler AND the staleness guard's " + "per-tier thresholds (stale prefix at 1.5× cadence, cold degrade at " + "3×) — so a fixed 36h can never mislabel a 48h-tier agent as stale a " + "day early. Modelled as an explicit int (not a hard-coded name list) " + "so the tiering is data-driven and auditable. A cost knob, not an " + "enablement knob: a stripped/absent value failing to 48 is safe " + "(the cheaper tier). Cascade: override (per-agent wins over default); " + "unlike `orientation`/`_reinject_turns` it IS accepted at the " + "defaults/profile tier."),
4186
+ inject_directives: exports_external.boolean().optional().describe("Memory v2 M3 Surface-A directive-injection switch (default TRUE — " + "on-by-default via the plugin settings.json stamp + config.py " + "DEFAULTS). When left unset the agent keeps injecting the " + "`<active_directives>` block on every turn, byte-identical to pre-M3. " + "Set FALSE to SUPPRESS that injection for this agent — its standing " + "rules are then served from the live rules block (memory.rules_block) " + "instead of re-injected directives, which is the change that collapses " + "the always-on directive-token spend (E-41). The suppression is " + "fail-safe and re-checked every turn in recall.py: it fires ONLY when " + "a non-empty rules block is physically present in the agent's " + "CLAUDE.md — a false flag with an empty/absent block keeps injecting " + "AND emits a degraded-canary notice, so a zero-standing-rules turn is " + "unreachable. Requires memory.rules_block=true first (the block must " + "be live before injection is turned off — enforce the ordering with " + "`switchroom memory flip`, never a bare flag flip). Coordinates with " + "M4's memory.recall prefetch (memoryPrefetchEnabled): the same guard " + "gates all three recall.py emit sites so a flipped agent leaks no " + "directive on a cache/prefetch hit. Cascade: override, per-agent ONLY " + "(never fleet-seeded from the defaults/profile tier — each flip is a " + "deliberate staggered M3 rollout step, same darkness safeguard as " + "rules_block). Default-true is the fail-safe direction under the " + "Zod-strip footgun: a stripped/misread key keeps the agent injecting."),
4183
4187
  disposition: exports_external.object({
4184
4188
  skepticism: exports_external.number().int().min(1).max(5).optional().describe("How much the bank doubts unverified claims (1-5; engine default 3)."),
4185
4189
  literalism: exports_external.number().int().min(1).max(5).optional().describe("How literally the bank reads statements vs inferring intent (1-5; engine default 3)."),
@@ -4625,6 +4629,7 @@ var init_schema = __esm(() => {
4625
4629
  profile: exports_external.string().optional(),
4626
4630
  directive_capture_nudge: exports_external.boolean().optional(),
4627
4631
  profile_capture_nudge: exports_external.boolean().optional(),
4632
+ orientation_cadence_hours: exports_external.number().int().min(1).optional(),
4628
4633
  anti_confabulation_directive: AntiConfabulationDirectiveSchema,
4629
4634
  observation_scopes: ObservationScopesSchema,
4630
4635
  observation_scope_strategy: ObservationScopeStrategySchema,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "switchroom",
3
3
  "//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
4
- "version": "0.21.16",
4
+ "version": "0.21.17",
5
5
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -1198,6 +1198,17 @@ export HINDSIGHT_DIRECTIVE_CAPTURE_NUDGE={{hindsightDirectiveCaptureNudge}}
1198
1198
  {{#if hindsightProfileCaptureNudge}}
1199
1199
  export HINDSIGHT_PROFILE_CAPTURE_NUDGE={{hindsightProfileCaptureNudge}}
1200
1200
  {{/if}}
1201
+ # Memory v2 M3 Surface-A — directive-injection switch (memory.inject_directives
1202
+ # cascade). On by default (plugin settings.json): recall.py injects the
1203
+ # <active_directives> block every turn. Export only when the operator overrode
1204
+ # it; set false to SUPPRESS injection for this agent (a flipped M3 canary) — its
1205
+ # standing rules then come from the live rules block instead. recall.py keeps
1206
+ # the suppression fail-safe: it drops injection ONLY when a non-empty rules
1207
+ # block is physically present in CLAUDE.md, else it keeps injecting and emits a
1208
+ # degraded-canary notice. Flip via `switchroom memory flip`, never a bare edit.
1209
+ {{#if hindsightInjectDirectives}}
1210
+ export HINDSIGHT_INJECT_DIRECTIVES={{hindsightInjectDirectives}}
1211
+ {{/if}}
1201
1212
  # Per-row observation scope (memory.observation_scopes cascade). Stamped on
1202
1213
  # every retain — Stop hook, sidechain, boot reconcile, queue drain, backfill.
1203
1214
  # "shared" makes consolidation pool this agent's observations into ONE global
@@ -21798,6 +21798,10 @@ var init_schema = __esm(() => {
21798
21798
  }).optional().describe("Operator-declared, per-specialist Hindsight mental models (RFC " + "Phase 5). Named, opt-in curated reflections this agent's bank should " + "carry \u2014 e.g. a coach's 'training-plan-state' or a lawyer's " + "'open-matters'. Ensured idempotently at scaffold/reconcile: NOTHING " + "is created unless declared here (zero declarations = zero models, " + "matching post-#2447 behaviour), and no fixed identity model is " + "reintroduced \u2014 'who the user is' stays owned by dedicated profile " + "banks (users.*.profile_bank), never a per-agent model. Per-agent " + "ONLY: intentionally not accepted at the defaults/profile tier, so a " + "model can never be fleet-seeded \u2014 each specialist opts in on its own " + "(the invariant-clean inverse of the retired blind auto-seeding)."),
21799
21799
  observations_mission: exports_external.string().optional().describe("Steers what the observation-consolidation LLM synthesises from raw " + "facts (the higher-order 'what patterns matter' lens). Cascade: override."),
21800
21800
  rules_block: exports_external.boolean().default(false).describe("Memory v2 M3 go-live flag (M1 only DEFINES this, default false; " + "M3 flips it per agent). When true, the sanctioned rules/index " + "blocks render live in this agent's CLAUDE.md (marker-delimited, " + "below the `# --- Yours ---` line) AND the permission deny for " + "direct Edit/Write of the agent's own CLAUDE.md is seeded \u2014 the " + "flag couples deny + tools together so a live deny never orphans " + "the invited free-text edit path (the `memory_edit_yours` verb is " + "the only sanctioned writer once flipped). Unset/false \u21d2 byte-" + "identical to pre-M1 behaviour: no blocks, no deny, dark build. " + "Cascade: override (per-agent wins over default; never fleet-" + "seeded \u2014 each agent's flip is a deliberate M3 rollout step)."),
21801
+ orientation: exports_external.boolean().optional().describe("Memory v2 M5 go-live flag (Surface B: orientation-at-boot), " + "effective default false \u2014 dark build (unset \u21d2 OFF, applied by the " + "scaffold resolver, NOT a Zod .default() \u2014 a hard default here would " + "shadow the per-agent value in the cascade the way it does every other " + "memory knob). When true, the orientation SessionStart " + "hook injects this agent's cron-refreshed `orientation` mental model " + "into context at boot AND re-seats it after every compaction (E-88), " + "deterministically and with zero tool call. Unset/false \u21d2 byte-" + "identical to pre-M5 behaviour: the hook no-ops before any bank " + "resolve or network call. Per-agent ONLY (never fleet-seeded \u2014 a " + "flip is a deliberate canary/rollout step gated on the agent's " + "m2-residue measurement + operator-created model, carve-M5 \u00a77). " + "Cascade: override (per-agent wins over default)."),
21802
+ orientation_reinject_turns: exports_external.number().int().min(0).optional().describe("Memory v2 M5 optional per-turn re-inject cadence, effective default " + "0 = off (unset \u21d2 0, applied by the scaffold resolver, not a Zod " + ".default() \u2014 same cascade-shadowing reason as `orientation`). At 0 the " + "briefing is injected only at SessionStart + compaction (the cheap " + "path). N>0 " + "would additionally re-inject the orientation briefing every N turns; " + "priced at ~55M tok/30d at every-turn, which is WHY it defaults off " + "(carve-M5 \u00a72/\u00a74). Only late-session context loss should ever set it, " + "per-agent, measured. A stripped/absent value fails to 0 (fail-safe " + "AND fail-cheap). Per-agent ONLY. Cascade: override."),
21803
+ orientation_cadence_hours: exports_external.number().int().min(1).optional().describe("Memory v2 M5 per-agent refresh cadence tier (hours), effective " + "default 48 (unset \u21d2 48, applied by the scaffold resolver, not a Zod " + ".default() \u2014 a hard default would shadow a `defaults.memory." + "orientation_cadence_hours` fleet value in the cascade). " + "The fast-churning agents (klanker, overlord) set 24; everyone else " + "inherits 48 (carve-M5 \u00a76, Ken's locked tiered cadence). Drives BOTH " + "the out-of-session refresh scheduler AND the staleness guard's " + "per-tier thresholds (stale prefix at 1.5\u00d7 cadence, cold degrade at " + "3\u00d7) \u2014 so a fixed 36h can never mislabel a 48h-tier agent as stale a " + "day early. Modelled as an explicit int (not a hard-coded name list) " + "so the tiering is data-driven and auditable. A cost knob, not an " + "enablement knob: a stripped/absent value failing to 48 is safe " + "(the cheaper tier). Cascade: override (per-agent wins over default); " + "unlike `orientation`/`_reinject_turns` it IS accepted at the " + "defaults/profile tier."),
21804
+ inject_directives: exports_external.boolean().optional().describe("Memory v2 M3 Surface-A directive-injection switch (default TRUE \u2014 " + "on-by-default via the plugin settings.json stamp + config.py " + "DEFAULTS). When left unset the agent keeps injecting the " + "`<active_directives>` block on every turn, byte-identical to pre-M3. " + "Set FALSE to SUPPRESS that injection for this agent \u2014 its standing " + "rules are then served from the live rules block (memory.rules_block) " + "instead of re-injected directives, which is the change that collapses " + "the always-on directive-token spend (E-41). The suppression is " + "fail-safe and re-checked every turn in recall.py: it fires ONLY when " + "a non-empty rules block is physically present in the agent's " + "CLAUDE.md \u2014 a false flag with an empty/absent block keeps injecting " + "AND emits a degraded-canary notice, so a zero-standing-rules turn is " + "unreachable. Requires memory.rules_block=true first (the block must " + "be live before injection is turned off \u2014 enforce the ordering with " + "`switchroom memory flip`, never a bare flag flip). Coordinates with " + "M4's memory.recall prefetch (memoryPrefetchEnabled): the same guard " + "gates all three recall.py emit sites so a flipped agent leaks no " + "directive on a cache/prefetch hit. Cascade: override, per-agent ONLY " + "(never fleet-seeded from the defaults/profile tier \u2014 each flip is a " + "deliberate staggered M3 rollout step, same darkness safeguard as " + "rules_block). Default-true is the fail-safe direction under the " + "Zod-strip footgun: a stripped/misread key keeps the agent injecting."),
21801
21805
  disposition: exports_external.object({
21802
21806
  skepticism: exports_external.number().int().min(1).max(5).optional().describe("How much the bank doubts unverified claims (1-5; engine default 3)."),
21803
21807
  literalism: exports_external.number().int().min(1).max(5).optional().describe("How literally the bank reads statements vs inferring intent (1-5; engine default 3)."),
@@ -22243,6 +22247,7 @@ var init_schema = __esm(() => {
22243
22247
  profile: exports_external.string().optional(),
22244
22248
  directive_capture_nudge: exports_external.boolean().optional(),
22245
22249
  profile_capture_nudge: exports_external.boolean().optional(),
22250
+ orientation_cadence_hours: exports_external.number().int().min(1).optional(),
22246
22251
  anti_confabulation_directive: AntiConfabulationDirectiveSchema,
22247
22252
  observation_scopes: ObservationScopesSchema,
22248
22253
  observation_scope_strategy: ObservationScopeStrategySchema,
@@ -105871,10 +105876,10 @@ function startOutboxSweep(deps) {
105871
105876
  }
105872
105877
 
105873
105878
  // ../src/build-info.ts
105874
- var VERSION2 = "0.21.16";
105875
- var COMMIT_SHA = "d6c1267a";
105876
- var COMMIT_DATE = "2026-08-17T09:16:43Z";
105877
- var LATEST_PR = 4763;
105879
+ var VERSION2 = "0.21.17";
105880
+ var COMMIT_SHA = "ac61272c";
105881
+ var COMMIT_DATE = "2026-08-17T16:42:58Z";
105882
+ var LATEST_PR = 4768;
105878
105883
  var COMMITS_AHEAD_OF_TAG = 0;
105879
105884
 
105880
105885
  // gateway/boot-version.ts
@@ -10,6 +10,15 @@
10
10
  "timeout": 30
11
11
  }
12
12
  ]
13
+ },
14
+ {
15
+ "hooks": [
16
+ {
17
+ "type": "command",
18
+ "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/orientation.py\"",
19
+ "timeout": 8
20
+ }
21
+ ]
13
22
  }
14
23
  ],
15
24
  "UserPromptSubmit": [
@@ -64,6 +64,7 @@ from lib.directives import ( # noqa: E402
64
64
  parse_active_directives_block,
65
65
  rule_already_captured,
66
66
  )
67
+ from lib import recall_buffer # noqa: E402
67
68
 
68
69
  # Reuse Stage B's pleasantry scrub so "as always" / "always happy to help"
69
70
  # can't trip the high-confidence detector either. Imported lazily-safe: if
@@ -387,6 +388,37 @@ def invalidate_cache_on_directive_write(messages: list, config: dict) -> None:
387
388
  debug_log(config, f"Directives cache invalidation skipped (error): {e}")
388
389
 
389
390
 
391
+ def invalidate_prefetch_buffer_on_directive_write(messages: list, config: dict, session_id: str) -> None:
392
+ """Drop this session's pending M4 prefetch buffer if this turn wrote a
393
+ directive (create/update/delete/retire).
394
+
395
+ red-team-M3 R2 (BLOCKER): the A4 cache invalidation above keeps the fresh
396
+ <active_directives> block current, but the M4 prefetch buffer carries a
397
+ RECALLED memories block captured at a prior turn's Stop — and a rule/
398
+ directive that was just retired can survive in that snapshot as recalled
399
+ text. Without this, a buffer prefetched while the rule was active would
400
+ re-inject the retired rule on the next turn (the exact §4.4 resurrection
401
+ failure), and `run_prefetch` bailing on an empty recall would leave that
402
+ stale buffer to be served on later turns too. Deleting the buffer + sentinel
403
+ here forces the consumer to fall to the synchronous, always-current path
404
+ (or the explicitly stale-marked fallback) instead of resurrecting it.
405
+
406
+ Only fires when `memoryPrefetchEnabled` is on (the buffer only exists then)
407
+ — a cheap no-op statting two absent files otherwise, so gated to avoid it.
408
+ Self-guarded; never raises (a Stop hook must not wedge a turn).
409
+ """
410
+ try:
411
+ if not messages or not config.get("memoryPrefetchEnabled", False):
412
+ return
413
+ idx, _text = find_last_human_turn(messages)
414
+ start = idx if idx is not None else -1
415
+ if turn_contains_directive_write(messages, start):
416
+ recall_buffer.invalidate(session_id or "unknown")
417
+ debug_log(config, "Prefetch buffer invalidated — turn wrote a directive (R2 resurrection guard)")
418
+ except Exception as e: # pragma: no cover - defensive; Stop must not wedge
419
+ debug_log(config, f"Prefetch-buffer invalidation skipped (error): {e}")
420
+
421
+
390
422
  def read_transcript(transcript_path: str) -> list:
391
423
  """Read a JSONL transcript into a list of message dicts (role/content).
392
424
 
@@ -508,6 +540,7 @@ def main():
508
540
  # and this is not an already-blocked re-fire.
509
541
  ttl = config.get("directivesCacheTtlSeconds", DIRECTIVES_CACHE_TTL_SECONDS)
510
542
  cache_on = isinstance(ttl, (int, float)) and ttl > 0
543
+ prefetch_on = bool(config.get("memoryPrefetchEnabled", False))
511
544
  verify_maybe = (
512
545
  config.get("directiveCaptureNudge", True)
513
546
  and config.get("directiveCaptureVerify", True)
@@ -515,7 +548,7 @@ def main():
515
548
  )
516
549
 
517
550
  messages: list = []
518
- if cache_on or verify_maybe:
551
+ if cache_on or verify_maybe or prefetch_on:
519
552
  messages = read_transcript(hook_input.get("transcript_path", ""))
520
553
 
521
554
  # A4: invalidate the directives cache when this turn wrote a directive, so
@@ -524,6 +557,15 @@ def main():
524
557
  if cache_on:
525
558
  invalidate_cache_on_directive_write(messages, config)
526
559
 
560
+ # R2 (BLOCKER): invalidate this session's M4 prefetch buffer when this turn
561
+ # wrote/retired a directive, so a stale pre-retire snapshot can never
562
+ # resurrect the retired rule on a later turn. Self-guarded no-op when
563
+ # prefetch is off.
564
+ if prefetch_on:
565
+ invalidate_prefetch_buffer_on_directive_write(
566
+ messages, config, hook_input.get("session_id") or "unknown"
567
+ )
568
+
527
569
  try:
528
570
  reason = evaluate(hook_input, config, messages=messages)
529
571
  except Exception as e: # never wedge a turn on a verify bug
@@ -519,3 +519,38 @@ class HindsightClient:
519
519
  if retain_mission:
520
520
  updates["retain_mission"] = retain_mission
521
521
  return self._request("PATCH", path, {"updates": updates}, timeout=timeout)
522
+
523
+ def list_mental_models(self, bank_id: str, timeout: int = 5) -> dict:
524
+ """List the mental models for a bank (Memory v2 M5 — orientation-at-boot).
525
+
526
+ The orientation SessionStart hook knows the orientation model by NAME
527
+ (the configured `memoryOrientationModel`), not by its `mm-…` id, so it
528
+ lists the bank's models and matches on name → id before reading content
529
+ (carve-M5 §0d/§3). Read-only GET on the ungated engine REST surface;
530
+ m5-0-probe measured the own-bank read at ~4ms on klanker.
531
+
532
+ REST: ``GET /v1/default/banks/{bank_id}/mental-models``. Returns the raw
533
+ response dict, expected to carry an ``items`` list where each item has at
534
+ least ``id`` and ``name``.
535
+ """
536
+ path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/mental-models"
537
+ return self._request("GET", path, timeout=timeout)
538
+
539
+ def get_mental_model(
540
+ self, bank_id: str, model_id: str, detail: str = "full", timeout: int = 5
541
+ ) -> dict:
542
+ """Read one mental model's content + freshness watermark (M5).
543
+
544
+ REST: ``GET /v1/default/banks/{bank_id}/mental-models/{model_id}?detail=full``
545
+ (m5-0-probe Q3/Q4 proved this returns ``content`` + the
546
+ ``last_refreshed_at`` watermark the staleness guard keys on, ~4ms warm).
547
+ Read-only; ungated. The caller wraps this in a generous read-timeout and
548
+ degrades to the cold notice on any error — a slow/failed read must never
549
+ block boot (carve §3 fail-safe).
550
+ """
551
+ bank = urllib.parse.quote(bank_id, safe="")
552
+ model = urllib.parse.quote(model_id, safe="")
553
+ path = f"/v1/default/banks/{bank}/mental-models/{model}"
554
+ if detail:
555
+ path = f"{path}?{urllib.parse.urlencode({'detail': detail})}"
556
+ return self._request("GET", path, timeout=timeout)
@@ -130,6 +130,20 @@ DEFAULTS = {
130
130
  # opt out per-agent via memory.profile_capture_nudge=false →
131
131
  # HINDSIGHT_PROFILE_CAPTURE_NUDGE.
132
132
  "profileCaptureNudge": True,
133
+ # Switchroom Memory v2 M3 Surface-A — directive-injection switch. When True
134
+ # (default), recall.py injects the `<active_directives>` block on every
135
+ # UserPromptSubmit at all three emit sites (main + prefetch/cache fast
136
+ # paths). When False (a flipped M3 canary), that injection is SUPPRESSED —
137
+ # the agent's standing rules come from the live rules block instead, which
138
+ # is the change that collapses the always-on directive-token spend (E-41).
139
+ # Suppression is fail-safe and re-checked every turn: it fires ONLY when a
140
+ # non-empty rules block is physically present in CLAUDE.md; a False flag
141
+ # with an empty/absent block keeps injecting AND emits a degraded-canary
142
+ # notice, so a zero-standing-rules turn is unreachable. Operators flip a
143
+ # canary per-agent via memory.inject_directives=false →
144
+ # HINDSIGHT_INJECT_DIRECTIVES; the ordered flip (rules_block live → migrate
145
+ # → this flag off) is enforced by `switchroom memory flip`.
146
+ "injectDirectives": True,
133
147
  # Switchroom #2873/#2903 Fix 6.2 — the BLOCKING half (Stage C
134
148
  # directive_verify.py Stop hook) split out from the advisory nudge. When
135
149
  # True (default) the verifier may block the stop once to re-prompt capture;
@@ -140,6 +154,23 @@ DEFAULTS = {
140
154
  # per-agent via memory.directive_capture_verify=false →
141
155
  # HINDSIGHT_DIRECTIVE_CAPTURE_VERIFY.
142
156
  "directiveCaptureVerify": True,
157
+ # Memory v2 M5 — orientation-at-boot (Surface B). All four fail-safe by
158
+ # default so a stripped/absent key (the Zod-strip footgun, carve §1) boots
159
+ # exactly as pre-M5. `memoryOrientationEnabled` is the per-agent kill switch,
160
+ # default OFF (dark build — the orientation SessionStart hook no-ops before
161
+ # any bank resolve or network call when off). `memoryOrientationModel` is the
162
+ # mental-model NAME the hook resolves to an id (the agent's OWN bank).
163
+ # `memoryOrientationCadenceHours` is the per-agent refresh cadence tier
164
+ # (klanker/overlord 24, everyone else 48) the staleness guard's per-tier
165
+ # thresholds (1.5×/3×) key on. `memoryOrientationReinjectTurns` is the
166
+ # epic's optional per-turn re-inject knob, default 0 = SessionStart+compaction
167
+ # only (the cheap path; N>0 is the ~55M/30d-at-every-turn expensive variant,
168
+ # which is why it defaults off). Delivered per-agent via the scaffold
169
+ # settings.json stamp and overridable via the HINDSIGHT_ORIENTATION_* env.
170
+ "memoryOrientationEnabled": False,
171
+ "memoryOrientationModel": "orientation",
172
+ "memoryOrientationCadenceHours": 48,
173
+ "memoryOrientationReinjectTurns": 0,
143
174
  # Switchroom hindsight-leverage A4 — TTL (seconds) for the directives-list
144
175
  # cache on the recall critical path (see lib/directives.py). The list is
145
176
  # re-fetched at most once per TTL window for no-write turns; in-session
@@ -510,11 +541,27 @@ ENV_OVERRIDES = {
510
541
  # it; the switchroom default is on (settings.json pins true; recall.py falls
511
542
  # back to True).
512
543
  "HINDSIGHT_PROFILE_CAPTURE_NUDGE": ("profileCaptureNudge", bool),
544
+ # Switchroom Memory v2 M3 Surface-A: directive-injection switch on/off. Set
545
+ # by start.sh from agents.<name>.memory.inject_directives only when the
546
+ # operator overrode it; the switchroom default is on (settings.json pins
547
+ # true; recall.py falls back to True). False SUPPRESSES the
548
+ # <active_directives> injection for a flipped canary, fail-safe on a live
549
+ # rules block (see recall.py directive_injection_decision).
550
+ "HINDSIGHT_INJECT_DIRECTIVES": ("injectDirectives", bool),
513
551
  # Switchroom #2873/#2903 Fix 6.2: the Stage C block on/off, independent of
514
552
  # the Stage B nudge. Set by start.sh from
515
553
  # agents.<name>.memory.directive_capture_verify only when the operator
516
554
  # overrode it; the switchroom default is on.
517
555
  "HINDSIGHT_DIRECTIVE_CAPTURE_VERIFY": ("directiveCaptureVerify", bool),
556
+ # Memory v2 M5 — orientation-at-boot. Env override channel for the four
557
+ # orientation knobs (settings.json carries the per-agent value; env wins for
558
+ # a docker-exec'd hook or an agent `env:` map). `memoryOrientationEnabled`
559
+ # is the per-agent kill switch (default off); the model NAME, cadence tier,
560
+ # and reinject count follow. See DEFAULTS above and carve-M5 §4.
561
+ "HINDSIGHT_ORIENTATION_ENABLED": ("memoryOrientationEnabled", bool),
562
+ "HINDSIGHT_ORIENTATION_MODEL": ("memoryOrientationModel", str),
563
+ "HINDSIGHT_ORIENTATION_CADENCE_HOURS": ("memoryOrientationCadenceHours", int),
564
+ "HINDSIGHT_ORIENTATION_REINJECT_TURNS": ("memoryOrientationReinjectTurns", int),
518
565
  # Switchroom hindsight-leverage A4 — directives-list cache TTL (seconds).
519
566
  # 0 disables the cache (rollback lever).
520
567
  "HINDSIGHT_DIRECTIVES_CACHE_TTL_SECONDS": ("directivesCacheTtlSeconds", int),
@@ -0,0 +1,248 @@
1
+ """Memory v2 M5 — Surface B: orientation-at-boot (pure logic).
2
+
3
+ carve-M5.md §3/§5/§0c. This module holds the network-free, deterministically
4
+ testable half of the orientation SessionStart hook: staleness classification
5
+ (per-tier thresholds, NOT a fixed 36h), rule-aware truncation to the content
6
+ token budget, and the `additionalContext` rendering. The hook entry
7
+ (`orientation.py`) does the I/O — resolve own bank, list mental models, match
8
+ the orientation model by name, GET its content — and calls into here so the
9
+ budget/staleness/render logic can be unit-tested without a live engine.
10
+
11
+ Why the split mirrors the fleet's other deterministic memory surfaces
12
+ (recall.py's nudge regexes, prefetch.py's buffer contract): the value is a
13
+ DETERMINISTIC mechanism, not model discretion, so every branch that decides
14
+ "inject / prefix-as-stale / degrade-to-cold / truncate" must be pinned by a
15
+ test that fails on the bug it guards (carve §8 tautology-guard discipline).
16
+
17
+ TOKEN BUDGET (carve §0c). The epic caps the injected briefing at 2048 tokens,
18
+ but M0 measured real mental models at ~1,766–1,962 tokens of CONTENT alone —
19
+ so a naive "cap at 2048" overflows the moment a stale prefix or framing is
20
+ prepended. The real number to design to is a CONTENT budget below the cap,
21
+ leaving headroom for the prefix + framing. Truncation is rule-aware: whole
22
+ markdown sections are kept, trailing sections dropped, and a visible marker is
23
+ emitted so the loss is never silent.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import math
29
+ from datetime import datetime, timezone
30
+
31
+ #: Hard ceiling on the total injected `additionalContext` (epic line 143).
32
+ ORIENTATION_TOTAL_TOKEN_CAP = 2048
33
+ #: Budget for the model CONTENT alone, below the cap so the stale prefix +
34
+ #: framing tags fit inside 2048 (carve §0c: ~1,800, reserving ~250).
35
+ ORIENTATION_CONTENT_TOKEN_BUDGET = 1800
36
+ #: Chars-per-token estimate M0 used to convert measured char sizes to tokens
37
+ #: (7,063 chars ≈ 1,766 tokens). Deterministic proxy — no tokenizer dependency
38
+ #: on the boot critical path.
39
+ CHARS_PER_TOKEN = 4
40
+
41
+ #: Emitted (once) when trailing sections were dropped to fit the budget, so the
42
+ #: loss is visible to the model rather than a silent mid-sentence byte-cut.
43
+ TRUNCATION_MARKER = "(orientation truncated to fit context budget)"
44
+
45
+ # Framing tags. Kept intentionally tiny — they count against the 2048 cap.
46
+ _OPEN_TAG = "<orientation source=\"memory\">"
47
+ _CLOSE_TAG = "</orientation>"
48
+
49
+
50
+ def estimate_tokens(text: str) -> int:
51
+ """Deterministic token estimate (ceil of chars / CHARS_PER_TOKEN).
52
+
53
+ A proxy, not a real tokenizer — but the SAME proxy M0 measured content
54
+ sizes with, so the budget math is internally consistent, and it needs no
55
+ model/tokenizer on the boot path. Empty string is 0 tokens.
56
+ """
57
+ if not text:
58
+ return 0
59
+ return math.ceil(len(text) / CHARS_PER_TOKEN)
60
+
61
+
62
+ def classify_staleness(
63
+ last_refreshed_at: str | None,
64
+ cadence_hours: int,
65
+ now: datetime | None = None,
66
+ ) -> tuple[str, float | None]:
67
+ """Classify an orientation model's freshness against its agent's TIER.
68
+
69
+ Returns ``(state, hours_ago)`` where state is one of:
70
+ - ``"fresh"`` — refreshed < 1.5× cadence ago: inject plainly.
71
+ - ``"stale"`` — 1.5×–3× cadence ago: inject WITH a visible prefix.
72
+ - ``"degraded"`` — > 3× cadence ago: do NOT inject stale-as-fresh; the
73
+ hook emits the cold notice + enqueues a refresh.
74
+ - ``"unknown"`` — no/unparseable ``last_refreshed_at``: treat as cold
75
+ (fail closed — never present an un-dated model as
76
+ fresh). ``hours_ago`` is None.
77
+
78
+ The thresholds are PER-TIER (carve §5): a fixed 36h would mislabel every
79
+ 48h-cadence agent as stale a full day early. 1.5× / 3× of the resolved
80
+ cadence is the tier-correct boundary — 36h/72h at cadence 24, 72h/144h at
81
+ cadence 48. Keyed on ``last_refreshed_at`` (the freshness watermark the
82
+ persisting refresh advances — m5-0-probe Q3), NOT ``updated_at`` (returned
83
+ null on the single-model read) and NOT the engine's own ``is_stale`` (its
84
+ threshold is engine-defined, not the agent's tier).
85
+ """
86
+ if now is None:
87
+ now = datetime.now(timezone.utc)
88
+ if not last_refreshed_at:
89
+ return ("unknown", None)
90
+ ts = _parse_iso(last_refreshed_at)
91
+ if ts is None:
92
+ return ("unknown", None)
93
+ # A naive timestamp is assumed UTC (the engine stores tz-aware UTC; this is
94
+ # belt-and-braces so an aware/naive mismatch can never raise on subtract).
95
+ if ts.tzinfo is None:
96
+ ts = ts.replace(tzinfo=timezone.utc)
97
+ hours_ago = (now - ts).total_seconds() / 3600.0
98
+ # Guard a clock-skew future timestamp: treat as fresh, never negative-stale.
99
+ if hours_ago < 0:
100
+ hours_ago = 0.0
101
+ stale_at = 1.5 * cadence_hours
102
+ degrade_at = 3.0 * cadence_hours
103
+ if hours_ago < stale_at:
104
+ return ("fresh", hours_ago)
105
+ if hours_ago < degrade_at:
106
+ return ("stale", hours_ago)
107
+ return ("degraded", hours_ago)
108
+
109
+
110
+ def _parse_iso(value: str) -> datetime | None:
111
+ """Parse an engine ISO-8601 timestamp, tolerating a trailing ``Z``."""
112
+ try:
113
+ # datetime.fromisoformat handles "+00:00" and fractional seconds; map a
114
+ # trailing Z (some engine surfaces emit it) to the offset form first.
115
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
116
+ except (ValueError, AttributeError):
117
+ return None
118
+
119
+
120
+ def truncate_to_budget(content: str, token_budget: int) -> tuple[str, bool]:
121
+ """Rule-aware truncation to ``token_budget`` tokens.
122
+
123
+ Returns ``(text, truncated)``. Prefers WHOLE markdown sections (a section
124
+ starts at a line beginning with ``#``): accumulates sections while they fit,
125
+ drops trailing sections that don't, and signals ``truncated=True`` so the
126
+ caller can append :data:`TRUNCATION_MARKER`. If the very first section
127
+ already exceeds the budget, hard-cuts at a whitespace boundary (never
128
+ mid-word) rather than emitting nothing.
129
+
130
+ Never returns more than the budget; an empty/whitespace input returns
131
+ ``("", False)``.
132
+ """
133
+ content = (content or "").strip()
134
+ if not content:
135
+ return ("", False)
136
+ if estimate_tokens(content) <= token_budget:
137
+ return (content, False)
138
+
139
+ sections = _split_sections(content)
140
+ kept: list[str] = []
141
+ used = 0
142
+ for sec in sections:
143
+ sec_tokens = estimate_tokens(sec)
144
+ # +1 token slack for the join newline between kept sections.
145
+ if kept and used + sec_tokens + 1 > token_budget:
146
+ break
147
+ if not kept and sec_tokens > token_budget:
148
+ # First section alone overflows — hard-cut on a word boundary.
149
+ return (_hard_cut(sec, token_budget), True)
150
+ kept.append(sec)
151
+ used += sec_tokens + (1 if len(kept) > 1 else 0)
152
+
153
+ if not kept:
154
+ return (_hard_cut(sections[0], token_budget), True)
155
+ return ("\n".join(kept).strip(), True)
156
+
157
+
158
+ def _split_sections(content: str) -> list[str]:
159
+ """Split markdown into whole sections at lines beginning with ``#``.
160
+
161
+ Leading preamble before the first header is its own section. A document
162
+ with no headers is a single section (the hard-cut path handles overflow).
163
+ """
164
+ lines = content.split("\n")
165
+ sections: list[str] = []
166
+ cur: list[str] = []
167
+ for line in lines:
168
+ if line.startswith("#") and cur:
169
+ sections.append("\n".join(cur))
170
+ cur = [line]
171
+ else:
172
+ cur.append(line)
173
+ if cur:
174
+ sections.append("\n".join(cur))
175
+ return sections
176
+
177
+
178
+ def _hard_cut(text: str, token_budget: int) -> str:
179
+ """Cut ``text`` to fit ``token_budget`` at a whitespace boundary."""
180
+ char_budget = max(0, token_budget * CHARS_PER_TOKEN)
181
+ if len(text) <= char_budget:
182
+ return text.strip()
183
+ cut = text[:char_budget]
184
+ # Back up to the last whitespace so we never slice a word in half.
185
+ ws = cut.rfind(" ")
186
+ nl = cut.rfind("\n")
187
+ boundary = max(ws, nl)
188
+ if boundary > 0:
189
+ cut = cut[:boundary]
190
+ return cut.strip()
191
+
192
+
193
+ def stale_prefix(hours_ago: float | None) -> str:
194
+ """One-line visible staleness prefix (carve §5). Never presents stale as fresh."""
195
+ if hours_ago is None:
196
+ return "(orientation staleness unknown — may be stale)"
197
+ return f"(orientation last refreshed {int(round(hours_ago))}h ago — may be stale)"
198
+
199
+
200
+ def cold_notice() -> str:
201
+ """One-line cold notice (carve §3-4): no usable model, boot un-blocked.
202
+
203
+ Visible by design — a missing/degraded briefing degrades to a NOTICE the
204
+ model can see, never a silently memoryless boot.
205
+ """
206
+ return (
207
+ f"{_OPEN_TAG}\n"
208
+ "orientation model not yet built or refreshed — booting without a "
209
+ "briefing (a background refresh has been requested)\n"
210
+ f"{_CLOSE_TAG}"
211
+ )
212
+
213
+
214
+ def render_orientation(
215
+ content: str,
216
+ staleness: str,
217
+ hours_ago: float | None,
218
+ ) -> str:
219
+ """Render the injected `additionalContext` for a usable model.
220
+
221
+ Applies the stale prefix when warranted, truncates the CONTENT to
222
+ :data:`ORIENTATION_CONTENT_TOKEN_BUDGET` (rule-aware), wraps in the tiny
223
+ framing tags, and guarantees the WHOLE rendered block (prefix + framing +
224
+ content) stays within :data:`ORIENTATION_TOTAL_TOKEN_CAP` — if the prefix
225
+ pushes it over, the content budget is trimmed further so the cap holds.
226
+
227
+ Callers pass only ``fresh`` or ``stale`` here; ``degraded``/``unknown`` go
228
+ to :func:`cold_notice` in the hook (never rendered as fresh).
229
+ """
230
+ prefix = stale_prefix(hours_ago) if staleness == "stale" else ""
231
+ # Reserve budget for the framing tags + prefix so the TOTAL stays under the
232
+ # hard cap, not just the content.
233
+ framing_tokens = estimate_tokens(_OPEN_TAG) + estimate_tokens(_CLOSE_TAG) + 2
234
+ prefix_tokens = estimate_tokens(prefix) + (1 if prefix else 0)
235
+ content_budget = min(
236
+ ORIENTATION_CONTENT_TOKEN_BUDGET,
237
+ ORIENTATION_TOTAL_TOKEN_CAP - framing_tokens - prefix_tokens,
238
+ )
239
+ body, truncated = truncate_to_budget(content, content_budget)
240
+ if truncated:
241
+ body = f"{body}\n\n{TRUNCATION_MARKER}"
242
+
243
+ parts = [_OPEN_TAG]
244
+ if prefix:
245
+ parts.append(prefix)
246
+ parts.append(body)
247
+ parts.append(_CLOSE_TAG)
248
+ return "\n".join(parts)