@composer-app/mcp 0.0.1-beta.3 → 0.0.1-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-VVYEIOFH.js → chunk-UVXQZ2TN.js} +370 -56
- package/dist/cli.js +1 -1
- package/dist/mcp.js +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +227 -22
|
@@ -3833,7 +3833,9 @@ function getElementText(el) {
|
|
|
3833
3833
|
let out = "";
|
|
3834
3834
|
for (const child of el.toArray()) {
|
|
3835
3835
|
if (child instanceof Y2.XmlText) {
|
|
3836
|
-
|
|
3836
|
+
for (const op of child.toDelta()) {
|
|
3837
|
+
if (typeof op.insert === "string") out += op.insert;
|
|
3838
|
+
}
|
|
3837
3839
|
} else if (isXmlElement(child)) {
|
|
3838
3840
|
out += getElementText(child);
|
|
3839
3841
|
}
|
|
@@ -3879,6 +3881,31 @@ function getOutline(doc) {
|
|
|
3879
3881
|
});
|
|
3880
3882
|
return outline;
|
|
3881
3883
|
}
|
|
3884
|
+
function getSectionBlockRange(doc, headingId) {
|
|
3885
|
+
const blocks = topLevelBlocks(doc);
|
|
3886
|
+
let startIndex = -1;
|
|
3887
|
+
let startLevel = 0;
|
|
3888
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
3889
|
+
const block = blocks[i];
|
|
3890
|
+
if (block.nodeName !== "heading") continue;
|
|
3891
|
+
const id = makeHeadingId(getElementText(block), i);
|
|
3892
|
+
if (id === headingId) {
|
|
3893
|
+
startIndex = i;
|
|
3894
|
+
startLevel = getHeadingLevel(block);
|
|
3895
|
+
break;
|
|
3896
|
+
}
|
|
3897
|
+
}
|
|
3898
|
+
if (startIndex === -1) return null;
|
|
3899
|
+
let endIndex = blocks.length;
|
|
3900
|
+
for (let i = startIndex + 1; i < blocks.length; i++) {
|
|
3901
|
+
const block = blocks[i];
|
|
3902
|
+
if (block.nodeName === "heading" && getHeadingLevel(block) <= startLevel) {
|
|
3903
|
+
endIndex = i;
|
|
3904
|
+
break;
|
|
3905
|
+
}
|
|
3906
|
+
}
|
|
3907
|
+
return { start: startIndex, end: endIndex };
|
|
3908
|
+
}
|
|
3882
3909
|
function getSection(doc, headingId) {
|
|
3883
3910
|
const blocks = topLevelBlocks(doc);
|
|
3884
3911
|
let startIndex = -1;
|
|
@@ -3922,17 +3949,33 @@ import * as Y3 from "yjs";
|
|
|
3922
3949
|
function buildFlatMap(fragment) {
|
|
3923
3950
|
let flat = "";
|
|
3924
3951
|
const map = [];
|
|
3952
|
+
const blockFlatStarts = [];
|
|
3925
3953
|
const walk = (node) => {
|
|
3926
3954
|
if (node instanceof Y3.XmlText) {
|
|
3927
|
-
|
|
3928
|
-
for (
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3955
|
+
let localOffset = 0;
|
|
3956
|
+
for (const op of node.toDelta()) {
|
|
3957
|
+
const value = op.insert;
|
|
3958
|
+
if (typeof value === "string") {
|
|
3959
|
+
const base = flat.length;
|
|
3960
|
+
for (let i = 0; i < value.length; i++) {
|
|
3961
|
+
map.push({
|
|
3962
|
+
xmlText: node,
|
|
3963
|
+
offsetInText: localOffset + i,
|
|
3964
|
+
flatIndex: base + i
|
|
3965
|
+
});
|
|
3966
|
+
}
|
|
3967
|
+
flat += value;
|
|
3968
|
+
localOffset += value.length;
|
|
3969
|
+
} else if (value !== void 0) {
|
|
3970
|
+
map.push({
|
|
3971
|
+
xmlText: node,
|
|
3972
|
+
offsetInText: localOffset,
|
|
3973
|
+
flatIndex: flat.length
|
|
3974
|
+
});
|
|
3975
|
+
flat += "\uFFFC";
|
|
3976
|
+
localOffset += 1;
|
|
3977
|
+
}
|
|
3934
3978
|
}
|
|
3935
|
-
flat += text;
|
|
3936
3979
|
return;
|
|
3937
3980
|
}
|
|
3938
3981
|
for (const child of node.toArray()) {
|
|
@@ -3943,6 +3986,7 @@ function buildFlatMap(fragment) {
|
|
|
3943
3986
|
};
|
|
3944
3987
|
const topLevel = fragment.toArray();
|
|
3945
3988
|
topLevel.forEach((node, idx) => {
|
|
3989
|
+
blockFlatStarts.push(flat.length);
|
|
3946
3990
|
if (node instanceof Y3.XmlText || node instanceof Y3.XmlElement) {
|
|
3947
3991
|
walk(node);
|
|
3948
3992
|
}
|
|
@@ -3950,7 +3994,7 @@ function buildFlatMap(fragment) {
|
|
|
3950
3994
|
flat += "\n";
|
|
3951
3995
|
}
|
|
3952
3996
|
});
|
|
3953
|
-
return { flat, map };
|
|
3997
|
+
return { flat, map, blockFlatStarts };
|
|
3954
3998
|
}
|
|
3955
3999
|
function findNthOccurrence(flat, needle, n) {
|
|
3956
4000
|
if (needle.length === 0) return null;
|
|
@@ -4068,11 +4112,23 @@ function resolveServerAnchor(doc, spec) {
|
|
|
4068
4112
|
return { ok: false, error: "text_not_found", currentSectionText };
|
|
4069
4113
|
}
|
|
4070
4114
|
const fragment = doc.getXmlFragment("default");
|
|
4071
|
-
const { flat, map } = buildFlatMap(fragment);
|
|
4072
|
-
const
|
|
4073
|
-
if (
|
|
4115
|
+
const { flat, map, blockFlatStarts } = buildFlatMap(fragment);
|
|
4116
|
+
const range = getSectionBlockRange(doc, spec.headingId);
|
|
4117
|
+
if (!range) {
|
|
4118
|
+
return {
|
|
4119
|
+
ok: false,
|
|
4120
|
+
error: "section_not_found",
|
|
4121
|
+
currentSectionText
|
|
4122
|
+
};
|
|
4123
|
+
}
|
|
4124
|
+
const sectionFlatStart = blockFlatStarts[range.start] ?? 0;
|
|
4125
|
+
const sectionFlatEnd = range.end < blockFlatStarts.length ? blockFlatStarts[range.end] : flat.length;
|
|
4126
|
+
const sectionFlat = flat.slice(sectionFlatStart, sectionFlatEnd);
|
|
4127
|
+
const sectionRelStart = findNthOccurrence(sectionFlat, spec.textToFind, occurrence);
|
|
4128
|
+
if (sectionRelStart === null) {
|
|
4074
4129
|
return { ok: false, error: "text_not_found", currentSectionText };
|
|
4075
4130
|
}
|
|
4131
|
+
const flatStart = sectionFlatStart + sectionRelStart;
|
|
4076
4132
|
const flatEnd = flatStart + spec.textToFind.length;
|
|
4077
4133
|
const startEntry = lookupFlatIndex(map, flatStart);
|
|
4078
4134
|
if (!startEntry) {
|
|
@@ -4116,16 +4172,29 @@ var RoomState = class {
|
|
|
4116
4172
|
/**
|
|
4117
4173
|
* Threads the agent has already written to (created a comment, added a
|
|
4118
4174
|
* suggestion, or replied). Once a thread is "active", subsequent remote
|
|
4119
|
-
* replies on it are surfaced to the model even if they don't
|
|
4120
|
-
*
|
|
4175
|
+
* replies on it are surfaced to the model even if they don't name the
|
|
4176
|
+
* agent — the conversation is already in progress, and requiring a
|
|
4121
4177
|
* re-mention every turn is bad UX.
|
|
4122
4178
|
*/
|
|
4123
4179
|
activeThreads = /* @__PURE__ */ new Set();
|
|
4180
|
+
/**
|
|
4181
|
+
* Timestamp (ms since epoch) of the most recent non-local transaction on
|
|
4182
|
+
* this doc. Initialized to construction time so a freshly-attached room
|
|
4183
|
+
* with an idle user bails out after the first full timeout window rather
|
|
4184
|
+
* than looking "fresh" forever. Bumped by `attachRemoteActivityTracker`
|
|
4185
|
+
* on every remote edit, comment, suggestion, or activity-feed write.
|
|
4186
|
+
*/
|
|
4187
|
+
_lastRemoteActivityAt = Date.now();
|
|
4124
4188
|
constructor(opts) {
|
|
4125
4189
|
this.roomId = opts.roomId;
|
|
4126
4190
|
this.actingAs = opts.actingAs;
|
|
4127
4191
|
this.identity = opts.identity;
|
|
4128
4192
|
this.watchMentions();
|
|
4193
|
+
attachRemoteActivityTracker(this.doc, {
|
|
4194
|
+
onActivity: (at) => {
|
|
4195
|
+
this._lastRemoteActivityAt = at;
|
|
4196
|
+
}
|
|
4197
|
+
});
|
|
4129
4198
|
this.provider = new YProvider(opts.serverHost, opts.roomId, this.doc, {
|
|
4130
4199
|
party: "composer-room",
|
|
4131
4200
|
connect: true,
|
|
@@ -4225,12 +4294,16 @@ var RoomState = class {
|
|
|
4225
4294
|
}
|
|
4226
4295
|
/**
|
|
4227
4296
|
* Mark a thread as active so subsequent remote replies on it surface as
|
|
4228
|
-
* mentions even without an explicit
|
|
4229
|
-
* handlers right after the agent creates or replies on a thread.
|
|
4297
|
+
* mentions even without an explicit mention of this agent. Called by MCP
|
|
4298
|
+
* write-tool handlers right after the agent creates or replies on a thread.
|
|
4230
4299
|
*/
|
|
4231
4300
|
markThreadActive(threadId) {
|
|
4232
4301
|
this.activeThreads.add(threadId);
|
|
4233
4302
|
}
|
|
4303
|
+
/** Timestamp (ms) of the most recent non-local transaction on this doc. */
|
|
4304
|
+
get lastRemoteActivityAt() {
|
|
4305
|
+
return this._lastRemoteActivityAt;
|
|
4306
|
+
}
|
|
4234
4307
|
enqueue(ev) {
|
|
4235
4308
|
const waiter = this.waiters.shift();
|
|
4236
4309
|
if (waiter) waiter(ev);
|
|
@@ -4241,20 +4314,60 @@ var RoomState = class {
|
|
|
4241
4314
|
enqueue: (ev) => this.enqueue(ev),
|
|
4242
4315
|
seen: this.seen,
|
|
4243
4316
|
activeThreads: this.activeThreads,
|
|
4244
|
-
identityUserId: this.identity.userId
|
|
4317
|
+
identityUserId: this.identity.userId,
|
|
4318
|
+
actingAs: this.actingAs,
|
|
4319
|
+
getSoloHumanAuthorId: () => this.computeSoloHumanAuthorId()
|
|
4245
4320
|
});
|
|
4246
4321
|
}
|
|
4322
|
+
/**
|
|
4323
|
+
* Read the provider's awareness map and decide whether the room is "solo"
|
|
4324
|
+
* right now — exactly one agent (us) and exactly one human. Returns the
|
|
4325
|
+
* sole human's `userId` in that case, else `undefined`. Any other shape
|
|
4326
|
+
* (multiple humans, multiple agents, empty, no `user` payload) returns
|
|
4327
|
+
* `undefined` so the observer stays silent unless the gate passes.
|
|
4328
|
+
*/
|
|
4329
|
+
computeSoloHumanAuthorId() {
|
|
4330
|
+
const states = this.provider.awareness.getStates();
|
|
4331
|
+
let humanCount = 0;
|
|
4332
|
+
let agentCount = 0;
|
|
4333
|
+
let soloHuman;
|
|
4334
|
+
for (const state of states.values()) {
|
|
4335
|
+
const user = state?.user;
|
|
4336
|
+
if (!user || typeof user.userId !== "string") continue;
|
|
4337
|
+
if (user.isAgent) {
|
|
4338
|
+
agentCount++;
|
|
4339
|
+
} else {
|
|
4340
|
+
humanCount++;
|
|
4341
|
+
soloHuman = user.userId;
|
|
4342
|
+
}
|
|
4343
|
+
}
|
|
4344
|
+
if (humanCount !== 1 || agentCount !== 1) return void 0;
|
|
4345
|
+
return soloHuman;
|
|
4346
|
+
}
|
|
4247
4347
|
};
|
|
4248
|
-
|
|
4249
|
-
|
|
4348
|
+
function buildActingAsMatcher(actingAs) {
|
|
4349
|
+
if (!actingAs) return () => false;
|
|
4350
|
+
const escaped = actingAs.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4351
|
+
const re = new RegExp(`@${escaped}(?![\\w])`, "i");
|
|
4352
|
+
return (text) => re.test(text);
|
|
4353
|
+
}
|
|
4354
|
+
function checkMentionsSidecar(mentions, identityUserId) {
|
|
4355
|
+
if (!Array.isArray(mentions) || mentions.length === 0) return "absent";
|
|
4356
|
+
if (identityUserId && mentions.includes(identityUserId)) return "hit";
|
|
4357
|
+
return "miss";
|
|
4358
|
+
}
|
|
4359
|
+
var ANY_AT_MENTION_RE = /@\w/;
|
|
4250
4360
|
function attachMentionObserver(doc, opts) {
|
|
4251
4361
|
const seen = opts.seen ?? /* @__PURE__ */ new Set();
|
|
4252
4362
|
const activeThreads = opts.activeThreads ?? /* @__PURE__ */ new Set();
|
|
4253
4363
|
const enqueue = opts.enqueue;
|
|
4254
4364
|
const identityUserId = opts.identityUserId;
|
|
4365
|
+
const hasActingAsMention = buildActingAsMatcher(opts.actingAs);
|
|
4366
|
+
const getSoloHumanAuthorId = opts.getSoloHumanAuthorId ?? (() => void 0);
|
|
4255
4367
|
const scan = (kind, threadId, entry, isLocal) => {
|
|
4256
4368
|
if (!entry || typeof entry !== "object") return;
|
|
4257
4369
|
const record = entry;
|
|
4370
|
+
const bodyAuthorUserId = typeof record.authorUserId === "string" ? record.authorUserId : void 0;
|
|
4258
4371
|
const replies = Array.isArray(record.replies) ? record.replies : [];
|
|
4259
4372
|
let lastAgentIdx = -1;
|
|
4260
4373
|
if (identityUserId !== void 0) {
|
|
@@ -4268,7 +4381,9 @@ function attachMentionObserver(doc, opts) {
|
|
|
4268
4381
|
}
|
|
4269
4382
|
const bodyAnswered = lastAgentIdx >= 0;
|
|
4270
4383
|
const body = typeof record.text === "string" ? record.text : typeof record.replacementText === "string" ? record.replacementText : "";
|
|
4271
|
-
|
|
4384
|
+
const bodySidecar = checkMentionsSidecar(record.mentions, identityUserId);
|
|
4385
|
+
const bodyHit = bodySidecar === "hit" || bodySidecar === "absent" && hasActingAsMention(body);
|
|
4386
|
+
if (bodyHit && !seen.has(threadId)) {
|
|
4272
4387
|
seen.add(threadId);
|
|
4273
4388
|
if (!isLocal && !bodyAnswered) {
|
|
4274
4389
|
enqueue({
|
|
@@ -4280,6 +4395,19 @@ function attachMentionObserver(doc, opts) {
|
|
|
4280
4395
|
...resolveAnchoredContext(doc, record.anchorFrom, record.anchorTo)
|
|
4281
4396
|
});
|
|
4282
4397
|
}
|
|
4398
|
+
} else if (!seen.has(threadId) && !isLocal && !bodyAnswered && bodySidecar !== "miss" && !ANY_AT_MENTION_RE.test(body)) {
|
|
4399
|
+
const soloHuman = getSoloHumanAuthorId();
|
|
4400
|
+
if (soloHuman && bodyAuthorUserId === soloHuman) {
|
|
4401
|
+
seen.add(threadId);
|
|
4402
|
+
enqueue({
|
|
4403
|
+
kind: "mention",
|
|
4404
|
+
threadId,
|
|
4405
|
+
threadKind: kind,
|
|
4406
|
+
threadText: body,
|
|
4407
|
+
reason: "solo_room",
|
|
4408
|
+
...resolveAnchoredContext(doc, record.anchorFrom, record.anchorTo)
|
|
4409
|
+
});
|
|
4410
|
+
}
|
|
4283
4411
|
}
|
|
4284
4412
|
for (let i = 0; i < replies.length; i++) {
|
|
4285
4413
|
const r = replies[i];
|
|
@@ -4292,16 +4420,25 @@ function attachMentionObserver(doc, opts) {
|
|
|
4292
4420
|
seen.add(key);
|
|
4293
4421
|
if (isLocal) continue;
|
|
4294
4422
|
if (i <= lastAgentIdx) continue;
|
|
4295
|
-
const
|
|
4423
|
+
const replySidecar = checkMentionsSidecar(reply.mentions, identityUserId);
|
|
4424
|
+
const isDirect = replySidecar === "hit" || replySidecar === "absent" && hasActingAsMention(reply.text);
|
|
4296
4425
|
const inActiveThread = activeThreads.has(threadId);
|
|
4297
|
-
|
|
4426
|
+
let reason = isDirect ? "direct_mention" : inActiveThread ? "active_thread" : null;
|
|
4427
|
+
if (!reason) {
|
|
4428
|
+
const replyAuthor = typeof reply.authorUserId === "string" ? reply.authorUserId : void 0;
|
|
4429
|
+
const soloHuman = getSoloHumanAuthorId();
|
|
4430
|
+
if (replySidecar !== "miss" && !ANY_AT_MENTION_RE.test(reply.text) && soloHuman && replyAuthor === soloHuman) {
|
|
4431
|
+
reason = "solo_room";
|
|
4432
|
+
}
|
|
4433
|
+
}
|
|
4434
|
+
if (!reason) continue;
|
|
4298
4435
|
enqueue({
|
|
4299
4436
|
kind: "mention",
|
|
4300
4437
|
threadId,
|
|
4301
4438
|
threadKind: kind,
|
|
4302
4439
|
threadText: reply.text,
|
|
4303
4440
|
replyId: reply.id,
|
|
4304
|
-
reason
|
|
4441
|
+
reason,
|
|
4305
4442
|
...resolveAnchoredContext(doc, record.anchorFrom, record.anchorTo)
|
|
4306
4443
|
});
|
|
4307
4444
|
}
|
|
@@ -4320,6 +4457,12 @@ function attachMentionObserver(doc, opts) {
|
|
|
4320
4457
|
function hashState(doc) {
|
|
4321
4458
|
return Buffer.from(Y4.encodeStateVector(doc)).toString("base64");
|
|
4322
4459
|
}
|
|
4460
|
+
function attachRemoteActivityTracker(doc, opts) {
|
|
4461
|
+
const now = opts.now ?? (() => Date.now());
|
|
4462
|
+
doc.on("afterTransaction", (tr) => {
|
|
4463
|
+
if (!tr.local) opts.onActivity(now());
|
|
4464
|
+
});
|
|
4465
|
+
}
|
|
4323
4466
|
|
|
4324
4467
|
// src/identity.ts
|
|
4325
4468
|
import * as fs from "fs/promises";
|
|
@@ -4352,7 +4495,9 @@ function pickColor() {
|
|
|
4352
4495
|
function isValidIdentity(value) {
|
|
4353
4496
|
if (typeof value !== "object" || value === null) return false;
|
|
4354
4497
|
const v = value;
|
|
4355
|
-
|
|
4498
|
+
if (typeof v.userId !== "string" || typeof v.color !== "string") return false;
|
|
4499
|
+
if (v.name !== void 0 && typeof v.name !== "string") return false;
|
|
4500
|
+
return true;
|
|
4356
4501
|
}
|
|
4357
4502
|
async function loadOrCreateIdentity(dir) {
|
|
4358
4503
|
const filePath = path.join(dir, FILE_NAME);
|
|
@@ -4360,18 +4505,16 @@ async function loadOrCreateIdentity(dir) {
|
|
|
4360
4505
|
const raw = await fs.readFile(filePath, "utf8");
|
|
4361
4506
|
const parsed = JSON.parse(raw);
|
|
4362
4507
|
if (isValidIdentity(parsed)) {
|
|
4363
|
-
|
|
4364
|
-
return { userId: parsed.userId, color: parsed.color };
|
|
4365
|
-
}
|
|
4366
|
-
const migrated = {
|
|
4508
|
+
const base = {
|
|
4367
4509
|
userId: parsed.userId,
|
|
4368
|
-
color: pickColor()
|
|
4510
|
+
color: isPaletteColor(parsed.color) ? parsed.color : pickColor(),
|
|
4511
|
+
...parsed.name ? { name: parsed.name } : {}
|
|
4369
4512
|
};
|
|
4370
|
-
|
|
4371
|
-
|
|
4372
|
-
|
|
4373
|
-
|
|
4374
|
-
return
|
|
4513
|
+
if (base.color === parsed.color) {
|
|
4514
|
+
return base;
|
|
4515
|
+
}
|
|
4516
|
+
await saveIdentity(dir, base);
|
|
4517
|
+
return base;
|
|
4375
4518
|
}
|
|
4376
4519
|
} catch (err) {
|
|
4377
4520
|
const code = err.code;
|
|
@@ -4382,11 +4525,15 @@ async function loadOrCreateIdentity(dir) {
|
|
|
4382
4525
|
userId: nanoid2(),
|
|
4383
4526
|
color: pickColor()
|
|
4384
4527
|
};
|
|
4528
|
+
await saveIdentity(dir, identity);
|
|
4529
|
+
return identity;
|
|
4530
|
+
}
|
|
4531
|
+
async function saveIdentity(dir, identity) {
|
|
4532
|
+
const filePath = path.join(dir, FILE_NAME);
|
|
4385
4533
|
await fs.mkdir(dir, { recursive: true });
|
|
4386
4534
|
await fs.writeFile(filePath, JSON.stringify(identity, null, 2), {
|
|
4387
4535
|
mode: FILE_MODE
|
|
4388
4536
|
});
|
|
4389
|
-
return identity;
|
|
4390
4537
|
}
|
|
4391
4538
|
|
|
4392
4539
|
// src/mdToFragment.ts
|
|
@@ -4464,6 +4611,30 @@ async function getIdentity() {
|
|
|
4464
4611
|
}
|
|
4465
4612
|
return identityCache;
|
|
4466
4613
|
}
|
|
4614
|
+
async function resolveActingAs(actingAsArg) {
|
|
4615
|
+
const identity = await getIdentity();
|
|
4616
|
+
if (identity.name) return { actingAs: identity.name, isFirstRun: false };
|
|
4617
|
+
if (!actingAsArg) {
|
|
4618
|
+
throw new Error(
|
|
4619
|
+
[
|
|
4620
|
+
"First Composer room on this machine \u2014 you need a persistent name before proceeding. STOP and ask the user what to call you.",
|
|
4621
|
+
"",
|
|
4622
|
+
"Offer ONE suggested default they can accept with a tap:",
|
|
4623
|
+
` - If you know the user's first name from conversation context, suggest "<FirstName>'s Agent" (e.g. "Josh's Agent").`,
|
|
4624
|
+
" - Otherwise pick something playful from outside the model-family space (e.g. Monty, Gerty, Rosie, Otto, Pip). Do NOT suggest Claude / Gemini / GPT / Sonnet / Opus / Haiku / other model names.",
|
|
4625
|
+
"",
|
|
4626
|
+
`Phrase it like: "I'll go by <suggested name> in Composer docs \u2014 sound good, or pick your own?"`,
|
|
4627
|
+
"",
|
|
4628
|
+
"Retry with their answer as `actingAs`. It persists to ~/.composer/user.json and is reused for every future room on this machine, so only ask once."
|
|
4629
|
+
].join("\n")
|
|
4630
|
+
);
|
|
4631
|
+
}
|
|
4632
|
+
const next = { ...identity, name: actingAsArg };
|
|
4633
|
+
await saveIdentity(COMPOSER_DIR2, next);
|
|
4634
|
+
identityCache = next;
|
|
4635
|
+
log("agent name persisted", { name: actingAsArg, userId: identity.userId });
|
|
4636
|
+
return { actingAs: actingAsArg, isFirstRun: true };
|
|
4637
|
+
}
|
|
4467
4638
|
function getOrError(roomId) {
|
|
4468
4639
|
const state = rooms.get(roomId);
|
|
4469
4640
|
if (!state) throw new Error(`not attached to room: ${roomId}`);
|
|
@@ -4482,7 +4653,7 @@ function parseRoomIdFromUrl(url) {
|
|
|
4482
4653
|
var TOOL_DEFS = [
|
|
4483
4654
|
{
|
|
4484
4655
|
name: "composer_create_room",
|
|
4485
|
-
description: "Create a new Composer room. Returns { roomId, browserUrl, snapshot }. Seed the doc by passing either `seedMarkdownPath` (absolute path to a markdown file on disk \u2014 preferred when the markdown already lives in a file, avoids streaming the content through the model) OR `seedMarkdown` (inline string, for content you generated in-turn). Pass exactly one. The seed file is read once at creation and never written back \u2014 edits in Composer stay in the room.",
|
|
4656
|
+
description: "Create a new Composer room. Returns { roomId, browserUrl, snapshot, actingAs, step1_sayToUser, step2_callTool }. EXECUTE step1 AND step2 IN ORDER before ending your turn: first output `step1_sayToUser` (it contains the browserUrl \u2014 the user needs that link or they can't open the doc), then call the tool named in `step2_callTool`. Skipping step2 means your agent is silent in the room; skipping step1 means the user has no way in. Seed the doc by passing either `seedMarkdownPath` (absolute path to a markdown file on disk \u2014 preferred when the markdown already lives in a file, avoids streaming the content through the model) OR `seedMarkdown` (inline string, for content you generated in-turn). Pass exactly one. The seed file is read once at creation and never written back \u2014 edits in Composer stay in the room.",
|
|
4486
4657
|
inputSchema: {
|
|
4487
4658
|
type: "object",
|
|
4488
4659
|
properties: {
|
|
@@ -4497,22 +4668,24 @@ var TOOL_DEFS = [
|
|
|
4497
4668
|
},
|
|
4498
4669
|
actingAs: {
|
|
4499
4670
|
type: "string",
|
|
4500
|
-
description:
|
|
4671
|
+
description: 'Your display name in the doc. FIRST-RUN FLOW: if you haven\'t saved a name yet, do NOT guess \u2014 call without `actingAs`, the MCP returns an error that instructs you to ask the user. Ask them ("what should I be called in Composer?") and retry with their answer. The name persists to ~/.composer/user.json. OPTIONAL on subsequent calls \u2014 the stored name wins and any value here is ignored.'
|
|
4501
4672
|
}
|
|
4502
|
-
}
|
|
4503
|
-
required: ["actingAs"]
|
|
4673
|
+
}
|
|
4504
4674
|
}
|
|
4505
4675
|
},
|
|
4506
4676
|
{
|
|
4507
4677
|
name: "composer_join_room",
|
|
4508
|
-
description: "Join an existing Composer room by browser URL. Returns
|
|
4678
|
+
description: "Join an existing Composer room by browser URL. Returns { roomId, browserUrl, snapshot, actingAs, step1_sayToUser, step2_callTool }. EXECUTE step1 AND step2 IN ORDER before ending your turn: first output `step1_sayToUser` (it contains the browserUrl so the user can confirm which doc you joined), then call the tool named in `step2_callTool`. Skipping step2 means your agent is silent in the room; skipping step1 leaves the user guessing which doc you landed in.",
|
|
4509
4679
|
inputSchema: {
|
|
4510
4680
|
type: "object",
|
|
4511
4681
|
properties: {
|
|
4512
4682
|
url: { type: "string" },
|
|
4513
|
-
actingAs: {
|
|
4683
|
+
actingAs: {
|
|
4684
|
+
type: "string",
|
|
4685
|
+
description: "Your display name in the doc. FIRST-RUN FLOW: if you haven't saved a name yet, call without `actingAs`, get the error, ASK THE USER what to be called, then retry with their answer. Persists to ~/.composer/user.json. OPTIONAL on subsequent calls \u2014 the stored name wins."
|
|
4686
|
+
}
|
|
4514
4687
|
},
|
|
4515
|
-
required: ["url"
|
|
4688
|
+
required: ["url"]
|
|
4516
4689
|
}
|
|
4517
4690
|
},
|
|
4518
4691
|
{
|
|
@@ -4526,12 +4699,12 @@ var TOOL_DEFS = [
|
|
|
4526
4699
|
},
|
|
4527
4700
|
{
|
|
4528
4701
|
name: "composer_next_event",
|
|
4529
|
-
description: "Block
|
|
4702
|
+
description: "Block for up to `timeoutSec` (default 600 / 10 min) waiting for a remote event. Returns `{ kind: 'mention' | 'timeout', ... }`. **The monitor loop is always-on** \u2014 every return carries a directive you must follow without waiting for user input. On `mention`, the `reason` is one of: `direct_mention` (sidecar or text named you \u2014 always reply), `active_thread` (plain reply on a thread you're already in \u2014 reply if the content invites one), or `solo_room` (you're alone with one human who didn't explicitly tag anyone \u2014 default to a helpful reply, but skip if the text reads like a note-to-self, acknowledgement, or aside). Handle the event, then execute the return's `requiredNextToolCall`. On `timeout`, `recentActivity` tells you whether to keep monitoring or exit with the goodbye line from `userMessage`.",
|
|
4530
4703
|
inputSchema: {
|
|
4531
4704
|
type: "object",
|
|
4532
4705
|
properties: {
|
|
4533
4706
|
roomId: { type: "string" },
|
|
4534
|
-
timeoutSec: { type: "number", default:
|
|
4707
|
+
timeoutSec: { type: "number", default: 600 }
|
|
4535
4708
|
},
|
|
4536
4709
|
required: ["roomId"]
|
|
4537
4710
|
}
|
|
@@ -4557,9 +4730,21 @@ var TOOL_DEFS = [
|
|
|
4557
4730
|
required: ["roomId"]
|
|
4558
4731
|
}
|
|
4559
4732
|
},
|
|
4733
|
+
{
|
|
4734
|
+
name: "composer_get_thread",
|
|
4735
|
+
description: "Read the full state of a comment or suggestion thread by id. Returns the body (or replacementText), every reply (with author, text, timestamp, optional mentions sidecar), the thread's anchored text, and the containing section as markdown. Use when `composer_next_event` surfaces an event and you need history: the event only gives you the triggering message, so call this to catch up on everything said before you were tagged.",
|
|
4736
|
+
inputSchema: {
|
|
4737
|
+
type: "object",
|
|
4738
|
+
properties: {
|
|
4739
|
+
roomId: { type: "string" },
|
|
4740
|
+
threadId: { type: "string" }
|
|
4741
|
+
},
|
|
4742
|
+
required: ["roomId", "threadId"]
|
|
4743
|
+
}
|
|
4744
|
+
},
|
|
4560
4745
|
{
|
|
4561
4746
|
name: "composer_add_comment",
|
|
4562
|
-
description: "Post a comment anchored to a text span. Anchor is { headingId, textToFind, occurrence? }. Returns { id } on success or an isError result if the anchor cannot be resolved.",
|
|
4747
|
+
description: "Post a new top-level comment anchored to a text span anywhere in the doc. Anchor is { headingId, textToFind, occurrence? }. Use this to flag something the user didn't ask about \u2014 cross-referencing related sections, raising a concern elsewhere in the doc, or seeding a thread on a new span. Use `composer_reply_comment` instead when continuing an existing thread. Returns { id } on success or an isError result if the anchor cannot be resolved.",
|
|
4563
4748
|
inputSchema: {
|
|
4564
4749
|
type: "object",
|
|
4565
4750
|
properties: {
|
|
@@ -4593,7 +4778,7 @@ var TOOL_DEFS = [
|
|
|
4593
4778
|
},
|
|
4594
4779
|
{
|
|
4595
4780
|
name: "composer_add_suggestion",
|
|
4596
|
-
description: "Post a text replacement suggestion.
|
|
4781
|
+
description: "Post a text replacement suggestion. A suggestion can target ANY span anywhere in the doc \u2014 not just the span of the thread that triggered you. Pick exactly one of:\n - `fromThreadId` \u2014 inherit the source thread's exact stored anchor. Right when the user's request is scoped to what they selected (the common case: 'rewrite this', 'make this shorter').\n - `anchor` \u2014 specify a span yourself via `{ headingId, textToFind, occurrence? }`. Use this when the user's request targets different text ('also update the intro', 'the bullet list in Section 3 is stale') OR for proactive suggestions with no source thread.\n**Anchor = what gets deleted.** Your `textToFind` is literally cut when the user accepts and `replacementText` is inserted in its place. Anchor the whole unit you're changing (full sentence including terminal punctuation; full list item text; full paragraph), match your replacementText's shape (inline for mid-paragraph edits, full markdown block for block replacements), end replacement at the same boundary as the anchor, and include any formatting you want preserved in the replacement itself \u2014 the anchor's bold / link / heading level is gone on accept. A too-narrow or mid-token anchor leaves broken spacing or smashed-together words. See SKILL.md 'Pick the right span' for the full rubric.\n**Ripple coverage is your responsibility.** If the change requires edits nearby or elsewhere (enumeration counts, cross-references, subject/verb agreement, restated facts), call this tool MULTIPLE times in the same turn \u2014 one suggestion per span \u2014 so accepting leaves the doc correct. If you're unsure whether ripples exist elsewhere in the doc, call `composer_get_full_doc` first. Returns { id } on success or an isError result if the anchor cannot be resolved.",
|
|
4597
4782
|
inputSchema: {
|
|
4598
4783
|
type: "object",
|
|
4599
4784
|
properties: {
|
|
@@ -4688,9 +4873,25 @@ function asAnchor(value) {
|
|
|
4688
4873
|
}
|
|
4689
4874
|
return { headingId, textToFind, occurrence };
|
|
4690
4875
|
}
|
|
4876
|
+
function buildMonitorHandoff(roomId, browserUrl, actingAs, opts) {
|
|
4877
|
+
const mention = `@${actingAs}`;
|
|
4878
|
+
const step1_sayToUser = opts.isFirstRun ? `${browserUrl}
|
|
4879
|
+
|
|
4880
|
+
First time using Composer? Here's how it works: I'm monitoring this doc right here in your terminal. Reach me anytime by tagging ${mention} in any comment or reply \u2014 I'll answer right in the thread, and I can also post suggestions you accept or reject inline.` : `${browserUrl}
|
|
4881
|
+
|
|
4882
|
+
I'm monitoring this doc from the terminal \u2014 tag me with ${mention} in any comment and I'll reply right there.`;
|
|
4883
|
+
return {
|
|
4884
|
+
step1_sayToUser,
|
|
4885
|
+
step2_callTool: {
|
|
4886
|
+
tool: "composer_next_event",
|
|
4887
|
+
args: { roomId },
|
|
4888
|
+
why: "MANDATORY and STRICTLY AFTER step 1. First output `step1_sayToUser` to the user \u2014 they need the URL to open the doc \u2014 THEN call this tool. The room is attached but NOT being watched until you enter the composer_next_event loop. Two failure modes to avoid: (a) outputting 'I'm monitoring' without calling this tool = silent agent; (b) calling this tool without first outputting the URL = user has no way in."
|
|
4889
|
+
}
|
|
4890
|
+
};
|
|
4891
|
+
}
|
|
4691
4892
|
async function handleCreateRoom(args) {
|
|
4692
4893
|
const a = asObject(args);
|
|
4693
|
-
const
|
|
4894
|
+
const actingAsArg = asOptionalString(a.actingAs, "actingAs");
|
|
4694
4895
|
const seedMarkdownInline = asOptionalString(a.seedMarkdown, "seedMarkdown");
|
|
4695
4896
|
const seedMarkdownPath = asOptionalString(
|
|
4696
4897
|
a.seedMarkdownPath,
|
|
@@ -4716,8 +4917,10 @@ async function handleCreateRoom(args) {
|
|
|
4716
4917
|
return errorResult(`failed to read seedMarkdownPath: ${message}`);
|
|
4717
4918
|
}
|
|
4718
4919
|
}
|
|
4920
|
+
const { actingAs, isFirstRun } = await resolveActingAs(actingAsArg);
|
|
4719
4921
|
const identity = await getIdentity();
|
|
4720
4922
|
const roomId = nanoid3(10);
|
|
4923
|
+
const browserUrl = browserUrlFor(roomId);
|
|
4721
4924
|
const state = new RoomState({
|
|
4722
4925
|
roomId,
|
|
4723
4926
|
serverHost: SERVER_HOST,
|
|
@@ -4729,22 +4932,42 @@ async function handleCreateRoom(args) {
|
|
|
4729
4932
|
writeMarkdownToFragment(state.doc.getXmlFragment("default"), seedMarkdown);
|
|
4730
4933
|
}
|
|
4731
4934
|
rooms.set(roomId, state);
|
|
4935
|
+
log(`composer room created \u2192 ${browserUrl}`, { roomId, actingAs });
|
|
4732
4936
|
return okResult({
|
|
4733
4937
|
roomId,
|
|
4734
|
-
browserUrl
|
|
4735
|
-
|
|
4938
|
+
browserUrl,
|
|
4939
|
+
actingAs,
|
|
4940
|
+
snapshot: state.snapshot(),
|
|
4941
|
+
...buildMonitorHandoff(roomId, browserUrl, actingAs, { isFirstRun })
|
|
4736
4942
|
});
|
|
4737
4943
|
}
|
|
4738
4944
|
async function handleJoinRoom(args) {
|
|
4739
4945
|
const a = asObject(args);
|
|
4740
4946
|
const url = asString(a.url, "url");
|
|
4741
|
-
const
|
|
4947
|
+
const actingAsArg = asOptionalString(a.actingAs, "actingAs");
|
|
4742
4948
|
const roomId = parseRoomIdFromUrl(url);
|
|
4743
|
-
const
|
|
4949
|
+
const browserUrl = browserUrlFor(roomId);
|
|
4744
4950
|
const existing = rooms.get(roomId);
|
|
4745
4951
|
if (existing) {
|
|
4746
|
-
|
|
4952
|
+
log(`composer room rejoined \u2192 ${browserUrl}`, {
|
|
4953
|
+
roomId,
|
|
4954
|
+
actingAs: existing.actingAs
|
|
4955
|
+
});
|
|
4956
|
+
return okResult({
|
|
4957
|
+
roomId,
|
|
4958
|
+
browserUrl,
|
|
4959
|
+
actingAs: existing.actingAs,
|
|
4960
|
+
snapshot: existing.snapshot(),
|
|
4961
|
+
// Rejoining a room already attached in this process is never a
|
|
4962
|
+
// first-run situation — we already wrote a name to disk at least
|
|
4963
|
+
// once in this process lifetime to have created `existing`.
|
|
4964
|
+
...buildMonitorHandoff(roomId, browserUrl, existing.actingAs, {
|
|
4965
|
+
isFirstRun: false
|
|
4966
|
+
})
|
|
4967
|
+
});
|
|
4747
4968
|
}
|
|
4969
|
+
const { actingAs, isFirstRun } = await resolveActingAs(actingAsArg);
|
|
4970
|
+
const identity = await getIdentity();
|
|
4748
4971
|
const state = new RoomState({
|
|
4749
4972
|
roomId,
|
|
4750
4973
|
serverHost: SERVER_HOST,
|
|
@@ -4753,7 +4976,14 @@ async function handleJoinRoom(args) {
|
|
|
4753
4976
|
});
|
|
4754
4977
|
await state.waitForInitialSync();
|
|
4755
4978
|
rooms.set(roomId, state);
|
|
4756
|
-
|
|
4979
|
+
log(`composer room joined \u2192 ${browserUrl}`, { roomId, actingAs });
|
|
4980
|
+
return okResult({
|
|
4981
|
+
roomId,
|
|
4982
|
+
browserUrl,
|
|
4983
|
+
actingAs,
|
|
4984
|
+
snapshot: state.snapshot(),
|
|
4985
|
+
...buildMonitorHandoff(roomId, browserUrl, actingAs, { isFirstRun })
|
|
4986
|
+
});
|
|
4757
4987
|
}
|
|
4758
4988
|
function handleAttachRoom(args) {
|
|
4759
4989
|
const a = asObject(args);
|
|
@@ -4761,13 +4991,46 @@ function handleAttachRoom(args) {
|
|
|
4761
4991
|
const state = getOrError(roomId);
|
|
4762
4992
|
return okResult({ roomId, snapshot: state.snapshot() });
|
|
4763
4993
|
}
|
|
4994
|
+
var ACTIVITY_WINDOW_MS = 10 * 60 * 1e3;
|
|
4995
|
+
var LEAVE_MESSAGE = "I've left the document. You can ask me to rejoin anytime and I'll continue replying.";
|
|
4764
4996
|
async function handleNextEvent(args) {
|
|
4765
4997
|
const a = asObject(args);
|
|
4766
4998
|
const roomId = asString(a.roomId, "roomId");
|
|
4767
|
-
const timeoutSec = typeof a.timeoutSec === "number" && Number.isFinite(a.timeoutSec) ? a.timeoutSec :
|
|
4999
|
+
const timeoutSec = typeof a.timeoutSec === "number" && Number.isFinite(a.timeoutSec) ? a.timeoutSec : 600;
|
|
4768
5000
|
const state = getOrError(roomId);
|
|
4769
5001
|
const event = await state.nextEvent(timeoutSec * 1e3);
|
|
4770
|
-
|
|
5002
|
+
if (event.kind === "mention") {
|
|
5003
|
+
return okResult({
|
|
5004
|
+
...event,
|
|
5005
|
+
requiredNextToolCall: {
|
|
5006
|
+
tool: "composer_next_event",
|
|
5007
|
+
args: { roomId },
|
|
5008
|
+
why: "MANDATORY \u2014 call this immediately after you've posted your reply / suggestion / resolve. The monitor loop is always-on; ending your turn without this call drops you out of the room and the user will think you stopped listening."
|
|
5009
|
+
}
|
|
5010
|
+
});
|
|
5011
|
+
}
|
|
5012
|
+
const msSinceActivity = Date.now() - state.lastRemoteActivityAt;
|
|
5013
|
+
const recentActivity = msSinceActivity < ACTIVITY_WINDOW_MS;
|
|
5014
|
+
const minutesSince = Math.max(1, Math.round(msSinceActivity / 6e4));
|
|
5015
|
+
if (recentActivity) {
|
|
5016
|
+
return okResult({
|
|
5017
|
+
kind: "timeout",
|
|
5018
|
+
recentActivity: true,
|
|
5019
|
+
secondsSinceActivity: Math.round(msSinceActivity / 1e3),
|
|
5020
|
+
requiredNextToolCall: {
|
|
5021
|
+
tool: "composer_next_event",
|
|
5022
|
+
args: { roomId },
|
|
5023
|
+
why: `MANDATORY \u2014 call this immediately. The user was active in the doc ~${minutesSince} minute${minutesSince === 1 ? "" : "s"} ago; do NOT exit the monitor loop.`
|
|
5024
|
+
}
|
|
5025
|
+
});
|
|
5026
|
+
}
|
|
5027
|
+
return okResult({
|
|
5028
|
+
kind: "timeout",
|
|
5029
|
+
recentActivity: false,
|
|
5030
|
+
secondsSinceActivity: Math.round(msSinceActivity / 1e3),
|
|
5031
|
+
userMessage: LEAVE_MESSAGE,
|
|
5032
|
+
instruction: `Say \`userMessage\` EXACTLY as written \u2014 do not paraphrase the goodbye. Then stop calling composer_next_event until the user asks you to rejoin. No requiredNextToolCall field: the loop is intentionally over.`
|
|
5033
|
+
});
|
|
4771
5034
|
}
|
|
4772
5035
|
function handleGetSection(args) {
|
|
4773
5036
|
const a = asObject(args);
|
|
@@ -4783,6 +5046,55 @@ function handleGetFullDoc(args) {
|
|
|
4783
5046
|
const state = getOrError(roomId);
|
|
4784
5047
|
return okResult({ markdown: serializeDocAsMarkdown(state.doc) });
|
|
4785
5048
|
}
|
|
5049
|
+
function handleGetThread(args) {
|
|
5050
|
+
const a = asObject(args);
|
|
5051
|
+
const roomId = asString(a.roomId, "roomId");
|
|
5052
|
+
const threadId = asString(a.threadId, "threadId");
|
|
5053
|
+
const state = getOrError(roomId);
|
|
5054
|
+
const commentRaw = state.doc.getMap("comments").get(threadId);
|
|
5055
|
+
const suggestionRaw = state.doc.getMap("suggestions").get(threadId);
|
|
5056
|
+
const raw = commentRaw ?? suggestionRaw;
|
|
5057
|
+
if (!raw) {
|
|
5058
|
+
return errorResult(`thread not found: ${threadId}`);
|
|
5059
|
+
}
|
|
5060
|
+
const kind = commentRaw ? "comment" : "suggestion";
|
|
5061
|
+
const anchoredContext = resolveAnchoredContext(
|
|
5062
|
+
state.doc,
|
|
5063
|
+
raw.anchorFrom,
|
|
5064
|
+
raw.anchorTo
|
|
5065
|
+
);
|
|
5066
|
+
const replies = Array.isArray(raw.replies) ? raw.replies : [];
|
|
5067
|
+
const shapedReplies = replies.filter(
|
|
5068
|
+
(r) => !!r && typeof r === "object" && typeof r.id === "string" && typeof r.text === "string"
|
|
5069
|
+
).map((r) => ({
|
|
5070
|
+
id: r.id,
|
|
5071
|
+
text: r.text,
|
|
5072
|
+
authorName: typeof r.authorName === "string" ? r.authorName : void 0,
|
|
5073
|
+
authorUserId: typeof r.authorUserId === "string" ? r.authorUserId : void 0,
|
|
5074
|
+
authorIsAgent: typeof r.authorIsAgent === "boolean" ? r.authorIsAgent : void 0,
|
|
5075
|
+
mentions: Array.isArray(r.mentions) ? r.mentions.filter((m) => typeof m === "string") : void 0,
|
|
5076
|
+
createdAt: typeof r.createdAt === "number" ? r.createdAt : void 0
|
|
5077
|
+
}));
|
|
5078
|
+
return okResult({
|
|
5079
|
+
threadId,
|
|
5080
|
+
kind,
|
|
5081
|
+
body: typeof raw.text === "string" ? raw.text : void 0,
|
|
5082
|
+
replacementText: kind === "suggestion" && typeof raw.replacementText === "string" ? raw.replacementText : void 0,
|
|
5083
|
+
originalText: kind === "suggestion" && typeof raw.originalText === "string" ? raw.originalText : void 0,
|
|
5084
|
+
authorName: typeof raw.authorName === "string" ? raw.authorName : void 0,
|
|
5085
|
+
authorUserId: typeof raw.authorUserId === "string" ? raw.authorUserId : void 0,
|
|
5086
|
+
authorIsAgent: typeof raw.authorIsAgent === "boolean" ? raw.authorIsAgent : void 0,
|
|
5087
|
+
createdAt: typeof raw.createdAt === "number" ? raw.createdAt : void 0,
|
|
5088
|
+
resolved: kind === "comment" && typeof raw.resolved === "boolean" ? raw.resolved : void 0,
|
|
5089
|
+
status: kind === "suggestion" && (raw.status === "pending" || raw.status === "accepted" || raw.status === "rejected") ? raw.status : void 0,
|
|
5090
|
+
mentions: Array.isArray(raw.mentions) ? raw.mentions.filter((m) => typeof m === "string") : void 0,
|
|
5091
|
+
anchoredText: anchoredContext.anchoredText,
|
|
5092
|
+
headingId: anchoredContext.headingId,
|
|
5093
|
+
headingText: anchoredContext.headingText,
|
|
5094
|
+
sectionMarkdown: anchoredContext.sectionMarkdown,
|
|
5095
|
+
replies: shapedReplies
|
|
5096
|
+
});
|
|
5097
|
+
}
|
|
4786
5098
|
function handleAddComment(args) {
|
|
4787
5099
|
const a = asObject(args);
|
|
4788
5100
|
const roomId = asString(a.roomId, "roomId");
|
|
@@ -4958,6 +5270,8 @@ async function dispatchTool(name, args) {
|
|
|
4958
5270
|
return handleGetSection(args);
|
|
4959
5271
|
case "composer_get_full_doc":
|
|
4960
5272
|
return handleGetFullDoc(args);
|
|
5273
|
+
case "composer_get_thread":
|
|
5274
|
+
return handleGetThread(args);
|
|
4961
5275
|
case "composer_add_comment":
|
|
4962
5276
|
return handleAddComment(args);
|
|
4963
5277
|
case "composer_reply_comment":
|
package/dist/cli.js
CHANGED
package/dist/mcp.js
CHANGED
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -12,8 +12,37 @@ to create, join, monitor, or act in a Composer doc.
|
|
|
12
12
|
|
|
13
13
|
### 1. Create
|
|
14
14
|
Triggers: "send this markdown to Composer", "make a Composer doc with this".
|
|
15
|
-
Action: call `composer_create_room({
|
|
16
|
-
|
|
15
|
+
Action: call `composer_create_room({ ... })`.
|
|
16
|
+
|
|
17
|
+
**First run only — ask the user what to call you.** If you have no saved
|
|
18
|
+
name on this machine, the MCP returns an error instructing you to stop
|
|
19
|
+
and ask. Offer one suggested default they can accept with a tap:
|
|
20
|
+
|
|
21
|
+
- If you know the user's first name, suggest `"<FirstName>'s Agent"`
|
|
22
|
+
(e.g. `"Josh's Agent"`).
|
|
23
|
+
- Otherwise suggest something playful that isn't a model family — `Monty`,
|
|
24
|
+
`Gerty`, `Rosie`, `Otto`, `Pip`. Do **not** suggest Claude, Gemini,
|
|
25
|
+
Sonnet, Opus, Haiku, GPT, or any other model name.
|
|
26
|
+
|
|
27
|
+
Phrase it like: *"I'll go by Monty in Composer docs — sound good, or pick
|
|
28
|
+
your own?"* Retry the tool call with their answer as `actingAs`. It
|
|
29
|
+
persists to `~/.composer/user.json` and is reused forever.
|
|
30
|
+
|
|
31
|
+
**On success** (first run or any subsequent run), the return gives you
|
|
32
|
+
two ordered steps — the field names encode the order:
|
|
33
|
+
|
|
34
|
+
1. `step1_sayToUser` — output this FIRST. It always starts with the
|
|
35
|
+
`browserUrl` because the user needs the link to open the doc; it
|
|
36
|
+
also carries the `@<your-name>` tagging hint. Relay it; you can
|
|
37
|
+
paraphrase lightly but do not drop the URL or the mention syntax.
|
|
38
|
+
2. `step2_callTool` — a structured `{ tool, args, why }` directive for
|
|
39
|
+
the follow-up `composer_next_event` call. Execute it AFTER step 1,
|
|
40
|
+
before ending your turn.
|
|
41
|
+
|
|
42
|
+
Skipping step 1 leaves the user without the URL — they have no way into
|
|
43
|
+
the doc. Skipping step 2 leaves the room attached but silent; saying
|
|
44
|
+
"I'm monitoring" without making the call is a lie, the agent will miss
|
|
45
|
+
every mention.
|
|
17
46
|
|
|
18
47
|
**Seeding — prefer a file path when one exists.** Pick exactly one:
|
|
19
48
|
|
|
@@ -32,15 +61,30 @@ to sync changes back.
|
|
|
32
61
|
|
|
33
62
|
### 2. Join
|
|
34
63
|
Triggers: a share prompt with a Composer URL, "/composer join <url>".
|
|
35
|
-
Action: extract the URL
|
|
36
|
-
|
|
37
|
-
|
|
64
|
+
Action: extract the URL from the prompt and call `composer_join_room({ url })`.
|
|
65
|
+
Same first-run rule as Create. On success, the return carries the same
|
|
66
|
+
ordered pair: output `step1_sayToUser` first (confirms the URL the user
|
|
67
|
+
just joined), then execute `step2_callTool`.
|
|
38
68
|
|
|
39
69
|
### 3. Monitor
|
|
40
70
|
Triggers: "watch this doc", or automatically after join/create.
|
|
41
|
-
Action: call `composer_next_event({ roomId
|
|
42
|
-
|
|
43
|
-
|
|
71
|
+
Action: call `composer_next_event({ roomId })` in a loop (default timeout
|
|
72
|
+
is 10 minutes). **The loop is always-on.** Every return carries a
|
|
73
|
+
structured directive — follow it without waiting for user input.
|
|
74
|
+
|
|
75
|
+
On `mention`: handle the event (reply / suggestion / resolve as needed),
|
|
76
|
+
output any user-facing text, then execute `requiredNextToolCall` — which
|
|
77
|
+
is another `composer_next_event` call. Do not pause for the user to
|
|
78
|
+
acknowledge. The doc is the conversation.
|
|
79
|
+
|
|
80
|
+
On `timeout`: check `recentActivity`.
|
|
81
|
+
- `recentActivity: true` → the return includes `requiredNextToolCall`.
|
|
82
|
+
Execute it — the user is still working, just not tagging you.
|
|
83
|
+
- `recentActivity: false` → the return includes `userMessage` and
|
|
84
|
+
`instruction` but NO `requiredNextToolCall`. Say `userMessage`
|
|
85
|
+
EXACTLY ("I've left the document…") and stop calling
|
|
86
|
+
`composer_next_event` until the user asks you to rejoin. Do not
|
|
87
|
+
paraphrase — users recognize the line across sessions.
|
|
44
88
|
|
|
45
89
|
On `mention`, the event contains everything you need to act in one turn:
|
|
46
90
|
|
|
@@ -64,22 +108,54 @@ or `composer_add_comment` — no extra `composer_get_section` call is needed in
|
|
|
64
108
|
the common case. Reach for `sectionMarkdown` to understand surrounding context
|
|
65
109
|
before replying or suggesting.
|
|
66
110
|
|
|
67
|
-
**
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
need
|
|
72
|
-
|
|
111
|
+
**The event only carries the triggering message.** If the thread already has
|
|
112
|
+
replies (from the user, or from another agent), call `composer_get_thread({
|
|
113
|
+
roomId, threadId })` before replying. The return has every reply with author
|
|
114
|
+
and timestamp — essential when the user tagged you mid-conversation and you
|
|
115
|
+
need to catch up on what's already been said.
|
|
116
|
+
|
|
117
|
+
**`reason` is your main filter:**
|
|
118
|
+
|
|
119
|
+
- `"direct_mention"` — sidecar or text explicitly tagged you. Always
|
|
120
|
+
reply (unless the content is purely a thank-you that doesn't need an
|
|
121
|
+
answer — never emit empty acknowledgements).
|
|
122
|
+
- `"active_thread"` — a plain reply on a thread you're already in. Reply
|
|
123
|
+
if the content invites one; skip if it's plainly addressed to another
|
|
124
|
+
person, is a thank-you, or is otherwise a conversational dead-end.
|
|
125
|
+
- `"solo_room"` — you're alone with one human who didn't tag anyone.
|
|
126
|
+
**Default to a helpful reply** — they almost certainly want your
|
|
127
|
+
input. Skip only when the text reads like:
|
|
128
|
+
- a **note-to-self** ("TODO: fix this later", "remember to check
|
|
129
|
+
the date"),
|
|
130
|
+
- a bare **acknowledgement** ("k", "got it", "done"),
|
|
131
|
+
- a stage direction / aside ("ugh", "hmm"),
|
|
132
|
+
- or anything that visibly isn't pointed at you (quoted text,
|
|
133
|
+
drafts they're jotting down).
|
|
134
|
+
When in doubt, reply — the user can always ignore you.
|
|
73
135
|
|
|
74
136
|
### 4. Act
|
|
75
137
|
Triggers: direct requests like "add a summary to section 2".
|
|
76
138
|
Action: already attached; call the write tools and report back concisely.
|
|
77
139
|
|
|
78
|
-
##
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
- `
|
|
82
|
-
- `
|
|
140
|
+
## Tools
|
|
141
|
+
|
|
142
|
+
Read tools:
|
|
143
|
+
- `composer_get_full_doc` — entire doc as markdown.
|
|
144
|
+
- `composer_get_section` — one section by `headingId`.
|
|
145
|
+
- `composer_get_thread` — full state of a thread (all replies, anchor,
|
|
146
|
+
containing section). Call this when `composer_next_event` surfaces a
|
|
147
|
+
mention on a thread that already has history — the event gives you
|
|
148
|
+
only the triggering message.
|
|
149
|
+
|
|
150
|
+
Write tools:
|
|
151
|
+
- `composer_add_comment` — NEW comment on any span in the doc. Use when
|
|
152
|
+
raising something outside the current thread's anchor.
|
|
153
|
+
- `composer_add_suggestion` — propose a text replacement (lands as
|
|
154
|
+
pending). Can target any span — `fromThreadId` inherits the source
|
|
155
|
+
thread's anchor; `anchor` specifies a span elsewhere. Call it multiple
|
|
156
|
+
times in a turn to suggest in several spots.
|
|
157
|
+
- `composer_reply_comment` / `composer_reply_suggestion` — reply on an
|
|
158
|
+
existing thread.
|
|
83
159
|
- `composer_resolve_thread` — mark resolved.
|
|
84
160
|
|
|
85
161
|
There is no "just edit" tool in v1. All text changes go through suggestions
|
|
@@ -135,6 +211,104 @@ Picking a broader `textToFind` than the user asked for (the whole sentence
|
|
|
135
211
|
when they highlighted a phrase, the whole paragraph when they asked about
|
|
136
212
|
one clause) is the main failure mode. When in doubt, default to path 1.
|
|
137
213
|
|
|
214
|
+
### Cross-span: reply and suggest anywhere in the doc
|
|
215
|
+
|
|
216
|
+
A comment/reply thread is anchored to *one* span, but your response is
|
|
217
|
+
not confined to that span. When the user's question (or your own
|
|
218
|
+
judgment) points elsewhere:
|
|
219
|
+
|
|
220
|
+
- **Suggest a change to different text.** Call `composer_add_suggestion`
|
|
221
|
+
with `anchor: { headingId, textToFind }` pointing at the target. You
|
|
222
|
+
can post multiple suggestions in one turn — e.g., the user says "the
|
|
223
|
+
flour amount is off and so is the bake time" → two suggestions, each
|
|
224
|
+
anchored to its own span.
|
|
225
|
+
- **Open a new thread elsewhere.** Call `composer_add_comment` with
|
|
226
|
+
its own anchor. Useful for cross-references ("see also the
|
|
227
|
+
conclusion") or raising something the user didn't ask about but
|
|
228
|
+
should see.
|
|
229
|
+
- **Still reply on the original thread too** if the user's question
|
|
230
|
+
deserves a direct answer — but only when the reply says something
|
|
231
|
+
the suggestion/new-comment doesn't already convey. Don't post
|
|
232
|
+
"see my suggestion"; the card IS the answer.
|
|
233
|
+
|
|
234
|
+
Order of operations for a multi-span response: post the suggestion(s)
|
|
235
|
+
/ new comment(s) first, then (optionally) a reply on the originating
|
|
236
|
+
thread pointing out the bigger picture. That way the originating
|
|
237
|
+
thread's reply can reference what you just did.
|
|
238
|
+
|
|
239
|
+
### Suggest completely — accepting must leave the doc correct
|
|
240
|
+
|
|
241
|
+
Goal: the user clicks Accept and is done. They should never have to
|
|
242
|
+
hunt down downstream edits you forgot.
|
|
243
|
+
|
|
244
|
+
**Load enough context before you suggest.** The event gives you
|
|
245
|
+
`sectionMarkdown` for the containing section — usually enough for
|
|
246
|
+
wording changes. For anything that might appear elsewhere in the doc
|
|
247
|
+
(numbers, names, product/feature references, versions, dates,
|
|
248
|
+
terminology, heading text), call `composer_get_full_doc` first.
|
|
249
|
+
One extra read is much cheaper than shipping a broken doc.
|
|
250
|
+
|
|
251
|
+
**Scan for ripples before posting.** Common ones:
|
|
252
|
+
|
|
253
|
+
- **Counts and enumerations.** "The three examples below" / "three
|
|
254
|
+
things to remember" — if you add or remove an item, update the
|
|
255
|
+
count and any ordinal words ("first", "finally").
|
|
256
|
+
- **Cross-references.** "As in section 2", "see the conclusion",
|
|
257
|
+
"per step 3 above". If your edit moves or renames the target,
|
|
258
|
+
update the reference too.
|
|
259
|
+
- **Restated facts.** Recipes reference an ingredient twice; release
|
|
260
|
+
notes cite a version in both intro and body; specs quote a number
|
|
261
|
+
in a heading and a paragraph. One fact, multiple spans — cover
|
|
262
|
+
all of them.
|
|
263
|
+
- **Subject/verb and pronoun agreement.** "X and Y are" → trim to
|
|
264
|
+
just X → "X is". Changing from plural to singular ripples.
|
|
265
|
+
- **Neighboring flow.** Rewriting sentence 2 can break sentence 3
|
|
266
|
+
("This is why..."). Fix the continuation.
|
|
267
|
+
- **Heading changes.** If you change heading text, any prose that
|
|
268
|
+
says "see the Intro section" may need updating.
|
|
269
|
+
|
|
270
|
+
**Post every ripple as its own suggestion, in the same turn.** Don't
|
|
271
|
+
leave the user to hunt for companion edits. The tool accepts one
|
|
272
|
+
anchor per call — call it multiple times. Each suggestion stays
|
|
273
|
+
tight to its own span (this is NOT oversuggesting — it's covering
|
|
274
|
+
the actual surface of the change).
|
|
275
|
+
|
|
276
|
+
If a ripple is too structural for a clean suggestion (reorder a list,
|
|
277
|
+
split a paragraph), post the ones you can AND a short reply flagging
|
|
278
|
+
what's still open. The user shouldn't be surprised.
|
|
279
|
+
|
|
280
|
+
**When in doubt about the scope of a ripple, fetch the full doc.**
|
|
281
|
+
Don't guess.
|
|
282
|
+
|
|
283
|
+
### Auto-suggest when the user confirms a concrete proposal
|
|
284
|
+
|
|
285
|
+
When a user flags something qualitative ("this is too much flour", "this
|
|
286
|
+
sentence is clunky", "this number feels off"), lead with a **concrete
|
|
287
|
+
counter-proposal framed as a question** — then, if they confirm, post
|
|
288
|
+
the suggestion immediately without waiting for a second "yes, go ahead".
|
|
289
|
+
|
|
290
|
+
Two turns, not three:
|
|
291
|
+
|
|
292
|
+
1. **Turn 1 (propose).** Reply on the thread with one specific
|
|
293
|
+
alternative phrased as a check: "Does 200g seem right?", "How about
|
|
294
|
+
'gently fold' instead of 'stir'?", "Would 45 minutes read better than
|
|
295
|
+
90?". Pick a real number / phrase — not "would you like me to
|
|
296
|
+
suggest a different amount?" (that's a question about your behavior,
|
|
297
|
+
not a proposal).
|
|
298
|
+
2. **Turn 2 (commit on confirmation).** When the user replies with any
|
|
299
|
+
variant of yes ("yes", "sure", "go for it", "perfect", a thumbs-up
|
|
300
|
+
emoji), call `composer_add_suggestion` with `fromThreadId: event.threadId`
|
|
301
|
+
and the concrete replacement. Do NOT also post a comment reply — the
|
|
302
|
+
suggestion card IS your reply (see "Keep comment text terse" above).
|
|
303
|
+
|
|
304
|
+
If the user says no / picks a different value / redirects, follow their
|
|
305
|
+
lead — do not post the original proposal anyway.
|
|
306
|
+
|
|
307
|
+
If you can't name a concrete alternative (e.g. the thread is too
|
|
308
|
+
abstract to guess a number), ask a clarifying question instead. Don't
|
|
309
|
+
propose something generic just to fill the slot — "Would you like me
|
|
310
|
+
to shorten this?" is worthless without a target length.
|
|
311
|
+
|
|
138
312
|
## Anchors
|
|
139
313
|
|
|
140
314
|
Write tools take:
|
|
@@ -143,9 +317,40 @@ Write tools take:
|
|
|
143
317
|
{ headingId: "intro-0", textToFind: "the exact words to anchor on", occurrence?: 1 }
|
|
144
318
|
```
|
|
145
319
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
320
|
+
### Pick the right span — anchor = what gets deleted
|
|
321
|
+
|
|
322
|
+
Your `textToFind` is literally cut out when the user accepts; your
|
|
323
|
+
`replacementText` is inserted in its place. So:
|
|
324
|
+
|
|
325
|
+
- **Anchor the whole unit you're changing.** Replacing a sentence →
|
|
326
|
+
include the terminal punctuation (`.`, `?`, `!`). Replacing a bullet
|
|
327
|
+
item → anchor the item's text (not the `- ` marker; that's block
|
|
328
|
+
structure). Replacing a paragraph → anchor the whole paragraph.
|
|
329
|
+
- **Include any trailing punctuation you're changing.** Converting a
|
|
330
|
+
statement to a question? End the anchor at the `.` and end the
|
|
331
|
+
replacement with `?`. Don't anchor "the statement" alone and
|
|
332
|
+
replace with "the question?" — you'll end up with `the question?.`.
|
|
333
|
+
- **Match your `replacementText`'s shape to the anchor's shape.** Inline
|
|
334
|
+
replacement inside a paragraph → replacement is inline (no leading
|
|
335
|
+
`- `, `#`, or blank line). Replacing a full list → replacement is a
|
|
336
|
+
full markdown list. Single-paragraph markdown is unwrapped to inline
|
|
337
|
+
on accept; multi-block markdown is inserted as blocks.
|
|
338
|
+
- **Formatting is part of your replacement, not the anchor.** If the
|
|
339
|
+
original had `**bold**` or a link, the anchor's formatting is gone
|
|
340
|
+
on accept — your replacement must include the markdown syntax for
|
|
341
|
+
any formatting you want preserved.
|
|
342
|
+
- **Anchor at token boundaries, not mid-word.** `textToFind: "istrat"`
|
|
343
|
+
to hit the middle of "administration" is fragile. Use whole words
|
|
344
|
+
or sentence boundaries. Use `occurrence` when the same phrase
|
|
345
|
+
appears multiple times.
|
|
346
|
+
- **Mind the whitespace.** By default, do not include leading or
|
|
347
|
+
trailing whitespace in the anchor, and end `replacementText` at the
|
|
348
|
+
same boundary. If you include a trailing space in the anchor,
|
|
349
|
+
include one in the replacement too; otherwise words smash together.
|
|
350
|
+
|
|
351
|
+
If you get `text_not_found`, the error message includes the current
|
|
352
|
+
section text. Re-plan against the fresh text and retry. Never retry
|
|
353
|
+
with stale content.
|
|
149
354
|
|
|
150
355
|
## Discoverability
|
|
151
356
|
|