@astrosheep/square 0.3.2
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/codex-plugin/.codex-plugin/plugin.json +25 -0
- package/codex-plugin/hooks/hooks.json +28 -0
- package/dist/activity-feed.js +36 -0
- package/dist/activity.js +151 -0
- package/dist/artifact.js +739 -0
- package/dist/claude-hook.js +112 -0
- package/dist/cmd/notify-once.js +37 -0
- package/dist/compact.js +39 -0
- package/dist/decisions.js +286 -0
- package/dist/delivery-health.js +249 -0
- package/dist/delivery.js +93 -0
- package/dist/doctor.js +34 -0
- package/dist/harness.js +584 -0
- package/dist/help.js +131 -0
- package/dist/inbox.js +33 -0
- package/dist/index.js +163 -0
- package/dist/list.js +126 -0
- package/dist/model.js +44 -0
- package/dist/notifications.js +97 -0
- package/dist/paseo-timeline.js +206 -0
- package/dist/presentation.js +468 -0
- package/dist/presented.js +211 -0
- package/dist/registry.js +299 -0
- package/dist/runtime.js +304 -0
- package/dist/search.js +54 -0
- package/dist/square-core.js +183 -0
- package/dist/square.js +1366 -0
- package/dist/stream.js +149 -0
- package/dist/terminal.js +125 -0
- package/dist/time.js +81 -0
- package/dist/wake-sink.js +219 -0
- package/dist/watch.js +386 -0
- package/extensions/square-opencode.js +87 -0
- package/extensions/square-pi.js +167 -0
- package/guides/architect.md +165 -0
- package/guides/brainstorm.md +404 -0
- package/guides/participant.md +171 -0
- package/package.json +57 -0
- package/skills/brainstorm/SKILL.md +136 -0
- package/skills/square/.claude-plugin/plugin.json +8 -0
- package/skills/square/SKILL.md +154 -0
- package/skills/square/hooks/hooks.json +27 -0
- package/skills/square-feedback/SKILL.md +55 -0
- package/skills/square-feedback/agents/openai.yaml +4 -0
- package/template.md +4 -0
- package/templates/architect.md +4 -0
- package/templates/brainstorm.md +4 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
3
|
+
const DEFAULT_POLL_INTERVAL_MS = 100;
|
|
4
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 3000;
|
|
5
|
+
function paseoWebSocketUrl() {
|
|
6
|
+
const override = process.env['SQUARE_PASEO_WS_URL']?.trim();
|
|
7
|
+
if (override)
|
|
8
|
+
return override;
|
|
9
|
+
const listen = process.env['PASEO_LISTEN']?.trim();
|
|
10
|
+
if (!listen)
|
|
11
|
+
return 'ws://127.0.0.1:6767/ws';
|
|
12
|
+
if (/^wss?:\/\//i.test(listen)) {
|
|
13
|
+
const url = new URL(listen);
|
|
14
|
+
if (url.pathname === '/' || url.pathname === '')
|
|
15
|
+
url.pathname = '/ws';
|
|
16
|
+
return url.toString();
|
|
17
|
+
}
|
|
18
|
+
if (/^tcp:\/\//i.test(listen)) {
|
|
19
|
+
const url = new URL(listen);
|
|
20
|
+
const secure = url.searchParams.get('ssl') === 'true';
|
|
21
|
+
url.protocol = secure ? 'wss:' : 'ws:';
|
|
22
|
+
url.pathname = '/ws';
|
|
23
|
+
return url.toString();
|
|
24
|
+
}
|
|
25
|
+
if (/^\d+$/.test(listen))
|
|
26
|
+
return `ws://127.0.0.1:${listen}/ws`;
|
|
27
|
+
return `ws://${listen.replace(/\/$/, '')}/ws`;
|
|
28
|
+
}
|
|
29
|
+
function parseSnapshot(payload) {
|
|
30
|
+
if (payload === null || typeof payload !== 'object') {
|
|
31
|
+
throw new Error('Invalid Paseo timeline response.');
|
|
32
|
+
}
|
|
33
|
+
const response = payload;
|
|
34
|
+
if (typeof response.error === 'string' && response.error) {
|
|
35
|
+
throw new Error(response.error);
|
|
36
|
+
}
|
|
37
|
+
const latestTools = new Map();
|
|
38
|
+
for (const entry of response.entries ?? []) {
|
|
39
|
+
const item = entry.item;
|
|
40
|
+
if (item?.['type'] !== 'tool_call' ||
|
|
41
|
+
typeof item['callId'] !== 'string' ||
|
|
42
|
+
(item['status'] !== 'running' && item['status'] !== 'completed' && item['status'] !== 'failed')) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
latestTools.set(item['callId'], item['status']);
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
agentStatus: typeof response.agent?.status === 'string' ? response.agent.status : 'unknown',
|
|
49
|
+
toolCalls: Array.from(latestTools, ([callId, status]) => ({ callId, status })),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
class PaseoTimelineProbe {
|
|
53
|
+
socket;
|
|
54
|
+
pending = new Map();
|
|
55
|
+
constructor(socket) {
|
|
56
|
+
this.socket = socket;
|
|
57
|
+
socket.addEventListener('message', (event) => {
|
|
58
|
+
let envelope;
|
|
59
|
+
try {
|
|
60
|
+
envelope = JSON.parse(String(event.data));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (envelope === null || typeof envelope !== 'object')
|
|
66
|
+
return;
|
|
67
|
+
const outer = envelope;
|
|
68
|
+
if (outer.type !== 'session' || outer.message === null || typeof outer.message !== 'object') {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const message = outer.message;
|
|
72
|
+
if (message.type !== 'fetch_agent_timeline_response' ||
|
|
73
|
+
message.payload === null ||
|
|
74
|
+
typeof message.payload !== 'object') {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const requestId = message.payload.requestId;
|
|
78
|
+
if (typeof requestId !== 'string')
|
|
79
|
+
return;
|
|
80
|
+
const request = this.pending.get(requestId);
|
|
81
|
+
if (!request)
|
|
82
|
+
return;
|
|
83
|
+
clearTimeout(request.timer);
|
|
84
|
+
this.pending.delete(requestId);
|
|
85
|
+
try {
|
|
86
|
+
request.resolve(parseSnapshot(message.payload));
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
request.reject(error instanceof Error ? error : new Error(String(error)));
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
const rejectPending = () => {
|
|
93
|
+
for (const [requestId, request] of this.pending) {
|
|
94
|
+
clearTimeout(request.timer);
|
|
95
|
+
request.reject(new Error('Paseo timeline connection closed.'));
|
|
96
|
+
this.pending.delete(requestId);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
socket.addEventListener('close', rejectPending);
|
|
100
|
+
socket.addEventListener('error', rejectPending);
|
|
101
|
+
}
|
|
102
|
+
static async connect(timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
|
|
103
|
+
const socket = new WebSocket(paseoWebSocketUrl());
|
|
104
|
+
await new Promise((resolvePromise, reject) => {
|
|
105
|
+
const timer = setTimeout(() => {
|
|
106
|
+
socket.close();
|
|
107
|
+
reject(new Error('Timed out connecting to Paseo.'));
|
|
108
|
+
}, timeoutMs);
|
|
109
|
+
socket.addEventListener('open', () => {
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
resolvePromise();
|
|
112
|
+
}, { once: true });
|
|
113
|
+
socket.addEventListener('error', () => {
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
reject(new Error('Could not connect to Paseo.'));
|
|
116
|
+
}, { once: true });
|
|
117
|
+
});
|
|
118
|
+
const probe = new PaseoTimelineProbe(socket);
|
|
119
|
+
socket.send(JSON.stringify({
|
|
120
|
+
type: 'hello',
|
|
121
|
+
clientId: `square-wake-${process.pid}-${randomUUID()}`,
|
|
122
|
+
clientType: 'cli',
|
|
123
|
+
protocolVersion: 1,
|
|
124
|
+
}));
|
|
125
|
+
return probe;
|
|
126
|
+
}
|
|
127
|
+
snapshot(agentId, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
|
|
128
|
+
const requestId = randomUUID();
|
|
129
|
+
return new Promise((resolvePromise, reject) => {
|
|
130
|
+
const timer = setTimeout(() => {
|
|
131
|
+
this.pending.delete(requestId);
|
|
132
|
+
reject(new Error('Timed out reading Paseo timeline.'));
|
|
133
|
+
}, timeoutMs);
|
|
134
|
+
this.pending.set(requestId, { resolve: resolvePromise, reject, timer });
|
|
135
|
+
this.socket.send(JSON.stringify({
|
|
136
|
+
type: 'session',
|
|
137
|
+
message: {
|
|
138
|
+
type: 'fetch_agent_timeline_request',
|
|
139
|
+
agentId,
|
|
140
|
+
requestId,
|
|
141
|
+
direction: 'tail',
|
|
142
|
+
limit: 200,
|
|
143
|
+
projection: 'projected',
|
|
144
|
+
},
|
|
145
|
+
}));
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
close() {
|
|
149
|
+
this.socket.close();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
async function waitWithSnapshots(agentId, readSnapshot, options) {
|
|
153
|
+
const initial = await readSnapshot(agentId);
|
|
154
|
+
if (initial.agentStatus === 'idle')
|
|
155
|
+
return true;
|
|
156
|
+
if (initial.agentStatus !== 'running')
|
|
157
|
+
return false;
|
|
158
|
+
const currentCalls = new Set(initial.toolCalls.filter((tool) => tool.status === 'running').map((tool) => tool.callId));
|
|
159
|
+
if (currentCalls.size === 0)
|
|
160
|
+
return true;
|
|
161
|
+
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
162
|
+
const delay = options.delay ?? ((ms) => sleep(ms));
|
|
163
|
+
const deadline = options.timeoutMs === undefined ? null : Date.now() + options.timeoutMs;
|
|
164
|
+
while (deadline === null || Date.now() < deadline) {
|
|
165
|
+
await delay(pollIntervalMs);
|
|
166
|
+
const snapshot = await readSnapshot(agentId);
|
|
167
|
+
if (snapshot.agentStatus === 'idle')
|
|
168
|
+
return true;
|
|
169
|
+
if (snapshot.agentStatus !== 'running')
|
|
170
|
+
return false;
|
|
171
|
+
const latest = new Map(snapshot.toolCalls.map((tool) => [tool.callId, tool.status]));
|
|
172
|
+
const allTerminal = Array.from(currentCalls).every((callId) => {
|
|
173
|
+
const status = latest.get(callId);
|
|
174
|
+
return status === 'completed' || status === 'failed';
|
|
175
|
+
});
|
|
176
|
+
if (allTerminal)
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Wait until the tool calls that were running at the initial snapshot finish.
|
|
183
|
+
* Tool calls that start later are intentionally ignored: the subsequent
|
|
184
|
+
* `paseo send` may replace the next tool call under the transitional policy.
|
|
185
|
+
*/
|
|
186
|
+
export async function waitForPaseoToolBoundary(agentId, options = {}) {
|
|
187
|
+
if (options.readSnapshot) {
|
|
188
|
+
try {
|
|
189
|
+
return await waitWithSnapshots(agentId, options.readSnapshot, options);
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
let probe = null;
|
|
196
|
+
try {
|
|
197
|
+
probe = await PaseoTimelineProbe.connect();
|
|
198
|
+
return await waitWithSnapshots(agentId, (id) => probe.snapshot(id), options);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
probe?.close();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
import { sameName } from './model.js';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fold, perceive } from './square-core.js';
|
|
5
|
+
import { actId, actStableIndex, extractMentions, publicActs, readCursor, rosterNames, sayNumberFor } from './runtime.js';
|
|
6
|
+
import { formatDuration, formatRelativeTime, formatTimestamp } from './time.js';
|
|
7
|
+
import { grepSnippet } from './search.js';
|
|
8
|
+
function headerLine(squarePath, opts = {}) {
|
|
9
|
+
const count = opts.participantCount ?? 0;
|
|
10
|
+
const heldSuffix = opts.held ? ' — a hand is raised' : '';
|
|
11
|
+
return `· the square at ${displayPath(squarePath)} — ${count} in the square${heldSuffix}`;
|
|
12
|
+
}
|
|
13
|
+
export function displayPath(squarePath, cwd = process.cwd()) {
|
|
14
|
+
if (!path.isAbsolute(squarePath))
|
|
15
|
+
return squarePath;
|
|
16
|
+
const comparableCwd = fs.realpathSync.native(cwd);
|
|
17
|
+
let comparableSquarePath = squarePath;
|
|
18
|
+
try {
|
|
19
|
+
comparableSquarePath = fs.realpathSync.native(squarePath);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// Some error outputs name a path before it exists; lexical comparison remains useful there.
|
|
23
|
+
}
|
|
24
|
+
const relative = path.relative(comparableCwd, comparableSquarePath);
|
|
25
|
+
return relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`) ? relative : squarePath;
|
|
26
|
+
}
|
|
27
|
+
export function withPathOutput(squarePath, body = '', opts = {}) {
|
|
28
|
+
return [headerLine(squarePath, opts), ...(body === '' ? [] : ['', body])].join('\n') + '\n';
|
|
29
|
+
}
|
|
30
|
+
export function quoteShell(value) {
|
|
31
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
32
|
+
}
|
|
33
|
+
export function commandPrefix(squarePath) {
|
|
34
|
+
return `square --square-path ${quoteShell(squarePath)}`;
|
|
35
|
+
}
|
|
36
|
+
export function participantCommandPrefix(squarePath, name) {
|
|
37
|
+
return `square --square-path ${quoteShell(path.resolve(squarePath))} --as ${quoteShell(name)}`;
|
|
38
|
+
}
|
|
39
|
+
function formatAge(ms) {
|
|
40
|
+
if (ms === undefined)
|
|
41
|
+
return '(none)';
|
|
42
|
+
if (ms < 1000)
|
|
43
|
+
return `${Math.max(0, ms)}ms`;
|
|
44
|
+
return `${Math.floor(ms / 1000)}s`;
|
|
45
|
+
}
|
|
46
|
+
function pluralize(count, singular, plural = `${singular}s`) {
|
|
47
|
+
return count === 1 ? singular : plural;
|
|
48
|
+
}
|
|
49
|
+
const PRESENCE_WINDOW_MS = 8 * 60 * 60 * 1000;
|
|
50
|
+
function presenceGlyph(participant) {
|
|
51
|
+
if (participant.state === 'done')
|
|
52
|
+
return '×';
|
|
53
|
+
if (participant.presence === 'watching')
|
|
54
|
+
return '◎';
|
|
55
|
+
if (participant.presenceAt !== undefined)
|
|
56
|
+
return '●';
|
|
57
|
+
return '○';
|
|
58
|
+
}
|
|
59
|
+
function presenceText(participant, now) {
|
|
60
|
+
if (participant.state === 'done') {
|
|
61
|
+
return participant.lastActiveAt === undefined ? 'stepped out of the square' : `stepped out of the square · ${formatRelativeTime(participant.lastActiveAt, now)}`;
|
|
62
|
+
}
|
|
63
|
+
if (participant.presence === 'watching') {
|
|
64
|
+
const at = participant.presenceAt ?? participant.lastActiveAt;
|
|
65
|
+
return at === undefined ? 'catching' : `catching · ${formatRelativeTime(at, now)}`;
|
|
66
|
+
}
|
|
67
|
+
if (participant.presenceAt === undefined)
|
|
68
|
+
return 'quiet';
|
|
69
|
+
return participant.activityCount > 0 ? `${formatRelativeTime(participant.presenceAt, now)}` : `quiet · ${formatRelativeTime(participant.presenceAt, now)}`;
|
|
70
|
+
}
|
|
71
|
+
export function renderPresenceLines(participants, now, limit = 5) {
|
|
72
|
+
const recent = participants
|
|
73
|
+
.map((p) => ({ p, at: p.presenceAt ?? p.lastActiveAt ?? -Infinity }))
|
|
74
|
+
.filter(({ p, at }) => p.state === 'done' || p.presence === 'watching' || (at !== -Infinity && now - at <= PRESENCE_WINDOW_MS))
|
|
75
|
+
.sort((a, b) => b.at - a.at || a.p.name.localeCompare(b.p.name))
|
|
76
|
+
.map(({ p }) => p);
|
|
77
|
+
const shown = recent.slice(0, limit);
|
|
78
|
+
if (shown.length === 0)
|
|
79
|
+
return [' ○ nobody nearby'];
|
|
80
|
+
const lines = shown.map((p) => ` ${presenceGlyph(p)} ${p.name} · ${presenceText(p, now)}`);
|
|
81
|
+
const remaining = recent.length - shown.length;
|
|
82
|
+
if (remaining > 0)
|
|
83
|
+
lines.push(` ○ …and ${remaining} more`);
|
|
84
|
+
return lines;
|
|
85
|
+
}
|
|
86
|
+
const ACT_HINTS = [
|
|
87
|
+
'*asterisks* are your body — *slams table*, *sketches in the air*, *shrugs*',
|
|
88
|
+
"you're standing in a square, not posting to a feed",
|
|
89
|
+
'half-shaped is welcome — a sketch, an objection, a joke, a fragment',
|
|
90
|
+
'@ only who you need — step back with catch --mention',
|
|
91
|
+
];
|
|
92
|
+
export function actHintLine(ownActCount) {
|
|
93
|
+
if (ownActCount !== 1 && ownActCount % 5 !== 0)
|
|
94
|
+
return undefined;
|
|
95
|
+
const hint = ACT_HINTS[Math.floor(ownActCount / 5) % ACT_HINTS.length];
|
|
96
|
+
return `· ${hint}`;
|
|
97
|
+
}
|
|
98
|
+
const BODY_PREVIEW_LENGTH = 200;
|
|
99
|
+
export function truncateChars(body, maxChars) {
|
|
100
|
+
const chars = [...body];
|
|
101
|
+
if (chars.length <= maxChars)
|
|
102
|
+
return { text: body, remaining: 0 };
|
|
103
|
+
return { text: chars.slice(0, maxChars).join('').trimEnd(), remaining: chars.length - maxChars };
|
|
104
|
+
}
|
|
105
|
+
function previewBody(body, maxLen = BODY_PREVIEW_LENGTH) {
|
|
106
|
+
const preview = truncateChars(body, maxLen);
|
|
107
|
+
return preview.remaining === 0 ? preview.text : `${preview.text}\n… ${preview.remaining} more chars`;
|
|
108
|
+
}
|
|
109
|
+
const UNREAD_PREVIEW_CHARS = 120;
|
|
110
|
+
export function previewActivityBody(body) {
|
|
111
|
+
const compact = body.replace(/\s+/g, ' ').trim();
|
|
112
|
+
if (compact === '')
|
|
113
|
+
return '(empty)';
|
|
114
|
+
const preview = truncateChars(compact, UNREAD_PREVIEW_CHARS);
|
|
115
|
+
return preview.remaining === 0 ? preview.text : `${preview.text}… (+${preview.remaining} chars)`;
|
|
116
|
+
}
|
|
117
|
+
export function renderRoomChangeText(event) {
|
|
118
|
+
const actor = event.actor ?? 'someone';
|
|
119
|
+
switch (event.kind) {
|
|
120
|
+
case 'join':
|
|
121
|
+
return `${actor} stepped into the square`;
|
|
122
|
+
case 'done':
|
|
123
|
+
return `${actor} stepped out of the square`;
|
|
124
|
+
case 'hold':
|
|
125
|
+
return `${actor} raised a hand${event.body ? ` — ${event.body}` : ''}`;
|
|
126
|
+
case 'resume':
|
|
127
|
+
return `${actor} lowered the hand`;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function renderedBody(body, maxChars) {
|
|
131
|
+
if (!body)
|
|
132
|
+
return '';
|
|
133
|
+
return maxChars === undefined ? body : previewBody(body, maxChars);
|
|
134
|
+
}
|
|
135
|
+
function bodySuffix(body) {
|
|
136
|
+
if (body === '')
|
|
137
|
+
return '';
|
|
138
|
+
return `\n${body.split('\n').map((line) => ` ${line}`).join('\n')}`;
|
|
139
|
+
}
|
|
140
|
+
export function renderEventCli(event, opts = {}) {
|
|
141
|
+
const now = opts.now;
|
|
142
|
+
const maxBody = opts.preview;
|
|
143
|
+
switch (event.kind) {
|
|
144
|
+
case 'join':
|
|
145
|
+
return `· ${renderRoomChangeText(event)}`;
|
|
146
|
+
case 'hold':
|
|
147
|
+
return `· ${renderRoomChangeText(event)}`;
|
|
148
|
+
case 'resume':
|
|
149
|
+
return `✓ ${renderRoomChangeText(event)}`;
|
|
150
|
+
case 'say': {
|
|
151
|
+
const body = renderedBody(event.body, maxBody);
|
|
152
|
+
const mention = opts.mention;
|
|
153
|
+
const mentionSuffix = mention !== undefined && extractMentions(event.body).some((name) => sameName(name, mention))
|
|
154
|
+
? ` · calls your name across the square — @${mention}`
|
|
155
|
+
: '';
|
|
156
|
+
return `● ${event.actor} #${opts.actNumber ?? 1} · ${actId(event)} · ${formatRelativeTime(event.at, now)}${mentionSuffix}${bodySuffix(body)}`;
|
|
157
|
+
}
|
|
158
|
+
case 'done': {
|
|
159
|
+
const body = renderedBody(event.body, maxBody);
|
|
160
|
+
return `× ${event.actor} stepped out of the square — done · ${actId(event)} · ${formatRelativeTime(event.at, now)}${bodySuffix(body)}`;
|
|
161
|
+
}
|
|
162
|
+
case 'read':
|
|
163
|
+
return '';
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function renderPresenceOnlySay(event) {
|
|
167
|
+
if (event.kind !== 'say' || event.reach === undefined || event.reach === 'bell')
|
|
168
|
+
return '';
|
|
169
|
+
return `*walks over to @${event.reach.beside}*`;
|
|
170
|
+
}
|
|
171
|
+
function perceptionFor(history, event, viewer) {
|
|
172
|
+
const cutoff = history.findIndex((item) => actStableIndex(item) === actStableIndex(event));
|
|
173
|
+
const acts = cutoff >= 0 ? history.slice(0, cutoff) : history;
|
|
174
|
+
return perceive(fold(acts), event, viewer);
|
|
175
|
+
}
|
|
176
|
+
export function renderVisibleEvent(history, event, viewer, opts = {}) {
|
|
177
|
+
if (event.kind !== 'say')
|
|
178
|
+
return renderEventCli(event, opts);
|
|
179
|
+
const seen = perceptionFor(history, event, viewer);
|
|
180
|
+
if (seen === 'none')
|
|
181
|
+
return '';
|
|
182
|
+
if (seen === 'presence')
|
|
183
|
+
return renderPresenceOnlySay(event);
|
|
184
|
+
return renderEventCli(event, opts);
|
|
185
|
+
}
|
|
186
|
+
function draftSavedLines(draftPath) {
|
|
187
|
+
return draftPath === undefined ? [] : [`· draft kept: ${draftPath}`];
|
|
188
|
+
}
|
|
189
|
+
function withDraftInput(command, draftPath) {
|
|
190
|
+
return draftPath === undefined ? command : `${command} < ${quoteShell(draftPath)}`;
|
|
191
|
+
}
|
|
192
|
+
function renderUnreadSummary(opts) {
|
|
193
|
+
return [
|
|
194
|
+
...opts.activitySummaries.flatMap((item) => [
|
|
195
|
+
...item.previews.slice(-1).map((preview) => {
|
|
196
|
+
const rendered = renderVisibleEvent([preview.act], preview.act, opts.viewer, { actNumber: preview.number });
|
|
197
|
+
if (rendered === '')
|
|
198
|
+
return ` · ${item.name} spoke — ${formatAge(item.latestActivityAgeMs)} ago`;
|
|
199
|
+
if (rendered.startsWith('*'))
|
|
200
|
+
return ` · ${item.name} spoke — ${formatAge(item.latestActivityAgeMs)} ago · ${rendered}`;
|
|
201
|
+
return ` · ${item.name} spoke — ${formatAge(item.latestActivityAgeMs)} ago · "${previewActivityBody(preview.act.body)}"`;
|
|
202
|
+
}),
|
|
203
|
+
]),
|
|
204
|
+
...opts.roomChanges.map(({ act }) => ` · ${renderRoomChangeText(act)}`),
|
|
205
|
+
];
|
|
206
|
+
}
|
|
207
|
+
export function renderPendingFeed(publicItems, roomChanges, viewer = '') {
|
|
208
|
+
const lines = [];
|
|
209
|
+
for (const item of publicItems) {
|
|
210
|
+
if (item.act.kind === 'say' && item.number !== undefined && item.act.at) {
|
|
211
|
+
const rendered = renderVisibleEvent(publicItems
|
|
212
|
+
.filter((entry) => (entry.act.kind === 'say' || entry.act.kind === 'done') && entry.act.at !== undefined)
|
|
213
|
+
.map((entry, index) => ({ ...entry.act, index })), { kind: 'say', actor: item.act.actor, at: item.act.at, body: item.act.body ?? '', index: item.index }, viewer, { actNumber: item.number });
|
|
214
|
+
if (rendered !== '')
|
|
215
|
+
lines.push(rendered);
|
|
216
|
+
}
|
|
217
|
+
else if (item.act.kind === 'done' && item.act.at) {
|
|
218
|
+
lines.push(renderEventCli({ kind: 'done', actor: item.act.actor, at: item.act.at, body: item.act.body ?? '', index: item.index }));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const publicIndexes = new Set(publicItems.map((item) => item.index));
|
|
222
|
+
for (const { act, index } of roomChanges) {
|
|
223
|
+
if (publicIndexes.has(index))
|
|
224
|
+
continue;
|
|
225
|
+
lines.push(`· ${renderRoomChangeText(act)}`);
|
|
226
|
+
}
|
|
227
|
+
return lines.join('\n\n');
|
|
228
|
+
}
|
|
229
|
+
export function renderActivityBlocked(opts) {
|
|
230
|
+
const readNowCommand = `${participantCommandPrefix(opts.squarePath, opts.name)} catch --now`;
|
|
231
|
+
return withPathOutput(opts.squarePath, [
|
|
232
|
+
"✕ your act doesn't land — the square moved behind your back",
|
|
233
|
+
...renderUnreadSummary({ activitySummaries: opts.activitySummaries, roomChanges: opts.unreadRoomChanges, viewer: opts.name }),
|
|
234
|
+
...draftSavedLines(opts.draftPath),
|
|
235
|
+
`» ${readNowCommand}`,
|
|
236
|
+
' read, then act again',
|
|
237
|
+
`» ${withDraftInput(opts.forceCommand, opts.draftPath)}`,
|
|
238
|
+
' only if you truly mean to speak over them',
|
|
239
|
+
].join('\n'), { participantCount: opts.participantCount, held: opts.held });
|
|
240
|
+
}
|
|
241
|
+
export function withJoinNextOutput(squarePath, body, opts = {}) {
|
|
242
|
+
return withPathOutput(squarePath, body.trimEnd(), opts);
|
|
243
|
+
}
|
|
244
|
+
export function withActivityNextOutput(squarePath, body = '', opts = {}) {
|
|
245
|
+
return withPathOutput(squarePath, body.trimEnd(), opts);
|
|
246
|
+
}
|
|
247
|
+
export function withWatchNextOutput(squarePath, body, opts = {}) {
|
|
248
|
+
return withPathOutput(squarePath, body.trimEnd(), opts);
|
|
249
|
+
}
|
|
250
|
+
export function renderActWaiting(opts) {
|
|
251
|
+
if (opts.reason === 'throttled') {
|
|
252
|
+
return ['✕ the square is packed', ` · your act is waiting · next opening in ${formatDuration(opts.delayMs)}`].join('\n');
|
|
253
|
+
}
|
|
254
|
+
return ["✕ your act doesn't land — a hand is raised", ' · your act is waiting'].join('\n');
|
|
255
|
+
}
|
|
256
|
+
export function renderActNoWait(opts) {
|
|
257
|
+
const retryCommand = `${participantCommandPrefix(opts.squarePath, opts.name)} act -`;
|
|
258
|
+
const lines = opts.reason === 'throttled'
|
|
259
|
+
? [
|
|
260
|
+
'✕ the square is packed',
|
|
261
|
+
` · next opening in ${formatDuration(opts.delayMs)}`,
|
|
262
|
+
...draftSavedLines(opts.draftPath),
|
|
263
|
+
`» ${withDraftInput(retryCommand, opts.draftPath)}`,
|
|
264
|
+
]
|
|
265
|
+
: [
|
|
266
|
+
"✕ your act doesn't land — a hand is raised",
|
|
267
|
+
` · ${opts.holdReason ?? 'the square holds its breath'}`,
|
|
268
|
+
...draftSavedLines(opts.draftPath),
|
|
269
|
+
`» ${withDraftInput(retryCommand, opts.draftPath)}`,
|
|
270
|
+
];
|
|
271
|
+
return withPathOutput(opts.squarePath, lines.join('\n'), { participantCount: opts.participantCount, held: opts.held });
|
|
272
|
+
}
|
|
273
|
+
export function renderPublicTail(events, lastN, now, viewer = '') {
|
|
274
|
+
const publicItems = publicActs(events);
|
|
275
|
+
const selected = lastN == null ? publicItems : publicItems.slice(-lastN);
|
|
276
|
+
const preview = lastN == null ? undefined : BODY_PREVIEW_LENGTH;
|
|
277
|
+
return selected
|
|
278
|
+
.map((event) => renderVisibleEvent(events, event, viewer, { now, preview, actNumber: event.kind === 'say' ? sayNumberFor(events, event) : undefined }))
|
|
279
|
+
.filter(Boolean)
|
|
280
|
+
.join('\n\n');
|
|
281
|
+
}
|
|
282
|
+
function lastPresenceAnchor(doc, name) {
|
|
283
|
+
const cursor = readCursor(doc, name);
|
|
284
|
+
for (let i = doc.acts.length - 1; i >= 0; i--) {
|
|
285
|
+
const event = doc.acts[i];
|
|
286
|
+
const index = actStableIndex(event);
|
|
287
|
+
if (index > cursor)
|
|
288
|
+
continue;
|
|
289
|
+
if (event.kind === 'say' || event.kind === 'done')
|
|
290
|
+
return index;
|
|
291
|
+
}
|
|
292
|
+
return -1;
|
|
293
|
+
}
|
|
294
|
+
function renderLastPresenceMarker(name) {
|
|
295
|
+
return `· ${name}'s footprints reach here`;
|
|
296
|
+
}
|
|
297
|
+
export function renderActivitiesView(doc, visible, lastN, full, squarePath, viewer = '') {
|
|
298
|
+
const publicVisible = visible.filter((item) => item.act.kind === 'say' || item.act.kind === 'done');
|
|
299
|
+
const shown = lastN == null ? publicVisible : publicVisible.slice(-lastN);
|
|
300
|
+
const previewLen = full ? undefined : BODY_PREVIEW_LENGTH;
|
|
301
|
+
const markers = new Map();
|
|
302
|
+
for (const participant of rosterNames(doc)) {
|
|
303
|
+
const anchor = lastPresenceAnchor(doc, participant);
|
|
304
|
+
if (anchor >= 0)
|
|
305
|
+
markers.set(anchor, [...(markers.get(anchor) ?? []), participant]);
|
|
306
|
+
}
|
|
307
|
+
const chunks = [];
|
|
308
|
+
for (const item of shown) {
|
|
309
|
+
const rendered = renderVisibleEvent(doc.acts, item.act, viewer, {
|
|
310
|
+
preview: previewLen,
|
|
311
|
+
actNumber: item.act.kind === 'say' ? sayNumberFor(doc.acts, item.act) : undefined,
|
|
312
|
+
});
|
|
313
|
+
if (rendered !== '')
|
|
314
|
+
chunks.push(rendered);
|
|
315
|
+
for (const participant of markers.get(item.index) ?? []) {
|
|
316
|
+
chunks.push(renderLastPresenceMarker(participant));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (chunks.length === 0)
|
|
320
|
+
return 'latest\n ○ no public activity in this view';
|
|
321
|
+
if (previewLen !== undefined) {
|
|
322
|
+
const truncated = shown.some((item) => item.act.kind === 'say' && item.act.body.length > previewLen);
|
|
323
|
+
if (truncated)
|
|
324
|
+
chunks.push(`» ${commandPrefix(squarePath)} echo --full`);
|
|
325
|
+
}
|
|
326
|
+
return chunks.join('\n\n');
|
|
327
|
+
}
|
|
328
|
+
const GREP_PREVIEW_CHARS = 160;
|
|
329
|
+
function highlightGrepMatch(text) {
|
|
330
|
+
if (!process.stdout.isTTY || process.env.NO_COLOR !== undefined || text === '')
|
|
331
|
+
return text;
|
|
332
|
+
return `\x1b[38;5;222m\x1b[1m${text}\x1b[0m`;
|
|
333
|
+
}
|
|
334
|
+
export function renderGrepActivitiesView(visible, totalMatches, full, squarePath, pattern, fixed = false) {
|
|
335
|
+
const publicVisible = visible.filter((item) => item.act.kind === 'say' || item.act.kind === 'done');
|
|
336
|
+
if (totalMatches === 0)
|
|
337
|
+
return `○ no activity matched ${quoteShell(pattern)}`;
|
|
338
|
+
const matchLabel = totalMatches === 1 ? 'match' : 'matches';
|
|
339
|
+
const chunks = [publicVisible.length === totalMatches ? `${totalMatches} ${matchLabel}` : `${publicVisible.length} of ${totalMatches} ${matchLabel}`];
|
|
340
|
+
let truncated = false;
|
|
341
|
+
for (const item of publicVisible) {
|
|
342
|
+
const rawBody = item.act.body ?? '';
|
|
343
|
+
if (full === true) {
|
|
344
|
+
const body = rawBody.split('\n').map((line) => ` ${line}`).join('\n');
|
|
345
|
+
chunks.push(`${actId(item.index)} · ${item.act.actor ?? 'unknown'} · ${formatTimestamp(item.act.at)}\n${body}`);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const snippet = grepSnippet(rawBody, pattern, GREP_PREVIEW_CHARS, fixed);
|
|
349
|
+
if (snippet === undefined)
|
|
350
|
+
continue;
|
|
351
|
+
const clippedBefore = snippet.beforeOmitted > 0;
|
|
352
|
+
const clippedAfter = snippet.afterOmitted > 0;
|
|
353
|
+
truncated ||= clippedBefore || clippedAfter;
|
|
354
|
+
const text = `${clippedBefore ? '… ' : ''}${snippet.before}${highlightGrepMatch(snippet.match)}${snippet.after}${clippedAfter ? ' …' : ''}`;
|
|
355
|
+
const omitted = clippedBefore || clippedAfter
|
|
356
|
+
? `\n · ${snippet.beforeOmitted} chars before · ${snippet.afterOmitted} chars after`
|
|
357
|
+
: '';
|
|
358
|
+
chunks.push(`${actId(item.index)} · ${item.act.actor ?? 'unknown'} · ${formatTimestamp(item.act.at)}\n ${text.trim()}${omitted}`);
|
|
359
|
+
}
|
|
360
|
+
if (publicVisible.length === 1) {
|
|
361
|
+
chunks.push(`» ${commandPrefix(squarePath)} echo --at ${actId(publicVisible[0].index)} -C 2${truncated ? ' --full' : ''}`);
|
|
362
|
+
}
|
|
363
|
+
else if (truncated && publicVisible.length > 1) {
|
|
364
|
+
chunks.push(`» ${commandPrefix(squarePath)} echo --at ${actId(publicVisible[0].index)} -C 2 --full`);
|
|
365
|
+
}
|
|
366
|
+
return chunks.join('\n\n');
|
|
367
|
+
}
|
|
368
|
+
function renderActivityLimitBody(opts) {
|
|
369
|
+
const countText = opts.count !== undefined && opts.hardCap !== undefined ? ` (${opts.count}/${opts.hardCap})` : '';
|
|
370
|
+
const doneCommand = `${participantCommandPrefix(opts.squarePath, opts.name)} done -`;
|
|
371
|
+
return [
|
|
372
|
+
`✕ your act doesn't land — the cap is reached${countText}`,
|
|
373
|
+
...draftSavedLines(opts.draftPath),
|
|
374
|
+
`» ${withDraftInput(doneCommand, opts.draftPath)}`,
|
|
375
|
+
].join('\n');
|
|
376
|
+
}
|
|
377
|
+
export function renderActivityLimit(opts) {
|
|
378
|
+
return withPathOutput(opts.squarePath, renderActivityLimitBody(opts), { participantCount: opts.participantCount, held: opts.held });
|
|
379
|
+
}
|
|
380
|
+
export function renderWatchInterrupted(_opts) {
|
|
381
|
+
return '✕ catch stopped';
|
|
382
|
+
}
|
|
383
|
+
export function renderWatchAlreadyActive(opts) {
|
|
384
|
+
return ['✕ you are already catching', `» ${participantCommandPrefix(opts.squarePath, opts.name)} catch --force`].join('\n');
|
|
385
|
+
}
|
|
386
|
+
export function renderWatchForceTakeover(_opts) {
|
|
387
|
+
return '✓ your new catch takes over';
|
|
388
|
+
}
|
|
389
|
+
export function renderWatchReplaced(_opts) {
|
|
390
|
+
return '✕ a newer catch took over';
|
|
391
|
+
}
|
|
392
|
+
export function renderWatchStatus(opts) {
|
|
393
|
+
const others = opts.presence === undefined
|
|
394
|
+
? undefined
|
|
395
|
+
: opts.presence.participants.filter((participant) => !sameName(participant.name, opts.name));
|
|
396
|
+
const presenceLines = opts.presence !== undefined && others !== undefined
|
|
397
|
+
? ['', 'around the square', ...renderPresenceLines(others, opts.presence.now)]
|
|
398
|
+
: [];
|
|
399
|
+
switch (opts.status) {
|
|
400
|
+
case 'stale':
|
|
401
|
+
case 'empty-now': {
|
|
402
|
+
const prefix = participantCommandPrefix(opts.squarePath, opts.name);
|
|
403
|
+
return [
|
|
404
|
+
'○ only footsteps in the square — nothing new for you',
|
|
405
|
+
...(opts.showCatchHint === false
|
|
406
|
+
? []
|
|
407
|
+
: [`» ${prefix} catch --idle 30m`, ` glance: ${prefix} catch --now`]),
|
|
408
|
+
...presenceLines,
|
|
409
|
+
].join('\n');
|
|
410
|
+
}
|
|
411
|
+
case 'quorum':
|
|
412
|
+
return ['✓ everyone else has left — the square is yours alone', `» ${participantCommandPrefix(opts.squarePath, opts.name)} done -`].join('\n');
|
|
413
|
+
case 'capped':
|
|
414
|
+
return ['✕ nothing left in you — the cap is reached', `» ${participantCommandPrefix(opts.squarePath, opts.name)} done -`].join('\n');
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
function renderRoomChanges(changes) {
|
|
418
|
+
if (changes.length === 0)
|
|
419
|
+
return '';
|
|
420
|
+
return ['▲ while your back was turned', ...changes.map(({ act }) => ` · ${renderRoomChangeText(act)}`)].join('\n');
|
|
421
|
+
}
|
|
422
|
+
export function renderDoctorClean() {
|
|
423
|
+
return '✓ no problems found';
|
|
424
|
+
}
|
|
425
|
+
export function renderDoctorProblems(problems) {
|
|
426
|
+
return [`✕ ${problems.length} ${pluralize(problems.length, 'problem')} found`, ...problems.map((problem) => ` · ${problem.kind}: ${problem.message}`)].join('\n');
|
|
427
|
+
}
|
|
428
|
+
export function renderDoctorUnfixable(reason) {
|
|
429
|
+
return ['✕ cannot repair', ` · ${reason}`].join('\n');
|
|
430
|
+
}
|
|
431
|
+
export function renderDoctorRepaired(actions, quarantinedCount, sidecarPath) {
|
|
432
|
+
if (actions.length === 0)
|
|
433
|
+
return '✓ no problems found';
|
|
434
|
+
return [
|
|
435
|
+
'✓ repaired',
|
|
436
|
+
...actions.map((action) => ` · ${action.message}`),
|
|
437
|
+
...(quarantinedCount > 0 && sidecarPath !== undefined ? [` · quarantined ${quarantinedCount} act block(s)`, ` · sidecar ${sidecarPath}`] : []),
|
|
438
|
+
].join('\n');
|
|
439
|
+
}
|
|
440
|
+
export function renderWatchOutput(history, publicItems, roomChanges, opts) {
|
|
441
|
+
const sections = [];
|
|
442
|
+
if (opts.stalePartial) {
|
|
443
|
+
const prefix = participantCommandPrefix(opts.squarePath, opts.viewer);
|
|
444
|
+
sections.push([
|
|
445
|
+
'○ only footsteps in the square — nothing new for you',
|
|
446
|
+
...(opts.showCatchHint === false
|
|
447
|
+
? []
|
|
448
|
+
: [`» ${prefix} catch --idle 30m`, ` glance: ${prefix} catch --now`]),
|
|
449
|
+
].join('\n'));
|
|
450
|
+
}
|
|
451
|
+
const publicIndexes = new Set(publicItems.map((item) => item.index));
|
|
452
|
+
const presenceChanges = roomChanges.filter(({ index }) => !publicIndexes.has(index));
|
|
453
|
+
const room = renderRoomChanges(presenceChanges);
|
|
454
|
+
if (room !== '')
|
|
455
|
+
sections.push(room);
|
|
456
|
+
if (publicItems.length > 0) {
|
|
457
|
+
const rendered = publicItems
|
|
458
|
+
.map((item) => renderVisibleEvent(history, item.act, opts.viewer, {
|
|
459
|
+
actNumber: item.act.kind === 'say' ? sayNumberFor(history, item.act) : undefined,
|
|
460
|
+
mention: opts.mention,
|
|
461
|
+
}))
|
|
462
|
+
.filter(Boolean)
|
|
463
|
+
.join('\n\n');
|
|
464
|
+
if (rendered !== '')
|
|
465
|
+
sections.push(rendered);
|
|
466
|
+
}
|
|
467
|
+
return sections.join('\n\n') + '\n';
|
|
468
|
+
}
|