@hyperdrive.bot/paseo-protocol 0.3.46 → 0.3.48

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.
@@ -0,0 +1,248 @@
1
+ /**
2
+ * The ONE agent-filter vocabulary and predicate, shared by every surface.
3
+ *
4
+ * ## Why this lives in `protocol`
5
+ *
6
+ * The app has had a complete filter system for months: three-state chips over
7
+ * status / kanban / tag / provider / origin, a three-state archived mode, a
8
+ * created-vs-active time field, nesting date windows plus exact ranges, and a
9
+ * free-text query. The MCP `list_agents` tool had five arguments, two of which
10
+ * did not mean what they read as. An agent driving paseo through MCP or the
11
+ * CLI therefore could not ask most of the questions a human can ask by
12
+ * clicking, which is the gap this module closes.
13
+ *
14
+ * The obvious fix -- reimplement the predicate daemon-side -- is the one this
15
+ * repo has already paid for three times. `screens/workspace/session-tab-filter.ts`
16
+ * documents the incidents: every time a dimension was added or renamed, one of
17
+ * the two hand-rolled copies was updated and the other went quietly inert. The
18
+ * chip stayed lit, the count kept moving, and the list did not change. A third
19
+ * copy behind an RPC would be worse, because nobody is looking at a list while
20
+ * it lies.
21
+ *
22
+ * So the semantics move DOWN into the shared package instead. `packages/app`
23
+ * re-exports these symbols from its existing `utils/*` module paths, so every
24
+ * app call site is unchanged and the app's own tests keep asserting against the
25
+ * same functions; the server imports the same predicate for `list_agents` and
26
+ * the CLI imports it for `agent ls`. One implementation, three surfaces.
27
+ *
28
+ * ## What is deliberately NOT here
29
+ *
30
+ * Anything that only exists because a human is pointing at it: the chip cycle
31
+ * (`cycleFilterValue` is kept because the app's store calls it, but no wire
32
+ * surface uses it), the calendar grid, the two-tap range draft reducer, sort
33
+ * order. Those stay in `packages/app`.
34
+ */
35
+ import type { WorkspaceStateBucket } from "./messages.js";
36
+ /**
37
+ * Which direction a single value is filtering in.
38
+ *
39
+ * `include` is "only this", `exclude` is "everything except this", `neutral` is
40
+ * the value saying nothing.
41
+ */
42
+ export type FilterValueMode = "neutral" | "include" | "exclude";
43
+ /**
44
+ * A multi-pick selection over the values of one dimension.
45
+ *
46
+ * Both halves are REQUIRED on the in-memory shape. An optional `exclude` would
47
+ * read `undefined` at a call site that forgot it, and `undefined` means "off",
48
+ * which is exactly how this system has shipped an inert filter before. Wire
49
+ * schemas may accept partial input, but they normalize into this before it
50
+ * reaches the predicate.
51
+ */
52
+ export interface FilterSelection<T extends string = string> {
53
+ /** Values the reader asked to see exclusively. Empty = no inclusion rule. */
54
+ include: T[];
55
+ /** Values the reader asked to remove. Empty = no exclusion rule. */
56
+ exclude: T[];
57
+ }
58
+ /** The cycle a chip walks: rest -> only -> except -> rest. */
59
+ export declare const FILTER_VALUE_MODES: FilterValueMode[];
60
+ export declare function isFilterValueMode(value: unknown): value is FilterValueMode;
61
+ /** A selection with no value in either half: the "dimension is off" value. */
62
+ export declare function emptyFilterSelection<T extends string = string>(): FilterSelection<T>;
63
+ /** Lifts a plain inclusion array into a selection. */
64
+ export declare function filterSelectionFromInclude<T extends string = string>(include: readonly T[]): FilterSelection<T>;
65
+ /**
66
+ * Builds a selection from two optional halves, which is the shape every wire
67
+ * surface hands over. A value listed in both halves keeps its inclusion, so a
68
+ * malformed request narrows rather than contradicting itself.
69
+ */
70
+ export declare function filterSelectionFrom<T extends string = string>(include: readonly T[] | undefined, exclude: readonly T[] | undefined): FilterSelection<T>;
71
+ /**
72
+ * Re-validates an untrusted value into a selection, keeping only string entries
73
+ * and never letting a value sit in both halves.
74
+ *
75
+ * Persisted app state is the only untrusted input the app has here, and a
76
+ * malformed one must degrade to "off" rather than throw on a screen that is
77
+ * just trying to render a list.
78
+ */
79
+ export declare function normalizeFilterSelection<T extends string = string>(value: unknown): FilterSelection<T>;
80
+ /** The mode `value` currently sits in. Unknown values are neutral by construction. */
81
+ export declare function getFilterValueMode<T extends string>(selection: FilterSelection<T>, value: T): FilterValueMode;
82
+ /** The mode a chip lands on from `current`. Wraps at the end of the cycle. */
83
+ export declare function nextFilterValueMode(current: FilterValueMode): FilterValueMode;
84
+ /** Moves `value` to `mode`, returning a NEW selection. */
85
+ export declare function setFilterValueMode<T extends string>(selection: FilterSelection<T>, value: T, mode: FilterValueMode): FilterSelection<T>;
86
+ /** One chip tap: advance `value` to the next mode in the cycle. */
87
+ export declare function cycleFilterValue<T extends string>(selection: FilterSelection<T>, value: T): FilterSelection<T>;
88
+ /** Returns `value` to neutral. */
89
+ export declare function clearFilterValue<T extends string>(selection: FilterSelection<T>, value: T): FilterSelection<T>;
90
+ /** True when the dimension is narrowing the list at all. */
91
+ export declare function isFilterSelectionActive<T extends string>(selection: FilterSelection<T>): boolean;
92
+ /** Every value the reader has touched, includes first. */
93
+ export declare function filterSelectionEntries<T extends string>(selection: FilterSelection<T>): {
94
+ value: T;
95
+ mode: Exclude<FilterValueMode, "neutral">;
96
+ }[];
97
+ /**
98
+ * Whether a subject with `value` survives this dimension.
99
+ *
100
+ * `null` models a subject with no value in this dimension at all. It cannot
101
+ * satisfy an inclusion rule -- the reader asked for specific values and this is
102
+ * not one of them -- but it is not excluded either, since it matches no
103
+ * excluded value.
104
+ */
105
+ export declare function passesFilterSelection<T extends string>(value: T | null, selection: FilterSelection<T>): boolean;
106
+ /**
107
+ * Whether a subject carrying MANY values in this dimension survives (tags).
108
+ *
109
+ * Include is satisfied by ANY overlap, and exclude drops the subject if ANY of
110
+ * its values is excluded -- "except #wip" has to remove a session tagged
111
+ * `#wip #urgent`, or the chip means nothing on precisely the sessions that
112
+ * carry several tags.
113
+ */
114
+ export declare function passesFilterSelectionMulti<T extends string>(values: readonly T[], selection: FilterSelection<T>): boolean;
115
+ /**
116
+ * `all` shows archived alongside live, `only` shows archived exclusively,
117
+ * `hide` drops them.
118
+ *
119
+ * A boolean cannot express `only`, which is why the wire surfaces take this
120
+ * instead of the `includeArchived` flag they started with.
121
+ */
122
+ export type AgentArchivedMode = "all" | "only" | "hide";
123
+ export declare const AGENT_ARCHIVED_MODES: AgentArchivedMode[];
124
+ export declare function isAgentArchivedMode(value: unknown): value is AgentArchivedMode;
125
+ export declare function nextAgentArchivedMode(current: AgentArchivedMode): AgentArchivedMode;
126
+ export declare function passesAgentArchivedMode(isArchived: boolean, mode: AgentArchivedMode): boolean;
127
+ export declare function isAgentArchivedModeActive(mode: AgentArchivedMode): boolean;
128
+ /** `root` = a human started it; `delegated` = another agent spawned it. */
129
+ export type AgentOrigin = "root" | "delegated";
130
+ export declare const AGENT_ORIGINS: AgentOrigin[];
131
+ export declare function isAgentOrigin(value: unknown): value is AgentOrigin;
132
+ export declare function deriveAgentOrigin(labels: Record<string, string> | null | undefined): AgentOrigin;
133
+ /**
134
+ * Which timestamp the span is measured against.
135
+ *
136
+ * This is a MODE, not a second dimension. AND-ing "created today" with "active
137
+ * today" produces a mostly-empty list; the reader means one question or the
138
+ * other.
139
+ */
140
+ export type AgentTimeField = "created" | "active";
141
+ /**
142
+ * The rolling presets. They NEST (`today` is inside `3d` is inside `7d` ...),
143
+ * which is why the span is single-select: OR-ing two always collapses to the
144
+ * wider one, leaving the narrower one looking selected while doing nothing.
145
+ * `stale` is the strict complement of `30d`, so the two partition the corpus.
146
+ */
147
+ export type AgentTimeWindow = "today" | "3d" | "7d" | "30d" | "stale";
148
+ export declare const AGENT_TIME_FIELDS: AgentTimeField[];
149
+ export declare const AGENT_TIME_WINDOWS: AgentTimeWindow[];
150
+ /**
151
+ * An inclusive span of LOCAL CALENDAR DAYS, `YYYY-MM-DD` on both ends.
152
+ *
153
+ * Stored as days rather than instants on purpose: resolving to instants at
154
+ * store time freezes one timezone's midnight into the filter, so the same saved
155
+ * span covers a different 24 hours after a flight or a DST change.
156
+ */
157
+ export interface AgentDateRange {
158
+ from: string;
159
+ to: string;
160
+ }
161
+ /**
162
+ * Presets and exact dates are ONE dimension in two shapes.
163
+ *
164
+ * A second field beside the window would be able to hold "7 days" AND "March
165
+ * 4" at once -- an intersection that is empty unless March 4 falls inside the
166
+ * week, and whose emptiness no surface can explain to the reader.
167
+ */
168
+ export type AgentTimeSelection = {
169
+ kind: "window";
170
+ window: AgentTimeWindow;
171
+ } | {
172
+ kind: "range";
173
+ range: AgentDateRange;
174
+ };
175
+ export declare function isAgentTimeField(value: unknown): value is AgentTimeField;
176
+ export declare function isAgentTimeWindow(value: unknown): value is AgentTimeWindow;
177
+ /** True for a real `YYYY-MM-DD` calendar day (rejects 2026-02-31). */
178
+ export declare function isAgentDay(value: unknown): value is string;
179
+ export declare function isAgentDateRange(value: unknown): value is AgentDateRange;
180
+ export declare function isAgentTimeSelection(value: unknown): value is AgentTimeSelection;
181
+ export declare function formatAgentDay(date: Date): string;
182
+ export declare function normalizeAgentDateRange(range: AgentDateRange): AgentDateRange;
183
+ export declare function isWithinAgentDateRange(timestamp: Date | null | undefined, range: AgentDateRange): boolean;
184
+ export declare function isWithinAgentTimeWindow(timestamp: Date | null | undefined, window: AgentTimeWindow, now: Date): boolean;
185
+ /** Dispatches on the selection's shape. `null` means the dimension is off. */
186
+ export declare function matchesAgentTimeSelection(timestamp: Date | null | undefined, selection: AgentTimeSelection | null, now: Date): boolean;
187
+ /**
188
+ * Everything one agent contributes to a filtering decision.
189
+ *
190
+ * Built by each surface from whatever record it holds (the app's directory
191
+ * entry, the daemon's list-item payload) so the predicate itself never has to
192
+ * know which one it is looking at.
193
+ */
194
+ export interface AgentFilterSubject {
195
+ /** The derived state bucket. See `deriveAgentStateBucket`. */
196
+ stateBucket: WorkspaceStateBucket;
197
+ /** User tag names, without the `tag:` prefix. */
198
+ tags: string[];
199
+ /** The kanban bucket id from `labels.kanban`, or `null` when unresolved. */
200
+ kanbanBucket: string | null;
201
+ isArchived: boolean;
202
+ /** The timestamp the caller resolved for the active `timeField`. */
203
+ timeValue: Date | null;
204
+ /** Provider id (`claude`, `codex`, `pool-tu`, ...). */
205
+ provider: string;
206
+ origin: AgentOrigin;
207
+ /** Lowercased haystack for the free-text query (title + cwd). */
208
+ searchableText: string;
209
+ }
210
+ /**
211
+ * A complete filter request.
212
+ *
213
+ * Composition: OR within a dimension, AND across dimensions.
214
+ *
215
+ * Note which dimensions carry an `exclude` half and which do not. `stateBucket`
216
+ * and `origin` are CLOSED sets (six values and two), so "except running" is
217
+ * expressible as an include list of the others and an exclude half would buy
218
+ * nothing. `tag`, `kanban` and `provider` are OPEN sets discovered from live
219
+ * data, so their complement cannot be enumerated at all and the exclude half is
220
+ * the only way to say "except this one". They are typed identically for the
221
+ * predicate's benefit; it is the WIRE schemas that decline to offer the
222
+ * exclude half where it adds nothing.
223
+ */
224
+ export interface AgentFilterCriteria {
225
+ stateSelection: FilterSelection<WorkspaceStateBucket>;
226
+ tagSelection: FilterSelection<string>;
227
+ kanbanSelection: FilterSelection<string>;
228
+ archivedMode: AgentArchivedMode;
229
+ timeSelection: AgentTimeSelection | null;
230
+ providerSelection: FilterSelection<string>;
231
+ originSelection: FilterSelection<AgentOrigin>;
232
+ /** Lowercased, trimmed. Empty = no text filter. */
233
+ textQuery: string;
234
+ }
235
+ /** Criteria that keep everything. The starting point for a partial request. */
236
+ export declare function emptyAgentFilterCriteria(): AgentFilterCriteria;
237
+ /** True when any dimension is narrowing the corpus. */
238
+ export declare function isAgentFilterActive(criteria: AgentFilterCriteria): boolean;
239
+ /**
240
+ * Whether `subject` survives every dimension of `criteria`.
241
+ *
242
+ * `now` is a parameter rather than a `new Date()` call so that a caller
243
+ * computing facet counts and a caller filtering the list read the SAME instant.
244
+ * Two independent clock reads can count a boundary session in the chip and drop
245
+ * it from the list, producing a "showing 4 of 5" that never resolves.
246
+ */
247
+ export declare function agentPassesFilter(subject: AgentFilterSubject, criteria: AgentFilterCriteria, now: Date): boolean;
248
+ //# sourceMappingURL=agent-filter.d.ts.map
@@ -0,0 +1,340 @@
1
+ /**
2
+ * The ONE agent-filter vocabulary and predicate, shared by every surface.
3
+ *
4
+ * ## Why this lives in `protocol`
5
+ *
6
+ * The app has had a complete filter system for months: three-state chips over
7
+ * status / kanban / tag / provider / origin, a three-state archived mode, a
8
+ * created-vs-active time field, nesting date windows plus exact ranges, and a
9
+ * free-text query. The MCP `list_agents` tool had five arguments, two of which
10
+ * did not mean what they read as. An agent driving paseo through MCP or the
11
+ * CLI therefore could not ask most of the questions a human can ask by
12
+ * clicking, which is the gap this module closes.
13
+ *
14
+ * The obvious fix -- reimplement the predicate daemon-side -- is the one this
15
+ * repo has already paid for three times. `screens/workspace/session-tab-filter.ts`
16
+ * documents the incidents: every time a dimension was added or renamed, one of
17
+ * the two hand-rolled copies was updated and the other went quietly inert. The
18
+ * chip stayed lit, the count kept moving, and the list did not change. A third
19
+ * copy behind an RPC would be worse, because nobody is looking at a list while
20
+ * it lies.
21
+ *
22
+ * So the semantics move DOWN into the shared package instead. `packages/app`
23
+ * re-exports these symbols from its existing `utils/*` module paths, so every
24
+ * app call site is unchanged and the app's own tests keep asserting against the
25
+ * same functions; the server imports the same predicate for `list_agents` and
26
+ * the CLI imports it for `agent ls`. One implementation, three surfaces.
27
+ *
28
+ * ## What is deliberately NOT here
29
+ *
30
+ * Anything that only exists because a human is pointing at it: the chip cycle
31
+ * (`cycleFilterValue` is kept because the app's store calls it, but no wire
32
+ * surface uses it), the calendar grid, the two-tap range draft reducer, sort
33
+ * order. Those stay in `packages/app`.
34
+ */
35
+ import { isDelegatedAgent } from "./agent-labels.js";
36
+ /** The cycle a chip walks: rest -> only -> except -> rest. */
37
+ export const FILTER_VALUE_MODES = ["neutral", "include", "exclude"];
38
+ export function isFilterValueMode(value) {
39
+ return value === "neutral" || value === "include" || value === "exclude";
40
+ }
41
+ /** A selection with no value in either half: the "dimension is off" value. */
42
+ export function emptyFilterSelection() {
43
+ return { include: [], exclude: [] };
44
+ }
45
+ /** Lifts a plain inclusion array into a selection. */
46
+ export function filterSelectionFromInclude(include) {
47
+ return { include: [...include], exclude: [] };
48
+ }
49
+ /**
50
+ * Builds a selection from two optional halves, which is the shape every wire
51
+ * surface hands over. A value listed in both halves keeps its inclusion, so a
52
+ * malformed request narrows rather than contradicting itself.
53
+ */
54
+ export function filterSelectionFrom(include, exclude) {
55
+ const includes = dedupe(include);
56
+ return { include: includes, exclude: dedupe(exclude).filter((v) => !includes.includes(v)) };
57
+ }
58
+ function dedupe(values) {
59
+ if (!values)
60
+ return [];
61
+ const seen = new Set();
62
+ const out = [];
63
+ for (const value of values) {
64
+ if (typeof value !== "string" || value === "" || seen.has(value))
65
+ continue;
66
+ seen.add(value);
67
+ out.push(value);
68
+ }
69
+ return out;
70
+ }
71
+ /**
72
+ * Re-validates an untrusted value into a selection, keeping only string entries
73
+ * and never letting a value sit in both halves.
74
+ *
75
+ * Persisted app state is the only untrusted input the app has here, and a
76
+ * malformed one must degrade to "off" rather than throw on a screen that is
77
+ * just trying to render a list.
78
+ */
79
+ export function normalizeFilterSelection(value) {
80
+ if (typeof value !== "object" || value === null)
81
+ return emptyFilterSelection();
82
+ const raw = value;
83
+ return filterSelectionFrom(normalizeValueList(raw.include), normalizeValueList(raw.exclude));
84
+ }
85
+ function normalizeValueList(value) {
86
+ return Array.isArray(value) ? dedupe(value.filter((e) => typeof e === "string")) : [];
87
+ }
88
+ /** The mode `value` currently sits in. Unknown values are neutral by construction. */
89
+ export function getFilterValueMode(selection, value) {
90
+ if (selection.include.includes(value))
91
+ return "include";
92
+ if (selection.exclude.includes(value))
93
+ return "exclude";
94
+ return "neutral";
95
+ }
96
+ /** The mode a chip lands on from `current`. Wraps at the end of the cycle. */
97
+ export function nextFilterValueMode(current) {
98
+ const index = FILTER_VALUE_MODES.indexOf(current);
99
+ return FILTER_VALUE_MODES[(index + 1) % FILTER_VALUE_MODES.length] ?? "neutral";
100
+ }
101
+ /** Moves `value` to `mode`, returning a NEW selection. */
102
+ export function setFilterValueMode(selection, value, mode) {
103
+ const include = selection.include.filter((entry) => entry !== value);
104
+ const exclude = selection.exclude.filter((entry) => entry !== value);
105
+ switch (mode) {
106
+ case "include":
107
+ return { include: [...include, value], exclude };
108
+ case "exclude":
109
+ return { include, exclude: [...exclude, value] };
110
+ case "neutral":
111
+ return { include, exclude };
112
+ }
113
+ }
114
+ /** One chip tap: advance `value` to the next mode in the cycle. */
115
+ export function cycleFilterValue(selection, value) {
116
+ return setFilterValueMode(selection, value, nextFilterValueMode(getFilterValueMode(selection, value)));
117
+ }
118
+ /** Returns `value` to neutral. */
119
+ export function clearFilterValue(selection, value) {
120
+ return setFilterValueMode(selection, value, "neutral");
121
+ }
122
+ /** True when the dimension is narrowing the list at all. */
123
+ export function isFilterSelectionActive(selection) {
124
+ return selection.include.length > 0 || selection.exclude.length > 0;
125
+ }
126
+ /** Every value the reader has touched, includes first. */
127
+ export function filterSelectionEntries(selection) {
128
+ return [
129
+ ...selection.include.map((value) => ({ value, mode: "include" })),
130
+ ...selection.exclude.map((value) => ({ value, mode: "exclude" })),
131
+ ];
132
+ }
133
+ /**
134
+ * Whether a subject with `value` survives this dimension.
135
+ *
136
+ * `null` models a subject with no value in this dimension at all. It cannot
137
+ * satisfy an inclusion rule -- the reader asked for specific values and this is
138
+ * not one of them -- but it is not excluded either, since it matches no
139
+ * excluded value.
140
+ */
141
+ export function passesFilterSelection(value, selection) {
142
+ if (selection.include.length > 0 && (value === null || !selection.include.includes(value))) {
143
+ return false;
144
+ }
145
+ if (value !== null && selection.exclude.includes(value))
146
+ return false;
147
+ return true;
148
+ }
149
+ /**
150
+ * Whether a subject carrying MANY values in this dimension survives (tags).
151
+ *
152
+ * Include is satisfied by ANY overlap, and exclude drops the subject if ANY of
153
+ * its values is excluded -- "except #wip" has to remove a session tagged
154
+ * `#wip #urgent`, or the chip means nothing on precisely the sessions that
155
+ * carry several tags.
156
+ */
157
+ export function passesFilterSelectionMulti(values, selection) {
158
+ if (selection.include.length > 0 && !selection.include.some((entry) => values.includes(entry))) {
159
+ return false;
160
+ }
161
+ if (selection.exclude.some((entry) => values.includes(entry)))
162
+ return false;
163
+ return true;
164
+ }
165
+ export const AGENT_ARCHIVED_MODES = ["all", "only", "hide"];
166
+ export function isAgentArchivedMode(value) {
167
+ return value === "all" || value === "only" || value === "hide";
168
+ }
169
+ export function nextAgentArchivedMode(current) {
170
+ const index = AGENT_ARCHIVED_MODES.indexOf(current);
171
+ return AGENT_ARCHIVED_MODES[(index + 1) % AGENT_ARCHIVED_MODES.length] ?? "all";
172
+ }
173
+ export function passesAgentArchivedMode(isArchived, mode) {
174
+ switch (mode) {
175
+ case "only":
176
+ return isArchived;
177
+ case "hide":
178
+ return !isArchived;
179
+ case "all":
180
+ return true;
181
+ }
182
+ }
183
+ export function isAgentArchivedModeActive(mode) {
184
+ return mode !== "all";
185
+ }
186
+ export const AGENT_ORIGINS = ["root", "delegated"];
187
+ export function isAgentOrigin(value) {
188
+ return value === "root" || value === "delegated";
189
+ }
190
+ export function deriveAgentOrigin(labels) {
191
+ return isDelegatedAgent({ labels }) ? "delegated" : "root";
192
+ }
193
+ export const AGENT_TIME_FIELDS = ["created", "active"];
194
+ export const AGENT_TIME_WINDOWS = ["today", "3d", "7d", "30d", "stale"];
195
+ const DAY_MS = 24 * 60 * 60 * 1000;
196
+ const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
197
+ export function isAgentTimeField(value) {
198
+ return value === "created" || value === "active";
199
+ }
200
+ export function isAgentTimeWindow(value) {
201
+ return (value === "today" || value === "3d" || value === "7d" || value === "30d" || value === "stale");
202
+ }
203
+ /** True for a real `YYYY-MM-DD` calendar day (rejects 2026-02-31). */
204
+ export function isAgentDay(value) {
205
+ if (typeof value !== "string" || !DAY_PATTERN.test(value))
206
+ return false;
207
+ const [year, month, day] = value.split("-").map(Number);
208
+ if (year === undefined || month === undefined || day === undefined)
209
+ return false;
210
+ const date = new Date(year, month - 1, day);
211
+ return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day;
212
+ }
213
+ export function isAgentDateRange(value) {
214
+ if (typeof value !== "object" || value === null)
215
+ return false;
216
+ const candidate = value;
217
+ return isAgentDay(candidate.from) && isAgentDay(candidate.to);
218
+ }
219
+ export function isAgentTimeSelection(value) {
220
+ if (typeof value !== "object" || value === null)
221
+ return false;
222
+ const candidate = value;
223
+ if (candidate.kind === "window")
224
+ return isAgentTimeWindow(candidate.window);
225
+ if (candidate.kind === "range")
226
+ return isAgentDateRange(candidate.range);
227
+ return false;
228
+ }
229
+ export function formatAgentDay(date) {
230
+ const year = String(date.getFullYear()).padStart(4, "0");
231
+ const month = String(date.getMonth() + 1).padStart(2, "0");
232
+ const day = String(date.getDate()).padStart(2, "0");
233
+ return `${year}-${month}-${day}`;
234
+ }
235
+ export function normalizeAgentDateRange(range) {
236
+ return range.from <= range.to ? range : { from: range.to, to: range.from };
237
+ }
238
+ function startOfLocalDay(now) {
239
+ const start = new Date(now);
240
+ start.setHours(0, 0, 0, 0);
241
+ return start.getTime();
242
+ }
243
+ function startOfDayMs(day) {
244
+ const [year, month, date] = day.split("-").map(Number);
245
+ return new Date(year ?? 0, (month ?? 1) - 1, date ?? 1, 0, 0, 0, 0).getTime();
246
+ }
247
+ function endOfDayExclusiveMs(day) {
248
+ const [year, month, date] = day.split("-").map(Number);
249
+ return new Date(year ?? 0, (month ?? 1) - 1, (date ?? 1) + 1, 0, 0, 0, 0).getTime();
250
+ }
251
+ export function isWithinAgentDateRange(timestamp, range) {
252
+ if (!timestamp)
253
+ return false;
254
+ const value = timestamp.getTime();
255
+ if (!Number.isFinite(value))
256
+ return false;
257
+ const { from, to } = normalizeAgentDateRange(range);
258
+ return value >= startOfDayMs(from) && value < endOfDayExclusiveMs(to);
259
+ }
260
+ export function isWithinAgentTimeWindow(timestamp, window, now) {
261
+ if (!timestamp)
262
+ return false;
263
+ const value = timestamp.getTime();
264
+ if (!Number.isFinite(value))
265
+ return false;
266
+ const nowMs = now.getTime();
267
+ switch (window) {
268
+ case "today":
269
+ return value >= startOfLocalDay(now);
270
+ case "3d":
271
+ return value >= nowMs - 3 * DAY_MS;
272
+ case "7d":
273
+ return value >= nowMs - 7 * DAY_MS;
274
+ case "30d":
275
+ return value >= nowMs - 30 * DAY_MS;
276
+ case "stale":
277
+ return value < nowMs - 30 * DAY_MS;
278
+ }
279
+ }
280
+ /** Dispatches on the selection's shape. `null` means the dimension is off. */
281
+ export function matchesAgentTimeSelection(timestamp, selection, now) {
282
+ if (selection === null)
283
+ return true;
284
+ return selection.kind === "window"
285
+ ? isWithinAgentTimeWindow(timestamp, selection.window, now)
286
+ : isWithinAgentDateRange(timestamp, selection.range);
287
+ }
288
+ /** Criteria that keep everything. The starting point for a partial request. */
289
+ export function emptyAgentFilterCriteria() {
290
+ return {
291
+ stateSelection: emptyFilterSelection(),
292
+ tagSelection: emptyFilterSelection(),
293
+ kanbanSelection: emptyFilterSelection(),
294
+ archivedMode: "all",
295
+ timeSelection: null,
296
+ providerSelection: emptyFilterSelection(),
297
+ originSelection: emptyFilterSelection(),
298
+ textQuery: "",
299
+ };
300
+ }
301
+ /** True when any dimension is narrowing the corpus. */
302
+ export function isAgentFilterActive(criteria) {
303
+ return (isFilterSelectionActive(criteria.stateSelection) ||
304
+ isFilterSelectionActive(criteria.tagSelection) ||
305
+ isFilterSelectionActive(criteria.kanbanSelection) ||
306
+ isAgentArchivedModeActive(criteria.archivedMode) ||
307
+ criteria.timeSelection !== null ||
308
+ isFilterSelectionActive(criteria.providerSelection) ||
309
+ isFilterSelectionActive(criteria.originSelection) ||
310
+ criteria.textQuery.length > 0);
311
+ }
312
+ /**
313
+ * Whether `subject` survives every dimension of `criteria`.
314
+ *
315
+ * `now` is a parameter rather than a `new Date()` call so that a caller
316
+ * computing facet counts and a caller filtering the list read the SAME instant.
317
+ * Two independent clock reads can count a boundary session in the chip and drop
318
+ * it from the list, producing a "showing 4 of 5" that never resolves.
319
+ */
320
+ export function agentPassesFilter(subject, criteria, now) {
321
+ if (!passesFilterSelection(subject.stateBucket, criteria.stateSelection))
322
+ return false;
323
+ if (!passesFilterSelectionMulti(subject.tags, criteria.tagSelection))
324
+ return false;
325
+ if (!passesFilterSelection(subject.kanbanBucket, criteria.kanbanSelection))
326
+ return false;
327
+ if (!passesAgentArchivedMode(subject.isArchived, criteria.archivedMode))
328
+ return false;
329
+ if (!matchesAgentTimeSelection(subject.timeValue, criteria.timeSelection, now))
330
+ return false;
331
+ if (!passesFilterSelection(subject.provider, criteria.providerSelection))
332
+ return false;
333
+ if (!passesFilterSelection(subject.origin, criteria.originSelection))
334
+ return false;
335
+ if (criteria.textQuery.length > 0 && !subject.searchableText.includes(criteria.textQuery)) {
336
+ return false;
337
+ }
338
+ return true;
339
+ }
340
+ //# sourceMappingURL=agent-filter.js.map