pi-longhaul 1.0.4 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,44 @@ entries and log event types. Rendered state text, prompt wording and section lay
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [v1.1.1] - 2026-10-23
12
+
13
+ ### Fixed
14
+
15
+ - v1.1.0 was tagged but never published: its release stopped before npm because the package's gallery image still
16
+ named the previous release. v1.1.1 is the first published release with the v1.1.0 changes below (standing rules
17
+ kept until withdrawn, the `/skill:` text no longer quotable as yours, the summarizer's tighter rule wording).
18
+
19
+ ## [v1.1.0] - 2026-10-23 [not published]
20
+
21
+ ### Changed
22
+
23
+ - Standing rules no longer retire by count. Only the newest 8 corrections used to stay, so a long session lost its
24
+ oldest rules, often its most basic ones, while requests mislabeled as rules kept their places. Now only your
25
+ withdrawal retires a rule. When the state is over its hard cap, after the work log, decisions and dead ends, the
26
+ oldest rules are shown as an 80-character excerpt that names the message holding them, instead of being dropped.
27
+ `caps.corrections` in `longhaul.json` now defaults to 0, no limit up to a safety ceiling of 64 that no real
28
+ session has come near; a number set there still retires the oldest past it. The log's `capFit` reports
29
+ `condensedCorrections`.
30
+ - The summarizer's answer is bounded at 32 rules, lifts, decisions and dead ends each, and each rule's text in its
31
+ prompt is cut to `caps.objectiveQuoteChars` with a pointer to the message, so many long rules cannot grow every
32
+ compaction's prompt.
33
+ - The summarizer is told more precisely what is not a rule: a request for the work at hand however firmly worded
34
+ ("make sure the banner is blue"), a question, a report of what you did ("I ran the script"), or an idea still
35
+ under discussion. A rule is what still binds once the work asked for now is done. `longhaul_remember` says the
36
+ same. When Longhaul takes over a session, an earlier request is not a rule either, even when it says how the
37
+ result must behave ("add an export button that saves the report as CSV"). Prompt revisions `state-19` and
38
+ `turns-11`.
39
+
40
+ ### Fixed
41
+
42
+ - A `/skill:<name>` invocation's text became standing rules: pi expands the skill into the user message, and its
43
+ rule-like lines ("Never push the tag before the commit") were recorded as the user's, six from one paste. Only the
44
+ words typed after the skill can be quoted now, whoever proposes the rule, and the summarizer sees such a message
45
+ as `[/skill:<name>] <your words>` (in turns mode too, with the skill's text behind `show`). A skill whose text
46
+ carries its own closing tag or a /goal block cannot pass that text off as yours either. A prompt template
47
+ (`/<name>`) leaves no such mark, so its text still reads as yours: docs/security.md names this limit.
48
+
11
49
  ## [v1.0.4] - 2026-10-22
12
50
 
13
51
  ### Fixed
package/CONTEXT.md CHANGED
@@ -66,8 +66,10 @@ The workspace at the moment of compaction: branch, commit, whether the tree is c
66
66
  test, build or lint command, open errors, background tasks still running. Newer than anything the model wrote.
67
67
 
68
68
  **Correction**:
69
- A standing rule the user gave, kept verbatim with the message it came from. A **Lift** is the user withdrawing
70
- one; the correction stays visible as history.
69
+ A standing rule the user gave, kept verbatim with the message it came from: one that still binds once the work
70
+ asked for now is done, never a request for that work. Only a **Lift**, the user withdrawing one, retires it, never
71
+ their number (short of a safety ceiling no real session reaches); the correction stays visible as history. A **Condensed** correction is one the hard cap shows as an
72
+ excerpt naming its message. A skill's text is never a correction: it is the skill author's, not the user's.
71
73
  _Avoid_: preference, constraint (pi's own summary heading, not this concept)
72
74
 
73
75
  **Learned values**:
package/README.md CHANGED
@@ -57,8 +57,9 @@ Longhaul answers each of these:
57
57
 
58
58
  - **Carried, not rewritten.** The Session state is updated with a diff, so nothing earlier is paraphrased
59
59
  again. A standing rule you gave ("never use mocks; fakes only") is kept in your own words, copied from your
60
- message by code, until you withdraw it ("the no-mocks rule no longer applies"). A one-time permission ("go
61
- ahead and use a mock here") leaves the rule standing.
60
+ message by code, until you withdraw it ("the no-mocks rule no longer applies"), however many rules you give:
61
+ by default none is dropped to make room (up to a safety ceiling of 64), and when the state is full the oldest show
62
+ their opening words and the message that holds them. A one-time permission ("go ahead and use a mock here") leaves the rule standing.
62
63
  - **Old reasoning left behind.** The kept window is handed over without the reasoning the state already
63
64
  superseded, so the next span starts from the state and recent tool input and output, as a fresh prompt would
64
65
  (*Old reasoning in the kept window*).
@@ -3,7 +3,7 @@
3
3
  "routeProviders": [],
4
4
  "localOnlyRoots": [],
5
5
  "summarizerHeaders": {},
6
- "caps": { "hardChars": 13500, "objectiveQuoteChars": 600, "corrections": 8, "correctionChars": 300, "decisions": 10, "deadEnds": 10, "itemChars": 300, "workLogLines": 12, "openErrors": 5, "worldChars": 1200, "filesChars": 2500, "learnedChars": 1500 },
6
+ "caps": { "hardChars": 13500, "objectiveQuoteChars": 600, "corrections": 0, "correctionChars": 300, "decisions": 10, "deadEnds": 10, "itemChars": 300, "workLogLines": 12, "openErrors": 5, "worldChars": 1200, "filesChars": 2500, "learnedChars": 1500 },
7
7
  "claimPatterns": [
8
8
  { "pattern": "npm test", "kind": "test" }, { "pattern": "node --test", "kind": "test" }, { "pattern": "go test", "kind": "test" },
9
9
  { "pattern": "pytest", "kind": "test" }, { "pattern": "cargo test", "kind": "test" }, { "pattern": "make test", "kind": "test" },
@@ -41,6 +41,9 @@ When pi is about to compact and Longhaul is selected (`PI_COMPACTOR` unset, empt
41
41
  - every quoted correction must be a verbatim substring of a user message (a quote cited under the wrong
42
42
  message binds to the newest message that does hold it; text no user wrote is rejected);
43
43
  - in a pi-goal `/goal` message only the objective inside the goal tags is quotable, never pi-goal's template;
44
+ - in a `/skill:<name>` message only the words typed after the skill are quotable, never the skill's text pi
45
+ expanded into the message (a pasted skill once became six rules); the message list shows it as
46
+ `[/skill:<name>] <your words>`;
44
47
  - an evidence id the ledger does not know is dropped, and the decision or dead end it backed is kept;
45
48
  - shape slips that say nothing about content (`null` for an absent optional, an omitted empty list) are
46
49
  normalized before the schema check, and every list and item is bounded by the schema.
@@ -154,7 +157,7 @@ messages in the log; `PI_COMPACT_STATE_DEBUG_STACK=1` adds stack traces.
154
157
  | `event` | when | says |
155
158
  |---|---|---|
156
159
  | `extension-loaded` | pi loads Longhaul (at startup and on a reload) | `piVersion`, `piVersionVerified` (`true` for an exact verified version, `"line"` for a verified release line, `false`) |
157
- | `compaction` | a pass finished | `span`, `tokensBefore`, `summaryChars`, `narrative` (kind, calls, accepted, dropped, usage, timings, and `reasoning`: the level asked for when the model cannot turn reasoning off), `route`, `capFit`, `world`, `carried`, `durationMs` |
160
+ | `compaction` | a pass finished | `span`, `tokensBefore`, `summaryChars`, `narrative` (kind, calls, accepted, dropped, usage, timings, and `reasoning`: the level asked for when the model cannot turn reasoning off), `route`, `capFit` (the work-log lines, decisions and dead ends retired to fit the hard cap, and `condensedCorrections`), `world`, `carried`, `durationMs` |
158
161
  | `compacted` | pi wrote the compaction entry | `fromExtension` (was the summary Longhaul's?), `reason`, `span`, `summaryChars`, `tokensBefore` |
159
162
  | `compact-failed` | pi reported the compaction failed | `reason`, `span` |
160
163
  | `kept-window-strip` | old reasoning was first left out of a new kept window | `cutoff`, `strippedBlocks`, `strippedChars`, `contextMessages` |
package/docs/security.md CHANGED
@@ -59,6 +59,17 @@ the session (a web page, a README, another agent's message) could try to plant a
59
59
  - A `/goal` objective counts only when the message has pi-goal's exact template **and** pi-goal recorded that
60
60
  goal in its own custom entry: text that reaches pi as a user message can copy the template, but only
61
61
  extension code writes a custom entry. A stopped goal is cleared.
62
+ - In a message that opens with pi's `/skill:` expansion (`<skill name=… location=…>…</skill>`), only the words
63
+ after the block are quotable: the block is the skill author's text, not the user's. pi does not escape a skill's
64
+ text, so the block ends at the LAST closing tag followed by a blank line or the end of the message (pi's own parse
65
+ takes the first): a skill that carries its own closing tag cannot pass part of its text off as the user's words.
66
+ The skill is found before a /goal block, and the goal test reads only the user's words, so a skill that shows a
67
+ goal block never names the objective. Text that copies the block's shape only hides its own words from being
68
+ quoted, which fails safe (a rule is missed, never planted). The block is found by a loop over its closing tags,
69
+ linear in the message, never by a backtracking pattern.
70
+ - Known limit: a pi prompt template (`/<name> args`) replaces the user message with the template's text and
71
+ leaves no mark in the transcript, so its rule-like lines can be quoted as the user's. Installing and invoking a
72
+ template is the user's choice, and a package that ships one can already run code as an extension.
62
73
  - The line under the state's title scopes "newer wins" to progress (Now, World, Work log). Standing rules stay
63
74
  until a user message withdraws one, and text in tool results or relayed from other agents is named as data.
64
75
  - Every rendered field is one line: no id, reference, evidence tag or narrative can carry a line break into
@@ -314,7 +325,14 @@ A transcript or a model answer can be arbitrarily long, and a compaction runs in
314
325
  - Reading the state back never spreads a transcript's entries into a function call (`Math.max(...)`,
315
326
  `push(...)`), whose arguments live on the stack: a branch with hundreds of thousands of events folds.
316
327
  - The rendered state has a hard cap (`caps.hardChars`) and every section its own; the background-task line is
317
- bounded on its own because it renders after World's fitted lines.
328
+ bounded on its own because it renders after World's fitted lines. Corrections are the exception by design: none
329
+ is retired to fit (an old rule binds as much as a new one), so once the work log, decisions and dead ends are
330
+ gone they are condensed, oldest first, to an 80-character excerpt naming the message. Only a state with more rules
331
+ than condensed lines fit stays over the cap (`capExhausted` in the log). Rules are bounded elsewhere: one message
332
+ can yield several (distinct passages of one long paste each count), so with no count cap set a state keeps at most
333
+ 64 (`CORRECTIONS_CEILING`), the oldest past it retiring and still recallable from the transcript, which no real
334
+ session has come near (the most seen was 22). One answer carries at most 32 rules, lifts, decisions or dead ends,
335
+ and each rule's text in the summarizer prompt is cut to `caps.objectiveQuoteChars`, naming its recall.
318
336
  - `longhaul_recall` answers are capped at 4,000 characters and output pages at 8,000.
319
337
  - The narrative call is bounded in count (`maxChunks` × (3 + `fixableRetries`): one more call per chunk, only when the
320
338
  last answer is first refused because a lift aimed at a rule that was replaced, and one replacement question per
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-longhaul",
3
- "version": "1.0.4",
3
+ "version": "1.1.1",
4
4
  "description": "Keep pi sessions working all night. A compactor that carries your session forward instead of rewriting it: your rules kept in your own words, the state written by code, old reasoning left behind, and about 5x faster than pi's own compaction on a local model.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,7 +32,7 @@
32
32
  "extensions": [
33
33
  "./src/index.ts"
34
34
  ],
35
- "image": "https://raw.githubusercontent.com/lobo235/pi-longhaul/v1.0.4/.github/assets/infographic.png"
35
+ "image": "https://raw.githubusercontent.com/lobo235/pi-longhaul/v1.1.1/.github/assets/infographic.png"
36
36
  },
37
37
  "files": [
38
38
  "src/**/*.ts",
package/src/config.ts CHANGED
@@ -69,7 +69,7 @@ export function readConfigFile(agentDir: string = getAgentDir()): string | undef
69
69
  }
70
70
  }
71
71
 
72
- const CAP_FLOORS: Record<keyof Caps, number> = { hardChars: 2000, objectiveQuoteChars: 40, corrections: 1, correctionChars: 40, decisions: 1, deadEnds: 1, itemChars: 40, workLogLines: 1, openErrors: 0, worldChars: 200, filesChars: 0, learnedChars: 0 };
72
+ const CAP_FLOORS: Record<keyof Caps, number> = { hardChars: 2000, objectiveQuoteChars: 40, corrections: 0, correctionChars: 40, decisions: 1, deadEnds: 1, itemChars: 40, workLogLines: 1, openErrors: 0, worldChars: 200, filesChars: 0, learnedChars: 0 };
73
73
  const CLAIM_KINDS: ReadonlySet<string> = new Set<ClaimKind>(["test", "build", "lint", "custom"]);
74
74
 
75
75
  function parseObject(raw: string | undefined): Record<string, unknown> {
package/src/intent.ts CHANGED
@@ -117,9 +117,39 @@ export function activeGoalId(branchEntries: SessionEntry[]): string | undefined
117
117
  return goal.status === undefined || goal.status === "active" ? goal.id : undefined;
118
118
  }
119
119
 
120
- /** The part of a user message a quote may bind to: the objective of a /goal-mode message, else all of it. */
120
+ /** The head of pi's `/skill:` expansion (agent-session.ts `parseSkillBlock`): the block opens the message. */
121
+ const SKILL_HEAD_RE = /^<skill name="([^"]+)" location="[^"]+">\n/;
122
+ const SKILL_CLOSE = "\n</skill>";
123
+
124
+ /**
125
+ * A skill invocation's name and the words the user typed after it ("" when none), or undefined when the message is
126
+ * not one. pi expands `/skill:<name> words` into the skill's text in a `<skill>` block, then the words after a blank
127
+ * line; the block is the skill author's text, so a rule in it is never the user's (a pasted skill once became six
128
+ * rules). The block ends at the LAST closing tag after which nothing, or a blank line and the words, follows: pi does
129
+ * not escape a skill's text, so text that carries its own closing tag must not pass for the user's words (pi's own
130
+ * parse takes the first; here the user's words that copy the tag lose words, the skill's text never gains any). A loop
131
+ * over closing tags rather than pi's lazy pattern, so a long message is read once, never backtracked.
132
+ */
133
+ export function skillInvocationOf(text: string): { name: string; words: string } | undefined {
134
+ const head = SKILL_HEAD_RE.exec(text);
135
+ if (!head) return undefined;
136
+ let words: string | undefined;
137
+ for (let at = text.indexOf(SKILL_CLOSE); at >= 0; at = text.indexOf(SKILL_CLOSE, at + 1)) {
138
+ const end = at + SKILL_CLOSE.length;
139
+ if (end === text.length) words = "";
140
+ else if (text.startsWith("\n\n", end) && text.length > end + 2) words = text.slice(end + 2).trim();
141
+ }
142
+ return words === undefined ? undefined : { name: head[1], words };
143
+ }
144
+
145
+ /**
146
+ * The part of a user message a quote may bind to: the user's own words after a skill's text, then the objective of a
147
+ * /goal-mode message, else all of it. The skill comes first, so a skill's text that shows a /goal block never names
148
+ * the objective.
149
+ */
121
150
  export function quotableText(text: string): string {
122
- return goalObjectiveOf(text) ?? text;
151
+ const own = skillInvocationOf(text)?.words ?? text;
152
+ return goalObjectiveOf(own) ?? own;
123
153
  }
124
154
 
125
155
  /** Words that make a user message read as a standing rule. A pointer for the model, never a verdict: quotes are still verified. */
@@ -43,7 +43,16 @@ function objectTargets(value: unknown): string[] | undefined {
43
43
  }
44
44
 
45
45
  /** What a rule is, as both modes' prompts say it. */
46
- export const RULE_DEFINITION = `A rule is a standing instruction that should keep holding for the rest of the session, however it is worded: a convention, a constraint, a fact about where the code must run. A message that asks for work and also sets a constraint that must keep holding after that work is done is a rule too ("add an error handler; the API must never return a stack trace"). A description of what is being built (its options, defaults, limits, file names, messages, exit codes) is not a rule, though later changes must keep it working: the code and its tests keep it. Label such a requirement a rule only when the user states it as standing over later work ("every dry-run line must start with DRY:", "never ...", "always ...", "from now on ..."). Code keeps a message of at most ${RULE_SHOWN_WHOLE_CHARS} characters whole; for a longer one, give the constraint's exact words as {"u<n>": {"rule": "<exact words>"}}, words that make sense on their own.`;
46
+ export const RULE_DEFINITION = `A rule is a standing instruction that should keep holding for the rest of the session, however it is worded: a convention, a constraint, a fact about where the code must run. The test is whether it still binds once the work asked for now is done. A message that asks for work and also sets a constraint that must keep holding after that work is done is a rule too ("add an error handler; the API must never return a stack trace"). A description of what is being built (its options, defaults, limits, file names, messages, exit codes) is not a rule, though later changes must keep it working: the code and its tests keep it. Label such a requirement a rule only when the user states it as standing over later work ("every dry-run line must start with DRY:", "never ...", "always ...", "from now on ..."). Code keeps a message of at most ${RULE_SHOWN_WHOLE_CHARS} characters whole; for a longer one, give the constraint's exact words as {"u<n>": {"rule": "<exact words>"}}, words that make sense on their own.`;
47
+
48
+ /**
49
+ * What is not a rule, as both modes' prompts say it: the classes that filled rule slots on real sessions (a request for
50
+ * the work at hand worded firmly, a question, a report of what the user did, an idea under discussion).
51
+ */
52
+ /** For a switchover's earlier messages: a feature asked for back then says how it must behave, which is no standing rule. */
53
+ export const EARLIER_REQUEST = 'An earlier request is the work asked for then, even when it says how the result must behave ("add an export button that saves the report as CSV", "make the import skip blank rows"): label it "not a rule"; a rule among them says what must hold for all later work too.';
54
+
55
+ export const NOT_A_RULE = '"not a rule" is everything else: a request for the work at hand, however firmly it is worded ("make sure the banner is blue", "be sure to mention the fix in the PR"); a question; an answer, an approval, or a report of what the user did or saw ("I ran the script", "it works now"); an idea or plan still under discussion ("I was thinking we could cache it"); a one-off.';
47
56
 
48
57
  /** What a lift is, as both modes' prompts say it: a narrowed rule replaces the old one, so the part that still holds stays. */
49
58
  export const LIFT_GUIDANCE = '"lifts" withdraws a standing rule. A message that only withdraws a rule is "lifts R<n>", even when it says what is fine instead ("the no-semicolons rule is lifted; either style is fine now"): what is fine is not a new rule, and how to carry out the withdrawal ("keep the existing tests passing") is not a new rule either. Use "rule and lifts R<n>" only when the message puts a new standing rule in the old one\'s place: it replaces the rule, or narrows it ("the type hint rule is lifted for test files; keep it for the package itself"), and the whole message then stands as the new rule; words that need the rest of the message to make sense ("keep it for ...") are not the rule. Use "lifts R<n>" alone only when the rule no longer holds anywhere. A new rule that cannot hold together with a rule in the state replaces it, even when it does not say so ("switch to two-space indentation" beside "use tabs"): label it "rule and lifts R<n>", and name every rule it replaces, a narrowing of it included ("rule and lifts R1, R6"). A permission for one occasion ("go ahead and push it this time") is not a lift: the rule still stands.';
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
8
8
 
9
- import { EXCERPT_CHARS, goalObjectiveOf, normalizeWs, type IndexedUserMessage, type MessageIndex } from "../intent.ts";
9
+ import { EXCERPT_CHARS, goalObjectiveOf, normalizeWs, skillInvocationOf, type IndexedUserMessage, type MessageIndex } from "../intent.ts";
10
10
  import { closedWhole } from "../store/events.ts";
11
11
 
12
12
  /**
@@ -61,9 +61,20 @@ function prefixed(message: AgentMessage, ordinal: string): AgentMessage {
61
61
  return { ...message, content } as AgentMessage;
62
62
  }
63
63
 
64
+ /** How a skill invocation is named wherever the summarizer is shown it: the skill's text is not the user's. */
65
+ export const skillTag = (name: string): string => `[/skill:${normalizeWs(name).slice(0, 40)}]`;
66
+
67
+ /** What the message list shows: a /goal by its objective, a skill invocation by its name and the user's own words. */
68
+ export function listedExcerpt(text: string): string {
69
+ const skill = skillInvocationOf(text);
70
+ const goal = goalObjectiveOf(skill?.words ?? text);
71
+ if (goal) return `[/goal] ${normalizeWs(goal)}`.slice(0, EXCERPT_CHARS + 8);
72
+ if (skill) return `${skillTag(skill.name)} ${skill.words ? spanExcerpt(skill.words) : "(no words of the user's own)"}`;
73
+ return spanExcerpt(text);
74
+ }
75
+
64
76
  function ordinalEntry(indexed: IndexedUserMessage): SpanInput["ordinals"][number] {
65
- const goal = goalObjectiveOf(indexed.text);
66
- return { ordinal: indexed.ordinal, excerpt: goal ? `[/goal] ${normalizeWs(goal)}`.slice(0, EXCERPT_CHARS + 8) : spanExcerpt(indexed.text), text: indexed.text };
77
+ return { ordinal: indexed.ordinal, excerpt: listedExcerpt(indexed.text), text: indexed.text };
67
78
  }
68
79
 
69
80
  export function prepareSpan(input: { messagesToSummarize: AgentMessage[]; turnPrefixMessages: AgentMessage[]; index: MessageIndex }): SpanInput {
@@ -12,7 +12,7 @@ import { quoteText } from "../render.ts";
12
12
  import { DEFAULT_CAPS, type Now, type Quote, type StateEvent } from "../store/events.ts";
13
13
  import { activeCorrections, activeDeadEnds, activeDecisions, GOAL_SOURCE_LABEL, goalLine, type GoalSource, type SessionState } from "../store/project.ts";
14
14
  import { fitFirstChunk } from "./fit.ts";
15
- import { LABEL_FORMS, LIFT_GUIDANCE, RULE_DEFINITION, RULE_WHOLE_CHARS } from "./message-labels.ts";
15
+ import { EARLIER_REQUEST, LABEL_FORMS, LIFT_GUIDANCE, NOT_A_RULE, RULE_DEFINITION, RULE_WHOLE_CHARS } from "./message-labels.ts";
16
16
 
17
17
  /**
18
18
  * state-2: the switchover seed (previous summary + the user's earlier rule-shaped messages) and /goal excerpts in the user-message index.
@@ -35,8 +35,11 @@ import { LABEL_FORMS, LIFT_GUIDANCE, RULE_DEFINITION, RULE_WHOLE_CHARS } from ".
35
35
  * (the seed floor now records "from now on" replacements, which "they need no label" left lifting nothing).
36
36
  * state-16: outside a /goal, the session's opening request is the goal line once the Request has moved from it
37
37
  * (store/project.ts goalLine), so the project's purpose survives every compaction; never quoted as the objective.
38
+ * state-18: a rule is what still binds once the work asked for now is done; "not a rule" names the kinds that filled
39
+ * rule slots on real sessions (a firm request for the task at hand, a question, a report, an idea under discussion).
40
+ * state-19: at a switchover, an earlier request is not a rule even when it says how the result must behave.
38
41
  */
39
- export const NARRATIVE_PROMPT_REVISION = "state-17";
42
+ export const NARRATIVE_PROMPT_REVISION = "state-19";
40
43
 
41
44
  export const NARRATIVE_SYSTEM_PROMPT = `You maintain the SESSION STATE of a coding session that is being compacted. You are given the state so far, the user's messages with their ordinals (u1, u2, ...), and the conversation being compacted. Answer with ONE fenced json block and nothing else:
42
45
 
@@ -53,7 +56,7 @@ export const NARRATIVE_SYSTEM_PROMPT = `You maintain the SESSION STATE of a codi
53
56
  }
54
57
  \`\`\`
55
58
 
56
- Rules. "objective" is the request the work is answering now. It is optional: include it only when none is set or the user moved to a different request in this span, and quote the user's own message verbatim. The goal line, when present, is the active /goal or the session's opening request: code keeps it, so never quote it as the objective; under a goal the objective is the specific ask. "labels": Label EVERY message in <user-messages> with one of ${LABEL_FORMS}. ${RULE_DEFINITION} ${LIFT_GUIDANCE} The target is R<n> from the state, or u<n> for a rule given earlier in this span. "not a rule" is a request, question, answer, approval or one-off. Code copies the user's words into the state, so a short message labeled a rule needs no quote; a message longer than ${RULE_WHOLE_CHARS} characters labeled a rule must come with the rule's exact words: {"u7": {"rule": "<exact words>"}}. "corrections" is only for a standing rule that an earlier summary records and no listed message gives; quote the user's exact words. "values": values a later turn may need again that no file will give back: a measurement, a total, a port or a count a turn reported, a hash it named, a file-name format the user chose ("<name>-YYYYMMDD.tar.gz"). Copy each exactly as it appears in the turn "ref" names (the user's message, your replies and the tool output up to the next user message); a value is a number with or without its unit ("166 ms"), a version, a short commit hash, a path or file name (with <placeholders> if the format has them), a host:port or a date; nothing else is kept. "name" is a short label (at most 40 characters, no colon) saying what it is. At most 12, never a password, key or token. "decisions" and "deadEnds" are your own one-sentence records with evidence: a command id C<n>, an error id E<n>, a message u<n>, or the words "last command" / "last error". "now" replaces the previous Now entirely. Never write "done" unless the state's Done gate line says met. ${CREDENTIAL_RULE} Empty arrays are fine. No prose outside the fence.`;
59
+ Rules. "objective" is the request the work is answering now. It is optional: include it only when none is set or the user moved to a different request in this span, and quote the user's own message verbatim. The goal line, when present, is the active /goal or the session's opening request: code keeps it, so never quote it as the objective; under a goal the objective is the specific ask. "labels": Label EVERY message in <user-messages> with one of ${LABEL_FORMS}. ${RULE_DEFINITION} ${LIFT_GUIDANCE} The target is R<n> from the state, or u<n> for a rule given earlier in this span. ${NOT_A_RULE} Code copies the user's words into the state, so a short message labeled a rule needs no quote; a message longer than ${RULE_WHOLE_CHARS} characters labeled a rule must come with the rule's exact words: {"u7": {"rule": "<exact words>"}}. "corrections" is only for a standing rule that an earlier summary records and no listed message gives; quote the user's exact words. "values": values a later turn may need again that no file will give back: a measurement, a total, a port or a count a turn reported, a hash it named, a file-name format the user chose ("<name>-YYYYMMDD.tar.gz"). Copy each exactly as it appears in the turn "ref" names (the user's message, your replies and the tool output up to the next user message); a value is a number with or without its unit ("166 ms"), a version, a short commit hash, a path or file name (with <placeholders> if the format has them), a host:port or a date; nothing else is kept. "name" is a short label (at most 40 characters, no colon) saying what it is. At most 12, never a password, key or token. "decisions" and "deadEnds" are your own one-sentence records with evidence: a command id C<n>, an error id E<n>, a message u<n>, or the words "last command" / "last error". "now" replaces the previous Now entirely. Never write "done" unless the state's Done gate line says met. ${CREDENTIAL_RULE} Empty arrays are fine. No prose outside the fence.`;
57
60
 
58
61
  export interface PromptState {
59
62
  /** The Goal line (store/project.ts goalLine), shown so the summarizer never re-picks it as the Request. */
@@ -89,7 +92,7 @@ export function seedOf(previousSummary: string | undefined): string | undefined
89
92
 
90
93
  const SEED_RULES_GUIDANCE = 'The user\'s own earlier messages that read like standing rules, verbatim, with their ordinals. Each one is recorded as corrections automatically, word for word: do not copy them into "corrections", and they need no label unless one replaces or withdraws an earlier rule: label that one "rule and lifts u<n>" or "lifts u<n>". List the ordinal of any that was a one-off request, an answer or a question rather than a rule still in force in a top-level "oneOffs" array, e.g. "oneOffs": ["u3"]; list none when all of them still hold. A rule a later message withdrew or replaced is not in force either: label that later message "lifts u<n>" (or "rule and lifts u<n>"), whether it is listed here or among the other messages.';
91
94
 
92
- const SEED_CONTEXT_GUIDANCE = 'The user\'s other earlier messages, each cut to its start. A later message may withdraw or replace an earlier rule: label it "lifts u<n>" (or "rule and lifts u<n>") with the earlier rule\'s ordinal, and that rule is not carried. Label every one of these too: "rule" when it is a standing rule still in force, however it is worded ("tests go under tests/", "our servers run Node 22"), "not a rule" otherwise.';
95
+ const SEED_CONTEXT_GUIDANCE = `The user's other earlier messages, each cut to its start. A later message may withdraw or replace an earlier rule: label it "lifts u<n>" (or "rule and lifts u<n>") with the earlier rule\'s ordinal, and that rule is not carried. Label every one of these too: "rule" when it is a standing rule still in force, however it is worded ("tests go under tests/", "our servers run Node 22"), "not a rule" otherwise. ${EARLIER_REQUEST}`;
93
96
 
94
97
  export const SEED_CONTEXT_EXCERPT_CHARS = 240;
95
98
 
@@ -136,7 +139,7 @@ export function promptStateOf(state: SessionState, remembered: StateEvent[], quo
136
139
  return {
137
140
  ...goalOf(state, quoteChars),
138
141
  objective: state.objective ? { ...cut(state.objective.quote), doneWhen: state.objective.doneWhen ? cut(state.objective.doneWhen) : undefined } : undefined,
139
- corrections: activeCorrections(state).map((item) => ({ id: item.id, ordinal: item.quote.ordinal, text: item.quote.text, lifted: item.liftedBy?.ordinal })),
142
+ corrections: activeCorrections(state).map((item) => ({ id: item.id, ordinal: item.quote.ordinal, text: quoteText(item.quote, quoteChars), lifted: item.liftedBy?.ordinal })),
140
143
  decisions: activeDecisions(state).map((item) => ({ id: item.id, text: item.text })),
141
144
  deadEnds: activeDeadEnds(state).map((item) => ({ id: item.id, text: item.text })),
142
145
  now: state.now ? { inProgress: state.now.inProgress, next: state.now.next, blocked: state.now.blocked, resume: state.now.resume } : undefined,
@@ -17,7 +17,7 @@ import type { CallTrace } from "../types.ts";
17
17
  import { callUsageOf } from "../usage.ts";
18
18
 
19
19
  /** Recorded as the span's promptRevision in turns mode, as NARRATIVE_PROMPT_REVISION is for the single call. */
20
- export const TURNS_PROMPT_REVISION = "turns-9";
20
+ export const TURNS_PROMPT_REVISION = "turns-11";
21
21
 
22
22
  export const TURNS_SYSTEM_PROMPT = `You maintain the SESSION STATE of a coding session that is being compacted. The first message holds the digest of the span: the state so far, the user's messages, the commands and errors (with ids), the files, the workspace and a condensed transcript. Code asks for one section at a time. Answer each with ONE JSON object and nothing else. Use only ids that appear in the digest. Call show(ref) to read a user message (u<n>), command (C<n>), error (E<n>) or tool output (T<n>) in full when the digest cut it. ${CREDENTIAL_RULE}`;
23
23
 
@@ -1,14 +1,17 @@
1
1
  /**
2
2
  * The Digest (CONTEXT.md *Digest*): what code knows about the span, built once and sent whole at the
3
3
  * start of the turn conversation -- the state so far, on a switchover the previous summary and the
4
- * user's earlier rule-shaped messages, the span's user messages verbatim with ordinals, the ledger's
5
- * commands and errors with outcomes, the file lists, World, and the condensed transcript. `show(ref)`
6
- * answers the one read-only tool: a user message, command or error record, or a whole tool output.
4
+ * user's earlier rule-shaped messages, the span's user messages verbatim with ordinals (a skill
5
+ * invocation by its name and the user's own words), the ledger's commands and errors with outcomes,
6
+ * the file lists, World, and the condensed transcript. `show(ref)` answers the one read-only tool: a
7
+ * user message, command or error record, or a whole tool output.
7
8
  */
8
9
  import { renderCommandLine, renderErrorLine } from "../../ledger/handoff-render.ts";
9
10
  import type { CommandRecord, ErrorRecord } from "../../ledger/handoff.ts";
10
11
  import type { FileLists } from "../../carry.ts";
11
12
  import { renderFilesSection } from "../../files-render.ts";
13
+ import { skillInvocationOf } from "../../intent.ts";
14
+ import { skillTag } from "../prepare.ts";
12
15
  import { stateBlock, type PromptState } from "../prompt.ts";
13
16
  import type { CondensedTranscript } from "./transcript.ts";
14
17
 
@@ -36,10 +39,14 @@ export const USER_MESSAGE_CAP = { head: 3000, tail: 1000 };
36
39
  /** One show() answer, at most. */
37
40
  export const SHOW_CHARS = 8000;
38
41
 
42
+ /** A skill invocation is shown by its name and the user's own words; the skill's text is behind show(uN). */
39
43
  function userMessageLine(message: { ordinal: string; text: string }): string {
44
+ const skill = skillInvocationOf(message.text);
45
+ const text = skill ? `${skillTag(skill.name)} ${skill.words || "(no words of the user's own)"}` : message.text;
46
+ const pointer = skill ? `\n[the skill's text: show ${message.ordinal}]` : "";
40
47
  const { head, tail } = USER_MESSAGE_CAP;
41
- if (message.text.length <= head + tail) return `[${message.ordinal}] ${message.text}`;
42
- return `[${message.ordinal}] ${message.text.slice(0, head)}\n[… ${message.text.length - head - tail} chars, show ${message.ordinal} …]\n${message.text.slice(-tail)}`;
48
+ if (text.length <= head + tail) return `[${message.ordinal}] ${text}${pointer}`;
49
+ return `[${message.ordinal}] ${text.slice(0, head)}\n[… ${text.length - head - tail} chars, show ${message.ordinal} …]\n${text.slice(-tail)}${pointer}`;
43
50
  }
44
51
 
45
52
  const block = (tag: string, lines: string[], empty = "(none)") => `<${tag}>\n${lines.length ? lines.join("\n") : empty}\n</${tag}>`;
@@ -8,8 +8,8 @@
8
8
  * forced label per earlier message made "not a rule" the cheap answer and dropped half the earlier
9
9
  * rules in a replay; opt-out keeps the default safe.
10
10
  */
11
- import { SPAN_EXCERPT_CHARS, spanExcerpt as excerpt } from "../prepare.ts";
12
- import { LABEL_FORMS, LIFT_GUIDANCE, labelProposals, RULE_DEFINITION, ruleLabelProblem, readLabels, RULE_WHOLE_CHARS, type Label } from "../message-labels.ts";
11
+ import { listedExcerpt, SPAN_EXCERPT_CHARS, spanExcerpt as excerpt } from "../prepare.ts";
12
+ import { EARLIER_REQUEST, LABEL_FORMS, LIFT_GUIDANCE, labelProposals, NOT_A_RULE, RULE_DEFINITION, ruleLabelProblem, readLabels, RULE_WHOLE_CHARS, type Label } from "../message-labels.ts";
13
13
  import type { Proposal } from "../../store/verify.ts";
14
14
  import type { Complaint, Facts, Parsed, Section } from "./section.ts";
15
15
 
@@ -22,19 +22,19 @@ export { RULE_WHOLE_CHARS };
22
22
  const shownOf = (facts: Facts) => (ordinal: string): number | undefined => (facts.contextOrdinals?.includes(ordinal) ? SPAN_EXCERPT_CHARS : undefined);
23
23
 
24
24
  function instruction(facts: Facts): string {
25
- const span = facts.spanOrdinals.map((ordinal) => `${ordinal}: ${excerpt(facts.textOf(ordinal) ?? "")}`);
26
- const seed = facts.seedOrdinals.map((ordinal) => `${ordinal}: ${excerpt(facts.textOf(ordinal) ?? "")}`);
27
- const context = (facts.contextOrdinals ?? []).map((ordinal) => `${ordinal}: ${excerpt(facts.textOf(ordinal) ?? "")}`);
25
+ const span = facts.spanOrdinals.map((ordinal) => `${ordinal}: ${listedExcerpt(facts.textOf(ordinal) ?? "")}`);
26
+ const seed = facts.seedOrdinals.map((ordinal) => `${ordinal}: ${listedExcerpt(facts.textOf(ordinal) ?? "")}`);
27
+ const context = (facts.contextOrdinals ?? []).map((ordinal) => `${ordinal}: ${listedExcerpt(facts.textOf(ordinal) ?? "")}`);
28
28
  const rules = facts.activeCorrections.map((rule) => `${rule.id} [${rule.ordinal}] ${excerpt(rule.text)}`);
29
29
  return [
30
30
  "Section 3 of 5: the user's messages. Label EVERY message listed under Messages in this span with one of:",
31
- `${LABEL_FORMS}. ${RULE_DEFINITION} ${LIFT_GUIDANCE} "not a rule" is a request, question, answer, approval or one-off.`,
31
+ `${LABEL_FORMS}. ${RULE_DEFINITION} ${LIFT_GUIDANCE} ${NOT_A_RULE}`,
32
32
  `Code copies the user's words. A message longer than ${RULE_WHOLE_CHARS} characters that you label a rule must come with the rule's exact words instead of "rule": {"u7": {"rule": "<the user's exact words>"}}.`,
33
33
  'Answer: {"labels": {"u3": "not a rule", "u4": "rule", "u5": "lifts R1"}}',
34
34
  `Standing rules already recorded:\n${rules.length ? rules.join("\n") : "(none)"}`,
35
35
  `Messages in this span:\n${span.length ? span.join("\n") : "(none)"}`,
36
36
  ...(seed.length ? [`Earlier messages that read like standing rules. Each one is kept as a rule automatically; do not label them. Only if one was a one-off request, an answer or a question rather than a rule still in force, list its id in a top-level "oneOffs" array, e.g. "oneOffs": ["u3"]; list none when all of them still hold:\n${seed.join("\n")}`] : []),
37
- ...(context.length ? [`The user's other earlier messages, each cut to its start. Label every one of these too: "rule" when it is a standing rule still in force, however it is worded ("tests go under tests/", "our servers run Node 22"), "not a rule" otherwise:\n${context.join("\n")}`] : []),
37
+ ...(context.length ? [`The user's other earlier messages, each cut to its start. Label every one of these too: "rule" when it is a standing rule still in force, however it is worded ("tests go under tests/", "our servers run Node 22"), "not a rule" otherwise. ${EARLIER_REQUEST}\n${context.join("\n")}`] : []),
38
38
  ].join("\n\n");
39
39
  }
40
40
 
@@ -29,14 +29,17 @@ const QuoteRef = Type.Object({ messageRef: Ref, text: Text });
29
29
  const NowItem = Type.String({ maxLength: NOW_ITEM_CHARS });
30
30
  const NowList = Type.Array(NowItem, { maxItems: NOW_ITEMS });
31
31
 
32
+ /** The most rules, lifts, decisions or dead ends one answer may carry: far above a real span's, so only a runaway answer is refused. */
33
+ export const LIST_ITEMS = 32;
34
+
32
35
  export const NarrativeDiffSchema = Type.Object({
33
36
  narrative: Text,
34
37
  objective: Type.Optional(Type.Object({ messageRef: Ref, text: Text, doneWhen: Type.Optional(QuoteRef) })),
35
- corrections: Type.Array(QuoteRef),
38
+ corrections: Type.Array(QuoteRef, { maxItems: LIST_ITEMS }),
36
39
  /** `text` is the withdrawing words: unlike a summary's handoff, the diff hand quotes too. */
37
- lifts: Type.Array(Type.Object({ target: Ref, messageRef: Ref, text: Text })),
38
- decisions: Type.Array(Type.Object({ text: Text, evidence: Type.Optional(Ref) })),
39
- deadEnds: Type.Array(Type.Object({ text: Text, evidence: Type.Optional(Ref) })),
40
+ lifts: Type.Array(Type.Object({ target: Ref, messageRef: Ref, text: Text }), { maxItems: LIST_ITEMS }),
41
+ decisions: Type.Array(Type.Object({ text: Text, evidence: Type.Optional(Ref) }), { maxItems: LIST_ITEMS }),
42
+ deadEnds: Type.Array(Type.Object({ text: Text, evidence: Type.Optional(Ref) }), { maxItems: LIST_ITEMS }),
40
43
  now: Type.Object({ inProgress: NowList, next: NowList, blocked: NowList, resume: NowItem }),
41
44
  /** Every user message of the span, labeled (message-labels.ts): code turns a "rule" into a correction in the user's own words. */
42
45
  labels: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
package/src/pass.ts CHANGED
@@ -367,7 +367,7 @@ function assembleResult(input: PassInput, config: StateConfig, fold: Fold, outco
367
367
  const pending: PendingSpan = { span: fold.span, events: closed.log.events.filter((event) => event.span === fold.span), ledger, snapshot, usage, caps: config.coldSliceCaps };
368
368
  const route = { spec: fold.route.spec, rule: fold.route.rule, model: fold.resolved ? `${fold.resolved.model.provider}/${fold.resolved.model.id}` : undefined, fallbackReason: fold.resolved?.fallbackReason, routeIgnored: fold.resolved?.routeIgnored, routeAvailable: fold.routeAvailable };
369
369
  const logOptions = logOptionsOf(input.env);
370
- const log = { event: "compaction", at, reason: input.reason, span: fold.span, tokensBefore: preparation.tokensBefore, summaryChars: summary.length, narrative: logSafeNarrative(details.narrative, logOptions), rejected: logSafeRejected(outcome.rejected, logOptions), route, routeHealth: outcome.routeHealth, capFit: closed.capFit.retired, capExhausted: closed.capFit.exhausted, anomalies: fold.log.anomalies, world: { claims: closed.state.world?.claims.length ?? 0, openErrors: closed.state.world?.openErrorIds.length ?? 0, dirty: closed.state.world?.dirty }, carried: { readFiles: files.readFiles.length, modifiedFiles: files.modifiedFiles.length, removed: logSafeRemoved(files.removed, logOptions), learnedValues: learned.length }, redacted, workspace: "skipped" in capture ? { skipped: logSafeSkipped(capture.skipped, logOptions) } : { captured: true }, durationMs: deps.now().getTime() - startedAt.getTime() };
370
+ const log = { event: "compaction", at, reason: input.reason, span: fold.span, tokensBefore: preparation.tokensBefore, summaryChars: summary.length, narrative: logSafeNarrative(details.narrative, logOptions), rejected: logSafeRejected(outcome.rejected, logOptions), route, routeHealth: outcome.routeHealth, capFit: { ...closed.capFit.retired, condensedCorrections: closed.capFit.condensed }, capExhausted: closed.capFit.exhausted, anomalies: fold.log.anomalies, world: { claims: closed.state.world?.claims.length ?? 0, openErrors: closed.state.world?.openErrorIds.length ?? 0, dirty: closed.state.world?.dirty }, carried: { readFiles: files.readFiles.length, modifiedFiles: files.modifiedFiles.length, removed: logSafeRemoved(files.removed, logOptions), learnedValues: learned.length }, redacted, workspace: "skipped" in capture ? { skipped: logSafeSkipped(capture.skipped, logOptions) } : { captured: true }, durationMs: deps.now().getTime() - startedAt.getTime() };
371
371
  return {
372
372
  compaction: { summary, firstKeptEntryId: preparation.firstKeptEntryId, tokensBefore: preparation.tokensBefore, usage: compactionUsageOf(outcome.usage), details },
373
373
  pending, outcome, routeHealth: outcome.routeHealth, route, capFit: closed.capFit, anomalies: fold.log.anomalies, notice: noticeOf(fold.span, outcome, summary.length), log,
@@ -54,7 +54,7 @@ function credentialRefusal(proposal: Proposal, branch: Parameters<typeof message
54
54
  }
55
55
 
56
56
  const PARAMETERS = Type.Object({
57
- kind: Type.Union(REMEMBER_KINDS.map((kind) => Type.Literal(kind)), { description: "correction: a standing rule the user gave (quote). lift: the user withdrew a correction (target + quote). decision / dead-end: your own one sentence (text, evidence). done-when: the user's acceptance words (quote)." }),
57
+ kind: Type.Union(REMEMBER_KINDS.map((kind) => Type.Literal(kind)), { description: "correction: a standing rule the user gave, still binding after the current task (quote). lift: the user withdrew a correction (target + quote). decision / dead-end: your own one sentence (text, evidence). done-when: the user's acceptance words (quote)." }),
58
58
  quote: Type.Optional(Type.String({ description: "The user's exact words, a substring of one user message (12+ chars). Example: \"never use mocks in these tests\"." })),
59
59
  text: Type.Optional(Type.String({ description: "Your own sentence for a decision or dead end. Example: \"Copied the ledger modules instead of importing them across extensions\"." })),
60
60
  evidence: Type.Optional(Type.String({ description: "C<n>, E<n>, u<n>, \"last command\" or \"last error\"." })),
@@ -67,7 +67,7 @@ export function registerRememberTool(pi: ExtensionAPI, deps: { readConfig: ReadF
67
67
  label: "Remember",
68
68
  description: "Write one verified item into the session state that survives compaction: a user correction (their exact words), a lift, a decision, a dead end, or the acceptance criterion. Rejections say why and whether to retry.",
69
69
  promptGuidelines: [
70
- `Call \`${REMEMBER_TOOL}\` the moment the user states a standing rule (kind correction, quote their exact words) or when you settle a decision or abandon an approach (kind decision / dead-end, one sentence with evidence C<n>/E<n>/u<n>).`,
70
+ `Call \`${REMEMBER_TOOL}\` the moment the user states a standing rule, one that still binds once the current task is done (kind correction, quote their exact words; a request for the task at hand is not one), or when you settle a decision or abandon an approach (kind decision / dead-end, one sentence with evidence C<n>/E<n>/u<n>).`,
71
71
  "Never paraphrase a correction: the quote must be a substring of a user message, at least 12 characters, or it is rejected with the closest match.",
72
72
  ],
73
73
  parameters: PARAMETERS,
package/src/render.ts CHANGED
@@ -105,8 +105,12 @@ function retiredNote(count: number, what?: string): string {
105
105
  return what ? `; ${count} retired, ${RECALL_TOOL} ${what}` : `; ${count} retired`;
106
106
  }
107
107
 
108
+ /** How much of a rule the cap fit condensed is shown (capfit.ts); the cut names the message that holds it whole. */
109
+ export const CONDENSED_CORRECTION_CHARS = 80;
110
+
108
111
  function correctionsSection({ state, caps }: RenderInput): string[] {
109
- const lines = activeCorrections(state).map((item) => `- ${item.id} [${item.quote.ordinal}] ${renderQuote(item.quote, caps.correctionChars)}${item.liftedBy ? ` (lifted by ${item.liftedBy.ordinal})` : ""}`);
112
+ const shown = (condensed: boolean | undefined) => (condensed ? Math.min(CONDENSED_CORRECTION_CHARS, caps.correctionChars) : caps.correctionChars);
113
+ const lines = activeCorrections(state).map((item) => `- ${item.id} [${item.quote.ordinal}] ${renderQuote(item.quote, shown(item.condensed))}${item.liftedBy ? ` (lifted by ${item.liftedBy.ordinal})` : ""}`);
110
114
  return [`## Constraints & corrections (verbatim, newest last${retiredNote(state.retired.corrections, "corrections")})`, ...(lines.length ? lines : ["(none)"])];
111
115
  }
112
116
 
@@ -1,21 +1,25 @@
1
1
  /**
2
2
  * The hard cap on the rendered state: over it, sections retire oldest-first in the
3
- * order work log, decisions, dead ends, corrections, until under it. Objective, Now and World are
4
- * never touched. The size function is injected so this stays a pure step over the projection and
5
- * the loop provably terminates: every step retires one item or stops.
3
+ * order work log, decisions, dead ends, until under it; then corrections are condensed, oldest first, to an
4
+ * excerpt that names the rule's recall (render.ts). A correction is never retired here: only a lift retires one,
5
+ * since an old rule binds as much as a new one. Objective, Now and World are never touched. The size function is
6
+ * injected so this stays a pure step over the projection and the loop provably terminates: every step retires or
7
+ * condenses one item or stops.
6
8
  */
7
9
  import type { Caps } from "./events.ts";
8
10
  import type { SessionState } from "./project.ts";
9
11
 
10
12
  export interface CapFitResult {
11
13
  state: SessionState;
12
- retired: { workLog: number; decisions: number; deadEnds: number; corrections: number };
13
- /** Nothing retirable was left and the state is still over the cap. */
14
+ retired: { workLog: number; decisions: number; deadEnds: number };
15
+ /** Corrections rendered as an excerpt to fit; each is still in the state, whole in recall. */
16
+ condensed: number;
17
+ /** Nothing retirable or condensable was left and the state is still over the cap. */
14
18
  exhausted: boolean;
15
19
  }
16
20
 
17
21
  type Section = keyof CapFitResult["retired"];
18
- const ORDER: Section[] = ["workLog", "decisions", "deadEnds", "corrections"];
22
+ const ORDER: Section[] = ["workLog", "decisions", "deadEnds"];
19
23
 
20
24
  /** Retire the oldest active item of `section`; false when there is none. */
21
25
  function retireOne(state: SessionState, section: Section): boolean {
@@ -33,13 +37,23 @@ function retireOne(state: SessionState, section: Section): boolean {
33
37
  return true;
34
38
  }
35
39
 
40
+ /** Condense the oldest active correction not condensed yet; false when there is none. */
41
+ function condenseOne(state: SessionState): boolean {
42
+ const oldest = state.corrections.find((item) => !item.retired && !item.condensed);
43
+ if (!oldest) return false;
44
+ oldest.condensed = true;
45
+ return true;
46
+ }
47
+
36
48
  export function fitToHardCap(input: SessionState, caps: Caps, measure: (state: SessionState) => number): CapFitResult {
37
49
  const state = structuredClone(input);
38
- const retired = { workLog: 0, decisions: 0, deadEnds: 0, corrections: 0 };
50
+ const retired = { workLog: 0, decisions: 0, deadEnds: 0 };
51
+ let condensed = 0;
39
52
  while (measure(state) > caps.hardChars) {
40
53
  const section = ORDER.find((candidate) => retireOne(state, candidate));
41
- if (!section) return { state, retired, exhausted: true };
42
- retired[section]++;
54
+ if (section) retired[section]++;
55
+ else if (condenseOne(state)) condensed++;
56
+ else return { state, retired, condensed, exhausted: true };
43
57
  }
44
- return { state, retired, exhausted: false };
58
+ return { state, retired, condensed, exhausted: false };
45
59
  }
@@ -80,6 +80,7 @@ export const closedWhole = (close: { foreign?: unknown; narrativeLess: boolean;
80
80
  export interface Caps {
81
81
  hardChars: number;
82
82
  objectiveQuoteChars: number;
83
+ /** The most active corrections kept, the oldest retiring past it; 0 (the default) keeps every one until it is lifted, up to CORRECTIONS_CEILING. */
83
84
  corrections: number;
84
85
  correctionChars: number;
85
86
  decisions: number;
@@ -95,7 +96,14 @@ export interface Caps {
95
96
  learnedChars: number;
96
97
  }
97
98
 
98
- export const DEFAULT_CAPS: Caps = { hardChars: 13500, objectiveQuoteChars: 600, corrections: 8, correctionChars: 300, decisions: 10, deadEnds: 10, itemChars: 300, workLogLines: 12, openErrors: 5, worldChars: 1200, filesChars: 2500, learnedChars: 1500 };
99
+ /**
100
+ * With no count cap set, the most active corrections a state keeps: a safety bound, never a working limit (the most a
101
+ * real session carried was 22). Past it the oldest retires, still recallable from the transcript, so text steered into
102
+ * many rules cannot grow the state, and every summarizer prompt, without end (security.md T4).
103
+ */
104
+ export const CORRECTIONS_CEILING = 64;
105
+
106
+ export const DEFAULT_CAPS: Caps = { hardChars: 13500, objectiveQuoteChars: 600, corrections: 0, correctionChars: 300, decisions: 10, deadEnds: 10, itemChars: 300, workLogLines: 12, openErrors: 5, worldChars: 1200, filesChars: 2500, learnedChars: 1500 };
99
107
 
100
108
  export const MIN_QUOTE_CHARS = 12;
101
109
 
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * Hot memory is `project(log, caps)`: a pure reducer over accepted events. Per-kind caps retire
3
- * the oldest of a kind on add; a lift marks a correction, and a correction lifted in an earlier
3
+ * the oldest decision or dead end on add. A correction does not retire by count unless `caps.corrections` asks
4
+ * for it (0, the default, never), since an old rule binds as much as a new one: a lift marks it, and a correction lifted in an earlier
4
5
  * span is retired, so it renders as lifted exactly once; a span close appends the work-log line,
5
6
  * carries World, and consolidates work-log lines past the cap into one code-written line.
6
7
  * Retirement is a projection rule: the log never deletes, and recall reads the log.
7
8
  */
8
9
  import type { Caps, ForeignCompactor, Now, Quote, Source, StateEvent, StateLog, World } from "./events.ts";
10
+ import { CORRECTIONS_CEILING } from "./events.ts";
9
11
  import { DEFAULT_NEAR_DUPLICATE_THRESHOLD, overlap, tokenize } from "./near-duplicate.ts";
10
12
  import { oneLine } from "../one-line.ts";
11
13
  import { scrubCredentials } from "../credential-scrub.ts";
@@ -19,6 +21,8 @@ export interface CorrectionItem {
19
21
  seq: number;
20
22
  liftedBy?: { messageId: string; ordinal: string; span: number };
21
23
  retired: boolean;
24
+ /** Set by the cap fit alone (capfit.ts): rendered as an excerpt naming its recall, never projected. */
25
+ condensed?: boolean;
22
26
  /** Ids this rule held before being recorded again from its message (addCorrection); see liftTarget. */
23
27
  formerIds?: Set<string>;
24
28
  /** `span:seq` of the earlier correction.add events folded into this rule, for recall (addCorrection). */
@@ -183,7 +187,7 @@ function addCorrection(state: SessionState, event: Extract<StateEvent, { kind: "
183
187
  const copyKeys = earlier?.copyKeys ?? [];
184
188
  if (earlier) copyKeys.push(eventKey(earlier));
185
189
  state.corrections.push({ id: event.id, quote: event.quote, span: event.span, source: event.source, evidence: event.evidence, seq: event.seq, retired: false, ...(formerIds.size ? { formerIds } : {}), ...(copyKeys.length ? { copyKeys } : {}) });
186
- state.retired.corrections += retireOverCap(state.corrections, caps.corrections);
190
+ state.retired.corrections += retireOverCap(state.corrections, caps.corrections > 0 ? caps.corrections : CORRECTIONS_CEILING);
187
191
  }
188
192
 
189
193
  export function applyEvent(previous: SessionState, event: StateEvent, caps: Caps): SessionState {