@astrosheep/square 0.3.74 → 0.3.75

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.
@@ -12,4 +12,6 @@ export declare function previewAttentionBody(body: string): string;
12
12
  export declare function attentionBodyIsClipped(body: string): boolean;
13
13
  export declare function displayAttentionPath(squarePath: string): string;
14
14
  export declare function renderAttentionDescription({ actor, recipient, route }: Pick<AttentionPreview, 'actor' | 'recipient' | 'route'>): string;
15
+ /** Full notification text for human-facing expansion, never the bounded model preview. */
16
+ export declare function renderAttentionFull(attention: AttentionPreview): string;
15
17
  export declare function renderAttentionPreview(attention: AttentionPreview): string;
@@ -24,14 +24,23 @@ export function renderAttentionDescription({ actor, recipient, route }) {
24
24
  case 'bell': return `${actor} rang the bell · everyone’s attention`;
25
25
  }
26
26
  }
27
- export function renderAttentionPreview(attention) {
27
+ /** Full notification text for human-facing expansion, never the bounded model preview. */
28
+ export function renderAttentionFull(attention) {
29
+ return renderAttentionBlock(attention, attention.body);
30
+ }
31
+ function renderAttentionBlock(attention, body) {
28
32
  return [
29
33
  '<square-activity>',
30
34
  `· ${displayAttentionPath(attention.squarePath)} · ${formatActivityId(attention.actIndex)}`,
31
35
  `● ${renderAttentionDescription(attention)}`,
32
36
  '',
33
- previewAttentionBody(attention.body),
37
+ body,
34
38
  '</square-activity>',
39
+ ].join('\n');
40
+ }
41
+ export function renderAttentionPreview(attention) {
42
+ return [
43
+ renderAttentionBlock(attention, previewAttentionBody(attention.body)),
35
44
  // A clipped body ends with the full command to read it all: a labeled caption, then the
36
45
  // command bare on its own line so it can be copied and run as it is.
37
46
  ...(attentionBodyIsClipped(attention.body)
@@ -156,8 +156,7 @@ export async function executeExpression(application, squarePath, requestedName,
156
156
  const lines = [message, `· draft kept: ${draftPath}`];
157
157
  const recipientFailure = detail?.subject === 'recipient';
158
158
  const repair = reason === 'bell_mention_conflict' ? { bell: true, fromBody: true }
159
- : reason === 'unmatched_mention' ? { bell: false, fromBody: true }
160
- : reason === 'not_standing' && recipientFailure ? { bell: false, fromBody: false } : undefined;
159
+ : reason === 'not_standing' && recipientFailure ? { bell: false, fromBody: false } : undefined;
161
160
  if (isSquareError(error)) {
162
161
  if ((reason === 'never_joined' && detail?.subject === 'caller') || error.code === 'unknown_participant' || error.code === 'not_joined') {
163
162
  lines.push(joinRecoveryCommand(squarePath, name));
@@ -4,6 +4,7 @@ import { usage } from './context.js';
4
4
  function renderReport(report) {
5
5
  const lines = [
6
6
  `host ledger: ${report.status}`,
7
+ `root: ${report.rootPath}`,
7
8
  `presence: ${report.presence} · evidence: ${report.evidence}`,
8
9
  ];
9
10
  if (report.presenceRevision !== undefined) {
@@ -21,9 +22,12 @@ function renderReport(report) {
21
22
  }
22
23
  if (report.status === 'legacy') {
23
24
  if (report.ready)
24
- lines.push('Stop all Square hosts before conversion.', 'square ledger migrate');
25
+ lines.push('Stop agent sessions and MCP services using Square with this ledger before conversion.', 'Keep them stopped until migration and verification succeed; then start them with the updated Square version.', 'Next: migrate, then verify.', 'square ledger migrate', 'square ledger verify');
25
26
  else
26
- lines.push('Unsafe legacy records; conversion refused.');
27
+ lines.push('Unsafe legacy records; conversion refused. Preserve the sources and resolve the rejected records before retrying verification.');
28
+ }
29
+ if (report.status === 'migrated' || report.status === 'upgraded') {
30
+ lines.push('Keep affected sessions and MCP services stopped until verification succeeds.', 'After verification, start them with the updated Square version.', 'square ledger verify');
27
31
  }
28
32
  return lines.join('\n') + '\n';
29
33
  }
@@ -12,7 +12,7 @@ export const helpCommand = {
12
12
  return renderGlobalHelp();
13
13
  const rendered = renderSubcommandHelp(intent.command);
14
14
  if (rendered === undefined)
15
- fail(`unknown command: ${intent.command}\nrun 'square help' to list available commands`);
15
+ fail(`unknown command: ${intent.command}\nrun 'square --help' to list available commands`);
16
16
  return rendered;
17
17
  },
18
18
  present: (result) => process.stdout.write(result),
package/dist/decisions.js CHANGED
@@ -107,30 +107,15 @@ export function decideAct(facts, input) {
107
107
  throw activityRefusal('invalid_args', `${participantIdentity(requested)} is not standing in this square`, { reason: 'not_standing', targets: [requested] });
108
108
  addMention(resolved);
109
109
  }
110
- if (input.noMention !== true) {
111
- const unmatched = [];
112
- for (const candidate of scanMentionCandidates(body)) {
113
- const resolved = resolveNameAt(body, candidate, joinedNames);
114
- if (resolved === undefined)
115
- unmatched.push(candidate);
116
- else
117
- addMention(resolved);
118
- }
119
- if (reach !== 'bell' && unmatched.length > 0) {
120
- const names = roster.map((participant) => participant.name);
121
- const notStanding = unmatched.filter((candidate) => resolveNameAt(body, candidate, names) !== undefined);
122
- const unknown = unmatched.filter((candidate) => !notStanding.includes(candidate));
123
- const lines = [];
124
- if (unknown.length > 0)
125
- lines.push(`${unknown.map((candidate) => participantIdentity(candidate.token)).join(', ')} ${unknown.length === 1 ? 'does' : 'do'} not match anyone in this square`);
126
- if (notStanding.length > 0)
127
- lines.push(`${notStanding.map((candidate) => participantIdentity(candidate.token)).join(', ')} ${notStanding.length === 1 ? 'is' : 'are'} not standing in this square`);
128
- throw activityRefusal('invalid_args', lines.join('\n'), { reason: 'unmatched_mention', targets: unmatched.map((candidate) => candidate.token) });
129
- }
110
+ const bodyMentions = input.noMention === true ? [] : scanMentionCandidates(body);
111
+ for (const candidate of bodyMentions) {
112
+ const resolved = resolveNameAt(body, candidate, joinedNames);
113
+ if (resolved !== undefined)
114
+ addMention(resolved);
130
115
  }
131
116
  if (reach === 'bell' && mentionNames.length > 0)
132
117
  throw activityRefusal('invalid_args', 'A bell cannot be combined with a mention', { reason: 'bell_mention_conflict', targets: mentionNames });
133
- if (input.requireExplicitReach && reach !== 'bell' && mentionNames.length === 0 && reply === undefined && input.noMention !== true) {
118
+ if (input.requireExplicitReach && reach !== 'bell' && mentionNames.length === 0 && bodyMentions.length === 0 && reply === undefined && input.noMention !== true) {
134
119
  throw activityRefusal('invalid_args', 'Express needs a recipient, reply, bell, or explicit bare reach', { reason: 'explicit_reach_required' });
135
120
  }
136
121
  const header = facts.header();
package/dist/help.js CHANGED
@@ -77,12 +77,12 @@ const COMMANDS = [
77
77
  {
78
78
  names: ['migrate'], usage: 'migrate', usesSquare: true, group: 'maintenance',
79
79
  summary: 'Convert an existing version 1 square to row storage in place.',
80
- details: ['For the selected square artifact only; there is no --check, --backup or --force.', 'A version 2 square is left untouched; an unrelated or unsupported artifact is refused without a write.'],
80
+ 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.'],
81
81
  },
82
82
  {
83
83
  names: ['ledger'], usage: 'ledger <verify|migrate|gc>', usesSquare: false, group: 'maintenance',
84
84
  summary: 'Verify, convert, or reclaim the machine-local host ledger.',
85
- details: ['verify: read-only SQLite integrity or full legacy readiness with accepted/ignored/rejected counts and source hashes; not-ready exits 2, an empty root stays empty. An older or unsupported database is refused with the migrate command and is never upgraded by verify.', 'migrate: stop every Square host first, then convert legacy NDJSON, or upgrade an older SQLite ledger in place. Sources, records, order and observer revisions are preserved; an existing current ledger is an untouched no-op.', 'gc [--dry-run]: reclaim old wake/presentation evidence only where the square artifact proves the recipient is closed — the activity reached them and catch receipted it or they later rejoined. Dispatching, lifecycle and unprovable rows are retained. Deleted rows free reusable pages, not file size (no VACUUM); retired Claude recognition tokens keep growing in total.', 'Run gc with --dry-run first: it reports eligible, retired, cas-miss and unreadable-artifact counts without writing. Eligible rows are candidates only: a changed record or an unusable Claude correlation is retained.', 'No participant or square location is required. Uses SQUARE_HOST_LEDGER_ROOT, then SQUARE_REGISTRY’s directory, then ~/.square/host-ledger.', 'These commands never stop, restart, install into or schedule hosts; conversion and schema upgrade are never automatic. No --force or repair.'],
85
+ details: ['verify: read-only SQLite integrity or full legacy readiness with accepted/ignored/rejected counts and source hashes; not-ready exits 2, an empty root stays empty. An older supported SQLite format requires explicit migration. Unsupported versions or invalid databases need diagnosis; migration is not a repair command.', 'migrate: stop agent sessions and MCP services using Square with this ledger first, then convert legacy NDJSON or upgrade supported SQLite version 1 in place. Keep them stopped, run square ledger verify afterward, then start them with the updated Square version. Sources, records, order and observer revisions are preserved; an existing current ledger is an untouched no-op.', 'gc [--dry-run]: reclaim old wake/presentation evidence only where the square artifact proves the recipient is closed — the activity reached them and catch receipted it or they later rejoined. Dispatching, lifecycle and unprovable rows are retained. Deleted rows free reusable pages, not file size (no VACUUM); retired Claude recognition tokens keep growing in total.', 'Run gc with --dry-run first: it reports eligible, retired, cas-miss and unreadable-artifact counts without writing. Eligible rows are candidates only: a changed record or an unusable Claude correlation is retained.', 'No participant or square location is required. Uses SQUARE_HOST_LEDGER_ROOT, then SQUARE_REGISTRY’s directory, then ~/.square/host-ledger.', 'These commands never stop, restart, install into or schedule hosts; conversion and schema upgrade are never automatic. No --force or repair.'],
86
86
  },
87
87
  ];
88
88
  function definitionFor(command) {
@@ -131,7 +131,7 @@ export function renderSubcommandHelp(command) {
131
131
  'Help:',
132
132
  ' -h, --help Show this command help.',
133
133
  '',
134
- "Run 'square help' to list available commands.",
134
+ "Run 'square --help' to list available commands.",
135
135
  '',
136
136
  ].join('\n');
137
137
  }
@@ -164,5 +164,5 @@ export function helpRequest(rawArgs) {
164
164
  export function commandUsageHint(command) {
165
165
  return command !== undefined && definitionFor(command) !== undefined
166
166
  ? `Run 'square ${command} --help' for usage.\n`
167
- : "Run 'square help' to list commands.\n";
167
+ : "Run 'square --help' to list commands.\n";
168
168
  }
@@ -7,6 +7,7 @@ export interface LedgerSourceReport {
7
7
  }
8
8
  export interface HostLedgerReport {
9
9
  status: 'empty' | 'legacy' | 'sqlite' | 'migrated' | 'upgraded';
10
+ rootPath: string;
10
11
  presence: number;
11
12
  evidence: number;
12
13
  presenceRevision?: number;
@@ -5,7 +5,7 @@ import { DatabaseSync } from 'node:sqlite';
5
5
  import { canonicalPathSync } from './canonical-path.js';
6
6
  import { withFileLock } from './file-lock.js';
7
7
  import { hostLedgerRoot } from './host-ledger-root.js';
8
- import { LEDGER_FILENAME, initializeLedger, insertLedgerRecord, ledgerRecordColumns, setLedgerRevisions, upgradeLedger, validateLedger, verifyLedger, } from './host-ledger-sqlite.js';
8
+ import { LEDGER_FILENAME, initializeLedger, insertLedgerRecord, ledgerRecordColumns, setLedgerRevisions, upgradeLedger, validateLedger, verifyLedger, inspectLedgerIdentity, } from './host-ledger-sqlite.js';
9
9
  import { inSqliteTransaction } from './sqlite-transaction.js';
10
10
  function rootFor(options) {
11
11
  return canonicalPathSync(options.rootPath ?? hostLedgerRoot(options.env));
@@ -25,21 +25,45 @@ async function exists(file) {
25
25
  throw error;
26
26
  }
27
27
  }
28
- /** Generic and payload-free: the only supported remedy is offline conversion under stopped hosts. */
29
- function verificationFailure() {
30
- return new Error('Host ledger database verification failed; destination left untouched.\nStop all Square hosts, then run: square ledger migrate');
28
+ function migrationGuidance(root, file) {
29
+ const name = file ?? path.join(root, LEDGER_FILENAME);
30
+ return `Host ledger at ${root} has a supported older format (${name}).\nStop agent sessions and MCP services using Square with this ledger; keep them stopped until migration and verification succeed; then start them with the updated Square version.\nsquare ledger migrate\nsquare ledger verify`;
31
31
  }
32
- async function verifyDatabase(file, signal) {
32
+ function verificationFailure(root, file, format) {
33
+ const name = file ?? path.join(root, LEDGER_FILENAME);
34
+ const detail = format === 'unsupported'
35
+ ? 'It has an unsupported format version.'
36
+ : format === 'unrelated'
37
+ ? 'It is unrelated to a Square host ledger.'
38
+ : format === undefined
39
+ ? 'It could not be read.'
40
+ : 'Its identity is current but its contents failed verification.';
41
+ return new Error(`Host ledger verification failed at ${name}; ${detail}\nDestination left untouched. Migration is not a repair command. Keep the affected sessions and MCP services stopped, preserve the ledger, and diagnose the underlying file before retrying.`);
42
+ }
43
+ async function verifyDatabase(file, root, signal) {
33
44
  abort(signal);
34
45
  let db;
35
46
  try {
36
47
  db = new DatabaseSync(file, { readOnly: true });
37
48
  const counts = await inSqliteTransaction(db, false, () => validateLedger(db), () => verifyLedger(db), signal);
38
- return { status: 'sqlite', ...counts };
49
+ return { status: 'sqlite', rootPath: root, ...counts };
39
50
  }
40
51
  catch {
41
52
  abort(signal);
42
- throw verificationFailure();
53
+ let format;
54
+ try {
55
+ db = new DatabaseSync(file, { readOnly: true });
56
+ format = inspectLedgerIdentity(db);
57
+ db.close();
58
+ db = undefined;
59
+ }
60
+ catch {
61
+ db?.close();
62
+ db = undefined;
63
+ }
64
+ if (format === 'old-sqlite')
65
+ throw new Error(migrationGuidance(root, file));
66
+ throw verificationFailure(root, file, format);
43
67
  }
44
68
  finally {
45
69
  db?.close();
@@ -94,9 +118,10 @@ async function readLegacy(root, kind, signal) {
94
118
  async function readSources(root, signal) {
95
119
  return { presence: await readLegacy(root, 'presence', signal), evidence: await readLegacy(root, 'evidence', signal) };
96
120
  }
97
- function sourceReport(sources) {
121
+ function sourceReport(root, sources) {
98
122
  return {
99
123
  status: sources.presence.report.sha256 === null && sources.evidence.report.sha256 === null ? 'empty' : 'legacy',
124
+ rootPath: root,
100
125
  presence: sources.presence.rows.length, evidence: sources.evidence.rows.length,
101
126
  sources: { presence: sources.presence.report, evidence: sources.evidence.report },
102
127
  ready: sources.presence.report.rejected === 0 && sources.evidence.report.rejected === 0,
@@ -108,8 +133,8 @@ export async function verifyHostLedger(options = {}) {
108
133
  abort(options.signal);
109
134
  const destination = path.join(root, LEDGER_FILENAME);
110
135
  if (await exists(destination))
111
- return verifyDatabase(destination, options.signal);
112
- return sourceReport(await readSources(root, options.signal));
136
+ return verifyDatabase(destination, root, options.signal);
137
+ return sourceReport(root, await readSources(root, options.signal));
113
138
  }
114
139
  /** The fixed offline fence: ownership claim, then both legacy source locks, in that order. */
115
140
  function withOfflineLocks(root, signal, body) {
@@ -124,24 +149,24 @@ function withOfflineLocks(root, signal, body) {
124
149
  async function currentFormat(root, signal) {
125
150
  const destination = path.join(root, LEDGER_FILENAME);
126
151
  try {
127
- return await verifyDatabase(destination, signal);
152
+ return await verifyDatabase(destination, root, signal);
128
153
  }
129
154
  catch {
130
155
  abort(signal);
131
156
  }
132
157
  if (!await exists(destination))
133
- throw verificationFailure();
158
+ throw verificationFailure(root);
134
159
  let db;
135
160
  try {
136
161
  db = new DatabaseSync(destination);
137
162
  const upgraded = await inSqliteTransaction(db, true, () => { }, () => upgradeLedger(db), signal);
138
163
  db.close();
139
164
  db = undefined;
140
- return { ...await verifyDatabase(destination, signal), status: upgraded ? 'upgraded' : 'sqlite' };
165
+ return { ...await verifyDatabase(destination, root, signal), status: upgraded ? 'upgraded' : 'sqlite' };
141
166
  }
142
167
  catch {
143
168
  abort(signal);
144
- throw verificationFailure();
169
+ throw verificationFailure(root, destination);
145
170
  }
146
171
  finally {
147
172
  db?.close();
@@ -154,16 +179,32 @@ export async function migrateHostLedger(options = {}) {
154
179
  const destination = path.join(root, LEDGER_FILENAME);
155
180
  if (await exists(destination)) {
156
181
  try {
157
- return await verifyDatabase(destination, signal);
182
+ return await verifyDatabase(destination, root, signal);
158
183
  }
159
184
  catch {
160
185
  abort(signal);
161
186
  }
162
- // An older SQLite format is upgraded offline; a current ledger stays a read-only no-op.
163
- return withOfflineLocks(root, signal, () => currentFormat(root, signal));
187
+ // Inspect only the database identity; record/schema verification remains the migration fence.
188
+ let format;
189
+ let probe;
190
+ try {
191
+ probe = new DatabaseSync(destination, { readOnly: true });
192
+ format = inspectLedgerIdentity(probe);
193
+ }
194
+ catch {
195
+ format = undefined;
196
+ }
197
+ finally {
198
+ probe?.close();
199
+ }
200
+ if (format === 'old-sqlite') {
201
+ // Only the supported prior format enters the existing offline upgrade fence.
202
+ return withOfflineLocks(root, signal, () => currentFormat(root, signal));
203
+ }
204
+ throw verificationFailure(root, destination, format);
164
205
  }
165
206
  if (!await exists(path.join(root, 'presence.ndjsonl')) && !await exists(path.join(root, 'evidence.ndjsonl'))) {
166
- return { status: 'empty', presence: 0, evidence: 0 };
207
+ return { status: 'empty', rootPath: root, presence: 0, evidence: 0 };
167
208
  }
168
209
  return withOfflineLocks(root, signal, () => convertLegacy(root, destination, signal));
169
210
  }
@@ -172,7 +213,7 @@ async function convertLegacy(root, destination, signal) {
172
213
  if (await exists(destination))
173
214
  return currentFormat(root, signal);
174
215
  const sources = await readSources(root, signal);
175
- const report = sourceReport(sources);
216
+ const report = sourceReport(root, sources);
176
217
  if (!report.ready) {
177
218
  const rejected = ['presence', 'evidence'].filter((kind) => sources[kind].report.rejected > 0)
178
219
  .map((kind) => `${kind}: ${sources[kind].report.rejected} rejected (first line ${sources[kind].firstRejectedLine})`);
@@ -198,7 +239,7 @@ async function convertLegacy(root, destination, signal) {
198
239
  }, signal);
199
240
  db.close();
200
241
  db = undefined;
201
- const verified = await verifyDatabase(file, signal);
242
+ const verified = await verifyDatabase(file, root, signal);
202
243
  if (verified.presence !== report.presence || verified.evidence !== report.evidence) {
203
244
  throw new Error('Host ledger import counts do not match; conversion refused.');
204
245
  }
@@ -208,7 +249,7 @@ async function convertLegacy(root, destination, signal) {
208
249
  }
209
250
  catch (error) {
210
251
  if (error.code === 'EEXIST')
211
- return await verifyDatabase(destination, signal);
252
+ return await verifyDatabase(destination, root, signal);
212
253
  throw error;
213
254
  }
214
255
  const directory = await fs.open(root, 'r');
@@ -9,6 +9,7 @@ export interface Stored<T> {
9
9
  readonly seq: number;
10
10
  readonly record: T;
11
11
  }
12
+ export type HostLedgerFormat = 'current' | 'old-sqlite' | 'unrelated' | 'unsupported';
12
13
  /** The complete record remains authority; these are only reproducible lookup projections. */
13
14
  export declare function ledgerRecordColumns(kind: Kind, input: object): Record<string, SQLInputValue>;
14
15
  export declare function initializeLedger(db: DatabaseSync): void;
@@ -28,9 +29,11 @@ export declare function verifyLedger(db: DatabaseSync): {
28
29
  presenceRevision: number;
29
30
  evidenceRevision: number;
30
31
  };
32
+ /** Read the file-type stamp and format version without making the runtime authority. */
33
+ export declare function inspectLedgerIdentity(db: DatabaseSync): HostLedgerFormat;
31
34
  /** Explicit offline migration only; the caller owns the write transaction. Preserve all records and revisions. */
32
35
  export declare function upgradeLedger(db: DatabaseSync): boolean;
33
- export declare function migrationRequired(): Error;
36
+ export declare function migrationRequired(root: string): Error;
34
37
  /** Transaction-local storage rows; sequence coordinates never cross HostLedgerPort. */
35
38
  export declare class LedgerTransaction {
36
39
  private readonly db;
@@ -137,6 +137,21 @@ function verifyLedgerAtVersion(db, version) {
137
137
  throw invalid('records without revision');
138
138
  return { ...counts, presenceRevision: revision.presence, evidenceRevision: revision.evidence };
139
139
  }
140
+ function inspectLegacyRoot(root) {
141
+ return fs.existsSync(path.join(root, 'presence.ndjsonl')) || fs.existsSync(path.join(root, 'evidence.ndjsonl'));
142
+ }
143
+ /** Read the file-type stamp and format version without making the runtime authority. */
144
+ export function inspectLedgerIdentity(db) {
145
+ const application = db.prepare('PRAGMA application_id').get().application_id;
146
+ const version = db.prepare('PRAGMA user_version').get().user_version;
147
+ if (application !== LEDGER_APPLICATION_ID)
148
+ return 'unrelated';
149
+ if (version === LEDGER_VERSION)
150
+ return 'current';
151
+ if (version === 1)
152
+ return 'old-sqlite';
153
+ return 'unsupported';
154
+ }
140
155
  /** Explicit offline migration only; the caller owns the write transaction. Preserve all records and revisions. */
141
156
  export function upgradeLedger(db) {
142
157
  if (db.prepare('PRAGMA user_version').get()?.user_version === LEDGER_VERSION) {
@@ -159,14 +174,26 @@ catch (error) {
159
174
  return false;
160
175
  throw error;
161
176
  } }
162
- export function migrationRequired() {
163
- return new Error('Host ledger conversion required. Stop all ledger users before running:\nsquare ledger migrate');
177
+ export function migrationRequired(root) {
178
+ return new Error(`Host ledger legacy format needs offline migration at ${root}.\nStop agent sessions and MCP services using Square with this ledger; keep them stopped until migration and verification succeed; then start them with the updated Square version.\nRun square ledger migrate from the same environment, then square ledger verify.`);
164
179
  }
165
180
  /** Compact runtime framing. Full schema, projections and integrity belong to explicit verify. */
166
181
  function validateLedgerHeader(db, version = LEDGER_VERSION) {
167
- if (db.prepare('PRAGMA application_id').get()?.application_id !== LEDGER_APPLICATION_ID
168
- || db.prepare('PRAGMA user_version').get()?.user_version !== version)
169
- throw invalid('unsupported identity or version. Stop all ledger users before running:\nsquare ledger migrate');
182
+ if (db.prepare('PRAGMA application_id').get()?.application_id !== LEDGER_APPLICATION_ID)
183
+ throw invalid('unrelated identity.');
184
+ if (db.prepare('PRAGMA user_version').get()?.user_version !== version)
185
+ throw invalid('unsupported format version.');
186
+ }
187
+ /** The runtime's only upgrade door: a supported old ledger is pointed at explicit migration. */
188
+ function runtimeLedgerHeader(db, root) {
189
+ try {
190
+ validateLedgerHeader(db);
191
+ }
192
+ catch (error) {
193
+ if (inspectLedgerIdentity(db) === 'old-sqlite')
194
+ throw migrationRequired(root);
195
+ throw error;
196
+ }
170
197
  }
171
198
  /** Publish new empty authority only. Never initialize or replace an existing destination. */
172
199
  function createEmptyLedger(root) {
@@ -203,8 +230,8 @@ function createEmptyLedger(root) {
203
230
  async function openLedger(root, write, signal) {
204
231
  const file = path.join(root, LEDGER_FILENAME);
205
232
  if (!exists(file)) {
206
- if (exists(path.join(root, 'presence.ndjsonl')) || exists(path.join(root, 'evidence.ndjsonl')))
207
- throw migrationRequired();
233
+ if (inspectLegacyRoot(root))
234
+ throw migrationRequired(root);
208
235
  if (!write)
209
236
  return undefined;
210
237
  if (signal?.aborted)
@@ -318,7 +345,7 @@ export async function withLedger(root, write, select, absent, signal) {
318
345
  if (!db)
319
346
  return absent;
320
347
  try {
321
- return await inSqliteTransaction(db, write, () => validateLedgerHeader(db), () => {
348
+ return await inSqliteTransaction(db, write, () => runtimeLedgerHeader(db, root), () => {
322
349
  const rows = new LedgerTransaction(db), result = select(rows);
323
350
  rows.commitRevisions();
324
351
  return result;
@@ -333,7 +360,7 @@ export async function readLedgerRevisions(root, signal) {
333
360
  if (!db)
334
361
  return undefined;
335
362
  try {
336
- return await inSqliteTransaction(db, false, () => validateLedgerHeader(db), () => ledgerRevisions(db), signal);
363
+ return await inSqliteTransaction(db, false, () => runtimeLedgerHeader(db, root), () => ledgerRevisions(db), signal);
337
364
  }
338
365
  finally {
339
366
  db.close();
package/dist/model.d.ts CHANGED
@@ -20,9 +20,8 @@ export type InternalSquareErrorCode = SquareErrorCode | 'not_found' | 'cap_reach
20
20
  export interface SquareErrorFacts {
21
21
  /** Express admission rejected before any activity was committed. */
22
22
  activityUnsent?: true;
23
- /** Why a name could not be resolved: never in the roster, no longer standing in it, a bell
24
- * colliding with a mention, an @name that matches nobody, or an activity catch cannot address. */
25
- reason?: 'never_joined' | 'not_standing' | 'bell_mention_conflict' | 'unmatched_mention' | 'not_addressed' | 'migration_required' | 'explicit_reach_required' | 'invalid_reply' | 'invalid_done_body';
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';
26
25
  /** The activity id a `catch --id` refusal named. */
27
26
  activityId?: string;
28
27
  /** Requested recipient spellings, without parsing presentation copy. */
@@ -39,7 +39,7 @@ export function formatRefusal(squarePath, bodyLines, opts = {}) {
39
39
  }
40
40
  export function refusalText(error) {
41
41
  const text = error instanceof Error ? error.message : String(error);
42
- const marked = isSquareError(error) && ['never_joined', 'not_standing', 'unmatched_mention'].includes(error.facts?.reason ?? '');
42
+ const marked = isSquareError(error) && ['never_joined', 'not_standing'].includes(error.facts?.reason ?? '');
43
43
  return marked ? text.split('\n').map((line) => `✕ ${line}`).join('\n') : text;
44
44
  }
45
45
  export function quoteShell(value) {
@@ -134,7 +134,7 @@ async function landExpression(square, name, body, options, transport, control) {
134
134
  }
135
135
  catch (error) {
136
136
  const recipient = isSquareError(error)
137
- && ['not_standing', 'unmatched_mention', 'bell_mention_conflict'].includes(error.facts?.reason ?? '');
137
+ && ['not_standing', 'bell_mention_conflict'].includes(error.facts?.reason ?? '');
138
138
  throw failure(error, {
139
139
  operation: 'express', participant: name, subject: recipient ? 'recipient' : 'caller', expressEffect: effect,
140
140
  });
@@ -22,7 +22,7 @@ Verification is read-only. It checks SQLite identity, version, schema, integrity
22
22
 
23
23
  To upgrade an NDJSON ledger:
24
24
 
25
- 1. Stop **every** Square host using that ledger, including interactive harnesses and background agents. Do not run old and new hosts together.
25
+ 1. Stop **every** agent session and MCP service using Square with this ledger, including interactive harnesses and background agents. Do not run old and new ones together.
26
26
  2. Keep a backup of the stopped legacy files.
27
27
  3. Run:
28
28
 
@@ -36,7 +36,7 @@ To upgrade an NDJSON ledger:
36
36
 
37
37
  Migration does not stop or restart hosts. Its locks fence compatible writers but do not prove hosts are stopped. It acquires the ownership claim fence, then `presence.ndjsonl.lock`, then `evidence.ndjsonl.lock`.
38
38
 
39
- An older SQLite ledger format is upgraded in place by the same `square ledger migrate` under the same stopped-hosts prerequisite and the same lock order. The upgrade preserves every record, duplicate, original order and both observer revisions. `square ledger verify` stays read-only: it refuses an older or unsupported database with the migrate command and never upgrades silently.
39
+ An older SQLite ledger format is upgraded in place by the same `square ledger migrate` under the same stopped-sessions prerequisite and the same lock order. The upgrade preserves every record, duplicate, original order and both observer revisions. `square ledger verify` stays read-only: it reports a supported old format with the migrate command and never upgrades silently. An unsupported format version or an unrelated database is refused with a diagnosis path instead: migration is not a repair command, and a failed migration is never answered by rerunning it blindly.
40
40
 
41
41
  The importer preserves all ages, future records, duplicates, original order, native correlation and residual fields. It ignores malformed JSON, wrong versions and records without a numeric timestamp, as the old reader did, and reports those counts. Any valid version-1 record whose indexed fields are unsafe refuses the entire conversion rather than losing evidence. Refusal identifies only source kind, rejected count and first rejected line; no accepted subset is published. Legacy unknown wake sources are normalized once during import.
42
42
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/square",
3
- "version": "0.3.74",
3
+ "version": "0.3.75",
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": {
@@ -77,6 +77,7 @@
77
77
  ]
78
78
  },
79
79
  "dependencies": {
80
+ "@earendil-works/pi-tui": "^1.3.0",
80
81
  "@modelcontextprotocol/sdk": "^1.32.0",
81
82
  "cross-spawn": "^7.0.6",
82
83
  "zod": "^4.6.5"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.3.74",
3
+ "version": "0.3.75",
4
4
  "description": "Native Claude Code inbox delivery for Square participants",
5
5
  "types": "./types.d.ts",
6
6
  "author": {
@@ -65,7 +65,7 @@ The ownership boundary belongs here. Does this match your read?
65
65
  EOF
66
66
  ```
67
67
 
68
- **Addressing.** Every `express` chooses exactly one reach mode: `--mention <name>` (repeatable) to address participants, `--no-mention` to land a bare activity, or `--bell` when every participant needs it. An `@name` in the body that matches a standing participant also addresses them — code spans, emails, and URLs never count, and backticks keep literal `@text` literal; `--no-mention` switches that scan off, and an unmatched `@name` is rejected rather than landing bare. Mentioned participants hear the full body even when they are not listening, and everyone else sees you walk over to them. `listen` opts a participant into future bare delivery. Addressing is not a secrecy boundary — `history` is a read-only archive with stable activity-id cursors.
68
+ **Addressing.** Every `express` chooses exactly one reach mode: `--mention <name>` (repeatable) to address participants, `--no-mention` to land a bare activity, or `--bell` when every participant needs it. An `@name` in the body that matches a standing participant also addresses them — code spans, emails, and URLs never count, and backticks keep literal `@text` literal; `--no-mention` switches that scan off. An unmatched `@name` stays ordinary body text and never blocks landing; only standing matches become recipients. Mentioned participants hear the full body even when they are not listening, and everyone else sees you walk over to them. `listen` opts a participant into future bare delivery. Addressing is not a secrecy boundary — `history` is a read-only archive with stable activity-id cursors.
69
69
 
70
70
  Every human-CLI `express` must choose exactly one reach mode: one or more `--mention <name>` flags, `--no-mention`, `--bell`, or an `@name` in the body. The CLI rejects an omitted mode and rejects combining these flag modes. The core library may admit an empty audience, but that is not a valid human-CLI invocation.
71
71
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "square",
3
- "version": "0.3.74",
3
+ "version": "0.3.75",
4
4
  "description": "Shared Square activity with reliable participant attention at Codex boundaries.",
5
5
  "author": {
6
6
  "name": "Square"
@@ -26,13 +26,18 @@ plain-text detail are projected into a card. Real tools, other custom messages,
26
26
  and unrecognized rows remain unchanged.
27
27
 
28
28
  The collapsed heading uses `metadata.details.items`; it never reverse-parses
29
- the content. Expansion shows `detail.text` verbatim, including fences, clipping
30
- notices, and catch commands. Copy content copies that exact string. It does not
31
- fetch extra activity or mark anything seen. Live events and fetched history use
29
+ the content. Expansion shows `metadata.details.fullText` when available: complete
30
+ notification bodies, even when the model preview was clipped or omitted. Older
31
+ messages fall back to `detail.text`, including clipping notices and catch commands.
32
+ Long content scrolls inside a bounded panel. The
33
+ card does not fetch extra activity or mark anything seen. Live events and fetched history use
32
34
  the same transformer.
33
35
 
34
- The card uses Paseo theme colors, selectable text, accessible expansion state,
35
- and compact layout on narrow displays. Removing the plugin restores Paseo's
36
+ The card is visually marked as Square: an accent glyph, a SQUARE badge, and the
37
+ square's name sit above the actor line, on a raised surface distinct from ordinary
38
+ tool output. It uses Paseo theme colors, selectable text, accessible expansion state,
39
+ and compact layout on narrow displays. Expansion starts at the top-right arrow;
40
+ collapse returns from the arrow below the expanded content. Removing the plugin restores Paseo's
36
41
  ordinary custom-message row.
37
42
 
38
43
  ## Develop
@@ -0,0 +1,221 @@
1
+ import { useMemo } from "react";
2
+ import { Text, View, type TextStyle } from "react-native";
3
+ import type { PluginTheme } from "@getpaseo/plugin";
4
+
5
+ interface InlinePart { text: string; bold?: boolean; italic?: boolean; code?: boolean }
6
+
7
+ // Minimal inline grammar: **bold**, *italic*, `code`. The first marker wins;
8
+ // no nesting, so action lines like *leans over* survive intact.
9
+ const INLINE_RE = /(\*\*[^*\n]+\*\*|\*[^*\n]+\*|`[^`\n]+`)/g;
10
+
11
+ export function parseInline(line: string): InlinePart[] {
12
+ const parts: InlinePart[] = [];
13
+ let last = 0;
14
+ for (const match of line.matchAll(INLINE_RE)) {
15
+ const index = match.index ?? 0;
16
+ if (index > last) parts.push({ text: line.slice(last, index) });
17
+ const token = match[0];
18
+ if (token.startsWith("**")) parts.push({ text: token.slice(2, -2), bold: true });
19
+ else if (token.startsWith("`")) parts.push({ text: token.slice(1, -1), code: true });
20
+ else parts.push({ text: token.slice(1, -1), italic: true });
21
+ last = index + token.length;
22
+ }
23
+ if (last < line.length) parts.push({ text: line.slice(last) });
24
+ return parts;
25
+ }
26
+
27
+ type Block =
28
+ | { kind: "code"; text: string }
29
+ | { kind: "heading"; level: number; text: string }
30
+ | { kind: "quote"; lines: string[] }
31
+ | { kind: "list"; items: string[] }
32
+ | { kind: "paragraph"; text: string };
33
+
34
+ function parseBlocks(markdown: string): Block[] {
35
+ const lines = markdown.replace(/\r\n/g, "\n").split("\n");
36
+ const blocks: Block[] = [];
37
+ let paragraph: string[] = [];
38
+ const flushParagraph = () => {
39
+ if (paragraph.length > 0) blocks.push({ kind: "paragraph", text: paragraph.join(" ") });
40
+ paragraph = [];
41
+ };
42
+ for (let i = 0; i < lines.length; i++) {
43
+ const line = lines[i]!;
44
+ if (line.trimStart().startsWith("```")) {
45
+ flushParagraph();
46
+ const body: string[] = [];
47
+ i += 1;
48
+ while (i < lines.length && !lines[i]!.trimStart().startsWith("```")) { body.push(lines[i]!); i += 1; }
49
+ blocks.push({ kind: "code", text: body.join("\n") });
50
+ continue;
51
+ }
52
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line);
53
+ if (heading) {
54
+ flushParagraph();
55
+ blocks.push({ kind: "heading", level: heading[1]!.length, text: heading[2]! });
56
+ continue;
57
+ }
58
+ if (line.startsWith(">")) {
59
+ flushParagraph();
60
+ const quote: string[] = [];
61
+ while (i < lines.length && lines[i]!.startsWith(">")) { quote.push(lines[i]!.replace(/^>\s?/, "")); i += 1; }
62
+ i -= 1;
63
+ blocks.push({ kind: "quote", lines: quote });
64
+ continue;
65
+ }
66
+ if (/^\s*(?:[-*+]|\d+\.)\s+/.test(line)) {
67
+ flushParagraph();
68
+ const items: string[] = [];
69
+ while (i < lines.length && /^\s*(?:[-*+]|\d+\.)\s+/.test(lines[i]!)) {
70
+ const item = /^\s*(?:[-*+]|\d+\.)\s+(.*)$/.exec(lines[i]!);
71
+ items.push(item?.[1] ?? lines[i]!);
72
+ i += 1;
73
+ }
74
+ i -= 1;
75
+ blocks.push({ kind: "list", items });
76
+ continue;
77
+ }
78
+ if (line.trim() === "") { flushParagraph(); continue; }
79
+ paragraph.push(line.trim());
80
+ }
81
+ flushParagraph();
82
+ return blocks;
83
+ }
84
+
85
+ interface SquareBlock { meta?: string; actor?: string; body: string }
86
+
87
+ // A square notification is one or more <square-activity> blocks. Split them out so the
88
+ // envelope lines become styled chrome and only the body goes through markdown.
89
+ function parseSquareBlocks(text: string): SquareBlock[] {
90
+ const blocks: SquareBlock[] = [];
91
+ const re = /<square-activity>\n([\s\S]*?)\n<\/square-activity>/g;
92
+ let last = 0;
93
+ for (const match of text.matchAll(re)) {
94
+ const index = match.index ?? 0;
95
+ const before = text.slice(last, index).trim();
96
+ if (before !== "") blocks.push({ body: before });
97
+ const inner = match[1]!;
98
+ const innerLines = inner.split("\n");
99
+ const meta = innerLines[0]?.startsWith("· ") ? innerLines.shift() : undefined;
100
+ const actor = innerLines[0]?.startsWith("● ") ? innerLines.shift() : undefined;
101
+ blocks.push({ meta, actor, body: innerLines.join("\n").trim() });
102
+ last = index + match[0].length;
103
+ }
104
+ const rest = text.slice(last).trim();
105
+ if (rest !== "") blocks.push({ body: rest });
106
+ if (blocks.length === 0) blocks.push({ body: text });
107
+ return blocks;
108
+ }
109
+
110
+ export function SquareMarkdown({ text, theme }: { text: string; theme: PluginTheme }) {
111
+ const { colors } = theme;
112
+ const styles = useMemo(() => ({
113
+ block: { gap: 6 },
114
+ paragraph: { color: colors.foreground, fontSize: 13, lineHeight: 20 },
115
+ bold: { fontWeight: "700" as const },
116
+ italic: { fontStyle: "italic" as const },
117
+ inlineCode: {
118
+ fontFamily: "Menlo",
119
+ fontSize: 12,
120
+ backgroundColor: colors.surface2,
121
+ color: colors.foreground,
122
+ },
123
+ codeBlock: {
124
+ backgroundColor: colors.surface0,
125
+ borderRadius: 8,
126
+ borderWidth: 1,
127
+ borderColor: colors.border,
128
+ paddingHorizontal: 9,
129
+ paddingVertical: 7,
130
+ },
131
+ codeText: { fontFamily: "Menlo", fontSize: 11.5, lineHeight: 17, color: colors.foreground },
132
+ heading: { color: colors.foreground, fontWeight: "700" as const },
133
+ quote: {
134
+ borderLeftWidth: 2,
135
+ borderLeftColor: colors.border,
136
+ paddingLeft: 8,
137
+ gap: 2,
138
+ },
139
+ quoteText: { color: colors.foregroundMuted, fontSize: 13, lineHeight: 20, fontStyle: "italic" as const },
140
+ meta: { color: colors.foregroundMuted, fontSize: 11, lineHeight: 15, fontFamily: "Menlo" },
141
+ actor: { color: colors.accent, fontSize: 12, lineHeight: 17 },
142
+ separator: { height: 1, backgroundColor: colors.border, marginVertical: 4 },
143
+ listRow: { flexDirection: "row" as const, gap: 6 },
144
+ bullet: { color: colors.accent, fontSize: 13, lineHeight: 20 },
145
+ }), [colors]);
146
+
147
+ const renderInline = (line: string, keyPrefix: string, extra?: TextStyle) => (
148
+ <Text key={keyPrefix} style={[styles.paragraph, extra]}>
149
+ {parseInline(line).map((part, index) => (
150
+ <Text
151
+ key={`${keyPrefix}-${index}`}
152
+ style={[
153
+ part.bold ? styles.bold : undefined,
154
+ part.italic ? styles.italic : undefined,
155
+ part.code ? styles.inlineCode : undefined,
156
+ ]}
157
+ >{part.code ? ` ${part.text} ` : part.text}</Text>
158
+ ))}
159
+ </Text>
160
+ );
161
+
162
+ const renderBlock = (block: Block, key: string) => {
163
+ switch (block.kind) {
164
+ case "code":
165
+ return (
166
+ <View key={key} style={styles.codeBlock}>
167
+ <Text selectable style={styles.codeText}>{block.text}</Text>
168
+ </View>
169
+ );
170
+ case "heading":
171
+ return renderInline(block.text, key, { fontSize: Math.max(13, 18 - block.level), fontWeight: "700" });
172
+ case "quote":
173
+ return (
174
+ <View key={key} style={styles.quote}>
175
+ {block.lines.map((line, index) => (
176
+ <Text key={`${key}-${index}`} style={styles.quoteText}>{line}</Text>
177
+ ))}
178
+ </View>
179
+ );
180
+ case "list":
181
+ return (
182
+ <View key={key} style={{ gap: 2 }}>
183
+ {block.items.map((item, index) => (
184
+ <View key={`${key}-${index}`} style={styles.listRow}>
185
+ <Text style={styles.bullet}>•</Text>
186
+ <Text style={[styles.paragraph, { flexShrink: 1 }]}>
187
+ {parseInline(item).map((part, partIndex) => (
188
+ <Text
189
+ key={partIndex}
190
+ style={[
191
+ part.bold ? styles.bold : undefined,
192
+ part.italic ? styles.italic : undefined,
193
+ part.code ? styles.inlineCode : undefined,
194
+ ]}
195
+ >{part.code ? ` ${part.text} ` : part.text}</Text>
196
+ ))}
197
+ </Text>
198
+ </View>
199
+ ))}
200
+ </View>
201
+ );
202
+ case "paragraph":
203
+ return renderInline(block.text, key);
204
+ }
205
+ };
206
+
207
+ const blocks = useMemo(() => parseSquareBlocks(text), [text]);
208
+
209
+ return (
210
+ <View style={styles.block}>
211
+ {blocks.map((block, blockIndex) => (
212
+ <View key={blockIndex} style={styles.block}>
213
+ {blockIndex > 0 && <View style={styles.separator} />}
214
+ {block.meta !== undefined && <Text style={styles.meta}>{block.meta}</Text>}
215
+ {block.actor !== undefined && <Text style={styles.actor}>{block.actor}</Text>}
216
+ {parseBlocks(block.body).map((inner, innerIndex) => renderBlock(inner, `${blockIndex}-${innerIndex}`))}
217
+ </View>
218
+ ))}
219
+ </View>
220
+ );
221
+ }
@@ -1,117 +1,87 @@
1
1
  import type { PluginTimelineItemProps } from "@getpaseo/plugin/client";
2
- import { copyText, Icon, useToast } from "@getpaseo/plugin/client/react-native";
2
+ import { Icon } from "@getpaseo/plugin/client/react-native";
3
3
  import { useMemo, useState } from "react";
4
- import { Platform, Pressable, ScrollView, Text, View } from "react-native";
4
+ import { Pressable, ScrollView, Text, View } from "react-native";
5
5
  import { squareHeadline, type SquareMessageData } from "../shared/message";
6
+ import { parseInline, SquareMarkdown } from "./markdown";
6
7
 
7
- function withOpacity(color: string, opacity: number): string {
8
- const hex = /^#([\da-f]{3}|[\da-f]{6})$/iu.exec(color);
9
- if (hex === null) return color;
10
- const value = hex[1]!.length === 3 ? [...hex[1]!].map((part) => part + part).join("") : hex[1]!;
11
- const alpha = Math.round(opacity * 255).toString(16).padStart(2, "0");
12
- return `#${value}${alpha}`;
13
- }
14
-
8
+ /** A plain card: who, what they did, where, then what they said. No badge, no chrome — the words carry the place. */
15
9
  export function SquareMessage({ item, theme, layout }: PluginTimelineItemProps<SquareMessageData>) {
16
10
  const [expanded, setExpanded] = useState(false);
17
- const toast = useToast();
18
- const { text, items } = item.data;
11
+ const { text, fullText, items } = item.data;
19
12
  const latest = items?.[items.length - 1];
13
+ // Preview typography only; expansion prefers the complete renderer-only text.
20
14
  const preview = latest?.preview?.replace(/\s+/g, " ").trim();
21
15
  const muted = theme.colors.foregroundMuted;
22
16
  const foreground = theme.colors.foreground;
17
+
23
18
  const styles = useMemo(() => ({
24
- marker: { color: theme.colors.accent, fontSize: 10, marginTop: 2 },
25
- headline: { color: foreground, fontSize: 13, lineHeight: 19, flexShrink: 1 },
26
- meta: { color: muted, fontSize: 11, lineHeight: 16, flexShrink: 1 },
27
- preview: { color: foreground, fontSize: 12, lineHeight: 18, flexShrink: 1 },
28
- bodyPanel: {
19
+ card: {
20
+ marginVertical: 3,
21
+ marginRight: layout.compact ? 0 : 8,
29
22
  backgroundColor: theme.colors.surface1,
30
- borderRadius: 12,
31
- marginTop: 5,
32
- marginLeft: 17,
33
- overflow: "hidden" as const,
23
+ borderWidth: 1,
24
+ borderColor: theme.colors.border,
25
+ borderRadius: 6,
26
+ paddingHorizontal: 12,
27
+ paddingVertical: 8,
28
+ gap: 4,
34
29
  },
35
- bodyText: {
36
- color: foreground,
37
- fontSize: 12,
38
- lineHeight: 21,
39
- fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
40
- paddingHorizontal: layout.compact ? 10 : 12,
41
- paddingTop: 8,
42
- },
43
- }), [foreground, layout.compact, muted, theme.colors.accent, theme.colors.surface1]);
30
+ whoRow: { flexDirection: "row" as const, alignItems: "baseline" as const, gap: 6 },
31
+ glyph: { color: theme.colors.accent, fontSize: 9 },
32
+ actor: { color: foreground, fontSize: 12.5, fontWeight: "600" as const },
33
+ route: { color: muted, fontSize: 12.5, flexShrink: 1 },
34
+ count: { color: muted, fontSize: 11 },
35
+ preview: { color: foreground, fontSize: 13, lineHeight: 19 },
36
+ action: { color: muted, fontStyle: "italic" as const },
37
+ bold: { fontWeight: "700" as const },
38
+ code: { fontFamily: "Menlo", fontSize: 12 },
39
+ }), [foreground, layout.compact, muted, theme.colors.accent, theme.colors.border, theme.colors.surface1]);
40
+
41
+ const headline = latest !== undefined ? squareHeadline(latest) : "Square context";
42
+ const routeText = latest?.actor !== undefined && headline.startsWith(latest.actor)
43
+ ? headline.slice(latest.actor.length).trim()
44
+ : headline;
44
45
 
45
46
  return (
46
- <View>
47
- <View>
48
- <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 7, paddingVertical: 2 }}>
49
- <Text style={styles.marker}>■</Text>
50
- <Text numberOfLines={1} style={styles.headline}>
51
- {latest?.square ?? "Square context"}
47
+ <View style={styles.card}>
48
+ <Pressable
49
+ accessibilityRole="button"
50
+ accessibilityLabel={`${expanded ? "收起" : "展开"} ${latest?.square ?? "square"}: ${headline}`}
51
+ aria-expanded={expanded}
52
+ onPress={() => setExpanded(value => !value)}
53
+ style={({ pressed }) => ({ gap: 4, opacity: pressed ? 0.7 : 1 })}
54
+ >
55
+ <View style={styles.whoRow}>
56
+ <Text style={styles.glyph}>■</Text>
57
+ {latest?.actor !== undefined && <Text style={styles.actor}>{latest.actor}</Text>}
58
+ <Text numberOfLines={1} style={styles.route}>
59
+ {routeText}{latest?.square !== undefined && ` · ${latest.square}`}
52
60
  </Text>
53
- {items && items.length > 1 && <Text style={styles.meta}>· {items.length} landed</Text>}
54
61
  <View style={{ flex: 1 }} />
55
- {!expanded && <Pressable
56
- accessibilityRole="button"
57
- accessibilityLabel={`展开 ${latest !== undefined ? squareHeadline(latest) : "Square context"}`}
58
- aria-expanded={false}
59
- onPress={() => setExpanded(true)}
60
- hitSlop={6}
61
- style={({ pressed }) => ({ padding: 5, opacity: pressed ? 0.6 : 1 })}
62
- >
63
- <Icon name="ChevronDown" size={18} color={muted} />
64
- </Pressable>}
62
+ {items !== undefined && items.length > 1 && <Text style={styles.count}>{items.length}</Text>}
63
+ <Icon name={expanded ? "ChevronUp" : "ChevronDown"} size={16} color={muted} />
65
64
  </View>
66
- {(latest?.actor !== undefined || preview) && <View style={{ marginLeft: 17, marginTop: 2 }}>
67
- <Text numberOfLines={1} style={styles.preview}>
68
- <Text style={{ color: theme.colors.accent, fontSize: 10 }}>● </Text>
69
- {latest?.actor !== undefined && <Text style={styles.meta}>{latest.actor} </Text>}
70
- {preview}
65
+ {!expanded && preview !== undefined && preview !== "" && (
66
+ <Text numberOfLines={2} style={styles.preview}>
67
+ {parseInline(preview).map((part, index) => (
68
+ <Text
69
+ key={index}
70
+ style={[
71
+ part.italic ? styles.action : undefined,
72
+ part.bold ? styles.bold : undefined,
73
+ part.code ? styles.code : undefined,
74
+ ]}
75
+ >{part.text}</Text>
76
+ ))}
71
77
  </Text>
72
- </View>}
73
- </View>
74
- {expanded && <View style={styles.bodyPanel}>
75
- <ScrollView style={{ maxHeight: layout.compact ? 280 : 400 }} nestedScrollEnabled>
76
- <Text selectable style={styles.bodyText}>{text}</Text>
78
+ )}
79
+ </Pressable>
80
+ {expanded && (
81
+ <ScrollView style={{ maxHeight: layout.compact ? 280 : 400, marginTop: 2 }} nestedScrollEnabled>
82
+ <SquareMarkdown text={fullText ?? text} theme={theme} />
77
83
  </ScrollView>
78
- <View style={{
79
- marginHorizontal: layout.compact ? 10 : 12,
80
- marginTop: 5,
81
- borderTopWidth: 1,
82
- borderTopColor: withOpacity(theme.colors.border, 0.55),
83
- paddingVertical: 3,
84
- flexDirection: "row",
85
- alignItems: "center",
86
- justifyContent: "space-between",
87
- }}>
88
- <Pressable
89
- accessibilityRole="button"
90
- accessibilityLabel="Copy Square content"
91
- onPress={() => { void copyText(text).catch(() => toast.error("Could not copy Square content")); }}
92
- style={({ pressed }) => ({
93
- flexDirection: "row",
94
- alignItems: "center",
95
- gap: 5,
96
- paddingHorizontal: 2,
97
- opacity: pressed ? 0.6 : 1,
98
- })}
99
- >
100
- <Icon name="Copy" size={12} color={muted} />
101
- <Text style={{ color: muted, fontSize: 11 }}>Copy content</Text>
102
- </Pressable>
103
- <Pressable
104
- accessibilityRole="button"
105
- accessibilityLabel="收起 Square context"
106
- aria-expanded={true}
107
- onPress={() => setExpanded(false)}
108
- hitSlop={6}
109
- style={({ pressed }) => ({ paddingHorizontal: 5, paddingVertical: 1, opacity: pressed ? 0.6 : 1 })}
110
- >
111
- <Icon name="ChevronUp" size={18} color={muted} />
112
- </Pressable>
113
- </View>
114
- </View>}
84
+ )}
115
85
  </View>
116
86
  );
117
87
  }
@@ -17,7 +17,10 @@ export const transformSquareMessage: PluginTimelineTransformerContribution<"tool
17
17
  version: 1,
18
18
  data: {
19
19
  text: item.detail.text,
20
- ...(details.success ? { items: details.data.items } : {}),
20
+ ...(details.success ? {
21
+ items: details.data.items,
22
+ ...(details.data.fullText === undefined ? {} : { fullText: details.data.fullText }),
23
+ } : {}),
21
24
  },
22
25
  }],
23
26
  };
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
 
3
3
  export const squareCardDetailsSchema = z.object({
4
+ fullText: z.string().optional(),
4
5
  items: z.array(z.object({
5
6
  square: z.string(),
6
7
  actor: z.string().optional(),
@@ -16,15 +17,16 @@ export function squareHeadline(item: z.output<typeof squareCardDetailsSchema>["i
16
17
  if (item.actor === undefined || item.route === undefined) return item.description;
17
18
  const to = item.recipient;
18
19
  switch (item.route) {
19
- case "mention": return to === undefined ? `${item.actor} mentioned you` : `${item.actor} mentioned ${to}`;
20
- case "reply": return to === undefined ? `${item.actor} replied to you` : `${item.actor} replied to ${to}`;
21
- case "attention": return `New from ${item.actor}`;
22
- case "bell": return `${item.actor} called everyone`;
20
+ case "mention": return to === undefined ? `${item.actor} spoke your name` : `${item.actor} spoke ${to}'s name`;
21
+ case "reply": return to === undefined ? `${item.actor} answered you` : `${item.actor} answered ${to}`;
22
+ case "attention": return to === undefined ? `${item.actor} spoke · you're listening` : `${item.actor} spoke · ${to} is listening`;
23
+ case "bell": return `${item.actor} rang the bell`;
23
24
  }
24
25
  }
25
26
 
26
27
  export const squareMessageSchema = z.object({
27
28
  text: z.string(),
29
+ fullText: z.string().optional(),
28
30
  items: squareCardDetailsSchema.shape.items.optional(),
29
31
  });
30
32
  export type SquareMessageData = z.output<typeof squareMessageSchema>;
@@ -1,9 +1,10 @@
1
1
  import path from 'node:path';
2
+ import { Text } from '@earendil-works/pi-tui';
2
3
 
3
4
  import { presentPendingAtBoundary, renderPendingAtBoundary } from '../../dist/boundary-presentation.js';
4
5
  import { automaticSessionEnd, automaticSessionStart } from '../../dist/automatic-session.js';
5
6
  import { sessionInbox, observeSessionPending } from '../../dist/inbox.js';
6
- import { previewAttentionBody, renderAttentionDescription } from '../../dist/attention-presentation.js';
7
+ import { previewAttentionBody, renderAttentionDescription, renderAttentionFull } from '../../dist/attention-presentation.js';
7
8
  import { createPiReceiver, sendPiMessage } from '../../dist/packages/agent-delivery/src/pi.js';
8
9
 
9
10
  class PiDeliveryDroppedError extends Error {
@@ -31,7 +32,7 @@ function framePiMessage(content) {
31
32
  return `\n${content.replace(/^\n+/, '').replace(/\n+$/, '')}\n`;
32
33
  }
33
34
 
34
- /** Renderer-only facts for the collapsed card; Pi never sends `details` to the model. */
35
+ /** Renderer-only summary and full text; Pi never sends `details` to the model. */
35
36
  export function pendingCardDetails(pending) {
36
37
  const items = pending.flatMap((membership) => membership.notifications.map((note) => ({
37
38
  square: path.basename(membership.squarePath, '.square'),
@@ -41,7 +42,14 @@ export function pendingCardDetails(pending) {
41
42
  preview: previewAttentionBody(note.body),
42
43
  description: renderAttentionDescription({ actor: note.actor, recipient: membership.name, route: note.route }),
43
44
  })));
44
- return items.length === 0 ? undefined : { items };
45
+ if (items.length === 0) return undefined;
46
+ const fullText = pending.flatMap((membership) => membership.notifications.map((note) => renderAttentionFull({
47
+ squarePath: membership.squarePath,
48
+ actIndex: note.actIndex,
49
+ recipient: membership.name,
50
+ ...note,
51
+ }))).join('\n\n');
52
+ return { items, fullText };
45
53
  }
46
54
 
47
55
  // Conservative terminal width: anything outside ASCII counts as two columns, so a
@@ -62,12 +70,17 @@ function fitColumns(text, columns) {
62
70
  }
63
71
 
64
72
  /**
65
- * Collapsed: one line naming who reached you and where. Expanded: Pi's default card,
66
- * which shows the exact content the model received.
73
+ * Collapsed: one line naming who reached you and where. Expanded: the complete
74
+ * notification text from renderer details, wrapped by Pi's terminal component.
67
75
  */
68
76
  export function renderSquareCard(message, { expanded, outputPad = 1 }, theme) {
77
+ if (expanded) {
78
+ return typeof message.details?.fullText === 'string'
79
+ ? new Text(message.details.fullText, outputPad, 0)
80
+ : undefined;
81
+ }
69
82
  const items = message.details?.items;
70
- if (expanded || !Array.isArray(items) || items.length === 0) return undefined;
83
+ if (!Array.isArray(items) || items.length === 0) return undefined;
71
84
  const [first] = items;
72
85
  const tail = ` · ${first.square}${items.length > 1 ? ` +${items.length - 1}` : ''}`;
73
86
  return {
@@ -240,8 +253,8 @@ export default function squarePiExtension(pi) {
240
253
  boundSessionId,
241
254
  async (content) => {
242
255
  if (signal.aborted || currentRunSignal?.aborted) throw new Error('Pi notification delivery cancelled');
243
- // One card for the human: collapsed it names who reached you, expanded it
244
- // shows the same content the model receives.
256
+ // One card for the human: a compact heading, expandable to the full body.
257
+ // Model context stays bounded; renderer-only details carry the complete text.
245
258
  const messageContent = framePiMessage(content);
246
259
  const landing = waitForLanding(messageContent, signal, 'steer');
247
260
  try {
@@ -259,6 +272,7 @@ export default function squarePiExtension(pi) {
259
272
  const inbox = await observer.read({ signal: boundarySignal, excludeKeys: handledPending });
260
273
  if (signal.aborted || token !== generation) return [];
261
274
  for (const key of inboxKeys(inbox)) observedPending.add(key);
275
+ pending = inbox;
262
276
  return inbox;
263
277
  },
264
278
  env,
@@ -65,7 +65,7 @@ The ownership boundary belongs here. Does this match your read?
65
65
  EOF
66
66
  ```
67
67
 
68
- **Addressing.** Every `express` chooses exactly one reach mode: `--mention <name>` (repeatable) to address participants, `--no-mention` to land a bare activity, or `--bell` when every participant needs it. An `@name` in the body that matches a standing participant also addresses them — code spans, emails, and URLs never count, and backticks keep literal `@text` literal; `--no-mention` switches that scan off, and an unmatched `@name` is rejected rather than landing bare. Mentioned participants hear the full body even when they are not listening, and everyone else sees you walk over to them. `listen` opts a participant into future bare delivery. Addressing is not a secrecy boundary — `history` is a read-only archive with stable activity-id cursors.
68
+ **Addressing.** Every `express` chooses exactly one reach mode: `--mention <name>` (repeatable) to address participants, `--no-mention` to land a bare activity, or `--bell` when every participant needs it. An `@name` in the body that matches a standing participant also addresses them — code spans, emails, and URLs never count, and backticks keep literal `@text` literal; `--no-mention` switches that scan off. An unmatched `@name` stays ordinary body text and never blocks landing; only standing matches become recipients. Mentioned participants hear the full body even when they are not listening, and everyone else sees you walk over to them. `listen` opts a participant into future bare delivery. Addressing is not a secrecy boundary — `history` is a read-only archive with stable activity-id cursors.
69
69
 
70
70
  Every human-CLI `express` must choose exactly one reach mode: one or more `--mention <name>` flags, `--no-mention`, `--bell`, or an `@name` in the body. The CLI rejects an omitted mode and rejects combining these flag modes. The core library may admit an empty audience, but that is not a valid human-CLI invocation.
71
71