@astrosheep/square 0.3.79 → 0.3.81

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/dist/artifact.js CHANGED
@@ -9,7 +9,7 @@ import { observeFileVersion } from './file-changes.js';
9
9
  import { canonicalPath } from './canonical-path.js';
10
10
  import { InternalSquareError, SquareError } from './model.js';
11
11
  import { wrapSquareWrite } from './square-domain.js';
12
- import { APPLICATION_ID, createSqliteDriver, initializeSqlite, exportSqliteState, inspectSqlite, invalidArtifact, validateSqliteHeader, emptyRuntimeState } from './square-sqlite.js';
12
+ import { APPLICATION_ID, CURRENT_USER_VERSION, createSqliteDriver, initializeSqlite, exportSqliteState, inspectSqlite, invalidArtifact, validateSqliteHeader, emptyRuntimeState } from './square-sqlite.js';
13
13
  const guideContents = new Map();
14
14
  function readGuide(name) {
15
15
  if (!['participant', 'architect', 'brainstorm'].includes(name))
@@ -107,7 +107,7 @@ export function createArtifactPort(location, initial, externalSignal) {
107
107
  database = new DatabaseSync(':memory:');
108
108
  try {
109
109
  initializeSqlite(database, initial);
110
- database.exec(`PRAGMA application_id=${APPLICATION_ID}; PRAGMA user_version=2`);
110
+ database.exec(`PRAGMA application_id=${APPLICATION_ID}; PRAGMA user_version=${CURRENT_USER_VERSION}`);
111
111
  }
112
112
  catch (error) {
113
113
  database.close();
@@ -270,7 +270,7 @@ async function initializeFile(location, state, signal) {
270
270
  db.exec('PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL');
271
271
  await inSqliteTransaction(db, true, () => { }, () => {
272
272
  initializeSqlite(db, state);
273
- db.exec(`PRAGMA application_id=${APPLICATION_ID}; PRAGMA user_version=2`);
273
+ db.exec(`PRAGMA application_id=${APPLICATION_ID}; PRAGMA user_version=${CURRENT_USER_VERSION}`);
274
274
  }, signal);
275
275
  }
276
276
  finally {
@@ -93,6 +93,7 @@ const LOCATION_REQUIRED_COMMANDS = new Set([
93
93
  'express',
94
94
  'listen',
95
95
  'ignore',
96
+ 'unlisten',
96
97
  'listening',
97
98
  'done',
98
99
  'hold',
@@ -81,7 +81,7 @@ export async function executeExpression(open, squarePath, requestedName, rawInpu
81
81
  name = participant.name;
82
82
  attempted = true;
83
83
  const outcome = await participant.express(rawInput.replace(/\r\n/g, '\n').trim(), {
84
- ...opts, reply: opts.reply, requireExplicitReach: true, observe: true,
84
+ ...opts, reply: opts.reply, observe: true,
85
85
  }, {
86
86
  signal: controller.signal,
87
87
  onProgress(progress) {
@@ -150,15 +150,13 @@ export async function executeExpression(open, squarePath, requestedName, rawInpu
150
150
  }
151
151
  const facts = isSquareError(error) ? error.facts : undefined;
152
152
  const reason = facts?.reason;
153
- const message = reason === 'explicit_reach_required'
154
- ? '✕ express needs --mention <name>, --no-mention, --bell, or an @name in the body\n · --reply <activity-id> also reaches the author it answers'
155
- : reason === 'bell_mention_conflict'
156
- ? '✕ a bell cannot be combined with a mention'
157
- : reason === 'invalid_reply'
158
- ? facts?.activityExists === false
159
- ? `Invalid --reply: ${facts.activityId ?? opts.reply ?? 'that activity'} is not in this square's history`
160
- : 'Invalid --reply: expected an activity id like act/12'
161
- : refusalText(error);
153
+ const message = reason === 'bell_mention_conflict'
154
+ ? '✕ a bell cannot be combined with a mention'
155
+ : reason === 'invalid_reply'
156
+ ? facts?.activityExists === false
157
+ ? `Invalid --reply: ${facts.activityId ?? opts.reply ?? 'that activity'} is not in this square's history`
158
+ : 'Invalid --reply: expected an activity id like act/12'
159
+ : refusalText(error);
162
160
  const lines = [message, `· draft kept: ${draftPath}`];
163
161
  const repair = reason === 'bell_mention_conflict' || (reason === 'not_standing' && detail?.subject === 'recipient');
164
162
  if (isSquareError(error)) {
@@ -170,7 +168,7 @@ export async function executeExpression(open, squarePath, requestedName, rawInpu
170
168
  if (repair) {
171
169
  lines.push(participantsRecoveryCommand(squarePath), reason === 'bell_mention_conflict'
172
170
  ? ' · ring the bell without explicit mentions:'
173
- : ' · pick someone standing here, or land it bare:', `${activityRetryCommand(squarePath, name, { ...opts, mentions: [], noMention: opts.reach === 'bell' ? opts.noMention : true })} < ${quoteShell(draftPath)}`);
171
+ : ' · retry without explicit recipients:', `${activityRetryCommand(squarePath, name, { ...opts, mentions: [] })} < ${quoteShell(draftPath)}`);
174
172
  }
175
173
  else if (error.code === 'invalid_args' || error.code === 'invalid_name') {
176
174
  if (opts.mentions?.length)
@@ -179,13 +177,9 @@ export async function executeExpression(open, squarePath, requestedName, rawInpu
179
177
  lines.push(`${commandPrefix(squarePath)} history --limit 10`);
180
178
  }
181
179
  }
182
- if (reason === 'explicit_reach_required') {
183
- lines.push(' · choose someone with --mention <name>, or send without addressing anyone:', `${activityRetryCommand(squarePath, name, { ...opts, noMention: true })} < ${quoteShell(draftPath)}`);
184
- }
185
- else if (reason === 'invalid_reply') {
186
- const needsBareReach = facts?.hasReachWithoutReply === false;
187
- const withoutReply = { ...opts, reply: undefined, ...(needsBareReach ? { noMention: true } : {}) };
188
- lines.push(`${commandPrefix(squarePath)} history --limit 10`, ' · choose a valid --reply from history, or send without a reply using this repair:', ...(needsBareReach ? [' · this repair is bare; replace --no-mention with --mention <name> to address someone'] : []), `${activityRetryCommand(squarePath, name, withoutReply)} < ${quoteShell(draftPath)}`);
180
+ if (reason === 'invalid_reply') {
181
+ const withoutReply = { ...opts, reply: undefined };
182
+ lines.push(`${commandPrefix(squarePath)} history --limit 10`, ' · choose a valid --reply from history, or send without a reply using this repair:', `${activityRetryCommand(squarePath, name, withoutReply)} < ${quoteShell(draftPath)}`);
189
183
  }
190
184
  else if (!repair) {
191
185
  lines.push(' · correct the problem above before retrying; keep your intended recipients and reply:', `${retryCommand} < ${quoteShell(draftPath)}`);
@@ -12,10 +12,10 @@ export const migrateCommand = {
12
12
  const outcome = await migrateSquare(squareInput(context));
13
13
  const body = outcome.status === 'migrated' && outcome.counts !== undefined
14
14
  ? [
15
- '✓ migrated to row storage',
15
+ '✓ migrated to current row storage',
16
16
  ` · ${outcome.counts.activities} activities · ${outcome.counts.receipts} receipts · ${outcome.counts.leases} leases · ${outcome.counts.routes} routes`,
17
17
  ].join('\n')
18
- : '· already format 2 — nothing changed';
18
+ : '· already current format — nothing changed';
19
19
  return { output: withPathOutput(squarePath, body, { participantCount: outcome.standing }) };
20
20
  },
21
21
  present(result) {
@@ -1,4 +1,4 @@
1
- import { buildCommand, doneCommand, expressCommand, holdCommand, ignoreCommand, joinCommand, listenCommand, listeningCommand, resumeCommand } from './square-commands.js';
1
+ import { buildCommand, doneCommand, expressCommand, holdCommand, ignoreCommand, joinCommand, listenCommand, listeningCommand, resumeCommand, unlistenCommand } from './square-commands.js';
2
2
  import { doctorCommand, migrateCommand } from './maintenance-commands.js';
3
3
  import { ledgerCommand } from './ledger-command.js';
4
4
  import { harnessCommand, installCommand, uninstallCommand } from './harness-command.js';
@@ -18,6 +18,7 @@ export const commandRegistry = [
18
18
  { names: ['express'], spec: expressCommand },
19
19
  { names: ['listen'], spec: listenCommand },
20
20
  { names: ['ignore'], spec: ignoreCommand },
21
+ { names: ['unlisten'], spec: unlistenCommand },
21
22
  { names: ['listening'], spec: listeningCommand },
22
23
  { names: ['done'], spec: doneCommand },
23
24
  { names: ['hold'], spec: holdCommand },
@@ -30,6 +30,7 @@ export declare const joinCommand: CommandSpec<JoinIntent, string>;
30
30
  export declare const expressCommand: CommandSpec<ActivityIntent>;
31
31
  export declare const listenCommand: CommandSpec<ListenerIntent, string>;
32
32
  export declare const ignoreCommand: CommandSpec<ListenerIntent, string>;
33
+ export declare const unlistenCommand: CommandSpec<ListenerIntent, string>;
33
34
  export declare const listeningCommand: CommandSpec<ListenerIntent, string>;
34
35
  export declare const doneCommand: CommandSpec<BodyIntent, string>;
35
36
  export declare const holdCommand: CommandSpec<BodyIntent, string>;
@@ -171,8 +171,12 @@ function listenerCommand(verb) {
171
171
  const actor = participantIdentity(projection.participant);
172
172
  const target = participantIdentity(projection.target);
173
173
  const action = data.activity !== null
174
- ? verb === 'listen' ? `${actor} turns an ear toward ${target}` : `${actor} turns away from ${target}`
175
- : verb === 'listen' ? `${actor} already turns an ear toward ${target}` : `${actor} is not turned toward ${target}`;
174
+ ? verb === 'listen' ? `${actor} turns an ear toward ${target}`
175
+ : verb === 'ignore' ? `${actor} turns away from ${target}`
176
+ : `${actor} stops listening to ${target}`
177
+ : verb === 'listen' ? `${actor} already turns an ear toward ${target}`
178
+ : verb === 'ignore' ? `${actor} is not turned toward ${target}`
179
+ : `${actor} is not listening to ${target}`;
176
180
  return withPathOutput(projection.location, `${data.activity === null ? '○' : '·'} ${action}`, {
177
181
  participantCount: projection.standingCount, held: projection.held,
178
182
  });
@@ -182,6 +186,7 @@ function listenerCommand(verb) {
182
186
  }
183
187
  export const listenCommand = listenerCommand('listen');
184
188
  export const ignoreCommand = listenerCommand('ignore');
189
+ export const unlistenCommand = listenerCommand('unlisten');
185
190
  export const listeningCommand = {
186
191
  parse(argv, context) { return parseListener(argv, context, false); },
187
192
  async execute(_intent, context) {
@@ -5,7 +5,7 @@ type TimedAct<K extends Act['kind'] = Act['kind']> = Extract<Act, {
5
5
  }> & {
6
6
  at: number;
7
7
  };
8
- import type { ExpressOptions, ExpressResult, ParticipantSummary as ParticipantStatus, UnreadActivitySummary } from './square-facade.js';
8
+ import type { ExpressResult, ParticipantSummary as ParticipantStatus, UnreadActivitySummary } from './square-facade.js';
9
9
  export type { ParticipantSummary as ParticipantStatus, ParticipantPresence as CorePresenceState, UnreadActivitySummary, UnreadActivityPreview } from './square-facade.js';
10
10
  export declare function resolveKnownName(facts: SquareRead, name: string): string;
11
11
  export interface DecideJoinResult {
@@ -42,12 +42,6 @@ export type ActDecision = ({
42
42
  };
43
43
  /** Count the directed suffix, but hydrate only its last three previews per spelling. */
44
44
  export declare function unreadActivitySummaries(facts: SquareRead, name: string, now: number): UnreadActivitySummary[];
45
- export declare function expressionReach(input: {
46
- body: string;
47
- } & Pick<ExpressOptions, 'reach' | 'mentions' | 'noMention'>): {
48
- bodyMentions: string[];
49
- hasReachWithoutReply: boolean;
50
- };
51
45
  export declare function decideAct(facts: SquareRead, input: {
52
46
  name: string;
53
47
  body: string;
@@ -57,12 +51,12 @@ export declare function decideAct(facts: SquareRead, input: {
57
51
  reach?: Reach;
58
52
  reply?: number;
59
53
  noMention?: boolean;
60
- requireExplicitReach?: boolean;
61
54
  }): ActDecision;
62
55
  export declare function validateDoneBody(body: unknown): void;
63
56
  export declare function coreDone(facts: SquareRead, name: string, body: string, now: number): TimedAct<'done'>;
64
- export declare function coreListen(facts: SquareRead, actor: string, target: string, now: number): TimedAct<"listen" | "ignore"> | undefined;
65
- export declare function coreIgnore(facts: SquareRead, actor: string, target: string, now: number): TimedAct<"listen" | "ignore"> | undefined;
57
+ export declare function coreListen(facts: SquareRead, actor: string, target: string, now: number): TimedAct<"listen" | "ignore" | "unlisten"> | undefined;
58
+ export declare function coreIgnore(facts: SquareRead, actor: string, target: string, now: number): TimedAct<"listen" | "ignore" | "unlisten"> | undefined;
59
+ export declare function coreUnlisten(facts: SquareRead, actor: string, target: string, now: number): TimedAct<"listen" | "ignore" | "unlisten"> | undefined;
66
60
  export declare function coreListening(facts: SquareRead, actor: string): readonly string[];
67
61
  export declare function coreHold(facts: SquareRead, actor: string, body: string, now: number): TimedAct<'hold'>;
68
62
  export declare function coreResume(facts: SquareRead, actor: string, now: number): TimedAct<'resume'>;
package/dist/decisions.js CHANGED
@@ -84,11 +84,6 @@ export function unreadActivitySummaries(facts, name, now) {
84
84
  previews: summary.previews.map((preview) => ({ number: preview.number, act: byIndex.get(preview.index), perception: 'full' })),
85
85
  })).sort((a, b) => a.latestActivityAgeMs - b.latestActivityAgeMs || a.name.localeCompare(b.name));
86
86
  }
87
- export function expressionReach(input) {
88
- const bodyMentions = input.reach === 'bell' || input.noMention === true ? [] : scanMentionCandidates(input.body);
89
- const hasReachWithoutReply = input.reach === 'bell' || (input.mentions?.length ?? 0) > 0 || input.noMention === true || bodyMentions.length > 0;
90
- return { bodyMentions, hasReachWithoutReply };
91
- }
92
87
  export function decideAct(facts, input) {
93
88
  const { now, force, body, reach, reply } = input;
94
89
  const current = resolveStanding(facts, input.name, true);
@@ -114,7 +109,7 @@ export function decideAct(facts, input) {
114
109
  throw activityRefusal('invalid_args', `${participantIdentity(requested)} is not standing in this square`, { reason: 'not_standing', targets: [requested] });
115
110
  addMention(resolved);
116
111
  }
117
- const { bodyMentions, hasReachWithoutReply } = expressionReach(input);
112
+ const bodyMentions = reach === 'bell' || input.noMention === true ? [] : scanMentionCandidates(body);
118
113
  const unmatchedMentions = [];
119
114
  for (const token of bodyMentions) {
120
115
  const resolved = resolveMention(token, joinedNames);
@@ -123,13 +118,10 @@ export function decideAct(facts, input) {
123
118
  else
124
119
  unmatchedMentions.push(token);
125
120
  }
126
- if (input.requireExplicitReach && !hasReachWithoutReply && reply === undefined) {
127
- throw activityRefusal('invalid_args', 'Express needs a recipient, reply, bell, or explicit bare reach', { reason: 'explicit_reach_required' });
128
- }
129
121
  const header = facts.header();
130
122
  if (reply !== undefined && (!Number.isSafeInteger(reply) || reply < 0 || reply >= header.nextActIndex)) {
131
123
  const label = Number.isSafeInteger(reply) && reply >= 0 ? formatActivityId(reply) : String(reply);
132
- throw activityRefusal('invalid_args', `Unknown reply activity: ${label}`, { reason: 'invalid_reply', activityId: label, activityExists: false, hasReachWithoutReply });
124
+ throw activityRefusal('invalid_args', `Unknown reply activity: ${label}`, { reason: 'invalid_reply', activityId: label, activityExists: false });
133
125
  }
134
126
  const replyAuthor = reply === undefined ? undefined : facts.activity(reply)?.actor;
135
127
  const replyAuthorNotHere = replyAuthor === undefined || sameName(replyAuthor, name) || facts.participant(replyAuthor)?.joined ? undefined : replyAuthor;
@@ -179,6 +171,8 @@ function attentionChange(facts, kind, actor, target, now) {
179
171
  requireStanding(current);
180
172
  const outgoing = facts.attention(current.name);
181
173
  const existing = outgoing.find((item) => sameName(item.target, target));
174
+ if (kind === 'unlisten')
175
+ return existing?.kind === 'listen' ? { kind, actor: current.name, target, at: now } : undefined;
182
176
  if (kind === 'listen' && existing?.kind !== 'listen' && outgoing.filter((item) => item.kind === 'listen').length >= MAX_IDENTITY_SET_SIZE) {
183
177
  throw new SquareError('invalid_args', `A participant can listen to at most ${MAX_IDENTITY_SET_SIZE} others`);
184
178
  }
@@ -186,6 +180,7 @@ function attentionChange(facts, kind, actor, target, now) {
186
180
  }
187
181
  export function coreListen(facts, actor, target, now) { return attentionChange(facts, 'listen', actor, target, now); }
188
182
  export function coreIgnore(facts, actor, target, now) { return attentionChange(facts, 'ignore', actor, target, now); }
183
+ export function coreUnlisten(facts, actor, target, now) { return attentionChange(facts, 'unlisten', actor, target, now); }
189
184
  export function coreListening(facts, actor) {
190
185
  const current = resolveStanding(facts, actor);
191
186
  return facts.attention(current.name).filter((item) => item.kind === 'listen').map((item) => item.target);
package/dist/help.js CHANGED
@@ -26,15 +26,16 @@ const COMMANDS = [
26
26
  " --bell Call everyone; body @names stay literal. Cannot combine with --mention.",
27
27
  ' --no-mention Keep body @names literal; explicit --mention and --reply still apply.',
28
28
  ' --reply <activity-id> Also reach the author of an earlier activity (for example act/12).',
29
- ' Choose reach with a flag above or an @name in the body. Unmatched @names stay literal, with a notice.',
30
- ' With no mention, bell, or reply, the activity lands bare; only listeners receive it.',
29
+ ' Address with --mention, --bell, --reply, or a standing @name in the body. Otherwise the activity lands bare; unmatched @names stay literal, with a notice.',
30
+ ' Bare activities are delivered only to participants listening to the sender.',
31
31
  '',
32
32
  'Options:',
33
33
  ' -f, --force Express without first catching unread activity or satisfying attention etiquette.',
34
34
  ' --no-wait If held or throttled, save a draft and return instead of waiting. A held wait gives up on its own after 10 minutes.',
35
35
  ],
36
36
  },
37
- { names: ['listen'], usage: '--as <name> listen <participant>', usesSquare: true, group: 'participant', summary: 'Turn an ear toward one participant\'s future bare says.' },
37
+ { names: ['listen'], usage: '--as <name> listen <participant>', usesSquare: true, group: 'participant', summary: 'Turn an ear toward one participant\'s future activity.' },
38
+ { names: ['unlisten'], usage: '--as <name> unlisten <participant>', usesSquare: true, group: 'participant', summary: 'Stop listening without blocking that participant\'s mentions or replies.' },
38
39
  { names: ['ignore'], usage: '--as <name> ignore <participant>', usesSquare: true, group: 'participant', summary: 'Ignore one participant\'s future mentions and bare says.' },
39
40
  { names: ['listening'], usage: '--as <name> listening', usesSquare: true, group: 'participant', summary: 'Show who you are turned toward.', details: ['Listening is future-only: the edge is fixed when a say lands; it never rewrites history.'] },
40
41
  {
@@ -89,8 +90,8 @@ const COMMANDS = [
89
90
  },
90
91
  {
91
92
  names: ['migrate'], usage: 'migrate', usesSquare: true, group: 'maintenance',
92
- summary: 'Convert an existing version 1 square to row storage in place.',
93
- details: ['For the selected .square artifact only. Global identity and delivery records use square ledger migrate instead.', 'Stop sessions and services using this artifact before conversion. Back up the stopped artifact, migrate it, then run square --location <path> doctor before starting updated clients.', 'A version 2 square is left untouched; an unrelated or unsupported artifact is refused without a write. There is no --check, --backup or --force.'],
93
+ summary: 'Convert an existing version 1 or 2 square to current format 3 in place.',
94
+ details: ['For the selected .square artifact only. Global identity and delivery records use square ledger migrate instead.', 'Stop sessions and services using this artifact before conversion. Back up the stopped artifact, migrate it, then run square --location <path> doctor before starting updated clients.', 'A version 3 square is left untouched; an unrelated or unsupported artifact is refused without a write. There is no --check, --backup or --force.'],
94
95
  },
95
96
  {
96
97
  names: ['ledger'], usage: 'ledger <verify|migrate|gc>', usesSquare: false, group: 'maintenance',
@@ -106,7 +107,7 @@ function isHelpFlag(value) {
106
107
  }
107
108
  export function renderGlobalHelp() {
108
109
  const groups = [
109
- { key: 'participant', title: 'In the square:', order: ['join', 'express', 'listen', 'ignore', 'listening', 'catch', 'history', 'status', 'hold', 'resume', 'done'] },
110
+ { key: 'participant', title: 'In the square:', order: ['join', 'express', 'listen', 'unlisten', 'ignore', 'listening', 'catch', 'history', 'status', 'hold', 'resume', 'done'] },
110
111
  { key: 'host', title: 'Prepare and manage:', order: ['build', 'list', 'participants'] },
111
112
  { key: 'maintenance', title: 'Setup:', order: ['install', 'uninstall', 'doctor', 'migrate', 'ledger'] },
112
113
  ];
package/dist/model.d.ts CHANGED
@@ -21,7 +21,7 @@ export interface SquareErrorFacts {
21
21
  /** Express admission rejected before any activity was committed. */
22
22
  activityUnsent?: true;
23
23
  /** Refusal reason, including absent participants, conflicting reach, and inaccessible activities. */
24
- reason?: 'never_joined' | 'not_standing' | 'bell_mention_conflict' | 'not_addressed' | 'migration_required' | 'explicit_reach_required' | 'invalid_reply' | 'invalid_done_body';
24
+ reason?: 'never_joined' | 'not_standing' | 'bell_mention_conflict' | 'not_addressed' | 'migration_required' | 'invalid_reply' | 'invalid_done_body';
25
25
  /** The activity id a `catch --id` refusal named. */
26
26
  activityId?: string;
27
27
  /** The host ledger requiring offline migration, rather than the selected artifact. */
@@ -30,8 +30,6 @@ export interface SquareErrorFacts {
30
30
  targets?: readonly string[];
31
31
  /** Whether the artifact holds that activity at all; an unknown id needs an ordinary page. */
32
32
  activityExists?: boolean;
33
- /** Express has a recipient, bell, body candidate, or explicit bare reach without its reply. */
34
- hasReachWithoutReply?: boolean;
35
33
  pending?: number;
36
34
  holder?: string;
37
35
  holdReason?: string;
@@ -101,7 +99,7 @@ export type PublicAct = Extract<StoredAct, {
101
99
  kind: 'say' | 'done';
102
100
  }>;
103
101
  export type RoomChangeAct = Extract<StoredAct, {
104
- kind: 'join' | 'done' | 'hold' | 'resume' | 'listen' | 'ignore';
102
+ kind: 'join' | 'done' | 'hold' | 'resume' | 'listen' | 'ignore' | 'unlisten';
105
103
  }>;
106
104
  export declare function formatHardCap(hardCap: HardCap): string;
107
105
  export declare function parseParticipantList(value: string): string[];
@@ -139,6 +139,8 @@ export function renderRoomChangeText(event) {
139
139
  return `${identity} turned an ear toward ${participantIdentity(event.target ?? 'someone')}`;
140
140
  case 'ignore':
141
141
  return `${identity} turned away from ${participantIdentity(event.target ?? 'someone')}`;
142
+ case 'unlisten':
143
+ return `${identity} stopped listening to ${participantIdentity(event.target ?? 'someone')}`;
142
144
  default:
143
145
  throw new Error('Expected a lifecycle activity');
144
146
  }
@@ -171,6 +173,7 @@ function renderEventCliFacts(event, opts = {}) {
171
173
  return { text: `${style('release', '✓')} ${renderRoomChangeText(event)}`, bodyClipped: false };
172
174
  case 'listen':
173
175
  case 'ignore':
176
+ case 'unlisten':
174
177
  return { text: `· ${renderRoomChangeText(event)}`, bodyClipped: false };
175
178
  case 'say': {
176
179
  const body = renderedBody(event.body, maxBody);
package/dist/runtime.js CHANGED
@@ -48,7 +48,7 @@ export function readCursor(facts, name) {
48
48
  const block = facts.directed(name, { afterIndex: boundary, unseenOnly: true }, { edge: 'oldest', limit: 1 }).items[0]?.index;
49
49
  const last = facts.activityHeads({
50
50
  afterIndex: boundary, ...(block === undefined ? {} : { beforeIndex: block }), actorRequired: true,
51
- kinds: ['join', 'done', 'say', 'hold', 'resume', 'listen', 'ignore'],
51
+ kinds: ['join', 'done', 'say', 'hold', 'resume', 'listen', 'ignore', 'unlisten'],
52
52
  }, { edge: 'newest', limit: 1 }).items[0]?.index;
53
53
  return Math.max(boundary, last ?? boundary);
54
54
  }
@@ -42,6 +42,7 @@ export interface ListenerCommittedResult {
42
42
  }
43
43
  export declare function listen(square: OperationContext, actor: string, target: string, control?: OperationControl): Promise<ListenerCommittedResult>;
44
44
  export declare function ignore(square: OperationContext, actor: string, target: string, control?: OperationControl): Promise<ListenerCommittedResult>;
45
+ export declare function unlisten(square: OperationContext, actor: string, target: string, control?: OperationControl): Promise<ListenerCommittedResult>;
45
46
  export declare function listening(square: OperationContext, actor: string, control?: OperationControl): Promise<readonly string[]>;
46
47
  export interface LifecycleCommittedResult extends ActivityResult {
47
48
  readonly observation: LandingContext & {
@@ -1,4 +1,4 @@
1
- import { coreDone, coreHold, coreIgnore, coreListen, coreListening, coreResume, decideAct, decideImplicitJoin, decideJoin, expressionReach, resolveKnownName, validateDoneBody } from './decisions.js';
1
+ import { coreDone, coreHold, coreIgnore, coreListen, coreListening, coreResume, coreUnlisten, decideAct, decideImplicitJoin, decideJoin, resolveKnownName, validateDoneBody } from './decisions.js';
2
2
  import { isSquareError, nameKey, SquareError, validateName } from './model.js';
3
3
  import { participantIdentity } from './participant-identity.js';
4
4
  import { deliverPending } from './delivery-operations.js';
@@ -191,7 +191,6 @@ export async function express(square, name, body, options = {}, control) {
191
191
  if (isSquareError(error))
192
192
  error.facts = {
193
193
  ...error.facts, reason: 'invalid_reply', activityId: options.reply,
194
- hasReachWithoutReply: expressionReach({ ...options, body }).hasReachWithoutReply,
195
194
  };
196
195
  throw error;
197
196
  }
@@ -199,7 +198,7 @@ export async function express(square, name, body, options = {}, control) {
199
198
  const committed = (await square.artifact.transact((facts) => {
200
199
  // Selected-row decoding is storage work, not evidence that admission safely refused.
201
200
  const proposed = decideAct(facts, { name, body, force: options.force ?? false, now, mentions: options.mentions,
202
- ...(options.reach === undefined ? {} : { reach: options.reach }), ...(options.noMention === undefined ? {} : { noMention: options.noMention }), ...('requireExplicitReach' in options ? { requireExplicitReach: options.requireExplicitReach === true } : {}), ...(reply === undefined ? {} : { reply }) });
201
+ ...(options.reach === undefined ? {} : { reach: options.reach }), ...(options.noMention === undefined ? {} : { noMention: options.noMention }), ...(reply === undefined ? {} : { reply }) });
203
202
  const holder = proposed.type === 'held' ? facts.hold().actor : undefined;
204
203
  const decision = rejectUnsentActivity(() => {
205
204
  const decision = proposed;
@@ -239,7 +238,7 @@ async function landListenerChange(square, verb, actor, target, control) {
239
238
  throwIfAborted(control);
240
239
  const now = square.clock();
241
240
  const committed = (await square.artifact.transact((facts) => {
242
- const act = verb === 'listen' ? coreListen(facts, actor, target, now) : coreIgnore(facts, actor, target, now);
241
+ const act = verb === 'listen' ? coreListen(facts, actor, target, now) : verb === 'ignore' ? coreIgnore(facts, actor, target, now) : coreUnlisten(facts, actor, target, now);
243
242
  const stored = act === undefined ? null : facts.land(act);
244
243
  return { stored, observation: { standingCount: inSquareCount(facts), held: facts.hold().active, now } };
245
244
  }, control?.signal)).value;
@@ -247,6 +246,7 @@ async function landListenerChange(square, verb, actor, target, control) {
247
246
  }
248
247
  export function listen(square, actor, target, control) { return landListenerChange(square, 'listen', actor, target, control); }
249
248
  export function ignore(square, actor, target, control) { return landListenerChange(square, 'ignore', actor, target, control); }
249
+ export function unlisten(square, actor, target, control) { return landListenerChange(square, 'unlisten', actor, target, control); }
250
250
  export async function listening(square, actor, control) { return (await square.artifact.read((facts) => coreListening(facts, actor), control?.signal)).value; }
251
251
  async function landCore(square, verb, actor, body = '', fence = {}, control) {
252
252
  if (verb === 'done')
@@ -8,7 +8,7 @@ import { catchLimit, observeCatchSelection } from './catch-decisions.js';
8
8
  import { directedSuffix } from './activity-feed.js';
9
9
  import { assertLiveOwner } from './participant-host.js';
10
10
  import { buildMemorySquare, openSquare, closeOpenSquare } from './open-square.js';
11
- import { catchUp, done, endOwnedSession, express, hold, ignore, implicitJoin, join, takeover, listen, listening, resume, retireEndedSessionRoutes } from './square-actions.js';
11
+ import { catchUp, done, endOwnedSession, express, hold, ignore, implicitJoin, join, takeover, listen, listening, unlisten, resume, retireEndedSessionRoutes } from './square-actions.js';
12
12
  import { recoveryObservation, currentParticipant, entryPresentation, resolveParticipant, snapshot, queryStatus, queryParticipants, catchPresence, toPublicActivity, publicActivities, selectHistory, NON_READ, HISTORY_DEFAULT_LIMIT, HISTORY_MAX_LIMIT } from './views.js';
13
13
  import { hasAutomaticDeliveryIdentity, hostLedgerForEnv, localParticipantName, localSessionIdentities, presenceEpoch, readParticipantOwner } from './registry.js';
14
14
  import { projectSessionBindings } from './square-projections.js';
@@ -246,7 +246,7 @@ class ParticipantHandle {
246
246
  }
247
247
  async listener(verb, target, options, control) {
248
248
  const name = (await resolveParticipant(this.square, this.name, control)).name;
249
- const { observation: committed, ...result } = await (verb === 'listen' ? listen : ignore)(this.square, name, target, control);
249
+ const { observation: committed, ...result } = await (verb === 'listen' ? listen : verb === 'ignore' ? ignore : unlisten)(this.square, name, target, control);
250
250
  return { ...result, ...(!options.observe ? {} : { observation: {
251
251
  ...observedContext(this.square, committed.standingCount, committed.held, name, committed.now),
252
252
  participant: name, requestedTarget: target, target: result.activity?.target ?? target,
@@ -254,6 +254,7 @@ class ParticipantHandle {
254
254
  }
255
255
  listen(target, options = {}, control) { return this.listener('listen', target, options, control); }
256
256
  ignore(target, options = {}, control) { return this.listener('ignore', target, options, control); }
257
+ unlisten(target, options = {}, control) { return this.listener('unlisten', target, options, control); }
257
258
  async listening(options = {}, control) {
258
259
  const name = (await resolveParticipant(this.square, this.name, control)).name;
259
260
  return { listening: await listening(this.square, name, control),
@@ -52,6 +52,11 @@ export type Act = {
52
52
  actor: Participant;
53
53
  target: Participant;
54
54
  at?: number;
55
+ } | {
56
+ kind: 'unlisten';
57
+ actor: Participant;
58
+ target: Participant;
59
+ at?: number;
55
60
  };
56
61
  export declare function audienceOf(say: {
57
62
  body: string;
@@ -4,6 +4,10 @@ export interface LandingEffect {
4
4
  readonly activity: StoredAct;
5
5
  readonly participant?: ParticipantFact;
6
6
  readonly removeListeningOf?: string;
7
+ readonly removeListening?: {
8
+ readonly listener: string;
9
+ readonly target: string;
10
+ };
7
11
  readonly replaceAttention?: AttentionFact;
8
12
  readonly audience: readonly LandedRecipient[];
9
13
  }
@@ -45,9 +45,13 @@ export function deriveLanding(activity, facts) {
45
45
  replaceAttention = { listener: participant.name, target: activity.target, kind: activity.kind, sinceIndex: activity.index };
46
46
  }
47
47
  }
48
+ const removeListening = activity.kind === 'unlisten'
49
+ && facts.outgoing.some((item) => item.kind === 'listen' && sameName(item.target, activity.target))
50
+ ? { listener: participant.name, target: activity.target } : undefined;
48
51
  return {
49
52
  activity, participant, audience,
50
53
  ...(activity.kind === 'done' ? { removeListeningOf: participant.name } : {}),
54
+ ...(removeListening === undefined ? {} : { removeListening }),
51
55
  ...(replaceAttention === undefined ? {} : { replaceAttention }),
52
56
  };
53
57
  }
@@ -56,7 +60,7 @@ export function landingFacts(read, activity) {
56
60
  actor: activity.actor === undefined ? undefined : read.participant(activity.actor),
57
61
  standing: activity.kind === 'say' ? read.roster().filter((item) => item.joined) : [],
58
62
  incoming: activity.kind === 'say' ? read.incomingAttention(activity.actor) : [],
59
- outgoing: activity.kind === 'listen' || activity.kind === 'ignore' ? read.attention(activity.actor) : [],
63
+ outgoing: activity.kind === 'listen' || activity.kind === 'ignore' || activity.kind === 'unlisten' ? read.attention(activity.actor) : [],
60
64
  replyAuthor: activity.kind === 'say' && activity.reply !== undefined ? read.activity(activity.reply)?.actor : undefined,
61
65
  };
62
66
  }
@@ -7,7 +7,7 @@ export type ArchiveRecord = Readonly<StoredAct>;
7
7
  export interface Activity {
8
8
  readonly id: ActivityId;
9
9
  readonly at: number;
10
- readonly kind: 'join' | 'say' | 'done' | 'hold' | 'resume' | 'listen' | 'ignore';
10
+ readonly kind: 'join' | 'say' | 'done' | 'hold' | 'resume' | 'listen' | 'ignore' | 'unlisten';
11
11
  readonly actor: string;
12
12
  readonly body?: string;
13
13
  readonly mentions: readonly string[];
@@ -29,7 +29,6 @@ export interface ExpressOptions extends ObservationOptions {
29
29
  readonly reply?: ActivityId;
30
30
  readonly noMention?: boolean;
31
31
  readonly noWait?: boolean;
32
- readonly requireExplicitReach?: boolean;
33
32
  }
34
33
  export interface ActivityResult {
35
34
  readonly activity: Activity;
@@ -185,6 +184,7 @@ export interface Participant {
185
184
  express(body: string, options?: ExpressOptions, control?: OperationControl): Promise<ExpressResult>;
186
185
  listen(target: string, options?: ObservationOptions, control?: OperationControl): Promise<ListenerChangeResult>;
187
186
  ignore(target: string, options?: ObservationOptions, control?: OperationControl): Promise<ListenerChangeResult>;
187
+ unlisten(target: string, options?: ObservationOptions, control?: OperationControl): Promise<ListenerChangeResult>;
188
188
  listening(options?: ObservationOptions, control?: OperationControl): Promise<ListeningResult>;
189
189
  catch(options?: CatchOptions, control?: OperationControl): Promise<CatchResult>;
190
190
  history(query?: HistoryQuery, control?: OperationControl): Promise<HistoryResult>;
@@ -8,17 +8,15 @@ export interface MigrationCounts {
8
8
  readonly routes: number;
9
9
  }
10
10
  export interface MigrationOutcome {
11
- readonly status: 'migrated' | 'already-format-2';
11
+ readonly status: 'migrated' | 'already-current';
12
12
  /** Participants standing after the conversion, for the CLI header. */
13
13
  readonly standing: number;
14
14
  readonly counts?: MigrationCounts;
15
15
  }
16
16
  /**
17
- * Explicit in-place conversion of a normally persisted version 1 artifact.
18
- *
19
- * The legacy snapshot is read, validated and written here and nowhere else in
20
- * production; the row schema, codec and landing transition come from the driver.
21
- * One transaction: preflight framing, the same recheck under the lock, the
22
- * preserved authority, the derived facts, the format flip and a single COMMIT.
17
+ * Explicit in-place conversion of a version 1 snapshot or version 2 row
18
+ * artifact. Each source is validated before and under the write lock; v1 is
19
+ * imported through the normal current row initializer, while v2 rebuilds only
20
+ * activities and its indexes before publishing the new format version.
23
21
  */
24
22
  export declare function migrateSquareFile(location: string, signal?: AbortSignal): Promise<MigrationOutcome>;
@@ -3,7 +3,7 @@ import { inSqliteTransaction } from './sqlite-transaction.js';
3
3
  import { canonicalPath } from './canonical-path.js';
4
4
  import { isWakeRouteKind } from './model.js';
5
5
  import { parseActivityId } from './square-core.js';
6
- import { APPLICATION_ID, V1_SNAPSHOT_TABLE, createSqliteDriver, initializeSqlite, invalidArtifact, validateLegacyFraming, validateSqliteHeader, validateStoredActivity, validateWatchLease, } from './square-sqlite.js';
6
+ import { APPLICATION_ID, ACTIVITIES_TABLE_SQL, ACTIVITY_INDEXES_SQL, CURRENT_USER_VERSION, V2_USER_VERSION, V1_SNAPSHOT_TABLE, createSqliteDriver, initializeSqlite, invalidArtifact, validateLegacyFraming, validateRowFraming, validateSqliteHeader, validateStoredActivity, validateWatchLease, } from './square-sqlite.js';
7
7
  function isObject(value) {
8
8
  return typeof value === 'object' && value !== null && !Array.isArray(value);
9
9
  }
@@ -57,7 +57,7 @@ function validateActs(value) {
57
57
  try {
58
58
  for (const candidate of value) {
59
59
  const act = validateStoredActivity(candidate);
60
- if (act.index <= previous)
60
+ if (act.kind === 'unlisten' || act.index <= previous)
61
61
  return false;
62
62
  previous = act.index;
63
63
  }
@@ -153,58 +153,78 @@ function counts(state) {
153
153
  routes: state.routes?.length ?? 0,
154
154
  };
155
155
  }
156
+ function rowCounts(database) {
157
+ const count = (table) => Number(database.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n);
158
+ return { activities: count('activities'), receipts: count('receipts'), leases: count('catch_leases'), routes: count('routes') };
159
+ }
160
+ const ACTIVITY_INDEX_NAMES = ['activity_kind_index', 'activity_actor_index', 'activity_say_actor_index', 'activity_say_time', 'activity_bell_actor_time'];
161
+ function rebuildV2Activities(database) {
162
+ database.exec([
163
+ ...ACTIVITY_INDEX_NAMES.map((name) => `DROP INDEX ${name};`),
164
+ 'ALTER TABLE activities RENAME TO activities_v2;',
165
+ ACTIVITIES_TABLE_SQL,
166
+ 'INSERT INTO activities(act_index,at,kind,actor_json,actor_key_json,reach,reply_index,payload_json) SELECT act_index,at,kind,actor_json,actor_key_json,reach,reply_index,payload_json FROM activities_v2 ORDER BY act_index;',
167
+ 'DROP TABLE activities_v2;',
168
+ ...ACTIVITY_INDEXES_SQL.map((sql) => `${sql};`),
169
+ ].join('\n'));
170
+ }
171
+ function validateMigrationSource(database, location) {
172
+ const before = identity(database);
173
+ if (before.applicationId !== APPLICATION_ID)
174
+ throw invalidArtifact('unrelated SQLite database.');
175
+ if (before.userVersion === 1) {
176
+ validateLegacyFraming(database, APPLICATION_ID, location);
177
+ return 1;
178
+ }
179
+ if (before.userVersion === V2_USER_VERSION) {
180
+ validateRowFraming(database, V2_USER_VERSION, location);
181
+ createSqliteDriver(database).header();
182
+ return 2;
183
+ }
184
+ if (before.userVersion === CURRENT_USER_VERSION) {
185
+ validateSqliteHeader(database, location);
186
+ return undefined;
187
+ }
188
+ throw invalidArtifact('unsupported SQLite format version.');
189
+ }
156
190
  /**
157
- * Explicit in-place conversion of a normally persisted version 1 artifact.
158
- *
159
- * The legacy snapshot is read, validated and written here and nowhere else in
160
- * production; the row schema, codec and landing transition come from the driver.
161
- * One transaction: preflight framing, the same recheck under the lock, the
162
- * preserved authority, the derived facts, the format flip and a single COMMIT.
191
+ * Explicit in-place conversion of a version 1 snapshot or version 2 row
192
+ * artifact. Each source is validated before and under the write lock; v1 is
193
+ * imported through the normal current row initializer, while v2 rebuilds only
194
+ * activities and its indexes before publishing the new format version.
163
195
  */
164
196
  export async function migrateSquareFile(location, signal) {
165
197
  const squarePath = await canonicalPath(location);
166
198
  const database = openExistingSquareDatabase(squarePath);
167
199
  try {
168
- // Owned framing, read-only, before any pragma or DDL touches the artifact.
169
- // Every preflight statement reads one snapshot taken in the helper's
170
- // retryable stage: a transient writer lock is waited out like any other
171
- // acquisition, and a migrator that commits while we look cannot move the
172
- // artifact between the identity read and the schema reads.
173
- let alreadyConverted;
200
+ let alreadyCurrent;
174
201
  const preflight = await inSqliteTransaction(database, false, () => {
175
- const before = identity(database);
176
- if (before.applicationId === APPLICATION_ID && before.userVersion === 1) {
177
- validateLegacyFraming(database, APPLICATION_ID, squarePath);
178
- return;
179
- }
180
- // A row artifact means another migrator finished here; anything else is
181
- // refused by the row header itself.
182
- validateSqliteHeader(database, squarePath);
183
- alreadyConverted = { status: 'already-format-2', standing: standing(database) };
184
- }, () => alreadyConverted, signal);
185
- if (preflight !== undefined)
186
- return preflight;
202
+ const source = validateMigrationSource(database, squarePath);
203
+ if (source === undefined)
204
+ alreadyCurrent = { status: 'already-current', standing: standing(database) };
205
+ return source;
206
+ }, (source) => source, signal);
207
+ if (preflight === undefined)
208
+ return alreadyCurrent;
187
209
  database.exec('PRAGMA busy_timeout = 0; PRAGMA synchronous = FULL');
188
210
  const result = {};
189
211
  await inSqliteTransaction(database, true, () => {
190
- // Another migration may have finished between preflight and this lock.
191
- const locked = identity(database);
192
- if (locked.applicationId !== APPLICATION_ID || locked.userVersion !== 1) {
193
- // A valid row artifact is done already; anything else is refused here.
194
- validateSqliteHeader(database, squarePath);
195
- return;
196
- }
197
- validateLegacyFraming(database, APPLICATION_ID, squarePath);
198
- result.converted = true;
212
+ const source = validateMigrationSource(database, squarePath);
213
+ result.source = source;
199
214
  }, () => {
200
- if (result.converted !== true)
201
- return;
202
- const legacy = decodeLegacySnapshot(database);
203
- initializeSqlite(database, legacy.state, legacy.revision);
204
- database.exec(`DROP TABLE ${V1_SNAPSHOT_TABLE}; PRAGMA user_version = 2`);
205
- result.outcome = { status: 'migrated', standing: standing(database), counts: counts(legacy.state) };
215
+ if (result.source === 1) {
216
+ const legacy = decodeLegacySnapshot(database);
217
+ initializeSqlite(database, legacy.state, legacy.revision);
218
+ database.exec(`DROP TABLE ${V1_SNAPSHOT_TABLE}; PRAGMA user_version = ${CURRENT_USER_VERSION}`);
219
+ result.outcome = { status: 'migrated', standing: standing(database), counts: counts(legacy.state) };
220
+ }
221
+ else if (result.source === 2) {
222
+ rebuildV2Activities(database);
223
+ database.exec(`PRAGMA user_version = ${CURRENT_USER_VERSION}`);
224
+ result.outcome = { status: 'migrated', standing: standing(database), counts: rowCounts(database) };
225
+ }
206
226
  }, signal);
207
- return result.outcome ?? { status: 'already-format-2', standing: standing(database) };
227
+ return result.outcome ?? { status: 'already-current', standing: standing(database) };
208
228
  }
209
229
  finally {
210
230
  database.close();
@@ -19,6 +19,8 @@ export interface SquareState {
19
19
  }
20
20
  export declare function emptyRuntimeState(nextActIndex?: number): SquareRuntimeState;
21
21
  export declare const APPLICATION_ID = 1397838405;
22
+ export declare const V2_USER_VERSION = 2;
23
+ export declare const CURRENT_USER_VERSION = 3;
22
24
  export declare const V1_SNAPSHOT_TABLE = "square_snapshot";
23
25
  export interface LegacyFraming {
24
26
  readonly applicationId: number;
@@ -32,11 +34,15 @@ export interface LegacyFraming {
32
34
  * migration. Anything else is unrelated or unsupported, not repaired.
33
35
  */
34
36
  export declare function validateLegacyFraming(database: DatabaseSync, applicationId: number, location?: string): LegacyFraming;
35
- export declare const SCHEMA_SQL = "CREATE TABLE square_meta (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n revision INTEGER NOT NULL CHECK (revision BETWEEN 0 AND 9007199254740991),\n next_act_index INTEGER NOT NULL CHECK (next_act_index BETWEEN 0 AND 9007199254740991),\n hard_cap INTEGER CHECK (hard_cap BETWEEN 1 AND 9007199254740991),\n throttle_per_minute INTEGER CHECK (throttle_per_minute BETWEEN 1 AND 9007199254740991),\n preamble_json TEXT NOT NULL CHECK (json_valid(preamble_json)),\n warmup_json TEXT NOT NULL CHECK (json_valid(warmup_json))\n);\nCREATE TABLE activities (\n act_index INTEGER PRIMARY KEY CHECK (act_index BETWEEN 0 AND 9007199254740991),\n at REAL NOT NULL,\n kind TEXT NOT NULL CHECK (kind IN ('join','done','say','hold','resume','read','listen','ignore')),\n actor_json TEXT COLLATE BINARY,\n actor_key_json TEXT COLLATE BINARY,\n reach TEXT CHECK (reach = 'bell'),\n reply_index INTEGER CHECK (reply_index >= 0 AND reply_index < act_index),\n payload_json TEXT NOT NULL CHECK (json_valid(payload_json)),\n CHECK ((actor_json IS NULL) = (actor_key_json IS NULL)),\n CHECK (kind IN ('hold','resume') OR actor_json IS NOT NULL),\n CHECK (kind = 'say' OR (reach IS NULL AND reply_index IS NULL))\n);\nCREATE INDEX activity_kind_index ON activities(kind, act_index);\nCREATE INDEX activity_actor_index ON activities(actor_key_json, act_index);\nCREATE INDEX activity_say_actor_index ON activities(actor_key_json, act_index) WHERE kind='say';\nCREATE INDEX activity_say_time ON activities(at, act_index) WHERE kind='say';\nCREATE INDEX activity_bell_actor_time ON activities(actor_key_json, at, act_index) WHERE kind='say' AND reach='bell';\n\nCREATE TABLE receipts (\n owner_json TEXT COLLATE BINARY NOT NULL,\n act_index INTEGER NOT NULL CHECK (act_index BETWEEN 0 AND 9007199254740991),\n at REAL NOT NULL,\n PRIMARY KEY (owner_json, act_index)\n) WITHOUT ROWID;\nCREATE INDEX receipt_owner_time ON receipts(owner_json, at);\nCREATE TABLE catch_leases (\n owner_json TEXT COLLATE BINARY PRIMARY KEY NOT NULL,\n lease_id_json TEXT NOT NULL,\n heartbeat_at REAL NOT NULL,\n expires_at REAL NOT NULL CHECK (expires_at >= heartbeat_at),\n filter_json TEXT CHECK (filter_json IS NULL OR json_valid(filter_json))\n) WITHOUT ROWID;\n\nCREATE TABLE routes (\n position INTEGER PRIMARY KEY CHECK (position >= 0),\n participant_key_json TEXT COLLATE BINARY NOT NULL,\n session_json TEXT COLLATE BINARY NOT NULL,\n kind TEXT NOT NULL CHECK (kind IN ('opencode-server','codex-queue','claude-native','paseo')),\n updated_at REAL NOT NULL,\n payload_json TEXT NOT NULL CHECK (json_valid(payload_json))\n);\nCREATE INDEX route_participant ON routes(participant_key_json, position);\nCREATE INDEX route_session ON routes(session_json, position);\n\nCREATE TABLE participants (\n name_key_json TEXT COLLATE BINARY PRIMARY KEY NOT NULL,\n name_json TEXT NOT NULL,\n first_index INTEGER NOT NULL UNIQUE,\n joined INTEGER NOT NULL CHECK (joined IN (0,1)),\n done INTEGER NOT NULL CHECK (done IN (0,1)),\n say_count INTEGER NOT NULL CHECK (say_count >= 0),\n last_active_at REAL,\n last_join_index INTEGER,\n CHECK (NOT (joined=1 AND done=1))\n) WITHOUT ROWID;\nCREATE TABLE attention (\n listener_key_json TEXT COLLATE BINARY NOT NULL,\n target_key_json TEXT COLLATE BINARY NOT NULL,\n target_json TEXT NOT NULL,\n kind TEXT NOT NULL CHECK (kind IN ('listen','ignore')),\n since_index INTEGER NOT NULL,\n PRIMARY KEY (listener_key_json, target_key_json)\n) WITHOUT ROWID;\nCREATE INDEX attention_target ON attention(target_key_json, kind, listener_key_json);\nCREATE TABLE landed_audience (\n act_index INTEGER NOT NULL,\n recipient_key_json TEXT COLLATE BINARY NOT NULL,\n recipient_order INTEGER NOT NULL CHECK (recipient_order >= 0),\n route TEXT NOT NULL CHECK (route IN ('mention','reply','attention','bell')),\n PRIMARY KEY (act_index, recipient_key_json),\n UNIQUE (act_index, recipient_order)\n) WITHOUT ROWID;\nCREATE INDEX audience_recipient ON landed_audience(recipient_key_json, act_index);";
37
+ export declare const ACTIVITIES_TABLE_SQL = "CREATE TABLE activities (\n act_index INTEGER PRIMARY KEY CHECK (act_index BETWEEN 0 AND 9007199254740991),\n at REAL NOT NULL,\n kind TEXT NOT NULL CHECK (kind IN ('join','done','say','hold','resume','read','listen','ignore','unlisten')),\n actor_json TEXT COLLATE BINARY,\n actor_key_json TEXT COLLATE BINARY,\n reach TEXT CHECK (reach = 'bell'),\n reply_index INTEGER CHECK (reply_index >= 0 AND reply_index < act_index),\n payload_json TEXT NOT NULL CHECK (json_valid(payload_json)),\n CHECK ((actor_json IS NULL) = (actor_key_json IS NULL)),\n CHECK (kind IN ('hold','resume') OR actor_json IS NOT NULL),\n CHECK (kind = 'say' OR (reach IS NULL AND reply_index IS NULL))\n);";
38
+ export declare const ACTIVITY_INDEXES_SQL: readonly ["CREATE INDEX activity_kind_index ON activities(kind, act_index)", "CREATE INDEX activity_actor_index ON activities(actor_key_json, act_index)", "CREATE INDEX activity_say_actor_index ON activities(actor_key_json, act_index) WHERE kind='say'", "CREATE INDEX activity_say_time ON activities(at, act_index) WHERE kind='say'", "CREATE INDEX activity_bell_actor_time ON activities(actor_key_json, at, act_index) WHERE kind='say' AND reach='bell'"];
39
+ export declare const SCHEMA_SQL: string;
40
+ export declare const SCHEMA_SQL_V2: string;
36
41
  export declare function invalidArtifact(detail: string): SquareError;
37
42
  export declare function validateStoredActivity(value: unknown): StoredAct;
38
43
  export declare function validateWatchLease(value: unknown): WatchLease;
39
44
  export declare function validateRoute(value: unknown): RouteFact;
45
+ export declare function validateRowFraming(db: DatabaseSync, version: 2 | 3, location?: string): void;
40
46
  export declare function validateSqliteHeader(db: DatabaseSync, location?: string): SquareHeader;
41
47
  export interface SqliteDriver extends SquareRead, LandingWriter {
42
48
  readonly changed: boolean;
@@ -6,6 +6,8 @@ export function emptyRuntimeState(nextActIndex = 0) {
6
6
  }
7
7
  export const APPLICATION_ID = 0x53515245;
8
8
  const V1_USER_VERSION = 1;
9
+ export const V2_USER_VERSION = 2;
10
+ export const CURRENT_USER_VERSION = 3;
9
11
  export const V1_SNAPSHOT_TABLE = 'square_snapshot';
10
12
  const V1_SNAPSHOT_DDL = `CREATE TABLE ${V1_SNAPSHOT_TABLE} (id INTEGER PRIMARY KEY CHECK(id=1), revision INTEGER NOT NULL CHECK(revision>=0), state TEXT NOT NULL)`;
11
13
  const V1_SNAPSHOT_COLUMNS = [
@@ -50,19 +52,12 @@ export function validateLegacyFraming(database, applicationId, location = '') {
50
52
  function normalizedLegacySql(sql) {
51
53
  return sql.trim().replace(/\s+/g, ' ').replace(/\s*(>=|[(),=])\s*/g, '$1');
52
54
  }
53
- export const SCHEMA_SQL = `CREATE TABLE square_meta (
54
- id INTEGER PRIMARY KEY CHECK (id = 1),
55
- revision INTEGER NOT NULL CHECK (revision BETWEEN 0 AND 9007199254740991),
56
- next_act_index INTEGER NOT NULL CHECK (next_act_index BETWEEN 0 AND 9007199254740991),
57
- hard_cap INTEGER CHECK (hard_cap BETWEEN 1 AND 9007199254740991),
58
- throttle_per_minute INTEGER CHECK (throttle_per_minute BETWEEN 1 AND 9007199254740991),
59
- preamble_json TEXT NOT NULL CHECK (json_valid(preamble_json)),
60
- warmup_json TEXT NOT NULL CHECK (json_valid(warmup_json))
61
- );
62
- CREATE TABLE activities (
55
+ const V2_ACTIVITY_KIND_CHECK = "kind IN ('join','done','say','hold','resume','read','listen','ignore')";
56
+ const CURRENT_ACTIVITY_KIND_CHECK = "kind IN ('join','done','say','hold','resume','read','listen','ignore','unlisten')";
57
+ export const ACTIVITIES_TABLE_SQL = `CREATE TABLE activities (
63
58
  act_index INTEGER PRIMARY KEY CHECK (act_index BETWEEN 0 AND 9007199254740991),
64
59
  at REAL NOT NULL,
65
- kind TEXT NOT NULL CHECK (kind IN ('join','done','say','hold','resume','read','listen','ignore')),
60
+ kind TEXT NOT NULL CHECK (${CURRENT_ACTIVITY_KIND_CHECK}),
66
61
  actor_json TEXT COLLATE BINARY,
67
62
  actor_key_json TEXT COLLATE BINARY,
68
63
  reach TEXT CHECK (reach = 'bell'),
@@ -71,12 +66,25 @@ CREATE TABLE activities (
71
66
  CHECK ((actor_json IS NULL) = (actor_key_json IS NULL)),
72
67
  CHECK (kind IN ('hold','resume') OR actor_json IS NOT NULL),
73
68
  CHECK (kind = 'say' OR (reach IS NULL AND reply_index IS NULL))
69
+ );`;
70
+ export const ACTIVITY_INDEXES_SQL = [
71
+ 'CREATE INDEX activity_kind_index ON activities(kind, act_index)',
72
+ 'CREATE INDEX activity_actor_index ON activities(actor_key_json, act_index)',
73
+ "CREATE INDEX activity_say_actor_index ON activities(actor_key_json, act_index) WHERE kind='say'",
74
+ "CREATE INDEX activity_say_time ON activities(at, act_index) WHERE kind='say'",
75
+ "CREATE INDEX activity_bell_actor_time ON activities(actor_key_json, at, act_index) WHERE kind='say' AND reach='bell'",
76
+ ];
77
+ export const SCHEMA_SQL = `CREATE TABLE square_meta (
78
+ id INTEGER PRIMARY KEY CHECK (id = 1),
79
+ revision INTEGER NOT NULL CHECK (revision BETWEEN 0 AND 9007199254740991),
80
+ next_act_index INTEGER NOT NULL CHECK (next_act_index BETWEEN 0 AND 9007199254740991),
81
+ hard_cap INTEGER CHECK (hard_cap BETWEEN 1 AND 9007199254740991),
82
+ throttle_per_minute INTEGER CHECK (throttle_per_minute BETWEEN 1 AND 9007199254740991),
83
+ preamble_json TEXT NOT NULL CHECK (json_valid(preamble_json)),
84
+ warmup_json TEXT NOT NULL CHECK (json_valid(warmup_json))
74
85
  );
75
- CREATE INDEX activity_kind_index ON activities(kind, act_index);
76
- CREATE INDEX activity_actor_index ON activities(actor_key_json, act_index);
77
- CREATE INDEX activity_say_actor_index ON activities(actor_key_json, act_index) WHERE kind='say';
78
- CREATE INDEX activity_say_time ON activities(at, act_index) WHERE kind='say';
79
- CREATE INDEX activity_bell_actor_time ON activities(actor_key_json, at, act_index) WHERE kind='say' AND reach='bell';
86
+ ${ACTIVITIES_TABLE_SQL}
87
+ ${ACTIVITY_INDEXES_SQL.join(';\n')};
80
88
 
81
89
  CREATE TABLE receipts (
82
90
  owner_json TEXT COLLATE BINARY NOT NULL,
@@ -134,6 +142,8 @@ CREATE TABLE landed_audience (
134
142
  ) WITHOUT ROWID;
135
143
  CREATE INDEX audience_recipient ON landed_audience(recipient_key_json, act_index);`;
136
144
  const EXPECTED_SCHEMA_SQL = SCHEMA_SQL.split(';').map(s => s.trim()).filter(Boolean).map(normalizedSql).sort();
145
+ export const SCHEMA_SQL_V2 = SCHEMA_SQL.replace(CURRENT_ACTIVITY_KIND_CHECK, V2_ACTIVITY_KIND_CHECK);
146
+ const EXPECTED_SCHEMA_SQL_V2 = SCHEMA_SQL_V2.split(';').map(s => s.trim()).filter(Boolean).map(normalizedSql).sort();
137
147
  const encode = JSON.stringify;
138
148
  const key = (name) => encode(nameKey(name));
139
149
  export function invalidArtifact(detail) {
@@ -189,7 +199,7 @@ function immutable(value) {
189
199
  }
190
200
  return value;
191
201
  }
192
- const kinds = ['join', 'done', 'say', 'hold', 'resume', 'read', 'listen', 'ignore'];
202
+ const kinds = ['join', 'done', 'say', 'hold', 'resume', 'read', 'listen', 'ignore', 'unlisten'];
193
203
  function head(row) {
194
204
  const index = integer(row.act_index), at = finite(row.at);
195
205
  requireFact(kinds.includes(string(row.kind)));
@@ -237,6 +247,7 @@ export function validateStoredActivity(value) {
237
247
  break;
238
248
  case 'listen':
239
249
  case 'ignore':
250
+ case 'unlisten':
240
251
  shape(a, [...common, 'target']);
241
252
  string(a.target, true);
242
253
  break;
@@ -247,7 +258,7 @@ export function validateStoredActivity(value) {
247
258
  function activity(row) {
248
259
  const h = head(row), payload = object(json(row.payload_json));
249
260
  // Payload cannot shadow indexed structural columns.
250
- const allowed = h.kind === 'say' ? ['body', 'mentions'] : h.kind === 'done' || h.kind === 'hold' ? ['body'] : h.kind === 'read' ? ['through'] : h.kind === 'listen' || h.kind === 'ignore' ? ['target'] : [];
261
+ const allowed = h.kind === 'say' ? ['body', 'mentions'] : h.kind === 'done' || h.kind === 'hold' ? ['body'] : h.kind === 'read' ? ['through'] : h.kind === 'listen' || h.kind === 'ignore' || h.kind === 'unlisten' ? ['target'] : [];
251
262
  requireFact(Object.keys(payload).every(k => allowed.includes(k)));
252
263
  return immutable(validateStoredActivity({ ...h, ...payload }));
253
264
  }
@@ -315,6 +326,17 @@ function route(row) {
315
326
  }));
316
327
  }
317
328
  function normalizedSql(sql) { return sql.trim().replace(/;$/, '').replace(/\s+/g, ' ').replace(/\s*([(),=])\s*/g, '$1'); }
329
+ function validateOwnedSchema(db, expected, location) {
330
+ const actual = db.prepare("SELECT sql FROM sqlite_schema WHERE name NOT GLOB 'sqlite_*' AND sql IS NOT NULL").all().map(r => normalizedSql(string(r.sql))).sort();
331
+ requireFact(actual.length === expected.length && actual.every((sql, i) => sql === expected[i]), 'unsupported row schema.');
332
+ requireFact(db.prepare('PRAGMA journal_mode').get().journal_mode === (location === ':memory:' ? 'memory' : 'delete'), 'unsupported journal mode.');
333
+ }
334
+ export function validateRowFraming(db, version, location = '') {
335
+ const identity = db.prepare('SELECT application_id,user_version FROM pragma_application_id,pragma_user_version').get();
336
+ requireFact(identity.application_id === APPLICATION_ID, 'unrelated SQLite database.');
337
+ requireFact(identity.user_version === version, 'unsupported SQLite format version.');
338
+ validateOwnedSchema(db, version === V2_USER_VERSION ? EXPECTED_SCHEMA_SQL_V2 : EXPECTED_SCHEMA_SQL, location);
339
+ }
318
340
  export function validateSqliteHeader(db, location = '') {
319
341
  const identity = db.prepare('SELECT application_id,user_version FROM pragma_application_id,pragma_user_version').get();
320
342
  requireFact(identity.application_id === APPLICATION_ID, 'unrelated SQLite database.');
@@ -322,10 +344,12 @@ export function validateSqliteHeader(db, location = '') {
322
344
  validateLegacyFraming(db, APPLICATION_ID, location);
323
345
  throw new SquareError('invalid_args', `Square artifact at ${location} needs migration.\nStop sessions using it, back it up, then migrate and verify before restarting.`, { reason: 'migration_required' });
324
346
  }
325
- requireFact(identity.user_version === 2, 'unsupported SQLite format version.');
326
- const actual = db.prepare("SELECT sql FROM sqlite_schema WHERE name NOT GLOB 'sqlite_*' AND sql IS NOT NULL").all().map(r => normalizedSql(string(r.sql))).sort();
327
- requireFact(actual.length === EXPECTED_SCHEMA_SQL.length && actual.every((sql, i) => sql === EXPECTED_SCHEMA_SQL[i]), 'unsupported row schema.');
328
- requireFact(db.prepare('PRAGMA journal_mode').get().journal_mode === (location === ':memory:' ? 'memory' : 'delete'), 'unsupported journal mode.');
347
+ if (identity.user_version === V2_USER_VERSION) {
348
+ validateRowFraming(db, V2_USER_VERSION, location);
349
+ throw new SquareError('invalid_args', `Square artifact at ${location} needs migration.\nStop sessions using it, back it up, then migrate and verify before restarting.`, { reason: 'migration_required' });
350
+ }
351
+ requireFact(identity.user_version === CURRENT_USER_VERSION, 'unsupported SQLite format version.');
352
+ validateRowFraming(db, CURRENT_USER_VERSION, location);
329
353
  return createSqliteDriver(db).header();
330
354
  }
331
355
  function activitySelection(filter, alias = 'a') {
@@ -550,6 +574,12 @@ export function createSqliteDriver(db) {
550
574
  }
551
575
  if (effect.removeListeningOf !== undefined)
552
576
  run("DELETE FROM attention WHERE listener_key_json=? AND kind='listen'", [key(effect.removeListeningOf)]);
577
+ if (effect.removeListening !== undefined) {
578
+ const target = effect.removeListening;
579
+ string(target.listener, true);
580
+ string(target.target, true);
581
+ run("DELETE FROM attention WHERE listener_key_json=? AND target_key_json=? AND kind='listen'", [key(target.listener), key(target.target)]);
582
+ }
553
583
  if (effect.replaceAttention) {
554
584
  const t = effect.replaceAttention;
555
585
  string(t.listener, true);
@@ -34,8 +34,9 @@ export const SQUARE_TOOLS = [
34
34
  limit: z.number().int().min(1).optional(), order: z.enum(['asc', 'desc']).optional(), before: activityId.optional(), after: activityId.optional(),
35
35
  grep: z.string().optional(), fixed: z.string().optional(), from: stringArray.optional(), mention: z.string().optional(),
36
36
  }, (square, args, control) => square.history(args, control)),
37
- tool('listen', 'Listen for future bare activity from a participant, clearing any ignore of them.', { as: participant, target: z.string() }, async (square, { as, target }, control) => (await square.participant(as)).listen(target, {}, control)),
37
+ tool('listen', 'Listen for future activity from a participant, clearing any ignore of them.', { as: participant, target: z.string() }, async (square, { as, target }, control) => (await square.participant(as)).listen(target, {}, control)),
38
38
  tool('ignore', 'Block a participant’s future mentions and bare activity. Listening again clears the ignore; already landed activity is unchanged.', { as: participant, target: z.string() }, async (square, { as, target }, control) => (await square.participant(as)).ignore(target, {}, control)),
39
+ tool('unlisten', 'Stop listening to a participant’s future activity; mentions and replies still reach you. An existing ignore is unchanged.', { as: participant, target: z.string() }, async (square, { as, target }, control) => (await square.participant(as)).unlisten(target, {}, control)),
39
40
  tool('listening', 'List participants this participant is listening to.', { as: participant }, async (square, { as }, control) => (await square.participant(as)).listening({}, control)),
40
41
  tool('hold', 'Raise a hand to hold the square.', { as: participant, reason: z.string().optional() }, async (square, { as, reason }, control) => (await square.participant(as)).hold(reason, {}, control)),
41
42
  tool('resume', 'Release the current hold.', { as: participant }, async (square, { as }, control) => (await square.participant(as)).resume({}, control)),
package/dist/views.d.ts CHANGED
@@ -6,7 +6,7 @@ import type { Activity, ArchiveRecord, HistoryQuery, HistoryContinuation, Operat
6
6
  export type { EntryPresentation, ListPresentation, CatchPresence, StreamProjection } from './square-facade.js';
7
7
  export declare const HISTORY_DEFAULT_LIMIT = 10;
8
8
  export declare const HISTORY_MAX_LIMIT = 100;
9
- export declare const NON_READ: readonly ["join", "done", "say", "hold", "resume", "listen", "ignore"];
9
+ export declare const NON_READ: readonly ["join", "done", "say", "hold", "resume", "listen", "ignore", "unlisten"];
10
10
  /** The one StoredAct-to-public-Activity field mapping. Rejects read and actorless acts. */
11
11
  export declare function toPublicActivity(stored: StoredAct): Activity;
12
12
  export declare function parseRequiredActivityId(id: ActivityId): number;
package/dist/views.js CHANGED
@@ -9,7 +9,7 @@ export const HISTORY_DEFAULT_LIMIT = 10;
9
9
  export const HISTORY_MAX_LIMIT = 100;
10
10
  const STATUS_HERE_WINDOW_MS = 30 * 60_000;
11
11
  const PRESENCE_WINDOW_MS = 8 * 60 * 60_000;
12
- export const NON_READ = ['join', 'done', 'say', 'hold', 'resume', 'listen', 'ignore'];
12
+ export const NON_READ = ['join', 'done', 'say', 'hold', 'resume', 'listen', 'ignore', 'unlisten'];
13
13
  const ARCHIVE = new Set(['join', 'done', 'say', 'hold', 'resume']);
14
14
  /** The one StoredAct-to-public-Activity field mapping. Rejects read and actorless acts. */
15
15
  export function toPublicActivity(stored) {
@@ -179,7 +179,7 @@ These are deliverer closures, not a reviewer verdict. Rows distinguish behavior
179
179
  | SDK projection roundtrip / leaked observations | SDK express uses `prepareExpression` + `landExpression`, strips observation/waited and retains delivery; SDK catch uses `catchOwned` and returns only established CatchResult. SDK test asserts established fields and avoids application body projections. SDK Square history stays unbounded, Participant recent-ten; parity history fixture proves both, and projection test preserves detached delivered predicate. |
180
180
  | Duplicate contracts, aliases and dead mixed views | Private status/unread producers import/re-export facade contracts; catch observation extends shared expression facts and enriched projection reuses it; required express participant replaces name alias. Retired helper search is empty. Recovery selects only gates/bounded unread summaries, not discarded feed/ordinals/perceptions. |
181
181
  | Packed owner orchestration hides effects/count integrity | History, lifecycle branches, maintenance/discovery/recovery, catch conflict and SDK recognize are expanded into readable steps. Remaining one-line SDK delegates are supported simple resource calls, not packed async policy. Inspect implementation diff; all new source is included in count. |
182
- | Invalid reply / missing reach / done repair regressions | Typed `invalid_reply` covers malformed and valid-but-absent IDs; typed reach/done reasons stay domain facts, CLI owns glyphs/flags. Express-recovery tests execute printed repaired commands, preserving raw drafts and other recipients; missing reach offers executable `--no-mention`; done ends scoped help. Unknown/landed effects offer reconciliation, never replay. |
182
+ | Invalid reply / done repair regressions | Typed `invalid_reply` covers malformed and valid-but-absent IDs; typed reach/done reasons stay domain facts, CLI owns glyphs/flags. Express-recovery tests execute printed repaired commands, preserving raw drafts, body candidates and other recipients. Unknown/landed effects offer reconciliation, never replay. |
183
183
  | Failing application clock prevents draft creation | `executeExpression` uses `saveActivityDraft`'s transport-local `Date.now` default, never the failed owner clock. Focused `express-recovery` fixture supplies a throwing clock and landed error plus throwing recovery; exact CRLF raw is already saved at recovery invocation, landed ID remains in history reconciliation, and no replay is offered. |
184
184
  | Captured default wake stops at adapter or executable selection | Amended `defaultWakeAdapters(env)` builds `CodexQueueAdapter({env})`; queue executable comes from explicit bin, else supplied env, never later ambient env. Boundary test uses a real isolated fake executable and captured child environment/queue destination after global mutation; injected port wins. No real native delivery. |
185
185
  | Force-build ignores cancellation under lock | Amended build/create signal reaches existing lock retries and transaction boundaries; abort checked before public link, cleanup signal-free, no postcommit rejection. Real exclusive-lock fixture cancels, unlocks, and proves unchanged snapshot/no delayed rebuild/no temporary artifact. Separate published-link fixture aborts after commit and still gets the build result. |
@@ -1,78 +1,75 @@
1
- # Migrating an existing square to row storage
1
+ # Migrating an existing square
2
2
 
3
3
  Square stores authority in one versioned SQLite artifact. Version 1 kept the
4
- whole history as one JSON snapshot; version 2 keeps typed rows — activities,
5
- sparse receipts, catch leases, ordered routes and the small configuration
6
- facts — in one in-place database.
4
+ whole history as one JSON snapshot. Version 2 introduced typed rows. Current
5
+ format 3 adds the `unlisten` history event without changing the other tables.
7
6
 
8
- Existing artifacts are **not** converted automatically. A normal read of a
9
- version 1 artifact refuses with a descriptive offline-migration requirement.
10
- The CLI adds the concrete recovery command; native tools retain the description:
7
+ Existing artifacts are **not** converted automatically. Ordinary reads and
8
+ writes refuse versions 1 and 2 with an offline-migration requirement. The CLI
9
+ adds the concrete recovery command; native tools retain the description:
11
10
 
12
11
  ```sh
13
12
  square --location /path/to/SQUARE.square migrate
14
13
  ```
15
14
 
16
- That is the only migration command. There is no `--check`, `--backup`,
17
- `--force`, bulk mode, sidecar, daemon or dual reader, and no operator
18
- configuration to set.
15
+ This is the only artifact migration command. There is no `--check`, `--backup`,
16
+ `--force`, bulk mode, sidecar, daemon or dual reader. The host ledger is a
17
+ separate resource; unlisten does not change its format.
19
18
 
20
19
  ## What the command does
21
20
 
22
- It converts the artifact **in place**, in **one transaction**:
23
-
24
- 1. Reads the owned legacy framing before touching anything (application id,
25
- format version, `DELETE` journal mode, the exact snapshot schema) and
26
- refuses anything that is not an owned version 1 square.
27
- 2. Takes `BEGIN IMMEDIATE`, re-checks that framing under the lock, and decodes
28
- and validates the complete snapshot inside the lock.
29
- 3. Creates the row schema and writes the preserved authority: activity indices
30
- and their gaps, optional-field presence, raw receipt and lease owner keys,
31
- route order and duplicates, string content and finite fractional timestamps.
32
- 4. Rebuilds the derived participant, hold, attention and landed-audience facts
33
- through the same single landing transition the runtime uses.
34
- 5. Drops the snapshot table, sets the format version, and commits once.
35
-
36
- The revision is preserved; migration never invents or advances it. Nothing is
37
- published piecemeal, and the file keeps its path, inode and registry identity.
38
-
39
- Two shapes are already fine and change nothing:
40
-
41
- - an artifact that is already version 2 (reported as already format 2);
42
- - an artifact converted by another process while this one waited for the lock.
21
+ Both supported conversions happen **in place**, in **one transaction**:
22
+
23
+ 1. Read the owned framing before any persistent pragma or DDL: application id,
24
+ source format, `DELETE` journal mode, and exact known schema.
25
+ 2. Take `BEGIN IMMEDIATE` and re-check that framing under the lock.
26
+ 3. Convert the source using its explicit path:
27
+ - **Version 1:** decode and validate the complete snapshot, create current
28
+ rows, and preserve activity indices/gaps, optional fields, raw receipt and
29
+ lease owners, ordered routes, strings and finite fractional timestamps.
30
+ Rebuild derived participant, hold, attention and landed-audience facts
31
+ with the runtime's shared landing transition, then drop the snapshot.
32
+ - **Version 2:** rebuild only `activities` and its indexes to accept
33
+ `unlisten`, copying every existing activity row unchanged. All other
34
+ tables, including attention, landed audience, receipts, leases, routes
35
+ and configuration, remain untouched. No history is reinterpreted.
36
+ 4. Set format 3 and commit once.
37
+
38
+ Revision and assigned activity indices are preserved. Migration adds no domain
39
+ activity. Nothing is published piecemeal, and path, inode and registry identity
40
+ remain unchanged. A format-3 artifact is reported as already current without
41
+ writing, including when another migrator finished while this one waited.
43
42
 
44
43
  ## What it refuses
45
44
 
46
- Nothing is written when the input is not a normally persisted version 1 square:
47
- a missing file, an unrelated SQLite database, an unsupported format version, a
48
- different journal mode, an unknown snapshot schema, unreadable snapshot JSON, or
49
- a route carrying a non-finite number (an exponent-overflow value such as
50
- `1e999`, which the previous permissive check accepted and an ordinary JSON round
51
- trip cannot carry) are all refused before any write. The refusal names the
52
- offending route property.
53
-
54
- Journal mode is never rewritten to make a migration possible.
45
+ Missing files, unrelated databases, unsupported versions, different journal
46
+ modes, unknown schemas and malformed headers are refused without a write.
47
+ Version-1 snapshot JSON and activity vocabulary must be valid for that source
48
+ format. Non-finite numbers anywhere in a legacy route, including nested extra
49
+ properties, are refused with the offending property named; they are not
50
+ silently coerced to null. Journal mode is never rewritten to make conversion
51
+ possible, and migration is not a corruption-repair command.
55
52
 
56
53
  ## Before you migrate
57
54
 
58
- - Stop attached runtimes, or upgrade them together with the migration. An
59
- in-flight remote delivery is not revoked by migration, and a version 1 writer
60
- either commits before the conversion or is refused afterwards — it can never
61
- overwrite converted rows.
62
- - An offline backup is optional and, if you want one, ordinary: copy the file
63
- while no writer is attached. The command does not manage backups.
55
+ Stop every session and service using that artifact. Make an offline backup, or
56
+ use SQLite's backup facilities rather than copying a live database. The command
57
+ does not manage backups, stop processes, revoke in-flight remote deliveries,
58
+ or update installed runtimes.
64
59
 
65
- ## Crash safety
60
+ Upgrade all attached runtimes before reopening the migrated artifact. Both
61
+ version-1 and version-2 runtimes refuse format 3; do not mix old and new
62
+ processes against the converted file. An older writer either commits before
63
+ conversion or meets the new version gate afterwards.
66
64
 
67
- Before `COMMIT`, SQLite's own journal recovers the complete version 1 artifact;
68
- after it, the complete version 2 rows are there. A process killed mid-migration
69
- therefore leaves one or the other, never a partial artifact, and re-running the
70
- command finishes the job. A busy `COMMIT` retries the same transaction and never
71
- re-runs the conversion.
65
+ ## Crash safety and checking
72
66
 
73
- ## Checking an artifact
67
+ Before `COMMIT`, SQLite's journal recovers the complete source version; after
68
+ it, complete format-3 rows are present. A failure or process death cannot leave
69
+ a partial conversion. A busy `COMMIT` retries the same transaction, never the
70
+ copy or domain callback.
74
71
 
75
- `square --location <path> doctor` is read-only. On a legacy artifact it
76
- recognizes version 1 and prints the migration command; it does not call the
77
- artifact damaged. On version 2 it validates the rows and recomputes the derived
78
- indices to compare them against the stored ones.
72
+ `square --location <path> doctor` is read-only. On versions 1 and 2 it prints
73
+ the migration requirement without calling the artifact damaged. On current
74
+ format 3 it validates rows and recomputes derived indices for comparison.
75
+ Run it after migration and before restarting upgraded clients.
@@ -1,6 +1,9 @@
1
1
  # Row-oriented artifact design
2
2
 
3
- **Status: design closed; row runtime candidate implemented and measured, awaiting independent review/acceptance.**
3
+ **Historical accepted design for artifact format 2.** The current runtime uses format 3 for the
4
+ `unlisten` event; see [Square API](square-api.md#artifact-format-and-explicit-migration) for current
5
+ format/migration behavior. The schema and delivery-stage terms below record the original row-storage
6
+ replacement, not a pending implementation or a second current-format definition.
4
7
  Source baseline: `99679eb` (0.3.70). This document specifies the design; [runtime delivery evidence](row-runtime-delivery-evidence.md) records implementation verification and actual capacity measurements. Explicit migration remains a separately accepted delivery.
5
8
 
6
9
  ## Decision in brief
@@ -42,7 +42,7 @@ try {
42
42
  ## One set of operations and defaults
43
43
 
44
44
  Square owns `history`, `status`, `participants`, `snapshot`, `stream`, and recovery observations.
45
- Participant owns `express`, `catch`, `listen`, `ignore`, `listening`, `hold`, `resume`, `done`;
45
+ Participant owns `express`, `catch`, `listen`, `unlisten`, `ignore`, `listening`, `hold`, `resume`, `done`;
46
46
  its `history` delegates to the same Square history operation.
47
47
 
48
48
  - Express waits while held/throttled unless `noWait: true`, for **every** caller. `ExpressResult`
@@ -58,6 +58,10 @@ its `history` delegates to the same Square history operation.
58
58
  bounds it. CLI/tool schemas accept numeric limits only. History never writes receipts.
59
59
  - `participants()` returns `{participants, observation?}` and `listening()` returns
60
60
  `{listening, observation?}`, not arrays carrying metadata.
61
+ - `unlisten(target)` removes only that participant's current listen edge to the target, without
62
+ leaving or blocking future mentions/replies. It returns the same `ListenerChangeResult` as listen
63
+ and ignore, with `activity: null` when no listen exists. Existing ignores are unchanged. Earlier
64
+ landed audiences and receipts are never rewritten; listening again remains available.
61
65
  - Lifecycle operations keep contextual reentry/ownership policy. In particular, done retains its
62
66
  native owner epoch fence instead of blindly joining first. Done accepts no message.
63
67
 
@@ -95,6 +99,23 @@ resources. Express wire output omits the internal delivery diagnostic as before.
95
99
  They are not methods on a synthetic cross-square object. Diagnose/migrate resolve a path without
96
100
  opening a valid current-format Square first.
97
101
 
102
+ ## Artifact format and explicit migration
103
+
104
+ The current artifact format is 3. It records `unlisten` as a real history activity; the derived
105
+ attention table still contains only listen or ignore edges. New artifacts use format 3. Ordinary
106
+ opens refuse older formats with migration guidance, and never migrate implicitly.
107
+
108
+ Stop all sessions and services using a particular artifact, back it up offline (or use SQLite's
109
+ backup API), then run `square --location <path.square> migrate` and `doctor` before reopening it
110
+ with upgraded runtimes. Older runtimes refuse format 3; do not mix versions against a migrated
111
+ artifact. Updating a package does not migrate any file.
112
+
113
+ Format 2 migration changes only the activities table's accepted event kinds and the format marker,
114
+ inside one transaction. Existing rows, revision, assigned indices, attention, landed audiences,
115
+ receipts, leases, and routes remain unchanged. Format 1 snapshots import directly into the current
116
+ format. Repeated migration of a current artifact is a byte-preserving no-op. This is separate from
117
+ host-ledger migration, whose format does not change for unlisten.
118
+
98
119
  ## Deliberate migration breaks
99
120
 
100
121
  The independent `createSquareApplication` API, its options/results/aliases, and `square-application.ts`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/square",
3
- "version": "0.3.79",
3
+ "version": "0.3.81",
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.79",
3
+ "version": "0.3.81",
4
4
  "description": "Native Claude Code inbox delivery for Square participants",
5
5
  "types": "./types.d.ts",
6
6
  "author": {
@@ -40,7 +40,7 @@ I disagree — the cache is the wrong layer for this.
40
40
  - **Mention** participants to address them. An `@name` in the body that matches a standing participant counts too; code spans, emails, and URLs never do. Unmatched names stay literal, with a notice to you. Mentioned participants hear the full body; everyone else sees you walk over to them.
41
41
  - **Reply** to an activity to also reach its author, regardless of the other reach you choose.
42
42
  - **Ring the bell** only when every participant needs it. Body `@names` stay literal; explicit mentions cannot combine with a bell.
43
- - **Land it bare** when it is meant for no one in particular. Only participants listening to you receive it.
43
+ - **Land it bare** when it is meant for no one in particular. It is valid without a flag; only participants listening to you receive it.
44
44
 
45
45
  Addressing is not a secrecy boundary. Everything stays readable through `history`.
46
46
 
@@ -54,9 +54,9 @@ Addressing is not a secrecy boundary. Everything stays readable through `history
54
54
 
55
55
  Waiting with an idle `catch` is the normal way to stay present between expressions. Joining tells you exactly what to keep open. Do not build a polling loop.
56
56
 
57
- ## Listen and ignore
57
+ ## Listen, unlisten and ignore
58
58
 
59
- `listen` to a participant so their future bare activities reach you. Mentions reach you regardless; bells reach everyone. `ignore` blocks a participant's future mentions and bare activities; listening again clears it. Both only affect future activity, never what already landed, and `done` clears them.
59
+ `listen` to a participant so their future activities reach you, including those addressed to others. `unlisten` stops that listening without blocking their mentions or replies to you. It does not change an existing `ignore`. `ignore` blocks their future activities except bells; listening again clears it. These changes are future-only: nothing already landed is withdrawn. `done` clears outgoing listens, not ignores.
60
60
 
61
61
  ## History
62
62
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.3.79",
3
+ "version": "0.3.81",
4
4
  "description": "Shared Square activity with reliable participant attention at Codex boundaries.",
5
5
  "author": {
6
6
  "name": "Square"
@@ -40,7 +40,7 @@ I disagree — the cache is the wrong layer for this.
40
40
  - **Mention** participants to address them. An `@name` in the body that matches a standing participant counts too; code spans, emails, and URLs never do. Unmatched names stay literal, with a notice to you. Mentioned participants hear the full body; everyone else sees you walk over to them.
41
41
  - **Reply** to an activity to also reach its author, regardless of the other reach you choose.
42
42
  - **Ring the bell** only when every participant needs it. Body `@names` stay literal; explicit mentions cannot combine with a bell.
43
- - **Land it bare** when it is meant for no one in particular. Only participants listening to you receive it.
43
+ - **Land it bare** when it is meant for no one in particular. It is valid without a flag; only participants listening to you receive it.
44
44
 
45
45
  Addressing is not a secrecy boundary. Everything stays readable through `history`.
46
46
 
@@ -54,9 +54,9 @@ Addressing is not a secrecy boundary. Everything stays readable through `history
54
54
 
55
55
  Waiting with an idle `catch` is the normal way to stay present between expressions. Joining tells you exactly what to keep open. Do not build a polling loop.
56
56
 
57
- ## Listen and ignore
57
+ ## Listen, unlisten and ignore
58
58
 
59
- `listen` to a participant so their future bare activities reach you. Mentions reach you regardless; bells reach everyone. `ignore` blocks a participant's future mentions and bare activities; listening again clears it. Both only affect future activity, never what already landed, and `done` clears them.
59
+ `listen` to a participant so their future activities reach you, including those addressed to others. `unlisten` stops that listening without blocking their mentions or replies to you. It does not change an existing `ignore`. `ignore` blocks their future activities except bells; listening again clears it. These changes are future-only: nothing already landed is withdrawn. `done` clears outgoing listens, not ignores.
60
60
 
61
61
  ## History
62
62