@wirecat/cli-core 0.381.0 → 0.383.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/attachments/documents.d.ts +3 -0
  3. package/dist/attachments/documents.js +17 -0
  4. package/dist/cli/bot/watch.js +3 -0
  5. package/dist/cli/messenger/chat-answers.d.ts +2 -0
  6. package/dist/cli/messenger/chat-answers.js +2 -0
  7. package/dist/cli/messenger/chat-lists.d.ts +2 -0
  8. package/dist/cli/messenger/context.d.ts +2 -2
  9. package/dist/cli/messenger/context.js +2 -1
  10. package/dist/cli/messenger/conversation-answers.d.ts +1 -0
  11. package/dist/cli/messenger/message-answers.d.ts +1 -0
  12. package/dist/cli/messenger/message-items.js +1 -0
  13. package/dist/cli/messenger/message-writes.d.ts +1 -0
  14. package/dist/cli/messenger/serve-relay.d.ts +1 -0
  15. package/dist/cli/messenger/stored.d.ts +3 -1
  16. package/dist/cli/messenger/stored.js +12 -5
  17. package/dist/cli/messenger/watch-command.js +2 -0
  18. package/dist/documents/blocks.d.ts +45 -0
  19. package/dist/documents/blocks.js +48 -0
  20. package/dist/documents/index.d.ts +5 -0
  21. package/dist/documents/index.js +4 -0
  22. package/dist/documents/markdown-blocks.d.ts +3 -0
  23. package/dist/documents/markdown-blocks.js +123 -0
  24. package/dist/documents/push-model.d.ts +89 -0
  25. package/dist/documents/push-model.js +1 -0
  26. package/dist/documents.d.ts +1 -0
  27. package/dist/documents.js +1 -0
  28. package/dist/domain/models.d.ts +31 -4
  29. package/dist/entities/chat.d.ts +4 -0
  30. package/dist/entities/chat.js +4 -0
  31. package/dist/entities/contact.d.ts +4 -0
  32. package/dist/entities/evidence.d.ts +8 -0
  33. package/dist/entities/index.d.ts +17 -0
  34. package/dist/entities/message.d.ts +2 -0
  35. package/dist/entities/message.js +2 -0
  36. package/dist/errors/reasons.js +3 -0
  37. package/dist/meetings/answers.d.ts +1 -0
  38. package/dist/meetings/index.d.ts +1 -1
  39. package/dist/meetings/index.js +1 -1
  40. package/dist/meetings/item.d.ts +10 -0
  41. package/dist/meetings/item.js +34 -18
  42. package/dist/ports/traits.d.ts +6 -0
  43. package/dist/services/chats.js +16 -2
  44. package/dist/services/deps.d.ts +2 -0
  45. package/dist/services/deps.js +2 -0
  46. package/dist/services/documents/block-diff.d.ts +24 -0
  47. package/dist/services/documents/block-diff.js +91 -0
  48. package/dist/services/documents/push.d.ts +60 -0
  49. package/dist/services/documents/push.js +218 -0
  50. package/dist/services/inbox.d.ts +1 -0
  51. package/dist/services/inbox.js +10 -3
  52. package/dist/store/keys.d.ts +1 -1
  53. package/dist/store/messages.d.ts +7 -1
  54. package/dist/store/messages.js +16 -3
  55. package/dist/store/sqlite/chat-rekey.js +9 -1
  56. package/dist/store/sqlite/chats.d.ts +2 -2
  57. package/dist/store/sqlite/chats.js +37 -8
  58. package/dist/store/sqlite/manifest.js +2 -0
  59. package/dist/store/sqlite/meeting-hit-query.js +18 -3
  60. package/dist/store/sqlite/messages.d.ts +9 -2
  61. package/dist/store/sqlite/messages.js +29 -2
  62. package/dist/store/sqlite/migrations.generated.js +16 -0
  63. package/dist/store/sqlite/reads.d.ts +50 -0
  64. package/dist/store/sqlite/reads.js +3 -1
  65. package/dist/store/sqlite/schema.d.ts +96 -0
  66. package/dist/store/sqlite/schema.js +6 -0
  67. package/dist/store/sqlite/search.d.ts +33 -0
  68. package/dist/store/sqlite/values.d.ts +4 -0
  69. package/dist/store/sqlite/values.js +3 -0
  70. package/package.json +5 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,47 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.383.0 — 22.10.2026
11
+
12
+ ### Added
13
+
14
+ - **`readSpreadsheet(bytes, "xlsx" | "ods")` from `@wirecat/cli-core/documents`** returns a workbook's cells per
15
+ worksheet (values as displayed), the same reader text extraction uses, so a provider can mirror a workbook's tabs.
16
+ - **Push local files into a provider as native documents (`@wirecat/cli-core/documents`).** `pushDocuments` mirrors
17
+ a local folder one way through a provider's `DocumentTarget`: new files are created, changed files updated, and
18
+ unchanged files skipped by content hash with no remote call. Before a create it saves a pending origin key, so a
19
+ crash after the provider wrote finds the file (`findByOrigin`) instead of creating a duplicate. The provider's
20
+ `prepare` compares the remote with the last push before writing; a remote copy changed since then is a conflict
21
+ unless `force`, and only the `write` step passes the guard. A local delete is reported as an
22
+ orphan and nothing is ever deleted remotely. `planPush` is the pure plan behind `dryRun`; `scanLocal` skips hidden
23
+ files, editor leftovers and symbolic links. Every write passes through the caller's `guard` with its own operation
24
+ id. For documents with structure, `markdownBlocks` (async, its parser loaded on first use) parses Markdown (CommonMark and GFM tables, task lists,
25
+ strikethrough) into `Block`s a provider writes natively, `diffBlocks` gives the edits that leave unchanged blocks
26
+ in place, and `reconcileBlocks` lets a checklist tick change on either side without a conflict. See
27
+ [documents](docs/dev/DOCUMENTS.md#push-local-files-to-a-provider).
28
+
29
+ ## 0.382.0 — 22.10.2026
30
+
31
+ ### Added
32
+
33
+ - **A chat's state from the phone (store version 28).** The store keeps `Chat.muted`, `Chat.archived` and two new
34
+ fields: `mutedUntil` (when a mute ends, UTC; none is for good) and `markedUnread` (the owner marked the chat unread).
35
+ A `ChatUpdate` that leaves one out keeps the stored value, and a mute past its end reads as over. Stored chats show
36
+ them, and `chat` gains `muted_until` and `marked_unread`. So `inbox` and `review` over the store now leave out muted
37
+ and archived chats, as they do for a messenger that answers live, and `inbox` takes in a chat marked unread with its
38
+ newest message from someone else. tg and max send `muted` and `archived` in their chat lists, so their stored chats
39
+ now show them too.
40
+ - **The store can count unread messages** for a messenger that sets `unreadCounts: "store"`: each new message from
41
+ someone else that `watch` or `listen` saves after the messenger's last count adds one, and a whole-chat `chats read`
42
+ stores 0. `store.messages.saveMessages` takes `countUnread`. tg and max leave it unset and count as before. See
43
+ [adapters](docs/dev/ADAPTERS.md).
44
+ - **Delivered and read on the owner's own messages (store version 27).** `Message.delivery` (`delivered` or `read`)
45
+ says how far the owner's message in a one-to-one chat got; a `watch` event `{ event: "delivery", chatId, chatTitle,
46
+ messageId, delivery, at }` (`MessageDeliveryEvent`) carries a receipt as it comes. `store.messages.saveDelivery` and
47
+ a saved message's own `delivery` keep it in the new `messages.delivery` column, only on the owner's messages and
48
+ only ever forward. Stored messages show it as `delivery`. tg and max send none and see no change. See
49
+ [adapters](docs/dev/ADAPTERS.md).
50
+
10
51
  ## 0.381.0 — 22.10.2026
11
52
 
12
53
  ### Added
@@ -14,6 +55,10 @@ shape of this file.
14
55
  - **Meeting search finds words speech recognition cut off.** A stored word of four letters or more that begins a
15
56
  query word, and that the Russian dictionary does not know, widens it: «може» for «может», «напри» for «например».
16
57
  Real shorter words («после» for «последующих») still do not. This is ztx's rule, behind the dictionary.
58
+ - **A meeting answer names what about it is doubtful, in `issues`.** `MEETING_ISSUES` lists the values; today one,
59
+ `time_without_zone`: the provider sent a time with no zone, and it was read in the computer's zone. A provider
60
+ adapter keeps the data and puts the issue in the meeting's `metadata.issues`; `meetingItem` shows the known ones and
61
+ leaves the field out when there are none. No store change.
17
62
 
18
63
  - **People's reactions in the store (store version 26).** For a messenger that sends one person's reaction change at a
19
64
  time (WhatsApp), a `watch` event `{ event: "person_reaction", chatId, chatTitle, messageId, reaction }`
@@ -30,6 +75,11 @@ shape of this file.
30
75
  (U+FE0F, U+FE0E, or the U+200D joiner) that a typed one lacks, so "Семья❤️" did not find the chat "Семья❤". Names
31
76
  now compare without those marks and with accented letters in one form, still ignoring case, wherever a chat or a
32
77
  person is named (`pickChat`, `pickPerson`, a stored alias).
78
+ - **`from:` finds a speaker known only by name across meetings.** A participant keyed by name in one meeting
79
+ (`name:…@account/occurrence:n`, as zoom-cli's `.txt` speakers and nameless API participants are) is grouped with
80
+ the same name elsewhere, and with the one identified speaker of that name when there is exactly one. Before,
81
+ each meeting's copy was a separate candidate, so `from:"Name"` was ambiguous. Candidates with an email show it.
82
+
33
83
  - **A store the process may not write is a permission error, not a crash.** Opening the store for writing where the
34
84
  file or its folder is read-only (`EROFS`, `EACCES`, `EPERM`) was reported as `internal_error` "this is a bug".
35
85
  It is now `permission_error` (exit 5) with reason `store.not-writable`, and the message names no path.
@@ -1,6 +1,9 @@
1
1
  import type { Extraction, FileHint } from "../readers/types.js";
2
+ import { type Sheet } from "./sheets.js";
2
3
  export type DocumentKind = "odt" | "ods" | "odp" | "xlsx" | "pptx" | "epub";
3
4
  export declare const documentKind: (hint: FileHint) => DocumentKind | undefined;
4
5
  /** v2 for spreadsheets: rows under their header instead of one line per cell, so older text is read again. */
5
6
  export declare const documentExtractor: (kind: DocumentKind) => string;
7
+ /** The cells of an XLSX or ODS workbook, one `Sheet` per worksheet in workbook order, values as displayed. */
8
+ export declare const readSpreadsheet: (bytes: Uint8Array, kind: "xlsx" | "ods", signal?: AbortSignal) => Promise<Sheet[]>;
6
9
  export declare const readDocument: (bytes: Uint8Array, kind: DocumentKind, signal?: AbortSignal) => Promise<Extraction>;
@@ -393,6 +393,23 @@ const readBook = (archive, output) => {
393
393
  visit(bodies[0]);
394
394
  }
395
395
  };
396
+ /** The cells of an XLSX or ODS workbook, one `Sheet` per worksheet in workbook order, values as displayed. */
397
+ export const readSpreadsheet = async (bytes, kind, signal) => {
398
+ try {
399
+ const archive = await readContainer(bytes, signal);
400
+ const sheets = kind === "xlsx" ? readSheet(archive) : readOdf(archive, kind, new Output());
401
+ if (!sheets)
402
+ throw new Error("wrong_format");
403
+ return sheets;
404
+ }
405
+ catch (error) {
406
+ if (error instanceof CliError)
407
+ throw error;
408
+ throw new CliError("validation_error", `the ${kind.toUpperCase()} workbook could not be read`, {
409
+ reason: "spreadsheet.unreadable",
410
+ });
411
+ }
412
+ };
396
413
  export const readDocument = async (bytes, kind, signal) => {
397
414
  const extractor = documentExtractor(kind);
398
415
  try {
@@ -140,6 +140,9 @@ export const botWatchCommand = (bot) => annotate(new Command("watch"), { effects
140
140
  else if (event.event === "star") {
141
141
  context.streams.data(`${event.starred ? "starred" : "unstarred"} in ${title(event.chatId)}: message ${event.messageId}\n`);
142
142
  }
143
+ else if (event.event === "delivery") {
144
+ context.streams.data(`${event.delivery} in ${title(event.chatId)}: message ${event.messageId}\n`);
145
+ }
143
146
  else if (event.event === "chat_event") {
144
147
  context.streams.data(`${event.change.event} in ${title(event.chatId)}\n`);
145
148
  }
@@ -61,7 +61,9 @@ export declare const chatShown: (account: AccountKey, card: Parameters<typeof ch
61
61
  last_message_at: string | null;
62
62
  participants_count: number | null;
63
63
  muted?: boolean | undefined;
64
+ muted_until?: string | undefined;
64
65
  archived?: boolean | undefined;
66
+ marked_unread?: boolean | undefined;
65
67
  membership_state?: "archived" | "external" | "imported" | "joined" | "left" | "public" | undefined;
66
68
  parent_chat_id?: string | undefined;
67
69
  scope?: "personal" | "work" | undefined;
@@ -28,7 +28,9 @@ export const chatAnswer = (account, chat) => defined({
28
28
  last_message_at: chat.lastMessageAt,
29
29
  participants_count: chat.participantsCount,
30
30
  muted: chat.muted,
31
+ muted_until: chat.mutedUntil ?? undefined,
31
32
  archived: chat.archived,
33
+ marked_unread: chat.markedUnread,
32
34
  membership_state: chat.membershipState,
33
35
  parent_chat_id: chat.parentChatId ?? undefined,
34
36
  scope: chat.scope,
@@ -21,7 +21,9 @@ export declare const chatsList: (services: Services, query: QueryWords, paging:
21
21
  last_message_at: string | null;
22
22
  participants_count: number | null;
23
23
  muted?: boolean | undefined;
24
+ muted_until?: string | undefined;
24
25
  archived?: boolean | undefined;
26
+ marked_unread?: boolean | undefined;
25
27
  membership_state?: "archived" | "external" | "imported" | "joined" | "left" | "public" | undefined;
26
28
  parent_chat_id?: string | undefined;
27
29
  scope?: "personal" | "work" | undefined;
@@ -236,12 +236,12 @@ type ConnectionWaits = Pick<BaseEnvironment, "closeWaitMs" | "rekeyPacing">;
236
236
  * does not `watch` is open: what it queued while nothing was connected is delivered once and cannot be fetched again.
237
237
  * A connection opened with `listen: true` is left to its `watch` and `feed`, which save the same.
238
238
  */
239
- export declare const connected: (connection: MessengerAdapter, { app, provider, name, deletedWithoutChat, pace, history, savedChatId, }: Pick<Messenger, "app" | "provider" | "name" | "deletedWithoutChat" | "pace" | "history" | "savedChatId">, { settings, env, renderer }: Pick<BaseContext, "settings" | "env" | "renderer">, events: EventSink, store?: LazyStore, { listen: watching, closeWaitMs, rekeyPacing, }?: Pick<ConnectOptions, "listen"> & ConnectionWaits) => {
239
+ export declare const connected: (connection: MessengerAdapter, { app, provider, name, deletedWithoutChat, pace, history, savedChatId, unreadCounts, }: Pick<Messenger, "app" | "provider" | "name" | "deletedWithoutChat" | "pace" | "history" | "savedChatId" | "unreadCounts">, { settings, env, renderer }: Pick<BaseContext, "settings" | "env" | "renderer">, events: EventSink, store?: LazyStore, { listen: watching, closeWaitMs, rekeyPacing, }?: Pick<ConnectOptions, "listen"> & ConnectionWaits) => {
240
240
  adapter: MessengerAdapter;
241
241
  close: () => Promise<void>;
242
242
  };
243
243
  /** `connectFor` and then `connected`, over one store handle: what the reads save and what the adapter looks up. */
244
- export declare const connectedFor: (command: Command, messenger: Pick<Messenger, "connect" | "app" | "provider" | "name" | "deletedWithoutChat" | "pace" | "history" | "savedChatId">, base: BaseContext, options: ConnectOptions & {
244
+ export declare const connectedFor: (command: Command, messenger: Pick<Messenger, "connect" | "app" | "provider" | "name" | "deletedWithoutChat" | "pace" | "history" | "savedChatId" | "unreadCounts">, base: BaseContext, options: ConnectOptions & {
245
245
  events: EventSink;
246
246
  }) => Promise<{
247
247
  connection: MessengerAdapter;
@@ -189,7 +189,7 @@ const waitsOf = (command) => {
189
189
  * does not `watch` is open: what it queued while nothing was connected is delivered once and cannot be fetched again.
190
190
  * A connection opened with `listen: true` is left to its `watch` and `feed`, which save the same.
191
191
  */
192
- export const connected = (connection, { app, provider, name, deletedWithoutChat, pace, history, savedChatId, }, { settings, env, renderer }, events, store = lazyStore(env, app.command), { listen: watching = false, closeWaitMs = SAVES_WAIT_MS, rekeyPacing, } = {}) => {
192
+ export const connected = (connection, { app, provider, name, deletedWithoutChat, pace, history, savedChatId, unreadCounts, }, { settings, env, renderer }, events, store = lazyStore(env, app.command), { listen: watching = false, closeWaitMs = SAVES_WAIT_MS, rekeyPacing, } = {}) => {
193
193
  const self = connection.self();
194
194
  if (self !== null)
195
195
  rememberAccount(app, settings.profile, self, env);
@@ -220,6 +220,7 @@ export const connected = (connection, { app, provider, name, deletedWithoutChat,
220
220
  rekeyed: (from, to) => rekeyRecipients(app, settings.profile, env, renderer.warn, from, to),
221
221
  recipientIds: () => (new RecipientList(recipientsPathFor(app, settings.profile, env), app.command).read() ?? []).flatMap((recipient) => [recipient.id, ...(recipient.partnerId ? [recipient.partnerId] : [])]),
222
222
  ...(history === "store" ? { namesFromStore: savedChatId ? { savedChatId } : {} } : {}),
223
+ ...(unreadCounts === "store" ? { countsUnread: true } : {}),
223
224
  });
224
225
  const passives = history === "store" && self !== null && !watching
225
226
  ? passivesOf(saving).map((passive) => listened(passive, events, renderer.warn, closeWaitMs))
@@ -175,6 +175,7 @@ export declare const conversationShown: (account: AccountKey, { summary, message
175
175
  } | null | undefined;
176
176
  scheduled_for?: string | undefined;
177
177
  starred?: true | undefined;
178
+ delivery?: "delivered" | "read" | undefined;
178
179
  email?: {
179
180
  subject: string | null;
180
181
  from: {
@@ -152,6 +152,7 @@ export declare const threadAnswer: ({ items, ...thread }: ThreadContext, reader:
152
152
  } | null | undefined;
153
153
  scheduled_for?: string | undefined;
154
154
  starred?: true | undefined;
155
+ delivery?: "delivered" | "read" | undefined;
155
156
  email?: {
156
157
  subject: string | null;
157
158
  from: {
@@ -59,6 +59,7 @@ export const messageItem = (message, reader) => {
59
59
  reactions: message.reactions,
60
60
  scheduled_for: message.scheduledFor,
61
61
  starred: message.starred === true ? true : undefined,
62
+ delivery: message.delivery,
62
63
  email: message.email === undefined
63
64
  ? undefined
64
65
  : {
@@ -154,6 +154,7 @@ export declare const writtenAnswer: ({ operationId, sendId, message, scheduledFo
154
154
  } | null | undefined;
155
155
  scheduled_for?: string | undefined;
156
156
  starred?: true | undefined;
157
+ delivery?: "delivered" | "read" | undefined;
157
158
  email?: {
158
159
  subject: string | null;
159
160
  from: {
@@ -32,6 +32,7 @@ export declare const serveRelay: (command: Command, messenger: Messenger) => {
32
32
  fetching?: import("../index.js").Fetching;
33
33
  history?: "server" | "store";
34
34
  historyOnDemand?: boolean;
35
+ unreadCounts?: "messenger" | "store";
35
36
  serverSearch?: boolean | "chat";
36
37
  services?: import("../../services/index.js").Override;
37
38
  connection?: "exclusive";
@@ -39,6 +39,8 @@ export interface Saving {
39
39
  * titles `watch` leaves out, are answered from the store first. Its server has no directory to ask.
40
40
  */
41
41
  namesFromStore?: Pick<MessengerTraits, "savedChatId">;
42
+ /** `MessengerTraits.unreadCounts: "store"`: a message `watch` or `listen` saves raises its chat's unread count. */
43
+ countsUnread?: boolean;
42
44
  }
43
45
  /**
44
46
  * The adapter, with what each read answers written to the store. **A failed write never fails the
@@ -48,4 +50,4 @@ export interface Saving {
48
50
  * would erase what `chats list` stored. Nor are `chat` and `contact`: a card's member list is not
49
51
  * known to be whole, and `saveMembers` replaces the list.
50
52
  */
51
- export declare const stored: (messenger: MessengerAdapter, { account, store, warn, events, pending, announcing, deletedWithoutChat, profile, rekeyed, rekeyPacing, closing, namesFromStore, recipientIds, }: Saving) => MessengerAdapter;
53
+ export declare const stored: (messenger: MessengerAdapter, { account, store, warn, events, pending, announcing, deletedWithoutChat, profile, rekeyed, rekeyPacing, closing, namesFromStore, recipientIds, countsUnread, }: Saving) => MessengerAdapter;
@@ -12,7 +12,7 @@ const REKEY_PACING = { lockMs: 50, pauseMs: 25 };
12
12
  * would erase what `chats list` stored. Nor are `chat` and `contact`: a card's member list is not
13
13
  * known to be whole, and `saveMembers` replaces the list.
14
14
  */
15
- export const stored = (messenger, { account, store, warn, events, pending, announcing, deletedWithoutChat, profile, rekeyed, rekeyPacing = REKEY_PACING, closing, namesFromStore, recipientIds, }) => {
15
+ export const stored = (messenger, { account, store, warn, events, pending, announcing, deletedWithoutChat, profile, rekeyed, rekeyPacing = REKEY_PACING, closing, namesFromStore, recipientIds, countsUnread = false, }) => {
16
16
  let warned = false;
17
17
  let recorded = profile === undefined;
18
18
  const notWritten = (operation, error) => {
@@ -318,7 +318,7 @@ export const stored = (messenger, { account, store, warn, events, pending, annou
318
318
  await rememberTitles(store, account, titles);
319
319
  return (messenger.watch?.((event) => {
320
320
  const seenAt = Date.now();
321
- void save("messages.watch", (opened) => keep(opened, account, event, seenAt, deletedWithoutChat));
321
+ void save("messages.watch", (opened) => keep(opened, account, event, seenAt, deletedWithoutChat, countsUnread));
322
322
  onEvent(namesFromStore ? titled(event, titles) : event);
323
323
  }, signal, onReady) ?? Promise.resolve());
324
324
  },
@@ -327,7 +327,7 @@ export const stored = (messenger, { account, store, warn, events, pending, annou
327
327
  ...(messenger.listen
328
328
  ? {
329
329
  listen: (onEvent, signal) => messenger.listen?.((event) => {
330
- void save("messages.listen", (opened) => keep(opened, account, event, Date.now(), deletedWithoutChat));
330
+ void save("messages.listen", (opened) => keep(opened, account, event, Date.now(), deletedWithoutChat, countsUnread));
331
331
  onEvent(event);
332
332
  }, signal) ?? Promise.resolve(),
333
333
  }
@@ -541,12 +541,16 @@ const fill = async (store, account, batch) => {
541
541
  * Each change as the store keeps it: a message or an edit upserted, a deletion a tombstone, reactions replaced, a
542
542
  * voter's vote or a person's reaction replacing their older one, a star set or taken off, a chat event kept once.
543
543
  */
544
- const keep = async (store, account, event, seenAt, among) => {
544
+ const keep = async (store, account, event, seenAt, among, countsUnread) => {
545
545
  switch (event.event) {
546
546
  case "message":
547
547
  case "edit": {
548
548
  const { chatTitle, ...message } = event.message;
549
- await store.messages.saveMessages(account, message.chatId, [message], { via: "update", seenAt });
549
+ await store.messages.saveMessages(account, message.chatId, [message], {
550
+ via: "update",
551
+ seenAt,
552
+ ...(countsUnread && event.event === "message" ? { countUnread: true } : {}),
553
+ });
550
554
  return;
551
555
  }
552
556
  case "delete":
@@ -561,6 +565,9 @@ const keep = async (store, account, event, seenAt, among) => {
561
565
  case "star":
562
566
  await store.messages.saveStar(account, event.chatId, event.messageId, event.starred);
563
567
  return;
568
+ case "delivery":
569
+ await store.messages.saveDelivery(account, event.chatId, event.messageId, event.delivery);
570
+ return;
564
571
  case "chat_event":
565
572
  await store.roster.saveChatEvents(account, event.chatId, [event.change]);
566
573
  return;
@@ -118,6 +118,8 @@ const describe = (event, render) => {
118
118
  }
119
119
  case "star":
120
120
  return `${event.starred ? "starred" : "unstarred"} in ${event.chatTitle ?? event.chatId}: message ${event.messageId}\n`;
121
+ case "delivery":
122
+ return `${event.delivery} in ${event.chatTitle ?? event.chatId}: message ${event.messageId}\n`;
121
123
  case "chat_event": {
122
124
  const { event: action, by, people } = event.change;
123
125
  const who = people.map((one) => one.name ?? one.id).join(", ");
@@ -0,0 +1,45 @@
1
+ export interface Inline {
2
+ text: string;
3
+ bold?: true;
4
+ italic?: true;
5
+ strike?: true;
6
+ code?: true;
7
+ link?: string;
8
+ }
9
+ export type ListKind = "bullet" | "number" | "check";
10
+ export type Block = {
11
+ kind: "heading";
12
+ level: 1 | 2 | 3 | 4 | 5 | 6;
13
+ inlines: Inline[];
14
+ } | {
15
+ kind: "paragraph";
16
+ inlines: Inline[];
17
+ } | {
18
+ kind: "item";
19
+ list: ListKind;
20
+ depth: number;
21
+ checked?: boolean;
22
+ inlines: Inline[];
23
+ } | {
24
+ kind: "code";
25
+ language?: string;
26
+ text: string;
27
+ } | {
28
+ kind: "quote";
29
+ inlines: Inline[];
30
+ } | {
31
+ kind: "table";
32
+ header: Inline[][];
33
+ rows: Inline[][][];
34
+ } | {
35
+ kind: "image";
36
+ url: string;
37
+ alt: string;
38
+ } | {
39
+ kind: "rule";
40
+ };
41
+ /** Adjacent runs with the same marks merge and empty runs drop, so two equal texts give one key. */
42
+ export declare const normalizeInlines: (inlines: Inline[]) => Inline[];
43
+ export declare const blockKey: (block: Block) => string;
44
+ /** The same key with checklist ticks left out: two blocks that differ only in a tick share it. */
45
+ export declare const blockShape: (block: Block) => string;
@@ -0,0 +1,48 @@
1
+ const MARKS = ["bold", "italic", "strike", "code", "link"];
2
+ const sameMarks = (a, b) => MARKS.every((mark) => a[mark] === b[mark]);
3
+ /** Adjacent runs with the same marks merge and empty runs drop, so two equal texts give one key. */
4
+ export const normalizeInlines = (inlines) => {
5
+ const out = [];
6
+ for (const inline of inlines) {
7
+ if (!inline.text)
8
+ continue;
9
+ const last = out.at(-1);
10
+ if (last && sameMarks(last, inline))
11
+ last.text += inline.text;
12
+ else
13
+ out.push({ ...inline });
14
+ }
15
+ return out;
16
+ };
17
+ const inlineKey = (inline) => [inline.text, ...MARKS.map((mark) => inline[mark] ?? "")];
18
+ const key = (block, withChecked) => {
19
+ switch (block.kind) {
20
+ case "heading":
21
+ return [block.kind, block.level, normalizeInlines(block.inlines).map(inlineKey)];
22
+ case "paragraph":
23
+ case "quote":
24
+ return [block.kind, normalizeInlines(block.inlines).map(inlineKey)];
25
+ case "item":
26
+ return [
27
+ block.kind,
28
+ block.list,
29
+ block.depth,
30
+ withChecked && block.list === "check" ? Boolean(block.checked) : "",
31
+ normalizeInlines(block.inlines).map(inlineKey),
32
+ ];
33
+ case "code":
34
+ return [block.kind, block.language ?? "", block.text];
35
+ case "table":
36
+ return [
37
+ block.kind,
38
+ [block.header, ...block.rows].map((row) => row.map((cell) => normalizeInlines(cell).map(inlineKey))),
39
+ ];
40
+ case "image":
41
+ return [block.kind, block.url, block.alt];
42
+ case "rule":
43
+ return [block.kind];
44
+ }
45
+ };
46
+ export const blockKey = (block) => JSON.stringify(key(block, true));
47
+ /** The same key with checklist ticks left out: two blocks that differ only in a tick share it. */
48
+ export const blockShape = (block) => JSON.stringify(key(block, false));
@@ -1,9 +1,14 @@
1
+ export { type BlockHunk, diffBlocks, type Reconciled, reconcileBlocks } from "../services/documents/block-diff.js";
1
2
  export { type Complete, extractMemories } from "../services/documents/extraction.js";
2
3
  export { documentIngestion, type IngestionOptions } from "../services/documents/ingestion.js";
3
4
  export { type DocumentKnowledge, documentKnowledge } from "../services/documents/knowledge.js";
4
5
  export type { SyncOptions } from "../services/documents/ports.js";
5
6
  export { extractDocumentMemories, type ImportedSource, type ProposalOptions } from "../services/documents/proposals.js";
7
+ export { type PushOptions, type PushResult, planPush, pushDocuments, scanLocal, } from "../services/documents/push.js";
6
8
  export { type SyncResult, syncDocuments } from "../services/documents/sync.js";
9
+ export { type Block, blockKey, blockShape, type Inline, type ListKind, normalizeInlines } from "./blocks.js";
7
10
  export { catalogFiles } from "./catalog.js";
11
+ export { markdownBlocks } from "./markdown-blocks.js";
8
12
  export type { DocumentSource, IndexedSource, PictureWriter, SourceChange, SourceContent, SourceFile, SourceIndex, SourceSkip, SourceState, SourceStatePort, SourceWalk, SourceWork, TabText, } from "./model.js";
13
+ export type { DocumentTarget, LocalDocument, Prepared, PushEntry, Pushed, PushPlan, PushState, PushStatePort, } from "./push-model.js";
9
14
  export { within } from "./scope.js";
@@ -1,7 +1,11 @@
1
+ export { diffBlocks, reconcileBlocks } from "../services/documents/block-diff.js";
1
2
  export { extractMemories } from "../services/documents/extraction.js";
2
3
  export { documentIngestion } from "../services/documents/ingestion.js";
3
4
  export { documentKnowledge } from "../services/documents/knowledge.js";
4
5
  export { extractDocumentMemories } from "../services/documents/proposals.js";
6
+ export { planPush, pushDocuments, scanLocal, } from "../services/documents/push.js";
5
7
  export { syncDocuments } from "../services/documents/sync.js";
8
+ export { blockKey, blockShape, normalizeInlines } from "./blocks.js";
6
9
  export { catalogFiles } from "./catalog.js";
10
+ export { markdownBlocks } from "./markdown-blocks.js";
7
11
  export { within } from "./scope.js";
@@ -0,0 +1,3 @@
1
+ import { type Block } from "./blocks.js";
2
+ /** Markdown (CommonMark + GFM tables, task lists and strikethrough) as the blocks a provider can write natively. */
3
+ export declare const markdownBlocks: (markdown: string) => Promise<Block[]>;
@@ -0,0 +1,123 @@
1
+ import { normalizeInlines } from "./blocks.js";
2
+ // Loaded on first use: importing the documents entry point would otherwise cost every command about 50 ms.
3
+ let parser;
4
+ const loadParser = () => (parser ??= Promise.all([
5
+ import("mdast-util-from-markdown"),
6
+ import("mdast-util-gfm"),
7
+ import("micromark-extension-gfm"),
8
+ ]).then(([{ fromMarkdown }, { gfmFromMarkdown }, { gfm }]) => (markdown) => fromMarkdown(markdown, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })));
9
+ /** Markdown (CommonMark + GFM tables, task lists and strikethrough) as the blocks a provider can write natively. */
10
+ export const markdownBlocks = async (markdown) => {
11
+ const tree = (await loadParser())(markdown);
12
+ const definitions = new Map();
13
+ const collect = (node) => {
14
+ if (node.type === "definition")
15
+ definitions.set(node.identifier, node);
16
+ if ("children" in node)
17
+ for (const child of node.children)
18
+ collect(child);
19
+ };
20
+ collect(tree);
21
+ const images = [];
22
+ const inlinesOf = (nodes, marks = {}) => nodes.flatMap((node) => {
23
+ switch (node.type) {
24
+ case "text":
25
+ return [{ ...marks, text: node.value.replace(/\n/g, " ") }];
26
+ case "inlineCode":
27
+ return [{ ...marks, code: true, text: node.value }];
28
+ case "strong":
29
+ return inlinesOf(node.children, { ...marks, bold: true });
30
+ case "emphasis":
31
+ return inlinesOf(node.children, { ...marks, italic: true });
32
+ case "delete":
33
+ return inlinesOf(node.children, { ...marks, strike: true });
34
+ case "link":
35
+ return inlinesOf(node.children, { ...marks, link: node.url });
36
+ case "linkReference": {
37
+ const url = definitions.get(node.identifier)?.url;
38
+ return inlinesOf(node.children, url ? { ...marks, link: url } : marks);
39
+ }
40
+ case "break":
41
+ return [{ ...marks, text: "\n" }];
42
+ case "image":
43
+ images.push({ kind: "image", url: node.url, alt: node.alt ?? "" });
44
+ return [];
45
+ case "imageReference": {
46
+ const url = definitions.get(node.identifier)?.url;
47
+ if (url)
48
+ images.push({ kind: "image", url, alt: node.alt ?? "" });
49
+ return [];
50
+ }
51
+ case "html":
52
+ return [{ ...marks, text: node.value }];
53
+ default:
54
+ return "children" in node ? inlinesOf(node.children, marks) : [];
55
+ }
56
+ });
57
+ const trimmed = (inlines) => {
58
+ const out = normalizeInlines(inlines);
59
+ const first = out[0];
60
+ const last = out.at(-1);
61
+ if (first)
62
+ first.text = first.text.trimStart();
63
+ if (last)
64
+ last.text = last.text.trimEnd();
65
+ return normalizeInlines(out);
66
+ };
67
+ const withImages = (block) => {
68
+ const found = images.splice(0);
69
+ if (block?.kind === "paragraph" && !block.inlines.length)
70
+ return found;
71
+ return block ? [block, ...found] : found;
72
+ };
73
+ const textOf = (node) => node.children.flatMap((child) => child.type === "paragraph" || child.type === "heading" ? inlinesOf(child.children) : []);
74
+ const blocks = (nodes, depth = 0) => nodes.flatMap((node) => {
75
+ switch (node.type) {
76
+ case "heading":
77
+ return withImages({ kind: "heading", level: node.depth, inlines: trimmed(inlinesOf(node.children)) });
78
+ case "paragraph":
79
+ return withImages({ kind: "paragraph", inlines: trimmed(inlinesOf(node.children)) });
80
+ case "code":
81
+ return [{ kind: "code", ...(node.lang ? { language: node.lang } : {}), text: node.value }];
82
+ case "blockquote":
83
+ return node.children.flatMap((child) => child.type === "paragraph" || child.type === "heading"
84
+ ? withImages({ kind: "quote", inlines: trimmed(inlinesOf(child.children)) })
85
+ : blocks([child], depth));
86
+ case "thematicBreak":
87
+ return [{ kind: "rule" }];
88
+ case "table": {
89
+ const [head, ...rows] = node.children.map((row) => row.children.map((cell) => trimmed(inlinesOf(cell.children))));
90
+ return [{ kind: "table", header: head ?? [], rows }];
91
+ }
92
+ case "list":
93
+ return node.children.flatMap((item) => {
94
+ const [first, ...rest] = item.children;
95
+ const head = {
96
+ kind: "item",
97
+ list: (item.checked != null ? "check" : node.ordered ? "number" : "bullet"),
98
+ depth,
99
+ ...(item.checked != null ? { checked: item.checked } : {}),
100
+ inlines: trimmed(first && first.type === "paragraph" ? inlinesOf(first.children) : []),
101
+ };
102
+ const tail = first && first.type !== "paragraph" ? [first, ...rest] : rest;
103
+ return [
104
+ ...withImages(head),
105
+ ...tail.flatMap((child) => child.type === "list"
106
+ ? blocks([child], depth + 1)
107
+ : child.type === "paragraph"
108
+ ? withImages({ kind: "paragraph", inlines: trimmed(inlinesOf(child.children)) })
109
+ : blocks([child], depth)),
110
+ ];
111
+ });
112
+ case "html":
113
+ return [{ kind: "paragraph", inlines: [{ text: node.value }] }];
114
+ case "definition":
115
+ case "footnoteDefinition":
116
+ case "yaml":
117
+ return [];
118
+ default:
119
+ return "children" in node ? withImages({ kind: "paragraph", inlines: trimmed(textOf(node)) }) : [];
120
+ }
121
+ });
122
+ return blocks(tree.children);
123
+ };