Indexing and Counting Conventions
JP exposes turn positions and counts in two places: the CLI (flags, arguments) and configuration (config files, --cfg, the inline compaction DSL). Internally those same positions are stored as zero-based indices in the conversation stream. This document fixes the convention so the translation between the two sides is consistent and happens in exactly one place per boundary.
The rule
User-facing positions are 1-based. The first turn is turn
1. This holds for every CLI flag and configuration value that names a turn position.Stored and internal positions are 0-based.
Compaction.from_turn,Compaction.to_turn,Turn::index(), andRangeBound::Absoluteare all 0-based and never change. The conversation stream is the source of truth and it counts from zero.Translate at the boundary, once. A 1-based user value becomes a 0-based index at the point where user input is resolved against the stream, and a 0-based index becomes a 1-based display value at the point where it is rendered. Nothing in between carries an ambiguous "is this 0- or 1-based?" value.
jp_conversation carries core values (0-based) and never sees a user value. Everything above it translates at the point where it parses or renders user input, and each such point is listed under "Where the translation lives": jp_cli's flag parsers for --turn/--from/--to, and RuleBound's FromStr/Display for config values and the inline DSL.
One consequence worth knowing before touching RuleBound: because it normalizes at parse time, its FromEnd payload is already a 0-based offset (FromEnd(0) is the last turn, written -1), matching jp_conversation::RangeBound::FromEnd so the two never need a shift between them. RuleBound::Absolute is the exception — it holds the 1-based number as written and is shifted in keep_first_to_bound / keep_last_to_bound.
Positions vs. counts
Only positions (indices into the conversation) are subject to the 1-based rule. A count — "how many turns" — is base-independent and is never shifted.
| Kind | Examples | Translated? |
|---|---|---|
| Position (absolute) | --turn N, --from N, --to N, DSL N..M | yes, N (1-based) → N - 1 (0-based) |
| Position (from end) | --turn -N, --from -N, --to -N, --keep-last -N, DSL -N, config keep_last = "-N" | yes, -1 is the last turn (-N → FromEnd(N - 1)) |
| Count | --first N, --last N, --keep-first N, --keep-last N, config keep_first = N | no |
| Time | 5h, 2days, 2026-01-01, RFC 3339 | resolved against timestamps, then snapped to a turn |
Two consequences worth calling out:
--turn -1,--from -1, and--to -1address the last turn, matching the 1-based reading where1is the first turn and-1is the last. As a result--from -Nselects the same starting turn as--last N, and--turn -2is the second-to-last turn. Either end of a--turnrange may count from the end, and the two forms mix freely:--turn -3..-2is the two turns before the last,--turn 2..-1is turn 2 through the end.-Nis a position in every surface that accepts it, including the compaction DSL and thekeep_first/keep_lastconfig values...-3compacts through the third turn from the end (leaving the final two), andkeep_last = 3is the count that keeps the last three.The attachment selector (
jp query --attach 'ID?a:RANGE') uses the same rules: 1-based,-1is the last turn,A..Binclusive.a:-3is one turn;a:-3..is the last three.A bare integer in a position slot is always a turn number, never a year. The accepted date formats all require separators, so
--from 2026is turn 2026 and--from 2026-01-01is the date.Ranges are written
A..Band are inclusive on both ends —1..5is turns 1 through 5 (five turns). This one format is shared by--turn A..B, the compaction DSL, and the timeline output. Either end may be omitted to mean the conversation start or end (10..,..10,..). Note this diverges from Rust's..(which is exclusive); there is no..=form.
Which bounds go where
A bound is legal in a config rule only if it is conversation-independent — a rule is written once and applied to every conversation, so its bounds have to mean the same thing in a conversation of any length. Counts, durations, from-end positions, and last-compaction all qualify. An absolute turn number does not: "turn 47" is a fact about one conversation.
RuleBound::Absolute is therefore reachable only from the inline DSL, which runs once against a conversation the user is looking at. RuleBound's Serialize refuses it, so it cannot reach a config file even indirectly.
On the CLI the distinction is between selecting and protecting rather than between value forms:
| Flag | Answers | Accepts |
|---|---|---|
--from / --to | which turns | a position (5, -3), a time, last-compaction (--from) |
--keep-first / --keep-last | what to protect | a count (3), a position (-3), a time, last-compaction (--keep-first) |
The two compose: the range flags name the selection, and the keep flags then protect turns at either end of it. A bare N on a keep flag is a count, so --keep-last 3 and --keep-last -4 protect the same three turns.
A bound never splits a turn
Every position form resolves to a whole turn, including the time-based ones. A time value is an addressing mode for a turn, not a cut point inside one: --from <time> starts at the first turn to begin after the cutoff, and --to <time> ends at (and includes) the turn that was running at the cutoff.
This is why the range ends are inclusive rather than half-open. Half-open reads well for a continuous coordinate, but once a bound names a turn, "which turn" is inherently inclusive. The conversation-creation filter on jp c rm / jp c archive / jp c use is the opposite case — it compares raw timestamps and never snaps to anything — so it is half-open and uses distinct flag names (--created-since / --created-before).
Where the translation lives
CLI turn selection (
jp_cli::cmd::turn_selection):parse_boundmaps a 1-based absoluteNtoRangeBound::Absolute(N - 1)and a from-end-NtoRangeBound::FromEnd(N - 1).--turnendpoints go throughparse_turn_pos, which produces the same two flavours (TurnPos::Absolute/TurnPos::FromEnd) holding the number as written;TurnPos::to_range_bounddoes the 1-based → 0-based shift.TurnSelection::resolvethen resolves every bound against the stream and produces aTurnSetof inclusive 0-based windows.--turn,--from/--to, and--first/--lastare three ways of naming the base selection and are mutually exclusive:Selector Start bound End bound --turn NAbsolute(N - 1)Absolute(N - 1)--turn -NFromEnd(N - 1)FromEnd(N - 1)--turn A..BAbsolute(A - 1)Absolute(B - 1)--first NAbsolute(0)Absolute(N - 1)--last NFromEnd(N - 1)FromEnd(0)Either end of
--turn A..Bmay be a from-end position, and the two forms mix freely (--turn 2..-1).--firstand--lastgiven together produce two windows and skip the turns between them.Keep flags (
jp_cli::cmd::turn_selection):keep_first_bound/keep_last_boundmapRuleBound::Absolute(N)(the 1-based value parsed from@N) toRangeBound::Absolute(N - 1); theRuleBound::Turns(N)(count) arm is untouched.TurnSelection::trimthen clamps each window to the turns those bounds leave unprotected — it clamps rather than shifts, so a turn already outside the window needs no protecting.Config
keep_first/keep_lastand the inline DSL:RuleBound'sFromStr(jp_config::conversation::compaction) andparse_dsl_bound(jp_cli::cmd::compact_flag) both map-NtoRuleBound::FromEnd(N - 1).The two parsers differ on a bare
N: in config it is a count (keep_first = 5keeps five turns), in the DSL it is a position (5..starts at turn 5). Only the DSL producesRuleBound::Absolute; see "Which bounds go where".Timeline output (
jp_cli::cmd::conversation::compact::timeline_lines): the stored 0-basedfrom_turn/to_turnare rendered as 1-based, e.g.Compacted turns 2..8.
When adding a new flag, config key, or output that names a turn, decide first whether it is a position or a count. If it is a position, it is 1-based on the user side and translated to 0-based exactly where it meets the stream.