pi-midcompact 0.3.0 → 0.5.0
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/README.md +117 -42
- package/README.zh-CN.md +236 -0
- package/figures/review-tui.png +0 -0
- package/figures/review-webui.png +0 -0
- package/package.json +3 -2
- package/skills/midcompact/SKILL.md +87 -72
- package/skills/midcompact/references/tool-interface.md +83 -0
- package/src/atoms.ts +31 -7
- package/src/content-metrics.ts +185 -0
- package/src/index.ts +526 -207
- package/src/inventory.ts +295 -0
- package/src/messages.ts +56 -1
- package/src/plan.ts +176 -20
- package/src/planning-lock.ts +42 -0
- package/src/projection.ts +43 -1
- package/src/renderers.ts +16 -19
- package/src/review-ui.ts +199 -155
- package/src/review-webui.html +1063 -0
- package/src/review-webui.ts +317 -0
- package/src/selection-ui.ts +220 -0
- package/src/selection.ts +95 -0
- package/src/start-ui.ts +57 -0
- package/src/state.ts +39 -2
- package/src/telemetry.ts +48 -18
- package/src/types.ts +141 -2
|
@@ -1,117 +1,132 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: midcompact
|
|
3
|
-
description: Use
|
|
3
|
+
description: Use when a midcompact runtime prompt starts or hands off a compression-planning transaction, or when current work needs details from an active committed midcompact block. Guides user-aligned candidate discovery, effort-scaled inspect/locate/plan work, replacement-summary writing, and recall.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Midcompact
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
## Route the activation
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
This skill handles two independent tasks: planning compression and recalling committed content. Choose the route before calling the tool.
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
| Signal | Immediate duty | First action |
|
|
13
|
+
|--------|----------------|--------------|
|
|
14
|
+
| Runtime prompt says `FINAL STATE: USER MANUAL` | Let the user create the initial DraftPlan | Reply exactly `OK`; call no midcompact tool |
|
|
15
|
+
| Runtime prompt says `FINAL STATE: AGENT DIRECT` | Start from the new empty draft | `action="inspect"` |
|
|
16
|
+
| A handoff reports a persisted DraftPlan and the user asks to continue | Read the shared selection and determine what help the user wants | `action="plan", op="show"` |
|
|
17
|
+
| The user or a projected summary needs detail from a committed block | Retrieve that history only | Follow **Recall workflow** |
|
|
13
18
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
The state-specific runtime prompt is authoritative. Recall does not enter planning or mutate the DraftPlan. During the User-manual acknowledgement turn, the no-tool instruction overrides every other route.
|
|
20
|
+
|
|
21
|
+
## Plan compression
|
|
22
|
+
|
|
23
|
+
Selected ranges become summaries in future model context; content outside them stays verbatim. Originals remain stored, but recall is a recovery path, not a substitute for a sufficient summary. Agent and user edit one DraftPlan; the user retains final control through review and `/midcompact commit`.
|
|
24
|
+
|
|
25
|
+
The start mode controls how the first draft is created. It does not determine whether a user selection is final, how much initiative the Agent should take, or how deeply the history should be investigated.
|
|
26
|
+
|
|
27
|
+
Apply one invariant:
|
|
28
|
+
|
|
29
|
+
> Every fact that can still affect future work must remain with sufficient fidelity, either outside the selected ranges or in their replacement summaries.
|
|
30
|
+
|
|
31
|
+
### Planning workflow
|
|
18
32
|
|
|
19
|
-
|
|
33
|
+
#### 1. Read the entry state and user intent
|
|
20
34
|
|
|
21
|
-
-
|
|
22
|
-
- **Originals survive.** Session entries stay on disk, and `action="recall"` brings an active committed block back into view. Compression is reversible at the information-access level, not a deletion — but recall returns a readable rendering, not a byte-exact replay, so it is no substitute for keeping something verbatim.
|
|
23
|
-
- **Projection is layered.** Compression applies to what future models see, not to stored history.
|
|
35
|
+
For a fresh Agent-direct transaction, begin with `action="inspect"`. For a handed-off DraftPlan, begin with `action="plan", op="show"`; inspect the anchor only if the requested work needs broader context.
|
|
24
36
|
|
|
25
|
-
|
|
37
|
+
Establish the user's desired scope, fidelity, and planning effort from their words, current selection, `User focus: ...`, and surrounding interaction. Treat answers collected through question, questionnaire, or similar tools as user-originated input even when represented as tool results.
|
|
26
38
|
|
|
27
|
-
|
|
28
|
-
|-------|------|
|
|
29
|
-
| Extension | Session-tree mechanics, projection, protocol safety |
|
|
30
|
-
| You | Semantic judgment: which content may yield to a summary, and what each summary must carry |
|
|
31
|
-
| User | Compression scope and depth; the only actor that can commit |
|
|
39
|
+
A manual selection records what the user selected, not whether they mean "only this" or "help me refine it." Follow a clear intent directly. If different interpretations would materially change the work, ask briefly whether to limit work to the selection or inspect and suggest changes. Do not force clarification when the user has already expressed a preference or delegated the judgment.
|
|
32
40
|
|
|
33
|
-
|
|
41
|
+
Match effort to the requested fidelity. A quick or approximate request calls for bounded planning; a precision-sensitive request may justify deeper work after alignment.
|
|
34
42
|
|
|
35
|
-
|
|
43
|
+
#### 2. Form a bounded semantic view
|
|
36
44
|
|
|
37
|
-
|
|
45
|
+
`inspect` returns factual structure with bounded user landmarks, grouped by user message. Use it for structure and volume, not to invent semantics. Ground candidates in visible conversation or a few targeted `locate` calls, and stop paging after the potentially relevant region. When exact candidate spans are known and their relative volume could change the choice, use read-only `inspect` spans to compare them without mutating the DraftPlan.
|
|
38
46
|
|
|
39
|
-
|
|
47
|
+
Segment by completed work phase, not message count. User-originated input and concluding Agent responses are useful landmarks for intent and outcome, but are not automatically KEEP. Intermediate tool exchanges may also contain decisions or evidence absent from the final response.
|
|
40
48
|
|
|
41
|
-
|
|
49
|
+
| Often able to yield to a summary | Often load-bearing |
|
|
50
|
+
|-----------------------------------|--------------------|
|
|
51
|
+
| Repetitive or superseded exploration | Current intent and active constraints |
|
|
52
|
+
| Tool output absorbed into a conclusion | Exact errors, evidence, or wording whose form matters |
|
|
53
|
+
| A completed subtask's intermediate process | Decisions and rationale still governing the work |
|
|
54
|
+
| A rejected attempt's mechanics | Its still-relevant failure reason |
|
|
42
55
|
|
|
43
|
-
|
|
44
|
-
- **Some information must not be entrusted to paraphrase.** Exact requirement wording, literal error text, decision-critical evidence, protocol structure. When exact form or provenance matters, keep the source verbatim unless the replacement preserves it with equivalent fidelity.
|
|
56
|
+
Use little or no `locate` during this reconnaissance. If a phase cannot yet be described faithfully, omit it from the proposal or mark it as needing confirmation rather than exploring the anchor broadly.
|
|
45
57
|
|
|
46
|
-
|
|
58
|
+
#### 3. Present semantic options and align
|
|
47
59
|
|
|
48
|
-
|
|
49
|
-
- Tool output fully absorbed into the answer that follows it → the answer sits outside the range → a thin summary suffices.
|
|
50
|
-
- A constraint, correction, or approval the user stated once → if the wording itself carries the constraint, keep it verbatim; otherwise the summary must state it explicitly.
|
|
51
|
-
- A rejected approach whose rejection reason still constrains current work → the summary must carry the reason; the exploration around it can go.
|
|
60
|
+
Before deep `locate` work or substantial DraftPlan mutation, establish the user's compression preference through explicit instruction, a reliable implication, or concise clarification.
|
|
52
61
|
|
|
53
|
-
|
|
62
|
+
Describe each proposal in recognizable conversation terms:
|
|
54
63
|
|
|
55
|
-
|
|
64
|
+
> From `<semantic start>` to `<semantic end>`, `<replace the whole phase / keep the endpoints and compress the work between>`; preserve `<load-bearing information>` in the summary.
|
|
56
65
|
|
|
57
|
-
|
|
66
|
+
Use short recognizable excerpts when available and clear paraphrases otherwise. State whether the endpoint messages remain verbatim, what intermediate work disappears, and what the summary carries. Do not identify a user-facing range primarily by atom refs, DraftPlan ids, or arbitrary item numbers.
|
|
58
67
|
|
|
59
|
-
|
|
68
|
+
When treatments involve a meaningful tradeoff, present concise alternatives and recommend one. A clear quick request may need only one proportionate proposal. Add factual content or image measurements only when they help the choice, and do not convert them into unsupported token-savings claims.
|
|
60
69
|
|
|
61
|
-
|
|
62
|
-
2. Judge each segment against the conservation law. For each candidate, be able to say where its load-bearing facts would end up.
|
|
63
|
-
3. Present candidates: where each begins and ends, roughly how large it is, and what its summary would carry. Name segments you deliberately excluded when the exclusion is non-obvious.
|
|
64
|
-
4. If scope or depth is still unresolved, ask how deep to go and which regions matter. Depth is the user's decision, not a number you optimize.
|
|
70
|
+
#### 4. Resolve boundaries and build the DraftPlan
|
|
65
71
|
|
|
66
|
-
|
|
72
|
+
After the intended treatment is clear, use `action="locate"` for targeted content and boundary checks. An atom is the smallest selectable unit; a tool call and its matching results form one indivisible `tool_exchange` atom. Keep source text outside a range when exact wording or provenance matters and a summary cannot preserve it equivalently.
|
|
67
73
|
|
|
68
|
-
|
|
74
|
+
Choose boundaries from the information that must survive, not from a fixed category. A range may replace a whole semantic phase, including its initiating and concluding messages. It may instead retain a load-bearing user instruction and concluding Agent response while replacing only the execution between them. It may split around important material to leave KEEP holes. These are reasoning patterns, not rules tied to start mode, message age, or one prescribed kind of work.
|
|
69
75
|
|
|
70
|
-
|
|
76
|
+
Build or refine the shared DraftPlan with `plan add`, `update`, `remove`, and `show`. A handed-off selection may be preserved or revised as the user's intent permits. Use separate ranges for non-contiguous phases; fill pending summaries before commit. If deeper inspection would materially change the agreed treatment, surface the change instead of silently applying it.
|
|
71
77
|
|
|
72
|
-
|
|
73
|
-
2. `action="plan", op="add"` per range — several ranges for non-contiguous compression. To keep one important atom verbatim inside a broader phase, add ranges around it; that is KEEP by omission in practice.
|
|
74
|
-
3. `action="plan", op="show"`, then present the complete plan described by content rather than atom IDs.
|
|
75
|
-
4. Recommend `/midcompact review` when the user wants to inspect the anchor timeline, ranges, summaries, and KEEP holes visually. Apply changes with `op="update"`, `op="remove"`, or new ranges.
|
|
76
|
-
5. Ask the user to run `/midcompact commit` when satisfied.
|
|
78
|
+
Read `references/tool-interface.md` before retrying a rejected operation or when exact parameters, protected-atom causes, or measurements matter.
|
|
77
79
|
|
|
78
|
-
|
|
80
|
+
#### 5. Write replacement summaries
|
|
81
|
+
|
|
82
|
+
A replacement summary is successor context for a future Agent, not a transcript or a prompt to perform compression. State the resulting knowledge and working state directly.
|
|
83
|
+
|
|
84
|
+
Preserve, when applicable:
|
|
85
|
+
|
|
86
|
+
- user intent and active constraints;
|
|
87
|
+
- decisions, conclusions, and necessary rationale;
|
|
88
|
+
- relevant files, symbols, interfaces, commands, or configuration;
|
|
89
|
+
- completed changes and validation results;
|
|
90
|
+
- rejected approaches only when their failure reason still matters;
|
|
91
|
+
- unresolved issues and any established next step or continuation state.
|
|
92
|
+
|
|
93
|
+
Remove repetitive exploration, superseded hypotheses, raw output captured by a conclusion, and chronology with no remaining consequence. Do not turn uncertainty into fact, invent a next action, or use references that only make sense inside the removed text. If retained endpoint messages already carry part of the intended context, do not duplicate them mechanically; use the summary to preserve what would otherwise be lost.
|
|
94
|
+
|
|
95
|
+
Organize by future utility rather than original chronology. When useful, use this compact frame without forcing empty fields:
|
|
96
|
+
|
|
97
|
+
```text
|
|
98
|
+
Goal and constraints:
|
|
99
|
+
Established state and decisions:
|
|
100
|
+
Artifacts and validation:
|
|
101
|
+
Open issues and established next state:
|
|
102
|
+
```
|
|
79
103
|
|
|
80
|
-
|
|
104
|
+
Length follows the information that must survive, not a target ratio. Final test: could a fresh Agent continue correctly from this summary plus retained context, without repeating work or violating a prior decision?
|
|
81
105
|
|
|
82
|
-
|
|
106
|
+
#### 6. Verify and hand off
|
|
83
107
|
|
|
84
|
-
`action="
|
|
108
|
+
Call `action="plan", op="show"`. Check that the intended semantic phases are covered, KEEP holes remain outside ranges, every range has a summary, and the summaries conserve the future working state.
|
|
85
109
|
|
|
86
|
-
`
|
|
110
|
+
Describe the completed proposal with the same recognizable landmarks used during alignment. Direct the user to `/midcompact select` or `/midcompact select-webui` for boundaries and KEEP holes, and to `/midcompact review` or `/midcompact review-webui` for summary inspection or rejection. Use browser variants when the TUI is unavailable or preferred. Ask the user to run `/midcompact commit` when ready; never commit for them.
|
|
87
111
|
|
|
88
|
-
|
|
89
|
-
|----|----------|
|
|
90
|
-
| `show` | — |
|
|
91
|
-
| `add` | `start`, `end`, `summary`; `topic` optional |
|
|
92
|
-
| `update` | `draft_id` and at least one of `summary`, `topic` |
|
|
93
|
-
| `remove` | `draft_id` |
|
|
112
|
+
## Recall compressed content
|
|
94
113
|
|
|
95
|
-
|
|
114
|
+
Recall works with or without an active planning transaction. It reads committed blocks active on the current branch without changing projection or DraftPlan.
|
|
96
115
|
|
|
97
|
-
|
|
98
|
-
|-----------|---------|
|
|
99
|
-
| Range crosses a protected atom | Split the plan around it. An atom is protected when its tool-call protocol is still open (an incomplete `tool_exchange`, or an orphaned tool result), when it is an existing compressed block, when its message kind is not one the extension can compress, or when its messages have no persistent session entry to anchor to. |
|
|
100
|
-
| Range overlaps an existing draft range | Remove or update that range instead. |
|
|
101
|
-
| `start` occurs after `end` | Refs are positional; order them. |
|
|
102
|
-
| Unknown atom ref | Usually a typo or a ref carried over from an earlier transaction. Re-run `locate` against the current snapshot. |
|
|
116
|
+
### Recall workflow
|
|
103
117
|
|
|
104
|
-
|
|
118
|
+
#### 1. Find the block
|
|
105
119
|
|
|
106
|
-
|
|
120
|
+
If its id is unknown, call `action="recall", pattern="..."` to search active topics and summaries. A projected summary also states its block id and exact recall call.
|
|
107
121
|
|
|
108
|
-
|
|
122
|
+
#### 2. Retrieve the detail
|
|
109
123
|
|
|
110
|
-
|
|
124
|
+
Call `action="recall", ref="c0001"`. If the readable, structure-flattened result ends with a truncation marker, retry with `detail="full"`. Retrieve only what the current task needs; do not start or change a plan merely to recall history.
|
|
111
125
|
|
|
112
|
-
|
|
126
|
+
## Tool conventions
|
|
113
127
|
|
|
114
|
-
- `
|
|
115
|
-
- `
|
|
128
|
+
- `g0001` labels an inventory group; use its `a...` span as internal landmarks.
|
|
129
|
+
- `a0001` is a transaction-local atom ref; `d1` is a DraftPlan range id; `c0001` is an active committed-block id.
|
|
130
|
+
- Protected atoms cannot enter a range. Split around them.
|
|
116
131
|
|
|
117
|
-
|
|
132
|
+
These refs are planning handles, not the primary way to explain compression to the user. Read `references/tool-interface.md` for exact defaults and limits, rejected operations, protected-atom causes, measurements, repeated compression, or recall truncation.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Midcompact Tool Interface
|
|
2
|
+
|
|
3
|
+
Read this reference when exact call requirements, limits, rejection behavior, repeated compression, or recall truncation matters. The main workflow remains in `../SKILL.md`.
|
|
4
|
+
|
|
5
|
+
## Inspect
|
|
6
|
+
|
|
7
|
+
Without `spans`, `action="inspect"` inventories the frozen anchor. It returns factual structure and bounded user landmarks, not full message bodies, assistant/tool previews, summaries, or image base64.
|
|
8
|
+
|
|
9
|
+
- `page_size`: default 20 groups, maximum 50.
|
|
10
|
+
- `cursor`: opaque value returned by the previous page.
|
|
11
|
+
- Results include group refs, atom spans, content chars, image facts, protected/compressible counts, and Pi-reported anchor usage.
|
|
12
|
+
|
|
13
|
+
Stop paging after the candidate regions are covered.
|
|
14
|
+
|
|
15
|
+
To compare explicit candidates without mutating the DraftPlan, pass one or more possibly overlapping spans:
|
|
16
|
+
|
|
17
|
+
```text
|
|
18
|
+
midcompact(action="inspect", spans=[
|
|
19
|
+
{"start":"a0006","end":"a0014"},
|
|
20
|
+
{"start":"a0006","end":"a0020"}
|
|
21
|
+
])
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Span inspection reports bounded endpoint landmarks, atom/message and role counts, tool exchanges and calls, factual content share, images, and protected/compressible counts. It has a 12,000-character total output budget and reports how many requested spans fit. It does not report per-span tokens: Pi supplies usage for the whole anchor, not token attribution by range. Do not combine `spans` with `page_size` or `cursor`.
|
|
25
|
+
|
|
26
|
+
## Locate
|
|
27
|
+
|
|
28
|
+
`action="locate"` returns atoms from the frozen anchor. Supply either:
|
|
29
|
+
|
|
30
|
+
- `ref`: one direct atom lookup; or
|
|
31
|
+
- at least one real filter: `pattern`, `tool_name`, or `source` other than `any`.
|
|
32
|
+
|
|
33
|
+
With no lookup or filter it returns no matches rather than an error. Filtered searches return at most three brief candidates; when more match, the result reports the total and asks for a more specific pattern or additional filter. `direction` is `oldest` by default or `newest`; `limit` may request one to three results.
|
|
34
|
+
|
|
35
|
+
Brief direct lookups preserve both ends of an atom landmark. Pattern searches show a bounded excerpt around the match rather than the atom prefix. `detail="full"` is allowed only with one direct `ref` and preserves both ends with an explicit middle-omission marker when the 12,000-character atom limit is exceeded.
|
|
36
|
+
|
|
37
|
+
A `g...` inventory ref is not a locate ref; use the group's `a...` start/end landmarks.
|
|
38
|
+
|
|
39
|
+
## Plan
|
|
40
|
+
|
|
41
|
+
`action="plan"` uses `op="show"` by default. Agent and user mutate the same DraftPlan.
|
|
42
|
+
|
|
43
|
+
| op | Required fields |
|
|
44
|
+
|----|-----------------|
|
|
45
|
+
| `show` | none; optional `draft_id` for one range |
|
|
46
|
+
| `add` | `start`, `end`; optional `summary`, `topic` |
|
|
47
|
+
| `update` | `draft_id` and at least one of `summary`, `topic` |
|
|
48
|
+
| `remove` | `draft_id` |
|
|
49
|
+
|
|
50
|
+
Default show lists each range with bounded `from`, `to`, and `summary` landmarks plus factual metrics. Use `op="show", detail="full", draft_id="d1"` for one stored summary and endpoint previews under a 40,000-character total budget; full detail without `draft_id` is rejected. Show is read-only and does not persist a duplicate DraftPlan entry.
|
|
51
|
+
|
|
52
|
+
Add/update return the changed range in brief form; remove returns its id and compact draft totals. Mutation responses omit the Pi-awareness header and unchanged ranges. Use explicit show when complete awareness is needed.
|
|
53
|
+
|
|
54
|
+
An omitted or empty `summary` creates a pending range. Review can open it, but commit requires at least one range and a non-empty summary for every range. Changing boundaries requires removing the old range and adding the replacement.
|
|
55
|
+
|
|
56
|
+
### Add rejection conditions
|
|
57
|
+
|
|
58
|
+
| Condition | Response |
|
|
59
|
+
|-----------|----------|
|
|
60
|
+
| The range crosses a protected atom | Split the range around that atom |
|
|
61
|
+
| The range overlaps an existing draft range | Remove or replace the existing range first |
|
|
62
|
+
| `start` occurs after `end` | Correct the positional order |
|
|
63
|
+
| An atom ref is unknown | Re-run inspect/locate against the current transaction snapshot |
|
|
64
|
+
|
|
65
|
+
An atom is protected when its tool protocol is incomplete or orphaned, it represents an existing compressed block, its message kind is unsupported, or it lacks the persistent session entry needed to anchor compression.
|
|
66
|
+
|
|
67
|
+
## Telemetry
|
|
68
|
+
|
|
69
|
+
Explicit `plan show` includes Pi-reported anchor usage when available and factual draft measurements: original/replacement content chars, image count, and decoded payload bytes. Mutation results omit awareness and report only compact totals plus the changed range where one remains. Local character or image measurements are not converted into token savings or projected-token percentages. Use measurements to compare the proposal with user-directed depth, not as an optimization target.
|
|
70
|
+
|
|
71
|
+
## Repeated compression
|
|
72
|
+
|
|
73
|
+
Committed blocks appear as protected atoms in later transaction snapshots and cannot be compressed again. A later transaction may compress newly accumulated raw history around those blocks. Re-run inspect/locate because atom refs are transaction-local.
|
|
74
|
+
|
|
75
|
+
## Recall
|
|
76
|
+
|
|
77
|
+
`action="recall"` works independently of a planning transaction and reads committed blocks active on the current branch.
|
|
78
|
+
|
|
79
|
+
- Without `ref`, `pattern` searches block topics and summaries; `limit` defaults to 8 and has a maximum of 20.
|
|
80
|
+
- With `ref="c0001"`, the tool renders that block's stored messages.
|
|
81
|
+
- `detail="full"` raises the rendering cap when the normal result is truncated.
|
|
82
|
+
|
|
83
|
+
Recall has no paging. The truncation marker is `[truncated; refine the recall request or inspect the source session for more]`. If `detail="full"` still truncates before the needed detail, report that recall cannot expose the omitted content; do not infer it.
|
package/src/atoms.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Atom, LocateQuery, MessageLike, MessageRef, SessionEntryLike } from "./types.js";
|
|
2
|
-
import {
|
|
2
|
+
import { aggregateMetrics, measureMessage } from "./content-metrics.js";
|
|
3
|
+
import { approxTokens, excerptAround, mapEntryIds, messageKey, renderMessage, toolCalls, truncateMiddle } from "./messages.js";
|
|
3
4
|
|
|
4
5
|
export function buildAtoms(messages: MessageLike[], branch: readonly SessionEntryLike[]): Atom[] {
|
|
5
6
|
const entryIds = mapEntryIds(messages, branch);
|
|
@@ -70,6 +71,11 @@ export function buildAtoms(messages: MessageLike[], branch: readonly SessionEntr
|
|
|
70
71
|
return atoms;
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
/** A protected atom cannot be part of any compressible range. */
|
|
75
|
+
export function isProtectedAtom(atom: Atom): boolean {
|
|
76
|
+
return !atom.compressible || !atom.protocolClosed || atom.kind === "compressed";
|
|
77
|
+
}
|
|
78
|
+
|
|
73
79
|
function hasEntry(ref: MessageRef): boolean {
|
|
74
80
|
return typeof ref.entryId === "string" && ref.entryId.length > 0;
|
|
75
81
|
}
|
|
@@ -103,8 +109,9 @@ function makeAtom(
|
|
|
103
109
|
messages,
|
|
104
110
|
entryIds: messages.flatMap((ref) => (ref.entryId ? [ref.entryId] : [])),
|
|
105
111
|
messageKeys: messages.map((ref) => ref.key),
|
|
106
|
-
preview:
|
|
112
|
+
preview: truncateMiddle(fullText, 700),
|
|
107
113
|
fullText,
|
|
114
|
+
metrics: aggregateMetrics(messages.map((ref) => measureMessage(ref.message))),
|
|
108
115
|
approxTokens: approxTokens(fullText),
|
|
109
116
|
compressible,
|
|
110
117
|
protocolClosed,
|
|
@@ -114,10 +121,17 @@ function makeAtom(
|
|
|
114
121
|
};
|
|
115
122
|
}
|
|
116
123
|
|
|
117
|
-
export
|
|
124
|
+
export const MAX_LOCATE_MATCHES = 3;
|
|
125
|
+
|
|
126
|
+
export interface LocatedAtoms {
|
|
127
|
+
atoms: Atom[];
|
|
128
|
+
totalMatches: number;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function locateAtomMatches(atoms: Atom[], query: LocateQuery): LocatedAtoms {
|
|
118
132
|
if (query.ref) {
|
|
119
133
|
const atom = atoms.find((candidate) => candidate.ref === query.ref);
|
|
120
|
-
return atom ? [atom] : [];
|
|
134
|
+
return { atoms: atom ? [atom] : [], totalMatches: atom ? 1 : 0 };
|
|
121
135
|
}
|
|
122
136
|
const pattern = query.pattern?.toLocaleLowerCase();
|
|
123
137
|
const toolName = query.toolName?.toLocaleLowerCase();
|
|
@@ -129,7 +143,13 @@ export function locateAtoms(atoms: Atom[], query: LocateQuery): Atom[] {
|
|
|
129
143
|
return Boolean(pattern || toolName || source !== "any");
|
|
130
144
|
});
|
|
131
145
|
if ((query.direction ?? "oldest") === "newest") matches = matches.reverse();
|
|
132
|
-
|
|
146
|
+
const totalMatches = matches.length;
|
|
147
|
+
const limit = Math.max(1, Math.min(query.limit ?? MAX_LOCATE_MATCHES, MAX_LOCATE_MATCHES));
|
|
148
|
+
return { atoms: matches.slice(0, limit), totalMatches };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function locateAtoms(atoms: Atom[], query: LocateQuery): Atom[] {
|
|
152
|
+
return locateAtomMatches(atoms, query).atoms;
|
|
133
153
|
}
|
|
134
154
|
|
|
135
155
|
function matchesSource(atom: Atom, source: NonNullable<LocateQuery["source"]>): boolean {
|
|
@@ -141,9 +161,13 @@ function matchesSource(atom: Atom, source: NonNullable<LocateQuery["source"]>):
|
|
|
141
161
|
return false;
|
|
142
162
|
}
|
|
143
163
|
|
|
144
|
-
export function formatLocatedAtom(atom: Atom, detail: "brief" | "full" = "brief"): string {
|
|
164
|
+
export function formatLocatedAtom(atom: Atom, detail: "brief" | "full" = "brief", pattern?: string): string {
|
|
145
165
|
const flags = [atom.kind, atom.compressible ? "compressible" : "protected", atom.protocolClosed ? "closed" : "open"].join(", ");
|
|
146
|
-
const text = detail === "full"
|
|
166
|
+
const text = detail === "full"
|
|
167
|
+
? atom.fullText.length <= 12_000 ? atom.fullText : truncateMiddle(atom.fullText, 12_000)
|
|
168
|
+
: pattern
|
|
169
|
+
? excerptAround(atom.fullText, pattern, 700)
|
|
170
|
+
: atom.preview;
|
|
147
171
|
return [
|
|
148
172
|
`${atom.ref} | position ${atom.index + 1} | ${flags}`,
|
|
149
173
|
atom.toolNames.length ? `tools: ${atom.toolNames.join(", ")}` : "",
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Sole owner of factual message content statistics. Never converts local char or
|
|
2
|
+
// image byte counts into token claims. Image base64 never contributes to text
|
|
3
|
+
// char counts.
|
|
4
|
+
|
|
5
|
+
import type { ContentMetrics, ImageFact, MessageLike } from "./types.js";
|
|
6
|
+
|
|
7
|
+
/** Count Unicode code points of a string. */
|
|
8
|
+
export function codePointCount(text: string): number {
|
|
9
|
+
let count = 0;
|
|
10
|
+
// for..of iterates by code point, not UTF-16 code unit.
|
|
11
|
+
for (const _ of text) count += 1;
|
|
12
|
+
return count;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Decode a base64 string into a Uint8Array without depending on Node Buffer. */
|
|
16
|
+
function decodeBase64(data: string): Uint8Array {
|
|
17
|
+
const binary = atob(data);
|
|
18
|
+
const bytes = new Uint8Array(binary.length);
|
|
19
|
+
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
20
|
+
return bytes;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Best-effort pixel dimension read from decoded image bytes. Returns undefined on failure. */
|
|
24
|
+
export function readImageDimensions(bytes: Uint8Array): { width?: number; height?: number } {
|
|
25
|
+
if (bytes.length < 8) return {};
|
|
26
|
+
// PNG: 89 50 4E 47 0D 0A 1A 0A; width/height are big-endian at offsets 16/20.
|
|
27
|
+
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
|
|
28
|
+
if (bytes.length < 24) return {};
|
|
29
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset + 16, 8);
|
|
30
|
+
return { width: view.getUint32(0), height: view.getUint32(4) };
|
|
31
|
+
}
|
|
32
|
+
// GIF: 47 49 46 38; width/height little-endian at offsets 6/8.
|
|
33
|
+
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) {
|
|
34
|
+
return { width: bytes[6]! | (bytes[7]! << 8), height: bytes[8]! | (bytes[9]! << 8) };
|
|
35
|
+
}
|
|
36
|
+
// JPEG: scan SOF0 (0xFFC0) segment for dimensions.
|
|
37
|
+
if (bytes[0] === 0xff && bytes[1] === 0xd8) {
|
|
38
|
+
let i = 2;
|
|
39
|
+
while (i < bytes.length - 9) {
|
|
40
|
+
if (bytes[i] !== 0xff) { i += 1; continue; }
|
|
41
|
+
const marker = bytes[i + 1];
|
|
42
|
+
if (marker === undefined) break;
|
|
43
|
+
// SOF0..SOF15 carry dimensions; skip DHT (0xC4) and the reserved JPG marker (0xC8).
|
|
44
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8) {
|
|
45
|
+
const height = (bytes[i + 5]! << 8) | bytes[i + 6]!;
|
|
46
|
+
const width = (bytes[i + 7]! << 8) | bytes[i + 8]!;
|
|
47
|
+
return { width, height };
|
|
48
|
+
}
|
|
49
|
+
const len = (bytes[i + 2]! << 8) | bytes[i + 3]!;
|
|
50
|
+
i += 2 + len;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
55
|
+
// WebP: RIFF....WEBP; VP8/VP8L/VP8X variants.
|
|
56
|
+
if (
|
|
57
|
+
bytes.length >= 30 &&
|
|
58
|
+
bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
|
|
59
|
+
bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50
|
|
60
|
+
) {
|
|
61
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
62
|
+
const chunk = (bytes[12]! << 16) | (bytes[13]! << 8) | bytes[14]!;
|
|
63
|
+
if (chunk === 0x56503820) { // "VP8 "
|
|
64
|
+
return { width: view.getUint16(26, true) & 0x3fff, height: view.getUint16(28, true) & 0x3fff };
|
|
65
|
+
}
|
|
66
|
+
if (chunk === 0x56503858) { // "VP8X"
|
|
67
|
+
const w = 1 + (view.getUint32(24, true) >>> 0);
|
|
68
|
+
return { width: w & 0xffffff, height: view.getUint32(27, true) & 0xffffff };
|
|
69
|
+
}
|
|
70
|
+
return {};
|
|
71
|
+
}
|
|
72
|
+
return {};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function safeJson(value: unknown): string {
|
|
76
|
+
try {
|
|
77
|
+
return JSON.stringify(value) ?? "";
|
|
78
|
+
} catch {
|
|
79
|
+
return "[unserializable]";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface PartLike {
|
|
84
|
+
type?: string;
|
|
85
|
+
text?: string;
|
|
86
|
+
thinking?: string;
|
|
87
|
+
name?: string;
|
|
88
|
+
arguments?: unknown;
|
|
89
|
+
data?: string;
|
|
90
|
+
mimeType?: string;
|
|
91
|
+
id?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
95
|
+
return typeof value === "object" && value !== null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Measure the content parts of a message (its `content` field). Image parts are
|
|
100
|
+
* recorded as ImageFacts and never contribute to contentChars.
|
|
101
|
+
*/
|
|
102
|
+
export function measureContentParts(content: unknown, startIndex = 0): ContentMetrics {
|
|
103
|
+
if (typeof content === "string") {
|
|
104
|
+
return { contentChars: codePointCount(content), imageCount: 0, images: [] };
|
|
105
|
+
}
|
|
106
|
+
if (!Array.isArray(content)) return { contentChars: 0, imageCount: 0, images: [] };
|
|
107
|
+
|
|
108
|
+
let contentChars = 0;
|
|
109
|
+
const images: ImageFact[] = [];
|
|
110
|
+
let imageIndex = 0;
|
|
111
|
+
|
|
112
|
+
for (const raw of content) {
|
|
113
|
+
if (!isObject(raw)) continue;
|
|
114
|
+
const part = raw as PartLike;
|
|
115
|
+
if (part.type === "text" && typeof part.text === "string") {
|
|
116
|
+
contentChars += codePointCount(part.text);
|
|
117
|
+
} else if (part.type === "thinking" && typeof part.thinking === "string") {
|
|
118
|
+
contentChars += codePointCount(part.thinking);
|
|
119
|
+
} else if (part.type === "toolCall") {
|
|
120
|
+
if (typeof part.name === "string") contentChars += codePointCount(part.name);
|
|
121
|
+
// Normalized arguments representation; id is metadata and not counted.
|
|
122
|
+
contentChars += codePointCount(safeJson(part.arguments));
|
|
123
|
+
} else if (part.type === "image") {
|
|
124
|
+
const mimeType = typeof part.mimeType === "string" ? part.mimeType : "application/octet-stream";
|
|
125
|
+
const data = typeof part.data === "string" ? part.data : "";
|
|
126
|
+
let payloadBytes = 0;
|
|
127
|
+
let dimensions: { width?: number; height?: number } = {};
|
|
128
|
+
if (data.length > 0) {
|
|
129
|
+
try {
|
|
130
|
+
const bytes = decodeBase64(data);
|
|
131
|
+
payloadBytes = bytes.length;
|
|
132
|
+
dimensions = readImageDimensions(bytes);
|
|
133
|
+
} catch {
|
|
134
|
+
payloadBytes = 0;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
images.push({
|
|
138
|
+
index: startIndex + imageIndex,
|
|
139
|
+
mimeType,
|
|
140
|
+
payloadBytes,
|
|
141
|
+
...(dimensions.width !== undefined ? { width: dimensions.width } : {}),
|
|
142
|
+
...(dimensions.height !== undefined ? { height: dimensions.height } : {}),
|
|
143
|
+
});
|
|
144
|
+
imageIndex += 1;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { contentChars, imageCount: images.length, images };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Measure a single message, including role-specific content fields
|
|
153
|
+
* (bash command/output, custom summary). Image payload bytes are never added to
|
|
154
|
+
* contentChars.
|
|
155
|
+
*/
|
|
156
|
+
export function measureMessage(message: MessageLike): ContentMetrics {
|
|
157
|
+
const base = measureContentParts(message.content);
|
|
158
|
+
let contentChars = base.contentChars;
|
|
159
|
+
const images = [...base.images];
|
|
160
|
+
|
|
161
|
+
if (message.role === "bashExecution") {
|
|
162
|
+
if (typeof message.command === "string") contentChars += codePointCount(message.command);
|
|
163
|
+
if (typeof message.output === "string") contentChars += codePointCount(message.output);
|
|
164
|
+
}
|
|
165
|
+
if (message.role === "custom" && typeof message.summary === "string") {
|
|
166
|
+
contentChars += codePointCount(message.summary);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { contentChars, imageCount: images.length, images };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Re-index images sequentially across the aggregated list. */
|
|
173
|
+
export function aggregateMetrics(parts: readonly ContentMetrics[]): ContentMetrics {
|
|
174
|
+
let contentChars = 0;
|
|
175
|
+
let imageCount = 0;
|
|
176
|
+
const images: ImageFact[] = [];
|
|
177
|
+
for (const part of parts) {
|
|
178
|
+
contentChars += part.contentChars;
|
|
179
|
+
imageCount += part.imageCount;
|
|
180
|
+
for (const image of part.images) {
|
|
181
|
+
images.push({ ...image, index: images.length });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { contentChars, imageCount, images };
|
|
185
|
+
}
|