@astrosheep/square 0.3.97 → 0.3.99

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,4 @@
1
+ import type { ActivityFilter } from './ports.js';
2
+ import type { ActivityQuery } from './square-facade.js';
3
+ /** Both public read paths compile the same filters; neither resolves senders through membership. */
4
+ export declare function activityQueryFilter(query: ActivityQuery, extraFields?: readonly string[]): ActivityFilter;
@@ -0,0 +1,51 @@
1
+ import { SquareError, validateName } from './model.js';
2
+ import { parseActivityId } from './square-core.js';
3
+ import { validateMetadataMatch } from './delivery-deferral.js';
4
+ import { compileSearchPattern } from './search.js';
5
+ /** Both public read paths compile the same filters; neither resolves senders through membership. */
6
+ export function activityQueryFilter(query, extraFields = []) {
7
+ const fail = (message) => { throw new SquareError('invalid_args', message); };
8
+ const fields = ['at', 'from', 'match', 'search', 'cursor', 'limit', ...extraFields];
9
+ if (typeof query !== 'object' || query === null || Array.isArray(query))
10
+ fail('Activity query must be an object.');
11
+ for (const field of Object.keys(query))
12
+ if (!fields.includes(field))
13
+ fail(`Unknown activity query field: ${field}`);
14
+ const indexes = query.at === undefined ? undefined : !Array.isArray(query.at) ? fail('Query at must be an array of activity IDs.')
15
+ : Array.from(query.at, id => {
16
+ const index = parseActivityId(id);
17
+ return index === undefined ? fail(`Invalid activity id: ${id}`) : index;
18
+ });
19
+ const actors = query.from === undefined ? undefined : !Array.isArray(query.from) ? fail('Query from must be an array of sender names.')
20
+ : Array.from(query.from, name => {
21
+ if (typeof name !== 'string')
22
+ fail('Query from must contain sender names.');
23
+ validateName(name);
24
+ return name;
25
+ });
26
+ const metadataMatch = query.match === undefined ? undefined : validateMetadataMatch(query.match);
27
+ let afterIndex, beforeIndex;
28
+ if (query.cursor !== undefined) {
29
+ const cursor = query.cursor;
30
+ if (typeof cursor !== 'object' || cursor === null || Array.isArray(cursor)
31
+ || !['before', 'after'].includes(cursor.direction) || Object.keys(cursor).some(key => !['id', 'direction'].includes(key)))
32
+ fail('Query cursor requires an activity ID and before/after direction.');
33
+ const index = parseActivityId(cursor.id);
34
+ if (index === undefined)
35
+ fail(`Invalid activity id: ${cursor.id}`);
36
+ if (cursor.direction === 'after')
37
+ afterIndex = index;
38
+ else
39
+ beforeIndex = index;
40
+ }
41
+ let search = query.search;
42
+ if (search !== undefined) {
43
+ if (typeof search !== 'object' || search === null || Array.isArray(search)
44
+ || typeof search.text !== 'string' || search.text === '' || !['literal', 'regex'].includes(search.mode)
45
+ || Object.keys(search).some(key => !['text', 'mode'].includes(key)))
46
+ fail('Query search requires non-empty text and literal/regex mode.');
47
+ compileSearchPattern(search.text, search.mode === 'literal');
48
+ search = { text: search.text, mode: search.mode };
49
+ }
50
+ return { indexes, actors, metadataMatch, search, afterIndex, beforeIndex };
51
+ }
package/dist/channel.d.ts CHANGED
@@ -2,7 +2,7 @@ import { type HarnessSessionChannel } from './participant-identity.js';
2
2
  import type { SquareResourceOwner, WakeTransportPort } from './ports.js';
3
3
  import type { ActivityId, ChannelSendInput, ChannelSendResult, DeliveryDeferral, HeldSeat, MetadataMatch, OperationControl, Owner, Participant, WakeAddress, PendingOptions, PendingResult, AckResult } from './square-facade.js';
4
4
  export { harnessOwner, decodeHarnessOwner, localUserOwner } from './owner.js';
5
- export type { ActivityDelivery, ActivityMetadata, ChannelTruncate, PerceivedActivity, PendingOptions, PendingResult, AckResult } from './square-facade.js';
5
+ export type { ActivityDelivery, ActivityMetadata, ActivityQuery, ActivitySearch, ActivityCursor, ChannelTruncate, PerceivedActivity, PendingOptions, PendingResult, AckResult } from './square-facade.js';
6
6
  export type { ActivityId, ChannelSendInput, ChannelSendResult, DeliveryDeferral, HeldSeat, MetadataMatch, OperationControl, Owner, WakeAddress, SquareResourceOwner, WakeTransportPort, HarnessSessionChannel };
7
7
  export interface ResolvedHarnessSession {
8
8
  readonly channel: HarnessSessionChannel;
@@ -375,8 +375,11 @@ function parseHistory(argv, context) {
375
375
  if (before !== undefined && after !== undefined)
376
376
  fail('--before and --after cannot be combined.');
377
377
  return {
378
- query: { limit, from: participants, since, after, before, at: at.length === 0 ? undefined : at,
379
- contextBefore: beforeContext, contextAfter: afterContext, mention, grep, fixed, order },
378
+ query: { limit, from: participants.length === 0 ? undefined : participants, since,
379
+ cursor: after !== undefined ? { id: after, direction: 'after' } : before === undefined ? undefined : { id: before, direction: 'before' },
380
+ at: at.length === 0 ? undefined : at,
381
+ contextBefore: beforeContext, contextAfter: afterContext, mention, order,
382
+ search: grep !== undefined ? { text: grep, mode: 'regex' } : fixed === undefined ? undefined : { text: fixed, mode: 'literal' } },
380
383
  noTruncate, format,
381
384
  continuationArgs: historyContinuationArgs(argv),
382
385
  };
@@ -155,10 +155,8 @@ function historyQueryArgs(query) {
155
155
  args.push('-A', String(query.contextAfter));
156
156
  if (query.mention !== undefined)
157
157
  args.push('--mention', query.mention);
158
- if (query.grep !== undefined)
159
- args.push('--grep', query.grep);
160
- if (query.fixed !== undefined)
161
- args.push('--fixed', query.fixed);
158
+ if (query.search !== undefined)
159
+ args.push(query.search.mode === 'regex' ? '--grep' : '--fixed', query.search.text);
162
160
  if (query.order !== undefined)
163
161
  args.push('--order', query.order);
164
162
  return args;
@@ -169,10 +167,10 @@ export function renderHistoryResult(squarePath, data, options = {}) {
169
167
  return undefined;
170
168
  const query = options.query ?? {};
171
169
  const noTruncate = options.noTruncate === true;
172
- const pattern = query.grep ?? query.fixed;
173
- const output = pattern === undefined || pattern === ''
170
+ const search = query.search;
171
+ const output = search === undefined
174
172
  ? renderHistoryProjection(projection, projection.visibleActivities, noTruncate, squarePath)
175
- : renderGrepActivitiesView(projection.visibleActivities, projection.totalMatches, noTruncate, squarePath, pattern, query.fixed !== undefined, () => 'full');
173
+ : renderGrepActivitiesView(projection.visibleActivities, projection.totalMatches, noTruncate, squarePath, search.text, search.mode === 'literal', () => 'full');
176
174
  const next = projection.continuation;
177
175
  const base = options.continuationArgs ?? historyQueryArgs(query);
178
176
  const continuation = next === undefined ? '' : `\n\n${historyContinuationCommand(squarePath, [...base, `--${next.direction}`, next.cursor, '--limit', String(next.limit)])}`;
package/dist/ports.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Act, ActivityId, Owner, HardCap, Seat, StoredAct, WakeAddress, WakeRoute, WatchLease } from './model.js';
2
2
  import type { AttemptOutcome } from './delivery-attempts.js';
3
3
  import type { MetadataMatch, DeliveryDeferralFact } from './delivery-deferral.js';
4
+ import type { ActivitySearch } from './square-facade.js';
4
5
  export type ActivityIndex = number;
5
6
  export interface SquareHeader {
6
7
  readonly revision: number;
@@ -49,6 +50,9 @@ export interface ActivityFilter {
49
50
  readonly actors?: readonly string[];
50
51
  readonly actorMatch?: 'identity' | 'exact';
51
52
  readonly afterAt?: number;
53
+ /** Shared query predicates, applied before paging or counting. */
54
+ readonly metadataMatch?: MetadataMatch;
55
+ readonly search?: ActivitySearch;
52
56
  }
53
57
  export interface IndexPage {
54
58
  readonly edge: 'oldest' | 'newest';
@@ -58,13 +62,8 @@ export interface FactPage<T> {
58
62
  readonly items: readonly T[];
59
63
  readonly hasMore: boolean;
60
64
  }
61
- export interface DirectedFilter {
62
- readonly afterIndex?: number;
63
- readonly beforeIndex?: number;
64
- readonly throughIndex?: number;
65
+ export interface DirectedFilter extends Omit<ActivityFilter, 'actors' | 'actorMatch'> {
65
66
  readonly unseenOnly?: true;
66
- /** Exact own-property scalar equalities, applied before paging or counting. */
67
- readonly metadataMatch?: MetadataMatch;
68
67
  readonly senders?: readonly string[];
69
68
  readonly attentionOnly?: true;
70
69
  }
package/dist/search.js CHANGED
@@ -1,7 +1,6 @@
1
- // Shared activity-body search semantics for filtering and presentation.
2
- // Regex mode intentionally uses JavaScript's built-in engine: patterns come from
3
- // the local CLI user and are not treated as a hostile multi-tenant input. Use
4
- // --fixed for literal text; do not claim regex mode has ReDoS protection.
1
+ // Shared case-insensitive search semantics for storage filtering and presentation.
2
+ // Regex mode intentionally uses JavaScript's built-in engine, without a ReDoS
3
+ // timeout. Callers should use literal mode for plain text and avoid untrusted regexes.
5
4
  import { SquareError } from './model.js';
6
5
  export function compileSearchPattern(pattern, fixed) {
7
6
  if (fixed)
@@ -11,7 +10,7 @@ export function compileSearchPattern(pattern, fixed) {
11
10
  }
12
11
  catch (err) {
13
12
  const detail = err instanceof Error ? err.message.replace(/^Invalid regular expression:\s*/i, '') : 'invalid expression';
14
- throw new SquareError('invalid_args', `Invalid --grep regex: ${detail}`);
13
+ throw new SquareError('invalid_args', `Invalid search regex: ${detail}`);
15
14
  }
16
15
  }
17
16
  function compactWhitespace(text) {
@@ -1,6 +1,7 @@
1
1
  import { formatActivityId, MAX_IDENTITY_SET_SIZE } from './square-core.js';
2
2
  import { assertCurrentSeat } from './square-domain.js';
3
3
  import { canonicalPath } from './canonical-path.js';
4
+ import { activityQueryFilter } from './activity-query.js';
4
5
  import { validateMetadataMatch, validateDeferralDuration, validateDeferralId } from './delivery-deferral.js';
5
6
  import { squareLocation } from './square-location.js';
6
7
  import crypto from 'node:crypto';
@@ -346,14 +347,13 @@ export class SqliteSquare {
346
347
  }
347
348
  async pending(options = {}, control) {
348
349
  checkControl(control);
349
- const limit = options.limit ?? 100;
350
+ const limit = options.limit === undefined ? 100 : options.limit;
350
351
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)
351
352
  throw new SquareError('invalid_args', 'Pending limit must be a positive integer no greater than 100.');
352
- const match = options.match === undefined ? undefined : validateMetadataMatch(options.match);
353
- const square = this.owner;
353
+ const { actors, ...query } = activityQueryFilter(options), square = this.owner;
354
354
  return (await square.artifact.read(facts => {
355
355
  assertCurrentSeat(facts, asSeatRef(this.seat));
356
- const filter = { unseenOnly: true, ...(match === undefined ? {} : { metadataMatch: match }) };
356
+ const filter = { ...query, senders: actors, unseenOnly: true };
357
357
  const heads = facts.directed(this.name, filter, { edge: 'oldest', limit });
358
358
  const activities = facts.activities(heads.items.map(head => head.index))
359
359
  .map(activity => toPerceivedActivity(facts, activity, this.name));
@@ -566,10 +566,8 @@ export class SqliteSquare {
566
566
  async history(query = {}, control) {
567
567
  if (query.limit !== undefined && query.limit !== null && (!Number.isSafeInteger(query.limit) || query.limit < 1 || query.limit > HISTORY_MAX_LIMIT))
568
568
  throw new SquareError('invalid_args', `History limit must be a positive integer no greater than ${HISTORY_MAX_LIMIT}`);
569
- if (query.grep !== undefined && query.fixed !== undefined)
570
- throw new SquareError('invalid_args', 'History cannot combine regular-expression and literal search');
571
- if (query.grep === '' || query.fixed === '')
572
- throw new SquareError('invalid_args', 'History search requires non-empty text');
569
+ if (query.order !== undefined && query.order !== 'asc' && query.order !== 'desc')
570
+ throw new SquareError('invalid_args', 'History order must be asc or desc.');
573
571
  if (query.since !== undefined && !Number.isFinite(query.since))
574
572
  throw new SquareError('invalid_args', 'History timestamp must be finite');
575
573
  for (const count of [query.contextBefore, query.contextAfter])
@@ -86,8 +86,23 @@ export interface CatchOptions extends ObservationOptions {
86
86
  readonly limit?: number;
87
87
  readonly replace?: boolean;
88
88
  }
89
- export interface PendingOptions {
89
+ export interface ActivitySearch {
90
+ readonly text: string;
91
+ readonly mode: 'literal' | 'regex';
92
+ }
93
+ export interface ActivityCursor {
94
+ readonly id: ActivityId;
95
+ readonly direction: 'before' | 'after';
96
+ }
97
+ /** Shared filters; absent arrays are unrestricted, empty arrays match nothing. */
98
+ export interface ActivityQuery {
99
+ readonly at?: readonly ActivityId[];
100
+ readonly from?: readonly string[];
90
101
  readonly match?: MetadataMatch;
102
+ readonly search?: ActivitySearch;
103
+ readonly cursor?: ActivityCursor;
104
+ }
105
+ export interface PendingOptions extends ActivityQuery {
91
106
  /** Oldest first; defaults to 100, maximum 100. */
92
107
  readonly limit?: number;
93
108
  }
@@ -140,18 +155,12 @@ export interface OperationFailureDetail {
140
155
  export interface OperationError extends Error {
141
156
  readonly operation?: OperationFailureDetail;
142
157
  }
143
- export interface HistoryQuery extends ObservationOptions {
158
+ export interface HistoryQuery extends ActivityQuery, ObservationOptions {
144
159
  /** Ten by default; null explicitly reads the full archive. */
145
160
  readonly limit?: number | null;
146
161
  readonly order?: 'asc' | 'desc';
147
- readonly before?: ActivityId;
148
- readonly after?: ActivityId;
149
- readonly grep?: string;
150
- readonly fixed?: string;
151
- readonly from?: readonly string[];
152
162
  readonly mention?: string;
153
163
  readonly since?: number;
154
- readonly at?: readonly ActivityId[];
155
164
  readonly contextBefore?: number;
156
165
  readonly contextAfter?: number;
157
166
  }
@@ -5,6 +5,8 @@ import { deriveLanding, landingFacts } from './square-domain.js';
5
5
  import { claimAttempt, prepareAttempt, settleAttempt } from './delivery-attempts.js';
6
6
  import { legacyRowFramingIssue, legacyActivitySchema } from './square-legacy-framing.js';
7
7
  import { receive, recordConsumed } from './receive.js';
8
+ import { formatActivityId } from './square-core.js';
9
+ import { compileSearchPattern } from './search.js';
8
10
  export function emptyRuntimeState(nextActIndex = 0) {
9
11
  return { nextActIndex, observations: {}, leases: {} };
10
12
  }
@@ -506,6 +508,30 @@ function activitySelection(filter, alias = 'a') {
506
508
  add(`${alias}.${column} IN (SELECT value FROM json_each(?))`, encode(values));
507
509
  }
508
510
  }
511
+ if (filter.metadataMatch !== undefined) {
512
+ // Walk literal object keys, never JSON-path strings or array indexes.
513
+ terms.push(`NOT EXISTS (
514
+ SELECT 1 FROM json_each(?) field WHERE NOT EXISTS (
515
+ WITH RECURSIVE walk(value,type,depth) AS (
516
+ SELECT json_extract(${alias}.payload_json,'$.metadata'),json_type(${alias}.payload_json,'$.metadata'),0
517
+ UNION ALL
518
+ SELECT child.value,child.type,walk.depth+1
519
+ FROM walk JOIN json_each(CASE WHEN walk.type='object' THEN walk.value ELSE '{}' END) child
520
+ ON child.key=json_extract(field.value,'$.path['||walk.depth||']')
521
+ WHERE walk.depth<json_array_length(field.value,'$.path')
522
+ )
523
+ SELECT 1 FROM walk WHERE depth=json_array_length(field.value,'$.path')
524
+ AND (type=json_type(field.value,'$.equals')
525
+ OR type IN ('integer','real') AND json_type(field.value,'$.equals') IN ('integer','real'))
526
+ AND value IS json_extract(field.value,'$.equals')
527
+ )
528
+ )`);
529
+ args.push(encode(filter.metadataMatch.fields));
530
+ }
531
+ if (filter.search !== undefined) {
532
+ terms.push(`square_search(?,?,${alias}.act_index,${alias}.actor_json,json_extract(${alias}.payload_json,'$.body'))=1`);
533
+ args.push(filter.search.mode, filter.search.text);
534
+ }
509
535
  return { sql: terms.length ? terms.join(' AND ') : '1', args };
510
536
  }
511
537
  function pageRows(rows, page, decode) {
@@ -525,12 +551,28 @@ export function createSqliteDriver(db) {
525
551
  const all = (sql, args = []) => db.prepare(sql).all(...args);
526
552
  const one = (sql, args = []) => db.prepare(sql).get(...args);
527
553
  const run = (sql, args = []) => Number(db.prepare(sql).run(...args).changes);
554
+ let searchRegistered = false;
555
+ function selectActivity(filter) {
556
+ if (filter.search !== undefined && !searchRegistered) {
557
+ const patterns = new Map();
558
+ db.function('square_search', { deterministic: true }, (mode, text, index, actor, body) => {
559
+ const key = encode([mode, text]);
560
+ let pattern = patterns.get(key);
561
+ if (pattern === undefined) {
562
+ pattern = compileSearchPattern(string(text), mode === 'literal');
563
+ patterns.set(key, pattern);
564
+ }
565
+ return Number([formatActivityId(integer(index)), decoded(actor, true), body === null ? '' : string(body)]
566
+ .some(field => pattern.test(field)));
567
+ });
568
+ searchRegistered = true;
569
+ }
570
+ return activitySelection(filter);
571
+ }
528
572
  const owner = (requested) => read.participant(requested)?.name ?? requested;
529
573
  const attentionRows = (condition, args) => all(`SELECT t.*,p.name_json FROM attention t JOIN participants p ON p.name_key_json=t.listener_key_json WHERE ${condition} ORDER BY t.since_index`, args).map(attention);
530
574
  function directedSelection(recipient, filter) {
531
- const p = read.participant(recipient), base = activitySelection({
532
- afterIndex: filter.afterIndex, beforeIndex: filter.beforeIndex, throughIndex: filter.throughIndex, actors: filter.senders
533
- });
575
+ const p = read.participant(recipient), base = selectActivity({ ...filter, actors: filter.senders });
534
576
  base.sql += ' AND d.recipient_key_json=?';
535
577
  base.args.push(p ? key(p.name) : '');
536
578
  if (filter.unseenOnly) {
@@ -539,27 +581,6 @@ export function createSqliteDriver(db) {
539
581
  }
540
582
  if (filter.attentionOnly)
541
583
  base.sql += " AND d.route IN ('mention','reply','bell')";
542
- if (filter.metadataMatch !== undefined) {
543
- // Walk object keys rather than JSON-path strings: dots, quotes and array-like
544
- // keys are literal, and arrays must never act as metadata objects.
545
- base.sql += ` AND NOT EXISTS (
546
- SELECT 1 FROM json_each(?) field WHERE NOT EXISTS (
547
- WITH RECURSIVE walk(value,type,depth) AS (
548
- SELECT json_extract(a.payload_json,'$.metadata'),json_type(a.payload_json,'$.metadata'),0
549
- UNION ALL
550
- SELECT child.value,child.type,walk.depth+1
551
- FROM walk JOIN json_each(CASE WHEN walk.type='object' THEN walk.value ELSE '{}' END) child
552
- ON child.key=json_extract(field.value,'$.path['||walk.depth||']')
553
- WHERE walk.depth<json_array_length(field.value,'$.path')
554
- )
555
- SELECT 1 FROM walk WHERE depth=json_array_length(field.value,'$.path')
556
- AND (type=json_type(field.value,'$.equals')
557
- OR type IN ('integer','real') AND json_type(field.value,'$.equals') IN ('integer','real'))
558
- AND value IS json_extract(field.value,'$.equals')
559
- )
560
- )`;
561
- base.args.push(encode(filter.metadataMatch.fields));
562
- }
563
584
  return base;
564
585
  }
565
586
  function directedHead(row, recipient) {
@@ -625,7 +646,7 @@ export function createSqliteDriver(db) {
625
646
  return r ? activity(r) : undefined;
626
647
  },
627
648
  activityHeads(filter, page) {
628
- const s = activitySelection(filter), limit = pageLimit(page);
649
+ const s = selectActivity(filter), limit = pageLimit(page);
629
650
  return pageRows(all(`SELECT a.act_index,a.at,a.kind,a.actor_json,a.actor_key_json,a.reach,a.reply_index FROM activities a WHERE ${s.sql} ORDER BY a.act_index ${page.edge === 'newest' ? 'DESC' : 'ASC'} LIMIT ?`, [...s.args, limit]), page, head);
630
651
  },
631
652
  activities(indexes) {
@@ -633,7 +654,7 @@ export function createSqliteDriver(db) {
633
654
  return immutable(all('SELECT * FROM activities WHERE act_index IN (SELECT value FROM json_each(?)) ORDER BY act_index', [encode(indexes)]).map(activity));
634
655
  },
635
656
  activityCount(filter) {
636
- const s = activitySelection(filter);
657
+ const s = selectActivity(filter);
637
658
  return integer(one(`SELECT COUNT(*) AS n FROM activities a WHERE ${s.sql}`, s.args).n);
638
659
  },
639
660
  contextIndexes(centers, before, after) {
@@ -28,6 +28,16 @@ export function squareToolsEnabled(env = process.env) {
28
28
  throw new SquareError('invalid_args', 'Invalid SQUARE_TOOLS: expected 0, 1, true, false, or none.');
29
29
  }
30
30
  export const SQUARE_TOOL_INSTRUCTIONS = 'Join once. Then express to speak, and catch to take in what reached you. History looks back without taking anything in.';
31
+ function historyToolQuery({ grep, fixed, before, after, ...query }) {
32
+ if (grep !== undefined && fixed !== undefined)
33
+ throw new SquareError('invalid_args', 'Use grep or fixed, not both.');
34
+ if (before !== undefined && after !== undefined)
35
+ throw new SquareError('invalid_args', 'Use before or after, not both.');
36
+ return { ...query,
37
+ search: grep !== undefined ? { text: grep, mode: 'regex' } : fixed === undefined ? undefined : { text: fixed, mode: 'literal' },
38
+ cursor: after !== undefined ? { id: after, direction: 'after' } : before === undefined ? undefined : { id: before, direction: 'before' },
39
+ };
40
+ }
31
41
  const target = z.string().describe('The participant to turn toward or away from.');
32
42
  export const SQUARE_TOOLS = [
33
43
  tool('join', 'Step into the square under your name. Do this once before you speak or catch. You arrive seeing what was said most recently.', {
@@ -65,7 +75,7 @@ export const SQUARE_TOOLS = [
65
75
  fixed: z.string().optional().describe('Only activities containing this exact text.'),
66
76
  from: stringArray.optional().describe('Only what these participants said.'),
67
77
  mention: z.string().optional().describe('Only what was addressed to this participant.'),
68
- }, (square, args, control) => square.history({ ...args, observe: true }, control), rendering((result, args) => renderHistoryResult(result.observation?.location ?? '', result, { query: args }))),
78
+ }, (square, args, control) => square.history({ ...historyToolQuery(args), observe: true }, control), rendering((result, args) => renderHistoryResult(result.observation?.location ?? '', result, { query: historyToolQuery(args) }))),
69
79
  tool('attention', 'Turn toward someone or away from them, from now on. Listen: hear everything they say, even to others. Unlisten: stop listening without clearing an ignore; mentions and replies still reach you unless ignored. Ignore: hear nothing from them but bells. What was already said stays as it was.', {
70
80
  as: participant, action: z.enum(['listen', 'unlisten', 'ignore']).describe('"listen", "unlisten" or "ignore". Listening again ends an ignore.'), target,
71
81
  }, async (square, { as, action, target }, control, identity) => {
package/dist/views.d.ts CHANGED
@@ -9,7 +9,7 @@ export declare const NON_READ: readonly ["join", "done", "say", "listen", "ignor
9
9
  export declare function toPublicActivity(stored: StoredAct): Activity;
10
10
  export declare function toPerceivedActivity(facts: SquareRead, stored: StoredAct, viewer: string): PerceivedActivity;
11
11
  export declare function parseRequiredActivityId(id: ActivityId): number;
12
- /** Compile the public query directly to typed selectors; only search hydrates unselected bodies. */
12
+ /** History adds archive context and ordering to the shared storage-side query predicates. */
13
13
  export declare function selectHistory(facts: SquareRead, query: HistoryQuery, limit: number | null, kinds: ActivityFilter['kinds'], presentation?: boolean): {
14
14
  continuation?: HistoryContinuation | undefined;
15
15
  activities: StoredAct[];
package/dist/views.js CHANGED
@@ -3,7 +3,7 @@ import { compareParticipantActivity, coreParticipants, resolveKnownName, unreadA
3
3
  import { SquareError, nameKey } from './model.js';
4
4
  import { inSquareCount } from './runtime.js';
5
5
  import { perceiveActivity } from './perception-projection.js';
6
- import { compileSearchPattern } from './search.js';
6
+ import { activityQueryFilter } from './activity-query.js';
7
7
  const PAGE_SIZE = 100;
8
8
  export const HISTORY_DEFAULT_LIMIT = 10;
9
9
  export const HISTORY_MAX_LIMIT = 100;
@@ -34,30 +34,26 @@ export function parseRequiredActivityId(id) {
34
34
  throw new SquareError('invalid_args', `Invalid activity id: ${id}`);
35
35
  return index;
36
36
  }
37
- /** Compile the public query directly to typed selectors; only search hydrates unselected bodies. */
37
+ /** History adds archive context and ordering to the shared storage-side query predicates. */
38
38
  export function selectHistory(facts, query, limit, kinds, presentation = true) {
39
- if (query.before !== undefined && query.after !== undefined)
40
- throw new SquareError('invalid_args', 'History cannot combine before and after cursors');
41
- const afterIndex = query.after === undefined ? undefined : parseRequiredActivityId(query.after);
42
- const beforeIndex = query.before === undefined ? undefined : parseRequiredActivityId(query.before);
43
- const actors = (query.from ?? []).map((name) => resolveKnownName(facts, name));
39
+ const shared = activityQueryFilter(query, ['observe', 'order', 'mention', 'since', 'contextBefore', 'contextAfter']);
40
+ const { afterIndex } = shared;
44
41
  const recipient = query.mention == null ? undefined : resolveKnownName(facts, query.mention);
45
- const pattern = query.grep ?? query.fixed;
46
- const search = pattern === undefined || pattern === '' ? undefined : compileSearchPattern(pattern, query.grep === undefined);
47
42
  if (limit !== null && (!Number.isSafeInteger(limit) || limit < 1))
48
43
  throw new SquareError('invalid_args', 'History limit must be a positive integer');
49
44
  const filter = {
50
- ...(actors.length === 0 ? {} : { actors }), afterIndex, beforeIndex, afterAt: query.since,
51
- ...(query.at?.length ? { indexes: facts.contextIndexes(query.at.map(parseRequiredActivityId), query.contextBefore ?? 0, query.contextAfter ?? 0) } : {}),
52
- kinds: search === undefined ? kinds : ['say', 'done'],
45
+ ...shared, afterAt: query.since,
46
+ ...(shared.indexes === undefined ? {} : { indexes: facts.contextIndexes(shared.indexes, query.contextBefore ?? 0, query.contextAfter ?? 0) }),
47
+ kinds: query.search === undefined ? kinds : ['say', 'done'],
53
48
  };
54
49
  let selected, totalMatches, publicBefore = false, publicAfter = false;
55
- if (recipient === undefined && search === undefined && limit !== null && limit <= PAGE_SIZE) {
50
+ if (recipient === undefined && limit !== null && limit <= PAGE_SIZE) {
56
51
  selected = facts.activityHeads(filter, { edge: afterIndex === undefined ? 'newest' : 'oldest', limit }).items;
57
52
  totalMatches = facts.activityCount(filter);
58
53
  if (presentation) {
59
54
  const publicHeads = selected.filter((head) => ARCHIVE.has(head.kind));
60
- const publicFilter = { ...filter, kinds: [...ARCHIVE] };
55
+ const publicFilter = { ...filter, kinds: filter.kinds === undefined
56
+ ? [...ARCHIVE] : filter.kinds.filter(kind => ARCHIVE.has(kind)) };
61
57
  publicBefore = publicHeads.length > 0 && facts.activityCount({ ...publicFilter, beforeIndex: publicHeads[0].index }) > 0;
62
58
  publicAfter = publicHeads.length > 0 && facts.activityCount({ ...publicFilter, afterIndex: publicHeads.at(-1).index }) > 0;
63
59
  }
@@ -69,13 +65,10 @@ export function selectHistory(facts, query, limit, kinds, presentation = true) {
69
65
  while (true) {
70
66
  const source = recipient === undefined
71
67
  ? facts.activityHeads({ ...filter, afterIndex: cursor }, { edge: 'oldest', limit: PAGE_SIZE })
72
- : facts.directed(recipient, { afterIndex: cursor, beforeIndex }, { edge: 'oldest', limit: PAGE_SIZE });
68
+ : facts.directed(recipient, { ...filter, senders: filter.actors, afterIndex: cursor }, { edge: 'oldest', limit: PAGE_SIZE });
73
69
  const heads = recipient === undefined ? source.items : source.items.length === 0 ? []
74
70
  : facts.activityHeads({ ...filter, indexes: source.items.map((head) => head.index).filter((index) => filter.indexes === undefined || filter.indexes.includes(index)) }, { edge: 'oldest', limit: PAGE_SIZE }).items;
75
- const matches = search === undefined ? undefined : new Set(facts.activities(heads.map((head) => head.index)).filter((act) => [formatActivityId(act.index), act.actor ?? '', 'body' in act ? act.body ?? '' : ''].some((field) => search.test(field))).map((act) => act.index));
76
71
  for (const head of heads) {
77
- if (matches !== undefined && !matches.has(head.index))
78
- continue;
79
72
  totalMatches++;
80
73
  if (presentation && ARCHIVE.has(head.kind)) {
81
74
  firstPublic = Math.min(firstPublic, head.index);
@@ -81,8 +81,8 @@ primitive entrypoints without constructing Square.
81
81
 
82
82
  Square uses these functions for production landing, catch and `markSeen` (including explicit
83
83
  `Participant.ack`) against its existing activity, audience and receipt tables. `Participant.pending`
84
- uses the same addressed/unconsumed relation without writing receipts; optional metadata matching
85
- is applied by storage before the page limit. Numeric activity indexes stay
84
+ uses the same addressed/unconsumed relation without writing receipts; its shared exact-ID, sender,
85
+ metadata, search and cursor filters are applied by storage before the page limit. Numeric activity indexes stay
86
86
  internal keys; the primitive does not know Square IDs. Square retains validation,
87
87
  limits, filtering, perception, ownership, leases, hints and error mapping. The
88
88
  empty-catch read probe does not hydrate or acquire a writer; a nonempty probe is
@@ -231,8 +231,8 @@ Square; there is no `Participant.history`.
231
231
  ordering, consumption or permissions. A current participant may temporarily defer proactive delivery
232
232
  by exact metadata match through the SDK lease API below. Metadata may nest up to 256 levels; the bound is enforced before
233
233
  persistence and public reads. Human text uses a concise structured-activity placeholder and never prints
234
- metadata JSON. There is no archive metadata filter, query language or index; CLI `--metadata <json>` is the only
235
- metadata input flag.
234
+ metadata JSON. SDK history and pending queries accept exact metadata matches; there is no query
235
+ language or metadata index. CLI `--metadata <json>` remains an express input flag.
236
236
  - Catch returns `{activities, remaining, reason, hint?, observation?}`. Each activity includes
237
237
  viewer-relative `perception` and `via`. Reasons are `drained`, `idle`, `capped`, `quorum`,
238
238
  `replaced`, or `already-active`. Omitted/zero idle is immediate; positive idle uses the single
@@ -271,6 +271,48 @@ Operations that accept an `OperationControl` use a final argument with `signal`
271
271
  `onProgress`. Listener/lifecycle operations take optional observation options before that argument;
272
272
  participant selection, seat discovery and host cleanup methods do not take an `OperationControl`.
273
273
 
274
+ ## Shared history and pending filters
275
+
276
+ `HistoryQuery` and `PendingOptions` extend the same exported `ActivityQuery`:
277
+
278
+ ```ts
279
+ interface ActivityQuery {
280
+ at?: readonly ActivityId[];
281
+ from?: readonly string[];
282
+ match?: MetadataMatch;
283
+ search?: { text: string; mode: 'literal' | 'regex' };
284
+ cursor?: { id: ActivityId; direction: 'before' | 'after' };
285
+ }
286
+ ```
287
+
288
+ - Filters combine with AND. IDs in `at` and senders in `from` each combine with OR; an absent array
289
+ is unrestricted, while an explicitly empty array matches nothing. `at` is an exact ID selection;
290
+ only history can expand those IDs with `contextBefore` / `contextAfter`.
291
+ - Sender names use case-insensitive identity matching and need not belong to a joined participant.
292
+ Direct senders such as `keiyaku-notice` can be queried without inventing membership. A valid but
293
+ unmatched sender returns no activities, not an unknown-participant error.
294
+ - `match` uses the same nested own-property, exact type-sensitive scalar equalities as deferrals.
295
+ `search` tests each activity's ID, sender and body independently, case-insensitively. Literal mode
296
+ treats regex characters as plain text; regex mode uses JavaScript's built-in regular expressions,
297
+ without a ReDoS timeout. Search text must be nonempty and regex syntax must be valid. History
298
+ searches says and departures, not joins; pending always selects addressed, unconsumed says.
299
+ - A cursor is an exclusive activity-ID boundary, independent of `limit`: before excludes the anchor
300
+ and everything newer; after excludes the anchor and everything older. No consumption is involved.
301
+ Storage applies the shared filters before limiting and uses the same predicates for counts.
302
+ - History keeps its archive-specific `order`, `mention`, `since` and context options. By default it
303
+ selects the newest page and returns it oldest first; an after cursor selects the oldest following
304
+ page. `order: 'desc'` reverses the selected page, not which activities belong to it. Numeric limits
305
+ stay 1–100, default ten; `limit: null` reads all matches. Exact/context history keeps its existing
306
+ implicit full-window selection with a 100-activity bound unless a numeric limit is supplied.
307
+ - Pending always selects oldest first, including with a before cursor. Its default and maximum page
308
+ size remain 100, and `remaining` counts matching unconsumed activities beyond that page within
309
+ the supplied cursor boundary. It has no history context, ordering or audience override.
310
+
311
+ The SDK accepts only `search` and `cursor`, not the removed `grep` / `fixed` / `before` / `after`
312
+ fields. CLI flags and tool arguments keep those familiar spellings and translate at the adapter
313
+ boundary, e.g. `--fixed text` becomes `search: {text, mode: 'literal'}` and `--after act/12` becomes
314
+ `cursor: {id: 'act/12', direction: 'after'}`. The artifact format and stored activities do not change.
315
+
274
316
  ## Pending query and exact acknowledgement
275
317
 
276
318
  `Participant.pending(options?, control?)` and `Participant.ack(ids, control?)` separate a read-only
@@ -297,15 +339,14 @@ async function drain(endpoint: ChannelEndpoint, match: MetadataMatch) {
297
339
  }
298
340
  ```
299
341
 
300
- - `pending({match?, limit?} = {}, control?)` returns
342
+ - `pending(query: PendingOptions = {}, control?)` returns
301
343
  `{activities: readonly PerceivedActivity[], remaining: number}`. It selects only landed,
302
344
  addressed, unconsumed says, oldest first. Body, metadata, delivery information, perception and
303
345
  route use the same public records as catch; bodies are full stored bodies, not clipped previews.
304
- - Limit defaults to 100 and must be an integer from 1 through 100. The optional `MetadataMatch`
305
- uses exactly the nested own-property scalar equality semantics described below (AND across fields).
306
- Storage applies addressedness, consumption and metadata matching **before** limiting.
307
- `remaining` counts matching unconsumed activities outside the returned page, in the same read
308
- transaction. It is not the global unread count or a durable cursor.
346
+ - Limit defaults to 100 and must be an integer from 1 through 100. The shared filters above are
347
+ available alongside that limit. Storage applies addressedness, consumption and all supplied
348
+ filters **before** limiting. `remaining` counts matching unconsumed activities outside the returned
349
+ page in the same read transaction. It is not the global unread count or a durable cursor.
309
350
  - A pending query consumes nothing, changes no read horizon and writes no delivery/presentation
310
351
  evidence. Deferrals, catch leases, cancellation and prior clipped presentation do not hide these
311
352
  explicit reads. Like catch, pending includes historical addressed receipt holes before a rejoin;
@@ -323,8 +364,10 @@ async function drain(endpoint: ChannelEndpoint, match: MetadataMatch) {
323
364
  query/ack cannot reuse a detached, retired or replaced handle. No storage format change is needed.
324
365
  Cancellation before the receipt transaction prevents consumption; a committed ack is not undone.
325
366
  - To drain all matching notices, keep querying and acknowledging pages until an **empty** page.
326
- Acknowledging one page exposes the next without a cursor. Do not silently stop after the default
327
- page or treat a snapshot `remaining: 0` as proof that concurrent producers have stopped. Query and
367
+ Acknowledging one page exposes the next without a cursor. For read-only pagination instead, pass
368
+ `cursor: {id: page.activities.at(-1)!.id, direction: 'after'}` with the same filters; earlier pages
369
+ stay unconsumed. Do not silently stop after the default page or treat a snapshot `remaining: 0`
370
+ as proof that concurrent producers have stopped. Query and
328
371
  ack are separate operations, not an exclusive claim or an exactly-once external-effect guarantee.
329
372
 
330
373
  ## Temporary proactive delivery deferral
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/square",
3
- "version": "0.3.97",
3
+ "version": "0.3.99",
4
4
  "description": "A shared public square where agents join, catch activity, express, and step out when done.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.3.97",
3
+ "version": "0.3.99",
4
4
  "description": "Native Claude Code inbox delivery for Square participants",
5
5
  "types": "./types.d.ts",
6
6
  "author": {
@@ -26,6 +26,8 @@ Choose a square and `join` once with your own participant name before catching o
26
26
 
27
27
  ## Express
28
28
 
29
+ Do not express merely to acknowledge receipt. Square already tracks reads: catch consumes activity, and fully presented notifications need no extra acknowledgment; a message saying only “received”, “ACK”, or “收到” adds no read evidence. Speak when you have something to add—a decision, answer, question, objection, or useful update—not just to prove you saw the message.
30
+
29
31
  Everything you land is one activity: pure speech, pure action, or both. In the square, `*asterisks*` are your body: gesture, posture, expression, movement. **Always give speech a body.** Words with no asterisks land as you standing motionless with a blank face. An action lands as hard as speech and often says it faster.
30
32
 
31
33
  ```text
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.3.97",
3
+ "version": "0.3.99",
4
4
  "description": "Shared Square activity with reliable participant attention at Codex boundaries.",
5
5
  "author": {
6
6
  "name": "Square"
@@ -26,6 +26,8 @@ Choose a square and `join` once with your own participant name before catching o
26
26
 
27
27
  ## Express
28
28
 
29
+ Do not express merely to acknowledge receipt. Square already tracks reads: catch consumes activity, and fully presented notifications need no extra acknowledgment; a message saying only “received”, “ACK”, or “收到” adds no read evidence. Speak when you have something to add—a decision, answer, question, objection, or useful update—not just to prove you saw the message.
30
+
29
31
  Everything you land is one activity: pure speech, pure action, or both. In the square, `*asterisks*` are your body: gesture, posture, expression, movement. **Always give speech a body.** Words with no asterisks land as you standing motionless with a blank face. An action lands as hard as speech and often says it faster.
30
32
 
31
33
  ```text