@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.
@@ -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
- out += child.toString();
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
- const text = node.toString();
3928
- for (let i = 0; i < text.length; i++) {
3929
- map.push({
3930
- xmlText: node,
3931
- offsetInText: i,
3932
- flatIndex: flat.length + i
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 flatStart = findNthOccurrence(flat, spec.textToFind, occurrence);
4073
- if (flatStart === null) {
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 say
4120
- * `@agent` — the conversation is already in progress, and requiring a
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 `@agent`. Called by MCP write-tool
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
- var AGENT_MENTION_RE = /@agent/i;
4249
- var hasAgentMention = (text) => AGENT_MENTION_RE.test(text);
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
- if (hasAgentMention(body) && !seen.has(threadId)) {
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 isDirect = hasAgentMention(reply.text);
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
- if (!isDirect && !inActiveThread) continue;
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: isDirect ? "direct_mention" : "active_thread",
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
- return typeof v.userId === "string" && typeof v.color === "string";
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
- if (isPaletteColor(parsed.color)) {
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
- await fs.mkdir(dir, { recursive: true });
4371
- await fs.writeFile(filePath, JSON.stringify(migrated, null, 2), {
4372
- mode: FILE_MODE
4373
- });
4374
- return migrated;
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: `Agent display name for this room, e.g. "Josh's Agent".`
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 the attach snapshot.",
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: { type: "string" }
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", "actingAs"]
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 until a remote @agent mention arrives or the timeout elapses.",
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: 300 }
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. When responding to a thread, default to passing fromThreadId \u2014 the suggestion inherits the source thread's exact stored anchor, which is the right span whenever the user's request is scoped to what they selected (the common case). Supply an `anchor` instead when the user's request explicitly targets a different span (e.g. they highlight one word but ask to rewrite the whole paragraph), or for proactive suggestions with no source thread. Pick one: supplying both is rejected. Returns { id } on success or an isError result if the anchor cannot be resolved.",
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 actingAs = asString(a.actingAs, "actingAs");
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: browserUrlFor(roomId),
4735
- snapshot: state.snapshot()
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 actingAs = asString(a.actingAs, "actingAs");
4947
+ const actingAsArg = asOptionalString(a.actingAs, "actingAs");
4742
4948
  const roomId = parseRoomIdFromUrl(url);
4743
- const identity = await getIdentity();
4949
+ const browserUrl = browserUrlFor(roomId);
4744
4950
  const existing = rooms.get(roomId);
4745
4951
  if (existing) {
4746
- return okResult({ roomId, snapshot: existing.snapshot() });
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
- return okResult({ roomId, snapshot: state.snapshot() });
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 : 300;
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
- return okResult(event);
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
@@ -4,7 +4,7 @@ import {
4
4
  logError,
5
5
  startMcpHttpServer,
6
6
  startMcpServer
7
- } from "./chunk-VVYEIOFH.js";
7
+ } from "./chunk-UVXQZ2TN.js";
8
8
 
9
9
  // src/setup.ts
10
10
  import * as fs from "fs/promises";
package/dist/mcp.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  startMcpHttpServer,
3
3
  startMcpServer
4
- } from "./chunk-VVYEIOFH.js";
4
+ } from "./chunk-UVXQZ2TN.js";
5
5
  export {
6
6
  startMcpHttpServer,
7
7
  startMcpServer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@composer-app/mcp",
3
- "version": "0.0.1-beta.3",
3
+ "version": "0.0.1-beta.4",
4
4
  "description": "Composer MCP",
5
5
  "license": "MIT",
6
6
  "author": "Josh Philpott",
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({ name, actingAs: "<user's name>'s Agent", ... })`.
16
- Return the `browserUrl`. You are already attached; enter monitor mode.
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 and the acting-as name from the prompt. Call
36
- `composer_join_room({ url, actingAs })`. Announce the doc outline and enter
37
- monitor mode.
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, timeoutSec: 300 })` in a loop.
42
- On `timeout`: loop again up to ~30 minutes, then ask the user if they want
43
- to keep watching.
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
- **Important:** `reason: "active_thread"` means the user replied on a thread
68
- the agent has already participated in no explicit `@agent` was required.
69
- Decide whether to respond based on content, not just the trigger; if the
70
- reply is plainly addressed to another person, or is a thank-you that doesn't
71
- need an answer, it's fine to leave it alone (don't emit an empty reply just
72
- to acknowledge). If it clearly asks you something, answer it.
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
- ## Write tools
79
-
80
- - `composer_add_comment` — comment anchored to text.
81
- - `composer_add_suggestion` — propose a text replacement (lands as pending).
82
- - `composer_reply_comment` / `composer_reply_suggestion` reply on a thread.
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
- If you get `text_not_found`, the error message includes the current section
147
- text. Re-plan against the fresh text and retry. Never retry with stale
148
- content.
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