shuadouyin 0.3.5 → 0.4.0

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/.dy-build-commit CHANGED
@@ -1 +1 @@
1
- 74b35764d458acaeeff44eaef3fe9e9a99941ac8
1
+ 7251333bbc6e84fd193536885101c3b6ebf46c86
package/CHANGELOG.md ADDED
@@ -0,0 +1,37 @@
1
+ # Changelog
2
+
3
+ ## 0.4.0 — 2026-10-31
4
+
5
+ ### 新增
6
+
7
+ - `chat recall` — 撤回自己发的一条私信。平台窗口是 3 分钟;超时后菜单里还有「撤回」但服务端会拒,所以它读响应而不是猜时间。没有二次确认,因此要 `--request-id`。
8
+ - `chat inbox` — 谁在等我回。未读徽标不可靠(打开会话就清),所以按「最后一句是不是对方说的」判断,并把对方分享的作品 id 放进 `shared_works`。
9
+ - `browse checkpoint` — 每批一条:走了哪条路、看到什么、每个交付条件是什么状态、下一步去哪里。进度从这些记录推导,不采信调用方的自述。
10
+ - `browse start --text-file` — 一轮要交付什么、按什么标准判、什么时候停。写一次不能改,改条件就是换一轮。
11
+ - `browse end --outcome` — `completed` 要求 checkpoint 真的达标,否则拒绝;`partial` 是诚实的交付。
12
+ - `persona show` — 按段读取人格,每段带 `complete`。`context show` 从头截断,人格的「身份与声音」在后面,预算不够时会静静切掉。
13
+ - `auth grant` / `auth revoke` / `auth grants` — 记住某个人可以做什么,带期限、每日上限和撤销记录。不是开关。
14
+
15
+ ### 改进
16
+
17
+ - `page facts --selector` 只返回问到的标记:4440 → 618 字节。
18
+ - `chat read` 不再复述上一条读到的消息内容:8832 → 4215 字节。
19
+ - `comment list` 去掉每行重复的作品 id 和恒为 false 的布尔:2689 → 1849 字节。
20
+ - `video digest` 不传 `--frames` 时按时长采样。之前两个默认值叠起来,让它从来没生效过。
21
+ - 每个页面命令的返回会带 `persona` 字段,提醒这一轮还没读人格、读的是旧版本、或从没读过。
22
+ - 三个预设各带一组同一事实的对照示例,说明这个声音在四种场景下怎么说话。
23
+
24
+ ### 修复
25
+
26
+ - `chat read` 只读视口内的消息。真实群 13 条只返回 7 条,**漏掉的全是别人说的**。
27
+ - `chat inbox` 把「新到旧」的数组当成「旧到新」,`awaiting_reply` 一直是反的。
28
+ - 分享卡的作品 id 读不出来:`parsedContent` 是 getter,而 `own()` 故意拒绝 accessor。
29
+ - `comment list` 和 `comment replies` 的关联信息移到结果顶层,不再每行重复。
30
+
31
+ ### 移除
32
+
33
+ - `src/operations/actions.ts` — 提案/批准模型,从未被任何命令或模块引用。它的每日额度已由 `social goal` 和 `auth grant` 覆盖。
34
+
35
+ ## 0.3.5 及更早
36
+
37
+ 版本号在这一版之前没有随功能推进,所以更早的提交没有对应的版本记录。用 `dy update --check` 的 `commit` 字段判断新旧。
package/README.md CHANGED
@@ -62,8 +62,15 @@ npm uninstall -g shuadouyin
62
62
 
63
63
  装 skill 给 agent 用(可选):
64
64
 
65
+ ```bash
66
+ npx skills add https://github.com/9Ashwin/ShuaDouyin --skill dy
67
+ ```
68
+
69
+ 或者用 CLI 自己装到 agent 目录:
70
+
65
71
  ```bash
66
72
  dy skill install
73
+ dy skill status # 已装的副本和仓库是否一致
67
74
  ```
68
75
 
69
76
  ## 开始使用
@@ -109,7 +116,15 @@ dy video info # 读当前作品
109
116
  dy guide # 返回完整的使用说明(约 3 万字),agent 读这个
110
117
  ```
111
118
 
112
- 或者把 `skills/dy` 装进你的 agent 技能目录。里面按主题分成八个参考文件:浏览与成长、记忆与人格、社交、直播、日报、自我改进、命令全表,以及**平台事实**——最后一个是踩过的坑,读它能省掉一次重来。
119
+ 或者把 skill 装进你的 agent 技能目录,这样它知道什么时候该用 `dy`、怎么用:
120
+
121
+ ```bash
122
+ npx skills add https://github.com/9Ashwin/ShuaDouyin --skill dy
123
+ ```
124
+
125
+ `skills/dy` 里按主题分成八个参考文件:浏览与成长、记忆与人格、社交、直播、日报、自我改进、命令全表,以及**平台事实**——最后一个是踩过的坑,读它能省掉一次重来。
126
+
127
+ `SKILL.md` 本身只有两百来行,是每次对话都会加载的那部分;参考文件按需读。
113
128
 
114
129
  ## 设计原则
115
130
 
package/dist/app.js CHANGED
@@ -5,6 +5,7 @@ import { CliError, normalizeError } from "./errors.js";
5
5
  import { Knowledge, knowledgeOperation, validateKnowledge } from "./knowledge.js";
6
6
  import { migrateLegacyLayout } from "./migrate.js";
7
7
  import { askDouyin } from "./operations/ai-search.js";
8
+ import { rounds } from "./operations/browsing.js";
8
9
  import { audio, capture, noteImage, noteSlide, observe } from "./operations/capture.js";
9
10
  import { captureChatImage, listChats, readChat } from "./operations/chat.js";
10
11
  import { compareChatWindows } from "./operations/chat-diff.js";
@@ -18,11 +19,13 @@ import { inspectCreator } from "./operations/creator.js";
18
19
  import { digest } from "./operations/digest.js";
19
20
  import { pageFactsRead } from "./operations/facts.js";
20
21
  import { readImage } from "./operations/image-file.js";
22
+ import { readInbox } from "./operations/inbox.js";
21
23
  import { resolveLink } from "./operations/links.js";
22
24
  import { liveChatRead, liveGiftSend, liveGifts, liveInfo, liveSay } from "./operations/live.js";
23
25
  import { applySearchFilters, closeChatIfOpen, control, listForwardRecipients, listResults, moveFeed, navigate, openRecommendFeed, searchUsers, settleOnWork, sweepFeed, verifySearchFilters, waitSearchResults, waitSnapshot, } from "./operations/navigation.js";
24
26
  import { phraseMemories, phraseMemoryText, usePhrase } from "./operations/phrases.js";
25
27
  import { readPerson } from "./operations/read-person.js";
28
+ import { recallMessage } from "./operations/recall.js";
26
29
  import { userRelations } from "./operations/relations.js";
27
30
  import { observeResponse } from "./operations/response-window.js";
28
31
  import { skillInstall, skillStatus } from "./operations/skill.js";
@@ -139,7 +142,7 @@ export class App {
139
142
  const session = await this.chrome.connect(active, this.timeout, "https://live.douyin.com");
140
143
  try {
141
144
  const result = operation === "live.say"
142
- ? await liveSay(session, input, active)
145
+ ? await liveSay(session, input, this.store, active)
143
146
  : await liveGiftSend(session, input, this.store, active);
144
147
  await this.store.append(operation, { request_id: input.request_id ?? null, ...result });
145
148
  return result;
@@ -165,6 +168,18 @@ export class App {
165
168
  timeout: this.timeout,
166
169
  });
167
170
  }
171
+ // Recalling is its own path for the same reason deleting is: it has no
172
+ // composer, no settlement and no confirmation, so the generic write
173
+ // path would fill an input that does not exist.
174
+ if (operation === "chat.recall") {
175
+ const session = await this.chrome.connect(active, this.timeout);
176
+ try {
177
+ return await recallMessage(session, input, this.store, active);
178
+ }
179
+ finally {
180
+ await session.close();
181
+ }
182
+ }
168
183
  if (isWrite(operation)) {
169
184
  // A failed send used to leave no trace: only the read path recorded
170
185
  // failures, so `diagnostics` and the repeated-failure rule in
@@ -216,6 +231,11 @@ export class App {
216
231
  // is attached here rather than deep inside each operation so no command
217
232
  // has to remember, and so a command added later cannot forget.
218
233
  const setup = await setupNotice(this.store.base);
234
+ // The persona note rides along the same way the setup question does.
235
+ // One place attaches it, so a command added later cannot forget, and
236
+ // it is advisory: a caller that has not read the persona yet must
237
+ // still be able to run the command that tells it to.
238
+ const persona = await personaNotice(this.store.base);
219
239
  if (operation === "chat.read" && this.store.bot_id) {
220
240
  if (!result || typeof result !== "object")
221
241
  throw new CliError("state_corrupt", "Chat read did not return a conversation window");
@@ -227,9 +247,23 @@ export class App {
227
247
  previous_context: previous,
228
248
  };
229
249
  }
230
- if (!["status", "auth.status"].includes(operation))
231
- await this.store.append(operation, result);
232
- return setup && result && typeof result === "object" ? { ...result, setup } : result;
250
+ if (!["status", "auth.status"].includes(operation)) {
251
+ // The round is resolved now, while the lock is held and the call is
252
+ // starting. Reading "the open round" after the fact would attach a
253
+ // long sweep to whichever round happened to be open when it ended.
254
+ const open = rounds(await this.store.events()).find((round) => round.status === "open");
255
+ const data = open && result && typeof result === "object"
256
+ ? { ...result, _browse: { round_id: open.id, started_at: open.started_at } }
257
+ : result;
258
+ await this.store.append(operation, data);
259
+ }
260
+ const notices = {
261
+ ...(setup ? { setup } : {}),
262
+ ...(persona ? { persona } : {}),
263
+ };
264
+ return Object.keys(notices).length && result && typeof result === "object"
265
+ ? { ...result, ...notices }
266
+ : result;
233
267
  }
234
268
  catch (error) {
235
269
  // Cancellation is the caller's choice and its outcome is not a
@@ -466,7 +500,7 @@ export class App {
466
500
  return listForwardRecipients(session, signal);
467
501
  }
468
502
  case "page.facts":
469
- return pageFactsRead(session, sourceCheckout().path ?? process.cwd());
503
+ return pageFactsRead(session, sourceCheckout().path ?? process.cwd(), input.target);
470
504
  case "live.info":
471
505
  return liveInfo(session);
472
506
  case "live.chat":
@@ -604,6 +638,8 @@ export class App {
604
638
  return { closed: await closeComments(session, signal) };
605
639
  case "chat.list":
606
640
  return listChats(session);
641
+ case "chat.inbox":
642
+ return readInbox(session, input, signal);
607
643
  case "chat.close":
608
644
  // Closing is a navigation step: the draft check lives on the page so
609
645
  // an unsent message is never discarded by a caller that did not see it.
@@ -729,4 +765,47 @@ export async function setupNotice(base) {
729
765
  function publicBrowser(state) {
730
766
  return { running: true, headless: state.headless, pid: state.pid, started_at: state.started_at };
731
767
  }
768
+ /**
769
+ * Whether the persona was read recently enough to be speaking from it.
770
+ *
771
+ * The skill says to read the persona before the first reply of a session, and a
772
+ * session has no boundary a CLI can see. What it can see is the sequence of
773
+ * commands: a round of work starts when the persona is read, and any page
774
+ * command that happens long after a read — or before the first one ever — is a
775
+ * caller that is probably working without it.
776
+ *
777
+ * This is a note, not a gate. Refusing a read because the persona was not loaded
778
+ * would break the case that matters most: a caller that has not read it yet has
779
+ * to be able to run the command that tells it to.
780
+ */
781
+ export async function personaNotice(base) {
782
+ const store = new Store(base);
783
+ const events = await store.events();
784
+ const reads = events.filter((event) => event.operation === "persona.read");
785
+ const last = reads.at(-1);
786
+ const current = await new Workspaces(base).document("AGENTS.md").catch(() => null);
787
+ if (!current)
788
+ return null;
789
+ // A persona edited after the last read is stale evidence even if the read was
790
+ // a minute ago: the agent is holding an older version. This is checked before
791
+ // the clock, because "read recently" and "read the current file" are different
792
+ // facts and the second one is the one that matters.
793
+ const lastRevision = last?.data ? last.data.revision : undefined;
794
+ const revisionChanged = !!lastRevision && lastRevision !== current.revision;
795
+ // Half an hour is a guess at "same sitting". A session can be longer, and
796
+ // saying so is better than a rule that silently expires mid-task.
797
+ const stale = !last || Date.now() - Date.parse(last.at) > 30 * 60_000;
798
+ if (!stale && !revisionChanged)
799
+ return null;
800
+ return {
801
+ status: last ? (revisionChanged ? "changed_since_read" : "stale") : "never_read",
802
+ read: "Run `dy persona show` before speaking as this bot. It returns the name, voice and conduct sections with a complete flag per section.",
803
+ revision: current.revision,
804
+ why: last
805
+ ? revisionChanged
806
+ ? "The persona was edited after it was last read, so anything spoken from the earlier copy is out of date."
807
+ : "No persona read in this stretch of work. The bot's name and voice live in AGENTS.md, and replying without it means replying in the model's own voice."
808
+ : "The persona has never been read on this workspace. Without it the bot speaks as a generic assistant, and nothing in the output says the owner's setting was skipped.",
809
+ };
810
+ }
732
811
  //# sourceMappingURL=app.js.map
package/dist/browser.js CHANGED
@@ -306,7 +306,11 @@ export class Session {
306
306
  async call(operation, args = {}) {
307
307
  this.signal.throwIfAborted();
308
308
  adapter ??= await readFile(new URL("../dist/page-adapter.js", import.meta.url), "utf8");
309
- const wire = await this.tab.evaluate(`(() => {\n${adapter}\nreturn DouyinPage.call(${JSON.stringify(operation)}, ${JSON.stringify(args)});\n})()`);
309
+ // The injected script is given a name so a profiler can report its coverage.
310
+ // Unnamed scripts arrive with an empty url, and precise coverage is only
311
+ // useful if the ranges can be attributed to this bundle rather than to the
312
+ // page's own code.
313
+ const wire = await this.tab.evaluate(`(() => {\n//# sourceURL=dy-page-adapter.js\n${adapter}\nreturn DouyinPage.call(${JSON.stringify(operation)}, ${JSON.stringify(args)});\n})()`);
310
314
  if (wire.error)
311
315
  throw new CliError(wire.error.code, wire.error.message, { hint: wire.error.hint });
312
316
  return wire.data;
package/dist/catalog.js CHANGED
@@ -229,7 +229,7 @@ export const COMMANDS = [
229
229
  type: "string",
230
230
  },
231
231
  ],
232
- guidance: "Reports the exact phrases that read as AI, with the reason each one does, plus the markers of a text scrubbed past natural — a comma chain, five equal-length sentences, one judgement sentence after another. Both directions matter: the catalog exists because deleting every tell is its own way of reading as written by a machine. The catalog is adapted from PenguinHarness's Chinese humanizer cues. Advice, not a gate: a flagged phrase kept on purpose is still a choice, and this never rewrites the text.",
232
+ guidance: "Reports the exact phrases that read as AI, with the reason each one does, plus the markers of a text scrubbed past natural — a comma chain, five equal-length sentences, one judgement sentence after another. Both directions matter: the catalog exists because deleting every tell is its own way of reading as written by a machine. The catalog is adapted from PenguinHarness's Chinese humanizer cues. Advice, not a gate: a flagged phrase kept on purpose is still a choice, and this never rewrites the text. It checks general readability only — passing it does not mean the text sounds like this bot, and it says nothing about whether the facts are right or whether the account is a person. Read the persona with persona show for the first question and check the facts yourself for the second.",
233
233
  effect: "local",
234
234
  },
235
235
  {
@@ -288,6 +288,21 @@ export const COMMANDS = [
288
288
  guidance: "Replaces the retired line that told the bot to keep the owner's settings fixed and never rewrite the persona. That line contradicts the growth section in the same document, and the older, more concrete line wins, so an agent reading it is told both to grow and never to change. Only the exact retired sentence is matched; a persona whose owner has already rewritten it is left alone. Running it twice changes nothing the second time.",
289
289
  effect: "local",
290
290
  },
291
+ {
292
+ id: "persona.show",
293
+ description: "Read the persona a caller needs before it speaks, projected by section",
294
+ flags: [
295
+ {
296
+ key: "budget",
297
+ name: "--budget <characters>",
298
+ description: "Room for the persona sections",
299
+ type: "number",
300
+ default: 6000,
301
+ },
302
+ ],
303
+ effect: "local",
304
+ guidance: "Returns the name, identity and voice sections, and the growth and communication rules, each with its own complete flag. Unlike context show, the budget is spent section by section and never cuts a section in half: a section that does not fit comes back empty with complete:false, because half a voice section reads like a whole one. The revision identifies the exact file this was read from. This is the persona as written, not proof an agent followed it — a host that builds its own request can guarantee the text reaches the model, and a skill-only caller cannot.",
305
+ },
291
306
  {
292
307
  id: "persona.evolve",
293
308
  description: "Rewrite the persona's derived voice section from what this account actually wrote",
@@ -458,17 +473,49 @@ export const COMMANDS = [
458
473
  default: 5,
459
474
  },
460
475
  { key: "goal", name: "--goal <id>", description: "Goal this round serves", type: "string" },
476
+ {
477
+ key: "text_file",
478
+ name: "--text-file <path>",
479
+ description: "Plan JSON: what this round delivers and how it is judged",
480
+ type: "string",
481
+ },
482
+ { key: "text", name: "--text <json>", description: "The same plan as an inline JSON object", type: "string" },
483
+ ],
484
+ effect: "local",
485
+ guidance: "A round left open by a session that died is resumed, not duplicated: two open rounds would each claim a budget and neither would be true. A plan states what this round delivers, which criteria a candidate must meet and what to do when progress stops; it is validated whole before anything is stored and cannot be changed afterwards, so a different plan needs a closed round first. Resuming with a different plan, or with a budget flag that disagrees with the open round, returns revision_conflict rather than merging the two. The budget is advisory — browse round says it is spent, and the caller decides to close.",
486
+ },
487
+ {
488
+ id: "browse.checkpoint",
489
+ description: "Record what one batch found, what it ruled out, and where it goes next",
490
+ argument: { key: "resource", name: "round-id", description: "Round ID from browse start" },
491
+ flags: [
492
+ { key: "text_file", name: "--text-file <path>", description: "Checkpoint JSON", type: "string" },
493
+ { key: "text", name: "--text <json>", description: "The same checkpoint inline", type: "string" },
494
+ {
495
+ key: "if_match",
496
+ name: "--if-match <revision>",
497
+ description: "Revision from browse round",
498
+ type: "string",
499
+ },
461
500
  ],
462
501
  effect: "local",
463
- guidance: "A round left open by a session that died is resumed, not duplicated: two open rounds would each claim a budget and neither would be true. The budget is advisory — browse round says it is spent, and the caller decides to close.",
502
+ guidance: "One batch is written as a whole or not at all. Each criterion must be supported, not_supported or unknown, and supported or not_supported must cite an event that exists and belongs to an entity in this round; unknown is how a missing answer is recorded. A criterion the plan does not have is refused, because evidence for an unasked question is not evidence. The same checkpoint id with the same content returns the stored result; the same id with different content is idempotency_conflict. Progress is derived from these checkpoints, so a batch that repeats what an earlier one already established does not count as new. This records the caller's assessment; it cannot and does not verify whether the conclusion is true.",
464
503
  },
465
504
  {
466
505
  id: "browse.end",
467
506
  description: "Close a browsing round and keep its summary",
468
507
  argument: { key: "resource", name: "round-id", description: "Round ID from browse start" },
469
- flags: [{ key: "reason", name: "--reason <text>", description: "Why the round stopped", type: "string" }],
508
+ flags: [
509
+ { key: "reason", name: "--reason <text>", description: "Why the round stopped", type: "string" },
510
+ {
511
+ key: "outcome",
512
+ name: "--outcome <kind>",
513
+ description: "completed | partial | blocked | cancelled",
514
+ type: "string",
515
+ },
516
+ ],
470
517
  effect: "local",
471
- guidance: "Closing twice is harmless; the first close wins and the second reports already_closed.",
518
+ guidance: "Closing twice is harmless; the first close wins and the second reports already_closed. A round started with a plan must state an outcome, because a round with a delivery condition that ends without one turns partial into done in whatever report reads it later. completed is refused unless the checkpoints actually establish the target count; that is a consistency check on the record, not a judgement about whether the candidates are any good.",
472
519
  },
473
520
  {
474
521
  id: "browse.watch",
@@ -659,8 +706,16 @@ export const COMMANDS = [
659
706
  {
660
707
  id: "page.facts",
661
708
  description: "Compare the page's real data-e2e markers against the ones the code uses",
709
+ flags: [
710
+ {
711
+ key: "target",
712
+ name: "--selector <marker>",
713
+ description: "Only this marker, or a prefix ending in *",
714
+ type: "string",
715
+ },
716
+ ],
662
717
  effect: "read",
663
- guidance: "Reports which selectors the adapter relies on that this page does not have, and which markers the page offers that nothing uses. A fixture written by hand cannot catch this: it was written from the same understanding that produced the code. Read-only.",
718
+ guidance: "Reports which selectors the adapter relies on that this page does not have, and which markers the page offers that nothing uses. A fixture written by hand cannot catch this: it was written from the same understanding that produced the code. A page carries about thirty-five markers and a caller usually needs one; --selector <marker> or --selector 'chat*' returns just that, and is what to use when the question is whether a control is present. Works only on the current page; opening a different one first is the caller's job. Read-only.",
664
719
  },
665
720
  {
666
721
  id: "live.info",
@@ -865,9 +920,12 @@ export const COMMANDS = [
865
920
  {
866
921
  key: "frames",
867
922
  name: "--frames <count>",
868
- description: "Positions to sample: 0 for none, 1, or 3 (default 3); 0 skips all seeking",
923
+ // No default. A default of 3 overrode the duration-aware choice, so
924
+ // every work got three frames whether it was thirty seconds or twenty
925
+ // minutes — which is the thing sampling by duration exists to avoid.
926
+ // Omitted means "choose from the duration"; 0 means none.
927
+ description: "Frames to sample; omit to choose by duration, 0 for none",
869
928
  type: "number",
870
- default: 3,
871
929
  },
872
930
  { key: "limit", name: "--limit <count>", description: "Comments to read (default 8)", type: "number" },
873
931
  { key: "output", name: "--output <directory>", description: "Frame output directory", type: "string" },
@@ -1548,6 +1606,78 @@ export const COMMANDS = [
1548
1606
  effect: "account_write",
1549
1607
  guidance: "Requires --request-id. Stops on identity or draft conflicts. --mention picks one exact name from the group @ picker; an ambiguous or missing name fails instead of mentioning the wrong member. --emoji sends one sticker from the composer's smiley, on its own: combining it with --text is refused because the platform sends a sticker the moment it is clicked. The latest end of a loaded conversation is not yet verifiable, so dispatched messages return delivery_uncertain; inspect the receipt and conversation and do not automatically resend.",
1550
1608
  },
1609
+ {
1610
+ id: "auth.grant",
1611
+ description: "Remember that one person may be written to, for a stated span",
1612
+ flags: [
1613
+ {
1614
+ key: "target",
1615
+ name: "--target <id>",
1616
+ description: "user:<sec_uid> or conversation:<id>",
1617
+ type: "string",
1618
+ required: true,
1619
+ },
1620
+ {
1621
+ key: "actions",
1622
+ name: "--actions <list>",
1623
+ description: "reply, comment, follow, like",
1624
+ type: "string",
1625
+ required: true,
1626
+ },
1627
+ { key: "days", name: "--days <count>", description: "Days until it lapses; 0 for none", type: "number" },
1628
+ { key: "per_day", name: "--per-day <count>", description: "Most sends to this target per day", type: "number" },
1629
+ { key: "reason", name: "--reason <text>", description: "Why this person, for the next reader", type: "string" },
1630
+ ],
1631
+ effect: "local",
1632
+ guidance: "This is not a way to switch off asking. It records one target and one set of actions, with an expiry and a reason, because the alternative people actually reach for — a note saying replies are fine — cannot be reviewed or revoked. A grant for one person does not extend to a group they are in, and one for a group does not extend to its members: those are different audiences. Nothing here decides whether a message is worth sending; it only records that the person was named.",
1633
+ },
1634
+ {
1635
+ id: "auth.revoke",
1636
+ description: "Stop a standing permission",
1637
+ flags: [
1638
+ { key: "resource", name: "--grant-id <id>", description: "ID from auth grants", type: "string", required: true },
1639
+ ],
1640
+ effect: "local",
1641
+ guidance: "Revoking does not delete the grant: what it covered already happened, and a later reader needs to know it existed and when it stopped.",
1642
+ },
1643
+ {
1644
+ id: "auth.grants",
1645
+ description: "List standing permissions and whether each is still in effect",
1646
+ flags: [],
1647
+ effect: "local",
1648
+ guidance: "Shows every grant with its target, actions, expiry and whether it is active. An expired or revoked grant is still listed, because the record of what was permitted is the point.",
1649
+ },
1650
+ {
1651
+ id: "chat.inbox",
1652
+ description: "Read what arrived unread, without deciding what to reply",
1653
+ flags: [
1654
+ {
1655
+ key: "limit",
1656
+ name: "--limit <count>",
1657
+ description: "How many unread conversations to read",
1658
+ type: "number",
1659
+ default: 10,
1660
+ },
1661
+ ],
1662
+ effect: "read",
1663
+ guidance: "Lists conversations with unread messages and reads them, so a caller has the content in hand to answer. It does not write a reply and does not generate one. Reading a conversation marks it read on the platform, so a second run may report less than the first — this is an observation, not a subscription. A message sharing a work reports that work's id under shared_works; read it before replying, since a reply about a work nobody watched is the failure this prevents.",
1664
+ },
1665
+ {
1666
+ id: "chat.recall",
1667
+ description: "Retract one of your own messages, inside the platform's time window",
1668
+ flags: [
1669
+ conversation,
1670
+ {
1671
+ key: "message_id",
1672
+ name: "--message-id <id>",
1673
+ description: "Stable ID of a message this account sent",
1674
+ type: "string",
1675
+ required: true,
1676
+ },
1677
+ ],
1678
+ effect: "account_write",
1679
+ guidance: "Requires --request-id. Only a message this account sent can be recalled; the page offers no recall entry on anyone else's, so the direction is checked before anything is clicked. The platform allows three minutes: past that the menu entry is still rendered but the request is refused, and this reports invalid_argument rather than guessing from the clock. There is no confirmation dialog, so a successful recall is confirmed by re-reading the conversation and finding the message gone.",
1680
+ },
1551
1681
  {
1552
1682
  id: "chat.voice",
1553
1683
  description: "Ask the page to transcribe voice messages and return the text",
package/dist/knowledge.js CHANGED
@@ -8,12 +8,15 @@ import { Growth } from "./growth.js";
8
8
  import { packMemories } from "./memory/context.js";
9
9
  import { Formation } from "./memory/formation.js";
10
10
  import { MemoryStore, validateMemoryInput } from "./memory.js";
11
- import { browsingContext, endRound, noteSkipped, noteWatched, roundSummary, rounds, startRound, } from "./operations/browsing.js";
11
+ import { grantAccess, grants, revokeAccess } from "./operations/authorization.js";
12
+ import { browsingContext, endRound, noteSkipped, noteWatched, recordCheckpoint, roundRevision, roundSummary, rounds, startRound, } from "./operations/browsing.js";
12
13
  import { chatHistory } from "./operations/chat-history.js";
13
14
  import { acknowledged, diagnostics, splitByFix } from "./operations/diagnostics.js";
14
15
  import { addGoal, discover, pauseGoal } from "./operations/discover.js";
15
16
  import { keepFindings } from "./operations/learn.js";
16
17
  import { evolvePersona, migratePersonaDocument, personaSection, presetOf } from "./operations/persona-evolve.js";
18
+ import { personaSurface } from "./operations/persona-surface.js";
19
+ import { parsePlan } from "./operations/plan.js";
17
20
  import { resetApply, resetPlan } from "./operations/reset.js";
18
21
  import { parseSources, signals } from "./operations/signals.js";
19
22
  import { closeFollowup, forgetPerson, goalUsage, outcomeSummary, pauseState, recordFollowup, recordOutcome, recordStage, setPaused, socialLog, socialView, } from "./operations/social.js";
@@ -33,6 +36,11 @@ export function knowledgeOperation(operation) {
33
36
  // persona.migrate falling through to the browser dispatcher, where the only
34
37
  // possible answer was "Unknown operation".
35
38
  operation.startsWith("persona.") ||
39
+ // Standing permissions are workspace state: they are recorded in the event
40
+ // log and never touch the page. The whole group, for the same reason the
41
+ // persona group is whole — one leaf listed at a time is how one falls
42
+ // through to the browser dispatcher and answers "Unknown operation".
43
+ operation.startsWith("auth.") ||
36
44
  operation.startsWith("learn.") ||
37
45
  // social.response reads the page, so it must not be claimed as local: the
38
46
  // prefix rule would have made it fall through here and report "unknown
@@ -157,14 +165,34 @@ export class Knowledge {
157
165
  return { operation, acknowledged_at: new Date().toISOString() };
158
166
  });
159
167
  case "browse.start":
160
- return this.workspaces.lock(() => startRound(new Store(this.workspaces.base), {
161
- works: input.budget,
162
- minutes: input.minutes,
163
- idle: input.idle,
164
- goal: input.goal,
165
- }));
168
+ return this.workspaces.lock(async () => {
169
+ // The plan is validated whole before anything is stored, so a bad
170
+ // field cannot leave half a round behind.
171
+ const raw = input.text?.trim() || (input.text_file ? await readFile(input.text_file, "utf8") : "");
172
+ const plan = raw ? parsePlan(JSON.parse(raw)) : undefined;
173
+ const stated = new Set(input._stated ?? []);
174
+ return startRound(new Store(this.workspaces.base), {
175
+ works: input.budget,
176
+ minutes: input.minutes,
177
+ idle: input.idle,
178
+ goal: input.goal,
179
+ ...(plan ? { plan } : {}),
180
+ explicit: {
181
+ works: stated.has("budget"),
182
+ minutes: stated.has("minutes"),
183
+ idle: stated.has("idle"),
184
+ },
185
+ });
186
+ });
187
+ case "browse.checkpoint":
188
+ return this.workspaces.lock(async () => {
189
+ const raw = input.text?.trim() || (input.text_file ? await readFile(input.text_file, "utf8") : "");
190
+ if (!raw)
191
+ throw new CliError("invalid_argument", "Supply the batch with --text-file or --text");
192
+ return recordCheckpoint(new Store(this.workspaces.base), input.resource ?? "", JSON.parse(raw), input.if_match);
193
+ });
166
194
  case "browse.end":
167
- return this.workspaces.lock(() => endRound(new Store(this.workspaces.base), input.resource ?? "", input.reason ?? ""));
195
+ return this.workspaces.lock(() => endRound(new Store(this.workspaces.base), input.resource ?? "", input.reason ?? "", input.outcome));
168
196
  case "browse.watch":
169
197
  return this.workspaces.lock(() => noteWatched(new Store(this.workspaces.base), input.round ?? "", input.video_id ?? ""));
170
198
  case "browse.skip":
@@ -173,9 +201,15 @@ export class Knowledge {
173
201
  return this.workspaces.lock(async () => {
174
202
  const store = new Store(this.workspaces.base);
175
203
  const events = await store.events();
176
- return input.resource
177
- ? (roundSummary(events, input.resource) ?? { round_id: input.resource, known: false })
178
- : { rounds: rounds(events).slice(-(input.limit ?? 10)) };
204
+ if (!input.resource)
205
+ return { rounds: rounds(events).slice(-(input.limit ?? 10)) };
206
+ const summary = roundSummary(events, input.resource);
207
+ if (!summary)
208
+ return { round_id: input.resource, known: false };
209
+ // The revision is what a checkpoint's --if-match names. It covers the
210
+ // plan and every checkpoint written so far, so a batch prepared
211
+ // against this view is refused if another one landed in between.
212
+ return { ...summary, revision: roundRevision(events, input.resource) };
179
213
  });
180
214
  case "context.browsing":
181
215
  return this.workspaces.lock(async () => browsingContext(await new Store(this.workspaces.base).events(), { limit: input.limit }));
@@ -289,6 +323,23 @@ export class Knowledge {
289
323
  }
290
324
  case "learn.findings":
291
325
  return this.workspaces.document("lessons.md");
326
+ case "persona.show": {
327
+ // Read-only and uncached: the revision returned has to be the one the
328
+ // text came from, or a caller has no way to tell an edit happened
329
+ // between reading the persona and using it.
330
+ const document = await this.workspaces.document("AGENTS.md");
331
+ const surface = personaSurface(BOT_ID, "AGENTS.md", document.revision, document.content, input.budget ?? 6000);
332
+ // The read is recorded. A command that runs afterwards can then tell
333
+ // whether the persona was picked up in this session, which is the
334
+ // difference between "the file is there" and "the agent has it". The
335
+ // record is the revision, so an edit between reading and using is
336
+ // visible rather than assumed away.
337
+ await new Store(this.workspaces.base).append("persona.read", {
338
+ revision: document.revision,
339
+ complete: surface.complete,
340
+ });
341
+ return surface;
342
+ }
292
343
  case "persona.snapshot": {
293
344
  const document = await this.workspaces.document("AGENTS.md");
294
345
  const version = await this.workspaces.snapshot("AGENTS.md", document.content);
@@ -320,6 +371,20 @@ export class Knowledge {
320
371
  return new Growth(this.workspaces).acknowledge(input.event_id ?? "");
321
372
  case "growth.event":
322
373
  return this.workspaces.lock(() => new Growth(this.workspaces).event(input.event_id ?? ""));
374
+ case "auth.grant":
375
+ return this.workspaces.lock(() => grantAccess(new Store(this.workspaces.base), {
376
+ target: input.target,
377
+ actions: input.actions,
378
+ days: input.days,
379
+ per_day: input.per_day,
380
+ reason: input.reason,
381
+ }));
382
+ case "auth.revoke":
383
+ return this.workspaces.lock(() => revokeAccess(new Store(this.workspaces.base), input.resource ?? input.target ?? ""));
384
+ case "auth.grants":
385
+ return this.workspaces.lock(async () => ({
386
+ grants: grants(await new Store(this.workspaces.base).events()),
387
+ }));
323
388
  case "bot.show":
324
389
  return this.workspaces.lock(async () => ({
325
390
  id: BOT_ID,
package/dist/main.js CHANGED
@@ -94,12 +94,22 @@ export function createProgram(io, parentSignal, version = "") {
94
94
  const { app: appOptions, globals } = options(command);
95
95
  const input = { dry_run: globals.dryRun, request_id: globals.requestId };
96
96
  const local = command.opts();
97
+ // Which flags the caller actually typed, as opposed to the defaults the
98
+ // catalog fills in. Resuming a round has to tell "the caller wants thirty
99
+ // works" from "commander put thirty here because nothing was said", or a
100
+ // resumed five-minute round quietly becomes a thirty-minute one.
101
+ const stated = [];
97
102
  for (const flag of spec.flags ?? []) {
98
103
  const option = new Option(flag.name);
99
104
  const value = local[option.attributeName()];
100
- if (value !== undefined)
101
- Object.assign(input, { [flag.key]: value });
105
+ if (value === undefined)
106
+ continue;
107
+ Object.assign(input, { [flag.key]: value });
108
+ if (command.getOptionValueSource(option.attributeName()) === "cli")
109
+ stated.push(flag.key);
102
110
  }
111
+ if (stated.length)
112
+ Object.assign(input, { _stated: stated });
103
113
  if (spec.argument)
104
114
  Object.assign(input, { [spec.argument.key]: values[0] });
105
115
  const signal = AbortSignal.any([