@wirecat/cli-core 0.502.0 → 0.504.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,38 @@ Notable changes to `@wirecat/cli-core` (`@wirecat/cli-messaging` up to 0.261.0,
5
5
  minor version may break callers, and says how under "Changed — may break callers". `pnpm docs:check` checks the
6
6
  shape of this file.
7
7
 
8
+ ## Unreleased
9
+
10
+ ## 0.504.0 — 31.10.2026
11
+
12
+ ### Added
13
+
14
+ - **`projects evidence --sync-first` in messenger CLIs: `projectsCommand(messenger)`.** A CLI
15
+ that mounts it reads a project's evidence from its profile's stored messages; `--sync-first` (off by default, not on
16
+ MCP) first syncs the project's linked chats the store does not hold back to `--since-time`, within `--sync-time`
17
+ (3 minutes), one chat at a time. A chat the time cut stays partial with its `missing` and `next`. The command is
18
+ declared a fetch (`remote: read`, `local: write`) because of `--sync-first`; cli-base fixes effects per command.
19
+ It prints what people wrote, so the `messages` permission guards it.
20
+ - **`gatherStoredProject`** (services) and **`projectAnswerParts`, `projectEvidenceText`, `projectWindowOf`,
21
+ `projectCutoffTime`** (cli): memo's `projects evidence` over a store, moved here so memo and the messenger CLIs
22
+ share it. The host resolves the sources and names its own sync command in the text.
23
+
24
+ ### Changed — may break callers
25
+
26
+ - **`gatherProjectEvidence`'s fetch port is per linked chat.** `ProjectSourceFetcher` (one fetch per source, no
27
+ caller) is replaced by `ProjectChatFetcher`: `fetch({ account, chatId, after, before, until, signal, onProgress })` →
28
+ `{ state: "complete" | "partial" | "failed", reason? }`. `fetch: "before-read"` now needs the `coverage` port too,
29
+ takes `budgets.maxFetchMs`, and fetches only linked chats the store does not hold for the window. The result's
30
+ `fetch` lists those chats (`ProjectChatFetched`) instead of one row per source; `ProjectFetchState` is gone. The
31
+ read budgets, `maxElapsedMs` included, start after the fetch.
32
+
33
+ ## 0.503.0 — 31.10.2026
34
+
35
+ ### Added
36
+
37
+ - **`session start --credential-storage` in the parity manifest, for max only** (WC-SECR-4). max's token goes through
38
+ cli-base's `Credentials`; tg's and WhatsApp's sign-ins are the library's own session file, the rule's exception.
39
+
8
40
  ## 0.502.0 — 31.10.2026
9
41
 
10
42
  ### Added
@@ -109,11 +109,12 @@ export { window, withPaging } from "./paging.js";
109
109
  export { type CommandPermissions, commandPathKey, commandPermissions, knownBeside, knownPermissionKeys, type PermissionKeyOf, type PermissionKeySource, refuseUnknownPermissionKeys, unknownPermissionIssues, unknownPermissionKeys, } from "./permission-keys.js";
110
110
  export { asFirstWord, commandWords, liftProfile, profileFree, refuseCommandName, rootOf, takesProfile, usableProfileName, } from "./profile.js";
111
111
  export { createProgram, type ProgramDefinition, type ProgramOptions, type RunOptions, run } from "./program.js";
112
+ export { type ProjectTextHints, projectAnswerParts, projectCutoffTime, projectEvidenceText, projectWindowOf, } from "./project-evidence.js";
112
113
  export { fieldsOf, projectFields } from "./result-fields.js";
113
114
  export { type Config, type Configuration, count, first, flag, fromFile, type GlobalFlags, NONE_NAMED, namedProfile, type ProfileKind, plain, type ResolveOptions, type Settings, type SettingsExtension, type SettingsExtensionContext, type Source, settingsFor, } from "./settings.js";
114
115
  export { type PermissionAnswer, readSetupAnswers, type SetupAnswers, setupPermissionCeiling } from "./setup-answers.js";
115
116
  export { AGENTS, type Agent, agentFor, screenFor } from "./setup-screen.js";
116
- export { accountCommand, addMeetingBatchCommand, addMeetingPreparationCommand, addMeetingProjectCandidatesCommand, addMeetingSeriesInventoryCommand, appDoctorCommand, appMcpCommand, attachmentsCommand, botAdminsCommand, botCommand, botMcpCommand, botMembersCommand, botMessagesCommand, callsCommand, chatsCommand, commandsCommand, completeCommand, configCommand, contactsCommand, conversationsCommand, deleteCommand, doctorCommand, editCommand, floodCommand, forwardCommand, generatedApiCommand, guideCommand, imagesCommand, inboxCommand, markReadCommand, mcpCommand, mentionsCommand, messagesCommand, metadataCommand, modelCatalogueCommand, modelsCommand, pinCommand, pollsCommand, reactionsCommand, recipientsCommand, repliesCommand, reviewCommand, runsCommand, scheduleCommand, searchCommand, searchesCommand, sendCommand, sendsCommand, serveCommand, serverCommand, skillCommand, starCommands, statsCommand, stickersCommand, storeCommand, tagsCommand, tasksCommand, tasksStatsCommand, topicsCommand, unpinCommand, upgradeCommand, watchCommand, } from "./shared-command-exports.js";
117
+ export { accountCommand, addMeetingBatchCommand, addMeetingPreparationCommand, addMeetingProjectCandidatesCommand, addMeetingSeriesInventoryCommand, appDoctorCommand, appMcpCommand, attachmentsCommand, botAdminsCommand, botCommand, botMcpCommand, botMembersCommand, botMessagesCommand, callsCommand, chatsCommand, commandsCommand, completeCommand, configCommand, contactsCommand, conversationsCommand, deleteCommand, doctorCommand, editCommand, floodCommand, forwardCommand, generatedApiCommand, guideCommand, imagesCommand, inboxCommand, markReadCommand, mcpCommand, mentionsCommand, messagesCommand, metadataCommand, modelCatalogueCommand, modelsCommand, pinCommand, pollsCommand, projectsCommand, reactionsCommand, recipientsCommand, repliesCommand, reviewCommand, runsCommand, scheduleCommand, searchCommand, searchesCommand, sendCommand, sendsCommand, serveCommand, serverCommand, skillCommand, starCommands, statsCommand, stickersCommand, storeCommand, tagsCommand, tasksCommand, tasksStatsCommand, topicsCommand, unpinCommand, upgradeCommand, watchCommand, } from "./shared-command-exports.js";
117
118
  export { assertSharedContracts, customizeSharedCommand, hasSharedContract, overrideSharedContract, type SharedContractOverride, sharedContractOverrides, sharedContracts, } from "./shared-contracts.js";
118
119
  export { runnable, withSafety } from "./steps.js";
119
120
  export { type StoreInfo, sharedStoreCommand, storeInfo, storeInfoCommand } from "./store-info.js";
package/dist/cli/index.js CHANGED
@@ -99,11 +99,12 @@ export { window, withPaging } from "./paging.js";
99
99
  export { commandPathKey, commandPermissions, knownBeside, knownPermissionKeys, refuseUnknownPermissionKeys, unknownPermissionIssues, unknownPermissionKeys, } from "./permission-keys.js";
100
100
  export { asFirstWord, commandWords, liftProfile, profileFree, refuseCommandName, rootOf, takesProfile, usableProfileName, } from "./profile.js";
101
101
  export { createProgram, run } from "./program.js";
102
+ export { projectAnswerParts, projectCutoffTime, projectEvidenceText, projectWindowOf, } from "./project-evidence.js";
102
103
  export { fieldsOf, projectFields } from "./result-fields.js";
103
104
  export { count, first, flag, fromFile, NONE_NAMED, namedProfile, plain, settingsFor, } from "./settings.js";
104
105
  export { readSetupAnswers, setupPermissionCeiling } from "./setup-answers.js";
105
106
  export { AGENTS, agentFor, screenFor } from "./setup-screen.js";
106
- export { accountCommand, addMeetingBatchCommand, addMeetingPreparationCommand, addMeetingProjectCandidatesCommand, addMeetingSeriesInventoryCommand, appDoctorCommand, appMcpCommand, attachmentsCommand, botAdminsCommand, botCommand, botMcpCommand, botMembersCommand, botMessagesCommand, callsCommand, chatsCommand, commandsCommand, completeCommand, configCommand, contactsCommand, conversationsCommand, deleteCommand, doctorCommand, editCommand, floodCommand, forwardCommand, generatedApiCommand, guideCommand, imagesCommand, inboxCommand, markReadCommand, mcpCommand, mentionsCommand, messagesCommand, metadataCommand, modelCatalogueCommand, modelsCommand, pinCommand, pollsCommand, reactionsCommand, recipientsCommand, repliesCommand, reviewCommand, runsCommand, scheduleCommand, searchCommand, searchesCommand, sendCommand, sendsCommand, serveCommand, serverCommand, skillCommand, starCommands, statsCommand, stickersCommand, storeCommand, tagsCommand, tasksCommand, tasksStatsCommand, topicsCommand, unpinCommand, upgradeCommand, watchCommand, } from "./shared-command-exports.js";
107
+ export { accountCommand, addMeetingBatchCommand, addMeetingPreparationCommand, addMeetingProjectCandidatesCommand, addMeetingSeriesInventoryCommand, appDoctorCommand, appMcpCommand, attachmentsCommand, botAdminsCommand, botCommand, botMcpCommand, botMembersCommand, botMessagesCommand, callsCommand, chatsCommand, commandsCommand, completeCommand, configCommand, contactsCommand, conversationsCommand, deleteCommand, doctorCommand, editCommand, floodCommand, forwardCommand, generatedApiCommand, guideCommand, imagesCommand, inboxCommand, markReadCommand, mcpCommand, mentionsCommand, messagesCommand, metadataCommand, modelCatalogueCommand, modelsCommand, pinCommand, pollsCommand, projectsCommand, reactionsCommand, recipientsCommand, repliesCommand, reviewCommand, runsCommand, scheduleCommand, searchCommand, searchesCommand, sendCommand, sendsCommand, serveCommand, serverCommand, skillCommand, starCommands, statsCommand, stickersCommand, storeCommand, tagsCommand, tasksCommand, tasksStatsCommand, topicsCommand, unpinCommand, upgradeCommand, watchCommand, } from "./shared-command-exports.js";
107
108
  export { assertSharedContracts, customizeSharedCommand, hasSharedContract, overrideSharedContract, sharedContractOverrides, sharedContracts, } from "./shared-contracts.js";
108
109
  export { runnable, withSafety } from "./steps.js";
109
110
  export { sharedStoreCommand, storeInfo, storeInfoCommand } from "./store-info.js";
@@ -0,0 +1,3 @@
1
+ import { Command } from "commander";
2
+ import { type Messenger } from "./context.js";
3
+ export declare const projectsCommand: (messenger: Messenger) => Command;
@@ -0,0 +1,134 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { annotate, annotateOption } from "@wirecat/cli-base/commands";
3
+ import { Command, Option } from "commander";
4
+ import { parseDuration } from "../../domain/duration.js";
5
+ import { FETCHING } from "../../services/archive.js";
6
+ import { gatherStoredProject } from "../../services/stored-project-gathering.js";
7
+ import { FETCH } from "../effects.js";
8
+ import { itemOf, renderAnswer, snakeKeys } from "../envelope.js";
9
+ import { positiveCount } from "../paging.js";
10
+ import { projectAnswerParts, projectCutoffTime, projectEvidenceText, projectWindowOf } from "../project-evidence.js";
11
+ import { lineOf } from "../steps.js";
12
+ import { messengerContext } from "./context.js";
13
+ const collect = (value, previous) => [...previous, value];
14
+ const upTo = (most, flag) => (value) => {
15
+ const n = Number(value);
16
+ if (!/^\d+$/.test(value) || n > most)
17
+ throw new CliError("validation_error", `${flag} takes a whole number from 0 to ${most}`);
18
+ return n;
19
+ };
20
+ /** More than any chat one fetch budget can bring; the budget, not the count, ends a long chat. */
21
+ const FETCH_LIMIT = 1_000_000;
22
+ /** This profile's account, the only one its connection can sync. */
23
+ const sourcesOf = async (store, account) => {
24
+ const stored = (await store.accounts.storedAccounts()).find((one) => one.provider === account.provider && one.account === account.account);
25
+ if (!stored)
26
+ throw new CliError("not_found", "the store holds nothing of this profile's account yet; sync a chat first", {
27
+ param: "profile",
28
+ });
29
+ return {
30
+ sources: [{ kind: "message", accountId: stored.id, provider: stored.provider, account: stored.account }],
31
+ unread: [],
32
+ scope: "explicit",
33
+ };
34
+ };
35
+ /**
36
+ * `archive.fetch` back to the window start, one linked chat at a time. Only this profile's account is a source, so
37
+ * the gathering never asks for another account's chat.
38
+ */
39
+ const archiveFetcher = (withServices, messenger) => {
40
+ const fetching = messenger.fetching ?? FETCHING;
41
+ return {
42
+ fetch: async ({ chatId, after, signal, onProgress }) => {
43
+ const result = await withServices((services) => services.archive.fetch(chatId, {
44
+ limit: FETCH_LIMIT,
45
+ pageSize: fetching.page,
46
+ pauseMs: parseDuration(fetching.pause, "pause"),
47
+ sinceMs: after,
48
+ catchUp: false,
49
+ note: () => { },
50
+ stop: signal,
51
+ onPage: ({ fetched, oldest }) => onProgress?.({ fetched, oldest }),
52
+ }));
53
+ if (result.issue)
54
+ return { state: "partial", reason: result.issue.code };
55
+ if (result.stopped)
56
+ return { state: "partial", reason: "fetch-budget" };
57
+ return result.complete || result.reachedSince ? { state: "complete" } : { state: "partial", reason: "limit" };
58
+ },
59
+ };
60
+ };
61
+ /** Off MCP: a tool call ends at about a minute, too soon for a sync, which would fail the whole answer. */
62
+ const cliOnly = (flags, description, value) => {
63
+ const option = new Option(flags, description);
64
+ if (value !== undefined)
65
+ option.default(value);
66
+ return annotateOption(option, { mcp: false });
67
+ };
68
+ export const projectsCommand = (messenger) => {
69
+ const projects = new Command("projects").description("what the store holds about the owner's projects");
70
+ const app = messenger.app.command;
71
+ projects.addCommand(annotate(new Command("evidence"), {
72
+ output: { entity: "project_evidence" },
73
+ examples: [`${app} personal projects evidence "Harbour" --since-time 30d --sync-first`],
74
+ // Declared as a fetch for --sync-first; without it nothing leaves this machine, and a fetch is never a write elsewhere.
75
+ effects: FETCH,
76
+ cooperative: true,
77
+ partial: true,
78
+ })
79
+ .summary("what this account's stored messages hold about a project, linked chats included")
80
+ .description("cited references about a project in this profile's stored messages. The project's name, key, each alias and " +
81
+ "each --alias, --context-query and --contact name is its own search; their hits are joined. Every message of " +
82
+ "a chat confirmed as the project's is read too, and so is what a confirmed person member wrote. Reads what the " +
83
+ "store holds; with --sync-first it first syncs the project's linked chats the store does not hold for the window")
84
+ .argument("<project>", "an existing project's id, key, exact name or alias, or a new project's name")
85
+ .option("--alias <name>", "another name of the project, searched on its own; repeat for more", collect, [])
86
+ .option("--context-query <words>", "more words that all appear in one text; repeat for another", collect, [])
87
+ .option("--contact <person>", "a name to search, or person:<id> to read what that person wrote", collect, [])
88
+ .option("--since-time <time>", "how far back: days (90d, at most 730d) or a start date (2026-09-01)", "60d")
89
+ .option("--before-time <time>", "fixed exclusive end: ISO 8601 timestamp with UTC offset")
90
+ .option("--limit <n>", "new references at most, up to 500; 100 if not given", positiveCount("--limit"))
91
+ .option("--max-pages <pages>", "store pages read at most this run, up to 512", positiveCount("--max-pages"))
92
+ .option("--cursor <cursor>", "continue a stopped search from its checkpoint, with the same options")
93
+ .option("--before-n <n>", "messages before each message hit, at most 5", upTo(5, "--before-n"), 2)
94
+ .option("--after-n <n>", "messages after each message hit, at most 5", upTo(5, "--after-n"), 2)
95
+ .option("--no-catch-up", "read the search index as it is, without indexing new text first")
96
+ .addOption(cliOnly("--sync-first", "first sync the linked chats this account does not hold back to --since-time; off by default"))
97
+ .addOption(cliOnly("--sync-time <duration>", "time for all of --sync-first's syncs together; 3m if not given", "3m"))
98
+ .action(async function (project) {
99
+ const options = this.opts();
100
+ const context = messengerContext(this, messenger);
101
+ const maxFetchMs = parseDuration(options.syncTime, "--sync-time");
102
+ const gather = (store, account, fetcher) => sourcesOf(store, account).then((sources) => gatherStoredProject(store, {
103
+ project,
104
+ sources,
105
+ ...projectWindowOf(options.sinceTime),
106
+ ...(options.beforeTime === undefined ? {} : { asOf: projectCutoffTime(options.beforeTime) }),
107
+ limit: options.limit ?? 100,
108
+ catchUp: options.catchUp,
109
+ beforeN: options.beforeN,
110
+ afterN: options.afterN,
111
+ ...(options.alias.length ? { aliases: options.alias } : {}),
112
+ ...(options.contextQuery.length ? { keywords: options.contextQuery } : {}),
113
+ ...(options.contact.length ? { people: options.contact } : {}),
114
+ ...(options.maxPages === undefined ? {} : { maxReads: options.maxPages }),
115
+ ...(options.cursor === undefined ? {} : { checkpoint: options.cursor }),
116
+ ...(fetcher ? { fetcher, maxFetchMs, onFetchProgress: progressNote(context.renderer.note) } : {}),
117
+ }));
118
+ const fetcher = options.syncFirst
119
+ ? archiveFetcher((work) => context.withServices(work, { name: "projects evidence fetch" }), messenger)
120
+ : undefined;
121
+ const found = await context.withStore((store, account) => gather(store, account, fetcher));
122
+ const parts = projectAnswerParts(options.sinceTime, found);
123
+ renderAnswer(this, context, itemOf(snakeKeys(found), parts), () => projectEvidenceText(found, {
124
+ sync: (one, since) => one.provider === messenger.provider
125
+ ? lineOf(this, `projects evidence ${quote(project)} --since-time ${since} --sync-first`)
126
+ : null,
127
+ showDocument: "the notes app that imported it",
128
+ }));
129
+ }));
130
+ return projects;
131
+ };
132
+ const quote = (text) => (/^[\w:.-]+$/.test(text) ? text : `"${text.replace(/(["\\$`])/g, "\\$1")}"`);
133
+ /** A line per saved page, counts only; a timed heartbeat with titles is the sync-progress work's (cli-core#2121). */
134
+ const progressNote = (note) => ({ chat, index, of, fetched }) => note(`chat ${index + 1} of ${of} · ${chat} · ${fetched} messages so far`);
@@ -0,0 +1,50 @@
1
+ import type { StoredProjectGathering } from "../services/stored-project-gathering.js";
2
+ /** Days back (`90d`) or a start date (`2026-09-01`); either way the run freezes its end, so a resume keeps it. */
3
+ export declare const projectWindowOf: (value: string) => {
4
+ days: number;
5
+ } | {
6
+ after: number;
7
+ };
8
+ export declare const projectCutoffTime: (value: string) => number;
9
+ /**
10
+ * The zone a typed start date was read in, and the window the run used, in UTC. `complete` is "the answer is whole":
11
+ * reads finished and every linked chat held in full; the answer's own `complete` stays "reads finished".
12
+ */
13
+ export declare const projectAnswerParts: (sinceTime: string, found: StoredProjectGathering) => {
14
+ next?: ({
15
+ kind: "command";
16
+ step: string;
17
+ command: string;
18
+ args: {
19
+ [x: string]: unknown;
20
+ };
21
+ line?: string | undefined;
22
+ why: string;
23
+ safe_to_auto_execute: boolean;
24
+ } | {
25
+ kind: "user_action";
26
+ step: string;
27
+ why: string;
28
+ safe_to_auto_execute: false;
29
+ })[] | undefined;
30
+ missing?: {
31
+ field: string;
32
+ why: string;
33
+ step?: string | undefined;
34
+ }[] | undefined;
35
+ meta: {
36
+ timezone?: string | undefined;
37
+ window: {
38
+ since: string;
39
+ until: string;
40
+ };
41
+ complete: boolean;
42
+ };
43
+ };
44
+ export interface ProjectTextHints {
45
+ /** The command that syncs one linked chat back to `since` (`YYYY-MM-DD`), or null when no command here can. */
46
+ sync: (chat: StoredProjectGathering["linkedChats"][number], since: string) => string | null;
47
+ /** How to open a document too large to read here. */
48
+ showDocument: string;
49
+ }
50
+ export declare const projectEvidenceText: (found: StoredProjectGathering, hints: ProjectTextHints) => string;
@@ -0,0 +1,142 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import * as v from "valibot";
3
+ import { dateWindow, momentOf } from "../services/moment.js";
4
+ import { coverageGaps } from "./messenger/search-answers.js";
5
+ /** Days back (`90d`) or a start date (`2026-09-01`); either way the run freezes its end, so a resume keeps it. */
6
+ export const projectWindowOf = (value) => {
7
+ const days = /^([1-9]\d{0,2})d$/.exec(value)?.[1];
8
+ if (days !== undefined && Number(days) <= 730)
9
+ return { days: Number(days) };
10
+ if (/^\d{4}-\d{2}-\d{2}(T[\d:.]+(Z|[+-]\d{2}:\d{2})?)?$/.test(value))
11
+ return { after: momentOf(value, "--since-time") };
12
+ throw new CliError("validation_error", `--since-time takes 1d to 730d or a date, such as 90d or 2026-09-01 — not ${value}`, { param: "since_time" });
13
+ };
14
+ export const projectCutoffTime = (value) => {
15
+ const parsed = v.safeParse(v.pipe(v.string(), v.isoTimestamp()), value);
16
+ const at = parsed.success ? Date.parse(parsed.output) : Number.NaN;
17
+ const date = parsed.success ? Date.parse(`${parsed.output.slice(0, 10)}T00:00:00.000Z`) : Number.NaN;
18
+ if (!parsed.success ||
19
+ !/(Z|[+-]\d{2}:\d{2})$/.test(parsed.output) ||
20
+ !Number.isFinite(at) ||
21
+ at < 1 ||
22
+ !Number.isFinite(date) ||
23
+ new Date(date).toISOString().slice(0, 10) !== parsed.output.slice(0, 10))
24
+ throw new CliError("validation_error", "--before-time needs a real ISO 8601 timestamp with a UTC offset", {
25
+ param: "before_time",
26
+ });
27
+ return at;
28
+ };
29
+ const coverageRows = ({ linkedChats }) => linkedChats.flatMap((one) => one.completeness === null || one.held === null
30
+ ? []
31
+ : [{ chatId: one.chat, title: one.title, completeness: one.completeness, held: one.held }]);
32
+ /**
33
+ * The zone a typed start date was read in, and the window the run used, in UTC. `complete` is "the answer is whole":
34
+ * reads finished and every linked chat held in full; the answer's own `complete` stays "reads finished".
35
+ */
36
+ export const projectAnswerParts = (sinceTime, found) => {
37
+ const { window } = found;
38
+ const { timezone } = dateWindow({ sinceTime }).meta;
39
+ const { next, missing } = coverageGaps(coverageRows(found), 0, new Date(window.after).toISOString());
40
+ return {
41
+ ...(next.length ? { next } : {}),
42
+ ...(missing.length ? { missing } : {}),
43
+ meta: {
44
+ ...(timezone === undefined ? {} : { timezone }),
45
+ window: { since: new Date(window.after).toISOString(), until: new Date(window.before).toISOString() },
46
+ complete: found.complete && found.completeness === "complete",
47
+ },
48
+ };
49
+ };
50
+ const LABELS = {
51
+ "person-only": " [person only]",
52
+ sender: " [sent by contact]",
53
+ "linked-chat": " [linked chat]",
54
+ };
55
+ const day = (at) => new Date(at).toISOString().slice(0, 10);
56
+ const FETCH_STATES = {
57
+ complete: "synced",
58
+ partial: "synced in part",
59
+ failed: "not synced",
60
+ skipped: "not synced, the fetch time ran out first",
61
+ };
62
+ export const projectEvidenceText = (found, hints) => {
63
+ const lanes = new Map(found.lanes.map((lane) => [
64
+ lane.id,
65
+ `${lane.query ?? lane.chat ?? `what ${lane.people.join(", ")} wrote`} (${lane.kinds.join(", ")})`,
66
+ ]));
67
+ const linked = found.items.filter((item) => item.context === "linked-chat").length;
68
+ const sent = found.items.filter((item) => item.context === "sender").length;
69
+ const unreadSenders = found.lanes.filter((lane) => lane.kinds.includes("sender") && !found.units.some((unit) => unit.lane === lane.id));
70
+ const failed = found.units.filter((unit) => unit.state === "failed" || unit.state === "unavailable");
71
+ const lines = [
72
+ `${found.project.name}${found.projectRef ? ` — ${found.projectRef}` : ""}: ${found.items.length} new references, ` +
73
+ `${day(found.window.after)} to ${day(found.window.before)}, ${found.scope.sources.length} sources`,
74
+ `as of: ${new Date(found.window.asOf).toISOString()} (exclusive end; reuse --before-time to repeat this window)`,
75
+ `searched: ${[...lanes.values()].join("; ")}`,
76
+ ...found.items.map((item) => `${day(item.at)} ${item.ref}${LABELS[item.context] ?? ""}\n ${item.text}` +
77
+ (item.around ?? [])
78
+ .map((one) => `\n ${one.found ? "(hit) " : ""}${one.sender ? `${one.sender}: ` : ""}${one.text}`)
79
+ .join("")),
80
+ ];
81
+ const people = found.people.slice(0, 8);
82
+ const morePeople = found.people.length - people.length + found.peopleOmitted;
83
+ if (people.length)
84
+ lines.push(`people who wrote these: ${people.map((one) => `${one.name} (${one.items})`).join(", ")}` +
85
+ `${morePeople ? ` and ${morePeople} more` : ""} — by stored contact, not by names in the text; yours left out`);
86
+ if (linked)
87
+ lines.push(`${linked} of these come from the project's linked chats and name nothing: membership, not a word match`);
88
+ if (sent)
89
+ lines.push(`${sent} of these were written by a contact and may name nothing: matched by sender, not words`);
90
+ for (const lane of unreadSenders)
91
+ lines.push(`${lane.people.join(", ")}: no stored identity in the selected sources, so nothing they wrote was read`);
92
+ if (found.mentions.items.length)
93
+ lines.push(`also names: ${found.mentions.items
94
+ .slice(0, 8)
95
+ .map((one) => `${one.target.kind} ${one.target.name} (${one.matches.length})`)
96
+ .join(", ")} — words found, not membership`);
97
+ if (found.matchedAgain.length)
98
+ lines.push(`${found.matchedAgain.length} earlier references found again`);
99
+ if (found.oversized.length)
100
+ lines.push(`too large to read here: ${found.oversized.map((one) => `${one.ref} (${Math.ceil(one.bytes / 1024)} KiB)`).join(", ")}` +
101
+ ` — open each with ${hints.showDocument}`);
102
+ if (failed.length)
103
+ lines.push(`${failed.length} reads failed or had no source: see --json units`);
104
+ if (found.index.state === "failed" || found.index.state === "unavailable")
105
+ lines.push(`the search index could not be brought up to date first (${found.index.code ?? found.index.state})`);
106
+ const pending = [
107
+ ...new Set(found.units.filter((unit) => unit.issue?.startsWith("index-")).map((unit) => found.scope.sources[unit.source])),
108
+ ];
109
+ if (pending.length)
110
+ lines.push(`not yet searchable: new text in ${pending.map((one) => `${one?.provider}:${one?.account}`).join(", ")} ` +
111
+ "is still waiting for the search index, so it was not found");
112
+ if (pending.some((one) => one?.kind === "mail" || one?.kind === "document") && found.index.state === "as-is")
113
+ lines.push("leave out --no-catch-up to index new mail and documents first");
114
+ if (pending.some((one) => one?.kind === "meeting"))
115
+ lines.push("index stored transcript cues through the meeting provider's indexing workflow; mail/document catch-up does not index them");
116
+ if (found.unreadSources.length)
117
+ lines.push(`not read: ${found.unreadSources
118
+ .map((one) => `${one.source} (${one.kind}${one.reason ? `, ${one.reason}` : ""})`)
119
+ .join(", ")}`);
120
+ for (const one of found.fetch)
121
+ lines.push(`${one.chat}: ${FETCH_STATES[one.state] ?? one.state} before reading` +
122
+ `${one.reason && one.state !== "skipped" ? ` (${one.reason})` : ""}`);
123
+ const held = (one) => one.completeness?.state === "unknown" || !one.held?.oldestAt
124
+ ? "never fetched"
125
+ : `held from ${one.held.oldestAt.slice(0, 10)} only`;
126
+ for (const one of found.linkedChats)
127
+ if (one.completeness !== null && one.completeness.state !== "complete") {
128
+ const sync = hints.sync(one, day(found.window.after));
129
+ lines.push(`${one.title ?? one.ref}: ${held(one)}${one.state === "pending" ? ", not read this run" : ""}` +
130
+ (sync ? ` — sync it: ${sync}` : ""));
131
+ }
132
+ const capped = found.linkedChats.filter((one) => one.capped);
133
+ if (capped.length)
134
+ lines.push(`linked chats with more to read, left for --cursor so one chat does not fill the run: ${capped
135
+ .map((one) => `${one.ref} (${one.items} this run)`)
136
+ .join(", ")}`);
137
+ if (found.checkpoint)
138
+ lines.push(`stopped${found.stopped ? ` at the ${found.stopped} budget` : ""}; continue with the same options and`, `--cursor ${found.checkpoint}`);
139
+ else
140
+ lines.push("every read finished; a finished search does not prove there is nothing more");
141
+ return lines.join("\n");
142
+ };
@@ -775,6 +775,7 @@ export declare const tagsCommand: (messenger: import("./index.js").Messenger) =>
775
775
  export declare const tasksCommand: (messenger: import("./index.js").Messenger) => import("commander").Command;
776
776
  export declare const tasksStatsCommand: (messenger: import("./index.js").Messenger) => import("commander").Command;
777
777
  export declare const topicsCommand: (messenger: import("./index.js").Messenger) => import("commander").Command;
778
+ export declare const projectsCommand: (messenger: import("./index.js").Messenger) => import("commander").Command;
778
779
  export declare const watchCommand: (messenger: import("./index.js").Messenger) => import("commander").Command;
779
780
  export declare const runsCommand: (app: import("./entry.js").AppIdentity) => import("commander").Command;
780
781
  export declare const skillCommand: (app: import("./entry.js").AppIdentity, skill: URL) => import("commander").Command;
@@ -36,6 +36,7 @@ import { pinCommand as pinCommandDefinition, starCommands as starCommandsDefinit
36
36
  import { metadataCommand as metadataCommandDefinition } from "./messenger/metadata-command.js";
37
37
  import { modelsCommand as modelsCommandDefinition } from "./messenger/models-command.js";
38
38
  import { pollsCommand as pollsCommandDefinition } from "./messenger/polls-command.js";
39
+ import { projectsCommand as projectsCommandDefinition } from "./messenger/projects-command.js";
39
40
  import { reactionsCommand as reactionsCommandDefinition } from "./messenger/reactions-command.js";
40
41
  import { callsCommand as callsCommandDefinition, stickersCommand as stickersCommandDefinition, } from "./messenger/records-command.js";
41
42
  import { repliesCommand as repliesCommandDefinition } from "./messenger/replies-command.js";
@@ -112,6 +113,7 @@ export const tagsCommand = sharedCommand(tagsCommandDefinition);
112
113
  export const tasksCommand = sharedCommand(tasksCommandDefinition);
113
114
  export const tasksStatsCommand = sharedCommand(tasksStatsCommandDefinition);
114
115
  export const topicsCommand = sharedCommand(topicsCommandDefinition);
116
+ export const projectsCommand = sharedCommand(projectsCommandDefinition);
115
117
  export const watchCommand = sharedCommand(watchCommandDefinition);
116
118
  export const runsCommand = sharedCommand(runsCommandDefinition);
117
119
  export const skillCommand = sharedCommand(skillCommandDefinition);
@@ -88,6 +88,7 @@ const OTHERS = [
88
88
  validation("memory.evidence-required", "a memory needs its evidence"),
89
89
  validation("memory.scope-required", "a memory needs its scope"),
90
90
  ["model.not-downloaded", "not_found", "the model is not downloaded on this machine"],
91
+ ["person.no-owner", "not_found", "no person is marked as the owner in the store"],
91
92
  ["transcription.no-key", "authentication_error", "no OpenAI key is set for transcription"],
92
93
  validation("transcription.too-large", "the audio is over the transcription provider's size limit"),
93
94
  [
@@ -322,7 +322,7 @@ const HOUSEKEEPING = new Set([
322
322
  const BOT_HOUSEKEEPING = new Set(["auth", "list", "sends", "recipients", "mcp"]);
323
323
  const STORE_MAINTENANCE = new Set(["info", "check", "copies"]);
324
324
  /** Commands outside `messages` that print what people wrote, so `deny messages` reaches them too. */
325
- const SHOW_MESSAGES = new Set(["inbox", "review", "watch", "serve", "store", "conversations"]);
325
+ const SHOW_MESSAGES = new Set(["inbox", "review", "watch", "serve", "store", "conversations", "projects"]);
326
326
  export const readKeysForCommand = (path) => {
327
327
  if (path[0] === "bot")
328
328
  return readKeysForCommand(path.slice(1)).map((key) => `bot.${key}`);
@@ -9,13 +9,14 @@ export type { MediaArchiveEntry, MediaIdentity } from "./media-archive-manifest.
9
9
  export type { MoveMediaArchiveOptions } from "./media-archive-move.js";
10
10
  export { moveMediaArchive } from "./media-archive-move.js";
11
11
  export { type DownloadedModel, downloadImageModel, downloadTextModel, type ModelDownloadOptions, } from "./model-downloads.js";
12
- export type { ProjectEvidenceContext, ProjectEvidenceItem, ProjectEvidencePerson, ProjectGathering, ProjectGatheringPorts, ProjectGatheringStop, ProjectIndexCatchUp, ProjectSourceFetcher, } from "./project-gathering.js";
12
+ export type { ProjectChatFetcher, ProjectChatFetchProgress, ProjectChatFetchRequest, ProjectEvidenceContext, ProjectEvidenceItem, ProjectEvidencePerson, ProjectGathering, ProjectGatheringPorts, ProjectGatheringStop, ProjectIndexCatchUp, } from "./project-gathering.js";
13
13
  export { gatherProjectEvidence } from "./project-gathering.js";
14
14
  export type { ProjectNeighbour, ProjectNeighbourReader } from "./project-gathering-around.js";
15
15
  export { documentPassage, storedMessageNeighbours, storedNeighbours } from "./project-gathering-around.js";
16
16
  export type { EvidenceMention, EvidenceMentions } from "./project-gathering-mentions.js";
17
17
  export { findEvidenceMentions } from "./project-gathering-mentions.js";
18
- export type { ProjectFetchState, ProjectGatheringBudgets, ProjectGatheringRequest, ProjectLane, ProjectLaneKind, ProjectLinkedChat, ProjectPerson, ProjectSender, ProjectUnitState, } from "./project-gathering-request.js";
18
+ export type { ProjectChatFetched, ProjectGatheringBudgets, ProjectGatheringRequest, ProjectLane, ProjectLaneKind, ProjectLinkedChat, ProjectPerson, ProjectSender, ProjectUnitState, } from "./project-gathering-request.js";
19
+ export { canonicalProjectRef, gatherStoredProject, type StoredProjectGathering, type StoredProjectInput, } from "./stored-project-gathering.js";
19
20
  import { type AccountService } from "./account.js";
20
21
  import { type AdminService } from "./admin.js";
21
22
  import { type AdminStatisticsService } from "./admin-statistics.js";
@@ -8,6 +8,7 @@ export { downloadImageModel, downloadTextModel, } from "./model-downloads.js";
8
8
  export { gatherProjectEvidence } from "./project-gathering.js";
9
9
  export { documentPassage, storedMessageNeighbours, storedNeighbours } from "./project-gathering-around.js";
10
10
  export { findEvidenceMentions } from "./project-gathering-mentions.js";
11
+ export { canonicalProjectRef, gatherStoredProject, } from "./stored-project-gathering.js";
11
12
  import { accountService } from "./account.js";
12
13
  import { adminService } from "./admin.js";
13
14
  import { adminStatisticsService } from "./admin-statistics.js";
@@ -52,6 +52,8 @@ export interface ProjectGatheringBudgets {
52
52
  maxPageBytes?: number;
53
53
  maxElapsedMs?: number;
54
54
  maxItems?: number;
55
+ /** All `fetch: before-read` chat fetches together. */
56
+ maxFetchMs?: number;
55
57
  }
56
58
  export type ProjectLaneKind = "project" | "key" | "alias" | "keyword" | "person" | "sender" | "linked-chat";
57
59
  export interface ProjectLane {
@@ -76,7 +78,13 @@ export interface ProjectUnitProgress {
76
78
  } | null;
77
79
  code: string | null;
78
80
  }
79
- export type ProjectFetchState = "fetched" | "unsupported" | "failed" | "not-requested";
81
+ /** A linked chat's fetch before the reads: `skipped` when the budget ran out before it started. */
82
+ export interface ProjectChatFetched {
83
+ chat: string;
84
+ state: "complete" | "partial" | "failed" | "skipped";
85
+ reason: string | null;
86
+ elapsedMs: number;
87
+ }
80
88
  export interface ProjectGatheringCheckpoint {
81
89
  version: 1;
82
90
  runKey: string;
@@ -84,11 +92,8 @@ export interface ProjectGatheringCheckpoint {
84
92
  after: number;
85
93
  before: number;
86
94
  asOf: number;
87
- fetched: {
88
- state: ProjectFetchState;
89
- history: "complete" | "partial" | "unknown" | null;
90
- code: string | null;
91
- }[];
95
+ /** The first invocation's chat fetches, carried so a resumed run reports them without fetching again. */
96
+ fetched: ProjectChatFetched[];
92
97
  units: ProjectUnitProgress[];
93
98
  /** Present only when the run reads linked chats: a hash of their references. */
94
99
  linkedChats?: string;
@@ -21,6 +21,7 @@ const BUDGETS = {
21
21
  maxPageBytes: [65536, 262144],
22
22
  maxElapsedMs: [60000, 600000],
23
23
  maxItems: [100, 500],
24
+ maxFetchMs: [180000, 1800000],
24
25
  };
25
26
  const fail = (message, param) => {
26
27
  throw new CliError("validation_error", message, param ? { param } : undefined);
@@ -2,22 +2,38 @@ import type { ContextEvidenceItem, ContextEvidenceReader, ContextIndex, ContextS
2
2
  import type { AccountKey } from "../store/types.js";
3
3
  import type { ChatCoverage } from "./chat-coverage.js";
4
4
  import { type ProjectNeighbour, type ProjectNeighbourReader } from "./project-gathering-around.js";
5
- import { type ProjectFetchState, type ProjectGatheringRequest, type ProjectLane } from "./project-gathering-request.js";
6
- /** A connector's history fetch into the store; the store reads that follow find what it saved. */
7
- export interface ProjectSourceFetcher {
8
- fetch(request: {
9
- source: ContextSource;
10
- after: number;
11
- before: number;
12
- signal?: AbortSignal;
13
- }): Promise<{
14
- status: "fetched" | "unsupported";
15
- history: "complete" | "partial" | "unknown";
5
+ import { type ProjectChatFetched, type ProjectGatheringRequest, type ProjectLane } from "./project-gathering-request.js";
6
+ /** One linked chat's history into the store, back to `after`; the reads that follow find what it saved. */
7
+ export interface ProjectChatFetchRequest {
8
+ account: AccountKey;
9
+ chatId: string;
10
+ after: number;
11
+ before: number;
12
+ /** Aborts when the run's fetch budget is spent; a fetcher stops there and keeps what it saved. */
13
+ signal: AbortSignal;
14
+ /** When that budget ends, by the run's clock: a fetcher that runs a process gives it this much time to stop itself. */
15
+ until: number;
16
+ onProgress?: (progress: ProjectChatFetchProgress) => void;
17
+ }
18
+ export interface ProjectChatFetchProgress {
19
+ fetched: number;
20
+ oldest: number | null;
21
+ }
22
+ export interface ProjectChatFetcher {
23
+ fetch(request: ProjectChatFetchRequest): Promise<{
24
+ state: "complete" | "partial" | "failed";
25
+ reason?: string;
16
26
  }>;
17
27
  }
18
28
  export interface ProjectGatheringPorts {
19
29
  evidence: ContextEvidenceReader;
20
- fetcher?: ProjectSourceFetcher;
30
+ fetcher?: ProjectChatFetcher;
31
+ /** Each linked chat's fetch as it goes, for a heartbeat; never message text. */
32
+ onFetchProgress?: (progress: ProjectChatFetchProgress & {
33
+ chat: string;
34
+ index: number;
35
+ of: number;
36
+ }) => void;
21
37
  index?: ContextIndex;
22
38
  neighbours?: ProjectNeighbourReader;
23
39
  /** What the store holds of the named chats of one account, e.g. `chatCoverage` over the store. */
@@ -103,12 +119,7 @@ export declare const gatherProjectEvidence: (ports: ProjectGatheringPorts, reque
103
119
  items?: number | undefined;
104
120
  capped?: boolean | undefined;
105
121
  }[];
106
- fetch: {
107
- state: ProjectFetchState;
108
- history: "complete" | "partial" | "unknown" | null;
109
- code: string | null;
110
- source: number;
111
- }[];
122
+ fetch: ProjectChatFetched[];
112
123
  index: ProjectIndexCatchUp;
113
124
  units: {
114
125
  lane: string;
@@ -15,6 +15,84 @@ const contextOf = (lane) => lane.kinds.includes("linked-chat")
15
15
  : lane.kinds.every((k) => k === "person")
16
16
  ? "person-only"
17
17
  : "project";
18
+ /** Complete, or up to date with no gaps and held back past the window start: nothing a fetch would add. */
19
+ const heldForWindow = (row, after) => {
20
+ if (!row)
21
+ return false;
22
+ const { state, upToDate, gaps } = row.completeness;
23
+ if (state === "complete")
24
+ return upToDate !== false;
25
+ const oldest = row.held.oldestAt === null ? Number.NaN : Date.parse(row.held.oldestAt);
26
+ return upToDate === true && !gaps && oldest <= after;
27
+ };
28
+ /**
29
+ * Brings the linked chats the store does not hold for the window up to date, one at a time, inside one time budget
30
+ * for all of them. A chat cut by the budget stays partial and the next one is not started, so a run never waits
31
+ * longer than the budget whatever the fetcher does.
32
+ */
33
+ const fetchLinkedChats = async (ports, plan, window, now, signal) => {
34
+ const wanted = [];
35
+ for (const linked of plan.linkedChats) {
36
+ if (linked.source === null)
37
+ continue;
38
+ const account = { provider: linked.provider, account: linked.account };
39
+ const [row] = await ports.coverage(account, [linked.chat]);
40
+ if (!heldForWindow(row, window.after))
41
+ wanted.push({ ...linked, account });
42
+ }
43
+ const deadline = now() + plan.budgets.maxFetchMs;
44
+ const results = [];
45
+ for (const [index, linked] of wanted.entries()) {
46
+ signal?.throwIfAborted();
47
+ const started = now();
48
+ const left = deadline - started;
49
+ if (left <= 0) {
50
+ results.push({ chat: linked.ref, state: "skipped", reason: "fetch-budget", elapsedMs: 0 });
51
+ continue;
52
+ }
53
+ const budget = new AbortController();
54
+ const timer = setTimeout(() => budget.abort(new CliError("timeout", "the fetch budget ran out")), left);
55
+ const onProgress = ports.onFetchProgress;
56
+ try {
57
+ const answer = await ports.fetcher.fetch({
58
+ account: linked.account,
59
+ chatId: linked.chat,
60
+ after: window.after,
61
+ before: window.before,
62
+ until: deadline,
63
+ signal: signal ? AbortSignal.any([signal, budget.signal]) : budget.signal,
64
+ ...(onProgress
65
+ ? { onProgress: (progress) => onProgress({ ...progress, chat: linked.ref, index, of: wanted.length }) }
66
+ : {}),
67
+ });
68
+ if (!["complete", "partial", "failed"].includes(answer?.state))
69
+ throw new CliError("invalid_response", "chat fetch returned an unknown state");
70
+ const cut = budget.signal.aborted || now() > deadline;
71
+ results.push({
72
+ chat: linked.ref,
73
+ state: cut && answer.state === "complete" ? "partial" : answer.state,
74
+ reason: answer.reason ?? (cut && answer.state !== "failed" ? "fetch-budget" : null),
75
+ elapsedMs: now() - started,
76
+ });
77
+ }
78
+ catch (error) {
79
+ signal?.throwIfAborted();
80
+ if (!(error instanceof CliError))
81
+ throw error;
82
+ const cut = budget.signal.aborted;
83
+ results.push({
84
+ chat: linked.ref,
85
+ state: cut ? "partial" : "failed",
86
+ reason: cut ? "fetch-budget" : error.code,
87
+ elapsedMs: now() - started,
88
+ });
89
+ }
90
+ finally {
91
+ clearTimeout(timer);
92
+ }
93
+ }
94
+ return results;
95
+ };
18
96
  export const gatherProjectEvidence = async (ports, request) => {
19
97
  if (!ports?.evidence || typeof ports.evidence.read !== "function")
20
98
  throw new CliError("configuration_error", "stored context reads are unavailable in this store");
@@ -27,38 +105,22 @@ export const gatherProjectEvidence = async (ports, request) => {
27
105
  // A default that cannot finish one round would leave whole searches or sources unread; an explicit one is kept.
28
106
  if (request.budgets?.maxReads === undefined)
29
107
  plan.budgets.maxReads = Math.min(512, Math.max(48, units.length));
30
- if (previous && (previous.units.length !== units.length || previous.fetched.length !== plan.sources.length))
108
+ if (previous && previous.units.length !== units.length)
31
109
  throw new CliError("validation_error", "invalid project gathering checkpoint", { param: "checkpoint" });
32
110
  const progress = previous?.units.map((u) => ({ ...u })) ??
33
111
  units.map(() => ({ state: "pending", pages: 0, cursor: null, code: null }));
34
- const fetched = previous?.fetched.map((f) => ({ ...f })) ??
35
- plan.sources.map(() => ({ state: "not-requested", history: null, code: null }));
112
+ let fetched = previous?.fetched.map((f) => ({ ...f })) ?? [];
36
113
  const seen = new Set(previous?.seen ?? []);
37
114
  const signal = plan.signal;
38
115
  const queries = plan.lanes.flatMap((lane) => (lane.query === null ? [] : [lane.query]));
39
- if (plan.fetch === "before-read" && !ports.fetcher)
40
- throw new CliError("configuration_error", "this host cannot fetch source history; use fetch none", {
116
+ if (plan.fetch === "before-read" && (!ports.fetcher || !ports.coverage))
117
+ throw new CliError("configuration_error", "this host cannot fetch chat history; use fetch none", {
41
118
  param: "fetch",
42
119
  });
43
- if (plan.fetch === "before-read" && ports.fetcher)
44
- for (const [index, source] of plan.sources.entries()) {
45
- if (fetched[index]?.state !== "not-requested")
46
- continue;
47
- signal?.throwIfAborted();
48
- try {
49
- const answer = await ports.fetcher.fetch({ source, after: window.after, before: window.before, signal });
50
- if (!["fetched", "unsupported"].includes(answer?.status) ||
51
- !["complete", "partial", "unknown"].includes(answer?.history))
52
- throw new CliError("invalid_response", "source fetch returned an unknown status");
53
- fetched[index] = { state: answer.status, history: answer.history, code: null };
54
- }
55
- catch (error) {
56
- signal?.throwIfAborted();
57
- if (!(error instanceof CliError))
58
- throw error;
59
- fetched[index] = { state: "failed", history: null, code: error.code };
60
- }
61
- }
120
+ if (plan.fetch === "before-read" && ports.fetcher && ports.coverage && previous === null)
121
+ fetched = await fetchLinkedChats(ports, plan, window, now, signal);
122
+ // The fetch has its own budget; the reads keep theirs whole.
123
+ const readFrom = now();
62
124
  const indexed = { state: "as-is", mail: 0, documents: 0, code: null };
63
125
  const kinds = [...new Set(plan.sources.map((source) => source.kind))].filter((kind) => kind !== "message");
64
126
  if (plan.index === "update" && kinds.length) {
@@ -173,7 +235,7 @@ export const gatherProjectEvidence = async (ports, request) => {
173
235
  ? "reads"
174
236
  : usage.readBytes >= plan.budgets.maxReadBytes
175
237
  ? "read-bytes"
176
- : now() - started >= plan.budgets.maxElapsedMs
238
+ : now() - readFrom >= plan.budgets.maxElapsedMs
177
239
  ? "elapsed"
178
240
  : usage.items >= plan.budgets.maxItems
179
241
  ? "items"
@@ -348,7 +410,7 @@ export const gatherProjectEvidence = async (ports, request) => {
348
410
  scope: { origin: plan.scope, sources: plan.sources },
349
411
  lanes: plan.lanes,
350
412
  linkedChats: linkedRows,
351
- fetch: fetched.map((f, index) => ({ source: index, ...f })),
413
+ fetch: fetched,
352
414
  index: indexed,
353
415
  units: units.map((unit, index) => ({
354
416
  lane: unit.lane.id,
@@ -0,0 +1,50 @@
1
+ import type { ContextSource } from "../context/evidence.js";
2
+ import type { MessageStore } from "../store/store.js";
3
+ import { gatherProjectEvidence, type ProjectChatFetcher, type ProjectGatheringPorts } from "./project-gathering.js";
4
+ import { type EvidenceMentions } from "./project-gathering-mentions.js";
5
+ export interface StoredProjectInput {
6
+ project: string;
7
+ /** The sources the host resolved from its `--source`, and those it could not read. */
8
+ sources: {
9
+ sources: ContextSource[];
10
+ unread: {
11
+ source: string;
12
+ kind: string;
13
+ reason?: "unknown-provider" | "out-of-scope";
14
+ }[];
15
+ scope: "explicit" | "all-connected";
16
+ };
17
+ aliases?: string[];
18
+ keywords?: string[];
19
+ people?: string[];
20
+ days?: number;
21
+ after?: number;
22
+ asOf?: number;
23
+ checkpoint?: string;
24
+ maxReads?: number;
25
+ limit?: number;
26
+ catchUp?: boolean;
27
+ beforeN?: number;
28
+ afterN?: number;
29
+ /** Syncs the linked chats the store does not hold for the window before reading; off when left out. */
30
+ fetcher?: ProjectChatFetcher;
31
+ maxFetchMs?: number;
32
+ onFetchProgress?: ProjectGatheringPorts["onFetchProgress"];
33
+ signal?: AbortSignal;
34
+ }
35
+ /** `project:<id>`, or the one project a key, name or alias names; several or none is refused, never guessed. */
36
+ export declare const canonicalProjectRef: (store: MessageStore, reference: string) => Promise<string>;
37
+ export type StoredProjectGathering = Awaited<ReturnType<typeof gatherProjectEvidence>> & {
38
+ projectRef: string | null;
39
+ unreadSources: {
40
+ source: string;
41
+ kind: string;
42
+ reason?: "out-of-scope" | "linked-chat-limit" | "member-limit" | "no-identity" | "unknown-provider";
43
+ }[];
44
+ mentions: EvidenceMentions;
45
+ };
46
+ /**
47
+ * `projects evidence` over a whole store: the project's names, key and aliases, the given words and contacts, its
48
+ * confirmed person members and its confirmed linked chats, in the sources the host resolved.
49
+ */
50
+ export declare const gatherStoredProject: (store: MessageStore, input: StoredProjectInput) => Promise<StoredProjectGathering>;
@@ -0,0 +1,207 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { formatReference, parseReference } from "../domain/references.js";
3
+ import { chatCoverage } from "./chat-coverage.js";
4
+ import { gatherProjectEvidence } from "./project-gathering.js";
5
+ import { storedNeighbours } from "./project-gathering-around.js";
6
+ import { findEvidenceMentions } from "./project-gathering-mentions.js";
7
+ const PROJECT_ID = /^project:[1-9]\d*$/;
8
+ /** `project:<id>`, or the one project a key, name or alias names; several or none is refused, never guessed. */
9
+ export const canonicalProjectRef = async (store, reference) => {
10
+ if (PROJECT_ID.test(reference)) {
11
+ const catalog = await store.knowledge.vocabulary({ kind: "project" });
12
+ if (!catalog.entries.some((entry) => entry.id === reference.slice(8)))
13
+ throw new CliError("not_found", "the owner project is unavailable");
14
+ return reference;
15
+ }
16
+ const found = await store.knowledge.resolveVocabulary(reference, { kind: "project" });
17
+ const [entry] = found.matches;
18
+ if (found.matches.length !== 1 || !entry)
19
+ throw new CliError(found.matches.length ? "validation_error" : "not_found", found.matches.length
20
+ ? `${reference} names ${found.matches.length} projects; give one by its key or project:<id>`
21
+ : `no project ${reference}`, { candidates: found.matches.map((one) => ({ id: one.id, key: one.key, name: one.name })) });
22
+ return `project:${entry.id}`;
23
+ };
24
+ /**
25
+ * An existing project brings its name and aliases; any other name is gathered as given. An id that names no
26
+ * project, or a name that matches several, is refused rather than guessed.
27
+ */
28
+ const projectOf = async (store, reference) => {
29
+ const id = /^(project:)?[1-9]\d*$/.test(reference);
30
+ let ref;
31
+ try {
32
+ ref = await canonicalProjectRef(store, id && !reference.startsWith("project:") ? `project:${reference}` : reference);
33
+ }
34
+ catch (error) {
35
+ if (!id && error instanceof CliError && error.code === "not_found")
36
+ return { ref: null, name: reference, key: null, aliases: [] };
37
+ throw error;
38
+ }
39
+ const entry = (await store.knowledge.vocabulary({ kind: "project" })).entries.find((one) => one.id === ref.slice(8));
40
+ if (!entry)
41
+ throw new CliError("not_found", "the owner project is unavailable");
42
+ return { ref, name: entry.name, key: entry.key, aliases: entry.aliases.map((alias) => alias.name) };
43
+ };
44
+ /** `person:me` as the owner's `person:<uid>`; any other reference as written. */
45
+ const withOwner = async (store, reference) => {
46
+ if (reference.trim() !== "person:me")
47
+ return reference;
48
+ const owner = await store.people.owner();
49
+ if (owner === undefined || owner.identities.length === 0)
50
+ throw new CliError("not_found", "person:me: no person is marked as you in the store; mark yours with `<messenger> <profile> contacts link <your id> me`", { reason: "person.no-owner" });
51
+ return `person:${owner.uid}`;
52
+ };
53
+ const sendersOf = (person) => person.identities.map((identity) => ({ provider: identity.provider, id: String(identity.id) }));
54
+ /**
55
+ * A `person:<id>` is read by what their stored identities wrote, and searched only by the short names the owner gave
56
+ * them: their stored name is a messenger display name («Степа Stefan Grachev AVITO M A») that no message spells out.
57
+ * Any other text is a name.
58
+ */
59
+ const contactOf = async (store, contact) => {
60
+ const reference = await withOwner(store, contact);
61
+ if (!reference.startsWith("person:"))
62
+ return [{ name: contact }];
63
+ const person = await store.people.personByUid(reference.slice("person:".length));
64
+ if (!person)
65
+ throw new CliError("not_found", `no person ${contact}`, { param: "contact" });
66
+ const senders = sendersOf(person);
67
+ const aliases = new Set();
68
+ for (const identity of person.identities)
69
+ for (const account of identity.accounts) {
70
+ const alias = await store.people
71
+ .privateContact({ provider: identity.provider, account }, identity.id)
72
+ .then((one) => one.alias)
73
+ .catch((error) => {
74
+ if (error instanceof CliError && error.code === "not_found")
75
+ return null;
76
+ throw error;
77
+ });
78
+ if (alias)
79
+ aliases.add(alias);
80
+ }
81
+ if (!senders.length && !aliases.size)
82
+ throw new CliError("validation_error", `${contact} has no stored identity or alias to search by; pass a name the texts use to --contact`, { param: "contact" });
83
+ const names = [...aliases].map((name) => ({ name, reference: contact }));
84
+ return senders.length ? [{ reference: contact, senders }, ...names] : names;
85
+ };
86
+ const LINKED_CHATS = 16;
87
+ const CORE_LIMIT = 16;
88
+ /** Confirmed `person` members, in a stable order, so a cut at the lane limit always drops the same ones. */
89
+ const memberRefsOf = async (store, project) => {
90
+ if (!project)
91
+ return [];
92
+ const refs = [];
93
+ for (const relation of await store.knowledge.relations(null, project))
94
+ if (relation.confirmed &&
95
+ relation.kind === "member-of" &&
96
+ relation.to === project &&
97
+ /^person:.+/.test(relation.from))
98
+ refs.push(relation.from);
99
+ return [...new Set(refs)].sort();
100
+ };
101
+ /**
102
+ * Members are read by what their identities wrote, like `--contact person:<id>` but without the owner's aliases:
103
+ * a short alias would add a text lane that matches mentions, and membership is about who wrote, not who was named.
104
+ */
105
+ const memberLanesOf = async (store, project, given, room) => {
106
+ const lanes = [];
107
+ const left = [];
108
+ for (const reference of (await memberRefsOf(store, project)).filter((one) => !given.has(one))) {
109
+ const person = await store.people.personByUid(reference.slice("person:".length));
110
+ const senders = person ? sendersOf(person) : [];
111
+ if (!senders.length)
112
+ left.push({ source: reference, kind: "member", reason: "no-identity" });
113
+ else if (lanes.length >= room)
114
+ left.push({ source: reference, kind: "member", reason: "member-limit" });
115
+ else
116
+ lanes.push({ reference, senders });
117
+ }
118
+ return { lanes, left };
119
+ };
120
+ /** Only links the owner confirmed: a suggested one would put a whole chat into the evidence on a guess. */
121
+ const linkedChatsOf = async (store, project) => {
122
+ if (!project)
123
+ return [];
124
+ const chats = [];
125
+ for (const relation of await store.knowledge.relations(null, project)) {
126
+ if (!relation.confirmed || relation.kind !== "member-of" || relation.to !== project)
127
+ continue;
128
+ if (!relation.from.startsWith("chat:"))
129
+ continue;
130
+ const reference = parseReference(relation.from);
131
+ if (reference.type === "chat")
132
+ chats.push(reference);
133
+ }
134
+ return chats.map(({ provider, account, chat }) => ({ provider, account, chat }));
135
+ };
136
+ /**
137
+ * `projects evidence` over a whole store: the project's names, key and aliases, the given words and contacts, its
138
+ * confirmed person members and its confirmed linked chats, in the sources the host resolved.
139
+ */
140
+ export const gatherStoredProject = async (store, input) => {
141
+ if (input.asOf !== undefined && input.after !== undefined && input.asOf <= input.after)
142
+ throw new CliError("validation_error", "--before-time must be later than --since-time", { param: "before_time" });
143
+ if (!store.contextEvidence)
144
+ throw new CliError("configuration_error", "Stored context reads are unavailable in this store");
145
+ const project = await projectOf(store, input.project);
146
+ const { sources, unread, scope } = input.sources;
147
+ if (sources.length === 0)
148
+ throw new CliError("not_found", "no selected source holds anything to read", { param: "source" });
149
+ const contacts = (await Promise.all([...new Set(input.people ?? [])].map((contact) => contactOf(store, contact)))).flat();
150
+ const aliases = [...new Set([...project.aliases, ...(input.aliases ?? [])])].filter((alias) => alias !== project.name);
151
+ // Counted without merging look-alikes, so the room left for members is never more than Core will take.
152
+ const searchLanes = 1 +
153
+ (project.key ? 1 : 0) +
154
+ aliases.length +
155
+ (input.keywords?.length ?? 0) +
156
+ contacts.filter((one) => one.name).length;
157
+ const room = Math.min(CORE_LIMIT - contacts.length, CORE_LIMIT - searchLanes - contacts.filter((one) => one.senders).length);
158
+ const members = await memberLanesOf(store, project.ref, new Set(contacts.flatMap((one) => one.reference ?? [])), room);
159
+ const people = [...contacts, ...members.lanes];
160
+ const linked = await linkedChatsOf(store, project.ref);
161
+ const linkedChats = linked.slice(0, LINKED_CHATS);
162
+ const budgets = {
163
+ ...(input.maxReads === undefined ? {} : { maxReads: input.maxReads }),
164
+ ...(input.limit === undefined ? {} : { maxItems: input.limit }),
165
+ ...(input.maxFetchMs === undefined ? {} : { maxFetchMs: input.maxFetchMs }),
166
+ };
167
+ const gathered = await gatherProjectEvidence({
168
+ evidence: store.contextEvidence,
169
+ neighbours: storedNeighbours(store),
170
+ coverage: (account, chatIds) => chatCoverage(store, account, chatIds),
171
+ ...(store.contextIndex ? { index: store.contextIndex } : {}),
172
+ ...(input.fetcher ? { fetcher: input.fetcher } : {}),
173
+ ...(input.onFetchProgress ? { onFetchProgress: input.onFetchProgress } : {}),
174
+ }, {
175
+ project: {
176
+ name: project.name,
177
+ ...(project.key ? { key: project.key } : {}),
178
+ ...(aliases.length ? { aliases } : {}),
179
+ },
180
+ index: input.catchUp === false ? "as-is" : "update",
181
+ around: { before: input.beforeN ?? 2, after: input.afterN ?? 2 },
182
+ ...(input.keywords?.length ? { keywords: input.keywords } : {}),
183
+ ...(people.length ? { people } : {}),
184
+ ...(linkedChats.length ? { linkedChats } : {}),
185
+ ...(input.fetcher ? { fetch: "before-read" } : {}),
186
+ sources,
187
+ scope,
188
+ ...(input.days === undefined ? {} : { lookbackDays: input.days }),
189
+ ...(input.after === undefined ? {} : { after: input.after }),
190
+ ...(input.asOf === undefined ? {} : { asOf: input.asOf }),
191
+ ...(input.checkpoint === undefined ? {} : { checkpoint: input.checkpoint }),
192
+ ...(Object.keys(budgets).length ? { budgets } : {}),
193
+ ...(input.signal ? { signal: input.signal } : {}),
194
+ });
195
+ const mentions = await findEvidenceMentions(store.knowledge, gathered, { exclude: project.ref });
196
+ const unreadChats = [
197
+ ...gathered.linkedChats
198
+ .filter((one) => one.state === "out-of-scope")
199
+ .map((one) => ({ source: one.ref, kind: "linked-chat", reason: "out-of-scope" })),
200
+ ...linked.slice(LINKED_CHATS).map((one) => ({
201
+ source: formatReference({ type: "chat", ...one }),
202
+ kind: "linked-chat",
203
+ reason: "linked-chat-limit",
204
+ })),
205
+ ];
206
+ return { projectRef: project.ref, unreadSources: [...unread, ...unreadChats, ...members.left], ...gathered, mentions };
207
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.502.0",
3
+ "version": "0.504.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",
package/parity.json CHANGED
@@ -269,6 +269,11 @@
269
269
  "value": "<names>",
270
270
  "meaning": "the counter fields: views,reactions,comments"
271
271
  },
272
+ "--credential-storage": {
273
+ "value": "<storage>",
274
+ "meaning": "where the sign-in secret is kept: keyring, which falls back to a private file by itself when no keyring works, or file, not recommended",
275
+ "default": "keyring"
276
+ },
272
277
  "--cursor": {
273
278
  "value": "<cursor>",
274
279
  "meaning": "continue from the extraction or ranking-evidence cursor"
@@ -4238,6 +4243,10 @@
4238
4243
  "in": ["tg"],
4239
4244
  "reason": "Telegram asks each client for its own app from my.telegram.org; MAX has no such step; WhatsApp has no application registration"
4240
4245
  },
4246
+ "--credential-storage": {
4247
+ "in": ["max"],
4248
+ "reason": "WC-SECR-4's discouraged file choice for the sign-in secret; max's token goes through cli-base's Credentials, while tg's and WhatsApp's sign-ins are the library's own session file (the rule's exception)"
4249
+ },
4241
4250
  "--sms": {
4242
4251
  "in": ["tg"],
4243
4252
  "reason": "asks Telegram to resend its login code by SMS; MAX has no such choice; WhatsApp links a device by QR or pairing code, never by an SMS code"