@rubytech/create-maxy-code 0.1.603 → 0.1.605

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.
Files changed (153) hide show
  1. package/dist/__tests__/email-sampler-unit.test.js +22 -0
  2. package/dist/index.js +3 -0
  3. package/dist/port-resolution.js +9 -1
  4. package/package.json +1 -1
  5. package/payload/platform/lib/routine-templates/dist/roster.d.ts +46 -2
  6. package/payload/platform/lib/routine-templates/dist/roster.d.ts.map +1 -1
  7. package/payload/platform/lib/routine-templates/dist/roster.js +84 -32
  8. package/payload/platform/lib/routine-templates/dist/roster.js.map +1 -1
  9. package/payload/platform/lib/routine-templates/src/__tests__/roster.test.ts +0 -0
  10. package/payload/platform/lib/routine-templates/src/__tests__/seed.test.ts +48 -2
  11. package/payload/platform/lib/routine-templates/src/roster.ts +133 -35
  12. package/payload/platform/lib/telegram-store-read/dist/index.d.ts +59 -0
  13. package/payload/platform/lib/telegram-store-read/dist/index.d.ts.map +1 -0
  14. package/payload/platform/lib/telegram-store-read/dist/index.js +219 -0
  15. package/payload/platform/lib/telegram-store-read/dist/index.js.map +1 -0
  16. package/payload/platform/lib/telegram-store-read/src/__tests__/fold.test.ts +124 -0
  17. package/payload/platform/lib/telegram-store-read/src/index.ts +262 -0
  18. package/payload/platform/lib/telegram-store-read/tsconfig.json +9 -0
  19. package/payload/platform/lib/telegram-store-read/vitest.config.ts +9 -0
  20. package/payload/platform/package.json +2 -2
  21. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +39 -6
  22. package/payload/platform/plugins/admin/skills/whats-new/SKILL.md +11 -0
  23. package/payload/platform/plugins/connector/PLUGIN.md +9 -1
  24. package/payload/platform/plugins/connector/mcp/dist/__tests__/queue-descriptor.test.d.ts +2 -0
  25. package/payload/platform/plugins/connector/mcp/dist/__tests__/queue-descriptor.test.d.ts.map +1 -0
  26. package/payload/platform/plugins/connector/mcp/dist/__tests__/queue-descriptor.test.js +67 -0
  27. package/payload/platform/plugins/connector/mcp/dist/__tests__/queue-descriptor.test.js.map +1 -0
  28. package/payload/platform/plugins/connector/mcp/dist/index.js +36 -5
  29. package/payload/platform/plugins/connector/mcp/dist/index.js.map +1 -1
  30. package/payload/platform/plugins/connector/mcp/dist/lib/store.d.ts +35 -0
  31. package/payload/platform/plugins/connector/mcp/dist/lib/store.d.ts.map +1 -1
  32. package/payload/platform/plugins/connector/mcp/dist/lib/store.js +33 -0
  33. package/payload/platform/plugins/connector/mcp/dist/lib/store.js.map +1 -1
  34. package/payload/platform/plugins/connector/mcp/package.json +4 -2
  35. package/payload/platform/plugins/connector/mcp/vitest.config.ts +8 -0
  36. package/payload/platform/plugins/docs/references/admin-ui.md +2 -2
  37. package/payload/platform/plugins/docs/references/outlook-guide.md +21 -2
  38. package/payload/platform/plugins/docs/references/telegram-guide.md +15 -1
  39. package/payload/platform/plugins/email/PLUGIN.md +14 -1
  40. package/payload/platform/plugins/email/mcp/dist/sampler/__tests__/retention.test.d.ts +2 -0
  41. package/payload/platform/plugins/email/mcp/dist/sampler/__tests__/retention.test.d.ts.map +1 -0
  42. package/payload/platform/plugins/email/mcp/dist/sampler/__tests__/retention.test.js +233 -0
  43. package/payload/platform/plugins/email/mcp/dist/sampler/__tests__/retention.test.js.map +1 -0
  44. package/payload/platform/plugins/email/mcp/dist/sampler/__tests__/sample-mailbox.test.js +30 -0
  45. package/payload/platform/plugins/email/mcp/dist/sampler/__tests__/sample-mailbox.test.js.map +1 -1
  46. package/payload/platform/plugins/email/mcp/dist/sampler/main.js +68 -3
  47. package/payload/platform/plugins/email/mcp/dist/sampler/main.js.map +1 -1
  48. package/payload/platform/plugins/email/mcp/dist/sampler/retention-sources.d.ts +3 -0
  49. package/payload/platform/plugins/email/mcp/dist/sampler/retention-sources.d.ts.map +1 -0
  50. package/payload/platform/plugins/email/mcp/dist/sampler/retention-sources.js +146 -0
  51. package/payload/platform/plugins/email/mcp/dist/sampler/retention-sources.js.map +1 -0
  52. package/payload/platform/plugins/email/mcp/dist/sampler/retention.d.ts +150 -0
  53. package/payload/platform/plugins/email/mcp/dist/sampler/retention.d.ts.map +1 -0
  54. package/payload/platform/plugins/email/mcp/dist/sampler/retention.js +199 -0
  55. package/payload/platform/plugins/email/mcp/dist/sampler/retention.js.map +1 -0
  56. package/payload/platform/plugins/email/mcp/dist/sampler/sample-mailbox.d.ts +5 -0
  57. package/payload/platform/plugins/email/mcp/dist/sampler/sample-mailbox.d.ts.map +1 -1
  58. package/payload/platform/plugins/email/mcp/dist/sampler/sample-mailbox.js +10 -5
  59. package/payload/platform/plugins/email/mcp/dist/sampler/sample-mailbox.js.map +1 -1
  60. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-section-properties-strip.test.js +11 -0
  61. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-ingest-section-properties-strip.test.js.map +1 -1
  62. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.d.ts.map +1 -1
  63. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.js +8 -0
  64. package/payload/platform/plugins/memory/mcp/dist/tools/memory-ingest.js.map +1 -1
  65. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sample-outlook-mailbox.test.d.ts +2 -0
  66. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sample-outlook-mailbox.test.d.ts.map +1 -0
  67. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sample-outlook-mailbox.test.js +324 -0
  68. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sample-outlook-mailbox.test.js.map +1 -0
  69. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sampler-cursor.test.d.ts +2 -0
  70. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sampler-cursor.test.d.ts.map +1 -0
  71. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sampler-cursor.test.js +102 -0
  72. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sampler-cursor.test.js.map +1 -0
  73. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sampler-enumerate.test.d.ts +2 -0
  74. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sampler-enumerate.test.d.ts.map +1 -0
  75. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sampler-enumerate.test.js +125 -0
  76. package/payload/platform/plugins/outlook/mcp/dist/__tests__/sampler-enumerate.test.js.map +1 -0
  77. package/payload/platform/plugins/outlook/mcp/dist/sampler/arm.d.ts +2 -0
  78. package/payload/platform/plugins/outlook/mcp/dist/sampler/arm.d.ts.map +1 -0
  79. package/payload/platform/plugins/outlook/mcp/dist/sampler/arm.js +109 -0
  80. package/payload/platform/plugins/outlook/mcp/dist/sampler/arm.js.map +1 -0
  81. package/payload/platform/plugins/outlook/mcp/dist/sampler/cursor.d.ts +25 -0
  82. package/payload/platform/plugins/outlook/mcp/dist/sampler/cursor.d.ts.map +1 -0
  83. package/payload/platform/plugins/outlook/mcp/dist/sampler/cursor.js +94 -0
  84. package/payload/platform/plugins/outlook/mcp/dist/sampler/cursor.js.map +1 -0
  85. package/payload/platform/plugins/outlook/mcp/dist/sampler/enumerate.d.ts +41 -0
  86. package/payload/platform/plugins/outlook/mcp/dist/sampler/enumerate.d.ts.map +1 -0
  87. package/payload/platform/plugins/outlook/mcp/dist/sampler/enumerate.js +122 -0
  88. package/payload/platform/plugins/outlook/mcp/dist/sampler/enumerate.js.map +1 -0
  89. package/payload/platform/plugins/outlook/mcp/dist/sampler/sample-outlook-mailbox.d.ts +73 -0
  90. package/payload/platform/plugins/outlook/mcp/dist/sampler/sample-outlook-mailbox.d.ts.map +1 -0
  91. package/payload/platform/plugins/outlook/mcp/dist/sampler/sample-outlook-mailbox.js +162 -0
  92. package/payload/platform/plugins/outlook/mcp/dist/sampler/sample-outlook-mailbox.js.map +1 -0
  93. package/payload/platform/plugins/scheduling/PLUGIN.md +8 -1
  94. package/payload/platform/plugins/scheduling/mcp/dist/index.js +1 -1
  95. package/payload/platform/plugins/scheduling/mcp/dist/index.js.map +1 -1
  96. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate-flag.test.d.ts +2 -0
  97. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate-flag.test.d.ts.map +1 -0
  98. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate-flag.test.js +90 -0
  99. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate-flag.test.js.map +1 -0
  100. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate.test.js +26 -4
  101. package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/gate.test.js.map +1 -1
  102. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate-flag.d.ts +78 -0
  103. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate-flag.d.ts.map +1 -0
  104. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate-flag.js +76 -0
  105. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate-flag.js.map +1 -0
  106. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.d.ts +8 -0
  107. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.d.ts.map +1 -1
  108. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.js +9 -1
  109. package/payload/platform/plugins/scheduling/mcp/dist/lib/gate.js.map +1 -1
  110. package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/gate-dispatch-wiring.test.js +15 -0
  111. package/payload/platform/plugins/scheduling/mcp/dist/scripts/__tests__/gate-dispatch-wiring.test.js.map +1 -1
  112. package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.d.ts +12 -1
  113. package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.d.ts.map +1 -1
  114. package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.js +34 -1
  115. package/payload/platform/plugins/scheduling/mcp/dist/scripts/agent-turn-dispatch.js.map +1 -1
  116. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js +9 -1
  117. package/payload/platform/plugins/scheduling/mcp/dist/scripts/check-due-events.js.map +1 -1
  118. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-gate-write.test.js +40 -0
  119. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/schedule-gate-write.test.js.map +1 -1
  120. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/scheduling-gate.test.js +435 -5
  121. package/payload/platform/plugins/scheduling/mcp/dist/tools/__tests__/scheduling-gate.test.js.map +1 -1
  122. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.d.ts.map +1 -1
  123. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.js +21 -1
  124. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.js.map +1 -1
  125. package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.d.ts +294 -6
  126. package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.d.ts.map +1 -1
  127. package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.js +553 -11
  128. package/payload/platform/plugins/scheduling/mcp/dist/tools/scheduling-gate.js.map +1 -1
  129. package/payload/platform/plugins/telegram/PLUGIN.md +1 -1
  130. package/payload/platform/plugins/telegram/mcp/dist/__tests__/messages.test.d.ts +2 -0
  131. package/payload/platform/plugins/telegram/mcp/dist/__tests__/messages.test.d.ts.map +1 -0
  132. package/payload/platform/plugins/telegram/mcp/dist/__tests__/messages.test.js +157 -0
  133. package/payload/platform/plugins/telegram/mcp/dist/__tests__/messages.test.js.map +1 -0
  134. package/payload/platform/plugins/telegram/mcp/dist/__tests__/public-pin.test.js +2 -2
  135. package/payload/platform/plugins/telegram/mcp/dist/__tests__/public-pin.test.js.map +1 -1
  136. package/payload/platform/plugins/telegram/mcp/dist/index.js +32 -34
  137. package/payload/platform/plugins/telegram/mcp/dist/index.js.map +1 -1
  138. package/payload/platform/plugins/telegram/mcp/dist/lib/public-pin.js +1 -1
  139. package/payload/platform/plugins/telegram/mcp/dist/lib/public-pin.js.map +1 -1
  140. package/payload/platform/plugins/telegram/mcp/dist/tools/messages.d.ts +27 -0
  141. package/payload/platform/plugins/telegram/mcp/dist/tools/messages.d.ts.map +1 -0
  142. package/payload/platform/plugins/telegram/mcp/dist/tools/messages.js +152 -0
  143. package/payload/platform/plugins/telegram/mcp/dist/tools/messages.js.map +1 -0
  144. package/payload/platform/plugins/telegram/skills/build/SKILL.md +1 -1
  145. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +1 -1
  146. package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
  147. package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -1
  148. package/payload/server/public/operator.html +1 -1
  149. package/payload/server/server.js +69 -5
  150. package/payload/platform/plugins/telegram/mcp/dist/tools/message-history.d.ts +0 -16
  151. package/payload/platform/plugins/telegram/mcp/dist/tools/message-history.d.ts.map +0 -1
  152. package/payload/platform/plugins/telegram/mcp/dist/tools/message-history.js +0 -68
  153. package/payload/platform/plugins/telegram/mcp/dist/tools/message-history.js.map +0 -1
@@ -0,0 +1,124 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { conversationCensus, readFoldedConversation } from "../index.js";
6
+
7
+ function seed(rows: unknown[], stem = "111_222"): string {
8
+ const dir = mkdtempSync(join(tmpdir(), "tg-store-"));
9
+ writeFileSync(join(dir, `${stem}.jsonl`), rows.map((r) => JSON.stringify(r)).join("\n") + "\n");
10
+ return dir;
11
+ }
12
+
13
+ const msg = (over: Record<string, unknown> = {}) => ({
14
+ messageId: "m1",
15
+ sessionId: "s",
16
+ dateSent: "2026-08-13T17:00:00.000Z",
17
+ body: "hello",
18
+ fromMe: false,
19
+ senderId: "222",
20
+ senderName: "Mcha",
21
+ chatId: "222",
22
+ channelKey: "111:222",
23
+ scope: "admin",
24
+ origin: "inbound",
25
+ createdAt: "2026-08-13T17:00:00.000Z",
26
+ ...over,
27
+ });
28
+
29
+ describe("readFoldedConversation", () => {
30
+ it("returns both directions in dateSent order", () => {
31
+ const dir = seed([
32
+ msg({
33
+ messageId: "m2",
34
+ dateSent: "2026-08-13T17:01:00.000Z",
35
+ origin: "agent",
36
+ fromMe: true,
37
+ body: "hi back",
38
+ }),
39
+ msg(),
40
+ ]);
41
+ const out = readFoldedConversation(dir, "111:222");
42
+ expect(out.map((m) => m.body)).toEqual(["hello", "hi back"]);
43
+ expect(out.map((m) => m.origin)).toEqual(["inbound", "agent"]);
44
+ });
45
+
46
+ it("lets a later update row replace the body", () => {
47
+ const dir = seed([
48
+ msg({ body: "[voice note]" }),
49
+ { kind: "update", targetMessageId: "m1", body: "the transcript", createdAt: "x" },
50
+ ]);
51
+ expect(readFoldedConversation(dir, "111:222")[0].body).toBe("the transcript");
52
+ });
53
+
54
+ it("keeps deleted sticky once a later patch arrives", () => {
55
+ const dir = seed([
56
+ msg(),
57
+ { kind: "update", targetMessageId: "m1", deleted: true, createdAt: "x" },
58
+ { kind: "update", targetMessageId: "m1", attachmentIds: ["a1"], createdAt: "x" },
59
+ ]);
60
+ expect(readFoldedConversation(dir, "111:222")[0].deleted).toBe(true);
61
+ });
62
+
63
+ it("de-dupes attachment ids across repeated patches", () => {
64
+ const dir = seed([
65
+ msg(),
66
+ { kind: "update", targetMessageId: "m1", attachmentIds: ["a1"], createdAt: "x" },
67
+ { kind: "update", targetMessageId: "m1", attachmentIds: ["a1", "a2"], createdAt: "x" },
68
+ ]);
69
+ expect(readFoldedConversation(dir, "111:222")[0].attachmentIds).toEqual(["a1", "a2"]);
70
+ });
71
+
72
+ it("lets a later update row replace the buttons", () => {
73
+ const dir = seed([
74
+ msg({ buttons: [{ text: "old", data: "o" }] }),
75
+ { kind: "update", targetMessageId: "m1", buttons: [{ text: "new", data: "n" }], createdAt: "x" },
76
+ ]);
77
+ expect(readFoldedConversation(dir, "111:222")[0].buttons).toEqual([{ text: "new", data: "n" }]);
78
+ });
79
+
80
+ it("refuses an unstorable channel key rather than building a path", () => {
81
+ const dir = seed([msg()]);
82
+ expect(readFoldedConversation(dir, "../../etc:222")).toEqual([]);
83
+ });
84
+
85
+ it("returns nothing for a conversation with no file, without throwing", () => {
86
+ const dir = seed([msg()]);
87
+ expect(readFoldedConversation(dir, "111:999")).toEqual([]);
88
+ });
89
+
90
+ it("skips one corrupt line and keeps the rest", () => {
91
+ const dir = mkdtempSync(join(tmpdir(), "tg-store-"));
92
+ writeFileSync(join(dir, "111_222.jsonl"), JSON.stringify(msg()) + "\n{not json\n");
93
+ expect(readFoldedConversation(dir, "111:222")).toHaveLength(1);
94
+ });
95
+
96
+ it("reads a business conversation under its three-part key", () => {
97
+ const dir = seed([msg({ channelKey: "111:biz:222" })], "111_biz_222");
98
+ expect(readFoldedConversation(dir, "111:biz:222")).toHaveLength(1);
99
+ });
100
+ });
101
+
102
+ describe("conversationCensus", () => {
103
+ it("counts conversations and message rows, and reports the newest dateSent", () => {
104
+ const dir = seed([
105
+ msg(),
106
+ msg({ messageId: "m2", dateSent: "2026-08-13T17:05:00.000Z" }),
107
+ { kind: "update", targetMessageId: "m1", body: "patched", createdAt: "x" },
108
+ ]);
109
+ writeFileSync(join(dir, "111_333.jsonl"), JSON.stringify(msg({ chatId: "333", senderId: "333" })) + "\n");
110
+ const census = conversationCensus(dir);
111
+ expect(census.conversations).toBe(2);
112
+ // The update row is a patch, not a message: counting it would inflate the total.
113
+ expect(census.rows).toBe(3);
114
+ expect(census.newest).toBe("2026-08-13T17:05:00.000Z");
115
+ });
116
+
117
+ it("reports zeroes and a null newest for a directory that does not exist", () => {
118
+ expect(conversationCensus(join(tmpdir(), "tg-store-absent-xyz"))).toEqual({
119
+ conversations: 0,
120
+ rows: 0,
121
+ newest: null,
122
+ });
123
+ });
124
+ });
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Task 2709 — reading one Telegram conversation back off disk, patches folded.
3
+ *
4
+ * The store is the only complete record of a Telegram conversation. No platform
5
+ * path writes a `:Message` node for Telegram: `persistMessage` in
6
+ * `ui/app/lib/neo4j-store.ts` has one caller, the webchat customer-inbound path,
7
+ * so a `:Message` carrying `channel:'telegram'` was written by an agent calling
8
+ * `memory-write` and records only what that agent chose to save. On 2026-08-13
9
+ * that was every inbound and one of ten outbound, and the agent read it back as
10
+ * the whole conversation.
11
+ *
12
+ * A message row is never rewritten once appended — `telegram-reader.ts` streams
13
+ * byte offsets as SSE resume ids, so a read-modify-write would invalidate every
14
+ * offset behind it. A correction is therefore a SECOND line, a
15
+ * `TelegramUpdateRecord`, naming its target. Reading the message rows alone
16
+ * yields the pre-patch text: a voice note's placeholder instead of its
17
+ * transcript, an edited message's original wording, and a withdrawn message with
18
+ * nothing saying it was withdrawn. Folding is what makes the read truthful,
19
+ * which is why this is a library and not four lines at the call site.
20
+ *
21
+ * Attachment ids come back RAW. `telegram-reader.ts` resolves each one to
22
+ * servable metadata against an uploads directory, because it is answering a
23
+ * browser; an agent has no use for that URL, and importing the uploads directory
24
+ * into the MCP would drag an HTTP concern across the plugin boundary. The three
25
+ * rules that ARE shared — body last-write-wins, buttons last-write-wins, deleted
26
+ * sticky-true — are pinned against the route by
27
+ * `ui/app/lib/telegram/__tests__/store-read-boundary.test.ts`.
28
+ *
29
+ * The key and filename rules are imported from `telegram-reach` rather than
30
+ * restated. That module is already the one derivation the store writer uses, and
31
+ * Task 2644 is the record of what a second derivation costs.
32
+ */
33
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
34
+ import { join } from "node:path";
35
+ import {
36
+ channelKeyToFileStem,
37
+ fileStemToChannelKey,
38
+ isStorableChannelKey,
39
+ } from "../../telegram-reach/dist/index.js";
40
+
41
+ /** One stored message with every patch that names it already applied. */
42
+ export interface FoldedMessage {
43
+ messageId: string;
44
+ /** ISO 8601, this install's clock at write time — NOT Telegram's own second. */
45
+ dateSent: string;
46
+ /** The patched body when an update replaced it, else the row's own. */
47
+ body: string;
48
+ /** 'inbound' | 'agent' | 'operator-manual'. Kept as the string the store
49
+ * holds: a reader that meets an origin this build does not know should show
50
+ * it, not drop the message. */
51
+ origin: string;
52
+ fromMe: boolean;
53
+ senderId: string;
54
+ senderName: string | null;
55
+ /** The sender withdrew this at their end. The body is KEPT: Telegram sends
56
+ * none on a deletion, so the stored body is the only account anywhere of what
57
+ * was withdrawn. */
58
+ deleted: boolean;
59
+ /** Servable attachment ids in arrival order, de-duplicated. Raw ids, never
60
+ * resolved metadata — see the file header. */
61
+ attachmentIds: string[];
62
+ /** A card's inline-keyboard labels. Null on an ordinary message that never
63
+ * carried a keyboard; an EMPTY ARRAY when an edit removed the one it had.
64
+ * The two are different facts and the distinction is the route's, kept here
65
+ * so the agent and the operator cannot read one card two ways. */
66
+ buttons: { text: string; data: string | null }[] | null;
67
+ }
68
+
69
+ export interface ConversationCensus {
70
+ /** Store files in this directory holding a storable channel key. */
71
+ conversations: number;
72
+ /** Message rows across all of them. Update rows are patches, not messages, and
73
+ * counting them would inflate the total. */
74
+ rows: number;
75
+ /** Newest `dateSent` anywhere in the directory, or null when empty. */
76
+ newest: string | null;
77
+ }
78
+
79
+ /** The fields this module reads. Declared structurally rather than imported from
80
+ * `ui/app/lib/telegram/message-store.ts`: the plugin cannot import from
81
+ * `platform/ui/app`, which is the same boundary `telegram-reach` sits behind. */
82
+ interface StoreRowLike {
83
+ kind?: unknown;
84
+ messageId?: unknown;
85
+ targetMessageId?: unknown;
86
+ dateSent?: unknown;
87
+ body?: unknown;
88
+ fromMe?: unknown;
89
+ senderId?: unknown;
90
+ senderName?: unknown;
91
+ origin?: unknown;
92
+ deleted?: unknown;
93
+ attachmentIds?: unknown;
94
+ buttons?: unknown;
95
+ }
96
+
97
+ function parseLines(raw: string): StoreRowLike[] {
98
+ const out: StoreRowLike[] = [];
99
+ for (const line of raw.split("\n")) {
100
+ if (!line.trim()) continue;
101
+ try {
102
+ out.push(JSON.parse(line) as StoreRowLike);
103
+ } catch {
104
+ // A partial write from a crash mid-append is skipped, never fatal: one
105
+ // corrupt line must not hide every other message in the conversation.
106
+ }
107
+ }
108
+ return out;
109
+ }
110
+
111
+ function isUpdateRow(row: StoreRowLike): boolean {
112
+ return row.kind === "update";
113
+ }
114
+
115
+ function readButtons(value: unknown): { text: string; data: string | null }[] | null {
116
+ if (!Array.isArray(value)) return null;
117
+ const out: { text: string; data: string | null }[] = [];
118
+ for (const b of value) {
119
+ if (!b || typeof b !== "object") continue;
120
+ const { text, data } = b as { text?: unknown; data?: unknown };
121
+ if (typeof text !== "string") continue;
122
+ out.push({ text, data: typeof data === "string" ? data : null });
123
+ }
124
+ return out.length > 0 ? out : null;
125
+ }
126
+
127
+ interface Patch {
128
+ body?: string;
129
+ buttons?: { text: string; data: string | null }[];
130
+ deleted?: true;
131
+ attachmentIds: string[];
132
+ }
133
+
134
+ /** Fold every update row into one patch per target id.
135
+ *
136
+ * Mirrors `telegram-reader.ts:77-106`. Later rows win for the body and the
137
+ * buttons. Attachment ids accumulate in arrival order and are de-duplicated per
138
+ * target, because two rows can legitimately name the same id: `appendMessage`
139
+ * dedupes a Telegram redelivery, but the route does not read that result — it
140
+ * downloads again, resolves the SAME deterministic id, and patches again.
141
+ * `deleted` is sticky: a withdrawal is not undone by a later transcript or
142
+ * media patch, and letting one clear it would hide the withdrawal. */
143
+ function foldPatches(rows: StoreRowLike[]): Map<string, Patch> {
144
+ const out = new Map<string, Patch>();
145
+ for (const row of rows) {
146
+ if (!isUpdateRow(row)) continue;
147
+ if (typeof row.targetMessageId !== "string") continue;
148
+ const patch = out.get(row.targetMessageId) ?? { attachmentIds: [] };
149
+ if (Array.isArray(row.attachmentIds)) {
150
+ for (const id of row.attachmentIds) {
151
+ if (typeof id !== "string") continue;
152
+ if (patch.attachmentIds.includes(id)) continue;
153
+ patch.attachmentIds.push(id);
154
+ }
155
+ }
156
+ if (typeof row.body === "string") patch.body = row.body;
157
+ // Keyed on the field BEING an array, not on it holding anything. An operator
158
+ // editing a card down to no buttons produces `buttons: []`, and that is a
159
+ // removal: the route reads it as one, because an empty array is truthy
160
+ // there. Treating it as "no patch" would keep showing the agent buttons the
161
+ // customer can no longer see, with nothing failing while the two surfaces
162
+ // disagreed.
163
+ if (Array.isArray(row.buttons)) patch.buttons = readButtons(row.buttons) ?? [];
164
+ if (row.deleted === true) patch.deleted = true;
165
+ out.set(row.targetMessageId, patch);
166
+ }
167
+ return out;
168
+ }
169
+
170
+ function conversationFile(storeDir: string, channelKey: string): string | null {
171
+ // The guard, not the path. Both halves of a key are Telegram numeric ids
172
+ // around an optional literal `biz`, so a key that passes cannot carry a `.`,
173
+ // `/` or `\`, and one that fails is refused rather than sanitised into a path
174
+ // nobody asked for.
175
+ if (!isStorableChannelKey(channelKey)) return null;
176
+ return join(storeDir, `${channelKeyToFileStem(channelKey)}.jsonl`);
177
+ }
178
+
179
+ /**
180
+ * One conversation, oldest first, with every patch applied.
181
+ *
182
+ * Empty for an unstorable key, a missing directory and a conversation with no
183
+ * file. Those are three different facts, and none of them is an error the caller
184
+ * can act on differently: all three mean there is nothing stored to show.
185
+ */
186
+ export function readFoldedConversation(storeDir: string, channelKey: string): FoldedMessage[] {
187
+ const file = conversationFile(storeDir, channelKey);
188
+ if (file === null || !existsSync(file)) return [];
189
+ let raw: string;
190
+ try {
191
+ raw = readFileSync(file, "utf8");
192
+ } catch {
193
+ return [];
194
+ }
195
+ const rows = parseLines(raw);
196
+ const patches = foldPatches(rows);
197
+ const out: FoldedMessage[] = [];
198
+ for (const row of rows) {
199
+ if (isUpdateRow(row)) continue;
200
+ if (typeof row.messageId !== "string") continue;
201
+ if (typeof row.dateSent !== "string") continue;
202
+ const patch = patches.get(row.messageId);
203
+ out.push({
204
+ messageId: row.messageId,
205
+ dateSent: row.dateSent,
206
+ body: patch?.body ?? (typeof row.body === "string" ? row.body : ""),
207
+ origin: typeof row.origin === "string" ? row.origin : "inbound",
208
+ fromMe: row.fromMe === true,
209
+ senderId: typeof row.senderId === "string" ? row.senderId : "",
210
+ senderName: typeof row.senderName === "string" ? row.senderName : null,
211
+ deleted: patch?.deleted === true,
212
+ attachmentIds: patch?.attachmentIds ?? [],
213
+ buttons: patch?.buttons ?? readButtons(row.buttons),
214
+ });
215
+ }
216
+ // Sort AFTER folding, so a patch that arrived before its target in file order
217
+ // still lands on it. Ties keep file order, which is arrival order.
218
+ return out.sort((a, b) => a.dateSent.localeCompare(b.dateSent));
219
+ }
220
+
221
+ /**
222
+ * What this account's store actually holds.
223
+ *
224
+ * `conversations` beside `rows` is load-bearing. A tool that returns nothing
225
+ * looks identical to a conversation that is empty, and `conversations=0
226
+ * rows=0` is also exactly what a reader pointed at the wrong directory prints —
227
+ * which is the failure Task 2644 shipped and Task 2557 named. With both numbers,
228
+ * a zero result on an account showing `conversations>0` is a wrong chat id, and
229
+ * `conversations=0` on a live account is a dead reader.
230
+ */
231
+ export function conversationCensus(storeDir: string): ConversationCensus {
232
+ const empty: ConversationCensus = { conversations: 0, rows: 0, newest: null };
233
+ if (!storeDir || !existsSync(storeDir)) return empty;
234
+ let entries: string[];
235
+ try {
236
+ entries = readdirSync(storeDir);
237
+ } catch {
238
+ return empty;
239
+ }
240
+ let conversations = 0;
241
+ let rows = 0;
242
+ let newest: string | null = null;
243
+ for (const entry of entries) {
244
+ if (!entry.endsWith(".jsonl")) continue;
245
+ const channelKey = fileStemToChannelKey(entry.slice(0, -".jsonl".length));
246
+ if (!isStorableChannelKey(channelKey)) continue;
247
+ conversations += 1;
248
+ let raw: string;
249
+ try {
250
+ raw = readFileSync(join(storeDir, entry), "utf8");
251
+ } catch {
252
+ continue;
253
+ }
254
+ for (const row of parseLines(raw)) {
255
+ if (isUpdateRow(row)) continue;
256
+ rows += 1;
257
+ if (typeof row.dateSent !== "string") continue;
258
+ if (newest === null || newest.localeCompare(row.dateSent) < 0) newest = row.dateSent;
259
+ }
260
+ }
261
+ return { conversations, rows, newest };
262
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src"],
8
+ "exclude": ["src/__tests__"]
9
+ }
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: "node",
6
+ globals: false,
7
+ include: ["src/__tests__/**/*.test.ts"],
8
+ },
9
+ });
@@ -10,8 +10,8 @@
10
10
  "pdf-lib": "^1.17.1"
11
11
  },
12
12
  "scripts": {
13
- "build": "tsc -p lib/account-schema-regions/tsconfig.json && tsc -p lib/account-ingest-token/tsconfig.json && tsc -p lib/models/tsconfig.json && tsc -p lib/agent-slug/tsconfig.json && tsc -p lib/mcp-spawn-tee/tsconfig.json && tsc -p lib/mcp-lifeline/tsconfig.json && tsc -p lib/mcp-eager/tsconfig.json && tsc -p lib/account-enumeration/tsconfig.json && tsc -p lib/account-scope/tsconfig.json && tsc -p lib/shared-folder/tsconfig.json && tsc -p lib/graph-write/tsconfig.json && tsc -p lib/ledger-core/tsconfig.json && tsc -p lib/embed-client/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/graph-trash/tsconfig.json && tsc -p lib/active-rules/tsconfig.json && tsc -p lib/admin-conversation-purge/tsconfig.json && tsc -p lib/graph-search/tsconfig.json && tsc -p lib/graph-style/tsconfig.json && tsc -p lib/device-url/tsconfig.json && tsc -p lib/brand-templating/tsconfig.json && tsc -p lib/entitlement/tsconfig.json && tsc -p lib/task-secrets/tsconfig.json && tsc -p lib/admins-write/tsconfig.json && tsc -p lib/admin-access-password/tsconfig.json && tsc -p lib/persistent-components/tsconfig.json && tsc -p lib/require-port-env/tsconfig.json && tsc -p lib/aeo-llms-txt-writer/tsconfig.json && tsc -p lib/obsidian-parser/tsconfig.json && tsc -p lib/storage-broker/tsconfig.json && tsc -p lib/routine-templates/tsconfig.json && tsc -p lib/agent-dispatch-rule/tsconfig.json && tsc -p lib/canonical-instant/tsconfig.json && tsc -p lib/dispatch-read/tsconfig.json && tsc -p lib/dispatch-write/tsconfig.json && tsc -p lib/learning-constants/tsconfig.json && tsc -p lib/telegram-button-labels/tsconfig.json && tsc -p lib/telegram-media/tsconfig.json && tsc -p lib/telegram-managed-bot/tsconfig.json && tsc -p lib/telegram-reach/tsconfig.json && tsc -p services/claude-session-manager/tsconfig.json && tsc -p services/whatsapp-channel/tsconfig.json && tsc -p services/webchat-channel/tsconfig.json && tsc -p services/telegram-channel/tsconfig.json && NODE_OPTIONS='--max-old-space-size=8192' tsc -b plugins/*/mcp/tsconfig.json",
14
- "build:lib": "tsc -p lib/account-schema-regions/tsconfig.json && tsc -p lib/account-ingest-token/tsconfig.json && tsc -p lib/models/tsconfig.json && tsc -p lib/agent-slug/tsconfig.json && tsc -p lib/mcp-spawn-tee/tsconfig.json && tsc -p lib/mcp-lifeline/tsconfig.json && tsc -p lib/mcp-eager/tsconfig.json && tsc -p lib/account-enumeration/tsconfig.json && tsc -p lib/account-scope/tsconfig.json && tsc -p lib/shared-folder/tsconfig.json && tsc -p lib/graph-write/tsconfig.json && tsc -p lib/ledger-core/tsconfig.json && tsc -p lib/embed-client/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/graph-trash/tsconfig.json && tsc -p lib/active-rules/tsconfig.json && tsc -p lib/admin-conversation-purge/tsconfig.json && tsc -p lib/graph-search/tsconfig.json && tsc -p lib/graph-style/tsconfig.json && tsc -p lib/device-url/tsconfig.json && tsc -p lib/brand-templating/tsconfig.json && tsc -p lib/entitlement/tsconfig.json && tsc -p lib/task-secrets/tsconfig.json && tsc -p lib/admins-write/tsconfig.json && tsc -p lib/admin-access-password/tsconfig.json && tsc -p lib/persistent-components/tsconfig.json && tsc -p lib/require-port-env/tsconfig.json && tsc -p lib/aeo-llms-txt-writer/tsconfig.json && tsc -p lib/obsidian-parser/tsconfig.json && tsc -p lib/storage-broker/tsconfig.json && tsc -p lib/routine-templates/tsconfig.json && tsc -p lib/agent-dispatch-rule/tsconfig.json && tsc -p lib/shared-folder/tsconfig.json && tsc -p lib/canonical-instant/tsconfig.json && tsc -p lib/dispatch-read/tsconfig.json && tsc -p lib/dispatch-write/tsconfig.json && tsc -p lib/telegram-button-labels/tsconfig.json && tsc -p lib/telegram-media/tsconfig.json && tsc -p lib/telegram-managed-bot/tsconfig.json && tsc -p lib/telegram-reach/tsconfig.json",
13
+ "build": "tsc -p lib/account-schema-regions/tsconfig.json && tsc -p lib/account-ingest-token/tsconfig.json && tsc -p lib/models/tsconfig.json && tsc -p lib/agent-slug/tsconfig.json && tsc -p lib/mcp-spawn-tee/tsconfig.json && tsc -p lib/mcp-lifeline/tsconfig.json && tsc -p lib/mcp-eager/tsconfig.json && tsc -p lib/account-enumeration/tsconfig.json && tsc -p lib/account-scope/tsconfig.json && tsc -p lib/shared-folder/tsconfig.json && tsc -p lib/graph-write/tsconfig.json && tsc -p lib/ledger-core/tsconfig.json && tsc -p lib/embed-client/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/graph-trash/tsconfig.json && tsc -p lib/active-rules/tsconfig.json && tsc -p lib/admin-conversation-purge/tsconfig.json && tsc -p lib/graph-search/tsconfig.json && tsc -p lib/graph-style/tsconfig.json && tsc -p lib/device-url/tsconfig.json && tsc -p lib/brand-templating/tsconfig.json && tsc -p lib/entitlement/tsconfig.json && tsc -p lib/task-secrets/tsconfig.json && tsc -p lib/admins-write/tsconfig.json && tsc -p lib/admin-access-password/tsconfig.json && tsc -p lib/persistent-components/tsconfig.json && tsc -p lib/require-port-env/tsconfig.json && tsc -p lib/aeo-llms-txt-writer/tsconfig.json && tsc -p lib/obsidian-parser/tsconfig.json && tsc -p lib/storage-broker/tsconfig.json && tsc -p lib/routine-templates/tsconfig.json && tsc -p lib/agent-dispatch-rule/tsconfig.json && tsc -p lib/canonical-instant/tsconfig.json && tsc -p lib/dispatch-read/tsconfig.json && tsc -p lib/dispatch-write/tsconfig.json && tsc -p lib/learning-constants/tsconfig.json && tsc -p lib/telegram-button-labels/tsconfig.json && tsc -p lib/telegram-media/tsconfig.json && tsc -p lib/telegram-managed-bot/tsconfig.json && tsc -p lib/telegram-reach/tsconfig.json && tsc -p lib/telegram-store-read/tsconfig.json && tsc -p services/claude-session-manager/tsconfig.json && tsc -p services/whatsapp-channel/tsconfig.json && tsc -p services/webchat-channel/tsconfig.json && tsc -p services/telegram-channel/tsconfig.json && NODE_OPTIONS='--max-old-space-size=8192' tsc -b plugins/*/mcp/tsconfig.json",
14
+ "build:lib": "tsc -p lib/account-schema-regions/tsconfig.json && tsc -p lib/account-ingest-token/tsconfig.json && tsc -p lib/models/tsconfig.json && tsc -p lib/agent-slug/tsconfig.json && tsc -p lib/mcp-spawn-tee/tsconfig.json && tsc -p lib/mcp-lifeline/tsconfig.json && tsc -p lib/mcp-eager/tsconfig.json && tsc -p lib/account-enumeration/tsconfig.json && tsc -p lib/account-scope/tsconfig.json && tsc -p lib/shared-folder/tsconfig.json && tsc -p lib/graph-write/tsconfig.json && tsc -p lib/ledger-core/tsconfig.json && tsc -p lib/embed-client/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/graph-trash/tsconfig.json && tsc -p lib/active-rules/tsconfig.json && tsc -p lib/admin-conversation-purge/tsconfig.json && tsc -p lib/graph-search/tsconfig.json && tsc -p lib/graph-style/tsconfig.json && tsc -p lib/device-url/tsconfig.json && tsc -p lib/brand-templating/tsconfig.json && tsc -p lib/entitlement/tsconfig.json && tsc -p lib/task-secrets/tsconfig.json && tsc -p lib/admins-write/tsconfig.json && tsc -p lib/admin-access-password/tsconfig.json && tsc -p lib/persistent-components/tsconfig.json && tsc -p lib/require-port-env/tsconfig.json && tsc -p lib/aeo-llms-txt-writer/tsconfig.json && tsc -p lib/obsidian-parser/tsconfig.json && tsc -p lib/storage-broker/tsconfig.json && tsc -p lib/routine-templates/tsconfig.json && tsc -p lib/agent-dispatch-rule/tsconfig.json && tsc -p lib/shared-folder/tsconfig.json && tsc -p lib/canonical-instant/tsconfig.json && tsc -p lib/dispatch-read/tsconfig.json && tsc -p lib/dispatch-write/tsconfig.json && tsc -p lib/telegram-button-labels/tsconfig.json && tsc -p lib/telegram-media/tsconfig.json && tsc -p lib/telegram-managed-bot/tsconfig.json && tsc -p lib/telegram-reach/tsconfig.json && tsc -p lib/telegram-store-read/tsconfig.json",
15
15
  "clean:dist": "node scripts/check-orphan-dist.mjs --remove",
16
16
  "gen:canonical-tools": "node scripts/generate-canonical-tool-names.mjs",
17
17
  "build:memory": "tsc -p plugins/memory/mcp/tsconfig.json",
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: platform-architecture
3
3
  description: Use when grounding any documented-surface claim about what Maxy ships — plugins, skills, specialists, install/deploy flows, internals. This is the install catalogue, not evidence of what is enabled on the current account. For install state on this account, call `capabilities-here`; for documented surface, cite the `Source:` URL inline.
4
- content-hash: sha256:2a7e28897d543df02b543552d72a457eca3956ab090a4bc5b886b487eb950d3e
4
+ content-hash: sha256:6af2c17c66037f12d4a3172828a7db1fef57c3a15e9be8264072e9ae2ae5b362
5
5
  brand: maxy-code
6
6
  product-name: Maxy
7
7
  ---
@@ -2971,7 +2971,21 @@ A group's ID is a long negative number, like `-1001234567890`. To find it, @ment
2971
2971
 
2972
2972
  ## Message History
2973
2973
 
2974
- Ask Maxy: "What messages has the bot received?" or "Show recent Telegram activity."
2974
+ Ask Maxy to read a conversation back: "What did I say to Sam on Telegram?" or "Show me the whole
2975
+ chat with that driver." It returns the conversation both ways round, oldest first: every message the bot
2976
+ received and every one it sent. A voice note reads as its transcript, an edited message as its new wording,
2977
+ and a message the other person deleted is shown struck out rather than quietly dropped.
2978
+
2979
+ Give it the chat id, which you can get from "Who can you reach on Telegram?". In a group, the id is the
2980
+ group's own.
2981
+
2982
+ Two things to know:
2983
+
2984
+ - **History starts when this machine started storing it.** A conversation that predates the install, or that
2985
+ ran while the machine was off, is not there to read. Telegram's Bot API offers no way to fetch it back.
2986
+ - **If the same chat id belongs to two conversations, it will ask which you meant** rather than guess. That
2987
+ happens when two of your bots both talk to the same person, and when a connected business account's
2988
+ customer has the same id as your own chat with that bot.
2975
2989
 
2976
2990
  ## Troubleshooting
2977
2991
 
@@ -3130,7 +3144,7 @@ The `outlook` plugin gives the admin agent access to Microsoft 365 / Outlook.com
3130
3144
  | `outlook-mail-attachment` | List or download any message's attachments — received or draft. With `messageId` alone, LISTS them (`id`, `name`, `contentType`, `size`, `isInline`, and a `kind` of file/item/reference) via `GET /me/messages/{id}/attachments` and `$select` (no bytes). A draft is a message in the same collection, so passing a draftId lists what that draft carries; the returned `name`/`id` is what `outlook-draft-edit`'s `removeAttachments` takes. With `messageId`+`attachmentId`, DOWNLOADS one file attachment: its bytes are written to `{accountDir}/uploads/outlook/<messageHash>/<bytesHash>-<name>` at `0o600` and the saved path is returned, ready for the Read tool, `SendUserFile`, or an `outlook-mail-reply` attachment. Only file attachments download — item/reference kinds, empty payloads, and anything over 25 MB are refused with nothing written. `outlook-mail-list` / `outlook-mail-search` / `outlook-mail-fetch-body` report `hasAttachments` so the agent knows when to call this. Needs `Mail.Read`. |
3131
3145
  | `outlook-mail-reply` | Reply in-thread (`createReply` / `createReplyAll` + send). Threads natively via conversationId. `replyAll`, added cc/bcc, and account-scoped file attachments (up to 25 MB per file; files over Graph's ~3 MB inline limit use a chunked upload session). Body is plain text, prepended above the quote. A reply whose recipients resolve **only** to the sending mailbox is **refused** before the send and its draft deleted: Graph addresses a reply to the original message's sender, so replying to your own sent message addresses it to yourself. An explicit `cc` does not exempt it — use `outlook-mail-send` with an explicit `to`. The resolved recipients are logged as `to=`. Needs `Mail.ReadWrite` + `Mail.Send`. |
3132
3146
  | `outlook-mail-delete` | Move messages to Deleted Items (`POST /me/messages/{id}/move`). Recoverable — never hard-deletes. An id already gone is counted not-moved. Needs `Mail.ReadWrite`. |
3133
- | `outlook-mail-ingest` | Write operator-reviewed messages into the business graph as `:ConversationArchive {source:'email'}` threads (the analogue of the IMAP `email-ingest`). Input `decisions`: one `{messageId, disposition:'ingest'\|'discard'}` per listed message. Each `ingest` message's full body + envelope is fetched, threads group by Graph `conversationId`, and participants resolve closed-set against Neo4j (`:Person`/`:AdminUser` by `accountId`); an unresolved From/To/Cc address skips its whole thread and is returned to the operator (no `:Person` auto-create). Resolvable threads dispatch through the shared `conversation-archive-ingest.sh --source email`. Human-in-the-loop: nothing lands without an explicit per-message decision. The plugin's only Neo4j coupling. Needs `Mail.Read`. |
3147
+ | `outlook-mail-ingest` | Write operator-reviewed messages into the business graph as `:ConversationArchive {source:'email'}` threads (the analogue of the IMAP `email-ingest`). Input `decisions`: one `{messageId, disposition:'ingest'\|'discard'}` per listed message. Each `ingest` message's full body + envelope is fetched, threads group by Graph `conversationId`, and participants resolve closed-set against Neo4j (`:Person`/`:AdminUser` by `accountId`); an unresolved From/To/Cc address skips its whole thread and is returned to the operator (no `:Person` auto-create). Resolvable threads dispatch through the shared `conversation-archive-ingest.sh --source email`. Human-in-the-loop: nothing lands without an explicit per-message decision. One of the plugin's two Neo4j couplings; the other is the email-sampler's Outlook arm below, which writes through the same dispatcher with no operator decision at all. Needs `Mail.Read`. |
3134
3148
  | `outlook-mail-otp-extract` | Poll the inbox for a one-time code from a sender (domain). Reads full bodies to extract it; optional subject regex and timeout (default 60 s). |
3135
3149
  | `outlook-calendar-list` | Calendar events in next rangeDays days (default 7, max 365). |
3136
3150
  | `outlook-calendar-event` | Full detail of a single event by id. |
@@ -3167,6 +3181,20 @@ The attach call checks the access before it stores anything, so what it says bac
3167
3181
 
3168
3182
  Mail only: calendar and contacts tools refuse on a shared mailbox and say so. If a shared mailbox stops answering, the delegation was probably removed in Exchange — `outlook-mailbox-list verify:true` reports which ones are still reachable.
3169
3183
 
3184
+ An attached shared mailbox is also sampled into the graph on a timer, the same as a signed-in one. See the sampler section below.
3185
+
3186
+ ## Deterministic ingest into the graph
3187
+
3188
+ Separately from `outlook-mail-ingest`, which needs an operator decision per message, the `<brand>-email-sampler.service` unit writes inbound Outlook mail into the graph on a timer with no agent turn. It samples every signed-in mailbox whose status is `ok` and every attached shared mailbox, across every account on the install, and writes `:ConversationArchive {source:'email'}` through the same dispatcher `outlook-mail-ingest` uses. Bodies are stored whole.
3189
+
3190
+ Its cursor is the newest `receivedDateTime` written plus the ids seen at that instant, held per account in `secrets/outlook/sampler-cursors.json` (mode 0600, keyed by SMTP address). The id set is what makes a timestamp cursor safe: filtering `gt` would drop a message arriving at the same instant as the boundary, and `ge` alone would re-ingest the boundary on every cycle.
3191
+
3192
+ A mailbox with no cursor backfills 7 days on its first pass. A brand with no `OUTLOOK_CLIENT_ID` runs no Outlook arm at all and logs one skip per cycle rather than an auth error per mailbox.
3193
+
3194
+ Two things behave the way `outlook-mail-ingest` behaves, because it is the same dispatcher. Participants resolve closed-set, so a thread with an unknown From/To/Cc address is skipped and its mail does not land; and attachments are not archived (the sampler writes text threads only).
3195
+
3196
+ Read it with `logs-read.sh --tail email-sampler`, filtering `provider=outlook`. A delegated mailbox's line carries `via=<delegator>`, which is what separates "the shared mailbox is quiet" from "the delegator stopped answering". Not yet proven on a live install.
3197
+
3170
3198
  ## Drafting to a Contact
3171
3199
 
3172
3200
  When the admin agent drafts or sends a message to a known contact, it reads that contact's record first and applies any routing the record carries, for example a preferred send-to address, showing the result for your approval before anything is sent.
@@ -3207,6 +3235,11 @@ All log lines start with `[outlook-mcp]` and write to `server.log`, except the d
3207
3235
  | Mail ingest (per message) | `mail-ingest op=ingest id=<id> conversationId=<id> disposition=ingest` |
3208
3236
  | Mail ingest (per thread) | `mail-ingest op=thread-dispatched root=<key> messages=<N> participants=<N>` (or `op=thread-failed root=<key> status=<N> err=<msg>`; `status=null` with a non-`none` `err` is a spawn launch failure, not a script rejection) |
3209
3237
  | Mail ingest (summary) | `mail-ingest op=summary ingested=<N> discarded=<N> threadsDispatched=<N> threadsSkipped=<N> unresolved=<N>` — one per call; `threadsSkipped` with `unresolved>0` is the closed-set resolution refusing an unknown participant, not a failure |
3238
+ | Sampler census, per cycle | `op=sample-census provider=outlook accounts=<n> mailboxes=<n> delegated=<n> skipped=<n>` — emitted every cycle even when every count is zero, so an install with no Outlook mailbox still says so; without it, silence would be indistinguishable from the arm never running. `accounts=0` means the accounts directory resolved somewhere that does not exist |
3239
+ | Sampler, per mailbox per cycle | `op=sample mailbox=<addr> provider=outlook fetched=<n> threads=<n> skipped=<n> cursor=<iso>` — NOT in `server.log`; this arm writes to the email-sampler log, read with `logs-read.sh --tail email-sampler`. `via=<delegator>` is added on a delegated mailbox. `fetched` counts messages read, `threads` counts what actually landed in the graph, and `skipped` counts threads the closed-set participant rule refused; `fetched>0 threads=0` means nothing was written even though the cursor advanced. An idle mailbox logs `fetched=0` with no thread counts |
3240
+ | Sampler, unresolved participants | `op=sample-unresolved mailbox=<addr> provider=outlook threads=<n> addresses=<csv>` — only when a thread was refused. The addresses are what to add as `:Person` or `:AdminUser` for the account; until then that mail stays out of the graph and the cursor has already passed it |
3241
+ | Sampler failure | `op=sample-error mailbox=<addr> provider=outlook detail=<msg>` — the cursor is not advanced, so the same window is retried next cycle |
3242
+ | Sampler inert | `op=sample-skip provider=outlook detail=no-client-id` — one line per cycle on a brand with no Azure app, instead of one auth error per mailbox |
3210
3243
  | Calendar list | `calendar-list account=<id> rangeDays=<N> count=<N> elapsedMs=<N>` |
3211
3244
  | Calendar event | `calendar-event account=<id> eventId=<trunc-12> elapsedMs=<N>` |
3212
3245
  | Event created | `cal-create-request account=<id> subj=<trunc-64>` then `event-created account=<id> eventId=<trunc-16> status=201`. A non-201 logs `event-create-failed ... status=<N>` and throws. A 201 whose body carried no readable id logs `event-created-id-unknown account=<id> status=201` and returns a created-but-id-unknown result (the event exists; do not retry) rather than a failure. |
@@ -3290,7 +3323,7 @@ Latency triage: `mail-list count=0 elapsedMs<200` consistent → permissions iss
3290
3323
 
3291
3324
  ## Out of scope
3292
3325
 
3293
- Mail move to arbitrary folders / flag, hard (permanent) delete, removing an attachment from a **sent** message (Graph forbids it), attachment-byte archival into the graph (`outlook-mail-ingest` lands text threads only; the IMAP `:DigitalDocument` archival is a separate concern), contacts write, recurring-series occurrence editing, the `scheduling` Neo4j calendar and any auto-sync with it, OneDrive / Files, push notifications, on-premises Exchange, M365 admin scopes (`User.Read.All`, `AuditLog.Read.All`), public-agent exposure, multi-tenant federation. Mail read, full-body read, inbound attachment list/download, reply, delete (to Deleted Items), and compose — all four outbound paths carrying attachments, with a draft's attachments editable in place (add, remove, replace) — are supported (`outlook-mail-fetch-body`, `outlook-mail-attachment`, `outlook-mail-reply`, `outlook-mail-delete`, `outlook-mail-ingest`, `outlook-mail-otp-extract`, `outlook-mail-send`, `outlook-draft`, `outlook-draft-edit`, `outlook-draft-send`), and the calendar is read plus control. See `platform/plugins/outlook/PLUGIN.md` for the full out-of-scope list.
3326
+ Mail move to arbitrary folders / flag, hard (permanent) delete, removing an attachment from a **sent** message (Graph forbids it), attachment-byte archival into the graph (`outlook-mail-ingest` and the email-sampler's Outlook arm both land text threads only; the IMAP `:DigitalDocument` archival is a separate concern), contacts write, recurring-series occurrence editing, the `scheduling` Neo4j calendar and any auto-sync with it, OneDrive / Files, push notifications, on-premises Exchange, M365 admin scopes (`User.Read.All`, `AuditLog.Read.All`), public-agent exposure, multi-tenant federation. Mail read, full-body read, inbound attachment list/download, reply, delete (to Deleted Items), and compose — all four outbound paths carrying attachments, with a draft's attachments editable in place (add, remove, replace) — are supported (`outlook-mail-fetch-body`, `outlook-mail-attachment`, `outlook-mail-reply`, `outlook-mail-delete`, `outlook-mail-ingest`, `outlook-mail-otp-extract`, `outlook-mail-send`, `outlook-draft`, `outlook-draft-edit`, `outlook-draft-send`), and the calendar is read plus control. See `platform/plugins/outlook/PLUGIN.md` for the full out-of-scope list.
3294
3327
 
3295
3328
  ---
3296
3329
  # Google Calendar
@@ -4319,9 +4352,9 @@ one, over the same automation set the routes list. The roster seed's own
4319
4352
  `descNull` counter is its narrow twin, scoped to seeded rows and so blind to
4320
4353
  exactly the operator-created ones most likely to carry no caption.
4321
4354
 
4322
- **The shipped roster.** Every account starts with fourteen routines —
4355
+ **The shipped roster.** Every account starts with thirteen routines —
4323
4356
  `start-of-day`, `inbound-check`, `telegram-inbound-check`, `end-of-day`,
4324
- `appointment-reminder`, `commitment-chase`, `calendar-reconcile`,
4357
+ `commitment-chase`, `calendar-reconcile`,
4325
4358
  `contact-reconcile`, `crm-reconcile`, `finance-reconcile`, `weekly-digest`,
4326
4359
  `channel-link-check`, `graph-health`, `filesystem-health`, in `/routines`
4327
4360
  display order. `ROUTINE_ROSTER_KEYS.length`
@@ -9,6 +9,17 @@ Invoked by the admin agent directly.
9
9
 
10
10
  This is the platform's release timeline, newest first. Each entry shows the date it shipped and the version it shipped in, so you can tell the operator how current their install is. To compare, read the installed version from `capabilities-here` and match it against the versions below. Keep answers high level and in plain English; this is a summary, not a full commit log.
11
11
 
12
+ ## 2026-08-14 (0.1.605)
13
+
14
+ - Five more routines now check whether there is anything to do before waking the assistant. Mail triage, appointment reminders, the operational and finance reconciles, and the two health reports all previously ran on the clock and did the deciding after starting up.
15
+ - Email kept in the graph is now pruned on a schedule, so it does not grow without limit.
16
+
17
+ ## 2026-08-13 (0.1.604)
18
+
19
+ - Your assistant can now read a Telegram conversation back to you, both sides of it, from the record kept on your own machine. Ask for the chat with someone and you get every message they sent and every one the bot sent, in order, with a voice note shown as its transcript and a message they deleted marked as deleted.
20
+ - The tool that was supposed to do this had never worked for anyone, on any account. Asking about a past Telegram conversation used to fall back on the handful of notes an assistant happened to save at the time, which is how a twenty-message exchange could be reported back as eleven, with only one of the bot's own replies among them.
21
+ - An hourly check now reports any conversation notes saved that way, so the same gap cannot reopen unnoticed.
22
+
12
23
  ## 2026-08-13 (0.1.603)
13
24
 
14
25
  - Your Telegram assistant now knows who it is talking to. When someone's Telegram ID is saved on their contact record, every message from them arrives with their name attached, instead of the assistant seeing only a number or whatever that person's Telegram profile happens to be called.
@@ -41,11 +41,19 @@ The credential is stored in the account's secrets file and is never returned by
41
41
 
42
42
  ## Capabilities
43
43
 
44
- - **connector-register** — store a named API's base URL and a static credential. The credential scheme is one of bearer, api-key-header (a named header), or basic. Returns the non-secret record only.
44
+ - **connector-register** — store a named API's base URL and a static credential, and optionally describe its changed-since queue. The credential scheme is one of bearer, api-key-header (a named header), or basic. Returns the non-secret record only.
45
45
  - **connector-call** — make a request (GET or a mutating method) to a registered connector. The credential is injected from the secret store; the request is host-bound and SSRF-guarded. Returns the status, redacted response headers, and the (size-capped) body.
46
46
  - **connector-list** — the registered connectors for this account: names and base URLs only.
47
47
  - **connector-deregister** — remove a connector and its stored credential.
48
48
 
49
+ ## The changed-since queue
50
+
51
+ A registered connector may carry a `queue`, which is what lets a scheduled routine wake only when that system has actually moved. It names four things: the `kind` (`operational` or `accounting`), the `path` to read under the base URL, the `changedSinceParam` the API expects the watermark in, and the `itemsField` holding the array of changed records.
52
+
53
+ The kind decides which routine watches it. The shipped operational-system reconcile gates on `operational` and the finance reconcile on `accounting`, and neither names a connector: the routine names a kind, the account names the queue. A connector registered without a queue is watched by nothing, which is visible in the listing rather than silent.
54
+
55
+ The scheduling gate reads it by calling `connector-list` and then `connector-call`, so the credential and the host guard never leave this plugin. A queue whose credential is refused (401 or 403) reports no work rather than an error, so the routine no-ops instead of waking a model to rediscover a dead credential every fire. Registering an existing name again replaces its record, so omitting the queue removes it; that drop writes `op=register name=<n> queue=dropped`, because a queue lost in silence mutes whichever routine gated on it.
56
+
49
57
  ## Who can use it
50
58
 
51
59
  These tools are admin-driven. The operator's admin agent registers connectors and makes calls; the tools are not granted to any specialist agent, given their reach (arbitrary authenticated outbound requests and a credential store). A vertical agent that needs an external API is served by the admin acting on its behalf.
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=queue-descriptor.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"queue-descriptor.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/queue-descriptor.test.ts"],"names":[],"mappings":""}