@wirecat/cli-core 0.529.0 → 0.531.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 (54) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/cli/config-command.d.ts +11 -1
  3. package/dist/cli/config-command.js +12 -10
  4. package/dist/cli/index.d.ts +2 -2
  5. package/dist/cli/index.js +1 -1
  6. package/dist/cli/list-query.d.ts +0 -4
  7. package/dist/cli/list-query.js +19 -1
  8. package/dist/cli/messenger/download-command.d.ts +2 -1
  9. package/dist/cli/messenger/download-command.js +2 -1
  10. package/dist/cli/messenger/schedule-command.d.ts +2 -19
  11. package/dist/cli/messenger/schedule-command.js +2 -231
  12. package/dist/cli/messenger/topics-command.js +22 -5
  13. package/dist/cli/schedule/schedule-command.d.ts +19 -0
  14. package/dist/cli/schedule/schedule-command.js +261 -0
  15. package/dist/cli/shared-command-exports.d.ts +2 -1
  16. package/dist/cli/shared-command-exports.js +2 -1
  17. package/dist/domain/models.d.ts +2 -0
  18. package/dist/entities/index.d.ts +41 -2
  19. package/dist/entities/index.js +3 -2
  20. package/dist/entities/list-results.d.ts +3 -0
  21. package/dist/entities/list-results.js +1 -0
  22. package/dist/entities/task-results.d.ts +30 -0
  23. package/dist/entities/task-results.js +1 -0
  24. package/dist/entities/task.d.ts +7 -0
  25. package/dist/entities/task.js +2 -0
  26. package/dist/errors/reasons.js +1 -0
  27. package/dist/files/index.d.ts +1 -0
  28. package/dist/files/index.js +1 -0
  29. package/dist/files/safe-name.d.ts +5 -0
  30. package/dist/files/safe-name.js +18 -0
  31. package/dist/mail/account-key.d.ts +2 -0
  32. package/dist/mail/account-key.js +1 -0
  33. package/dist/mail/cli/index.js +10 -10
  34. package/dist/mail/index.d.ts +1 -0
  35. package/dist/mail/index.js +1 -0
  36. package/dist/mail/retry.js +2 -1
  37. package/dist/mail/rule-stored.js +2 -1
  38. package/dist/mail/sender-scores.js +2 -1
  39. package/dist/mail/sync.js +3 -2
  40. package/dist/ports/messenger.d.ts +3 -0
  41. package/dist/services/file-download.d.ts +2 -5
  42. package/dist/services/file-download.js +3 -18
  43. package/dist/services/tasks.d.ts +4 -2
  44. package/dist/services/tasks.js +2 -2
  45. package/dist/services/topics.d.ts +3 -0
  46. package/dist/services/topics.js +20 -6
  47. package/dist/store/index.d.ts +1 -0
  48. package/dist/store/sqlite/task-board.js +3 -1
  49. package/dist/store/sqlite/task-comments.d.ts +23 -0
  50. package/dist/store/sqlite/task-comments.js +41 -0
  51. package/dist/store/sqlite/things.js +8 -1
  52. package/dist/store/sqlite/work-tasks.d.ts +13 -2
  53. package/dist/store/sqlite/work-tasks.js +52 -1
  54. package/package.json +1 -1
@@ -0,0 +1,18 @@
1
+ import { basename } from "node:path";
2
+ /**
3
+ * A name another person chose cannot climb out of the folder, cannot be a dot file we would then
4
+ * hide, and carries no control or direction character to rewrite the terminal or disguise its extension.
5
+ */
6
+ export const safeName = (name, platform = process.platform) => {
7
+ let plain = name === undefined
8
+ ? ""
9
+ : basename(name.replaceAll("\\", "/"))
10
+ .replace(/[\p{Cc}​-‏‪-‮⁦-⁩]/gu, "")
11
+ .replace(/^\.+/, "");
12
+ if (platform === "win32") {
13
+ plain = plain.replace(/[<>:"|?*]/g, "_").replace(/[. ]+$/, "");
14
+ if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(plain))
15
+ return undefined;
16
+ }
17
+ return plain === "" ? undefined : plain;
18
+ };
@@ -0,0 +1,2 @@
1
+ import type { AccountKey } from "../store/index.js";
2
+ export declare const mailAccountKey: (address: string) => AccountKey;
@@ -0,0 +1 @@
1
+ export const mailAccountKey = (address) => ({ provider: "email", account: address.toLowerCase() });
@@ -84,9 +84,9 @@ const searchCommand = (mail) => {
84
84
  partial: true,
85
85
  output: { entity: "message", view: "hit" },
86
86
  examples: [
87
- `${mail.app.command} ya search messages invoice`,
88
- `${mail.app.command} ya search messages 'from:ann@example.com has:attachment date>30d'`,
89
- `${mail.app.command} ya search messages 'list:github.com is:unread' --newest`,
87
+ `${mail.app.command} personal search messages invoice`,
88
+ `${mail.app.command} personal search messages 'from:ann@example.com has:attachment date>30d'`,
89
+ `${mail.app.command} personal search messages 'list:github.com is:unread' --newest`,
90
90
  ],
91
91
  })
92
92
  .summary(mail.server ? "find emails by words and fields" : "find stored emails by words and fields")
@@ -103,7 +103,7 @@ const searchCommand = (mail) => {
103
103
  .option("--timezone <zone>", "the IANA timezone for calendar date boundaries")
104
104
  .addHelpText("after", `
105
105
  Fields: from: to: cc: bcc: subject: body: mailbox: (or label:) list: (a List-Id or its domain) is:unread|read|starred
106
- has:attachment, filename:, content: (the text of attachments), size>, date: — \`${mail.app.command} ya search messages 'content:invoice'\`.
106
+ has:attachment, filename:, content: (the text of attachments), size>, date: — \`${mail.app.command} personal search messages 'content:invoice'\`.
107
107
  chat: names a thread by its subject. Mail sent to many is in the mailbox bulk; NOT mailbox:bulk leaves it out.
108
108
  Mail from senders with signs of fraud comes after the rest of the page; --plain-order keeps it in place.
109
109
  --json: the list is items; each hit's reference is ref (msg:email/…), which messages show reads.${mail.server
@@ -194,8 +194,8 @@ const messagesCommand = (mail) => {
194
194
  annotate(messages.command("show"), {
195
195
  effects: LOCAL_READ,
196
196
  examples: [
197
- `${mail.app.command} ya messages show 'msg:email/me%40example.com/thread-1/%3Cid%40example.com%3E'`,
198
- `${mail.app.command} ya messages show 'msg:email/me%40example.com/thread-1/%3Cid%40example.com%3E' --before-n 2`,
197
+ `${mail.app.command} personal messages show 'msg:email/me%40example.com/thread-1/%3Cid%40example.com%3E'`,
198
+ `${mail.app.command} personal messages show 'msg:email/me%40example.com/thread-1/%3Cid%40example.com%3E' --before-n 2`,
199
199
  ],
200
200
  })
201
201
  .summary("one stored email whole, with the text of its attachments")
@@ -243,8 +243,8 @@ const conversationsCommand = (mail) => {
243
243
  const list = withQuery(annotate(conversations.command("list"), {
244
244
  effects: LOCAL_READ,
245
245
  examples: [
246
- `${mail.app.command} ya conversations list`,
247
- `${mail.app.command} ya conversations list subject:invoice emails_count>1`,
246
+ `${mail.app.command} personal conversations list`,
247
+ `${mail.app.command} personal conversations list subject:invoice emails_count>1`,
248
248
  ],
249
249
  })
250
250
  .summary("stored email threads, newest first")
@@ -291,8 +291,8 @@ const conversationsCommand = (mail) => {
291
291
  annotate(conversations.command("show"), {
292
292
  effects: LOCAL_READ,
293
293
  examples: [
294
- `${mail.app.command} ya conversations show 'chat:email/me%40example.com/thread-1'`,
295
- `${mail.app.command} ya conversations show 'msg:email/me%40example.com/thread-1/%3Cid%40example.com%3E'`,
294
+ `${mail.app.command} personal conversations show 'chat:email/me%40example.com/thread-1'`,
295
+ `${mail.app.command} personal conversations show 'msg:email/me%40example.com/thread-1/%3Cid%40example.com%3E'`,
296
296
  ],
297
297
  })
298
298
  .summary("one email thread, every email oldest first")
@@ -1,3 +1,4 @@
1
+ export { mailAccountKey } from "./account-key.js";
1
2
  export { BULK, bulkBox } from "./bulk.js";
2
3
  export { factsOf } from "./facts.js";
3
4
  export { GMAIL_ALL_MAIL, mailFolders } from "./folders.js";
@@ -1,3 +1,4 @@
1
+ export { mailAccountKey } from "./account-key.js";
1
2
  export { BULK, bulkBox } from "./bulk.js";
2
3
  export { factsOf } from "./facts.js";
3
4
  export { GMAIL_ALL_MAIL, mailFolders } from "./folders.js";
@@ -1,4 +1,5 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
+ import { mailAccountKey } from "./account-key.js";
2
3
  import { addressOf, FETCH_CHUNK, readAttachments } from "./attachments.js";
3
4
  import { mailFolders } from "./folders.js";
4
5
  import { mailHeadersOf } from "./headers.js";
@@ -12,7 +13,7 @@ export const RETRYABLE_ATTACHMENTS = ["engine-missing"];
12
13
  */
13
14
  export const retryUnread = async ({ store, source, account, since, max, maxBodyChars, maxAttachmentChars, now, loadEngine, index, }) => {
14
15
  const startedAt = now();
15
- const key = { provider: "email", account: account.address.toLowerCase() };
16
+ const key = mailAccountKey(account.address);
16
17
  const find = source.find;
17
18
  if (find === undefined)
18
19
  throw new CliError("validation_error", "--retry-attachments reads mail again by its Gmail id; an mbox file or a plain IMAP account has none");
@@ -1,4 +1,5 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
+ import { mailAccountKey } from "./account-key.js";
2
3
  import { PRESET_NAMES } from "./presets.js";
3
4
  const ACTION_OF = { full: "keep", envelope: "envelope", skip: "skip" };
4
5
  const KINDS = {
@@ -56,7 +57,7 @@ export const storedMailRule = (stored) => {
56
57
  export const storedMailRuleSet = async (store, account, query) => {
57
58
  let stored;
58
59
  try {
59
- const { id } = await store.accounts.storedAccount({ provider: "email", account: account.address.toLowerCase() });
60
+ const { id } = await store.accounts.storedAccount(mailAccountKey(account.address));
60
61
  stored = await store.mail.rules.list(id, query);
61
62
  }
62
63
  catch (error) {
@@ -1,4 +1,5 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
+ import { mailAccountKey } from "./account-key.js";
2
3
  import { domainsToAge, groupKey, probeMail } from "./probe.js";
3
4
  import { decide } from "./rules.js";
4
5
  import { FRAUD_SIGNALS, FREE_MAIL, registrableOf, WEIGHTS } from "./score.js";
@@ -18,7 +19,7 @@ const RECENT = 2 * 365 * DAY;
18
19
  * looked up first, for senders heard from in the last two years.
19
20
  */
20
21
  export const scoreSenders = async (store, address, ages, now = Date.now(), rules = []) => {
21
- const key = { provider: "email", account: address.toLowerCase() };
22
+ const key = mailAccountKey(address);
22
23
  const last = (await store.sync.syncState(key, SCORED))?.value;
23
24
  const done = last === undefined ? undefined : JSON.parse(last);
24
25
  if (done !== undefined && done.version === SCORE_VERSION && now - done.at < DAY)
package/dist/mail/sync.js CHANGED
@@ -2,6 +2,7 @@ import { setTimeout } from "node:timers/promises";
2
2
  import { CliError } from "@wirecat/cli-base";
3
3
  import { importEngine } from "../attachments/extract.js";
4
4
  import { isBusy } from "../store/sqlite/transaction.js";
5
+ import { mailAccountKey } from "./account-key.js";
5
6
  import { FETCH_CHUNK, MAX_ATTACHMENT_CHARS, readAttachments } from "./attachments.js";
6
7
  import { BULK, bulkBox } from "./bulk.js";
7
8
  import { parsedEmail } from "./email-input.js";
@@ -108,7 +109,7 @@ export const syncMail = async (source, store, options) => {
108
109
  })),
109
110
  ];
110
111
  const complete = last.complete && plan.stopped === null && unreadable.length === 0 && remaining === 0;
111
- const account = { provider: "email", account: options.account.address.toLowerCase() };
112
+ const account = mailAccountKey(options.account.address);
112
113
  const indexed = plan.stopped === null ? await index?.(store, account) : undefined;
113
114
  const deletionsSkipped = rounds.map((round) => round.deletionsSkipped).findLast((text) => text !== undefined);
114
115
  const { unreadable: _unreadable, ...rest } = last;
@@ -155,7 +156,7 @@ const syncRound = async (source, store, { account, since, max = 200, maxBodyChar
155
156
  index,
156
157
  });
157
158
  const startedAt = now();
158
- const key = { provider: "email", account: account.address.toLowerCase() };
159
+ const key = mailAccountKey(account.address);
159
160
  const accountId = await store.accounts.saveAccount(key, { name: account.address });
160
161
  const folders = await mailFolders(account, source);
161
162
  if (folders.length < 1 || folders.length > 50)
@@ -272,6 +272,7 @@ export interface ForumControl {
272
272
  enableForum(chatId: Id): Promise<ForumState>;
273
273
  createTopic(chatId: Id, title: string, options: {
274
274
  sendId: string;
275
+ icon?: string;
275
276
  }): Promise<Topic>;
276
277
  }
277
278
  export interface TopicEditing {
@@ -281,6 +282,8 @@ export interface TopicEditing {
281
282
  orderPinnedTopics(chatId: Id, topicIds: Id[]): Promise<void>;
282
283
  /** Deletes the topic and every message in it, for everyone; it cannot be undone. A topic that is gone is `not_found`. */
283
284
  deleteTopic?(chatId: Id, topicId: Id): Promise<void>;
285
+ /** The emojis any account may give a topic as its icon. */
286
+ topicIcons?(): Promise<string[]>;
284
287
  }
285
288
  export interface ThreadAddressing {
286
289
  validateThread(chatId: Id, threadId: Id, options: {
@@ -1,5 +1,6 @@
1
1
  import type { RefreshedFile } from "../domain/attachments.js";
2
2
  import type { Id } from "../domain/models.js";
3
+ import { safeName } from "../files/safe-name.js";
3
4
  import type { RemoteFile } from "../ports/messenger.js";
4
5
  import { batchProgress } from "./batch.js";
5
6
  import type { MessagesService } from "./messages.js";
@@ -39,11 +40,7 @@ export declare const saveFiles: (messages: Pick<MessagesService, "keepDownloaded
39
40
  maxErrorPercent: number;
40
41
  };
41
42
  }>;
42
- /**
43
- * A name another person chose cannot climb out of the folder, cannot be a dot file we would then
44
- * hide, and carries no control or direction character to rewrite the terminal or disguise its extension.
45
- */
46
- export declare const safeName: (name: string | undefined, platform?: NodeJS.Platform) => string | undefined;
43
+ export { safeName };
47
44
  /**
48
45
  * The bytes go to a temporary name first and are then hard-linked to the real one, which fails if the
49
46
  * name is taken — so an existing file survives, and an interrupted download leaves no half file under
@@ -1,9 +1,10 @@
1
1
  import { createWriteStream, mkdirSync } from "node:fs";
2
2
  import { link, rm } from "node:fs/promises";
3
- import { basename, join, resolve } from "node:path";
3
+ import { join, resolve } from "node:path";
4
4
  import { Readable, Transform } from "node:stream";
5
5
  import { pipeline } from "node:stream/promises";
6
6
  import { CliError } from "@wirecat/cli-base";
7
+ import { safeName } from "../files/safe-name.js";
7
8
  import { batchProgress } from "./batch.js";
8
9
  /** The files the messenger gave a new reference for while their bytes were read, to save without a path. */
9
10
  export const refreshedFiles = (files) => files.flatMap(({ kind, name, position, providerRef }) => providerRef === undefined || Object.keys(providerRef).length === 0
@@ -90,23 +91,7 @@ const EXTENSIONS = {
90
91
  };
91
92
  /** Telegram sends a photo with no name and no type; it is always a JPEG. */
92
93
  const BY_KIND = { photo: "jpg" };
93
- /**
94
- * A name another person chose cannot climb out of the folder, cannot be a dot file we would then
95
- * hide, and carries no control or direction character to rewrite the terminal or disguise its extension.
96
- */
97
- export const safeName = (name, platform = process.platform) => {
98
- let plain = name === undefined
99
- ? ""
100
- : basename(name.replaceAll("\\", "/"))
101
- .replace(/[\p{Cc}​-‏‪-‮⁦-⁩]/gu, "")
102
- .replace(/^\.+/, "");
103
- if (platform === "win32") {
104
- plain = plain.replace(/[<>:"|?*]/g, "_").replace(/[. ]+$/, "");
105
- if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(plain))
106
- return undefined;
107
- }
108
- return plain === "" ? undefined : plain;
109
- };
94
+ export { safeName };
110
95
  /**
111
96
  * The bytes go to a temporary name first and are then hard-linked to the real one, which fails if the
112
97
  * name is taken — so an existing file survives, and an interrupted download leaves no half file under
@@ -2,7 +2,7 @@ import { type QueryAst } from "@wirecat/cli-base/query";
2
2
  import type * as v from "valibot";
3
3
  import { task as taskEntity } from "../entities/task.js";
4
4
  import { type CloseSuggestion, type SimilarTask, type TaskDraft, type TaskPair, type TaskPairSide, type TaskRelation } from "../store/index.js";
5
- import type { AccountKey, MessageStore, TaskDetails, ThingLink } from "../store/store.js";
5
+ import type { AccountKey, Author, MessageStore, TaskDetails, ThingLink } from "../store/store.js";
6
6
  import { type ClosedState, type GroupStats, TASK_KINDS, TASK_STATES, type Task, type TaskKind, type TaskOrigin } from "../tasks/index.js";
7
7
  import type { ServiceDeps } from "./deps.js";
8
8
  import { type TaskCategory, type UnlistedCategory } from "./task-categories.js";
@@ -126,7 +126,9 @@ export interface TasksService {
126
126
  * unless `choice` says to attach the message to one of them or to open the task anyway. A task opened while one
127
127
  * closed in the last 14 days is likely or maybe the same job follows it up (`followUpOf`); that one stays closed.
128
128
  */
129
- add(message: string, type: TaskKind, origin: TaskOrigin, plan?: TaskPlan, choice?: DuplicateChoice): Promise<{
129
+ add(message: string, type: TaskKind, origin: TaskOrigin, plan?: TaskPlan, choice?: DuplicateChoice,
130
+ /** Who adds it, when an agent is known by name; otherwise the origin says. */
131
+ author?: Author): Promise<{
130
132
  task: TaskView;
131
133
  created: boolean;
132
134
  attached?: boolean;
@@ -218,7 +218,7 @@ export const tasksService = (deps, categories) => {
218
218
  ...(previews ? { previews: await Promise.all(shown.map(({ task }) => taskView(store, account, task))) } : {}),
219
219
  };
220
220
  }),
221
- add: (message, type, origin, { category: named, ...plan } = {}, choice = {}) => inStore(async (store, account) => {
221
+ add: (message, type, origin, { category: named, ...plan } = {}, choice = {}, author = undefined) => inStore(async (store, account) => {
222
222
  const chosen = named === undefined ? undefined : await categoryOf(store, named);
223
223
  const source = message.trim();
224
224
  const native = isNoteReference(source);
@@ -253,7 +253,7 @@ export const tasksService = (deps, categories) => {
253
253
  ...(plan.dueAt ? { due: plan.dueAt } : {}),
254
254
  };
255
255
  const subject = { account: taskAccount(account), draft };
256
- const by = origin === "owner" ? "owner" : { bot: origin };
256
+ const by = author ?? (origin === "owner" ? "owner" : { bot: origin });
257
257
  if (choice.attach !== undefined) {
258
258
  const target = await owned(store, account, choice.attach);
259
259
  const similarity = await store.tasks.compare(subject, target.id);
@@ -3,6 +3,8 @@ import { type Operated } from "../sends/guarded.js";
3
3
  import type { ServiceDeps } from "./deps.js";
4
4
  export interface TopicsService {
5
5
  show(chat: string, topic: string): Promise<Topic>;
6
+ /** The emojis `create` and `edit` take as an icon. */
7
+ icons(): Promise<string[]>;
6
8
  enable(chat: string, options: {
7
9
  upgrade: boolean;
8
10
  }): Promise<Operated<{
@@ -13,6 +15,7 @@ export interface TopicsService {
13
15
  }>>;
14
16
  create(chat: string, title: string, options: {
15
17
  sendId?: string;
18
+ icon?: string;
16
19
  }): Promise<Operated<{
17
20
  chatId: string;
18
21
  topic: Topic;
@@ -2,9 +2,9 @@ import { CliError, isCliError } from "@wirecat/cli-base";
2
2
  import { capability } from "../ports/messenger.js";
3
3
  import { guardedWrite } from "../sends/guarded.js";
4
4
  import { newOperationId, newSendId } from "../sends/send-id.js";
5
- const actionOf = ({ title, closed, pinned, hidden }) => {
5
+ const actionOf = ({ title, closed, pinned, hidden, icon }) => {
6
6
  const set = [closed, pinned, hidden].filter((one) => one !== undefined).length;
7
- if (title !== undefined || set !== 1)
7
+ if (title !== undefined || icon !== undefined || set !== 1)
8
8
  return "topic-edit";
9
9
  if (closed !== undefined)
10
10
  return closed ? "topic-close" : "topic-reopen";
@@ -16,6 +16,12 @@ const validTitle = (title) => {
16
16
  if (title.trim() === "" || new TextEncoder().encode(title).byteLength > 128)
17
17
  throw new CliError("validation_error", "a topic title needs 1–128 UTF-8 bytes");
18
18
  };
19
+ const validIcon = (icon) => {
20
+ const emoji = icon.trim();
21
+ if (emoji === "")
22
+ throw new CliError("validation_error", "which icon? give an emoji from `topics icons list`");
23
+ return emoji;
24
+ };
19
25
  export const topicsService = (deps) => {
20
26
  const online = async () => {
21
27
  if (deps.offline)
@@ -31,6 +37,11 @@ export const topicsService = (deps) => {
31
37
  throw new CliError("validation_error", "which topic? give its id, from `topics list`");
32
38
  return capability(await deps.connection(), "topic", "show a forum topic")(chat, id);
33
39
  },
40
+ icons: async () => {
41
+ if (deps.offline)
42
+ throw new CliError("validation_error", "`topics icons list` asks the messenger; not with --offline");
43
+ return capability(await deps.connection(), "topicIcons", "list topic icons")();
44
+ },
34
45
  enable: async (chat, { upgrade }) => {
35
46
  const connection = await online();
36
47
  const probe = capability(connection, "forumState", "configure forum topics");
@@ -108,8 +119,9 @@ export const topicsService = (deps) => {
108
119
  forum: true,
109
120
  };
110
121
  },
111
- create: async (chat, title, { sendId }) => {
122
+ create: async (chat, title, { sendId, icon: typedIcon }) => {
112
123
  validTitle(title);
124
+ const icon = typedIcon === undefined ? undefined : validIcon(typedIcon);
113
125
  const connection = await online();
114
126
  const create = capability(connection, "createTopic", "create forum topics");
115
127
  const probe = capability(connection, "forumState", "read forum settings");
@@ -124,7 +136,7 @@ export const topicsService = (deps) => {
124
136
  action: "topic-create",
125
137
  key: "topics.create",
126
138
  length: title.length,
127
- }, () => create(target, title, { sendId: id }), (done) => ({ threadId: done.id, resultChatId: target }), async () => {
139
+ }, () => create(target, title, { sendId: id, ...(icon === undefined ? {} : { icon }) }), (done) => ({ threadId: done.id, resultChatId: target }), async () => {
128
140
  const state = await probe(chatId);
129
141
  if (!state.forum)
130
142
  throw new CliError("validation_error", "enable topics first with `topics enable`; creating a topic never upgrades a group");
@@ -138,8 +150,9 @@ export const topicsService = (deps) => {
138
150
  },
139
151
  edit: async (chat, typedTopic, change) => {
140
152
  const { title, closed, pinned, hidden } = change;
141
- if (title === undefined && closed === undefined && pinned === undefined && hidden === undefined)
142
- throw new CliError("validation_error", "nothing to change — rename, close, reopen, pin, unpin or hide it");
153
+ const icon = change.icon == null ? change.icon : validIcon(change.icon);
154
+ if ([title, closed, pinned, hidden, icon].every((one) => one === undefined))
155
+ throw new CliError("validation_error", "nothing to change — rename, close, reopen, pin, unpin, hide or give an icon");
143
156
  if (title !== undefined)
144
157
  validTitle(title);
145
158
  const topicId = typedTopic.trim();
@@ -155,6 +168,7 @@ export const topicsService = (deps) => {
155
168
  ...(closed === undefined ? {} : { closed }),
156
169
  ...(pinned === undefined ? {} : { pinned }),
157
170
  ...(hidden === undefined ? {} : { hidden }),
171
+ ...(icon === undefined ? {} : { icon }),
158
172
  }));
159
173
  return { operationId, chatId, topic };
160
174
  },
@@ -59,6 +59,7 @@ export type { TaskSort } from "./sqlite/task-board.js";
59
59
  export { PROJECT_TASK_FIELDS, TASK_SORTS } from "./sqlite/task-board.js";
60
60
  export type { SimilarSubject, SimilarTask, TaskDraft } from "./sqlite/task-candidates.js";
61
61
  export { type AgentCloseSettings, type CloseSuggestion, certaintyOf } from "./sqlite/task-close.js";
62
+ export type { NamedAuthor, TaskComment } from "./sqlite/task-comments.js";
62
63
  export type { TaskPair, TaskPairDecision, TaskPairSide } from "./sqlite/task-pairs.js";
63
64
  export { TASK_PAIR_DECISIONS } from "./sqlite/task-pairs.js";
64
65
  export type { TaskRelation, TaskRelationKind, TaskRelationsStore } from "./sqlite/task-relations.js";
@@ -27,7 +27,9 @@ export const PROJECT_TASK_FIELDS = [
27
27
  relation: "low, medium, high, critical or none; a range of labels or of the 0–1 number: priority:[high TO critical]",
28
28
  }),
29
29
  field("agent", "kw", "agent:hermes", { relation: "the agent working on it, by name; none or any" }),
30
- field("due", "time", "due:[today TO 2026-12-31]", { relation: "the due date; NOT due:* has none" }),
30
+ field("due", "time", "due:[today TO 2026-12-31]", {
31
+ relation: "the due date; today, tomorrow and yesterday are your calendar days; NOT due:* has none",
32
+ }),
31
33
  field("overdue", "bool", "overdue:true", { relation: "open work due before today" }),
32
34
  field("when", "enum", "when:today", {
33
35
  values: ["overdue", "today", "upcoming", "later", "none"],
@@ -0,0 +1,23 @@
1
+ import type { Actor } from "./actors.js";
2
+ import { type Author } from "./memories.js";
3
+ import type { StoreContext } from "./open.js";
4
+ type Row = Record<string, unknown>;
5
+ /** Who wrote a row with `author_type` and `author_id`, by the name the store holds now. */
6
+ export type NamedAuthor = {
7
+ type: Actor["type"];
8
+ id: string;
9
+ name: string | null;
10
+ owner: boolean;
11
+ };
12
+ export interface TaskComment {
13
+ ref: string;
14
+ text: string;
15
+ author: NamedAuthor | null;
16
+ at: string;
17
+ }
18
+ export declare const authorNamed: (database: StoreContext["database"], row: Row) => NamedAuthor | null;
19
+ /** A task's comments are notes about it, oldest first. */
20
+ export declare const commentsOf: (database: StoreContext["database"], task: number) => TaskComment[];
21
+ /** In the author's name, unlike `notes.addNote`, which is always the owner's. */
22
+ export declare const addComment: (context: StoreContext, task: number, body: string, by: Author) => TaskComment;
23
+ export {};
@@ -0,0 +1,41 @@
1
+ import { authorActor } from "./memories.js";
2
+ import { write } from "./transaction.js";
3
+ export const authorNamed = (database, row) => {
4
+ if (row.author_type == null)
5
+ return null;
6
+ const named = database
7
+ .prepare(row.author_type === "bot"
8
+ ? "SELECT name, 0 AS owner FROM bots WHERE id = ?"
9
+ : "SELECT name, owner FROM persons WHERE id = ?")
10
+ .get(Number(row.author_id));
11
+ return {
12
+ type: row.author_type,
13
+ id: String(row.author_id),
14
+ name: named?.name == null ? null : String(named.name),
15
+ owner: Number(named?.owner) === 1,
16
+ };
17
+ };
18
+ /** A task's comments are notes about it, oldest first. */
19
+ export const commentsOf = (database, task) => database
20
+ .prepare("SELECT n.id, n.body, n.created_at, n.author_type, n.author_id FROM notes n " +
21
+ "WHERE n.notable_type = 'task' AND n.notable_id = ? AND n.deleted_at IS NULL ORDER BY n.created_at, n.id")
22
+ .all(task)
23
+ .map((row) => ({
24
+ ref: `note:${row.id}`,
25
+ text: String(row.body),
26
+ author: authorNamed(database, row),
27
+ at: new Date(Number(row.created_at)).toISOString(),
28
+ }));
29
+ /** In the author's name, unlike `notes.addNote`, which is always the owner's. */
30
+ export const addComment = (context, task, body, by) => {
31
+ const { database, now } = context;
32
+ const made = write(database, () => {
33
+ const { actor } = authorActor(context, by);
34
+ const at = now();
35
+ return Number(database
36
+ .prepare("INSERT INTO notes (notable_type, notable_id, body, author_type, author_id, revision, created_at, updated_at) " +
37
+ "VALUES ('task', ?, ?, ?, ?, 1, ?, ?) RETURNING id")
38
+ .get(task, body, actor.type, actor.id, at, at)?.id);
39
+ });
40
+ return commentsOf(database, task).find((one) => one.ref === `note:${made}`);
41
+ };
@@ -223,7 +223,14 @@ export const stateOfThing = (database, thing) => {
223
223
  /** The `tasks.id` behind what callers name a task by: the package's id, or the task's key. */
224
224
  export const taskRowOf = (database, id) => {
225
225
  const row = database.prepare("SELECT id FROM tasks WHERE package_id = ? OR key = ? ORDER BY id LIMIT 1").get(id, id);
226
- return row ? Number(row.id) : undefined;
226
+ if (row)
227
+ return Number(row.id);
228
+ // A task moved to another project took a new key there; its old key still names it.
229
+ const moved = database
230
+ .prepare("SELECT task_id FROM task_events WHERE kind = 'project' AND json_extract(changes, '$.key[0]') = ? " +
231
+ "ORDER BY id DESC LIMIT 1")
232
+ .get(id);
233
+ return moved ? Number(moved.task_id) : undefined;
227
234
  };
228
235
  /** The package's id for a task row: the reverse of `taskRowOf`. */
229
236
  export const taskIdOf = (database, rowId) => {
@@ -8,9 +8,10 @@ import type { StoreContext } from "./open.js";
8
8
  import { type Where } from "./query-filter.js";
9
9
  import { type DueGroup, type TaskSort } from "./task-board.js";
10
10
  import { type CloseSuggestion } from "./task-close.js";
11
+ import { type NamedAuthor, type TaskComment } from "./task-comments.js";
11
12
  import { type TaskRelationsStore } from "./task-relations.js";
12
13
  export declare const TASK_ROLES: readonly ["assignee", "reviewer", "watcher"];
13
- export declare const TASK_EVENT_KINDS: readonly ["created", "title", "description", "status", "assigned", "unassigned", "due", "priority", "moved", "linked", "unlinked", "category", "close-suggestion", "agent", "claimed", "released", "next-check", "resolution", "verdict", "close-certainty", "tags"];
14
+ export declare const TASK_EVENT_KINDS: readonly ["created", "title", "description", "status", "assigned", "unassigned", "due", "priority", "moved", "linked", "unlinked", "category", "close-suggestion", "agent", "claimed", "released", "next-check", "resolution", "verdict", "close-certainty", "tags", "project", "type"];
14
15
  export type WorkTaskType = (typeof WORK_TASK_TYPES)[number];
15
16
  export type TaskType = (typeof TASK_TYPES)[number];
16
17
  export type TaskRole = (typeof TASK_ROLES)[number];
@@ -30,8 +31,12 @@ export interface TaskFields {
30
31
  reason?: string | null;
31
32
  /** A name the caller checked against its category list; every change is a `category` event. */
32
33
  category?: string;
34
+ /** Another owner project, by key or `project:<id>`: the task takes the next key there; its old key still finds it. */
35
+ project?: string;
36
+ /** A project task's kind, bug, feature or chore. */
37
+ type?: WorkTaskType;
33
38
  }
34
- export interface NewWorkTask extends Omit<TaskFields, "reason"> {
39
+ export interface NewWorkTask extends Omit<TaskFields, "reason" | "project" | "type"> {
35
40
  /** `project:<id>` or the project's key. */
36
41
  project: string;
37
42
  title: string;
@@ -51,6 +56,8 @@ export interface TaskDetails {
51
56
  /** The project's key and name, as `boardCounts` names it. */
52
57
  projectKey: string;
53
58
  projectName: string;
59
+ /** Who added it: the owner, or an agent by its bot name; `null` for a row with no author. */
60
+ createdBy: NamedAuthor | null;
54
61
  title: string;
55
62
  description: string | null;
56
63
  type: TaskType;
@@ -185,6 +192,10 @@ export interface WorkTasks extends TaskRelationsStore {
185
192
  limit?: number;
186
193
  }): Promise<TaskDetails[]>;
187
194
  events(id: string): Promise<TaskEvent[]>;
195
+ /** A note about the task in the author's name, as an agent's comment is the agent's; the revision stays. */
196
+ comment(id: string, text: string, by: Author): Promise<TaskComment>;
197
+ /** Its comments, oldest first. */
198
+ comments(id: string): Promise<TaskComment[]>;
188
199
  /** The owner's project tasks, every project at once, narrowed by a query over `PROJECT_TASK_FIELDS`. */
189
200
  board(query: QueryAst | undefined, options: BoardOptions): Promise<{
190
201
  items: TaskDetails[];
@@ -10,6 +10,7 @@ import { queryFilter } from "./query-filter.js";
10
10
  import { stemmerCache } from "./stems.js";
11
11
  import { BOARD_SCOPE, boardOrder, dueGroups, projectTaskColumns, withoutField, } from "./task-board.js";
12
12
  import { closeSuggestionOf, SUGGESTION_COLUMNS } from "./task-close.js";
13
+ import { addComment, authorNamed, commentsOf } from "./task-comments.js";
13
14
  import { INBOX_KEY_LENGTH, inboxKey } from "./task-keys.js";
14
15
  import { taskRelationsStoreOver } from "./task-relations.js";
15
16
  import { taskIdOf, taskRowOf, thingOf } from "./things.js";
@@ -37,6 +38,8 @@ export const TASK_EVENT_KINDS = [
37
38
  "verdict",
38
39
  "close-certainty",
39
40
  "tags",
41
+ "project",
42
+ "type",
40
43
  ];
41
44
  const iso = (value) => (value == null ? null : new Date(Number(value)).toISOString());
42
45
  export const recordTaskEvent = (database, task, actor, kind, changes, at) => {
@@ -122,6 +125,7 @@ export const workTaskOperationsOver = (context) => {
122
125
  key: String(row.key),
123
126
  project: `project:${row.project_id}`,
124
127
  ...projectNamed(Number(row.project_id)),
128
+ createdBy: authorNamed(database, row),
125
129
  title: String(row.title),
126
130
  description: row.description == null ? null : String(row.description),
127
131
  type: row.type,
@@ -164,11 +168,43 @@ export const workTaskOperationsOver = (context) => {
164
168
  const countOf = (where) => Number(database.prepare(`SELECT count(*) AS n FROM tasks t WHERE ${where.sql}`).get(...where.params)?.n ?? 0);
165
169
  const rowById = (id) => database.prepare("SELECT * FROM tasks WHERE id = ?").get(id);
166
170
  const details = (id) => detailsOf(rowById(id));
171
+ const onBoard = (task) => Boolean(database.prepare(`SELECT 1 FROM tasks t WHERE t.id = ? AND ${BOARD_SCOPE}`).get(task));
172
+ /** The task takes the target's next number and key; the source's counter never goes back, so its old key stays unique. */
173
+ const moveToProject = (task, before, target, actor, at) => {
174
+ if (target === Number(before.project_id))
175
+ return;
176
+ if (!onBoard(task))
177
+ throw new CliError("validation_error", "only a project task moves to another project");
178
+ const targetRow = database.prepare("SELECT key FROM projects WHERE id = ?").get(target);
179
+ if (String(targetRow.key).length === INBOX_KEY_LENGTH)
180
+ throw new CliError("validation_error", "a task moves only into one of your projects");
181
+ const children = database
182
+ .prepare("SELECT key FROM tasks WHERE parent_id = ? AND deleted_at IS NULL ORDER BY id")
183
+ .all(task)
184
+ .map((row) => String(row.key));
185
+ if (children.length)
186
+ throw new CliError("validation_error", `${String(before.key)} has subtasks (${children.join(", ")}); move or detach them first`, { reason: "task.has-subtasks", subtasks: children });
187
+ const counted = database
188
+ .prepare("UPDATE projects SET tasks_count = tasks_count + 1, updated_at = ? WHERE id = ? RETURNING key, tasks_count")
189
+ .get(at, target);
190
+ const number = Number(counted.tasks_count);
191
+ const key = `${String(counted.key)}-${number}`;
192
+ database
193
+ .prepare("UPDATE tasks SET project_id = ?, number = ?, key = ?, parent_id = NULL, updated_at = ? WHERE id = ?")
194
+ .run(target, number, key, at, task);
195
+ recordTaskEvent(database, task, actor, "project", {
196
+ project: [`project:${before.project_id}`, `project:${target}`],
197
+ key: [String(before.key), key],
198
+ ...(before.parent_id == null ? {} : { parent: [`task:${taskIdOf(database, Number(before.parent_id))}`, null] }),
199
+ }, at);
200
+ };
167
201
  const edit = (task, fields, actor, created = false) => {
168
202
  const before = rowById(task);
169
203
  const at = now();
170
204
  const set = [];
171
205
  const values = [];
206
+ // The revision triggers do not watch `type`, so a kind change bumps it here.
207
+ let bump = false;
172
208
  const change = (column, value, event, key = column) => {
173
209
  if ((before[column] ?? null) === value)
174
210
  return;
@@ -237,8 +273,21 @@ export const workTaskOperationsOver = (context) => {
237
273
  }
238
274
  if (fields.category !== undefined)
239
275
  change("category", fields.category, "category");
240
- if (set.length === 0)
276
+ if (fields.type !== undefined && fields.type !== before.type) {
277
+ if (!WORK_TASK_TYPES.includes(fields.type) || !onBoard(task))
278
+ throw new CliError("validation_error", `a project task's kind is one of ${WORK_TASK_TYPES.join(", ")}`);
279
+ change("type", fields.type, "type");
280
+ bump = true;
281
+ }
282
+ if (fields.project !== undefined)
283
+ moveToProject(task, before, projectOf(fields.project), actor, at);
284
+ if (set.length === 0) {
285
+ if (bump)
286
+ database.prepare("UPDATE tasks SET revision = revision + 1 WHERE id = ?").run(task);
241
287
  return;
288
+ }
289
+ if (bump)
290
+ set.push("revision = revision + 1");
242
291
  database.prepare(`UPDATE tasks SET ${set.join(", ")}, updated_at = ? WHERE id = ?`).run(...values, at, task);
243
292
  };
244
293
  /** The column's tasks in board order, the moving one left out; no priority reads as 0, the bottom of low. */
@@ -555,6 +604,8 @@ export const workTaskOperationsOver = (context) => {
555
604
  owner: Number(row.owner) === 1,
556
605
  tasks: Number(row.n),
557
606
  })),
607
+ comment: async (id, text, by) => addComment(context, required(id), textOf(text, "a comment", 20000), by),
608
+ comments: async (id) => commentsOf(database, required(id)),
558
609
  events: async (id) => database
559
610
  .prepare("SELECT e.actor_type, e.actor_id, e.kind, e.changes, e.created_at, " +
560
611
  "CASE e.actor_type WHEN 'person' THEN p.name WHEN 'bot' THEN b.name END AS actor_name FROM task_events e " +